Auth & Sessions
Authentication is deliberately simple and deliberately swappable: a local credential provider plus a sessions table, sitting exactly where Clerk will slot in for production. The seam is convex/lib/functions.ts.
The flow
Key decisions
- Opaque bearer tokens, not JWTs. Sessions are rows; revocation is deletion.
auth.signOutdeletes the session. - One login door. Staff and pet parents share
/login; where you land depends on your memberships (staff →/accountspicker, customers → portal). - Typed error codes.
authError()throwsConvexError({code})withUNAUTHENTICATED/FORBIDDEN/ORG_NOT_FOUNDso the codes survive Convex's production error redaction. The web app's error boundaries map them: UNAUTHENTICATED → redirect to/login, FORBIDDEN → 403 screen, ORG_NOT_FOUND → 404 screen. - No existence leaks. Detail reads (
clients.get,pets.get, …) returnnullfor malformed and cross-tenant ids — an attacker can't distinguish "not yours" from "doesn't exist". This is covered by the authz test suite (QA-003).
Client-side session plumbing (apps/web)
src/lib/session.tsx:
SessionProvider— token in localStorage, exposes{token, ready, setToken}.useOrgQuery(fn, args | "skip")— injects{sessionToken, orgSlug}, auto-skips until ready; the workhorse for all org-scoped reads.useOrgPaginatedQuery— same, wrappingusePaginatedQuery(clients/pets lists).useOrgMutation/useOrgAction— wrappers so callers pass only domain args.useRequireAuth()— effect redirect to/loginwhen ready and tokenless.
Alternative authentication paths
| Path | Mechanism | Used for |
|---|---|---|
| Platform surface | raw sessionToken + requirePlatformOwner (any active owner membership) | /platform |
| Calendar feeds | opaque feedToken on the membership (?token= query param), no session | GET /feeds/events.ics, /feeds/events.json, /feeds/consumption.json |
| Desk PIN | 4-digit memberships.pin | quick staff identification at the kiosk |
| Public | no auth | platform.apply, platform.directory |
Feed tokens are minted/rotated per user via settings.regenerateFeedToken.
Production plan
Swap the credential provider for Clerk: signIn/password fields go away, getSessionUser resolves a Clerk session instead, and everything downstream of resolveOrgContext — the entire authorization model — is untouched. The users.passwordHash/passwordSalt fields are explicitly marked as the local-provider seam.