Commit Graph
9 Commits
Author SHA1 Message Date
steveandClaude Opus 4.8 78dbadf95c Add plant catalog backend: CRUD + seeded built-ins (#12)
Build image / build-and-push (push) Successful in 5s
Gadfly review (reusable) / review (pull_request) Successful in 10m32s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m32s
Extends the plants store (which had only the /full read side) with full
catalog CRUD, adds a service layer with visibility/immutability rules, REST
endpoints, and a seed migration of ~32 built-ins.

- 0003_seed_plants.sql: 32 built-in plants (owner_id NULL, read-only), seeded
  once by the version-tracked migration runner.
- store: ListPlantsForActor (built-ins + own), Get/Create/Update/Delete +
  CountPlantingsForPlant; isForeignKeyViolation backstop for the RESTRICT FK.
- service: built-ins are read-only (ErrForbidden); another user's plants are
  invisible (ErrNotFound); delete of a referenced plant → ErrPlantInUse (409);
  validation mirrors the schema (category enum, spacing>0, hex color, icon).
- api: GET,POST /plants and PATCH,DELETE /plants/:id with the version guard.
- Made the migration-count store tests derive their expectation from the files
  so future migrations don't break them.

Service + API tests cover visibility, built-in immutability, validation,
version conflict, and delete-in-use.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 21:33:12 -04: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