Raise the Better Auth rate limit from max:10/window:60 to max:100/window:10
to match library defaults, and exempt /get-session from rate limiting entirely
via customRules (returns null = no rate limit check).
Both AUTH_DISABLED and production rateLimit blocks updated.
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
* fix(GRO-766): prevent horizontal overflow on portal mobile pages
- Add overflow-x-hidden to main content area in CustomerPortal
- Add w-full overflow-hidden to content wrapper div
- Add flex-wrap to BillingPayments tab button row
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(GRO-769): proxy logo uploads through API server to fix mixed content
The pre-signed URL flow used an internal HTTP endpoint for S3 uploads,
which browsers blocked as mixed content on HTTPS pages. Instead of
generating a pre-signed URL that the browser uploads to directly,
the new /logo/upload endpoint receives the file via multipart POST
and streams it to S3 from the API server using the internal endpoint.
This resolves the mixed content error that was blocking logo uploads
on dev.groombook.dev.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
- Extend updateInvoiceSchema to accept optional tipSplits array in PATCH body
- Validate tip splits sum to 100% (10000 bps) when marking paid with tipCents > 0
- Return 422 if tipSplits not provided and no existing splits in DB
- Save tip splits atomically in same DB transaction as invoice status update
- Update frontend markPaid() to send tipSplits in PATCH body instead of separate POST
- Remove non-atomic POST /tip-splits call from markPaid flow
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Defensive validation in /api/branding ensures base64-encoded logo content
matches its declared MIME type by checking image magic bytes (PNG, JPEG,
GIF, WebP). If the content doesn't match, the legacy base64 fields are
nulled out before returning to prevent MIME type confusion attacks.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Use Drizzle's inArray() instead of raw sql template with = ANY()
to avoid PostgreSQL array binding issues in the reminder scheduler
bulk sent-check query.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Move hostname validation to run AFTER OIDC_INTERNAL_BASE replacement
(was checking raw discovery URLs before replacement caused false positives)
- Only validate authorizationUrl hostname against issuer; token/userinfo
are server-to-server and may legitimately use internal hostnames
- Infra: add OIDC_INTERNAL_BASE env var to dev overlay (was missing, matches UAT)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add SMS opt-in fields to clients schema (smsOptIn, smsConsentDate, smsOptOutDate, smsConsentText)
- Add channel column to reminderLogs with per-channel idempotency
- Create SMS service with Telnyx SDK integration and E.164 validation
- Update reminders service to conditionally send SMS to opted-in clients
- Add TCPA opt-out text to SMS reminders
- Graceful degradation: catch SMS errors without blocking email
- Fix: use clients.phone instead of non-existent clients.phoneE164
- Update clients route to expose SMS fields in API
- Add telnyx dependency to API package
- Create database migration 0028_sms_reminders
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* Fix invoice status transitions, tip-split validation, refund idempotency, and tip-split response format
- Add ALLOWED_TRANSITIONS state machine for invoice status changes (GRO-637)
- Replace floating-point tip-split validation with integer basis-points math
- Add idempotency key support to refund endpoint with new refunds table
- Return full invoice shape from POST /:id/tip-splits matching GET response
- All existing tests pass
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(invoices): wrap refund flow in transaction for idempotency safety
- Wrap idempotency check + processRefund() + db.insert() in db.transaction()
- This prevents duplicate Stripe refunds if the DB insert fails after Stripe processes the refund
- Add migration 0027_refunds for the refunds table (was missing)
- Removes out-of-scope changes from PR #278 (csrf.ts, appointmentGroups, appointments, book, groomingLogs, services, stripe-webhooks)
Fixes GRO-637 per CTO review
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(api): wire up CSRF middleware for protected routes
Register csrfMiddleware in the protected API routes after authMiddleware
and resolveStaffMiddleware to protect against CSRF attacks on state-
changing operations (POST, PUT, PATCH, DELETE).
Addresses CTO review feedback on PR #278.
* fix(api): remove CSRF middleware that breaks POST/PUT/PATCH/DELETE
The CSRF middleware requires x-csrf-token header but the frontend never
sends it, which would break all mutating operations with 403 errors.
CSRF protection should be implemented in a separate coordinated PR with
frontend changes.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Flea Flicker <flea-flicker@groombook.ai>
Auto-link staff records by email when userId is NULL on first authenticated request.
Resolves GRO-667 UAT 403 blocker.
Co-Authored-By: Flea Flicker <noreply@anthropic.com>
Adds Zod validation across 5 API routes:
1. invoices GET / — query param validation (uuid, enum, int bounds)
2. book POST / — future-time refinement on startTime
3. appointments — recurrence series capped at 1 year
4. services — durationMinutes capped at 480 (8 hours)
5. stripe-webhooks — UUID validation on invoice IDs before DB lookup
Closes GRO-636
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add SQL-level LIMIT/OFFSET pagination to churn risk query
- Add separate COUNT(*) subquery for total without fetching all rows
- Accept page and limit query params with sensible defaults and bounds
- Return page, limit, and churnRiskTotal in response
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Replace SELECT-then-UPDATE with atomic UPDATE ... WHERE token=? AND status='pending' RETURNING *
to prevent confirmation token replay attacks (TOCTOU race condition)
- Fix requireRoleOrSuperUser() error message: swap the conditional branches so
'Forbidden: super user privileges required' is returned when user lacks role,
and 'Forbidden: role X is not permitted' when user is not superuser
- Add 'and' mock export to confirmation.test.ts and rbac.test.ts for new query patterns
- Update test expectations to match corrected error message semantics
Prevents ENOENT crash in migrate and seed jobs.
Root cause: corepack tries to mkdir /home/node/.cache/node/corepack/v1
but the directory does not exist in the builder stage. This was a
regression in c438f57 where the cache directory was not pre-created.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- appointmentGroups: Hono<AppEnv>() + groomer isolation on all 5 endpoints
- groomingLogs: Hono<AppEnv>() + groomer isolation on GET, POST, DELETE with appointmentId preserved
- appointments: batherStaffId conflict checks in POST and PATCH handlers
- Non-groomer roles retain full access
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* feat(GRO-566): add SKIP_OOBE env var to bypass setup wizard
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* Add rate_limit table migration for Better Auth (GRO-574)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(GRO-574): switch rate limit to memory storage to unblock UAT
Better Auth rate_limit table migration exists on branch but hasn't
been deployed to UAT. Switching to memory storage bypasses the
missing table entirely, restoring auth functionality immediately.
Memory storage is per-instance (not shared) — rate limiting still
functions but won't be distributed across pods. This is acceptable
for UAT while the migration is being promoted through the pipeline.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: groombook-qa[bot] <269744346+groombook-qa[bot]@users.noreply.github.com>
Adds the missing rate_limit table that Better Auth v1.5.6 requires when rateLimit.storage is set to 'database'. Without this table, all auth endpoints return HTTP 500.
Also includes GRO-566: SKIP_OOBE env var to bypass setup wizard in dev/test.
cc @cpfarhood
- apps/web: upgrade better-auth from ^1.0.0 to ^1.5.6 (matches API)
- apps/web/vite.config.ts: exclude /api/auth/* from service worker caching
- apps/api/index.ts: return 503 when auth not configured
- apps/api/middleware/auth.ts: return 503 when auth not initialized
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The OAuth callback was failing with "please_restart_the_process" because
Better-Auth's default DB-backed state (verification table) was unreliable —
the UAT hourly reset wipes all tables including verification records. Switch
to cookie-based state storage so the encrypted state survives in the browser
cookie across the redirect flow.
Also removes explicit redirectURI from socialProviders (Better-Auth derives
it from baseURL) and adds visible error feedback on the login page when
OAuth callbacks fail.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Explicitly set redirectURI in social provider configs to ensure
Better-Auth uses the correct callback URL for OAuth providers.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- auth.ts: add google/github social providers from better-auth/social-providers
- auth.ts: add getActiveProviders() to enumerate configured OAuth/social providers
- index.ts: add /api/auth/providers public endpoint for frontend
- App.tsx: update LoginPage to show Google/GitHub buttons based on /api/auth/providers response
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add ALLOW_RESET env var override to reset.ts safety guard
- Add reset Docker build target to Dockerfile
- Add reset image build step to CI docker job
- Add reset image tag update to CD job dev overlay update
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add database migration 0024 with indexes on invoices, invoice_line_items, and invoice_tip_splits
- Update Drizzle schema with index definitions for sync
- Add pagination (limit/offset) to GET /api/invoices with max 200 limit
- Add LEFT JOIN to include clientName in invoice list response
- Return { data: [...], total: N } response shape for pagination
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Fix type errors that caused CI Lint & Typecheck job to fail:
- setup.ts: replace unavailable isNull import with sql template tag
(isNull not exported from @groombook/db; sql IS exported)
- setup.ts: add non-null assertion on newStaff after insert.returning()
- setup.test.ts: add sql mock template tag to @groombook/db mock
- setup.test.ts: fix evaluateCond to handle sql template tag type
- setup.test.ts: add type assertions for body.staff in OOBE regression tests
- setup.test.ts: fix dbStaffRows type casts in mock insert function
All 18 tests pass, full typecheck clean.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Exempt POST /api/setup from resolveStaffMiddleware so OOBE users (with no
pre-existing staff record) can complete the out-of-box experience without
getting blocked by the "no staff record found" 403 error.
Changes:
- rbac.ts: add /api/setup to path exemption alongside /api/auth/
- setup.ts POST /: add find-or-create logic that:
- Looks up existing staff by userId from JWT
- Auto-links legacy staff records by email if userId is null
- Creates a new staff record if none exists (OOBE case)
- Returns 400 if JWT has no email and no staff record found
- setup.test.ts: add regression tests for all scenarios
Fixes GRO-485 (OOBE regression introduced by GRO-480).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
drizzle-orm is not a direct dependency of @groombook/api, causing
TS2307 at typecheck time. Re-export isNull from @groombook/db and
update the import in rbac.ts.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
When a staff record exists with a matching email but no userId (e.g. seed data
or admin UI-created records), resolveStaffMiddleware now auto-links it to the
Better-Auth user record on first SSO login instead of returning 403.
Safety: only links when userId IS NULL, never overwrites an existing link.
Email matching is safe since it comes from the trusted SSO provider (Authentik).
Staff emails are unique by schema.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The authProviderRouter was registered twice at /admin/auth-provider in
apps/api/src/index.ts. The second registration is a no-op but creates
confusion. Remove the duplicate line.
Co-authored-by: Paperclip <noreply@paperclip.ing>
Use a 16-byte random salt per encryption instead of the fixed
"groombook-auth-provider-config" salt. This prevents identical
plaintexts from producing identical ciphertexts, closing the
timing/anagram security gap identified in GRO-452.
New format: salt:iv:ciphertext:authTag (all base64).
Legacy format (iv:ciphertext:authTag) is still accepted for
backward-compatible decryption of existing stored values.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Switch the test endpoint from putAuthProviderSchema.omit({ clientSecret })
(which requires providerId, displayName, clientId, scopes) to the
minimal authProviderTestSchema (issuerUrl, internalBaseUrl?) that matches
what the Settings.tsx frontend actually sends.
Co-Authored-By: Paperclip <noreply@paperclip.ing>