feat(mobile): shared api/config layer, server-mode CSRF fix, robustness + update-safe DB (batch 10)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-11 10:48:23 -05:00
parent 2c29ec45bd
commit c1523ece1a
7 changed files with 267 additions and 113 deletions

View File

@ -64,14 +64,22 @@ if (typeof global.Intl === 'undefined') {
}; };
} }
// Server-side paths, relative to this directory (the app's writable storage // Server-side paths. CRITICAL: keep the database, backups, and temp OUTSIDE the
// on-device — nodejs-mobile-cordova copies nodejs-project here at runtime). // nodejs-project directory. nodejs-mobile-cordova re-copies nodejs-project from
process.env.DB_PATH = path.join(__dirname, 'data', 'bills.db'); // the APK assets on every app update, which would wipe anything stored inside it
process.env.BACKUP_PATH = path.join(__dirname, 'backups'); // (a silent data-loss bug). __dirname is <filesDir>/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 // 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'). // worker's temp-export cleanup doesn't error (ENOENT scandir '/tmp').
process.env.TMPDIR = path.join(__dirname, 'tmp'); process.env.TMPDIR = path.join(DATA_ROOT, 'tmp');
try { fs.mkdirSync(process.env.TMPDIR, { recursive: true }); } catch (_) { /* best effort */ } 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.PORT = process.env.PORT || '3000';
process.env.BIND_HOST = '127.0.0.1'; process.env.BIND_HOST = '127.0.0.1';
// The Capacitor WebView's origin (androidScheme: 'https') is cross-origin // The Capacitor WebView's origin (androidScheme: 'https') is cross-origin

View File

