Documentation
IntroductionArchitectureMonorepo & ToolingRunning Locally
Screen Flow MapUser JourneysPersonas & RolesWeb ScreensClient Portal
Convex OverviewData ModelFunctions ReferenceAuth & SessionsRoles & PermissionsJobs & Providers
Web Design SystemAdmin Console DesignPatterns & Conventions
Web AppiOS AppMarketing Website
Testing & HarnessCI Pipeline

Auth & Sessions

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

/login
one door for staff and pet parents
email + password
auth.signIn (action)
PBKDF2-SHA256 verify · 100k iterations (Web Crypto)
ok
sessions table
opaque token · 14-day TTL · revocation = row delete
Client stores token
localStorage.paw_session_token
every call: { sessionToken, orgSlug }
resolveOrgContext
1 · session valid & unexpired?
2 · org by slug, active?
3 · membership active?
→ ctx = { user, org, membership }
UNAUTHENTICATED → redirect to /login
ORG_NOT_FOUND → 404 screen
FORBIDDEN → 403 screen
Other authentication paths
· Platform surface — raw token + any active owner membership
· Calendar feeds — per-user feedToken, no session
· Desk PIN — 4-digit kiosk identification
· Public — platform.apply, platform.directory
No existence leaks
Detail reads return nullfor malformed AND cross-tenant ids — "not yours" is indistinguishable from "doesn't exist" (verified by the authz suite, QA-003).
Auth & tenancy resolution — opaque bearer tokens today, Clerk slots into the same seam for production

Key decisions

  • Opaque bearer tokens, not JWTs. Sessions are rows; revocation is deletion. auth.signOut deletes the session.
  • One login door. Staff and pet parents share /login; where you land depends on your memberships (staff → /accounts picker, customers → portal).
  • Typed error codes. authError() throws ConvexError({code}) with UNAUTHENTICATED / FORBIDDEN / ORG_NOT_FOUND so 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, …) return null for 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, wrapping usePaginatedQuery (clients/pets lists).
  • useOrgMutation / useOrgAction — wrappers so callers pass only domain args.
  • useRequireAuth() — effect redirect to /login when ready and tokenless.

Alternative authentication paths

PathMechanismUsed for
Platform surfaceraw sessionToken + requirePlatformOwner (any active owner membership)/platform
Calendar feedsopaque feedToken on the membership (?token= query param), no sessionGET /feeds/events.ics, /feeds/events.json, /feeds/consumption.json
Desk PIN4-digit memberships.pinquick staff identification at the kiosk
Publicno authplatform.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.