feat: SEO by URL over the management API and MCP (tracer)

- PUT /_seo/api/v1/seo writes to the record behind a URL in its locale,
  else to an exact URL rule (force_rule writes a rule); the answer says
  where (D-11)
- management routes resolve the backend administrator from a token or
  the MCP dispatch and require golem15.seo.manage (D-10, D-15)
- MCP tool set_url_seo; history records via mcp
- classes.WithTx, FindOwner, SetURLSEO, SaveRule and DeleteRule
This commit is contained in:
2026-10-27 12:06:31 +01:00
parent b3981614cb
commit cadf6ad805
10 changed files with 808 additions and 7 deletions

193
classes/owner.go Normal file
View File

@@ -0,0 +1,193 @@
package classes
import (
"context"
"net/url"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// Where a change addressed by URL is written (D-11).
const (
WrittenToRecord = "record"
WrittenToRule = "rule"
)
// Owner is the owner of a URL's SEO (D-11, P-25): the record behind the
// URL in the matched locale, or an exact URL rule. URL is the address in
// the site's form (normalised path with its query); RuleID is the rule's
// id once it exists. Published is true for a published record.
type Owner struct {
WrittenTo string `json:"written_to"`
URL string `json:"url"`
Type string `json:"type,omitempty"`
ID string `json:"id,omitempty"`
Locale string `json:"locale,omitempty"`
RuleID uint `json:"rule_id,omitempty"`
Published bool `json:"-"`
}
// FindOwner resolves the owner of rawURL (a path or an absolute URL of the
// site) inside db: the URL is normalised with the site's address rules,
// then the signpost patterns (MatchPath) name the record behind it. An
// exact match without a query is the record's SEO in the matched locale;
// anything else, or forceRule, is an exact URL rule on the normalised
// address. ErrBadURL refuses an input that is not a path or http(s) URL.
func (s *Service) FindOwner(ctx context.Context, db *gorm.DB, rawURL string, forceRule bool) (Owner, pact.MenuItemType, error) {
path, _, err := parseTarget(rawURL)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
settings, err := LoadSettings(db)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
norm, _ := Normalize(&url.URL{Path: path, RawQuery: rawQueryOf(rawURL)}, NormalizeOptionsOf(settings))
address := joinQuery(norm.Path, norm.RawQuery)
if !forceRule && norm.RawQuery == "" {
if reg := s.Registry(); reg != nil {
unprefixed := s.UnprefixedLocale()
key, match, err := reg.MatchPath(ctx, trimSlash(norm.Path), unprefixed)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
if match != nil && match.Exact {
typ, ok := reg.ContentType(key)
if ok && typ.Content != nil {
owner := Owner{WrittenTo: WrittenToRecord, URL: address, Type: key, ID: match.ID, Locale: match.Locale}
if rec, err := reg.Describe(ctx, key, match.ID, unprefixed); err == nil && rec != nil {
owner.Published = rec.Published
}
return owner, typ, nil
}
}
}
}
owner := Owner{WrittenTo: WrittenToRule, URL: address}
rule, found, err := ExactRule(db, address)
if err != nil {
return Owner{}, pact.MenuItemType{}, err
}
if found {
owner.RuleID = rule.ID
}
return owner, pact.MenuItemType{}, nil
}
// ExactRule returns the exact URL rule of address; found is false when
// there is none.
func ExactRule(db *gorm.DB, address string) (models.Rule, bool, error) {
var rows []models.Rule
if err := db.Where("url = ? AND match = ?", address, models.MatchExact).Limit(1).Find(&rows).Error; err != nil {
return models.Rule{}, false, err
}
if len(rows) == 0 {
return models.Rule{}, false, nil
}
return rows[0], true, nil
}
// URLSEOWrite is one change of a URL's SEO (D-11): the address, the
// locale of a record's entry (empty takes the locale the URL matched),
// the values to write (column -> value; an absent column is left as it
// is, an empty value inherits), and ForceRule to write a URL rule even
// when a record is behind the URL.
type URLSEOWrite struct {
URL string
Locale string
Values map[string]string
ForceRule bool
}
// URLSEOResult is where a URL change was written and the stored values
// of the owner after the write.
type URLSEOResult struct {
Owner
Values map[string]string `json:"values"`
}
// SetURLSEO writes in.Values to the owner of in.URL inside tx (D-11): the
// record's SEO entry when a record is behind the URL, else an exact URL
// rule, created when missing. Robots values are brought to the C5 grammar,
// a canonical must be a path or an http(s) URL, and the focus keyphrase
// belongs to records only; problems are refused with a *ProblemsError.
// History records the write with the via of ctx.
func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (URLSEOResult, error) {
owner, typ, err := s.FindOwner(ctx, tx, in.URL, in.ForceRule)
if err != nil {
return URLSEOResult{}, err
}
values := map[string]string{}
problems := map[string][]models.Problem{}
known := map[string]bool{}
for _, column := range EntryColumns {
known[column] = true
}
for column, raw := range in.Values {
v := strings.TrimSpace(raw)
switch {
case !known[column]:
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.field_unknown"})
continue
case column == "keyphrase" && owner.WrittenTo == WrittenToRule && v != "":
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.keyphrase_record_only"})
continue
case column == "robots" && v != "":
normal, err := NormalizeRobots(v)
if err != nil {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
continue
}
v = normal
case column == "canonical" && v != "" && !ValidURLRef(v):
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
continue
}
values[column] = v
}
if len(problems) > 0 {
return URLSEOResult{}, &ProblemsError{Problems: problems}
}
if owner.WrittenTo == WrittenToRecord {
locale := owner.Locale
if l := strings.TrimSpace(in.Locale); l != "" {
locale = l
}
owner.Locale = locale
write := RecordValues{}
for column, v := range values {
write[column] = map[string]string{locale: v}
}
_, after, err := s.SaveRecord(ctx, tx, RecordWrite{Type: typ, ID: owner.ID, Values: write})
if err != nil {
return URLSEOResult{}, err
}
out := map[string]string{}
e := after[locale]
for _, column := range EntryColumns {
out[column] = EntryValue(e, column)
}
return URLSEOResult{Owner: owner, Values: out}, nil
}
rule := models.Rule{URL: owner.URL, Match: models.MatchExact}
if owner.RuleID != 0 {
if rule, err = LoadRule(tx, owner.RuleID); err != nil {
return URLSEOResult{}, err
}
}
for column, v := range values {
SetRuleValue(&rule, column, v)
}
if err := s.SaveRule(ctx, tx, &rule); err != nil {
return URLSEOResult{}, err
}
owner.RuleID = rule.ID
out := map[string]string{}
for _, p := range models.RuleProperties {
out[p.Column] = rule.Value(p.Column)
}
return URLSEOResult{Owner: owner, Values: out}, nil
}

118
classes/rules.go Normal file
View File

@@ -0,0 +1,118 @@
package classes
import (
"context"
"errors"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// ErrNoRecord is returned for an id that names no row.
var ErrNoRecord = errors.New("golem15.seo: record not found")
// LoadRule returns rule id; ErrNoRecord when there is none.
func LoadRule(db *gorm.DB, id uint) (models.Rule, error) {
var rows []models.Rule
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
return models.Rule{}, err
}
if len(rows) == 0 {
return models.Rule{}, ErrNoRecord
}
return rows[0], nil
}
// SaveRule validates and stores r inside tx and records it in history: the
// address is trimmed and the match normalised, robots values are brought
// to the C5 grammar and a canonical must be a path or an http(s) URL. Field
// problems are refused with a *ProblemsError.
func (s *Service) SaveRule(ctx context.Context, tx *gorm.DB, r *models.Rule) error {
if r == nil {
return errors.New("golem15.seo: no rule")
}
r.URL = strings.TrimSpace(r.URL)
r.Match = models.NormalizeMatch(r.Match)
problems := r.Problems(tx)
if v := strings.TrimSpace(r.Robots); v != "" {
normal, err := NormalizeRobots(v)
if err != nil {
problems["robots"] = append(problems["robots"], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
}
r.Robots = normal
}
if v := strings.TrimSpace(r.Canonical); v != "" && !ValidURLRef(v) {
problems["canonical"] = append(problems["canonical"], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
}
if len(problems) > 0 {
return &ProblemsError{Problems: problems}
}
now := time.Now().UTC()
var before map[string]any
if r.ID == 0 {
r.CreatedAt, r.UpdatedAt = &now, &now
if err := tx.Create(r).Error; err != nil {
return err
}
} else {
old, err := LoadRule(tx, r.ID)
if err != nil {
return err
}
before = RuleValues(old)
r.CreatedAt, r.UpdatedAt = old.CreatedAt, &now
if err := tx.Save(r).Error; err != nil {
return err
}
}
return s.Record(ctx, tx, Change{
Kind: models.SubjectRule,
SubjectID: strconv.FormatUint(uint64(r.ID), 10),
What: s.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": r.URL}),
Before: before,
After: RuleValues(*r),
})
}
// DeleteRule deletes r inside tx and records it in history.
func (s *Service) DeleteRule(ctx context.Context, tx *gorm.DB, r models.Rule) error {
if err := tx.Delete(&models.Rule{}, r.ID).Error; err != nil {
return err
}
return s.Record(ctx, tx, Change{
Kind: models.SubjectRule,
SubjectID: strconv.FormatUint(uint64(r.ID), 10),
What: s.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": r.URL}),
Before: RuleValues(r),
})
}
// SetRuleValue writes one value column of a rule; an unknown column is
// ignored.
func SetRuleValue(r *models.Rule, column, value string) {
switch column {
case "url":
r.URL = value
case "match":
r.Match = value
case "note":
r.Note = value
case "title":
r.Title = value
case "description":
r.Description = value
case "canonical":
r.Canonical = value
case "robots":
r.Robots = value
case "og_title":
r.OGTitle = value
case "og_description":
r.OGDescription = value
case "og_image":
r.OGImage = value
}
}

View File

@@ -46,9 +46,25 @@ func (s *Service) App() *backpack.App {
return s.app
}
// DB returns the write transaction carried by ctx (cabana.TxFromContext),
// else the application's database bound to ctx.
type txKey struct{}
// WithTx returns ctx carrying tx, the transaction an API write or a batch
// runs in, so every read of the service (the resolver, the redirect set,
// the settings) sees the uncommitted changes of that transaction.
func WithTx(ctx context.Context, tx *gorm.DB) context.Context {
if tx == nil {
return ctx
}
return context.WithValue(ctx, txKey{}, tx)
}
// DB returns the transaction carried by ctx (WithTx, else the admin write
// transaction of cabana.TxFromContext), else the application's database
// bound to ctx.
func (s *Service) DB(ctx context.Context) (*gorm.DB, error) {
if tx, ok := ctx.Value(txKey{}).(*gorm.DB); ok && tx != nil {
return tx, nil
}
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
return tx, nil
}
@@ -71,6 +87,19 @@ func (s *Service) Registry() *signpost.Registry {
return reg
}
// GormDB returns the application's database without a transaction, the
// handle lagoon.Transaction opens a write on.
func (s *Service) GormDB() (*gorm.DB, error) {
if s == nil || s.app == nil {
return nil, ErrNotBooted
}
db, ok := s.app.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("golem15.seo: database is not configured")
}
return db, nil
}
// Config returns the application config, nil when there is none.
func (s *Service) Config() *compass.Config {
if s == nil || s.app == nil {

185
controllers/api/auth.go Normal file
View File

@@ -0,0 +1,185 @@
package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
// PermissionManage is the one SEO permission every management route
// requires (D-15).
const PermissionManage = "golem15.seo.manage"
// tokenCover is the scope a personal API token must cover.
const tokenCover = "golem15.seo.*"
// maxBody caps a management request body; an import has its own cap.
const maxBody = 1 << 20
// The answers of the management API.
const (
errAuthenticationRequired = "Authentication required"
errInsufficientPerms = "Insufficient permissions"
errNotFound = "Not Found"
errInternal = "Internal Server Error"
errInvalidBody = "The request body is invalid."
)
// apiError is an error a management handler answers with its own status
// and body.
type apiError struct {
Status int
Body any
}
func (e *apiError) Error() string { return http.StatusText(e.Status) }
// Fail answers status with {"error": message}.
func Fail(status int, message string) error {
return &apiError{Status: status, Body: map[string]any{"error": message}}
}
// handler is a management endpoint: it returns the status and the body to
// answer, or an error the wrapper maps.
type handler func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error)
// manage wraps a management endpoint (D-10, D-15, P-24): it resolves the
// backend administrator from a backend JWT, a personal API token or the
// in-process MCP dispatch marker (cabana.ResolveBackend, so an MCP call runs
// as the linked administrator), refuses 401 without one and 403 without
// golem15.seo.manage, and maps the service's typed refusals: a redirect
// loop is 422 {"error":"redirect_loop","path":[...]}, field problems 422
// {"error":"validation_failed","errors":{...}}, a missing row 404. Every
// answer carries X-Robots-Tag: noindex.
func manage(service func() *classes.Service, h handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex")
svc := service()
if svc == nil {
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "SEO is not available"})
return
}
out, principal, err := cabana.ResolveBackend(svc.App(), r, tokenCover)
if err != nil || principal == nil {
if errors.Is(err, cabana.ErrTokenScope) {
wire.WriteJSON(w, http.StatusForbidden, map[string]string{"error": errInsufficientPerms})
return
}
wire.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": errAuthenticationRequired})
return
}
if !cabana.Allows(principal, []string{PermissionManage}) {
wire.WriteJSON(w, http.StatusForbidden, map[string]string{"error": errInsufficientPerms})
return
}
status, body, err := h(out.Context(), svc, out)
if err != nil {
writeError(w, out.Context(), svc, err)
return
}
if status == http.StatusNoContent {
w.WriteHeader(status)
return
}
wire.WriteJSON(w, status, body)
}
}
// writeError maps a handler error to its answer.
func writeError(w http.ResponseWriter, ctx context.Context, svc *classes.Service, err error) {
var api *apiError
var loop *classes.LoopError
var problems *classes.ProblemsError
switch {
case errors.As(err, &api):
wire.WriteJSON(w, api.Status, api.Body)
case errors.As(err, &loop):
wire.WriteJSON(w, http.StatusUnprocessableEntity, LoopBody(ctx, svc, loop))
case errors.As(err, &problems):
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "validation_failed",
"errors": ProblemMessages(ctx, svc, problems),
})
case errors.Is(err, classes.ErrBadURL):
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"error": err.Error()})
case errors.Is(err, classes.ErrNoRecord), errors.Is(err, gorm.ErrRecordNotFound):
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": errNotFound})
default:
log.Error("golem15.seo: management API", "error", err)
wire.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": errInternal})
}
}
// LoopBody is the answer of a refused redirect loop (D-19): the error
// code, the loop's path and the message in the request locale.
func LoopBody(ctx context.Context, svc *classes.Service, loop *classes.LoopError) map[string]any {
return map[string]any{
"error": "redirect_loop",
"path": loop.Check.Path,
"message": svc.LoopMessage(ctx, loop.Check),
}
}
// ProblemMessages translates field problems in the request locale.
func ProblemMessages(ctx context.Context, svc *classes.Service, problems *classes.ProblemsError) map[string][]string {
out := map[string][]string{}
for field, list := range problems.Problems {
for _, p := range list {
out[field] = append(out[field], svc.PhraseCtx(ctx, p.Key, p.Params))
}
}
return out
}
// decode reads a JSON body of at most limit bytes into v; an empty body
// leaves v as it is.
func decode(r *http.Request, limit int64, v any) error {
if r.Body == nil {
return nil
}
dec := json.NewDecoder(io.LimitReader(r.Body, limit+1))
if err := dec.Decode(v); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return Fail(http.StatusUnprocessableEntity, errInvalidBody)
}
return nil
}
// pathID reads the {name} segment as a positive id.
func pathID(r *http.Request, name string) (uint, error) {
n, err := strconv.ParseUint(strings.TrimSpace(r.PathValue(name)), 10, 64)
if err != nil || n == 0 {
return 0, Fail(http.StatusNotFound, errNotFound)
}
return uint(n), nil
}
// write runs fn in one transaction on the application's database, with
// the transaction on ctx (classes.WithTx) so every read of the service
// sees the write.
func write(ctx context.Context, svc *classes.Service, fn func(ctx context.Context, tx *gorm.DB) error) error {
gdb, err := svc.GormDB()
if err != nil {
return err
}
return lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
return fn(classes.WithTx(ctx, tx), tx)
})
}
// read returns the database for a read handler.
func read(ctx context.Context, svc *classes.Service) (*gorm.DB, error) {
return svc.DB(ctx)
}

