Passwordless Auth — Build Prompt

Copy this and paste it into the new app's chat as one message.

Open raw
# PROMPT: Build a Passwordless Email-Code Login System (AWS SES + Base44)

> Copy everything below the line into the new Base44 app's chat as a single message.

---

Build a **passwordless authentication system** for this app. Do not use Base44's built-in login page and do not build passwords. The entire flow is: **collect email → email a 6-digit code via AWS SES → collect the code → verify it → issue a session token → resolve that email's subscription tier**.

This is a proven architecture. Follow it exactly.

## Why this design

The app must be publicly reachable (marketing pages, webinar landing pages, broker-branded subdomains) while still knowing who each visitor is and what they've paid for. Passwords create friction and support load. A one-time code proves email ownership, and email ownership is the join key to the billing record. There is no password to reset, leak, or store.

## Architecture overview

```
Frontend gate component
   │  step 1: email  ──► sendVerificationCode  ──► AWS SES ──► user's inbox
   │  step 2: code   ──► verifyEmailCode       ──► session_token + user{tier}
   │                                                │
   │                                     localStorage session
   │                                                │
   └─ on every page load ──────────► validateSession ──► fresh user{tier}
```

Three backend functions, two entities, one frontend component. Tier is **never** trusted from the browser — it is resolved server-side from the billing record on every validation.

---

## STEP 1 — Entities

### `LoginCode`
Short-lived one-time codes. Nothing else reads this.

```json
{
  "name": "LoginCode",
  "type": "object",
  "properties": {
    "email": { "type": "string", "format": "email" },
    "code": { "type": "string", "description": "6-digit verification code" },
    "expires_at": { "type": "string", "format": "date-time" },
    "used": { "type": "boolean", "default": false }
  },
  "required": ["email", "code", "expires_at"],
  "indexes": [{ "fields": ["email", "code"] }],
  "rls": {
    "create": { "user_condition": { "role": "admin" } },
    "read":   { "user_condition": { "role": "admin" } },
    "update": { "user_condition": { "role": "admin" } },
    "delete": { "user_condition": { "role": "admin" } }
  }
}
```

**The RLS lockdown is mandatory.** Codes and session tokens are bearer credentials. If any authenticated visitor can list these records, they can read another person's code or steal a live token and impersonate them. Backend functions reach these records with `asServiceRole`, which bypasses RLS — so locking them to admin costs the app nothing and closes the hole completely.

### `SessionToken`
Server-side session records. The browser holds only an opaque string; the truth lives here so a session can be revoked instantly.

```json
{
  "name": "SessionToken",
  "type": "object",
  "properties": {
    "email": { "type": "string" },
    "token": { "type": "string", "description": "SHA-256 hash of the token — never the raw value" },
    "expires_at": { "type": "string", "format": "date-time" },
    "is_valid": { "type": "boolean", "default": true }
  },
  "required": ["email", "token", "expires_at"],
  "indexes": [{ "fields": ["token"], "unique": true }],
  "rls": {
    "create": { "user_condition": { "role": "admin" } },
    "read":   { "user_condition": { "role": "admin" } },
    "update": { "user_condition": { "role": "admin" } },
    "delete": { "user_condition": { "role": "admin" } }
  }
}
```

Store the **hash**, not the token. If the database is ever exposed, hashes are not usable as credentials — the same reason passwords are hashed.

### `Customer` — the tier source of truth
Whatever entity holds billing state, it is the **only** authority on what a user can access. Stripe webhooks write to it; auth functions only read it. Minimum fields: `email`, `full_name`, `current_tier`, `subscription_active`, `subscription_status`, `access_until`, `email_verified`, `email_verified_at`.

Never let a marketing platform (Mailchimp, Kajabi, etc.) decide tier. Those systems are eventually-consistent and tag-based; treating them as authoritative silently grants or revokes paid access when a tag drifts. Sync to them fire-and-forget, and read tier only from the billing record.

---

## STEP 2 — AWS SES credentials and setup

This is the part that needs real AWS work before any code runs.

