[object Object]

← back to Butlr

Add password-reset token store, pluggable delivery layer, and forgot/reset routes

051c3227b3927f1279043e25c8a6bce81ed4010b · 2026-05-19 12:22:53 -0700 · SteveStudio2

Files touched

Diff

commit 051c3227b3927f1279043e25c8a6bce81ed4010b
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 19 12:22:53 2026 -0700

    Add password-reset token store, pluggable delivery layer, and forgot/reset routes
---
 .deploy.conf          |   2 +-
 .gitignore            |   3 +
 lib/owner-auth.js     | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/password-reset.js | 124 +++++++++++++++++++++++++++++++
 lib/reset-delivery.js | 111 ++++++++++++++++++++++++++++
 lib/users.js          |  24 ++++++
 6 files changed, 463 insertions(+), 1 deletion(-)

diff --git a/.deploy.conf b/.deploy.conf
index 2a81e43..0c628f3 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -3,7 +3,7 @@ DEPLOY_HOST=45.61.58.125
 DEPLOY_PATH=/root/public-projects/butlr
 HEALTH_URL=https://butlr.agentabrams.com/healthz
 REQUIRED_ENVS=""
-RSYNC_EXTRA_EXCLUDES="data/users.json data/users.json.* data/calls.json data/calls.json.* data/recordings data/transcripts data/audio-cache data/menu-map data/dnc-blocks.jsonl data/dnc-suppression.json data/uploads data/uploads-meta.json"
+RSYNC_EXTRA_EXCLUDES="data/users.json data/users.json.* data/password-resets.json data/password-resets.json.* data/calls.json data/calls.json.* data/recordings data/transcripts data/audio-cache data/menu-map data/dnc-blocks.jsonl data/dnc-suppression.json data/uploads data/uploads-meta.json"
 
 # Block deploys while calls are in flight — pm2 reload kills the WSS Media
 # Stream lane and silently drops audio capture. Override with FORCE_DEPLOY=1.
diff --git a/.gitignore b/.gitignore
index 1110982..d18e73b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,9 @@ data/menu-map/
 data/dnc-blocks.jsonl
 data/dnc-suppression.json
 data/users.json
+data/users.json.*
+data/password-resets.json
+data/password-resets.json.*
 data/uploads/
 data/uploads-meta.json
 
diff --git a/lib/owner-auth.js b/lib/owner-auth.js
index 7cd885f..a5b40c0 100644
--- a/lib/owner-auth.js
+++ b/lib/owner-auth.js
@@ -9,11 +9,30 @@
 // seedLegacyAdmin() promotes that token to an admin user on boot, so
 // Steve's existing browser cookie keeps working through the migration.
 
+const rateLimit = require('express-rate-limit');
 const users = require('./users');
+const passwordReset = require('./password-reset');
+const resetDelivery = require('./reset-delivery');
 
 const COOKIE_NAME = 'butlr_owner';
 const ONE_YEAR = 60 * 60 * 24 * 365;
 