71
controllers/api/seo.go Normal file
View File

@@ -0,0 +1,71 @@
package api
import (
"context"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"gorm.io/gorm"
)
// SEOValues are the SEO values of a URL in a request: a value that is
// absent is left as it is, an empty one inherits.
type SEOValues struct {
Title *string `json:"title,omitempty" jsonschema:"the SEO title"`
Description *string `json:"description,omitempty" jsonschema:"the meta description"`
Keyphrase *string `json:"keyphrase,omitempty" jsonschema:"the focus keyphrase (records only)"`
Canonical *string `json:"canonical,omitempty" jsonschema:"the canonical URL: a path or an absolute http(s) URL"`
Robots *string `json:"robots,omitempty" jsonschema:"robots directives, for example noindex,follow"`
OGTitle *string `json:"og_title,omitempty" jsonschema:"the social title"`
OGDescription *string `json:"og_description,omitempty" jsonschema:"the social description"`
OGImage *string `json:"og_image,omitempty" jsonschema:"the social image: a path or an absolute URL"`
}
// Map returns the values that are set, keyed by column.
func (v SEOValues) Map() map[string]string {
out := map[string]string{}
for column, p := range map[string]*string{
"title": v.Title, "description": v.Description, "keyphrase": v.Keyphrase,
"canonical": v.Canonical, "robots": v.Robots, "og_title": v.OGTitle,
"og_description": v.OGDescription, "og_image": v.OGImage,
} {
if p != nil {
out[column] = *p
}
}
return out
}
// SetSEOInput is the body of PUT /_seo/api/v1/seo.
type SetSEOInput struct {
URL string `json:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"`
Locale string `json:"locale,omitempty" jsonschema:"the locale of a record's SEO; the locale the URL matches when omitted"`
Values SEOValues `json:"values" jsonschema:"the values to change"`
ForceRule bool `json:"force_rule,omitempty" jsonschema:"write a URL rule even when a record is behind the URL"`
}
// SetSEO serves PUT /_seo/api/v1/seo (D-11, P-25): the values go to the
// record behind the URL in its locale, else to an exact URL rule (created
// when missing), or to a rule when force_rule is set. It answers 200 with
// where the change was written ({"written_to":"record"|"rule", "type",
// "id", "locale", "rule_id"}) and the stored values; 422 for a URL that is
// not a path or http(s) URL or for invalid values. History records the
// write with via mcp when it comes through MCP.
func SetSEO(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var in SetSEOInput
if err := decode(r, maxBody, &in); err != nil {
return 0, nil, err
}
var res classes.URLSEOResult
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
res, err = svc.SetURLSEO(ctx, tx, classes.URLSEOWrite{URL: in.URL, Locale: in.Locale, Values: in.Values.Map(), ForceRule: in.ForceRule})
return err
})
if err != nil {
return 0, nil, err
}
return http.StatusOK, res, nil
})
}