### Create the sending identity
1. AWS Console → **Amazon SES** → pick a region and stay in it (`us-east-1` is the reference).
2. **Verified identities** → verify your **sending domain** (not just one address). Add the DKIM CNAME records AWS gives you to your DNS. Domain-level DKIM is what keeps codes out of spam — a bare verified address without DKIM gets filtered heavily.
3. **Request production access.** A brand-new SES account is in *sandbox*: it can only send to addresses you have individually verified, which silently breaks the flow for every real user. Filling in the production-access request is not optional.

### Create a least-privilege IAM user
IAM → Users → create a **programmatic-access** user with this inline policy and nothing more:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["ses:SendEmail", "ses:SendRawEmail"],
    "Resource": "*"
  }]
}
```

Scope it to just SES send. If these keys leak, the damage is capped at sending email — not reading your S3 or spinning up compute.

### Register the two secrets in Base44
Ask the app owner to set these (values are entered by them; you never see or hardcode them):

| Secret name | Where it comes from |
|---|---|
| `AWS_ACCESS_KEY_ID` | IAM user's access key ID |
| `AWS_SECRET_ACCESS_KEY` | IAM user's secret access key (shown once at creation) |

Declare them with the `set_secrets` tool **before** writing the function that reads them, then read them in backend code via `Deno.env.get('AWS_ACCESS_KEY_ID')`. Also decide the `Source` sender address now — it must be on the verified domain, e.g. `noreply@yourdomain.com`. SES rejects any other From address.

---

## STEP 3 — Backend function: `sendVerificationCode`

File: `base44/functions/sendVerificationCode/entry.ts`

```ts
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.48';
import { SESClient, SendEmailCommand } from 'npm:@aws-sdk/client-ses@3.540.0';

const SES_REGION = 'us-east-1';
const FROM_ADDRESS = 'noreply@yourdomain.com'; // MUST be on the SES-verified domain
const BRAND_NAME = 'Your Brand';

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'POST',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    });
  }

  try {
    const base44 = createClientFromRequest(req);
    const { email } = await req.json();

    if (!email || !email.includes('@')) {
      return Response.json({ error: 'Valid email required' }, { status: 400 });
    }
    const normalizedEmail = email.trim().toLowerCase();

    // 6-digit code, 10-minute TTL
    const code = String(Math.floor(100000 + Math.random() * 900000));
    const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();

    // Purge only EXPIRED/USED codes for this email — never the fresh ones.
    // Deleting all prior codes breaks "Resend": the user typically types the
    // FIRST code they received, and that record must still validate.
    try {
      const existing = await base44.asServiceRole.entities.LoginCode.filter({ email: normalizedEmail });
      const now = new Date();
      for (const old of existing) {
        if (old.used === true || (old.expires_at && new Date(old.expires_at) < now)) {
          try { await base44.asServiceRole.entities.LoginCode.delete(old.id); } catch (_) {}
        }
      }
    } catch (e) {
      console.warn('[sendVerificationCode] cleanup skipped:', e.message);
    }

    await base44.asServiceRole.entities.LoginCode.create({
      email: normalizedEmail, code, expires_at: expiresAt, used: false,
    });

    const htmlBody = `<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f4f4f7;font-family:Arial,sans-serif;color:#333;">
  <div style="max-width:600px;margin:0 auto;padding:20px;">
    <div style="background:#111;padding:28px 24px;text-align:center;border-radius:12px 12px 0 0;">
      <span style="color:#fff;font-size:22px;font-weight:800;">${BRAND_NAME}</span>
    </div>
    <div style="background:#fff;padding:32px 28px;">
      <h2 style="margin:0 0 12px;color:#1a2332;">Your Verification Code</h2>
      <p style="font-size:15px;color:#555;">Enter this code to finish signing in.</p>
      <div style="background:#f8f9fb;border-left:4px solid #111;padding:18px 20px;margin:24px 0;">
        <p style="margin:0 0 8px;font-size:13px;color:#888;">Your one-time code:</p>
        <div style="font-size:36px;font-weight:700;letter-spacing:10px;">${code}</div>
      </div>
      <p style="font-size:13px;color:#888;">This code expires in <strong>10 minutes</strong>.</p>
      <p style="font-size:13px;color:#888;">Didn't request it? You can ignore this email.</p>
    </div>
  </div>
</body></html>`;

    let emailSent = false;
    try {
      const ses = new SESClient({
        region: SES_REGION,
        credentials: {
          accessKeyId: Deno.env.get('AWS_ACCESS_KEY_ID'),
          secretAccessKey: Deno.env.get('AWS_SECRET_ACCESS_KEY'),
        },
      });
      await ses.send(new SendEmailCommand({
        Source: `${BRAND_NAME} <${FROM_ADDRESS}>`,
        Destination: { ToAddresses: [normalizedEmail] },
        Message: {
          Subject: { Data: `Your ${BRAND_NAME} Verification Code` },
          Body: { Html: { Data: htmlBody } },
        },
      }));
      emailSent = true;
    } catch (emailErr) {
      console.error('[sendVerificationCode] SES failed:', emailErr.message);
    }

    // Report delivery failure WITHOUT revealing the code — the UI shows a
    // "check your address" hint instead of leaving the user staring at an
    // empty inbox with no explanation.
    return Response.json({ success: true, email_sent: emailSent });
  } catch (error) {
    console.error('[sendVerificationCode] Error:', error);
    return Response.json({ error: error.message }, { status: 500 });
  }
});
```

Note the shape of the response: it always returns `success: true` whether or not the address exists. Returning "no such user" here would turn the endpoint into an account-enumeration oracle. The delivery-failure signal (`email_sent: false`) is a UI hint, not an account existence check.

---

## STEP 4 — Backend function: `verifyEmailCode`

File: `base44/functions/verifyEmailCode/entry.ts`

```ts
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.48';

