Phase 0: backend + frontend scaffold, single-binary build #21

Merged
steve merged 2 commits from phase-0-scaffold into main 2026-07-18 19:25:30 +00:00
36 changed files with 4720 additions and 0 deletions
+18
View File
@@ -25,3 +25,21 @@ go.work.sum
# env file
.env
# pansy binary + SQLite database files
/pansy
*.db
*.db-wal
*.db-shm
# Embedded web build: the placeholder index.html is committed so `go build`
# works on a fresh checkout; `make web` overwrites it and adds hashed assets,
# which are generated artifacts and must not be committed.
/internal/webdist/dist/*
!/internal/webdist/dist/index.html
# macOS
.DS_Store
# Playwright MCP scratch output
.playwright-mcp/
+51
View File
@@ -0,0 +1,51 @@
# pansy — one static binary: Go API + embedded React build.
#
# make build # production binary ./pansy with the web build embedded
# make dev # Go API (:8080) + Vite dev (:5173) together, hot reload
# make test # go test + web tests
#
# pansy is a standalone module; GOWORK=off keeps the parent workspace out of the
# way, and CGO_ENABLED=0 keeps the binary static (pure-Go SQLite driver).
BINARY := pansy
WEBDIST := internal/webdist/dist
GO_ENV := CGO_ENABLED=0 GOWORK=off
.PHONY: all build web dev dev-api dev-web test clean
all: build
## build: bundle the frontend and compile the single static binary.
build: web
$(GO_ENV) go build -o $(BINARY) ./cmd/pansy
@echo "built ./$(BINARY)"
## web: produce the production web bundle and sync it into the embed directory.
web:
cd web && npm ci && npm run build
rm -rf $(WEBDIST)
cp -r web/dist $(WEBDIST)
## dev: run the API and Vite dev server together (Ctrl-C stops both).
## Prefer two terminals if you want independent restarts:
## term 1: make dev-api term 2: make dev-web
dev:
@echo "API on :8080, Vite on :5173 (proxies /api → :8080). Ctrl-C stops both."
@$(MAKE) -j2 dev-api dev-web
dev-api:
$(GO_ENV) go run ./cmd/pansy
dev-web:
cd web && npm run dev
## test: run backend and (if present) frontend tests.
test:
$(GO_ENV) go test ./...
cd web && npm test --if-present
## clean: remove the binary and the generated embed contents (restore placeholder).
clean:
rm -f $(BINARY)
rm -rf $(WEBDIST)
git checkout -- $(WEBDIST)/index.html 2>/dev/null || true
+54
View File
@@ -4,3 +4,57 @@ Self-hostable garden planner: drag beds, bags, and containers onto a real-scale
See [DESIGN.md](DESIGN.md) for the architecture. Work is tracked in this repo's issues — start from the tracking epic.
## Quickstart
**Prerequisites:** Go 1.26+, Node 20+.
### Develop
Run the Go API and the Vite dev server together (Vite proxies `/api` → the API):
```sh
make dev
```
Then open http://localhost:5173. Or run the two halves in separate terminals for independent restarts:
```sh
make dev-api # Go API on :8080
make dev-web # Vite dev server on :5173
```
### Build & run
Produce the single static binary with the web build embedded, then run it:
```sh
make build
./pansy
```
Open http://localhost:8080 — one process serves both the JSON API and the app.
Review

🟡 PANSY_REGISTRATION=closed is documented as a working option but no code consumes it in Phase 0; row should be marked 'plumbed for #4' like the OIDC rows

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **PANSY_REGISTRATION=closed is documented as a working option but no code consumes it in Phase 0; row should be marked 'plumbed for #4' like the OIDC rows** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
### Test
```sh
make test
```
## Configuration
All configuration is via environment variables; every value has a default, so `./pansy` runs with none set.
| Variable | Default | Description |
| ------------------------- | ------------------ | ------------------------------------------------------------------ |
| `PANSY_PORT` | `8080` | TCP port the HTTP server listens on. |
| `PANSY_DB` | `./pansy.db` | SQLite database file path (created if absent). |
| `PANSY_BASE_URL` | *(empty)* | Externally-visible base URL; used to derive the OIDC redirect URI. |
| `PANSY_REGISTRATION` | `open` | `open` or `closed` — gates local self-service signup. |
| `PANSY_LOCAL_AUTH` | `true` | Enable local password auth. Set `false` for pure-OIDC. |
| `PANSY_OIDC_ISSUER` | *(empty)* | OIDC issuer/discovery URL (Authentik). Enables SSO when set. |
| `PANSY_OIDC_CLIENT_ID` | *(empty)* | OIDC client ID. |
| `PANSY_OIDC_CLIENT_SECRET`| *(empty)* | OIDC client secret. |
| `PANSY_OIDC_BUTTON_LABEL` | `Sign in with SSO` | Label for the OIDC button on the login page. |
| `PANSY_TRUSTED_PROXIES` | *(none)* | Comma-separated proxy CIDRs/IPs to trust for client-IP resolution. |
Auth variables are read at startup now and consumed as the auth features land (issues #4/#5).
+87
View File
@@ -0,0 +1,87 @@
// Command pansy is the single static binary: it loads configuration, opens and
// migrates the SQLite database, builds the HTTP server (JSON API + embedded SPA),
// and serves until interrupted.
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/api"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/webdist"
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
if err := run(); err != nil {
slog.Error("pansy: fatal", "error", err)
os.Exit(1)
}
}
func run() error {
cfg := config.Load()
db, err := store.Open(cfg.DBPath)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer db.Close()
// Migrations run against a short-lived context so a hung DB can't block boot
// forever; the schema is tiny so this is generous.
migCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := db.Migrate(migCtx); err != nil {
return fmt.Errorf("migrate database: %w", err)
}
slog.Info("pansy: database ready", "path", cfg.DBPath)
r := api.New(cfg)
api.RegisterSPA(r, webdist.Dist())
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: r,
ReadTimeout: 15 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Serve in the background; the main goroutine waits for a signal, then drains.
serveErr := make(chan error, 1)
go func() {
slog.Info("pansy: listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serveErr <- err
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
select {
case err := <-serveErr:
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
slog.Info("pansy: shutting down")
stop() // restore default signal handling so a second Ctrl-C force-quits
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
module gitea.stevedudenhoeffer.com/steve/pansy
go 1.26.2
require (
github.com/gin-gonic/gin v1.10.1
github.com/samber/slog-gin v1.15.0
modernc.org/sqlite v1.34.4
)
require (
github.com/bytedance/sonic v1.11.9 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+142
View File
@@ -0,0 +1,142 @@
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/samber/slog-gin v1.15.0 h1:Kqs/ilXd9divtslWjbz5DVptmLlzyntbBiXUAta2SFg=
github.com/samber/slog-gin v1.15.0/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+45
View File
@@ -0,0 +1,45 @@
// 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/config"
)
// New builds the gin engine with the standard middleware stack and registers the
// API routes. 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) *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)
}
v1 := r.Group("/api/v1")
v1.GET("/healthz", healthz)
return r
}
// 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})
}
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"io/fs"
"net/http"
"path"
"strings"
"github.com/gin-gonic/gin"
)
// RegisterSPA serves the embedded single-page app for every route gin didn't
// otherwise match. A request whose path maps to a real file (e.g. a hashed asset
// bundle) is served directly; any other non-API path falls back to index.html so
// client-side deep links like /gardens/42 load the app. Unmatched /api paths
// return a JSON 404 rather than HTML.
func RegisterSPA(r *gin.Engine, dist fs.FS) {
fileServer := http.FileServer(http.FS(dist))
// index.html is served on every client-route deep link; read it once at
// startup rather than re-reading the embedded FS per request. A build without
// it is a packaging bug, so fail loudly.
index, err := fs.ReadFile(dist, "index.html")
if err != nil {
panic("api: embedded web build missing index.html: " + err.Error())
}
r.NoRoute(func(c *gin.Context) {
reqPath := c.Request.URL.Path
// API paths (including a bare "/api") are never the SPA: a miss is a real
// JSON 404, not the app shell.
if reqPath == "/api" || strings.HasPrefix(reqPath, "/api/") {
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "no such endpoint")
return
}
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
writeAPIError(c, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
c.Header("X-Content-Type-Options", "nosniff")
name := strings.TrimPrefix(path.Clean(reqPath), "/")
if name == "" {
serveIndex(c, index)
return
}
if info, statErr := fs.Stat(dist, name); statErr == nil {
// An embedded directory is neither a client route nor a servable file;
// serving it would leak a directory listing, so it is a 404.
if info.IsDir() {
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "not found")
return
}
// Vite emits content-hashed filenames under assets/, so those are safe
// to cache forever; index.html must always revalidate to pick up new builds.
if strings.HasPrefix(name, "assets/") {
Review

"assets/" prefix literal checked twice in same handler; extract a local to avoid drift

maintainability · flagged by 1 model

  • internal/api/spa.go:60 and internal/api/spa.go:72strings.HasPrefix(name, "assets/") is evaluated twice in the same handler, once to set immutable cache headers and once to short-circuit a missing-asset 404. The two branches are distinct, but the repeated prefix literal is the kind of thing that drifts (e.g. if Vite is ever configured with a different assetsDir). A one-line isAsset := strings.HasPrefix(name, "assets/") local would keep them in sync and reads more clearly. Trivial.

🪰 Gadfly · advisory

⚪ **"assets/" prefix literal checked twice in same handler; extract a local to avoid drift** _maintainability · flagged by 1 model_ - `internal/api/spa.go:60` and `internal/api/spa.go:72` — `strings.HasPrefix(name, "assets/")` is evaluated twice in the same handler, once to set immutable cache headers and once to short-circuit a missing-asset 404. The two branches are distinct, but the repeated prefix literal is the kind of thing that drifts (e.g. if Vite is ever configured with a different `assetsDir`). A one-line `isAsset := strings.HasPrefix(name, "assets/")` local would keep them in sync and reads more clearly. Trivial. <sub>🪰 Gadfly · advisory</sub>
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "no-cache")
}
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
// A missing file under assets/ is a genuine 404, not a client route:
// falling back to index.html would return HTML with a 200 for a .js/.css
// request, which the browser rejects with a confusing MIME-type error.
if strings.HasPrefix(name, "assets/") {
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "asset not found")
return
}
serveIndex(c, index)
})
}
// serveIndex writes the cached index.html as the SPA fallback (no-cache so new
// builds are picked up). net/http suppresses the body for HEAD requests.
func serveIndex(c *gin.Context, index []byte) {
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
}
// writeAPIError writes pansy's standard JSON error envelope.
Review

🟡 writeAPIError (standard JSON error envelope) lives in the SPA file; should move to a shared api/errors.go as handlers land

maintainability · flagged by 2 models

  • internal/api/spa.go:88writeAPIError is described as "pansy's standard JSON error envelope" (a package-wide concern, and the exact shape the frontend api.ts messageFrom already parses), yet it lives in the SPA-fallback file. As the api package grows real handlers (the package doc in api.go:3-5 explicitly says handlers stay thin and call a service layer), every one of them will reach for this helper and import it from the SPA file — a leaky placement. Confirmed against api.go (o…

🪰 Gadfly · advisory

🟡 **writeAPIError (standard JSON error envelope) lives in the SPA file; should move to a shared api/errors.go as handlers land** _maintainability · flagged by 2 models_ - `internal/api/spa.go:88` — `writeAPIError` is described as "pansy's standard JSON error envelope" (a package-wide concern, and the exact shape the frontend `api.ts` `messageFrom` already parses), yet it lives in the SPA-fallback file. As the `api` package grows real handlers (the package doc in `api.go:3-5` explicitly says handlers stay thin and call a service layer), every one of them will reach for this helper and import it from the SPA file — a leaky placement. Confirmed against `api.go` (o… <sub>🪰 Gadfly · advisory</sub>
func writeAPIError(c *gin.Context, status int, code, message string) {
c.JSON(status, gin.H{"error": gin.H{"code": code, "message": message}})
}
+105
View File
@@ -0,0 +1,105 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"github.com/gin-gonic/gin"
)
func testEngine() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
// A real API route so unmatched /api/* paths reach NoRoute (as in production).
r.GET("/api/v1/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
dist := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(`<!doctype html><div id="root"></div>`)},
"assets/app-abc123.js": &fstest.MapFile{Data: []byte(`console.log(1)`)},
}
RegisterSPA(r, dist)
return r
}
func do(t *testing.T, r *gin.Engine, method, path string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(method, path, nil))
return w
}
func TestSPAServesHashedAssetImmutable(t *testing.T) {
w := do(t, testEngine(), http.MethodGet, "/assets/app-abc123.js")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
t.Errorf("Cache-Control = %q, want immutable", cc)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
t.Errorf("Content-Type = %q, want javascript", ct)
}
}
func TestSPAServesIndexNoCache(t *testing.T) {
w := do(t, testEngine(), http.MethodGet, "/")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if cc := w.Header().Get("Cache-Control"); cc != "no-cache" {
t.Errorf("Cache-Control = %q, want no-cache", cc)
}
}
func TestSPAFallbackForClientRoute(t *testing.T) {
// A deep link with no matching file returns index.html so client routing works.
w := do(t, testEngine(), http.MethodGet, "/gardens/42")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if !strings.Contains(w.Body.String(), `id="root"`) {
t.Errorf("expected index.html body, got %q", w.Body.String())
}
}
func TestSPAMissingAssetIs404(t *testing.T) {
// A missing asset must NOT fall back to index.html (which would be a 200 of
// HTML for a .js request — a MIME-type error in the browser).
w := do(t, testEngine(), http.MethodGet, "/assets/missing-xyz.js")
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "json") {
t.Errorf("Content-Type = %q, want json", ct)
}
}
func TestSPADirectoryIs404NotListing(t *testing.T) {
// A request that resolves to an embedded directory must 404, never a listing.
for _, p := range []string{"/assets", "/assets/"} {
w := do(t, testEngine(), http.MethodGet, p)
if w.Code != http.StatusNotFound {
t.Errorf("GET %s status = %d, want 404", p, w.Code)
}
}
}
func TestSPAUnmatchedAPIIs404JSON(t *testing.T) {
w := do(t, testEngine(), http.MethodGet, "/api/v1/nope")
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "json") {
t.Errorf("Content-Type = %q, want json (not the SPA HTML)", ct)
}
}
func TestSPARejectsNonGET(t *testing.T) {
w := do(t, testEngine(), http.MethodPost, "/gardens")
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", w.Code)
}
}
+141
View File
@@ -0,0 +1,141 @@
// Package config loads pansy's runtime configuration from the environment.
//
// Every value has a sensible default so `pansy` runs with zero configuration
// for local use; production deployments override via PANSY_* env vars. Auth
// values (local + OIDC) are plumbed here now and consumed by the auth issues
// (#4 local, #5 OIDC).
package config
import (
"log/slog"
"os"
"strconv"
"strings"
)
// Registration gates local self-service signup.
const (
RegistrationOpen = "open"
RegistrationClosed = "closed"
)
// Config is the fully-resolved runtime configuration.
type Config struct {
// Port is the TCP port the HTTP server listens on (PANSY_PORT, default 8080).
Port int
// DBPath is the SQLite database file path (PANSY_DB, default ./pansy.db).
DBPath string
// BaseURL is the externally-visible base URL, used to derive the OIDC
// redirect URI and absolute links (PANSY_BASE_URL). Empty in bare local dev.
BaseURL string
// Registration is "open" or "closed"; gates POST /auth/register for local
// accounts (PANSY_REGISTRATION, default open). OIDC JIT provisioning ignores
// this — the IdP gates access.
Registration string
// LocalAuth enables argon2id username/password auth (PANSY_LOCAL_AUTH,
// default true). Set false for pure-Authentik deployments.
LocalAuth bool
// OIDC holds the optional OpenID Connect provider settings.
OIDC OIDCConfig
// TrustedProxies is the set of proxy CIDRs/IPs gin trusts for client IP
// resolution (PANSY_TRUSTED_PROXIES, comma-separated). Empty trusts none.
TrustedProxies []string
}
// OIDCConfig holds the OpenID Connect provider settings (Authentik is the
// primary IdP). Consumed by #5.
type OIDCConfig struct {
Issuer string // PANSY_OIDC_ISSUER — discovery base URL
ClientID string // PANSY_OIDC_CLIENT_ID
ClientSecret string // PANSY_OIDC_CLIENT_SECRET
ButtonLabel string // PANSY_OIDC_BUTTON_LABEL — login-page button text
}
// Enabled reports whether enough OIDC config is present to attempt discovery.
func (o OIDCConfig) Enabled() bool {
return o.Issuer != "" && o.ClientID != ""
}
// RegistrationOpen reports whether local self-service signup is allowed.
func (c *Config) RegistrationOpen() bool {
Review

🟡 RegistrationOpen() method is dead code and shadows the RegistrationOpen constant name

maintainability · flagged by 1 model

  • internal/config/config.go:60(*Config).RegistrationOpen() is dead code: grep -rn "RegistrationOpen()" --include="*.go" . across the whole repo returns only its own definition, zero callers. It also shares its name with the package-level constant RegistrationOpen = "open" declared at config.go:18 in the same file. This compiles (Go keeps method names and package-level identifiers in separate namespaces), but config.RegistrationOpen (string constant) and cfg.RegistrationOpen() (b…

🪰 Gadfly · advisory

🟡 **RegistrationOpen() method is dead code and shadows the RegistrationOpen constant name** _maintainability · flagged by 1 model_ - `internal/config/config.go:60` — `(*Config).RegistrationOpen()` is dead code: `grep -rn "RegistrationOpen()" --include="*.go" .` across the whole repo returns only its own definition, zero callers. It also shares its name with the package-level constant `RegistrationOpen = "open"` declared at `config.go:18` in the same file. This compiles (Go keeps method names and package-level identifiers in separate namespaces), but `config.RegistrationOpen` (string constant) and `cfg.RegistrationOpen()` (b… <sub>🪰 Gadfly · advisory</sub>
return c.Registration == RegistrationOpen
}
// Load reads configuration from the environment, applying defaults. It never
// fails: invalid numeric/boolean values fall back to the default and are logged.
func Load() *Config {
cfg := &Config{
Port: envInt("PANSY_PORT", 8080),
Review

🟡 Duplicated default port literal 8080

maintainability · flagged by 1 model

  • internal/config/config.go:68,88-89 — The default port literal 8080 is duplicated: once as the fallback argument to envInt, and again in the validation block's log message and reassignment. If the default ever changes, all three sites must be updated together or they drift. Extract a package-level constant (defaultPort = 8080) and reference it everywhere.

🪰 Gadfly · advisory

🟡 **Duplicated default port literal 8080** _maintainability · flagged by 1 model_ - **`internal/config/config.go:68,88-89`** — The default port literal `8080` is duplicated: once as the fallback argument to `envInt`, and again in the validation block's log message and reassignment. If the default ever changes, all three sites must be updated together or they drift. Extract a package-level constant (`defaultPort = 8080`) and reference it everywhere. <sub>🪰 Gadfly · advisory</sub>
DBPath: envStr("PANSY_DB", "./pansy.db"),
BaseURL: strings.TrimRight(envStr("PANSY_BASE_URL", ""), "/"),
Registration: envStr("PANSY_REGISTRATION", RegistrationOpen),
LocalAuth: envBool("PANSY_LOCAL_AUTH", true),
OIDC: OIDCConfig{
Issuer: envStr("PANSY_OIDC_ISSUER", ""),
ClientID: envStr("PANSY_OIDC_CLIENT_ID", ""),
ClientSecret: envStr("PANSY_OIDC_CLIENT_SECRET", ""),
ButtonLabel: envStr("PANSY_OIDC_BUTTON_LABEL", "Sign in with SSO"),
},
TrustedProxies: envList("PANSY_TRUSTED_PROXIES"),
}
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
slog.Warn("config: invalid PANSY_REGISTRATION, defaulting to open", "value", cfg.Registration)
cfg.Registration = RegistrationOpen
}
if cfg.Port < 1 || cfg.Port > 65535 {
slog.Warn("config: PANSY_PORT out of range, using default", "value", cfg.Port, "default", 8080)
cfg.Port = 8080
}
return cfg
}
func envStr(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
slog.Warn("config: invalid int env, using default", "key", key, "value", v, "default", def)
return def
}
return n
}
func envBool(key string, def bool) bool {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
slog.Warn("config: invalid bool env, using default", "key", key, "value", v, "default", def)
return def
}
return b
}
func envList(key string) []string {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+155
View File
@@ -0,0 +1,155 @@
// Package domain holds pansy's core types: plain structs mirroring the database
// schema and the sentinel errors the service layer returns. It has no
// dependencies on store, api, or any framework — every other package depends on
// it, not the other way around.
package domain
import "errors"
// Sentinel errors returned by the service layer and mapped to HTTP status codes
// by the API layer (404, 403, 409 respectively).
var (
// ErrNotFound means the requested entity does not exist (or is invisible to
// the actor, when we deliberately mask existence).
ErrNotFound = errors.New("not found")
// ErrForbidden means the actor is known but lacks permission for the action.
ErrForbidden = errors.New("forbidden")
// ErrVersionConflict means the row's version did not match the one supplied
// with a PATCH/DELETE; the caller should refetch and retry.
ErrVersionConflict = errors.New("version conflict")
)
// Enumerated string values mirrored from the schema CHECK constraints.
const (
UnitMetric = "metric"
UnitImperial = "imperial"
RoleViewer = "viewer"
RoleEditor = "editor"
KindBed = "bed"
KindGrowBag = "grow_bag"
KindContainer = "container"
KindInGround = "in_ground"
KindTree = "tree"
KindPath = "path"
KindStructure = "structure"
ShapeRect = "rect"
ShapeCircle = "circle"
ShapePolygon = "polygon" // reserved for post-v1
Review

🟡 ShapePolygon constant is exposed in the v1 domain surface but is explicitly reserved/post-v1; invites callers to emit a value the editor/API reject

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **ShapePolygon constant is exposed in the v1 domain surface but is explicitly reserved/post-v1; invites callers to emit a value the editor/API reject** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
CategoryVegetable = "vegetable"
CategoryHerb = "herb"
CategoryFlower = "flower"
CategoryFruit = "fruit"
CategoryTreeShrub = "tree_shrub"
CategoryCover = "cover"
)
// User is a pansy account. It may have a local password, OIDC identity, or both.
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
PasswordHash *string `json:"-"` // never serialized
OIDCIssuer *string `json:"-"`
OIDCSubject *string `json:"-"`
IsAdmin bool `json:"isAdmin"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// Session is a server-side session record. The raw token is never stored; only
// its sha256 hash (the primary key) is.
type Session struct {
TokenHash string `json:"-"`
UserID int64 `json:"userId"`
ExpiresAt string `json:"expiresAt"`
CreatedAt string `json:"createdAt"`
}
// Garden is a planning surface owned by one user, at real-world scale (cm).
type Garden struct {
ID int64 `json:"id"`
OwnerID int64 `json:"ownerId"`
Name string `json:"name"`
WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"`
UnitPref string `json:"unitPref"`
Notes string `json:"notes"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// GardenShare grants a non-owner user viewer or editor access to a garden.
type GardenShare struct {
ID int64 `json:"id"`
GardenID int64 `json:"gardenId"`
UserID int64 `json:"userId"`
Role string `json:"role"`
CreatedBy int64 `json:"createdBy"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// GardenObject is any placeable object in a garden (bed, container, tree, path…).
// Positioned by center point + rotation about center, in garden space.
type GardenObject struct {
ID int64 `json:"id"`
GardenID int64 `json:"gardenId"`
Kind string `json:"kind"`
Name string `json:"name"`
Shape string `json:"shape"`
Points *string `json:"points,omitempty"` // reserved: JSON for polygons
XCM float64 `json:"xCm"`
YCM float64 `json:"yCm"`
WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"`
RotationDeg float64 `json:"rotationDeg"`
ZIndex int `json:"zIndex"`
Plantable bool `json:"plantable"`
Color *string `json:"color,omitempty"`
Props *string `json:"props,omitempty"` // kind-specific JSON
Notes string `json:"notes"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// Plant is a catalog entry. OwnerID nil means a read-only seeded built-in.
type Plant struct {
ID int64 `json:"id"`
OwnerID *int64 `json:"ownerId,omitempty"` // nil = built-in
Name string `json:"name"`
Category string `json:"category"`
SpacingCM float64 `json:"spacingCm"`
Color string `json:"color"`
Icon string `json:"icon"`
DaysToMaturity *int `json:"daysToMaturity,omitempty"`
Notes string `json:"notes"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// Planting ("plop") is a circular patch of one plant, positioned in its parent
// object's local frame. Count nil means it is derived from area / spacing².
type Planting struct {
ID int64 `json:"id"`
ObjectID int64 `json:"objectId"`
PlantID int64 `json:"plantId"`
XCM float64 `json:"xCm"`
YCM float64 `json:"yCm"`
RadiusCM float64 `json:"radiusCm"`
Count *int `json:"count,omitempty"` // nil = derived
Label *string `json:"label,omitempty"`
PlantedAt *string `json:"plantedAt,omitempty"`
RemovedAt *string `json:"removedAt,omitempty"`
Version int64 `json:"version"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
+143
View File
@@ -0,0 +1,143 @@
package store
import (
"context"
"embed"
"fmt"
"io/fs"
"log/slog"
"sort"
"strconv"
"strings"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// migration is one numbered SQL file: version parsed from the filename prefix.
type migration struct {
version int
name string
sql string
}
// Migrate applies every embedded migration whose version has not yet been
// recorded, in ascending order, each in its own transaction. It is idempotent:
// a second run with no new files is a no-op. Migration files are named
// NNNN_description.sql (e.g. 0001_init.sql); NNNN is the version.
func (d *DB) Migrate(ctx context.Context) error {
if _, err := d.sql.ExecContext(ctx,
`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)`,
); err != nil {
return fmt.Errorf("store: create schema_migrations: %w", err)
}
applied, err := d.appliedVersions(ctx)
if err != nil {
return err
}
migrations, err := loadMigrations()
if err != nil {
return err
}
for _, m := range migrations {
if applied[m.version] {
continue
}
if err := d.applyMigration(ctx, m); err != nil {
return err
}
slog.Info("store: applied migration", "version", m.version, "name", m.name)
}
return nil
}
func (d *DB) appliedVersions(ctx context.Context) (map[int]bool, error) {
rows, err := d.sql.QueryContext(ctx, `SELECT version FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("store: read schema_migrations: %w", err)
}
defer rows.Close()
applied := map[int]bool{}
for rows.Next() {
var v int
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("store: scan schema_migrations: %w", err)
}
applied[v] = true
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate schema_migrations: %w", err)
}
return applied, nil
}
func (d *DB) applyMigration(ctx context.Context, m migration) error {
tx, err := d.sql.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin migration %d: %w", m.version, err)
}
defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
if _, err := tx.ExecContext(ctx, m.sql); err != nil {
return fmt.Errorf("store: apply migration %d (%s): %w", m.version, m.name, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations (version) VALUES (?)`, m.version,
); err != nil {
return fmt.Errorf("store: record migration %d: %w", m.version, err)
}
return tx.Commit()
Review

applyMigration returns tx.Commit() unwrapped, so a commit failure doesn't identify the migration version

error-handling · flagged by 1 model

The draft's single finding is confirmed against the actual code: internal/store/migrate.go:93 returns tx.Commit() unwrapped, while every other error path in applyMigration (lines 81, 86, 91) wraps with the migration version/name. The surrounding correctness claims in the draft (deferred tx.Rollback, rows.Err() check, close-on-defer) all hold against the source.

🪰 Gadfly · advisory

⚪ **applyMigration returns tx.Commit() unwrapped, so a commit failure doesn't identify the migration version** _error-handling · flagged by 1 model_ The draft's single finding is confirmed against the actual code: `internal/store/migrate.go:93` returns `tx.Commit()` unwrapped, while every other error path in `applyMigration` (lines 81, 86, 91) wraps with the migration version/name. The surrounding correctness claims in the draft (deferred `tx.Rollback`, `rows.Err()` check, close-on-defer) all hold against the source. <sub>🪰 Gadfly · advisory</sub>
}
// loadMigrations reads and parses every embedded migration file, sorted by
// version ascending.
func loadMigrations() ([]migration, error) {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return nil, fmt.Errorf("store: read migrations dir: %w", err)
}
var migrations []migration
seen := map[int]string{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
version, err := parseVersion(e.Name())
if err != nil {
return nil, err
}
if prev, dup := seen[version]; dup {
return nil, fmt.Errorf("store: duplicate migration version %d: %q and %q", version, prev, e.Name())
}
seen[version] = e.Name()
body, err := fs.ReadFile(migrationsFS, "migrations/"+e.Name())
if err != nil {
return nil, fmt.Errorf("store: read migration %s: %w", e.Name(), err)
}
migrations = append(migrations, migration{version: version, name: e.Name(), sql: string(body)})
}
sort.Slice(migrations, func(i, j int) bool {
return migrations[i].version < migrations[j].version
})
return migrations, nil
}
// parseVersion extracts the leading integer from a migration filename such as
// "0001_init.sql".
func parseVersion(name string) (int, error) {
prefix, _, ok := strings.Cut(name, "_")
if !ok {
return 0, fmt.Errorf("store: migration %q missing NNNN_ prefix", name)
}
v, err := strconv.Atoi(prefix)
if err != nil {
return 0, fmt.Errorf("store: migration %q has non-numeric version: %w", name, err)
}
return v, nil
}
+139
View File
@@ -0,0 +1,139 @@
-- 0001_init.sql — pansy initial schema.
--
-- Conventions (see DESIGN.md § Domain model):
-- * All measurements are centimeters (REAL); imperial is a display concern.
-- * `version INTEGER NOT NULL DEFAULT 1` on every user-mutable row; PATCH/DELETE
-- carry it and the service layer increments on write for 409 conflict detection.
-- * Enumerations are enforced with CHECK constraints.
-- * Timestamps are ISO-8601 TEXT (UTC); dates are 'YYYY-MM-DD' TEXT.
-- * Foreign keys cascade on owner/parent deletion; enforcement is on (PRAGMA foreign_keys).
-- users -----------------------------------------------------------------------
-- A user may authenticate locally (password_hash), via OIDC (oidc_issuer+subject),
-- or both (linked by email on first OIDC login). First registered user is admin.
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL,
password_hash TEXT, -- argon2id; NULL for OIDC-only users
oidc_issuer TEXT, -- NULL for local-only users
oidc_subject TEXT,
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
Review

🟠 users schema allows partial OIDC identity (issuer without subject or vice versa)

correctness · flagged by 2 models

  • internal/store/migrations/0001_init.sql:27 — The users table allows oidc_issuer to be non-NULL while oidc_subject is NULL (or vice versa), despite the documented semantics that OIDC identity is a pair. A missing CHECK ((oidc_issuer IS NULL AND oidc_subject IS NULL) OR (oidc_issuer IS NOT NULL AND oidc_subject IS NOT NULL)) permits invalid partial OIDC records that the application will have to clean up later. Fix: add the paired-nullability CHECK to enforce the invariant at the sc…

🪰 Gadfly · advisory

🟠 **users schema allows partial OIDC identity (issuer without subject or vice versa)** _correctness · flagged by 2 models_ - **`internal/store/migrations/0001_init.sql:27`** — The `users` table allows `oidc_issuer` to be non-NULL while `oidc_subject` is NULL (or vice versa), despite the documented semantics that OIDC identity is a pair. A missing `CHECK ((oidc_issuer IS NULL AND oidc_subject IS NULL) OR (oidc_issuer IS NOT NULL AND oidc_subject IS NOT NULL))` permits invalid partial OIDC records that the application will have to clean up later. Fix: add the paired-nullability CHECK to enforce the invariant at the sc… <sub>🪰 Gadfly · advisory</sub>
-- NULLs compare distinct in SQLite, so this only constrains real (issuer, subject) pairs.
CREATE UNIQUE INDEX idx_users_oidc ON users (oidc_issuer, oidc_subject);
-- sessions --------------------------------------------------------------------
-- Opaque bearer token stored only as a sha256 hash; the raw token lives in the
-- HttpOnly cookie. 30-day sliding expiry (expires_at bumped on use).
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY, -- sha256 of the 32-byte random token
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_sessions_user ON sessions (user_id);
-- gardens ---------------------------------------------------------------------
CREATE TABLE gardens (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
width_cm REAL NOT NULL,
height_cm REAL NOT NULL,
unit_pref TEXT NOT NULL DEFAULT 'metric' CHECK (unit_pref IN ('metric', 'imperial')),
notes TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_gardens_owner ON gardens (owner_id);
-- garden_shares ---------------------------------------------------------------
-- The owner is implicit via gardens.owner_id and never has a share row.
CREATE TABLE garden_shares (
id INTEGER PRIMARY KEY,
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('viewer', 'editor')),
created_by INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE (garden_id, user_id)
);
CREATE INDEX idx_garden_shares_garden ON garden_shares (garden_id);
CREATE INDEX idx_garden_shares_user ON garden_shares (user_id);
-- garden_objects --------------------------------------------------------------
-- One polymorphic table for every placeable object. Positioned by CENTER point +
-- rotation about center, in garden space (origin top-left, x→right, y→down).
-- shape='polygon' and the points JSON column are reserved for post-v1; the v1
-- editor emits only 'rect' and 'circle' (circle: width_cm = diameter).
CREATE TABLE garden_objects (
id INTEGER PRIMARY KEY,
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure')),
name TEXT NOT NULL DEFAULT '',
shape TEXT NOT NULL DEFAULT 'rect' CHECK (shape IN ('rect', 'circle', 'polygon')),
points TEXT, -- reserved: JSON [[x,y],...] for shape='polygon'
x_cm REAL NOT NULL, -- center in garden space
y_cm REAL NOT NULL,
width_cm REAL NOT NULL,
height_cm REAL NOT NULL,
rotation_deg REAL NOT NULL DEFAULT 0, -- clockwise about center
z_index INTEGER NOT NULL DEFAULT 0,
plantable INTEGER NOT NULL DEFAULT 0 CHECK (plantable IN (0, 1)),
color TEXT, -- optional hex override
props TEXT, -- kind-specific extras (JSON)
notes TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_garden_objects_garden ON garden_objects (garden_id);
-- plants ----------------------------------------------------------------------
-- Catalog. owner_id NULL = built-in (seeded, read-only; clone to customize).
-- Users see built-ins plus their own.
CREATE TABLE plants (
id INTEGER PRIMARY KEY,
owner_id INTEGER REFERENCES users (id) ON DELETE CASCADE, -- NULL = built-in
name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover')),
spacing_cm REAL NOT NULL, -- mature spacing
color TEXT NOT NULL, -- hex
icon TEXT NOT NULL DEFAULT '', -- emoji (zero image assets in v1)
days_to_maturity INTEGER,
notes TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_plants_owner ON plants (owner_id);
-- plantings ("plops") ---------------------------------------------------------
-- A circular patch of one plant, positioned in the PARENT OBJECT's local frame
-- (origin at object center, unrotated axes) so moving/rotating the bed moves its
-- plants for free. count NULL = derived max(1, round(pi*r^2 / spacing_cm^2)).
-- "Clear bed" sets removed_at; v1 lists rows where removed_at IS NULL.
CREATE TABLE plantings (
id INTEGER PRIMARY KEY,
object_id INTEGER NOT NULL REFERENCES garden_objects (id) ON DELETE CASCADE,
plant_id INTEGER NOT NULL REFERENCES plants (id) ON DELETE RESTRICT,
x_cm REAL NOT NULL, -- center in object-local frame
y_cm REAL NOT NULL,
radius_cm REAL NOT NULL,
count INTEGER, -- NULL = derived from area / spacing^2
label TEXT,
planted_at TEXT, -- 'YYYY-MM-DD'
removed_at TEXT, -- 'YYYY-MM-DD'; NULL = currently planted
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_plantings_object ON plantings (object_id);
CREATE INDEX idx_plantings_plant ON plantings (plant_id);
+106
View File
@@ -0,0 +1,106 @@
// Package store provides pansy's durable persistence layer: a pure-Go SQLite
// database (modernc.org/sqlite — no cgo, so the binary stays static) with a
// tiny embedded migration runner. Entity query methods are added per feature
// issue; this file only handles opening and the connection pragmas.
package store
import (
"database/sql"
"fmt"
"net/url"
"strings"
_ "modernc.org/sqlite" // pure-Go driver, registered as "sqlite"
)
// requiredPragmas are applied to every connection (they are per-connection in
// SQLite): wait rather than fail on a contended write, use WAL journaling, and
// enforce foreign keys. They are attached to the DSN so the whole pool inherits
// them.
var requiredPragmas = []string{
"busy_timeout(5000)",
"journal_mode(WAL)",
"foreign_keys(1)",
}
// DB is a handle to the SQLite database backing pansy. Safe for concurrent use:
// WAL mode allows many readers with one writer, and busy_timeout makes a
// contended writer wait rather than fail with SQLITE_BUSY.
type DB struct {
sql *sql.DB
}
// Open opens (creating if absent) the SQLite database at path and returns a DB.
// The pragmas — busy_timeout, WAL journal, and foreign-key enforcement — are
// applied per connection via the DSN so every pooled connection inherits them,
// regardless of whether path is a bare filename, a path with query parameters,
// or a full file: URI. A path of ":memory:" yields an ephemeral in-memory
// database (useful in tests); the pool is pinned to a single connection so all
// queries see the same DB. The caller owns the returned DB and must Close it.
func Open(path string) (*DB, error) {
dsn, memory := buildDSN(path)
sqldb, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("store: open %q: %w", path, err)
}
if memory {
// Each new connection to a bare ":memory:" DSN gets its OWN empty database,
// so a pooled second connection wouldn't see the migrated schema. Pin the
// pool to one connection to keep an in-memory DB coherent.
sqldb.SetMaxOpenConns(1)
}
if err := sqldb.Ping(); err != nil {
sqldb.Close()
return nil, fmt.Errorf("store: ping %q: %w", path, err)
}
return &DB{sql: sqldb}, nil
}
// buildDSN converts the configured path into a modernc DSN that always carries
// the required pragmas, and reports whether it is an in-memory database. It
// accepts a bare filename, a path with existing query parameters, or a full
// file: URI, and merges the pragmas without clobbering any the user supplied.
func buildDSN(path string) (dsn string, memory bool) {
Review

🔴 buildDSN truncates filenames containing ? by treating them as URI query strings

correctness, error-handling · flagged by 4 models

  • internal/store/sqlite.go:66buildDSN unconditionally splits on ? even for bare filesystem paths. A valid path like /tmp/weird?name.db (where ? is a legal Linux filename character) is truncated to /tmp/weird and the remainder is treated as malformed query parameters and discarded. The existing test TestBuildDSNAlwaysIncludesPragmas exercises this exact input but only asserts pragma presence, not filename preservation, so the bug passes undetected. Fix: only split on ? when…

🪰 Gadfly · advisory

🔴 **buildDSN truncates filenames containing ? by treating them as URI query strings** _correctness, error-handling · flagged by 4 models_ - **`internal/store/sqlite.go:66`** — `buildDSN` unconditionally splits on `?` even for bare filesystem paths. A valid path like `/tmp/weird?name.db` (where `?` is a legal Linux filename character) is truncated to `/tmp/weird` and the remainder is treated as malformed query parameters and discarded. The existing test `TestBuildDSNAlwaysIncludesPragmas` exercises this exact input but only asserts pragma presence, not filename preservation, so the bug passes undetected. Fix: only split on `?` when… <sub>🪰 Gadfly · advisory</sub>
body := strings.TrimPrefix(path, "file:")
filePart, rawQuery, _ := strings.Cut(body, "?")
query := url.Values{}
if q, err := url.ParseQuery(rawQuery); err == nil {
query = q
}
// Detect in-memory precisely: the exact ":memory:" token, or an explicit
Review

🟠 buildDSN loses filename when bare path contains literal '?'

correctness · flagged by 1 model

  • internal/store/sqlite.go:72-95buildDSN mis-splits bare paths on ?, discarding part of the filename. For a bare path such as /tmp/weird?name.db (where ? is a literal character in the Unix filename), strings.Cut(body, "?") treats name.db as the query string. Because url.ParseQuery("name.db") fails, the query remains empty and the database is created at /tmp/weird instead of the intended path. The existing test (TestBuildDSNAlwaysIncludesPragmas) only asserts pragma pre…

🪰 Gadfly · advisory

🟠 **buildDSN loses filename when bare path contains literal '?'** _correctness · flagged by 1 model_ - **`internal/store/sqlite.go:72-95` — `buildDSN` mis-splits bare paths on `?`, discarding part of the filename.** For a bare path such as `/tmp/weird?name.db` (where `?` is a literal character in the Unix filename), `strings.Cut(body, "?")` treats `name.db` as the query string. Because `url.ParseQuery("name.db")` fails, the query remains empty and the database is created at `/tmp/weird` instead of the intended path. The existing test (`TestBuildDSNAlwaysIncludesPragmas`) only asserts pragma pre… <sub>🪰 Gadfly · advisory</sub>
// mode=memory parameter. A substring match would misfire on real file paths.
memory = filePart == ":memory:" || query.Get("mode") == "memory"
have := make(map[string]bool, len(query["_pragma"]))
for _, p := range query["_pragma"] {
have[pragmaName(p)] = true
}
for _, p := range requiredPragmas {
if !have[pragmaName(p)] {
query.Add("_pragma", p)
}
}
// A '#' in a filesystem path would otherwise start a URI fragment and drop
// the query string that carries the pragmas.
filePart = strings.ReplaceAll(filePart, "#", "%23")
return "file:" + filePart + "?" + query.Encode(), memory
}
// pragmaName extracts the pragma's name from a "_pragma" value such as
// "busy_timeout(5000)" or "foreign_keys=1", lowercased for case-insensitive
// de-duplication against a user-supplied value.
func pragmaName(p string) string {
if i := strings.IndexAny(p, "(="); i >= 0 {
p = p[:i]
}
return strings.ToLower(strings.TrimSpace(p))
}
// Close closes the underlying database.
func (d *DB) Close() error { return d.sql.Close() }
// SQL exposes the underlying *sql.DB for the per-entity query files.
func (d *DB) SQL() *sql.DB { return d.sql }
+180
View File
@@ -0,0 +1,180 @@
package store
import (
"context"
"path/filepath"
"strings"
"testing"
)
// openTestDB opens a fresh file-backed DB in a temp dir and migrates it.
func openTestDB(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func TestMigrateCreatesSchema(t *testing.T) {
db := openTestDB(t)
want := []string{
"users", "sessions", "gardens", "garden_shares",
"garden_objects", "plants", "plantings", "schema_migrations",
}
for _, table := range want {
var name string
err := db.SQL().QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table,
).Scan(&name)
if err != nil {
t.Errorf("expected table %q to exist: %v", table, err)
}
}
var version int
if err := db.SQL().QueryRow(`SELECT max(version) FROM schema_migrations`).Scan(&version); err != nil {
t.Fatalf("read schema_migrations: %v", err)
}
if version != 1 {
t.Errorf("schema version = %d, want 1", version)
}
}
func TestMigrateIsIdempotent(t *testing.T) {
db := openTestDB(t)
// A second Migrate must be a no-op and leave exactly one recorded version.
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("second Migrate: %v", err)
}
var count int
if err := db.SQL().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil {
t.Fatalf("count schema_migrations: %v", err)
}
if count != 1 {
t.Errorf("schema_migrations rows = %d, want 1", count)
}
}
func TestForeignKeysEnforced(t *testing.T) {
db := openTestDB(t)
// The DSN sets foreign_keys(1) per connection; a planting referencing a
// nonexistent object must be rejected. This guards the pragma actually
// reaching the app's pooled connections, not just the sqlite3 CLI.
_, err := db.SQL().Exec(
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (999, 999, 0, 0, 10)`,
)
if err == nil {
t.Fatal("expected foreign-key violation, got nil")
}
}
func TestCheckConstraintRejectsBadEnum(t *testing.T) {
db := openTestDB(t)
// Seed a user + garden so the FK is satisfied and only the CHECK can fail.
res, err := db.SQL().Exec(
`INSERT INTO users (email, display_name) VALUES ('[email protected]', 'A')`)
if err != nil {
t.Fatalf("insert user: %v", err)
}
uid, _ := res.LastInsertId()
Review

🟡 LastInsertId error silently discarded in test, leads to confusing FK failure on driver error

error-handling · flagged by 1 model

  • internal/store/store_test.go:89,95res.LastInsertId() errors are silently ignored (uid, _ := ...). If the driver ever returns an error, the test continues with uid=0, and the subsequent FK insert fails with a misleading foreign-key violation message instead of the actual LastInsertId error. Fix: Assert the error in tests: uid, err := res.LastInsertId(); if err != nil { t.Fatalf(...) }.

🪰 Gadfly · advisory

🟡 **LastInsertId error silently discarded in test, leads to confusing FK failure on driver error** _error-handling · flagged by 1 model_ * **`internal/store/store_test.go:89,95`** — `res.LastInsertId()` errors are silently ignored (`uid, _ := ...`). If the driver ever returns an error, the test continues with `uid=0`, and the subsequent FK insert fails with a misleading foreign-key violation message instead of the actual `LastInsertId` error. **Fix:** Assert the error in tests: `uid, err := res.LastInsertId(); if err != nil { t.Fatalf(...) }`. <sub>🪰 Gadfly · advisory</sub>
res, err = db.SQL().Exec(
`INSERT INTO gardens (owner_id, name, width_cm, height_cm) VALUES (?, 'G', 100, 100)`, uid)
if err != nil {
t.Fatalf("insert garden: %v", err)
}
gid, _ := res.LastInsertId()
// kind='invalid' violates the CHECK constraint.
_, err = db.SQL().Exec(
`INSERT INTO garden_objects (garden_id, kind, x_cm, y_cm, width_cm, height_cm)
VALUES (?, 'invalid', 0, 0, 10, 10)`, gid)
if err == nil {
t.Fatal("expected CHECK constraint violation for bad kind, got nil")
}
}
func TestForeignKeysEnforcedForFileURIDSN(t *testing.T) {
// A file: URI (or any path with query params) must still get foreign_keys(1):
// the earlier passthrough silently dropped the pragmas for these DSNs.
p := filepath.Join(t.TempDir(), "uri.db")
db, err := Open("file:" + p + "?_pragma=synchronous(1)")
if err != nil {
t.Fatalf("Open file: URI: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate: %v", err)
}
_, err = db.SQL().Exec(
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (999, 999, 0, 0, 10)`,
)
if err == nil {
t.Fatal("expected foreign-key violation for file: URI DSN, got nil")
}
}
func TestBuildDSNAlwaysIncludesPragmas(t *testing.T) {
for _, in := range []string{
"./pansy.db",
"/data/pansy.db",
"file:/data/pansy.db",
"file:/data/pansy.db?cache=shared",
"/tmp/weird?name.db",
} {
dsn, _ := buildDSN(in)
for _, want := range []string{"busy_timeout", "journal_mode", "foreign_keys"} {
if !strings.Contains(dsn, want) {
t.Errorf("buildDSN(%q) = %q, missing %s pragma", in, dsn, want)
}
}
}
}
func TestBuildDSNDoesNotDuplicateUserPragma(t *testing.T) {
// A user-supplied busy_timeout must not be duplicated by our default.
dsn, _ := buildDSN("file:/data/x.db?_pragma=busy_timeout(9000)")
if strings.Count(dsn, "busy_timeout") != 1 {
t.Errorf("buildDSN kept both user and default busy_timeout: %q", dsn)
}
}
func TestBuildDSNMemoryDetection(t *testing.T) {
if _, mem := buildDSN(":memory:"); !mem {
t.Error(":memory: not detected as in-memory")
}
if _, mem := buildDSN("file:x.db?mode=memory"); !mem {
t.Error("mode=memory not detected as in-memory")
}
if _, mem := buildDSN("/var/lib/pansy/pansy.db"); mem {
t.Error("file path wrongly detected as in-memory")
}
}
func TestInMemoryDBIsCoherentAcrossQueries(t *testing.T) {
// A bare ":memory:" DSN gives each connection its own DB; Open must pin the
// pool so the migrated schema is visible to subsequent queries.
db, err := Open(":memory:")
if err != nil {
t.Fatalf("Open(:memory:): %v", err)
}
defer db.Close()
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate: %v", err)
}
var name string
if err := db.SQL().QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name='gardens'`,
).Scan(&name); err != nil {
t.Fatalf("in-memory schema not visible: %v", err)
}
}
+24
View File
@@ -0,0 +1,24 @@
// Package webdist embeds the production web build so pansy ships as one static
// binary. The Makefile syncs web/dist into this package's dist/ directory before
// `go build`; a minimal placeholder dist/index.html is committed so the module
// builds without a web build present (the Makefile overwrites it).
package webdist
import (
"embed"
"io/fs"
)
//go:embed all:dist
var embedded embed.FS
// Dist returns the embedded web build rooted at its top level (index.html,
// assets/…), ready to hand to api.RegisterSPA.
func Dist() fs.FS {
sub, err := fs.Sub(embedded, "dist")
if err != nil {
// Only possible if the embed directive and this path disagree — a build-time bug.
panic("webdist: dist subtree missing: " + err.Error())
}
return sub
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>pansy</title>
</head>
<body>
<!-- Placeholder shell. `make web` replaces this directory with the real Vite
build (web/dist). Serving this file means the binary was built without a
web build; run `make build` for the full app. -->
<main style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem;">
<h1>pansy</h1>
<p>The web build is not embedded in this binary. Run <code>make build</code> to bundle the frontend.</p>
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.DS_Store
.vite/
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<title>pansy</title>
<meta name="description" content="Self-hostable garden planner — plan beds, containers, and plops of plants at real scale." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2701
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "pansy-web",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Pansy garden planner — web frontend.",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.95.0",
"clsx": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
/** Placeholder page scaffolding used until each feature issue fills these in. */
export function PageStub({ title, children }: { title: string; children?: ReactNode }) {
return (
<section>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="mt-2 text-sm text-muted">
{children ?? 'Placeholder — this page arrives in a later issue.'}
</p>
</section>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { Link, Outlet } from '@tanstack/react-router'
const navLinks = [
{ to: '/gardens', label: 'Gardens' },
{ to: '/plants', label: 'Plants' },
] as const
// TanStack Router concatenates the base className with activeProps/inactiveProps,
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the
// state props — never in the base — to avoid ambiguous overrides.
const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors'
const navLinkActive = 'bg-border/60 text-fg'
const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg'
/** Top-level chrome: a sticky nav bar plus the routed page in an <Outlet>. */
export function AppShell() {
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur">
<nav className="mx-auto flex max-w-5xl items-center gap-4 px-4 py-3">
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
🌱 pansy
</Link>
<div className="flex flex-1 items-center gap-1">
{navLinks.map((l) => (
<Link
key={l.to}
to={l.to}
className={navLinkBase}
activeProps={{ className: navLinkActive }}
inactiveProps={{ className: navLinkInactive }}
>
{l.label}
</Link>
))}
</div>
<Link
to="/login"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg"
>
Sign in
</Link>
</nav>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
<Outlet />
</main>
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
// Typed fetch wrapper for the pansy JSON API under /api/v1.
//
// Every non-2xx response throws an ApiError carrying the HTTP status and the
// parsed response body. That contract matters beyond this issue: the optimistic
// editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and,
// on a 409, reads the *current* row back out of `ApiError.body` to reconcile.
const BASE = '/api/v1'
/** Error thrown for any non-2xx response (or a network failure, status 0). */
export class ApiError extends Error {
readonly status: number
/** Parsed response body (JSON object, string, or null). On a 409 this is the current server row. */
readonly body: unknown
constructor(message: string, status: number, body: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.body = body
}
/** A version conflict (DESIGN.md § Sync); `body` holds the current server row. */
get isConflict(): boolean {
return this.status === 409
}
/** No authenticated session; callers typically redirect to /login. */
get isUnauthorized(): boolean {
return this.status === 401
}
}
type ParamValue = string | number | boolean | undefined | null
export type Params = Record<string, ParamValue>
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
/** JSON request body; serialized and sent with a JSON content-type. */
body?: unknown
/** Query-string parameters; undefined/null/'' entries are omitted. */
params?: Params
signal?: AbortSignal
}
function buildUrl(path: string, params?: Params): string {
const url = BASE + path
Review

🟠 buildUrl produces malformed URL when path omits leading slash

error-handling · flagged by 1 model

  • web/src/lib/api.ts:47buildUrl concatenates BASE + path without ensuring path starts with /. If a caller passes a naked path like apiFetch<Health>('healthz'), the resulting URL is /api/v1healthz (missing slash), which will always 404. This is an unhandled edge case for malformed input. Fix: normalize with a leading slash, e.g. const url = BASE + (path.startsWith('/') ? path : '/' + path), or assert in dev.

🪰 Gadfly · advisory

🟠 **buildUrl produces malformed URL when path omits leading slash** _error-handling · flagged by 1 model_ - **`web/src/lib/api.ts:47`** — `buildUrl` concatenates `BASE + path` without ensuring `path` starts with `/`. If a caller passes a naked path like `apiFetch<Health>('healthz')`, the resulting URL is `/api/v1healthz` (missing slash), which will always 404. This is an unhandled edge case for malformed input. **Fix:** normalize with a leading slash, e.g. `const url = BASE + (path.startsWith('/') ? path : '/' + path)`, or assert in dev. <sub>🪰 Gadfly · advisory</sub>
if (!params) return url
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue
qs.set(k, String(v))
}
const s = qs.toString()
return s ? `${url}?${s}` : url
}
async function parseBody(res: Response): Promise<unknown> {
if (res.status === 204) return null
const text = await res.text()
if (!text) return null
const contentType = res.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
try {
return JSON.parse(text)
} catch {
return text
}
}
return text
}
function messageFrom(body: unknown, status: number): string {
if (body && typeof body === 'object') {
const err = (body as { error?: { message?: string } }).error
if (err?.message) return err.message
const msg = (body as { message?: string }).message
if (msg) return msg
}
return `Request failed (${status})`
}
/** Perform a request against /api/v1 and return the parsed JSON body as T. */
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, params, signal } = opts
// Serialize before the try so a JSON.stringify failure (e.g. a circular value)
// surfaces as itself, not as a misleading "cannot reach the server" error.
const requestBody = body !== undefined ? JSON.stringify(body) : undefined
Review