View File

@@ -290,3 +290,7 @@ bots:
search_comment: "AI search and answers. Off blocks it in robots.txt."
training_comment: "Collects content for AI training. Off blocks it in robots.txt."
user_comment: "Fetches a page when a user asks for it. Off blocks it in robots.txt."
api:
field_unknown: This is not an SEO field.
keyphrase_record_only: A focus keyphrase belongs to a record; a URL rule has none.
canonical_invalid: The canonical URL must be a path starting with / or an absolute http(s) URL.

View File

@@ -300,3 +300,7 @@ bots:
search_comment: "Wyszukiwanie i odpowiedzi AI. Wyłączenie blokuje go w robots.txt."
training_comment: "Zbiera treści do trenowania AI. Wyłączenie blokuje go w robots.txt."
user_comment: "Pobiera stronę na prośbę użytkownika. Wyłączenie blokuje go w robots.txt."
api:
field_unknown: To nie jest pole SEO.
keyphrase_record_only: Fraza kluczowa należy do rekordu; reguła adresu jej nie ma.
canonical_invalid: Adres kanoniczny musi być ścieżką zaczynającą się od / lub pełnym adresem http(s).

38
mcp.go Normal file
View File

@@ -0,0 +1,38 @@
package seo
import (
"net/http"
apicontrollers "git.golem15.com/golem15/sm-seo-plugin/controllers/api"
"git.golem15.com/golem15/summercms/modules/pact"
)
var _ pact.HasMCP = (*Plugin)(nil)
// mcpGuard is the guard every SEO tool runs as: the backend account linked
// to the MCP user. The routes resolve that administrator themselves.
const mcpGuard = "backend"
// mcpPermission is the one SEO permission (D-15) every tool is listed with;
// the routes check it on every call.
const mcpPermission = apicontrollers.PermissionManage
// MCP declares the SEO tools (D-10, P-24): one per operation of the SEO
// admin, each bound to a /_seo/api/v1 management route, run as the backend
// account linked to the MCP user and listed only when that account has
// golem15.seo.manage. pier prefixes them with seo_. GET tools are
// read-only, so a read grant sees only those.
func (p *Plugin) MCP() pact.MCP {
return pact.MCP{Tools: []pact.MCPTool{
{
Name: "set_url_seo",
Title: "Set the SEO of a URL",
Description: "Changes the SEO values of a page addressed by URL. A record behind the URL gets the change in its SEO tab in the URL's locale; otherwise, or with force_rule, an exact URL rule is written. The answer says where it was written.",
Guard: mcpGuard,
Method: http.MethodPut,
Path: "/_seo/api/v1/seo",
Input: apicontrollers.SetSEOInput{},
Permissions: []string{mcpPermission},
},
}}
}

