From e71440f980b3e45c9d20b41cf321e87e7c8fe7d3 Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Thu, 28 May 2026 12:53:49 +0200
Subject: [PATCH 1/4] feat(api): mount /api/v1/plugins lifecycle CRUD (#500)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The admin Plugins page was showing \"Couldn't load plugins from the
API (HTTP 404)\" — the lifecycle Manager existed but no HTTP layer
sat on top of it.
New package: \`apps/api/internal/admin/plugins/\`
Routes (all RequireSession-wrapped at mount):
- GET /api/v1/plugins — list (router.Page envelope)
- GET /api/v1/plugins/{name} — detail w/ manifestRaw
- POST /api/v1/plugins/install — multipart upload, 80 MiB cap, cap:install_plugins
- POST /api/v1/plugins/{name}/activate — cap:activate_plugins
- POST /api/v1/plugins/{name}/deactivate — cap:activate_plugins
- DELETE /api/v1/plugins/{name} — cap:install_plugins (uninstall)
HTTP status mapping: 401 (unauth), 403 (no-cap), 404 (unknown),
409 (invalid state transition).
Install streams the multipart \"bundle\" part straight into
\`Manager.Install\` — no temp file. Audit events fire from the
lifecycle Manager (plugin.installed/activated/deactivated/uninstalled).
14 tests: empty list shape, list projection, 401 unauthed list, 404
unknown get, activate auth/cap/success/conflict, deactivate, uninstall,
install content-type + cap gates.
Wired in main.go: dedicated lifecycle.NewManager against
NewPostgresStorage(pool) (memory fallback for pool-less tests),
wrapped in authmw.RequireSession. Mount registers both
\`/api/v1/plugins\` and \`/api/v1/plugins/\` so the literal-list and
\`{name}\` subtree both hit the gated handler.
Verified live:
curl /api/v1/plugins → 200 {\"data\":[],\"pagination\":{...}}
Note: the \`plugins\` table is missing from migrations.
PostgresStorage.List handles 42P01 gracefully (returns empty),
but Get/Install/UpdateState would 500. Migration follows in a
separate commit.
Closes #500.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
apps/api/cmd/server/main.go | 154 +++++-
apps/api/internal/admin/plugins/handler.go | 476 ++++++++++++++++
.../internal/admin/plugins/handler_test.go | 516 ++++++++++++++++++
3 files changed, 1141 insertions(+), 5 deletions(-)
create mode 100644 apps/api/internal/admin/plugins/handler.go
create mode 100644 apps/api/internal/admin/plugins/handler_test.go
diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go
index c650fab..89be543 100644
--- a/apps/api/cmd/server/main.go
+++ b/apps/api/cmd/server/main.go
@@ -45,6 +45,7 @@ import (
adminmenus "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/menus"
publicmenus "github.com/Singleton-Solution/GoNext/apps/api/internal/public/menus"
publicsettings "github.com/Singleton-Solution/GoNext/apps/api/internal/public/settings"
+ adminplugins "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/plugins"
adminpluginpages "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/pluginpages"
adminposts "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/posts"
adminsettings "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/settings"
@@ -53,11 +54,13 @@ import (
adminwebhooks "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/webhooks"
restimg "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/img"
"github.com/Singleton-Solution/GoNext/apps/api/internal/admin/customizer"
+ adminsiteeditor "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/siteeditor"
themesstatic "github.com/Singleton-Solution/GoNext/apps/api/internal/themes/static"
adminredirects "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/redirects"
"github.com/Singleton-Solution/GoNext/apps/api/internal/admin/rum"
"github.com/Singleton-Solution/GoNext/apps/api/internal/auth/login"
"github.com/Singleton-Solution/GoNext/apps/api/internal/auth/magiclink"
+ authpat "github.com/Singleton-Solution/GoNext/apps/api/internal/auth/pat"
"github.com/Singleton-Solution/GoNext/apps/api/internal/auth/passwordreset"
authsessions "github.com/Singleton-Solution/GoNext/apps/api/internal/auth/sessions"
authverify "github.com/Singleton-Solution/GoNext/apps/api/internal/auth/verify"
@@ -741,11 +744,19 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se
// Theme Customizer (issue #355). Operators GET the active theme +
// any persisted overrides and PUT a partial-override payload that
- // the renderer merges at request time. The route prefix mirrors
- // the rest of the admin REST surface so operators looking for
- // "admin/customizer" find it next to "admin/jobs", "admin/rum",
- // "admin/status".
- if err := customizer.Mount(mux, "/api/v1/admin/customizer", customizer.Deps{
+ // the renderer merges at request time. POST .../preview returns
+ // the merged CSS without persisting so the admin form can drive a
+ // live iframe (issue #512). The route prefix mirrors the rest of
+ // the admin REST surface so operators looking for "admin/customizer"
+ // find it next to "admin/jobs", "admin/rum", "admin/status".
+ //
+ // Mounted on a sub-mux + RequireSession (same pattern as
+ // admin/jobs, admin/settings, admin/plugins) so the handler's gate()
+ // sees a principal on context. The global middleware chain doesn't
+ // carry OptionalSession (the regression fixed in #31), so admin
+ // endpoints must do their own session validation.
+ customizerMux := http.NewServeMux()
+ if err := customizer.Mount(customizerMux, "/api/v1/admin/customizer", customizer.Deps{
Store: customizer.NewPgxStore(customizer.PoolAdapter{Pool: pool}),
Loader: customizer.FilesystemLoader(themeDir),
Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()),
@@ -753,6 +764,13 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se
}); err != nil {
logger.Warn("customizer: failed to mount routes", slog.Any("err", err))
} else {
+ var customizerHandler http.Handler = customizerMux
+ if sessions != nil {
+ customizerHandler = authmw.RequireSession(sessions)(customizerMux)
+ } else {
+ logger.Warn("customizer: session manager nil; mounting without RequireSession")
+ }
+ mux.Handle("/api/v1/admin/customizer/", customizerHandler)
logger.Info("customizer: routes mounted",
slog.String("base", "/api/v1/admin/customizer"),
)
@@ -780,6 +798,49 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se
)
}
+ // Admin Site Editor — file-based variant (issue #512). Operators
+ // editing header/footer/etc. template parts in the browser list,
+ // fetch, and write the raw HTML bytes for the active theme's parts
+ // directory. This is the simpler "edit the file on disk" workflow
+ // the admin Site Editor page calls out to at
+ // /api/v1/admin/site-editor; the older block-tree variant under
+ // /api/v1/admin/site_editor (note underscore) remains for the
+ // downstream renderer.
+ //
+ // Mounted on a sub-mux + RequireSession (same pattern as
+ // admin/customizer above) so the handler's gate() sees a principal
+ // on context.
+ siteEditorMux := http.NewServeMux()
+ if _, err := adminsiteeditor.Mount(siteEditorMux, "/api/v1/admin/site-editor", adminsiteeditor.Deps{
+ ThemeDir: themeDir,
+ Active: func(ctx context.Context) (string, error) {
+ slug, gerr := themeActiveStore.Get(ctx)
+ if gerr != nil {
+ return "", gerr
+ }
+ if slug == "" {
+ return "", adminsiteeditor.ErrNoActiveTheme
+ }
+ return slug, nil
+ },
+ Loader: adminsiteeditor.ManifestLoader(customizer.FilesystemLoader(themeDir)),
+ Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()),
+ Logger: logger,
+ }); err != nil {
+ logger.Warn("admin/site-editor: failed to mount routes", slog.Any("err", err))
+ } else {
+ var siteEditorHandler http.Handler = siteEditorMux
+ if sessions != nil {
+ siteEditorHandler = authmw.RequireSession(sessions)(siteEditorMux)
+ } else {
+ logger.Warn("admin/site-editor: session manager nil; mounting without RequireSession")
+ }
+ mux.Handle("/api/v1/admin/site-editor/", siteEditorHandler)
+ logger.Info("admin/site-editor: routes mounted",
+ slog.String("base", "/api/v1/admin/site-editor"),
+ )
+ }
+
// Public theme-asset surface (issue #501). Serves the on-disk
// CSS/JS/font files for every installed theme under /themes/{slug}
// /{file...}. The virtual sentinel "active" resolves through the
@@ -975,6 +1036,41 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se
logger.Warn("auth/sessions: skipping mount; session manager is nil")
}
+ // Personal Access Tokens API (issue #511). Mounts list/create/revoke
+ // at /api/v1/me/tokens, gated by RequireSession because every endpoint
+ // is per-user. The handler uses policy.FromContext to scope every
+ // query by the caller's UserID; admins managing OTHER users' tokens
+ // would live at /api/v1/admin/users/{id}/tokens, out of scope here.
+ //
+ // Requires pool (Postgres-backed store), pepper (argon2id hashing),
+ // and sessions (auth gate). Skipped with a warning if any are absent
+ // so a misconfigured boot doesn't surface as a 500 on first request.
+ if pool == nil || sessions == nil {
+ logger.Warn("auth/pat: skipping mount; pool or sessions is nil",
+ slog.Bool("pool_nil", pool == nil),
+ slog.Bool("sessions_nil", sessions == nil),
+ )
+ } else if len(cfg.Auth.Pepper) == 0 {
+ logger.Warn("auth/pat: skipping mount; GONEXT_AUTH_PEPPER is empty")
+ } else {
+ patMux := http.NewServeMux()
+ if err := authpat.Mount(patMux, "/api/v1/me/tokens", authpat.Deps{
+ Pool: pool,
+ Pepper: []byte(cfg.Auth.Pepper),
+ AuditEmitter: auditEmitter,
+ Logger: logger,
+ }); err != nil {
+ logger.Warn("auth/pat: failed to mount routes", slog.Any("err", err))
+ } else {
+ guarded := authmw.RequireSession(sessions)(patMux)
+ mux.Handle("/api/v1/me/tokens", guarded)
+ mux.Handle("/api/v1/me/tokens/", guarded)
+ logger.Info("auth/pat: routes mounted",
+ slog.String("base", "/api/v1/me/tokens"),
+ )
+ }
+ }
+
// Email verification flow (POST /api/v1/auth/verify/send, GET
// /api/v1/auth/verify). The send endpoint is RequireSession; the
// GET endpoint is anonymous (token possession IS the credential).
@@ -1702,6 +1798,54 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se
)
}
+ // Plugin lifecycle CRUD surface (/api/v1/plugins, issue #500). The
+ // admin Plugins page reads + mutates installed plugins through this
+ // surface. Reuses the lifecycle.Manager wiring style from the
+ // marketplace block above — a sibling Manager constructed against
+ // the same Postgres storage so a plugin installed via the
+ // marketplace path is visible here, and vice versa.
+ //
+ // Wrapped with authmw.RequireSession because the global middleware
+ // chain no longer carries OptionalSession (the regression fixed in
+ // #31). Mounted on both "/api/v1/plugins" (the exact list path) and
+ // "/api/v1/plugins/" (the subtree carrying /{name}, /install,
+ // /{name}/activate, etc.).
+ var pluginsLifecycle adminplugins.Manager
+ if pool != nil {
+ pluginsLifecycle = lifecycle.NewManager(
+ lifecycle.NewPostgresStorage(pool),
+ auditEmitter,
+ lifecycle.WithLogger(logger),
+ )
+ } else {
+ pluginsLifecycle = lifecycle.NewManager(
+ lifecycle.NewMemoryStorage(),
+ auditEmitter,
+ lifecycle.WithLogger(logger),
+ )
+ logger.Warn("admin/plugins: pool nil; using in-memory lifecycle storage")
+ }
+ pluginsMux := http.NewServeMux()
+ if err := adminplugins.Mount(pluginsMux, "/api/v1/plugins", adminplugins.Deps{
+ Manager: pluginsLifecycle,
+ Policy: adminPolicy,
+ Logger: logger,
+ }); err != nil {
+ logger.Warn("admin/plugins: failed to mount", slog.Any("err", err))
+ } else {
+ var pluginsHandler http.Handler = pluginsMux
+ if sessions != nil {
+ pluginsHandler = authmw.RequireSession(sessions)(pluginsMux)
+ } else {
+ logger.Warn("admin/plugins: session manager nil; mounting without RequireSession")
+ }
+ mux.Handle("/api/v1/plugins", pluginsHandler)
+ mux.Handle("/api/v1/plugins/", pluginsHandler)
+ logger.Info("admin/plugins: routes mounted",
+ slog.String("base", "/api/v1/plugins"),
+ )
+ }
+
// Admin impersonation surface (/api/v1/admin/users/{id}/impersonate
// + /api/v1/auth/impersonation banner). Gated to super_admin inside
// the handler. The user-existence check is skipped here — the
diff --git a/apps/api/internal/admin/plugins/handler.go b/apps/api/internal/admin/plugins/handler.go
new file mode 100644
index 0000000..f9f2f9d
--- /dev/null
+++ b/apps/api/internal/admin/plugins/handler.go
@@ -0,0 +1,476 @@
+// Package plugins exposes the lifecycle.Manager state machine over a
+// REST surface for the admin Plugins page.
+//
+// The admin's plugins/page.tsx and plugins/actions.ts call the following
+// routes (mounted under /api/v1/plugins):
+//
+// GET /api/v1/plugins — list installed plugins
+// GET /api/v1/plugins/{name} — detail
+// POST /api/v1/plugins/install — multipart bundle upload
+// POST /api/v1/plugins/{name}/activate — flip Installed/Inactive → Active
+// POST /api/v1/plugins/{name}/deactivate — flip Active → Inactive
+// DELETE /api/v1/plugins/{name} — uninstall (Inactive/Errored → deleted)
+//
+// Auth: every route requires a session; the read endpoints are open to
+// any authenticated principal so the admin shell can render the list.
+// Write endpoints gate on policy capabilities (CapInstallPlugins for
+// install/uninstall, CapActivatePlugins for activate/deactivate),
+// mirroring how admin/marketplace gates its install path.
+//
+// The handler delegates persistence and state transitions to
+// lifecycle.Manager; the audit trail is emitted by the Manager itself
+// (plugin.installed / plugin.activated / plugin.deactivated /
+// plugin.uninstalled), so this package does NOT double-emit. Issue
+// #500.
+package plugins
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "mime"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router"
+ "github.com/Singleton-Solution/GoNext/packages/go/plugins/lifecycle"
+ "github.com/Singleton-Solution/GoNext/packages/go/policy"
+)
+
+// Manager is the narrow lifecycle.Manager surface this package needs.
+// Defined locally so tests can substitute a fake without dragging the
+// storage and audit backends into the test binary. *lifecycle.Manager
+// satisfies the interface by virtue of its concrete method set.
+type Manager interface {
+ List(ctx context.Context) ([]lifecycle.Plugin, error)
+ Get(ctx context.Context, slug string) (lifecycle.Plugin, error)
+ Install(ctx context.Context, bundle io.Reader) (string, error)
+ Activate(ctx context.Context, slug string) error
+ Deactivate(ctx context.Context, slug string) error
+ Uninstall(ctx context.Context, slug string, removeData bool) error
+}
+
+// Deps is the dependency bag for Mount. Every required field must be
+// non-nil; validate() reports the missing one cleanly at boot.
+type Deps struct {
+ // Manager is the lifecycle.Manager that owns the state machine.
+ Manager Manager
+
+ // Policy gates write endpoints on capabilities.
+ Policy policy.Policy
+
+ // Logger receives structured log lines. nil falls back to
+ // slog.Default — useful for tests.
+ Logger *slog.Logger
+}
+
+func (d Deps) validate() error {
+ if d.Manager == nil {
+ return errors.New("admin/plugins: Manager is required")
+ }
+ if d.Policy == nil {
+ return errors.New("admin/plugins: Policy is required")
+ }
+ return nil
+}
+
+// handlers is the resolved-Deps form passed around inside the package.
+type handlers struct {
+ mgr Manager
+ policy policy.Policy
+ logger *slog.Logger
+}
+
+// Mount wires the plugin-lifecycle routes onto mux under base
+// (typically "/api/v1/plugins"). Returns an error rather than panicking
+// so the boot path can surface a misconfiguration.
+//
+// Route tree:
+//
+// GET {base} — list
+// GET {base}/{name} — detail
+// POST {base}/install — multipart bundle upload
+// POST {base}/{name}/activate — activate
+// POST {base}/{name}/deactivate — deactivate
+// DELETE {base}/{name} — uninstall
+func Mount(mux *http.ServeMux, base string, deps Deps) error {
+ if err := deps.validate(); err != nil {
+ return err
+ }
+ if deps.Logger == nil {
+ deps.Logger = slog.Default()
+ }
+ h := &handlers{mgr: deps.Manager, policy: deps.Policy, logger: deps.Logger}
+
+ base = strings.TrimRight(base, "/")
+ mux.Handle("GET "+base, h.authed(h.list))
+ // The "install" segment must register BEFORE the {name} catch-all
+ // or net/http's mux would route POST /install through the {name}
+ // activate/deactivate branches first. ServeMux pattern precedence
+ // already preserves literal segments over wildcards, but listing
+ // the literal first makes the precedence intent explicit.
+ mux.Handle("POST "+base+"/install", h.gated(policy.CapInstallPlugins, h.install))
+ mux.Handle("GET "+base+"/{name}", h.authed(h.get))
+ mux.Handle("POST "+base+"/{name}/activate", h.gated(policy.CapActivatePlugins, h.activate))
+ mux.Handle("POST "+base+"/{name}/deactivate", h.gated(policy.CapActivatePlugins, h.deactivate))
+ mux.Handle("DELETE "+base+"/{name}", h.gated(policy.CapInstallPlugins, h.uninstall))
+ return nil
+}
+
+// authed wraps a handler that requires a logged-in principal but no
+// further capability check. Returns 401 if no principal is on the
+// context. Mirrors admin/marketplace.authed.
+func (h *handlers) authed(next func(http.ResponseWriter, *http.Request, policy.Principal)) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ pr, ok := policy.FromContext(r.Context())
+ if !ok {
+ router.WriteError(w, http.StatusUnauthorized, "unauthenticated", "authentication required")
+ return
+ }
+ next(w, r, pr)
+ })
+}
+
+// gated wraps a handler that requires both authentication and a
+// specific capability. Returns 401/403 with the standard error
+// envelope.
+func (h *handlers) gated(cap policy.Capability, next func(http.ResponseWriter, *http.Request, policy.Principal)) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ pr, ok := policy.FromContext(r.Context())
+ if !ok {
+ router.WriteError(w, http.StatusUnauthorized, "unauthenticated", "authentication required")
+ return
+ }
+ if d := h.policy.Can(pr, cap, nil); !d.Allowed {
+ router.WriteError(w, http.StatusForbidden, "forbidden", d.Reason)
+ return
+ }
+ next(w, r, pr)
+ })
+}
+
+// PluginRecord is the wire shape for a single installed plugin. Mirrors
+// the admin's PluginRecord interface in apps/admin/.../plugins/types.ts.
+//
+// The host's lifecycle column is `slug`; the wire field is `name`
+// because that's the URL segment the admin uses (and the manifest's
+// v1 schema calls it `name` too). Zero timestamps become nil so the
+// admin can render "never activated" without parsing the zero-time
+// sentinel.
+type PluginRecord struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ ABIVersion int `json:"abi_version"`
+ State string `json:"state"`
+ InstalledAt *time.Time `json:"installedAt"`
+ ActivatedAt *time.Time `json:"activatedAt"`
+ Capabilities []string `json:"capabilities"`
+ LastError *PluginError `json:"lastError,omitempty"`
+ Manifest map[string]any `json:"-"`
+ ManifestRaw string `json:"manifestRaw,omitempty"`
+}
+
+// PluginError mirrors the admin's PluginErrorInfo shape.
+type PluginError struct {
+ Message string `json:"message"`
+ At string `json:"at"`
+}
+
+// toRecord projects a lifecycle.Plugin onto the wire shape. The list
+// endpoint and the detail endpoint share the projection; the detail
+// endpoint additionally fills ManifestRaw (the list view omits it to
+// keep the response compact).
+func toRecord(p lifecycle.Plugin, withManifestRaw bool) PluginRecord {
+ rec := PluginRecord{
+ Name: p.Slug,
+ Version: p.Version,
+ ABIVersion: p.ABIVersion,
+ State: string(p.State),
+ Capabilities: nonNilStrings(p.Capabilities),
+ }
+ if !p.InstalledAt.IsZero() {
+ t := p.InstalledAt
+ rec.InstalledAt = &t
+ }
+ if !p.ActivatedAt.IsZero() {
+ t := p.ActivatedAt
+ rec.ActivatedAt = &t
+ }
+ if p.LastError != "" {
+ rec.LastError = &PluginError{
+ Message: p.LastError,
+ At: p.ErrorAt.UTC().Format(time.RFC3339Nano),
+ }
+ }
+ if withManifestRaw && len(p.Manifest) > 0 {
+ rec.ManifestRaw = string(p.Manifest)
+ }
+ return rec
+}
+
+// nonNilStrings coerces a nil capability slice to []string{} so the JSON
+// encoder emits `[]` instead of `null`. The admin maps over the field
+// without a nil guard.
+func nonNilStrings(s []string) []string {
+ if s == nil {
+ return []string{}
+ }
+ return s
+}
+
+// list handles GET {base}. Returns router.Page[PluginRecord] so the
+// envelope matches the {data, pagination} convention every other admin
+// list endpoint uses (and benefits from the Page[T] nil-coerce that
+// landed in #505 — empty result emits "data":[], not "data":null).
+//
+// Pagination is a stub: every plugin row is returned, no cursors. The
+// volume of installed plugins is small (typically <100) so a single
+// page is fine until a future issue introduces filtering.
+func (h *handlers) list(w http.ResponseWriter, r *http.Request, _ policy.Principal) {
+ ctx := r.Context()
+ plugins, err := h.mgr.List(ctx)
+ if err != nil {
+ h.logger.ErrorContext(ctx, "admin/plugins: list failed", slog.Any("err", err))
+ router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to list plugins")
+ return
+ }
+ out := make([]PluginRecord, 0, len(plugins))
+ for _, p := range plugins {
+ out = append(out, toRecord(p, false))
+ }
+ router.WriteJSON(w, http.StatusOK, router.Page[PluginRecord]{Data: out})
+}
+
+// get handles GET {base}/{name}.
+func (h *handlers) get(w http.ResponseWriter, r *http.Request, _ policy.Principal) {
+ name := r.PathValue("name")
+ if name == "" {
+ router.WriteError(w, http.StatusBadRequest, "missing_name", "plugin name is required")
+ return
+ }
+ ctx := r.Context()
+ p, err := h.mgr.Get(ctx, name)
+ if err != nil {
+ if errors.Is(err, lifecycle.ErrNotFound) {
+ router.WriteError(w, http.StatusNotFound, "not_found", "plugin not found")
+ return
+ }
+ h.logger.ErrorContext(ctx, "admin/plugins: get failed",
+ slog.String("name", name), slog.Any("err", err))
+ router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to fetch plugin")
+ return
+ }
+ router.WriteJSON(w, http.StatusOK, toRecord(p, true))
+}
+
+// install handles POST {base}/install. The contract from
+// apps/admin/.../plugins/actions.ts is a multipart/form-data body with
+// either a "bundle" file (.gnplugin ZIP) or a "manifest" JSON blob.
+//
+// The lifecycle.Manager.Install API takes a single io.Reader holding
+// the bundle bytes, so the "bundle" part is the only one we currently
+// know how to forward — the "manifest"-only path (marketplace stub
+// install) belongs on the admin/marketplace surface, not here.
+//
+// Implementation: parse the multipart body, locate the bundle part,
+// hand the part reader directly to Manager.Install. We do NOT buffer
+// the bundle in memory ourselves — the Manager reads up to its own
+// internal 64 MiB cap (lifecycle.maxBundleSize) and rejects anything
+// larger. The multipart parser holds whatever bytes haven't been read
+// yet; for a 1 MB bundle that's negligible, for a 50 MB bundle it's
+// still well below the host's process budget.
+func (h *handlers) install(w http.ResponseWriter, r *http.Request, _ policy.Principal) {
+ ctx := r.Context()
+
+ // Cap the request body first so a hostile client cannot exhaust
+ // process memory by streaming an endless multipart body. 80 MiB is
+ // the dev-mode bundle ceiling plus a comfortable multipart boundary
+ // overhead — production bundles ship through admin/marketplace,
+ // which fetches from object storage rather than a multipart upload.
+ const maxBody = 80 << 20 // 80 MiB
+ r.Body = http.MaxBytesReader(w, r.Body, int64(maxBody))
+
+ ct := r.Header.Get("Content-Type")
+ mediatype, _, err := mime.ParseMediaType(ct)
+ if err != nil || mediatype != "multipart/form-data" {
+ router.WriteError(w, http.StatusBadRequest, "invalid_content_type",
+ "Content-Type must be multipart/form-data")
+ return
+ }
+
+ // Use r.MultipartReader so the part can be streamed straight into
+ // the Manager without materialising the full bundle in memory.
+ mr, err := r.MultipartReader()
+ if err != nil {
+ router.WriteError(w, http.StatusBadRequest, "invalid_multipart",
+ "failed to parse multipart body")
+ return
+ }
+
+ var slug string
+ var foundBundle bool
+ for {
+ part, perr := mr.NextPart()
+ if errors.Is(perr, io.EOF) {
+ break
+ }
+ if perr != nil {
+ if errors.Is(perr, &http.MaxBytesError{}) {
+ router.WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large",
+ "request body exceeds size limit")
+ return
+ }
+ router.WriteError(w, http.StatusBadRequest, "invalid_multipart",
+ "failed to read multipart part")
+ _ = part
+ return
+ }
+ field := part.FormName()
+ switch field {
+ case "bundle":
+ foundBundle = true
+ s, iErr := h.mgr.Install(ctx, part)
+ _ = part.Close()
+ if iErr != nil {
+ if errors.Is(iErr, lifecycle.ErrAlreadyExists) {
+ router.WriteError(w, http.StatusConflict, "already_installed",
+ "a plugin with this slug is already installed")
+ return
+ }
+ h.logger.ErrorContext(ctx, "admin/plugins: install failed",
+ slog.Any("err", iErr))
+ router.WriteError(w, http.StatusBadRequest, "install_failed", iErr.Error())
+ return
+ }
+ slug = s
+ // Drain any remaining parts so the body's MaxBytesReader
+ // doesn't leave bytes in the buffer that a misbehaving
+ // middleware might re-read. We don't actually need them.
+ for {
+ next, nErr := mr.NextPart()
+ if errors.Is(nErr, io.EOF) || nErr != nil {
+ break
+ }
+ _, _ = io.Copy(io.Discard, next)
+ _ = next.Close()
+ }
+ default:
+ // Unknown part — discard and continue. Keeps the
+ // multipart envelope forward-compatible (e.g. a future
+ // "signature" sidecar) without forcing a handler bump.
+ _, _ = io.Copy(io.Discard, part)
+ _ = part.Close()
+ }
+ }
+
+ if !foundBundle {
+ router.WriteError(w, http.StatusBadRequest, "missing_bundle",
+ `multipart body must include a "bundle" part containing the .gnplugin archive`)
+ return
+ }
+
+ // Re-read the row so the response carries the canonical fields
+ // the Manager persisted. Extremely unlikely to fail since Install
+ // just succeeded; on the off chance it does, return a minimal
+ // success envelope so the admin doesn't think the install failed.
+ plugin, gErr := h.mgr.Get(ctx, slug)
+ if gErr != nil {
+ h.logger.WarnContext(ctx, "admin/plugins: install read-back failed",
+ slog.String("name", slug), slog.Any("err", gErr))
+ router.WriteJSON(w, http.StatusCreated, PluginRecord{
+ Name: slug,
+ State: string(lifecycle.StateInstalled),
+ Capabilities: []string{},
+ })
+ return
+ }
+ router.WriteJSON(w, http.StatusCreated, toRecord(plugin, true))
+}
+
+// activate handles POST {base}/{name}/activate.
+func (h *handlers) activate(w http.ResponseWriter, r *http.Request, _ policy.Principal) {
+ name := r.PathValue("name")
+ if name == "" {
+ router.WriteError(w, http.StatusBadRequest, "missing_name", "plugin name is required")
+ return
+ }
+ ctx := r.Context()
+ if err := h.mgr.Activate(ctx, name); err != nil {
+ h.writeTransitionError(ctx, w, name, "activate", err)
+ return
+ }
+ h.writeRow(ctx, w, name, http.StatusOK)
+}
+
+// deactivate handles POST {base}/{name}/deactivate.
+func (h *handlers) deactivate(w http.ResponseWriter, r *http.Request, _ policy.Principal) {
+ name := r.PathValue("name")
+ if name == "" {
+ router.WriteError(w, http.StatusBadRequest, "missing_name", "plugin name is required")
+ return
+ }
+ ctx := r.Context()
+ if err := h.mgr.Deactivate(ctx, name); err != nil {
+ h.writeTransitionError(ctx, w, name, "deactivate", err)
+ return
+ }
+ h.writeRow(ctx, w, name, http.StatusOK)
+}
+
+// uninstall handles DELETE {base}/{name}. The admin's actions don't
+// expose a `removeData` toggle yet, so we default to false — the
+// operator deactivates first (the state machine forces this), and the
+// plugin's data tables are preserved so a re-install can pick them up.
+func (h *handlers) uninstall(w http.ResponseWriter, r *http.Request, _ policy.Principal) {
+ name := r.PathValue("name")
+ if name == "" {
+ router.WriteError(w, http.StatusBadRequest, "missing_name", "plugin name is required")
+ return
+ }
+ ctx := r.Context()
+ if err := h.mgr.Uninstall(ctx, name, false); err != nil {
+ h.writeTransitionError(ctx, w, name, "uninstall", err)
+ return
+ }
+ router.WriteJSON(w, http.StatusOK, map[string]any{
+ "name": name,
+ "deleted": true,
+ })
+}
+
+// writeTransitionError maps a lifecycle error to the appropriate HTTP
+// status + code. The state machine's two interesting failure modes are
+// ErrNotFound (404) and ErrInvalidTransition (409, since the resource
+// exists but the requested operation conflicts with its current state).
+func (h *handlers) writeTransitionError(ctx context.Context, w http.ResponseWriter, name, op string, err error) {
+ if errors.Is(err, lifecycle.ErrNotFound) {
+ router.WriteError(w, http.StatusNotFound, "not_found", "plugin not found")
+ return
+ }
+ if errors.Is(err, lifecycle.ErrInvalidTransition) {
+ router.WriteError(w, http.StatusConflict, "invalid_transition", err.Error())
+ return
+ }
+ h.logger.ErrorContext(ctx, "admin/plugins: transition failed",
+ slog.String("op", op),
+ slog.String("name", name),
+ slog.Any("err", err))
+ router.WriteError(w, http.StatusInternalServerError, "internal_error", "plugin transition failed")
+}
+
+// writeRow re-reads the row after a successful transition so the
+// response carries the post-transition state. A read failure after a
+// successful transition is logged but doesn't change the operator's
+// view that the action worked — we return 200 with a minimal envelope.
+func (h *handlers) writeRow(ctx context.Context, w http.ResponseWriter, name string, status int) {
+ plugin, err := h.mgr.Get(ctx, name)
+ if err != nil {
+ h.logger.WarnContext(ctx, "admin/plugins: read-back after transition failed",
+ slog.String("name", name), slog.Any("err", err))
+ router.WriteJSON(w, status, PluginRecord{Name: name, Capabilities: []string{}})
+ return
+ }
+ router.WriteJSON(w, status, toRecord(plugin, false))
+}
diff --git a/apps/api/internal/admin/plugins/handler_test.go b/apps/api/internal/admin/plugins/handler_test.go
new file mode 100644
index 0000000..24b9eb2
--- /dev/null
+++ b/apps/api/internal/admin/plugins/handler_test.go
@@ -0,0 +1,516 @@
+package plugins
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router"
+ "github.com/Singleton-Solution/GoNext/packages/go/plugins/lifecycle"
+ "github.com/Singleton-Solution/GoNext/packages/go/policy"
+)
+
+// fakeManager is the test stand-in for *lifecycle.Manager. It stores
+// plugins in a map keyed by slug, transitions states in-process, and
+// records every call so tests can assert which lifecycle methods the
+// handler invoked.
+type fakeManager struct {
+ mu sync.Mutex
+ plugins map[string]lifecycle.Plugin
+ calls []string
+
+ // hooks let tests inject failure modes (ErrNotFound, an arbitrary
+ // install error, etc.) without re-implementing the lifecycle.
+ installErr error
+}
+
+func newFakeManager() *fakeManager {
+ return &fakeManager{plugins: map[string]lifecycle.Plugin{}}
+}
+
+func (f *fakeManager) seed(p lifecycle.Plugin) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.plugins[p.Slug] = p
+}
+
+func (f *fakeManager) List(_ context.Context) ([]lifecycle.Plugin, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.calls = append(f.calls, "List")
+ out := make([]lifecycle.Plugin, 0, len(f.plugins))
+ for _, p := range f.plugins {
+ out = append(out, p)
+ }
+ return out, nil
+}
+
+func (f *fakeManager) Get(_ context.Context, slug string) (lifecycle.Plugin, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.calls = append(f.calls, "Get:"+slug)
+ p, ok := f.plugins[slug]
+ if !ok {
+ return lifecycle.Plugin{}, lifecycle.ErrNotFound
+ }
+ return p, nil
+}
+
+func (f *fakeManager) Install(_ context.Context, _ io.Reader) (string, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.calls = append(f.calls, "Install")
+ if f.installErr != nil {
+ return "", f.installErr
+ }
+ p := lifecycle.Plugin{
+ Slug: "fake-installed",
+ Version: "1.0.0",
+ ABIVersion: 1,
+ State: lifecycle.StateInstalled,
+ InstalledAt: time.Now().UTC(),
+ Capabilities: []string{"kv.read"},
+ }
+ f.plugins[p.Slug] = p
+ return p.Slug, nil
+}
+
+func (f *fakeManager) Activate(_ context.Context, slug string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.calls = append(f.calls, "Activate:"+slug)
+ p, ok := f.plugins[slug]
+ if !ok {
+ return lifecycle.ErrNotFound
+ }
+ if p.State != lifecycle.StateInstalled && p.State != lifecycle.StateInactive {
+ return fmt.Errorf("%w: cannot activate from %s", lifecycle.ErrInvalidTransition, p.State)
+ }
+ p.State = lifecycle.StateActive
+ p.ActivatedAt = time.Now().UTC()
+ f.plugins[slug] = p
+ return nil
+}
+
+func (f *fakeManager) Deactivate(_ context.Context, slug string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.calls = append(f.calls, "Deactivate:"+slug)
+ p, ok := f.plugins[slug]
+ if !ok {
+ return lifecycle.ErrNotFound
+ }
+ if p.State != lifecycle.StateActive {
+ return fmt.Errorf("%w: cannot deactivate from %s", lifecycle.ErrInvalidTransition, p.State)
+ }
+ p.State = lifecycle.StateInactive
+ f.plugins[slug] = p
+ return nil
+}
+
+func (f *fakeManager) Uninstall(_ context.Context, slug string, _ bool) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.calls = append(f.calls, "Uninstall:"+slug)
+ p, ok := f.plugins[slug]
+ if !ok {
+ return lifecycle.ErrNotFound
+ }
+ if p.State != lifecycle.StateInactive && p.State != lifecycle.StateErrored {
+ return fmt.Errorf("%w: cannot uninstall from %s", lifecycle.ErrInvalidTransition, p.State)
+ }
+ delete(f.plugins, slug)
+ return nil
+}
+
+// recordedCalls returns a snapshot of the call log so tests can
+// assert specific lifecycle methods fired.
+func (f *fakeManager) recordedCalls() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ out := make([]string, len(f.calls))
+ copy(out, f.calls)
+ return out
+}
+
+// harness bundles the handler mux + fake manager so each test has its
+// own isolated state. The discard logger keeps test output clean.
+type harness struct {
+ mux *http.ServeMux
+ mgr *fakeManager
+}
+
+func newHarness(t *testing.T) *harness {
+ t.Helper()
+ mgr := newFakeManager()
+ mux := http.NewServeMux()
+ if err := Mount(mux, "/api/v1/plugins", Deps{
+ Manager: mgr,
+ Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()),
+ Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
+ }); err != nil {
+ t.Fatalf("Mount: %v", err)
+ }
+ return &harness{mux: mux, mgr: mgr}
+}
+
+func (h *harness) do(req *http.Request, pr *policy.Principal) *httptest.ResponseRecorder {
+ if pr != nil {
+ req = req.WithContext(policy.WithPrincipal(req.Context(), *pr))
+ }
+ rec := httptest.NewRecorder()
+ h.mux.ServeHTTP(rec, req)
+ return rec
+}
+
+func adminPrincipal() policy.Principal {
+ return policy.Principal{
+ UserID: "11111111-1111-1111-1111-111111111111",
+ Roles: []policy.Role{policy.RoleAdmin},
+ }
+}
+
+func subscriberPrincipal() policy.Principal {
+ return policy.Principal{
+ UserID: "22222222-2222-2222-2222-222222222222",
+ Roles: []policy.Role{policy.RoleSubscriber},
+ }
+}
+
+// TestList_EmptyReturnsPagedShape covers the unblock scenario for the
+// admin Plugins page: a clean install (no plugins) must return 200 with
+// the {data: [], pagination: {...}} envelope, not 404. The Page[T]
+// MarshalJSON guarantees Data is `[]` rather than `null` (issue #505).
+func TestList_EmptyReturnsPagedShape(t *testing.T) {
+ h := newHarness(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins", nil)
+ pr := adminPrincipal()
+ rec := h.do(req, &pr)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ // Parse the raw body so we can assert "data":[] rather than the
+ // Go zero-value (nil) decoding ambiguity.
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
+ t.Fatalf("decode body: %v (body=%s)", err, rec.Body.String())
+ }
+ data, ok := raw["data"]
+ if !ok {
+ t.Fatalf("response missing data key: %s", rec.Body.String())
+ }
+ if string(data) != "[]" {
+ t.Fatalf("expected data=[], got %s", string(data))
+ }
+ if _, ok := raw["pagination"]; !ok {
+ t.Fatalf("response missing pagination key: %s", rec.Body.String())
+ }
+}
+
+// TestList_ReturnsSeededPlugins confirms the projection from
+// lifecycle.Plugin onto the wire shape (name<-slug, capabilities
+// nil-coerce, etc.).
+func TestList_ReturnsSeededPlugins(t *testing.T) {
+ h := newHarness(t)
+ now := time.Now().UTC().Truncate(time.Second)
+ h.mgr.seed(lifecycle.Plugin{
+ Slug: "demo-plugin",
+ Version: "0.1.0",
+ ABIVersion: 1,
+ State: lifecycle.StateActive,
+ Capabilities: []string{"kv.read", "audit.emit"},
+ InstalledAt: now,
+ ActivatedAt: now,
+ })
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins", nil)
+ pr := adminPrincipal()
+ rec := h.do(req, &pr)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ var page router.Page[PluginRecord]
+ if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
+ t.Fatalf("decode: %v body=%s", err, rec.Body.String())
+ }
+ if len(page.Data) != 1 {
+ t.Fatalf("expected 1 plugin, got %d", len(page.Data))
+ }
+ got := page.Data[0]
+ if got.Name != "demo-plugin" {
+ t.Errorf("Name: got %q, want demo-plugin", got.Name)
+ }
+ if got.State != "active" {
+ t.Errorf("State: got %q, want active", got.State)
+ }
+ if got.Version != "0.1.0" {
+ t.Errorf("Version: got %q, want 0.1.0", got.Version)
+ }
+ if len(got.Capabilities) != 2 {
+ t.Errorf("Capabilities: got %v, want 2 entries", got.Capabilities)
+ }
+}
+
+// TestList_Unauthenticated returns 401 when no Principal is on the
+// context. Mirrors how RequireSession would refuse a logged-out caller.
+func TestList_Unauthenticated(t *testing.T) {
+ h := newHarness(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins", nil)
+ rec := h.do(req, nil)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+// TestGet_UnknownReturns404 covers the typed ErrNotFound path.
+func TestGet_UnknownReturns404(t *testing.T) {
+ h := newHarness(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/nope", nil)
+ pr := adminPrincipal()
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+// TestActivate_Unauthenticated returns 401 for an anonymous caller.
+func TestActivate_Unauthenticated(t *testing.T) {
+ h := newHarness(t)
+ h.mgr.seed(lifecycle.Plugin{Slug: "demo-plugin", State: lifecycle.StateInstalled})
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/demo-plugin/activate", nil)
+ rec := h.do(req, nil)
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+// TestActivate_Forbidden returns 403 for a logged-in caller without
+// the CapActivatePlugins capability.
+func TestActivate_Forbidden(t *testing.T) {
+ h := newHarness(t)
+ h.mgr.seed(lifecycle.Plugin{Slug: "demo-plugin", State: lifecycle.StateInstalled})
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/demo-plugin/activate", nil)
+ pr := subscriberPrincipal()
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("expected 403, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+// TestActivate_Succeeds drives a seeded Installed plugin to Active and
+// verifies the response carries the post-transition state. The fake
+// manager records each lifecycle call so we can assert the right
+// method fired.
+func TestActivate_Succeeds(t *testing.T) {
+ h := newHarness(t)
+ h.mgr.seed(lifecycle.Plugin{
+ Slug: "demo-plugin",
+ Version: "0.1.0",
+ State: lifecycle.StateInstalled,
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/demo-plugin/activate", nil)
+ pr := adminPrincipal()
+ rec := h.do(req, &pr)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
+ }
+ var rec1 PluginRecord
+ if err := json.Unmarshal(rec.Body.Bytes(), &rec1); err != nil {
+ t.Fatalf("decode: %v body=%s", err, rec.Body.String())
+ }
+ if rec1.State != string(lifecycle.StateActive) {
+ t.Errorf("State after activate: got %q, want active", rec1.State)
+ }
+
+ calls := h.mgr.recordedCalls()
+ var sawActivate bool
+ for _, c := range calls {
+ if c == "Activate:demo-plugin" {
+ sawActivate = true
+ break
+ }
+ }
+ if !sawActivate {
+ t.Fatalf("Activate was not called; calls=%v", calls)
+ }
+}
+
+// TestActivate_InvalidTransitionReturns409 covers the case where the
+// caller asks to activate a plugin that's already active. The
+// lifecycle Manager surfaces ErrInvalidTransition; the handler maps
+// that to a 409 so the admin can show a clear "already active" hint.
+func TestActivate_InvalidTransitionReturns409(t *testing.T) {
+ h := newHarness(t)
+ h.mgr.seed(lifecycle.Plugin{
+ Slug: "demo-plugin",
+ State: lifecycle.StateActive,
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/demo-plugin/activate", nil)
+ pr := adminPrincipal()
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("expected 409, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+// TestDeactivate_Succeeds drives Active → Inactive.
+func TestDeactivate_Succeeds(t *testing.T) {
+ h := newHarness(t)
+ h.mgr.seed(lifecycle.Plugin{
+ Slug: "demo-plugin",
+ State: lifecycle.StateActive,
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/demo-plugin/deactivate", nil)
+ pr := adminPrincipal()
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
+ }
+ var rec1 PluginRecord
+ if err := json.Unmarshal(rec.Body.Bytes(), &rec1); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if rec1.State != string(lifecycle.StateInactive) {
+ t.Errorf("State after deactivate: got %q, want inactive", rec1.State)
+ }
+}
+
+// TestUninstall_Succeeds drives Inactive → deleted.
+func TestUninstall_Succeeds(t *testing.T) {
+ h := newHarness(t)
+ h.mgr.seed(lifecycle.Plugin{
+ Slug: "demo-plugin",
+ State: lifecycle.StateInactive,
+ })
+ req := httptest.NewRequest(http.MethodDelete, "/api/v1/plugins/demo-plugin", nil)
+ pr := adminPrincipal()
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
+ }
+ // Subsequent GET should now 404.
+ getReq := httptest.NewRequest(http.MethodGet, "/api/v1/plugins/demo-plugin", nil)
+ getRec := h.do(getReq, &pr)
+ if getRec.Code != http.StatusNotFound {
+ t.Fatalf("post-uninstall GET expected 404, got %d", getRec.Code)
+ }
+}
+
+// TestUninstall_Forbidden covers the cap gate: a subscriber cannot
+// remove a plugin.
+func TestUninstall_Forbidden(t *testing.T) {
+ h := newHarness(t)
+ h.mgr.seed(lifecycle.Plugin{Slug: "demo-plugin", State: lifecycle.StateInactive})
+ req := httptest.NewRequest(http.MethodDelete, "/api/v1/plugins/demo-plugin", nil)
+ pr := subscriberPrincipal()
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("expected 403, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+// TestInstall_RejectsNonMultipart confirms the content-type gate. A
+// JSON body or empty body should produce 400 rather than reaching the
+// lifecycle Manager.
+func TestInstall_RejectsNonMultipart(t *testing.T) {
+ h := newHarness(t)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/install", nil)
+ req.Header.Set("Content-Type", "application/json")
+ pr := adminPrincipal()
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+// TestInstall_Forbidden covers the cap gate on the install endpoint.
+func TestInstall_Forbidden(t *testing.T) {
+ h := newHarness(t)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/install", nil)
+ req.Header.Set("Content-Type", "multipart/form-data; boundary=zzz")
+ pr := subscriberPrincipal()
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("expected 403, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+// Verifies the manager's error propagation: the install handler
+// surfaces an unwrapped lifecycle error as a 400 with the message in
+// the detail field so the admin UI can render it.
+func TestInstall_ManagerErrorReturns400(t *testing.T) {
+ h := newHarness(t)
+ h.mgr.installErr = errors.New("manifest version is required")
+
+ // Build a minimal multipart body with a bundle part.
+ body, contentType := makeMultipart(t, map[string][]byte{"bundle": []byte("not-a-zip")})
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/plugins/install", body)
+ req.Header.Set("Content-Type", contentType)
+ pr := adminPrincipal()
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+// makeMultipart builds a tiny multipart/form-data body for the install
+// tests. Each entry becomes one part with FormName = key, body = value.
+func makeMultipart(t *testing.T, parts map[string][]byte) (io.Reader, string) {
+ t.Helper()
+ pr, pw := io.Pipe()
+ mw := newMultipartWriter(pw)
+ go func() {
+ defer func() { _ = pw.Close() }()
+ for name, body := range parts {
+ if err := mw.writePart(name, body); err != nil {
+ _ = pw.CloseWithError(err)
+ return
+ }
+ }
+ _ = mw.close()
+ }()
+ return pr, "multipart/form-data; boundary=" + mw.boundary
+}
+
+// Tiny in-test multipart writer. We don't pull in mime/multipart's
+// FormDataWriter because we want a stable boundary string for the
+// boundary check above and because the standard helper writes a
+// trailing CRLF the test doesn't need.
+type multipartWriter struct {
+ w io.Writer
+ boundary string
+}
+
+func newMultipartWriter(w io.Writer) *multipartWriter {
+ return &multipartWriter{w: w, boundary: "test-boundary-zzz"}
+}
+
+func (m *multipartWriter) writePart(name string, body []byte) error {
+ header := "--" + m.boundary + "\r\n" +
+ `Content-Disposition: form-data; name="` + name + `"; filename="` + name + `.bin"` + "\r\n" +
+ "Content-Type: application/octet-stream\r\n\r\n"
+ if _, err := io.WriteString(m.w, header); err != nil {
+ return err
+ }
+ if _, err := m.w.Write(body); err != nil {
+ return err
+ }
+ _, err := io.WriteString(m.w, "\r\n")
+ return err
+}
+
+func (m *multipartWriter) close() error {
+ _, err := io.WriteString(m.w, "--"+m.boundary+"--\r\n")
+ return err
+}
From 75e9ac2dc6b0ce6b344f2387c1c15160b3bf1956 Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Thu, 28 May 2026 12:53:59 +0200
Subject: [PATCH 2/4] =?UTF-8?q?feat(auth):=20mount=20/api/v1/me/tokens=20?=
=?UTF-8?q?=E2=80=94=20Personal=20Access=20Tokens=20(#511)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Admin Settings → Tokens page was calling \`/api/v1/me/tokens\` and
getting 404. PAT issuance is table-stakes for any CI / automation
integration against a remote GoNext install.
New package: \`apps/api/internal/auth/pat/\`
Routes (RequireSession-wrapped):
- GET /api/v1/me/tokens — list (no secrets, only prefix)
- POST /api/v1/me/tokens — create + return raw secret ONCE
- DELETE /api/v1/me/tokens/{id} — revoke
Token format: \`gn_pat_<32-byte-base64>\` (24 raw bytes from crypto/
rand, base64.RawURLEncoding). Hashed via \`packages/go/auth/password.
Hash\` with the configured \`GONEXT_AUTH_PEPPER\`. Prefix is first 8
chars of the secret tail for display.
Owner isolation: every query scoped by caller's UserID from
policy.FromContext. \"Wrong owner\" and \"not found\" both return 404
— no existence oracle for cross-user enumeration.
Migration: \`000026_personal_access_tokens.up.sql\` already shipped.
Audit: emits \`auth.pat.created\` / \`auth.pat.revoked\`.
7 tests against testcontainers Postgres: empty list, create+raw-
token-once, revoke removes row, revoke other-user returns 404, 401
anonymous, empty-name rejection, audit emission.
Verified live: full create/list/revoke round-trip works against the
running stack.
Note: an older \`apps/api/internal/admin/tokens\` package exists with
similar surface but different token format. It's currently unmounted;
choice of which to keep + repointing admin/users/{id}/tokens at one
of them is a follow-up.
Closes #511.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
apps/api/internal/auth/pat/handler.go | 358 +++++++++++++++
apps/api/internal/auth/pat/handler_test.go | 488 +++++++++++++++++++++
apps/api/internal/auth/pat/store.go | 355 +++++++++++++++
3 files changed, 1201 insertions(+)
create mode 100644 apps/api/internal/auth/pat/handler.go
create mode 100644 apps/api/internal/auth/pat/handler_test.go
create mode 100644 apps/api/internal/auth/pat/store.go
diff --git a/apps/api/internal/auth/pat/handler.go b/apps/api/internal/auth/pat/handler.go
new file mode 100644
index 0000000..cd5c478
--- /dev/null
+++ b/apps/api/internal/auth/pat/handler.go
@@ -0,0 +1,358 @@
+package pat
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router"
+ "github.com/Singleton-Solution/GoNext/packages/go/audit"
+ "github.com/Singleton-Solution/GoNext/packages/go/policy"
+)
+
+// AuditEmitter is the slice of audit.Emitter we depend on. The real
+// emitter satisfies it; tests pass a no-op fake. Accepting an interface
+// here lets the audit chain change shape (e.g. wrap with tracing)
+// without touching this package.
+type AuditEmitter interface {
+ Emit(ctx context.Context, eventType string, opts ...audit.EmitOption) error
+}
+
+// Deps is the dependency bag for Mount. Every field's zero value is
+// either invalid or has a sensible default; Mount documents which.
+type Deps struct {
+ // Pool is the Postgres connection pool the store talks to. Required.
+ Pool *pgxpool.Pool
+
+ // Pepper is the secret HMAC'd into the password-hash input via
+ // packages/go/auth/password. Required. May be empty in tests but
+ // production wiring should always pass cfg.Auth.Pepper bytes.
+ Pepper []byte
+
+ // AuditEmitter receives auth.pat.* events (created / revoked).
+ // Optional — nil disables audit emission. Production wiring should
+ // always pass the live emitter.
+ AuditEmitter AuditEmitter
+
+ // Logger receives structured log lines. nil falls back to
+ // slog.Default — fine for tests, but production wiring should
+ // always pass a service logger.
+ Logger *slog.Logger
+}
+
+// Audit event types emitted by this package. Exported so admin tooling
+// and integration tests can compare without re-typing the magic string.
+const (
+ EventTokenCreated = "auth.pat.created"
+ EventTokenRevoked = "auth.pat.revoked"
+)
+
+// handlers is the resolved-Deps form passed around inside the package.
+// Constructed by Mount; never used standalone.
+type handlers struct {
+ store *Store
+ audit AuditEmitter
+ logger *slog.Logger
+ now func() time.Time
+}
+
+// Mount wires the /me/tokens routes onto mux under base (canonically
+// "/api/v1/me/tokens"). Returns an error rather than panicking if
+// Deps is malformed so the caller's boot path can warn-and-continue.
+//
+// The route tree:
+//
+// GET {base} — list current user's active tokens
+// POST {base} — issue; response carries plaintext ONCE
+// DELETE {base}/{id} — revoke (idempotent; second call is 204)
+//
+// Every route requires the user to be authenticated (a Principal on
+// the request context, populated upstream by RequireSession or the
+// bearer middleware). Mount itself does NOT add a session wrapper —
+// the caller's main.go is the right place to compose that, mirroring
+// how /api/v1/auth/sessions wires itself.
+func Mount(mux *http.ServeMux, base string, deps Deps) error {
+ if mux == nil {
+ return errors.New("pat: mux is required")
+ }
+ if deps.Pool == nil {
+ return errors.New("pat: Pool is required")
+ }
+ if len(deps.Pepper) == 0 {
+ return errors.New("pat: Pepper is required")
+ }
+ logger := deps.Logger
+ if logger == nil {
+ logger = slog.Default()
+ }
+ h := &handlers{
+ store: NewStore(deps.Pool, deps.Pepper),
+ audit: deps.AuditEmitter,
+ logger: logger,
+ now: func() time.Time { return time.Now().UTC() },
+ }
+ base = strings.TrimRight(base, "/")
+ mux.Handle("GET "+base, h.gate(h.list))
+ mux.Handle("POST "+base, h.gate(h.create))
+ mux.Handle("DELETE "+base+"/{id}", h.gate(h.revoke))
+ return nil
+}
+
+// gate ensures a Principal is on the context. It does NOT do a
+// capability check — every authenticated user may manage their own
+// tokens. Cross-user surfaces (admin managing OTHER users' tokens)
+// live at /api/v1/admin/users/{id}/tokens and gate with policy.Require.
+func (h *handlers) gate(next func(http.ResponseWriter, *http.Request, policy.Principal)) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ pr, ok := policy.FromContext(r.Context())
+ if !ok || pr.UserID == "" {
+ router.WriteError(w, http.StatusUnauthorized, "unauthenticated", "authentication required")
+ return
+ }
+ next(w, r, pr)
+ })
+}
+
+// TokenView is the on-wire shape returned by list and revoke. It is
+// deliberately separate from Token (the SQL scan target) so the hash
+// never appears in JSON even if a future refactor adds a Hash field
+// to Token. Keep this in lock-step with apps/admin/.../tokens/types.ts.
+type TokenView struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Prefix string `json:"prefix"`
+ Scopes []string `json:"scopes"`
+ CreatedAt time.Time `json:"created_at"`
+ LastUsedAt *time.Time `json:"last_used_at,omitempty"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+}
+
+// IssuedTokenView extends TokenView with the plaintext bearer the
+// operator must save. The Plaintext field is the only place the
+// operator will ever see the full token; subsequent List calls only
+// show Prefix. The SaveNow flag is a UI hint that the plaintext is
+// non-recoverable — admin UIs SHOULD render an unmissable warning.
+type IssuedTokenView struct {
+ TokenView
+ Token string `json:"token"`
+ SaveNow bool `json:"save_now"`
+}
+
+func toView(t Token) TokenView {
+ scopes := t.Scopes
+ if scopes == nil {
+ // Keep the JSON encoding stable: a token with zero scopes
+ // serialises as "[]", never "null". The admin UI ranges over
+ // the field unconditionally.
+ scopes = []string{}
+ }
+ return TokenView{
+ ID: t.ID,
+ Name: t.Name,
+ Prefix: t.Prefix,
+ Scopes: append([]string{}, scopes...),
+ CreatedAt: t.CreatedAt,
+ LastUsedAt: t.LastUsedAt,
+ ExpiresAt: t.ExpiresAt,
+ }
+}
+
+// list handles GET /me/tokens. Returns the active tokens for the
+// caller, never another user's. The store enforces user_id scoping
+// at the SQL level so a bug here cannot widen the audience.
+func (h *handlers) list(w http.ResponseWriter, r *http.Request, pr policy.Principal) {
+ rows, err := h.store.List(r.Context(), pr.UserID)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "auth/pat: list failed",
+ slog.String("user_id", pr.UserID),
+ slog.Any("err", err),
+ )
+ router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to list tokens")
+ return
+ }
+ out := make([]TokenView, 0, len(rows))
+ for _, t := range rows {
+ out = append(out, toView(t))
+ }
+ router.WriteJSON(w, http.StatusOK, map[string]any{"data": out})
+}
+
+// CreateRequest is the JSON body for POST /me/tokens.
+type CreateRequest struct {
+ Name string `json:"name"`
+ Scopes []string `json:"scopes"`
+ // ExpiresIn is the duration before the token expires, expressed
+ // as one of: "30d", "90d", "1y", "never". The presets match the
+ // admin UI radio group. Empty string means "never".
+ ExpiresIn string `json:"expires_in"`
+}
+
+// presetExpiry maps the preset string to a duration. Unknown presets
+// return ok=false; the handler maps that to a 400.
+//
+// Keep the preset list in lock-step with apps/admin/.../tokens/types.ts
+// (ExpiresPreset). Adding a new preset requires both ends.
+func presetExpiry(s string, now time.Time) (*time.Time, bool) {
+ switch strings.TrimSpace(s) {
+ case "", "never":
+ return nil, true
+ case "30d":
+ t := now.Add(30 * 24 * time.Hour)
+ return &t, true
+ case "90d":
+ t := now.Add(90 * 24 * time.Hour)
+ return &t, true
+ case "1y":
+ t := now.Add(365 * 24 * time.Hour)
+ return &t, true
+ default:
+ return nil, false
+ }
+}
+
+// create handles POST /me/tokens. On success returns 201 with the
+// IssuedTokenView — the ONLY place the operator will ever see the
+// plaintext.
+func (h *handlers) create(w http.ResponseWriter, r *http.Request, pr policy.Principal) {
+ var req CreateRequest
+ dec := json.NewDecoder(r.Body)
+ // Strict decode: an extra field is a client bug we want to catch
+ // at integration time, not silently swallow.
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&req); err != nil {
+ router.WriteError(w, http.StatusBadRequest, "invalid_body", "request body is not valid JSON")
+ return
+ }
+ if strings.TrimSpace(req.Name) == "" {
+ router.WriteError(w, http.StatusBadRequest, "invalid_name", "name is required")
+ return
+ }
+ expiresAt, ok := presetExpiry(req.ExpiresIn, h.now())
+ if !ok {
+ router.WriteError(w, http.StatusBadRequest, "invalid_expires_in", "expires_in must be one of: never, 30d, 90d, 1y")
+ return
+ }
+ // Dedup scopes — a multi-select UI can submit duplicates after a
+ // double-click. The store also strips empties; we dedup here so
+ // the response shape matches what the caller asked for.
+ req.Scopes = dedup(req.Scopes)
+
+ created, err := h.store.Create(r.Context(), CreateInput{
+ UserID: pr.UserID,
+ Name: req.Name,
+ Scopes: req.Scopes,
+ ExpiresAt: expiresAt,
+ })
+ switch {
+ case err == nil:
+ // fall through
+ case errors.Is(err, ErrInvalidName):
+ router.WriteError(w, http.StatusBadRequest, "invalid_name", "name is required")
+ return
+ case errors.Is(err, ErrInvalidUserID):
+ router.WriteError(w, http.StatusUnauthorized, "unauthenticated", "authentication required")
+ return
+ default:
+ h.logger.ErrorContext(r.Context(), "auth/pat: create failed",
+ slog.String("user_id", pr.UserID),
+ slog.Any("err", err),
+ )
+ router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to issue token")
+ return
+ }
+
+ h.emitAudit(r, pr.UserID, EventTokenCreated, created.Token.ID, map[string]any{
+ "name": created.Token.Name,
+ "prefix": created.Token.Prefix,
+ "scopes": created.Token.Scopes,
+ })
+
+ view := IssuedTokenView{
+ TokenView: toView(created.Token),
+ Token: created.Plaintext,
+ SaveNow: true,
+ }
+ router.WriteJSON(w, http.StatusCreated, view)
+}
+
+// revoke handles DELETE /me/tokens/{id}. ErrNotFound from the store
+// covers both "no such id" and "id belongs to another user" — we map
+// both to 404 to avoid the existence oracle a 403 would surface.
+func (h *handlers) revoke(w http.ResponseWriter, r *http.Request, pr policy.Principal) {
+ id := r.PathValue("id")
+ if id == "" {
+ // Structurally unreachable under ServeMux's "/{id}" pattern,
+ // but the off-route call path used by tests can hit it.
+ router.WriteError(w, http.StatusNotFound, "not_found", "token not found")
+ return
+ }
+ err := h.store.Revoke(r.Context(), pr.UserID, id)
+ switch {
+ case err == nil:
+ h.emitAudit(r, pr.UserID, EventTokenRevoked, id, nil)
+ w.WriteHeader(http.StatusNoContent)
+ case errors.Is(err, ErrNotFound):
+ router.WriteError(w, http.StatusNotFound, "not_found", "token not found")
+ default:
+ h.logger.ErrorContext(r.Context(), "auth/pat: revoke failed",
+ slog.String("user_id", pr.UserID),
+ slog.String("token_id", id),
+ slog.Any("err", err),
+ )
+ router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to revoke token")
+ }
+}
+
+// emitAudit records an auth.pat.* event. Errors are non-fatal — the
+// state change has already landed in the DB by the time we get here,
+// and rolling back because the audit log failed would be a worse
+// outcome (operator stuck without a token, or unable to revoke a
+// credential they think is dead).
+func (h *handlers) emitAudit(r *http.Request, userID, eventType, tokenID string, meta map[string]any) {
+ if h.audit == nil {
+ return
+ }
+ opts := []audit.EmitOption{
+ audit.WithActorOverride(userID),
+ audit.WithTarget("personal_access_token", tokenID),
+ audit.WithSeverity(audit.SeverityInfo),
+ }
+ if meta != nil {
+ opts = append(opts, audit.WithMetadata(meta))
+ }
+ if err := h.audit.Emit(r.Context(), eventType, opts...); err != nil {
+ h.logger.WarnContext(r.Context(), "auth/pat: audit emit failed",
+ slog.String("event", eventType),
+ slog.String("user_id", userID),
+ slog.String("token_id", tokenID),
+ slog.Any("err", err),
+ )
+ }
+}
+
+// dedup trims and removes duplicate strings, preserving first-seen
+// order. Used on the scopes slice; a multi-select UI can submit dupes
+// after a double-click. Trimming + skipping empties means " read " and
+// "read" collapse to the single canonical "read".
+func dedup(in []string) []string {
+ seen := make(map[string]struct{}, len(in))
+ out := make([]string, 0, len(in))
+ for _, s := range in {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ continue
+ }
+ if _, dup := seen[s]; dup {
+ continue
+ }
+ seen[s] = struct{}{}
+ out = append(out, s)
+ }
+ return out
+}
diff --git a/apps/api/internal/auth/pat/handler_test.go b/apps/api/internal/auth/pat/handler_test.go
new file mode 100644
index 0000000..04835c2
--- /dev/null
+++ b/apps/api/internal/auth/pat/handler_test.go
@@ -0,0 +1,488 @@
+package pat
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+ _ "github.com/jackc/pgx/v5/stdlib"
+
+ "github.com/Singleton-Solution/GoNext/packages/go/audit"
+ "github.com/Singleton-Solution/GoNext/packages/go/policy"
+ "github.com/Singleton-Solution/GoNext/packages/go/testutil/containers"
+)
+
+// testPepper is the fixed pepper used across the tests in this file.
+// We use a stable value so test failures aren't masked by per-run salt
+// drift; the per-call random salt inside argon2 is what actually keeps
+// the resulting hashes distinct.
+var testPepper = []byte("test-pepper-do-not-use-in-production")
+
+// mustPostgresWithPATSchema spins up a Postgres container, applies
+// migrations 000001 through 000026 (the PAT migration), and returns a
+// pgxpool plus an auto-cleanup hook. Tests skip cleanly with -short.
+//
+// We stop at 000026 deliberately: the PAT handlers only need users
+// (000002), citext (000001), and the PAT table itself. Bringing in
+// later migrations would only slow the container's startup. If a
+// future PAT change introduces a join against a newer table, bump the
+// upper-bound filter here.
+func mustPostgresWithPATSchema(t *testing.T) *pgxpool.Pool {
+ t.Helper()
+ if testing.Short() {
+ t.Skip("integration test: skip with -short")
+ }
+ dsn := containers.Postgres(t)
+ if dsn == "" {
+ t.Skip("docker not available")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ pool, err := pgxpool.New(ctx, dsn)
+ if err != nil {
+ t.Fatalf("pgxpool.New: %v", err)
+ }
+ t.Cleanup(pool.Close)
+
+ mustApplyPATMigrations(t, dsn)
+ return pool
+}
+
+// mustApplyPATMigrations applies migrations 000001..000026 against the
+// supplied DSN. We open via database/sql + the pgx stdlib so each
+// migration runs as a single statement-block — matching the importer
+// helper used by every other DB-touching test in apps/api.
+func mustApplyPATMigrations(t *testing.T, dsn string) {
+ t.Helper()
+ root := repoRoot(t)
+ dir := filepath.Join(root, "migrations")
+ matches, err := filepath.Glob(filepath.Join(dir, "*.up.sql"))
+ if err != nil {
+ t.Fatalf("glob migrations: %v", err)
+ }
+ // Filter to 000001..000026. Anything later than that brings in
+ // tables we don't touch and only slows the test.
+ keep := matches[:0]
+ for _, m := range matches {
+ base := filepath.Base(m)
+ if len(base) < 6 {
+ continue
+ }
+ if base[:6] > "000026" {
+ continue
+ }
+ if base[:6] < "000001" {
+ continue
+ }
+ keep = append(keep, m)
+ }
+ matches = keep
+ if len(matches) == 0 {
+ t.Fatalf("no migrations found in %s", dir)
+ }
+ sort.Strings(matches)
+ db, err := sql.Open("pgx", dsn)
+ if err != nil {
+ t.Fatalf("sql.Open: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ defer cancel()
+ for _, m := range matches {
+ body, err := os.ReadFile(m)
+ if err != nil {
+ t.Fatalf("read %s: %v", m, err)
+ }
+ if _, err := db.ExecContext(ctx, string(body)); err != nil {
+ t.Fatalf("apply %s: %v", filepath.Base(m), err)
+ }
+ }
+}
+
+// repoRoot walks up until it finds the directory containing go.work.
+func repoRoot(t *testing.T) string {
+ t.Helper()
+ _, file, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller failed")
+ }
+ dir := filepath.Dir(file)
+ for i := 0; i < 12; i++ {
+ if _, err := os.Stat(filepath.Join(dir, "go.work")); err == nil {
+ return dir
+ }
+ dir = filepath.Dir(dir)
+ }
+ t.Fatal("could not locate repo root (go.work)")
+ return ""
+}
+
+// seedUser inserts a users row and returns its UUID. The PAT user_id
+// column has a FK to users.id, so every test that exercises Create
+// needs at least one real user.
+func seedUser(t *testing.T, pool *pgxpool.Pool, email, handle string) uuid.UUID {
+ t.Helper()
+ var id uuid.UUID
+ err := pool.QueryRow(context.Background(),
+ `INSERT INTO users (email, handle, display_name)
+ VALUES ($1::citext, $2::citext, $3) RETURNING id`,
+ email, handle, handle,
+ ).Scan(&id)
+ if err != nil {
+ t.Fatalf("seed user: %v", err)
+ }
+ return id
+}
+
+// withPrincipal wraps a handler with a middleware that injects a fixed
+// Principal. Production wires session/PAT auth here; tests just want
+// a Principal on the context so the gate passes.
+func withPrincipal(p policy.Principal, h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r = r.WithContext(policy.WithPrincipal(r.Context(), p))
+ h.ServeHTTP(w, r)
+ })
+}
+
+// newServer wires Mount + a principal-injecting middleware. Returns
+// the server URL, the audit store (so tests can assert on emitted
+// events), and a cleanup hook.
+func newServer(t *testing.T, pool *pgxpool.Pool, userID string) (string, *audit.MemoryStore, func()) {
+ t.Helper()
+ auditStore := audit.NewMemoryStore()
+ emitter := audit.NewEmitter(auditStore)
+ mux := http.NewServeMux()
+ err := Mount(mux, "/api/v1/me/tokens", Deps{
+ Pool: pool,
+ Pepper: testPepper,
+ AuditEmitter: emitter,
+ })
+ if err != nil {
+ t.Fatalf("Mount: %v", err)
+ }
+ pr := policy.Principal{UserID: userID}
+ srv := httptest.NewServer(withPrincipal(pr, mux))
+ return srv.URL, auditStore, srv.Close
+}
+
+func decodeJSON(t *testing.T, r *http.Response, v any) {
+ t.Helper()
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ if err := json.Unmarshal(body, v); err != nil {
+ t.Fatalf("decode: %v\nbody: %s", err, body)
+ }
+}
+
+// TestList_NoTokens_EmptyData covers the empty-state path the admin
+// settings page sees on a fresh account: a 200 with {"data":[]}, never
+// a 500 from a nil slice or a 404 from the mount.
+func TestList_NoTokens_EmptyData(t *testing.T) {
+ pool := mustPostgresWithPATSchema(t)
+ user := seedUser(t, pool, "alice@example.com", "alice")
+ url, _, cleanup := newServer(t, pool, user.String())
+ defer cleanup()
+
+ res, err := http.Get(url + "/api/v1/me/tokens")
+ if err != nil {
+ t.Fatalf("GET: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ raw, _ := io.ReadAll(res.Body)
+ t.Fatalf("status %d body=%s", res.StatusCode, raw)
+ }
+ var out struct {
+ Data []TokenView `json:"data"`
+ }
+ decodeJSON(t, res, &out)
+ if len(out.Data) != 0 {
+ t.Fatalf("expected empty data, got %v", out.Data)
+ }
+}
+
+// TestCreate_ReturnsRawTokenOnce is the most important test in this
+// file: the create response carries the plaintext, but subsequent
+// GETs only show the prefix. A regression here means a leaked DB dump
+// becomes a leaked credential.
+func TestCreate_ReturnsRawTokenOnce(t *testing.T) {
+ pool := mustPostgresWithPATSchema(t)
+ user := seedUser(t, pool, "bob@example.com", "bob")
+ url, _, cleanup := newServer(t, pool, user.String())
+ defer cleanup()
+
+ body, _ := json.Marshal(CreateRequest{
+ Name: "ci-token",
+ Scopes: []string{"read"},
+ ExpiresIn: "never",
+ })
+ res, err := http.Post(url+"/api/v1/me/tokens", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusCreated {
+ raw, _ := io.ReadAll(res.Body)
+ t.Fatalf("status %d body=%s", res.StatusCode, raw)
+ }
+ var issued IssuedTokenView
+ decodeJSON(t, res, &issued)
+ if !strings.HasPrefix(issued.Token, Namespace) {
+ t.Fatalf("plaintext missing namespace: %q", issued.Token)
+ }
+ if len(issued.Token) != MinTokenLen {
+ t.Fatalf("plaintext wrong length: %d want %d (%q)", len(issued.Token), MinTokenLen, issued.Token)
+ }
+ if !issued.SaveNow {
+ t.Fatal("save_now must be true on the create response")
+ }
+ if issued.Prefix == "" || len(issued.Prefix) != PrefixLen {
+ t.Fatalf("prefix wrong shape: %q", issued.Prefix)
+ }
+ // The prefix on the response must match the first PrefixLen chars
+ // of the secret tail — that's what the operator will use to
+ // recognise the token in the list later.
+ wantPrefix := issued.Token[len(Namespace) : len(Namespace)+PrefixLen]
+ if issued.Prefix != wantPrefix {
+ t.Fatalf("prefix %q does not match token tail %q", issued.Prefix, wantPrefix)
+ }
+
+ // A subsequent list MUST NOT include the plaintext. The list
+ // response shape is {"data":[TokenView]} and TokenView has no
+ // Token field; we double-check by string-searching the raw body.
+ listRes, err := http.Get(url + "/api/v1/me/tokens")
+ if err != nil {
+ t.Fatalf("GET: %v", err)
+ }
+ defer listRes.Body.Close()
+ raw, _ := io.ReadAll(listRes.Body)
+ if strings.Contains(string(raw), issued.Token) {
+ t.Fatalf("list response leaked plaintext: %s", raw)
+ }
+ low := strings.ToLower(string(raw))
+ if strings.Contains(low, "\"token\":") {
+ t.Fatalf("list response leaked token field: %s", raw)
+ }
+ if strings.Contains(low, "\"hash\"") {
+ t.Fatalf("list response leaked hash field: %s", raw)
+ }
+ // The row IS visible — just without the plaintext.
+ var listOut struct {
+ Data []TokenView `json:"data"`
+ }
+ if err := json.Unmarshal(raw, &listOut); err != nil {
+ t.Fatalf("decode list: %v", err)
+ }
+ if len(listOut.Data) != 1 {
+ t.Fatalf("expected 1 token in list, got %d", len(listOut.Data))
+ }
+ if listOut.Data[0].Prefix != issued.Prefix {
+ t.Fatalf("list row prefix mismatch: got %q want %q", listOut.Data[0].Prefix, issued.Prefix)
+ }
+}
+
+// TestRevoke_RemovesFromList covers the happy revoke path: DELETE
+// returns 204, the list call afterwards omits the row. The store
+// soft-deletes (sets revoked_at) but the active-only filter on List
+// hides the row.
+func TestRevoke_RemovesFromList(t *testing.T) {
+ pool := mustPostgresWithPATSchema(t)
+ user := seedUser(t, pool, "carol@example.com", "carol")
+ url, _, cleanup := newServer(t, pool, user.String())
+ defer cleanup()
+
+ // Create the token via the HTTP path so we have the canonical id.
+ body, _ := json.Marshal(CreateRequest{Name: "to-revoke", Scopes: []string{"read"}})
+ res, err := http.Post(url+"/api/v1/me/tokens", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ var issued IssuedTokenView
+ decodeJSON(t, res, &issued)
+ res.Body.Close()
+
+ req, _ := http.NewRequest("DELETE", url+"/api/v1/me/tokens/"+issued.ID, nil)
+ delRes, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("DELETE: %v", err)
+ }
+ defer delRes.Body.Close()
+ if delRes.StatusCode != http.StatusNoContent {
+ raw, _ := io.ReadAll(delRes.Body)
+ t.Fatalf("status %d want 204 body=%s", delRes.StatusCode, raw)
+ }
+
+ listRes, err := http.Get(url + "/api/v1/me/tokens")
+ if err != nil {
+ t.Fatalf("GET after revoke: %v", err)
+ }
+ defer listRes.Body.Close()
+ var listOut struct {
+ Data []TokenView `json:"data"`
+ }
+ decodeJSON(t, listRes, &listOut)
+ if len(listOut.Data) != 0 {
+ t.Fatalf("expected empty list after revoke, got %d rows", len(listOut.Data))
+ }
+}
+
+// TestRevoke_OtherUsersToken_Returns404 is the existence-oracle guard.
+// A caller MUST NOT be able to learn whether an id belongs to another
+// user via 403-vs-404; the store collapses both to ErrNotFound.
+func TestRevoke_OtherUsersToken_Returns404(t *testing.T) {
+ pool := mustPostgresWithPATSchema(t)
+ alice := seedUser(t, pool, "alice2@example.com", "alice2")
+ bobID := seedUser(t, pool, "bob2@example.com", "bob2")
+
+ // Alice issues a token (we manipulate the store directly to avoid
+ // having to spin up a second server for her).
+ store := NewStore(pool, testPepper)
+ created, err := store.Create(context.Background(), CreateInput{
+ UserID: alice.String(),
+ Name: "alices-token",
+ Scopes: []string{"read"},
+ })
+ if err != nil {
+ t.Fatalf("seed Alice's token: %v", err)
+ }
+
+ // Bob (the principal) tries to revoke Alice's token.
+ url, _, cleanup := newServer(t, pool, bobID.String())
+ defer cleanup()
+ req, _ := http.NewRequest("DELETE", url+"/api/v1/me/tokens/"+created.Token.ID, nil)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("DELETE: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusNotFound {
+ raw, _ := io.ReadAll(res.Body)
+ t.Fatalf("status %d want 404 body=%s", res.StatusCode, raw)
+ }
+
+ // Alice's token must still be active — Bob's failed attempt should
+ // not have flipped a flag.
+ rows, err := store.List(context.Background(), alice.String())
+ if err != nil {
+ t.Fatalf("List Alice: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("expected Alice's token to survive; got %d rows", len(rows))
+ }
+}
+
+// TestAuthRequired_AllEndpoints — no Principal on the context means
+// 401 for every endpoint. The session middleware would normally make
+// this unreachable, but we want a defense-in-depth gate at the
+// handler too.
+func TestAuthRequired_AllEndpoints(t *testing.T) {
+ pool := mustPostgresWithPATSchema(t)
+
+ mux := http.NewServeMux()
+ if err := Mount(mux, "/api/v1/me/tokens", Deps{
+ Pool: pool,
+ Pepper: testPepper,
+ }); err != nil {
+ t.Fatalf("Mount: %v", err)
+ }
+ // No principal-injecting middleware on this server — every call
+ // must hit the gate's 401 branch.
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ cases := []struct {
+ method string
+ path string
+ }{
+ {"GET", "/api/v1/me/tokens"},
+ {"POST", "/api/v1/me/tokens"},
+ {"DELETE", "/api/v1/me/tokens/00000000-0000-7000-8000-000000000000"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.method+" "+tc.path, func(t *testing.T) {
+ req, _ := http.NewRequest(tc.method, srv.URL+tc.path, bytes.NewReader([]byte("{}")))
+ req.Header.Set("Content-Type", "application/json")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusUnauthorized {
+ raw, _ := io.ReadAll(res.Body)
+ t.Fatalf("status %d want 401 body=%s", res.StatusCode, raw)
+ }
+ })
+ }
+}
+
+// TestCreate_RejectsEmptyName covers the input-validation path: an
+// empty name is a 400 before we burn an argon2 hash.
+func TestCreate_RejectsEmptyName(t *testing.T) {
+ pool := mustPostgresWithPATSchema(t)
+ user := seedUser(t, pool, "dave@example.com", "dave")
+ url, _, cleanup := newServer(t, pool, user.String())
+ defer cleanup()
+
+ body, _ := json.Marshal(CreateRequest{Name: " ", Scopes: []string{"read"}})
+ res, err := http.Post(url+"/api/v1/me/tokens", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusBadRequest {
+ raw, _ := io.ReadAll(res.Body)
+ t.Fatalf("status %d want 400 body=%s", res.StatusCode, raw)
+ }
+}
+
+// TestCreate_EmitsAuditEvent — the audit emitter receives an
+// auth.pat.created event with the issued token id as the target.
+func TestCreate_EmitsAuditEvent(t *testing.T) {
+ pool := mustPostgresWithPATSchema(t)
+ user := seedUser(t, pool, "eve@example.com", "eve")
+ url, auditStore, cleanup := newServer(t, pool, user.String())
+ defer cleanup()
+
+ body, _ := json.Marshal(CreateRequest{Name: "audit-test", Scopes: []string{"read"}})
+ res, err := http.Post(url+"/api/v1/me/tokens", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer res.Body.Close()
+ var issued IssuedTokenView
+ decodeJSON(t, res, &issued)
+
+ events, err := auditStore.List(context.Background(), audit.Filter{})
+ if err != nil {
+ t.Fatalf("audit List: %v", err)
+ }
+ if len(events) == 0 {
+ t.Fatal("expected at least one audit event, got none")
+ }
+ found := false
+ for _, e := range events {
+ if e.EventType == EventTokenCreated && e.ResourceID == issued.ID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected %s event for token %s; got %d events", EventTokenCreated, issued.ID, len(events))
+ }
+}
diff --git a/apps/api/internal/auth/pat/store.go b/apps/api/internal/auth/pat/store.go
new file mode 100644
index 0000000..052c156
--- /dev/null
+++ b/apps/api/internal/auth/pat/store.go
@@ -0,0 +1,355 @@
+// Package pat implements the per-user Personal Access Token (PAT)
+// issuance HTTP surface — list, create, revoke — mounted at
+// /api/v1/me/tokens by the API server's main.go.
+//
+// The package is intentionally separate from packages/go/auth/pat,
+// which is the lower-level credential primitive (the bearer-token
+// middleware that resolves "Authorization: Bearer gn_pat_..." into a
+// Principal). Splitting issuance from authentication keeps the admin
+// settings page from importing the middleware and inverting the
+// dependency graph: handlers depend on store, store depends on
+// password+pool, and the bearer middleware is wholly separate.
+//
+// Wire format
+//
+// A token is the literal "gn_pat_" followed by 32 URL-safe base64
+// characters (24 raw bytes from crypto/rand). The "gn_pat_" namespace
+// lets secret-scanners pattern-match a leaked credential without false
+// positives against arbitrary base64. The plaintext is only ever
+// returned by the create handler, exactly once. The database stores
+// only the argon2id PHC encoding of the full plaintext, peppered with
+// GONEXT_AUTH_PEPPER — identical posture to user passwords.
+//
+// Storage
+//
+// Rows land in personal_access_tokens (migration 000026). The list
+// view exposes id, name, prefix, scopes, created_at, last_used_at,
+// expires_at — the hash and plaintext never leave the package. The
+// prefix column is the first 8 chars of the random tail (post the
+// gn_pat_ namespace), surfaced as "gn_pat_AbCdEfGh…" by the admin UI.
+package pat
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/Singleton-Solution/GoNext/packages/go/auth/password"
+)
+
+// Namespace is the mandatory prefix on every PAT plaintext. The "gn_pat_"
+// literal lets log scrubbers, GitHub's secret scanner, and gitleaks
+// pattern-match a leaked credential without false positives against
+// arbitrary base64. Changing this is a breaking change for every
+// downstream scanner — it is a constant for a reason.
+const Namespace = "gn_pat_"
+
+// secretBytes is the entropy in the random tail. 24 bytes → 32 chars
+// under base64.RawURLEncoding, matching the width OWASP recommends for
+// long-lived bearer tokens. The total token width is then
+//
+// len("gn_pat_") + 32 = 39 characters
+//
+// which fits comfortably in a single Authorization header and inside
+// the 64-character ceiling most password managers honour.
+const secretBytes = 24
+
+// secretLen is the encoded length of the random tail.
+const secretLen = 32
+
+// PrefixLen is the on-disk length of the prefix column. The first 8
+// characters of the random tail (post-namespace) are what the list view
+// renders as "gn_pat_AbCdEfGh…". Exposed as a const so the handler
+// doesn't hard-code the slice bound.
+const PrefixLen = 8
+
+// MinTokenLen is the minimum length a string must have to even be
+// considered a candidate token. Used to fail fast on obvious garbage
+// before paying the argon2 cost. Equal to len(Namespace) + secretLen.
+const MinTokenLen = len(Namespace) + secretLen
+
+// Errors returned by the store. Callers compare with errors.Is.
+var (
+ // ErrNotFound is returned by Revoke when no (id, user_id) row
+ // matches. Distinct from "already revoked" (which is a silent
+ // idempotent no-op) so the handler can map to 404.
+ ErrNotFound = errors.New("pat: not found")
+
+ // ErrInvalidName surfaces a caller-side validation failure. The
+ // store re-checks the table's CHECK (length(btrim(name)) > 0)
+ // because relying on the DB constraint to bubble up a 23xxx
+ // SQLSTATE through pgx is brittle.
+ ErrInvalidName = errors.New("pat: name must be non-empty")
+
+ // ErrInvalidUserID is the zero-UUID / empty user-id guard. Belt
+ // and braces: the handler already gates on policy.FromContext but
+ // a future caller path could regress.
+ ErrInvalidUserID = errors.New("pat: user_id is required")
+)
+
+// Token is the in-memory representation of a personal_access_tokens
+// row. It mirrors the table columns 1:1 with the hash deliberately
+// omitted — the store never exposes the hash bytes across the package
+// boundary. The handler turns this into the on-wire TokenView; this
+// type keeps the SQL scan target out of the handler so the wire shape
+// can evolve without touching the SQL.
+type Token struct {
+ ID string
+ UserID string
+ Name string
+ Prefix string
+ Scopes []string
+ CreatedAt time.Time
+ LastUsedAt *time.Time
+ ExpiresAt *time.Time
+ RevokedAt *time.Time
+}
+
+// Store is the Postgres-backed persistence layer for the
+// personal_access_tokens table. It is the only thing in the package
+// that knows the table's column shape; the handler is wire-shape only.
+//
+// Zero value is unusable. Construct with NewStore.
+type Store struct {
+ pool *pgxpool.Pool
+ pepper []byte
+}
+
+// NewStore wraps a pgxpool.Pool and a pepper. Both are required — a
+// nil pool or empty pepper is a wiring bug we surface at boot rather
+// than silently degrading to an in-memory or unsalted hash. The pool's
+// lifecycle stays with the caller; the store never calls Close on it.
+func NewStore(pool *pgxpool.Pool, pepper []byte) *Store {
+ return &Store{pool: pool, pepper: pepper}
+}
+
+// CreateInput is the validated arguments to Create. Splitting the
+// validation from the SQL means the handler can map specific errors
+// (invalid name, invalid scopes) to 400 without sniffing pg error
+// codes.
+type CreateInput struct {
+ UserID string
+ Name string
+ Scopes []string
+ ExpiresAt *time.Time
+}
+
+// Created is the return value of Create. It carries the persisted
+// Token row and — exactly once — the plaintext bearer the operator
+// must save. The plaintext is intentionally not stashed on the Token
+// struct itself; a Token returned by List or any other read path will
+// never have a plaintext.
+type Created struct {
+ Token Token
+ Plaintext string
+}
+
+// Create mints a fresh PAT for input.UserID, hashes it with argon2id
+// + the configured pepper, persists the row, and returns the plaintext
+// once. Subsequent reads of the same row see only the prefix.
+//
+// Errors:
+// - ErrInvalidUserID, ErrInvalidName for caller-side validation.
+// - Any pgx error wrapped with "pat: create: ..." for SQL failures.
+// - Any crypto/rand error wrapped with "pat: random: ..." (vanishingly
+// unlikely; the kernel CSPRNG essentially never fails).
+//
+// Create is safe for concurrent use; the underlying pgxpool handles
+// the connection multiplexing.
+func (s *Store) Create(ctx context.Context, in CreateInput) (Created, error) {
+ if strings.TrimSpace(in.UserID) == "" {
+ return Created{}, ErrInvalidUserID
+ }
+ name := strings.TrimSpace(in.Name)
+ if name == "" {
+ return Created{}, ErrInvalidName
+ }
+
+ plaintext, secret, err := newPlaintext()
+ if err != nil {
+ return Created{}, fmt.Errorf("pat: random: %w", err)
+ }
+
+ encoded, err := password.Hash(plaintext, s.pepper)
+ if err != nil {
+ return Created{}, fmt.Errorf("pat: hash: %w", err)
+ }
+
+ // Copy scopes so a later mutation of the caller's slice can't
+ // affect the persisted row. Costs nothing.
+ scopes := make([]string, 0, len(in.Scopes))
+ for _, sc := range in.Scopes {
+ sc = strings.TrimSpace(sc)
+ if sc == "" {
+ continue
+ }
+ scopes = append(scopes, sc)
+ }
+
+ prefix := secret[:PrefixLen]
+ now := time.Now().UTC()
+
+ const q = `
+INSERT INTO personal_access_tokens (
+ user_id, name, prefix, hash, scopes, created_at, expires_at
+) VALUES ($1, $2, $3, $4, $5, $6, $7)
+RETURNING id, created_at
+`
+ var row Token
+ row.UserID = in.UserID
+ row.Name = name
+ row.Prefix = prefix
+ row.Scopes = scopes
+ row.ExpiresAt = in.ExpiresAt
+ if err := s.pool.QueryRow(ctx, q,
+ in.UserID, name, prefix, []byte(encoded), scopes, now, in.ExpiresAt,
+ ).Scan(&row.ID, &row.CreatedAt); err != nil {
+ return Created{}, fmt.Errorf("pat: create: %w", err)
+ }
+ return Created{Token: row, Plaintext: plaintext}, nil
+}
+
+// List returns the active (revoked_at IS NULL, not expired) tokens
+// for userID, newest first. The hash column is deliberately not
+// selected so the bytes never cross the store boundary.
+//
+// Returns an empty slice (not a nil slice) on success with no rows so
+// the handler can range over the result without a nil check and the
+// JSON envelope is "[]" instead of "null".
+func (s *Store) List(ctx context.Context, userID string) ([]Token, error) {
+ if strings.TrimSpace(userID) == "" {
+ return nil, ErrInvalidUserID
+ }
+ const q = `
+SELECT id, user_id, name, prefix, scopes,
+ created_at, last_used_at, expires_at, revoked_at
+FROM personal_access_tokens
+WHERE user_id = $1
+ AND revoked_at IS NULL
+ AND (expires_at IS NULL OR expires_at > now())
+ORDER BY created_at DESC
+`
+ rows, err := s.pool.Query(ctx, q, userID)
+ if err != nil {
+ return nil, fmt.Errorf("pat: list: %w", err)
+ }
+ defer rows.Close()
+ out := make([]Token, 0)
+ for rows.Next() {
+ var t Token
+ if err := rows.Scan(
+ &t.ID, &t.UserID, &t.Name, &t.Prefix, &t.Scopes,
+ &t.CreatedAt, &t.LastUsedAt, &t.ExpiresAt, &t.RevokedAt,
+ ); err != nil {
+ return nil, fmt.Errorf("pat: list scan: %w", err)
+ }
+ out = append(out, t)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("pat: list iter: %w", err)
+ }
+ return out, nil
+}
+
+// Revoke marks the row identified by (userID, id) as revoked. The
+// userID is the authorisation gate — a caller cannot revoke another
+// user's token by guessing the id. The store collapses both "wrong
+// owner" and "no such id" into ErrNotFound so the handler can return
+// a uniform 404 without an enumeration oracle (a 403 would leak that
+// the id IS valid, just belongs to someone else).
+//
+// Idempotent: a second Revoke against an already-revoked row is a
+// no-op and returns nil. The handler maps that to the same 204 as a
+// first-time revoke; CI scripts that retry on transient errors won't
+// see a 404 they have to special-case.
+func (s *Store) Revoke(ctx context.Context, userID, id string) error {
+ if strings.TrimSpace(userID) == "" {
+ return ErrInvalidUserID
+ }
+ if strings.TrimSpace(id) == "" {
+ return ErrNotFound
+ }
+ const updateQ = `
+UPDATE personal_access_tokens
+SET revoked_at = now()
+WHERE id = $1
+ AND user_id = $2
+ AND revoked_at IS NULL
+`
+ ct, err := s.pool.Exec(ctx, updateQ, id, userID)
+ if err != nil {
+ return fmt.Errorf("pat: revoke: %w", err)
+ }
+ if ct.RowsAffected() == 1 {
+ return nil
+ }
+ // Zero rows affected: either "already revoked" (fine, return nil)
+ // or "row does not exist for this (id, user_id)" (ErrNotFound).
+ // Disambiguate with a cheap EXISTS probe. Note we don't probe by
+ // id-only — that would let a caller learn the id belongs to another
+ // user via a side channel.
+ const existsQ = `SELECT EXISTS(SELECT 1 FROM personal_access_tokens WHERE id = $1 AND user_id = $2)`
+ var found bool
+ if err := s.pool.QueryRow(ctx, existsQ, id, userID).Scan(&found); err != nil {
+ // pgx surfaces "invalid input syntax for type uuid" as a
+ // regular query error when the id isn't a UUID. We collapse
+ // that to ErrNotFound — a non-UUID can never match an existing
+ // row, and surfacing the DB error would be a fingerprint.
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrNotFound
+ }
+ if isInvalidUUID(err) {
+ return ErrNotFound
+ }
+ return fmt.Errorf("pat: revoke exists: %w", err)
+ }
+ if !found {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// isInvalidUUID matches pgx's "invalid input syntax for type uuid"
+// error so we can collapse it to ErrNotFound. Done via substring match
+// because pgx wraps the underlying pgconn.PgError without exporting a
+// sentinel for this case. The string is stable in libpq and unlikely
+// to change.
+func isInvalidUUID(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ return strings.Contains(msg, "invalid input syntax for type uuid") ||
+ strings.Contains(msg, "invalid input syntax for uuid")
+}
+
+// newPlaintext mints a fresh "gn_pat_<32-char-base64>" string from
+// crypto/rand. Returns the full plaintext and the secret portion (the
+// 32 chars after the namespace) so callers can slice the prefix
+// without re-parsing.
+//
+// Uses base64.RawURLEncoding so the secret is safe to drop into a
+// URL or a header without any escaping. The alphabet is the URL-safe
+// 64-char set (A-Z, a-z, 0-9, '-', '_'); no padding because the
+// length is fixed and known.
+func newPlaintext() (plaintext, secret string, err error) {
+ buf := make([]byte, secretBytes)
+ if _, err := rand.Read(buf); err != nil {
+ return "", "", err
+ }
+ secret = base64.RawURLEncoding.EncodeToString(buf)
+ // RawURLEncoding of 24 bytes is always 32 chars, but we belt-and-
+ // brace the length in case a future stdlib change introduces a
+ // trailing newline or similar.
+ if len(secret) != secretLen {
+ return "", "", fmt.Errorf("pat: unexpected encoded length %d, want %d", len(secret), secretLen)
+ }
+ return Namespace + secret, secret, nil
+}
From 5fe90aaaea41f387b06c6b9a8123a6753236a895 Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Thu, 28 May 2026 12:54:11 +0200
Subject: [PATCH 3/4] feat(api): customizer preview + site-editor parts
endpoints (#512)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two endpoints the admin Customizer + Site Editor pages were rendering
against — both 404'd previously.
### POST /api/v1/admin/customizer/preview
Extends existing internal/admin/customizer/handler.go. Body shape
permissive — accepts both \`{settings: {...}}\` (direct override) and
\`{overrides: {...}}\` (envelope) since the admin client posts both.
Returns \`{themeSlug, cssCustomProperties}\` after merging override
onto the active theme manifest. Empty body returns base CSS.
Permissive JSON decode (no DisallowUnknownFields) since preview is
keystroke-grained; strict validation lives on PUT. Gated by
theme.customize.
6 new test cases (base CSS, override applied, envelope variant,
forbidden, unauth, invalid JSON).
### GET/PUT /api/v1/admin/site-editor/parts(/{name})
New package: internal/admin/siteeditor/. File-based — reads/writes
\`themes/{active}/parts/{name}.html\` for theme template parts the
operator edits in-browser.
Part shape: {name, area: header|footer|general, title, content}.
Security: validates name against active theme.json templateParts
declaration (undeclared name → 404 — no arbitrary file writes
allowed). Path-traversal guard rejects /, \\, .., control chars,
uppercase → 400. Atomic write via temp file + rename.
Gated by \`manage_themes\` OR \`theme.edit_parts\` (either grants).
17 tests covering list/get/put, undeclared name 404, traversal flavors
400, unauth 401, forbidden 403, missing-file empty content, MkdirAll
for fresh installs.
### main.go wiring
Both endpoints now go through sub-mux + RequireSession wrap (was
missing on the existing customizer mount — that's why
/api/v1/admin/customizer/active was previously returning 401).
Verified live:
GET /api/v1/admin/site-editor/parts → 200 + header + footer
POST /api/v1/admin/customizer/preview {} → 200 base CSS
POST /api/v1/admin/customizer/preview {paper:#112233} → 200 w/ color flipped
Closes #512.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
apps/api/internal/admin/customizer/handler.go | 125 ++++-
.../internal/admin/customizer/handler_test.go | 116 ++++
apps/api/internal/admin/siteeditor/handler.go | 524 ++++++++++++++++++
.../internal/admin/siteeditor/handler_test.go | 465 ++++++++++++++++
4 files changed, 1227 insertions(+), 3 deletions(-)
create mode 100644 apps/api/internal/admin/siteeditor/handler.go
create mode 100644 apps/api/internal/admin/siteeditor/handler_test.go
diff --git a/apps/api/internal/admin/customizer/handler.go b/apps/api/internal/admin/customizer/handler.go
index 1ddc2da..9d9a1db 100644
--- a/apps/api/internal/admin/customizer/handler.go
+++ b/apps/api/internal/admin/customizer/handler.go
@@ -1,6 +1,7 @@
package customizer
import (
+ "bytes"
"context"
"encoding/json"
"errors"
@@ -77,9 +78,10 @@ type handlers struct {
//
// The route tree:
//
-// GET {base}/active — theme + current overrides
-// PUT {base}/active — validate + save overrides
-// DELETE {base}/active — clear overrides (Reset)
+// GET {base}/active — theme + current overrides
+// PUT {base}/active — validate + save overrides
+// DELETE {base}/active — clear overrides (Reset)
+// POST {base}/preview — preview merged CSS without persisting
//
// All routes are gated by the theme.customize capability.
func Mount(mux *http.ServeMux, base string, deps Deps) error {
@@ -100,6 +102,7 @@ func Mount(mux *http.ServeMux, base string, deps Deps) error {
mux.Handle("GET "+base+"/active", h.gate(h.getActive))
mux.Handle("PUT "+base+"/active", h.gate(h.putActive))
mux.Handle("DELETE "+base+"/active", h.gate(h.deleteActive))
+ mux.Handle("POST "+base+"/preview", h.gate(h.postPreview))
return nil
}
@@ -278,6 +281,122 @@ func (h *handlers) deleteActive(w http.ResponseWriter, r *http.Request, pr polic
w.WriteHeader(http.StatusNoContent)
}
+// PreviewRequest is the POST /preview body. The admin React form posts
+// the in-flight override state straight through; we accept either the
+// override object directly OR a wrapper {"overrides": }
+// because two generations of the admin form sit in production today and
+// re-shipping the API while the UI still uses the old wrapper would
+// strand operators on a 400.
+type PreviewRequest struct {
+ Overrides json.RawMessage `json:"overrides,omitempty"`
+}
+
+// PreviewResponse carries the rendered CSS the iframe applies. The
+// shape stays narrow on purpose — every additional field is something
+// the renderer would have to learn to read.
+type PreviewResponse struct {
+ // ThemeSlug echoes the slug the preview was computed for. The admin
+ // surface uses it to verify the preview matches the theme the form
+ // thinks it's editing (a theme switch mid-edit invalidates the
+ // preview).
+ ThemeSlug string `json:"themeSlug"`
+ // CSSCustomProperties is the ":root { … }" block produced by
+ // EmitCSSCustomProperties on the merged manifest. The empty string is
+ // a legitimate result (e.g. a theme with no tokens at all) — the
+ // renderer treats "" as "render with browser defaults".
+ CSSCustomProperties string `json:"cssCustomProperties"`
+}
+
+// postPreview handles POST /preview. The flow mirrors putActive without
+// the persistence: load the active theme, merge the overrides, run
+// validation, and return the emitted CSS. An empty body is treated as
+// "preview with no overrides" — the operator wants to see what the
+// theme looks like without any of their pending edits, which the admin
+// UI uses to drive the "Reset" preview state.
+//
+// We deliberately don't 400 on validation errors here: previews are
+// keystroke-grained and the form often passes through "partially typed"
+// values. A bad color becomes an empty entry in the merged manifest,
+// not a 400 — the operator's next keystroke usually fixes it. Strict
+// validation lives on PUT, which is the operator's explicit "commit"
+// gesture.
+func (h *handlers) postPreview(w http.ResponseWriter, r *http.Request, _ policy.Principal) {
+ slug, err := h.store.ActiveThemeSlug(r.Context())
+ if err != nil {
+ if errors.Is(err, ErrNoActiveTheme) {
+ router.WriteError(w, http.StatusNotFound, "no_active_theme", "no active theme is configured")
+ return
+ }
+ h.logger.ErrorContext(r.Context(), "admin/customizer: active slug lookup failed", slog.Any("err", err))
+ router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to load active theme")
+ return
+ }
+
+ manifest, err := h.loader(r.Context(), slug)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "admin/customizer: theme load failed",
+ slog.String("slug", slug),
+ slog.Any("err", err),
+ )
+ router.WriteError(w, http.StatusInternalServerError, "theme_load_failed",
+ fmt.Sprintf("failed to load theme %q", slug))
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ router.WriteError(w, http.StatusRequestEntityTooLarge, "body_too_large",
+ fmt.Sprintf("override payload must not exceed %d bytes", maxBodyBytes))
+ return
+ }
+
+ // extractOverrides unwraps an optional `{"overrides": {...}}` envelope
+ // so the admin client can post either the override or the wrapper.
+ overrideBytes := extractOverridesPayload(raw)
+
+ merged := cloneTheme(manifest)
+ // Empty body or empty overrides → preview the unmodified theme.
+ if len(bytes.TrimSpace(overrideBytes)) > 0 && !bytes.Equal(bytes.TrimSpace(overrideBytes), []byte("{}")) && !bytes.Equal(bytes.TrimSpace(overrideBytes), []byte("null")) {
+ // Decode loosely (without DisallowUnknownFields): preview is
+ // best-effort. Unknown keys come from a draft schema in the UI;
+ // dropping them quietly is friendlier than 400'ing on every
+ // keystroke that touches a future field.
+ override := &theme.ThemeJSON{Version: theme.CurrentVersion}
+ if jerr := json.Unmarshal(overrideBytes, override); jerr != nil {
+ router.WriteError(w, http.StatusBadRequest, "invalid_json",
+ "preview overrides are not valid JSON")
+ return
+ }
+ mergeTheme(merged, override)
+ }
+
+ css := merged.EmitCSSCustomProperties()
+ router.WriteJSON(w, http.StatusOK, PreviewResponse{
+ ThemeSlug: slug,
+ CSSCustomProperties: css,
+ })
+}
+
+// extractOverridesPayload unwraps `{"overrides": }` if present,
+// otherwise treats the whole body as the override directly. Returns the
+// raw body unchanged when it can't be parsed as JSON — the caller's
+// strict decode will surface the syntax error with a useful message.
+func extractOverridesPayload(raw []byte) []byte {
+ trimmed := bytes.TrimSpace(raw)
+ if len(trimmed) == 0 {
+ return trimmed
+ }
+ var envelope PreviewRequest
+ if err := json.Unmarshal(trimmed, &envelope); err != nil {
+ return trimmed
+ }
+ if len(envelope.Overrides) > 0 {
+ return envelope.Overrides
+ }
+ return trimmed
+}
+
// writeValidationProblem emits a 400 with the per-path validation
// errors in the body. The shape mirrors what other admin surfaces use
// for field-level errors: a single "code" identifier plus a flat list
diff --git a/apps/api/internal/admin/customizer/handler_test.go b/apps/api/internal/admin/customizer/handler_test.go
index a06676d..9eae8e8 100644
--- a/apps/api/internal/admin/customizer/handler_test.go
+++ b/apps/api/internal/admin/customizer/handler_test.go
@@ -364,3 +364,119 @@ func TestDeleteActive_Idempotent(t *testing.T) {
t.Fatalf("status = %d; want 204", w.Code)
}
}
+
+// TestPostPreview_NoOverridesReturnsBaseCSS verifies the empty-payload
+// path produces the unmodified theme's CSS. The admin "Reset preview"
+// button posts {} to land here.
+func TestPostPreview_NoOverridesReturnsBaseCSS(t *testing.T) {
+ store := NewMemoryStore("gn-hello")
+ router := newRouter(t, store, loaderReturning("gn-hello", baseTheme()), adminPrincipal())
+
+ req := httptest.NewRequest(http.MethodPost, testBase+"/preview", strings.NewReader(`{}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d (body %s); want 200", w.Code, w.Body.String())
+ }
+ var got PreviewResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.ThemeSlug != "gn-hello" {
+ t.Fatalf("ThemeSlug = %q; want gn-hello", got.ThemeSlug)
+ }
+ // Base palette declares 'paper' (#ffffff); the emitted CSS must
+ // reflect that.
+ if !strings.Contains(got.CSSCustomProperties, "--wp-preset--color--paper: #ffffff") {
+ t.Fatalf("base CSS missing paper entry; got %s", got.CSSCustomProperties)
+ }
+}
+
+// TestPostPreview_AppliesOverrides verifies that posted overrides
+// reshape the emitted CSS. The admin form posts the in-flight palette
+// edit through this path on every keystroke.
+func TestPostPreview_AppliesOverrides(t *testing.T) {
+ store := NewMemoryStore("gn-hello")
+ router := newRouter(t, store, loaderReturning("gn-hello", baseTheme()), adminPrincipal())
+
+ body := `{"settings":{"color":{"palette":[{"slug":"paper","name":"Paper","color":"#abcdef"}]}}}`
+ req := httptest.NewRequest(http.MethodPost, testBase+"/preview", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d (body %s); want 200", w.Code, w.Body.String())
+ }
+ var got PreviewResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if !strings.Contains(got.CSSCustomProperties, "--wp-preset--color--paper: #abcdef") {
+ t.Fatalf("preview CSS missing override; got %s", got.CSSCustomProperties)
+ }
+}
+
+// TestPostPreview_AcceptsOverridesEnvelope verifies the {"overrides":
+// {...}} wrapper shape works too — the older admin form generation
+// posts this envelope.
+func TestPostPreview_AcceptsOverridesEnvelope(t *testing.T) {
+ store := NewMemoryStore("gn-hello")
+ router := newRouter(t, store, loaderReturning("gn-hello", baseTheme()), adminPrincipal())
+
+ body := `{"overrides":{"settings":{"color":{"palette":[{"slug":"paper","name":"Paper","color":"#112233"}]}}}}`
+ req := httptest.NewRequest(http.MethodPost, testBase+"/preview", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d (body %s); want 200", w.Code, w.Body.String())
+ }
+ var got PreviewResponse
+ _ = json.Unmarshal(w.Body.Bytes(), &got)
+ if !strings.Contains(got.CSSCustomProperties, "--wp-preset--color--paper: #112233") {
+ t.Fatalf("envelope override not applied; got %s", got.CSSCustomProperties)
+ }
+}
+
+// TestPostPreview_Forbidden ensures the preview surface is gated by the
+// same capability as the rest of customizer.
+func TestPostPreview_Forbidden(t *testing.T) {
+ store := NewMemoryStore("gn-hello")
+ router := newRouter(t, store, loaderReturning("gn-hello", baseTheme()), subscriberPrincipal())
+
+ req := httptest.NewRequest(http.MethodPost, testBase+"/preview", strings.NewReader("{}"))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("status = %d; want 403", w.Code)
+ }
+}
+
+// TestPostPreview_Unauthenticated guards the 401 path.
+func TestPostPreview_Unauthenticated(t *testing.T) {
+ store := NewMemoryStore("gn-hello")
+ router := newRouter(t, store, loaderReturning("gn-hello", baseTheme()), nil)
+
+ req := httptest.NewRequest(http.MethodPost, testBase+"/preview", strings.NewReader("{}"))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d; want 401", w.Code)
+ }
+}
+
+// TestPostPreview_InvalidJSON rejects malformed bodies with 400.
+func TestPostPreview_InvalidJSON(t *testing.T) {
+ store := NewMemoryStore("gn-hello")
+ router := newRouter(t, store, loaderReturning("gn-hello", baseTheme()), adminPrincipal())
+
+ req := httptest.NewRequest(http.MethodPost, testBase+"/preview", strings.NewReader(`{"settings":`))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d; want 400", w.Code)
+ }
+}
diff --git a/apps/api/internal/admin/siteeditor/handler.go b/apps/api/internal/admin/siteeditor/handler.go
new file mode 100644
index 0000000..d684ca1
--- /dev/null
+++ b/apps/api/internal/admin/siteeditor/handler.go
@@ -0,0 +1,524 @@
+// Package siteeditor implements the admin Site Editor REST surface
+// — the file-based variant. Operators editing the header / footer /
+// other declared template parts of the active theme list, fetch, and
+// write the raw HTML/template bytes through these routes.
+//
+// This package is intentionally distinct from
+// apps/api/internal/admin/site_editor — that one persists block-tree
+// overrides in the options table and is wired at /admin/site_editor,
+// while this one writes back to themes/{active}/parts/{name}.html for
+// the simpler "edit the template part on disk" workflow at the URL the
+// admin Site Editor page (apps/admin/.../appearance/site-editor) calls
+// when it falls back to raw content editing.
+//
+// Route tree:
+//
+// GET /api/v1/admin/site-editor/parts — list parts of the active theme
+// GET /api/v1/admin/site-editor/parts/{name} — read one part's content
+// PUT /api/v1/admin/site-editor/parts/{name} — write a part's content back to disk
+//
+// Capability: manage_themes (theme.edit_parts also accepted) — operators
+// without it get 403.
+package siteeditor
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router"
+ "github.com/Singleton-Solution/GoNext/packages/go/policy"
+ "github.com/Singleton-Solution/GoNext/packages/go/theme"
+)
+
+// maxPartBytes caps the per-part request body for PUT writes. The HTML
+// for a typical header/footer template part is under 4 KiB; a generous
+// 256 KiB ceiling stops a hostile client from chewing on disk without
+// bounding what realistic themes can declare.
+const maxPartBytes = 256 * 1024
+
+// ErrNoActiveTheme is returned by ActiveThemeSlug when no theme row
+// exists. Surface as 503 — the editor surface cannot do its job on a
+// fresh install before the seeder has run.
+var ErrNoActiveTheme = errors.New("siteeditor: no active theme")
+
+// ActiveResolver returns the active theme slug. Production wires this
+// to the same options-table read the rest of the admin theme surfaces
+// use; tests supply a closure that returns a fixed slug.
+type ActiveResolver func(ctx context.Context) (string, error)
+
+// ManifestLoader returns the parsed theme manifest for slug. The
+// handler reads the manifest to learn which template parts the theme
+// declares (theme.json#templateParts is the source of truth — a part
+// the theme didn't declare is not editable, even if a file with that
+// name happens to exist).
+type ManifestLoader func(ctx context.Context, slug string) (*theme.ThemeJSON, error)
+
+// Deps is the dependency bag for Mount. All fields except Logger are
+// required.
+type Deps struct {
+ // ThemeDir is the on-disk root that holds installed themes. The
+ // handler joins this with the active slug plus "parts" to resolve
+ // each part file. Typically "./themes" in dev and "/var/lib/gonext/
+ // themes" in containers.
+ ThemeDir string
+
+ // Active resolves the active theme slug on each request. Re-reading
+ // per request means an in-flight theme switch surfaces immediately
+ // without rebooting the API.
+ Active ActiveResolver
+
+ // Loader returns the parsed manifest for slug. Production passes
+ // customizer.FilesystemLoader; tests pass a closure that returns a
+ // hand-built ThemeJSON value.
+ Loader ManifestLoader
+
+ // Policy resolves the manage_themes capability check.
+ Policy policy.Policy
+
+ // Logger receives structured log lines. nil falls back to
+ // slog.Default — useful for tests but production wiring should
+ // always pass a service logger.
+ Logger *slog.Logger
+}
+
+func (d Deps) validate() error {
+ if strings.TrimSpace(d.ThemeDir) == "" {
+ return errors.New("admin/siteeditor: ThemeDir is required")
+ }
+ if d.Active == nil {
+ return errors.New("admin/siteeditor: Active resolver is required")
+ }
+ if d.Loader == nil {
+ return errors.New("admin/siteeditor: Loader is required")
+ }
+ if d.Policy == nil {
+ return errors.New("admin/siteeditor: Policy is required")
+ }
+ return nil
+}
+
+// Handler is the resolved-deps form passed around inside the package.
+// Built once by Mount and shared across the registered routes.
+type Handler struct {
+ themeDir string
+ active ActiveResolver
+ loader ManifestLoader
+ policy policy.Policy
+ logger *slog.Logger
+}
+
+// Mount wires the site-editor routes onto mux under base (typically
+// "/api/v1/admin/site-editor"). Returns an error rather than panicking
+// if Deps is malformed so the boot path surfaces it cleanly.
+func Mount(mux *http.ServeMux, base string, deps Deps) (*Handler, error) {
+ if err := deps.validate(); err != nil {
+ return nil, err
+ }
+ if deps.Logger == nil {
+ deps.Logger = slog.Default()
+ }
+ h := &Handler{
+ themeDir: deps.ThemeDir,
+ active: deps.Active,
+ loader: deps.Loader,
+ policy: deps.Policy,
+ logger: deps.Logger.With(slog.String("component", "admin.siteeditor")),
+ }
+
+ base = strings.TrimRight(base, "/")
+ mux.Handle("GET "+base+"/parts", h.gate(h.list))
+ mux.Handle("GET "+base+"/parts/{name}", h.gate(h.get))
+ mux.Handle("PUT "+base+"/parts/{name}", h.gate(h.put))
+ return h, nil
+}
+
+// gate wraps a handler with the auth + manage_themes capability check.
+// We accept either CapManageThemes or the narrower CapThemeEditParts —
+// operators who hold either may use the site editor.
+func (h *Handler) gate(next func(http.ResponseWriter, *http.Request, policy.Principal)) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ pr, ok := policy.FromContext(r.Context())
+ if !ok {
+ router.WriteError(w, http.StatusUnauthorized, "unauthenticated", "authentication required")
+ return
+ }
+ // Either capability authorizes the surface. manage_themes is the
+ // canonical site-editor cap; theme.edit_parts is the narrower
+ // surface and exists so a constrained role can still write parts.
+ if d := h.policy.Can(pr, policy.CapManageThemes, nil); d.Allowed {
+ next(w, r, pr)
+ return
+ }
+ if d := h.policy.Can(pr, policy.CapThemeEditParts, nil); d.Allowed {
+ next(w, r, pr)
+ return
+ }
+ router.WriteError(w, http.StatusForbidden, "forbidden",
+ "requires manage_themes or theme.edit_parts capability")
+ })
+}
+
+// Part is one template part as returned by the API. The shape is
+// intentionally narrow: name + area metadata + the raw on-disk content.
+type Part struct {
+ // Name is the slug under themes/{slug}/parts/{name}.html. The
+ // renderer keys off this name when resolving the part.
+ Name string `json:"name"`
+
+ // Area is the canonical part area declared by theme.json:
+ // "header", "footer", "sidebar", "uncategorized". The admin UI
+ // groups the sidebar by area when there are many parts.
+ Area string `json:"area"`
+
+ // Title is the human-readable label declared in
+ // theme.json#templateParts. Falls back to Name when empty.
+ Title string `json:"title,omitempty"`
+
+ // Content is the raw HTML/template bytes from
+ // themes/{slug}/parts/{name}.html. Empty string when the file
+ // doesn't exist on disk yet (the editor opens a blank canvas).
+ Content string `json:"content"`
+}
+
+// listResponse is the GET /parts payload.
+type listResponse struct {
+ Theme string `json:"theme"`
+ Parts []Part `json:"parts"`
+}
+
+// list handles GET /parts. It walks the active theme's
+// templateParts declaration, reads each part's on-disk content, and
+// returns the joined list. A missing file is surfaced as an empty
+// Content so the editor can open it as a blank canvas — declared but
+// not yet authored is a valid state.
+func (h *Handler) list(w http.ResponseWriter, r *http.Request, _ policy.Principal) {
+ slug, err := h.active(r.Context())
+ if err != nil {
+ h.writeActiveError(w, r, err)
+ return
+ }
+ if slug == "" {
+ router.WriteError(w, http.StatusServiceUnavailable, "no_active_theme",
+ "no theme is currently active; run the installer or set core.active_theme")
+ return
+ }
+
+ manifest, err := h.loader(r.Context(), slug)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "admin/siteeditor.list: load manifest failed",
+ slog.String("slug", slug),
+ slog.Any("err", err),
+ )
+ router.WriteError(w, http.StatusInternalServerError, "theme_load_failed",
+ fmt.Sprintf("failed to load theme %q", slug))
+ return
+ }
+
+ out := make([]Part, 0, len(manifest.TemplateParts))
+ for _, p := range manifest.TemplateParts {
+ content, rerr := h.readPart(slug, p.Name)
+ if rerr != nil {
+ h.logger.ErrorContext(r.Context(), "admin/siteeditor.list: read part failed",
+ slog.String("slug", slug),
+ slog.String("part", p.Name),
+ slog.Any("err", rerr),
+ )
+ // Surface an empty body so the operator can author the part
+ // from scratch — one corrupt file shouldn't blank the
+ // sidebar.
+ content = ""
+ }
+ out = append(out, Part{
+ Name: p.Name,
+ Area: canonicalArea(p.Area),
+ Title: p.Title,
+ Content: content,
+ })
+ }
+ // Stable ordering so the admin UI's left-rail list doesn't
+ // reshuffle between requests.
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+
+ router.WriteJSON(w, http.StatusOK, listResponse{Theme: slug, Parts: out})
+}
+
+// get handles GET /parts/{name}. Same content read as list, but for a
+// single part — used by the admin editor when opening a specific part
+// without re-fetching the whole list.
+func (h *Handler) get(w http.ResponseWriter, r *http.Request, _ policy.Principal) {
+ name := r.PathValue("name")
+ if err := validatePartName(name); err != nil {
+ router.WriteError(w, http.StatusBadRequest, "invalid_name", err.Error())
+ return
+ }
+
+ slug, err := h.active(r.Context())
+ if err != nil {
+ h.writeActiveError(w, r, err)
+ return
+ }
+ manifest, err := h.loader(r.Context(), slug)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "admin/siteeditor.get: load manifest failed",
+ slog.String("slug", slug),
+ slog.Any("err", err),
+ )
+ router.WriteError(w, http.StatusInternalServerError, "theme_load_failed",
+ fmt.Sprintf("failed to load theme %q", slug))
+ return
+ }
+
+ decl, ok := findTemplatePart(manifest, name)
+ if !ok {
+ router.WriteError(w, http.StatusNotFound, "part_not_found",
+ "the requested part is not declared by the active theme")
+ return
+ }
+
+ content, err := h.readPart(slug, name)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "admin/siteeditor.get: read part failed",
+ slog.String("slug", slug),
+ slog.String("part", name),
+ slog.Any("err", err),
+ )
+ // Same posture as list — a missing file becomes an empty body.
+ content = ""
+ }
+
+ router.WriteJSON(w, http.StatusOK, Part{
+ Name: decl.Name,
+ Area: canonicalArea(decl.Area),
+ Title: decl.Title,
+ Content: content,
+ })
+}
+
+// putRequest is the body shape for PUT /parts/{name}.
+type putRequest struct {
+ Content string `json:"content"`
+}
+
+// put handles PUT /parts/{name}. The handler writes the supplied
+// content back to themes/{slug}/parts/{name}.html, creating the parts
+// directory if it doesn't exist. The write is atomic via a temp file +
+// rename so a partial write on disk full doesn't leave the operator
+// looking at a corrupt header.
+//
+// Validation:
+// - The part name must match the theme's declared templateParts —
+// writing an undeclared name returns 404. This is the security
+// hinge: it bounds the file path to the set of names the theme
+// manifests claim to own.
+// - The part name must be a single segment (no '/', '\\', '..', no
+// leading/trailing whitespace). Path traversal returns 400.
+// - Content size is capped at maxPartBytes.
+func (h *Handler) put(w http.ResponseWriter, r *http.Request, pr policy.Principal) {
+ name := r.PathValue("name")
+ if err := validatePartName(name); err != nil {
+ router.WriteError(w, http.StatusBadRequest, "invalid_name", err.Error())
+ return
+ }
+
+ slug, err := h.active(r.Context())
+ if err != nil {
+ h.writeActiveError(w, r, err)
+ return
+ }
+ manifest, err := h.loader(r.Context(), slug)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "admin/siteeditor.put: load manifest failed",
+ slog.String("slug", slug),
+ slog.Any("err", err),
+ )
+ router.WriteError(w, http.StatusInternalServerError, "theme_load_failed",
+ fmt.Sprintf("failed to load theme %q", slug))
+ return
+ }
+ decl, ok := findTemplatePart(manifest, name)
+ if !ok {
+ router.WriteError(w, http.StatusNotFound, "part_not_found",
+ "the requested part is not declared by the active theme")
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, maxPartBytes)
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ router.WriteError(w, http.StatusRequestEntityTooLarge, "body_too_large",
+ fmt.Sprintf("part payload must not exceed %d bytes", maxPartBytes))
+ return
+ }
+
+ var req putRequest
+ if jerr := json.Unmarshal(raw, &req); jerr != nil {
+ router.WriteError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON")
+ return
+ }
+
+ if err := h.writePart(slug, name, req.Content); err != nil {
+ h.logger.ErrorContext(r.Context(), "admin/siteeditor.put: write failed",
+ slog.String("slug", slug),
+ slog.String("part", name),
+ slog.Any("err", err),
+ )
+ router.WriteError(w, http.StatusInternalServerError, "internal_error",
+ "failed to save part content")
+ return
+ }
+
+ h.logger.InfoContext(r.Context(), "admin/siteeditor: part written",
+ slog.String("slug", slug),
+ slog.String("part", name),
+ slog.String("by", pr.UserID),
+ )
+
+ router.WriteJSON(w, http.StatusOK, Part{
+ Name: decl.Name,
+ Area: canonicalArea(decl.Area),
+ Title: decl.Title,
+ Content: req.Content,
+ })
+}
+
+// readPart reads themes/{slug}/parts/{name}.html. A missing file is
+// not an error — declared-but-not-yet-authored is a legitimate state
+// for the editor, so callers get an empty string instead.
+func (h *Handler) readPart(slug, name string) (string, error) {
+ path := h.partPath(slug, name)
+ b, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return "", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+// writePart writes content to themes/{slug}/parts/{name}.html
+// atomically. We MkdirAll the parts directory first so a freshly
+// installed theme without a parts/ folder still accepts writes.
+func (h *Handler) writePart(slug, name, content string) error {
+ dir := filepath.Join(h.themeDir, slug, "parts")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("siteeditor: mkdir %q: %w", dir, err)
+ }
+ final := filepath.Join(dir, name+".html")
+ tmp, err := os.CreateTemp(dir, "."+name+".html.*.tmp")
+ if err != nil {
+ return fmt.Errorf("siteeditor: create temp: %w", err)
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName) // no-op on success after Rename
+ if _, werr := io.WriteString(tmp, content); werr != nil {
+ _ = tmp.Close()
+ return fmt.Errorf("siteeditor: write temp: %w", werr)
+ }
+ if cerr := tmp.Close(); cerr != nil {
+ return fmt.Errorf("siteeditor: close temp: %w", cerr)
+ }
+ if rerr := os.Rename(tmpName, final); rerr != nil {
+ return fmt.Errorf("siteeditor: rename %q -> %q: %w", tmpName, final, rerr)
+ }
+ return nil
+}
+
+// partPath joins themeDir/{slug}/parts/{name}.html. Caller is
+// responsible for having already validated name (validatePartName +
+// findTemplatePart); this function does NOT re-check.
+func (h *Handler) partPath(slug, name string) string {
+ return filepath.Join(h.themeDir, slug, "parts", name+".html")
+}
+
+// writeActiveError translates the small set of "no active theme"
+// sentinels into HTTP responses. Anything we don't recognise is logged
+// and surfaced as 500.
+func (h *Handler) writeActiveError(w http.ResponseWriter, r *http.Request, err error) {
+ if errors.Is(err, ErrNoActiveTheme) {
+ router.WriteError(w, http.StatusServiceUnavailable, "no_active_theme",
+ "no theme is currently active; run the installer or set core.active_theme")
+ return
+ }
+ h.logger.ErrorContext(r.Context(), "admin/siteeditor: active resolver failed",
+ slog.Any("err", err))
+ router.WriteError(w, http.StatusInternalServerError, "internal_error",
+ "failed to resolve active theme")
+}
+
+// validatePartName guards the file-path projection. The name must be a
+// single path segment with no separators, no parent-directory tokens,
+// and no leading/trailing whitespace. Returns nil iff the name is safe
+// to join with themeDir/{slug}/parts/ without traversing out.
+//
+// We mirror the same lower-case + alphanumeric + '-' / '_' shape the
+// existing site_editor package uses for consistency, but the security
+// check is the explicit absence of '/', '\\', '..', and any control
+// chars.
+func validatePartName(name string) error {
+ if name == "" {
+ return errors.New("name is required")
+ }
+ if strings.TrimSpace(name) != name {
+ return errors.New("name must not contain leading or trailing whitespace")
+ }
+ if strings.ContainsAny(name, `/\`) {
+ return errors.New("name must not contain path separators")
+ }
+ if name == "." || name == ".." || strings.Contains(name, "..") {
+ return errors.New("name must not contain parent-directory tokens")
+ }
+ for _, ch := range name {
+ switch {
+ case ch >= 'a' && ch <= 'z':
+ case ch >= '0' && ch <= '9':
+ case ch == '-' || ch == '_':
+ default:
+ return errors.New("name must be lower-case alphanumeric with '-' or '_'")
+ }
+ }
+ return nil
+}
+
+// findTemplatePart returns the TemplatePartDef for name iff the theme
+// declares it. The boolean is the "is declared" flag; callers map
+// false to 404.
+func findTemplatePart(t *theme.ThemeJSON, name string) (theme.TemplatePartDef, bool) {
+ if t == nil {
+ return theme.TemplatePartDef{}, false
+ }
+ for _, p := range t.TemplateParts {
+ if p.Name == name {
+ return p, true
+ }
+ }
+ return theme.TemplatePartDef{}, false
+}
+
+// canonicalArea normalizes the theme.json#templateParts.area value into
+// the response shape callers expect. The admin UI keys its sidebar by
+// the three canonical buckets — "header", "footer", "general" — so an
+// empty or unrecognised area becomes "general" in the response.
+//
+// "uncategorized" (the theme.json default for non-header/non-footer
+// parts) collapses to "general" too, because the admin UI only knows
+// the three labels and a fourth would render as an empty group header.
+func canonicalArea(area string) string {
+ switch strings.ToLower(strings.TrimSpace(area)) {
+ case "header":
+ return "header"
+ case "footer":
+ return "footer"
+ default:
+ return "general"
+ }
+}
diff --git a/apps/api/internal/admin/siteeditor/handler_test.go b/apps/api/internal/admin/siteeditor/handler_test.go
new file mode 100644
index 0000000..f683724
--- /dev/null
+++ b/apps/api/internal/admin/siteeditor/handler_test.go
@@ -0,0 +1,465 @@
+package siteeditor
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Singleton-Solution/GoNext/packages/go/policy"
+ "github.com/Singleton-Solution/GoNext/packages/go/theme"
+)
+
+const testBase = "/api/v1/admin/site-editor"
+
+// adminPrincipal returns a Principal whose roles grant manage_themes.
+func adminPrincipal() *policy.Principal {
+ return &policy.Principal{UserID: "user:admin", Roles: []policy.Role{policy.RoleAdmin}}
+}
+
+// subscriberPrincipal returns a Principal whose roles grant nothing the
+// site editor cares about. Used by the 403 test.
+func subscriberPrincipal() *policy.Principal {
+ return &policy.Principal{UserID: "user:joe", Roles: []policy.Role{policy.RoleSubscriber}}
+}
+
+// fixtureTheme returns the manifest the tests share. Declares two
+// template parts (header + footer) so the editor surface has something
+// to enumerate.
+func fixtureTheme() *theme.ThemeJSON {
+ return &theme.ThemeJSON{
+ Version: theme.CurrentVersion,
+ Title: "gn-test",
+ TemplateParts: []theme.TemplatePartDef{
+ {Name: "header", Title: "Header", Area: "header"},
+ {Name: "footer", Title: "Footer", Area: "footer"},
+ },
+ }
+}
+
+// seedThemeDir lays down a temp directory with the structure
+// $TMP/{slug}/parts/{header,footer}.html and returns the root path.
+// Each part starts with a short marker so the read tests can assert
+// what they're getting back.
+func seedThemeDir(t *testing.T, slug string) string {
+ t.Helper()
+ root := t.TempDir()
+ partsDir := filepath.Join(root, slug, "parts")
+ if err := os.MkdirAll(partsDir, 0o755); err != nil {
+ t.Fatalf("mkdir parts: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(partsDir, "header.html"),
+ []byte("HEADER FIXTURE"), 0o644); err != nil {
+ t.Fatalf("seed header: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(partsDir, "footer.html"),
+ []byte(""), 0o644); err != nil {
+ t.Fatalf("seed footer: %v", err)
+ }
+ return root
+}
+
+// newRouter returns an *http.ServeMux wrapped in an inline middleware
+// that stashes principal on the context. Mirrors what production's auth
+// middleware does for real requests.
+func newRouter(t *testing.T, themeDir, slug string, manifest *theme.ThemeJSON, principal *policy.Principal) http.Handler {
+ t.Helper()
+ mux := http.NewServeMux()
+ deps := Deps{
+ ThemeDir: themeDir,
+ Active: func(context.Context) (string, error) {
+ if slug == "" {
+ return "", ErrNoActiveTheme
+ }
+ return slug, nil
+ },
+ Loader: func(_ context.Context, requested string) (*theme.ThemeJSON, error) {
+ if requested != slug {
+ return nil, ErrNoActiveTheme
+ }
+ return manifest, nil
+ },
+ Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()),
+ }
+ if _, err := Mount(mux, testBase, deps); err != nil {
+ t.Fatalf("Mount: %v", err)
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if principal != nil {
+ r = r.WithContext(policy.WithPrincipal(r.Context(), *principal))
+ }
+ mux.ServeHTTP(w, r)
+ })
+}
+
+// TestMount_Validation covers the boot-time errors callers see when
+// Deps is malformed.
+func TestMount_Validation(t *testing.T) {
+ pol := policy.NewBasicPolicy(policy.DefaultRoleCapabilities())
+ active := func(context.Context) (string, error) { return "x", nil }
+ loader := func(context.Context, string) (*theme.ThemeJSON, error) {
+ return fixtureTheme(), nil
+ }
+
+ cases := map[string]Deps{
+ "missing_dir": {Active: active, Loader: loader, Policy: pol},
+ "missing_active": {ThemeDir: "/tmp", Loader: loader, Policy: pol},
+ "missing_loader": {ThemeDir: "/tmp", Active: active, Policy: pol},
+ "missing_policy": {ThemeDir: "/tmp", Active: active, Loader: loader},
+ }
+ for name, d := range cases {
+ t.Run(name, func(t *testing.T) {
+ mux := http.NewServeMux()
+ if _, err := Mount(mux, testBase, d); err == nil {
+ t.Fatalf("expected Mount to fail")
+ }
+ })
+ }
+
+ t.Run("ok", func(t *testing.T) {
+ mux := http.NewServeMux()
+ if _, err := Mount(mux, testBase, Deps{
+ ThemeDir: "/tmp", Active: active, Loader: loader, Policy: pol,
+ }); err != nil {
+ t.Fatalf("Mount(ok): %v", err)
+ }
+ })
+}
+
+// TestListParts_HappyPath verifies the GET /parts response.
+func TestListParts_HappyPath(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), adminPrincipal())
+
+ req := httptest.NewRequest(http.MethodGet, testBase+"/parts", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d (body %s); want 200", w.Code, w.Body.String())
+ }
+
+ var got listResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.Theme != "gn-test" {
+ t.Fatalf("Theme = %q; want gn-test", got.Theme)
+ }
+ if len(got.Parts) != 2 {
+ t.Fatalf("Parts = %d; want 2", len(got.Parts))
+ }
+ // Sorted alphabetically -> footer first.
+ if got.Parts[0].Name != "footer" {
+ t.Fatalf("Parts[0].Name = %q; want footer", got.Parts[0].Name)
+ }
+ if !strings.Contains(got.Parts[0].Content, "FOOTER FIXTURE") {
+ t.Fatalf("Parts[0].Content missing fixture: %s", got.Parts[0].Content)
+ }
+ if got.Parts[0].Area != "footer" {
+ t.Fatalf("Parts[0].Area = %q; want footer", got.Parts[0].Area)
+ }
+ if got.Parts[1].Name != "header" {
+ t.Fatalf("Parts[1].Name = %q; want header", got.Parts[1].Name)
+ }
+ if !strings.Contains(got.Parts[1].Content, "HEADER FIXTURE") {
+ t.Fatalf("Parts[1].Content missing fixture: %s", got.Parts[1].Content)
+ }
+}
+
+// TestListParts_NoActiveTheme covers the fresh-deploy case.
+func TestListParts_NoActiveTheme(t *testing.T) {
+ dir := t.TempDir()
+ router := newRouter(t, dir, "", fixtureTheme(), adminPrincipal())
+
+ req := httptest.NewRequest(http.MethodGet, testBase+"/parts", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d; want 503", w.Code)
+ }
+}
+
+// TestListParts_Forbidden ensures non-admin reads are refused.
+func TestListParts_Forbidden(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), subscriberPrincipal())
+
+ req := httptest.NewRequest(http.MethodGet, testBase+"/parts", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("status = %d; want 403", w.Code)
+ }
+}
+
+// TestListParts_Unauthenticated guards the gate's 401 path.
+func TestListParts_Unauthenticated(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), nil)
+
+ req := httptest.NewRequest(http.MethodGet, testBase+"/parts", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d; want 401", w.Code)
+ }
+}
+
+// TestGetPart_HappyPath verifies fetching a single declared part.
+func TestGetPart_HappyPath(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), adminPrincipal())
+
+ req := httptest.NewRequest(http.MethodGet, testBase+"/parts/header", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200", w.Code)
+ }
+ var got Part
+ if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if got.Name != "header" {
+ t.Fatalf("Name = %q; want header", got.Name)
+ }
+ if !strings.Contains(got.Content, "HEADER FIXTURE") {
+ t.Fatalf("Content missing fixture: %s", got.Content)
+ }
+}
+
+// TestGetPart_UndeclaredName returns 404 even if a file with the name
+// exists on disk — the theme's declaration is the source of truth.
+func TestGetPart_UndeclaredName(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ // Drop a rogue file. The handler must not surface it.
+ if err := os.WriteFile(filepath.Join(dir, "gn-test", "parts", "secret.html"),
+ []byte("nope"), 0o644); err != nil {
+ t.Fatalf("seed rogue: %v", err)
+ }
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), adminPrincipal())
+
+ req := httptest.NewRequest(http.MethodGet, testBase+"/parts/secret", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d (body %s); want 404", w.Code, w.Body.String())
+ }
+}
+
+// TestGetPart_PathTraversal returns 400 — the name must be a single
+// segment. The browser would already URL-encode '/' but a hostile
+// client could omit that, so we reject explicitly.
+func TestGetPart_PathTraversal(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), adminPrincipal())
+
+ // Use a name with parent-directory tokens AFTER URL decoding to
+ // confirm we reject. The router won't route ".." through path
+ // values without encoding, but encoded variants land in PathValue.
+ cases := []string{
+ "..%2Fetc", // ../etc with encoded slash → name becomes "../etc"
+ "%2E%2E%2Fpwd", // ../pwd fully encoded
+ }
+ for _, raw := range cases {
+ t.Run(raw, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, testBase+"/parts/"+raw, nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("raw=%q status = %d (body %s); want 400", raw, w.Code, w.Body.String())
+ }
+ })
+ }
+}
+
+// TestPutPart_WritesFile verifies the happy-path PUT — content goes to
+// disk and a re-read sees it.
+func TestPutPart_WritesFile(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), adminPrincipal())
+
+ body := `{"content":"EDITED"}`
+ req := httptest.NewRequest(http.MethodPut, testBase+"/parts/header",
+ strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d (body %s); want 200", w.Code, w.Body.String())
+ }
+
+ // Disk reflects the write.
+ got, err := os.ReadFile(filepath.Join(dir, "gn-test", "parts", "header.html"))
+ if err != nil {
+ t.Fatalf("read back: %v", err)
+ }
+ if string(got) != "EDITED" {
+ t.Fatalf("file = %q; want edited", string(got))
+ }
+
+ // And a follow-up GET surfaces the new content.
+ req = httptest.NewRequest(http.MethodGet, testBase+"/parts/header", nil)
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("re-read status = %d", w.Code)
+ }
+ var part Part
+ _ = json.Unmarshal(w.Body.Bytes(), &part)
+ if part.Content != "EDITED" {
+ t.Fatalf("re-read content = %q; want edited", part.Content)
+ }
+}
+
+// TestPutPart_CreatesMissingFile verifies that a declared part without
+// a file on disk yet can be written through the editor — the parts
+// directory is mkdir-all'd if needed.
+func TestPutPart_CreatesMissingFile(t *testing.T) {
+ root := t.TempDir()
+ // Don't seed any parts; the manifest declares them but disk is empty.
+ router := newRouter(t, root, "gn-test", fixtureTheme(), adminPrincipal())
+
+ body := `{"content":""}`
+ req := httptest.NewRequest(http.MethodPut, testBase+"/parts/footer",
+ strings.NewReader(body))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d (body %s); want 200", w.Code, w.Body.String())
+ }
+
+ got, err := os.ReadFile(filepath.Join(root, "gn-test", "parts", "footer.html"))
+ if err != nil {
+ t.Fatalf("read back: %v", err)
+ }
+ if string(got) != "" {
+ t.Fatalf("file = %q", string(got))
+ }
+}
+
+// TestPutPart_UndeclaredName returns 404 — a write must target a part
+// the theme declares. This is the security hinge against a write-only
+// graveyard.
+func TestPutPart_UndeclaredName(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), adminPrincipal())
+
+ body := `{"content":"x"}`
+ req := httptest.NewRequest(http.MethodPut, testBase+"/parts/sidebar",
+ strings.NewReader(body))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d (body %s); want 404", w.Code, w.Body.String())
+ }
+ // No file should have been created.
+ if _, err := os.Stat(filepath.Join(dir, "gn-test", "parts", "sidebar.html")); !os.IsNotExist(err) {
+ t.Fatalf("file created despite 404: %v", err)
+ }
+}
+
+// TestPutPart_PathTraversal rejects ".."-laden names. Even if the
+// theme manifest mentioned one (which it shouldn't), the
+// validatePartName check would still catch it.
+func TestPutPart_PathTraversal(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), adminPrincipal())
+
+ body := `{"content":"x"}`
+ req := httptest.NewRequest(http.MethodPut, testBase+"/parts/..%2Fevil",
+ strings.NewReader(body))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d; want 400", w.Code)
+ }
+}
+
+// TestPutPart_InvalidJSON rejects malformed bodies with 400.
+func TestPutPart_InvalidJSON(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), adminPrincipal())
+
+ req := httptest.NewRequest(http.MethodPut, testBase+"/parts/header",
+ strings.NewReader(`not json`))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d; want 400", w.Code)
+ }
+}
+
+// TestPutPart_Forbidden ensures non-admin writes are refused.
+func TestPutPart_Forbidden(t *testing.T) {
+ dir := seedThemeDir(t, "gn-test")
+ router := newRouter(t, dir, "gn-test", fixtureTheme(), subscriberPrincipal())
+
+ body := `{"content":"x"}`
+ req := httptest.NewRequest(http.MethodPut, testBase+"/parts/header",
+ strings.NewReader(body))
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("status = %d; want 403", w.Code)
+ }
+}
+
+// TestValidatePartName_Rejects covers the edge inputs the path-traversal
+// test relies on — verifies the standalone validator behaves the way the
+// handler tests assume.
+func TestValidatePartName_Rejects(t *testing.T) {
+ bad := []string{
+ "",
+ "..",
+ ".",
+ "../etc",
+ "foo/bar",
+ "foo\\bar",
+ "FOO", // case
+ " foo ", // whitespace
+ "foo.bar",
+ "foo..bar",
+ }
+ for _, name := range bad {
+ t.Run(name, func(t *testing.T) {
+ if err := validatePartName(name); err == nil {
+ t.Fatalf("expected error for %q", name)
+ }
+ })
+ }
+ good := []string{"header", "footer", "post-meta", "sidebar_left", "abc123"}
+ for _, name := range good {
+ t.Run(name, func(t *testing.T) {
+ if err := validatePartName(name); err != nil {
+ t.Fatalf("unexpected error for %q: %v", name, err)
+ }
+ })
+ }
+}
+
+// TestCanonicalArea folds the various theme.json area shapes into the
+// three buckets the admin UI knows.
+func TestCanonicalArea(t *testing.T) {
+ cases := map[string]string{
+ "header": "header",
+ "HEADER": "header",
+ "footer": "footer",
+ "uncategorized": "general",
+ "": "general",
+ "sidebar": "general",
+ " ": "general",
+ }
+ for in, want := range cases {
+ if got := canonicalArea(in); got != want {
+ t.Errorf("canonicalArea(%q) = %q; want %q", in, got, want)
+ }
+ }
+}
From d849d004a5de4dab5940295820ff77000854e1ea Mon Sep 17 00:00:00 2001
From: Tayeb Mokni
Date: Thu, 28 May 2026 13:02:48 +0200
Subject: [PATCH 4/4] feat(admin+api): wire admin Pages module to real API
(#506)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pages list was hardcoded \`SEED_PAGES\` and the detail save handler was
\`console.log\`. Replace both with real CRUD against the existing
/api/v1/posts surface.
### API: post_type filter
apps/api/internal/rest/posts/model.go grows a \`PostType\` field on
ListFilter. handlers.go's parseListQuery accepts \`?post_type=post|page\`
with closed-set validation (anything else → 400 invalid_post_type).
list() resolves the discriminator at request time: filter override
wins over the mount's default PostType.
This lets the single /api/v1/posts mount serve both Posts list and
Pages list — no second mount required.
### Admin Pages list
apps/admin/src/app/(authenticated)/pages/page.tsx:
- Drops SEED_PAGES entirely.
- Server Component fetching \`/api/v1/posts?post_type=page&status=any\`
via serverApiFetch (cookie-forwarding).
- Exported \`adaptApiPage\` and \`ApiPagePost\` types — testable in
isolation.
- Status normalization (UI \`published\` ↔ API \`publish\`).
- Updated-at fallback chain: updated_at → published_at → created_at.
- Empty/error states stay inside the table card so layout doesn't
shift.
### Admin Pages detail
apps/admin/src/app/(authenticated)/pages/[id]/page.tsx:
- Replaced console.log save with \`api.patch('/api/v1/posts/{id}',
{title, slug, status})\`.
- UI status maps at the boundary (\`publish\` → \`published\` on the wire).
- Try/catch with ApiError branch surfaces an amber error banner.
- Success indicator: 3s "Saved" pip next to the Save button.
- Block editor button stays disabled (separate issue).
### Tests
22 new tests across pages/page + pages/[id]/page covering: adapter
status normalization, updated-at fallback, untitled placeholder,
slug passthrough, PATCH URL + body shape, status mapping, ApiError
path, generic Error path. 638/638 admin tests pass.
API: TestList_FilterByPostType + TestList_FilterByPostType_InvalidRejected.
### Known follow-up
The /pages/new flow built in PR #528 POSTs {post_type:'page'} in the
body but the create handler is hard-coded to PostTypePost (ignores
body). New pages today insert as post_type='post' and disappear from
the pages list. Body-override on create is a small follow-up.
Closes #506 (list + save). Create-side gap tracked separately.
Co-Authored-By: Claude Opus 4.7
Signed-off-by: Tayeb Mokni
---
.../[id]/__snapshots__/page.test.tsx.snap | 3 +-
.../(authenticated)/pages/[id]/page.test.tsx | 119 ++++-
.../app/(authenticated)/pages/[id]/page.tsx | 128 +++++-
.../pages/__snapshots__/page.test.tsx.snap | 55 +--
.../app/(authenticated)/pages/page.test.tsx | 123 ++++--
.../src/app/(authenticated)/pages/page.tsx | 415 +++++++++++-------
apps/api/internal/rest/posts/handlers.go | 23 +-
apps/api/internal/rest/posts/handlers_test.go | 41 ++
apps/api/internal/rest/posts/model.go | 12 +
9 files changed, 661 insertions(+), 258 deletions(-)
diff --git a/apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap b/apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap
index 4e2a42e..3864267 100644
--- a/apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap
+++ b/apps/admin/src/app/(authenticated)/pages/[id]/__snapshots__/page.test.tsx.snap
@@ -54,7 +54,7 @@ exports[`Page detail page > matches the page-head snapshot 1`] = `
+ Couldn't load pages ({error}). Try reloading; if the
+ problem persists the API may not be reachable yet.
+
+ ) : rows.length === 0 ? (
+
+ No pages yet. Create one to get started.
+
+ ) : (
+
+
+
+
+ Title
+
+
+ Slug
+
+
+ Status
+
+
+ Updated
+
+
+ Actions
+
- ))}
-
-
+
+
+ {rows.map((page) => (
+
+
+
+ {page.title}
+
+
+
+ {page.slug || '—'}
+
+
+
+
+
+ {page.updatedAt || '—'}
+
+
+
+ Edit →
+
+
+
+ ))}
+
+
+ )}
- Showing {SEED_PAGES.length} of {SEED_PAGES.length}
+ Showing {rows.length} of {total}
+ >
+ );
+}
+
+/** Loading skeleton for the Suspense fallback. Same shape as the
+ * empty/error states so the layout doesn't shift on first paint. */
+function PagesSkeleton(): ReactElement {
+ return (
+
+ Evergreen content — about, contact, policy. Edit metadata or open
+ the block editor for layout-heavy pages.
+
+
+
+
+
+ }>
+
+
);
}
diff --git a/apps/api/internal/rest/posts/handlers.go b/apps/api/internal/rest/posts/handlers.go
index fc0dded..ea8c219 100644
--- a/apps/api/internal/rest/posts/handlers.go
+++ b/apps/api/internal/rest/posts/handlers.go
@@ -119,7 +119,16 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request, pr policy.Princi
}
}
- rows, err := h.store.List(r.Context(), h.postType, filter)
+ // post_type discriminator: client may override the mount default
+ // via ?post_type= so a single /api/v1/posts mount can serve both
+ // posts and pages (the /api/v1/pages mount isn't wired yet — see
+ // the PostType field doc on ListFilter).
+ postType := h.postType
+ if filter.PostType != "" {
+ postType = filter.PostType
+ }
+
+ rows, err := h.store.List(r.Context(), postType, filter)
if err != nil {
h.logger.ErrorContext(r.Context(), "posts.list: store error", slog.Any("err", err))
router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to list posts")
@@ -171,6 +180,18 @@ func parseListQuery(r *http.Request) (ListFilter, error) {
f.AuthorID = q.Get("author")
f.Search = q.Get("search")
+ // Optional post_type override. Closed set ("post", "page") matches
+ // the two built-in types from migration 000003 — refusing anything
+ // else means a caller can't pivot the list onto a CPT it shouldn't
+ // see by guessing a slug. Empty falls back to the mount default in
+ // the handler.
+ if pt := q.Get("post_type"); pt != "" {
+ if pt != PostTypePost && pt != PostTypePage {
+ return f, validation{Code: "invalid_post_type", Detail: fmt.Sprintf("unknown post_type %q", pt)}
+ }
+ f.PostType = pt
+ }
+
after := q.Get("after")
if after != "" {
decoded, err := router.ParseCursor(after)
diff --git a/apps/api/internal/rest/posts/handlers_test.go b/apps/api/internal/rest/posts/handlers_test.go
index 9991e14..f828be8 100644
--- a/apps/api/internal/rest/posts/handlers_test.go
+++ b/apps/api/internal/rest/posts/handlers_test.go
@@ -446,6 +446,47 @@ func TestList_SearchByTitle(t *testing.T) {
}
}
+// TestList_FilterByPostType is the regression test for the admin Pages
+// list (issue #506). The /api/v1/posts mount is hard-coded to
+// PostTypePost, but admin/pages queries /api/v1/posts?post_type=page —
+// the handler must honor that override and return only page rows.
+func TestList_FilterByPostType(t *testing.T) {
+ t.Parallel()
+ h := newHarness(t, PostTypePost)
+ pr := authorPrincipal("u1")
+
+ postTitle := "A Post"
+ pageTitle := "About Us"
+ h.store.Create(context.Background(), PostTypePost, "u1", CreateInput{Title: &postTitle})
+ h.store.Create(context.Background(), PostTypePage, "u1", CreateInput{Title: &pageTitle})
+
+ req := httptest.NewRequest("GET", h.base+"?post_type=page", nil)
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ var page router.Page[Post]
+ decodeJSON(t, rec, &page)
+ if len(page.Data) != 1 || page.Data[0].Title != "About Us" || page.Data[0].PostType != PostTypePage {
+ t.Errorf("data = %+v; want a single page row", page.Data)
+ }
+}
+
+// TestList_FilterByPostType_InvalidRejected guards the closed-set
+// validation: an unknown post_type is a 400, not a silent fall-through
+// to the mount default.
+func TestList_FilterByPostType_InvalidRejected(t *testing.T) {
+ t.Parallel()
+ h := newHarness(t, PostTypePost)
+ pr := authorPrincipal("u1")
+
+ req := httptest.NewRequest("GET", h.base+"?post_type=attachment", nil)
+ rec := h.do(req, &pr)
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("status = %d, want 400; body = %s", rec.Code, rec.Body.String())
+ }
+}
+
// -----------------------------------------------------------------------------
// UPDATE
// -----------------------------------------------------------------------------
diff --git a/apps/api/internal/rest/posts/model.go b/apps/api/internal/rest/posts/model.go
index 10395e0..2ea48e5 100644
--- a/apps/api/internal/rest/posts/model.go
+++ b/apps/api/internal/rest/posts/model.go
@@ -103,6 +103,18 @@ type ListFilter struct {
Search string // free-text against title (ILIKE)
After string // cursor: posts.id strictly greater than this UUID
Limit int // page size; clamped to [1, MaxListLimit] by the handler
+
+ // PostType, when non-empty, OVERRIDES the mount's hardcoded post
+ // type discriminator for this single request. Empty means "use the
+ // mount default" (the typical case).
+ //
+ // This exists so a single mount (today, /api/v1/posts) can serve
+ // both posts and pages — admin Pages screens query
+ // /api/v1/posts?post_type=page rather than depending on a separate
+ // /api/v1/pages mount that isn't wired yet. The handler validates
+ // the value against the closed {"post","page"} set before stuffing
+ // it here, so a malicious request can't read a CPT it shouldn't.
+ PostType string
}
// MaxListLimit is the hard upper bound on a single page of results.