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
This commit is contained in:
2026-07-18 18:39:27 -04:00
co-authored by Claude Opus 4.8
parent f39ed52868
commit 3fbefa4e05
7 changed files with 165 additions and 120 deletions
-27
View File
@@ -1,7 +1,6 @@
package api
import (
"errors"
"log/slog"
"net/http"
"net/url"
@@ -210,29 +209,3 @@ func (h *handlers) clearSessionCookie(c *gin.Context) {
func mustActor(c *gin.Context) *domain.User {
return c.MustGet(actorKey).(*domain.User)
}
// writeServiceError maps a service-layer sentinel error to pansy's JSON error
// envelope. Login failures never leak which of email/password was wrong.
func writeServiceError(c *gin.Context, err error) {
switch {
case errors.Is(err, domain.ErrInvalidCredentials):
writeAPIError(c, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid email or password")
case errors.Is(err, domain.ErrEmailTaken):
writeAPIError(c, http.StatusConflict, "EMAIL_TAKEN", "an account with that email already exists")
case errors.Is(err, domain.ErrRegistrationClosed):
writeAPIError(c, http.StatusForbidden, "REGISTRATION_CLOSED", "registration is closed")
case errors.Is(err, domain.ErrLocalAuthDisabled):
writeAPIError(c, http.StatusForbidden, "LOCAL_AUTH_DISABLED", "local authentication is disabled")
case errors.Is(err, domain.ErrOIDCNoEmail):
writeAPIError(c, http.StatusBadRequest, "OIDC_NO_EMAIL", "the identity provider returned no email")
case errors.Is(err, domain.ErrOIDCEmailUnverified):
writeAPIError(c, http.StatusForbidden, "OIDC_EMAIL_UNVERIFIED", "the identity provider's email is not verified")
case errors.Is(err, domain.ErrOIDCIdentityConflict):
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
case errors.Is(err, domain.ErrInvalidInput):
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input")
default:
slog.Error("api: unhandled service error", "error", err)
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
}
}