Navigation

Auth

Authentication is OAuth-only through better-auth with the Drizzle adapter — Google, GitHub, and Facebook — mounted at the catch-all server/api/auth/[...].ts. Sessions are cookie-based; the Vue client reads them through authClient.useSession (better-auth's Vue plugin with the server's inferred additional fields, so user.biography is typed end-to-end).

How it works

flowchart LR
  login[login page<br/>Google / GitHub / Facebook] --> ba[better-auth handler<br/>server/api/auth/...]
  ba --> pg[(users / sessions tables<br/>Drizzle adapter)]
  page[auth-gated page] --> mw[auth middleware<br/>session? else /login]
  client[$trpc call] --> proc[standardAuthedProcedure]
  proc --> isAuthed[getAuthedMiddleware + rate limiter<br/>session → AuthedContext]
  proc --> plugin[achievementPlugin]
  • Route gating — definePageMeta({ middleware: "auth" }) redirects signed-out visitors to /login; the login page itself uses the inverse guest middleware. Everything else is public by default.
  • Procedure gating — standardAuthedProcedure = publicProcedure + getAuthedMiddleware(RateLimiterType.Standard) (session check + rate limiting in one middleware, yielding AuthedContext with getSessionPayload) + the achievement plugin. standardRateLimitedProcedure is the unauthenticated sibling for public reads. Room-scoped RBAC procedures build on top (see esbabbler RBAC).
  • Users table — better-auth owns the users/sessions schema; Esposter adds biography via additionalFields, validated by the Drizzle-derived Zod schema. better-auth's own endpoints share the standard rate-limiter budget.
  • One query per session read — advanced.database.joins is on, and the adapter comes from @better-auth/drizzle-adapter/relations-v2 because only that entrypoint resolves a join through our v2 relations. It derives the relation key from the schema table key, so sessions and accounts name their relation to a user users, not the singular user every other table uses — rename either one and better-auth silently drops back to a second round trip per read, or throws where drizzle cannot find the relation.
  • One row per provider account — accounts is unique on (providerId, accountId). better-auth resolves an OAuth sign-in's owner by that pair and throws once two rows match it, which locks that user out until the duplicate is removed by hand. Nothing else enforces it: better-auth declares no such index, and the issuer column whose unique once stood in for it went with better-auth 1.7.3.
  • Device identity — getDeviceId/checkIsSameDevice fingerprint requests (push-subscription scoping), and generateToken mints the shared-secret tokens used by webhook delivery.
  • Session lifetime is the account holder's to end — every active session is listed and revocable at /user/settings, with its push subscriptions and live connections going with it. The session rows are read from our own table because better-auth's listSessions is freshness-gated, while the revokes go through better-auth. See session and device management.

Key files

Paths relative to apps/web.

FileRole
server/auth.tsbetter-auth configuration
server/api/auth/[...].tsthe mounted auth handler
app/services/auth/authClient.tstyped Vue session client
app/middleware/auth.ts, app/middleware/guest.tsroute gating
server/trpc/middleware/getAuthedMiddleware.tssession + rate-limit middleware
server/trpc/procedure/standardAuthedProcedure.tsthe standard authed chain
server/services/auth/device id + webhook token services

Notes

  • OAuth-only is deliberate — see users rejected: password auth.
  • Anonymous users are first-class where products support it (games persist to localStorage; the feed is readable rate-limited) — auth gates writing and personal state, not browsing.

Details

Command palette

Keyboard shortcuts