@ -1,16 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Preferences } from '@capacitor/preferences'; import { Preferences } from '@capacitor/preferences';
import { BiometricAuth } from '@aparajita/capacitor-biometric-auth'; 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 SetupScreen from './SetupScreen';
import LoadingScreen from './LoadingScreen'; import LoadingScreen from './LoadingScreen';
import LoginScreen from './LoginScreen'; import LoginScreen from './LoginScreen';
import BiometricSetup from './BiometricSetup'; import BiometricSetup from './BiometricSetup';
import BiometricLock from './BiometricLock'; import BiometricLock from './BiometricLock';
const LOCAL_URL = 'http://127.0.0.1:3000';
const SERVER_URL_KEY = 'serverUrl';
const BIOMETRIC_KEY = 'biometricEnabled';
type Stage = type Stage =
| 'checking' | 'checking'
| 'setup' | 'setup'
@ -25,19 +22,23 @@ export default function App() {
const [serverUrl, setServerUrl] = useState(''); const [serverUrl, setServerUrl] = useState('');
useEffect(() => { useEffect(() => {
Preferences.get({ key: SERVER_URL_KEY }).then(async ({ value }) => { Preferences.get({ key: PREF_SERVER_URL })
if (value === 'local') { .then(async ({ value }) => {
if (value === LOCAL_MODE_SENTINEL) {
setStage('local'); setStage('local');
return; return;
} }
if (value) { if (value) {
setServerUrl(value); setServerUrl(value);
const { value: biometricEnabled } = await Preferences.get({ key: BIOMETRIC_KEY }); const { value: biometricEnabled } = await Preferences.get({ key: PREF_BIOMETRIC });
setStage(biometricEnabled === 'true' ? 'biometric-lock' : 'redirecting'); setStage(biometricEnabled === 'true' ? 'biometric-lock' : 'redirecting');
return; return;
} }
setStage('setup'); 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(() => { useEffect(() => {
@ -52,12 +53,12 @@ export default function App() {
} }
async function handleLocalMode() { 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'); setStage('local');
} }
async function handleLoginSuccess() { async function handleLoginSuccess() {
await Preferences.set({ key: SERVER_URL_KEY, value: serverUrl }); await Preferences.set({ key: PREF_SERVER_URL, value: serverUrl });
try { try {
const { isAvailable } = await BiometricAuth.checkBiometry(); const { isAvailable } = await BiometricAuth.checkBiometry();
setStage(isAvailable ? 'biometric-setup' : 'redirecting'); setStage(isAvailable ? 'biometric-setup' : 'redirecting');
@ -67,12 +68,12 @@ export default function App() {
} }
async function handleBiometricSetupDone(enabled: boolean) { 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'); setStage('redirecting');
} }
async function handleBiometricFallback() { async function handleBiometricFallback() {
await Preferences.set({ key: BIOMETRIC_KEY, value: 'false' }); await Preferences.set({ key: PREF_BIOMETRIC, value: 'false' });
setStage('login'); setStage('login');
} }

View File

@ -1,9 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getOrCreateEncryptionKey } from './crypto'; import { getOrCreateEncryptionKey } from './crypto';
import { HEALTH_URL, HEALTH_POLL_INTERVAL_MS, HEALTH_POLL_TIMEOUT_MS } from './config';
const LOCAL_URL = 'http://127.0.0.1:3000';
const HEALTH_URL = `${LOCAL_URL}/api/health`;
const POLL_INTERVAL_MS = 250;
interface Props { interface Props {
/** Called once the embedded server responds to a health check. */ /** 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. * 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) { export default function LoadingScreen({ onReady }: Props) {
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -20,18 +20,26 @@ export default function LoadingScreen({ onReady }: Props) {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let pollTimer: ReturnType<typeof setTimeout>; let pollTimer: ReturnType<typeof setTimeout>;
const deadline = Date.now() + HEALTH_POLL_TIMEOUT_MS;
function fail(msg: string) {
if (!cancelled) setError(msg);
}
function pollHealth() { 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) fetch(HEALTH_URL)
.then(res => { .then(res => {
if (!cancelled && res.ok) { if (cancelled) return;
onReady(); if (res.ok) onReady();
} else if (!cancelled) { else pollTimer = setTimeout(pollHealth, HEALTH_POLL_INTERVAL_MS);
pollTimer = setTimeout(pollHealth, POLL_INTERVAL_MS);
}
}) })
.catch(() => { .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 => { nodejs.start('main.js', err => {
if (err && !cancelled) { if (cancelled) return;
setError('Failed to start local server: ' + String(err)); if (err) {
fail('Failed to start local server: ' + String(err));
return; return;
} }
if (cancelled) return;
// Hand the device-bound encryption key to the embedded Node process over // The embedded process relays fatal startup errors as { type: 'serverError' }
// the nodejs-mobile-cordova channel. main.js waits for this message // (see main.js). Register the listener before replying to 'ready' so we
// before requiring server.js, so encryption is configured from the // never miss it, and hand over the device-bound encryption key once main.js
// first request. The listener is registered synchronously to avoid // reports ready — main.js waits for it before requiring the server.
// missing main.js's 'ready' message; the key itself is fetched async.
nodejs.channel.on('message', msg => { nodejs.channel.on('message', msg => {
if (cancelled || (msg as { type?: string })?.type !== 'ready') return; if (cancelled) return;
getOrCreateEncryptionKey().then(key => { 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 }); if (!cancelled) nodejs.channel.post('message', { type: 'encryptionKey', key });
}); })
.catch(() => fail('Could not unlock secure storage on this device.'));
}); });
pollHealth(); pollHealth();

View File

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { login, totpChallenge } from './api';
interface Props { interface Props {
serverUrl: string; serverUrl: string;
@ -9,21 +10,6 @@ interface Props {
type Stage = 'credentials' | 'totp' | 'webauthn'; 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) { export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) {
const [stage, setStage] = useState<Stage>('credentials'); const [stage, setStage] = useState<Stage>('credentials');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@ -46,34 +32,25 @@ export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) {
} }
setError(''); setError('');
setLoading(true); setLoading(true);
try { // api.login normalizes network/timeout/429/5xx into res.error.
const { ok, data } = await postJson(`${serverUrl}/api/auth/login`, { const res = await login(serverUrl, username.trim(), password);
username: username.trim(), setLoading(false);
password,
});
if (!ok) { if (!res.ok) {
setError((data as ApiError)?.message || 'Invalid username or password.'); setError(res.error || 'Invalid username or password.');
return; return;
} }
const data = res.data;
if (data.requires_totp) { if (data?.requires_totp) {
setChallengeToken(data.challenge_token); setChallengeToken(data.challenge_token || '');
setStage('totp'); setStage('totp');
return; return;
} }
if (data?.requires_webauthn) {
if (data.requires_webauthn) {
setStage('webauthn'); setStage('webauthn');
return; return;
} }
onSuccess(); onSuccess();
} catch {
setError('Could not reach the server. Check the URL and your connection.');
} finally {
setLoading(false);
}
} }
async function handleTotp() { async function handleTotp() {
@ -83,23 +60,16 @@ export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) {
} }
setError(''); setError('');
setLoading(true); setLoading(true);
try { // totpChallenge fetches + echoes the CSRF token (the challenge endpoint,
const { ok, data } = await postJson(`${serverUrl}/api/auth/totp/challenge`, { // unlike /login, is not CSRF-exempt).
challenge_token: challengeToken, const res = await totpChallenge(serverUrl, challengeToken, code.trim());
code: code.trim(), setLoading(false);
});
if (!ok) { if (!res.ok) {
setError((data as ApiError)?.message || 'Invalid authenticator code.'); setError(res.error || 'Invalid authenticator code.');
return; return;
} }
onSuccess(); onSuccess();
} catch {
setError('Could not reach the server. Check the URL and your connection.');
} finally {
setLoading(false);
}
} }
if (stage === 'webauthn') { if (stage === 'webauthn') {

128
src/api.ts Normal file
View File

@ -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<T> {
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<T>(
url: string,
init: RequestInit,
timeoutMs = REQUEST_TIMEOUT_MS,
): Promise<ApiResult<T>> {
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<T>(url: string, body: unknown, headers: Record<string, string> = {}): Promise<ApiResult<T>> {
return request<T>(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<ApiResult<LoginResponse>> {
return postJson<LoginResponse>(`${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<string> {
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<ApiResult<LoginResponse>> {
const csrf = await fetchCsrfToken(baseUrl);
return postJson<LoginResponse>(
`${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<ApiResult<{ user?: { id: number } }>> {
return request(`${baseUrl}/api/auth/me`, { method: 'GET' });
}

29
src/config.ts Normal file
View File

@ -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';

View File

@ -1,7 +1,5 @@
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin'; import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
import { SECURE_KEY_NAME, ENCRYPTION_KEY_BYTES } from './config';
const KEY_NAME = 'tokenEncryptionKey';
const KEY_BYTES = 48;
function generateHexKey(bytes: number): string { function generateHexKey(bytes: number): string {
const arr = new Uint8Array(bytes); 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), * Returns the device's local-mode database encryption key (TOKEN_ENCRYPTION_KEY),
* generating and persisting one in secure storage (Android Keystore / iOS Keychain) * 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<string> { export async function getOrCreateEncryptionKey(): Promise<string> {
try { try {
const { value } = await SecureStoragePlugin.get({ key: KEY_NAME }); const { value } = await SecureStoragePlugin.get({ key: SECURE_KEY_NAME });
if (value) return value; if (value) return value;
} catch { } catch {
// Not found — fall through to generate one. // Not found — fall through to generate one.
} }
const key = generateHexKey(KEY_BYTES); const key = generateHexKey(ENCRYPTION_KEY_BYTES);
await SecureStoragePlugin.set({ key: KEY_NAME, value: key }); 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; return key;
} }