🟡 null body serialized as JSON 'null' because guard uses !== undefined instead of != null

error-handling · flagged by 1 model

  • web/src/lib/api.ts:89 — A null request body is serialized as the JSON string "null" because the guard is body !== undefined instead of body != null. Callers often use null to mean “no body,” but here it triggers Content-Type: application/json and sends a literal null payload, which can break server-side handlers expecting an object or no body at all. Fix: Change the guard to body != null so both null and undefined are treated as absent.

🪰 Gadfly · advisory

🟡 **null body serialized as JSON 'null' because guard uses !== undefined instead of != null** _error-handling · flagged by 1 model_ * **`web/src/lib/api.ts:89`** — A `null` request body is serialized as the JSON string `"null"` because the guard is `body !== undefined` instead of `body != null`. Callers often use `null` to mean “no body,” but here it triggers `Content-Type: application/json` and sends a literal `null` payload, which can break server-side handlers expecting an object or no body at all. **Fix:** Change the guard to `body != null` so both `null` and `undefined` are treated as absent. <sub>🪰 Gadfly · advisory</sub>
let res: Response
try {
res = await fetch(buildUrl(path, params), {
method,
signal,
credentials: 'same-origin', // send the HttpOnly session cookie
headers: {
accept: 'application/json',
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
},
body: requestBody,
})
} catch (err) {
if ((err as Error)?.name === 'AbortError') throw err
throw new ApiError('Cannot reach the pansy server.', 0, null)
}
const payload = await parseBody(res)
Review

