Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,19 @@ GONEXT_AUTH_CSRF_SECRET=change-me-32-bytes-dev-only-CCCCCCCCCC==
# and dev hosts stay non-indexable by default).
# Default: (GONEXT_ENV == production)
# GONEXT_PUBLIC_SITE_ALLOW_INDEX=true

# Origin of the apps/web Next.js renderer used for outbound ISR cache
# invalidation hooks. When a post or page is published, the REST surface
# POSTs to {URL}/api/revalidate?path=...&secret=... so Next.js can clear
# its incremental-static-regeneration cache. Empty disables the hook
# (chassis-without-renderer deployments).
# Default: unset
# GONEXT_NEXT_REVALIDATE_URL=https://example.com

# Shared secret the Next.js /api/revalidate route handler validates
# before clearing its cache. Sent as the `secret` query parameter on
# the outbound POST. Empty disables the hook (same shape as an empty
# URL). Treat as a deployment secret; rotate alongside the apps/web
# matching value.
# Default: unset
# GONEXT_NEXT_REVALIDATE_SECRET=replace-with-a-strong-random-string
31 changes: 26 additions & 5 deletions apps/api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import (
"github.com/Singleton-Solution/GoNext/packages/go/session"
"github.com/Singleton-Solution/GoNext/packages/go/shutdown"
"github.com/Singleton-Solution/GoNext/packages/go/theme/seed"
"github.com/Singleton-Solution/GoNext/packages/go/webhooks/revalidate"
)

const serviceName = "api"
Expand Down Expand Up @@ -785,12 +786,32 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se
postsStore = restposts.NewMemoryStore()
}
postsPolicy := policy.NewBasicPolicy(policy.DefaultRoleCapabilities())

// ISR revalidation client (#86). Both env vars must be set for the
// client to actually fire; an unset deployment is the
// chassis-without-Next.js case and the client's Notify becomes a
// no-op. We log enabled-ness once at boot so operators can confirm
// from the structured log stream whether ISR hooks are wired.
revalidateClient := revalidate.New(
cfg.PublicSite.NextRevalidateURL,
cfg.PublicSite.NextRevalidateSecret,
revalidate.WithLogger(logger),
)
if revalidateClient.Enabled() {
logger.Info("rest/posts: ISR revalidate hook enabled",
slog.String("base", cfg.PublicSite.NextRevalidateURL),
)
} else {
logger.Info("rest/posts: ISR revalidate hook disabled (GONEXT_NEXT_REVALIDATE_URL / _SECRET unset)")
}

