Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.
dev ... main

22 changed files with 39 additions and 4714 deletions

12
.gitignore vendored
View File

@ -14,15 +14,3 @@ Thumbs.db
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/
*.code-workspace
# Build output
build-output/
# Private project/agent docs — never commit
PROJECT.md
PLAN.md
FUTURE.md
HISTORY.md
DEVELOPMENT_LOG.md
.learnings/

View File

@ -11,19 +11,6 @@ const config: CapacitorConfig = {
server: { server: {
// androidScheme must be https for cookies to work correctly // androidScheme must be https for cookies to work correctly
androidScheme: 'https', androidScheme: 'https',
// In local mode, the WebView navigates to the embedded Node server on
// 127.0.0.1 — without this it's treated as an external link and opened
// in the system browser instead of the app's WebView.
allowNavigation: ['127.0.0.1'],
},
plugins: {
CapacitorHttp: {
// Routes fetch()/XHR through native HTTP so the server-mode login
// request isn't blocked by CORS, and the resulting session cookie is
// written to the WebView's cookie store for the subsequent WebView
// navigation to the server URL.
enabled: true,
},
}, },
}; };

View File

@ -1,136 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
// better-sqlite3's better_sqlite3.node is architecture-specific (it links
// against libnode.so, which is the real Android binary already loaded into
// this process by nodejs-mobile-cordova). The copy under node_modules/ is
// whichever architecture it happened to be built for last; replace it with
// the prebuild matching this device's actual arch before anything requires
// better-sqlite3. See scripts/build-better-sqlite3-arm.sh.
(function installBetterSqlite3Prebuild() {
const archDirs = {
arm64: 'arm64-v8a',
arm: 'armeabi-v7a',
x64: 'x86_64',
ia32: 'x86',
};
const archDir = archDirs[process.arch];
if (!archDir) return; // Unknown arch — fall back to whatever's bundled.
const prebuilt = path.join(__dirname, 'prebuilds', archDir, 'better_sqlite3.node');
const target = path.join(__dirname, 'node_modules', 'better-sqlite3', 'build', 'Release', 'better_sqlite3.node');
if (!fs.existsSync(prebuilt)) return;
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(prebuilt, target);
} catch (err) {
console.error('[main.js] Failed to install better-sqlite3 prebuild for', process.arch, err);
}
})();
// Node 12.19 predates crypto.randomUUID (added in Node 14.17), used by
// services/authService.js for session token IDs.
const nodeCrypto = require('crypto');
if (typeof nodeCrypto.randomUUID !== 'function') {
nodeCrypto.randomUUID = function () {
const b = nodeCrypto.randomBytes(16);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const hex = b.toString('hex');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-');
};
}
// Node 12.19 predates crypto.hkdfSync (added in Node 15.0), used by
// services/encryptionService.js's deriveKey to derive the token-encryption
// key from TOKEN_ENCRYPTION_KEY. Implements HKDF-Extract/Expand (RFC 5869)
// via createHmac.
if (typeof nodeCrypto.hkdfSync !== 'function') {
nodeCrypto.hkdfSync = function (digest, ikm, salt, info, keylen) {
const hashLen = nodeCrypto.createHash(digest).digest().length;
const saltBuf = salt && salt.length ? Buffer.from(salt) : Buffer.alloc(hashLen, 0);
const ikmBuf = Buffer.isBuffer(ikm) ? ikm : Buffer.from(ikm);
const infoBuf = Buffer.isBuffer(info) ? info : Buffer.from(info || '');
const prk = nodeCrypto.createHmac(digest, saltBuf).update(ikmBuf).digest();
let t = Buffer.alloc(0);
let okm = Buffer.alloc(0);
let counter = 1;
while (okm.length < keylen) {
t = nodeCrypto
.createHmac(digest, prk)
.update(t)
.update(infoBuf)
.update(Buffer.from([counter]))
.digest();
okm = Buffer.concat([okm, t]);
counter++;
}
return okm.slice(0, keylen);
};
}
// nodejs-mobile-cordova 0.4.3 embeds Node 12.19 built without ICU, so
// `Intl` is entirely undefined. Polyfill just the bits the server uses
// (utils/money.js, routes/dataSources.js, routes/status.js) — en-US number
// formatting with thousands separators, and a no-op DateTimeFormat whose
// resolvedOptions().timeZone the callers already treat as optional.
if (typeof global.Intl === 'undefined') {
global.Intl = {
NumberFormat: function (_locale, options) {
const opts = options || {};
const minFrac = opts.minimumFractionDigits || 0;
const maxFrac = Math.max(opts.maximumFractionDigits || 0, minFrac);
this.format = function (value) {
const fixed = Number(value).toFixed(maxFrac);
let [intPart, fracPart] = fixed.split('.');
const negative = intPart.startsWith('-');
if (negative) intPart = intPart.slice(1);
intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
let out = (negative ? '-' : '') + intPart;
if (fracPart) out += '.' + fracPart;
return out;
};
},
DateTimeFormat: function () {
this.resolvedOptions = function () {
return { timeZone: 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');
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
// from this server's http://localhost:3000, so the LoadingScreen's
// /api/health poll and the app's API calls need CORS allowed for it.
process.env.CORS_ORIGIN = 'https://localhost';
const cordova = require('cordova-bridge');
// Wait for the WebView to hand over the device-bound encryption key (stored
// in Android Keystore / iOS Keychain — see src/crypto.ts) before starting the
// server, so encryptionService.js picks up TOKEN_ENCRYPTION_KEY on first use.
cordova.channel.on('message', function (msg) {
if (msg && msg.type === 'encryptionKey' && typeof msg.key === 'string') {
process.env.TOKEN_ENCRYPTION_KEY = msg.key;
require('./server/server.js');
}
});
cordova.channel.post('message', { type: 'ready' });

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +0,0 @@
{
"name": "nodejs-project",
"version": "1.0.0",
"private": true,
"dependencies": {
"@simplewebauthn/server": "^13.0.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.9.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^8.4.1",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.9",
"openid-client": "^5.7.1",
"otplib": "^13.4.1",
"qrcode": "^1.5.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodejs-mobile-gyp": "^0.4.0"
}
}

511
package-lock.json generated
View File

@ -8,13 +8,11 @@
"name": "bill-tracker-mobile", "name": "bill-tracker-mobile",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@aparajita/capacitor-biometric-auth": "^10.0.0",
"@capacitor/android": "^8.4.0", "@capacitor/android": "^8.4.0",
"@capacitor/app": "^8.1.0", "@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.4.0", "@capacitor/core": "^8.4.0",
"@capacitor/ios": "^8.4.0", "@capacitor/ios": "^8.4.0",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"capacitor-secure-storage-plugin": "^0.13.0",
"nodejs-mobile-cordova": "^0.4.3", "nodejs-mobile-cordova": "^0.4.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@ -24,26 +22,10 @@
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"esbuild": "^0.28.0",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"vite": "^8.0.0" "vite": "^8.0.0"
} }
}, },
"node_modules/@aparajita/capacitor-biometric-auth": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@aparajita/capacitor-biometric-auth/-/capacitor-biometric-auth-10.0.0.tgz",
"integrity": "sha512-azjWaRucB8x1/gSzSTp/NxelaOZyg+m765gSzjwUkSQgg30RrZPmPq6pyLeeXVSZNDY3Rxf5Hj8obgZaXirCFQ==",
"license": "MIT",
"dependencies": {
"@capacitor/android": "^8.0.2",
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.0.2",
"@capacitor/ios": "^8.0.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@capacitor/android": { "node_modules/@capacitor/android": {
"version": "8.4.0", "version": "8.4.0",
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.0.tgz", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.0.tgz",
@ -156,448 +138,6 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@ionic/cli-framework-output": { "node_modules/@ionic/cli-framework-output": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz",
@ -1435,15 +975,6 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/capacitor-secure-storage-plugin": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.13.0.tgz",
"integrity": "sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/caseless": { "node_modules/caseless": {
"version": "0.12.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
@ -1655,48 +1186,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/extend": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",

View File

@ -12,13 +12,11 @@
"android:run": "npm run sync && npm run sync:server && npm run sync:android-assets && npx cap run android --no-sync" "android:run": "npm run sync && npm run sync:server && npm run sync:android-assets && npx cap run android --no-sync"
}, },
"dependencies": { "dependencies": {
"@aparajita/capacitor-biometric-auth": "^10.0.0",
"@capacitor/android": "^8.4.0", "@capacitor/android": "^8.4.0",
"@capacitor/app": "^8.1.0", "@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.4.0", "@capacitor/core": "^8.4.0",
"@capacitor/ios": "^8.4.0", "@capacitor/ios": "^8.4.0",
"@capacitor/preferences": "^8.0.1", "@capacitor/preferences": "^8.0.1",
"capacitor-secure-storage-plugin": "^0.13.0",
"nodejs-mobile-cordova": "^0.4.3", "nodejs-mobile-cordova": "^0.4.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@ -28,7 +26,7 @@
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"esbuild": "^0.28.0", "esbuild": "^0.25.0",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"vite": "^8.0.0" "vite": "^8.0.0"
} }

View File

@ -1,98 +0,0 @@
#!/usr/bin/env bash
# Cross-compiles better-sqlite3 for arm64-v8a and armeabi-v7a (real Android
# devices) and writes the resulting better_sqlite3.node binaries into
# nodejs-assets/nodejs-project/prebuilds/<abi>/. main.js installs the one
# matching the device's actual process.arch at runtime — see main.js's
# installBetterSqlite3Prebuild().
#
# The existing x86_64 build (emulator) in
# nodejs-assets/nodejs-project/node_modules/better-sqlite3/build/Release/
# already covers nodejs-assets/nodejs-project/prebuilds/x86_64/ via
# fix-better-sqlite3-android.sh; re-copy it manually if that build changes:
# cp nodejs-assets/nodejs-project/node_modules/better-sqlite3/build/Release/better_sqlite3.node \
# nodejs-assets/nodejs-project/prebuilds/x86_64/
#
# Requires: Android NDK (ANDROID_HOME or ANDROID_NDK_HOME set), and
# `npm install` already run in nodejs-assets/nodejs-project (so
# better-sqlite3's source + nodejs-mobile-gyp are present).
#
# Usage: ./scripts/build-better-sqlite3-arm.sh
set -euo pipefail
MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG_SRC="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules/better-sqlite3"
PREBUILDS_DIR="$MOBILE_DIR/nodejs-assets/nodejs-project/prebuilds"
GYP="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules/.bin/nodejs-mobile-gyp"
NODEDIR="$MOBILE_DIR/node_modules/nodejs-mobile-cordova/libs/android/libnode"
NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}"
if [[ -z "$NDK" ]]; then
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
[[ -n "$SDK" ]] && NDK="$(ls -d "$SDK"/ndk/*/ 2>/dev/null | sort -V | tail -1)"
fi
NDK="${NDK%/}"
if [[ ! -d "$PKG_SRC" ]]; then
echo "Error: $PKG_SRC not found. Run npm install in nodejs-assets/nodejs-project first." >&2
exit 1
fi
if [[ -z "$NDK" || ! -d "$NDK" ]]; then
echo "Error: Android NDK not found. Set ANDROID_NDK_HOME (or ANDROID_HOME with an ndk/ subdir)." >&2
exit 1
fi
if [[ ! -x "$GYP" ]]; then
echo "Error: $GYP not found. Run npm install in nodejs-assets/nodejs-project first." >&2
exit 1
fi
TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64"
API=24 # matches android/variables.gradle minSdkVersion
# Each ABI's libnode.so ships gzipped; better-sqlite3's binding.gyp (via
# nodejs-mobile's common.gypi) needs the real .so to add it as a NEEDED entry.
for abi in arm64-v8a armeabi-v7a; do
LIBNODE_DIR="$NODEDIR/bin/$abi"
if [[ ! -f "$LIBNODE_DIR/libnode.so" && -f "$LIBNODE_DIR/libnode.so.gz" ]]; then
gunzip -k "$LIBNODE_DIR/libnode.so.gz"
fi
done
build_one() {
local abi="$1" gyp_arch="$2" cc="$3" cxx="$4"
local build_dir="$MOBILE_DIR/build-output/better-sqlite3-$abi"
echo "── Building better-sqlite3 for $abi ($gyp_arch) ──"
rm -rf "$build_dir"
mkdir -p "$build_dir"
rsync -a --exclude=build --exclude=prebuilds "$PKG_SRC/" "$build_dir/"
# v8::Object::CreationContext() was removed in newer V8; nodejs-mobile's
# bundled V8 (Node 12.19) only has the old non-checked accessor. Same patch
# as fix-better-sqlite3-android.sh.
sed -i 's/obj->GetCreationContext().ToLocalChecked()/obj->CreationContext()/' \
"$build_dir/src/util/binder.cpp"
(
cd "$build_dir"
CC="$TOOLCHAIN/bin/$cc" \
CXX="$TOOLCHAIN/bin/$cxx" \
AR="$TOOLCHAIN/bin/llvm-ar" \
GYP_DEFINES="OS=android" \
"$GYP" configure --target=12.19.0 --arch="$gyp_arch" --nodedir="$NODEDIR"
CC="$TOOLCHAIN/bin/$cc" \
CXX="$TOOLCHAIN/bin/$cxx" \
AR="$TOOLCHAIN/bin/llvm-ar" \
"$GYP" build
)
mkdir -p "$PREBUILDS_DIR/$abi"
cp "$build_dir/build/Release/better_sqlite3.node" "$PREBUILDS_DIR/$abi/better_sqlite3.node"
echo "Wrote $PREBUILDS_DIR/$abi/better_sqlite3.node"
}
build_one arm64-v8a arm64 aarch64-linux-android${API}-clang aarch64-linux-android${API}-clang++
build_one armeabi-v7a arm armv7a-linux-androideabi${API}-clang armv7a-linux-androideabi${API}-clang++
echo "Done. Verify with: file $PREBUILDS_DIR/*/better_sqlite3.node"

View File

@ -1,62 +0,0 @@
#!/usr/bin/env bash
# Re-applies the two patches better-sqlite3 needs to load correctly under
# nodejs-mobile (Node 12.19 / Android x86_64 emulator). Required after any
# fresh `npm install` in nodejs-assets/nodejs-project, since both patches
# target files inside node_modules.
#
# Usage: ./scripts/fix-better-sqlite3-android.sh
# Run from the mobile project root, after `npm install` has already built
# better-sqlite3 once via nodejs-mobile-gyp (so build/better_sqlite3.target.mk
# and the Release/ dir already exist).
set -euo pipefail
MOBILE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG_DIR="$MOBILE_DIR/nodejs-assets/nodejs-project/node_modules/better-sqlite3"
LIBNODE="$MOBILE_DIR/android/app/libs/cdvnodejsmobile/libnode/bin/x86_64/libnode.so"
if [[ ! -d "$PKG_DIR" ]]; then
echo "Error: $PKG_DIR not found. Run npm install in nodejs-assets/nodejs-project first." >&2
exit 1
fi
# 1. v8::Object::CreationContext() was removed in newer V8; nodejs-mobile's
# bundled V8 (Node 12.19) only has the old non-checked accessor.
BINDER="$PKG_DIR/src/util/binder.cpp"
if grep -q 'GetCreationContext().ToLocalChecked()' "$BINDER"; then
sed -i 's/obj->GetCreationContext().ToLocalChecked()/obj->CreationContext()/' "$BINDER"
echo "Patched $BINDER"
else
echo "$BINDER already uses CreationContext() (no patch needed)"
fi
# 2. nodejs-mobile-cordova's common.gypi only adds the libnode.so NEEDED entry
# when target_arch=="x86_64", but gyp reports "x64" for 64-bit x86, so the
# rule never fires. Add it to LIBS directly so the addon can resolve V8/Node
# symbols (e.g. v8::Isolate::GetCurrent) at dlopen time on Android.
TARGET_MK="$PKG_DIR/build/better_sqlite3.target.mk"
if [[ ! -f "$TARGET_MK" ]]; then
echo "Error: $TARGET_MK not found. Run npm install to build better-sqlite3 first." >&2
exit 1
fi
if ! grep -q "libnode.so" "$TARGET_MK"; then
sed -i "/^\t-llog \\\\\$/a\\\\\t$LIBNODE" "$TARGET_MK"
echo "Patched $TARGET_MK"
else
echo "$TARGET_MK already references libnode.so (no patch needed)"
fi
# Rebuild and relink with the patched sources/Makefile.
cd "$PKG_DIR/build"
rm -f Release/better_sqlite3.node Release/obj.target/better_sqlite3.node
make BUILDTYPE=Release better_sqlite3.node
# Keep the x86_64 prebuild (emulator) in sync — main.js picks the right one
# for the device's process.arch at runtime. arm64-v8a/armeabi-v7a prebuilds
# are produced separately by build-better-sqlite3-arm.sh.
PREBUILD_DIR="$MOBILE_DIR/nodejs-assets/nodejs-project/prebuilds/x86_64"
mkdir -p "$PREBUILD_DIR"
cp "$PKG_DIR/build/Release/better_sqlite3.node" "$PREBUILD_DIR/better_sqlite3.node"
echo "Done. Verify with: readelf -d \"$PKG_DIR/build/Release/better_sqlite3.node\" | grep libnode"

View File

@ -87,39 +87,6 @@ if (fs.existsSync(nodeJsJava)) {
} }
} }
// Local mode runs the embedded Node server on http://127.0.0.1:3000, which
// Android's Network Security Config blocks by default (cleartext traffic).
// `cap sync`/`cap add android` don't know about this, so (re)write the config
// and wire it into AndroidManifest.xml's <application> tag each time.
const networkSecurityConfigXml = `<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Local mode runs an embedded Node.js server on 127.0.0.1; the WebView
(https://localhost) needs to reach it over plain HTTP. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
`;
const networkSecurityConfigPath = path.join(
MOBILE_DIR, 'android', 'app', 'src', 'main', 'res', 'xml', 'network_security_config.xml'
);
fs.mkdirSync(path.dirname(networkSecurityConfigPath), { recursive: true });
fs.writeFileSync(networkSecurityConfigPath, networkSecurityConfigXml);
const manifestPath = path.join(MOBILE_DIR, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
if (fs.existsSync(manifestPath)) {
const src = fs.readFileSync(manifestPath, 'utf8');
if (!src.includes('android:networkSecurityConfig')) {
const patched = src.replace(
/(<application\b)/,
'$1\n android:networkSecurityConfig="@xml/network_security_config"'
);
fs.writeFileSync(manifestPath, patched);
console.log(`Patched ${path.relative(MOBILE_DIR, manifestPath)} (added networkSecurityConfig)`);
}
}
// nodejs-mobile-cordova's build.gradle sets up a CMake build with // nodejs-mobile-cordova's build.gradle sets up a CMake build with
// `cmake.path = "libs/cdvnodejsmobile/CMakeLists.txt"` (relative to each // `cmake.path = "libs/cdvnodejsmobile/CMakeLists.txt"` (relative to each
// module's projectDir) for both `app` and `capacitor-cordova-android-plugins`. // module's projectDir) for both `app` and `capacitor-cordova-android-plugins`.

View File

@ -54,19 +54,17 @@ rm -rf "$DEST/db/node_modules"
mkdir -p "$DEST/docs" mkdir -p "$DEST/docs"
cp "$REPO_ROOT/docs/advisory_non_bill_transaction_filters_us_ms_5000.json" "$DEST/docs/" cp "$REPO_ROOT/docs/advisory_non_bill_transaction_filters_us_ms_5000.json" "$DEST/docs/"
cp "$REPO_ROOT/docs/top_200_us_subscriptions_researched_2026-06-06.json" "$DEST/docs/" cp "$REPO_ROOT/docs/top_200_us_subscriptions_researched_2026-06-06.json" "$DEST/docs/"
cp "$REPO_ROOT/docs/merchant_store_match_us_nems_online_5k_v0_2.json" "$DEST/docs/"
# Built frontend, served as static files by server.js (path.join(__dirname, 'dist')).
cp -R "$REPO_ROOT/dist" "$DEST/dist"
# nodejs-mobile-cordova 0.4.3 embeds Node 12.19, which doesn't support # nodejs-mobile-cordova 0.4.3 embeds Node 12.19, which doesn't support
# optional chaining (?.) / nullish coalescing (??) used in this project and # optional chaining (?.) / nullish coalescing (??) used in this project and
# in some npm dependencies (e.g. express-rate-limit). Transpile server-side # in some npm dependencies (e.g. express-rate-limit). Transpile server-side
# .js files down to Node 12-compatible syntax in place. Runs before the # .js files down to Node 12-compatible syntax in place (excludes the
# frontend dist/ is copied below so its browser ESM bundle isn't rewritten # prebuilt frontend bundle in dist/).
# to CJS (which breaks with "module is not defined" in the WebView).
node "$MOBILE_DIR/scripts/transpile-node12.js" "$DEST" node "$MOBILE_DIR/scripts/transpile-node12.js" "$DEST"
# Built frontend, served as static files by server.js (path.join(__dirname, 'dist')).
cp -R "$REPO_ROOT/dist" "$DEST/dist"
# Also transpile already-installed dependencies, if present (run # Also transpile already-installed dependencies, if present (run
# `npm install` in nodejs-assets/nodejs-project after this script to pick up # `npm install` in nodejs-assets/nodejs-project after this script to pick up
# new/updated deps, then re-run this script to transpile them). # new/updated deps, then re-run this script to transpile them).

View File

@ -1,110 +1,50 @@
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 SetupScreen from './SetupScreen'; import SetupScreen from './SetupScreen';
import LoadingScreen from './LoadingScreen'; 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 LOCAL_URL = 'http://localhost:3000';
const SERVER_URL_KEY = 'serverUrl';
const BIOMETRIC_KEY = 'biometricEnabled';
type Stage =
| 'checking'
| 'setup'
| 'login'
| 'biometric-setup'
| 'biometric-lock'
| 'local'
| 'redirecting';
export default function App() { export default function App() {
const [stage, setStage] = useState<Stage>('checking'); const [ready, setReady] = useState(false);
const [serverUrl, setServerUrl] = useState(''); const [localMode, setLocalMode] = useState(false);
useEffect(() => { useEffect(() => {
Preferences.get({ key: SERVER_URL_KEY }).then(async ({ value }) => { Preferences.get({ key: 'serverUrl' }).then(({ value }) => {
if (value === 'local') { if (value === 'local') {
setStage('local'); setLocalMode(true);
return; } else if (value) {
// Navigate the WebView to the saved server URL.
// From this point the remote server's UI takes over entirely.
window.location.replace(value);
} else {
setReady(true);
} }
if (value) {
setServerUrl(value);
const { value: biometricEnabled } = await Preferences.get({ key: BIOMETRIC_KEY });
setStage(biometricEnabled === 'true' ? 'biometric-lock' : 'redirecting');
return;
}
setStage('setup');
}); });
}, []); }, []);
useEffect(() => { async function handleConnect(url: string) {
if (stage === 'redirecting') { await Preferences.set({ key: 'serverUrl', value: url });
window.location.replace(serverUrl); window.location.replace(url);
}
}, [stage, serverUrl]);
function handleConnect(url: string) {
setServerUrl(url);
setStage('login');
} }
async function handleLocalMode() { async function handleLocalMode() {
await Preferences.set({ key: SERVER_URL_KEY, value: 'local' }); await Preferences.set({ key: 'serverUrl', value: 'local' });
setStage('local'); setLocalMode(true);
} }
async function handleLoginSuccess() { if (localMode) {
await Preferences.set({ key: SERVER_URL_KEY, value: serverUrl }); return <LoadingScreen onReady={() => window.location.replace(LOCAL_URL)} />;
try {
const { isAvailable } = await BiometricAuth.checkBiometry();
setStage(isAvailable ? 'biometric-setup' : 'redirecting');
} catch {
setStage('redirecting');
}
} }
async function handleBiometricSetupDone(enabled: boolean) { if (!ready) {
await Preferences.set({ key: BIOMETRIC_KEY, value: enabled ? 'true' : 'false' }); // Brief blank while we check preferences before redirecting
setStage('redirecting'); return (
<div style={{ background: '#09090b', height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="spinner" />
</div>
);
} }
async function handleBiometricFallback() { return <SetupScreen onConnect={handleConnect} onLocalMode={handleLocalMode} />;
await Preferences.set({ key: BIOMETRIC_KEY, value: 'false' });
setStage('login');
}
switch (stage) {
case 'local':
return <LoadingScreen onReady={() => window.location.replace(LOCAL_URL)} />;
case 'setup':
return <SetupScreen onConnect={handleConnect} onLocalMode={handleLocalMode} />;
case 'login':
return (
<LoginScreen
serverUrl={serverUrl}
onSuccess={handleLoginSuccess}
onBack={() => setStage('setup')}
/>
);
case 'biometric-setup':
return <BiometricSetup onDone={handleBiometricSetupDone} />;
case 'biometric-lock':
return <BiometricLock onUnlocked={() => setStage('redirecting')} onFallback={handleBiometricFallback} />;
default:
// 'checking' / 'redirecting' — brief blank while we check preferences
// or hand off to the WebView.
return (
<div style={{ background: '#09090b', height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="spinner" />
</div>
);
}
} }

View File

@ -1,67 +0,0 @@
import { useEffect, useState } from 'react';
import { BiometricAuth, BiometryError, BiometryErrorType } from '@aparajita/capacitor-biometric-auth';
interface Props {
onUnlocked: () => void;
/** User can't (or won't) use biometrics — fall back to server login. */
onFallback: () => void;
}
/**
* App-lock gate shown on launch when biometric unlock is enabled. The
* server session cookie persists in the WebView's cookie store between
* launches, so this is what stands between "phone unlocked" and "Bill
* Tracker dashboard visible".
*/
export default function BiometricLock({ onUnlocked, onFallback }: Props) {
const [error, setError] = useState('');
const [checking, setChecking] = useState(true);
async function attempt() {
setError('');
setChecking(true);
try {
await BiometricAuth.authenticate({
reason: 'Unlock Bill Tracker',
cancelTitle: 'Cancel',
allowDeviceCredential: true,
androidTitle: 'Unlock Bill Tracker',
});
onUnlocked();
} catch (err) {
if (err instanceof BiometryError && err.code === BiometryErrorType.userCancel) {
setError('');
} else {
setError('Authentication failed. Try again.');
}
} finally {
setChecking(false);
}
}
useEffect(() => {
attempt();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="setup-root">
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}>
{checking ? (
<div className="spinner" />
) : (
<>
<h1 className="setup-title">Locked</h1>
<p className="setup-subtitle">{error || 'Authenticate to continue.'}</p>
<button className="btn-primary" onClick={attempt} style={{ width: '100%' }}>
Try Again
</button>
<button className="btn-secondary" onClick={onFallback} style={{ width: '100%' }}>
Sign In Instead
</button>
</>
)}
</div>
</div>
);
}

View File

@ -1,69 +0,0 @@
import { useState } from 'react';
import { BiometricAuth } from '@aparajita/capacitor-biometric-auth';
interface Props {
/** Called with whether biometric unlock should be enabled going forward. */
onDone: (enabled: boolean) => void;
}
/**
* Shown once, right after a successful server login, while biometry is
* available on the device. Confirms the user can actually authenticate
* before turning the lock on.
*/
export default function BiometricSetup({ onDone }: Props) {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleEnable() {
setError('');
setLoading(true);
try {
await BiometricAuth.authenticate({
reason: 'Confirm to enable biometric unlock',
cancelTitle: 'Cancel',
allowDeviceCredential: true,
androidTitle: 'Enable biometric unlock',
});
onDone(true);
} catch {
setError('Could not verify your identity. You can try again or skip for now.');
} finally {
setLoading(false);
}
}
return (
<div className="setup-root">
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}>
<div className="logo-mark" aria-hidden="true">
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="10" fill="#6366f1" fillOpacity="0.15" />
<path
d="M20 12a6 6 0 0 0-6 6v2a6 6 0 0 0 .5 2.4M20 12a6 6 0 0 1 6 6v2c0 3-1 5-2 6.5M14 20v2c0 2.5.7 4.3 1.8 5.8M26 20v2c0 1.3-.2 2.6-.6 3.8M17.5 27.5c.8.9 1.8 1.7 2.9 2.2M11 14a9 9 0 0 1 18 0"
stroke="#6366f1"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h1 className="setup-title">Enable biometric unlock?</h1>
<p className="setup-subtitle">
Use your fingerprint or face to quickly unlock Bill Tracker the next time you open the
app.
</p>
{error && <p className="form-error">{error}</p>}
<button className="btn-primary" onClick={handleEnable} disabled={loading} style={{ width: '100%' }}>
{loading ? 'Verifying…' : 'Enable Biometric Unlock'}
</button>
<button className="btn-secondary" onClick={() => onDone(false)} disabled={loading} style={{ width: '100%' }}>
Not Now
</button>
</div>
</div>
);
}

View File

@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getOrCreateEncryptionKey } from './crypto';
const LOCAL_URL = 'http://127.0.0.1:3000'; const LOCAL_URL = 'http://localhost:3000';
const HEALTH_URL = `${LOCAL_URL}/api/health`; const HEALTH_URL = `${LOCAL_URL}/api/health`;
const POLL_INTERVAL_MS = 250; const POLL_INTERVAL_MS = 250;
@ -35,31 +34,16 @@ export default function LoadingScreen({ onReady }: Props) {
}); });
} }
const nodejs = window.nodejs; if (!window.nodejs) {
if (!nodejs) {
setError('Local server engine is unavailable on this platform.'); setError('Local server engine is unavailable on this platform.');
return; return;
} }
nodejs.start('main.js', err => { window.nodejs.start('main.js', err => {
if (err && !cancelled) { if (err && !cancelled) {
setError('Failed to start local server: ' + String(err)); setError('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 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.
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 });
});
});
pollHealth(); pollHealth();
}); });

