[object Object]

← back to Animals

yolo: Email verification on signup

2069600c6c42637ab7b98a2b29324de5a42c6b99 · 2026-05-08 12:49:51 -0700 · animal-yolo

Task: yolo.email-verify-gate
Prompt: In ~/Projects/animals: when a user signs up, send a verification link via George (localhost:9850 /api/send) and require click before they can post marketplace listings or send friend requests. Store v

Files touched

Diff

commit 2069600c6c42637ab7b98a2b29324de5a42c6b99
Author: animal-yolo <steve@designerwallcoverings.com>
Date:   Fri May 8 12:49:51 2026 -0700

    yolo: Email verification on signup
    
    Task: yolo.email-verify-gate
    Prompt: In ~/Projects/animals: when a user signs up, send a verification link via George (localhost:9850 /api/send) and require click before they can post marketplace listings or send friend requests. Store v
---
 agents/animal-yolo/state.json         |   4 +-
 migrations/013_email_verification.sql |  31 +++++++
 src/lib/auth.js                       |   9 ++
 src/lib/email_verification.js         | 157 ++++++++++++++++++++++++++++++++++
 src/server/community.js               |   5 +-
 src/server/dog_friends.js             |   3 +-
 6 files changed, 205 insertions(+), 4 deletions(-)

diff --git a/agents/animal-yolo/state.json b/agents/animal-yolo/state.json
index 772c950..f225282 100644
--- a/agents/animal-yolo/state.json
+++ b/agents/animal-yolo/state.json
@@ -1,5 +1,5 @@
 {
-  "cursor": 3,
-  "runs": 3,
+  "cursor": 4,
+  "runs": 4,
   "started_at": "2026-05-08T07:01:22.386Z"
 }
\ No newline at end of file
diff --git a/migrations/013_email_verification.sql b/migrations/013_email_verification.sql
new file mode 100644
index 0000000..a98d255
--- /dev/null
+++ b/migrations/013_email_verification.sql
@@ -0,0 +1,31 @@
+-- Project: Animals — email verification tokens.
+--
+-- Wires a "click the link in your inbox before you can post listings or send
+-- friend requests" gate. Token is generated at signup, emailed via George,
+-- consumed (cleared) when the user clicks /auth/verify?token=.
+--
+-- Grandfathering: every existing app_users row gets email_verified_at = created_at
+-- and email_verified = TRUE so we don't lock out anyone who signed up before
+-- the gate existed. The boolean stays in sync with the timestamp — boolean is
+-- the cheap predicate, timestamp is the audit trail.
+
+BEGIN;
+
+ALTER TABLE app_users
+  ADD COLUMN IF NOT EXISTS email_verification_token   TEXT,
+  ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMPTZ,
+  ADD COLUMN IF NOT EXISTS email_verified_at          TIMESTAMPTZ;
+
+-- Tokens are random opaque strings — must be unique while live (NULL after consumption).
+CREATE UNIQUE INDEX IF NOT EXISTS idx_app_users_verification_token
+  ON app_users (email_verification_token)
+  WHERE email_verification_token IS NOT NULL;
+
+-- Grandfather every pre-existing user. After this migration, only NEW signups
+-- (post-deploy) will have email_verified_at = NULL.
+UPDATE app_users
+   SET email_verified_at = created_at,
+       email_verified    = TRUE
+ WHERE email_verified_at IS NULL;
+
+COMMIT;
diff --git a/src/lib/auth.js b/src/lib/auth.js
index 381cace..f278857 100644
--- a/src/lib/auth.js
+++ b/src/lib/auth.js
@@ -13,6 +13,7 @@ import crypto from 'node:crypto';
 import { pool, one, query } from './db.js';
 import { log } from './log.js';
 import { geocodeZip } from './geo.js';
+import { issueAndSendToken } from './email_verification.js';
 // Migrated 2026-05-04 (tick 28): use directory-core's haversineMi instead of
 // the local copy. Same math, single source of truth across all 4 verticals.
 import { haversineMi as _haversineMi } from 'directory-core/geo';