+// Password policy for new passwords (signup already enforces 8+; the reset
+// flow re-uses this single source of truth). Kept deliberately modest —
+// length is the dominant factor; we reject the most obvious weak strings.
+const MIN_PASSWORD_LEN = 8;
+function validatePassword(pw) {
+  if (!pw || typeof pw !== 'string') return 'password_too_short';
+  if (pw.length < MIN_PASSWORD_LEN) return 'password_too_short';
+  if (pw.length > 200) return 'password_too_long';
+  // Reject all-same-char and a few notorious weak passwords.
+  if (/^(.)\1+$/.test(pw)) return 'password_too_weak';
+  if (['password', 'password1', '12345678', 'butlr123', 'qwertyui'].includes(pw.toLowerCase())) {
+    return 'password_too_weak';
+  }
+  return null; // ok
+}
+
 function parseCookies(req) {
   const out = {};
   const h = req.headers.cookie;
@@ -102,6 +121,7 @@ input{font-size:16px;padding:11px 12px;width:100%;box-sizing:border-box;border:1
 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}
+.notice{background:#f0f7f0;border:1px solid #bcdcbc;color:#1d4d1d;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}
@@ -127,10 +147,51 @@ ${error ? `<div class="err">${escapeAttr(error)}</div>` : ''}
   <input id="password" type="password" name="password" autocomplete="current-password" required>
   <button type="submit">Sign in</button>
 </form>
+<p class="alt"><a href="/forgot-password">Forgot your password?</a></p>
 <p class="alt">No account yet? <a href="/signup${ret ? `?return=${encodeURIComponent(ret)}` : ''}">Create one</a>.</p>`;
   return pageShell('Sign in', body);
 }
 
+// ── /forgot-password + /reset-password form HTML ──────────────────────
+
+function forgotPasswordPage({ error, notice, email }) {
+  // `notice` is the anti-enumeration confirmation — shown whether or not the
+  // account exists, so the page intentionally reveals nothing either way.
+  const body = `<h1>Reset your password</h1>
+<p class="lead">Enter the email on your Butlr account. If it matches an account, we'll send a link to set a new password.</p>
+${error ? `<div class="err">${escapeAttr(error)}</div>` : ''}
+${notice ? `<div class="notice">${escapeAttr(notice)}</div>` : ''}
+${notice ? '' : `<form method="POST" action="/forgot-password">
+  <label for="email">Email</label>
+  <input id="email" type="email" name="email" autocomplete="email" required value="${escapeAttr(email || '')}">
+  <button type="submit">Send reset link</button>
+</form>`}
+<p class="alt"><a href="/login">Back to sign in</a></p>`;
+  return pageShell('Reset password', body);
+}
+
+function resetPasswordPage({ token, error, invalid }) {
+  if (invalid) {
+    const body = `<h1>Link expired</h1>
+<p class="lead">This password-reset link is invalid, has expired, or has already been used. Reset links last one hour and work once.</p>
+<p class="alt"><a href="/forgot-password">Request a new reset link</a></p>`;
+    return pageShell('Link expired', body);
+  }
+  const body = `<h1>Choose a new password</h1>
+<p class="lead">Pick a password 8+ characters. After saving, you'll be signed in on this device and signed out everywhere else.</p>
+${error ? `<div class="err">${escapeAttr(error)}</div>` : ''}
+<form method="POST" action="/reset-password">
+  <input type="hidden" name="token" value="${escapeAttr(token || '')}">
+  <label for="password">New password (8+ characters)</label>
+  <input id="password" type="password" name="password" autocomplete="new-password" minlength="8" required>
+  <label for="password2">Confirm new password</label>
+  <input id="password2" type="password" name="password2" autocomplete="new-password" minlength="8" required>
+  <button type="submit">Save new password</button>
+</form>
+<p class="alt"><a href="/login">Back to sign in</a></p>`;
+  return pageShell('Set new password', 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>
@@ -158,6 +219,9 @@ function errMessage(code) {
     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.';
+    case 'password_too_long': return 'Password is too long (200 character max).';
+    case 'password_too_weak': return 'That password is too easy to guess. Pick something less common.';
+    case 'rate_limited': return 'Too many attempts. Please wait a minute and try again.';
     default: return 'Something went wrong. Please try again.';
   }
 }
@@ -237,6 +301,142 @@ function mountLogin(router) {
     res.clearCookie(COOKIE_NAME, { path: '/' });
     res.redirect(302, '/');
   });
+
+  // ── Password reset ──────────────────────────────────────────────────
+  //
+  // Channel: see lib/reset-delivery.js. Default is 'email' with a 'log'
+  // transport until Steve wires a real email sender.
+  //
+  // Rate-limit the request endpoint hard — it is the abusable surface
+  // (enumeration probing + delivery-cost amplification). 5 POSTs / 15 min
+  // per IP. The global 200/min/IP limiter from server.js still applies on
+  // top; this is the targeted layer.
+  const forgotLimiter = rateLimit({
+    windowMs: 15 * 60 * 1000,
+    max: 5,
+    standardHeaders: 'draft-7',
+    legacyHeaders: false,
+    // Count only the POST (the actual send). GETs to render the form are fine.
+    skip: (req) => req.method !== 'POST',
+    message: { ok: false, error: 'rate_limited' },
+    handler: (req, res) => {
+      res.status(429).type('text/html').send(
+        forgotPasswordPage({ error: errMessage('rate_limited') })
+      );
+    },
+  });
+
+  // Reset-password POST also gets a limiter — caps brute-forcing of tokens.
+  const resetLimiter = rateLimit({
+    windowMs: 15 * 60 * 1000,
+    max: 20,
+    standardHeaders: 'draft-7',
+    legacyHeaders: false,
+    skip: (req) => req.method !== 'POST',
+  });
+
+  // GET /forgot-password → form
+  router.get('/forgot-password', (req, res) => {
+    res.type('text/html').send(forgotPasswordPage({}));
+  });
+
+  // POST /forgot-password → issue token + deliver. ANTI-ENUMERATION:
+  // responds with the identical confirmation page whether or not the
+  // account exists, and does the same amount of async work either way.
+  router.post('/forgot-password', forgotLimiter, async (req, res) => {
+    const email = (req.body && req.body.email) || '';
+    const NOTICE = 'If an account exists for that email, a password-reset link is on its way. Check your inbox — the link expires in one hour.';
+
+    // Always render the same page; never reveal account existence.
+    const respond = () => res.type('text/html').send(forgotPasswordPage({ notice: NOTICE }));
+
+    try {
+      const user = users.findByEmail(email);
+      if (!user) {
+        // No account — do a tiny delay so timing roughly matches the
+        // create-token + deliver path, then respond identically.
+        await new Promise(r => setTimeout(r, 120));
+        return respond();
+      }
+      // Real account — mint a single-use token, build the link, deliver it.
+      const { token } = passwordReset.createToken(user.id);
+      const base = (res.locals && res.locals.PUBLIC_URL) || process.env.PUBLIC_URL || '';
+      const resetUrl = `${base.replace(/\/$/, '')}/reset-password?token=${token}`;
+      const delivery = await resetDelivery.deliverResetLink({
+        user,
+        resetUrl,
+        expiresMinutes: Math.round(passwordReset.TTL_MS / 60000),
+      });
+      if (!delivery.ok) {
+        // Delivery failed (e.g. SMS channel but no phone on account, or
+        // email transport unconfigured). Log it for Steve — but STILL show
+        // the user the identical notice so nothing leaks.
+        console.error('[forgot-password] delivery failed for user',
+          user.id, 'channel=', delivery.channel, 'reason=', delivery.reason);
+      }
+      return respond();
+    } catch (e) {
+      console.error('[forgot-password] error:', e && e.stack || e);
+      // Even on error, do not leak — show the generic notice.
+      return respond();
+    }
+  });
+
+  // GET /reset-password?token=… → new-password form (token carried through)
+  router.get('/reset-password', (req, res) => {
+    const token = (req.query && req.query.token) || '';
+    const check = passwordReset.findLiveByToken(token);
+    if (!check.ok) {
+      return res.status(400).type('text/html').send(resetPasswordPage({ invalid: true }));
+    }
+    res.type('text/html').send(resetPasswordPage({ token }));
+  });
+
+  // POST /reset-password → verify token, set new password, sign in.
+  router.post('/reset-password', resetLimiter, async (req, res) => {
+    const token = (req.body && req.body.token) || '';
+    const password = (req.body && req.body.password) || '';
+    const password2 = (req.body && req.body.password2) || '';
+
+    // Verify the token is live BEFORE doing anything else.
+    const check = passwordReset.findLiveByToken(token);
+    if (!check.ok) {
+      return res.status(400).type('text/html').send(resetPasswordPage({ invalid: true }));
+    }
+
+    if (!password || !password2) {
+      return res.status(400).type('text/html').send(
+        resetPasswordPage({ token, error: errMessage('missing_fields') }));
+    }
+    if (password !== password2) {
+      return res.status(400).type('text/html').send(
+        resetPasswordPage({ token, error: errMessage('password_mismatch') }));
+    }
+    const pwErr = validatePassword(password);
+    if (pwErr) {
+      return res.status(400).type('text/html').send(
+        resetPasswordPage({ token, error: errMessage(pwErr) }));
+    }
+
+    // Consume the token (single-use, race-guarded) THEN write the password.
+    const consumed = passwordReset.consumeToken(token);
+    if (!consumed.ok) {
+      // Lost a race, or expired between the two reads — treat as invalid.
+      return res.status(400).type('text/html').send(resetPasswordPage({ invalid: true }));
+    }
+
+    const result = await users.setPassword(consumed.row.user_id, password);
+    if (!result.ok) {
+      console.error('[reset-password] setPassword failed:', result.error);
+      return res.status(400).type('text/html').send(
+        resetPasswordPage({ token, error: errMessage(result.error) }));
+    }
+
+    // setPassword rotated the owner_token — every other session for this
+    // user is now dead. Sign the user in here with the fresh token.
+    setSessionCookie(res, result.owner_token);
+    res.redirect(302, '/');
+  });
 }
 
 module.exports = { requireOwner, softAuth, mountLogin, setSessionCookie, COOKIE_NAME };
diff --git a/lib/password-reset.js b/lib/password-reset.js
new file mode 100644
index 0000000..34856e8
--- /dev/null
+++ b/lib/password-reset.js
@@ -0,0 +1,124 @@
+// Password-reset token store for Butlr.
+//
+// Mirrors the lib/users.js JSON-store pattern: atomic writes (tmp + rename),
+// 0600 perms, no external DB. One file: data/password-resets.json — array of:
+//   { id, user_id, token_sha256, created_at, expires_at, used_at }
+//
+// SECURITY MODEL
+//   - The raw reset token is generated with crypto.randomBytes(32) and is
+//     ONLY ever returned once, in-memory, to the route that delivers it.
+//     It is NEVER written to disk or logged. We persist sha256(token) only.
+//   - Lookup hashes the presented token and compares with timingSafeEqual.
+//   - Single-use: once consumed, used_at is stamped and the token can never
+//     verify again.
+//   - Expiry: TTL_MS (1 hour) from creation.
+//   - Issuing a new token for a user invalidates that user's prior unused
+//     tokens (defence against multiple live links from repeated requests).
+//
+// Pruning: expired / used rows older than 24h are dropped on every write so
+// the file does not grow unbounded.
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const FILE = path.join(__dirname, '..', 'data', 'password-resets.json');
+const TOKEN_BYTES = 32;            // 64 hex chars
+const TTL_MS = 60 * 60 * 1000;     // 1 hour
+const PRUNE_AGE_MS = 24 * 60 * 60 * 1000; // drop dead rows older than 24h
+
+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(String(s), 'utf8').digest('hex');
+}
+
+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; }
+}
+
+// Drop rows that are used or expired AND older than PRUNE_AGE_MS.
+function prune(rows, now) {
+  return rows.filter(r => {
+    const dead = r.used_at || (Date.parse(r.expires_at) || 0) < now;
+    if (!dead) return true;
+    const age = now - (Date.parse(r.created_at) || 0);
+    return age < PRUNE_AGE_MS;
+  });
+}
+
+// ── public API ────────────────────────────────────────────────────────
+
+// Create a single-use reset token for a user. Invalidates that user's
+// prior unused tokens. Returns the RAW token (caller delivers it, never
+// persists it). The store only ever sees sha256(token).
+function createToken(userId) {
+  const now = Date.now();
+  let rows = prune(readAll(), now);
+
+  // Invalidate any still-live tokens for this user.
+  for (const r of rows) {
+    if (r.user_id === userId && !r.used_at) {
+      r.used_at = new Date(now).toISOString();
+      r.invalidated_reason = 'superseded';
+    }
+  }
+
+  const rawToken = crypto.randomBytes(TOKEN_BYTES).toString('hex');
+  const row = {
+    id: crypto.randomBytes(6).toString('base64url'),
+    user_id: userId,
+    token_sha256: sha256(rawToken),
+    created_at: new Date(now).toISOString(),
+    expires_at: new Date(now + TTL_MS).toISOString(),
+    used_at: null,
+  };
+  rows.push(row);
+  writeAllAtomic(rows);
+  return { token: rawToken, expires_at: row.expires_at };
+}
+
+// Look up a live (unused, unexpired) token row by raw token.
+// Returns { ok, row } or { ok:false, reason }.
+function findLiveByToken(rawToken) {
+  if (!rawToken || typeof rawToken !== 'string') return { ok: false, reason: 'missing' };
+  const tokHash = sha256(rawToken);
+  const now = Date.now();
+  const rows = readAll();
+  const row = rows.find(r => constantTimeEqualHex(r.token_sha256 || '', tokHash));
+  if (!row) return { ok: false, reason: 'not_found' };
+  if (row.used_at) return { ok: false, reason: 'used' };
+  if ((Date.parse(row.expires_at) || 0) < now) return { ok: false, reason: 'expired' };
+  return { ok: true, row };
+}
+
+// Mark a token row consumed (single-use). Idempotent-safe: returns false if
+// it was already used between verify and consume (race guard).
+function consumeToken(rawToken) {
+  const tokHash = sha256(rawToken);
+  const now = Date.now();
+  let rows = prune(readAll(), now);
+  const row = rows.find(r => constantTimeEqualHex(r.token_sha256 || '', tokHash));
+  if (!row) return { ok: false, reason: 'not_found' };
+  if (row.used_at) return { ok: false, reason: 'used' };
+  if ((Date.parse(row.expires_at) || 0) < now) return { ok: false, reason: 'expired' };
+  row.used_at = new Date(now).toISOString();
+  writeAllAtomic(rows);
+  return { ok: true, row };
+}
+
+module.exports = { createToken, findLiveByToken, consumeToken, TTL_MS };
diff --git a/lib/reset-delivery.js b/lib/reset-delivery.js
new file mode 100644
index 0000000..dd7fa9b
--- /dev/null
+++ b/lib/reset-delivery.js
@@ -0,0 +1,111 @@
+// Password-reset delivery layer for Butlr.
+//
+// ── WHY THIS IS PLUGGABLE ──────────────────────────────────────────────
+// As of this build Butlr accounts (lib/users.js) are keyed by EMAIL ONLY —
+// there is no phone number on the user record, and signup only collects an
+// email. Butlr also has NO email-sending infrastructure wired (no
+// nodemailer, no SMTP, no transactional-email API key in .env). It DOES
+// have a working, DNC-gated Twilio SMS sender (lib/twilio.sendSms).
+//
+// So neither channel is turnkey:
+//   - EMAIL  — the natural channel (accounts are email-keyed) but no sender.
+//   - SMS    — a working sender exists, but no phone is stored on accounts.
+//
+// This module therefore exposes ONE function, deliverResetLink(), and picks
+// the channel from RESET_DELIVERY_CHANNEL env:
+//
+//   RESET_DELIVERY_CHANNEL=email  (default)
+//       Sends the reset LINK by email. Until an email transport is wired,
+//       the 'log' transport just prints the link to the server log so the
+//       flow is fully testable and Steve can hand-deliver during dev.
+//       To go live, set RESET_EMAIL_TRANSPORT=smtp|api and fill the
+//       matching env (see sendEmail() below) — or drop in nodemailer.
+//
+//   RESET_DELIVERY_CHANNEL=sms
+//       Sends the reset LINK by SMS via the existing twilio.sendSms().
+//       Requires the user record to have a `phone` field — accounts created
+//       before a phone-collection change will NOT have one, and delivery
+//       returns { ok:false, reason:'no_phone' }. The CALLER must still
+//       respond identically to the user (anti-enumeration) — see the route.
+//
+// SECURITY: the raw reset token is contained in the link/URL only. It is
+// never logged here in a way that ties it to an account beyond what the
+// chosen transport inherently does (the 'log' transport prints it for dev —
+// acceptable because dev logs are not user-facing; do NOT use 'log' in prod
+// with RESET_DELIVERY_CHANNEL=email expecting privacy).
+
+const CHANNEL = (process.env.RESET_DELIVERY_CHANNEL || 'email').toLowerCase();
+
+function log(...a) { console.log('[reset-delivery]', new Date().toISOString(), ...a); }
+
+// ── EMAIL ──────────────────────────────────────────────────────────────
+// Transport is selected by RESET_EMAIL_TRANSPORT:
+//   'log' (default)  — prints the link to the server log (dev / not-yet-wired)
+//   'smtp' / 'api'   — placeholder; throws so a misconfig fails loud, not
+//                      silently. Wire nodemailer or a provider SDK here.
+async function sendEmail({ to, subject, text }) {
+  const transport = (process.env.RESET_EMAIL_TRANSPORT || 'log').toLowerCase();
+  if (transport === 'log') {
+    log(`EMAIL[log-transport] to=${to} subject="${subject}"`);
+    log(`  ${text.split('\n').join('\n  ')}`);
+    return { ok: true, transport: 'log' };
+  }
+  // A real transport is requested but not implemented in this build.
+  // Fail loud rather than silently dropping a password-reset email.
+  throw new Error(
+    `RESET_EMAIL_TRANSPORT=${transport} requested but no email transport is ` +
+    `wired in lib/reset-delivery.js. Add nodemailer/provider SDK here, or ` +
+    `set RESET_EMAIL_TRANSPORT=log for dev.`
+  );
+}
+
+// ── SMS ────────────────────────────────────────────────────────────────
+async function sendSms({ to, body }) {
+  const twilio = require('./twilio');
+  return twilio.sendSms({ to, body });
+}
+
+// ── public API ─────────────────────────────────────────────────────────
+//
+// deliverResetLink({ user, resetUrl, expiresMinutes })
+//   user           — full user record from lib/users (has .email, maybe .phone)
+//   resetUrl        — absolute https URL carrying ?token=… (built by the route)
+//   expiresMinutes  — integer, for the message copy
+//
+// Returns { ok, channel, reason? }. A false ok with reason 'no_phone' means
+// the SMS channel is selected but the account has no phone — the route MUST
+// still respond to the user identically (no enumeration leak).
+async function deliverResetLink({ user, resetUrl, expiresMinutes }) {
+  const mins = expiresMinutes || 60;
+
+  if (CHANNEL === 'sms') {
+    if (!user.phone) {
+      log(`SMS channel selected but user ${user.id} has no phone on record`);
+      return { ok: false, channel: 'sms', reason: 'no_phone' };
+    }
+    const body =
+      `Butlr password reset: open this link within ${mins} min to set a new ` +
+      `password. ${resetUrl}  If you didn't request this, ignore this message.`;
+    const r = await sendSms({ to: user.phone, body });
+    return { ok: !!r.ok, channel: 'sms', reason: r.ok ? undefined : (r.error || 'sms_failed') };
+  }
+
+  // default — email
+  const subject = 'Reset your Butlr password';
+  const text =
+    `Someone (hopefully you) asked to reset the password for your Butlr ` +
+    `account.\n\n` +
+    `Open this link within ${mins} minutes to choose a new password:\n` +
+    `${resetUrl}\n\n` +
+    `This link can be used once. If you didn't request a reset, you can ` +
+    `safely ignore this email — your password will not change.`;
+  try {
+    const r = await sendEmail({ to: user.email, subject, text });
+    return { ok: !!r.ok, channel: 'email', reason: r.ok ? undefined : 'email_failed' };
+  } catch (e) {
+    log('email delivery threw:', e.message);
+    return { ok: false, channel: 'email', reason: 'email_transport_unconfigured' };
+  }
+}
+
+module.exports = { deliverResetLink, CHANNEL };
diff --git a/lib/users.js b/lib/users.js
index 4bad7e2..f46e55c 100644
--- a/lib/users.js
+++ b/lib/users.js
@@ -136,6 +136,29 @@ async function rotateOwnerToken(userId) {
   return tok;
 }
 
