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

@@ -116,6 +116,8 @@ func (p *Plugin) TokenScopes() []pact.TokenScope {
A scoped request passes only when one method-allowed scope matches every required permission code and the owner holds a required code inside that scope. A superuser with a scoped token is limited to that scope. An empty requirement list is refused unless the scope is full (`*` or `admin:read` on GET/HEAD). Routes that never call `cabana.Allows` (`GET /field-types`, `POST /markdown/preview`) apply the same empty-list rule. Account profile, avatar, token management, refresh and logout refuse every personal token (`details.reason` `interactive_only`), including `*`. `GET /auth/me` accepts any valid token.
Member personal API tokens, the ones the user plugin issues to frontend users, have their own catalog. A plugin declares scopes for them with `pact.HasFrontendTokenScopes` (`pact.FrontendTokenScope`: name, label and description, no permissions or methods, because the route's scope middleware decides). Those scopes appear only in the member token list and never in this admin catalog, and `pact.HasTokenScopes` scopes never appear in the member list. Both catalogs check names with `pact.ValidTokenScopeName`.
### Expiry and revoke
Expiry is `30d`, `90d` (default), `365d` or `never`. Soft revoke sets `revoked_at`; revoked tokens are hidden from the default list. A password reset does not invalidate personal API tokens.

View File

@@ -83,6 +83,7 @@ A WinterCMS plugin overrides `register*` methods of `PluginBase`. A SummerCMS pl
| `pact.HasMailTemplates` | Mail templates and layouts. |
| `pact.HasPermissions`, `pact.HasNavigation`, `pact.HasSettings` | Backend permissions, navigation and settings screens. |
| `pact.HasTokenScopes` | Personal API token scopes (`pact.TokenScope`) compiled at admin boot. |
| `pact.HasFrontendTokenScopes` | Member personal API token scopes (`pact.FrontendTokenScope`) a route's `user.scope:<name>` middleware checks, compiled by the user plugin; the admin token catalog never reads them. |
| `pact.HasFrontendPermissions` | Frontend permission codes (`pact.FrontendPermission`) a route's permission middleware checks, offered in the user plugin's permission editors. |
| `pact.HasAdminControllers` | Admin controllers built from `fields.yaml` and `columns.yaml`. |
| `pact.HasAdminActionExtensions` | Record actions added to any admin controller by ID (`pact.AdminActionExtension`); a `pact.AdminRecordAction` with a `Prompt` asks the administrator for one text value first and receives it as `pact.AdminRecordActionInput.Value`. |

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