148
mcp_test.go Normal file
View File

@@ -0,0 +1,148 @@
package seo_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// principal resolves the backend administrator of an access token, the
// identity an MCP session acts as.
func (e *testEnv) principal(t *testing.T, token string) *bouncer.Principal {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+token)
_, p, err := cabana.ResolveBackend(e.app, req)
if err != nil || p == nil {
t.Fatalf("resolve principal: %v", err)
}
return p
}
// mcp runs one request the way pier dispatches a tool call: in-process,
// without an Authorization header, with the backend dispatch marker.
func (e *testEnv) mcp(t *testing.T, method, path string, p *bouncer.Principal, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader)
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", seq.Add(1)%250+1)
req.Header.Set("Accept-Language", "en")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p}))
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
// mcpExpect runs an MCP-dispatched request and fails unless it answers
// status; it returns the decoded JSON body.
func (e *testEnv) mcpExpect(t *testing.T, status int, method, path string, p *bouncer.Principal, body any) map[string]any {
t.Helper()
rec := e.mcp(t, method, path, p, body)
if rec.Code != status {
t.Fatalf("MCP %s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String())
}
out := map[string]any{}
if rec.Body.Len() > 0 && strings.HasPrefix(strings.TrimSpace(rec.Body.String()), "{") {
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
}
return out
}
// TestMCPSetURLSeoTracer is the end-to-end slice of D-10/D-11: an MCP
// client (an administrator dispatched for the backend guard) sets noindex
// on a URL a record is behind; the change lands in the record's SEO,
// resolve serves it as robots and X-Robots-Tag, and history records it
// with via mcp. A URL without a record gets an exact URL rule, and an
// administrator without golem15.seo.manage is refused.
func TestMCPSetURLSeoTracer(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
id := env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true})
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
got := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
"url": "/pages/a", "values": map[string]any{"robots": "noindex"},
})
if got["written_to"] != "record" || got["type"] != pagesTypeKey || got["id"] != id || got["locale"] != "en" {
t.Fatalf("set record = %v, want written_to record %s/%s en", got, pagesTypeKey, id)
}
res := env.resolve(t, "/pages/a")
seoBody, _ := res["seo"].(map[string]any)
if res["action"] != "ok" || !strings.HasPrefix(fmt.Sprint(seoBody["robots"]), "noindex") || !strings.HasPrefix(fmt.Sprint(seoBody["x_robots_tag"]), "noindex") {
t.Fatalf("resolve /pages/a = %v, want robots and x_robots_tag noindex", res)
}
revs := env.revisions(t, "entry")
if len(revs) != 1 || revs[0].Via != "mcp" || !strings.HasPrefix(revs[0].AuthorLabel, "MCP: ") || !strings.Contains(revs[0].After, "noindex") {
t.Fatalf("entry revisions = %+v, want one via mcp with the MCP author and noindex", revs)
}
got = env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
"url": "/landing", "values": map[string]any{"title": "Landing", "robots": "noindex,follow"},
})
ruleID, _ := got["rule_id"].(float64)
if got["written_to"] != "rule" || ruleID == 0 || got["url"] != "/landing" {
t.Fatalf("set /landing = %v, want written_to rule with a rule id", got)
}
var rules []struct {
URL, Match, Title, Robots string
}
if err := env.db.Raw(`SELECT url, match, title, robots FROM golem15_seo_rules`).Scan(&rules).Error; err != nil {
t.Fatal(err)
}
if len(rules) != 1 || rules[0].URL != "/landing" || rules[0].Match != "exact" || rules[0].Title != "Landing" {
t.Fatalf("rules = %+v, want one exact /landing rule", rules)
}
if revs := env.revisions(t, "rule"); len(revs) != 1 || revs[0].Via != "mcp" {
t.Fatalf("rule revisions = %+v, want one via mcp", revs)
}
res = env.resolve(t, "/landing")
seoBody, _ = res["seo"].(map[string]any)
if res["action"] != "ok" || seoBody["title"] != "Landing" {
t.Fatalf("resolve /landing = %v, want the rule's title", res)
}
// force_rule writes a rule even with a record behind the URL.
got = env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
"url": "/pages/a", "values": map[string]any{"title": "Forced"}, "force_rule": true,
})
if got["written_to"] != "rule" {
t.Fatalf("force_rule = %v, want written_to rule", got)
}
// Without golem15.seo.manage: 403 over MCP and over a bearer token.
plain := env.admin(t, false)
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", env.principal(t, plain), map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "index"}}); rec.Code != http.StatusForbidden {
t.Fatalf("MCP without the permission = %d %s, want 403", rec.Code, rec.Body.String())
}
if rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", plain, map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "index"}}); rec.Code != http.StatusForbidden {
t.Fatalf("bearer without the permission = %d %s, want 403", rec.Code, rec.Body.String())
}
if rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", "", map[string]any{"url": "/pages/a"}); rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous = %d, want 401", rec.Code)
}
if revs := env.revisions(t, "entry"); len(revs) != 1 {
t.Fatalf("refused writes changed history: %+v", revs)
}
}