const ADMIN_EMAILS = new Set(['owner@yourdomain.com']);
const TIER_RANK = { free: 0, basic: 1, pro: 2, premium: 3 };

function normalizeTier(raw) {
  if (!raw) return 'free';
  return raw.toLowerCase().trim();
}

async function sha256(text) {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'POST',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    });
  }

  try {
    const base44 = createClientFromRequest(req);
    const { email, code } = await req.json();
    if (!email || !code) {
      return Response.json({ error: 'Email and code required' }, { status: 400 });
    }

    const normalizedEmail = email.trim().toLowerCase();
    const codeStr = String(code).trim();

    // ── 1. Validate the code ──
    const codes = await base44.asServiceRole.entities.LoginCode.filter({
      email: normalizedEmail, code: codeStr,
    });
    if (!codes || codes.length === 0) {
      return Response.json({ verified: false, error: 'Invalid code. Please check and try again.' }, { status: 401 });
    }

    const now = new Date();
    const record = codes.find(c => !c.used && (!c.expires_at || new Date(c.expires_at) >= now));
    if (!record) {
      for (const c of codes) { try { await base44.asServiceRole.entities.LoginCode.delete(c.id); } catch (_) {} }
      return Response.json({ verified: false, error: 'Code expired. Please request a new one.' }, { status: 401 });
    }

    // Burn every code for this email — a code must be single-use, or an
    // intercepted email stays replayable for the rest of its 10-minute window.
    for (const c of codes) { try { await base44.asServiceRole.entities.LoginCode.delete(c.id); } catch (_) {} }

    // ── 2. Issue the session: raw token to the browser, hash to the DB ──
    const sessionToken = crypto.randomUUID() + crypto.randomUUID().replace(/-/g, '');
    const tokenHash = await sha256(sessionToken);

    try {
      const old = await base44.asServiceRole.entities.SessionToken.filter({ email: normalizedEmail });
      for (const t of old) { try { await base44.asServiceRole.entities.SessionToken.delete(t.id); } catch (_) {} }
    } catch (_) {}

    await base44.asServiceRole.entities.SessionToken.create({
      email: normalizedEmail,
      token: tokenHash,
      expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
      is_valid: true,
    });

    // ── 3. Admin override ──
    if (ADMIN_EMAILS.has(normalizedEmail)) {
      return Response.json({
        verified: true,
        session_token: sessionToken,
        user: { email: normalizedEmail, full_name: 'Administrator', tier: 'admin' },
      });
    }

    // ── 4. Resolve tier from the billing record ──
    let tier = 'free';
    let fullName = '';
    let existing = null;
    try {
      const customers = await base44.asServiceRole.entities.Customer.filter({ email: normalizedEmail });
      if (customers?.length > 0) {
        existing = customers[0];
        tier = normalizeTier(existing.current_tier);
        fullName = existing.full_name || '';

        // Cancelled-but-paid-through: honour access_until, then demote once.
        if (existing.subscription_status === 'cancelled_pending' && existing.access_until
            && new Date(existing.access_until) < new Date()) {
          tier = 'free';
          try {
            await base44.asServiceRole.entities.Customer.update(existing.id, {
              current_tier: 'free', subscription_active: false,
              subscription_status: 'cancelled', access_until: null,
            });
          } catch (_) {}
        }
      }
    } catch (e) {
      console.error('[verifyEmailCode] Customer lookup failed:', e.message);
    }

    // ── 5. First-time visitor: create the free record ──
    if (!existing) {
      try {
        await base44.asServiceRole.entities.Customer.create({
          email: normalizedEmail, full_name: '', current_tier: 'free',
          subscription_active: false, subscription_status: 'inactive',
          email_verified: true, email_verified_at: new Date().toISOString(),
        });
      } catch (e) {
        console.warn('[verifyEmailCode] Customer create skipped:', e.message);
      }
    } else if (!existing.email_verified) {
      try {
        await base44.asServiceRole.entities.Customer.update(existing.id, {
          email_verified: true, email_verified_at: new Date().toISOString(),
        });
      } catch (_) {}
    }

    return Response.json({
      verified: true,
      session_token: sessionToken,
      user: { email: normalizedEmail, full_name: fullName, tier },
    });
  } catch (error) {
    console.error('[verifyEmailCode] Error:', error);
    return Response.json({ error: error.message }, { status: 500 });
  }
});
```

Creating the free `Customer` record on first verification is what makes the funnel work: a visitor who came for one webinar is now a known, email-verified contact who can be upgraded later, with no separate signup step.

---

## STEP 5 — Backend function: `validateSession`

File: `base44/functions/validateSession/entry.ts`

```ts
import { createClientFromRequest } from 'npm:@base44/sdk@0.8.48';

