Add cross-reference map, admin health page, passkeys and device list, settings tabs
Build and Push Docker Image / build-and-push (push) Successful in 10m22s

- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 12:52:49 -04:00
1 parent 207bfcb919
commit bdb04cdedc
35 files changed
+1860 -22

No files matched your search

+21 -4
View File
@@ -1,14 +1,15 @@
// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups.
// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with
// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off.
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
import { join } from 'path';
import { backupDatabase, dataDir } from './db.js';
import { logEvent } from './health.js';
import { backupDatabase, dataDir, replaceDatabase } from './db.js';
const DAY = 24 * 60 * 60 * 1000;
export const backupDir = () => join(dataDir(), 'backups');
const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16);
const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
export const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
export function listSnapshots(dir = backupDir()) {
if (!existsSync(dir)) return [];
@@ -32,13 +33,29 @@ export async function runBackup(now = new Date()) {
return file;
}
export const snapshotPath = (name) => (isSnapshot(name) && existsSync(join(backupDir(), name)) ? join(backupDir(), name) : null);
// Puts a snapshot back as the live database. A fresh snapshot of what is there now is taken first (from a
// copy of the chosen file, so the two can't collide), so a restore can itself be undone.
export async function restoreSnapshot(name, now = new Date()) {
const src = snapshotPath(name);
if (!src) throw new Error('No such snapshot.');
const hold = join(backupDir(), `.restoring-${Date.now()}.tmp`);
copyFileSync(src, hold);
try {
const before = await runBackup(now);
replaceDatabase(hold);
return { restored: name, safetyCopy: before };
} finally { unlinkSync(hold); }
}
// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up).
export function startBackupScheduler() {
if (process.env.BACKUP_DISABLED === '1') return;
const tick = () => {
const latest = listSnapshots()[0];
if (latest && Date.now() - latest.at < DAY) return;
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => console.error('Database snapshot failed:', err.message));
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => { console.error('Database snapshot failed:', err.message); logEvent('backup', err.message); });
};
setTimeout(tick, 30_000).unref();
setInterval(tick, 60 * 60 * 1000).unref();
+60 -2
View File
@@ -1,5 +1,5 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { copyFileSync, mkdirSync, rmSync } from 'fs';
import { MAX_VERSIONS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
@@ -17,6 +17,18 @@ export function backupDatabase(destPath) {
return db.backup(destPath);
}
/** Swaps the live database for a copy of `srcFile` (a snapshot). The connection is reopened either way. */
export function replaceDatabase(srcFile) {
db.close();
try {
copyFileSync(srcFile, DB_PATH);
rmSync(`${DB_PATH}-wal`, { force: true });
rmSync(`${DB_PATH}-shm`, { force: true });
} finally {
initDb();
}
}
// ---------------------------------------------------------------------------
// Init — create tables if they don't exist, migrate older schemas
// ---------------------------------------------------------------------------
@@ -48,6 +60,18 @@ export function initDb() {
sess TEXT NOT NULL,
expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS passkeys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id);
`);
// Older databases predate multi-user support — add the ownership column.
@@ -82,6 +106,9 @@ export function initDb() {
`);
}
// Optional "new sign-in" notification, sent to the same ntfy topic.
if (!userCols.some((c) => c.name === 'login_alerts')) db.exec('ALTER TABLE users ADD COLUMN login_alerts INTEGER NOT NULL DEFAULT 0');
// Optional daily verse of the day, sent to the same ntfy topic.
if (!userCols.some((c) => c.name === 'votd_enabled')) {
db.exec(`
@@ -273,6 +300,7 @@ function parseUserRow(row) {
reminderEnabled: !!row.reminderEnabled,
votdEnabled: !!row.votdEnabled,
prayerEnabled: !!row.prayerEnabled,
loginAlerts: !!row.loginAlerts,
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
};
}
@@ -284,7 +312,7 @@ const USER_SELECT = `
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz,
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate,
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime, login_alerts AS loginAlerts
FROM users
`;
@@ -837,6 +865,7 @@ export function adminGetAllUsers() {
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
export function adminDeleteUser(userId) {
db.prepare('DELETE FROM passkeys WHERE user_id = ?').run(userId);
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
db.transaction(() => {
db.prepare('DELETE FROM collaborators WHERE user_id = ?').run(userId);
@@ -931,3 +960,32 @@ export function destroyAllSessionsForUser(userId, exceptSid = null) {
const placeholders = staleSids.map(() => '?').join(',');
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
}
// ---------------------------------------------------------------------------
// Passkeys, sign-in alerts and the list of signed-in devices
// ---------------------------------------------------------------------------
export function setLoginAlerts(userId, enabled) {
db.prepare('UPDATE users SET login_alerts = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
}
export function addPasskey({ id, userId, publicKey, counter, transports, name }) {
db.prepare('INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(id, userId, publicKey, counter, JSON.stringify(transports ?? []), name, Date.now());
}
const passkeyRow = (r) => (r ? { ...r, transports: JSON.parse(r.transports || '[]') } : null);
const PASSKEY_COLS = 'id, user_id AS userId, public_key AS publicKey, counter, transports, name, created_at AS createdAt, last_used_at AS lastUsedAt';
export const getPasskey = (id) => passkeyRow(db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE id = ?`).get(id));
export const listPasskeys = (userId) => db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE user_id = ? ORDER BY created_at`).all(userId).map(passkeyRow);
export const touchPasskey = (id, counter) => db.prepare('UPDATE passkeys SET counter = ?, last_used_at = ? WHERE id = ?').run(counter, Date.now(), id);
export const deletePasskey = (id, userId) => db.prepare('DELETE FROM passkeys WHERE id = ? AND user_id = ?').run(id, userId).changes > 0;
export const renamePasskey = (id, userId, name) => db.prepare('UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?').run(name, id, userId).changes > 0;
/** Every live session of one user: [{ sid, sess, expires }]. */
export function listUserSessions(userId) {
return db.prepare('SELECT sid, sess, expires FROM sessions WHERE expires > ?').all(Date.now())
.map((r) => { try { return { sid: r.sid, sess: JSON.parse(r.sess), expires: r.expires }; } catch { return null; } })
.filter((r) => r?.sess?.userId === userId);
}
+185
View File
@@ -0,0 +1,185 @@
// Passkeys (WebAuthn sign-in with Face ID, a fingerprint or a security key) and the list of devices signed in.
// A passkey is a second factor in itself (the device checks it is really you), so signing in with one skips the
// authenticator-code step. Passwords and 2FA keep working as before.
import { createHash } from 'crypto';
import {
generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse,
} from '@simplewebauthn/server';
import {
addPasskey, deletePasskey, destroyAllSessionsForUser, destroySession, getPasskey, getUserById, listPasskeys,
listUserSessions, renamePasskey, setLoginAlerts, touchPasskey,
} from './db.js';
import { logEvent } from './health.js';
import { sendNtfy } from './reminders.js';
const MONTH = 30 * 24 * 60 * 60 * 1000;
const APP_NAME = 'Bible Study';
// "Chrome on macOS" from a User-Agent string; good enough to recognise your own devices.
export function describeDevice(ua = '') {
const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : null;
const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : /Mac OS X|Macintosh/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : null;
if (browser && os) return `${browser} on ${os}`;
return browser ?? os ?? 'Unknown device';
}
export const sessionKey = (sid) => createHash('sha256').update(sid).digest('hex').slice(0, 16);
// Where the browser says it is, for checking passkeys: APP_URL when set, else the address this request came to.
export function relyingParty(req, env = process.env) {
if (env.APP_URL) {
try { const u = new URL(env.APP_URL); return { rpID: u.hostname, origin: u.origin }; } catch { /* fall through */ }
}
return { rpID: req.hostname, origin: `${req.protocol}://${req.get('host')}` };
}
/** Called right after a session gets its userId: remembers the device and (if asked) sends a sign-in alert. */
export function noteSignIn(req, user, method) {
const label = describeDevice(req.get('user-agent') ?? '');
req.session.device = { label, ip: req.ip, at: Date.now(), method };
if (user.loginAlerts && user.ntfyTopic) {
const how = { password: 'password', passkey: 'passkey', mfa: 'password and authenticator code', register: 'a new account' }[method] ?? method;
sendNtfy(user, { title: 'New sign-in', body: `${label} signed in to your Bible Study account with ${how}. If that wasn’t you, change your password and sign out other devices.`, tags: 'warning' })
.catch((err) => { console.error('Sign-in alert failed:', err.message); logEvent('notify', `Sign-in alert: ${err.message}`, { userId: user.id }); });
}
}
const publicPasskey = (p) => ({ id: p.id, name: p.name, createdAt: p.createdAt, lastUsedAt: p.lastUsedAt });
export function registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany }) {
const fail = (res, status, error) => res.status(status).json({ error });
// ---- Signing in with a passkey (no email needed: the passkey says who you are) ----
app.post('/api/auth/passkey/options', async (req, res) => {
try {
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(tooMany);
const { rpID } = relyingParty(req);
const options = await generateAuthenticationOptions({ rpID, userVerification: 'required' });
req.session.passkeyChallenge = options.challenge;
res.json(options);
} catch (err) {
console.error('POST /api/auth/passkey/options error:', err);
fail(res, 500, 'Could not start passkey sign-in.');
}
});
app.post('/api/auth/passkey/verify', async (req, res) => {
const keys = [`ip:${req.ip}`];
try {
if (isLimited(...keys)) return res.status(429).json(tooMany);
const challenge = req.session?.passkeyChallenge;
const response = req.body;
const passkey = typeof response?.id === 'string' ? getPasskey(response.id) : null;
const failed = () => { recordFailure(...keys); logEvent('signin', `Passkey sign-in failed from ${req.ip}`); return fail(res, 401, 'That passkey didn’t work. Try again, or use your password.'); };
if (!challenge || !passkey) return failed();
const { rpID, origin } = relyingParty(req);
let result;
try {
result = await verifyAuthenticationResponse({
response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true,
credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter, transports: passkey.transports },
});
} catch { return failed(); }
if (!result.verified) return failed();
const user = getUserById(passkey.userId);
if (!user) return failed();
touchPasskey(passkey.id, result.authenticationInfo.newCounter);
clearFailures(...keys);
req.session.regenerate((err) => {
if (err) return fail(res, 500, 'Could not create session.');
req.session.userId = user.id;
noteSignIn(req, user, 'passkey');
res.json(userPayload(user));
});
} catch (err) {
console.error('POST /api/auth/passkey/verify error:', err);
fail(res, 500, 'Could not check that passkey.');
}
});
// ---- Managing passkeys (signed in) ----
app.get('/api/passkeys', requireAuth, (req, res) => res.json(listPasskeys(req.session.userId).map(publicPasskey)));
app.post('/api/passkeys/options', requireAuth, async (req, res) => {
try {
const user = getUserById(req.session.userId);
const { rpID } = relyingParty(req);
const options = await generateRegistrationOptions({
rpName: APP_NAME, rpID, userName: user.email, userID: new TextEncoder().encode(user.id), attestationType: 'none',
excludeCredentials: listPasskeys(user.id).map((p) => ({ id: p.id, transports: p.transports })),
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
});
req.session.passkeyChallenge = options.challenge;
res.json(options);
} catch (err) {
console.error('POST /api/passkeys/options error:', err);
fail(res, 500, 'Could not start adding a passkey.');
}
});
app.post('/api/passkeys', requireAuth, async (req, res) => {
try {
const challenge = req.session.passkeyChallenge;
if (!challenge) return fail(res, 400, 'Start again: that request expired.');
const { rpID, origin } = relyingParty(req);
let result;
try {
result = await verifyRegistrationResponse({ response: req.body?.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true });
} catch (err) { return fail(res, 400, `That passkey couldn’t be added (${err.message}).`); }
if (!result.verified) return fail(res, 400, 'That passkey couldn’t be added.');
delete req.session.passkeyChallenge;
const { credential } = result.registrationInfo;
if (getPasskey(credential.id)) return fail(res, 409, 'That passkey is already registered.');
const name = String(req.body?.name ?? '').trim().slice(0, 60) || describeDevice(req.get('user-agent') ?? '');
addPasskey({ id: credential.id, userId: req.session.userId, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: credential.counter, transports: credential.transports, name });
res.json(listPasskeys(req.session.userId).map(publicPasskey));
} catch (err) {
console.error('POST /api/passkeys error:', err);
fail(res, 500, 'Could not save that passkey.');
}
});
app.patch('/api/passkeys/:id', requireAuth, (req, res) => {
const name = String(req.body?.name ?? '').trim().slice(0, 60);
if (!name) return fail(res, 400, 'Give it a name.');
if (!renamePasskey(req.params.id, req.session.userId, name)) return fail(res, 404, 'No such passkey.');
res.json(listPasskeys(req.session.userId).map(publicPasskey));
});
app.delete('/api/passkeys/:id', requireAuth, (req, res) => {
if (!deletePasskey(req.params.id, req.session.userId)) return fail(res, 404, 'No such passkey.');
res.json(listPasskeys(req.session.userId).map(publicPasskey));
});
// ---- Where you are signed in ----
app.get('/api/sessions', requireAuth, (req, res) => {
const rows = listUserSessions(req.session.userId).map(({ sid, sess, expires }) => ({
id: sessionKey(sid), current: sid === req.sessionID,
label: sess.device?.label ?? 'Signed in before this list existed', ip: sess.device?.ip ?? null,
method: sess.device?.method ?? null, signedInAt: sess.device?.at ?? null,
lastSeen: expires - MONTH, // sessions are renewed on each request, so this is when it was last used
})).sort((a, b) => Number(b.current) - Number(a.current) || b.lastSeen - a.lastSeen);
res.json(rows);
});
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
const found = listUserSessions(req.session.userId).find((s) => sessionKey(s.sid) === req.params.id);
if (!found) return fail(res, 404, 'That device is already signed out.');
if (found.sid === req.sessionID) return fail(res, 400, 'Use Sign out for this device.');
destroySession(found.sid);
res.json({ ok: true });
});
app.post('/api/sessions/sign-out-others', requireAuth, (req, res) => {
destroyAllSessionsForUser(req.session.userId, req.sessionID);
res.json({ ok: true });
});
app.put('/api/auth/login-alerts', requireAuth, (req, res) => {
const user = getUserById(req.session.userId);
const enabled = !!req.body?.enabled;
if (enabled && !user.ntfyTopic) return fail(res, 400, 'Set up a notification topic first (under Notifications).');
setLoginAlerts(user.id, enabled);
res.json({ loginAlerts: enabled });
});
}
+171
View File
@@ -0,0 +1,171 @@
// @vitest-environment node
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { createHash, generateKeyPairSync, randomBytes, sign } from 'crypto';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import express from 'express';
import session from 'express-session';
const b64 = (b) => Buffer.from(b).toString('base64url');
const sha = (b) => createHash('sha256').update(b).digest();
// A tiny CBOR writer, just enough for the attestation a passkey device would send.
const head = (major, n) => (n < 24 ? Buffer.from([(major << 5) | n]) : n < 256 ? Buffer.from([(major << 5) | 24, n]) : Buffer.from([(major << 5) | 25, n >> 8, n & 255]));
const cbor = (v) => {
if (Buffer.isBuffer(v)) return Buffer.concat([head(2, v.length), v]);
if (typeof v === 'string') return Buffer.concat([head(3, Buffer.byteLength(v)), Buffer.from(v)]);
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
const entries = v instanceof Map ? [...v] : Object.entries(v);
return Buffer.concat([head(5, entries.length), ...entries.flatMap(([k, x]) => [cbor(k), cbor(x)])]);
};
// A software "phone": makes passkeys and answers sign-in challenges, like Face ID would.
function authenticator(rpID, origin) {
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const jwk = publicKey.export({ format: 'jwk' });
const credId = randomBytes(16);
let counter = 0;
const rpHash = sha(rpID);
const client = (type, challenge, o = origin) => Buffer.from(JSON.stringify({ type, challenge, origin: o, crossOrigin: false }));
return {
id: b64(credId),
create(challenge, o) {
const cose = cbor(new Map([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x, 'base64url')], [-3, Buffer.from(jwk.y, 'base64url')]]));
const len = Buffer.alloc(2); len.writeUInt16BE(credId.length);
const cnt = Buffer.alloc(4);
const authData = Buffer.concat([rpHash, Buffer.from([0x45]), cnt, Buffer.alloc(16), len, credId, cose]);
return { id: b64(credId), rawId: b64(credId), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(client('webauthn.create', challenge, o)), attestationObject: b64(cbor({ fmt: 'none', attStmt: {}, authData })), transports: ['internal'] } };
},
get(challenge, o) {
counter += 1;
const cnt = Buffer.alloc(4); cnt.writeUInt32BE(counter);
const authData = Buffer.concat([rpHash, Buffer.from([0x05]), cnt]);
const clientData = client('webauthn.get', challenge, o);
const signature = sign('sha256', Buffer.concat([authData, sha(clientData)]), privateKey);
return { id: b64(credId), rawId: b64(credId), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(clientData), authenticatorData: b64(authData), signature: b64(signature), userHandle: null } };
},
};
}
let dir; let server; let base; let db;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'devices-'));
process.env.DATA_DIR = dir; delete process.env.APP_URL;
vi.resetModules();
db = await import('./db.js'); db.initDb();
const { registerDeviceRoutes } = await import('./devices.js');
const { requireAuth } = await import('./auth.js');
const app = express();
app.use(express.json());
app.use(session({ secret: 'test', resave: false, saveUninitialized: false, store: new (await import('./sessionStore.js')).SqliteSessionStore(), cookie: { maxAge: 30 * 24 * 3600 * 1000 } }));
const { noteSignIn } = await import('./devices.js');
// stand-in for the password sign-in route
app.post('/test/login', (req, res) => { const u = db.getUserByEmail(req.body.email); req.session.regenerate(() => { req.session.userId = u.id; noteSignIn(req, u, 'password'); res.json({ ok: true }); }); });
registerDeviceRoutes(app, { requireAuth, userPayload: (u) => ({ id: u.id, email: u.email }), isLimited: () => false, recordFailure() {}, clearFailures() {}, tooMany: { error: 'slow down' } });
await new Promise((r) => { server = app.listen(0, r); });
base = `http://localhost:${server.address().port}`;
db.createUser({ id: 'u1', email: 'a@x.test', passwordHash: 'h' });
});
afterEach(async () => { await new Promise((r) => server.close(r)); rmSync(dir, { recursive: true, force: true }); delete process.env.DATA_DIR; });
// A browser: keeps its own cookie
function browser(ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17 Safari/605.1.15') {
let cookie = '';
const call = async (method, path, body) => {
const res = await fetch(base + path, { method, headers: { 'Content-Type': 'application/json', 'User-Agent': ua, ...(cookie ? { Cookie: cookie } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
const set = res.headers.get('set-cookie');
if (set) cookie = set.split(';')[0];
return { status: res.status, body: await res.json().catch(() => null) };
};
return { call, login: () => call('POST', '/test/login', { email: 'a@x.test' }) };
}
const originOf = () => base;
test('add a passkey, then sign in with it from another device', async () => {
const phone = authenticator('localhost', originOf());
const laptop = browser(); await laptop.login();
const { body: opts } = await laptop.call('POST', '/api/passkeys/options');
expect(opts.authenticatorSelection.userVerification).toBe('required');
const added = await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge), name: 'My phone' });
expect(added.status).toBe(200);
expect(added.body).toMatchObject([{ id: phone.id, name: 'My phone' }]);
const other = browser('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1');
expect((await other.call('GET', '/api/passkeys')).status).toBe(401); // not signed in yet
const { body: ask } = await other.call('POST', '/api/auth/passkey/options');
const ok = await other.call('POST', '/api/auth/passkey/verify', phone.get(ask.challenge));
expect(ok.status).toBe(200);
expect(ok.body.email).toBe('a@x.test');
expect((await other.call('GET', '/api/passkeys')).body[0].lastUsedAt).toBeGreaterThan(0);
const devices = (await other.call('GET', '/api/sessions')).body;
expect(devices.map((d) => d.label)).toEqual(['Safari on iPhone', 'Safari on macOS']);
expect(devices[0]).toMatchObject({ current: true, method: 'passkey' });
});
test('a passkey from the wrong site, an unknown one, or a replay is refused', async () => {
const phone = authenticator('localhost', originOf());
const laptop = browser(); await laptop.login();
const { body: opts } = await laptop.call('POST', '/api/passkeys/options');
const bad = await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge, 'https://evil.example') });
expect(bad.status).toBe(400);
await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge), name: 'ok' });
const stranger = browser();
const { body: ask } = await stranger.call('POST', '/api/auth/passkey/options');
expect((await stranger.call('POST', '/api/auth/passkey/verify', authenticator('localhost', originOf()).get(ask.challenge))).status).toBe(401); // never registered
expect((await stranger.call('POST', '/api/auth/passkey/verify', phone.get('wrong-challenge'))).status).toBe(401);
const good = phone.get(ask.challenge);
expect((await stranger.call('POST', '/api/auth/passkey/verify', good)).status).toBe(200);
const replay = browser();
await replay.call('POST', '/api/auth/passkey/options');
expect((await replay.call('POST', '/api/auth/passkey/verify', good)).status).toBe(401); // that answer was for another challenge
});
test('passkeys can be renamed and removed, and only by their owner', async () => {
const phone = authenticator('localhost', originOf());
const me = browser(); await me.login();
const { body: opts } = await me.call('POST', '/api/passkeys/options');
await me.call('POST', '/api/passkeys', { response: phone.create(opts.challenge) });
expect((await me.call('PATCH', `/api/passkeys/${phone.id}`, { name: 'Pixel' })).body[0].name).toBe('Pixel');
expect((await me.call('PATCH', `/api/passkeys/${phone.id}`, { name: ' ' })).status).toBe(400);
db.createUser({ id: 'u2', email: 'b@x.test', passwordHash: 'h' });
expect(db.deletePasskey(phone.id, 'u2')).toBe(false);
expect((await me.call('DELETE', `/api/passkeys/${phone.id}`)).body).toEqual([]);
const { body: ask } = await browser().call('POST', '/api/auth/passkey/options');
expect(ask.challenge).toBeTruthy();
});
test('devices can be signed out one by one or all at once', async () => {
const a = browser(); const b = browser('Mozilla/5.0 (Windows NT 10.0) Chrome/120 Safari/537'); const c = browser('Mozilla/5.0 (Linux; Android 14) Chrome/120 Mobile Safari/537');
await a.login(); await b.login(); await c.login();
const list = (await a.call('GET', '/api/sessions')).body;
expect(list).toHaveLength(3);
expect(list[0].current).toBe(true);
expect((await a.call('DELETE', `/api/sessions/${list[0].id}`)).status).toBe(400); // not your own
const bId = list.find((d) => d.label === 'Chrome on Windows').id;
expect((await a.call('DELETE', `/api/sessions/${bId}`)).status).toBe(200);
expect((await b.call('GET', '/api/sessions')).status).toBe(401);
expect((await a.call('DELETE', `/api/sessions/${bId}`)).status).toBe(404);
await a.call('POST', '/api/sessions/sign-out-others');
expect((await c.call('GET', '/api/sessions')).status).toBe(401);
expect((await a.call('GET', '/api/sessions')).body).toHaveLength(1);
});
test('sign-in alerts need a notification topic', async () => {
const a = browser(); await a.login();
expect((await a.call('PUT', '/api/auth/login-alerts', { enabled: true })).status).toBe(400);
db.setReminderSettings('u1', { ntfyUrl: null, ntfyTopic: 'my-topic', reminderTime: '08:00', reminderEnabled: false, reminderTz: 'UTC' });
expect((await a.call('PUT', '/api/auth/login-alerts', { enabled: true })).body).toEqual({ loginAlerts: true });
expect(db.getUserById('u1').loginAlerts).toBe(true);
});
test('devices are described in plain words', async () => {
const { describeDevice, relyingParty } = await import('./devices.js');
expect(describeDevice('Mozilla/5.0 (Windows NT 10.0) Chrome/120 Edg/120')).toBe('Edge on Windows');
expect(describeDevice('Mozilla/5.0 (X11; Linux) Firefox/120')).toBe('Firefox on Linux');
expect(describeDevice('')).toBe('Unknown device');
const req = { hostname: 'study.example', protocol: 'https', get: () => 'study.example:8443' };
expect(relyingParty(req, {})).toEqual({ rpID: 'study.example', origin: 'https://study.example:8443' });
expect(relyingParty(req, { APP_URL: 'https://study.example/app' })).toEqual({ rpID: 'study.example', origin: 'https://study.example' });
});
+37
View File
@@ -0,0 +1,37 @@
// What the admin's Health page shows: a short in-memory log of things that went wrong (failed sign-ins, failed
// notifications, backups), plus disk use and the snapshot list. The log resets when the server restarts.
import { existsSync, statSync, statfsSync } from 'fs';
import { join } from 'path';
import { dataDir } from './db.js';
import { backupDir, listSnapshots } from './backups.js';
const MAX_EVENTS = 200;
const events = [];
const started = Date.now();
// kind: 'signin' | 'notify' | 'backup'. `ok` false marks a failure.
export function logEvent(kind, detail, { ok = false, userId = null } = {}) {
events.unshift({ at: Date.now(), kind, ok, detail: String(detail).slice(0, 300), userId });
if (events.length > MAX_EVENTS) events.length = MAX_EVENTS;
}
export const recentEvents = (kind, limit = 50) => events.filter((e) => !kind || e.kind === kind).slice(0, limit);
export function clearEvents() { events.length = 0; }
const size = (path) => { try { return statSync(path).size; } catch { return 0; } };
export function diskUse() {
const dir = dataDir();
const db = size(join(dir, 'projects.db')) + size(join(dir, 'projects.db-wal'));
const backups = listSnapshots(backupDir()).reduce((n, s) => n + s.bytes, 0);
let free = null; let total = null;
try { const s = statfsSync(existsSync(dir) ? dir : '.'); free = s.bavail * s.bsize; total = s.blocks * s.bsize; } catch { /* not supported here */ }
return { db, backups, free, total };
}
export function backupStatus() {
const snapshots = listSnapshots(backupDir());
return { enabled: process.env.BACKUP_DISABLED !== '1', keep: Number(process.env.BACKUP_KEEP) || 7, snapshots, latest: snapshots[0] ?? null };
}
export const uptimeSeconds = () => Math.round((Date.now() - started) / 1000);
+50
View File
@@ -0,0 +1,50 @@
// @vitest-environment node
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
let dir;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'health-')); process.env.DATA_DIR = dir; vi.resetModules(); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); delete process.env.DATA_DIR; });
const load = async () => {
const db = await import('./db.js'); db.initDb();
return { db, backups: await import('./backups.js'), health: await import('./health.js') };
};
test('the event log keeps the newest first, filters by kind and is capped', async () => {
const { health } = await load();
health.logEvent('signin', 'a'); health.logEvent('notify', 'b'); health.logEvent('signin', 'c');
expect(health.recentEvents('signin').map((e) => e.detail)).toEqual(['c', 'a']);
for (let i = 0; i < 300; i += 1) health.logEvent('signin', String(i));
expect(health.recentEvents(null, 1000)).toHaveLength(200);
});
test('a snapshot can be restored, and what was there is kept as a safety copy', async () => {
const { db, backups } = await load();
db.createUser({ id: 'u1', email: 'a@x.test', passwordHash: 'h' });
await backups.runBackup(new Date('2026-10-01T10:00:00Z'));
db.createUser({ id: 'u2', email: 'b@x.test', passwordHash: 'h' });
expect(db.countUsers()).toBe(2);
const [snap] = backups.listSnapshots();
const result = await backups.restoreSnapshot(snap.name, new Date('2026-10-02T10:00:00Z'));
expect(db.countUsers()).toBe(1); // back to when the snapshot was taken
expect(backups.listSnapshots().map((s) => s.name)).toContain(result.safetyCopy.split('/').pop());
});
test('only real snapshot names resolve', async () => {
const { backups } = await load();
expect(backups.snapshotPath('../projects.db')).toBeNull();
expect(backups.snapshotPath('projects-2026-01-01-00-00.db')).toBeNull(); // valid name, but no such file
await expect(backups.restoreSnapshot('nope')).rejects.toThrow('No such snapshot');
});
test('disk use reports the database and snapshot sizes', async () => {
const { backups, health } = await load();
await backups.runBackup();
const use = health.diskUse();
expect(use.db).toBeGreaterThan(0);
expect(use.backups).toBeGreaterThan(0);
expect(health.backupStatus().latest).not.toBeNull();
});
+60 -2
View File
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import {
initDb, dataDir, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
initDb, dataDir, listPasskeys, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
setShareToken, clearShareToken, getShareSettings, setShareSettings, getSharedByToken, getSharePassHash,
@@ -35,11 +35,13 @@ import { votdAvailable } from './votd.js';
import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js';
import { registerBibleRoutes } from './bibleApi.js';
import { registerIqBibleRoutes } from './iqbible.js';
import { registerDeviceRoutes, noteSignIn } from './devices.js';
import { expiryFromDays, hashPasscode, checkShareAccess } from './shareLinks.js';
import { SIGNUP_MODES, isSignupMode, checkSignup, generateInviteCode, normalizeInviteCode } from './signup.js';
import { mergeHistory } from './readerHistory.js';
import { buildAccountExport, accountExportName } from './accountExport.js';
import { startBackupScheduler } from './backups.js';
import { startBackupScheduler, runBackup, restoreSnapshot, snapshotPath } from './backups.js';
import { logEvent, recentEvents, diskUse, backupStatus, uptimeSeconds } from './health.js';
import { cleanMarks } from './readerMarks.js';
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
import { isSaveConflict, parseBase } from './projectSync.js';
@@ -54,6 +56,7 @@ function userPayload(user) {
reminderTime: user.reminderTime ?? null,
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null,
loginAlerts: !!user.loginAlerts, passkeyCount: listPasskeys(user.id).length,
};
}
@@ -185,6 +188,7 @@ app.post('/api/auth/register', async (req, res) => {
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
req.session.userId = user.id;
noteSignIn(req, user, 'register');
res.json({
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
@@ -207,6 +211,7 @@ app.post('/api/auth/login', async (req, res) => {
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
if (!valid) {
recordFailure(...keys);
logEvent('signin', `Wrong password for ${email.slice(0, 80) || '(blank)'} from ${req.ip}`);
return res.status(401).json({ error: 'Incorrect email or password.' });
}
clearFailures(`acct:${email}`);
@@ -220,6 +225,7 @@ app.post('/api/auth/login', async (req, res) => {
return res.json({ mfaRequired: true });
}
req.session.userId = user.id;
noteSignIn(req, user, 'password');
res.json(userPayload(user));
});
} catch (err) {
@@ -256,6 +262,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
if (!ok) {
recordFailure(...keys);
logEvent('signin', `Wrong two-factor code for ${user.email} from ${req.ip}`, { userId: user.id });
return res.status(401).json({ error: 'Invalid code.' });
}
clearFailures(`mfa:${pendingUserId}`);
@@ -263,6 +270,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
req.session.userId = user.id;
noteSignIn(req, user, 'mfa');
res.json(userPayload(user));
});
} catch (err) {
@@ -754,6 +762,7 @@ app.put('/api/reader/marks', requireAuth, (req, res) => {
// GET /api/reading-plans — the current user's whole progress list
registerBibleRoutes(app, requireAuth);
registerIqBibleRoutes(app, requireAuth);
registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY });
app.get('/api/reading-plans', requireAuth, (req, res) => {
try {
@@ -996,6 +1005,55 @@ app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async
});
// Who may sign up, and the invite codes for it
// Server health: disk use, snapshots, and a short log of failures (in memory; resets on restart).
app.get('/api/admin/health', requireAuth, requireAdmin, (_req, res) => {
try {
const emailOf = (id) => (id ? getUserById(id)?.email ?? null : null);
const withUser = (e) => ({ ...e, email: emailOf(e.userId) });
res.json({
uptimeSeconds: uptimeSeconds(), node: process.version, dataDir: dataDir(),
disk: diskUse(), backups: backupStatus(),
iqConfigured: !!process.env.IQBIBLE_API_KEY,
signinFailures: recentEvents('signin', 30).map(withUser),
notifyFailures: recentEvents('notify', 30).map(withUser),
backupFailures: recentEvents('backup', 10),
});
} catch (err) {
console.error('GET /api/admin/health error:', err);
res.status(500).json({ error: 'Failed to read server health.' });
}
});
app.post('/api/admin/backups', requireAuth, requireAdmin, async (_req, res) => {
try {
await runBackup();
res.json(backupStatus());
} catch (err) {
console.error('POST /api/admin/backups error:', err);
logEvent('backup', err.message);
res.status(500).json({ error: 'The snapshot could not be made.' });
}
});
app.get('/api/admin/backups/:name', requireAuth, requireAdmin, (req, res) => {
const file = snapshotPath(req.params.name);
if (!file) return res.status(404).json({ error: 'No such snapshot.' });
res.download(file, req.params.name);
});
// Restoring replaces every account's data, so the snapshot's name has to be typed back as confirmation.
app.post('/api/admin/backups/:name/restore', requireAuth, requireAdmin, async (req, res) => {
if (!snapshotPath(req.params.name)) return res.status(404).json({ error: 'No such snapshot.' });
if (req.body?.confirm !== req.params.name) return res.status(400).json({ error: 'Type the snapshot’s name to confirm.' });
try {
const result = await restoreSnapshot(req.params.name);
res.json({ restored: result.restored, safetyCopy: result.safetyCopy.split('/').pop() });
} catch (err) {
console.error('POST /api/admin/backups/:name/restore error:', err);
res.status(500).json({ error: 'The snapshot could not be restored.' });
}
});
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
});
+1 -1
View File
@@ -31,7 +31,7 @@ export const ROUTES = [
route('/genealogies/@'), route('/bible-characters/@'), route('/bible-people/@'), route('/bible-people/@/@'),
route('/harmony'), route('/harmony/@'), route('/textual-variants/@/@'), route('/textual-variants/@/@/@'),
route('/chronology/for/@/@'), route('/reference/weights-measures'),
route('/cross-references/@/@'), route('/cross-references/@/@/@'),
route('/cross-references/graph'), route('/cross-references/@/@'), route('/cross-references/@/@/@'),
route('/dictionaries'), route('/dictionaries/@'),
route('/devotionals/today'), route('/devotionals/@/@'),
route('/bibles'), route('/bibles/languages'), route('/bibles/parallel'), route('/bibles/@'), route('/bibles/@/@'), route('/bibles/@/verse-of-the-day'), route('/bibles/@/reading-plan'),
+4
View File
@@ -6,6 +6,7 @@ import { lookup } from 'dns/promises';
import { isIP } from 'net';
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate, getUsersWithPrayerReminder, setLastPrayerDate, getPrayers } from './db.js';
import { openPrayers } from './prayers.js';
import { logEvent } from './health.js';
import { fetchVerseOfDay, votdMessage } from './votd.js';
// A reminder still goes out if the server was briefly down (or restarting) at the
@@ -119,6 +120,7 @@ export async function checkReminders(now = new Date()) {
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
logEvent('notify', `Reading reminder: ${err.message}`, { userId: user.id });
}
}
}
@@ -139,6 +141,7 @@ export async function checkVotd(now = new Date()) {
setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Verse of the day failed for user ${user.id}:`, err.message);
logEvent('notify', `Verse of the day: ${err.message}`, { userId: user.id });
}
}
}
@@ -169,6 +172,7 @@ export async function checkPrayerReminders(now = new Date()) {
setLastPrayerDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Prayer reminder failed for user ${user.id}:`, err.message);
logEvent('notify', `Prayer reminder: ${err.message}`, { userId: user.id });
}
}
}