From 6145916872ef92bab60370f016a3dc94c05da4ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:01:20 +0000 Subject: [PATCH 1/9] feat: add API read-only mode for the move (#130) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnPh61Aps5neY87hh1pu9T --- api/_lib/cors.js | 13 +- api/_lib/firebase-auth.js | 9 +- api/login.js | 3 +- app/src/lib/__tests__/apiReadOnly.test.js | 194 ++++++++++++++++++++++ server/readOnlyMiddleware.js | 22 +++ server/server.js | 4 + shared/readOnly.js | 30 ++++ 7 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 app/src/lib/__tests__/apiReadOnly.test.js create mode 100644 server/readOnlyMiddleware.js create mode 100644 shared/readOnly.js diff --git a/api/_lib/cors.js b/api/_lib/cors.js index 9b03bb5..00ed4e9 100644 --- a/api/_lib/cors.js +++ b/api/_lib/cors.js @@ -1,3 +1,5 @@ +import { isBlockedWrite, READ_ONLY_BODY, READ_ONLY_STATUS } from '../../shared/readOnly.js'; + const allowedOrigins = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173,http://localhost:3000') .split(',') .map(origin => origin.trim()) @@ -47,11 +49,14 @@ export function setCorsHeaders(req, res) { } /** - * Higher-order function to handle CORS for a handler + * Higher-order function to handle CORS for a handler. + * Also refuses writes while the API is read-only (API_READ_ONLY=true). * @param {Function} handler - The route handler function + * @param {Object} [options] + * @param {boolean} [options.allowWhenReadOnly] - Let POSTs through in read-only mode (sign-in only) * @returns {Function} Wrapped handler with CORS support */ -export function handleCors(handler) { +export function handleCors(handler, { allowWhenReadOnly = false } = {}) { return async (req, res) => { const allowedOrigin = setCorsHeaders(req, res); @@ -64,6 +69,10 @@ export function handleCors(handler) { return res.status(204).end(); } + if (isBlockedWrite(req.method, { allowWrite: allowWhenReadOnly })) { + return res.status(READ_ONLY_STATUS).json(READ_ONLY_BODY); + } + return handler(req, res); }; } diff --git a/api/_lib/firebase-auth.js b/api/_lib/firebase-auth.js index e7cb4e8..7222ccb 100644 --- a/api/_lib/firebase-auth.js +++ b/api/_lib/firebase-auth.js @@ -1,4 +1,5 @@ import { createVerify } from 'node:crypto'; +import { isApiReadOnly } from '../../shared/readOnly.js'; const FIREBASE_PROJECT_ID = process.env.FIREBASE_PROJECT_ID || @@ -184,7 +185,10 @@ async function linkExistingUser(existingUser, firebaseUser, query) { return existingUser; } -export async function getOrCreateFirebaseUser(firebaseUser, query) { +// In API read-only mode this only looks up an already-linked user: it never +// syncs the email, links an existing account or creates one, so nothing in the +// database changes while it is copied. +export async function getOrCreateFirebaseUser(firebaseUser, query, { readOnly = isApiReadOnly() } = {}) { if (!firebaseUser?.uid || typeof query !== 'function') { return null; } @@ -204,6 +208,7 @@ export async function getOrCreateFirebaseUser(firebaseUser, query) { if (result.rows.length > 0) { const localUser = result.rows[0]; + if (readOnly) return localUser; // If the Firebase user has changed their verified email, sync it to the local record. // Uses IS DISTINCT FROM so the UPDATE is a no-op when another request already synced. if (firebaseUser.emailVerified && firebaseUser.email && localUser.email !== firebaseUser.email) { @@ -226,6 +231,8 @@ export async function getOrCreateFirebaseUser(firebaseUser, query) { return localUser; } + if (readOnly) return null; + if (firebaseUser.email && !firebaseUser.emailVerified) { return null; } diff --git a/api/login.js b/api/login.js index 74e3b5e..4dde75e 100644 --- a/api/login.js +++ b/api/login.js @@ -34,4 +34,5 @@ async function handler(req, res) { return res.status(status).json(body); } -export default handleCors(handler); +// Sign-in stays open in read-only mode so people can still read their data. +export default handleCors(handler, { allowWhenReadOnly: true }); diff --git a/app/src/lib/__tests__/apiReadOnly.test.js b/app/src/lib/__tests__/apiReadOnly.test.js new file mode 100644 index 0000000..939b60d --- /dev/null +++ b/app/src/lib/__tests__/apiReadOnly.test.js @@ -0,0 +1,194 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const query = vi.fn(); +vi.mock('../../../../api/_lib/db.js', () => ({ query: (...args) => query(...args) })); + +const { + isApiReadOnly, + isBlockedWrite, + READ_ONLY_BODY, + READ_ONLY_STATUS, +} = await import('../../../../shared/readOnly.js'); +const { getOrCreateFirebaseUser } = await import('../../../../api/_lib/firebase-auth.js'); + +const require = createRequire(import.meta.url); +const { createReadOnlyMiddleware } = require('../../../../server/readOnlyMiddleware.js'); + +function makeRes() { + const res = { statusCode: 200, body: undefined, ended: false }; + res.status = (code) => { res.statusCode = code; return res; }; + res.json = (body) => { res.body = body; return res; }; + res.send = (body) => { res.body = body; return res; }; + res.end = () => { res.ended = true; return res; }; + res.setHeader = () => res; + return res; +} + +beforeEach(() => { + query.mockReset(); + query.mockResolvedValue({ rows: [], rowCount: 0 }); +}); + +afterEach(() => { + delete process.env.API_READ_ONLY; +}); + +describe('isApiReadOnly', () => { + it('is off unless API_READ_ONLY is set to a true value', () => { + expect(isApiReadOnly({})).toBe(false); + expect(isApiReadOnly({ API_READ_ONLY: 'false' })).toBe(false); + expect(isApiReadOnly({ API_READ_ONLY: '0' })).toBe(false); + expect(isApiReadOnly({ API_READ_ONLY: 'true' })).toBe(true); + expect(isApiReadOnly({ API_READ_ONLY: ' TRUE ' })).toBe(true); + expect(isApiReadOnly({ API_READ_ONLY: '1' })).toBe(true); + }); + + it('blocks only unsafe methods, and only when read-only', () => { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + expect(isBlockedWrite(method, { readOnly: true })).toBe(true); + expect(isBlockedWrite(method, { readOnly: false })).toBe(false); + expect(isBlockedWrite(method, { readOnly: true, allowWrite: true })).toBe(false); + } + for (const method of ['GET', 'HEAD', 'OPTIONS']) { + expect(isBlockedWrite(method, { readOnly: true })).toBe(false); + } + }); +}); + +const ENDPOINTS = { + 'register.js': () => import('../../../../api/register.js'), + 'saved-calcs.js': () => import('../../../../api/saved-calcs.js'), + 'contributor.js': () => import('../../../../api/contributor.js'), + 'upload-data.js': () => import('../../../../api/upload-data.js'), + 'user-data.js': () => import('../../../../api/user-data.js'), +}; + +// Every write the Vercel functions accept. Kept in step with the method checks +// in api/*.js; the read-only check runs in handleCors before any of them. +const VERCEL_WRITES = [ + ['register.js', 'POST'], + ['saved-calcs.js', 'POST'], + ['saved-calcs.js', 'DELETE'], + ['saved-calcs.js', 'PATCH'], + ['contributor.js', 'POST'], + ['upload-data.js', 'POST'], + ['user-data.js', 'POST'], + ['user-data.js', 'PUT'], + ['user-data.js', 'PATCH'], + ['user-data.js', 'DELETE'], +]; + +describe('Vercel functions in read-only mode', () => { + it.each(VERCEL_WRITES)('%s refuses %s with the read-only error', async (file, method) => { + process.env.API_READ_ONLY = 'true'; + const { default: handler } = await ENDPOINTS[file](); + const res = makeRes(); + + await handler({ method, headers: {}, body: {}, query: { id: '1' } }, res); + + expect(res.statusCode).toBe(READ_ONLY_STATUS); + expect(res.body).toEqual(READ_ONLY_BODY); + expect(query).not.toHaveBeenCalled(); + }); + + it('keeps reads working', async () => { + process.env.API_READ_ONLY = 'true'; + const { default: handler } = await import('../../../../api/community-data.js'); + const res = makeRes(); + + await handler({ method: 'GET', headers: {}, query: {} }, res); + + expect(res.statusCode).not.toBe(READ_ONLY_STATUS); + }); + + it('keeps sign-in open', async () => { + process.env.API_READ_ONLY = 'true'; + const { default: handler } = await import('../../../../api/login.js'); + const res = makeRes(); + + await handler({ method: 'POST', headers: {}, body: { username: 'a', password: 'b' } }, res); + + expect(res.statusCode).not.toBe(READ_ONLY_STATUS); + }); + + it('accepts writes when read-only mode is off', async () => { + const { default: handler } = await import('../../../../api/register.js'); + const res = makeRes(); + + await handler({ method: 'POST', headers: {}, body: {} }, res); + + expect(res.statusCode).not.toBe(READ_ONLY_STATUS); + }); +}); + +describe('Express server in read-only mode', () => { + const serverSource = readFileSync(new URL('../../../../server/server.js', import.meta.url), 'utf8'); + const writeRoutes = [...serverSource.matchAll(/app\.(post|put|patch|delete)\('(\/api\/[^']+)'/g)] + .map(([, method, path]) => [method.toUpperCase(), path]); + + async function run(method, path, readOnly) { + const middleware = createReadOnlyMiddleware(Promise.resolve({ + READ_ONLY_BODY, + READ_ONLY_STATUS, + isBlockedWrite: (m, opts) => isBlockedWrite(m, { ...opts, readOnly }), + })); + const res = makeRes(); + const next = vi.fn(); + await middleware({ method, originalUrl: path.replace(/:\w+/g, '1') }, res, next); + return { res, next }; + } + + it('registers the middleware before every route', () => { + const middlewareAt = serverSource.indexOf("app.use('/api', createReadOnlyMiddleware())"); + const firstRouteAt = serverSource.search(/app\.(get|post|put|patch|delete)\('\/api\//); + expect(middlewareAt).toBeGreaterThan(-1); + expect(middlewareAt).toBeLessThan(firstRouteAt); + }); + + it('finds the server write routes', () => { + expect(writeRoutes.length).toBeGreaterThanOrEqual(10); + }); + + it('refuses every write route except sign-in', async () => { + for (const [method, path] of writeRoutes) { + const { res, next } = await run(method, path, true); + if (path === '/api/login') { + expect(next, `${method} ${path}`).toHaveBeenCalled(); + } else { + expect(res.statusCode, `${method} ${path}`).toBe(READ_ONLY_STATUS); + expect(res.body).toEqual(READ_ONLY_BODY); + expect(next).not.toHaveBeenCalled(); + } + } + }); + + it('lets reads through, and everything through when off', async () => { + expect((await run('GET', '/api/user-data', true)).next).toHaveBeenCalled(); + expect((await run('POST', '/api/user-data', false)).next).toHaveBeenCalled(); + }); +}); + +describe('Firebase sign-in in read-only mode', () => { + const firebaseUser = { uid: 'uid-1', email: 'new@example.com', emailVerified: true }; + + it('returns an already-linked user without writing', async () => { + query.mockResolvedValueOnce({ rows: [{ id: 3, username: 'u', email: 'old@example.com' }] }); + + const user = await getOrCreateFirebaseUser(firebaseUser, query, { readOnly: true }); + + expect(user).toEqual({ id: 3, username: 'u', email: 'old@example.com' }); + expect(query).toHaveBeenCalledTimes(1); + expect(query.mock.calls[0][0]).toMatch(/^SELECT/); + }); + + it('does not link or create an account for an unknown user', async () => { + query.mockResolvedValueOnce({ rows: [] }); + + const user = await getOrCreateFirebaseUser(firebaseUser, query, { readOnly: true }); + + expect(user).toBeNull(); + expect(query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/readOnlyMiddleware.js b/server/readOnlyMiddleware.js new file mode 100644 index 0000000..90fb18f --- /dev/null +++ b/server/readOnlyMiddleware.js @@ -0,0 +1,22 @@ +// API read-only mode for the move to Firebase (shared/readOnly.js, issue #130). +// Sign-in stays open; every other write under /api gets 503 so nothing in the +// database changes while it is copied. The Vercel functions apply the same rule +// in api/_lib/cors.js. +const READ_ONLY_ALLOWED_WRITES = new Set(['/api/login']); + +function createReadOnlyMiddleware(readOnlyModulePromise = import('../shared/readOnly.js')) { + return async function readOnlyMiddleware(req, res, next) { + try { + const { isBlockedWrite, READ_ONLY_BODY, READ_ONLY_STATUS } = await readOnlyModulePromise; + const allowWrite = READ_ONLY_ALLOWED_WRITES.has(req.originalUrl.split('?')[0]); + if (isBlockedWrite(req.method, { allowWrite })) { + return res.status(READ_ONLY_STATUS).json(READ_ONLY_BODY); + } + return next(); + } catch (err) { + return next(err); + } + }; +} + +module.exports = { createReadOnlyMiddleware }; diff --git a/server/server.js b/server/server.js index ca7de33..fd15602 100644 --- a/server/server.js +++ b/server/server.js @@ -14,6 +14,7 @@ const { parseImportRows, upsertImportedYieldRowsSqlite, } = require('./importRows'); +const { createReadOnlyMiddleware } = require('./readOnlyMiddleware'); const ExcelJS = require('exceljs'); const crypto = require('crypto'); @@ -74,6 +75,9 @@ app.use((err, req, res, next) => { app.use('/api', apiRateLimit); app.use(express.json()); +// API read-only mode for the move to Firebase (issue #130): must come before every route. +app.use('/api', createReadOnlyMiddleware()); + const CSV_FORMULA_PREFIX = /^[=+\-@]/; const sanitizeCsvValue = (value) => { const stringValue = value === null || value === undefined ? '' : String(value); diff --git a/shared/readOnly.js b/shared/readOnly.js new file mode 100644 index 0000000..5ad454b --- /dev/null +++ b/shared/readOnly.js @@ -0,0 +1,30 @@ +/** + * API read-only mode for the move to Firebase (ADR 0001, issue #130). + * + * With API_READ_ONLY=true, both backends answer every write with 503 and keep + * serving reads, so nothing in the database changes while it is copied. + * Sign-in stays open so people can still read their data. + * + * 503 (not 4xx) is deliberate: the old client's sync treats it as temporary + * and keeps the change queued, so it can still be saved to a file. + */ + +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +export const READ_ONLY_STATUS = 503; + +export const READ_ONLY_BODY = Object.freeze({ + error: 'read_only', + message: 'Local Catch is moving to a new address and no longer accepts changes here. Your data is safe.', +}); + +export function isApiReadOnly(env = process.env) { + return TRUE_VALUES.has(String(env.API_READ_ONLY ?? '').trim().toLowerCase()); +} + +/** True when a request must be refused because the API is read-only. */ +export function isBlockedWrite(method, { readOnly = isApiReadOnly(), allowWrite = false } = {}) { + if (!readOnly || allowWrite) return false; + return !SAFE_METHODS.has(String(method || '').toUpperCase()); +} From f8e0d02faf266c6749fd36d528d499f361f4405c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:05:26 +0000 Subject: [PATCH 2/9] feat: add move notice, read-only app and unsent-changes file (#130) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnPh61Aps5neY87hh1pu9T --- app/src/App.jsx | 2 + app/src/components/Calculator.jsx | 5 +- app/src/components/ContributorProfile.jsx | 6 +- app/src/components/DataManagement.jsx | 22 ++++--- app/src/components/MoveNotice.jsx | 67 ++++++++++++++++++++ app/src/components/UploadData.jsx | 4 +- app/src/config/move.js | 19 ++++++ app/src/context/DataContext.jsx | 58 ++++++++++++++++++ app/src/lib/__tests__/apiReadOnly.test.js | 1 + app/src/lib/unsentChanges.js | 74 +++++++++++++++++++++++ app/src/lib/unsentChanges.test.js | 55 +++++++++++++++++ docs/ENVIRONMENT_VARIABLES.md | 3 + docs/move-runbook.md | 42 +++++++++++++ 13 files changed, 345 insertions(+), 13 deletions(-) create mode 100644 app/src/components/MoveNotice.jsx create mode 100644 app/src/config/move.js create mode 100644 app/src/lib/unsentChanges.js create mode 100644 app/src/lib/unsentChanges.test.js create mode 100644 docs/move-runbook.md diff --git a/app/src/App.jsx b/app/src/App.jsx index b9d6fc9..adeb501 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -16,6 +16,7 @@ import { DataProvider, useData } from './context/DataContext'; import { useTheme } from './context/ThemeContext'; import SyncStatusBadge from './components/SyncStatusBadge'; import SyncDetailsPanel from './components/SyncDetailsPanel'; +import MoveNotice from './components/MoveNotice'; const NavBar = () => { const { user } = useAuth(); @@ -185,6 +186,7 @@ function AppContent() { Skip to main content +
{ }, []); const handleSave = async () => { - if (!user || result === null) return; + if (!user || result === null || isAppReadOnly) return; const key = inputsKey; try { const headers = await getAuthHeaders('application/json'); @@ -755,7 +756,7 @@ const Calculator = () => { {result !== null && ( user ? (
- +

+ )} + +
+ ); +} diff --git a/app/src/components/UploadData.jsx b/app/src/components/UploadData.jsx index 209620d..781014c 100644 --- a/app/src/components/UploadData.jsx +++ b/app/src/components/UploadData.jsx @@ -4,6 +4,7 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { useData } from '../context/DataContext'; import { apiUrl } from '../config/api'; +import { isAppReadOnly } from '../config/move'; const TEMPLATE_CSV = `Species,% Yield,Product,Source\nAtlantic Salmon,45,Skinless Fillet,\nHalibut,38,Steak,\nDungeness Crab,25,Picked Meat,\n`; @@ -46,6 +47,7 @@ const UploadData = () => { const handleDragLeave = () => setDragOver(false); const handleUpload = async () => { + if (isAppReadOnly) return; if (!file || !user || !isOnline) return; setUploading(true); @@ -239,7 +241,7 @@ const UploadData = () => { - + {/* No discard while the app is read-only for the move: those records + can only be kept or saved to a file (issue #130). */} + {onDiscard && ( + + )}

)} + {exportError && ( +

{exportError}

+ )} ); From dbeae7ac8ff75f1cb665b73434751eb0bf0d2d49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:11:38 +0000 Subject: [PATCH 9/9] fix: no recovery discard while the app is read-only Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnPh61Aps5neY87hh1pu9T --- app/src/components/RecoveryModal.jsx | 36 +++++++++++++++------------- app/src/context/DataContext.jsx | 3 ++- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/app/src/components/RecoveryModal.jsx b/app/src/components/RecoveryModal.jsx index cfc24ab..cd6d50f 100644 --- a/app/src/components/RecoveryModal.jsx +++ b/app/src/components/RecoveryModal.jsx @@ -11,7 +11,7 @@ import { Link } from 'react-router-dom'; * isAuthenticated {bool} whether a user is signed in * assigning {bool} assignment in progress * onAssign {Function} copy recovery records into active account scope - * onDiscard {Function} permanently remove recovery records + * onDiscard {Function} permanently remove recovery records; omit to hide Discard * onLater {Function} dismiss modal without any action */ export default function RecoveryModal({ calcs, yields, isAuthenticated, assigning, onAssign, onDiscard, onLater }) { @@ -60,14 +60,16 @@ export default function RecoveryModal({ calcs, yields, isAuthenticated, assignin > Review later - + {onDiscard && ( + + )} ) : ( @@ -90,13 +92,15 @@ export default function RecoveryModal({ calcs, yields, isAuthenticated, assignin > Later - + {onDiscard && ( + + )} )} diff --git a/app/src/context/DataContext.jsx b/app/src/context/DataContext.jsx index b4da31f..74fb822 100644 --- a/app/src/context/DataContext.jsx +++ b/app/src/context/DataContext.jsx @@ -256,6 +256,7 @@ export function DataProvider({ children }) { }, [uid, reloadFromRepo, triggerSync]); const handleRecoveryDiscard = useCallback(async () => { + if (isAppReadOnly) return; try { await discardRecovery(); } catch { /* best-effort */ } setRecoveryCounts(null); }, []); @@ -709,7 +710,7 @@ export function DataProvider({ children }) { isAuthenticated={!!uid} assigning={recoveryAssigning} onAssign={handleRecoveryAssign} - onDiscard={handleRecoveryDiscard} + onDiscard={isAppReadOnly ? undefined : handleRecoveryDiscard} onLater={handleRecoveryLater} /> )}