const ADMIN_EMAILS = new Set(['owner@yourdomain.com']);
const ROLLING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;   // extends on each use
const MAX_SESSION_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000; // hard cap

function normalizeTier(raw) {
  if (!raw) return 'free';
  return raw.toLowerCase().trim();
}

async function sha256(text) {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'POST',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    });
  }

  try {
    const base44 = createClientFromRequest(req);
    const { session_token } = await req.json();
    if (!session_token) return Response.json({ valid: false }, { status: 401 });

    const tokenHash = await sha256(session_token);
    const tokens = await base44.asServiceRole.entities.SessionToken.filter({ token: tokenHash });
    if (!tokens || tokens.length === 0) return Response.json({ valid: false }, { status: 401 });

    const record = tokens[0];

    if (!record.is_valid) {
      return Response.json({ valid: false, error: 'Session revoked' }, { status: 401 });
    }
    if (new Date(record.expires_at) < new Date()) {
      try { await base44.asServiceRole.entities.SessionToken.delete(record.id); } catch (_) {}
      return Response.json({ valid: false, error: 'Session expired' }, { status: 401 });
    }

    // Absolute lifetime cap. Without it, a rolling window means an active
    // session never expires — one stolen token would be valid forever.
    if (Date.now() - new Date(record.created_date).getTime() > MAX_SESSION_LIFETIME_MS) {
      try { await base44.asServiceRole.entities.SessionToken.update(record.id, { is_valid: false }); } catch (_) {}
      return Response.json({ valid: false, error: 'Please sign in again' }, { status: 401 });
    }

    // Rolling extension — active users are never logged out mid-use.
    try {
      await base44.asServiceRole.entities.SessionToken.update(record.id, {
        expires_at: new Date(Date.now() + ROLLING_WINDOW_MS).toISOString(),
      });
    } catch (_) {}

    const normalizedEmail = record.email.toLowerCase().trim();

    if (ADMIN_EMAILS.has(normalizedEmail)) {
      return Response.json({
        valid: true,
        user: { email: normalizedEmail, full_name: 'Administrator', tier: 'admin' },
      });
    }

    // Re-read tier on EVERY validation, so an upgrade or cancellation takes
    // effect on the next page load instead of at the next login.
    let tier = 'free';
    let fullName = '';
    try {
      const customers = await base44.asServiceRole.entities.Customer.filter({ email: normalizedEmail });
      if (customers?.length > 0) {
        tier = normalizeTier(customers[0].current_tier);
        fullName = customers[0].full_name || '';
      }
    } catch (e) {
      // Never downgrade a paying user because of a transient read failure —
      // signal "retry", don't answer "free".
      console.error('[validateSession] Customer lookup failed:', e.message);
      return Response.json({ valid: false, error: 'Temporary error, please retry', retry: true }, { status: 503 });
    }

    return Response.json({ valid: true, user: { email: normalizedEmail, full_name: fullName, tier } });
  } catch (error) {
    console.error('[validateSession] Error:', error);
    return Response.json({ error: error.message }, { status: 500 });
  }
});
```

The 503-on-read-failure branch matters more than it looks. If a database hiccup returned `tier: 'free'`, a premium subscriber would abruptly see paywalls, and support tickets would follow. Distinguishing "unknown right now" from "not entitled" prevents that.

---

## STEP 6 — Frontend gate component

File: `src/components/auth/EmailCodeGate.jsx`. Two steps in one component, driven by a `step` state.

```jsx
import React, { useState, useRef } from 'react';
import { base44 } from '@/api/base44Client';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