View File

@@ -18,12 +18,17 @@ const (
BucketNotFound = "seo-notfound"
// BucketRobots is the /robots.txt bucket.
BucketRobots = "seo-robots"
// BucketAPI is the management API's bucket (P-24).
BucketAPI = "seo-api"
)
// Routes mounts GET /robots.txt at the site root and the /_seo/api/v1
// endpoints. None carries backend middleware: crawlers and the frontend
// server call them anonymously (the 404 report with its own token). Every
// /_seo/api/v1 answer carries X-Robots-Tag: noindex.
// Routes mounts GET /robots.txt at the site root, the public /_seo/api/v1
// endpoints and the management API. The public ones carry no backend
// middleware: crawlers and the frontend server call them anonymously (the
// 404 report with its own token). The management routes (bucket seo-api)
// resolve the administrator themselves from a backend token or the MCP
// dispatch and require golem15.seo.manage (D-10, D-15); each one is an MCP
// tool (mcp.go). Every /_seo/api/v1 answer carries X-Robots-Tag: noindex.
func (p *Plugin) Routes(r pact.Router) error {
r.Get("/robots.txt", apicontrollers.Robots(p.Service), "throttle:"+BucketRobots)
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
@@ -35,6 +40,10 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Get("/verification/{name}", noindex(apicontrollers.Verification(p.Service)), "throttle:"+BucketResolve)
g.Where("name", classes.VerificationNamePattern)
})
r.Group("/_seo/api/v1", nil, func(g pact.Router) {
api := "throttle:" + BucketAPI
g.Put("/seo", apicontrollers.SetSEO(p.Service), api)
})
return nil
}
@@ -49,7 +58,8 @@ func noindex(h http.HandlerFunc) http.HandlerFunc {
// Buckets registers the per-client-IP buckets behind the configured
// trusted proxies: seo-resolve 600 a minute (the frontend server resolves
// every page it renders), seo-notfound 600 a minute (one report per 404
// page) and seo-robots 120 a minute.
// page), seo-robots 120 a minute and seo-api 300 a minute (an MCP agent
// working through an audit).
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(nil)
if p.app != nil {
@@ -69,5 +79,6 @@ func (p *Plugin) Buckets() map[string]surf.Bucket {
BucketResolve: bucket(BucketResolve, 600),
BucketNotFound: bucket(BucketNotFound, 600),
BucketRobots: bucket(BucketRobots, 120),
BucketAPI: bucket(BucketAPI, 300),
}
}