feat: plugin-declared member token scopes (pact.HasFrontendTokenScopes)

- CompileAPITokenScopes builds the catalog from the built-ins and the
  activated plugins' declarations; a bad name, a redeclared built-in, a
  duplicate, an empty label or a missing phrase key fails boot
- POST /tokens, user:token:create and MintAPITokenFor check the
  application's own catalog (422 text unchanged); MintAPIToken keeps its
  signature with a process-wide fallback for declared scopes
- GET /_user/api/v1/tokens/scopes lists the built-ins and the declared
  scopes with translated labels
- built-in scope labels in en and pl; TestMemberTokenScopesSmoke
This commit is contained in:
Jakub Zych
2026-10-23 17:00:12 +02:00
parent 970719ce75
commit ee5c096d0d
11 changed files with 470 additions and 16 deletions

View File

@@ -21,6 +21,7 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
@@ -110,12 +111,40 @@ func (p *Plugin) Boot(app *backpack.App) error {
if err := p.bootBackendLinks(app); err != nil {
return err
}
if err := p.bootAPITokenScopes(app); err != nil {
return err
}
if err := p.bootMCPGuard(app); err != nil {
return err
}
return classes.RegisterUserAuthorizer(app)
}
// bootAPITokenScopes compiles the member token scope catalog from the
// activated plugins (pact.HasFrontendTokenScopes), checks that every phrase
// key it names exists, and publishes it. A bad declaration fails boot.
func (p *Plugin) bootAPITokenScopes(app *backpack.App) error {
activated, _ := app.Lookup[party.Activated]()
catalog, err := classes.CompileAPITokenScopes(activated)
if err != nil {
return err
}
if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
for _, scope := range catalog.Scopes() {
for _, text := range []string{scope.Label, scope.Description} {
if strings.Contains(text, "::") && !tr.Has(text) {
who := scope.PluginID
if who == "" {
who = "golem15.user"
}
return fmt.Errorf("golem15.user: member token scope %s (%s) names missing phrase key %s", scope.Name, who, text)
}
}
}
}
return app.Publish(catalog)
}
// bootMCPGuard registers the "user" MCP guard provider in the application's
// *pact.MCPGuards (publishing the registry when no one did yet) and
// publishes the same value as pact.MCPPersonalTokenResolver for the