export default function EmailCodeGate({ onVerified, title = 'Sign in' }) {
  const [step, setStep] = useState('email');
  const [email, setEmail] = useState('');
  const [code, setCode] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const [emailFailed, setEmailFailed] = useState(false);
  const codeRef = useRef(null);

  const sendCode = async () => {
    setLoading(true); setError('');
    try {
      const res = await base44.functions.invoke('sendVerificationCode', { email });
      setEmailFailed(res.data?.email_sent === false);
      setStep('code');
      setTimeout(() => codeRef.current?.focus(), 100);
    } catch (e) {
      setError(e?.response?.data?.error || 'Failed to send code.');
    } finally { setLoading(false); }
  };

  const verifyCode = async () => {
    if (code.length !== 6) { setError('Enter the 6-digit code.'); return; }
    setLoading(true); setError('');
    try {
      const res = await base44.functions.invoke('verifyEmailCode', { email, code });
      if (res.data?.verified) {
        const session = {
          email: res.data.user.email,
          full_name: res.data.user.full_name,
          tier: res.data.user.tier,
          session_token: res.data.session_token,
          verified_at: new Date().toISOString(),
        };
        localStorage.setItem('app_session', JSON.stringify(session));
        onVerified(session);
      } else {
        setError(res.data?.error || 'Invalid code.');
      }
    } catch (e) {
      setError(e?.response?.data?.error || 'Verification failed.');
    } finally { setLoading(false); }
  };

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
      <div className="w-full max-w-md rounded-2xl bg-card p-8 shadow-2xl">
        {step === 'email' ? (
          <>
            <h2 className="mb-2 text-xl font-bold">{title}</h2>
            <p className="mb-5 text-sm text-muted-foreground">
              Enter your email and we'll send a 6-digit code. No password needed.
            </p>
            <Input
              type="email" value={email} placeholder="your@email.com"
              onChange={(e) => setEmail(e.target.value)}
              onKeyDown={(e) => e.key === 'Enter' && email && sendCode()}
            />
            {error && <p className="mt-2 text-sm text-destructive">{error}</p>}
            <Button className="mt-4 w-full" disabled={loading || !email} onClick={sendCode}>
              {loading ? 'Sending…' : 'Send my code'}
            </Button>
          </>
        ) : (
          <>
            <h2 className="mb-2 text-xl font-bold">Check your email</h2>
            {emailFailed ? (
              <div className="mb-5 rounded-lg border border-amber-400 bg-amber-50 p-3 text-sm text-amber-900">
                We couldn't deliver to <strong>{email}</strong>. Double-check the address and try again.
              </div>
            ) : (
              <p className="mb-5 text-sm text-muted-foreground">
                We sent a 6-digit code to <strong>{email}</strong>
              </p>
            )}
            <Input
              ref={codeRef} type="text" inputMode="numeric" maxLength={6}
              placeholder="000000" value={code}
              className="text-center text-2xl font-bold tracking-[0.5em]"
              onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
              onKeyDown={(e) => e.key === 'Enter' && verifyCode()}
            />
            {error && <p className="mt-2 text-sm text-destructive">{error}</p>}
            <Button className="mt-4 w-full" disabled={loading || code.length !== 6} onClick={verifyCode}>
              {loading ? 'Verifying…' : 'Continue'}
            </Button>
            <div className="mt-4 flex justify-center gap-3 text-sm text-muted-foreground">
              <button className="underline" disabled={loading} onClick={sendCode}>Resend code</button>
              <span>•</span>
              <button className="underline" onClick={() => { setStep('email'); setCode(''); setError(''); }}>
                Change email
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}
```

Details that decide whether this actually gets used: strip non-digits from the code field, set `inputMode="numeric"` so phones show a number pad, autofocus the code input when the step changes, submit on Enter, and always offer **Resend** and **Change email** — a typo'd address is the single most common failure and without an escape hatch the user is stuck.

---

## STEP 7 — Session rehydration hook

File: `src/hooks/useSession.jsx`. Every gated page reads tier from here, never from `localStorage` directly.

```jsx
import { useState, useEffect } from 'react';
import { base44 } from '@/api/base44Client';

export function useSession() {
  const [session, setSession] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    (async () => {
      const stored = localStorage.getItem('app_session');
      if (!stored) { setLoading(false); return; }
      try {
        const { session_token } = JSON.parse(stored);
        const res = await base44.functions.invoke('validateSession', { session_token });
        if (res.data?.valid) {
          const fresh = { ...res.data.user, session_token };
          localStorage.setItem('app_session', JSON.stringify(fresh));
          setSession(fresh);
        } else if (!res.data?.retry) {
          localStorage.removeItem('app_session');
        }
      } catch (_) {
        // transient failure: keep the stored session, don't force a re-login
      } finally { setLoading(false); }
    })();
  }, []);

  const logout = () => { localStorage.removeItem('app_session'); setSession(null); };
  return { session, loading, logout, isAuthenticated: !!session };
}
```

The tier that gates features must come from this hook's server response. A tier value read straight out of `localStorage` is user-editable — anyone can open devtools and promote themselves to `premium`. `localStorage` holds the opaque token; the server decides what it's worth.

---

## Build order

1. `set_secrets` for `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, and confirm SES domain verification + production access.
2. Create `LoginCode` and `SessionToken` entities (with the admin-only RLS above).
3. Write `sendVerificationCode`, then test it with `test_backend_function` using a real inbox.
4. Write `verifyEmailCode`, then test with the code that arrives.
5. Write `validateSession`, then test with the returned token.
6. Build `EmailCodeGate` and `useSession`, and wire the gate into the pages that need it.

## Non-negotiables

- Codes: 6 digits, 10-minute TTL, single-use, deleted after verification.
- `LoginCode` and `SessionToken` are admin-only in RLS; functions use `asServiceRole`.
- Session tokens are stored hashed, with a rolling 7-day window and a 90-day hard cap.
- Tier is resolved server-side from the billing record on every `validateSession` call.
- Never return the code in an API response, and never reveal whether an email is registered.
- Never let a marketing/CRM platform decide entitlement — sync to it fire-and-forget only.