diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 75e24731..77c069f7 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1616,6 +1616,35 @@ Plans: **Wave 6** *(blocked on Wave 5 completion)* - [x] 32-06-PLAN.md — unit tests, additive and compat proof, framework patch release, plugin tags, demo mount, changelog +### Phase 32.1: MCP OAuth for plugin apps: declared scopes, MCP subject on context, per-client MCP consent (INSERTED) + +**Goal:** As an application whose plugins declare their own member token scopes, I want an agent connected to `/mcp` over OAuth to get the same tools, attribution and revocation as one using a personal token, so that OAuth sign-in can be switched on in production. A consuming app's agent-access work found three gaps (its D-36, D-37 and D-38); all three are additive and ship as patch releases of the framework (0.5.x) and `sm-oauthserver-plugin`. +**Requirements**: TBD +**Depends on:** Phase 19.4 (OIDC provider on wristband), Phase 19.7 (generic MCP module) +**Plans:** 5 plans + +**Success Criteria** (what must be TRUE): + 1. Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted over MCP OAuth: the wristband metadata and authorize allow-list and pier's protected-resource scopes include them, so a tool route behind `user.scope:` lists and runs for an OAuth agent granted that scope. Today the list is fixed to `read`, `write`, `ai`, `offline_access` (and `backend`). + 2. A route run by an MCP tool call can read the MCP subject (guard, user, client ID, token id) from its request context through an accessor, so an application records the OAuth client of a write; a personal-token call's credential keeps carrying the token id (sm-user-plugin v0.1.14). + 3. `sm-oauthserver-plugin` records a consent for MCP grants, so the existing per-client revoke ends one agent's grant and its MCP sessions without touching the user's other agents. + 4. Module READMEs and `docs/` pages describe the three changes; the framework and `sm-oauthserver-plugin` get patch tags. + +Plans: +**Wave 1** +- [ ] 32.1-01-PLAN.md — Framework seam: catalog, MCPCall, one advertised scope list + +**Wave 2** *(blocked on Wave 1 completion)* +- [ ] 32.1-02-PLAN.md — sm-user-plugin from v0.1.14: publish the catalog and Implies for OAuth subjects + +**Wave 3** *(blocked on Wave 2 completion)* +- [ ] 32.1-03-PLAN.md — sm-oauthserver-plugin: consent replace, revoke, backfill + +**Wave 4** *(blocked on Wave 3 completion)* +- [ ] 32.1-04-PLAN.md — vue-oauth-layer /connect checkboxes + +**Wave 5** *(blocked on Wave 4 completion)* +- [ ] 32.1-05-PLAN.md — Unit tests, then the release checkpoint + ### Phase 33: SEO output, audit and content **Goal:** As a site editor and as an SEO specialist auditing a SummerCMS site, I want the rendered sites to emit complete SEO output (sitemaps, structured data, pagination, hreflang, headings, breadcrumbs) from the CMS, and an audit that finds the remaining problems, so that sites are indexed correctly and issues can be fixed through MCP. Decisions are in `.planning/phases/32-seo-foundation/32-CONTEXT.md`. This covers: diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-PLAN.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-PLAN.md new file mode 100644 index 00000000..d028fdca --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-PLAN.md @@ -0,0 +1,249 @@ +--- +phase: 32.1 +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - modules/pact/capabilities.go + - modules/pact/mcp.go + - modules/pact/mcp_call_test.go + - modules/pact/README.md + - modules/pier/dispatch.go + - modules/pier/prm.go + - modules/pier/stateless.go + - modules/pier/mcp_seam_test.go + - modules/pier/README.md + - modules/wristband/authorize.go + - modules/wristband/server.go + - modules/wristband/consent.go + - modules/wristband/mcp_scopes_smoke_test.go + - modules/wristband/README.md + - docs/services/mcp.md + - docs/services/oauth-server.md + - docs/services/authentication.md +autonomous: true +requirements: [] +estimate: + tokens: 52000 + raw_tokens: 52000 + tasks: 3 + confidence: low +must_haves: + truths: + - "SC-1: A wristband MCP server whose Options.ScopesSupported is empty advertises and accepts read, write, ai, offline_access and backend. A non-empty list is used as given, so DefaultOptions still refuses backend. OIDC discovery scopes stay openid, profile, email (D-02)." + - "SC-1: pier protected-resource metadata copies pact.MCPAdvertisedScopes from the published verifier when that list is non-empty, and otherwise keeps PRMScopes (D-04). Pier does not read the raw member-token catalog and does not change the read/write ceiling (D-01)." + - "SC-2: Every in-process MCP tool call carries pact.MCPCall (Subject, SessionID, Tool) set immediately before bouncer.WithDispatch. MCPCallFrom on a context without that value returns ok false (D-09, D-10, D-11)." + - "PendingRequestView.OfflineAccess is filled from the pending row. IssueCode's signature is unchanged." + - "pact, pier and wristband READMEs and docs/services/mcp.md, oauth-server.md and authentication.md name only identifiers this plan adds, and never a consuming application." + artifacts: + - path: modules/pact/mcp.go + provides: "MemberTokenScopeCatalog, MCPAdvertisedScopes, MCPCall, WithMCPCall, MCPCallFrom" + contains: "MemberTokenScopes" + - path: modules/pact/capabilities.go + provides: "FrontendTokenScope.Implies" + contains: "Implies" + - path: modules/pier/dispatch.go + provides: "MCPCall set beside WithDispatch" + contains: "WithMCPCall" + - path: modules/pier/prm.go + provides: "PRM scopes from the verifier or PRMScopes" + contains: "MCPAdvertisedScopes" + - path: modules/wristband/consent.go + provides: "PendingRequestView.OfflineAccess" + contains: "OfflineAccess" + key_links: + - from: modules/wristband/server.go Metadata + to: modules/wristband/authorize.go allowedAuthorizeScopes + via: one resolved scope slice + pattern: resolvedScopes + - from: modules/pier/prm.go + to: pact.MCPAdvertisedScopes + via: app.Lookup of MCPTokenVerifier then a type assert + pattern: MCPScopesSupported + - from: modules/pier/dispatch.go + to: pact.MCPCallFrom + via: WithMCPCall immediately before WithDispatch + pattern: WithMCPCall +--- + + +Framework seam for SC-1 and SC-2 (D-01, D-02, D-03, D-04, D-05, D-09, D-10, D-11). Publish the catalog and advertised-scope interfaces, carry an MCP call on context, and make the wristband MCP metadata and authorize allow-list share one resolved scope list so an empty Options.ScopesSupported cannot fall back to the four-name map and drop backend. + +Purpose: sm-user-plugin and sm-oauthserver-plugin need these types before they can advertise declared scopes or record who called a tool. The read/write ceiling and the OIDC scope list stay as they are. +Output: pact, pier and wristband API plus the three docs pages and module READMEs. One tracer smoke. The broad unit-test pass is plan 05. + +spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip). + +Discretion recorded here, not left open: +- Catalog interface is pact.MemberTokenScopeCatalog with MemberTokenScopes() []FrontendTokenScope. The method is not named FrontendTokenScopes, because HasFrontendTokenScopes already has that method (D-05). +- Advertised list is a second interface pact.MCPAdvertisedScopes with MCPScopesSupported() []string. Do not add a method to pact.MCPTokenVerifier. +- pact.MCPCall fields are Subject pact.MCPSubject, SessionID string, Tool string. Setter is pact.WithMCPCall. Accessor is pact.MCPCallFrom (D-10). +- MCP flavour only: empty Options.ScopesSupported resolves to read, write, ai, offline_access, backend. Metadata and allowedAuthorizeScopes both use that slice. DefaultOptions stays the non-empty four-name list and still refuses backend. TestMCPScopesSmoke's empty-slice assertion is updated in the same commit. Do not edit modules/wristband/oidc_options.go. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md +@modules/pact/mcp.go +@modules/pact/capabilities.go +@modules/pier/dispatch.go +@modules/pier/prm.go +@modules/pier/stateless.go +@modules/pier/session.go +@modules/wristband/authorize.go +@modules/wristband/server.go +@modules/wristband/consent.go +@modules/wristband/mcp_scopes_smoke_test.go +@modules/bouncer/dispatch.go + + + + + + Tracer: one advertised scope list and one MCP call on the dispatch context + D-03 and D-10 publish a scope list and a pact.MCPCall shape that later grants and callers will store and read. + modules/pact/capabilities.go, modules/pact/mcp.go, modules/pact/mcp_call_test.go, modules/wristband/authorize.go, modules/wristband/server.go, modules/wristband/mcp_scopes_smoke_test.go, modules/pier/dispatch.go, modules/pier/prm.go, modules/pier/stateless.go, modules/pier/mcp_seam_test.go + + modules/pact/capabilities.go (FrontendTokenScope at the struct that today has Name, Label, Description only) + modules/pact/mcp.go (MCPSubject, MCPTokenVerifier, MCPGrant) + modules/bouncer/dispatch.go (WithDispatch context key; do not copy its nil-to-background behaviour) + modules/wristband/authorize.go (allowedAuthorizeScopes empty fallback) + modules/wristband/server.go (Metadata copies opts.ScopesSupported; flavourMCP versus flavourOIDC) + modules/wristband/mcp_scopes_smoke_test.go (empty slice currently expects four names and no backend) + modules/pier/prm.go (PRMScopes and mountPRM) + modules/pier/dispatch.go (dispatcher.run sets bouncer.WithDispatch at the credential assignment) + modules/pier/session.go (toolHandler copies req.Session.ID into callInfo.session; ephemeralSession is the audit word) + modules/pier/stateless.go (statelessPath.ServeHTTP) + modules/pier/tracer_smoke_test.go (package pier_test mount helper to copy, not to rewrite) + modules/pier/filter.go (read only: id.writes ceiling stays) + + + Add the pact surface and wire one path through wristband and pier. Do not add a method to pact.MCPTokenVerifier. Do not edit modules/pier/filter.go or modules/wristband/oidc_options.go (D-01, D-02). + + In modules/pact/capabilities.go add Implies string on FrontendTokenScope. Zero value means not implied. Update the comment that says the struct carries no permission patterns so it still says the route middleware is what enforces a member scope, and that Implies is only "", "read" or "write" for a later OAuth MCP check. Do not implement that check in this repo (D-01a is plan 02). + + In modules/pact/mcp.go add MemberTokenScopeCatalog with MemberTokenScopes() []FrontendTokenScope, and MCPAdvertisedScopes with MCPScopesSupported() []string (D-05, D-04). Add MCPCall{Subject MCPSubject, SessionID string, Tool string}, WithMCPCall(ctx, call) context.Context and MCPCallFrom(ctx) (MCPCall, bool) (D-09, D-10). Use an unexported context key. If ctx is nil, WithMCPCall must not invent context.Background; the dispatch context is never nil. MCPCallFrom returns ok false when the value is absent, including a nil context (D-11). Leave MCPSubject's fields unchanged. + + In wristband, add an unexported resolvedScopes on Server used by both Metadata's scopes_supported and allowedAuthorizeScopes. Non-empty Options.ScopesSupported is cloned as-is. Empty or nil, on the MCP flavour only (NewServer, flavour zero value, not flavourOIDC), resolves to exactly read, write, ai, offline_access, backend in that order. That is the fix for the four-name fallback dropping backend. Update TestMCPScopesSmoke so the empty-slice case expects those five names and accepts backend, and keep the DefaultOptions case that still refuses backend because that list is non-empty and has no backend. Do not change PKCE, client lookup, or redirect matching (Authorize still validates those before the /connect redirect). + + In pier mountPRM, keep the issuer lookup. If the looked-up pact.MCPTokenVerifier is non-nil and also implements pact.MCPAdvertisedScopes and MCPScopesSupported() is non-empty, ScopesSupported is a clone of that slice. Otherwise keep a clone of PRMScopes (D-04). Do not import an OAuth plugin and do not read MemberTokenScopeCatalog here. + + In dispatcher.run, immediately before the existing bouncer.WithDispatch line, set callCtx = pact.WithMCPCall(callCtx, pact.MCPCall{Subject: *subject, SessionID: sessionID, Tool: t.name}) where subject is the id.subjectFor(t.guardName) value already computed (D-11). sessionID is info.session, except the audit word ephemeralSession becomes "". Add a stateless bool on callInfo. statelessPath.ServeHTTP sets it true on the bearer token-info extraCall value, writing the struct back, before the handler runs. toolHandler copies req.Session.ID() into info.session only when that flag is false and the id is non-empty. Do not expand scopes in pier. Do not put a raw bearer or a code in a log line. + + Tests, same commit: modules/pact/mcp_call_test.go TestMCPCallRoundTrip proves WithMCPCall then MCPCallFrom returns the same Subject.Guard, SessionID and Tool, and MCPCallFrom(context.Background()) is ok false. modules/pier/mcp_seam_test.go TestMCPSeam, package pier_test, does three things in one test: wristband.NewServer with nil ScopesSupported answers metadata scopes_supported equal to the five fixed names including backend; a server whose ScopesSupported is those five plus acme:read advertises that exact slice; a pier mounted with a fake MCPTokenVerifier that also implements MCPAdvertisedScopes advertises that slice on the protected-resource document, and one tools/call of a frontend tool reaches a handler where pact.MCPCallFrom is ok, Tool is the tool name, and bouncer dispatch is still set. A verifier that does not implement MCPAdvertisedScopes still yields PRMScopes. Commit in this repo only, staging the files listed on this task. Do not git add -A. + + + go test -short -count=1 -run 'TestMCPCallRoundTrip|TestMCPSeam|TestMCPScopesSmoke|TestDiscoveryFields' ./modules/pact ./modules/pier ./modules/wristband + non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPCallRoundTrip or TestMCPSeam has no PASS line + + + - MCPTokenVerifier still has only MCPVerifyToken and MCPResource. + - Empty MCP Options.ScopesSupported metadata and allow-list both include backend. DefaultOptions still refuses backend. + - TestDiscoveryFields still expects scopes_supported openid, profile, email on the default OIDC document. + - TestMCPSeam PASS shows a declared name on PRM only when the verifier advertises it, and one tool handler observes MCPCall. + - filter.go and oidc_options.go have no diff in this commit. + + One MCP tool call carries MCPCall, and an empty MCP scope list no longer drops backend. The commit is only the files of this task. + + + + Expose offline_access on the pending consent view + OfflineAccess is an additive field on a view struct. IssueCode's signature stays. + modules/wristband/consent.go + + modules/wristband/consent.go (PendingRequestView and IssueCode) + modules/wristband/authorize.go (offline_access is peeled onto the pending row, not stored in Scopes) + + + Add OfflineAccess bool to PendingRequestView and set it in PendingRequest from the pending row's existing OfflineAccess flag. Do not change the IssueCode signature, MarkIssued's wristband parameters, or the redirect URL. Callers that ignore the new field stay valid. Do not treat offline_access as a scope inside wristband. Do not log codes. + + + go test -short -count=1 ./modules/wristband + non-zero exit, or the output contains FAIL + + + - PendingRequestView has an exported OfflineAccess field set from the pending row. + - IssueCode is still func (s *Server) IssueCode(ctx context.Context, requestID string, userID uint, grantedScopes []string, collectionIDs []uint) (string, error). + + Consent show can see the offline_access column without a new IssueCode argument. + + + + Document the seam in READMEs and docs + Docs follow the API added in this plan and can be rewritten with it. + modules/pact/README.md, modules/pier/README.md, modules/wristband/README.md, docs/services/mcp.md, docs/services/oauth-server.md, docs/services/authentication.md + + modules/pact/README.md + modules/pier/README.md + modules/wristband/README.md + docs/services/mcp.md + docs/services/oauth-server.md + docs/services/authentication.md + modules/pact/mcp.go + modules/pier/prm.go + + + Document only identifiers this plan added: MemberTokenScopeCatalog, MemberTokenScopes, MCPAdvertisedScopes, MCPScopesSupported, MCPCall, WithMCPCall, MCPCallFrom, FrontendTokenScope.Implies, PendingRequestView.OfflineAccess, and the existing PRMScopes fallback. State the D-03 rule in pier README, docs/services/mcp.md and docs/services/oauth-server.md: fixed names read, write, ai, offline_access, backend are always on the MCP advertised set; an unset host filter means every declared member scope; a set filter means only those declared names; an empty filter means the five fixed names only. Name the host config key golem15.oauthserver.mcp_scopes as the plugin key plan 03 adds. Do not document it as a framework config key. State that pier copies MCPScopesSupported when non-empty and otherwise PRMScopes (D-04). State that Subject.TokenID is a member personal-token id only when ClientID is empty, and is the OAuth access-token row otherwise, and that Subject.Scopes is the grant, not an implied expansion (D-10, D-11). State that the read/write ceiling is unchanged (D-01) and that OIDC discovery stays openid, profile, email (D-02). State that an empty MCP Options.ScopesSupported resolves to the five fixed names, while a non-empty list is used as given. Framework docs and READMEs must not name a consuming application. Config keys are not checked by the docs checker; the prose must still match the key spelling above. Commit in this repo, staging only these doc files plus any code file this task had to touch (it should touch none). Do not git add -A. + + + go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go vet ./modules/pact ./modules/pier ./modules/wristband && go test -short -count=1 ./modules/pact ./modules/pier ./modules/wristband + non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL + + + - TestDocsTree and docs:build --check pass. + - The three READMEs and three docs pages mention MCPCallFrom and MCPAdvertisedScopes and do not mention a consuming application by name. + - go test -short on pact, pier and wristband passes. + + SC-4 docs for the framework seam are in the same release as the API they name. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| MCP client to wristband authorize | Untrusted scope string and redirect cross here. This plan only changes which names are legal. | +| pier dispatch to route handler | The handler receives MCPCall built from the already-resolved subject, not from tool arguments. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-32.1-01 | Elevation of privilege | wristband resolvedScopes | high | mitigate | Empty MCP Options.ScopesSupported resolves to the five fixed names including backend. A non-empty list is not widened. OIDC discovery is untouched. | +| T-32.1-02 | Elevation of privilege | pier filter.go read/write ceiling | high | mitigate | Do not edit Identity.writes or the ceiling in filter.go (D-01). Implies is data on the catalog only; pier does not expand scopes. | +| T-32.1-03 | Spoofing | wristband Authorize redirect | high | mitigate | Do not edit client lookup, exact redirect match, PKCE or the /connect redirect. Scope resolution runs after those checks. | +| T-32.1-04 | Information disclosure | pact.MCPCall.Subject.TokenID | medium | mitigate | README states TokenID is a user_api_tokens id only when ClientID is empty. Dispatch logs keep the existing audit line and must not gain the raw bearer or an authorization code. | + + + +go test -short on pact, pier and wristband passes, TestDiscoveryFields still locks the OIDC scope list, and TestDocsTree passes after the README commit. + + + +- SC-1 and SC-2 have a framework path: one advertised slice drives MCP metadata and authorize, pier PRM reads it from the verifier, and one tool call exposes MCPCall. +- D-01 ceiling and D-02 OIDC list are unchanged files. +- Docs name the new identifiers and the mcp_scopes rule. + + + +Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-SUMMARY.md` when done. + + +## Artifacts this phase produces + +- pact.MemberTokenScopeCatalog, method MemberTokenScopes +- pact.MCPAdvertisedScopes, method MCPScopesSupported +- pact.MCPCall, pact.WithMCPCall, pact.MCPCallFrom +- FrontendTokenScope.Implies +- wristband Server resolvedScopes (unexported), used by Metadata and allowedAuthorizeScopes +- PendingRequestView.OfflineAccess +- callInfo.stateless (unexported) so MCPCall.SessionID is empty on the stateless path and never the audit word ephemeral diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-PLAN.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-PLAN.md new file mode 100644 index 00000000..d6b4f8f1 --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-PLAN.md @@ -0,0 +1,173 @@ +--- +phase: 32.1 +plan: 02 +type: execute +wave: 2 +depends_on: + - 32.1-01 +files_modified: + - ../sm-user-plugin/classes/api_token_scopes.go + - ../sm-user-plugin/classes/mcp_guard.go + - ../sm-user-plugin/classes/mcp_guard_test.go + - ../sm-user-plugin/plugin.go + - ../sm-user-plugin/README.md + - ../sm-user-plugin/go.mod +autonomous: true +requirements: [] +estimate: + tokens: 38000 + raw_tokens: 38000 + tasks: 2 + confidence: low +must_haves: + truths: + - "SC-1: On a branch from tag v0.1.14, an OAuth MCP subject (ClientID non-empty) whose grant holds read satisfies a declared scope with Implies read, in both MCPAllows and the credential MCPDispatch builds (D-01a). write does not satisfy Implies read." + - "A personal-token subject (ClientID empty) is not expanded. Its credential ID stays subject.TokenID, the v0.1.14 rule (D-11)." + - "The compiled catalog is published as pact.MemberTokenScopeCatalog. Implied names are not written onto the token row, the fingerprint, or subject.Scopes (D-05, D-01a)." + - "The app submodule at sm-demo-app/plugins/golem15/user is not edited." + artifacts: + - path: ../sm-user-plugin/classes/mcp_guard.go + provides: "OAuth-only Implies expansion shared by MCPAllows and MCPDispatch" + contains: "ClientID" + - path: ../sm-user-plugin/plugin.go + provides: "Publish of MemberTokenScopeCatalog beside the concrete catalog" + contains: "MemberTokenScopeCatalog" + key_links: + - from: classes/mcp_guard.go MCPAllows + to: classes/mcp_guard.go MCPDispatch + via: one helper that clones scopes only when ClientID is non-empty + pattern: ClientID + - from: plugin.go bootAPITokenScopes + to: pact.MemberTokenScopeCatalog + via: app.Publish of the interface + pattern: MemberTokenScopeCatalog +--- + + +sm-user-plugin from tag v0.1.14 publishes the member-token catalog and applies Implies only to OAuth MCP subjects (D-01a, D-05, D-11). Personal tokens keep their scopes and their credential id. + +Purpose: an OAuth grant of read and write can list and run the same tools as a personal token that carries the declared names the plugins marked as implied, without storing those names on the grant. +Output: a worktree at the path below, on a branch from v0.1.14, plus one tracer test. The rest of the Implies matrix is plan 05. + +Worktree, exact: /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin +Remote: git@git.golem15.com:golem15/sm-user-plugin.git +Branch: phase-32.1-mcp-implied-scopes, created from tag v0.1.14 (commit a1b78b80). Do not use sm-demo-app/plugins/golem15/user or any other app submodule. That checkout is v0.1.13 and has the wrong credential-id body. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md +@modules/pact/capabilities.go +@modules/pact/mcp.go + + + + + + Tracer: v0.1.14 worktree publishes the catalog and expands one implied scope + git ls-remote git@git.golem15.com:golem15/sm-user-plugin.git refs/tags/v0.1.14 prints a commit, and /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin is either absent or already this phase's worktree on phase-32.1-mcp-implied-scopes. Halt if that path is any other checkout, including an app submodule. + D-01a: once a plugin marks Implies read or write, live OAuth agents gain those tools. Removing it later shrinks their tool list. + ../sm-user-plugin/go.mod, ../sm-user-plugin/classes/api_token_scopes.go, ../sm-user-plugin/classes/mcp_guard.go, ../sm-user-plugin/plugin.go, ../sm-user-plugin/classes/mcp_guard_test.go + + The worktree's classes/mcp_guard.go after the branch exists (v0.1.14 MCPDispatch sets credential.ID only when ClientID is empty). Do not read the v0.1.13 submodule as the body to edit. + The worktree's classes/api_token_scopes.go CompileAPITokenScopes and builtInAPITokenScopeKeys. + The worktree's plugin.go bootAPITokenScopes (today app.Publish of the concrete catalog and of pact.MCPPersonalTokenResolver). + modules/pact/mcp.go MemberTokenScopeCatalog in the framework repo. + ../sm-oauthserver-plugin/go.mod replace line, as the pattern for a sibling replace. + + + Fetch the tag and create the worktree with git clone or git worktree add of git@git.golem15.com:golem15/sm-user-plugin.git at /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin, branch phase-32.1-mcp-implied-scopes from v0.1.14. Confirm HEAD's MCPDispatch comment says a personal-token credential ID is subject.TokenID and an OAuth credential keeps ID 0. If the comment still says the credential has ID 0 and never sets credential.ID, stop: that is v0.1.13. + + Point the summercms replace at ../summercms.go so this worktree compiles against plan 01. If go.mod has no replace for git.golem15.com/golem15/summercms, add one. Commit that replace with the feature commit in the plugin repo. Do not retarget any app submodule. + + On *APITokenScopeCatalog add MemberTokenScopes() []pact.FrontendTokenScope returning the compiled list (built-ins and declared). In bootAPITokenScopes, after the existing concrete app.Publish(catalog), also app.Publish the same value as pact.MemberTokenScopeCatalog (D-05). Do not remove the concrete publish or the MCPPersonalTokenResolver publish. + + Extend CompileAPITokenScopes: reject Implies other than "", "read" or "write"; reject a declared name backend or offline_access. Built-in read, write and ai keep Implies empty. Existing rejections of read, write, ai as declared names, duplicates, and empty label or description stay. + + Add one helper used by MCPAllows and MCPDispatch (D-01a). When subject.ClientID is empty, return subject.Scopes unchanged. Otherwise clone subject.Scopes and, for each catalog entry whose Implies is read or write, if the clone contains that stock scope and not the declared name, append the declared name. Never append a name the catalog does not mark. Never let write satisfy Implies read. Do not write the clone back onto subject.Scopes, a token row, or a fingerprint. MCPAllows checks req.Scopes against the helper's slice. MCPDispatch puts that slice on the in-memory models.APIToken.Scopes and still sets credential.ID = subject.TokenID only when ClientID is empty (D-11). Do not set APIToken.OAuthClientID. + + Tracer test TestMCPImpliesOAuth in classes/mcp_guard_test.go: an OAuth subject with ClientID set and scopes read passes MCPAllows for a requirement servers:read when the catalog entry says Implies read, and the MCPDispatch credential HasScope servers:read while its ID stays 0. Commit only in the plugin repo, staging paths under that worktree. Do not git add -A. Do not push or tag. + + + go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin test -short -count=1 -run '^TestMCPImpliesOAuth$' . + non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL + + + - git -C the worktree rev-parse HEAD is a descendant of v0.1.14, not of the v0.1.13 submodule commit 07196a4 alone. + - TestMCPImpliesOAuth PASS: OAuth read grant satisfies Implies read, credential ID is 0. + - sm-demo-app/plugins/golem15/user has no diff from this task. + + The v0.1.14 branch publishes the catalog and one OAuth implied scope works end to end through allow and dispatch. + + + + Reject bad Implies and keep personal tokens unexpanded + D-01a and D-03: boot errors and the personal-token exemption are part of the contract plugins and grants will rely on. + ../sm-user-plugin/classes/api_token_scopes.go, ../sm-user-plugin/classes/mcp_guard.go, ../sm-user-plugin/classes/mcp_guard_test.go, ../sm-user-plugin/README.md + + The worktree classes/api_token_scopes.go and classes/mcp_guard.go after the tracer commit. + The worktree README.md + modules/pact/capabilities.go FrontendTokenScope.Implies comment + + + Finish the helper's edges if the tracer left any call site on slices.Clone(subject.Scopes) for the scope check. MCPAllows and MCPDispatch must both call the helper. A personal token (ClientID empty) with only read must not gain a declared name. A grant of write must not satisfy Implies read. A second copy of an already-present declared name is not appended. + + CompileAPITokenScopes error text names the plugin and the bad Implies value or the reserved name backend or offline_access, in the same style as the existing declared-name errors. Phrase-key checks in bootAPITokenScopes stay. + + README: document FrontendTokenScope.Implies (empty, read, or write), that expansion happens only inside MCPAllows and MCPDispatch when ClientID is non-empty, that personal tokens are unchanged, and that the catalog is published as pact.MemberTokenScopeCatalog. Do not name a consuming application. Do not persist implied names. Commit in the plugin repo only. Do not push or tag. Do not edit the framework repo in this plan. + + + go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin test -short -count=1 -run 'TestMCP' . + non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPImpliesOAuth has no PASS line + + + - A personal-token test in this package shows scopes unchanged and credential ID equal to TokenID. + - An Implies value other than empty, read, or write fails CompileAPITokenScopes. + - README names MemberTokenScopeCatalog and Implies and names no consuming application. + + D-01a holds on both call sites, personal tokens are not expanded, and the plugin README matches the API. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| OAuth grant scopes to MCPAllows / user.scope | The grant is the attacker's consented set. Implies may add only names the plugin marked. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-32.1-05 | Elevation of privilege | MCPUserGuard Implies helper | high | mitigate | Expand only when ClientID is non-empty, only for a catalog Implies of read or write, only the declared name that says so. Do not write the expanded set onto the token, the fingerprint, or subject.Scopes. Personal tokens are returned unchanged (D-01a, D-11). | + + + +go test -short -run TestMCP in the v0.1.14 worktree passes, including TestMCPImpliesOAuth, and the app submodule is untouched. + + + +- Catalog publication and OAuth-only Implies are on a branch from v0.1.14. +- Personal-token credential ID behaviour from that tag is intact. + + + +Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md` when done. + + +## Artifacts this phase produces + +- Worktree /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin branch phase-32.1-mcp-implied-scopes from tag v0.1.14 +- *APITokenScopeCatalog.MemberTokenScopes +- app.Publish of pact.MemberTokenScopeCatalog +- CompileAPITokenScopes rejection of bad Implies and of declared names backend and offline_access +- Unexported effective-scope helper in classes/mcp_guard.go +- TestMCPImpliesOAuth diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-PLAN.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-PLAN.md new file mode 100644 index 00000000..633533a2 --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-PLAN.md @@ -0,0 +1,303 @@ +--- +phase: 32.1 +plan: 03 +type: execute +wave: 3 +depends_on: + - 32.1-01 + - 32.1-02 +files_modified: + - ../sm-oauthserver-plugin/config/config.yaml + - ../sm-oauthserver-plugin/servers.go + - ../sm-oauthserver-plugin/classes/mcp_verifier.go + - ../sm-oauthserver-plugin/classes/mcp_store.go + - ../sm-oauthserver-plugin/classes/mcp_scopes.go + - ../sm-oauthserver-plugin/controllers/mcp_consent_api.go + - ../sm-oauthserver-plugin/controllers/authorizations.go + - ../sm-oauthserver-plugin/lang/en/lang.yaml + - ../sm-oauthserver-plugin/lang/pl/lang.yaml + - ../sm-oauthserver-plugin/README.md + - ../sm-oauthserver-plugin/authorizations_smoke_test.go + - ../sm-oauthserver-plugin/mcp_scopes_test.go + - ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go +autonomous: false +requirements: [] +estimate: + tokens: 64000 + raw_tokens: 64000 + tasks: 4 + confidence: low +must_haves: + truths: + - "SC-1: golem15.oauthserver.mcp_scopes unset advertises every declared member scope; a set list advertises only those names; an empty list advertises the five fixed names. Fixed names read, write, ai, offline_access, backend are always present. An unknown name fails boot (D-03, D-04)." + - "SC-3: An MCP allow upserts golem15_oauthserver_consents for that user and client, replacing scopes. It does not call SaveConsent. A later allow of a smaller set drops the unticked names (D-08, D-12)." + - "SC-3: Revoke takes lockUserGrants, deletes the consent, OIDC codes, MCP codes, access tokens and refresh tokens of the pair, then emits MCPSubjectRevoked with ClientID and a zero TokenID (D-12, D-15)." + - "GET /oauth/authorizations entries include kind oidc or mcp as the last key. A POST of decision allow with no scopes field still grants the whole request (D-13, D-06)." + - "offline_access stays a column flag. The pending view surfaces it. IssueCode's signature is unchanged (D-06)." + artifacts: + - path: ../sm-oauthserver-plugin/config/config.yaml + provides: "golem15.oauthserver.mcp_scopes" + contains: "mcp_scopes" + - path: ../sm-oauthserver-plugin/classes/mcp_verifier.go + provides: "MCPScopesSupported on the existing verifier value" + contains: "MCPScopesSupported" + - path: ../sm-oauthserver-plugin/controllers/mcp_consent_api.go + provides: "scope_details, logo_url, consent replace" + contains: "scope_details" + - path: ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go + provides: "idempotent MCP consent backfill" + contains: "golem15_oauthserver_consents" + key_links: + - from: servers.go NewServers + to: classes/mcp_verifier.go MCPScopesSupported + via: the same slice assigned to mcpOpts.ScopesSupported + pattern: MCPScopesSupported + - from: controllers/mcp_consent_api.go MCPConsentDecide + to: golem15_oauthserver_consents + via: replace upsert after IssueCode, not SaveConsent + pattern: ON CONFLICT + - from: controllers/authorizations.go revoke + to: golem15_oauthserver_mcp_codes + via: lockUserGrants then Delete of MCPCode + pattern: lockUserGrants +--- + + +sm-oauthserver-plugin closes SC-1's host filter and SC-3's consent row (D-02, D-03, D-04, D-06, D-07, D-08, D-12, D-13, D-14, D-15). The MCP server's one scope slice is the fixed five plus declared names filtered by golem15.oauthserver.mcp_scopes. Consent JSON gains scope_details and logo_url. A decision replaces the stored scopes. Revoke deletes MCP codes under the grants lock. kind is additive. The backfill is one-way and waits for the checkpoint in this plan. + +Purpose: an agent that consents over MCP can be revoked per client, and the protected-resource list cannot drift from the authorization server. +Output: plugin code, phrases, README, the authorizations key-list update, and, after the checkpoint, the backfill migration. Behaviour proofs beyond the existing smoke tests are plan 05. + +Discretion recorded here: +- An opt-in name that no plugin declares fails boot. The error names the key golem15.oauthserver.mcp_scopes and the unknown name. A warning would start a server whose metadata disagrees with the file (D-03). +- kind is always the string models.ClientKindOIDC or models.ClientKindMCP ("oidc" or "mcp"), never omitted, and it is the last JSON key (D-13). + +Do not bump go.mod require versions to unreleased tags. The existing replace of summercms to ../summercms.go stays. Do not edit views/consent.html.tmpl. Do not edit wristband. Do not force read into a grant. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md +@../sm-oauthserver-plugin/servers.go +@../sm-oauthserver-plugin/classes/mcp_verifier.go +@../sm-oauthserver-plugin/controllers/mcp_consent_api.go +@../sm-oauthserver-plugin/controllers/authorizations.go +@../sm-oauthserver-plugin/classes/oidc_store.go +@../sm-oauthserver-plugin/classes/mcp_store.go +@../sm-oauthserver-plugin/classes/mcp_backend_binding.go +@../sm-oauthserver-plugin/config/config.yaml +@modules/pier/config.go +@modules/pact/mcp.go + + + + + + Tracer: one MCP scope slice from config to metadata and the verifier + /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/go.mod exists and its replace points at ../summercms.go. Plan 01's pact.MCPAdvertisedScopes is in that tree. + D-03: scope names published in metadata and later stored on grants are costly to retract. + ../sm-oauthserver-plugin/config/config.yaml, ../sm-oauthserver-plugin/servers.go, ../sm-oauthserver-plugin/classes/mcp_scopes.go, ../sm-oauthserver-plugin/classes/mcp_verifier.go, ../sm-oauthserver-plugin/mcp_scopes_test.go + + ../sm-oauthserver-plugin/servers.go NewServers (mcpOpts := opts then append backend) + ../sm-oauthserver-plugin/config/config.yaml header (SUMMER_ double-underscore env spelling) and the mcp: block + ../sm-oauthserver-plugin/classes/mcp_verifier.go MCPTokenVerifier + modules/pier/config.go originsKey (the []any / []string / comma-string switch to copy) + modules/compass config Has versus String (String is empty for both missing and empty) + modules/pact/mcp.go MCPAdvertisedScopes + + + Add config key mcp_scopes under the mcp block in config/config.yaml, with a comment that unset means every declared member scope, a YAML list means only those declared names, and an empty list means the five fixed names only. Document the env spelling SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES. Do not change mcp.enabled's default of false. + + Add an unexported builder, called from NewServers, that returns the MCP slice and an error. Order is always read, write, ai, offline_access, backend, then declared names in catalog order. Declared names are MemberTokenScopes entries whose name is not read, write or ai. Lookup pact.MemberTokenScopeCatalog. A missing catalog means no declared names. When Config.Has("golem15.oauthserver.mcp_scopes") is false, include every declared name. When it is true, include only declared names the list contains. Read the value with Lookup, accepting []string, []any of strings, or a comma-separated string, the same three shapes originsKey accepts. An unknown name fails boot with an error that contains golem15.oauthserver.mcp_scopes and that name. Do not filter the five fixed names. Never return an empty slice. + + Assign that slice only to mcpOpts.ScopesSupported. mcpOpts := opts is already a struct copy. Do not assign cfg.OIDC.ScopesSupported and do not append backend a second time (D-02). Store the same slice on the MCPTokenVerifier value NewServers already builds, and add MCPScopesSupported() []string so that value implements pact.MCPAdvertisedScopes (D-04). Publish stays the existing verifier publish; the extra method is on that concrete value, not a new method of pact.MCPTokenVerifier. + + TestMCPAdvertisedScopes in mcp_scopes_test.go, no Docker: a fake catalog with acme:read, key unset, yields the five fixed names plus acme:read; key set to acme:read yields the same; key set to empty yields the five only; key set to not-declared returns an error naming the key; the OIDC server's scopes stay openid, profile, email. Hit Metadata on the MCP server from NewServers for the unset case and compare scopes_supported to MCPScopesSupported. Commit in the plugin repo only, those files. Do not git add -A. Do not push or tag. + + + go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -short -count=1 -run '^TestMCPAdvertisedScopes$' . + non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL + + + - TestMCPAdvertisedScopes PASS covers unset, set, empty, and unknown name. + - OIDC discovery scopes in that test stay openid, profile, email. + - mcpOpts.ScopesSupported is never empty, including the empty-catalog case. + + Metadata and the verifier advertise one non-empty slice, and an unknown mcp_scopes name fails boot. + + + + Consent JSON, replace-not-merge, offline_access, revoke, and kind + docker info exits 0. The plugin tests use testcontainers Postgres and skip the database when Docker is down. + Consent JSON fields and the revoke delete list are code. The backfill that writes rows on every install is the next task, not this one. + ../sm-oauthserver-plugin/controllers/mcp_consent_api.go, ../sm-oauthserver-plugin/controllers/authorizations.go, ../sm-oauthserver-plugin/classes/mcp_store.go, ../sm-oauthserver-plugin/lang/en/lang.yaml, ../sm-oauthserver-plugin/lang/pl/lang.yaml, ../sm-oauthserver-plugin/README.md, ../sm-oauthserver-plugin/authorizations_smoke_test.go + + ../sm-oauthserver-plugin/controllers/mcp_consent_api.go (show body, grantedScopes, IssueCode call, WithConsentedBackend) + ../sm-oauthserver-plugin/classes/oidc_store.go SaveConsent (unions; do not call it) and TouchConsent + ../sm-oauthserver-plugin/classes/mcp_store.go MarkIssued (does not write offline_access today) + ../sm-oauthserver-plugin/classes/mcp_backend_binding.go WithConsentedBackend and lockUserGrants + ../sm-oauthserver-plugin/controllers/authorizations.go revoke and the authorization struct + ../sm-oauthserver-plugin/authorizations_smoke_test.go (len of keys is 6) + ../sm-oauthserver-plugin/mcp_consent_smoke_test.go the subtest that posts scopes backend and expects 422 + ../sm-oauthserver-plugin/models/client.go ClientKindOIDC and ClientKindMCP + modules/phrasebook translator Get + + + Keep scopes as []string. Add scope_details objects with name, label, description, locked, and add logo_url on the client object (D-07). locked is true only for read when read was requested. logo_url is classes.ClientLogoURL, empty string when none. Labels: if the catalog text contains "::", resolve it with phrasebook.Translator.Get; otherwise use the literal. backend and offline_access are not in the member catalog. Add phrase keys under consent.mcp in lang/en/lang.yaml and lang/pl/lang.yaml. English: backend label "Also act as an administrator", description "The agent may use the administrator account this page names."; offline_access label "Stay connected", description "The agent may refresh its access without asking you to sign in again." Polish: backend label "Działaj też jako administrator", description "Agent może używać konta administratora, które wskazuje ta strona."; offline_access label "Pozostań połączony", description "Agent może odnawiać dostęp bez ponownego logowania." + + When PendingRequestView.OfflineAccess is true, include offline_access in the show scopes array and in scope_details (locked false). grantedScopes intersects the submitted list with that augmented requested list, not only view.ScopesRequested. Strip the word offline_access before IssueCode. Do not change IssueCode's signature. A submit whose only remaining scope is offline_access still returns 422 no_grantable_scopes. Do not special-case read inside grantedScopes. The existing smoke subtest that posts scopes backend for a read-plus-backend request with no link must still get 422 no_grantable_scopes (D-06). + + Follow WithConsentedBackend with a context value the MCP store reads in MarkIssued. When the POST body omits the scopes field, do not set the override, so the pending row's offline_access flag is kept (old all-or-nothing clients). When scopes is present, set the column from whether offline_access survived the intersection. Absent context means MarkIssued leaves the column as the pending row has it. + + D-08 and D-12, after IssueCode returns nil: load the consent for (userID, clientID). Compare the requested set (data scopes plus offline_access when that flag was on the pending view) to the stored scopes. If a row exists and nothing requested is missing, call TouchConsent only and do not change scopes. If a row exists and something is missing, or there is no row, insert or replace scopes with the granted set, including the word offline_access when the flag was granted. Replace means the stored array becomes the granted set, not a union. Do not call oidc SaveConsent. Deny does not change the row. Add additive consent_required on the show body: false when a row exists and nothing requested is missing, true otherwise. Do not skip the authorize redirect; the user is known only on these jwt.auth routes. + + A POST with decision allow and no scopes field still grants every requested data scope (D-06, old pages). Do not require scope_details or kind on the request. + + revoke (D-12, D-15): inside the existing transaction, call classes.lockUserGrants for the user before the deletes. Then delete the consent, models.AuthCode, models.AccessToken, models.RefreshToken, and models.MCPCode for the same user_id and client_id. Keep 404 errNotFound when no consent exists. After commit, keep the existing MCPSubjectRevoked event with Guard user, ClientID set, and TokenID left zero. Do not add a second event or a second backend revoke. One delete of the access and refresh rows covers backend_user_id on those rows. + + GET /oauth/authorizations: select c.kind, set authorization.Kind with json kind, last field. Always set it from the client kind, oidc or mcp. Update authorizations_smoke_test.go so the key list is client_id, name, logo_url, scopes, first_authorized_at, last_used_at, kind, in that order. The length check must match the new list. Existing OIDC rows in that test get kind oidc. + + README: document mcp_scopes (unset, set, empty, fixed five always, boot error on an unknown name), scope_details and logo_url, consent replace versus the OIDC union, offline_access as a column, kind, and that revoke deletes MCP codes under lockUserGrants. Say that a non-nil DCR scope ceiling still drops names outside that ceiling and this phase does not rewrite old ceilings. Do not name a consuming application. Do not log bearers, codes, or refresh tokens. Commit in the plugin repo only. Do not push or tag. Do not write the backfill file in this task. + + + go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -count=1 -timeout 240s -run 'TestMCPConsentSmoke|TestAuthorizationsSmoke|TestMCPAdvertisedScopes' . + non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestAuthorizationsSmoke has no PASS line + + + - Show JSON still has scopes as a string array, plus scope_details and client.logo_url. + - MCPConsentDecide does not call SaveConsent. + - authorizations_smoke_test.go expects seven keys ending in kind. + - The backend-only consent POST still expects 422 no_grantable_scopes. + - revoke deletes models.MCPCode after lockUserGrants, and the revoked event has no TokenID. + + Consent, revoke, and the authorizations list behave as D-06 through D-13 and D-15, and the existing smokes pass. + + + + Confirm the one-way MCP consent backfill + D-14 writes consent rows into golem15_oauthserver_consents on every install. A later rollback cannot tell those rows from consents the new code created. + Write the D-14 backfill that inserts one golem15_oauthserver_consents row per live MCP user and client, with Rollback as a no-op? + D-14 is already the chosen behaviour. This checkpoint is the one-way door before the migration file exists. Rollback must not delete every MCP consent, because that would also delete consents created after the migration. The next task writes the file only if write-noop-rollback is accepted. On resume, before that task creates or refuses the migration, write `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt` containing only the accepted option id, `write-noop-rollback` or `hold`. Do not create the migration in this task. + + + + + Select write-noop-rollback or hold + + .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md D-14 + ../sm-oauthserver-plugin/updates/202610230001_add_backend_user_id.go + + + - The accepted option is recorded before any backfill file is created. + - That record is `32.1-03-backfill-decision.txt` in this phase directory and its entire text is `write-noop-rollback` or `hold`. + - hold means the next task does not add a migration. + + + + + Backfill MCP consents for live grants + D-14 inserts consent rows on every consuming install. Rollback is a no-op and does not delete those rows. + ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go, .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt + + ../sm-oauthserver-plugin/updates/202610230001_add_backend_user_id.go (Register pattern) + ../sm-oauthserver-plugin/models/consent.go + ../sm-oauthserver-plugin/models/access_token.go + ../sm-oauthserver-plugin/models/refresh_token.go + ../sm-oauthserver-plugin/models/client.go ClientKindMCP + + + Run this task only when the checkpoint option write-noop-rollback was accepted. If the accepted option is hold, stop without creating a migration file and say so in the summary. Do not rewrite `32.1-03-backfill-decision.txt` after this task starts changing the plugin tree. If you resumed and that record is not on disk yet, write the accepted option id into it before creating or deleting the migration, then leave the record unchanged. The verify reads that record and fails when it is absent or disagrees with the migration file. + + Add updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go with migration ID 202610280001_backfill_golem15_oauthserver_mcp_consents, registered from init via Register, so it sorts after 202610230001_add_golem15_oauthserver_backend_user_id. Migrate, in Go on the transaction: one row per (user_id, client_id) where the client kind is mcp and the pair has at least one live access or refresh token. Live means revoked_at IS NULL and (expires_at IS NULL or expires_at is in the future). Skip the pair when any consent row already exists. scopes is the union of those tokens' scopes arrays; if any live token has offline_access true, include the string offline_access. created_at and updated_at are the earliest created_at among the live tokens. last_used_at stays null. A second run inserts nothing. Rollback returns nil and its comment says the writes are one-way and a down migration must not delete MCP consents. Do not delete token rows. Commit in the plugin repo only. Do not push or tag. + + + python3 -c 'import pathlib,sys; rec=pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-backfill-decision.txt"); mig=pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go"); +assert rec.is_file(), "checkpoint record absent" +opt=rec.read_text().strip() +exists=mig.is_file() +assert opt in ("write-noop-rollback","hold"), "checkpoint record is not an option id" +if opt=="hold": + assert not exists, "hold but migration file exists" + sys.exit(0) +text=mig.read_text() if exists else "" +assert exists, "write-noop-rollback but migration file missing" +for s in ("202610280001_backfill_golem15_oauthserver_mcp_consents","Register","golem15_oauthserver_consents"): + assert s in text, "migration missing "+s +i=text.find("Rollback") +assert i>=0, "Rollback missing" +rest=text[i:] +nl=rest.find("\nfunc ") +body=rest if nl<0 else rest[:nl] +assert ".Delete(" not in body and "DELETE FROM" not in body, "Rollback deletes" +assert "return nil" in body, "Rollback does not return nil"' + non-zero exit, stdout or stderr contains checkpoint record absent, checkpoint record is not an option id, hold but migration file exists, write-noop-rollback but migration file missing, migration missing, Rollback missing, Rollback deletes, or Rollback does not return nil + + + - When write-noop-rollback was accepted, the migration ID sorts after 202610230001_add_golem15_oauthserver_backend_user_id, Rollback does not delete consents, and the file is committed in the plugin repo. + - When hold was accepted, that file does not exist. + + D-14 is either written as an idempotent no-op-rollback migration or explicitly held. It is not written before the checkpoint. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser POST /oauth/mcp/requests/{request} | Signed-in user submits decision and scopes. The body is untrusted. | +| DELETE /oauth/authorizations/{client_id} | Signed-in user names a client. Another user's row must stay. | +| Operator config mcp_scopes | Names enter metadata and the allow-list at boot. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-32.1-06 | Elevation of privilege | MCP consent upsert | high | mitigate | Replace scopes on a real decision. Do not call SaveConsent, which unions. Silent re-grant only TouchConsent. Deny does not change the row (D-08). | +| T-32.1-07 | Elevation of privilege | authorizations.revoke | high | mitigate | lockUserGrants exclusive inside the transaction before deletes. Delete models.MCPCode for the pair plus consent, auth codes, access and refresh. Event is MCPSubjectRevoked with ClientID and zero TokenID (D-12, D-15). | +| T-32.1-08 | Information disclosure | PendingRequest / IssueCode | high | mitigate | Keep owner checks. Missing, foreign, expired and used stay wristband.ErrPendingNotFound. Do not add a lookup by request id alone. | +| T-32.1-09 | Tampering | grantedScopes when scopes is absent | medium | mitigate | A POST of decision allow without scopes still grants the whole requested set. scope_details and kind are response fields. Old pages ignore them. | +| T-32.1-10 | Information disclosure | consent and revoke logs | medium | mitigate | Do not log the bearer, the authorization code, or a refresh token. Errors stay the existing JSON codes. | + + + +TestMCPAdvertisedScopes passes under -short. TestMCPConsentSmoke and TestAuthorizationsSmoke pass against Postgres. The backfill file exists only after write-noop-rollback. + + + +- One scope slice drives the MCP server and the verifier. +- Consent replace, offline_access, revoke-deletes-MCP-code, and kind are in the plugin. +- D-14 is gated and, when accepted, idempotent with a no-op rollback. + + + +Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-SUMMARY.md` when done. + + +## Artifacts this phase produces + +- Config key golem15.oauthserver.mcp_scopes and env SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES +- Unexported advertised-scope builder used by NewServers +- MCPTokenVerifier.MCPScopesSupported +- Consent JSON fields scope_details (name, label, description, locked), client.logo_url, consent_required +- Phrase keys consent.mcp.backend, consent.mcp.backend_description, consent.mcp.offline_access, consent.mcp.offline_access_description in en and pl +- Context override read by mcpTx.MarkIssued for the offline_access column +- Replace upsert into golem15_oauthserver_consents +- authorization.kind json kind +- revoke deletes golem15_oauthserver_mcp_codes under lockUserGrants +- Migration ID 202610280001_backfill_golem15_oauthserver_mcp_consents when the checkpoint accepts it diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-PLAN.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-PLAN.md new file mode 100644 index 00000000..9609c8a6 --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-PLAN.md @@ -0,0 +1,183 @@ +--- +phase: 32.1 +plan: 04 +type: execute +wave: 4 +depends_on: + - 32.1-03 +files_modified: + - ../vue-oauth-layer/package.json + - ../vue-oauth-layer/nuxt.config.ts + - ../vue-oauth-layer/app/pages/connect.vue + - ../vue-oauth-layer/app/utils/connectConsent.ts + - ../vue-oauth-layer/tests/connect.test.ts + - ../vue-oauth-layer/README.md + - ../vue-oauth-layer/.playground/nuxt.config.ts +autonomous: true +requirements: [] +estimate: + tokens: 42000 + raw_tokens: 42000 + tasks: 2 + confidence: low +must_haves: + truths: + - "SC-3: The layer page /connect renders the MCP consent show JSON as checkboxes. read is locked in the UI. The server is not asked to force read into the grant (D-06, D-17)." + - "Client name, logo, scope label, scope description and the admin name are text, never raw HTML (D-07)." + - "backend shows the also-act-as-admin line when backend_admin is set. A 409 backend_link_changed fetches the show again (D-17)." + - "No application /connect page is edited, replaced, or deleted. The repo is not tagged or pushed in this plan." + artifacts: + - path: ../vue-oauth-layer/app/pages/connect.vue + provides: "Nuxt layer page /connect" + contains: "connectConsent" + - path: ../vue-oauth-layer/app/utils/connectConsent.ts + provides: "Pure mapping from show JSON to POST body" + contains: "allowBody" + - path: ../vue-oauth-layer/tests/connect.test.ts + provides: "node:test coverage of the mapping" + contains: "node:test" + key_links: + - from: app/pages/connect.vue + to: app/utils/connectConsent.ts + via: the page imports the pure functions the tests import + pattern: allowBody + - from: allowBody + to: POST /oauth/mcp/requests/{request} + via: JSON decision, scopes, backend_admin + pattern: decision +--- + + +Initial public Nuxt layer vue-oauth-layer, one plan (D-17, D-06, D-07). It provides /connect for redirect consent from the MCP consent JSON plan 03 returns. Apps adopt it later. This plan does not migrate any app and does not tag or push. + +Purpose: a redirect-mode application can render per-scope consent without copying a page, while an application that already has /connect keeps its own page because Nuxt project files win over a layer. +Output: a new repo beside vue-menu-layer, with node --test and a README. The first tag is plan 05's checkpoint, not this plan. + +Copy vue-menu-layer's skeleton (fileURLToPath, node --test, no extra runtime dependencies), not vue-journal-layer. Sibling path of the template: /media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer. New repo path: /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md +@../vue-menu-layer/package.json +@../vue-menu-layer/nuxt.config.ts +@../vue-menu-layer/tests/menuLinks.test.ts +@../sm-oauthserver-plugin/controllers/mcp_consent_api.go + + + + + + Tracer: show JSON maps to a locked-read allow body + /media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer/package.json exists, and /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer is either absent or already this phase's new repo. Halt if that path is some other project. + D-17: the layer's /connect props and POST body become the contract apps will pin by tag. + ../vue-oauth-layer/package.json, ../vue-oauth-layer/nuxt.config.ts, ../vue-oauth-layer/app/utils/connectConsent.ts, ../vue-oauth-layer/app/pages/connect.vue, ../vue-oauth-layer/tests/connect.test.ts, /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer + + ../vue-menu-layer/package.json (engines, peer nuxt 4.4.8, dev vue 3.5.35 and typescript 5.9.3, test script) + ../vue-menu-layer/nuxt.config.ts (fileURLToPath) + ../vue-menu-layer/tests/menuLinks.test.ts (node:test and node:assert/strict importing a ts util) + ../sm-oauthserver-plugin/controllers/mcp_consent_api.go show and decide bodies, after plan 03 + + + Create the repo with git init at /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer. Do not add a remote, do not push, do not tag. + + package.json name vue-oauth-layer, version 0.1.0, private true, type module, main ./nuxt.config.ts, engines node >= 22.18, peerDependency nuxt 4.4.8, devDependencies nuxt 4.4.8, typescript 5.9.3, vue 3.5.35. Script test is node --test --test-reporter=tap tests/*.test.ts. No other runtime dependency. Do not add sanitize-html, an SEO module, or i18n. + + nuxt.config.ts builds every path with fileURLToPath(new URL('.', import.meta.url)). No relative layer path. No Nuxt modules. compatibilityDate 2025-07-15. devtools disabled. + + app/utils/connectConsent.ts exports pure functions the page and the tests both import. scopeBoxes(show) returns one box per scope_details entry, each checked to start, with locked copied from the detail. allowBody(show, clearedNames) returns decision allow. When show.consent_required is false, the body is decision allow and omits scopes, so the server keeps the stored set. Otherwise scopes is the checked names. A box with locked true cannot be cleared: read stays in scopes when it was requested. denyBody returns decision deny. backendAdminLogin(show) is show.backend_admin.login when backend_admin is non-null and backend is among the scopes, else absent. shouldRefetch(status, errorCode) is true only for HTTP 409 and error backend_link_changed. + + app/pages/connect.vue reads the request query, calls those functions, and posts JSON to /oauth/mcp/requests/{request} on the same origin with credentials included. It does not add a Nitro proxy. Render label, description, client name and admin name as text. Do not use v-html. The tracer page must already import connectConsent.ts so the test and the page share one implementation. A 401 redirects to the loginPath prop, default /login. + + tests/connect.test.ts: one show payload with scope_details for read (locked true) and write (locked false), clearing write, allowBody scopes are read only. consent_required false yields a body without a scopes key. The test reads app/pages/connect.vue and fails if the source contains v-html. Run pnpm install in the new repo so the test script's node can resolve the package, then commit only inside vue-oauth-layer. Do not git add -A. Do not edit any app. + + + pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test + non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0 + + + - pnpm test exits 0 and the locked-read case passes. + - connect.vue imports the same module the test imports. + - No v-html in app/pages/connect.vue. + - No app repository has a diff from this task. + + A show payload becomes an allow POST that keeps locked read, and the page uses that function. + + + + Finish /connect and the layer README + The page copy and the README install line are what a later tag publishes (D-17). + ../vue-oauth-layer/app/pages/connect.vue, ../vue-oauth-layer/app/utils/connectConsent.ts, ../vue-oauth-layer/tests/connect.test.ts, ../vue-oauth-layer/README.md, ../vue-oauth-layer/.playground/nuxt.config.ts, /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer + + ../vue-oauth-layer/app/utils/connectConsent.ts + ../vue-oauth-layer/app/pages/connect.vue + ../vue-menu-layer/README.md install and extends example, as the shape to copy with this layer's name + ../sm-oauthserver-plugin/controllers/mcp_consent_api.go backend_link_changed and backend_admin + + + Complete the page (D-17). Show the client name and, when logo_url is non-empty, an img whose alt is the client name as text. Every requested scope starts checked. read's checkbox is disabled and checked, and the POST from a shown form always includes read when it was requested. The server does not force read; do not send a flag that would make it do so. offline_access, backend and declared scopes can be cleared. When backend_admin is non-null and backend is requested, show the sentence "also act as admin" followed by the admin name as text, and send backend_admin set to backend_admin.login. Allow posts decision allow. Deny posts decision deny. On 409 with error backend_link_changed, fetch the show JSON again and render it. When consent_required is false, post decision allow immediately and do not render checkboxes. + + Do not log the user in. Do not read a product name from an app. loginPath prop default remains /login, documented as the handoff when the JSON route answers 401. + + Tests cover: locked read stays in the POST when the box cannot be cleared; a cleared write is absent; consent_required false omits scopes; 409 backend_link_changed is the refetch signal and a 422 is not; backend_admin.login is sent only when the line is shown. The source check that rejects v-html stays. + + README: what the layer is, the install shape pnpm add git+https://git.golem15.com/golem15/vue-oauth-layer.git#vX.Y.Z, and extends ['vue-oauth-layer']. Say the tag does not exist until a later release the user accepts. Say an application's own app/pages/connect.vue wins over this layer, and this repo does not edit those pages. Do not name a consuming application. .playground/nuxt.config.ts extends the layer via fileURLToPath of the parent directory so a local dev server resolves the layer. No new dependency. Commit in the layer repo only. Do not push or tag. + + + pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test + non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0 + + + - The page implements allow, deny, locked read, the admin line, and the 409 re-show. + - README shows the git pin shape and extends, and does not claim a tag was pushed. + - pnpm test passes. + + D-17's /connect behaviour is in the layer, tested, and not tagged. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| MCP consent JSON to the browser | Client name, labels and admin name are attacker-influenced (DCR name, phrase text). | +| Page POST back to the consent route | The browser can omit fields. The server intersection is the control. This page must not widen it. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-32.1-11 | Tampering | app/pages/connect.vue | high | mitigate | Render label, description, client name and admin name as text. The unit test fails if the page source contains v-html. Do not bind those strings as HTML. | +| T-32.1-12 | Elevation of privilege | read checkbox | low | accept | The checkbox is disabled and the page's own POST includes read when the form was shown. A client that omits read only narrows the grant. The server must not reinsert read, because the existing 422 smoke posts backend alone. Narrowing is not a privilege gain. | + + + +pnpm test in vue-oauth-layer passes. No app /connect file is modified. No tag and no push. + + + +- /connect maps the consent JSON, locks read in the UI, and refetches on backend_link_changed. +- The layer has no runtime dependency beyond the Nuxt peer the menu layer already pins. + + + +Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-SUMMARY.md` when done. + + +## Artifacts this phase produces + +- /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer +- Repo /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer +- app/pages/connect.vue +- app/utils/connectConsent.ts exports scopeBoxes, allowBody, denyBody, backendAdminLogin, shouldRefetch +- tests/connect.test.ts +- Prop loginPath default /login +- README install shape git+https://git.golem15.com/golem15/vue-oauth-layer.git#vX.Y.Z diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-PLAN.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-PLAN.md new file mode 100644 index 00000000..c1ecd9d7 --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-PLAN.md @@ -0,0 +1,383 @@ +--- +phase: 32.1 +plan: 05 +type: execute +wave: 5 +depends_on: + - 32.1-01 + - 32.1-02 + - 32.1-03 + - 32.1-04 +files_modified: + - modules/pact/mcp_call_negative_test.go + - modules/pier/dispatch_mcpcall_test.go + - modules/pier/prm_scopes_test.go + - ../sm-user-plugin/classes/mcp_guard_test.go + - ../sm-oauthserver-plugin/mcp_consent_test.go + - ../sm-oauthserver-plugin/authorizations_kind_test.go + - ../sm-oauthserver-plugin/mcp_backfill_test.go + - ../sm-oauthserver-plugin/go.mod +autonomous: false +requirements: [] +estimate: + tokens: 58000 + raw_tokens: 58000 + tasks: 4 + confidence: low +must_haves: + truths: + - "SC-2: MCPCallFrom on a bare context is ok false, and a frontend tool and a backend tool each carry MCPCall for the guard that owns the tool (D-09, D-11)." + - "SC-1: Implies cases on the v0.1.14 worktree cover OAuth read, personal-token non-expansion, and write not satisfying Implies read (D-01a). PRM with and without MCPAdvertisedScopes is asserted (D-04)." + - "SC-3: Consent replace does not merge, offline_access follows the column, revoke deletes the MCP code, authorizations kind is mcp or oidc, and the backfill is idempotent when the migration exists (D-06, D-08, D-12, D-13, D-14)." + - "SC-4: TestDocsTree and docs:build --check pass. Tags, pushes, and the changelog post happen only after the release checkpoint accepts them (D-16)." + artifacts: + - path: modules/pact/mcp_call_negative_test.go + provides: "Bare-context MCPCallFrom and a FrontendTokenScope literal without Implies" + contains: "TestMCPCallFromBareContext" + - path: modules/pier/dispatch_mcpcall_test.go + provides: "Frontend and backend dispatch both set MCPCall" + contains: "TestDispatchMCPCallBothGuards" + - path: ../sm-oauthserver-plugin/mcp_consent_test.go + provides: "Replace, offline_access, and revoke-deletes-code cases" + contains: "TestMCPConsentReplace" + key_links: + - from: modules/pier/dispatch_mcpcall_test.go + to: pact.MCPCallFrom + via: a real tools/call on each guard + pattern: MCPCallFrom + - from: mcp_backfill_test.go + to: migration 202610280001_backfill_golem15_oauthserver_mcp_consents + via: two migrate runs + pattern: backfill +--- + + +Last plan of the phase: the unit tests that prove SC-1, SC-2, SC-3 and SC-4 beyond the tracer smokes of plans 01–04, then a human gate before any tag, push, or changelog post (D-16). + +Purpose: the project rule puts the broad test pass last. Tagging is not part of an autonomous task. +Output: test files in the framework repo, the user-plugin worktree, and sm-oauthserver-plugin; pnpm test of vue-oauth-layer; docs checker. Tags only if the checkpoint option is tag-and-publish. + +Do not edit app submodules, do not turn mcp.enabled on in an application, and do not hardcode the next framework version. + + + +@~/.claude/msd-core/workflows/execute-plan.md +@~/.claude/msd-core/templates/summary.md + + + +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md +@.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-VALIDATION.md +@modules/pier/dispatch_test.go +@modules/pier/tracer_smoke_test.go +@modules/pact/mcp_call_test.go +@../sm-oauthserver-plugin/mcp_consent_smoke_test.go +@../sm-oauthserver-plugin/testmain_test.go + + + + + + Tracer: bare context has no MCP call, and both guards' tool calls do + Tests only. They lock the accessor contract already added in plan 01. + modules/pact/mcp_call_negative_test.go, modules/pier/dispatch_mcpcall_test.go, modules/pier/prm_scopes_test.go + + modules/pact/mcp_call_test.go (do not weaken TestMCPCallRoundTrip) + modules/pier/mcp_seam_test.go + modules/pier/dispatch_test.go and modules/pier/tracer_smoke_test.go for the mount helpers + modules/pier/backend_guard.go BackendGuard constant + modules/pier/prm.go + + + Add tests. Do not change production behaviour unless a test shows the plan 01 setter missed a guard; the setter in dispatcher.run is shared, so a miss is a test-setup bug, not a second setter. + + modules/pact/mcp_call_negative_test.go: TestMCPCallFromBareContext asserts MCPCallFrom(context.Background()) and MCPCallFrom on a request context that never called WithMCPCall return ok false (D-11). TestFrontendTokenScopeLiteralWithoutImplies builds FrontendTokenScope{Name, Label, Description} with no Implies field and asserts Implies is empty, so existing composite literals stay valid. + + modules/pier/prm_scopes_test.go: TestPRMUsesAdvertisedScopes mounts pier with a verifier that implements MCPAdvertisedScopes and expects that slice on the protected-resource document, including a declared name. TestPRMFallsBackToPRMScopes mounts pier with a verifier that does not implement the interface and expects PRMScopes, which includes backend (D-04). + + modules/pier/dispatch_mcpcall_test.go: TestDispatchMCPCallBothGuards, package pier_test, calls one frontend tool and one backend tool. Each handler reads pact.MCPCallFrom and records Guard and Tool. The frontend call's Guard is the frontend subject's guard. The backend call's Guard is the backend guard name. SessionID is not the word ephemeral. A personal-token versus OAuth difference is not asserted here; that is the user-plugin test in the next task. Do not print tokens. Commit in the framework repo, staging only these test files. Do not git add -A. + + + go test -short -count=1 -run 'TestMCPCallFromBareContext|TestFrontendTokenScopeLiteralWithoutImplies|TestPRMUsesAdvertisedScopes|TestPRMFallsBackToPRMScopes|TestDispatchMCPCallBothGuards' ./modules/pact ./modules/pier + non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or any of those five test names has no PASS line + + + - All five named tests PASS. + - The backend tool's recorded guard is the backend guard, and the frontend tool's guard is the frontend subject. + - No production file is in this commit. + + SC-2's negative and both guards, and SC-1's PRM fallback, are proven in the framework repo. + + + + Plugin, layer, and docs proofs for SC-1 through SC-4 + docker info exits 0; the user-plugin worktree exists at /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin on phase-32.1-mcp-implied-scopes; vue-oauth-layer/package.json exists. + Tests and a docs-checker run. No schema write except the oauthserver test database. + ../sm-user-plugin/classes/mcp_guard_test.go, ../sm-oauthserver-plugin/mcp_consent_test.go, ../sm-oauthserver-plugin/authorizations_kind_test.go, ../sm-oauthserver-plugin/mcp_backfill_test.go, /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin, /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer + + The worktree classes/mcp_guard_test.go (keep TestMCPImpliesOAuth) + ../sm-oauthserver-plugin/mcp_consent_test.go and mcp_consent_smoke_test.go + ../sm-oauthserver-plugin/testmain_test.go (freshDB skips when testing.Short) + ../sm-oauthserver-plugin/authorizations_smoke_test.go + ../sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go if it exists + ../vue-oauth-layer/tests/connect.test.ts + + + sm-user-plugin, on the v0.1.14 worktree, extend classes/mcp_guard_test.go. Do not edit an app submodule. Add TestMCPImpliesPersonalUnchanged: ClientID empty, scopes read, catalog Implies read on servers:read, MCPAllows rejects a requirement of servers:read, and MCPDispatch credential ID equals TokenID and does not HasScope servers:read. Add TestMCPImpliesWriteDoesNotGrantRead: OAuth ClientID set, scopes write, Implies read, MCPAllows rejects servers:read. Add TestMCPImpliesNotPersisted: after MCPDispatch the subject.Scopes slice is unchanged. Keep the v0.1.14 credential-id behaviour. Commit in the plugin repo only. + + sm-oauthserver-plugin, Docker, not -short. Use the package Postgres helper. Add to mcp_consent_test.go: TestMCPConsentReplace allows read and write, then allows only read, and the consent row's scopes are only read (not a union). The test must fail if the code calls SaveConsent. TestMCPOfflineAccessUntick: pending offline_access true, POST scopes without that word, the issued MCP code's offline_access column is false; a POST that omits the scopes field leaves the flag as the pending row had it. TestMCPRevokeDeletesCode: issue an MCP code, DELETE /oauth/authorizations/{client_id}, exchanging that code fails, and a second MCP client of the same user still has its token. The revoked event's ClientID is the revoked client and TokenID is zero. + + authorizations_kind_test.go TestAuthorizationsKind: an OIDC consent lists kind oidc and an MCP consent lists kind mcp, and the keys are client_id, name, logo_url, scopes, first_authorized_at, last_used_at, kind. + + If the D-14 file exists, mcp_backfill_test.go TestMCPConsentBackfillIdempotent: a live MCP access token with no consent gets one row whose scopes union the token scopes and include offline_access when the column is true; a pair that already has a consent is left as it was; a second migrate inserts nothing. If the checkpoint in plan 03 held the migration, do not add this test and say that in the summary. + + Do not log bearer tokens or authorization codes in test output. Assert status codes and ids. + + vue-oauth-layer: pnpm install only if node_modules is missing, then pnpm test. Do not add a dependency. + + Framework: go test ./cmd/summer -run TestDocsTree -count=1 and go run ./cmd/summer docs:build --check, then go vet ./... && go test -short ./... . Commit framework test fixes in the framework repo and plugin tests in each plugin repo. Do not git add -A. Do not tag or push in this task. + + + cd /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin && go test -short -count=1 -run 'TestMCPImplies' . + non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPImpliesPersonalUnchanged has no PASS line + go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -count=1 -timeout 300s -run 'TestMCPConsentReplace|TestMCPOfflineAccessUntick|TestMCPRevokeDeletesCode|TestAuthorizationsKind|TestMCPConsentBackfillIdempotent' . + non-zero exit, or the output contains `[no tests to run]` for a test this command still names, or a line contains FAIL. If plan 03 held the backfill, `[no tests to run]` is a failure only when TestMCPConsentBackfillIdempotent is still named in the -run list; drop that name and record why. + pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test + non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0 + go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go vet ./... && go test -short ./... + non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL + + + - TestMCPImpliesPersonalUnchanged, TestMCPImpliesWriteDoesNotGrantRead, TestMCPConsentReplace, TestMCPOfflineAccessUntick, TestMCPRevokeDeletesCode and TestAuthorizationsKind each have a PASS line. + - TestMCPConsentBackfillIdempotent has a PASS line when the migration file exists, and is absent when the migration was held. + - TestDocsTree, docs:build --check, go vet ./... and go test -short ./... pass. + - vue-oauth-layer pnpm test passes. + + SC-1 through SC-4 have automated proofs beyond the earlier tracer smokes, and no tag has been created. + + + + Whether to tag, push, and post the changelog + A pushed framework tag is the release other apps will require. It is not created unless this checkpoint accepts it (D-16). + Tag and push the framework 0.5 patch, the sm-user-plugin patch, the sm-oauthserver-plugin patch, and the first vue-oauth-layer tag, then post the framework changelog? + D-16 is patch releases only. The version numbers are computed from existing tags at execution time, never typed in advance. The changelog is the summercms.io post for the framework tag. Plugin repos are pushed before any app pointer bump, and this phase does not bump app pointers. There is no auto_select: an unattended run must stop here. On resume, before the next task changes any ref, write `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt`. First line is `hold` or `tag-and-publish`. Then four sections in this order: a header line `framework yes` or `framework no`, one local tag name per line, then a line that is only a period, and the same for `user`, `oauth`, and `layer`. The header is yes only when that repo already has a remote named origin. Tag names come from a read-only for-each-ref of refs/tags. Do not create a tag, do not push, and do not publish the changelog in this task. + + + + + Select tag-and-publish or hold + + .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md D-16 + CLAUDE.md section on changelog posts + + + - No git tag, git push, or changelog publish runs before this checkpoint returns. + - hold leaves every repo untagged by this plan. + - `32.1-05-release-decision.txt` exists before the next task changes any ref, and its first line is the accepted option id. + + + + + Tag and publish only if the checkpoint accepted it + Pushed tags and a changelog post are the release. hold does none of this. + ../sm-oauthserver-plugin/go.mod, .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-release-decision.txt, .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md + + ../sm-oauthserver-plugin/go.mod require lines for summercms and sm-user-plugin + CLAUDE.md changelog section (scripts/changelog-post.py, no token printing) + ../sm-summercmsio-app/DEPLOY.md section Posting to the changelog, if present + + + Read `32.1-05-release-decision.txt`. Do not rewrite it after any ref changes. If you resumed and the file is not on disk yet, write it in the form the checkpoint describes, from the accepted option and a read-only for-each-ref of each repo, before changing any ref, then leave the file unchanged. If the first line is hold, do not create a tag, do not push, and do not run the changelog script. Say that in the summary and stop. + + If the accepted option is tag-and-publish (D-16): compute versions from git tag --list. Framework: highest v0.5.* patch, plus one. Never a minor bump and never a version literal written into Go source. sm-user-plugin: highest v0.1.* patch, plus one, on branch phase-32.1-mcp-implied-scopes. sm-oauthserver-plugin: highest v0.1.* patch, plus one. vue-oauth-layer: v0.1.0, the version already in its package.json, only if that tag does not already exist. + + Order: commit any uncommitted test files by explicit path in their own repos. Tag the framework. Tag the user plugin. In sm-oauthserver-plugin, set the require versions of summercms and sm-user-plugin to those new tags, commit that go.mod and go.sum change by path, then tag the plugin. Tag vue-oauth-layer. Do not delete or move the existing summercms replace line. + + Push each repo that already has origin: the branch that was tagged, then the tag. Do not force-push. If vue-oauth-layer has no origin, leave the local tag and stop before inventing a remote; report that the empty remote git@git.golem15.com:golem15/vue-oauth-layer.git still has to be created by the user. Do not bump any app submodule pointer. + + Changelog, only for the framework tag, from ../sm-summercmsio-app: scripts/changelog-post.py source for that tag, then publish `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-changelog-notes.md` with Highlights, Added, Changed, and Fixed. The notes say which framework, sm-user-plugin, sm-oauthserver-plugin and vue-oauth-layer versions close the three gaps, and they may name sm-servers-app as the audience (D-16). Do not put that application name into framework source, READMEs, or docs. No planning IDs, no quick codes, no H1. Do not print, copy, or mint the journal token. If ~/.config/summercms/journal-token is missing or the script refuses it, stop and ask. Do not git add -A. The verify reads the checkpoint record and fails when that record is absent or the local refs, the origin listing, or the changelog post disagree with it. Do not point the verify command at tag creation or at a push. + + + python3 -c 'import pathlib, subprocess, sys, urllib.request, urllib.error +root = pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/summercms.go") +phase = root / ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con" +rec = phase / "32.1-05-release-decision.txt" +notes = phase / "32.1-05-changelog-notes.md" +repos = {"framework": root, "user": pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin"), "oauth": pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin"), "layer": pathlib.Path("/media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer")} +if not rec.is_file(): + sys.exit("checkpoint record absent") +lines = [ln.strip() for ln in rec.read_text().splitlines()] +if not lines or lines[0] not in ("hold", "tag-and-publish"): + sys.exit("checkpoint record option is not hold or tag-and-publish") +opt = lines[0] +sections = {} +i = 1 +for name in ("framework", "user", "oauth", "layer"): + if i >= len(lines): + sys.exit("checkpoint record missing " + name) + head = lines[i].split() + i += 1 + if len(head) != 2 or head[0] != name or head[1] not in ("yes", "no"): + sys.exit("checkpoint record bad header " + name) + tags = [] + while i < len(lines) and lines[i] != ".": + if lines[i]: + tags.append(lines[i]) + i += 1 + if i >= len(lines) or lines[i] != ".": + sys.exit("checkpoint record missing end " + name) + i += 1 + sections[name] = tags +def git_out(path, args): + if not path.is_dir(): + sys.exit("repo missing " + str(path)) + r = subprocess.run(["git", "-C", str(path), *args], capture_output=True, text=True) + if r.returncode != 0: + sys.exit("git failed in " + path.name) + return r.stdout +def current_tags(path): + out = git_out(path, ["for-each-ref", "--format=%(refname:short)", "refs/tags"]) + return [ln.strip() for ln in out.splitlines() if ln.strip()] +def has_origin(path): + r = subprocess.run(["git", "-C", str(path), "remote", "get-url", "origin"], capture_output=True, text=True) + return r.returncode == 0 +def remote_tags(path): + out = git_out(path, ["ls-remote", "--tags", "origin"]) + names = [] + for ln in out.splitlines(): + parts = ln.split() + if len(parts) < 2: + continue + ref = parts[-1] + if ref.endswith("^{}"): + continue + prefix = "refs/tags/" + if ref.startswith(prefix): + names.append(ref[len(prefix):]) + return names +def bump(tags, prefix): + best = None + for t in tags: + if t.startswith(prefix) and t[len(prefix):].isdigit(): + n = int(t[len(prefix):]) + best = n if best is None else max(best, n) + if best is None: + sys.exit("no baseline tag for " + prefix) + return prefix + str(best + 1) +if opt == "hold": + for name in ("framework", "user", "oauth", "layer"): + if set(current_tags(repos[name])) != set(sections[name]): + sys.exit("hold but local tags differ " + name) + sys.exit(0) +expected = {"framework": {bump(sections["framework"], "v0.5.")}, "user": {bump(sections["user"], "v0.1.")}, "oauth": {bump(sections["oauth"], "v0.1.")}, "layer": set() if "v0.1.0" in sections["layer"] else {"v0.1.0"}} +fw_tag = None +for name in ("framework", "user", "oauth", "layer"): + cur = set(current_tags(repos[name])) + base = set(sections[name]) + added = cur - base + removed = base - cur + if removed: + sys.exit("tags removed in " + name) + if added != expected[name]: + sys.exit("tags disagree in " + name) + if has_origin(repos[name]) and not expected[name] <= set(remote_tags(repos[name])): + sys.exit("push missing in " + name) + if name == "framework": + fw_tag = next(iter(expected[name])) +token = pathlib.Path.home() / ".config" / "summercms" / "journal-token" +if not token.is_file(): + sys.exit(0) +if fw_tag is None or not str(fw_tag).startswith("v"): + sys.exit("framework tag missing") +ver = fw_tag[1:] +if not notes.is_file(): + sys.exit("changelog notes missing") +content = notes.read_text().strip() +if (not content) or content.startswith("# "): + sys.exit("changelog notes empty or start with an H1") +for h in ("## Highlights", "## Added", "## Changed", "## Fixed"): + if h not in content: + sys.exit("changelog notes missing heading") +if ver not in content: + sys.exit("changelog notes missing version") +url = "https://summercms.io/changelog/summercms-" + ver.replace(".", "-") +req = urllib.request.Request(url, headers={"User-Agent": "summercms-plan-verify"}) +try: + with urllib.request.urlopen(req, timeout=30) as r: + body = r.read().decode("utf-8", "replace") +except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "replace") +except Exception: + sys.exit("changelog fetch failed") +if ("SummerCMS " + ver) not in body: + sys.exit("changelog post missing " + ver)' + non-zero exit. That includes checkpoint record absent, checkpoint record option is not hold or tag-and-publish, checkpoint record missing, checkpoint record bad header, repo missing, git failed, hold but local tags differ, no baseline tag, tags removed, tags disagree, push missing, framework tag missing, changelog notes missing, changelog notes empty or start with an H1, changelog notes missing heading, changelog notes missing version, changelog fetch failed, or changelog post missing. + + + - hold: git tag --list in each repo shows no tag created by this task, and the changelog script was not run. + - tag-and-publish: the framework tag is the next v0.5 patch, the two plugins are the next v0.1 patches, vue-oauth-layer is v0.1.0, and each repo that had origin was pushed. The framework changelog post was attempted, or the task stopped because the token file was missing. + - No app submodule pointer moved. + + D-16 either shipped as patch tags plus the framework changelog, or stayed local because the checkpoint said hold. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Test output and changelog notes | Tokens and authorization codes must not be copied into logs or the public post. | +| git push of a release tag | The tag is what other repositories will require. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-32.1-13 | Information disclosure | tests and changelog-post.py | medium | mitigate | Assert status codes and ids. Do not print bearer tokens, authorization codes, or the journal token. If the token file is missing, stop. | +| T-32.1-14 | Spoofing | release tags | low | accept | Tagging does not change PKCE, redirect matching, or the read/write ceiling. The checkpoint is the control on publishing, not a new auth path. | + + + +The named tests PASS, docs:build --check passes, and no tag exists unless tag-and-publish was accepted. + + + +- SC-1 through SC-4 are covered by the tests named in this plan. +- Tags, pushes, and the changelog post are behind the checkpoint. + + + +Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-SUMMARY.md` when done. + + +## Artifacts this phase produces + +- /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin +- /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer + +Tests added: +- TestMCPCallFromBareContext +- TestFrontendTokenScopeLiteralWithoutImplies +- TestPRMUsesAdvertisedScopes +- TestPRMFallsBackToPRMScopes +- TestDispatchMCPCallBothGuards +- TestMCPImpliesPersonalUnchanged +- TestMCPImpliesWriteDoesNotGrantRead +- TestMCPImpliesNotPersisted +- TestMCPConsentReplace +- TestMCPOfflineAccessUntick +- TestMCPRevokeDeletesCode +- TestAuthorizationsKind +- TestMCPConsentBackfillIdempotent when the D-14 migration file exists + +Release, only after tag-and-publish: next framework v0.5 patch tag, next sm-user-plugin v0.1 patch tag, next sm-oauthserver-plugin v0.1 patch tag, vue-oauth-layer v0.1.0, and the framework changelog post. diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md new file mode 100644 index 00000000..273d910b --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-PATTERNS.md @@ -0,0 +1,358 @@ +# Phase 32.1: MCP OAuth for plugin apps — Pattern Map + +**Mapped:** 2026-10-28 +**Files analyzed:** 28 +**Analogs found:** 27 / 28 (the new `vue-oauth-layer` repo has no in-repo file; copy the sibling `vue-menu-layer` git repo) + +Tracked-source check: framework paths are `git ls-files` in this repo. Plugin paths are `git ls-files` inside `sm-oauthserver-plugin` (`df2eddd`, v0.1.3) and the `sm-user-plugin` submodule at `sm-demo-app/plugins/golem15/user` (`07196a4`, **v0.1.13**). That submodule is the only local checkout; **implement the user plugin from tag v0.1.14 (`a1b78b80`), not from this worktree.** `vue-menu-layer` is its own repo (`ef3a090`). Do not edit app submodule pointers. + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `modules/pact/capabilities.go` (`FrontendTokenScope.Implies`) | model | transform | same file, `FrontendTokenScope` / `TokenScope` | exact (in-place) | +| `modules/pact/mcp.go` (`MCPCall`, `WithMCPCall`, `MCPCallFrom`, `MCPAdvertisedScopes`) | model | request-response | `modules/bouncer/dispatch.go` + `modules/pact/mcp.go` `MCPTokenVerifier` | exact | +| `modules/pier/dispatch.go` | service | request-response | same file, `bouncer.WithDispatch` site | exact (in-place) | +| `modules/pier/prm.go` | service | request-response | same file, `mountPRM` | exact (in-place) | +| `modules/wristband/consent.go` (`PendingRequestView.OfflineAccess`) | model | CRUD | same file, `PendingRequestView` | exact (in-place) | +| `modules/{pact,pier,wristband}/README.md`, `docs/services/{mcp,oauth-server,authentication}.md` | config | transform | the same pages | exact (in-place) | +| pact/pier unit tests (new cases) | test | request-response | `modules/wristband/mcp_scopes_test.go`, existing pier tests | role-match | +| sm-user-plugin `classes/api_token_scopes.go` | service | transform | same file, `CompileAPITokenScopes` | exact (in-place; branch from v0.1.14) | +| sm-user-plugin `classes/mcp_guard.go` | middleware | request-response | same file, `MCPAllows` / `MCPDispatch` | exact (in-place; v0.1.14 body) | +| sm-user-plugin `plugin.go` `bootAPITokenScopes` | provider | event-driven (boot) | same function + `app.Publish[pact.MCPPersonalTokenResolver]` | exact | +| sm-user-plugin `README.md` | config | transform | same README | exact | +| sm-user-plugin `classes/mcp_guard_test.go` (extend) | test | request-response | existing `TestMCP*` in that package | role-match | +| sm-oauthserver-plugin `config/config.yaml` (`mcp_scopes`) | config | file-I/O | same file header + `modules/pier/config.go` `originsKey` | role-match | +| sm-oauthserver-plugin `servers.go` `NewServers` | service | transform | same function | exact (in-place) | +| sm-oauthserver-plugin `classes/mcp_verifier.go` | service | request-response | same type; optional method like `methodAllower` | role-match | +| sm-oauthserver-plugin `controllers/mcp_consent_api.go` | controller | request-response | same file | exact (in-place) | +| sm-oauthserver-plugin `controllers/authorizations.go` | controller | CRUD | same file `revoke` | exact (in-place) | +| sm-oauthserver-plugin `updates/.go` | migration | batch | `updates/202610230001_add_backend_user_id.go` | exact | +| sm-oauthserver-plugin `lang/en/lang.yaml`, `lang/pl/lang.yaml` | config | file-I/O | existing oauthserver lang files | exact | +| sm-oauthserver-plugin `README.md` | config | transform | same README | exact | +| sm-oauthserver-plugin consent/authorizations/backfill tests | test | CRUD | `mcp_consent_smoke_test.go`, `authorizations_smoke_test.go` | exact (extend) | +| `vue-oauth-layer` (`package.json`, `nuxt.config.ts`, `app/pages/connect.vue`, `tests/*.test.ts`, README) | component | request-response | `vue-menu-layer` (sibling repo) | role-match | + +## Pattern Assignments + +### `modules/pact/capabilities.go` (model, transform) + +**Analog:** `modules/pact/capabilities.go` (tracked) + +**Core pattern** (lines 814–834) — add `Implies string` only. Zero value keeps existing composite literals valid. Update the comment that says the struct carries no permission patterns. + +```go +type FrontendTokenScope struct { + Name string + Label string + Description string +} +type HasFrontendTokenScopes interface { + FrontendTokenScopes() []FrontendTokenScope +} +``` + +Do not add a method named `FrontendTokenScopes` on the new catalog interface (that name is already `HasFrontendTokenScopes`). + +--- + +### `modules/pact/mcp.go` (model, request-response) + +**Analogs:** `modules/bouncer/dispatch.go` (context key) and `modules/pact/mcp.go` (interfaces already here). + +**Context-key pattern** (`modules/bouncer/dispatch.go` lines 5–34). Copy the unexported key. Prefer not turning a nil ctx into `context.Background()`: pier's dispatch context is never nil. `MCPCallFrom` returns `ok == false` when the value is absent (plain HTTP). + +```go +type dispatchKey struct{} + +func WithDispatch(ctx context.Context, d *Dispatch) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, dispatchKey{}, d) +} +``` + +**Optional interface, not a new method on `MCPTokenVerifier`** (`modules/pact/mcp.go` lines 217–225). A new method would break every fake. Match the second-interface pattern in `modules/pier/backend_guard.go` lines 29–31: + +```go +type methodAllower interface { + MCPAllowsMethod(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string, method string) (bool, error) +} +``` + +Recommended additive types (discretion, already in RESEARCH): + +```go +type MemberTokenScopeCatalog interface { + MemberTokenScopes() []FrontendTokenScope +} +type MCPAdvertisedScopes interface { + MCPScopesSupported() []string +} +type MCPCall struct { + Subject MCPSubject + SessionID string + Tool string +} +``` + +`MCPSubject` stays lines 134–140 (`Guard`, `UserID`, `Scopes`, `ClientID`, `TokenID`). Publish lookup shape is `modules/backpack/app.go` lines 59–73: `Publish[T]` / `Lookup[T]`. + +--- + +### `modules/pier/dispatch.go` (service, request-response) + +**Analog:** same file, lines 169–208. + +Set the call on the line immediately before `WithDispatch`. `subject` is `id.subjectFor(t.guardName)` (line 169). `Tool` is `t.name`. `SessionID` comes from the call info's session, forced to `""` on the stateless path and never the audit word `"ephemeral"`. + +```go +subject := id.subjectFor(t.guardName) +// ... +credential := disp.Credential +if credential == nil && t.guardName == BackendGuard { + credential = id.credential +} +callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential}) +``` + +Do not expand scopes here. Ceiling stays in `modules/pier/filter.go` (`id.writes()` before `MCPAllows`). + +--- + +### `modules/pier/prm.go` (service, request-response) + +**Analog:** same file, lines 12–40. + +```go +var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"} +// ... +if verifier, ok := p.app.Lookup[pact.MCPTokenVerifier](); ok && verifier != nil { + if res := verifier.MCPResource(); res != "" { /* issuer */ } +} +meta := &oauthex.ProtectedResourceMetadata{ + Resource: p.cfg.Resource, + ScopesSupported: append([]string(nil), PRMScopes...), + BearerMethodsSupported: []string{"header"}, +} +``` + +Replace only the `ScopesSupported` assignment: if the same verifier also implements `pact.MCPAdvertisedScopes` and the slice is non-empty, clone that; otherwise keep `PRMScopes`. Do not import the oauthserver plugin. Do not read the raw member-token catalog (that skips the opt-in filter). + +--- + +### `modules/wristband/consent.go` (model, CRUD) + +**Analog:** same file, lines 35–74. Add `OfflineAccess bool` to `PendingRequestView` and fill it in `PendingRequest`. Do not change `IssueCode`'s signature. The store reads a context value (same idea as `classes.WithConsentedBackend`) so a direct `IssueCode` call keeps today's flag. + +```go +type PendingRequestView struct { + ClientName string + RedirectHost string + ScopesRequested []string + ExpiresAt time.Time +} +func (s *Server) IssueCode(ctx context.Context, requestID string, userID uint, grantedScopes []string, collectionIDs []uint) (string, error) +``` + +Authorize allow-list is already `Options.ScopesSupported` when non-empty (`modules/wristband/authorize.go` `allowedAuthorizeScopes`). No wristband change is required to accept a declared name once the host fills the slice. An empty slice falls back to the PHP four-name map and drops `backend` — the host must never pass an empty slice. + +--- + +### sm-user-plugin `classes/api_token_scopes.go` + `plugin.go` (service + boot provider) + +**Analog (structure only):** `sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go` lines 52–127 and `plugin.go` lines 154–195. **Branch from v0.1.14.** Do not edit the submodule. + +`CompileAPITokenScopes` already rejects built-in names `read`/`write`/`ai`, duplicates, empty label/description, and fails boot. Extend that same loop: reject `Implies` other than `""`, `"read"`, `"write"`, and reject declared names `backend` and `offline_access`. Built-ins keep `Implies` empty. Phrase-key check stays in `bootAPITokenScopes` (`tr.Has`). + +Publish the compiled list as the new pact interface, beside the existing concrete publish and the guard publish: + +```go +return app.Publish(catalog) // today: *APITokenScopeCatalog +// and: +return app.Publish[pact.MCPPersonalTokenResolver](guard) +``` + +The published catalog includes built-ins (labels for consent) plus declared scopes. Advertised declared names are catalog entries whose name is not `read`, `write`, or `ai`. `offline_access` and `backend` are not member-token scopes; oauthserver prepends the fixed five. + +--- + +### sm-user-plugin `classes/mcp_guard.go` (middleware, request-response) + +**Analog:** v0.1.13 worktree `MCPAllows` lines 87–136 is the wrong credential body. On **v0.1.14** `MCPDispatch` sets `credential.ID = subject.TokenID` only when `subject.ClientID == ""` and leaves OAuth credentials at ID 0. Keep that. One helper used by both `MCPAllows` and `MCPDispatch`: + +- `ClientID == ""`: return `subject.Scopes` unchanged (personal token). +- else: clone scopes; for each catalog entry with `Implies` `"read"` or `"write"`, if the grant contains that stock scope and the declared name is absent, append it. +- Never write the expanded set onto the token row, the session fingerprint, or `subject.Scopes` as stored. + +`MCPAllows` today (v0.1.13 lines 90–94), which the helper replaces for the scope check only: + +```go +for _, scope := range req.Scopes { + if !slices.Contains(subject.Scopes, scope) { + return false, nil + } +} +``` + +`MCPDispatch` puts the helper's slice on `models.APIToken.Scopes` (`lagoon.Jsonable`). `middleware.Scope` reads `token.HasScope` (`slices.Contains` on that slice). Do not set `APIToken.OAuthClientID`. The OAuth client id belongs on `pact.MCPCall`. Do not expand scopes in pier's backend guard (`modules/pier/backend_guard.go`). + +--- + +### sm-oauthserver-plugin `servers.go` + `config/config.yaml` (service + config) + +**Analog:** `servers.go` lines 154–158 and `modules/pier/config.go` `originsKey` lines 230–262. + +```go +mcpOpts := opts +mcpOpts.Resource = cfg.MCPResource +mcpOpts.ScopesSupported = append(slices.Clone(opts.ScopesSupported), "backend") +``` + +`mcpOpts := opts` is already a struct copy. Build the MCP slice on that copy only: fixed five (`read`, `write`, `ai`, `offline_access`, `backend`), then declared names in catalog order, filtered by `golem15.oauthserver.mcp_scopes` when `Config.Has` is true. Empty list (key set) means fixed five only. Unset means every declared scope. Do not assign `cfg.OIDC.ScopesSupported`. Do not append `"backend"` twice. Unknown opt-in name fails boot (name the key and the name), matching `CompileAPITokenScopes`. + +Read the list with `Lookup` and the `[]any` / `[]string` / comma-string switch from `originsKey` (env `SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES`). Config file header documents the env spelling (`config/config.yaml` lines 1–3). + +`classes/mcp_verifier.go` returns `pact.MCPGrant` (lines 109–118) and must also implement `MCPScopesSupported()` with the same slice `NewServers` assigned. Publish both `pact.MCPTokenVerifier` and the optional scopes interface on that value. + +--- + +### sm-oauthserver-plugin `controllers/mcp_consent_api.go` (controller, request-response) + +**Analog:** same file. Keep `scopes []string`. Add `scope_details` (`name`, `label`, `description`, `locked`) and `logo_url` on the client. `locked` is true only for requested `read`. + +Show body today (lines 24–43): + +```go +type mcpConsentShowBody struct { + RequestID string `json:"request_id"` + Client mcpConsentClient `json:"client"` + Scopes []string `json:"scopes"` + ExpiresAt string `json:"expires_at"` + BackendAdmin *mcpConsentAdmin `json:"backend_admin"` +} +``` + +Keep `grantedScopes` (lines 168–180). Do not force `read` back in (smoke test posts `scopes: ["backend"]` and expects 422). Augment the requested list with `offline_access` when the pending flag is set, intersect against that, strip the word before `IssueCode`, and set the column from the decision via context. A submit whose only remaining scope is `offline_access` still 422. `read` is data-bearing. `backend` counts only when a link is recorded (existing strip at lines 144–161). + +Labels: catalog phrase through `phrasebook.Translator.Get` when the text contains `::`, else the literal. `backend` and `offline_access` are not in the user catalog; add en+pl phrase keys in this plugin. Logo: `classes.ClientLogoURL` (already used in `authorizations.go` line 81). + +**Do not call `oidcTx.SaveConsent`.** It unions ( `classes/oidc_store.go` lines 328–361). MCP decision replaces scopes (`ON CONFLICT DO UPDATE SET scopes = excluded.scopes`). Silent re-grant (`consent_required: false`, nothing missing) only `TouchConsent` (lines 364–366) and issues the requested subset. First grant inserts. Deny does not change the row. Upsert after `IssueCode` returns nil. Store `offline_access` in the consent scope list when the flag was granted. `models.ClientKindMCP` / `ClientKindOIDC` are `"mcp"` / `"oidc"` (`models/client.go` lines 14–17). + +--- + +### sm-oauthserver-plugin `controllers/authorizations.go` (controller, CRUD) + +**Analog:** same file, lines 21–157. + +Add `Kind string \`json:"kind"\`` last on `authorization`. Select `c.kind`. Always set it (`oidc` or `mcp`), do not omit OIDC. Key order stays `client_id`, `name`, `logo_url`, `scopes`, `first_authorized_at`, `last_used_at`, then `kind`. Update `authorizations_smoke_test.go` which requires exactly six keys. + +`revoke` (lines 134–157) locks the consent row then deletes `Consent`, `AuthCode`, `AccessToken`, `RefreshToken`. Also delete `models.MCPCode` for the same pair. Take `classes.lockUserGrants` (exclusive, `classes/mcp_backend_binding.go` lines 61–75) inside the transaction **before** the deletes. Keep 404 `errNotFound` when no consent. Keep the existing `MCPSubjectRevoked` with `ClientID` and **no** `TokenID` (lines 124–126). Do not add a second backend revoke: access/refresh rows already carry `backend_user_id`. + +--- + +### sm-oauthserver-plugin backfill migration (migration, batch) + +**Analog:** `updates/202610230001_add_backend_user_id.go` lines 15–35 (`init() { Register(...) }`, ID sorts after `202610230001_add_golem15_oauthserver_backend_user_id`). + +```go +var addBackendUserID = []*gormigrate.Migration{{ + ID: "202610230001_add_golem15_oauthserver_backend_user_id", + Migrate: func(tx *gorm.DB) error { return run(tx, []string{ /* SQL */ }) }, + Rollback: func(tx *gorm.DB) error { return run(tx, []string{ /* SQL */ }) }, +}} +func init() { Register(addBackendUserID...) } +``` + +A Go loop inside `Migrate` is the practical way to union JSON scope arrays (`models.Strings` is `lagoon.Jsonable[[]string]`). One row per live MCP `(user_id, client_id)`; skip pairs that already have a consent; include `offline_access` from the boolean column; `created_at` = earliest live token; `last_used_at` stays null; second run inserts nothing. `Rollback` no-ops with a comment (D-14 is one-way). Do not delete every MCP consent on rollback. + +--- + +### Docs (config, transform) + +**Analogs:** the pages themselves. Identifiers and the code that exports them land in the same commit (`go test ./cmd/summer -run TestDocsTree`). Framework docs never name a consuming app. Document: + +- `pact.MCPCall` / `MCPCallFrom` / setter: `Subject.TokenID` is a `user_api_tokens` id only when `ClientID == ""`; otherwise it is the oauthserver access-token row. `Subject.Scopes` is the grant, not the implied expansion. +- `golem15.oauthserver.mcp_scopes`: unset = all declared, set = only those names, empty list = fixed five only, fixed five always. Config keys are not checked by the docs checker; review them by hand. +- pier PRM reads the verifier list, else `PRMScopes`. +- Existing DCR clients with a non-nil scope ceiling silently drop names outside that ceiling. Do not rewrite old ceilings. + +Do not add checkboxes to `views/consent.html.tmpl` (OIDC page). MCP authorize always redirects to `{issuer}/connect?request=`. + +--- + +### `vue-oauth-layer` (component, request-response) — no file in this repo + +**Analog:** sibling repo `/media/nvme/dev/golem15/summercms.io/summercms/vue-menu-layer` (git `ef3a090`), not `vue-journal-layer` (journal pulls SEO/i18n/fonts). + +**package.json** (lines 1–24): `"type": "module"`, `"main": "./nuxt.config.ts"`, `"engines": {"node": ">=22.18"}`, peer `nuxt` 4.4.8, dev `vue` 3.5.35 and `typescript` 5.9.3, `"test": "node --test --test-reporter=tap tests/*.test.ts"`. + +**nuxt.config.ts** (lines 1–13): paths from `fileURLToPath(new URL('.', import.meta.url))`. A relative path resolves against the consuming app. + +**Tests:** `tests/menuLinks.test.ts` lines 1–25 — `node:test` + `node:assert/strict` over pure functions, no browser. Map show JSON → boxes, locked `read`, POST body, 409 re-fetch. Page at `app/pages/connect.vue` (Nuxt merges layer pages; an app's own page wins). Render labels as text, not `v-html`. Do not proxy the OAuth API. 401 redirects to a documented login prop, default `/login`. Do not tag or push the repo in the plan. Do not edit any app's `/connect`. + +README install line shape (`vue-menu-layer/README.md` lines 5–7 and 27–35): + +```bash +pnpm add git+https://git.golem15.com/golem15/vue-menu-layer.git#v0.2.0 +``` + +```ts +export default defineNuxtConfig({ + extends: ['vue-menu-layer'], +}) +``` + +## Shared Patterns + +### Publish / lookup +**Source:** `modules/backpack/app.go` lines 59–73 +**Apply to:** user-plugin catalog, oauthserver verifier, pier PRM lookup + +```go +func (a *App) Publish[T any](value T) error +func (a *App) Lookup[T any]() (T, bool) +``` + +### Optional second interface +**Source:** `modules/pier/backend_guard.go` lines 29–31 +**Apply to:** `pact.MCPAdvertisedScopes` on the existing verifier value. Do not add methods to `MCPTokenVerifier`. + +### Config list (YAML list, string slice, or comma env string) +**Source:** `modules/pier/config.go` lines 230–246 +**Apply to:** `golem15.oauthserver.mcp_scopes`. Use `Config.Has` to tell unset from empty. `Config.String` returns `""` for both. + +### Consent intersection (keep) +**Source:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go` lines 168–180 +**Apply to:** MCP consent decide. Augment requested with `offline_access` first; do not special-case `read`. + +### Consent replace vs union +**Source:** `sm-oauthserver-plugin/classes/oidc_store.go` lines 328–366 (`SaveConsent` unions; `TouchConsent` only stamps `last_used_at`) +**Apply to:** MCP must not call `SaveConsent`. Decision path replaces `scopes`. Silent path calls `TouchConsent` only. + +### Grants lock then delete +**Source:** `classes/mcp_backend_binding.go` lines 43–75 and `controllers/authorizations.go` lines 134–157 +**Apply to:** per-client revoke, plus `models.MCPCode`. Event stays `MCPSubjectRevoked` + `ClientID` only (`authorizations.go` lines 124–126). + +### Error JSON +**Source:** `mcp_consent_api.go` `writeDecision` lines 196–208 and `authorizations.go` lines 116–120 +**Apply to:** new consent and revoke branches. 401 `unauthenticated`, 404 `request_not_found` / `not_found`, 409 `backend_link_changed`, 422 `no_grantable_scopes`, 500 `server_error`. + +### Boot failure on a bad declaration +**Source:** `CompileAPITokenScopes` error strings (`golem15.user: plugin %s declared ...`) and `bootAPITokenScopes` missing phrase key +**Apply to:** bad `Implies`, reserved names `backend`/`offline_access`, unknown `mcp_scopes` entry. + +## No Analog Found + +| File | Role | Data Flow | Reason | +|------|------|-----------|--------| +| `vue-oauth-layer` repo (does not exist) | component | request-response | Not in this module. Copy `vue-menu-layer`, not journal. RESEARCH Pattern 10 is the page behaviour; there is no `/connect` Vue page in the framework. | + +User-plugin files exist only as a v0.1.13 submodule. They are valid structural analogs. The credential-id assignment must be copied from tag v0.1.14, which is not checked out. RESEARCH quotes that function (`classes/mcp_guard.go` lines 115–143 on the tag). + +## Metadata + +**Analog search scope:** `modules/pact`, `modules/pier`, `modules/wristband`, `modules/bouncer`, `modules/backpack`, `modules/compass`; `sm-oauthserver-plugin`; `sm-demo-app/plugins/golem15/user` (v0.1.13, read-only); `vue-menu-layer`. +**Files scanned:** the classification table plus `oidc_store.go`, `mcp_backend_binding.go`, `mcp_verifier.go`, `models/client.go`. +**Pattern extraction date:** 2026-10-28 diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md new file mode 100644 index 00000000..ae5ed432 --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-RESEARCH.md @@ -0,0 +1,1022 @@ +# Phase 32.1: MCP OAuth for plugin apps — Research + +**Researched:** 2026-10-28 +**Domain:** MCP OAuth scopes, in-process call context, per-client consent (wristband, pier, pact, sm-oauthserver-plugin, sm-user-plugin, vue-oauth-layer) +**Confidence:** HIGH + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +### Declared scopes and the read/write ceiling +- **D-01:** **The 19.7 D-20 ceiling stays.** OAuth `read`/`write` remain the ceiling: a non-read-only tool still needs `write`, and a route's `user.scope:` still needs the declared scope in the grant. A write tool behind `user.scope:bm:write` needs both `write` and `bm:write`, as a personal token with read/write plus declared scopes does today. No pier ceiling change. +- **D-01a:** **Declared scopes can be implied by stock `read`/`write` (Request 3).** `pact.FrontendTokenScope` gets an additive field (proposed `Implies`, value `"read"` or `"write"`, empty = not implied). An OAuth MCP grant holding the stock scope also satisfies every declared scope that names it as implied. For example, `servers:read` with `Implies: "read"` is satisfied by a `read` grant, so an agent granted `read write` lists the same servers tools as a personal token with `servers:read servers:write` (sm-servers-app's "OAuth list equals personal list"). The expansion applies only to OAuth MCP subjects, in sm-user-plugin's MCP guard (`MCPAllows`/`MCPDispatch`) or where the subject's scopes are built. A personal token's scopes keep their meaning, and the user still never gets a scope the plugin did not mark as implied. Declared scopes are still advertised (D-03) for clients that want a narrower grant. — **Reversibility:** costly — once apps rely on read/write implying their scopes, removing it shrinks live agents' tool lists. +- **D-02:** **Declared scopes are offered on the MCP OAuth server only**, like `backend` (19.7 D-21). The OIDC server keeps its PHP-parity scope list. +- **D-03:** **Advertised scopes = fixed scopes + declared scopes, filtered by an optional opt-in list.** When the config list is set, only the declared scopes it names are advertised and accepted. When it is unset, every declared scope is. The fixed scopes (`read`, `write`, `ai`, `offline_access`, `backend`) are always present. The same set drives the wristband MCP server's metadata `scopes_supported`, its authorize allow-list (`allowedAuthorizeScopes`) and pier's PRM `scopes_supported`. This rule must be documented clearly in the oauthserver README, the pier README and `docs/services/oauth-server.md` / `docs/services/mcp.md`. A name in the list that no plugin declares should be a clear boot error or warning; the planner decides which. — **Reversibility:** costly — scope names published in metadata and stored in grants. +- **D-04:** **The config key lives in sm-oauthserver-plugin** (proposed `golem15.oauthserver.mcp_scopes`), next to where `NewServers` builds `mcpOpts.ScopesSupported`. **pier reads the list from the published verifier** (`pact.MCPTokenVerifier`, extended additively) instead of its own `PRMScopes` constant, so the PRM and the authorization server cannot drift. `pier.PRMScopes` stays as the fallback when no verifier publishes a list. +- **D-05:** **Plumbing: a published pact catalog.** The plugin that owns member tokens (sm-user-plugin, which already compiles and validates `FrontendTokenScopes()` at boot) publishes the compiled list on the app (`app.Lookup[...]`) through a new additive pact interface. sm-oauthserver-plugin and pier read it at boot. Labels and descriptions travel with the names so the consent page can show them. + +### Consent page +- **D-06:** **Per-scope checkboxes.** This changes the consent behaviour from 19.4's allow/deny-the-whole-request. Every requested scope starts ticked. `read`, when requested, is locked (cannot be unticked). `offline_access`, `backend` and declared scopes can be unticked. A submit that leaves no data-bearing scope keeps the existing 422 `no_grantable_scopes`. `MCPConsentDecide` already intersects submitted ∩ requested (`grantedScopes`), so the server side mostly exists. +- **D-07:** **Both surfaces.** The MCP consent JSON (show answer of `controllers/mcp_consent_api.go`) carries a label and description per requested scope: the FrontendTokenScope label/description resolved through phrasebook for declared scopes, existing text for fixed scopes. It also says whether each scope is locked, so redirect-mode app pages can render checkboxes or keep all-or-nothing (as the consuming app chose in its D-07). The built-in consent page renders the checkboxes. *Research must confirm which built-in page the MCP flow uses today* (`views/consent.html.tmpl` vs redirect-only to an app `/connect` page). If the MCP flow has no built-in page, the checkboxes go wherever the plugin ships its MCP consent rendering, and the planner raises it. +- **D-08:** **Re-consent on scope growth.** When a client that already holds a consent requests any scope the recorded consent does not cover, the user goes through the consent page again. After the decision, the stored consent's scopes become the newly granted set. + +### MCP call context accessor +- **D-09:** **The accessor lives in `pact`:** `pact.MCPCallFrom(ctx) (MCPCall, bool)` with a matching setter that pier calls next to `bouncer.WithDispatch` in `dispatch.go`. Plugins already import pact, so they need no pier or bouncer import. +- **D-10:** **New `pact.MCPCall` wrapper:** `MCPCall{Subject MCPSubject, SessionID string, Tool string}`. `MCPSubject` (the guard-facing type) stays unchanged. `SessionID` is the MCP session id and is empty in pier's stateless mode. `Tool` is the tool name. Exact field names are the planner's call within this shape. — **Reversibility:** costly — exported pact API that consuming apps will read. +- **D-11:** **Every MCP tool call carries it:** OAuth and personal-token, frontend and backend guards. The subject is the one pier resolved for that tool's guard (`id.subjectFor(t.guardName)`). Plain HTTP requests have none (`ok == false`). A personal-token call's credential keeps carrying the token id as today (sm-user-plugin v0.1.14); the accessor is additive to it, not a replacement. + +### MCP consent row and revoke +- **D-12:** **The same `golem15_oauthserver_consents` table.** When an MCP code is issued (`MCPConsentDecide` → `IssueCode`, and any direct grant path through the MCP store), upsert the (user_id, client_id) row with the granted scopes. MCP clients are DCR clients distinct from OIDC clients, so the unique (user_id, client_id) constraint does not collide. The existing `revoke` (deletes the consent, plus codes, access and refresh tokens of the pair, then emits `MCPSubjectRevoked{ClientID}`, which pier uses to evict sessions) then works unchanged for MCP clients. +- **D-13:** **MCP clients appear in `GET /oauth/authorizations`** with an additive kind marker (e.g. `"kind":"mcp"`; OIDC entries get `"oidc"` or omit it, per the planner). Existing fields and order are unchanged. +- **D-14:** **A data migration backfills consents** for grants that predate this release: one row per (user, client) with live MCP access or refresh tokens, scopes = union of the tokens' scopes, `created_at` = earliest token. It must be idempotent and skip pairs that already have a consent. — **Reversibility:** one-way — writes rows into a shared table on every consuming install. +- **D-15:** **One revoke ends everything for that client:** frontend and backend (`backend_user_id`) access and every MCP session of the client. No separate backend revoke. + +### Consent page as a Nuxt layer +- **D-17:** **An initial `vue-oauth-layer` ships in this phase.** It is a new public Nuxt layer repo next to `vue-journal-layer`, `vue-faq-layer`, `vue-menu-layer` and `vue-paymentgateway-layer`, with the same structure and the same pnpm-git-dependency-pinned-by-tag consumption. It provides the `/connect` page of redirect consent mode, rendered from the MCP consent JSON (D-07): client name and logo, per-scope checkboxes with labels and descriptions, `read` locked, the "also act as admin " line for `backend`, allow/deny, and the `backend_link_changed` 409 re-show. Apps adopt it later as their own work, so 32.1 does not migrate any app's existing `/connect` page. The layer gets a tag. It is one plan in the phase. + +### Release +- **D-16:** Patch releases only: framework 0.5.x (never hardcode the version), `sm-user-plugin` (catalog publication, implied scopes), `sm-oauthserver-plugin` (scopes, consent JSON/page, consent row, backfill, listing marker), plus the first tag of `vue-oauth-layer`. Commit and push each plugin before bumping pointers in apps. Each pushed framework tag gets a changelog post. The release notes tell sm-servers-app which versions close Requests 3–5. + +### Claude's Discretion +- The pact interface name and shape for the published scope catalog (D-05), within "names + label + description, additive". +- Whether an opt-in name nobody declares fails boot or warns (D-03). +- Field names of `MCPCall` and the setter name (D-10). +- How the kind marker is spelled in the authorizations listing (D-13). + +### Deferred Ideas (OUT OF SCOPE) +- Offering declared scopes on the OIDC server (non-MCP OAuth clients). Rejected for this phase (D-02). +- Release/rollout order across framework, sm-user-plugin, sm-oauthserver-plugin and the consuming app was not discussed in detail; the planner follows the standard "plugin first, pointer bump second" order. +- Moving apps' existing `/connect` pages (sm-servers-app, sm-bm-app, sm-demo-app) onto `vue-oauth-layer`: each app's own work. +- Switching OAuth on in sm-servers-app production: that app's decision once the releases land. + +### Reviewed Todos (not folded) +- 27 pending todos matched on keywords only (e.g. "Record PHP OIDC parity against WavePath", user-plugin logout cookie, activate-by-code second factor). None touch MCP scopes, call context or MCP consent; none were folded. + + + +## Phase Requirements + +ROADMAP marks this phase **Requirements: TBD**. No REQ-IDs are mapped. Do not invent any. + +The contract is the four success criteria in `.planning/ROADMAP.md` (Phase 32.1): + +| Criterion | What must be true | Where the work lands | +|-----------|-------------------|----------------------| +| 1 | A scope declared through `pact.HasFrontendTokenScopes` can be requested, consented to and granted on MCP OAuth, and pier's protected-resource metadata lists it, so `user.scope:` lists and runs for an OAuth agent. Today the list is fixed. | pact catalog + `Implies`, sm-user-plugin guard, sm-oauthserver `mcp_scopes` / `NewServers`, pier PRM | +| 2 | A route run by an MCP tool call can read guard, user, client ID and token id from the request context. A personal-token credential still carries the token id (sm-user-plugin v0.1.14). | `pact.MCPCallFrom` + pier `dispatch.go` | +| 3 | sm-oauthserver-plugin records a consent for MCP grants, so the existing per-client revoke ends that agent's grant and its MCP sessions only. | consent upsert, revoke gap (MCP codes), backfill, authorizations `kind` | +| 4 | Module READMEs and `docs/` describe the three changes. Patch tags are a documented outcome, not a step the plan performs. | docs in the same commits as the API they describe | + +The consuming app's expected-gap tests (`TestMCPOAuthToolsMatchPersonal`, `TestMCPOAuthWriteAttribution`, `TestMCPOAuthEmptyGrant`) are that app's work. This phase does not edit them and does not add app-side scope middleware. + + +## Project Constraints (from CLAUDE.md) + +- Standard library first. A new dependency needs a research doc or a phase/decision note (`.planning/research/STACK.md` is the decided set). This phase adds no Go module and no npm runtime dependency. +- YAML is `goccy/go-yaml`, never `gopkg.in/yaml.v3`. Plugin config stays in the existing `config/config.yaml` files. +- A change to an exported API, config key, CLI command or dependency updates that module's README and the affected `docs/` pages in the same commit. Every identifier in a README or docs page must exist in the package (`summer docs:build --check` / `go test ./cmd/summer -run TestDocsTree`). +- Framework code never names a consuming application. Docs and examples use neutral names (`blog`, `acme`), not sm-servers-app or a product name. +- Do not push, tag or deploy. D-16's patch tags and the changelog post are outcomes the user performs after the plans. Plans name the tag shape (`v0.5.x`, plugin patch, first `vue-oauth-layer` tag) and stop. +- Several sessions work in this repo. Plans name files explicitly. Do not stage `STATE.md` / `state.json` or other sessions' edits. Plugin work happens in the plugin repos, not by editing an app submodule pointer. +- Lean planning: few large plans. Unit tests are the last plan of the phase. Earlier plans may update existing smoke tests when they must stay green, and are not blocked on new coverage. +- State changes go through `msd_run query state.*`, not raw `STATE.md` edits. g15office sync is the planner/executor's job, not this research. +- `go vet ./...` and `go test -short ./...` stay green. Docker-backed tests skip under `-short`. A `TestMain` that starts containers must call `flag.Parse()` and check `testing.Short()`. + +## Summary + +An OAuth agent at `/mcp` is a second caller of the same pier dispatch path as a personal `usr_` token. The authorization server (wristband MCP flavour, built by sm-oauthserver-plugin) only accepts a fixed scope list, so a route behind `user.scope:servers:write` never sees that name on an OAuth grant. The in-process call already puts a bouncer principal and an in-memory `*models.APIToken` on the context; it does not put the MCP subject there, so a handler cannot record the OAuth client. MCP grants never write `golem15_oauthserver_consents`, so `DELETE /oauth/authorizations/{client_id}` answers 404 and deletes nothing for those clients. Session eviction itself already works: a `pact.MCPSubjectRevoked` with `ClientID` set closes only that client's sessions. + +Close the three gaps additively. Publish the member-token catalog (including a new `Implies` field) from sm-user-plugin. sm-oauthserver-plugin turns that catalog plus `golem15.oauthserver.mcp_scopes` into the MCP server's `Options.ScopesSupported`. pier copies the same list off the published verifier into protected-resource metadata, falling back to `PRMScopes` when no verifier publishes one. Expand implied scopes only inside the user plugin's MCP guard, on both `MCPAllows` (the tool list) and `MCPDispatch` (the credential `user.scope` reads). Put `pact.MCPCall` on the dispatch context next to `bouncer.WithDispatch`. Upsert a consent row when an MCP code is issued, replace its scopes on a real decision (do not reuse the OIDC merge), and teach `revoke` to delete `golem15_oauthserver_mcp_codes` under the existing grants lock. The MCP consent UI is not `views/consent.html.tmpl` (that file is the OIDC page). Wristband always redirects the browser to `{issuer}/connect?request=`. Checkboxes live in the consent JSON (additive) and in a new `vue-oauth-layer` page. Do not edit OIDC scopes, the read/write ceiling, or any app's `/connect` page. + +**Primary recommendation:** One framework plan (pact + pier + the small wristband view field), one sm-user-plugin plan based on tag v0.1.14, one sm-oauthserver-plugin plan, one vue-oauth-layer plan, then a last plan for the new unit tests. No new dependencies. Do not tag. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Declared scope names, labels, `Implies` | API / Backend (sm-user-plugin boot) | pact type only | The user plugin already compiles `HasFrontendTokenScopes` and fails boot on a bad name. Pier must not import the user plugin. | +| Which of those names MCP OAuth advertises and accepts | API / Backend (sm-oauthserver-plugin) | wristband `Options.ScopesSupported` | The opt-in list is oauthserver config. Wristband already allow-lists exactly `Options.ScopesSupported` when it is non-empty. OIDC discovery stays on `OIDCOptions.ScopesSupported`. | +| Protected-resource `scopes_supported` | API / Backend (pier) | published verifier | D-04: pier reads the verifier so the PRM cannot drift from the authorization server. | +| Read/write tool ceiling | API / Backend (pier `Identity.writes`) | — | Unchanged. It reads the grant's own `write` scope, not implied declared scopes. | +| `user.scope:` on an MCP call | API / Backend (sm-user-plugin guard + `middleware.Scope`) | — | Listing uses `MCPAllows`. The call uses the in-memory credential's `HasScope`. Both must see the same expanded set, or a tool lists and then 403s. | +| Who called (guard, user, client, token, session, tool) | API / Backend (pact context set by pier) | — | Plugins already import pact. Do not make them import pier or read `bouncer.Dispatch`. | +| Consent record, backfill, per-client revoke | Database / Storage (sm-oauthserver-plugin) | pier session eviction via the existing event | Same `golem15_oauthserver_consents` table. Pier already evicts on `ClientID`. | +| `/connect` checkboxes | Browser / Client (vue-oauth-layer) | JSON from `MCPConsentShow` | The MCP flow has no server-rendered page. The layer is not mounted by 32.1 into any app. | + +## Standard Stack + +No new package. Use what the repos already require. + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Go stdlib (`context`, `net/http`, `slices`) | go 1.27.0 (toolchain on this machine: go1.27.2) | Context accessor, handlers, scope-set math | Project rule: stdlib first. `[VERIFIED: go.mod:1-5]` module `git.golem15.com/golem15/summercms`, `go 1.27.0`. Machine: `go version go1.27.2-X:nodwarf5 linux/amd64`. | +| `modules/pact` | this repo, HEAD `v0.5.1-27-gbd23a82` | `FrontendTokenScope`, `MCPSubject`, `MCPTokenVerifier`, `MCPGuard` | The plugin-facing seam. Do not add a method to `MCPTokenVerifier` (see patterns). | +| `modules/wristband` | same | MCP authorize allow-list, `IssueCode`, metadata | Already copies `Options.ScopesSupported` into RFC 8414 metadata and into `allowedAuthorizeScopes`. | +| `modules/pier` | same | Dispatch, PRM, session eviction | `github.com/modelcontextprotocol/go-sdk` v1.8.0 is already imported here only. Do not widen that import. `[VERIFIED: .planning/research/STACK.md]` row for `github.com/modelcontextprotocol/go-sdk` v1.8.0; `go.mod` require matches. | +| `modules/bouncer` | same | `WithDispatch` / `Credential` | Personal-token id stays on the credential. The new accessor is beside it, not instead of it. | +| `modules/phrasebook` | same | `(*Translator).Get` / `Has` | Consent labels. User-plugin boot already rejects a missing phrase key on a catalog entry. | +| `github.com/go-gormigrate/gormigrate/v2` | v2.1.7 | Consent backfill migration | Already how oauthserver ships schema. `[VERIFIED: sm-oauthserver-plugin/go.mod:9]` | +| sm-oauthserver-plugin | checkout `v0.1.3` at `/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin` | Two wristband servers, consent JSON, consents, revoke | `require`s summercms `v0.5.0` and sm-user-plugin `v0.1.11` `[VERIFIED: sm-oauthserver-plugin/go.mod:6-7]`. Bump those requires only after the framework and user-plugin patches exist. Do not tag in the plan. | +| sm-user-plugin | **tag v0.1.14** (`a1b78b80`), not the v0.1.13 submodule worktrees | Catalog, `Implies`, guard expansion | v0.1.14 is the commit that sets the personal-token credential id. A branch from v0.1.13 would drop it. There is no standalone clone in this workspace; app submodules are detached at v0.1.13 (`07196a4`). `[VERIFIED: git ls-remote tags v0.1.14 → a1b78b80; git log -1 v0.1.14]` | +| Nuxt layer skeleton | copy `vue-menu-layer` (`nuxt` 4.4.8, `vue` 3.5.35, `typescript` 5.9.3, `node >= 22.18`) | `vue-oauth-layer` | Same pins the sibling layers already publish. No new runtime dependency (no SEO module, no sanitize-html). `[VERIFIED: vue-menu-layer/package.json:1-24]` | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `modules/compass` `Config.Lookup` / `Has` | in repo | Read `golem15.oauthserver.mcp_scopes` | There is no `Config.Strings` slice helper. Copy the `[]any` / `[]string` / comma-string switch in `modules/pier/config.go` `originsKey`. | +| `phrasebook.Translator.Get` | in repo | Resolve `plugin::lang...` labels on the consent JSON | Literal labels pass through. Missing key returns the key (`translator.go`). | +| testcontainers Postgres | v0.44.0 | oauthserver / user-plugin DB tests | Only off `-short`. Docker is available on this machine. | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Optional second interface `pact.MCPAdvertisedScopes` | Adding `MCPScopesSupported()` onto `MCPTokenVerifier` | Adding a method breaks every implementer and test fake. The codebase already uses a second interface for an optional guard method (`methodAllower` in `modules/pier/filter.go`). D-04's "extended additively" means this, not a new method on the existing interface. | +| Additive `scope_details` on the consent JSON | Replacing `scopes: []string` with objects | `MCPConsentShow` today returns a string array, and `TestMCPConsentSmoke` asserts `[]string{"read","write","backend"}`. Existing app `/connect` pages (out of scope) read that array. Keep it. | +| Replace-scopes upsert for MCP | `oidcTx.SaveConsent` | `SaveConsent` **merges** scopes on conflict. D-08 says the stored set **becomes** the newly granted set. Calling `SaveConsent` would make an untick a no-op. | +| Checkboxes on `views/consent.html.tmpl` | vue-oauth-layer `app/pages/connect.vue` | The template is the OIDC page. MCP never renders it. | + +**Installation:** none in the framework module. The layer repo is created beside the others and consumed later with the existing pin shape: + +```bash +pnpm add git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.6.0 +``` + +That line is the sibling pattern `[VERIFIED: vue-journal-layer/README.md:6 and sm-summercmsio-app/vue-summercmsio-app/package.json:25]`, not a dependency this phase adds to an app. + +**Version verification:** no `npm view` / new module. Framework tag line is `v0.5.1` plus 27 commits. Do not hardcode the next patch number (D-16). + +## Package Legitimacy Audit + +This phase installs no new Go module and no new npm package. The Package Legitimacy Gate was not run. `vue-oauth-layer` vendors nothing beyond the Nuxt, Vue and TypeScript versions already pinned by `vue-menu-layer`. If a plan adds any other module, stop and run `msd_run query package-legitimacy check` before it is recommended. + +**Packages removed due to [SLOP] verdict:** none +**Packages flagged as suspicious [SUS]:** none + +## Architecture Patterns + +### System Architecture Diagram + +``` +plugin FrontendTokenScopes() + │ + ▼ +sm-user-plugin Boot + CompileAPITokenScopes (validates names, Implies, phrase keys) + app.Publish(MemberTokenScopeCatalog) app.Publish(MCPPersonalTokenResolver) + │ │ + ▼ │ +sm-oauthserver-plugin Boot (Requires golem15.user, so it runs after) + mcp_scopes filter → mcpOpts.ScopesSupported + wristband MCP Server.Metadata + allowedAuthorizeScopes + app.Publish(MCPTokenVerifier + MCPAdvertisedScopes) + │ + ▼ (later, surf.BuildRouter, after every plugin Boot) +pier.Mount → mountPRM + verifier.MCPScopesSupported() or else PRMScopes + │ +Browser ──GET /oauth/mcp/authorize──► wristband + │ 302 {issuer}/connect?request= + ▼ +vue-oauth-layer /connect (only after an app extends the layer) + │ GET/POST /oauth/mcp/requests/{request} jwt.auth + ▼ +MCPConsentShow (scope_details) / MCPConsentDecide + grantedScopes = submitted ∩ requested + IssueCode → golem15_oauthserver_mcp_codes + upsert golem15_oauthserver_consents (replace scopes, do not merge) + │ + ▼ +POST /oauth/mcp/token → access + refresh rows (shared tables, kind=mcp client) + │ +Agent bearer ──► pier identityResolver.resolveOAuth + MCPSubject{Guard, UserID, Scopes: grant scopes, ClientID, TokenID} + │ + ▼ +tools/list: allowsTool → ceiling (writes) → guard.MCPAllows + user guard expands Implies onto a copy of scopes + │ +tools/call: guard.MCPDispatch builds credential (expanded scopes, ID only if ClientID=="") + pact.WithMCPCall + bouncer.WithDispatch + route middleware user.scope reads credential.HasScope + handler may call pact.MCPCallFrom + +DELETE /oauth/authorizations/{client_id} + lockUserGrants → delete consent + OIDC codes + MCP codes + access + refresh + Fire MCPSubjectRevoked{Guard:"user", ClientID} + pier revokedMatch ends that client's sessions (stateful and stateless) +``` + +Boot order is safe without a new `Requires` edge. `party` runs every `Boot` before `surf.BuildRouter`, and `pier.Mount` is the last step of `BuildRouter` `[VERIFIED: modules/party/registry.go:141-147, modules/surf/router.go:697-705]`. User-plugin boot publishes the catalog; oauthserver boot (`Requires() []string{"golem15.user"}` `[VERIFIED: sm-oauthserver-plugin/plugin.go:60]`) reads it; pier reads the verifier after both boots. + +### Recommended Project Structure + +Framework (this repo): + +``` +modules/pact/mcp.go # MCPCall, WithMCPCall, MCPCallFrom, MCPAdvertisedScopes +modules/pact/capabilities.go # FrontendTokenScope.Implies +modules/pier/dispatch.go # setter next to WithDispatch +modules/pier/prm.go # scopes from the verifier, else PRMScopes +modules/wristband/consent.go # PendingRequestView gains OfflineAccess (additive) +docs/services/mcp.md +docs/services/oauth-server.md +docs/services/authentication.md +modules/{pact,pier,wristband}/README.md +``` + +sm-user-plugin (branch from **v0.1.14**): + +``` +classes/api_token_scopes.go # Implies validation, publish pact catalog +classes/mcp_guard.go # effectiveScopes in MCPAllows and MCPDispatch +plugin.go # bootAPITokenScopes already publishes the concrete catalog +README.md +``` + +sm-oauthserver-plugin (the v0.1.3 clone above): + +``` +config/config.yaml # mcp_scopes +servers.go # NewServers builds the filtered list +classes/mcp_verifier.go # MCPScopesSupported +controllers/mcp_consent_api.go # scope_details, offline_access, consent upsert +controllers/authorizations.go # kind, revoke deletes MCP codes under lockUserGrants +updates/ # new backfill migration, next to the existing Register pattern +lang/en/lang.yaml # backend + offline_access consent copy (and pl) +lang/pl/lang.yaml +README.md +``` + +vue-oauth-layer (new repo, not this module): + +``` +nuxt.config.ts # absolute paths from fileURLToPath, like vue-menu-layer +package.json # main: ./nuxt.config.ts, engines node >= 22.18 +app/pages/connect.vue # Nuxt merges layer pages; the app's own page wins +app/components/OAuth/... +tests/*.test.ts # node --test, no browser required for the JSON mapping +.playground/ +README.md +``` + +### Pattern 1: Advertised scopes are one slice, three readers + +**What:** `wristband.Server.allowedAuthorizeScopes` returns exactly `Options.ScopesSupported` when that slice is non-empty, and the PHP default map only when it is empty. Metadata writes the same slice. + +```go +// modules/wristband/server.go:96-99 +ScopesSupported: []string{"read", "write", "ai", "offline_access"}, + +// modules/wristband/authorize.go:21-40 +var authorizeAllowedScopes = map[string]bool{ + "read": true, "write": true, "ai": true, "offline_access": true, +} +func (s *Server) allowedAuthorizeScopes() map[string]bool { + if s == nil || len(s.opts.ScopesSupported) == 0 { + return authorizeAllowedScopes + } + // ... one entry per Options.ScopesSupported +} + +// modules/pier/prm.go:12-14 +var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"} +``` + +`[VERIFIED: modules/wristband/server.go:96-99, modules/wristband/authorize.go:21-40, modules/pier/prm.go:12-14]` + +**When to use:** sm-oauthserver `NewServers` already does `mcpOpts.ScopesSupported = append(slices.Clone(opts.ScopesSupported), "backend")` `[VERIFIED: sm-oauthserver-plugin/servers.go:154-158]`. Replace that construction with: fixed five, then declared names in catalog order, filtered by `mcp_scopes` when the key is set. Assign that slice to `mcpOpts.ScopesSupported` and return the same slice from `MCPScopesSupported`. Do not also append `"backend"` a second time. + +OIDC must stay: + +```go +// modules/wristband/oidc_options.go:113 +ScopesSupported: []string{"openid", "profile", "email"}, +``` + +`[VERIFIED: modules/wristband/oidc_options.go:60-63 and :113]` + +**Unset vs empty.** `Config.String` returns `""` for both a missing key and an empty value `[VERIFIED: modules/compass/config.go:121-127]`. D-03 treats unset as "all declared" and a set list as "only those names". Use `Config.Has("golem15.oauthserver.mcp_scopes")` / `Lookup`. An empty list is set: advertise only the fixed five. A comma-separated env string should be accepted the same way `originsKey` accepts a string, because `SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES` is the env spelling the oauthserver config header documents for nested keys (`config/config.yaml` lines 1-3). + +**Opt-in name nobody declares (discretion):** fail boot. A warning still starts a server whose metadata and allow-list disagree with the operator's file, and scope names in metadata are costly to retract (D-03). Match `CompileAPITokenScopes`, which already fails boot on a bad declaration. The error names the key and the unknown name. + +**Order to freeze in tests:** `read`, `write`, `ai`, `offline_access`, `backend`, then declared names in catalog order (activation order, which is what `CompileAPITokenScopes` already uses). Fixed names are not filtered by the opt-in list. + +**Do not let pier read the raw catalog for the PRM.** D-05 says pier reads the catalog at boot; D-04 says the PRM comes from the verifier so the opt-in filter cannot drift. Honor D-04 for the PRM. The catalog's labels are for the consent JSON, which oauthserver builds. Pier does not need labels. + +### Pattern 2: Catalog interface (discretion, recommended) + +Publish a concrete value looked up as an interface, the same way the user plugin publishes the guard: + +```go +return app.Publish[pact.MCPPersonalTokenResolver](guard) +``` + +`[VERIFIED: sm-demo-app/plugins/golem15/user/plugin.go:195]` (`app.Publish` is generic on `T` `[VERIFIED: modules/backpack/app.go:59-73]`). + +Recommended pact surface, additive, no new method on an existing interface: + +```go +// MemberTokenScopeCatalog is the compiled member-token scopes of one +// application (built-ins and declared), published by the plugin that owns +// member tokens. Label and Description are phrase keys or literal text. +// Implies is "", "read" or "write". +type MemberTokenScopeCatalog interface { + MemberTokenScopes() []FrontendTokenScope +} +``` + +Do not name the method `FrontendTokenScopes`. `HasFrontendTokenScopes` already has that method `[VERIFIED: modules/pact/capabilities.go:827-834]`, and a plugin would then be usable as the catalog by accident. + +`FrontendTokenScope` gains one field: + +```go +type FrontendTokenScope struct { + Name string + Label string + Description string + Implies string // "", "read" or "write" +} +``` + +The comment on the struct currently says it carries no permission patterns `[VERIFIED: modules/pact/capabilities.go:814-825]`. Update that comment in the same change. Zero value of `Implies` keeps every existing composite literal valid. + +`CompileAPITokenScopes` rejects a declared name that is `read`, `write` or `ai` `[VERIFIED: sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go:108-112]`. Also reject `Implies` other than `""`, `"read"`, `"write"`, and reject a declared name of `backend` or `offline_access` (both match `ValidTokenScopeName`, so they would otherwise collide with the fixed MCP scopes). Built-in entries keep `Implies` empty. A grep of `FrontendTokenScopes` under `/media/nvme/dev/golem15/summercms.io/summercms` found only test fakes inside the user plugin, not a production declarer in this tree. Sibling plugins outside this tree can still declare `backend`; the boot error is the guard. + +The published catalog includes built-ins plus declared scopes, because consent labels for `read` / `write` / `ai` already live on it: + +```go +var builtInAPITokenScopeKeys = map[string][2]string{ + "read": {"golem15.user::lang.api_tokens.scope_read", "golem15.user::lang.api_tokens.scope_read_description"}, + "write": {"golem15.user::lang.api_tokens.scope_write", "golem15.user::lang.api_tokens.scope_write_description"}, + "ai": {"golem15.user::lang.api_tokens.scope_ai", "golem15.user::lang.api_tokens.scope_ai_description"}, +} +``` + +`[VERIFIED: sm-demo-app/plugins/golem15/user/classes/api_token_scopes.go:52-58]` + +English copy of those keys: "Read" / "Read your account and the data the site lets you see."; "Write" / "Change your account and the data the site lets you edit."; "AI" / "Use the site's AI features on your behalf." `[VERIFIED: sm-demo-app/plugins/golem15/user/lang/en/lang.yaml:146-152]` + +`backend` and `offline_access` are not in that catalog. OIDC consent copy only has `consent.scope.profile` and `consent.scope.email` `[VERIFIED: sm-oauthserver-plugin/lang/en/lang.yaml:21-24]`. Add oauthserver phrase keys for the MCP-only fixed scopes (en and pl) and resolve them in `MCPConsentShow`. Do not invent the sentences in the framework. + +Advertised declared names are catalog entries whose name is not `read`, `write` or `ai`. The fixed five are prepended by oauthserver, not taken from the catalog (`offline_access` and `backend` are not member-token scopes). + +### Pattern 3: Implied scopes, both checks, OAuth only + +`MCPAllows` requires every `user.scope:` name to be in `subject.Scopes` `[VERIFIED: sm-demo-app/plugins/golem15/user/classes/mcp_guard.go:87-95]`. The route middleware does not trust that. It reads the credential: + +```go +func Scope(scope string) pact.Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + credential, _ := bouncer.Credential(r.Context()) + token, ok := credential.(*models.APIToken) + if !ok || token == nil || !token.HasScope(scope) { + wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope}) + return + } + next.ServeHTTP(w, r) + }) + } +} +``` + +`[VERIFIED: sm-demo-app/plugins/golem15/user/middleware/api_token.go:89-101]` + +`HasScope` is `slices.Contains(t.Scopes.Get(), scope)` `[VERIFIED: sm-demo-app/plugins/golem15/user/models/api_token.go:39-40]`. + +v0.1.14 `MCPDispatch` (quote from the tag, not from the v0.1.13 worktree): + +```go +// classes/mcp_guard.go on tag v0.1.14, lines 115-143 +// MCPDispatch builds the principal ... For a +// personal-token subject (ClientID empty) the credential's ID is the +// subject's TokenID, the user_api_tokens row the session was opened with, so +// a handler can record which token made a change; an OAuth subject's TokenID +// is the OAuth server's own token row, not one of this plugin's, and its +// credential keeps ID 0. The credential is never saved. +func (g *MCPUserGuard) MCPDispatch(...) (pact.MCPDispatch, error) { + credential := &models.APIToken{ + UserID: subject.UserID, + Scopes: lagoon.Jsonable[[]string]{Data: slices.Clone(subject.Scopes), Valid: true}, + } + if subject.ClientID == "" { + credential.ID = subject.TokenID + } + return pact.MCPDispatch{Principal: principal, Credential: credential, Guards: []string{"api_token"}}, nil +} +``` + +`[VERIFIED: sm-user-plugin tag v0.1.14 classes/mcp_guard.go:115-143, read from the fetched blob]` + +The v0.1.13 worktree still says the credential has ID 0 and never sets `credential.ID` `[VERIFIED: sm-demo-app/plugins/golem15/user/classes/mcp_guard.go:115-136]`. Implement on top of v0.1.14. + +**Where to expand.** One helper used by `MCPAllows` and `MCPDispatch`: + +- If `subject.ClientID == ""`, return `subject.Scopes` unchanged (personal token, D-01a). +- If `subject.ClientID != ""`, start from a clone of `subject.Scopes`. For each catalog entry with `Implies` of `"read"` or `"write"`, if the grant contains that stock scope and the declared name is not already present, append the declared name. +- Never append a name the catalog does not mark. Never treat `write` as implying a scope marked `Implies: "read"`. +- Do not write the expanded set back onto the token row, the identity fingerprint, or `subject.Scopes` as stored on the session. Expansion is a check-time view. Pier's ceiling keeps using the grant (`Identity.writes` is `!ceiling || slices.Contains(id.Scopes, "write")` `[VERIFIED: modules/pier/identity.go:78-82]`). A non-read-only tool still needs `write` on the grant before `MCPAllows` runs `[VERIFIED: modules/pier/filter.go:61-69]`. + +`MCPDispatch` puts the helper's slice on the credential. It still sets `credential.ID` only when `ClientID == ""`. It does not set `APIToken.OAuthClientID`. That column is a `user_api_tokens` field `[VERIFIED: models/api_token.go:23]`; the OAuth client id belongs on `pact.MCPCall`, not on a row that must never be saved. + +The backend guard's `MCPDispatch` returns a principal and no credential `[VERIFIED: modules/pier/backend_guard.go:88-97]`. Do not expand scopes there. `const BackendGuard = "backend"` `[VERIFIED: modules/pier/backend_guard.go:21]`. User guard name is `SubjectGuardUser = "user"` `[VERIFIED: sm-demo-app/plugins/golem15/user/classes/subject_events.go:15-17]`. + +### Pattern 4: `pact.MCPCall` next to `WithDispatch` + +Recommended names (discretion, match the decision's shape and `bouncer.WithDispatch`): + +```go +type MCPCall struct { + Subject MCPSubject + SessionID string + Tool string +} + +func WithMCPCall(ctx context.Context, call MCPCall) context.Context +func MCPCallFrom(ctx context.Context) (MCPCall, bool) +``` + +Unexported context key in package pact, same idea as `bouncer.dispatchKey` `[VERIFIED: modules/bouncer/dispatch.go:5]`. Nil context becomes `context.Background()` only if you copy `WithDispatch`; prefer requiring the caller's context (the dispatch context is never nil). `MCPCallFrom` on a context without the value returns `ok == false`, including every ordinary HTTP request (D-11). + +Set it in `dispatcher.run` immediately before the existing line: + +```go +callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential}) +``` + +`[VERIFIED: modules/pier/dispatch.go:169-208]` + +- `Subject` is `*subject` from `id.subjectFor(t.guardName)` (already computed at line 169). That is the frontend subject or the backend subject for this tool, not both. +- `Tool` is `t.name`. +- `SessionID` is `info.session` when the call is on the stateful session path. `toolHandler` sets `info.session = req.Session.ID()` when the SDK session id is non-empty `[VERIFIED: modules/pier/session.go:703-710]`. The stateful no-session path (`serveEphemeral`) stores the audit word `"ephemeral"` `[VERIFIED: modules/pier/session.go:40-42 and :217-226]`. D-10 says stateless mode stores an empty `SessionID`. `serveEphemeral` is not the stateless protocol path (`statelessPath` in `stateless.go`). Pass `info.session` through, but force `""` when the request was served by `statelessPath`. The audit string `"ephemeral"` must not be copied into `MCPCall.SessionID` either; that word is an audit label, not a session id. Confirm during implementation whether the SDK's stateless handler reports a session id; if it does, the force-empty flag still wins. + +`MCPSubject` stays: + +```go +type MCPSubject struct { + Guard string + UserID uint + Scopes []string + ClientID string + TokenID uint +} +``` + +`[VERIFIED: modules/pact/mcp.go:134-140]` + +OAuth fill (`resolveOAuth`): `ClientID: grant.ClientID`, `TokenID: grant.TokenID`, `Scopes` copied from the grant, not expanded `[VERIFIED: modules/pier/identity.go:222-229]`. `MCPVerifyToken` sets `TokenID: token.ID` from `golem15_oauthserver_access_tokens` `[VERIFIED: sm-oauthserver-plugin/classes/mcp_verifier.go:109-118]`. Personal-token fill sets `TokenID: token.ID` from `user_api_tokens` and leaves `ClientID` empty `[VERIFIED: classes/mcp_guard.go:179 on the v0.1.13 worktree, unchanged on v0.1.14]`. + +Handlers must not treat `MCPCall.Subject.TokenID` as a `user_api_tokens` id when `ClientID != ""`. That is the bug v0.1.14 avoided on the credential. The accessor exposes both ids so the handler can tell them apart. Document this in `modules/pact/README.md` and `docs/services/mcp.md`. + +`Identity.Scopes` on an OAuth call is the grant, shared by both subjects (`consentedBackendSubject` copies the same slice `[VERIFIED: modules/pier/identity.go:240-254]`). `MCPCall.Subject.Scopes` is that grant, not the implied expansion. A handler that needs "what `user.scope` allowed" reads the credential; a handler that needs "what the user granted" reads `MCPCall.Subject.Scopes`. + +### Pattern 5: MCP consent is JSON + `{issuer}/connect`, not the OIDC template + +`Server.Authorize` (MCP flavour only; OIDC returns earlier) always ends: + +```go +spa := s.opts.Issuer + "/connect?request=" + rfc3986Escape(requestID) +w.Header().Set("Cache-Control", "no-store, private") +writeRedirectHTML(w, http.StatusFound, spa) +``` + +`[VERIFIED: modules/wristband/authorize.go:192-198]` + +There is no consent HTML in wristband (`consent.go` package comment: "consent has no HTTP handler here" `[VERIFIED: modules/wristband/consent.go:1-9]`). `views/consent.html.tmpl` is "the OpenID Connect consent page" rendered by `classes.Host.RenderConsent`, posted back to the OIDC authorize endpoint, with a scope list of `
  • ` labels and Allow/Deny buttons, no checkboxes `[VERIFIED: sm-oauthserver-plugin/views/consent.html.tmpl:1-6 and :272-291]`. + +**D-07 finding:** the MCP flow has no built-in page. Do not add checkboxes to `consent.html.tmpl`. That would change the OIDC page (19.4), which D-02 leaves alone. The plugin's MCP rendering is `MCPConsentShow` / `MCPConsentDecide`, mounted whenever `mcp.enabled` is true, independent of `consent_mode` `[VERIFIED: sm-oauthserver-plugin/routes.go:70-82]`. Checkboxes go on `vue-oauth-layer`'s `/connect` page. Apps that already have `/connect` keep it until they delete their page: Nuxt gives project files higher priority than an extended layer `[CITED: https://nuxt.com/docs/4.x/guide/going-further/layers]`, and a layer's `app/pages/*` is auto-scanned by that same document. + +Routes (quote the paths, do not rename them): + +- `GET /oauth/mcp/requests/{request}` → `MCPConsentShow` +- `POST /oauth/mcp/requests/{request}` → `MCPConsentDecide` +- both behind `jwt.auth` and `throttle:` + the authorize bucket `[VERIFIED: sm-oauthserver-plugin/routes.go:78-82]` +- `GET /oauth/authorizations`, `DELETE /oauth/authorizations/{client_id}` `[VERIFIED: routes.go:66-68]` + +Show body today: + +```go +type mcpConsentShowBody struct { + RequestID string `json:"request_id"` + Client mcpConsentClient `json:"client"` + Scopes []string `json:"scopes"` + ExpiresAt string `json:"expires_at"` + BackendAdmin *mcpConsentAdmin `json:"backend_admin"` +} +type mcpConsentClient struct { + Name string `json:"name"` + RedirectHost string `json:"redirect_host"` +} +``` + +`[VERIFIED: sm-oauthserver-plugin/controllers/mcp_consent_api.go:24-43]` + +`grantedScopes` is submitted ∩ requested, or all requested when `scopes` is absent `[VERIFIED: mcp_consent_api.go:168-180]`. Empty grant → `wristband.ErrNoGrantableScopes` → 422 `{"error":"no_grantable_scopes"}` `[VERIFIED: mcp_consent_api.go:196-208]`. `backend_link_changed` is 409 `[VERIFIED: mcp_consent_api.go:150-156]`. + +**Keep `scopes` a string array.** Add `scope_details`: + +```json +"scope_details": [ + {"name": "read", "label": "Read", "description": "...", "locked": true} +] +``` + +`locked` is true only for `read` when `read` was requested (D-06). Every other name, including `write`, `backend`, `offline_access` and declared scopes, has `locked: false`. Labels: catalog phrase via `phrasebook.Translator.Get(ctx, key, nil)` when the text contains `::`; otherwise the literal. `Get` returns the key itself when the phrase is missing `[VERIFIED: modules/phrasebook/translator.go:157-163]`. Boot already fails if a catalog phrase is missing, so a declared scope's key exists. New backend/offline phrases must be added before the JSON looks them up, or the page shows the key. + +Logo: `classes.ClientLogoURL(ctx, app, clientID)` already used by the authorizations list `[VERIFIED: controllers/authorizations.go:81]`. Add `logo_url` on `client` (empty string or null when none). Extra JSON fields do not break the smoke test's struct decode of `Scopes []string`. Replacing `scopes` with objects would. + +**Do not force `read` back into `grantedScopes` on the server.** The lock is the page always submitting `read` because the checkbox cannot be cleared. A body that omits `read` still drops it. `TestMCPConsentSmoke` posts `scopes: ["backend"]` for a request of `read backend` with no link and expects 422 `no_grantable_scopes` `[VERIFIED: sm-oauthserver-plugin/mcp_consent_smoke_test.go:227-232]`. Forcing `read` would turn that into a grant of `read`. Omitting `read` only narrows the grant; it is not a privilege escalation. Document the lock as a page rule. + +**`offline_access` is not in the pending scope list.** Authorize peels it into `AuthCodeRecord.OfflineAccess` and does not store it in `Scopes` `[VERIFIED: modules/wristband/authorize.go:154-182]`. `PendingRequestView` does not expose the flag `[VERIFIED: modules/wristband/consent.go:35-43]`. `MarkIssued` does not update the `offline_access` column `[VERIFIED: sm-oauthserver-plugin/classes/mcp_store.go:250-273]`. The token response appends the word from the boolean, not from the stored scope array `[VERIFIED: modules/wristband/token.go:141-142]`. + +So D-06's untick does nothing today. Minimal additive fix: + +1. Add `OfflineAccess bool` to `PendingRequestView` (wristband). Callers that ignore it stay correct. +2. When the flag is set, include `offline_access` in the show `scopes` array and in `scope_details` (unlocked). +3. `grantedScopes` must intersect against that augmented list, not only `view.ScopesRequested` (which still lacks the word). +4. Pass the data scopes (no `offline_access`) to `IssueCode`, and set the column from the decision. Do not change the `IssueCode` signature (`func (s *Server) IssueCode(ctx, requestID, userID, grantedScopes, collectionIDs)` `[VERIFIED: modules/wristband/consent.go:74]`). Follow `WithConsentedBackend`: a context value the MCP store's `MarkIssued` reads, defaulting to the pending row's current flag when absent so a test that calls `IssueCode` directly keeps today's behavior. When the POST's submitted scopes omit `offline_access`, set the flag false. When `scopes` is absent (all-or-nothing, current apps), leave the flag as the request asked. + +`offline_access` is not data-bearing. The authorize comment says a request with nothing left but `offline_access` is `invalid_scope` "outside this client's ceiling" `[VERIFIED: modules/wristband/authorize.go:120-141]`. A consent submit whose only remaining scope is `offline_access` must still 422 `no_grantable_scopes`. `read` counts as data-bearing. `backend` counts only when a link is recorded; the handler already strips it otherwise. + +### Pattern 6: Re-consent compares scopes; a decision replaces them + +OIDC silent path (do not copy onto the MCP authorize endpoint; that endpoint does not know the user): + +```go +consent, err := tx.ConsentFor(ctx, userID, req.ClientID) +if consent != nil && len(missingScopes(req.Scopes, consent.Scopes)) == 0 { + // consume request, TouchConsent, mint code for req.Scopes +} +``` + +`[VERIFIED: modules/wristband/oidc_authorize.go:235-257]` + +`missingScopes` is requested minus granted `[VERIFIED: oidc_authorize.go:490-498]`. + +`SaveConsent` inserts, or on conflict **unions** and stamps `last_used_at` `[VERIFIED: sm-oauthserver-plugin/classes/oidc_store.go:328-361]`. MCP must not call it. + +MCP authorize cannot skip the redirect: the user is identified only later, on the jwt.auth JSON routes. Implement D-08 in `MCPConsentShow` / `MCPConsentDecide`: + +- Compare the requested set (data scopes plus `offline_access` when the flag is set) to the consent row for `(userID, clientID)`. +- If a row exists and nothing is missing, `MCPConsentShow` adds `consent_required: false` (additive). The layer then POSTs `decision: allow` without rendering checkboxes (or POSTs immediately). That POST issues the code for the **requested** subset and `TouchConsent`s (`last_used_at` only). It does not shrink the stored set. D-08's "become the newly granted set" applies after a decision on a page the user actually saw. +- If any requested scope is missing, `consent_required: true` and the page is shown. After allow, upsert with the granted set **replacing** `scopes`, not unioned. A deny does not change the row. +- First grant (no row): insert on allow. + +The only production caller of `IssueCode` is `MCPConsentDecide` `[VERIFIED: grep of IssueCode( in sm-oauthserver-plugin; the other two hits are tests]`. Put the upsert there, after `IssueCode` returns nil. There is no second "direct grant" HTTP path. Refresh rotation copies the existing token's scopes; it must not rewrite the consent. Code exchange does not either. + +Unique key, already in the schema: + +```sql +CONSTRAINT oas_consents_user_client_unique UNIQUE (user_id, client_id) +``` + +`[VERIFIED: sm-oauthserver-plugin/updates/202610190001_create_golem15_oauthserver_tables.go:104-113]` + +Client kinds cannot share a `client_id` (`oas_clients_client_id_unique`) and MCP rows are `kind = 'mcp'` `[VERIFIED: same file lines 39 and 50; models/client.go:14-17]`: + +```go +const ( + ClientKindOIDC = "oidc" + ClientKindMCP = "mcp" +) +``` + +Store `offline_access` in the consent's scope list when the flag was granted, even though the code's `scopes` column does not contain the word. Otherwise the next request looks like scope growth. + +### Pattern 7: Revoke must delete MCP codes, under the grants lock + +`revoke` today locks the consent row and deletes it plus `AuthCode`, `AccessToken` and `RefreshToken` for `(user_id, client_id)` `[VERIFIED: controllers/authorizations.go:132-157]`. It does **not** delete `models.MCPCode` (`golem15_oauthserver_mcp_codes`). It does **not** call `lockUserGrants`. After the commit it fires: + +```go +userclasses.FireSubjectEvent(r.Context(), app, pact.MCPSubjectChanged{ + Guard: userclasses.SubjectGuardUser, UserID: userID, Kind: pact.MCPSubjectRevoked, ClientID: clientID, +}) +``` + +`[VERIFIED: controllers/authorizations.go:124-126]` + +D-12's "works unchanged" is wrong for MCP codes. An issued code that survives revoke can still be exchanged for a new access token. Cascade already deletes MCP codes on user delete, and its scoped revocation deletes unused MCP codes so they cannot mint a successor `[VERIFIED: classes/cascade.go:29-31 and :147-149]`. Per-client revoke has to do the same. + +`lockUserGrants` is an exclusive `pg_advisory_xact_lock(hashtext('golem15_oauthserver_user_grants:'+userID))` `[VERIFIED: classes/mcp_backend_binding.go:43-75]`. Code exchange and refresh take the shared lock first. `revoke` must take the exclusive lock **before** deleting tokens, or a rotation in flight mints a successor the delete does not see. That is the same race `mcp_backend_binding_test.go` pins for link removal. Take the lock inside the existing transaction, then delete: + +- the consent (the 404 when none exists stays: `errNotFound` → `{"error":"not_found"}`) +- `models.AuthCode`, `models.AccessToken`, `models.RefreshToken` (already) +- `models.MCPCode` for the same `(user_id, client_id)`, including issued-but-unused codes + +Access and refresh rows are shared by OIDC and MCP. Deleting by `(user_id, client_id)` removes the MCP token whose `backend_user_id` column is on that same row `[VERIFIED: models/access_token.go:20-22, models/refresh_token.go:21-23]`. There is no second backend token. One delete covers D-15's "frontend and backend access". Do not add a separate backend revoke. + +Pier already ends the sessions. `revokedMatch` for `MCPSubjectRevoked` with `ClientID` set keeps identities whose subject under `ev.Guard` has that `ClientID` `[VERIFIED: modules/pier/events.go:74-90]`. The event's guard is `"user"`. `subjectFor("user")` returns the frontend subject, which carries `ClientID`. Closing that session drops the backend subject on the same identity. `handleEvent` also `evictFunc`s stateless entries `[VERIFIED: modules/pier/events.go:45-54]`. Do not add a second event. Do not set `TokenID` on this event: a non-zero `TokenID` matches only personal-token sessions (`ClientID == ""`) `[VERIFIED: events.go:87-89]`. + +DCR clients are stored with a nil scope ceiling unless registration sent one `[VERIFIED: classes/mcp_store.go:117-118]`. A nil ceiling is not a filter `[VERIFIED: modules/wristband/authorize.go:125-126]`. Declared scopes do not need a per-client ceiling update for newly registered clients. Existing clients that already have a non-nil `scopes` column will silently drop any requested name not in that ceiling (over-broad requests narrow; they do not 400, unless nothing data-bearing remains). Do not rewrite old ceilings in this phase. Document it. + +### Pattern 8: Authorizations `kind` (discretion, recommended) + +Always set `kind` from `clients.kind`, whose only legal values are `oidc` and `mcp`. Spell them `models.ClientKindOIDC` and `models.ClientKindMCP` (`"oidc"`, `"mcp"`). Do not omit the field for OIDC: one spelling, and the column is never empty. Existing keys stay, in the same order, with `kind` last: + +`client_id`, `name`, `logo_url`, `scopes`, `first_authorized_at`, `last_used_at`, `kind` + +The smoke test fails if the key count changes. It requires exactly those six keys `[VERIFIED: sm-oauthserver-plugin/authorizations_smoke_test.go:66-68]`. Update that test in the same change. PHP parity flows under `testdata/parity/flows/authorizations.yaml` record requests, not a frozen key set; re-run them rather than assuming they snapshot `kind`. + +The list query joins consents to clients but does not select `kind` `[VERIFIED: controllers/authorizations.go:57-63]`. Add `c.kind` to the select. Once MCP consent rows exist, those clients show up with no other filter. That is D-13. + +### Pattern 9: Backfill (D-14) + +New gormigrate file under `updates/`, registered with `init() { Register(...) }` like `202610230001_add_backend_user_id.go`. ID sorts after `202610230001_add_golem15_oauthserver_backend_user_id`. Raw SQL or a Go loop inside `Migrate` are both fine; a Go loop is the practical way to union JSON arrays. `models.Strings` is `lagoon.Jsonable[[]string]` stored as a JSON array in a TEXT column `[VERIFIED: models/registry.go:25-34]`. + +Rules: + +- One row per `(user_id, client_id)` where the client `kind = 'mcp'` and the pair has at least one live access or refresh token. +- Live: `revoked_at IS NULL` and (`expires_at` IS NULL or `expires_at > now()`). Access tokens minted by this server always have an expiry (`mcp_verifier.go` treats a missing expiry as invalid `[VERIFIED: classes/mcp_verifier.go:79-81]`). +- Skip the pair when a consent row already exists (any scopes). Do not merge into an OIDC row; the unique key plus the kind filter makes that a different client id anyway. +- `scopes` = union of those tokens' `scopes` arrays. If any of those tokens has `offline_access` true, include the string `offline_access` in the union (the column, not the JSON, is the source of truth). +- `created_at` = earliest `created_at` among the live tokens of the pair. `updated_at` = the same. `last_used_at` stays null. +- Idempotent: a second run inserts nothing. +- Do not invent a down migration that deletes the backfilled rows only. `Rollback` may no-op with a comment, or delete only rows this migration can prove it inserted. Deleting every MCP-client consent on rollback would also delete consents the new code created after the migration. Prefer a no-op rollback and say so in the migration comment. The writes are one-way (D-14). + +### Pattern 10: `vue-oauth-layer` + +Copy the **menu** layer, not the journal layer. Journal pulls in `@nuxtjs/seo`, `@nuxtjs/i18n`, `@nuxt/fonts` and `sanitize-html` `[VERIFIED: vue-journal-layer/package.json:17-22]`. A consent page does not need them. Menu's `package.json` is the skeleton: `"type": "module"`, `"main": "./nuxt.config.ts"`, `"engines": {"node": ">=22.18"}`, peer `nuxt` 4.4.8, dev `vue` 3.5.35 and `typescript` 5.9.3, script `"test": "node --test --test-reporter=tap tests/*.test.ts"` `[VERIFIED: vue-menu-layer/package.json:1-24]`. + +`nuxt.config.ts` must build paths from `fileURLToPath(new URL('.', import.meta.url))`. A relative path in a layer config resolves against the consuming app. The menu layer's comment says this explicitly `[VERIFIED: vue-menu-layer/nuxt.config.ts:1-18]`. + +Put the page at `app/pages/connect.vue`. Nuxt 4 layers auto-scan `app/pages/*` `[CITED: https://nuxt.com/docs/4.x/guide/going-further/layers]`. The page reads `?request=`, calls `GET /oauth/mcp/requests/{request}` with the app's session cookie / bearer (the same `jwt.auth` the route already requires), and POSTs `{decision, scopes, backend_admin}`. When `consent_required` is false, it submits allow without checkboxes. When `backend_admin` is non-null and `backend` is requested, show "also act as admin {name}" and send `backend_admin.login` back. A 409 `backend_link_changed` re-fetches the show and renders again. `read` with `locked: true` is a disabled checked checkbox and is always included in the POST. Allow and deny are the decisions the API already accepts (`decision` other than `allow` denies `[VERIFIED: mcp_consent_api.go:133-136]`). + +The layer does not log the user in and does not know a product name. 401 from the JSON route is the app's existing login handoff (leave a single redirect to the app's login path as a documented prop, default `/login`, which is oauthserver's own `login_url` default `[VERIFIED: config/config.yaml:15]`). Do not add a server route that proxies the OAuth API; the page is on the issuer origin, same as today's app `/connect` pages. + +Tests are `node --test` over pure functions: given a show payload, which boxes render, which are locked, what POST body an allow with one box cleared produces, what a 409 does. No Playwright. A playground mounts the page against a fixture JSON. + +Consumption (document in the layer README, do not edit apps): + +```ts +export default defineNuxtConfig({ + extends: ['vue-oauth-layer'], +}) +``` + +and `"vue-oauth-layer": "git+https://git.golem15.com/golem15/vue-oauth-layer.git#vX.Y.Z"`, matching `changelog/nuxt.config.ts` which extends `'vue-journal-layer'` `[VERIFIED: sm-summercmsio-app/vue-summercmsio-app/changelog/nuxt.config.ts:30]`. + +The repo does not exist yet (`find` under `/media/nvme/dev` found no `vue-oauth-layer`). Create it beside `vue-journal-layer`. Do not tag it in the plan. + +### Anti-Patterns to Avoid + +- **Expanding scopes inside pier `resolveOAuth`.** Pier would have to understand `Implies`, and the fingerprint would change every time a plugin edited a declaration. The user guard is the place D-01a names. +- **Expanding personal tokens.** `ClientID == ""` is the discriminator v0.1.14 already uses. A `read` personal token must not grow `servers:write`. +- **Calling `SaveConsent` for MCP.** It unions. Untick would not stick. +- **Adding a method to `MCPTokenVerifier`.** Breaks fakes. Optional interface. +- **Letting pier build the PRM from the raw catalog.** Skips the opt-in filter. +- **Checkboxes on the OIDC template.** Wrong flow. +- **Replacing the `scopes` JSON array.** Breaks current `/connect` pages this phase must not migrate. +- **Setting `TokenID` on the per-client revoked event.** That selects personal-token sessions only. +- **Hardcoding the next framework version** or running `git tag`. +- **Editing an app submodule** of sm-user-plugin at v0.1.13. That worktree does not contain the credential-id fix. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Scope allow-list | A second list in pier and another in the plugin | One slice on `Options.ScopesSupported`, echoed by `MCPScopesSupported` | Empty `ScopesSupported` silently falls back to the PHP four-name map and drops `backend`. | +| Consent intersection | A new policy function | `grantedScopes` | Already requested-order intersection, nil submission means "all requested". | +| Session teardown | A new pier API | `MCPSubjectRevoked` + `ClientID` | Stateful and stateless eviction already exists. | +| Consent storage | A new table | `golem15_oauthserver_consents` | Unique `(user_id, client_id)` already matches D-12. | +| Protected-resource document | A hand-written JSON handler | `auth.ProtectedResourceMetadataHandler` in `mountPRM` | RFC 9728 shape is the SDK's. Only the scope slice changes. | +| Phrase lookup | A private map of English strings | `phrasebook.Translator` | Labels are already phrase keys. en and pl ship together. | +| OAuth library | A new dependency | wristband | STACK.md already retired zitadel/oidc for this surface. | +| Nuxt UI kit | A component library | One Vue page, `node --test` | The menu layer has no runtime dependencies. Match it. | + +**Key insight:** The protocol pieces (allow-list, intersection, session eviction, consent table) exist. The gaps are that the allow-list is a constant, the subject never reaches the handler, and MCP never writes the row `revoke` requires. Filling those in is smaller than a new OAuth stack, and a new stack would break the PHP-parity OIDC server that shares the process. + +## Runtime State Inventory + +This is not a rename. It is a one-way data backfill (D-14) plus new rows on every future MCP grant. Categories: + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| Stored data | `golem15_oauthserver_access_tokens` and `golem15_oauthserver_refresh_tokens` for `kind = 'mcp'` clients may exist with no `golem15_oauthserver_consents` row. OIDC consents already live in that table. MCP codes live in `golem15_oauthserver_mcp_codes` and are not consents. | Code: write new rows on `IssueCode`. Data migration: backfill live MCP pairs, skip pairs that already have a consent. No update to token rows. | +| Live service config | `golem15.oauthserver.mcp.enabled` defaults `false` `[VERIFIED: config/config.yaml:72-75]`. Consuming apps keep their own value. Nothing in this repo turns it on. | None in this phase. Do not edit app config. | +| OS-registered state | None. No systemd unit, task scheduler entry or process name is introduced. Verified by the phase touching only Go sources, SQL migrations and a new Nuxt repo. | None | +| Secrets/env vars | No secret is renamed. New optional config `golem15.oauthserver.mcp_scopes`. Env overlay spelling is `SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES` (the plugin's existing double-underscore rule). | Document the key. Do not mint tokens. | +| Build artifacts | Framework admin `dist` is not involved. `vue-oauth-layer` is a new repo, not an embed in `modules/boardwalk/dist`. | None in this repo. The layer's first tag is a user step, not a plan step. | + +## Common Pitfalls + +### Pitfall 1: Tool lists, then the route 403s +**What goes wrong:** `MCPAllows` honors `Implies`, the tool appears, `user.scope` returns 403 "Missing required scope". +**Why it happens:** Listing and the call read different slices. Only the credential reaches `middleware.Scope`. +**How to avoid:** One helper, both call sites. A test that dispatches and asserts `HasScope` for an implied name, and a test that a personal token with only `read` does not gain it. +**Warning signs:** `MCPDispatch` still does `slices.Clone(subject.Scopes)` with no catalog lookup. + +### Pitfall 2: `offline_access` untick is a no-op +**What goes wrong:** The box clears, the refresh token is still issued, the next visit still shows the scope as granted. +**Why it happens:** The flag is not in `PendingRequest.Scopes`, and `MarkIssued` does not write the column. +**How to avoid:** Pattern 5. A test that allows without `offline_access` and asserts the code's `offline_access` column is false, plus a test that omitting `scopes` entirely (old clients) keeps the flag. +**Warning signs:** `scope_details` contains `offline_access` but `IssueCode` is called with no store-side update. + +### Pitfall 3: Revoke 200, agent still connects +**What goes wrong:** Consent and access tokens are gone, the client exchanges a leftover MCP code, or a refresh that was in flight inserts a successor. +**Why it happens:** `revoke` never deletes `golem15_oauthserver_mcp_codes` and never takes `lockUserGrants`. +**How to avoid:** Pattern 7. Test: issue a code, revoke, exchange, expect `invalid_grant`. Test: two MCP clients for one user, revoke one, the other's token and sessions remain. +**Warning signs:** The delete list is still `AuthCode`, `AccessToken`, `RefreshToken` only. + +### Pitfall 4: OIDC metadata grows a plugin scope +**What goes wrong:** `/.well-known/openid-configuration` lists `servers:read`. PHP-parity clients and the parity fixtures drift. +**Why it happens:** Someone appends declared scopes to `opts.ScopesSupported` before `NewOIDCServer` copies them, or edits `DefaultOIDCOptions`. +**How to avoid:** Build the MCP slice on a copy (`mcpOpts := opts` is already a struct copy in `NewServers`). Never assign `cfg.OIDC.ScopesSupported`. Add a test that OIDC discovery scopes stay `openid`, `profile`, `email` (plus `offline_access` only when `refresh.enabled` is on, which is existing behavior `[VERIFIED: modules/wristband/oidc_authorize.go:467-472]`). +**Warning signs:** A single `ScopesSupported` variable passed to both constructors. + +### Pitfall 5: Empty `ScopesSupported` drops `backend` +**What goes wrong:** A bug clears the slice, authorize accepts only `read`, `write`, `ai`, `offline_access`, and `backend` 302s to `invalid_scope` with "scope contains an unsupported value". +**Why it happens:** `allowedAuthorizeScopes` treats a nil or empty slice as the PHP default `[VERIFIED: authorize.go:31-33]`. `parseAuthorizeScopes` uses that map `[VERIFIED: authorize.go:227-241]`. An absent scope parameter defaults to `["read"]` `[VERIFIED: authorize.go:231-234]`. +**How to avoid:** The constructed slice always has the five fixed names, even when no plugin declares anything and even when `mcp_scopes` is an empty list. Unit-test the empty-catalog case. +**Warning signs:** `len(scopes) == 0` passed into `NewServer`. + +### Pitfall 6: Authorizations key-count test +**What goes wrong:** `TestAuthorizations` (smoke) fails with `authorization keys` because `kind` made the object longer. +**Why it happens:** The test requires `len(first) == 6` for a fixed key list `[VERIFIED: authorizations_smoke_test.go:66-68]`. +**How to avoid:** Update the key list in that test in the same commit as the struct field. Put `kind` last so existing field order in the struct stays. +**Warning signs:** The plan says "additive JSON" and does not name the smoke test file. + +### Pitfall 7: Consent merge +**What goes wrong:** The user clears `write`, the row still contains `write`, the next silent grant hands it out again. +**Why it happens:** `SaveConsent` unions under a row lock. +**How to avoid:** `ON CONFLICT DO UPDATE SET scopes = excluded.scopes` (replace) on the decision path only. Silent re-grant only touches `last_used_at`. +**Warning signs:** MCP calls `tx.SaveConsent`. + +### Pitfall 8: Implementing the user plugin on v0.1.13 +**What goes wrong:** The credential id fix ships backwards. Personal-token handlers lose `credential.ID`. +**Why it happens:** Every app submodule inspected is detached at v0.1.13. v0.1.14 is only a remote tag. +**How to avoid:** Branch the plugin repo from `v0.1.14`. The plan says so in the first user-plugin task. Do not edit `sm-demo-app/plugins/golem15/user` or any other submodule. +**Warning signs:** A diff that rewrites the "credential has ID 0" comment instead of the v0.1.14 comment. + +### Pitfall 9: `read` locked only in CSS +**What goes wrong:** A styled page still lets the browser submit an unchecked `read`, or a test starts failing because the server forced `read` back in. +**Why it happens:** D-06's lock and the 422 smoke test disagree if the server rewrites the intersection. +**How to avoid:** Disabled checkbox plus the page's POST always includes `read` when it was requested. Server intersection stays. Document both. +**Warning signs:** `grantedScopes` grows a special case for `read` without a test change note. + +### Pitfall 10: Docs checker +**What goes wrong:** `TestDocsTree` fails because a README names `MCPCallFrom` before the function exists, or names a function that was renamed. +**Why it happens:** The checker requires every identifier in a README or docs page to exist in the package. +**How to avoid:** Docs and the exported identifiers land in the same commit. Run `go test ./cmd/summer -run TestDocsTree` on that commit. Config keys are not checked automatically; review `golem15.oauthserver.mcp_scopes` by hand against `config/config.yaml` and the README. + +## Code Examples + +### Allowed authorize scopes follow `Options.ScopesSupported` + +```go +// Source: modules/wristband/authorize.go:27-41 (this repo) +// allowedAuthorizeScopes is the set parseAuthorizeScopes accepts on this +// server: exactly Options.ScopesSupported, the values the metadata document +// advertises, so a host that lists an extra scope (such as backend for the +// MCP module) accepts it at authorize; an empty list keeps the PHP default. +func (s *Server) allowedAuthorizeScopes() map[string]bool { + if s == nil || len(s.opts.ScopesSupported) == 0 { + return authorizeAllowedScopes + } + allowed := make(map[string]bool, len(s.opts.ScopesSupported)) + for _, sc := range s.opts.ScopesSupported { + if sc = strings.TrimSpace(sc); sc != "" { + allowed[sc] = true + } + } + return allowed +} +``` + +A declared name is accepted once it is in that slice. No wristband change is required for acceptance. The host (oauthserver) fills the slice. + +### PRM today, and the one-line change + +```go +// Source: modules/pier/prm.go:23-40 (this repo) +func (p *Pier) mountPRM() error { + // ... issuer from verifier.MCPResource(), unchanged ... + meta := &oauthex.ProtectedResourceMetadata{ + Resource: p.cfg.Resource, + ScopesSupported: append([]string(nil), PRMScopes...), + BearerMethodsSupported: []string{"header"}, + } +``` + +Replace the `ScopesSupported` assignment with: if the looked-up `pact.MCPTokenVerifier` also implements `pact.MCPAdvertisedScopes` and `MCPScopesSupported()` is non-empty, use that clone; otherwise `PRMScopes`. A verifier that is missing (MCP OAuth switched off) keeps today's constant. Do not import sm-oauthserver from pier. + +### Dispatch setter site + +```go +// Source: modules/pier/dispatch.go:204-208 (this repo) +credential := disp.Credential +if credential == nil && t.guardName == BackendGuard { + credential = id.credential +} +callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential}) +``` + +Insert `callCtx = pact.WithMCPCall(callCtx, pact.MCPCall{Subject: *subject, SessionID: sessionID, Tool: t.name})` on the line before `WithDispatch`. `subject` is the value from line 169. `sessionID` is derived from `info.session` as in Pattern 4. + +### Consent decision intersection (keep) + +```go +// Source: sm-oauthserver-plugin/controllers/mcp_consent_api.go:168-180 +// grantedScopes is submitted ∩ requested in the requested order, or the +// requested scopes when nothing was submitted. +func grantedScopes(requested []string, submitted *[]string) []string { + if submitted == nil { + return slices.Clone(requested) + } + granted := make([]string, 0, len(requested)) + for _, scope := range requested { + if slices.Contains(*submitted, scope) && !slices.Contains(granted, scope) { + granted = append(granted, scope) + } + } + return granted +} +``` + +Pass an augmented `requested` that includes `offline_access` when the pending flag is set, then strip that word before `IssueCode`. + +### Nuxt layer page merge + +A layer file `app/pages/connect.vue` becomes the app's `/connect` route unless the app already has that page. `[CITED: https://nuxt.com/docs/4.x/guide/going-further/layers]` ("`app/pages/*` — Extend the default pages"; "Your project files — always have the highest priority"). + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| PHP OAuth allow-list of four scopes | Go MCP server uses `Options.ScopesSupported`, defaulting to those four plus `backend` when oauthserver builds it | Phase 19.7 | Declared scopes are a host concern. Wristband does not learn plugin names. | +| Consent page is the OIDC server template | MCP consent is `{issuer}/connect` plus JSON | Phase 19.7 routes | Checkboxes are a layer and a JSON field, not `consent.html.tmpl`. | +| In-memory MCP credential always had ID 0 | Personal-token credential ID is `TokenID` when `ClientID` is empty | sm-user-plugin v0.1.14 | This phase must not regress that. OAuth attribution is the new accessor, not `credential.ID`. | +| OIDC `SaveConsent` unions | MCP decision replaces the scope set | This phase | Do not share the function. | + +**Deprecated/outdated:** +- Treating `PRMScopes` as the only metadata list. It remains the fallback, not the source, once a verifier publishes a list. +- Any plan that adds scopes to `OIDCOptions.ScopesSupported`. Out of scope (D-02). + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | The SDK stateless handler may still expose a session id, so `SessionID` must be forced empty on that path rather than trusted. Not confirmed by reading the SDK. | Pattern 4 | A stateless call could surface a non-empty id. Forcing empty matches D-10 either way. | +| A2 | No production plugin already declares a member scope named `backend` or `offline_access`. The tree searched only contains user-plugin test fakes. | Pattern 2 | Boot would start failing for that plugin until it renames the scope. The error text names the plugin. | +| A3 | A comma-separated `SUMMER_GOLEM15__OAUTHSERVER__MCP_SCOPES` is worth accepting. The env provider's exact decode of a YAML list was not executed. | Pattern 1 | Operators using only the env var might get a single string. `Lookup` plus the `originsKey` switch covers string and list. Confirm with one test. | +| A4 | `vue-oauth-layer` should not set `read` as a server-side mandatory scope. | Pattern 5 | A modified client can drop `read`. That narrows the grant. If the product decision was "the server refuses to drop `read`", the smoke test at `mcp_consent_smoke_test.go` "backend only without a link" must change with it. | + +**If this table is empty:** not empty. A1–A4 need no user confirmation before planning, except A4 if the planner wants the server to enforce the lock. The recommendation is to leave the intersection alone. + +## Open Questions (RESOLVED) + +1. **Server-side `read` lock** — RESOLVED: the UI locks `read`, and the server does not force it into the grant. + - What we know: The page can disable the checkbox. The server today drops an omitted `read`, and a smoke test depends on that. + - Resolution: Plan 04 disables the checkbox and the page POST includes `read` when the form was shown. `grantedScopes` is unchanged. The server does not reinsert `read`. A client that omits `read` only narrows the grant. The existing 422 smoke that posts `backend` alone stays. + +2. **Stateless session id** — RESOLVED: `SessionID` is `""` on the stateless path, and the audit word `ephemeral` is not published. + - What we know: D-10 wants `""`. `serveEphemeral` uses the audit word `"ephemeral"`. The stateless path is a different type. + - Resolution: Plan 01 sets `callInfo.stateless` and copies `req.Session.ID()` only when that flag is false. On the stateless path `MCPCall.SessionID` is `""`. The word `ephemeral` stays an internal audit token and is not written onto `MCPCall`. + +3. **Where the user-plugin branch is checked out** — RESOLVED: the worktree is the path plan 02 creates from tag v0.1.14, and app submodules stay untouched. + - What we know: Tag v0.1.14 exists on `git.golem15.com:golem15/sm-user-plugin.git`. Local app submodules are v0.1.13. + - Resolution: Plan 02 checks out `/media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin`, branch `phase-32.1-mcp-implied-scopes`, from tag v0.1.14. It does not edit `sm-demo-app/plugins/golem15/user` or any other app submodule. The oauthserver working tree stays the existing clone at `/media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin`. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Go | Framework and both plugins | ✓ | go1.27.2 (go.mod says 1.27.0) | — | +| Docker | `go test` without `-short` (testcontainers Postgres) | ✓ | `docker info` succeeded | `-short` skips those tests | +| Node | vue-oauth-layer tests | ✓ | v24.22.0 | — | +| npm | not required if the layer uses pnpm | ✓ | 12.2.1 | pnpm | +| pnpm | layer install, matching sibling layers | ✓ | 12.14.0 | — | +| sm-oauthserver-plugin checkout | Plugin plan | ✓ | v0.1.3 at `../sm-oauthserver-plugin` | — | +| sm-user-plugin v0.1.14 worktree | Plugin plan | ✗ as a branch checkout | Tag fetched into the demo-app submodule; worktree still v0.1.13 | Executor branches from the tag. Do not edit the submodule. | +| vue-oauth-layer repo | Layer plan | ✗ | — | Create the repo. Do not tag or push. | +| Postgres on the host | not required | — | tests use testcontainers | — | + +**Missing dependencies with no fallback:** +- None for compilation. The user-plugin worktree and the layer repo are created by the executor, not installed as tools. + +**Missing dependencies with fallback:** +- A full `go test` of the plugins needs Docker. It is available. `-short` remains the per-commit loop. + +## Validation Architecture + +`workflow.nyquist_validation` is true `[VERIFIED: .planning/config.json:20]`. + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Go `testing` (stdlib). Plugins use testcontainers-go v0.44.0 off `-short`. Layer uses `node --test`. | +| Config file | none — Go has no repo-level test config. Layer: `package.json` script `test`, copied from vue-menu-layer. | +| Quick run command | `go test -short ./modules/pact ./modules/pier ./modules/wristband ./modules/bouncer` | +| Full suite command | `go test -short ./...` in this repo; `go test ./...` in each plugin repo (Docker); `pnpm test` in vue-oauth-layer | + +Plugin DB tests call `freshDB`, which skips under `-short` with `t.Skip("requires testcontainers postgres")` `[VERIFIED: sm-oauthserver-plugin/testmain_test.go:81-87]`. Wristband MCP scope tests use an in-memory backend and run under `-short` (`modules/wristband/mcp_scopes_test.go`, `mcp_scopes_smoke_test.go`). + +Pier does not carry a `//go:build summercms_mcp` tag on its tests (the tag is on `modules/surf/pier_on.go` only). `go test -short ./modules/pier` does not need the tag. + +### Phase Requirements → Test Map + +No REQ-IDs. Map the success criteria. + +| ID | Behavior | Test Type | Automated Command | File Exists? | +|----|----------|-----------|-------------------|-------------| +| SC-1 | MCP metadata and authorize accept a declared name and still reject an unknown name; OIDC discovery scopes unchanged; empty catalog still advertises the fixed five including `backend` | unit | `go test -short ./modules/wristband -run 'TestMCPScopes|TestMetadata' -count=1` and the oauthserver discovery test off `-short` | ❌ Wave 0 — extend `modules/wristband/mcp_scopes_test.go`; plugin test new | +| SC-1 | `Implies: "read"` lets an OAuth subject with `read` pass `MCPAllows` and `HasScope("servers:read")`; a personal token does not gain it; `write` does not satisfy `Implies: "read"` | unit | `go test -short . -run 'TestMCP' -count=1` in sm-user-plugin | ❌ Wave 0 — extend `mcp_guard_test.go` on v0.1.14 | +| SC-1 | pier PRM `scopes_supported` equals the verifier's list, and equals `PRMScopes` when no verifier publishes one | unit | `go test -short ./modules/pier -run 'TestPRM|TestProtected' -count=1` | ❌ Wave 0 — new test next to `prm.go` | +| SC-2 | `MCPCallFrom` on a dispatched call returns guard, user, client, token, tool; empty on a plain `httptest` request; personal-token credential ID still equals `TokenID`; OAuth credential ID stays 0 | unit | `go test -short ./modules/pier -run TestDispatch -count=1` and user-plugin `TestMCP` | ❌ Wave 0 | +| SC-3 | Allow upserts one consent row; a second decision replaces scopes; revoke deletes consent, MCP code, access, refresh, fires `MCPSubjectRevoked` with that `ClientID` only; the other client survives; backfill is idempotent and skips existing rows | integration | `cd ../sm-oauthserver-plugin && go test -count=1 -run 'TestMCPConsent|TestAuthorizations|TestMCPRevocation'` (not `-short`) | ❌ Wave 0 — extend `mcp_consent_test.go`, `authorizations_test.go`, new backfill test | +| SC-3 | `GET /oauth/authorizations` includes `"kind":"mcp"` or `"kind":"oidc"` and the previous six fields | integration | same package, `-run TestAuthorizations` | ❌ update `authorizations_smoke_test.go` key list | +| SC-4 | Docs identifiers resolve | unit | `go test ./cmd/summer -run TestDocsTree -count=1` | ✅ command exists; new identifiers fail it until the docs commit | +| Layer | Checkbox POST body, locked `read`, 409 re-show mapping | unit | `pnpm test` in vue-oauth-layer | ❌ Wave 0 — the repo does not exist | + +### Sampling Rate +- **Per task commit:** `go test -short` on the packages that commit touched (`./modules/pact`, `./modules/pier`, `./modules/wristband`, or the plugin's `-short` suite). `go vet` on those packages. +- **Per wave merge:** `go test -short ./...` in the repo that merged. Plugin waves that touch consent or revoke also run the non-short tests named above when Docker is up. +- **Phase gate:** Full `-short` suite in the framework repo, non-short consent/revoke/backfill tests in sm-oauthserver-plugin, `go test -short ./...` in sm-user-plugin on the v0.1.14 branch, `pnpm test` in the layer, `go test ./cmd/summer -run TestDocsTree`. Before `/msd-verify-work`. + +### Wave 0 Gaps +- [ ] `modules/pact` test that `FrontendTokenScope` still composites without `Implies` and that `MCPCallFrom` is false on a bare context — covers SC-2's negative +- [ ] `modules/pier` PRM test with and without `MCPAdvertisedScopes` — covers SC-1 +- [ ] `modules/pier` dispatch test that the context carries `MCPCall` for a frontend tool and a backend tool — covers SC-2 +- [ ] sm-user-plugin `mcp_guard_test.go` cases for `Implies` — covers SC-1. Must be written against v0.1.14 so the credential-id cases stay +- [ ] sm-oauthserver-plugin consent upsert, replace-not-merge, offline_access flag, revoke-deletes-MCP-code, backfill idempotence — covers SC-3 +- [ ] `authorizations_smoke_test.go` key list — covers SC-3's kind field +- [ ] vue-oauth-layer `tests/connect.test.ts` — covers the page mapping +- [ ] Framework install: none. Layer: `pnpm install` in the new repo once `package.json` exists +- Existing wristband and pier test infrastructure is enough to host the new cases. No new framework test dependency. + +## Security Domain + +`security_enforcement` is not set in `.planning/config.json`. Absent means enabled. + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | Existing wristband MCP (PKCE, S256, exact redirect) and OIDC. This phase does not add a grant type or relax redirect checks. OIDC scope list stays `openid profile email`. | +| V3 Session Management | yes | Pier sessions. Revoke keeps using `MCPSubjectRevoked` with `ClientID` only, so one client's sessions end. Do not put `TokenID` on that event. | +| V4 Access Control | yes | Read/write ceiling unchanged (`Identity.writes`). Declared scope still required, satisfied either by the grant or by `Implies` of a stock scope the grant holds. Personal tokens are not expanded. `backend` still needs the scope plus the linked account the consent recorded (`BackendUserID`). | +| V5 Input Validation | yes | `ValidTokenScopeName` plus boot rejection of bad `Implies`, duplicate names, and unknown `mcp_scopes` entries. Authorize still rejects a name not in `Options.ScopesSupported` with `invalid_scope`. Consent intersection still drops scopes the client did not request. | +| V6 Cryptography | no new crypto | Codes and tokens stay SHA-256 at rest. Do not log raw tokens. Do not hand-roll a hash. | + +### Known Threat Patterns for this phase + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Scope upgrade via `Implies` | Elevation of privilege | Only names the declaring plugin set on `Implies`, only the stock scope named there, only when `ClientID != ""`. A plugin that sets `Implies: "write"` on a dangerous scope is choosing that. Do not imply undeclared names. | +| Untick ignored because consent unions | Elevation of privilege | Replace scopes on a real decision. Do not call `SaveConsent`. | +| Revoke leaves an MCP code or a racing refresh | Elevation of privilege | Delete `golem15_oauthserver_mcp_codes` inside `revoke`, after `lockUserGrants`. | +| Cross-user consent read | Information disclosure | Keep `PendingRequest` / `IssueCode` owner checks (`ErrPendingNotFound` collapses missing, foreign, expired, used). Do not add a lookup by request id alone. | +| Open redirect on authorize | Spoofing | Do not touch the client and redirect checks that run before the `/connect` redirect. | +| Consent JSON XSS in the layer | Tampering | Render label, description, client name and admin name as text, not `v-html`. They are phrasebook strings and a DCR client name (`maxMCPClientName` is 120 runes `[VERIFIED: mcp_consent_api.go:22]`). | +| `kind` or `scope_details` confusing an old page | Tampering | Old pages ignore unknown fields and still POST `decision: allow` without `scopes`, which grants the whole request. That is the current all-or-nothing contract. Do not change it. | +| Logging the bearer or the code | Information disclosure | Tests and docs must not print access tokens. The changelog path must not include a token. | + +## Sources + +### Primary (HIGH confidence) +- This repo, read this session: `modules/pact/capabilities.go:814-845`, `modules/pact/mcp.go:116-303`, `modules/wristband/server.go:36-99` and `:183-196`, `modules/wristband/authorize.go:21-41` and `:114-241`, `modules/wristband/consent.go:1-74`, `modules/wristband/oidc_options.go:60-113`, `modules/wristband/oidc_authorize.go:235-257` and `:467-498`, `modules/wristband/token.go:141-142`, `modules/pier/prm.go:12-40`, `modules/pier/dispatch.go:36-48` and `:169-208`, `modules/pier/filter.go:61-90`, `modules/pier/identity.go:18-25` and `:66-82` and `:204-303`, `modules/pier/events.go:30-94`, `modules/pier/session.go:40-42` and `:217-226` and `:703-710`, `modules/pier/backend_guard.go:16-97`, `modules/bouncer/dispatch.go:5-34`, `modules/bouncer/registry.go` (dispatch honors credential), `modules/backpack/app.go:59-73`, `modules/party/registry.go:141-147`, `modules/surf/router.go:697-705`, `modules/compass/config.go:112-127`, `modules/phrasebook/translator.go:150-163`, `.planning/config.json`, `.planning/ROADMAP.md` Phase 32.1, `.planning/research/STACK.md` MCP SDK row. +- sm-oauthserver-plugin v0.1.3: `servers.go:136-165`, `routes.go:70-82`, `config/config.yaml:68-75`, `controllers/mcp_consent_api.go`, `controllers/authorizations.go`, `classes/oidc_store.go:328-361`, `classes/mcp_store.go:117-118` and `:250-273`, `classes/mcp_verifier.go:58-119`, `classes/mcp_backend_binding.go:43-75`, `classes/cascade.go:29-31` and `:87-149`, `models/consent.go`, `models/client.go:14-17`, `models/mcp_code.go`, `models/access_token.go`, `models/refresh_token.go`, `models/registry.go:25-34`, `updates/202610190001_create_golem15_oauthserver_tables.go:104-113`, `updates/202610190003_create_golem15_oauthserver_mcp_codes.go`, `views/consent.html.tmpl:1-6`, `mcp_consent_smoke_test.go:227-232`, `authorizations_smoke_test.go:66-68`, `testmain_test.go:81-87`, `plugin.go:58-107`, `go.mod`. +- sm-user-plugin: worktree v0.1.13 `classes/mcp_guard.go`, `classes/api_token_scopes.go`, `classes/api_tokens.go:19-27`, `middleware/api_token.go:89-101`, `models/api_token.go:39-40`, `plugin.go:154-195`, `classes/subject_events.go:15-17`, `lang/en/lang.yaml:146-152`. Tag v0.1.14 `classes/mcp_guard.go:115-143` (fetched blob). + +### Secondary (MEDIUM confidence) +- [CITED: https://nuxt.com/docs/4.x/guide/going-further/layers] — layer `app/pages/*` auto-scan and project-files-win priority. Fetched 2026-10-28. +- Sibling layer pins: `vue-menu-layer/package.json`, `vue-journal-layer/package.json`, `vue-journal-layer/README.md` install line, `sm-summercmsio-app/vue-summercmsio-app/changelog/nuxt.config.ts` `extends`. + +### Tertiary (LOW confidence) +- A1 (SDK stateless session id) — not read from the SDK source this session. The plan forces an empty `SessionID` on that path, so the unknown does not change the design. + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — no new library; versions are the go.mod and package.json already in the trees. +- Architecture: HIGH — scope lists, dispatch, consent table, revoke gap and the `/connect` redirect were read from source. MEDIUM only on the stateless session-id detail (forced empty regardless). +- Pitfalls: HIGH — each one is a behavior the current code exhibits (empty allow-list fallback, SaveConsent union, revoke's delete list, v0.1.13 vs v0.1.14, smoke-test key count). + +**Research date:** 2026-10-28 +**Valid until:** 2026-11-27 (stable in-repo seams; re-check if v0.1.15 of sm-user-plugin or a wristband scope change lands first) + +## Suggested plan split + +The project rule is few large plans, and the planner must present the count and wait before writing PLAN.md files. Five plans match the repos and the "unit tests last" rule: + +1. **Framework seam** — `Implies` on `FrontendTokenScope`, `MemberTokenScopeCatalog`, `MCPAdvertisedScopes`, `MCPCall` / `WithMCPCall` / `MCPCallFrom`, pier setter and PRM, `PendingRequestView.OfflineAccess`, module READMEs and `docs/services/mcp.md` plus the wristband sentences in `docs/services/oauth-server.md` and `docs/services/authentication.md`. Smoke-level tests that keep `go test -short` green for the new symbols. No oauthserver import. +2. **sm-user-plugin, from tag v0.1.14** — validate `Implies`, publish the catalog, expand in `MCPAllows` and `MCPDispatch` without touching the v0.1.14 credential-id rule, README. Do not edit app submodules. +3. **sm-oauthserver-plugin** — `golem15.oauthserver.mcp_scopes`, `NewServers` slice, verifier `MCPScopesSupported`, `scope_details` + logo, offline_access flag through the store, consent replace-upsert, `consent_required`, revoke deletes MCP codes under `lockUserGrants`, backfill migration, `kind` on the list, en/pl phrases, README. Update `authorizations_smoke_test.go`. Leave `views/consent.html.tmpl` and OIDC scopes alone. +4. **vue-oauth-layer** — new repo, menu-layer skeleton, `app/pages/connect.vue`, `node --test`, README, no app migration, no tag. +5. **Unit tests** — the new cases in the Wave 0 list that plans 1–4 did not already need to keep existing tests green. This is the last plan. + +Files this phase must not modify: `modules/wristband/oidc_options.go` scope default, `modules/pier/filter.go` ceiling, `modules/pact/mcp.go` `MCPSubject` field set (add types beside it, do not rename fields), `views/consent.html.tmpl`, any app `/connect` page, any app `summer.yaml` `mcp.enabled`, `modules/boardwalk/dist`, `.planning/STATE.md` by hand. + +## Files the executor will touch + +**Modify (framework):** `modules/pact/capabilities.go`, `modules/pact/mcp.go`, `modules/pact/README.md`, `modules/pier/dispatch.go`, `modules/pier/prm.go`, `modules/pier/README.md`, `modules/wristband/consent.go`, `modules/wristband/README.md`, `docs/services/mcp.md`, `docs/services/oauth-server.md`, `docs/services/authentication.md`, plus the test files named in Validation Architecture. + +**Modify (sm-oauthserver-plugin):** `config/config.yaml`, `servers.go`, `classes/mcp_verifier.go`, `classes/mcp_store.go`, `controllers/mcp_consent_api.go`, `controllers/authorizations.go`, `lang/en/lang.yaml`, `lang/pl/lang.yaml`, `README.md`, `authorizations_smoke_test.go`, `mcp_consent_smoke_test.go`, `mcp_consent_test.go`, new file under `updates/`. + +**Modify (sm-user-plugin, v0.1.14 tree):** `classes/api_token_scopes.go`, `classes/mcp_guard.go`, `plugin.go` only if publish needs a new line next to the existing catalog publish, `README.md`, `mcp_guard_test.go`. + +**Create:** `vue-oauth-layer` repo (`package.json`, `nuxt.config.ts`, `app/pages/connect.vue`, `tests/connect.test.ts`, `README.md`, `.playground`). + +**Do not modify:** the files listed in Suggested plan split, and do not bump app submodule pointers (deferred; "plugin first, pointer bump second" is a later app change).