Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
Build and Push Docker Image / build-and-push (push) Successful in 6m47s

- Admin: sign-up Open / Invite only / Closed with single-use invite links
- Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes)
- Series: group studies on the dashboard, filter by series, calendar file for a study's sessions
- Lesson handout: printable page per session (passage, outline, questions, writing lines)
- Save a chunk's notes as your own study template (synced)
- Voice dictation for notes and scripts (browser speech recognition)
- Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices
- Export highlights and bookmarks as Markdown or Word, grouped by named colour
- Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY)
- Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:26:43 -04:00
1 parent 88bc59bac0
commit 54cb1c217d
73 files changed
+2739 -134

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ export function buildAccountExport(user, store, now = new Date()) {
colorLabels: prefs.colorLabels?.v ?? {},
ink: store.ink() ?? {},
},
readingHistory: store.history?.() ?? { chapters: {}, days: [] },
readingPlans: {
progress: plans.progress ?? [],
customPlans: plans.customPlans ?? [],
+6
View File
@@ -28,6 +28,12 @@ const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_OR
ABBREV_BY_NAME.set('psalms', 'PSA');
ABBREV_BY_NAME.set('song of songs', 'SNG');
// The full book name for a USFM code ("JHN" -> "John"), or null.
export function bookNameFor(abbrev) {
const i = BOOK_ORDER.indexOf(String(abbrev ?? '').toUpperCase());
return i >= 0 ? ESV_NAMES[i] : null;
}
export function parseEsvReference(ref) {
const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/);
const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase());
+231 -13
View File
@@ -81,6 +81,15 @@ export function initDb() {
`);
}
// Optional daily verse of the day, sent to the same ntfy topic.
if (!userCols.some((c) => c.name === 'votd_enabled')) {
db.exec(`
ALTER TABLE users ADD COLUMN votd_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN votd_time TEXT;
ALTER TABLE users ADD COLUMN last_votd_date TEXT;
`);
}
// Reminders fire at the user's own local time, so remember their timezone.
if (!userCols.some((c) => c.name === 'reminder_tz')) {
db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT');
@@ -93,9 +102,56 @@ export function initDb() {
if (!projectCols.some((c) => c.name === 'outline_share_token')) {
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
}
// Share links can expire and be protected by a passcode.
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`);
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
// Server-wide settings (who may sign up, ...) and the invite codes that let someone sign up when sign-up is invite-only.
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invites (
code TEXT PRIMARY KEY,
note TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_by TEXT,
used_at INTEGER
);
`);
// Co-teachers: other accounts allowed to edit a study, and the single-use links that let them join.
db.exec(`
CREATE TABLE IF NOT EXISTS collaborators (
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_collaborators_user ON collaborators(user_id);
CREATE TABLE IF NOT EXISTS edit_invites (
token TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_by TEXT
);
`);
// What the person has read in the Reader (chapters and days), merged from every device.
db.exec(`
CREATE TABLE IF NOT EXISTS reader_history (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// One-time codes for "forgot password": hashed, short-lived, limited tries.
db.exec(`
CREATE TABLE IF NOT EXISTS password_resets (
@@ -182,6 +238,7 @@ function parseUserRow(row) {
...row,
totpEnabled: !!row.totpEnabled,
reminderEnabled: !!row.reminderEnabled,
votdEnabled: !!row.votdEnabled,
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
};
}
@@ -191,7 +248,8 @@ const USER_SELECT = `
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
podcast_name AS podcastName,
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
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
FROM users
`;
@@ -234,6 +292,25 @@ export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime,
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId);
}
/** Turns the daily verse of the day on or off, and says when to send it. */
export function setVotdSettings(userId, { votdEnabled, votdTime }) {
db.prepare('UPDATE users SET votd_enabled = ?, votd_time = ? WHERE id = ?').run(votdEnabled ? 1 : 0, votdTime || null, userId);
}
/** Every account with the verse of the day turned on and a topic to send it to. */
export function getUsersWithVotdEnabled() {
return db.prepare(`
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, votd_time AS votdTime,
last_votd_date AS lastVotdDate, reminder_tz AS reminderTz
FROM users
WHERE votd_enabled = 1 AND ntfy_topic IS NOT NULL AND votd_time IS NOT NULL
`).all();
}
export function setLastVotdDate(userId, dateString) {
db.prepare('UPDATE users SET last_votd_date = ? WHERE id = ?').run(dateString, userId);
}
/** Every account with reminders turned on and a topic configured — checked once a minute. */
export function getUsersWithRemindersEnabled() {
return db.prepare(`
@@ -249,6 +326,41 @@ export function setLastReminderDate(userId, dateString) {
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
}
// ---------------------------------------------------------------------------
// Settings and sign-up invites
// ---------------------------------------------------------------------------
export function getSetting(key, fallback = null) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : fallback;
}
export function setSetting(key, value) {
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, String(value));
}
export function createInvite(code, note, expiresAt) {
db.prepare('INSERT INTO invites (code, note, created_at, expires_at) VALUES (?, ?, ?, ?)').run(code, note || null, Date.now(), expiresAt);
}
export function listInvites() {
return db.prepare('SELECT code, note, created_at AS createdAt, expires_at AS expiresAt, used_by AS usedBy, used_at AS usedAt FROM invites ORDER BY created_at DESC LIMIT 100').all();
}
export function deleteInvite(code) {
db.prepare('DELETE FROM invites WHERE code = ?').run(code);
}
/** An invite that exists, hasn't expired and hasn't been used. */
export function findOpenInvite(code) {
return db.prepare('SELECT code FROM invites WHERE code = ? AND used_by IS NULL AND expires_at > ?').get(code, Date.now()) ?? null;
}
/** Marks an invite used by a new account; false if someone else got there first. */
export function useInvite(code, userId) {
return db.prepare('UPDATE invites SET used_by = ?, used_at = ? WHERE code = ? AND used_by IS NULL AND expires_at > ?').run(userId, Date.now(), code, Date.now()).changes > 0;
}
// ---------------------------------------------------------------------------
// Password reset codes
// ---------------------------------------------------------------------------
@@ -325,6 +437,18 @@ export function setReaderMarks(userId, { bookmarks, highlights }) {
return updatedAt;
}
export function getReaderHistory(userId) {
const row = db.prepare('SELECT data FROM reader_history WHERE user_id = ?').get(userId);
try { return row ? JSON.parse(row.data) : { chapters: {}, days: [] }; } catch { return { chapters: {}, days: [] }; }
}
export function setReaderHistory(userId, history) {
db.prepare(`
INSERT INTO reader_history (user_id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(userId, JSON.stringify(history), Date.now());
}
/** Upserts the ink strokes for one reader page. */
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
db.prepare(`
@@ -376,20 +500,33 @@ export function claimOrphanProjects(userId) {
* Does NOT return full project data to keep the response small.
*/
export function getAllProjects(userId) {
// the studies you own, plus the ones another account has invited you to edit
const rows = db.prepare(`
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
FROM projects
WHERE user_id = ?
ORDER BY last_edited DESC
`).all(userId);
return rows;
SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
CASE WHEN p.user_id = ? THEN 0 ELSE 1 END AS shared, u.email AS ownerEmail
FROM projects p
LEFT JOIN users u ON u.id = p.user_id
WHERE p.user_id = ? OR p.id IN (SELECT project_id FROM collaborators WHERE user_id = ?)
ORDER BY p.last_edited DESC
`).all(userId, userId, userId);
return rows.map((r) => (r.shared ? { ...r, shared: true } : { id: r.id, title: r.title, lastEdited: r.lastEdited, chapterSummary: r.chapterSummary }));
}
/** True when the account owns the study or has been invited to edit it. */
export function canEditProject(id, userId) {
return !!db.prepare(`
SELECT 1 FROM projects p WHERE p.id = ? AND (p.user_id = ? OR EXISTS (SELECT 1 FROM collaborators c WHERE c.project_id = p.id AND c.user_id = ?))
`).get(id, userId, userId);
}
export const isProjectOwner = (id, userId) => !!db.prepare('SELECT 1 FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
/**
* Returns a single full project by id, scoped to userId, or null if not found/not owned.
*/
export function getProject(id, userId) {
const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
if (!canEditProject(id, userId)) return null;
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
if (!row) return null;
try {
return JSON.parse(row.data);
@@ -400,7 +537,8 @@ export function getProject(id, userId) {
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
export function getProjectEdited(id, userId) {
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
if (!canEditProject(id, userId)) return null;
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(id);
return row ? row.lastEdited : null;
}
@@ -410,7 +548,7 @@ export function getProjectEdited(id, userId) {
*/
export function upsertProject(project, userId) {
const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id);
if (existing && existing.userId !== userId) {
if (existing && existing.userId !== userId && !canEditProject(project.id, userId)) {
return null;
}
@@ -437,7 +575,54 @@ export function upsertProject(project, userId) {
* Delete a project by id, scoped to userId. No-op if not found/not owned.
*/
export function deleteProject(id, userId) {
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId);
db.transaction(() => {
if (!db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId).changes) {
// not the owner: "deleting" a study you were invited to just removes you from it
db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(id, userId);
return;
}
db.prepare('DELETE FROM collaborators WHERE project_id = ?').run(id);
db.prepare('DELETE FROM edit_invites WHERE project_id = ?').run(id);
})();
}
// ---------------------------------------------------------------------------
// Co-teachers
// ---------------------------------------------------------------------------
export function createEditInvite(token, projectId, expiresAt) {
db.prepare('INSERT INTO edit_invites (token, project_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, projectId, Date.now(), expiresAt);
}
export function listCollaborators(projectId) {
return db.prepare(`
SELECT c.user_id AS userId, u.email, c.added_at AS addedAt FROM collaborators c JOIN users u ON u.id = c.user_id
WHERE c.project_id = ? ORDER BY c.added_at
`).all(projectId);
}
export function countOpenEditInvites(projectId) {
return db.prepare('SELECT COUNT(*) AS n FROM edit_invites WHERE project_id = ? AND used_by IS NULL AND expires_at > ?').get(projectId, Date.now()).n;
}
export function removeCollaborator(projectId, userId) {
db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(projectId, userId);
}
/** Uses a single-use invite: -> { projectId, title } | { error }. The owner is told "you own this" rather than added. */
export function acceptEditInvite(token, userId) {
return db.transaction(() => {
const inv = db.prepare('SELECT project_id AS projectId, expires_at AS expiresAt, used_by AS usedBy FROM edit_invites WHERE token = ?').get(token);
if (!inv) return { error: 'This invite link is not valid.' };
if (inv.usedBy) return { error: 'This invite link has already been used.' };
if (inv.expiresAt <= Date.now()) return { error: 'This invite link has expired. Ask for a new one.' };
const project = db.prepare('SELECT title, user_id AS ownerId FROM projects WHERE id = ?').get(inv.projectId);
if (!project) return { error: 'That study no longer exists.' };
if (project.ownerId === userId) return { error: 'That is your own study.' };
db.prepare('INSERT OR IGNORE INTO collaborators (project_id, user_id, added_at) VALUES (?, ?, ?)').run(inv.projectId, userId, Date.now());
db.prepare('UPDATE edit_invites SET used_by = ? WHERE token = ?').run(userId, token);
return { projectId: inv.projectId, title: project.title };
})();
}
// ---------------------------------------------------------------------------
@@ -458,7 +643,7 @@ export function setShareToken(id, userId, token) {
/** Revokes a project's share link, scoped to userId. */
export function clearShareToken(id, userId) {
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
}
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
@@ -473,7 +658,39 @@ export function setOutlineShareToken(id, userId, token) {
}
export function clearOutlineShareToken(id, userId) {
db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
db.prepare('UPDATE projects SET outline_share_token = NULL, outline_share_expires = NULL, outline_share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
}
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
const shareCols = (outline) => (outline
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass' }
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass' });
/** { token, expiresAt, hasPasscode } for the owner's link settings. */
export function getShareSettings(id, userId, outline = false) {
const c = shareCols(outline);
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass } : null;
}
/** The stored passcode hash of a link (kept when only the expiry is changed). */
export function getSharePassHash(id, userId, outline = false) {
const c = shareCols(outline);
return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null;
}
/** Sets expiry (ms timestamp or null) and passcode hash (or null) on an existing link. */
export function setShareSettings(id, userId, { expiresAt, passHash }, outline = false) {
const c = shareCols(outline);
return db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
}
/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */
export function getSharedByToken(token, outline = false) {
const c = shareCols(outline);
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash FROM projects WHERE ${c.token} = ?`).get(token);
if (!row) return null;
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null }; } catch { return null; }
}
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
@@ -513,6 +730,7 @@ export function adminGetAllUsers() {
export function adminDeleteUser(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);
db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId);
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
})();
+151 -36
View File
@@ -10,13 +10,16 @@ import {
initDb, dataDir, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken,
setShareToken, clearShareToken, getShareSettings, setShareSettings, getSharedByToken, getSharePassHash,
setOutlineShareToken, clearOutlineShareToken,
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
getReadingProgress, setReadingProgress,
setReminderSettings,
setReminderSettings, setVotdSettings,
getReaderHistory, setReaderHistory,
isProjectOwner, createEditInvite, listCollaborators, countOpenEditInvites, removeCollaborator, acceptEditInvite,
getSetting, setSetting, createInvite, listInvites, deleteInvite, findOpenInvite, useInvite,
setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset,
} from './db.js';
import { SqliteSessionStore } from './sessionStore.js';
@@ -26,9 +29,13 @@ import {
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
generateResetCode, normalizeResetCode,
} from './auth.js';
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 { 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 { cleanMarks } from './readerMarks.js';
@@ -43,6 +50,7 @@ function userPayload(user) {
isAdmin: isAdminEmail(user.email),
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
reminderTime: user.reminderTime ?? null,
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
};
}
@@ -146,6 +154,9 @@ app.post('/api/auth/register', async (req, res) => {
const email = String(req.body?.email ?? '').trim().toLowerCase();
const password = String(req.body?.password ?? '');
const inviteCode = normalizeInviteCode(req.body?.invite);
const allowed = checkSignup({ mode: getSetting('signup', 'open'), userCount: countUsers(), inviteOk: !!findOpenInvite(inviteCode) });
if (!allowed.ok) return res.status(allowed.status).json({ error: allowed.error });
if (!isValidEmail(email)) {
return res.status(400).json({ error: 'Enter a valid email address.' });
}
@@ -158,6 +169,10 @@ app.post('/api/auth/register', async (req, res) => {
const passwordHash = await hashPassword(password);
const user = createUser({ id: randomUUID(), email, passwordHash });
if (getSetting('signup', 'open') === 'invite' && countUsers() > 1 && !useInvite(inviteCode, user.id)) {
adminDeleteUser(user.id); // someone used the code between the check and now
return res.status(403).json({ error: 'That invite code was just used. Ask for a new one.' });
}
// The very first account inherits any projects created before multi-user support existed.
if (countUsers() === 1) {
@@ -313,6 +328,11 @@ app.post('/api/auth/reset', async (req, res) => {
}
});
// What the sign-in page needs to know: whether to offer "create an account", and whether it needs an invite code.
app.get('/api/auth/signup-mode', (_req, res) => {
res.json({ mode: countUsers() === 0 ? 'open' : getSetting('signup', 'open') });
});
app.post('/api/auth/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('connect.sid');
@@ -367,6 +387,14 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
}
}
// the verse of the day is optional, and only offered when the server can look it up
const votdEnabled = !!req.body?.votdEnabled && votdAvailable();
const votdTime = String(req.body?.votdTime ?? '').trim();
if (votdEnabled) {
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' });
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' });
}
setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null });
setReminderSettings(req.session.userId, {
ntfyUrl: ntfyUrl || null,
ntfyTopic: ntfyTopic || null,
@@ -376,6 +404,7 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
});
res.json({
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
votdEnabled, votdTime: votdTime || null,
});
} catch (err) {
console.error('PATCH /api/auth/reminder-settings error:', err);
@@ -489,6 +518,7 @@ app.get('/api/account/export', requireAuth, (req, res) => {
marks: () => getReaderMarks(userId),
ink: () => getAllReaderInk(userId),
plans: () => getReadingProgress(userId),
history: () => getReaderHistory(userId),
});
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
res.json(backup);
@@ -632,6 +662,24 @@ app.get('/api/reader/marks', requireAuth, (req, res) => {
}
});
// GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole
app.get('/api/reader/history', requireAuth, (req, res) => {
try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) {
console.error('GET /api/reader/history error:', err);
res.status(500).json({ error: 'Failed to load your reading history.' });
}
});
app.put('/api/reader/history', requireAuth, (req, res) => {
try {
const merged = mergeHistory(getReaderHistory(req.session.userId), req.body);
setReaderHistory(req.session.userId, merged);
res.json(merged);
} catch (err) {
console.error('PUT /api/reader/history error:', err);
res.status(500).json({ error: 'Failed to save your reading history.' });
}
});
// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights })
app.put('/api/reader/marks', requireAuth, (req, res) => {
try {
@@ -715,28 +763,37 @@ app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
// GET /api/projects/:id/share — current share status for the project owner
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
try {
const project = getProject(req.params.id, req.session.userId);
if (!project) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: getShareToken(req.params.id, req.session.userId) });
const settings = getShareSettings(req.params.id, req.session.userId);
if (!settings) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode });
} catch (err) {
console.error('GET /api/projects/:id/share error:', err);
res.status(500).json({ error: 'Failed to load share status.' });
}
});
// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token
app.post('/api/projects/:id/share', requireAuth, (req, res) => {
// Sets up (or changes) a read-only link: body { expiresInDays?: number | null, passcode?: string | null }.
// Leaving a field out keeps what it was; null/"" clears it. Returns the (new or existing) token.
async function saveLink(req, res, outline) {
try {
const existing = getShareToken(req.params.id, req.session.userId);
const token = existing || randomUUID().replace(/-/g, '');
const ok = setShareToken(req.params.id, req.session.userId, token);
if (!ok) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: token });
const userId = req.session.userId;
const settings = getShareSettings(req.params.id, userId, outline);
if (!settings) return res.status(404).json({ error: 'Project not found.' });
const token = settings.token || randomUUID().replace(/-/g, '');
if (!settings.token && !(outline ? setOutlineShareToken : setShareToken)(req.params.id, userId, token)) return res.status(404).json({ error: 'Project not found.' });
const body = req.body ?? {};
const expiresAt = 'expiresInDays' in body ? expiryFromDays(body.expiresInDays) : settings.expiresAt;
// a blank passcode field means "keep it"; null clears; a string sets
const current = getSharePassHash(req.params.id, userId, outline);
const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current;
setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline);
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash });
} catch (err) {
console.error('POST /api/projects/:id/share error:', err);
console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err);
res.status(500).json({ error: 'Failed to enable sharing.' });
}
});
}
app.post('/api/projects/:id/share', requireAuth, (req, res) => saveLink(req, res, false));
// DELETE /api/projects/:id/share — revoke the share link
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
@@ -749,27 +806,49 @@ app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
}
});
// ---------------------------------------------------------------------------
// Co-teachers: other accounts that may edit a study. The owner makes a single-use invite link; whoever
// opens it while signed in joins. The owner (or the co-teacher themself) can remove an editor.
// ---------------------------------------------------------------------------
app.get('/api/projects/:id/collaborators', requireAuth, (req, res) => {
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
res.json({ collaborators: listCollaborators(req.params.id), pendingInvites: countOpenEditInvites(req.params.id) });
});
app.post('/api/projects/:id/collaborators/invite', requireAuth, (req, res) => {
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
const token = randomUUID().replace(/-/g, '');
const expiresAt = Date.now() + 7 * 86_400_000;
createEditInvite(token, req.params.id, expiresAt);
res.json({ token, expiresAt });
});
app.delete('/api/projects/:id/collaborators/:userId', requireAuth, (req, res) => {
const me = req.session.userId;
if (!isProjectOwner(req.params.id, me) && req.params.userId !== me) return res.status(403).json({ error: 'Only the owner can remove someone else.' });
removeCollaborator(req.params.id, req.params.userId);
res.json({ ok: true });
});
app.post('/api/join/:token', requireAuth, (req, res) => {
const result = acceptEditInvite(req.params.token, req.session.userId);
if (result.error) return res.status(400).json({ error: result.error });
res.json(result);
});
// Read-only outline links. A separate token from the study share link, and the public endpoint returns
// only the outlines (see outlineShare.js), never the notes or scripts.
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
try {
res.json({ shareToken: getOutlineShareToken(req.params.id, req.session.userId) });
const settings = getShareSettings(req.params.id, req.session.userId, true);
res.json({ shareToken: settings?.token ?? null, expiresAt: settings?.expiresAt ?? null, hasPasscode: !!settings?.hasPasscode });
} catch (err) {
console.error('GET /api/projects/:id/outline-share error:', err);
res.status(500).json({ error: 'Failed to load share status.' });
}
});
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => {
try {
const token = getOutlineShareToken(req.params.id, req.session.userId) || randomUUID().replace(/-/g, '');
if (!setOutlineShareToken(req.params.id, req.session.userId, token)) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: token });
} catch (err) {
console.error('POST /api/projects/:id/outline-share error:', err);
res.status(500).json({ error: 'Failed to enable sharing.' });
}
});
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => saveLink(req, res, true));
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
try {
@@ -781,24 +860,37 @@ app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
}
});
// GET /api/share-outline/:token — PUBLIC, no login required
app.get('/api/share-outline/:token', (req, res) => {
// A wrong passcode counts as a failed attempt for that link and address
async function openSharedLink(req, res, outline) {
const found = getSharedByToken(req.params.token, outline);
if (!found) { res.status(404).json({ error: outline ? 'This outline link is invalid or has been turned off.' : 'This share link is invalid or has been revoked.' }); return null; }
const keys = [`ip:${req.ip}`, `share:${req.params.token}`];
if (isLimited(...keys)) { res.status(429).json(TOO_MANY); return null; }
const access = await checkShareAccess({ expiresAt: found.expiresAt, passHash: found.passHash, supplied: req.get('X-Share-Passcode') });
if (!access.ok) {
if (access.needsPasscode && req.get('X-Share-Passcode')) recordFailure(...keys);
res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 });
return null;
}
return found.project;
}
// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed)
app.get('/api/share-outline/:token', async (req, res) => {
try {
const project = getProjectByOutlineToken(req.params.token);
if (!project) return res.status(404).json({ error: 'This outline link is invalid or has been turned off.' });
res.json(buildOutlinePayload(project));
const project = await openSharedLink(req, res, true);
if (project) res.json(buildOutlinePayload(project));
} catch (err) {
console.error('GET /api/share-outline/:token error:', err);
res.status(500).json({ error: 'Failed to load the shared outline.' });
}
});
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
app.get('/api/share/:token', (req, res) => {
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed)
app.get('/api/share/:token', async (req, res) => {
try {
const project = getProjectByShareToken(req.params.token);
if (!project) return res.status(404).json({ error: 'This share link is invalid or has been revoked.' });
res.json(project);
const project = await openSharedLink(req, res, false);
if (project) res.json(project);
} catch (err) {
console.error('GET /api/share/:token error:', err);
res.status(500).json({ error: 'Failed to load shared project.' });
@@ -848,6 +940,29 @@ app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async
}
});
// Who may sign up, and the invite codes for it
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
});
app.put('/api/admin/signup', requireAuth, requireAdmin, (req, res) => {
if (!isSignupMode(req.body?.mode)) return res.status(400).json({ error: `Mode must be one of: ${SIGNUP_MODES.join(', ')}.` });
setSetting('signup', req.body.mode);
res.json({ mode: req.body.mode });
});
app.post('/api/admin/invites', requireAuth, requireAdmin, (req, res) => {
const days = Math.min(90, Math.max(1, Number(req.body?.days) || 7));
const code = generateInviteCode();
createInvite(code, String(req.body?.note ?? '').trim().slice(0, 80), Date.now() + days * 86_400_000);
res.json({ code, expiresInDays: days });
});
app.delete('/api/admin/invites/:code', requireAuth, requireAdmin, (req, res) => {
deleteInvite(normalizeInviteCode(req.params.code));
res.json({ ok: true });
});
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
try {
res.json(adminGetAllProjects());
+15
View File
@@ -24,6 +24,21 @@ export function cleanPrefValue(key, value) {
}
return out;
}
if (key === 'studyTemplates') {
// The person's own study templates: prompts for the note boxes and scaffold text to start each chunk with
if (!Array.isArray(value)) return undefined;
const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
const fields = (o, names, max) => {
const out = {};
for (const n of names) { const t = text(o?.[n], max); if (t.trim()) out[n] = t; }
return out;
};
return value.slice(0, 12).filter((t) => t && typeof t.id === 'string' && /^user-[\w-]{1,40}$/.test(t.id) && typeof t.name === 'string' && t.name.trim()).map((t) => {
const prompts = fields(t.prompts, ['observation', 'interpretation', 'application'], 800);
const starters = fields(t.starters, ['generalNotes', 'observation', 'interpretation', 'application'], 2000);
return { id: t.id, name: text(t.name, 40).trim(), description: text(t.description, 120), ...(Object.keys(prompts).length ? { prompts } : {}), ...(Object.keys(starters).length ? { starters } : {}) };
});
}
if (key === 'position') {
if (!value || typeof value !== 'object') return undefined;
const book = String(value.book ?? '').toUpperCase();
+11
View File
@@ -67,3 +67,14 @@ test('the database keeps one row per user', async () => {
expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } });
expect(db.getUserPrefs('u2')).toEqual({});
});
test('study templates are kept only when well-formed, and trimmed to sane sizes', async () => {
const { cleanPrefValue } = await import('./prefs.js');
const good = { id: 'user-abc1', name: ' Lesson ', description: 'x', prompts: { observation: 'Look', junk: 'no' }, starters: { generalNotes: 'Context:', bogus: 'no' } };
expect(cleanPrefValue('studyTemplates', [good, { id: 'other', name: 'bad id' }, { id: 'user-2' }, null])).toEqual([
{ id: 'user-abc1', name: 'Lesson', description: 'x', prompts: { observation: 'Look' }, starters: { generalNotes: 'Context:' } },
]);
expect(cleanPrefValue('studyTemplates', 'nope')).toBeUndefined();
expect(cleanPrefValue('studyTemplates', Array.from({ length: 30 }, (_, i) => ({ id: `user-${i}`, name: `T${i}` }))).length).toBe(12);
expect(cleanPrefValue('studyTemplates', [{ id: 'user-a', name: 'x', starters: { observation: 'y'.repeat(5000) } }])[0].starters.observation.length).toBe(2000);
});
+32
View File
@@ -0,0 +1,32 @@
// What a person has read in the Reader: which chapters (how many times, and when last) and on which days.
// Merging two copies never loses anything, so two devices can both add to it: each chapter keeps the larger
// count and the later date, and the days are combined. The data comes from the browser, so it is rebuilt field by field.
const KEY = /^[1-3A-Z]{3}\.\d{1,3}$/;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
const MAX_DAYS = 5000;
export const emptyHistory = () => ({ chapters: {}, days: [] });
export function cleanHistory(raw) {
const chapters = {};
if (raw?.chapters && typeof raw.chapters === 'object' && !Array.isArray(raw.chapters)) {
for (const [key, v] of Object.entries(raw.chapters).slice(0, 1300)) {
if (!KEY.test(key) || !v || typeof v !== 'object') continue;
const n = Number.isInteger(v.n) ? Math.min(9999, Math.max(1, v.n)) : 1;
chapters[key] = { n, last: DATE.test(v.last ?? '') ? v.last : '' };
}
}
const days = Array.isArray(raw?.days) ? [...new Set(raw.days.filter((d) => typeof d === 'string' && DATE.test(d)))].sort().slice(-MAX_DAYS) : [];
return { chapters, days };
}
export function mergeHistory(a, b) {
const x = cleanHistory(a);
const y = cleanHistory(b);
const chapters = { ...x.chapters };
for (const [key, v] of Object.entries(y.chapters)) {
const mine = chapters[key];
chapters[key] = mine ? { n: Math.max(mine.n, v.n), last: mine.last > v.last ? mine.last : v.last } : v;
}
return { chapters, days: [...new Set([...x.days, ...y.days])].sort().slice(-MAX_DAYS) };
}
+23
View File
@@ -0,0 +1,23 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { cleanHistory, mergeHistory } from './readerHistory.js';
test('only well-formed chapters and days are kept', () => {
const out = cleanHistory({
chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'bad': { n: 1 }, 'GEN.1': { n: -5, last: 'yesterday' }, '1JN.2': null },
days: ['2026-10-02', 'nope', '2026-10-01', '2026-10-01'],
});
expect(out.chapters).toEqual({ 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '' } });
expect(out.days).toEqual(['2026-10-01', '2026-10-02']);
expect(cleanHistory(null)).toEqual({ chapters: {}, days: [] });
});
test('merging keeps the larger count, the later date and every day', () => {
const a = { chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '2026-09-01' } }, days: ['2026-10-01'] };
const b = { chapters: { 'JHN.3': { n: 1, last: '2026-10-05' }, 'ROM.8': { n: 1, last: '2026-10-04' } }, days: ['2026-10-04', '2026-10-05'] };
const m = mergeHistory(a, b);
expect(m.chapters['JHN.3']).toEqual({ n: 2, last: '2026-10-05' });
expect(Object.keys(m.chapters).sort()).toEqual(['GEN.1', 'JHN.3', 'ROM.8']);
expect(m.days).toEqual(['2026-10-01', '2026-10-04', '2026-10-05']);
expect(mergeHistory(m, m)).toEqual(m); // merging again changes nothing
});
+23 -1
View File
@@ -4,7 +4,8 @@
// No API keys or push-subscription plumbing needed.
import { lookup } from 'dns/promises';
import { isIP } from 'net';
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate } from './db.js';
import { fetchVerseOfDay, votdMessage } from './votd.js';
// A reminder still goes out if the server was briefly down (or restarting) at the
// exact minute — as long as it's within this window after the chosen time.
@@ -121,9 +122,30 @@ export async function checkReminders(now = new Date()) {
}
}
// Sends the verse of the day to everyone who asked for it, at their own time. One verse a day, the same for all.
export async function checkVotd(now = new Date()) {
const due = getUsersWithVotdEnabled().filter((u) => isDue({ reminderTime: u.votdTime, reminderTz: u.reminderTz, lastReminderDate: u.lastVotdDate }, now));
if (due.length === 0) return;
const verse = await fetchVerseOfDay(now);
if (!verse) return; // no key, or IQ Bible didn't answer: try again next minute (within the grace window)
for (const user of due) {
const key = `votd:${user.id}`;
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
_lastAttempt.set(key, now.getTime());
try {
await sendNtfy(user, votdMessage(verse));
_lastAttempt.delete(key);
setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Verse of the day failed for user ${user.id}:`, err.message);
}
}
}
/** Checks once a minute whether any user's reminder time has arrived. */
export function startReminderScheduler() {
setInterval(() => {
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err));
}, 60 * 1000);
}
+27
View File
@@ -0,0 +1,27 @@
// The rules for a read-only link: it can expire, and it can need a passcode. Kept separate from the routes
// so they can be tested on their own.
import bcrypt from 'bcryptjs';
export const MAX_PASSCODE = 100;
// "never" (null/0) or a number of days, up to a year -> an expiry time in ms (or null)
export function expiryFromDays(days, now = Date.now()) {
const n = Number(days);
if (!days || !Number.isFinite(n) || n <= 0) return null;
return now + Math.min(365, Math.round(n)) * 86_400_000;
}
export async function hashPasscode(passcode) {
const p = String(passcode ?? '').slice(0, MAX_PASSCODE);
return p.trim() ? bcrypt.hash(p.trim(), 10) : null;
}
// -> { ok: true } | { ok: false, status: 410, error } (expired) | { ok: false, status: 401, needsPasscode: true, error }
export async function checkShareAccess({ expiresAt, passHash, supplied }, now = Date.now()) {
if (expiresAt && expiresAt <= now) return { ok: false, status: 410, error: 'This link has expired. Ask the person who shared it for a new one.' };
if (!passHash) return { ok: true };
const given = String(supplied ?? '').slice(0, MAX_PASSCODE).trim();
if (!given) return { ok: false, status: 401, needsPasscode: true, error: 'This link needs a passcode.' };
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
return { ok: true };
}
+33
View File
@@ -0,0 +1,33 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { checkShareAccess, expiryFromDays, hashPasscode } from './shareLinks.js';
const NOW = 1_800_000_000_000;
test('expiry is a number of days from now, or never', () => {
expect(expiryFromDays(null, NOW)).toBeNull();
expect(expiryFromDays(0, NOW)).toBeNull();
expect(expiryFromDays('x', NOW)).toBeNull();
expect(expiryFromDays(7, NOW)).toBe(NOW + 7 * 86_400_000);
expect(expiryFromDays(9999, NOW)).toBe(NOW + 365 * 86_400_000); // never more than a year
});
test('a link with no rules is open; an expired one says so', async () => {
expect((await checkShareAccess({}, NOW)).ok).toBe(true);
expect(await checkShareAccess({ expiresAt: NOW + 1000 }, NOW)).toEqual({ ok: true });
expect(await checkShareAccess({ expiresAt: NOW - 1 }, NOW)).toMatchObject({ ok: false, status: 410 });
});
test('a passcode is asked for, and only the right one opens the link', async () => {
const passHash = await hashPasscode(' moses ');
expect(passHash).not.toContain('moses');
expect(await checkShareAccess({ passHash }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true });
expect(await checkShareAccess({ passHash, supplied: 'aaron' }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true, error: expect.stringMatching(/not right/) });
expect(await checkShareAccess({ passHash, supplied: ' moses ' }, NOW)).toEqual({ ok: true });
expect(await hashPasscode(' ')).toBeNull();
});
test('an expired link is reported as expired even before the passcode is checked', async () => {
const passHash = await hashPasscode('x');
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
});
+25
View File
@@ -0,0 +1,25 @@
// Who may create an account. The administrator picks one of three settings:
// open anyone who can reach the server (the original behaviour)
// invite only someone with an unused invite code from the administrator
// closed nobody new
// The very first account can always be made, so a fresh install can't lock itself out.
import { randomBytes } from 'crypto';
export const SIGNUP_MODES = ['open', 'invite', 'closed'];
export const isSignupMode = (m) => SIGNUP_MODES.includes(m);
// -> { ok: true } or { ok: false, status, error }. `inviteOk` says whether the code given is a valid unused one.
export function checkSignup({ mode, userCount, inviteOk }) {
if (userCount === 0 || mode === 'open') return { ok: true };
if (mode === 'closed') return { ok: false, status: 403, error: 'Sign-up is closed. Ask the administrator for an account.' };
if (!inviteOk) return { ok: false, status: 403, error: 'Sign-up needs an invite code from the administrator.' };
return { ok: true };
}
// An easy-to-read invite code (no look-alike characters), e.g. K7QP-4MXD-9TRW.
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
export function generateInviteCode() {
const chars = Array.from(randomBytes(12), (b) => ALPHABET[b % ALPHABET.length]).join('');
return `${chars.slice(0, 4)}-${chars.slice(4, 8)}-${chars.slice(8, 12)}`;
}
export const normalizeInviteCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^(.{4})(.{4})(.{4})$/, '$1-$2-$3');
+24
View File
@@ -0,0 +1,24 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { checkSignup, generateInviteCode, normalizeInviteCode, isSignupMode } from './signup.js';
test('open lets anyone in; the first account is always allowed', () => {
expect(checkSignup({ mode: 'open', userCount: 5, inviteOk: false }).ok).toBe(true);
expect(checkSignup({ mode: 'closed', userCount: 0, inviteOk: false }).ok).toBe(true);
expect(checkSignup({ mode: 'invite', userCount: 0, inviteOk: false }).ok).toBe(true);
});
test('invite-only needs a good code, closed lets nobody in', () => {
expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: false })).toMatchObject({ ok: false, status: 403 });
expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: true }).ok).toBe(true);
expect(checkSignup({ mode: 'closed', userCount: 2, inviteOk: true })).toMatchObject({ ok: false, status: 403 });
});
test('invite codes are readable and typed forgivingly', () => {
const code = generateInviteCode();
expect(code).toMatch(/^[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}$/);
expect(normalizeInviteCode(` ${code.toLowerCase().replace(/-/g, ' ')} `)).toBe(code);
expect(normalizeInviteCode('abcd2345jkmn')).toBe('ABCD-2345-JKMN');
expect(isSignupMode('invite')).toBe(true);
expect(isSignupMode('nope')).toBe(false);
});
+33
View File
@@ -0,0 +1,33 @@
// Today's verse of the day for the push notification: IQ Bible picks it (the same verse for everyone each UTC day),
// in the KJV, which is free to quote. Needs IQBIBLE_API_KEY on the server. One lookup a day serves every user.
import { bookNameFor } from './bibleApi.js';
let cached = null; // { date, verse }
export const votdAvailable = () => Boolean(process.env.IQBIBLE_API_KEY);
// IQ Bible's answer -> { reference, text }, or null when it isn't one we can use
export function parseVotd(body) {
const d = body?.data;
const book = bookNameFor(d?.book_usfm);
if (!book || !d?.chapter_number || !d?.verse_number || typeof d.text !== 'string' || !d.text.trim()) return null;
return { reference: `${book} ${d.chapter_number}:${d.verse_number}`, text: d.text.replace(/^[¶§*\s]+/, '').replace(/\s+/g, ' ').trim() };
}
export async function fetchVerseOfDay(now = new Date(), fetcher = fetch) {
const date = now.toISOString().slice(0, 10);
if (cached?.date === date) return cached.verse;
if (!votdAvailable()) return null;
const base = (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, '');
try {
const res = await fetcher(`${base}/bibles/eng_kjv/verse-of-the-day`, { headers: { 'X-IQBible-API-Key': process.env.IQBIBLE_API_KEY }, signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
const verse = parseVotd(await res.json());
if (verse) cached = { date, verse };
return verse;
} catch { return null; }
}
export const clearVotdCache = () => { cached = null; };
export const votdMessage = ({ reference, text }) => ({ title: `Verse of the day: ${reference}`, body: `${text}\n\n${reference} (KJV)`, tags: 'sunrise' });
+40
View File
@@ -0,0 +1,40 @@
// @vitest-environment node
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
import { parseVotd, fetchVerseOfDay, clearVotdCache, votdMessage } from './votd.js';
beforeEach(() => { clearVotdCache(); process.env.IQBIBLE_API_KEY = 'k'; });
afterEach(() => { delete process.env.IQBIBLE_API_KEY; vi.restoreAllMocks(); });
const body = { data: { book_usfm: 'ROM', chapter_number: 8, verse_number: 28, text: '¶ And we know that all things work together for good.' } };
const ok = () => vi.fn(async () => ({ ok: true, json: async () => body }));
test('IQ Bible\'s verse becomes a reference and clean text', () => {
expect(parseVotd(body)).toEqual({ reference: 'Romans 8:28', text: 'And we know that all things work together for good.' });
expect(parseVotd({ data: { book_usfm: 'XXX', chapter_number: 1, verse_number: 1, text: 'x' } })).toBeNull();
expect(parseVotd({})).toBeNull();
});
test('the notification carries the verse and its reference', () => {
expect(votdMessage({ reference: 'Romans 8:28', text: 'All things.' })).toEqual({ title: 'Verse of the day: Romans 8:28', body: 'All things.\n\nRomans 8:28 (KJV)', tags: 'sunrise' });
});
test('one lookup a day serves everyone, and a new day looks again', async () => {
const fetcher = ok();
const day1 = new Date('2026-10-06T12:00:00Z');
expect((await fetchVerseOfDay(day1, fetcher)).reference).toBe('Romans 8:28');
await fetchVerseOfDay(new Date('2026-10-06T23:00:00Z'), fetcher);
expect(fetcher).toHaveBeenCalledTimes(1);
await fetchVerseOfDay(new Date('2026-10-07T01:00:00Z'), fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher.mock.calls[0][0]).toContain('/bibles/eng_kjv/verse-of-the-day');
expect(fetcher.mock.calls[0][1].headers['X-IQBible-API-Key']).toBe('k');
});
test('without a key, or when IQ Bible fails, there is no verse (and nothing is remembered)', async () => {
delete process.env.IQBIBLE_API_KEY;
expect(await fetchVerseOfDay(new Date(), ok())).toBeNull();
process.env.IQBIBLE_API_KEY = 'k';
expect(await fetchVerseOfDay(new Date(), vi.fn(async () => ({ ok: false })))).toBeNull();
expect(await fetchVerseOfDay(new Date(), vi.fn(async () => { throw new Error('down'); }))).toBeNull();
expect((await fetchVerseOfDay(new Date(), ok())).reference).toBe('Romans 8:28'); // a later good answer still works
});