← back to Ventura Claw Leads
lib/auth.js
172 lines
// Business-side auth — bcrypt + session-based + claim tokens.
// Mirrors the NPH installer-side pattern but scoped to business_users.
const crypto = require('node:crypto');
const bcrypt = require('bcrypt');
const db = require('./db');
const BCRYPT_ROUNDS = 12;
const CLAIM_TTL_HOURS = 24;
async function hashPassword(plain) {
return bcrypt.hash(plain, BCRYPT_ROUNDS);
}
async function verifyPassword(plain, hash) {
if (!hash) return false;
return bcrypt.compare(plain, hash);
}
function normalizeEmail(s) {
return String(s || '').trim().toLowerCase();
}
function isEmailShape(s) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
}
async function findUserByEmail(email) {
return db.one(
`SELECT id, email, password_hash, business_id, display_name, email_verified, last_login_at FROM business_users WHERE email = $1`,
[normalizeEmail(email)]
);
}
async function createUser({ email, businessId, passwordHash, displayName, emailVerified }) {
const r = await db.one(
`INSERT INTO business_users (email, password_hash, business_id, display_name, email_verified)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (email) DO NOTHING
RETURNING id, email, business_id, display_name, email_verified`,
[normalizeEmail(email), passwordHash || null, businessId, displayName || null, !!emailVerified]
);
if (r) return r;
// Conflict — pull the existing record (someone tried to re-claim).
return findUserByEmail(email);
}
async function setPassword(userId, plain) {
const hash = await hashPassword(plain);
await db.query(`UPDATE business_users SET password_hash = $2 WHERE id = $1`, [userId, hash]);
}
async function recordLogin(userId) {
await db.query(`UPDATE business_users SET last_login_at = now() WHERE id = $1`, [userId]);
}
// Session-based business auth. session.businessUserId is set on login.
async function attachBusiness(req, res, next) {
res.locals.currentBusinessUser = null;
res.locals.currentBusiness = null;
if (!req.session || !req.session.businessUserId) return next();
try {
const user = await db.one(
`SELECT bu.id, bu.email, bu.business_id, bu.display_name,
b.slug, b.business_name, b.tier, b.subscription_status, b.stripe_customer_id, b.stripe_subscription_id
FROM business_users bu
JOIN businesses b ON b.id = bu.business_id
WHERE bu.id = $1`,
[req.session.businessUserId]
);
if (user) {
res.locals.currentBusinessUser = { id: user.id, email: user.email, display_name: user.display_name };
res.locals.currentBusiness = {
id: user.business_id, slug: user.slug, business_name: user.business_name,
tier: user.tier, subscription_status: user.subscription_status,
stripe_customer_id: user.stripe_customer_id, stripe_subscription_id: user.stripe_subscription_id
};
} else {
// Stale session — user was deleted; clear it.
req.session.businessUserId = null;
}
} catch (e) {
console.warn('[auth] attachBusiness error', e.message);
}
next();
}
function requireBusiness(req, res, next) {
if (!res.locals.currentBusinessUser) {
return res.redirect('/admin/login?next=' + encodeURIComponent(req.originalUrl));
}
next();
}
// ─── Claim tokens ──────────────────────────────────────────────────────
function claimSecret() {
return process.env.CLAIM_SIGNING_SECRET || process.env.SESSION_SECRET || 'dev-only-not-for-prod';
}
async function issueClaimToken({ businessId, email, ipHash }) {
const raw = `claim:${businessId}:${normalizeEmail(email)}:${Date.now()}:${crypto.randomBytes(8).toString('hex')}`;
const token = crypto.createHmac('sha256', claimSecret()).update(raw).digest('base64url').slice(0, 32);
const expiresAt = new Date(Date.now() + CLAIM_TTL_HOURS * 3600 * 1000).toISOString();
await db.query(
`INSERT INTO claim_tokens (token, business_id, email, expires_at, ip_hash)
VALUES ($1, $2, $3, $4, $5)`,
[token, businessId, normalizeEmail(email), expiresAt, ipHash || null]
);
return { token, expiresAt };
}
async function consumeClaimToken(token) {
const row = await db.one(
`SELECT ct.id, ct.business_id, ct.email, ct.consumed_at, ct.expires_at,
b.slug, b.business_name, b.claim_status
FROM claim_tokens ct
JOIN businesses b ON b.id = ct.business_id
WHERE ct.token = $1`,
[token]
);
if (!row) return { ok: false, reason: 'invalid' };
if (row.consumed_at) return { ok: false, reason: 'already_used' };
if (new Date(row.expires_at) < new Date()) return { ok: false, reason: 'expired' };
return { ok: true, claim: row };
}
async function markClaimConsumed(token) {
await db.query(`UPDATE claim_tokens SET consumed_at = now() WHERE token = $1`, [token]);
}
// ─── Team invitations (multi-user per business) ────────────────────────
const INVITE_TTL_HOURS = 7 * 24; // 1 week — invites can sit longer than claims
async function issueInviteToken({ businessId, email, invitedByUserId, ipHash }) {
const raw = `invite:${businessId}:${normalizeEmail(email)}:${Date.now()}:${crypto.randomBytes(8).toString('hex')}`;
const token = crypto.createHmac('sha256', claimSecret()).update(raw).digest('base64url').slice(0, 32);
const expiresAt = new Date(Date.now() + INVITE_TTL_HOURS * 3600 * 1000).toISOString();
await db.query(
`INSERT INTO business_invitations (token, business_id, email, invited_by_user_id, expires_at, ip_hash)
VALUES ($1, $2, $3, $4, $5, $6)`,
[token, businessId, normalizeEmail(email), invitedByUserId, expiresAt, ipHash || null]
);
return { token, expiresAt };
}
async function consumeInviteToken(token) {
const row = await db.one(
`SELECT bi.id, bi.business_id, bi.email, bi.invited_by_user_id, bi.consumed_at, bi.expires_at,
b.slug, b.business_name,
inviter.email AS inviter_email, inviter.display_name AS inviter_name
FROM business_invitations bi
JOIN businesses b ON b.id = bi.business_id
LEFT JOIN business_users inviter ON inviter.id = bi.invited_by_user_id
WHERE bi.token = $1`,
[token]
);
if (!row) return { ok: false, reason: 'invalid' };
if (row.consumed_at) return { ok: false, reason: 'already_used' };
if (new Date(row.expires_at) < new Date()) return { ok: false, reason: 'expired' };
return { ok: true, invite: row };
}
async function markInviteConsumed(token) {
await db.query(`UPDATE business_invitations SET consumed_at = now() WHERE token = $1`, [token]);
}
module.exports = {
hashPassword, verifyPassword, normalizeEmail, isEmailShape,
findUserByEmail, createUser, setPassword, recordLogin,
attachBusiness, requireBusiness,
issueClaimToken, consumeClaimToken, markClaimConsumed,
issueInviteToken, consumeInviteToken, markInviteConsumed
};