Authentication & Authorization Overview
Convertella has three distinct authentication boundaries, each designed for a different user type.
Auth Boundaries
Section titled “Auth Boundaries”| Boundary | Users | Method | Session Store | Cookie |
|---|---|---|---|---|
| Buyer | Property buyers | Passwordless OTP (email/WhatsApp) | Lucia sessions in Postgres (survives redeploys) | convertella_session |
| CRM | Agents, Developers | Passwordless OTP (email, 2-step) | Lucia admin_sessions in Postgres (survives redeploys) | convertella_admin_session |
| Operator | Convertella staff | Cloudflare Access (Zero Trust) | None (stateless JWT) | CF_Authorization |
All three share the same low-level OTP primitives:
| Primitive | Location |
|---|---|
| OTP generation + hashing | src/lib/otp/hash.ts |
| Email delivery | src/lib/otp/cf-email.ts |
| IP rate limiting | src/lib/otp/ip-rate-limit.ts |
| Constants (expiry, attempts) | src/lib/otp/constants.ts |
| Error types | src/lib/otp/errors.ts |
Buyer Portal
Section titled “Buyer Portal”- Auth method: 6-digit OTP via email (and optionally WhatsApp)
- Registration: Self-service — OTP verification auto-creates a buyer record
- Table:
buyers - Lucia adapter:
src/lib/auth/lucia.ts - Session table:
sessions - Key endpoints:
POST /api/auth/otp/send,POST /api/auth/otp/verify
See Buyer Auth for details.
- Auth method: 6-digit OTP via email (magic link deprecated since 2026-05)
- Registration: Invite-only (operator or invite endpoints)
- Login page:
/(root; internally rewrites to/admin/login) - Table:
users - Lucia adapter:
src/lib/auth/admin-lucia.ts - Session table:
admin_sessions - Key endpoints:
POST /api/auth/admin/otp/send,POST /api/auth/admin/otp/verify - Browser URLs: Clean paths (
/units,/leads, etc.) — the/admin/prefix is a legacy internal detail; the middleware redirects/admin/*to clean paths
See CRM Auth for details.
Why Three Separate Systems?
Section titled “Why Three Separate Systems?”- Buyer sessions are tied to
buyers.id— the FK constraint requires a buyer record. CRM users don’t exist in thebuyerstable. - CRM sessions need to store
active_project_idfor the project switcher, plus link tousers.idfor role-based access. Session rows live in Postgres — not cleared by Worker redeploys. - Operator access has no local user record — it uses Cloudflare Access JWT verification against an email allowlist.
- CRM invites require
role(developer|agent) and at least one project; agents may span organizations viaagent_project_assignments.
Middleware Dispatch
Section titled “Middleware Dispatch”The middleware in src/middleware.ts dispatches based on URL prefix:
| URL Prefix | Guard |
|---|---|
/admin/* (internal), /api/admin/*, /api/auth/admin/* | CRM Lucia session (redirect to / if missing) |
/api/operator/* | Operator bearer token or Access JWT |
| Everything else | Buyer middleware (Host-based tenant + Lucia session + operator token fallback) |
The middleware rewrites clean CRM paths (
/units,/leads, etc.) to/admin/*internally. browser URLs use clean paths; the/admin/prefix is only used internally by the platform.
Tenant Context Propagation
Section titled “Tenant Context Propagation”Every request ends up with a TenantContext on locals.tenant:
type TenantContext = { projectId: string | null; organizationId?: string | null; portalId?: string | null; isOperator?: boolean;};The context is built from different sources depending on the auth boundary:
- Buyer: Hostname resolution → Lucia user attributes fallback
- CRM: Lucia session →
active_project_idfromadmin_sessionstable - Operator: Cloudflare Access JWT → project selector from request