if err := restposts.Mount(mux, "/api/v1/posts", restposts.Deps{
Store: postsStore,
Policy: postsPolicy,
Audit: auditEmitter,
Logger: logger,
PostType: restposts.PostTypePost,
Store: postsStore,
Policy: postsPolicy,
Audit: auditEmitter,
Logger: logger,
PostType: restposts.PostTypePost,
Revalidate: revalidateClient,
}); err != nil {
logger.Warn("rest/posts: failed to mount", slog.Any("err", err))
} else {
Expand Down
21 changes: 21 additions & 0 deletions apps/api/internal/rest/posts/deps.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
package posts

import (
"context"
"errors"
"log/slog"

"github.com/Singleton-Solution/GoNext/packages/go/audit"
"github.com/Singleton-Solution/GoNext/packages/go/policy"
)

// RevalidateNotifier is the surface the posts handler depends on to
// fire ISR revalidation hooks at the public renderer (apps/web).
// Production wires a *revalidate.Client; tests use nil (the handler
// gracefully no-ops) or a stub that records calls.
//
// The interface is deliberately tiny — Notify and NotifyMany — so
// tests don't have to import packages/go/webhooks/revalidate just to
// build a fake. See packages/go/webhooks/revalidate for the contract.
type RevalidateNotifier interface {
Notify(ctx context.Context, path string) error
NotifyMany(ctx context.Context, paths []string) error
}

// PostTypePost is the value for /api/v1/posts mounts.
const PostTypePost = "post"

Expand Down Expand Up @@ -46,6 +60,13 @@ type Deps struct {
// sets PostTypePage. The discriminator is reflected in capability
// resolution (CapEditPosts vs CapEditPages) and in every store call.
PostType string

// Revalidate, when non-nil, receives best-effort ISR cache
// invalidation hooks after a successful publish (create or
// transition-to-published). nil disables the hook entirely — the
// handler treats this as the chassis-without-apps/web deployment
// case and falls back to no-op behavior. Issue #86.
Revalidate RevalidateNotifier
}

// validate is called by [Mount] to fail fast on misconfigured wiring.
Expand Down
94 changes: 82 additions & 12 deletions apps/api/internal/rest/posts/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,13 @@ func Mount(mux *http.ServeMux, base string, deps Deps) error {
deps.Logger = slog.Default()
}
h := &handlers{
store: deps.Store,
policy: deps.Policy,
audit: deps.Audit,
logger: deps.Logger,
postType: deps.PostType,
caps: capsFor(deps.PostType),
store: deps.Store,
policy: deps.Policy,
audit: deps.Audit,
logger: deps.Logger,
postType: deps.PostType,
caps: capsFor(deps.PostType),
revalidate: deps.Revalidate,
}

mux.Handle("GET "+base, h.requireAuth(h.list))
Expand All @@ -69,12 +70,13 @@ func Mount(mux *http.ServeMux, base string, deps Deps) error {
// handlers carries the resolved dependencies for a single mount. One
// instance per call to Mount; no global state.
type handlers struct {
store Store
policy policy.Policy
audit *audit.Emitter
logger *slog.Logger
postType string
caps capabilitySet
store Store
policy policy.Policy
audit *audit.Emitter
logger *slog.Logger
postType string
caps capabilitySet
revalidate RevalidateNotifier
}

// requireAuth wraps a handler with the principal-presence guard.
Expand Down Expand Up @@ -291,6 +293,13 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request, pr policy.Prin

h.emitAudit(r.Context(), pr, post, "created")

// ISR revalidation (#86). Created posts only need a revalidation
// hook if they landed in "published" status — drafts and pending
// rows are not visible to the public renderer.
if post.Status == "published" {
h.notifyRevalidate(r.Context(), post, "created")
}

w.Header().Set(HeaderVersion, strconv.Itoa(post.Version))
router.SetETag(w, router.HashETag(post.hash))
router.WriteJSON(w, http.StatusCreated, post)
Expand Down Expand Up @@ -365,6 +374,16 @@ func (h *handlers) update(w http.ResponseWriter, r *http.Request, pr policy.Prin

h.emitAudit(r.Context(), pr, updated, "updated")

// ISR revalidation (#86). Two cases trigger the hook:
// 1. The row is still / now in "published" status — the public
// page needs a fresh render.
// 2. The row WAS published and is no longer (unpublished /
// trashed via a status change) — the public page needs to
// transition to 404 / removed.
if updated.Status == "published" || existing.Status == "published" {
h.notifyRevalidate(r.Context(), updated, "updated")
}

w.Header().Set(HeaderVersion, strconv.Itoa(updated.Version))
router.SetETag(w, router.HashETag(updated.hash))
router.WriteJSON(w, http.StatusOK, updated)
Expand Down Expand Up @@ -479,6 +498,57 @@ func (h *handlers) writeStoreError(w http.ResponseWriter, r *http.Request, err e
}
}

// notifyRevalidate fires the ISR cache-invalidation hooks for a post
// or page that just published / unpublished. The renderer's URL
// convention is:
//
// post type "post" → /posts/{slug}
// post type "page" → /{slug}
//
// Plus the homepage feed ("/") for "post" — a new entry on the home
// list deserves a fresh render. Pages don't push to the home feed by
// default; they're typically reached via the main menu and the menu
// itself doesn't change on publish.
//
// All failures are logged and swallowed. A failed revalidation means
// the renderer serves a stale page for up to its next-revalidate
// interval, which is correct degrade behavior — failing the publish
// (rolling back the write) because Next.js was unreachable would be
// the wrong trade-off.
func (h *handlers) notifyRevalidate(ctx context.Context, post Post, verb string) {
if h.revalidate == nil {
return
}
var paths []string
switch h.postType {
case PostTypePost:
if post.Slug != "" {
paths = append(paths, "/posts/"+post.Slug)
}
// The home feed always wants a refresh when a post lands or
// drops out — the published list is at "/", not "/posts".
paths = append(paths, "/")
case PostTypePage:
if post.Slug != "" {
paths = append(paths, "/"+post.Slug)
}
}
if len(paths) == 0 {
return
}
if err := h.revalidate.NotifyMany(ctx, paths); err != nil {
// Best-effort: log at Warn (not Error) — staleness is a soft
// failure, not a runbook-paging incident.
h.logger.WarnContext(ctx, "posts: revalidate notify failed",
slog.String("post_id", post.ID),
slog.String("verb", verb),
slog.String("post_type", h.postType),
slog.Any("paths", paths),
slog.Any("err", err),
)
}
}

// emitAudit is the audit emission shim. A nil emitter is tolerated.
// Errors are logged and swallowed — audit is best-effort, never the
// reason a user-facing write fails. (See packages/go/audit godoc.)
Expand Down
Loading
Loading