From c1523ece1a861805493100199abef8b2d3ba9841 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 11 Jul 2026 10:48:23 -0500 Subject: [PATCH] feat(mobile): shared api/config layer, server-mode CSRF fix, robustness + update-safe DB (batch 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify the native shell and harden it: - src/config.ts: single source for local host/port, preference keys, secure-storage key, timeouts, and CSRF constants (dedupes LOCAL_URL etc. across App/LoadingScreen). - src/api.ts: one HTTP client — credentials mode, AbortController timeouts, and error normalization into user-facing messages (network/timeout/401/403/429/5xx), with typed auth responses. - Batch 10 CSRF fix: /api/auth/totp/challenge is not CSRF-exempt, so the native TOTP step now fetches GET /api/auth/csrf-token and echoes it as x-csrf-token (mirrors the web client); TOTP login no longer breaks for 2FA users. - Error-handling / boot robustness: LoadingScreen gets a health-poll timeout and surfaces main.js's {type:'serverError'} channel messages instead of spinning forever; App.tsx bootstrap and the encryption-key fetch now .catch to a visible state; crypto.ts reports secure-storage failures. - Data safety: move the SQLite DB, backups, and tmp OUT of nodejs-project into the app's filesDir (bt-data). nodejs-mobile re-copies nodejs-project on every app update, which was silently wiping local-mode data. Verified on the emulator: create data -> bump versionCode -> reinstall -> migrations skipped, no re-seed, data persists. Co-Authored-By: Claude Opus 4.8 --- nodejs-assets/nodejs-project/main.js | 20 +++-- src/App.tsx | 43 ++++----- src/LoadingScreen.tsx | 61 ++++++++----- src/LoginScreen.tsx | 82 ++++++----------- src/api.ts | 128 +++++++++++++++++++++++++++ src/config.ts | 29 ++++++ src/crypto.ts | 17 ++-- 7 files changed, 267 insertions(+), 113 deletions(-) create mode 100644 src/api.ts create mode 100644 src/config.ts diff --git a/nodejs-assets/nodejs-project/main.js b/nodejs-assets/nodejs-project/main.js index f053392..50a26a4 100644 --- a/nodejs-assets/nodejs-project/main.js +++ b/nodejs-assets/nodejs-project/main.js @@ -64,14 +64,22 @@ if (typeof global.Intl === 'undefined') { }; } -// Server-side paths, relative to this directory (the app's writable storage -// on-device — nodejs-mobile-cordova copies nodejs-project here at runtime). -process.env.DB_PATH = path.join(__dirname, 'data', 'bills.db'); -process.env.BACKUP_PATH = path.join(__dirname, 'backups'); +// Server-side paths. CRITICAL: keep the database, backups, and temp OUTSIDE the +// nodejs-project directory. nodejs-mobile-cordova re-copies nodejs-project from +// the APK assets on every app update, which would wipe anything stored inside it +// (a silent data-loss bug). __dirname is /www/nodejs-project, so two +// levels up is the app-private filesDir, which persists across updates. +const DATA_ROOT = path.join(__dirname, '..', '..', 'bt-data'); +process.env.DB_PATH = path.join(DATA_ROOT, 'db', 'bills.db'); +process.env.BACKUP_PATH = path.join(DATA_ROOT, 'backups'); // Android has no /tmp; point os.tmpdir() at app-writable storage so the daily // worker's temp-export cleanup doesn't error (ENOENT scandir '/tmp'). -process.env.TMPDIR = path.join(__dirname, 'tmp'); -try { fs.mkdirSync(process.env.TMPDIR, { recursive: true }); } catch (_) { /* best effort */ } +process.env.TMPDIR = path.join(DATA_ROOT, 'tmp'); +try { + fs.mkdirSync(path.dirname(process.env.DB_PATH), { recursive: true }); + fs.mkdirSync(process.env.BACKUP_PATH, { recursive: true }); + fs.mkdirSync(process.env.TMPDIR, { recursive: true }); +} catch (_) { /* best effort — database.cts also ensures the DB dir */ } process.env.PORT = process.env.PORT || '3000'; process.env.BIND_HOST = '127.0.0.1'; // The Capacitor WebView's origin (androidScheme: 'https') is cross-origin diff --git a/src/App.tsx b/src/App.tsx index 27a967f..052b459 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,13 @@ import { useEffect, useState } from 'react'; import { Preferences } from '@capacitor/preferences'; import { BiometricAuth } from '@aparajita/capacitor-biometric-auth'; +import { LOCAL_URL, PREF_SERVER_URL, PREF_BIOMETRIC, LOCAL_MODE_SENTINEL } from './config'; import SetupScreen from './SetupScreen'; import LoadingScreen from './LoadingScreen'; import LoginScreen from './LoginScreen'; import BiometricSetup from './BiometricSetup'; import BiometricLock from './BiometricLock'; -const LOCAL_URL = 'http://127.0.0.1:3000'; -const SERVER_URL_KEY = 'serverUrl'; -const BIOMETRIC_KEY = 'biometricEnabled'; - type Stage = | 'checking' | 'setup' @@ -25,19 +22,23 @@ export default function App() { const [serverUrl, setServerUrl] = useState(''); useEffect(() => { - Preferences.get({ key: SERVER_URL_KEY }).then(async ({ value }) => { - if (value === 'local') { - setStage('local'); - return; - } - if (value) { - setServerUrl(value); - const { value: biometricEnabled } = await Preferences.get({ key: BIOMETRIC_KEY }); - setStage(biometricEnabled === 'true' ? 'biometric-lock' : 'redirecting'); - return; - } - setStage('setup'); - }); + Preferences.get({ key: PREF_SERVER_URL }) + .then(async ({ value }) => { + if (value === LOCAL_MODE_SENTINEL) { + setStage('local'); + return; + } + if (value) { + setServerUrl(value); + const { value: biometricEnabled } = await Preferences.get({ key: PREF_BIOMETRIC }); + setStage(biometricEnabled === 'true' ? 'biometric-lock' : 'redirecting'); + return; + } + setStage('setup'); + }) + // A failed preferences read must not leave us stuck on the spinner — + // fall back to first-run setup so the user can (re)connect. + .catch(() => setStage('setup')); }, []); useEffect(() => { @@ -52,12 +53,12 @@ export default function App() { } async function handleLocalMode() { - await Preferences.set({ key: SERVER_URL_KEY, value: 'local' }); + await Preferences.set({ key: PREF_SERVER_URL, value: LOCAL_MODE_SENTINEL }); setStage('local'); } async function handleLoginSuccess() { - await Preferences.set({ key: SERVER_URL_KEY, value: serverUrl }); + await Preferences.set({ key: PREF_SERVER_URL, value: serverUrl }); try { const { isAvailable } = await BiometricAuth.checkBiometry(); setStage(isAvailable ? 'biometric-setup' : 'redirecting'); @@ -67,12 +68,12 @@ export default function App() { } async function handleBiometricSetupDone(enabled: boolean) { - await Preferences.set({ key: BIOMETRIC_KEY, value: enabled ? 'true' : 'false' }); + await Preferences.set({ key: PREF_BIOMETRIC, value: enabled ? 'true' : 'false' }); setStage('redirecting'); } async function handleBiometricFallback() { - await Preferences.set({ key: BIOMETRIC_KEY, value: 'false' }); + await Preferences.set({ key: PREF_BIOMETRIC, value: 'false' }); setStage('login'); } diff --git a/src/LoadingScreen.tsx b/src/LoadingScreen.tsx index 3e28aee..2dd3e67 100644 --- a/src/LoadingScreen.tsx +++ b/src/LoadingScreen.tsx @@ -1,9 +1,6 @@ import { useEffect, useState } from 'react'; import { getOrCreateEncryptionKey } from './crypto'; - -const LOCAL_URL = 'http://127.0.0.1:3000'; -const HEALTH_URL = `${LOCAL_URL}/api/health`; -const POLL_INTERVAL_MS = 250; +import { HEALTH_URL, HEALTH_POLL_INTERVAL_MS, HEALTH_POLL_TIMEOUT_MS } from './config'; interface Props { /** Called once the embedded server responds to a health check. */ @@ -12,7 +9,10 @@ interface Props { /** * Shown while the embedded Node.js server (nodejs-mobile) boots in local mode. - * Starts the engine on mount, then polls /api/health until it responds. + * Starts the engine on mount, hands over the encryption key, then polls + * /api/health until it responds. Surfaces a real error — instead of spinning + * forever — if the key can't be produced, the server reports a startup error + * over the channel, or the health check never succeeds within the timeout. */ export default function LoadingScreen({ onReady }: Props) { const [error, setError] = useState(''); @@ -20,18 +20,26 @@ export default function LoadingScreen({ onReady }: Props) { useEffect(() => { let cancelled = false; let pollTimer: ReturnType; + const deadline = Date.now() + HEALTH_POLL_TIMEOUT_MS; + + function fail(msg: string) { + if (!cancelled) setError(msg); + } function pollHealth() { + if (cancelled) return; + if (Date.now() > deadline) { + fail('The local server did not start in time. Please reopen the app to try again.'); + return; + } fetch(HEALTH_URL) .then(res => { - if (!cancelled && res.ok) { - onReady(); - } else if (!cancelled) { - pollTimer = setTimeout(pollHealth, POLL_INTERVAL_MS); - } + if (cancelled) return; + if (res.ok) onReady(); + else pollTimer = setTimeout(pollHealth, HEALTH_POLL_INTERVAL_MS); }) .catch(() => { - if (!cancelled) pollTimer = setTimeout(pollHealth, POLL_INTERVAL_MS); + if (!cancelled) pollTimer = setTimeout(pollHealth, HEALTH_POLL_INTERVAL_MS); }); } @@ -42,22 +50,29 @@ export default function LoadingScreen({ onReady }: Props) { } nodejs.start('main.js', err => { - if (err && !cancelled) { - setError('Failed to start local server: ' + String(err)); + if (cancelled) return; + if (err) { + fail('Failed to start local server: ' + String(err)); return; } - if (cancelled) return; - // Hand the device-bound encryption key to the embedded Node process over - // the nodejs-mobile-cordova channel. main.js waits for this message - // before requiring server.js, so encryption is configured from the - // first request. The listener is registered synchronously to avoid - // missing main.js's 'ready' message; the key itself is fetched async. + // The embedded process relays fatal startup errors as { type: 'serverError' } + // (see main.js). Register the listener before replying to 'ready' so we + // never miss it, and hand over the device-bound encryption key once main.js + // reports ready — main.js waits for it before requiring the server. nodejs.channel.on('message', msg => { - if (cancelled || (msg as { type?: string })?.type !== 'ready') return; - getOrCreateEncryptionKey().then(key => { - if (!cancelled) nodejs.channel.post('message', { type: 'encryptionKey', key }); - }); + if (cancelled) return; + const m = msg as { type?: string; message?: string }; + if (m?.type === 'serverError') { + fail('The local server failed to start: ' + (m.message || 'unknown error')); + return; + } + if (m?.type !== 'ready') return; + getOrCreateEncryptionKey() + .then(key => { + if (!cancelled) nodejs.channel.post('message', { type: 'encryptionKey', key }); + }) + .catch(() => fail('Could not unlock secure storage on this device.')); }); pollHealth(); diff --git a/src/LoginScreen.tsx b/src/LoginScreen.tsx index 53dee4a..8898292 100644 --- a/src/LoginScreen.tsx +++ b/src/LoginScreen.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { login, totpChallenge } from './api'; interface Props { serverUrl: string; @@ -9,21 +10,6 @@ interface Props { type Stage = 'credentials' | 'totp' | 'webauthn'; -interface ApiError { - message?: string; -} - -async function postJson(url: string, body: unknown): Promise<{ ok: boolean; data: any }> { - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(body), - }); - const data = await res.json().catch(() => ({})); - return { ok: res.ok, data }; -} - export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) { const [stage, setStage] = useState('credentials'); const [username, setUsername] = useState(''); @@ -46,34 +32,25 @@ export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) { } setError(''); setLoading(true); - try { - const { ok, data } = await postJson(`${serverUrl}/api/auth/login`, { - username: username.trim(), - password, - }); + // api.login normalizes network/timeout/429/5xx into res.error. + const res = await login(serverUrl, username.trim(), password); + setLoading(false); - if (!ok) { - setError((data as ApiError)?.message || 'Invalid username or password.'); - return; - } - - if (data.requires_totp) { - setChallengeToken(data.challenge_token); - setStage('totp'); - return; - } - - if (data.requires_webauthn) { - setStage('webauthn'); - return; - } - - onSuccess(); - } catch { - setError('Could not reach the server. Check the URL and your connection.'); - } finally { - setLoading(false); + if (!res.ok) { + setError(res.error || 'Invalid username or password.'); + return; } + const data = res.data; + if (data?.requires_totp) { + setChallengeToken(data.challenge_token || ''); + setStage('totp'); + return; + } + if (data?.requires_webauthn) { + setStage('webauthn'); + return; + } + onSuccess(); } async function handleTotp() { @@ -83,23 +60,16 @@ export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) { } setError(''); setLoading(true); - try { - const { ok, data } = await postJson(`${serverUrl}/api/auth/totp/challenge`, { - challenge_token: challengeToken, - code: code.trim(), - }); + // totpChallenge fetches + echoes the CSRF token (the challenge endpoint, + // unlike /login, is not CSRF-exempt). + const res = await totpChallenge(serverUrl, challengeToken, code.trim()); + setLoading(false); - if (!ok) { - setError((data as ApiError)?.message || 'Invalid authenticator code.'); - return; - } - - onSuccess(); - } catch { - setError('Could not reach the server. Check the URL and your connection.'); - } finally { - setLoading(false); + if (!res.ok) { + setError(res.error || 'Invalid authenticator code.'); + return; } + onSuccess(); } if (stage === 'webauthn') { diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..bd888f4 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,128 @@ +// Shared HTTP client for the native (pre-WebView) server-mode auth flow. +// +// One place for: credentials mode, request timeouts, CSRF (double-submit token +// fetched from a readable endpoint and echoed as a header), and error +// normalization into user-facing messages. CapacitorHttp (enabled globally in +// capacitor.config.ts) routes fetch() through native HTTP so these calls aren't +// blocked by CORS and the session cookie lands in the WebView's cookie store. + +import { REQUEST_TIMEOUT_MS, CSRF_HEADER, CSRF_TOKEN_PATH } from './config'; + +export interface ApiResult { + ok: boolean; + /** HTTP status, or 0 for a network/timeout failure before any response. */ + status: number; + data: T | null; + /** Normalized, user-facing message when !ok; null on success. */ + error: string | null; +} + +export interface LoginResponse { + requires_totp?: boolean; + requires_webauthn?: boolean; + challenge_token?: string; + user?: { id: number; username: string; role: string }; +} + +interface ApiErrorBody { + message?: string; + error?: string; + code?: string; +} + +/** Map a failed response to a single honest, user-facing sentence. */ +function messageForStatus(status: number, body: ApiErrorBody | null): string { + const serverMsg = body?.message || body?.error; + switch (true) { + case status === 401: + case status === 403: + return serverMsg || 'Not authorized. Please sign in again.'; + case status === 429: + return serverMsg || 'Too many attempts. Please wait a moment and try again.'; + case status >= 500: + return 'The server had a problem. Please try again in a moment.'; + default: + return serverMsg || `Request failed (${status}).`; + } +} + +async function request( + url: string, + init: RequestInit, + timeoutMs = REQUEST_TIMEOUT_MS, +): Promise> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + ...init, + credentials: 'include', + signal: controller.signal, + }); + let data: T | null = null; + try { + data = (await res.json()) as T; + } catch { + data = null; // empty or non-JSON body + } + if (!res.ok) { + return { ok: false, status: res.status, data, error: messageForStatus(res.status, data as ApiErrorBody | null) }; + } + return { ok: true, status: res.status, data, error: null }; + } catch (err) { + const aborted = err instanceof DOMException ? err.name === 'AbortError' : (err as { name?: string })?.name === 'AbortError'; + return { + ok: false, + status: 0, + data: null, + error: aborted + ? 'The server took too long to respond. Check your connection and try again.' + : 'Could not reach the server. Check the URL and your connection.', + }; + } finally { + clearTimeout(timer); + } +} + +function postJson(url: string, body: unknown, headers: Record = {}): Promise> { + return request(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); +} + +/** POST /api/auth/login — CSRF-exempt (no session yet). */ +export function login(baseUrl: string, username: string, password: string): Promise> { + return postJson(`${baseUrl}/api/auth/login`, { username, password }); +} + +/** + * Fetch the double-submit CSRF token from the readable endpoint. Returns '' on + * failure (the caller surfaces the downstream 403 rather than blocking here). + */ +export async function fetchCsrfToken(baseUrl: string): Promise { + const res = await request<{ token?: string }>(`${baseUrl}${CSRF_TOKEN_PATH}`, { method: 'GET' }); + return res.ok && res.data?.token ? res.data.token : ''; +} + +/** + * POST /api/auth/totp/challenge — NOT CSRF-exempt, so fetch + echo the token. + */ +export async function totpChallenge( + baseUrl: string, + challengeToken: string, + code: string, +): Promise> { + const csrf = await fetchCsrfToken(baseUrl); + return postJson( + `${baseUrl}/api/auth/totp/challenge`, + { challenge_token: challengeToken, code }, + csrf ? { [CSRF_HEADER]: csrf } : {}, + ); +} + +/** GET /api/auth/me — used to re-validate a session (e.g. after biometric unlock). */ +export function getSession(baseUrl: string): Promise> { + return request(`${baseUrl}/api/auth/me`, { method: 'GET' }); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b898fd2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,29 @@ +// Single source of truth for the React shell's local-mode + networking constants. +// (main.js — the embedded Node entry — sets the matching PORT/BIND_HOST on its +// own side; keep the port here in sync with it.) + +export const LOCAL_PORT = 3000; +export const LOCAL_URL = `http://127.0.0.1:${LOCAL_PORT}`; +export const HEALTH_URL = `${LOCAL_URL}/api/health`; + +// @capacitor/preferences keys +export const PREF_SERVER_URL = 'serverUrl'; +export const PREF_BIOMETRIC = 'biometricEnabled'; +/** Value stored under PREF_SERVER_URL when the user chose on-device local mode. */ +export const LOCAL_MODE_SENTINEL = 'local'; + +// capacitor-secure-storage-plugin +export const SECURE_KEY_NAME = 'tokenEncryptionKey'; +export const ENCRYPTION_KEY_BYTES = 48; + +// Networking / boot +export const REQUEST_TIMEOUT_MS = 15_000; +export const HEALTH_POLL_INTERVAL_MS = 250; +/** Give up the "starting local server" spinner and surface a real error instead + * of polling forever. Generous — the first launch unpacks the Node project. */ +export const HEALTH_POLL_TIMEOUT_MS = 60_000; + +// CSRF (double-submit). The cookie is httpOnly, so the token is read from a +// readable endpoint and echoed in a header — mirroring client/api.ts on the web. +export const CSRF_HEADER = 'x-csrf-token'; +export const CSRF_TOKEN_PATH = '/api/auth/csrf-token'; diff --git a/src/crypto.ts b/src/crypto.ts index 4f6d8aa..bc13e87 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,7 +1,5 @@ import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin'; - -const KEY_NAME = 'tokenEncryptionKey'; -const KEY_BYTES = 48; +import { SECURE_KEY_NAME, ENCRYPTION_KEY_BYTES } from './config'; function generateHexKey(bytes: number): string { const arr = new Uint8Array(bytes); @@ -12,17 +10,22 @@ function generateHexKey(bytes: number): string { /** * Returns the device's local-mode database encryption key (TOKEN_ENCRYPTION_KEY), * generating and persisting one in secure storage (Android Keystore / iOS Keychain) - * on first launch. + * on first launch. Throws if secure storage is unavailable — the caller + * (LoadingScreen) surfaces that rather than starting the server with no key. */ export async function getOrCreateEncryptionKey(): Promise { try { - const { value } = await SecureStoragePlugin.get({ key: KEY_NAME }); + const { value } = await SecureStoragePlugin.get({ key: SECURE_KEY_NAME }); if (value) return value; } catch { // Not found — fall through to generate one. } - const key = generateHexKey(KEY_BYTES); - await SecureStoragePlugin.set({ key: KEY_NAME, value: key }); + const key = generateHexKey(ENCRYPTION_KEY_BYTES); + try { + await SecureStoragePlugin.set({ key: SECURE_KEY_NAME, value: key }); + } catch (err) { + throw new Error('Failed to persist the encryption key to secure storage: ' + String(err)); + } return key; }