feat(pact): add HasFrontendTokenScopes and the shared ValidTokenScopeName rule (E-2)

- pact.FrontendTokenScope and pact.HasFrontendTokenScopes declare member
  (usr_) token scopes; the admin token catalog never reads them
- pact.ValidTokenScopeName carries cabana's scope-name pattern; cabana's
  g15_ catalog switches to it with unchanged behaviour and messages
This commit is contained in:
Jakub Zych
2026-10-23 16:15:25 +02:00
parent c14238b94b
commit 9eedfbbb29
5 changed files with 43 additions and 5 deletions

View File

@@ -2,7 +2,6 @@ package cabana
import (
"fmt"
"regexp"
"strings"
"git.golem15.com/golem15/summercms/modules/bouncer"
@@ -16,8 +15,6 @@ const (
scopeAdminRead = "admin:read"
)
var tokenScopeNameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`)
var allowedTokenMethods = map[string]bool{
"GET": true, "HEAD": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true,
}
@@ -107,7 +104,7 @@ func compileTokenScopes(reg *Registry, plugins []party.Plugin) error {
func validatePluginTokenScope(reg *Registry, pluginID string, scope pact.TokenScope, cat *tokenScopeCatalog) error {
name := strings.TrimSpace(scope.Name)
if name == "" || !tokenScopeNameRE.MatchString(name) {
if name == "" || !pact.ValidTokenScopeName(name) {
return fmt.Errorf("cabana: plugin %s registered invalid token scope name %q", pluginID, scope.Name)
}
if name == scopeAll || name == "admin" || strings.HasPrefix(name, "admin:") {

View File

@@ -26,6 +26,7 @@ Use cases:
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page. A `pact.NavigationItem.Visible` func hides a navigation entry at request time (nil = always listed).
- Personal API token scopes: `pact.TokenScope` and `pact.HasTokenScopes`, so a plugin declares named scopes (permissions and HTTP methods) that administrators can assign when creating a personal API token.
- Member token scopes: `pact.FrontendTokenScope` and `pact.HasFrontendTokenScopes`, the frontend counterpart of `pact.HasTokenScopes`, so a plugin declares scopes for the user plugin's member personal API tokens; the admin token catalog never reads them. `pact.ValidTokenScopeName` is the one scope-name rule both catalogs apply.
- Frontend permission codes: `pact.FrontendPermission` and `pact.HasFrontendPermissions`, the frontend counterpart of `pact.HasPermissions`, so a plugin guarding a route with a permission code also declares that code for the user plugin's permission editors.
- MCP declarations, free of any MCP SDK: `pact.HasMCP` with `pact.MCP`, `pact.MCPTool` and `pact.MCPPrompt` bind tools and prompts to the plugin's own token-guarded routes; `pact.MCPGuard` and `pact.MCPGuards` let the owner of a route guard publish how its routes are guarded and dispatched; `pact.MCPTokenVerifier`, `pact.MCPPersonalTokenResolver` and `pact.MCPLinkedBackendResolver` are the services the MCP endpoint looks up; `pact.MCPSubjectChanged` is the event that refreshes a session's tool list.
- My account contributions: `pact.HasAccountContributions` with `pact.AccountContribution` and `pact.AccountAction`, so a plugin adds a section with a status line and buttons to the administrator's own account page, run for the signed-in administrator only.
@@ -122,6 +123,9 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.HasPermissions` | Declares backend permissions. |
| `pact.TokenScope` | One personal API token scope: name, label, description, permission patterns and HTTP methods. |
| `pact.HasTokenScopes` | Declares personal API token scopes compiled at admin boot. |
| `pact.FrontendTokenScope` | One member personal API token scope: `Name` (what a route's `user.scope:<name>` middleware checks), `Label` and `Description`. |
| `pact.HasFrontendTokenScopes` | Declares member token scopes, the frontend counterpart of `pact.HasTokenScopes`; compiled by the plugin that owns member tokens, never by the admin token catalog. |
| `pact.ValidTokenScopeName` | Reports whether a scope name is valid (`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`); shared by both token catalogs. |
| `pact.NavigationItem` | One backend navigation entry, with an optional side menu and an optional `Visible` func the admin runs on every navigation request. |
| `pact.SettingsItem` | One settings screen entry: a singleton settings form (`Model`, `Form`, `NewModel`), or a link to an admin controller when `Controller` names its ID, as a WinterCMS `'url' => Backend::url(...)` entry. |
| `pact.AdminController` | Admin controller identity: ID, model name and YAML config directory. |
@@ -202,7 +206,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
- SummerCMS modules: [bonfire](../bonfire/README.md) (the command type in `pact.HasCommands`).
- Third-party: `github.com/go-gormigrate/gormigrate/v2`, `gorm.io/gorm`.
- Standard library: `context`, `encoding/json`, `io`, `io/fs`, `net/http`, `strings`, `time`.
- Standard library: `context`, `encoding/json`, `io`, `io/fs`, `net/http`, `regexp`, `strings`, `time`.
## Testing

View File

@@ -6,6 +6,7 @@ import (
"io"
"io/fs"
"net/http"
"regexp"
"strings"
"time"
@@ -653,6 +654,39 @@ type HasTokenScopes interface {
TokenScopes() []TokenScope
}
// FrontendTokenScope is one scope of the member personal API tokens (usr_)
// that the user plugin issues, declared by a plugin: the name a route's
// user.scope:<name> middleware checks, with the label and description the
// member token screens show. Label and Description are phrase keys
// (containing "::") or literal text. It is the frontend counterpart of
// TokenScope and carries no permission patterns or method filter: the route
// middleware alone decides what a member scope allows.
type FrontendTokenScope struct {
Name string
Label string
Description string
}
// HasFrontendTokenScopes is implemented by plugins that declare member
// personal API token scopes; the frontend counterpart of HasTokenScopes. The
// framework's admin token catalog never reads it: the plugin that owns member
// tokens compiles it, validates the names with ValidTokenScopeName and fails
// boot on a bad one.
type HasFrontendTokenScopes interface {
FrontendTokenScopes() []FrontendTokenScope
}
var tokenScopeNameRE = regexp.MustCompile(`^[a-z][a-z0-9_-]*(:[a-z][a-z0-9_-]*)?$`)
// ValidTokenScopeName reports whether name is a valid personal API token
// scope name: lowercase, starting with a letter, letters, digits, "_" and "-",
// with at most one ":" separating two such parts (for example "blog:write").
// It is the one name rule both the admin token catalog (TokenScope) and the
// member token catalog (FrontendTokenScope) apply.
func ValidTokenScopeName(name string) bool {
return tokenScopeNameRE.MatchString(name)
}
// NavigationItem is one registerNavigation() entry. Controller is the admin
// controller ID; the SPA derives its route from that ID. Every field but
// Visible is static and compiled once at boot; Visible is the one hook the