Skip to content

CRM Auth (Lucia + OTP)

CRM authentication uses the same OTP pattern as buyer auth, but with a separate Lucia instance and session table:

  1. User enters email address at / (the CRM login page)
  2. POST /api/auth/admin/otp/send validates the email, applies IP rate limiting, and — if the email exists in the users table — generates a 6-digit OTP, hashes it with HMAC-SHA256, and delivers it via Resend (or Cloudflare Email Service when configured)
  3. If the email does not exist: request silently succeeds (no email reveals validity)
  4. User enters the 6-digit code → POST /api/auth/admin/otp/verify
  5. Server re-hashes the candidate code, looks up a matching row in otpCodes (projectId IS NULL), marks it used, looks up the users table, verifies type IN ('agent', 'developer')
  6. A Lucia CRM session is created in Postgres (admin_sessions, 7-day expiry), cookie convertella_admin_session set
  7. If the user has project access, active_project_id is seeded (prefers demo-project when available)
  8. Redirect to CRM dashboard

Login page location: The CRM login lives at / (root path). Internally, this rewrites to /admin/login. The login page is also the default redirect target when an unauthenticated session attempts to access any CRM route.

AspectBuyer OTPCRM OTP
Servicesrc/lib/otp/service.ts (uses stored procs + TenantContext)src/lib/auth/admin-otp/service.ts (direct Drizzle, no tenant)
projectId scopeRequired (RLS-scoped)NULL (no project context at login)
User tablebuyersusers
RegistrationSelf-service (OTP verify creates buyer)Invite-only (operator/invite endpoints)
Delivery channelsEmail + WhatsAppEmail only

CRM auth uses a separate Lucia instance from buyer auth:

AspectBuyer LuciaCRM Lucia
Filesrc/lib/auth/lucia.tssrc/lib/auth/admin-lucia.ts
Session tablesessions (FK → buyers.id)admin_sessions (FK → users.id)
User tablebuyersusers
Cookie nameconvertella_sessionconvertella_admin_session
Session attributesNoneactiveProjectId
ModulePurpose
src/lib/auth/admin-lucia.tsCRM Lucia instance
src/lib/auth/admin-session.tsSession validation, project access, cookie management — see session-revocation caveat below
src/lib/auth/provision-crm-user.tsShared CRM user invite (role + project assignments)
src/lib/auth/admin-otp/service.tssendAdminOtp() and verifyAdminOtp() — Drizzle-based, no tenant context
src/lib/auth/admin-otp/types.tsAdminOtpSendResult, AdminOtpVerifyResult, AdminOtpError
src/pages/api/auth/admin/otp/send.tsOTP request handler
src/pages/api/auth/admin/otp/verify.tsOTP verification and session creation
src/pages/api/auth/admin/logout.tsSession invalidation
src/pages/admin/login.astro2-step login form (email → code)
src/pages/admin/accept.astroDeprecated — legacy magic-link handler (removed in future release)

The CRM OTP service reuses these modules from the buyer OTP system:

ModuleUsed For
src/lib/otp/hash.tsgenerateOtpDigits(), hashOtp() (HMAC-SHA256 with server secret)
src/lib/otp/normalize.tsnormalizeIdentifier() — email normalization
src/lib/otp/deliver.tsdeliverOtp() — email delivery via Cloudflare Email Service
src/lib/otp/cf-email.tssendOtpEmail() — subject customizable via params.subject
src/lib/otp/ip-rate-limit.tsassertOtpSendAllowedForIp() — in-memory rolling window (30/hr/IP)
src/lib/otp/constants.tsOTP_EXPIRY_MS (10 min), OTP_MAX_ATTEMPTS (3)
  • OTP is never stored in plaintext — only HMAC-SHA256 hash in otpCodes
  • projectId is NULL for CRM OTPs (no tenant context at authentication)
  • Codes expire after 10 minutes
  • Max 3 verification attempts per code (otpCodes.attempts column)
  • Delivery failure rolls back the OTP row (no orphaned codes)
  • Per-IP rate limit: 30 send requests/hour
  • Silent 200 response on rate limit (does not reveal rate limiting exists)

CRM sessions are not stored in the Worker isolate or KV:

  • Session rows live in admin_sessions (Neon Postgres) with opaque cookie id convertella_admin_session
  • Cloudflare redeploys and cold starts do not log users out — the cookie still resolves to the same DB row
  • Lucia may extend expires_at in the second half of the session lifetime; the browser cookie is refreshed when session.fresh without rotating the session id
  • Sessions do not rotate on every HTTP request (rotation caused race logouts when SSR fired parallel requests)

Logout, expiry (7 days), or deleting the session row ends the session. Changing DATABASE_URL to a different database also invalidates existing sessions.

Session re-validation occurs at most every 24 hours. For immediate revocation, delete the session row directly:

DELETE FROM admin_sessions WHERE user_id = '<uuid>';

This is the only way to forcibly log out a user before the next re-validation interval.

CRM users can have multiple accessible projects. The admin_sessions.active_project_id column tracks the currently active project:

  • Seeded on login when the user has accessible projects
  • Set via POST /api/admin/session/active-project
  • Project access re-checked at most every 24h (last_validated_at) via requireAdminApiAuth
  • Auto-filled on first request when null but projects exist
  • Developers: All projects belonging to their organization (via memberships with role = developer)
  • Agents: Only projects in agent_project_assignments (may span multiple organizations — one agent can work for different developers)

Cross-org agent assignments (architectural decision, PM-approved): Agents belong to projects, not organizations. An agent with assignments across different orgs can switch between those projects in the CRM. API handlers apply agent_project_assignments scope without an org-level join. This is intentional — agents are contractors who may serve multiple developers simultaneously.

New CRM accounts are invite-only (operator panel or CRM APIs). provisionCrmUser() requires:

FieldDeveloperAgent
roledeveloperagent
organizationIdRequired (memberships)Not used
projectIdsRequired; all must belong to organizationIdRequired; any valid projects (cross-org allowed)

Operator invite UI lists all projects with the organization name as secondary text so agents can be assigned across orgs in one step.