Skip to content

Authentication & Authorization Overview

Convertella has three distinct authentication boundaries, each designed for a different user type.

BoundaryUsersMethodSession StoreCookie
BuyerProperty buyersPasswordless OTP (email/WhatsApp)Lucia sessions in Postgres (survives redeploys)convertella_session
CRMAgents, DevelopersPasswordless OTP (email, 2-step)Lucia admin_sessions in Postgres (survives redeploys)convertella_admin_session
OperatorConvertella staffCloudflare Access (Zero Trust)None (stateless JWT)CF_Authorization

All three share the same low-level OTP primitives:

PrimitiveLocation
OTP generation + hashingsrc/lib/otp/hash.ts
Email deliverysrc/lib/otp/cf-email.ts
IP rate limitingsrc/lib/otp/ip-rate-limit.ts
Constants (expiry, attempts)src/lib/otp/constants.ts
Error typessrc/lib/otp/errors.ts
  • 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.

  1. Buyer sessions are tied to buyers.id — the FK constraint requires a buyer record. CRM users don’t exist in the buyers table.
  2. CRM sessions need to store active_project_id for the project switcher, plus link to users.id for role-based access. Session rows live in Postgres — not cleared by Worker redeploys.
  3. Operator access has no local user record — it uses Cloudflare Access JWT verification against an email allowlist.
  4. CRM invites require role (developer | agent) and at least one project; agents may span organizations via agent_project_assignments.

The middleware in src/middleware.ts dispatches based on URL prefix:

URL PrefixGuard
/admin/* (internal), /api/admin/*, /api/auth/admin/*CRM Lucia session (redirect to / if missing)
/api/operator/*Operator bearer token or Access JWT
Everything elseBuyer 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.

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_id from admin_sessions table
  • Operator: Cloudflare Access JWT → project selector from request