View File

@ -1,215 +0,0 @@
import { useState } from 'react';
interface Props {
serverUrl: string;
/** Called once the backend has confirmed the credentials and set a session cookie. */
onSuccess: () => void;
onBack: () => void;
}
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<Stage>('credentials');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [challengeToken, setChallengeToken] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
function handleKey(e: React.KeyboardEvent) {
if (e.key !== 'Enter') return;
if (stage === 'credentials') handleLogin();
if (stage === 'totp') handleTotp();
}
async function handleLogin() {
if (!username.trim() || !password) {
setError('Enter your username and password.');
return;
}
setError('');
setLoading(true);
try {
const { ok, data } = await postJson(`${serverUrl}/api/auth/login`, {
username: username.trim(),
password,
});
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);
}
}
async function handleTotp() {
if (!code.trim()) {
setError('Enter your authenticator code.');
return;
}
setError('');
setLoading(true);
try {
const { ok, data } = await postJson(`${serverUrl}/api/auth/totp/challenge`, {
challenge_token: challengeToken,
code: code.trim(),
});
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 (stage === 'webauthn') {
return (
<div className="setup-root">
<div className="setup-card">
<h1 className="setup-title">Security key required</h1>
<p className="setup-subtitle">
This account signs in with a security key. Continue to sign in through the server's
web interface.
</p>
<button className="btn-primary" onClick={onSuccess}>
Continue
</button>
<button className="btn-secondary" onClick={onBack}>
Back
</button>
</div>
</div>
);
}
if (stage === 'totp') {
return (
<div className="setup-root">
<div className="setup-card">
<h1 className="setup-title">Two-factor code</h1>
<p className="setup-subtitle">Enter the code from your authenticator app.</p>
<div className="form-group">
<label className="form-label" htmlFor="totp-code">Code</label>
<input
id="totp-code"
className="form-input"
type="text"
inputMode="numeric"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="123456"
value={code}
onChange={e => { setCode(e.target.value); setError(''); }}
onKeyDown={handleKey}
disabled={loading}
autoFocus
/>
{error && <p className="form-error">{error}</p>}
</div>
<button className="btn-primary" onClick={handleTotp} disabled={loading || !code.trim()}>
{loading ? 'Verifying…' : 'Verify'}
</button>
<button className="btn-secondary" onClick={onBack} disabled={loading}>
Back
</button>
</div>
</div>
);
}
return (
<div className="setup-root">
<div className="setup-card">
<h1 className="setup-title">Sign in</h1>
<p className="setup-subtitle">Sign in to {serverUrl}</p>
<div className="form-group">
<label className="form-label" htmlFor="login-username">Username</label>
<input
id="login-username"
className="form-input"
type="text"
inputMode="text"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="Username"
value={username}
onChange={e => { setUsername(e.target.value); setError(''); }}
onKeyDown={handleKey}
disabled={loading}
autoFocus
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="login-password">Password</label>
<input
id="login-password"
className="form-input"
type="password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="Password"
value={password}
onChange={e => { setPassword(e.target.value); setError(''); }}
onKeyDown={handleKey}
disabled={loading}
/>
{error && <p className="form-error">{error}</p>}
</div>
<button className="btn-primary" onClick={handleLogin} disabled={loading || !username.trim() || !password}>
{loading ? 'Signing in…' : 'Sign In'}
</button>
<button className="btn-secondary" onClick={onBack} disabled={loading}>
Back
</button>
</div>
</div>
);
}

View File

@ -84,7 +84,7 @@ export default function SetupScreen({ onConnect, onLocalMode }: Props) {
onClick={handleConnect} onClick={handleConnect}
disabled={connecting || !url.trim()} disabled={connecting || !url.trim()}
> >
{connecting ? 'Connecting…' : 'Continue'} {connecting ? 'Connecting…' : 'Connect to Server'}
</button> </button>
<div className="divider"> <div className="divider">

View File

@ -1,28 +0,0 @@
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
const KEY_NAME = 'tokenEncryptionKey';
const KEY_BYTES = 48;
function generateHexKey(bytes: number): string {
const arr = new Uint8Array(bytes);
globalThis.crypto.getRandomValues(arr);
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
}
/**
* 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.
*/
export async function getOrCreateEncryptionKey(): Promise<string> {
try {
const { value } = await SecureStoragePlugin.get({ key: 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 });
return key;
}

4
src/vite-env.d.ts vendored
View File

@ -4,9 +4,5 @@
interface Window { interface Window {
nodejs?: { nodejs?: {
start: (filename: string, callback?: (err: unknown) => void) => void; start: (filename: string, callback?: (err: unknown) => void) => void;
channel: {
on: (event: string, callback: (msg: unknown) => void) => void;
post: (event: string, message: unknown) => void;
};
}; };
} }