26 Commits
Author SHA1 Message Date
steveandClaude Opus 4.8 156b3fd14c Seed-packet capture: vision model, extraction, catalog match, create (backend) (#81)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 9m44s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m44s
Photograph a seed packet → it fills in the plant and the purchase. This is the
backend; the scan UI is a follow-up PR.

Vision model config (mirrors the agent model from #79):
- Migration 0011 adds instance_settings.vision_model; PANSY_VISION_MODEL is the
  env default. Precedence Settings → env → empty; the KEY stays in the env.
- EffectiveVision resolves it; /capabilities advertises "vision" only when a
  model + key are configured, so the UI offers the scan button only when it works.

Extraction is one-shot, NOT an agent loop (internal/vision):
- majordomo.Generate[SeedPacket] derives a JSON schema from the struct tags and
  hands the image to the vision model; it can't call a tool, so it can't touch
  the garden — it only reads a picture and returns data. Numeric fields are
  pointers, so a field the packet doesn't print comes back nil, not a made-up 0.
- Hermetic test: majordomo's fake provider returns canned packet JSON and
  Generate unmarshals it, image + derived schema included. No live model.

The image is normalized to JPEG at the upload boundary (imagenorm from #80),
which is where an iPhone HEIC becomes readable — majordomo's media path can't
decode HEIC. imagenorm now links into the binary (~7 MB, the cost #80 deferred).

The hard part is catalog matching, not OCR (internal/service/seed_packet.go):
- A wrong auto-match splits a variety's seed-lot history across duplicate rows,
  so the service NEVER auto-creates. matchPlants surfaces RANKED candidates
  (exact name → variety-in-name → same species, conservative and name-based),
  the user confirms, and CreateFromPacket makes the plant (new or existing) + the
  lot. Exactly one of plantId/newPlant, refused otherwise.
- Plants/lots aren't in the undo history (catalog/inventory), so no change set.
- The extractor is injectable (service.WithPacketExtractor) so ExtractSeedPacket
  and the /scan endpoint test end to end against a fake, no live model.

Endpoints: POST /seed-lots/scan (multipart image → proposal, reads only; extends
the read deadline for a slow phone upload, caps the body, maps too-large/unreadable
to clear statuses) and POST /seed-lots/from-packet (confirmed proposal → 201).

Docs: README (PANSY_VISION_MODEL), DESIGN (routes + the decision and why the
model can't touch the garden).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:50:50 -04:00
steveandClaude Opus 4.8 41c28592a2 Admin-gated Settings: runtime model selection, enforce is_admin (#79)
Gadfly review (reusable) / review (pull_request) Failing after 30s
Adversarial Review (Gadfly) / review (pull_request) Failing after 30s
Build image / build-and-push (push) Successful in 14s
Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.

Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
  first instance-level state. Holds agent_model ('' = inherit env) and
  agent_enabled (NULL = inherit env), version-guarded like every mutable row.
  SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
  early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
  an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
  nil-check agent.get(), so a settings change turns the assistant on/off/onto a
  new model with no restart and no race against in-flight readers. /capabilities
  reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
  to turn a spec into a model. Both agent (to run) and service (to validate a
  spec before storing it) import it; it can't live in agent, which imports
  service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
  rather than a broken assistant on the next turn.

Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
  enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
  a running page — and the settings save invalidates it.

Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.

Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.

Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 20:27:36 -04:00
steve 4ea0d0b262 Undo reported "nothing left to undo" after a successful undo (#72)
Build image / build-and-push (push) Successful in 19s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 12:25:34 +00:00
steve 3a3ce16fce Agent runtime: majordomo in-process, Ollama Cloud config, chat endpoint (#56) (#70)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:22:10 +00:00
steve 7f8b5254d0 Journal UI: write and read season notes where you're already looking (#53) (#67)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:55:45 +00:00
steve 8c5ddb21ec Season view: filter the editor to a year (#54) (#66)
Build image / build-and-push (push) Successful in 17s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:47:34 +00:00
steve b96ca1ec0a Grow journal: time-stamped notes on gardens, beds and plantings (#52) (#65)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:40:17 +00:00
steve 78a04672b5 Seed provenance + inventory: source links and seed lots (#50) (#64)
Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:39:24 +00:00
steve f208da94d6 Revision history: change sets + revisions + revert — the undo substrate (#48) (#61)
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:07:30 +00:00
steve e22bdd6cab Copy a garden (deep duplicate) from the gardens list (#46)
Build image / build-and-push (push) Successful in 16s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 03:58:39 +00:00
steve e74fb308c1 Configurable grid + snap-to-grid in the layout system (#45)
Build image / build-and-push (push) Successful in 7s
Closes #44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 07:07:14 +00:00
steve 9968c06243 Public read-only share link (no login / no OIDC) (#41) (#43)
Build image / build-and-push (push) Successful in 8s
Per-garden public read-only link: unauthenticated GET /api/v1/public/gardens/:token (no requireAuth, no OIDC), owner-only enable/rotate/disable, and a /g/$token page rendering GardenCanvas read-only. Review fixes: redact plant owner ids, Cache-Control: no-store, 400 on malformed body, shared resetTransient store action.

Closes #41.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 06:18:31 +00:00
steve 18e4a299c2 Agent seam: bulk ops (FillRegion/ClearObject/DescribeGarden) + DefineTool wrappers (#19)
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 05:16:40 +00:00
steve 2a86f87b50 Sharing backend: shares CRUD + ACL enforcement everywhere (#16)
Build image / build-and-push (push) Successful in 4s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 03:49:54 +00:00
steve f4e5dab98c Plantings backend: plop CRUD, derived counts, removed_at (#14)
Build image / build-and-push (push) Successful in 7s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 02:41:22 +00:00
steve 293532f021 Plant catalog backend: CRUD + seeded built-ins (#12)
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 01:48:22 +00:00
steveandClaude Opus 4.8 0793fef17c Address Gadfly review on #10: clearable color/props, column lists, dedup
Build image / build-and-push (push) Successful in 7s
Fixes from the PR #28 adversarial review (considered; not graded).

Correctness / API
- PATCH /objects/:id can now clear nullable color/props back to NULL: the
  request takes them as json.RawMessage, and ObjectPatch carries an explicit
  Set flag so an explicit `null` (clear) is distinguished from an absent
  field (unchanged) — the strongest cross-model finding (6 hits). New test.

Maintainability
- store/plantings.go + plants.go use explicit qualified column lists
  (qualifyColumns helper) instead of SELECT *, matching gardens/objects and
  surviving a future column add.
- Consolidated objectKinds + plantableByDefault into one kind→traits map.
- objectForRole factors the fetch-then-authorize shared by UpdateObject and
  DeleteObject; dropped the redundant kind check in CreateObject
  (finalizeObject is the single validation point).
- Request→service mapping via toInput()/toPatch() methods (matches gardens).
- Renamed handler gardenFull → getGardenFull (verbNoun); test helper
  decodeGarden → decodeMap; generic bind-error messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 19:47:10 -04:00
steveandClaude Opus 4.8 3dd935fb19 Add garden objects backend: polymorphic CRUD + /full (#10)
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m9s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m9s
Garden objects (beds, bags, containers, in-ground, trees, paths,
structures) in one polymorphic table, following the #7 service
conventions.

- store/objects.go: scanObject + Create (RETURNING), Get, ListForGarden
  (z_index order), version-guarded Update (RETURNING, returns current row
  on conflict), Delete (plantings cascade via FK).
- store/plantings.go + plants.go: the read side /full needs now —
  ListActivePlantingsForGarden (removed_at IS NULL) and
  ListReferencedPlants (distinct plants used by active plantings). Both
  return empty until #14 adds plantings; #12/#14 extend these files.
- service/objects.go: Create/Update(partial patch)/Delete/GardenFull, all
  (ctx, actorID, ...) through requireGardenRole(editor for mutations,
  viewer for /full). finalizeObject validates kind + shape (rect/circle;
  polygon reserved), finite dims in [1cm,100m], NaN/Inf rejection, loose
  bbox-overlaps-garden placement (partial overhang OK), hex color, valid
  JSON props, name/notes caps; normalizes rotation to [0,360). Plantable
  defaults by kind, overridable.
- api/objects.go: POST /gardens/:id/objects, PATCH/DELETE /objects/:id,
  GET /gardens/:id/full ({garden,objects,plantings,plants}). props is any
  JSON value stored as text; PATCH is partial + required version, 409
  returns the current row.

Tests: service (defaults, plantable-by-kind, rotation normalize,
validation rejects incl. off-field/polygon/bad-color/bad-props, partial
patch + version conflict, cross-user ErrNotFound, delete, /full shape) and
api (CRUD + /full, 409 envelope, cross-user 404, polygon/no-kind 400,
props round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 19:30:30 -04:00
steveandClaude Opus 4.8 3fbefa4e05 Address Gadfly review on #7: garden cap, dim validation, shared errors
Build image / build-and-push (push) Successful in 5s
Fixes from the PR #26 adversarial review (graded 18 real / 0 false positive).

Correctness / security
- maxGardenCM fixed to 10_000 (100 m), matching its comment — it was
  100_000 cm (1 km), 10x too lax (5 models flagged this).
- Dimension validation now rejects NaN/Inf (which slip past naive
  comparisons) and subnormal-tiny positives, via a finite [1cm, 100m]
  check. Name (200) and notes (10_000) are length-capped so untrusted
  input can't balloon storage.
- Update version binding is `required,min=1`, so a negative/zero version
  is a 400, not a 409.

Maintainability / performance
- One unified writeServiceError (new errors.go) maps every auth + resource
  sentinel; writeResourceError removed. writeVersionConflict and
  parseIDParam moved to errors.go (shared, not in the gardens feature file).
- Request structs share an embedded gardenFields (one toInput).
- CreateGarden uses INSERT ... RETURNING (one round-trip).
- ListGardensForOwner has a defensive LIMIT (pagination is post-v1).

Tests: name/notes length, NaN/Inf/subnormal dims, dimension-at-cap valid,
negative/zero version -> 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 18:39:27 -04:00
steveandClaude Opus 4.8 f39ed52868 Add gardens CRUD + service-layer conventions (#7)
Build image / build-and-push (push) Successful in 4s
Gadfly review (reusable) / review (pull_request) Successful in 9m56s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m56s
Establishes the patterns every later backend issue copies: the actor
parameter, centralized role checks, and the version-guard/409 sync
protocol. The service layer is the seam both REST handlers and future
agent tools call, so permissions live here, not in handlers.

- service/gardens.go: Service methods take (ctx, actorID, args).
  requireGardenRole(ctx, actor, gardenID, min) is THE authorization point
  — owner is implicit via owner_id now; #16 extends it to consult
  garden_shares. A user with no role gets ErrNotFound (existence masked),
  not ErrForbidden. Create/Get/List/Update/Delete with input validation
  (name required, 0 dims default to 10 m on create / rejected on update,
  negatives always rejected, unit metric|imperial, 100 m cap).
- store/gardens.go: version-guarded UPDATE ... WHERE id=? AND version=?
  RETURNING; a no-match re-reads to return (current row,
  ErrVersionConflict) vs ErrNotFound. ListGardensForOwner returns a
  non-nil slice.
- api/gardens.go: GET,POST /gardens and GET,PATCH,DELETE /gardens/:id
  behind requireAuth. writeVersionConflict documents the 409 envelope
  ({error:{code,message}, current:{...}}) — the contract for every
  mutable resource. writeResourceError maps ErrNotFound/Forbidden/
  InvalidInput/VersionConflict; parseIDParam guards path ids.

Tests: service (defaults, validation, owned-only list, version
conflict returns current + retry, cross-user ErrNotFound, delete) and
api (full CRUD flow, 409 envelope shape, cross-user 404, auth required,
create validation). Verified against the running binary: create stores
imperial 122x244 cm and list returns it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 18:20:39 -04:00
steveandClaude Opus 4.8 8ef092713f Address Gadfly review on #5: OIDC identity guard, verified-email, timeouts
Build image / build-and-push (push) Successful in 9s
Fixes from the PR #24 adversarial review (graded 23 real / 1 false positive):

Security / correctness
- LinkOIDC no longer overwrites a different stored identity: the UPDATE
  matches only when the row has no identity yet or already carries this
  exact one, so a second IdP asserting the same verified email can't
  hijack or lock out an account (returns ErrOIDCIdentityConflict). Uses
  a single UPDATE...RETURNING (also fixes the ignored-RowsAffected /
  misleading-ErrNotFound path and the round-trip).
- Provisioning now requires a verified email for BOTH linking and JIT
  creation (was: linking only), so an unverified-email identity can't
  create an account — nor become the first admin on a fresh instance,
  nor squat an email a real user later owns.
- OIDC-identity collisions surface as the dedicated ErrOIDCIdentityConflict
  instead of the email-specific ErrEmailTaken.

Robustness
- readOIDCTxCookie requires a non-empty nonce (an empty one would make the
  callback's nonce check pass vacuously).
- Callback token exchange + verify run under a 15s context timeout so a
  slow IdP can't outlast the server write timeout.
- ensure() performs discovery outside the mutex, so concurrent cold-start
  requests don't serialize behind one another's full timeout.
- setOIDCTxCookie returns its marshal error; oidcLogin aborts rather than
  redirecting to the IdP with no tx cookie.

Maintainability
- redirectAuthError helper dedups the ~dozen callback redirects (and the
  empty-code path now logs like the rest).
- Distinct login error codes (no_email / email_unverified / oidc_conflict)
  for the UI; writeServiceError maps the OIDC sentinels; shared test issuer
  const.

Tests: unverified email refused for both link and JIT; identity-overwrite
refused while the original identity keeps working.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:33:25 -04:00
steveandClaude Opus 4.8 84edf3e42a Add OIDC login via Authentik: PKCE, JIT provisioning, email linking (#5)
Build image / build-and-push (push) Successful in 5s
Gadfly review (reusable) / review (pull_request) Successful in 9m32s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m32s
OIDC is pansy's primary login path (Authentik the target IdP); local
auth (#4) remains the fallback and both issue the same session cookie.

- deps: github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 (both pure Go;
  CGO stays off).
- api/oidc.go: lazy issuer discovery (retried per-request, never crashes
  a server that also serves local auth), GET /auth/oidc/login builds an
  authorization-code URL with PKCE S256 + random state + nonce stashed in
  a short-lived HttpOnly cookie, GET /auth/oidc/callback verifies state
  (constant-time), exchanges the code with the PKCE verifier, verifies the
  ID token + nonce, and starts a pansy session. Failures redirect to
  /login?error=... ; success to /gardens.
- service.LoginOIDC: (issuer,subject) match -> login; else *verified*
  email match -> link onto the existing account; else JIT-create (the IdP
  gates access, so PANSY_REGISTRATION doesn't apply). Unverified email
  colliding with an existing account is refused (takeover guard); no email
  is refused (email is the account key). Reuses the atomic CreateUser.
- store: GetUserByOIDC + LinkOIDC (unique-pair backstop).
- config: OIDCReady() (needs issuer+client+BaseURL for the redirect URI);
  /auth/providers now reports oidc from it and defaults the button label
  to "Sign in with Authentik". OIDC routes are only registered when ready,
  so an unconfigured instance 404s them.
- PANSY_LOCAL_AUTH=false rejects/hides local auth but not OIDC.

Tests: service provisioning (JIT, repeat login, link, unverified-collision
refusal, no-email, name fallback, works with local auth off); api
(routes-absent-when-unconfigured, providers reporting, login redirect with
PKCE params + tx cookie via a fake discovery server, callback state/error
paths). Smoke-tested: unreachable issuer degrades to error=oidc_unavailable
with the server still up and local auth working.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:13:47 -04:00
steveandClaude Opus 4.8 02c928ac6d Address Gadfly review on #4: TOCTOU, sliding cookie, CSRF, argon2
Build image / build-and-push (push) Successful in 5s
Fixes from the PR #23 adversarial review (graded 35 real / 1 false positive):

Security / correctness
- Race-free registration: is_admin and the registration gate are now
  computed atomically inside a single INSERT...SELECT, so concurrent
  first registrations can't both become admin or bypass closed
  registration (fixed the whole TOCTOU cluster).
- Sliding session now reaches the browser: ResolveSession returns the
  current expiry and requireAuth re-sets the cookie, so active users
  aren't logged out 30 days after login regardless of activity.
- Login CSRF: csrfGuard rejects state-changing requests whose Origin
  doesn't match PANSY_BASE_URL (no-op when unset, so the dev proxy is
  unaffected). SameSite=Lax alone didn't cover this.
- argon2id tuned to RFC 9106's second recommended profile (t=3).
- Timing equalizer can't fail open: the dummy hash is derived
  deterministically (fixed salt, no RNG) so it's always present.
- Password length (<=1024) enforced in the service for both register
  and login, not just HTTP binding tags; login rejects over-long input
  before spending argon2 work.

Error handling / robustness
- Login logs a malformed stored hash instead of silently treating it as
  a wrong password.
- Best-effort session writes (Touch/Delete during renewal, expiry, and
  corrupt-expiry cleanup) now log on failure.
- index sessions.expires_at via new migration 0002 (0001 is immutable).

Maintainability
- Extract startSessionAndRespond and abortUnauthenticated; make
  writeServiceError a free function; consistent error handling in
  decodeHash; doc/comment fixes.

Tests: over-long password, CSRF guard (cross-origin/same-origin/dev
no-op), and cookie refresh on authenticated requests; migration-version
assertions bumped to 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:04:35 -04:00
steveandClaude Opus 4.8 0e41ccd95a Add local auth: users, sessions, register/login/logout/me (#4)
Build image / build-and-push (push) Successful in 8s
Gadfly review (reusable) / review (pull_request) Successful in 7m7s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m7s
Implements pansy's local (email + password) authentication and the
session layer that OIDC (#5) will also reuse.

- store: users.go (create/get-by-id/get-by-email/count) and sessions.go
  (create/get/touch/delete/delete-expired), scanning the existing 0001
  schema.
- service: the business-logic seam. auth.go (Register/Login/session
  lifecycle/Providers) + password.go (argon2id, 64 MiB/1/4, PHC-encoded,
  constant-time verify) + service.go (Service, clock injection, token
  hashing). First user is admin; closed registration still allows the
  bootstrap user; unknown-email and wrong-password are indistinguishable
  (same error, same argon2 work via a dummy hash).
- api: POST /auth/register|login|logout, GET /auth/me|providers, plus a
  requireAuth middleware that resolves the HttpOnly session cookie
  (SameSite=Lax, Secure under https) to the actor. Handlers stay thin.
- main: wires the service and a periodic expired-session sweep; sessions
  are also dropped lazily on access. Sliding 30-day expiry.
- tests: service (register/login/expiry/renewal/cleanup, password) and
  api (cookie flow, middleware, validation, providers).

Verified end-to-end via curl: register -> me -> restart -> session
persists -> logout -> 401.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 16:38:03 -04:00
steveandClaude Opus 4.8 0f3dedab73 Address Gadfly review findings on Phase 0
Applied the fixes warranted by the adversarial review of PR #21 (all
findings graded in the gadfly store):

Store (highest impact):
- buildDSN always merges busy_timeout/journal_mode/foreign_keys pragmas
  into the DSN, even for file: URIs or paths with a query string — the
  prior passthrough silently disabled FK enforcement for those PANSY_DB
  values. Also escape '#' in the path and tighten in-memory detection to
  an exact ':memory:' token or mode=memory param (no substring misfire).
- migrate.go: detect duplicate migration versions with a clear error;
  wrap appliedVersions' rows.Err() with the store: prefix.

API:
- SetTrustedProxies failure now falls back to trust-none instead of
  leaving gin's trust-everyone default (X-Forwarded-For spoofing).
- SPA: 404 embedded directories (was a directory listing), 404 bare /api,
  add X-Content-Type-Options: nosniff, cache index.html bytes once at
  startup, single fs.Stat existence check, shared writeAPIError helper.
- Move gin.SetMode out of package init() into New().

Config: validate PANSY_PORT range (fall back to 8080 with a warning).

Web:
- api.ts: serialize the request body before the fetch try so a
  JSON.stringify failure isn't misreported as a network error.
- AppShell: move state-specific/conflicting utilities into
  active/inactiveProps so concatenated Tailwind classes don't collide.

Tests: added store DSN-pragma/memory-detection cases and SPA
directory-404 case. go build/vet/test clean (CGO off); web tsc + build
clean; re-verified in a browser (SPA, deep links, /assets 404, nav).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 15:19:30 -04:00
steveandClaude Opus 4.8 91da9ff945 Phase 0: backend + frontend scaffold, single-binary build
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m9s
Implements the Pansy v1 scaffold (epic #20, phase 0).

#1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config,
   gin server with slog + recovery + /api/v1/healthz, modernc SQLite store
   with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration
   runner, full initial schema (users, sessions, gardens, garden_shares,
   garden_objects, plants, plantings), domain structs + sentinel errors,
   graceful shutdown.

#2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack
   Router/Query, typed /api/v1 fetch wrapper (ApiError carries status +
   body so later issues can read 409 conflict rows), dev proxy /api -> :8080,
   responsive app shell with stub pages for all five routes.

#3 Single binary: //go:embed of the web build with a committed placeholder,
   SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched
   /api and missing assets), Makefile (web/build/dev/test), README quickstart
   + env var table.

Verified: go build/vet/test clean (CGO off); binary migrates idempotently and
serves healthz; web tsc + build clean; integrated binary serves the SPA, deep
links, and correct cache headers; app mounts and navigates all five routes at
desktop and 375px widths.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 14:52:05 -04:00