← back to NationalPaperHangers
lib/services/users.js
171 lines
// lib/services/users.js
//
// Pure user/identity service. Reads/writes the new `users`, `roles`, and
// `installer_members` tables introduced in db/migrations/005_users_roles.sql.
//
// Phase 1: NOT yet imported from any runtime route. Available for Phase 2
// cutover (rewriting /login, /signup, attachInstaller → attachUser).
//
// No Express dependency. No req/res. Pure functions over lib/db. Errors
// bubble up to the caller. bcrypt is the only outside-world dep.
'use strict';
const bcrypt = require('bcrypt');
const db = require('../db');
const SALT_ROUNDS = 12;
// Constant-time bcrypt verify against this dummy hash on miss, so login
// timing doesn't leak whether an email exists. Same pattern as routes/auth.js.
const DUMMY_HASH = '$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidinv';
const VALID_GLOBAL_ROLES = new Set([
'admin',
'ops',
'installer_owner',
'installer_member'
]);
// ─────────────────────────────────────────────────────────────────────────
// Lookup
// ─────────────────────────────────────────────────────────────────────────
async function findByEmail(email) {
if (!email) return null;
const e = String(email).toLowerCase().trim();
return db.one(
`SELECT id, email, password_hash, name, created_at, last_login_at
FROM users
WHERE email = $1`,
[e]
);
}
async function findById(id) {
if (!id) return null;
return db.one(
`SELECT id, email, name, created_at, last_login_at
FROM users
WHERE id = $1`,
[id]
);
}
// ─────────────────────────────────────────────────────────────────────────
// Create
// ─────────────────────────────────────────────────────────────────────────
async function createUser({ email, password, name }) {
if (!email || !password) {
throw new Error('email_and_password_required');
}
if (password.length < 8) {
throw new Error('password_too_short');
}
const e = String(email).toLowerCase().trim();
const hash = await bcrypt.hash(password, SALT_ROUNDS);
const row = await db.one(
`INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, created_at`,
[e, hash, name || null]
);
return row;
}
// ─────────────────────────────────────────────────────────────────────────
// Authenticate — constant-time even on email miss
// ─────────────────────────────────────────────────────────────────────────
async function authenticate(email, plainPw) {
const user = await findByEmail(email);
const hash = (user && user.password_hash) ? user.password_hash : DUMMY_HASH;
const ok = await bcrypt.compare(plainPw || '', hash);
if (!user || !ok) return null;
// Touch last_login_at. Best-effort; auth still succeeds if this fails.
try {
await db.query('UPDATE users SET last_login_at = now() WHERE id = $1', [user.id]);
} catch (e) {
// Swallow — login should still succeed.
}
// Strip password_hash before returning.
const { password_hash, ...safe } = user;
return safe;
}
// ─────────────────────────────────────────────────────────────────────────
// Roles (global capabilities)
// ─────────────────────────────────────────────────────────────────────────
async function grantRole(userId, role, grantedBy = null) {
if (!VALID_GLOBAL_ROLES.has(role)) {
throw new Error(`invalid_role:${role}`);
}
await db.query(
`INSERT INTO roles (user_id, role, granted_by)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, role) DO NOTHING`,
[userId, role, grantedBy]
);
}
async function revokeRole(userId, role) {
if (!VALID_GLOBAL_ROLES.has(role)) {
throw new Error(`invalid_role:${role}`);
}
await db.query(
`DELETE FROM roles WHERE user_id = $1 AND role = $2`,
[userId, role]
);
}
async function userRoles(userId) {
if (!userId) return [];
const rows = await db.many(
`SELECT role FROM roles WHERE user_id = $1 ORDER BY role`,
[userId]
);
return rows.map(r => r.role);
}
async function hasRole(userId, role) {
const r = await db.one(
`SELECT 1 FROM roles WHERE user_id = $1 AND role = $2 LIMIT 1`,
[userId, role]
);
return !!r;
}
// ─────────────────────────────────────────────────────────────────────────
// Installer membership lookup
// ─────────────────────────────────────────────────────────────────────────
async function userInstallers(userId) {
if (!userId) return [];
return db.many(
`SELECT i.id, i.slug, i.business_name, i.tier, i.subscription_status,
i.status, im.role AS membership_role
FROM installer_members im
JOIN installers i ON i.id = im.installer_id
WHERE im.user_id = $1
ORDER BY i.business_name`,
[userId]
);
}
module.exports = {
findByEmail,
findById,
createUser,
authenticate,
grantRole,
revokeRole,
userRoles,
hasRole,
userInstallers,
VALID_GLOBAL_ROLES
};