@@ -126,6 +127,14 @@ export async function signup(req, res) {
 
     const token = await createSession(user.id, req);
     res.cookie(COOKIE, token, COOKIE_OPTS);
+
+    // Issue the email-verification token and fire the George send. This is
+    // fire-and-forget inside issueAndSendToken — signup must succeed even if
+    // George is down. The user is logged in but gated from posting listings /
+    // sending friend requests until they click the link.
+    issueAndSendToken({ userId: user.id, email: user.email, fullName: full_name })
+      .catch(err => log.warn('verification token issue failed', { user: user.id, err: err.message }));
+
     res.json({ ok: true, user, circle });
   } catch (err) {
     log.error('signup failed', { err: err.message });
diff --git a/src/lib/email_verification.js b/src/lib/email_verification.js
new file mode 100644
index 0000000..737c88b
--- /dev/null
+++ b/src/lib/email_verification.js
@@ -0,0 +1,157 @@
+// Email verification — generate a token, persist it, send the click-link
+// through George (localhost:9850 /api/send). The signup flow calls
+// issueAndSendToken; the user clicks the link, which hits /auth/verify in
+// community.js (handler in auth.js).
+//
+// Failure to send is non-fatal — signup must succeed even if George is down,
+// or the user would be locked out of their own account during a transient
+// outage. Steve can manually trigger /auth/resend-verification.
+
+import crypto from 'node:crypto';
+import { pool, one } from './db.js';
+import { log } from './log.js';
+
+const GEORGE_URL  = process.env.GEORGE_GMAIL_URL || 'http://localhost:9850';
+// George requires Basic auth on /api/send. Falls back to the project-wide
+// default that the secrets-manager fan-out uses (see ~/Projects/animals/CLAUDE
+// pattern: GEORGE_AUTH=admin:DWSecure2024!).
+const GEORGE_AUTH = process.env.GEORGE_AUTH || 'admin:DWSecure2024!';
+const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || 'https://animalsdirectory.com';
+const FROM_NAME   = process.env.GEORGE_FROM_NAME || 'AnimalsDirectory';
+
+export function generateToken() {
+  return crypto.randomBytes(32).toString('base64url');
+}
+
+// Persist a fresh token onto the user row + send the email. Returns the URL
+// (mostly so dev/testing can grab it without round-tripping George).
+export async function issueAndSendToken({ userId, email, fullName }) {
+  const token = generateToken();
+  await pool.query(
+    `UPDATE app_users
+        SET email_verification_token   = $1,
+            email_verification_sent_at = NOW()
+      WHERE id = $2`,
+    [token, userId]
+  );
+  const url = `${PUBLIC_BASE}/auth/verify?token=${encodeURIComponent(token)}`;
+
+  // Fire-and-forget send — caller already returned 200 to the user. Don't
+  // throw; just log. If George is down, /auth/resend-verification covers it.
+  sendVerificationEmail({ to: email, fullName, url }).catch(err => {
+    log.warn('verification email send failed', { userId, email, err: err.message });
+  });
+
+  return { token, url };
+}
+
+async function sendVerificationEmail({ to, fullName, url }) {
+  const subject = 'Confirm your AnimalsDirectory email';
+  const greeting = fullName ? `Hi ${fullName},` : 'Hi,';
+  const body = `
+<p>${esc(greeting)}</p>
+<p>Thanks for joining AnimalsDirectory + PawCircles. Click the link below to confirm your email — once you do, you'll be able to post marketplace listings and send friend requests.</p>
+<p><a href="${esc(url)}" style="display:inline-block;background:#2563eb;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none">Confirm my email</a></p>
+<p style="color:#666;font-size:.9em">Or paste this link into your browser:<br><code style="word-break:break-all">${esc(url)}</code></p>
+<p style="color:#888;font-size:.85em">If you didn't sign up, you can ignore this email — the account stays locked until someone confirms.</p>
+<p style="color:#888;font-size:.85em">— ${esc(FROM_NAME)}</p>
+`.trim();
+
+  // 10s timeout — we're already fire-and-forget; don't let a hung George
+  // pile up open sockets.
+  const r = await fetch(`${GEORGE_URL}/api/send`, {
+    method: 'POST',
+    headers: {
+      'content-type': 'application/json',
+      'authorization': 'Basic ' + Buffer.from(GEORGE_AUTH).toString('base64'),
+    },
+    body: JSON.stringify({ to, subject, body }),
+    signal: AbortSignal.timeout(10000),
+  });
+  if (!r.ok) {
+    const text = await r.text().catch(() => '');
+    throw new Error(`george returned ${r.status}: ${text.slice(0, 200)}`);
+  }
+}
+
+function esc(s) {
+  return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+}
+
+// GET /auth/verify?token=… — consume the token, flip the user verified, render
+// a tiny confirmation page. Idempotent on re-click for already-verified users
+// (the token is cleared on first click, so a refreshed link shows "link expired
+// or already used" rather than re-flipping anything).
+export async function verifyToken(req, res) {
+  const token = String(req.query?.token || '');
+  if (!token) return res.status(400).send(verifyPage('Missing token.', false));
+  const row = await one(
+    `UPDATE app_users
+        SET email_verified         = TRUE,
+            email_verified_at      = NOW(),
+            email_verification_token = NULL
+      WHERE email_verification_token = $1
+      RETURNING id, email`,
+    [token]
+  );
+  if (!row) {
+    return res.status(400).send(verifyPage(
+      'This verification link is invalid, expired, or has already been used. If you are already logged in, you can request a new one.',
+      false
+    ));
+  }
+  log.info('email verified', { userId: row.id });
+  res.send(verifyPage(
+    `Thanks — ${esc(row.email)} is confirmed. You can now post marketplace listings and send friend requests.`,
+    true
+  ));
+}
+
+// POST /auth/resend-verification — must be logged in. Generates a fresh token
+// and re-sends. No-op (200) if the user is already verified, so it's safe to
+// hammer.
+export async function resendVerification(req, res) {
+  if (!req.user) return res.status(401).json({ error: 'login required' });
+  if (req.user.email_verified) return res.json({ ok: true, already_verified: true });
+  const { url } = await issueAndSendToken({
+    userId: req.user.id,
+    email: req.user.email,
+    fullName: req.user.full_name,
+  });
+  // url returned in dev only (no PUBLIC_BASE_URL set / NODE_ENV !== production)
+  // so tests/curl can grab it without scraping email.
+  const body = { ok: true };
+  if (process.env.NODE_ENV !== 'production') body.url = url;
+  res.json(body);
+}
+
+// requireVerifiedEmail — gate middleware. Mount AFTER requireUser so req.user
+// is populated. Returns 403 with a machine-readable code so the front-end can
+// surface a "check your inbox" CTA instead of a generic auth error.
+export function requireVerifiedEmail(req, res, next) {
+  if (!req.user) return res.status(401).json({ error: 'login required' });
+  if (req.user.email_verified) return next();
+  return res.status(403).json({
+    error: 'email_not_verified',
+    message: 'Confirm your email before posting listings or sending friend requests. Check your inbox for the link, or POST /auth/resend-verification to get another.',
+  });
+}
+
+function verifyPage(message, ok) {
+  const color = ok ? '#16a34a' : '#dc2626';
+  const title = ok ? 'Email confirmed' : 'Verification problem';
+  return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<title>${esc(title)} — AnimalsDirectory</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+  body{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 20px;color:#111;line-height:1.5}
+  h1{color:${color};margin:0 0 16px;font-size:1.5rem}
+  a.btn{display:inline-block;margin-top:18px;background:#2563eb;color:#fff;padding:10px 18px;border-radius:6px;text-decoration:none}
+</style></head><body>
+<h1>${esc(title)}</h1>
+<p>${esc(message)}</p>
+<a class="btn" href="/">Continue</a>
+</body></html>`;
+}
diff --git a/src/server/community.js b/src/server/community.js
index 3be0c63..f9248e1 100644
--- a/src/server/community.js
+++ b/src/server/community.js
@@ -4,6 +4,7 @@
 import express from 'express';
 import { pool, many, one } from '../lib/db.js';
 import { attachUser, requireUser, signup, login, logout, userCanSeeListing, userCanSeeListingSync } from '../lib/auth.js';
+import { verifyToken, resendVerification, requireVerifiedEmail } from '../lib/email_verification.js';
 import { geocodeZip } from '../lib/geo.js';
 import { renderSignup, renderLogin, renderMarketplace, renderListing, renderListingNew, renderCircle, renderMyCircles, renderMarketingFirms, renderUpgradePreview } from './community_render.js';
 
@@ -68,6 +69,8 @@ community.get('/login',  (req, res) => res.send(renderLogin({ user: req.user }))
 community.post('/auth/signup', signup);
 community.post('/auth/login',  login);
 community.post('/auth/logout', logout);
+community.get ('/auth/verify', verifyToken);
+community.post('/auth/resend-verification', requireUser, resendVerification);
 
 // ── marketplace ───────────────────────────────────────────────────────────
 community.get('/marketplace', async (req, res) => {
@@ -155,7 +158,7 @@ community.get('/marketplace/new', (req, res) => {
   res.send(renderListingNew({ user: req.user }));
 });
 
-community.post('/marketplace/new', requireUser, rateLimit({ key: 'mkt-new', limit: 10 }), async (req, res) => {
+community.post('/marketplace/new', requireUser, requireVerifiedEmail, rateLimit({ key: 'mkt-new', limit: 10 }), async (req, res) => {
   const { listing_type, title, body, zip, city, state, species, breed_id, price_cents,
           contact_email, contact_phone, visibility, photo_urls } = req.body || {};
   if (!listing_type || !title || !zip) return res.status(400).json({ error: 'listing_type, title, zip required' });
diff --git a/src/server/dog_friends.js b/src/server/dog_friends.js
index 4f981d5..14cee45 100644
--- a/src/server/dog_friends.js
+++ b/src/server/dog_friends.js
@@ -4,6 +4,7 @@
 import express from 'express';
 import { pool, many, one } from '../lib/db.js';
 import { attachUser, requireUser } from '../lib/auth.js';
+import { requireVerifiedEmail } from '../lib/email_verification.js';
 import { renderRegisterDog, renderFriendMatches, renderFriendRequests } from './dog_friends_render.js';
 
 export const dogFriends = express.Router();
@@ -111,7 +112,7 @@ dogFriends.get('/friends/suggest/:petId', attachUser, requireUser, requirePetOwn
   res.send(renderFriendMatches({ me, matches: scored }));
 });
 
-dogFriends.post('/friends/request', express.json(), attachUser, requireUser, requirePetOwner, async (req, res) => {
+dogFriends.post('/friends/request', express.json(), attachUser, requireUser, requireVerifiedEmail, requirePetOwner, async (req, res) => {
   const { from_pet_id, to_pet_id, message } = req.body || {};
   const fromId = parseInt(from_pet_id, 10);
   const toId = parseInt(to_pet_id, 10);

← e7e8d06 yolo: Breed page: nearest businesses that work with this bre  ·  back to Animals  ·  yolo: 3D dog park: use dog's actual photo as billboard textu d1144d1 →