Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s

Sync and data safety
- Autosave flushes on leaving a study or the tab; storage-full errors are reported
- Unsynced studies, deletes and ink are queued and retried when back online
- Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect
- Server assigns study versions (no client-clock conflicts); "Combine both"
  merges a conflicted study chunk by chunk
- Reading-plan progress made offline is merged, not overwritten; plans, ink and
  marks are cleared from the device on sign-out

Security
- Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and
  account; constant-time login; TOTP replay protection; input validation;
  SESSION_SECRET required in production; admin user delete fixed
- Forgot password: one-time code sent to the account's ntfy topic

Features
- IQ Bible proxy (route allow-list, text-only caching per its terms): more
  commentaries, lexicons, study aids, daily verse and devotional, plan calendar
  export, narrations, podcast background
- Study templates, reading-plan catch-up, account export, daily database snapshots

Tests and tooling
- ESLint config; App tests split into src/test; tests for sync queues, library
  hook, merge, templates, IQ Bible UI and reset form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:13:36 -04:00
1 parent 6bb392933a
commit f274bc9c8a
67 files changed
+4734 -1569

No files matched your search

+49 -2
View File
@@ -9,6 +9,13 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
let db;
export const dataDir = () => DATA_DIR;
/** A consistent copy of the whole database, made while it is in use. */
export function backupDatabase(destPath) {
return db.backup(destPath);
}
// ---------------------------------------------------------------------------
// Init — create tables if they don't exist, migrate older schemas
// ---------------------------------------------------------------------------
@@ -89,6 +96,16 @@ export function initDb() {
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');
// One-time codes for "forgot password": hashed, short-lived, limited tries.
db.exec(`
CREATE TABLE IF NOT EXISTS password_resets (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
expires INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0
);
`);
// Reader ink — one row per user + book + chapter, stored as JSON
db.exec(`
CREATE TABLE IF NOT EXISTS reader_ink (
@@ -232,6 +249,30 @@ export function setLastReminderDate(userId, dateString) {
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
}
// ---------------------------------------------------------------------------
// Password reset codes
// ---------------------------------------------------------------------------
export function setPasswordReset(userId, codeHash, expires) {
db.prepare(`
INSERT INTO password_resets (user_id, code_hash, expires, attempts) VALUES (?, ?, ?, 0)
ON CONFLICT(user_id) DO UPDATE SET code_hash = excluded.code_hash, expires = excluded.expires, attempts = 0
`).run(userId, codeHash, expires);
}
export function getPasswordReset(userId) {
const row = db.prepare('SELECT code_hash AS codeHash, expires, attempts FROM password_resets WHERE user_id = ?').get(userId);
return row && row.expires > Date.now() ? row : null;
}
export function bumpPasswordResetAttempts(userId) {
db.prepare('UPDATE password_resets SET attempts = attempts + 1 WHERE user_id = ?').run(userId);
}
export function clearPasswordReset(userId) {
db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(userId);
}
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
export function setBackupCodeHashes(userId, backupCodeHashes) {
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
@@ -373,7 +414,9 @@ export function upsertProject(project, userId) {
return null;
}
const lastEdited = project.lastEdited ?? Date.now();
// The server's clock decides the version, so a device with a wrong clock can't hide or fake changes.
const prev = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(project.id);
const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1);
const chapterSummary = buildSummary(project);
const updated = { ...project, lastEdited };
@@ -468,7 +511,11 @@ 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 users WHERE id = ?').run(userId);
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
db.transaction(() => {
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);
})();
}
/** Every project across every user, with the owner's email, for the admin projects list. */