← 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
M lib/data.jsM lib/owner-auth.jsM lib/twilio.jsA lib/users.jsM package-lock.jsonM package.jsonM routes/listen.jsM routes/public.jsM routes/twilio-webhooks.jsA scripts/migrate-calls-to-user.jsM server.js
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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, '"')}">
- <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 => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[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 →