CRM Auth (Lucia + OTP)
CRM authentication uses the same OTP pattern as buyer auth, but with a separate Lucia instance and session table:
- User enters email address at
/(the CRM login page) POST /api/auth/admin/otp/sendvalidates the email, applies IP rate limiting, and — if the email exists in theuserstable — generates a 6-digit OTP, hashes it with HMAC-SHA256, and delivers it via Resend (or Cloudflare Email Service when configured)- If the email does not exist: request silently succeeds (no email reveals validity)
- User enters the 6-digit code →
POST /api/auth/admin/otp/verify - Server re-hashes the candidate code, looks up a matching row in
otpCodes(projectId IS NULL), marks it used, looks up theuserstable, verifiestype IN ('agent', 'developer') - A Lucia CRM session is created in Postgres (
admin_sessions, 7-day expiry), cookieconvertella_admin_sessionset - If the user has project access,
active_project_idis seeded (prefersdemo-projectwhen available) - 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.
Key Differences from Buyer OTP
Section titled “Key Differences from Buyer OTP”| Aspect | Buyer OTP | CRM OTP |
|---|---|---|
| Service | src/lib/otp/service.ts (uses stored procs + TenantContext) | src/lib/auth/admin-otp/service.ts (direct Drizzle, no tenant) |
projectId scope | Required (RLS-scoped) | NULL (no project context at login) |
| User table | buyers | users |
| Registration | Self-service (OTP verify creates buyer) | Invite-only (operator/invite endpoints) |
| Delivery channels | Email + WhatsApp | Email only |
Separate Lucia Instance
Section titled “Separate Lucia Instance”CRM auth uses a separate Lucia instance from buyer auth:
| Aspect | Buyer Lucia | CRM Lucia |
|---|---|---|
| File | src/lib/auth/lucia.ts | src/lib/auth/admin-lucia.ts |
| Session table | sessions (FK → buyers.id) | admin_sessions (FK → users.id) |
| User table | buyers | users |
| Cookie name | convertella_session | convertella_admin_session |
| Session attributes | None | activeProjectId |
Key Components
Section titled “Key Components”| Module | Purpose |
|---|---|
src/lib/auth/admin-lucia.ts | CRM Lucia instance |
src/lib/auth/admin-session.ts | Session validation, project access, cookie management — see session-revocation caveat below |
src/lib/auth/provision-crm-user.ts | Shared CRM user invite (role + project assignments) |
src/lib/auth/admin-otp/service.ts | sendAdminOtp() and verifyAdminOtp() — Drizzle-based, no tenant context |
src/lib/auth/admin-otp/types.ts | AdminOtpSendResult, AdminOtpVerifyResult, AdminOtpError |
src/pages/api/auth/admin/otp/send.ts | OTP request handler |
src/pages/api/auth/admin/otp/verify.ts | OTP verification and session creation |
src/pages/api/auth/admin/logout.ts | Session invalidation |
src/pages/admin/login.astro | 2-step login form (email → code) |
src/pages/admin/accept.astro | Deprecated — legacy magic-link handler (removed in future release) |
Reused OTP Primitives
Section titled “Reused OTP Primitives”The CRM OTP service reuses these modules from the buyer OTP system:
| Module | Used For |
|---|---|
src/lib/otp/hash.ts | generateOtpDigits(), hashOtp() (HMAC-SHA256 with server secret) |
src/lib/otp/normalize.ts | normalizeIdentifier() — email normalization |
src/lib/otp/deliver.ts | deliverOtp() — email delivery via Cloudflare Email Service |
src/lib/otp/cf-email.ts | sendOtpEmail() — subject customizable via params.subject |
src/lib/otp/ip-rate-limit.ts | assertOtpSendAllowedForIp() — in-memory rolling window (30/hr/IP) |
src/lib/otp/constants.ts | OTP_EXPIRY_MS (10 min), OTP_MAX_ATTEMPTS (3) |
OTP Security (CRM)
Section titled “OTP Security (CRM)”- OTP is never stored in plaintext — only HMAC-SHA256 hash in
otpCodes projectIdisNULLfor CRM OTPs (no tenant context at authentication)- Codes expire after 10 minutes
- Max 3 verification attempts per code (
otpCodes.attemptscolumn) - 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)
Session persistence
Section titled “Session persistence”CRM sessions are not stored in the Worker isolate or KV:
- Session rows live in
admin_sessions(Neon Postgres) with opaque cookie idconvertella_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_atin the second half of the session lifetime; the browser cookie is refreshed whensession.freshwithout 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 revocation
Section titled “Session revocation”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.
Project Switcher
Section titled “Project Switcher”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) viarequireAdminApiAuth - Auto-filled on first request when null but projects exist
Accessible Projects
Section titled “Accessible Projects”- Developers: All projects belonging to their organization (via
membershipswithrole = 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_assignmentsscope without an org-level join. This is intentional — agents are contractors who may serve multiple developers simultaneously.
CRM user provisioning (invite)
Section titled “CRM user provisioning (invite)”New CRM accounts are invite-only (operator panel or CRM APIs). provisionCrmUser() requires:
| Field | Developer | Agent |
|---|---|---|
role | developer | agent |
organizationId | Required (memberships) | Not used |
projectIds | Required; all must belong to organizationId | Required; 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.