+// Set a new password for a user (used by the password-reset flow).
+//
+// SECURITY — this also rotates the owner_token, which is the session cookie
+// value. Rotating it invalidates every existing session for that user
+// (any device still holding the old butlr_owner cookie is logged out), so a
+// password reset boots a potential attacker. Returns the FRESH owner_token
+// so the reset route can immediately sign the user back in on this device.
+async function setPassword(userId, newPassword) {
+  if (!newPassword || newPassword.length < 8) return { ok: false, error: 'password_too_short' };
+  const all = readAll();
+  const idx = all.findIndex(u => u.id === userId);
+  if (idx < 0) return { ok: false, error: 'user_not_found' };
+  all[idx].password_hash = await bcrypt.hash(newPassword, BCRYPT_COST);
+  // Rotate the session token — invalidates all prior sessions for this user.
+  const tok = newToken();
+  all[idx].owner_token = tok;
+  all[idx].owner_token_sha256 = sha256(tok);
+  all[idx].password_changed_at = new Date().toISOString();
+  delete all[idx].legacy_seeded; // password is now real, not a placeholder
+  writeAllAtomic(all);
+  return { ok: true, user: publicView(all[idx]), owner_token: tok };
+}
+
 function constantTimeEqualHex(a, b) {
   if (typeof a !== 'string' || typeof b !== 'string') return false;
   if (a.length !== b.length) return false;
@@ -202,6 +225,7 @@ module.exports = {
   findById,
   findByEmail,
   rotateOwnerToken,
+  setPassword,
   seedLegacyAdmin,
   publicView,
 };

← 06f6cc8 callback_phone: use BUTLR_OWNER_CALLBACK_PHONE env + per-cal  ·  back to Butlr  ·  Add password-reset test suite and wire into npm test 2606c4d →