Files
Hermes 5add962178
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=B] fix(auth): /auth/me hotfix — isValid not isSessionValid + guard (DC-093 r2)
Live verification of 4125d7a caught a 500 every 60s: the handler called
deps.session.isSessionValid(req), but the production session context
(src/context/session.js) exposes isValid — isSessionValid is only the
middleware-internal name. The round-1 test stub mirrored the wrong
name, so tests passed while prod 500'd (stub-shape-fits-bug).

- routes/auth/index.js: precompute guarded _authed (typeof isValid ===
  'function' check); malformed session object can no longer 500 a
  60s-polled endpoint. Fallback true: handler runs only after the
  session middleware admitted the request.
- routes/auth/admin.js:119: same latent 500 fixed (isSessionValid →
  isValid) — pre-existing DC-048 bug, any legacy-session /me call.
- Test stub now carries the real shape {isValid} with isSessionValid
  deliberately absent — regression to the wrong name now fails tests.

Judge: GLM-5.3 cold read round 2, grade B ship; stale-comment polish
folded in. Full suite 115/2682 green.
2026-08-22 19:06:34 -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.isValid(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;
};