Alex Dunmow 623ea2e14b feat(manifest): public_routes + sitemap manifest capability (ADR 0026)
Plugins declare site-root path claims (exact or prefix) and a sitemap
contribution flag in plugin.mod; the packer validates and stamps them
into PluginManifest. Validation rejects reserved prefixes (/admin,
/api/plugins, /ws), traversal, duplicates, and prefix claims that cover
a reserved prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 02:41:31 +08:00

185 lines
7.5 KiB
Go

package plugin
import (
"fmt"
"regexp"
"strings"
tomlpkg "github.com/BurntSushi/toml"
)
type ModFile struct {
Plugin ModPlugin `toml:"plugin"`
Compatibility *ModCompat `toml:"compatibility"`
Requires []ModRequirement `toml:"requires"`
}
type ModPlugin struct {
// Name is the lowercase identifier used for the plugin slug in URLs and DB
// lookups. The CLI normalises this on write; the registry normalises on
// create. Use DisplayName for human-readable presentation.
Name string `toml:"name"`
// DisplayName is the human-readable form (any case). Optional; if empty
// the registry falls back to the input name with its original case.
DisplayName string `toml:"display_name,omitempty"`
// Description is the short summary surfaced in the registry. Optional.
Description string `toml:"description,omitempty"`
// Scope is the plugin owner namespace as it appears in plugin.mod. It may
// include the leading "@" (e.g. "@themes") or omit it (e.g. "themes") —
// both forms are accepted. Consumers comparing scopes should trim the "@"
// before comparing; use ModFile.Coords() for a normalised display string.
Scope string `toml:"scope"`
Version string `toml:"version"`
Kind string `toml:"kind,omitempty"`
Categories []string `toml:"categories,omitempty"`
Tags []string `toml:"tags,omitempty"`
// RequiredIconPacks names icon-pack slugs the host CMS must ensure are
// installed before the plugin is loaded (e.g. "tabler", "phosphor"). The
// standalone-plugin loader honours this best-effort by auto-installing any
// missing packs from the bundled registry; slugs outside that whitelist are
// logged and skipped (admins install them manually). Empty / omitted means
// the plugin has no icon-pack dependencies.
RequiredIconPacks []string `toml:"required_icon_packs,omitempty"`
// Private marks the plugin as account-scoped. When true, Coords() returns
// the canonical "@private/<name>@<version>" form regardless of the Scope
// field, and the publish flow attributes the plugin to the publisher's
// active account rather than to a public scope.
Private bool `toml:"private,omitempty"`
// DataDir requests a persistent per-plugin /data preopen at load time. It
// is a first-class field (not an arbitrary key) precisely so the CLI's
// mod round-trip cannot silently drop it: writeMod reconstructs plugin.mod
// from struct fields only, so any key without a home here is lost on the
// next `ninja plugin init`/bump. `ninja plugin build` stamps this into the
// packed manifest (PluginManifest.data_dir); the .bnp reader also reads it
// straight from plugin.mod as a fallback. OFF by default.
DataDir bool `toml:"data_dir,omitempty"`
// AllowedHosts declares the egress host patterns the plugin needs for
// http.request (ADR 0023, cms repo): an exact hostname or a left-anchored
// multi-label wildcard (e.g. "*.cal.com"), each https/443 unless the entry
// names an explicit port. First-class for the same reason as DataDir — the
// writeMod round-trip drops any key without a struct home. `ninja plugin
// build` validates the patterns and stamps them into the manifest
// (PluginManifest.allowed_hosts). Empty means no egress.
AllowedHosts []string `toml:"allowed_hosts,omitempty"`
// MaxResponseMB requests a larger per-request http.request response cap
// than the 10 MB default. Rides the same admin grant as AllowedHosts. Zero
// means the default.
MaxResponseMB uint32 `toml:"max_response_mb,omitempty"`
// PublicRoutes declares site-root paths the plugin serves through its
// HTTP handler (ADR 0026, cms repo): exact paths and path prefixes the
// host mounts at the public site root. First-class for the same reason as
// DataDir and AllowedHosts: the writeMod round-trip drops any key without
// a struct home, and a dropped route claim silently unmounts live SEO
// URLs on the next publish. `ninja plugin build` validates the claims
// (ValidatePublicRoutes) and stamps them into the manifest
// (PluginManifest.public_routes). Empty means the plugin serves HTTP only
// under /api/plugins/<name>.
PublicRoutes []ModPublicRoute `toml:"public_routes,omitempty"`
// Sitemap requests sitemap contribution: when true the host fetches
// entries from the plugin's well-known sitemap endpoint and merges them
// into the site sitemap. Stamped into the manifest alongside
// PublicRoutes; requires an HTTP handler.
Sitemap bool `toml:"sitemap,omitempty"`
}
// ModPublicRoute is one site-root path claim in plugin.mod, e.g.
// { path = "/area", prefix = true }.
type ModPublicRoute struct {
Path string `toml:"path"`
Prefix bool `toml:"prefix,omitempty"`
}
type ModCompat struct {
BlockCore string `toml:"block_core"`
}
type ModRequirement struct {
Name string `toml:"name"`
Version string `toml:"version"`
}
func ParseModFull(b []byte) (*ModFile, error) {
var m ModFile
if err := tomlpkg.Unmarshal(b, &m); err != nil {
return nil, err
}
return &m, nil
}
// Coords returns the canonical display coordinate for the plugin in the form
// "@scope/name@version" (or "name@version" when no scope is set).
//
// The leading "@" on m.Plugin.Scope is intentionally trimmed before
// re-prefixing so that authors may write either "@themes" or "themes" in
// plugin.mod and get the same output. Callers that need the raw scope as
// written should read m.Plugin.Scope directly.
func (m *ModFile) Coords() string {
if m == nil {
return ""
}
if m.Plugin.Private {
return "@" + PrivateScopeSlug + "/" + m.Plugin.Name + "@" + m.Plugin.Version
}
scope := strings.TrimPrefix(m.Plugin.Scope, "@")
if scope == "" {
return m.Plugin.Name + "@" + m.Plugin.Version
}
return "@" + scope + "/" + m.Plugin.Name + "@" + m.Plugin.Version
}
// PrivateScopeSlug is the registry namespace under which all private plugins
// live. Coords for private plugins resolve to "@private/<name>@<version>";
// uniqueness is enforced by (owner_account_id, name), not by the slug.
const PrivateScopeSlug = "private"
const (
TagMinLen = 2
TagMaxLen = 30
TagMaxCount = 10
)
// tagSlugRe matches lowercase a-z, 0-9, with single hyphens between groups.
// Rejects leading/trailing/consecutive hyphens.
var tagSlugRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
// NormalizeTags trims, lowercases, dedupes (case-insensitively), validates,
// and caps a slice of tags. Returns the cleaned slice or an error listing
// every offending input so authors fix them in one pass.
//
// Rules:
// - trim surrounding whitespace; drop empty entries silently
// - lowercase
// - require [a-z0-9-]{TagMinLen..TagMaxLen}, no leading/trailing/consecutive hyphens
// - dedupe case-insensitively, preserving first occurrence order
// - at most TagMaxCount entries (counted after dedupe)
func NormalizeTags(in []string) ([]string, error) {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
var bad []string
for _, raw := range in {
t := strings.ToLower(strings.TrimSpace(raw))
if t == "" {
continue
}
if _, dup := seen[t]; dup {
continue
}
if len(t) < TagMinLen || len(t) > TagMaxLen || !tagSlugRe.MatchString(t) {
bad = append(bad, raw)
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
if len(bad) > 0 {
return nil, fmt.Errorf(
"invalid tags (must be %d-%d chars, lowercase a-z 0-9 and single hyphens, no leading/trailing hyphen): %s",
TagMinLen, TagMaxLen, strings.Join(bad, ", "),
)
}
if len(out) > TagMaxCount {
return nil, fmt.Errorf("too many tags: got %d, max %d", len(out), TagMaxCount)
}
return out, nil
}