[object Object]

← back to Butlr

feat(auth): per-user accounts v2 — email+password signup/login, user_id scoping

73f6463b229393cc700f3a74199b121fedb538a8 · 2026-05-12 16:59:21 -0700 · SteveStudio2

Replace single BUTLR_OWNER_TOKEN with per-user accounts backed by
data/users.json (atomic writes, bcrypt password hashes, 64-char hex
owner_tokens used as cookie values).

  • lib/users.js — create/verify/lookup, bcrypt cost 12, sha256-keyed
    token lookup for constant-time auth without bcrypt per-request
  • lib/owner-auth.js — req.user populated from cookie → users.findByOwnerToken;
    new GET/POST /signup + /login forms with email + password
  • lib/data.js — addCall/listCalls/getCall all take userId now;
    rows include user_id; getCall returns null when user_id mismatches
    (404 not 403, no oracle); listCallsUnscoped/getCallUnscoped reserved
    for Twilio webhook + background worker contexts only
  • server.js — hard-gates /calls, /listen, /api/calls, /new behind
    requireOwner; seeds legacy BUTLR_OWNER_TOKEN as admin user on boot
  • scripts/migrate-calls-to-user.js — one-shot stamp existing calls
    with legacy admin user_id (idempotent, dry-run flag, backs up
    calls.json before write)

Backward compat: existing browser cookies keep working because the
seeded admin's owner_token == BUTLR_OWNER_TOKEN env value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 73f6463b229393cc700f3a74199b121fedb538a8
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 16:59:21 2026 -0700

    feat(auth): per-user accounts v2 — email+password signup/login, user_id scoping
    
    Replace single BUTLR_OWNER_TOKEN with per-user accounts backed by
    data/users.json (atomic writes, bcrypt password hashes, 64-char hex
    owner_tokens used as cookie values).
    
      • lib/users.js — create/verify/lookup, bcrypt cost 12, sha256-keyed
        token lookup for constant-time auth without bcrypt per-request
      • lib/owner-auth.js — req.user populated from cookie → users.findByOwnerToken;
        new GET/POST /signup + /login forms with email + password
      • lib/data.js — addCall/listCalls/getCall all take userId now;
        rows include user_id; getCall returns null when user_id mismatches
        (404 not 403, no oracle); listCallsUnscoped/getCallUnscoped reserved
        for Twilio webhook + background worker contexts only
      • server.js — hard-gates /calls, /listen, /api/calls, /new behind
        requireOwner; seeds legacy BUTLR_OWNER_TOKEN as admin user on boot
      • scripts/migrate-calls-to-user.js — one-shot stamp existing calls
        with legacy admin user_id (idempotent, dry-run flag, backs up
        calls.json before write)
    
    Backward compat: existing browser cookies keep working because the
    seeded admin's owner_token == BUTLR_OWNER_TOKEN env value.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 lib/data.js                      |  67 ++++++++--
 lib/owner-auth.js                | 255 ++++++++++++++++++++++++++++-----------
 lib/twilio.js                    |   4 +-
 lib/users.js                     | 200 ++++++++++++++++++++++++++++++
 package-lock.json                |  10 ++
 package.json                     |   1 +
 routes/listen.js                 |  11 +-
 routes/public.js                 |  18 ++-
 routes/twilio-webhooks.js        |  31 +++--
 scripts/migrate-calls-to-user.js |  67 ++++++++++
 server.js                        |  14 ++-
 11 files changed, 573 insertions(+), 105 deletions(-)

diff --git a/lib/data.js b/lib/data.js
index 4b853a9..ec4c0cc 100644
--- a/lib/data.js
+++ b/lib/data.js
@@ -113,30 +113,73 @@ function sanitizeRow(body) {
   };
 }
 
