Files
pansy/internal/api/api.go
T
steveandClaude Opus 4.8 3cfa72cb25
Build image / build-and-push (push) Successful in 18s
Gadfly review (reusable) / review (pull_request) Successful in 5m18s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m19s
REST fill/clear on objects; clear-bed becomes one change set (#82)
FillRegion and ClearObject were reachable only through the agent toolbox, so
on an instance with no model configured the most valuable bulk operation in a
garden planner — and the one carrying the most carefully reasoned geometry in
the codebase — did not exist at all. internal/service/ops.go said these lived
on *Service so "any future REST surface" would inherit the ACL checks; this is
that surface.

POST /objects/:id/fill takes a region EITHER by compass name ("all", "ne",
"south half") or as an explicit rect in the object's local frame, and refuses
both-or-neither: silently preferring one would make a client bug look like a
geometry bug. It answers 200 rather than 201 because a fill can legitimately
create nothing (the region is already planted) and there is no single resource
to point a Location at.

POST /objects/:id/clear replaces a client-side loop of PATCHes. That loop
wrote one change set per plop, so clearing a 40-plop bed took 40 presses of
Undo to put back — while the agent's clear_object, for the identical
user-facing action, undid in one click. CLAUDE.md states the rule it violated:
multi-row operations record together so they undo as one unit. Moving it
server-side also removes the partial-failure case the loop had to reconcile.

TestClearObjectIsOneChangeSetAPI pins that: fill a bed, count change sets,
clear it, assert the count rose by exactly one.

DESIGN.md's API block gains the two new routes, and the four it was already
missing: GET/POST/DELETE /gardens/:id/share-link and the unauthenticated
GET /public/gardens/:token. An unauthenticated surface absent from the
architecture doc is the one worth fixing on sight.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 18:20:54 -04:00

217 lines
9.4 KiB
Go

// Package api wires pansy's HTTP surface: a gin engine with structured logging
// and panic recovery, the versioned JSON API under /api/v1, and (via spa.go) the
// embedded single-page-app fallback. Handlers stay thin — decode, call the
// service layer, encode — so all business logic and permission checks live in
// internal/service (added by later issues).
package api
import (
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// handlers carries the dependencies shared by every HTTP handler. Handlers stay
// thin: decode the request, call a service method, encode the result.
type handlers struct {
cfg *config.Config
svc *service.Service
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
// agent is nil unless the assistant is configured; the chat routes are only
// registered when it isn't, so a handler never has to check.
agent *agent.Runner
}
// New builds the gin engine with the standard middleware stack and registers the
// API routes against the given service. The embedded SPA fallback is registered
// separately by the caller via RegisterSPA (see spa.go) so the API can be built
// and tested without a web build present.
func New(cfg *config.Config, svc *service.Service) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(sloggin.New(slog.Default()), gin.Recovery())
if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
// Do not leave gin's trust-everyone default active on a parse failure —
// that would let any client spoof X-Forwarded-For. Fall back to trusting
// no proxies, which is also the behavior when none are configured.
slog.Error("api: invalid trusted proxies, trusting none", "error", err)
_ = r.SetTrustedProxies(nil)
}
h := &handlers{cfg: cfg, svc: svc}
v1 := r.Group("/api/v1")
// CSRF defense for every state-changing API call (no-op unless PANSY_BASE_URL
// is set; see csrfGuard).
v1.Use(h.csrfGuard())
v1.GET("/healthz", healthz)
// What this instance can actually do, so the UI offers only what works.
// Registered after the agent below, because it reports whether the runner
// actually built — not merely whether it was configured to.
v1.GET("/capabilities", h.capabilities)
// Auth endpoints are exempt from requireAuth (you can't be logged in yet);
// /me is the one that needs a session. Feature routers in later issues attach
// h.requireAuth() to their own protected groups.
auth := v1.Group("/auth")
auth.POST("/register", h.register)
auth.POST("/login", h.login)
auth.POST("/logout", h.logout)
auth.GET("/providers", h.providers)
auth.GET("/me", h.requireAuth(), h.me)
// OIDC routes exist only when OIDC can actually be offered, so an unconfigured
// instance 404s them (matching what /auth/providers advertises). Provider
// discovery is lazy (first request), so a briefly-unreachable IdP doesn't stop
// the server — or local auth — from starting.
switch {
case cfg.OIDCReady():
h.oidc = newOIDCClient(cfg)
auth.GET("/oidc/login", h.oidcLogin)
auth.GET("/oidc/callback", h.oidcCallback)
case cfg.OIDC.Enabled():
slog.Warn("api: OIDC is configured but PANSY_BASE_URL is unset; OIDC disabled (an absolute redirect URI is required)")
}
// Feature resources sit behind requireAuth, which resolves the session cookie
// to the actor the service layer's permission checks key off.
gardens := v1.Group("/gardens", h.requireAuth())
gardens.GET("", h.listGardens)
gardens.POST("", h.createGarden)
gardens.GET("/:id", h.getGarden)
gardens.PATCH("/:id", h.updateGarden)
gardens.DELETE("/:id", h.deleteGarden)
gardens.POST("/:id/copy", h.copyGarden) // duplicate a garden the actor owns
gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load
gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first
gardens.GET("/:id/years", h.getGardenYears) // years with planting data
gardens.POST("/:id/objects", h.createObject)
// The grow journal hangs off a garden even for entries about one bed or one
// plop, so it inherits the ordinary garden-role check.
gardens.GET("/:id/journal", h.listJournal)
gardens.POST("/:id/journal", h.createJournalEntry)
gardens.GET("/:id/journal/counts", h.getJournalCounts)
// Sharing (owner-managed; a recipient may remove their own share).
gardens.GET("/:id/shares", h.listShares)
gardens.POST("/:id/shares", h.addShare)
gardens.PATCH("/:id/shares/:userId", h.updateShare)
gardens.DELETE("/:id/shares/:userId", h.removeShare)
// Public read-only share link (owner-managed): GET reports state, POST
// enables/rotates, DELETE disables. The link itself is served unauthenticated
// below.
gardens.GET("/:id/share-link", h.getShareLink)
gardens.POST("/:id/share-link", h.createShareLink)
gardens.DELETE("/:id/share-link", h.deleteShareLink)
// Objects are addressed by their own id; the service resolves the owning
// garden for the permission check.
objects := v1.Group("/objects", h.requireAuth())
objects.PATCH("/:id", h.updateObject)
objects.DELETE("/:id", h.deleteObject)
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
// Bulk ops. These wrap the same service methods the agent tools call, so an
// instance with no model configured still gets the most valuable operation in
// the app — and so "clear bed" is ONE change set rather than one per plop.
objects.POST("/:id/fill", h.fillObject)
objects.POST("/:id/clear", h.clearObject)
// Plantings ("plops") are addressed by their own id; the service resolves the
// owning object/garden for the permission check.
plantings := v1.Group("/plantings", h.requireAuth())
plantings.PATCH("/:id", h.updatePlanting)
plantings.DELETE("/:id", h.deletePlanting)
// The garden assistant, registered only when it can actually be offered —
// the same shape as OIDC. An instance with no API key serves the app
// normally and simply doesn't have these routes.
if !cfg.Agent.Ready() {
// Say WHY, at startup, in the logs an operator is already looking at.
// Someone who set the key and sees no assistant otherwise has nothing to
// check — and "is the variable reaching the container?" is exactly the
// question they need answered.
slog.Info("api: garden assistant disabled",
"enabled", cfg.Agent.Enabled,
"hasApiKey", cfg.Agent.OllamaCloudAPIKey != "",
"model", cfg.Agent.Model,
"hint", "needs OLLAMA_CLOUD_API_KEY set in the container's environment (not just the stack's)")
}
if cfg.Agent.Ready() {
runner, err := agent.NewRunner(svc, cfg)
if err != nil {
// Configured but unusable (an unresolvable model spec, say). Log it and
// carry on without the assistant rather than refusing to start: a
// garden planner that won't boot because of a chat feature is worse
// than one without chat.
slog.Error("api: garden assistant disabled", "error", err)
} else {
h.agent = runner
agentGroup := v1.Group("/agent", h.requireAuth())
agentGroup.POST("/chat", h.agentChat)
gardens.GET("/:id/agent/history", h.getAgentHistory)
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
slog.Info("api: garden assistant enabled", "model", cfg.Agent.Model)
}
}
// Undo. A change set is addressed by its own id; the service resolves the
// owning garden for the permission check, same as objects and plantings.
changeSets := v1.Group("/change-sets", h.requireAuth())
changeSets.POST("/:id/revert", h.revertChangeSet)
// Journal entries are addressed by their own id; the service resolves the
// owning garden for the permission check, same as objects and plantings.
journal := v1.Group("/journal", h.requireAuth())
journal.PATCH("/:id", h.updateJournalEntry)
journal.DELETE("/:id", h.deleteJournalEntry)
// Plant catalog: built-ins (seeded, read-only) plus the actor's own rows.
plants := v1.Group("/plants", h.requireAuth())
plants.GET("", h.listPlants)
plants.POST("", h.createPlant)
plants.PATCH("/:id", h.updatePlant)
plants.DELETE("/:id", h.deletePlant)
// Seed lots: what the actor bought, and what's left. Private to the buyer,
// so these hang off the session actor rather than off a garden.
seedLots := v1.Group("/seed-lots", h.requireAuth())
seedLots.GET("", h.listSeedLots)
seedLots.POST("", h.createSeedLot)
seedLots.GET("/:id", h.getSeedLot)
seedLots.PATCH("/:id", h.updateSeedLot)
seedLots.DELETE("/:id", h.deleteSeedLot)
// Public, unauthenticated read of a garden by its share token. Deliberately
// NOT behind requireAuth: the token is the capability, so a logged-out visitor
// opens a shared link without being redirected to /login or OIDC. Only GET,
// only the read-only /full payload — never a mutation.
public := v1.Group("/public")
public.GET("/gardens/:token", h.getPublicGarden)
return r
}
// capabilities reports what this instance can actually do, so the UI offers only
// what works.
//
// It reports whether the runner BUILT, not whether it was configured: a
// configured-but-unresolvable model leaves the routes unregistered, and saying
// "yes" there would offer a chat tab whose first message 404s.
func (h *handlers) capabilities(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"agent": h.agent != nil})
}
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
func healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}