Files
dashcaddy/dashcaddy-api/routes/auth/admin.js
Hermes 6732a1e1df [glm-grade=B] fix(auth): DC-089 mask invite/user emails in server logs
Two log sites in routes/auth/admin.js wrote raw email PII to the server
log: the SMTP-unconfigured 'auth-invite-send' warn and the 'invite
accepted, user created' info. Both now route through
AuthProvider.maskEmail() with a '[unmaskable-email]' sentinel fallback
(never the raw address). Two regression tests assert the raw address is
absent from log meta and the masked form present. Response contract
unchanged (full email still returned to the authenticated admin).

Judge: GLM-5.3 cold read (deleg_f0896de3), grade B / ship / zero
blockers; polish notes folded in. Verdict URN:
urn:ump:jd2htpwq76ni6bapj3vxjpvypfdnoc4argqbzpcerutmh7u5khea
Full suite: 2610/2610 (109 suites).
2026-08-22 16:22:53 -07:00

390 lines
17 KiB
JavaScript

/**
* Admin + me routes — DC-048.
*
* Mounted at /api/v1/auth. All `/admin/*` routes require the session to
* belong to a user with role 'admin'. `/me` requires any authenticated session.
*
* Endpoints:
* GET /me — current user (id, email, role, isAdmin)
* GET /admin/users — list all users
* POST /admin/users — pre-authorize an email (allowlist)
* PATCH /admin/users/:id — change a user's role
* DELETE /admin/users/:id — delete user + remove from allowlist
* GET /admin/allowlist — list authorized emails
* GET /admin/invites — list outstanding invites
* POST /admin/invites — issue a new invite (returns raw token ONCE)
* DELETE /admin/invites/:id — revoke an invite
*
* POST /invites/accept — PUBLIC — redeem an invite token,
* create user, set session cookie
* GET /invites/:token — PUBLIC — peek at an invite (email,
* role, expires) without consuming it.
*/
'use strict';
const express = require('express');
const path = require('path');
const platformPaths = require('../../platform-paths');
const { createUserStore } = require('../../src/security/user-store');
const { createInviteStore } = require('../../src/security/invite-store');
const emailSender = require('../../src/auth/providers/email-sender');
const AuthProvider = require('../../src/auth/providers/base');
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
const { ok, successMessage } = require('../../src/utils/responses');
function _requireAdmin(req, _res, next) {
if (!req.user || req.user.role !== 'admin') {
return next(new ForbiddenError('Admin role required'));
}
next();
}
/**
* DC-052: license-tier gate for user-creation endpoints.
*
* Free = up to 3 users total. Pro = unlimited. When the count would
* exceed the cap and the host isn't Pro, throw a PaymentRequiredError
* so the caller knows exactly what to do. The error message names the
* tier name ("Pro") so the upsell is clear.
*
* NOTE: passes through when the userStore isn't mounted (single-user
* installs without email auth — those don't even have /admin/*).
*/
async function _requireProIfUserLimitReached(req, _res, next) {
try {
const licenseManager = req.app.locals && req.app.locals.licenseManager;
if (!licenseManager || typeof licenseManager.isPro !== 'function') return next();
if (licenseManager.isPro()) return next();
const userStore = req.app.locals && req.app.locals.userStore;
if (!userStore || typeof userStore.countUsers !== 'function') return next();
const count = await userStore.countUsers();
if (count >= 3) {
return next(new PaymentRequiredError(
'Free tier supports up to 3 users. Upgrade to Pro for unlimited users.'
));
}
next();
} catch (e) {
next(e);
}
}
function _buildEmailText({ acceptUrl, ttlHours, role }) {
return [
'Hi,',
'',
'You\'ve been invited to join a DashCaddy instance as a ' + role + '.',
'Click the link below within ' + ttlHours + ' hours to accept:',
'',
acceptUrl,
'',
'This link is single-use. If you weren\'t expecting this invitation,',
'you can safely ignore this email.',
'',
'— DashCaddy',
].join('\n');
}
function _buildEmailHtml({ acceptUrl, ttlHours, role }) {
return [
'<!doctype html><html><body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;">',
'<h2 style="margin:0 0 12px">You\'re invited to DashCaddy</h2>',
'<p>You\'ve been invited to join as <strong>' + role + '</strong>.</p>',
'<p>Click the button below within ' + ttlHours + ' hours to accept:</p>',
'<p style="margin:24px 0"><a href="' + acceptUrl + '" style="background:#1f2937;color:#fff;padding:10px 16px;border-radius:6px;text-decoration:none;display:inline-block">Accept invitation</a></p>',
'<p style="color:#6b7280;font-size:12px">If the button doesn\'t work, paste this link into your browser:<br><span style="word-break:break-all">' + acceptUrl + '</span></p>',
'<p style="color:#6b7280;font-size:12px">If you weren\'t expecting this, you can ignore this email.</p>',
'</body></html>',
].join('\n');
}
module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }) {
const router = express.Router();
// user-store / invite-store handle their own defensive dataDir resolution
// (they ignore Proxy/function values from universal-deps test deps).
const resolvedDataDir = dataDir || (platformPaths && platformPaths.dataDir);
const userStore = createUserStore({ dataDir: resolvedDataDir, log });
const inviteStore = createInviteStore({ dataDir: resolvedDataDir, log });
// ── /me ───────────────────────────────────────────────────────────────
router.get('/me', asyncHandler(async (req, res) => {
if (!req.user || !req.user.id) {
// Legacy session without user attribution. Return the bare role
// (defaults to admin for backwards-compat) but signal via
// `legacy: true` so the UI knows.
return ok(res, {
user: null,
authenticated: session ? session.isSessionValid(req) : false,
role: 'admin', // legacy: assume operator-level access
legacy: true,
});
}
const stored = await userStore.getUser(req.user.id);
return ok(res, {
user: stored
? {
id: stored.id,
email: stored.email,
displayName: stored.displayName,
role: stored.role,
isAdmin: stored.role === 'admin',
createdAt: stored.createdAt,
lastLoginAt: stored.lastLoginAt,
loginCount: stored.loginCount,
}
: null,
authenticated: true,
role: req.user.role,
legacy: false,
});
}, 'auth-me'));
// ── /admin/users ──────────────────────────────────────────────────────
router.get('/admin/users', _requireAdmin, asyncHandler(async (_req, res) => {
const users = await userStore.listUsers();
return ok(res, { users });
}, 'auth-admin-users-list'));
router.post('/admin/users', _requireAdmin, _requireProIfUserLimitReached, asyncHandler(async (req, res) => {
const { email, role } = req.body || {};
if (!email) throw new ValidationError('email is required', 'email');
if (role && !userStore.VALID_ROLES.has(role)) {
throw new ValidationError('Invalid role', 'role');
}
const result = await userStore.addToAllowlist(email);
if (!result.ok) throw new ValidationError(result.reason, 'email');
// If a role was provided AND the user already exists, also set the role.
if (role) {
const existing = await userStore.getUserByEmail(email);
if (existing) {
await userStore.setRole(existing.id, role);
}
}
return ok(res, {
email: email.toLowerCase(),
alreadyExisted: result.alreadyExisted,
});
}, 'auth-admin-users-create'));
router.patch('/admin/users/:id', _requireAdmin, asyncHandler(async (req, res) => {
const { role } = req.body || {};
if (!role || !userStore.VALID_ROLES.has(role)) {
throw new ValidationError('Invalid role', 'role');
}
const result = await userStore.setRole(req.params.id, role);
if (!result.ok) {
throw result.reason === 'not_found'
? new NotFoundError('User not found')
: new ValidationError(result.reason, 'role');
}
return successMessage(res, 'Role updated');
}, 'auth-admin-users-update'));
router.delete('/admin/users/:id', _requireAdmin, asyncHandler(async (req, res) => {
const result = await userStore.deleteUser(req.params.id);
if (!result.ok) {
if (result.reason === 'not_found') throw new NotFoundError('User not found');
if (result.reason === 'last_admin') {
throw new ValidationError('Cannot delete the last admin');
}
throw new ValidationError(result.reason);
}
return successMessage(res, 'User deleted');
}, 'auth-admin-users-delete'));
// ── /admin/allowlist ──────────────────────────────────────────────────
router.get('/admin/allowlist', _requireAdmin, asyncHandler(async (_req, res) => {
const emails = await userStore.listAllowlist();
return ok(res, { emails });
}, 'auth-admin-allowlist'));
// ── /admin/invites ────────────────────────────────────────────────────
router.get('/admin/invites', _requireAdmin, asyncHandler(async (_req, res) => {
const invites = await inviteStore.listOutstanding();
return ok(res, { invites });
}, 'auth-admin-invites-list'));
router.post('/admin/invites', _requireAdmin, _requireProIfUserLimitReached, asyncHandler(async (req, res) => {
const { email, role, ttlHours, sendEmail } = req.body || {};
if (!email) throw new ValidationError('email is required', 'email');
const ttlMs = (typeof ttlHours === 'number' && ttlHours > 0 && ttlHours <= 168)
? ttlHours * 60 * 60 * 1000
: inviteStore.DEFAULT_TTL_MS;
const invitedBy = (req.user && req.user.email) || 'admin';
const issued = await inviteStore.issue({
email,
role: (role && userStore.VALID_ROLES.has(role)) ? role : 'operator',
ttlMs,
invitedBy,
});
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
// Build the accept URL once — used both for the response and for email delivery.
const baseUrl = (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
(req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001')));
const acceptUrl = baseUrl + '/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept';
// DC-085: link-first delivery. Default = no email, just hand the link back.
// Operators opt INTO email by sending { sendEmail: true } (or the admin UI
// checks the "Send email" checkbox). When SMTP is unconfigured AND the
// operator did opt in, we surface the failure as `deliveredVia: 'failed'`
// but NEVER leak the raw token into the server log — the link is already
// in the response, so the operator has a UI-side fallback.
let deliveredVia = 'manual';
if (sendEmail === true) {
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
try {
const smtpConfig = req.app.locals && req.app.locals.emailConfig;
if (smtpConfig && emailSender.isConfigured(smtpConfig)) {
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
deliveredVia = 'email';
} else {
// Operator asked for email but SMTP isn't configured. Surface the
// failure cleanly; the link is still in the response so the
// operator can share it manually. Do NOT log the raw URL — it
// would duplicate what's already in the response and pollute the
// server log on every unconfigured-install invite.
log.warn && log.warn('auth-invite-send',
'invite send skipped: SMTP not configured (operator opted in)',
{ inviteId: issued.id, email: AuthProvider.maskEmail(issued.email) || '[unmaskable-email]' });
deliveredVia = 'failed';
}
} catch (sendErr) {
log.warn && log.warn('auth-invite-send',
'invite send failed: ' + (sendErr.message || String(sendErr)),
{ inviteId: issued.id });
deliveredVia = 'failed';
}
}
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
const shareText =
'Join my DashCaddy as ' + issued.role + ' — ' + acceptUrl +
' — expires in ' + ttlHoursOut + 'h.';
return ok(res, {
id: issued.id,
email: issued.email,
role: issued.role,
expiresAt: issued.expiresAt,
acceptUrl,
shareText,
deliveredVia,
maskedEmail,
});
}, 'auth-admin-invites-create'));
router.delete('/admin/invites/:id', _requireAdmin, asyncHandler(async (req, res) => {
const result = await inviteStore.revoke(req.params.id);
if (!result.ok) throw new NotFoundError('Invite not found');
return successMessage(res, 'Invite revoked');
}, 'auth-admin-invites-revoke'));
// ── /invites (public) ──────────────────────────────────────────────────
// PUBLIC: peek at an invite without consuming it.
router.get('/invites/:token', asyncHandler(async (req, res) => {
const peeked = await inviteStore.peek(req.params.token);
if (!peeked) {
// Same response as "not found" — don't leak token state.
return ok(res, { valid: false });
}
return ok(res, {
valid: true,
email: peeked.email,
role: peeked.role,
expiresAt: peeked.expiresAt,
});
}, 'auth-invites-peek'));
// PUBLIC: accept an invite token. Creates the user, sets the session.
// DC-052: gated by Pro-or-room — if the user cap is hit and the host
// isn't Pro, reject before the user is created. The invite token is
// still marked used so a stale invite can't be replayed later when
// room opens up.
router.post('/invites/:token/accept', asyncHandler(async (req, res) => {
const licenseManager = req.app.locals && req.app.locals.licenseManager;
const localUserStore = req.app.locals && req.app.locals.userStore;
if (licenseManager && typeof licenseManager.isPro === 'function' && !licenseManager.isPro()
&& localUserStore && typeof localUserStore.countUsers === 'function') {
const count = await localUserStore.countUsers();
if (count >= 3) {
// Burn the invite — it can't be redeemed later under a paid tier
// without the host first running `addToAllowlist` to re-add the
// email. This prevents invite-leak spam from filling the user
// table and being immortalized.
await inviteStore.accept(req.params.token, { acceptedBy: null }).catch(() => {});
throw new PaymentRequiredError(
'Free tier supports up to 3 users. Upgrade to Pro to redeem this invitation.'
);
}
}
const result = await inviteStore.accept(req.params.token, {
acceptedBy: req.user ? req.user.email : null,
});
if (!result.ok) {
throw new ValidationError('Invitation is ' + result.reason.replace('_', ' '), 'token');
}
// Authorize the email + create the user record.
const invite = result.invite;
const userResult = await userStore.login({
email: invite.email,
ip: req.ip || '',
displayName: invite.email.split('@')[0],
createdBy: 'invite:' + invite.id,
});
if (!userResult.ok) {
throw new ValidationError('Could not create user from invite: ' + userResult.reason);
}
// Create session (same shape as email verify path).
if (session) {
session.create(req, '24h');
session.setCookie(res, '24h');
}
if (req.app.locals && req.app.locals.renewCSRFToken) {
req.app.locals.renewCSRFToken(res, req.secure || req.protocol === 'https');
}
// Attach user to request for audit log.
req.user = {
id: userResult.user.id,
email: userResult.user.email,
role: userResult.user.role,
isAdmin: userResult.user.role === 'admin',
isBootstrap: false,
viaProvider: 'invite',
};
log.info && log.info('auth', 'invite accepted, user created', {
userId: userResult.user.id,
email: AuthProvider.maskEmail(userResult.user.email) || '[unmaskable-email]',
role: userResult.user.role,
inviteId: invite.id,
});
return ok(res, {
message: 'Invitation accepted',
user: {
id: userResult.user.id,
email: userResult.user.email,
role: userResult.user.role,
},
csrfToken: res.locals && res.locals.csrfToken,
});
}, 'auth-invites-accept'));
return router;
};