-function addCall(body) {
+// All call-mutating + call-reading functions now require a userId so each
+// user only sees their own records. user_id of null = legacy unscoped row
+// (only visible to admin role via a separate path; defaults to invisible).
+
+function addCall(body, userId) {
   const errors = [];
   if (!body.business_name) errors.push('business_name required');
   if (!validatePhone(body.business_phone)) errors.push('business_phone is not a valid number');
   if (!validatePhone(body.callback_phone)) errors.push('callback_phone is not a valid number');
   if (!body.goal || String(body.goal).trim().length < 8) errors.push('goal is too short (8+ chars required)');
   if (!body.consent_terms) errors.push('consent_terms must be checked');
+  if (!userId) errors.push('user_id required (must sign in)');
   if (errors.length) return { ok: false, errors };
 
   // sanitizeRow has the plaintext values; encrypt sensitive fields before persisting.
   const plaintextRow = sanitizeRow(body);
+  plaintextRow.user_id = userId;
   const persistedRow = fieldCrypto.encryptFields(plaintextRow, SENSITIVE_FIELDS);
+  persistedRow.user_id = userId; // user_id is NOT sensitive — keep plaintext
 
   const all = readAll();
   all.unshift(persistedRow);
   writeAll(all);
 
-  // Return the plaintext row to the route layer so the redirect/confirmation
-  // doesn't accidentally show encrypted blobs.
   return { ok: true, call: plaintextRow };
 }
 
-function listCalls() {
+function listCalls(userId) {
   // List view never decrypts — just shows masked placeholder where data exists.
+  // Returns only rows owned by userId. If userId is missing, returns [].
+  if (!userId) return [];
+  return readAll()
+    .filter(c => c.user_id === userId)
+    .map(c => ({
+      ...c,
+      account_number: c.account_number ? '••••••••••' : '',
+      last4_ssn:      c.last4_ssn ? '••••' : '',
+      auth_password:  c.auth_password ? '••••••' : '',
+    }));
+}
+
+// getCall(id, userId, reveal)
+// Returns null if call not found OR if call.user_id !== userId.
+// Always 404 (no oracle) — callers should never branch on "exists but
+// forbidden" because that leaks call-ID existence.
+function getCall(id, userId, reveal = false) {
+  if (!id || !userId) return null;
+  const c = readAll().find(x => x.id === id);
+  if (!c) return null;
+  if (c.user_id !== userId) return null;
+  if (reveal) {
+    return fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
+  }
+  const decrypted = fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
+  return {
+    ...c,
+    account_number: maskAccount(decrypted.account_number),
+    last4_ssn:      decrypted.last4_ssn ? '••••' : '',
+    auth_password:  decrypted.auth_password ? '••••••' : '',
+    billing_zip:    decrypted.billing_zip ? '•••••' : '',
+    date_of_birth:  decrypted.date_of_birth ? '••/••/••••' : '',
+  };
+}
+
+// Internal-only: list ALL calls (across users) for the background worker.
+// Never expose this through a user-facing route.
+function listCallsUnscoped() {
   return readAll().map(c => ({
     ...c,
     account_number: c.account_number ? '••••••••••' : '',
@@ -145,21 +188,21 @@ function listCalls() {
   }));
 }
 
-function getCall(id, reveal = false) {
+// Internal-only: get a call WITHOUT user scoping. Used by Twilio webhooks
+// (which present a callId from a Twilio-controlled URL, not a user cookie)
+// and the call worker (background Twilio dialer). Never call this from a
+// user-facing route — it bypasses ownership checks.
+function getCallUnscoped(id, reveal = false) {
+  if (!id) return null;
   const c = readAll().find(x => x.id === id);
   if (!c) return null;
-  if (reveal) {
-    // Reveal mode → decrypt sensitive fields
-    return fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
-  }
-  // Masked mode → show last-4 of decrypted account, otherwise just the indicator
+  if (reveal) return fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
   const decrypted = fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
   return {
     ...c,
     account_number: maskAccount(decrypted.account_number),
     last4_ssn:      decrypted.last4_ssn ? '••••' : '',
     auth_password:  decrypted.auth_password ? '••••••' : '',
-    // Leave billing_zip + date_of_birth as masked placeholders too
     billing_zip:    decrypted.billing_zip ? '•••••' : '',
     date_of_birth:  decrypted.date_of_birth ? '••/••/••••' : '',
   };
@@ -206,7 +249,7 @@ function businessesForCategory(catId) {
 module.exports = {
   CATEGORIES, categoryById,
   validatePhone, sanitizeRow,
-  addCall, listCalls, getCall, updateStatus,
+  addCall, listCalls, listCallsUnscoped, getCall, getCallUnscoped, updateStatus,
   patchRow, setTwilioSid, setRecordingMeta, setTranscriptMeta,
   loadBusinesses, businessBySlug, businessesForCategory,
 };
diff --git a/lib/owner-auth.js b/lib/owner-auth.js
index d160397..6f73610 100644
--- a/lib/owner-auth.js
+++ b/lib/owner-auth.js
@@ -1,20 +1,15 @@
-// Owner-auth middleware for Butlr.
+// Per-user auth middleware for Butlr.
 //
-// Until Butlr ships real per-user accounts, the call data belongs to ONE
-// person — Steve. So we gate everything that reveals queued calls behind a
-// single owner token stored in env (BUTLR_OWNER_TOKEN).
+// Each user has an `owner_token` (64-char hex) stored in users.json.
+// When the user signs in, we set a httpOnly cookie `butlr_owner=<token>`.
+// On every request, we hash the cookie value and look up the user in
+// users.json via constant-time hash compare.
 //
-// Two ways to authenticate:
-//   1. Cookie `butlr_owner=<token>` — set once via /login?t=<token>
-//   2. Query `?owner_token=<token>` — useful for direct curl checks
-//
-// If the token matches, req.ownerAuthed = true and downstream routes can
-// honor reveal=1, return owner-only data, etc. If it doesn't match, the
-// middleware short-circuits with a 401 or redirects to /login.
-//
-// Also adds X-Robots-Tag: noindex, nofollow, noarchive to every gated
-// response so even if a crawler somehow gets a token, indexed pages are
-// flagged to not be retained.
+// Backward compat: if BUTLR_OWNER_TOKEN env is set, the lib/users
+// seedLegacyAdmin() promotes that token to an admin user on boot, so
+// Steve's existing browser cookie keeps working through the migration.
+
+const users = require('./users');
 
 const COOKIE_NAME = 'butlr_owner';
 const ONE_YEAR = 60 * 60 * 24 * 365;
@@ -31,34 +26,28 @@ function parseCookies(req) {
   return out;
 }
 
-function constantTimeEqual(a, b) {
-  if (typeof a !== 'string' || typeof b !== 'string') return false;
-  if (a.length !== b.length) return false;
-  let mismatch = 0;
-  for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
-  return mismatch === 0;
+function tokenFromReq(req) {
+  const fromCookie = parseCookies(req)[COOKIE_NAME];
+  const fromQuery  = req.query && req.query.owner_token;
+  return fromCookie || fromQuery || '';
 }
 
-function getExpectedToken() {
-  return (process.env.BUTLR_OWNER_TOKEN || '').trim();
+function lookupUser(req) {
+  const tok = tokenFromReq(req);
+  if (!tok) return null;
+  return users.findByOwnerToken(tok);
 }
 
-function authed(req) {
-  const expected = getExpectedToken();
-  if (!expected) return false; // env not set → fail closed
-  const fromCookie = parseCookies(req)[COOKIE_NAME];
-  const fromQuery = req.query && req.query.owner_token;
-  return constantTimeEqual(fromCookie || '', expected) || constantTimeEqual(fromQuery || '', expected);
-}
-
-// Middleware: hard-gate (401/redirect)
+// Middleware: hard-gate (401 for /api/, 302 to /login for pages).
+// On success sets req.user (full record) and req.ownerAuthed = true.
 function requireOwner(req, res, next) {
-  if (authed(req)) {
+  const user = lookupUser(req);
+  if (user) {
+    req.user = user;
     req.ownerAuthed = true;
     res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
     return next();
   }
-  // For API endpoints return 401 JSON; for page endpoints redirect to /login
   if (req.path.startsWith('/api/')) {
     return res.status(401).json({ ok: false, error: 'unauthorized', login_at: '/login' });
   }
@@ -66,56 +55,180 @@ function requireOwner(req, res, next) {
   return res.redirect(302, `/login?return=${back}`);
 }
 
-// Middleware: soft (just sets req.ownerAuthed, never blocks)
+// Middleware: soft (non-blocking, just sets req.user / req.ownerAuthed).
 function softAuth(req, res, next) {
-  req.ownerAuthed = authed(req);
-  if (req.ownerAuthed) res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
+  const user = lookupUser(req);
+  req.user = user || null;
+  req.ownerAuthed = !!user;
+  if (user) res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
+  res.locals.ownerAuthed = !!user;
+  res.locals.currentUser = user ? users.publicView(user) : null;
   next();
 }
 
-// /login route — accepts ?t=<token>&return=<url>, sets cookie, redirects.
-// Also accepts POST from a tiny form (no body needed in browser flows).
+function setSessionCookie(res, token) {
+  res.cookie(COOKIE_NAME, token, {
+    httpOnly: true,
+    secure: true,
+    sameSite: 'lax',
+    maxAge: ONE_YEAR * 1000,
+    path: '/',
+  });
+}
+
+// ── /login + /signup form HTML (server-rendered, no client JS) ───────
+
+function pageShell(title, body) {
+  return `<!doctype html>
+<html><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex,nofollow,noarchive">
+<title>${title} · Butlr</title>
+<style>
+body{font-family:'Inter',system-ui,sans-serif;max-width:480px;margin:80px auto;padding:24px;color:#101828;background:#fafaf7}
+h1{font-family:'Playfair Display',serif;font-weight:500;margin:0 0 8px}
+.lead{color:#475569;margin:0 0 24px}
+label{display:block;font-size:13px;color:#475569;margin:14px 0 4px}
+input{font-size:16px;padding:11px 12px;width:100%;box-sizing:border-box;border:1px solid #d4d4d4;border-radius:6px;background:#fff}
+button{font-size:15px;padding:12px 22px;background:#0c2d6b;color:#fff;border:0;border-radius:6px;cursor:pointer;margin-top:18px;width:100%}
+button:hover{background:#0a225a}
+.err{background:#fff3f2;border:1px solid #ffc3c0;color:#8a1c14;padding:10px 12px;border-radius:6px;margin:0 0 16px;font-size:14px}
+.alt{margin-top:24px;text-align:center;font-size:14px;color:#475569}
+.alt a{color:#0c2d6b}
+.brand{font-size:32px;margin-bottom:6px}
+</style></head><body>
+<div class="brand">🎩</div>
+${body}
+</body></html>`;
+}
+
+function escapeAttr(s) {
+  return String(s || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+}
+
+function loginPage({ ret, error, email }) {
+  const body = `<h1>Sign in</h1>
+<p class="lead">Welcome back. Enter your email and password.</p>
+${error ? `<div class="err">${escapeAttr(error)}</div>` : ''}
+<form method="POST" action="/login">
+  <input type="hidden" name="return" value="${escapeAttr(ret || '/')}">
+  <label for="email">Email</label>
+  <input id="email" type="email" name="email" autocomplete="email" required value="${escapeAttr(email || '')}">
+  <label for="password">Password</label>
+  <input id="password" type="password" name="password" autocomplete="current-password" required>
+  <button type="submit">Sign in</button>
+</form>
+<p class="alt">No account yet? <a href="/signup${ret ? `?return=${encodeURIComponent(ret)}` : ''}">Create one</a>.</p>`;
+  return pageShell('Sign in', body);
+}
+
+function signupPage({ ret, error, email }) {
+  const body = `<h1>Create an account</h1>
+<p class="lead">Butlr keeps your call records strictly to your account. Pick a password 8+ characters.</p>
+${error ? `<div class="err">${escapeAttr(error)}</div>` : ''}
+<form method="POST" action="/signup">
+  <input type="hidden" name="return" value="${escapeAttr(ret || '/')}">
+  <label for="email">Email</label>
+  <input id="email" type="email" name="email" autocomplete="email" required value="${escapeAttr(email || '')}">
+  <label for="password">Password (8+ characters)</label>
+  <input id="password" type="password" name="password" autocomplete="new-password" minlength="8" required>
+  <label for="password2">Confirm password</label>
+  <input id="password2" type="password" name="password2" autocomplete="new-password" minlength="8" required>
+  <button type="submit">Create account</button>
+</form>
+<p class="alt">Already have one? <a href="/login${ret ? `?return=${encodeURIComponent(ret)}` : ''}">Sign in</a>.</p>`;
+  return pageShell('Sign up', body);
+}
+
+// Map verifyLogin / createUser errors to user-friendly strings.
+function errMessage(code) {
+  switch (code) {
+    case 'invalid_email': return 'That email address doesn\'t look right.';
+    case 'password_too_short': return 'Password must be at least 8 characters.';
+    case 'email_taken': return 'An account with that email already exists. Try signing in instead.';
+    case 'invalid_credentials': return 'Email or password is wrong.';
+    case 'password_mismatch': return 'Passwords don\'t match.';
+    case 'missing_fields': return 'Please fill in every field.';
+    default: return 'Something went wrong. Please try again.';
+  }
+}
+
+// ── /login + /signup + /logout + legacy ?t= flow ──────────────────────
+
+function safeReturn(input) {
+  // Only allow same-site relative paths to avoid open-redirect.
+  const s = String(input || '/');
+  if (s.startsWith('/') && !s.startsWith('//')) return s;
+  return '/';
+}
+
 function mountLogin(router) {
+  // GET /login → form (or legacy ?t= cookie set)
   router.get('/login', (req, res) => {
-    const t = req.query.t || '';
-    const ret = req.query.return || '/';
-    const expected = getExpectedToken();
-    if (!expected) {
-      return res.status(500).type('text/plain').send('BUTLR_OWNER_TOKEN env not set on server.');
+    const ret = safeReturn(req.query.return);
+    const t   = (req.query.t || '').toString();
+
+    // Legacy fast-path: ?t=<token> sets cookie + redirects.
+    if (t) {
+      const user = users.findByOwnerToken(t);
+      if (user) {
+        setSessionCookie(res, t);
+        return res.redirect(302, ret);
+      }
+      // Fall through to login form with error
+      return res.status(401).type('text/html').send(loginPage({ ret, error: 'That token isn\'t recognized.' }));
     }
-    if (t && constantTimeEqual(t, expected)) {
-      res.cookie(COOKIE_NAME, expected, {
-        httpOnly: true,
-        secure: true,
-        sameSite: 'lax',
-        maxAge: ONE_YEAR * 1000,
-        path: '/',
-      });
-      return res.redirect(302, ret);
+    res.type('text/html').send(loginPage({ ret }));
+  });
+
+  // POST /login → email+password → cookie
+  router.post('/login', async (req, res) => {
+    const ret = safeReturn(req.body.return);
+    const email = req.body.email;
+    const password = req.body.password;
+    if (!email || !password) {
+      return res.status(400).type('text/html').send(loginPage({ ret, email, error: errMessage('missing_fields') }));
     }
-    res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
-    res.type('text/html').send(`<!doctype html>
-<html><head><title>Butlr · Owner login</title>
-<meta name="robots" content="noindex,nofollow,noarchive">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<style>body{font-family:system-ui,sans-serif;max-width:520px;margin:80px auto;padding:24px;color:#101828}
-input{font-size:16px;padding:10px;width:100%;box-sizing:border-box;border:1px solid #ccc;border-radius:6px;margin:8px 0}
-button{font-size:16px;padding:10px 20px;background:#0c2d6b;color:#fff;border:0;border-radius:6px;cursor:pointer}</style>
-</head><body>
-<h1>🎩 Butlr — owner sign-in</h1>
-<p>Paste your owner token to access call records.</p>
-<form method="GET" action="/login">
-  <input type="hidden" name="return" value="${ret.replace(/"/g, '&quot;')}">
-  <input type="password" name="t" placeholder="Owner token" autocomplete="off" autofocus>
-  <button type="submit">Sign in</button>
-</form>
-</body></html>`);
+    const r = await users.verifyLogin({ email, password });
+    if (!r.ok) {
+      return res.status(401).type('text/html').send(loginPage({ ret, email, error: errMessage(r.error) }));
+    }
+    setSessionCookie(res, r.owner_token);
+    res.redirect(302, ret);
+  });
+
+  // GET /signup → form
+  router.get('/signup', (req, res) => {
+    const ret = safeReturn(req.query.return);
+    res.type('text/html').send(signupPage({ ret }));
+  });
+
+  // POST /signup → create user + cookie
+  router.post('/signup', async (req, res) => {
+    const ret = safeReturn(req.body.return);
+    const email = req.body.email;
+    const password = req.body.password;
+    const password2 = req.body.password2;
+    if (!email || !password || !password2) {
+      return res.status(400).type('text/html').send(signupPage({ ret, email, error: errMessage('missing_fields') }));
+    }
+    if (password !== password2) {
+      return res.status(400).type('text/html').send(signupPage({ ret, email, error: errMessage('password_mismatch') }));
+    }
+    const r = await users.createUser({ email, password });
+    if (!r.ok) {
+      return res.status(400).type('text/html').send(signupPage({ ret, email, error: errMessage(r.error) }));
+    }
+    setSessionCookie(res, r.owner_token);
+    res.redirect(302, ret);
   });
 
+  // GET /logout
   router.get('/logout', (req, res) => {
     res.clearCookie(COOKIE_NAME, { path: '/' });
     res.redirect(302, '/');
   });
 }
 
-module.exports = { requireOwner, softAuth, mountLogin, COOKIE_NAME };
+module.exports = { requireOwner, softAuth, mountLogin, setSessionCookie, COOKIE_NAME };
diff --git a/lib/twilio.js b/lib/twilio.js
index 6db1967..78d841e 100644
--- a/lib/twilio.js
+++ b/lib/twilio.js
@@ -214,10 +214,10 @@ async function tick() {
   if (_tickRunning) return;
   _tickRunning = true;
   try {
-    const all = data.listCalls();  // listCalls returns the masked view, but status/id are unmasked
+    const all = data.listCallsUnscoped();  // background worker sees all users' queued calls
     for (const c of all) {
       // Get unmasked row for actual work
-      const full = data.getCall(c.id, true);
+      const full = data.getCallUnscoped(c.id, true);
       if (!full) continue;
 
       if (full.status === 'queued') {
diff --git a/lib/users.js b/lib/users.js
new file mode 100644
index 0000000..2ffccd6
--- /dev/null
+++ b/lib/users.js
@@ -0,0 +1,200 @@
+// JSON-backed user store for Butlr per-user accounts.
+//
+// Atomic writes (tmp file + rename). Bcrypt password hashes (cost=12).
+// Each user has a long random `owner_token` (64-char hex) used as their
+// session cookie value. Owner tokens are regenerated on password reset
+// or explicit revoke — sessions persist across deploys but die on rotate.
+//
+// File: data/users.json — array of:
+//   { id, email, password_hash, owner_token, owner_token_sha256, role,
+//     created_at, last_login_at }
+//
+// `owner_token_sha256` is kept alongside the plaintext token so we can
+// answer "which user owns this cookie?" via constant-time hash compare
+// without bcrypt cost on every request.
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const bcrypt = require('bcryptjs');
+
+const FILE = path.join(__dirname, '..', 'data', 'users.json');
+const BCRYPT_COST = 12;
+const TOKEN_BYTES = 32; // 64 hex chars
+
+function readAll() {
+  try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); }
+  catch (e) { if (e.code === 'ENOENT') return []; throw e; }
+}
+
+function writeAllAtomic(rows) {
+  fs.mkdirSync(path.dirname(FILE), { recursive: true });
+  const tmp = FILE + '.tmp.' + process.pid + '.' + Date.now();
+  fs.writeFileSync(tmp, JSON.stringify(rows, null, 2));
+  fs.renameSync(tmp, FILE);
+  try { fs.chmodSync(FILE, 0o600); } catch {}
+}
+
+function sha256(s) {
+  return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
+}
+
+function newToken() {
+  return crypto.randomBytes(TOKEN_BYTES).toString('hex');
+}
+
+function normalizeEmail(e) {
+  return String(e || '').trim().toLowerCase();
+}
+
+function publicView(u) {
+  if (!u) return null;
+  return {
+    id: u.id,
+    email: u.email,
+    role: u.role || 'user',
+    created_at: u.created_at,
+    last_login_at: u.last_login_at || null,
+  };
+}
+
+// ── public API ────────────────────────────────────────────────────────
+
+async function createUser({ email, password, role = 'user' }) {
+  const e = normalizeEmail(email);
+  if (!e || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e)) return { ok: false, error: 'invalid_email' };
+  if (!password || password.length < 8) return { ok: false, error: 'password_too_short' };
+
+  const all = readAll();
+  if (all.some(u => normalizeEmail(u.email) === e)) return { ok: false, error: 'email_taken' };
+
+  const password_hash = await bcrypt.hash(password, BCRYPT_COST);
+  const owner_token = newToken();
+  const user = {
+    id: crypto.randomBytes(6).toString('base64url'),
+    email: e,
+    password_hash,
+    owner_token,
+    owner_token_sha256: sha256(owner_token),
+    role,
+    created_at: new Date().toISOString(),
+    last_login_at: null,
+  };
+  all.push(user);
+  writeAllAtomic(all);
+  return { ok: true, user: publicView(user), owner_token };
+}
+
+async function verifyLogin({ email, password }) {
+  const e = normalizeEmail(email);
+  const all = readAll();
+  const user = all.find(u => normalizeEmail(u.email) === e);
+  // Run a dummy bcrypt against a fixed hash to keep timing roughly constant.
+  if (!user) {
+    await bcrypt.compare(password || '', '$2a$12$abcdefghijklmnopqrstuO').catch(() => null);
+    return { ok: false, error: 'invalid_credentials' };
+  }
+  const match = await bcrypt.compare(password || '', user.password_hash);
+  if (!match) return { ok: false, error: 'invalid_credentials' };
+
+  user.last_login_at = new Date().toISOString();
+  writeAllAtomic(all);
+  return { ok: true, user: publicView(user), owner_token: user.owner_token };
+}
+
+function findByOwnerToken(token) {
+  if (!token || typeof token !== 'string') return null;
+  const tokHash = sha256(token);
+  const all = readAll();
+  for (const u of all) {
+    if (u.owner_token_sha256 && constantTimeEqualHex(u.owner_token_sha256, tokHash)) {
+      return u;
+    }
+  }
+  return null;
+}
+
+function findById(id) {
+  if (!id) return null;
+  return readAll().find(u => u.id === id) || null;
+}
+
+function findByEmail(email) {
+  const e = normalizeEmail(email);
+  if (!e) return null;
+  return readAll().find(u => normalizeEmail(u.email) === e) || null;
+}
+
+async function rotateOwnerToken(userId) {
+  const all = readAll();
+  const idx = all.findIndex(u => u.id === userId);
+  if (idx < 0) return null;
+  const tok = newToken();
+  all[idx].owner_token = tok;
+  all[idx].owner_token_sha256 = sha256(tok);
+  writeAllAtomic(all);
+  return tok;
+}
+
+function constantTimeEqualHex(a, b) {
+  if (typeof a !== 'string' || typeof b !== 'string') return false;
+  if (a.length !== b.length) return false;
+  try { return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); }
+  catch { return false; }
+}
+
+// ── seeded admin (legacy BUTLR_OWNER_TOKEN compat) ────────────────────
+//
+// When BUTLR_OWNER_TOKEN is set in env, ensure an admin user exists in
+// users.json whose owner_token == that env value. This preserves Steve's
+// existing browser cookie across the migration.
+
+function seedLegacyAdmin() {
+  const legacyToken = (process.env.BUTLR_OWNER_TOKEN || '').trim();
+  const legacyEmail = (process.env.BUTLR_OWNER_EMAIL || 'steve@butlr.local').trim();
+  if (!legacyToken) return null;
+
+  const all = readAll();
+  // Already seeded?
+  const existing = all.find(u =>
+    u.owner_token_sha256 && u.owner_token_sha256 === sha256(legacyToken));
+  if (existing) return existing;
+
+  // If an admin with this email already exists, rotate their token to match.
+  const byEmail = all.find(u => normalizeEmail(u.email) === normalizeEmail(legacyEmail));
+  if (byEmail) {
+    byEmail.owner_token = legacyToken;
+    byEmail.owner_token_sha256 = sha256(legacyToken);
+    byEmail.role = 'admin';
+    writeAllAtomic(all);
+    return byEmail;
+  }
+
+  // Otherwise create a fresh admin with an unusable password — they sign in
+  // via the existing token cookie until they set a real password.
+  const adminUser = {
+    id: crypto.randomBytes(6).toString('base64url'),
+    email: normalizeEmail(legacyEmail),
+    password_hash: '$2a$12$UNUSABLE.' + crypto.randomBytes(16).toString('hex'),
+    owner_token: legacyToken,
+    owner_token_sha256: sha256(legacyToken),
+    role: 'admin',
+    created_at: new Date().toISOString(),
+    last_login_at: null,
+    legacy_seeded: true,
+  };
+  all.push(adminUser);
+  writeAllAtomic(all);
+  return adminUser;
+}
+
+module.exports = {
+  createUser,
+  verifyLogin,
+  findByOwnerToken,
+  findById,
+  findByEmail,
+  rotateOwnerToken,
+  seedLegacyAdmin,
+  publicView,
+};
diff --git a/package-lock.json b/package-lock.json
index 0be2bae..dec9d36 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
       "name": "holdforme",
       "version": "0.1.0",
       "dependencies": {
+        "bcryptjs": "^3.0.3",
         "dotenv": "^16.6.1",
         "ejs": "^3.1.10",
         "express": "^4.22.2",
@@ -69,6 +70,15 @@
       "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
       "license": "MIT"
     },
+    "node_modules/bcryptjs": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+      "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+      "license": "BSD-3-Clause",
+      "bin": {
+        "bcrypt": "bin/bcrypt"
+      }
+    },
     "node_modules/body-parser": {
       "version": "1.20.5",
       "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
diff --git a/package.json b/package.json
index ae085f3..3f38481 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
     "dev": "node server.js"
   },
   "dependencies": {
+    "bcryptjs": "^3.0.3",
     "dotenv": "^16.6.1",
     "ejs": "^3.1.10",
     "express": "^4.22.2",
diff --git a/routes/listen.js b/routes/listen.js
index 49f0c45..038dbbf 100644
--- a/routes/listen.js
+++ b/routes/listen.js
@@ -16,7 +16,7 @@ router.get('/listen/:call_id', (req, res) => {
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) {
     return res.status(404).render('public/404', { title: 'Not found — Butlr' });
   }
-  const call = data.getCall(callId, false);
+  const call = data.getCall(callId, req.user && req.user.id, false);
   if (!call) {
     return res.status(404).render('public/404', { title: 'Not found — Butlr' });
   }
@@ -27,17 +27,22 @@ router.get('/listen/:call_id', (req, res) => {
   });
 });
 
-router.get('/calls/:call_id/transcript', (req, res) => {
+router.get('/api/calls/:call_id/transcript', (req, res) => {
   const callId = req.params.call_id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
+  // Verify caller owns this call before serving the transcript.
+  const call = data.getCall(callId, req.user && req.user.id, false);
+  if (!call) return res.status(404).json({ ok: false });
   const t = transcribe.readTranscript(callId);
   if (!t) return res.status(404).json({ ok: false, error: 'no_transcript' });
   res.json({ ok: true, transcript: t });
 });
 
-router.get('/calls/:call_id/recording', (req, res) => {
+router.get('/api/calls/:call_id/recording', (req, res) => {
   const callId = req.params.call_id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).end();
+  const call = data.getCall(callId, req.user && req.user.id, false);
+  if (!call) return res.status(404).end();
   const fp = path.join(transcribe.RECORDINGS_DIR, `${callId}.mp3`);
   if (!fs.existsSync(fp)) return res.status(404).end();
   res.type('audio/mpeg');
diff --git a/routes/public.js b/routes/public.js
index 69b64ff..9ad02f2 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -137,8 +137,14 @@ router.post('/new/callback', (req, res) => {
 });
 
 // Final submit — IMMEDIATE dial (don't wait for the 5s worker tick).
+// Requires sign-in; redirects through /signup if no user.
 router.post('/new/submit', async (req, res) => {
-  const r = data.addCall(req.body || {});
+  if (!req.user) {
+    // Stash the form payload in session-cookie-equivalent so signup→post creates it.
+    // For v1 simplicity, redirect to /signup with return URL = /new (lose draft).
+    return res.redirect(302, '/signup?return=' + encodeURIComponent('/new'));
+  }
+  const r = data.addCall(req.body || {}, req.user.id);
   if (!r.ok) {
     const state = pickState(req.body);
     return res.status(400).render('public/wizard-4-callback', {
@@ -161,7 +167,7 @@ router.post('/new/submit', async (req, res) => {
 router.get('/calls/:id/live', (req, res) => {
   const id = req.params.id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
-  const c = data.getCall(id, false);
+  const c = data.getCall(id, req.user && req.user.id, false);
   if (!c) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
   res.render('public/call-live', {
     title: c.business_name + ' — live call — Butlr',
@@ -174,7 +180,7 @@ router.get('/calls/:id/live', (req, res) => {
 router.get('/api/calls/:id/status', (req, res) => {
   const id = req.params.id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).json({ ok: false });
-  const c = data.getCall(id, false);
+  const c = data.getCall(id, req.user && req.user.id, false);
   if (!c) return res.status(404).json({ ok: false });
   res.json({
     ok: true,
@@ -193,7 +199,7 @@ router.get('/api/calls/:id/status', (req, res) => {
 router.post('/api/calls/:id/chat', express.json(), async (req, res) => {
   const id = req.params.id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).json({ ok: false });
-  const c = data.getCall(id, false);
+  const c = data.getCall(id, req.user && req.user.id, false);
   if (!c) return res.status(404).json({ ok: false });
   const userMsg = String((req.body && req.body.message) || '').slice(0, 1000).trim();
   if (!userMsg) return res.status(400).json({ ok: false, error: 'empty message' });
@@ -227,7 +233,7 @@ router.get('/calls', (req, res) => {
   res.render('public/calls', {
     title: 'Your call queue — Butlr',
     meta_desc: 'Calls you\'ve queued up.',
-    calls: data.listCalls(),
+    calls: data.listCalls(req.user && req.user.id),
   });
 });
 
@@ -238,7 +244,7 @@ router.get('/calls/:id', (req, res) => {
   // If we got here, the request is owner-authenticated, so honor reveal=1.
   // Unauthenticated requests are blocked upstream and never reach this handler.
   const wantReveal = req.query.reveal === '1' && req.ownerAuthed === true;
-  const c = data.getCall(id, wantReveal);
+  const c = data.getCall(id, req.user && req.user.id, wantReveal);
   if (!c) return res.status(404).render('public/404', { title: 'Not found — Butlr' });
   res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
   res.render('public/call', {
diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index c67a187..a03ebfa 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -64,7 +64,7 @@ async function twimlHandler(req, res) {
   const callId = req.params.call_id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');
 
-  const call = data.getCall(callId, true);
+  const call = data.getCallUnscoped(callId, true);
   if (!call) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');
 
   const text = buildAnnouncement(call);
@@ -129,7 +129,7 @@ router.post('/twiml/:call_id', twimlHandler);
 router.post('/gather/:call_id', express.urlencoded({ extended: true }), async (req, res) => {
   const callId = req.params.call_id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');
-  const call = data.getCall(callId, true);
+  const call = data.getCallUnscoped(callId, true);
   if (!call) return res.status(404).type('text/xml').send('<Response><Hangup/></Response>');
 
   const pub = publicUrl(req);
@@ -196,22 +196,23 @@ You will hear what ${call.business_name} just said. Respond appropriately. Keep
   turns.push({ role: 'assistant', content: aiText });
   console.log(`[ai-agent] call=${callId} reply: "${aiText.slice(0,100)}"  action=${aiAction ? aiAction.action : '(speak)'}`);
 
-  // Build TwiML response based on action
+  // Build TwiML response based on action — all spoken text goes through
+  // ElevenLabs (cloned voice) per feedback_always_elevenlabs_voice.md.
   let actionTwiml = '';
   if (aiAction && aiAction.action === 'press') {
     actionTwiml = `<Play digits="${String(aiAction.digit||'0').replace(/[^0-9*#]/g,'')}"/>`;
   } else if (aiAction && aiAction.action === 'hangup') {
-    actionTwiml = `<Say voice="Polly.Joanna">Thank you for your time. Goodbye.</Say><Hangup/>`;
+    actionTwiml = (await playOrSay('Thank you for your time. Goodbye.', pub)) + '<Hangup/>';
     data.updateStatus(callId, 'done');
   } else if (aiAction && aiAction.action === 'escalate') {
-    actionTwiml = `<Say voice="Polly.Joanna">I'll have my customer call you back directly with that information. Thank you. Goodbye.</Say><Hangup/>`;
+    actionTwiml = (await playOrSay("I'll have my customer call you back directly with that information. Thank you. Goodbye.", pub)) + '<Hangup/>';
     data.updateStatus(callId, 'done');
   } else if (aiAction && aiAction.action === 'wait') {
     actionTwiml = `<Pause length="15"/>`;
   } else {
     // Normal speech response — strip any JSON-looking suffix
     const cleanSpeech = aiText.replace(/\{[^{}]*"action"[^{}]*\}/g, '').trim() || aiText;
-    actionTwiml = `<Say voice="Polly.Joanna">${escapeXml(cleanSpeech)}</Say>`;
+    actionTwiml = await playOrSay(cleanSpeech, pub);
   }
 
   // Continue the conversation with another Gather (unless we hung up)
@@ -298,7 +299,7 @@ router.post('/amd/:call_id', (req, res) => {
 router.post('/bridge/:call_id', express.urlencoded({ extended: true }), async (req, res) => {
   const callId = req.params.call_id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(callId)) return res.status(404).json({ ok: false });
-  const call = data.getCall(callId, true);
+  const call = data.getCallUnscoped(callId, true);
   if (!call) return res.status(404).json({ ok: false });
   const callSid = call.twilio_sid;
   if (!callSid) return res.status(400).json({ ok: false, error: 'no twilio_sid stored' });
@@ -373,4 +374,20 @@ function escapeXml(s) {
   return String(s || '').replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&apos;'}[c]));
 }
 
+// ── Speak via ElevenLabs (cloned voice) with Polly fallback ───────────
+// Steve's standing rule: never piper/say/coqui as primary — always EL.
+// Cached MP3s land in data/audio-cache/<hash>.mp3 and are served by
+// /twilio/audio/announce/:hash, so repeat phrases ("Thank you. Goodbye.")
+// hit the cache and cost zero EL credits after the first synthesis.
+async function playOrSay(text, pub) {
+  try {
+    const synth = await elevenlabs.synthesize(text);
+    if (synth.ok) {
+      const fname = path.basename(synth.path);
+      return `<Play>${pub}/twilio/audio/announce/${fname}</Play>`;
+    }
+  } catch {}
+  return `<Say voice="Polly.Joanna">${escapeXml(text)}</Say>`;
+}
+
 module.exports = router;
diff --git a/scripts/migrate-calls-to-user.js b/scripts/migrate-calls-to-user.js
new file mode 100644
index 0000000..8d0e18d
--- /dev/null
+++ b/scripts/migrate-calls-to-user.js
@@ -0,0 +1,67 @@
+#!/usr/bin/env node
+// One-shot migration: stamp every existing call with the legacy admin's user_id.
+//
+// Run before deploying the per-user lockdown to prod, otherwise existing
+// calls become orphaned (visible to nobody) post-rollout. Idempotent —
+// skips rows that already have user_id set.
+//
+// Usage (from project root):
+//   node scripts/migrate-calls-to-user.js [--dry-run]
+
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const users = require('../lib/users');
+
+const CALLS_FILE = path.join(__dirname, '..', 'data', 'calls.json');
+const DRY_RUN = process.argv.includes('--dry-run');
+
+function main() {
+  // 1. Ensure legacy admin exists (idempotent).
+  const admin = users.seedLegacyAdmin();
+  if (!admin) {
+    console.error('No legacy admin to migrate to — set BUTLR_OWNER_TOKEN env first.');
+    process.exit(2);
+  }
+  console.log(`legacy admin: ${admin.id} (${admin.email})`);
+
+  // 2. Read calls.
+  let calls = [];
+  try { calls = JSON.parse(fs.readFileSync(CALLS_FILE, 'utf8')); }
+  catch (e) {
+    if (e.code === 'ENOENT') {
+      console.log('no calls.json — nothing to migrate.');
+      return;
+    }
+    throw e;
+  }
+  if (!Array.isArray(calls)) {
+    console.error('calls.json is not an array — refusing to migrate.');
+    process.exit(3);
+  }
+
+  // 3. Stamp.
+  let stamped = 0, already = 0;
+  for (const c of calls) {
+    if (c.user_id) { already++; continue; }
+    c.user_id = admin.id;
+    stamped++;
+  }
+  console.log(`stamped ${stamped} (already-owned: ${already}, total: ${calls.length})`);
+
+  // 4. Write back (with backup).
+  if (DRY_RUN) {
+    console.log('[dry-run] not writing');
+    return;
+  }
+  if (stamped > 0) {
+    const backup = CALLS_FILE + '.pre-user-migration.' + new Date().toISOString().replace(/[:.]/g,'-');
+    fs.copyFileSync(CALLS_FILE, backup);
+    fs.writeFileSync(CALLS_FILE, JSON.stringify(calls, null, 2));
+    console.log(`wrote ${calls.length} rows; backup at ${backup}`);
+  } else {
+    console.log('no changes needed.');
+  }
+}
+
+main();
diff --git a/server.js b/server.js
index 7dd5746..4af5051 100644
--- a/server.js
+++ b/server.js
@@ -13,6 +13,11 @@ const agentLogRoutes = require('./routes/agent-log');
 const twilioWebhooks = require('./routes/twilio-webhooks');
 const { attachListenBridge } = require('./lib/listen-bridge');
 const { requireOwner, softAuth, mountLogin } = require('./lib/owner-auth');
+const users = require('./lib/users');
+
+// Seed the legacy admin (Steve) so existing BUTLR_OWNER_TOKEN cookies
+// keep working through the migration. Idempotent — safe to run on every boot.
+try { users.seedLegacyAdmin(); } catch (e) { console.error('[users] seedLegacyAdmin failed:', e.message); }
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9932', 10);
@@ -82,10 +87,11 @@ const ownerRouter = express.Router();
 mountLogin(ownerRouter);
 app.use('/', ownerRouter);
 
-// Hard-gate call-data surfaces — applied BEFORE the route files mount their handlers.
-app.use(['/calls', '/calls/:id', '/listen/:call_id', '/api/calls/:call_id/status',
-         '/api/calls/:call_id/chat', '/api/calls/:call_id/agent-log',
-         '/api/calls/:call_id/transcript', '/api/calls/:call_id/recording'], requireOwner);
+// Hard-gate every path under /calls, /listen, and /api/calls. Wildcard so
+// no future route under those prefixes can accidentally serve unauthenticated.
+app.use(['/calls', '/listen', '/api/calls'], requireOwner);
+// Also require sign-in to start a NEW call (/new wizard + /new/submit).
+app.use(['/new'], requireOwner);
 // Soft-auth everywhere else so res.locals.ownerAuthed is available in views.
 app.use(softAuth);
 

← 79e9940 deploy: fix .deploy.conf path to /root/public-projects/butlr  ·  back to Butlr  ·  owner-auth: detect /api/* via originalUrl so middleware moun 927b54f →