Authentication and organizations

better-auth on the API, the Vue client, route guards, and the current UI gaps.

API

apps/api/server/auth/better-auth.instance.ts builds a singleton betterAuth({...}):

  • Adapter: drizzle (pg) with tables from @taman/db-pg (user, session, account, verification, organization, team, teamMember, member, invitation).
  • Plugins: admin (adminAc / adminRoles) and organization (organizationAc / organizationRoles, teams enabled, no default team on org create).
  • Email/password: enabled, requireEmailVerification: true.
  • Email verification: autoSignInAfterVerification: true; sends via the email dispatcher.
  • Google: socialProviders.google with prompt: 'select_account'. Empty client id/secret means Google login will fail until you set them.
  • Cookies: prefix taman. IDs are UUIDv7.
  • Extra user field: phoneNumber (optional string).
  • Account linking: enabled.
  • Session hook: if activeOrganizationId is missing on create, resolveActiveOrganizationId tries to pick one.

HTTP surface: apps/api/server/routes/api/auth/[...all].ts. RPC procedures do not re-implement login; they read the session through resolveContext.

Non-admin RPC callers without activeOrganizationId get ORPCError('FORBIDDEN') with data.code = ORG_REQUIRED (@taman/api-contract).

Web client

apps/web/src/auth/auth.client.ts:

export const authClient = createAuthClient({
  baseURL: apiUrl,
  plugins: [
    adminClient({ ac: adminAc, roles: adminRoles }),
  ],
});
The client does not install organizationClient. Organization/team helpers (authClient.organization.*) are not typed or available in the SPA. The API still runs the organization plugin (invitations, members, teams).

Session is resolved in the router guard via ensureSession() (TanStack Query, cookies). registerAccessDirective reads roles from useSessionStore().roles through a lazy getter so Vue Query exists before the first useQuery.

Route meta

meta.auth (from @vinicunca/taman-core/typings):

ValueWho may enter
falseNo auth check; excluded from generateAccess
{ only: 'guest' }Unauthenticated only (login). Authenticated users go to defaultHomePath
{ only: 'user' } / omittedAuthenticated. Default.

Root (path: '/') declares auth: false but AUTH_LAYOUT_ROUTE_NAMES skips it so that value does not leak to children. Leaf meta is resolved by walking matched from the leaf up (resolveAuthMetaFromMatched).

Login route: /auth/login (LOGIN_PATH). There is no onboarding route or route mode yet. A signed-in user without an organization reaches protected pages, and API calls fail with ORG_REQUIRED.

Current UI gaps

These are missing in apps/web/src/views today:

  • No profile page
  • No onboarding page or route mode
  • No organization create / switch / invite / member views

Generated apps therefore cannot complete an org-required flow in the UI.

Organization checks happen in two places:

  1. The authed RPC middleware rejects a session without activeOrganizationId, unless the user is a platform admin (role: admin).
  2. Each feature service still needs a membership. TodoService throws ORG_REQUIRED when member is null, because every query is scoped to member.organizationId. The admin role does not bypass this.

nx run api:seed therefore creates admin@taman.local and a demo organization with the admin as owner. When a session starts, the session.create hook (resolveActiveOrganizationId) makes the user's first membership the active organization. If you were already signed in before seeding, sign out and back in.

Google OAuth

Set NITRO_GOOGLE_CLIENT_ID and NITRO_GOOGLE_CLIENT_SECRET. Authorized redirect URIs must include {NITRO_BASE_URL}/api/auth/callback/google. NITRO_TRUSTED_ORIGINS must include the web origin.