440e43eb785ad7655925744a141e629bde4b925c
57
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f39ed52868 |
Add gardens CRUD + service-layer conventions (#7)
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 |
||
|
|
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 |
||
|
|
84edf3e42a |
Add OIDC login via Authentik: PKCE, JIT provisioning, email linking (#5)
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 |
||
|
|
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 |
||
|
|
0e41ccd95a |
Add local auth: users, sessions, register/login/logout/me (#4)
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 |
||
|
|
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 |
||
|
|
91da9ff945 |
Phase 0: backend + frontend scaffold, single-binary build
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 |