🟠 parseBody's res.text() runs outside apiFetch's try/catch, so a body-read failure rejects with a raw TypeError instead of an ApiError, breaking the wrapper's status/body contract that the optimistic editor depends on

error-handling · flagged by 2 models

  • web/src/lib/api.ts:108 — body parsing can throw a raw error instead of ApiError, breaking the wrapper's central contract. parseBody(res) calls await res.text() at line 60 (and JSON.parse(text) at line 65, though that one is guarded by a try/catch that falls back to text). The parseBody(res) call at line 108 is outside the try/catch in apiFetch, which only wraps the fetch at lines 92–106. So if the body stream fails mid-read (connection drop, aborted after headers, proxy…

🪰 Gadfly · advisory

🟠 **parseBody's res.text() runs outside apiFetch's try/catch, so a body-read failure rejects with a raw TypeError instead of an ApiError, breaking the wrapper's status/body contract that the optimistic editor depends on** _error-handling · flagged by 2 models_ - **`web/src/lib/api.ts:108` — body parsing can throw a raw error instead of `ApiError`, breaking the wrapper's central contract.** `parseBody(res)` calls `await res.text()` at line 60 (and `JSON.parse(text)` at line 65, though that one is guarded by a `try/catch` that falls back to text). The `parseBody(res)` call at line 108 is outside the `try/catch` in `apiFetch`, which only wraps the `fetch` at lines 92–106. So if the body stream fails mid-read (connection drop, aborted after headers, proxy… <sub>🪰 Gadfly · advisory</sub>
if (!res.ok) {
throw new ApiError(messageFrom(payload, res.status), res.status, payload)
}
return payload as T
}
/** Convenience verbs over apiFetch. */
export const api = {
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'POST', body }),
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'DELETE' }),
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
/** Merge conditional class names, resolving Tailwind conflicts (last wins). */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+23
View File
@@ -0,0 +1,23 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'
import './styles/index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, staleTime: 30_000 },
},
})
const rootEl = document.getElementById('root')
if (!rootEl) throw new Error('Missing #root element')
createRoot(rootEl).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
)
+13
View File
@@ -0,0 +1,13 @@
import { getRouteApi } from '@tanstack/react-router'
import { PageStub } from '@/components/PageStub'
const routeApi = getRouteApi('/gardens/$gardenId')
export function GardenEditorPage() {
const { gardenId } = routeApi.useParams()
return (
<PageStub title={`Garden ${gardenId}`}>
The field editor (canvas, palette, plops) arrives across issues #9#15.
</PageStub>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { PageStub } from '@/components/PageStub'
export function GardensPage() {
return <PageStub title="Gardens">The garden list and create flow arrive in issue #8.</PageStub>
}
+5
View File
@@ -0,0 +1,5 @@
import { PageStub } from '@/components/PageStub'
export function LoginPage() {
return <PageStub title="Sign in">Login form and SSO button arrive in issue #6.</PageStub>
}
+5
View File
@@ -0,0 +1,5 @@
import { PageStub } from '@/components/PageStub'
export function PlantsPage() {
return <PageStub title="Plants">The plant catalog arrives in issue #13.</PageStub>
}
+5
View File
@@ -0,0 +1,5 @@
import { PageStub } from '@/components/PageStub'
export function RegisterPage() {
return <PageStub title="Create account">Registration form arrives in issue #6.</PageStub>
}
+54
View File
@@ -0,0 +1,54 @@
import {
createRootRoute,
createRoute,
createRouter,
redirect,
} from '@tanstack/react-router'
import { AppShell } from '@/components/layout/AppShell'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { GardensPage } from '@/pages/GardensPage'
import { GardenEditorPage } from '@/pages/GardenEditorPage'
import { PlantsPage } from '@/pages/PlantsPage'
const rootRoute = createRootRoute({ component: AppShell })
// The root path has no page of its own yet; send it to the gardens list. A real
// auth-aware landing/redirect lands with the route guard in issue #6.
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
beforeLoad: () => {
throw redirect({ to: '/gardens' })
},
})
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', component: LoginPage })
const registerRoute = createRoute({ getParentRoute: () => rootRoute, path: 'register', component: RegisterPage })
const gardensRoute = createRoute({ getParentRoute: () => rootRoute, path: 'gardens', component: GardensPage })
const gardenEditorRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'gardens/$gardenId',
component: GardenEditorPage,
})
const plantsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'plants', component: PlantsPage })
const routeTree = rootRoute.addChildren([
indexRoute,
loginRoute,
registerRoute,
gardensRoute,
gardenEditorRoute,
plantsRoute,
])
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
+46
View File
@@ -0,0 +1,46 @@
@import 'tailwindcss';
/* Garden-planner theme tokens. Each --color-* becomes a Tailwind utility
(bg-*, text-*, border-*). Light by default; dark via prefers-color-scheme. */
@theme {
--color-bg: #f8faf7;
--color-surface: #ffffff;
--color-border: #e3e8e0;
--color-muted: #61705d;
--color-fg: #1c2419;
--color-accent: #3f8f4f;
--color-accent-strong: #2f7a3e;
--color-accent-contrast: #ffffff;
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
@layer base {
html,
body,
#root {
height: 100%;
}
body {
background-color: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
}
/* Dark theme: override the same tokens so utilities recolor automatically. */
@media (prefers-color-scheme: dark) {
@theme {
--color-bg: #10140f;
--color-surface: #171c15;
--color-border: #2a3226;
--color-muted: #8a967f;
--color-fg: #e7ede2;
--color-accent: #5bb06a;
--color-accent-strong: #6fc07d;
--color-accent-contrast: #0c1109;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts"]
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
// In dev, Vite serves the SPA with HMR and proxies /api to the Go backend. The
// backend listens on PANSY_PORT (default 8080); read it so the proxy target
// tracks an overridden port. The production build is embedded into the Go binary
// (see internal/webdist), so no proxy is involved there.
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const backendPort = env.PANSY_PORT || process.env.PANSY_PORT || '8080'
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: `http://127.0.0.1:${backendPort}`,
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
}
})