docs(32.1): create phase plan
This commit is contained in:
@@ -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:<declared>` 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:
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Tracer: one advertised scope list and one MCP call on the dispatch context</name>
|
||||
<reversibility rating="costly">D-03 and D-10 publish a scope list and a pact.MCPCall shape that later grants and callers will store and read.</reversibility>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
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)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short -count=1 -run 'TestMCPCallRoundTrip|TestMCPSeam|TestMCPScopesSmoke|TestDiscoveryFields' ./modules/pact ./modules/pier ./modules/wristband</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPCallRoundTrip or TestMCPSeam has no PASS line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Expose offline_access on the pending consent view</name>
|
||||
<reversibility rating="reversible">OfflineAccess is an additive field on a view struct. IssueCode's signature stays.</reversibility>
|
||||
<files>modules/wristband/consent.go</files>
|
||||
<read_first>
|
||||
modules/wristband/consent.go (PendingRequestView and IssueCode)
|
||||
modules/wristband/authorize.go (offline_access is peeled onto the pending row, not stored in Scopes)
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short -count=1 ./modules/wristband</automated>
|
||||
<fails_when>non-zero exit, or the output contains FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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).
|
||||
</acceptance_criteria>
|
||||
<done>Consent show can see the offline_access column without a new IssueCode argument.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Document the seam in READMEs and docs</name>
|
||||
<reversibility rating="reversible">Docs follow the API added in this plan and can be rewritten with it.</reversibility>
|
||||
<files>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</files>
|
||||
<read_first>
|
||||
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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>SC-4 docs for the framework seam are in the same release as the API they name.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
go test -short on pact, pier and wristband passes, TestDiscoveryFields still locks the OIDC scope list, and TestDocsTree passes after the README commit.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## 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
|
||||
@@ -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
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Tracer: v0.1.14 worktree publishes the catalog and expands one implied scope</name>
|
||||
<precondition>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.</precondition>
|
||||
<reversibility rating="costly">D-01a: once a plugin marks Implies read or write, live OAuth agents gain those tools. Removing it later shrinks their tool list.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>
|
||||
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.
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin test -short -count=1 -run '^TestMCPImpliesOAuth$' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>The v0.1.14 branch publishes the catalog and one OAuth implied scope works end to end through allow and dispatch.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Reject bad Implies and keep personal tokens unexpanded</name>
|
||||
<reversibility rating="costly">D-01a and D-03: boot errors and the personal-token exemption are part of the contract plugins and grants will rely on.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>
|
||||
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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin test -short -count=1 -run 'TestMCP' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPImpliesOAuth has no PASS line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>D-01a holds on both call sites, personal tokens are not expanded, and the plugin README matches the API.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
go test -short -run TestMCP in the v0.1.14 worktree passes, including TestMCPImpliesOAuth, and the app submodule is untouched.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Catalog publication and OAuth-only Implies are on a branch from v0.1.14.
|
||||
- Personal-token credential ID behaviour from that tag is intact.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## 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
|
||||
@@ -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
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Tracer: one MCP scope slice from config to metadata and the verifier</name>
|
||||
<precondition>/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.</precondition>
|
||||
<reversibility rating="costly">D-03: scope names published in metadata and later stored on grants are costly to retract.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>
|
||||
../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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -short -count=1 -run '^TestMCPAdvertisedScopes$' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>Metadata and the verifier advertise one non-empty slice, and an unknown mcp_scopes name fails boot.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Consent JSON, replace-not-merge, offline_access, revoke, and kind</name>
|
||||
<precondition>docker info exits 0. The plugin tests use testcontainers Postgres and skip the database when Docker is down.</precondition>
|
||||
<reversibility rating="reversible">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.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>
|
||||
../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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -count=1 -timeout 240s -run 'TestMCPConsentSmoke|TestAuthorizationsSmoke|TestMCPAdvertisedScopes' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestAuthorizationsSmoke has no PASS line</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>Consent, revoke, and the authorizations list behave as D-06 through D-13 and D-15, and the existing smokes pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking" auto_select="write-noop-rollback">
|
||||
<name>Confirm the one-way MCP consent backfill</name>
|
||||
<reversibility rating="one-way">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.</reversibility>
|
||||
<decision>Write the D-14 backfill that inserts one golem15_oauthserver_consents row per live MCP user and client, with Rollback as a no-op?</decision>
|
||||
<context>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.</context>
|
||||
<options>
|
||||
<option id="write-noop-rollback">
|
||||
<name>Write the backfill, Rollback is a no-op</name>
|
||||
<pros>Live MCP grants become revocable. A second migrate inserts nothing. Rollback cannot destroy later consents.</pros>
|
||||
<cons>Rows stay after a down migration. Undo is a hand-written delete, not the migration Rollback.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Do not write the migration in this run</name>
|
||||
<pros>No rows are inserted on install until a later explicit decision.</pros>
|
||||
<cons>Grants that predate this release still have no consent row, so per-client revoke answers 404 for them. D-14 stays unmet.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Select write-noop-rollback or hold</resume-signal>
|
||||
<read_first>
|
||||
.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
|
||||
</read_first>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Backfill MCP consents for live grants</name>
|
||||
<reversibility rating="one-way">D-14 inserts consent rows on every consuming install. Rollback is a no-op and does not delete those rows.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>
|
||||
../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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"'</automated>
|
||||
<fails_when>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</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>D-14 is either written as an idempotent no-op-rollback migration or explicitly held. It is not written before the checkpoint.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
TestMCPAdvertisedScopes passes under -short. TestMCPConsentSmoke and TestAuthorizationsSmoke pass against Postgres. The backfill file exists only after write-noop-rollback.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## 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
|
||||
@@ -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
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Tracer: show JSON maps to a locked-read allow body</name>
|
||||
<precondition>/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.</precondition>
|
||||
<reversibility rating="costly">D-17: the layer's /connect props and POST body become the contract apps will pin by tag.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>
|
||||
../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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test</automated>
|
||||
<fails_when>non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>A show payload becomes an allow POST that keeps locked read, and the page uses that function.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Finish /connect and the layer README</name>
|
||||
<reversibility rating="costly">The page copy and the README install line are what a later tag publishes (D-17).</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>
|
||||
../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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test</automated>
|
||||
<fails_when>non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>D-17's /connect behaviour is in the layer, tested, and not tagged.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
pnpm test in vue-oauth-layer passes. No app /connect file is modified. No tag and no push.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- /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.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## 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
|
||||
@@ -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
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Tracer: bare context has no MCP call, and both guards' tool calls do</name>
|
||||
<reversibility rating="reversible">Tests only. They lock the accessor contract already added in plan 01.</reversibility>
|
||||
<files>modules/pact/mcp_call_negative_test.go, modules/pier/dispatch_mcpcall_test.go, modules/pier/prm_scopes_test.go</files>
|
||||
<read_first>
|
||||
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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -short -count=1 -run 'TestMCPCallFromBareContext|TestFrontendTokenScopeLiteralWithoutImplies|TestPRMUsesAdvertisedScopes|TestPRMFallsBackToPRMScopes|TestDispatchMCPCallBothGuards' ./modules/pact ./modules/pier</automated>
|
||||
<fails_when>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</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>SC-2's negative and both guards, and SC-1's PRM fallback, are proven in the framework repo.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Plugin, layer, and docs proofs for SC-1 through SC-4</name>
|
||||
<precondition>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.</precondition>
|
||||
<reversibility rating="reversible">Tests and a docs-checker run. No schema write except the oauthserver test database.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>
|
||||
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
|
||||
</read_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin && go test -short -count=1 -run 'TestMCPImplies' .</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL, or TestMCPImpliesPersonalUnchanged has no PASS line</fails_when>
|
||||
<automated>go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-oauthserver-plugin test -count=1 -timeout 300s -run 'TestMCPConsentReplace|TestMCPOfflineAccessUntick|TestMCPRevokeDeletesCode|TestAuthorizationsKind|TestMCPConsentBackfillIdempotent' .</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
<automated>pnpm --prefix /media/nvme/dev/golem15/summercms.io/summercms/vue-oauth-layer test</automated>
|
||||
<fails_when>non-zero exit, or the tap output contains `not ok`, or a line `# tests 0`, or a line `# fail` whose count is not 0</fails_when>
|
||||
<automated>go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go vet ./... && go test -short ./...</automated>
|
||||
<fails_when>non-zero exit, or the output contains `[no tests to run]`, or a line contains FAIL</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>SC-1 through SC-4 have automated proofs beyond the earlier tracer smokes, and no tag has been created.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking">
|
||||
<name>Whether to tag, push, and post the changelog</name>
|
||||
<reversibility rating="costly">A pushed framework tag is the release other apps will require. It is not created unless this checkpoint accepts it (D-16).</reversibility>
|
||||
<decision>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?</decision>
|
||||
<context>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.</context>
|
||||
<options>
|
||||
<option id="tag-and-publish">
|
||||
<name>Tag, push, and post the changelog</name>
|
||||
<pros>The three gaps are on patch tags an application can require. The framework tag gets its changelog post the same day.</pros>
|
||||
<cons>The tags are public. A mistake is a further patch, not a rewritten tag.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Do not tag or push</name>
|
||||
<pros>The code stays local until the user asks again.</pros>
|
||||
<cons>SC-4's patch tags stay unmet until a later go-ahead. No changelog post.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Select tag-and-publish or hold</resume-signal>
|
||||
<read_first>
|
||||
.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
|
||||
</read_first>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Tag and publish only if the checkpoint accepted it</name>
|
||||
<reversibility rating="costly">Pushed tags and a changelog post are the release. hold does none of this.</reversibility>
|
||||
<files>../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</files>
|
||||
<read_first>
|
||||
../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_first>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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)'</automated>
|
||||
<fails_when>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.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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.
|
||||
</acceptance_criteria>
|
||||
<done>D-16 either shipped as patch tags plus the framework changelog, or stayed local because the checkpoint said hold.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
The named tests PASS, docs:build --check passes, and no tag exists unless tag-and-publish was accepted.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1 through SC-4 are covered by the tests named in this plan.
|
||||
- Tags, pushes, and the changelog post are behind the checkpoint.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
||||
## 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.
|
||||
@@ -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/<new backfill>.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
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user