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

+111
View File
@@ -0,0 +1,111 @@
// Client for the server's IQ Bible proxy (/api/iq/...). The server holds the API key and decides what may be
// asked for; here we only build requests and keep answers for a few minutes so one open panel doesn't
// repeat the same call. (Longer caching is the server's job and follows the API's licence terms.)
const MEMO_MS = 5 * 60 * 1000;
const memo = new Map(); // key -> { at, promise }
let availability = null;
export function iqAvailable() {
availability ??= fetch('/api/iq/status', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : { available: false }))
.then((d) => Boolean(d.available))
.catch(() => false);
return availability;
}
export function resetIq() { availability = null; memo.clear(); }
const buildPath = (path, query) => {
const qs = new URLSearchParams(Object.entries(query ?? {}).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => [k, String(v)])).toString();
return `/api/iq/${path.replace(/^\/+/, '')}${qs ? `?${qs}` : ''}`;
};
// Link for a file the server streams from IQ Bible (calendar, CSV) — opened with the signed-in session.
export const iqUrl = buildPath;
// Resolves to { ok, data } or { ok: false, status, error }. Successful answers are remembered briefly.
export function iqGet(path, query, { fresh = false } = {}) {
const url = buildPath(path, query);
const hit = memo.get(url);
if (!fresh && hit && Date.now() - hit.at < MEMO_MS) return hit.promise;
const promise = fetch(url, { credentials: 'include' })
.then(async (res) => {
const data = await res.json().catch(() => null);
if (!res.ok) return { ok: false, status: res.status, error: data?.error?.message ?? (typeof data?.error === 'string' ? data.error : `HTTP ${res.status}`) };
return { ok: true, data };
})
.catch(() => ({ ok: false, status: null, error: 'Network error' }));
memo.set(url, { at: Date.now(), promise });
promise.then((r) => { if (!r.ok) memo.delete(url); });
return promise;
}
// Same, for endpoints that answer in plain text (ai/context with format=text). Resolves to a string or null.
export async function iqGetText(path, query) {
try {
const res = await fetch(buildPath(path, query), { credentials: 'include' });
return res.ok ? await res.text() : null;
} catch { return null; }
}
// A study's passages as background reading for an AI prompt: for the first verse of each chunk, IQ Bible's
// bundle of verse text, cross-references, commentary and Strong's glosses. Capped, because every lookup
// spends account allowance (4 requests each).
export function chunkRefs(project, max = 6) {
const refs = [];
for (const ch of project?.chapters ?? []) {
for (const c of ch.chunks ?? []) {
if (ch.bookAbbrev && Number(ch.chapter) > 0 && c.startVerse > 0) refs.push({ ref: `${ch.bookAbbrev}.${Number(ch.chapter)}.${c.startVerse}`, label: `${ch.book} ${ch.chapter}:${c.startVerse}` });
}
}
return { refs: refs.slice(0, max), skipped: Math.max(0, refs.length - max) };
}
export async function gatherIqContext(project, { max = 6, version = 'eng_kjv' } = {}) {
const { refs, skipped } = chunkRefs(project, max);
const parts = [];
for (const { ref, label } of refs) {
const text = await iqGetText('ai/context', { ref, include: 'crossrefs,commentary,strongs', version, format: 'text' });
if (text?.trim()) parts.push(`### ${label}\n${text.trim()}`);
}
return { text: parts.join('\n\n'), count: parts.length, skipped };
}
// "G0026" / "g26" / "H0430" -> "G26" / "H430"; anything else -> null
export function normalizeStrongs(s) {
const m = /^([GH])0*(\d+)/i.exec(String(s ?? '').trim());
return m ? `${m[1].toUpperCase()}${Number(m[2])}` : null;
}
// The commentary entries that speak to `verse`: those covering it by range, else the closest earlier ones.
export function pickIqEntries(entries, chapter, verse) {
const here = (entries ?? []).filter((e) => (e.chapter ?? chapter) === chapter && (e.verse ?? 0) > 0 && e.text);
const covers = here.filter((e) => (e.verse ?? 0) <= verse && verse <= ((e.chapter_end ?? chapter) === chapter ? (e.verse_end ?? e.verse) : Infinity));
if (covers.length) return covers;
const earlier = here.filter((e) => e.verse <= verse);
if (!earlier.length) return [];
const start = Math.max(...earlier.map((e) => e.verse));
return earlier.filter((e) => e.verse === start);
}
// Plain label for a loosely-shaped record from the API (topics, variants, harmony sections, ...).
const LABEL_KEYS = ['title', 'name', 'topic', 'label', 'heading', 'citation', 'event', 'term'];
export function describeRecord(item) {
if (item == null) return { title: '', lines: [] };
if (typeof item !== 'object') return { title: String(item), lines: [] };
const titleKey = LABEL_KEYS.find((k) => typeof item[k] === 'string' && item[k]);
const lines = Object.entries(item)
.filter(([k, v]) => k !== titleKey && typeof v === 'string' && v && v.length < 600 && !/url|slug|id$/i.test(k))
.slice(0, 4)
.map(([k, v]) => ({ key: k.replace(/_/g, ' '), value: v }));
return { title: titleKey ? item[titleKey] : '', lines };
}
// The list inside a response ({ data: [...] }, { topics: [...] }, or the first array found).
export function listOf(data) {
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.data)) return data.data;
const first = Object.values(data).find(Array.isArray);
return first ?? [];
}
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from 'vitest';
import { normalizeStrongs, pickIqEntries, describeRecord, listOf, iqUrl } from './iqbible.js';
test('Strong\'s numbers are normalised', () => {
expect(normalizeStrongs('G0026')).toBe('G26');
expect(normalizeStrongs('h430')).toBe('H430');
expect(normalizeStrongs('H1647G')).toBe('H1647');
expect(normalizeStrongs('nope')).toBeNull();
});
test('commentary entries are chosen by range, then by the closest earlier verse', () => {
const entries = [
{ chapter: 3, verse: 0, text: 'intro' },
{ chapter: 3, verse: 1, verse_end: 8, text: 'a' },
{ chapter: 3, verse: 9, text: 'b' },
{ chapter: 3, verse: 9, text: 'b2' },
];
expect(pickIqEntries(entries, 3, 5).map((e) => e.text)).toEqual(['a']);
expect(pickIqEntries(entries, 3, 12).map((e) => e.text)).toEqual(['b', 'b2']);
expect(pickIqEntries(entries, 4, 1)).toEqual([]);
});
test('loosely shaped records are summarised and lists found', () => {
expect(describeRecord({ name: 'faith', edition: 'iqbible', citation: 'John 3:16' }).title).toBe('faith');
expect(describeRecord('x').title).toBe('x');
expect(listOf({ status: 'ok', data: [1, 2] })).toEqual([1, 2]);
expect(listOf({ topics: [3] })).toEqual([3]);
expect(iqUrl('bibles/eng_kjv/reading-plan', { days: 30, books: 'MAT,MRK', format: 'ical', skip: '' })).toBe('/api/iq/bibles/eng_kjv/reading-plan?days=30&books=MAT%2CMRK&format=ical');
});
+69
View File
@@ -0,0 +1,69 @@
// Combines two copies of one study (this device's and the other device's) chunk by chunk, so a
// conflict doesn't force a choice between them. Chunks are matched by id; each chunk carries the time it
// was last edited here (editedAt). Nothing the person wrote is dropped: where both copies changed the
// same note, the newer one stays and the older one is appended under a marker.
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle'];
const LIST_FIELDS = ['tags', 'crossReferences', 'greekWords'];
export const MERGE_MARKER = '— from your other device —';
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const blank = (s) => !(s ?? '').toString().trim();
function mergeText(newer, older) {
if (same(newer, older) || blank(older)) return newer ?? '';
if (blank(newer)) return older;
if (newer.includes(older)) return newer;
if (older.includes(newer)) return older;
return `${newer.replace(/\s+$/, '')}\n\n${MERGE_MARKER}\n${older}`;
}
function union(a = [], b = []) {
const seen = new Set();
return [...a, ...b].filter((item) => { const k = JSON.stringify(item); if (seen.has(k)) return false; seen.add(k); return true; });
}
export function mergeChunk(mine, theirs) {
if (same(mine, theirs)) return { chunk: mine, changed: false };
const mineNewer = (mine.editedAt ?? 0) >= (theirs.editedAt ?? 0);
const [newer, older] = mineNewer ? [mine, theirs] : [theirs, mine];
const out = { ...older, ...newer };
TEXT_FIELDS.forEach((f) => { out[f] = mergeText(newer[f], older[f]); });
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
return { chunk: out, changed: !same(out, mine) };
}
const chapterKey = (ch) => `${ch.bookAbbrev}|${ch.chapter}`;
// -> { project, stats: { added, merged } } where `added` counts chunks that only the other copy had and
// `merged` counts chunks both copies had that differed. Settings such as the title and the open chunk stay mine.
export function mergeStudies(mine, theirs) {
const stats = { added: 0, merged: 0 };
const theirChapters = new Map((theirs.chapters ?? []).map((ch) => [chapterKey(ch), ch]));
const chapters = (mine.chapters ?? []).map((ch) => {
const other = theirChapters.get(chapterKey(ch));
theirChapters.delete(chapterKey(ch));
if (!other) return ch;
const theirChunks = new Map((other.chunks ?? []).map((c) => [c.id, c]));
const chunks = (ch.chunks ?? []).map((c) => {
const o = theirChunks.get(c.id);
theirChunks.delete(c.id);
if (!o) return c;
const { chunk, changed } = mergeChunk(c, o);
if (changed) stats.merged += 1;
return chunk;
});
const extra = [...theirChunks.values()];
stats.added += extra.length;
return { ...ch, chunks: [...chunks, ...extra] };
});
for (const ch of theirChapters.values()) {
stats.added += (ch.chunks ?? []).length;
chapters.push(ch);
}
const ids = new Set(chapters.flatMap((c) => c.chunks.map((k) => k.id)));
return {
project: { ...mine, chapters, selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
stats,
};
}
+51
View File
@@ -0,0 +1,51 @@
import { test, expect } from 'vitest';
import { mergeStudies, mergeChunk, MERGE_MARKER } from './mergeStudies.js';
const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: '', interpretation: '', application: '', generalNotes: '', finalScript: '', tags: [], crossReferences: [], greekWords: [], ...extra });
const study = (chunks, extra = {}) => ({ id: 's', title: 'T', selectedChunkId: chunks[0]?.id ?? null, chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks }], ...extra });
test('notes written on different chunks, or different fields, are both kept', () => {
const mine = study([chunk('a', { observation: 'mine' }), chunk('b')]);
const theirs = study([chunk('a', { application: 'theirs' }), chunk('b', { interpretation: 'also theirs' })]);
const { project, stats } = mergeStudies(mine, theirs);
const [a, b] = project.chapters[0].chunks;
expect(a.observation).toBe('mine');
expect(a.application).toBe('theirs');
expect(b.interpretation).toBe('also theirs');
expect(stats.merged).toBe(2);
});
test('chunks only one copy has are kept, including whole new chapters', () => {
const mine = study([chunk('a')]);
const theirs = study([chunk('a'), chunk('c', { observation: 'new here' })]);
theirs.chapters.push({ book: 'John', bookAbbrev: 'JHN', chapter: '4', verses: [], chunks: [chunk('d')] });
const { project, stats } = mergeStudies(mine, theirs);
expect(project.chapters[0].chunks.map((c) => c.id)).toEqual(['a', 'c']);
expect(project.chapters).toHaveLength(2);
expect(stats.added).toBe(2);
});
test('the same note changed on both: the newer one stays and the older is kept under a marker', () => {
const mine = chunk('a', { observation: 'Version one', editedAt: 100 });
const theirs = chunk('a', { observation: 'Version two', editedAt: 200 });
const { chunk: out } = mergeChunk(mine, theirs);
expect(out.observation.startsWith('Version two')).toBe(true);
expect(out.observation).toContain(MERGE_MARKER);
expect(out.observation).toContain('Version one');
});
test('an edit that only extends the other copy does not duplicate it; lists are unioned', () => {
const { chunk: out } = mergeChunk(
chunk('a', { observation: 'Short', tags: ['x'], editedAt: 1 }),
chunk('a', { observation: 'Short, and then more', tags: ['y', 'x'], editedAt: 2 }),
);
expect(out.observation).toBe('Short, and then more');
expect(out.tags.sort()).toEqual(['x', 'y']);
});
test('identical copies merge to themselves', () => {
const s = study([chunk('a', { observation: 'same' })]);
const { project, stats } = mergeStudies(s, structuredClone(s));
expect(project).toEqual(s);
expect(stats).toEqual({ added: 0, merged: 0 });
});
+50
View File
@@ -0,0 +1,50 @@
// Starting points for a new study. A template changes what the note boxes ask (their prompts) and can
// pre-fill the "Background / general notes" box of each new chunk with a scaffold to fill in. Choosing
// "Blank" leaves the default Observe / Interpret / Apply prompts.
export const STUDY_TEMPLATES = [
{ id: 'blank', name: 'Blank', description: 'Observe, Interpret, Apply with the standard prompts.' },
{
id: 'inductive',
name: 'Inductive study',
description: 'Careful, question-led notes: observe the text, interpret it in context, then apply it.',
prompts: {
observation: 'Look before you leap.\n• List the people, places, and events.\n• Mark repeated words, contrasts, and connecting words (but, therefore, so that).\n• What is the main verb or command here?\n• What questions does the text raise?',
interpretation: 'What did it mean then?\n• What is the author\'s main point in this section?\n• How does it fit the paragraph before and after?\n• Which other Scripture sheds light on it?\n• What does it teach about God?',
application: 'What now?\n• What truth must I believe?\n• What sin to confess, or command to obey?\n• What will I do this week, and who will I tell?',
},
starter: 'Context (author, audience, setting):\n\nStructure (how the passage is built):\n\nKey words:\n',
},
{
id: 'sermon',
name: 'Sermon prep',
description: 'A big idea, an outline and illustrations for each passage, for teaching or preaching.',
prompts: {
observation: 'What does the text say?\n• The flow of thought, verse by verse.\n• Key terms and grammar worth noting.',
interpretation: 'What does it mean?\n• The main point the author is making.\n• Where it sits in the whole Bible story, and how it points to Christ.',
application: 'How should it change us?\n• Who is in the room, and what do they need to hear?\n• Specific steps, not just ideas.',
},
starter: 'Big idea (one sentence):\n\nOutline:\n1. \n2. \n3. \n\nIllustration:\n\nChrist in this text:\n\nCall to respond:\n',
},
{
id: 'devotional',
name: 'Devotional',
description: 'Gentle, personal prompts for reading and praying through a passage.',
prompts: {
observation: 'Read it slowly, twice.\n• What word or phrase stands out?\n• What do you notice about God here?',
interpretation: 'What is God showing you?\n• What does this reveal about who God is?\n• What does it say about who you are?',
application: 'Respond.\n• What will you thank God for?\n• What will you ask, confess, or obey?\n• A prayer in your own words:',
},
starter: '',
},
];
export const getTemplate = (id) => STUDY_TEMPLATES.find((t) => t.id === id) ?? STUDY_TEMPLATES[0];
// The prompt for one note box in a study built from `templateId`, or undefined for the default one.
export const templatePrompt = (templateId, field) => getTemplate(templateId).prompts?.[field];
// Fields a new chunk starts with under this template.
export const templateStarter = (templateId) => {
const starter = getTemplate(templateId).starter;
return starter ? { generalNotes: starter } : {};
};
+18
View File
@@ -0,0 +1,18 @@
import { test, expect } from 'vitest';
import { getTemplate, templatePrompt, templateStarter, STUDY_TEMPLATES } from './studyTemplates.js';
import { blankChunk } from '../hooks/useChunkBuilder.js';
test('unknown or missing templates are the blank one', () => {
expect(getTemplate('nope').id).toBe('blank');
expect(getTemplate(undefined).id).toBe('blank');
expect(templatePrompt('blank', 'observation')).toBeUndefined();
expect(templateStarter('blank')).toEqual({});
});
test('templates change the prompts and may pre-fill background notes', () => {
expect(STUDY_TEMPLATES.map((t) => t.id)).toEqual(['blank', 'inductive', 'sermon', 'devotional']);
expect(templatePrompt('sermon', 'application')).toMatch(/room/);
expect(blankChunk(1, 5, null, 'sermon').generalNotes).toMatch(/Big idea/);
expect(blankChunk(1, 5).generalNotes).toBe('');
expect(blankChunk(1, 5, null, 'devotional').generalNotes).toBe('');
});
+19
View File
@@ -0,0 +1,19 @@
// Fetches a chapter's raw text for building a study (the free Bible source). The Reader's "Save for
// offline" downloads go through the same address, so a saved book builds a study with no connection.
// Failures say what's wrong and what to do, instead of a generic error.
export async function fetchStudyChapter(translation, bookAbbrev, chapter, { signal } = {}) {
const code = translation === 'NET' ? 'eng_net' : (translation || 'BSB');
let res;
try {
res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal });
} catch (err) {
if (err?.name === 'AbortError') throw err;
const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
throw new Error(offline
? "You're offline and this chapter isn't saved on this device. In the Reader, use Save for offline while connected."
: 'Could not reach the Bible text. Check your connection and try again.', { cause: err });
}
if (res.status === 503) throw new Error("You're offline and this chapter isn't saved on this device. In the Reader, use Save for offline while connected.");
if (!res.ok) throw new Error('Unable to load chapter.');
return res.json();
}
+22
View File
@@ -0,0 +1,22 @@
// Small per-account lists of study ids kept in localStorage so sync work survives a closed tab or a
// dropped connection: studies saved here that the server hasn't accepted ("unsynced"), studies deleted
// here whose server copy is still to be removed ("deletes"), and studies the server was known to have
// ("known", so one that has since vanished from it can be recognised as deleted on another device).
import { indexKey } from './bibleUtils.jsx';
function idSet(kind) {
const key = () => `${indexKey()}:${kind}`;
const read = () => { try { const v = JSON.parse(localStorage.getItem(key()) ?? '[]'); return Array.isArray(v) ? v : []; } catch { return []; } };
const write = (ids) => { try { localStorage.setItem(key(), JSON.stringify([...new Set(ids)])); } catch { /* storage unavailable */ } };
return {
list: read,
has: (id) => read().includes(id),
add: (id) => write([...read(), id]),
remove: (id) => write(read().filter((x) => x !== id)),
replace: write,
};
}
export const unsyncedStudies = idSet('unsynced');
export const pendingDeletes = idSet('deletes');
export const knownOnServer = idSet('known');
+20
View File
@@ -0,0 +1,20 @@
import { test, expect, beforeEach } from 'vitest';
import { unsyncedStudies, pendingDeletes, knownOnServer } from './syncQueue.js';
beforeEach(() => localStorage.clear());
test('queues remember ids, once each, and forget them', () => {
unsyncedStudies.add('a'); unsyncedStudies.add('a'); unsyncedStudies.add('b');
expect(unsyncedStudies.list()).toEqual(['a', 'b']);
unsyncedStudies.remove('a');
expect(unsyncedStudies.has('a')).toBe(false);
expect(unsyncedStudies.has('b')).toBe(true);
});
test('the three queues are kept apart', () => {
pendingDeletes.add('x');
knownOnServer.replace(['y', 'z']);
expect(pendingDeletes.list()).toEqual(['x']);
expect(knownOnServer.list()).toEqual(['y', 'z']);
expect(unsyncedStudies.list()).toEqual([]);
});
+1 -1
View File
@@ -69,7 +69,7 @@ export const shortHebrewGloss = (kjvDef) => {
// take first chunk before comma, semicolon, or opening paren
s = s.split(/[,;(]/)[0].trim();
// strip trailing punctuation
s = s.replace(/[.\-]+$/, '').trim();
s = s.replace(/[.-]+$/, '').trim();
return s;
};