@oneaddress/setup 1.6.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1162 -80
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
856
856
  var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
857
857
  var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
858
858
  var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
859
- var WIZARD_VERSION = true ? "1.6.2" : "?";
859
+ var WIZARD_VERSION = true ? "2.0.0" : "?";
860
860
  function printCompactHeader() {
861
861
  const INNER = 42;
862
862
  const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
@@ -963,19 +963,22 @@ data.db-shm
963
963
  "version": "1.0.0",
964
964
  "private": true,
965
965
  "scripts": {
966
- "dev": "tsx watch src/server.ts",
967
- "start": "node dist/server.js",
968
- "build": "tsup src/server.ts --format cjs --no-dts --outDir dist",
966
+ "dev": "tsx watch src/index.ts",
967
+ "start": "tsx src/index.ts",
968
+ "headless": "tsx src/index.ts -- --headless",
969
+ "build": "tsup src/index.ts --format esm --no-dts --outDir dist",
969
970
  "type-check": "tsc --noEmit",
970
- "test": "tsx scripts/test.ts"
971
+ "test": "tsx scripts/test.ts"
971
972
  },
972
973
  "dependencies": {
973
974
  "@oneaddress/partner-sdk": "^1.8.0",
975
+ "blessed": "^0.1.81",
974
976
  "dotenv": "^16.0.0",
975
977
  "express": "^4.18.0",
976
978
  "express-rate-limit": "^8.6.2"
977
979
  },
978
980
  "devDependencies": {
981
+ "@types/blessed": "^0.1.25",
979
982
  "@types/express": "^4.17.0",
980
983
  "@types/node": "^22.5.0",
981
984
  "tsup": "^8.0.0",
@@ -1041,32 +1044,844 @@ data.db-shm
1041
1044
  {
1042
1045
  name: "src/db.ts",
1043
1046
  content: `/**
1044
- * SQLite database \u2014 initialised automatically on first run.
1047
+ * SQLite database, and the at-rest encryption layer over it.
1045
1048
  *
1046
- * Uses the Node.js built-in \`node:sqlite\` module (available in Node 22.5+).
1047
- * No extra packages or native compilation required.
1049
+ * Uses the built-in \`node:sqlite\` module (Node 22.5+), so there is nothing to
1050
+ * compile and nothing extra to install.
1048
1051
  *
1049
- * This module only OPENS the database and exports the handle. The schema \u2014 the
1050
- * customer roster plus the append-only address-history audit trail \u2014 is created
1051
- * by src/store.ts, which OWNS those tables, so everything about how your data is
1052
- * shaped lives in the one file you are meant to edit.
1052
+ * This module opens the file, derives the at-rest keys, and exports the helpers
1053
+ * \`src/store.ts\` uses to encrypt on the way in and decrypt on the way out. The
1054
+ * SCHEMA lives in store.ts, which owns your data shape, so everything you are
1055
+ * meant to edit stays in the one file.
1053
1056
  *
1054
- * The database file lives at DB_PATH (default: data.db in the project root).
1055
- * Change the location with the DB_PATH environment variable.
1057
+ * ## Where the passphrase comes from
1058
+ *
1059
+ * \`ONEADDRESS_DB_PASSPHRASE\`, read once when this module loads. \`src/index.ts\`
1060
+ * prompts for it and sets it before importing anything that touches the
1061
+ * database, so a service can supply it from the environment and a person can
1062
+ * type it. Deliberately an environment variable rather than an argument
1063
+ * threaded through every call: the store is a module-level singleton, and a
1064
+ * half-initialised one is worse than either alternative.
1065
+ *
1066
+ * ## Unencrypted is a supported mode
1067
+ *
1068
+ * No passphrase means the database runs in the clear, exactly as it did before
1069
+ * this existed. That keeps an existing install working and lets you try the
1070
+ * receiver without deciding anything. The dashboard shows which mode you are
1071
+ * in, and shows the unencrypted one in red, because a protection you only
1072
+ * mention when it is present is one nobody notices the absence of.
1056
1073
  */
1057
1074
  import { DatabaseSync } from 'node:sqlite';
1058
1075
  import { join } from 'node:path';
1076
+ import {
1077
+ accountIndex,
1078
+ buildVerifier,
1079
+ decryptField,
1080
+ deriveKeys,
1081
+ encryptField,
1082
+ isEncrypted,
1083
+ newSalt,
1084
+ verifierMatches,
1085
+ WrongPassphraseError,
1086
+ type VaultKeys,
1087
+ } from './vault.js';
1059
1088
 
1060
1089
  const DB_PATH = process.env.DB_PATH ?? join(process.cwd(), 'data.db');
1061
1090
 
1062
1091
  const db = new DatabaseSync(DB_PATH);
1092
+ db.exec('PRAGMA journal_mode = WAL');
1093
+
1094
+ // Holds the salt and the verifier. Created before anything else needs them.
1095
+ db.exec(\`
1096
+ CREATE TABLE IF NOT EXISTS db_meta (
1097
+ key TEXT PRIMARY KEY,
1098
+ value TEXT NOT NULL
1099
+ );
1100
+ \`);
1101
+
1102
+ function readMeta(key: string): string | null {
1103
+ const row = db.prepare('SELECT value FROM db_meta WHERE key = ?').get(key) as
1104
+ | { value: string }
1105
+ | undefined;
1106
+ return row?.value ?? null;
1107
+ }
1108
+
1109
+ function writeMeta(key: string, value: string): void {
1110
+ db.prepare(
1111
+ 'INSERT INTO db_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
1112
+ ).run(key, value);
1113
+ }
1114
+
1115
+ /**
1116
+ * The salt for this database file.
1117
+ *
1118
+ * Written once and thereafter only read. Derive from a different salt and the
1119
+ * same passphrase produces different keys, which makes every existing row
1120
+ * unreadable, so this must never be regenerated for a file that has data.
1121
+ */
1122
+ function saltForThisDatabase(): Buffer {
1123
+ const existing = readMeta('salt');
1124
+ if (existing) return Buffer.from(existing, 'base64');
1125
+ const salt = newSalt();
1126
+ writeMeta('salt', salt.toString('base64'));
1127
+ return salt;
1128
+ }
1129
+
1130
+ function openKeys(): VaultKeys | null {
1131
+ const passphrase = process.env.ONEADDRESS_DB_PASSPHRASE;
1132
+ if (!passphrase || !passphrase.trim()) return null;
1063
1133
 
1064
- // WAL mode \u2014 better performance for concurrent reads
1065
- db.exec("PRAGMA journal_mode = WAL");
1134
+ const keys = deriveKeys(passphrase, saltForThisDatabase());
1066
1135
 
1067
- console.log(\`[db] SQLite database ready \u2192 \${DB_PATH}\`);
1136
+ // Checked BEFORE anything is written. A mistyped passphrase must not be able
1137
+ // to write a single row of ciphertext that nothing can ever read back.
1138
+ const stored = readMeta('verifier');
1139
+ if (stored === null) writeMeta('verifier', buildVerifier(keys));
1140
+ else if (!verifierMatches(keys, stored)) throw new WrongPassphraseError();
1141
+
1142
+ return keys;
1143
+ }
1144
+
1145
+ const keys = openKeys();
1146
+
1147
+ /** True when the personal columns in this file are ciphertext. */
1148
+ export const encrypted = keys !== null;
1149
+
1150
+ /** Encrypt one field for storage, or pass it through when running in the clear. */
1151
+ export function enc(column: string, value: string | null): string | null {
1152
+ return keys ? encryptField(keys, column, value) : value;
1153
+ }
1154
+
1155
+ /** Decrypt one stored field. Plaintext passes through, so a migrating file works. */
1156
+ export function dec(column: string, value: string | null): string | null {
1157
+ return keys ? decryptField(keys, column, value) : value;
1158
+ }
1159
+
1160
+ /**
1161
+ * The value to store and query an account number by.
1162
+ *
1163
+ * A blind index when locked, the plain number otherwise. This is what keeps the
1164
+ * account lookup an exact indexed hit rather than a table scan, which matters
1165
+ * because every dispatch resolves on it.
1166
+ */
1167
+ export function accountKey(accountNumber: string): string {
1168
+ return keys ? accountIndex(keys, accountNumber) : accountNumber.trim().toLowerCase();
1169
+ }
1170
+
1171
+ /**
1172
+ * Encrypt a value unless it already is encrypted.
1173
+ *
1174
+ * \`encryptField\` cannot tell a ciphertext from a plaintext and will encrypt one
1175
+ * twice if asked, leaving a row that decrypts to an envelope rather than to an
1176
+ * address. Anything that re-writes existing rows must go through this, not
1177
+ * through \`enc\`, and then re-running a migration is harmless by construction
1178
+ * rather than by remembering to set a flag.
1179
+ */
1180
+ export function once(column: string, value: string | null): string | null {
1181
+ if (!keys) return value;
1182
+ return isEncrypted(value) ? value : encryptField(keys, column, value);
1183
+ }
1068
1184
 
1185
+ export { WrongPassphraseError };
1069
1186
  export default db;
1187
+ `
1188
+ },
1189
+ {
1190
+ name: "src/brand.ts",
1191
+ content: `/**
1192
+ * OneAddress brand for the terminal.
1193
+ *
1194
+ * The same wordmark, palette and geometry the setup wizard prints, so the
1195
+ * receiver a partner ends up running looks like the tool that installed it and
1196
+ * like the site they signed up on. One brand, three surfaces.
1197
+ *
1198
+ * ## Why the mark is pixel art rather than an image
1199
+ *
1200
+ * A terminal has no images. The wordmark is drawn from 7-row glyphs at 7
1201
+ * columns each, which is the only representation that survives SSH, tmux, a
1202
+ * Windows console and a CI log unchanged. It is the same glyph set the wizard
1203
+ * uses, deliberately: a redrawn "close enough" version is how two marks that
1204
+ * are supposed to be one start to drift.
1205
+ *
1206
+ * ## The width rule that is easy to get wrong
1207
+ *
1208
+ * The full banner is 88 columns. A default Git Bash window is 80, so the full
1209
+ * mark wraps there and shows the logo half-cut, which looks broken rather than
1210
+ * looking large. Under 88 columns we print a compact framed wordmark instead:
1211
+ * it fits any real terminal and says the same thing. Check the width, never
1212
+ * assume it.
1213
+ */
1214
+
1215
+ // \u2500\u2500 Palette \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1216
+ // Hex values are the design system's, not approximations: amber #E0A248 and
1217
+ // cream #F5EED8 are the same constants the web app's \`C\` object carries.
1218
+ export const HEX = {
1219
+ amber: '#E0A248',
1220
+ amberLight: '#F0C878',
1221
+ cream: '#F5EED8',
1222
+ ink: '#13151C',
1223
+ faint: '#82642D',
1224
+ dim: '#463A23',
1225
+ mid: '#9B8764',
1226
+ } as const;
1227
+
1228
+ // \u2500\u2500 ANSI, for the boot lines printed before the dashboard takes the screen \u2500\u2500\u2500
1229
+ const R = '\\x1b[0m';
1230
+ const B = '\\x1b[1m';
1231
+ const AMB = '\\x1b[38;2;224;162;72m';
1232
+ const CRM = '\\x1b[38;2;245;238;216m';
1233
+ const FNT = '\\x1b[38;2;130;100;45m';
1234
+ const DIM = '\\x1b[38;2;70;58;35m';
1235
+
1236
+ export const amber = (s: string) => \`\${B}\${AMB}\${s}\${R}\`;
1237
+ export const cream = (s: string) => \`\${B}\${CRM}\${s}\${R}\`;
1238
+ export const faint = (s: string) => \`\${FNT}\${s}\${R}\`;
1239
+ export const dim = (s: string) => \`\${DIM}\${s}\${R}\`;
1240
+
1241
+ // \u2500\u2500 Wordmark glyphs \u2014 7 rows, 7 columns each \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1242
+ /* eslint-disable no-multi-spaces */
1243
+ const _O = [' \u2588\u2588\u2588\u2588\u2588 ', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', ' \u2588\u2588\u2588\u2588\u2588 '];
1244
+ const _N = ['\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588\u2588\u2588\u2588\u2588', '\u2588\u2588 \u2588\u2588\u2588\u2588', '\u2588\u2588 \u2588\u2588\u2588', '\u2588\u2588 \u2588\u2588'];
1245
+ const _E = ['\u2588\u2588\u2588\u2588\u2588\u2588\u2588', '\u2588\u2588 ', '\u2588\u2588 ', '\u2588\u2588\u2588\u2588\u2588\u2588 ', '\u2588\u2588 ', '\u2588\u2588 ', '\u2588\u2588\u2588\u2588\u2588\u2588\u2588'];
1246
+ const _A = [' \u2588\u2588\u2588 ', ' \u2588\u2588 \u2588\u2588 ', '\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588\u2588\u2588\u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588'];
1247
+ const _D = ['\u2588\u2588\u2588\u2588\u2588\u2588 ', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588\u2588\u2588\u2588 '];
1248
+ const _RG = ['\u2588\u2588\u2588\u2588\u2588\u2588 ', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588\u2588\u2588\u2588 ', '\u2588\u2588\u2588\u2588 ', '\u2588\u2588 \u2588\u2588 ', '\u2588\u2588 \u2588\u2588 '];
1249
+ const _S = [' \u2588\u2588\u2588\u2588\u2588\u2588', '\u2588\u2588 ', '\u2588\u2588 ', ' \u2588\u2588\u2588\u2588\u2588 ', ' \u2588\u2588', ' \u2588\u2588', '\u2588\u2588\u2588\u2588\u2588\u2588 '];
1250
+ /* eslint-enable no-multi-spaces */
1251
+
1252
+ /** "ONE" in cream, 23 columns wide. */
1253
+ export const ONE_ROWS = Array.from({ length: 7 }, (_, i) => [_O[i], _N[i], _E[i]].join(' '));
1254
+
1255
+ /** "ADDRESS" in amber, 55 columns wide. */
1256
+ export const ADDRESS_ROWS = Array.from(
1257
+ { length: 7 },
1258
+ (_, i) => [_A[i], _D[i], _D[i], _RG[i], _E[i], _S[i], _S[i]].join(' '),
1259
+ );
1260
+
1261
+ /** Columns the full banner needs. Below this, use the compact mark. */
1262
+ export const FULL_BANNER_COLUMNS = 88;
1263
+
1264
+ /** Does this terminal have room for the full wordmark? */
1265
+ export function terminalFitsFullMark(columns = process.stdout.columns ?? 80): boolean {
1266
+ return columns >= FULL_BANNER_COLUMNS;
1267
+ }
1268
+
1269
+ /**
1270
+ * Print the boot banner to stdout, before any full-screen UI starts.
1271
+ *
1272
+ * Deliberately plain \`console.log\`: this runs while the terminal is still a
1273
+ * terminal. Once the dashboard starts, blessed owns the screen and nothing may
1274
+ * write to stdout behind its back.
1275
+ */
1276
+ export function printBanner(subtitle: string): void {
1277
+ const wide = terminalFitsFullMark();
1278
+ console.log('');
1279
+ if (wide) {
1280
+ for (let i = 0; i < 7; i++) {
1281
+ console.log(' ' + cream(ONE_ROWS[i]) + ' ' + amber(ADDRESS_ROWS[i]));
1282
+ }
1283
+ } else {
1284
+ const INNER = 42;
1285
+ console.log(' ' + faint('\u250C') + dim('\u2500'.repeat(INNER)) + faint('\u2510'));
1286
+ const pad = (s: string, len: number) => s + ' '.repeat(Math.max(0, INNER - 2 - len));
1287
+ const mark = cream('One') + amber('Address');
1288
+ console.log(' ' + dim('\u2502') + ' ' + pad(mark, 10) + dim('\u2502'));
1289
+ console.log(' ' + faint('\u2514') + dim('\u2500'.repeat(INNER)) + faint('\u2518'));
1290
+ }
1291
+ console.log('');
1292
+ console.log(' ' + faint(subtitle));
1293
+ console.log('');
1294
+ }
1295
+ `
1296
+ },
1297
+ {
1298
+ name: "src/vault.ts",
1299
+ content: `/**
1300
+ * At-rest encryption for YOUR customer database.
1301
+ *
1302
+ * ## Two keys, and why they must not be one
1303
+ *
1304
+ * This receiver holds two secrets that do completely different jobs:
1305
+ *
1306
+ * - PARTNER_PRIVATE_KEY_PEM is the ECDH key OneAddress holds the public half
1307
+ * of. It decrypts what ARRIVES. OneAddress chose to send to it.
1308
+ * - the key derived here is yours alone, from a passphrase OneAddress has
1309
+ * never seen and cannot ask you for. It protects what is STORED.
1310
+ *
1311
+ * Using one key for both would mean the credential you share with a counterparty
1312
+ * is also the key to your own customer file. Keep them apart.
1313
+ *
1314
+ * ## Field-level, and what that leaves visible
1315
+ *
1316
+ * Whole-file encryption would need SQLCipher and a native build. This encrypts
1317
+ * the personal columns using Node's built-in crypto, so it installs anywhere
1318
+ * Node runs. Be clear-eyed about what the file still reveals:
1319
+ *
1320
+ * - how many customers there are
1321
+ * - when each record changed, and how often
1322
+ * - a stable per-account index, so rows can be told apart and watched
1323
+ *
1324
+ * What it protects: every name, address, email and phone number. Someone who
1325
+ * copies data.db off this machine gets none of those.
1326
+ *
1327
+ * ## It fails closed, and says why
1328
+ *
1329
+ * A known token is stored encrypted under the derived key, so a wrong passphrase
1330
+ * is caught when the database opens rather than as decryption errors on your
1331
+ * first real dispatch, which reads as data corruption instead of as a typo.
1332
+ */
1333
+ import {
1334
+ createCipheriv,
1335
+ createDecipheriv,
1336
+ createHmac,
1337
+ randomBytes,
1338
+ scryptSync,
1339
+ timingSafeEqual,
1340
+ } from 'node:crypto';
1341
+
1342
+ /** Envelope version. A format change becomes detectable rather than silent. */
1343
+ const VERSION = 'v1';
1344
+
1345
+ /** scrypt cost. Paid once when the database opens, never per row. */
1346
+ const SCRYPT_N = 32768;
1347
+ const SCRYPT_KEYLEN = 64;
1348
+
1349
+ /**
1350
+ * scrypt needs 128 * N * r bytes, which at N=32768 and the default r=8 is about
1351
+ * 33.5 MB, just over Node's default 32 MB cap. Raised deliberately rather than
1352
+ * lowering N, because N is the cost and reducing it to fit a default is
1353
+ * weakening the protection on purpose.
1354
+ */
1355
+ const SCRYPT_MAXMEM = 64 * 1024 * 1024;
1356
+
1357
+ const VERIFIER_PLAINTEXT = 'oneaddress-receiver-at-rest-v1';
1358
+
1359
+ export class WrongPassphraseError extends Error {
1360
+ constructor() {
1361
+ super('That passphrase does not open this database.');
1362
+ this.name = 'WrongPassphraseError';
1363
+ }
1364
+ }
1365
+
1366
+ export interface VaultKeys {
1367
+ /** AES-256-GCM key for the personal columns. */
1368
+ cipherKey: Buffer;
1369
+ /** HMAC key for the account blind index. Separate on purpose. */
1370
+ macKey: Buffer;
1371
+ }
1372
+
1373
+ /**
1374
+ * Derive both keys from one passphrase.
1375
+ *
1376
+ * Two keys, not one used twice: an AES key doubling as an HMAC key is a
1377
+ * cross-protocol mistake, and splitting costs nothing.
1378
+ */
1379
+ export function deriveKeys(passphrase: string, salt: Buffer): VaultKeys {
1380
+ const full = scryptSync(passphrase.normalize('NFKC'), salt, SCRYPT_KEYLEN, {
1381
+ N: SCRYPT_N,
1382
+ maxmem: SCRYPT_MAXMEM,
1383
+ });
1384
+ return { cipherKey: full.subarray(0, 32), macKey: full.subarray(32, 64) };
1385
+ }
1386
+
1387
+ /** A fresh per-database salt. Stored alongside the data; not a secret. */
1388
+ export function newSalt(): Buffer {
1389
+ return randomBytes(16);
1390
+ }
1391
+
1392
+ /**
1393
+ * Bind a ciphertext to the column it belongs in.
1394
+ *
1395
+ * AES-GCM authenticates bytes and has no opinion about where they were stored,
1396
+ * so without this a postcode ciphertext moved into the street column decrypts
1397
+ * cleanly and the row reads back wrong. With it, the tag check fails.
1398
+ */
1399
+ function aad(column: string): Buffer {
1400
+ return Buffer.from(\`oa-receiver|\${VERSION}|\${column}\`, 'utf8');
1401
+ }
1402
+
1403
+ /**
1404
+ * Encrypt one field.
1405
+ *
1406
+ * An absent value stays absent: encrypting null would give every missing phone
1407
+ * number a distinct-looking ciphertext that then renders as garbage.
1408
+ */
1409
+ export function encryptField(keys: VaultKeys, column: string, plaintext: string | null): string | null {
1410
+ if (plaintext === null || plaintext === '') return plaintext;
1411
+ const iv = randomBytes(12);
1412
+ const cipher = createCipheriv('aes-256-gcm', keys.cipherKey, iv, { authTagLength: 16 });
1413
+ cipher.setAAD(aad(column));
1414
+ const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
1415
+ return [
1416
+ VERSION,
1417
+ iv.toString('base64url'),
1418
+ cipher.getAuthTag().toString('base64url'),
1419
+ ct.toString('base64url'),
1420
+ ].join('.');
1421
+ }
1422
+
1423
+ /** True when a stored value carries this module's envelope. */
1424
+ export function isEncrypted(value: string | null): boolean {
1425
+ return typeof value === 'string' && value.startsWith(\`\${VERSION}.\`) && value.split('.').length === 4;
1426
+ }
1427
+
1428
+ /**
1429
+ * Decrypt one field.
1430
+ *
1431
+ * A value that is not in the envelope format is returned unchanged, so a
1432
+ * database written before you set a passphrase keeps working while it migrates.
1433
+ */
1434
+ export function decryptField(keys: VaultKeys, column: string, stored: string | null): string | null {
1435
+ if (stored === null || stored === '') return stored;
1436
+ if (!isEncrypted(stored)) return stored;
1437
+
1438
+ const [, ivB64, tagB64, ctB64] = stored.split('.');
1439
+ const decipher = createDecipheriv('aes-256-gcm', keys.cipherKey, Buffer.from(ivB64, 'base64url'), {
1440
+ authTagLength: 16,
1441
+ });
1442
+ decipher.setAAD(aad(column));
1443
+ decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
1444
+ return Buffer.concat([
1445
+ decipher.update(Buffer.from(ctB64, 'base64url')),
1446
+ decipher.final(),
1447
+ ]).toString('utf8');
1448
+ }
1449
+
1450
+ /**
1451
+ * Deterministic index for the account-number lookup.
1452
+ *
1453
+ * The account number is the one column that must stay searchable by exact
1454
+ * equality, because every dispatch resolves on it. An HMAC under a key an
1455
+ * attacker does not hold keeps that lookup exact while leaving the number
1456
+ * itself unreadable in the file. Lower-cased, matching the case-insensitive
1457
+ * comparison it replaces.
1458
+ */
1459
+ export function accountIndex(keys: VaultKeys, accountNumber: string): string {
1460
+ return createHmac('sha256', keys.macKey)
1461
+ .update(accountNumber.trim().toLowerCase(), 'utf8')
1462
+ .digest('hex');
1463
+ }
1464
+
1465
+ /** The token stored when a database is first locked. */
1466
+ export function buildVerifier(keys: VaultKeys): string {
1467
+ return encryptField(keys, 'verifier', VERIFIER_PLAINTEXT)!;
1468
+ }
1469
+
1470
+ /** Check a passphrase against the stored verifier before any row is touched. */
1471
+ export function verifierMatches(keys: VaultKeys, storedVerifier: string): boolean {
1472
+ let decrypted: string | null;
1473
+ try {
1474
+ decrypted = decryptField(keys, 'verifier', storedVerifier);
1475
+ } catch {
1476
+ return false; // A tag failure is exactly what a wrong passphrase looks like.
1477
+ }
1478
+ if (decrypted === null) return false;
1479
+ const a = Buffer.from(decrypted, 'utf8');
1480
+ const b = Buffer.from(VERIFIER_PLAINTEXT, 'utf8');
1481
+ return a.length === b.length && timingSafeEqual(a, b);
1482
+ }
1483
+ `
1484
+ },
1485
+ {
1486
+ name: "src/report.ts",
1487
+ content: `/**
1488
+ * Where this receiver's narration goes.
1489
+ *
1490
+ * ## Why this exists instead of \`console.log\`
1491
+ *
1492
+ * The dashboard (\`src/tui.ts\`) takes over the terminal. Anything written to
1493
+ * stdout while it is running lands on top of the drawn screen and corrupts it,
1494
+ * so the protocol layer cannot simply print. It reports, and whoever is running
1495
+ * decides where that goes: the dashboard renders it into the event log, and
1496
+ * \`--headless\` writes it to stdout exactly as this receiver always did.
1497
+ *
1498
+ * ## The signature is console's on purpose
1499
+ *
1500
+ * \`info\`, \`warn\` and \`error\` take the same variadic arguments as \`console.log\`,
1501
+ * \`console.warn\` and \`console.error\`, so a message reads identically at the call
1502
+ * site and the default sink can hand them straight through. That is deliberate:
1503
+ * the alternative is rewriting two dozen messages into some structured shape,
1504
+ * which changes what a partner sees in their logs for no benefit and makes the
1505
+ * diff impossible to review.
1506
+ *
1507
+ * ## Default is stdout, so nothing is ever silently swallowed
1508
+ *
1509
+ * With no sink attached this IS \`console\`. A receiver started without the
1510
+ * dashboard, a script that imports the server, a crash before the UI is up:
1511
+ * all of them print. The dashboard attaches a sink and detaches on exit.
1512
+ */
1513
+
1514
+ export type ReportLevel = 'info' | 'warn' | 'error';
1515
+
1516
+ export interface ReportLine {
1517
+ /** Unix ms, so a sink can render its own timestamp. */
1518
+ at: number;
1519
+ level: ReportLevel;
1520
+ /** The arguments exactly as the call site passed them. */
1521
+ args: unknown[];
1522
+ }
1523
+
1524
+ type Sink = (line: ReportLine) => void;
1525
+
1526
+ /** Write to stdout, matching what this receiver printed before the dashboard existed. */
1527
+ const consoleSink: Sink = ({ level, args }) => {
1528
+ if (level === 'error') console.error(...args);
1529
+ else if (level === 'warn') console.warn(...args);
1530
+ else console.log(...args);
1531
+ };
1532
+
1533
+ let sink: Sink = consoleSink;
1534
+
1535
+ function emit(level: ReportLevel, args: unknown[]): void {
1536
+ // A sink that throws must never take the server down with it. A broken UI is
1537
+ // a broken UI; a dropped dispatch is a customer's address not arriving.
1538
+ try {
1539
+ sink({ at: Date.now(), level, args });
1540
+ } catch {
1541
+ consoleSink({ at: Date.now(), level, args });
1542
+ }
1543
+ }
1544
+
1545
+ export const report = {
1546
+ info: (...args: unknown[]): void => emit('info', args),
1547
+ warn: (...args: unknown[]): void => emit('warn', args),
1548
+ error: (...args: unknown[]): void => emit('error', args),
1549
+
1550
+ /** Send lines somewhere else. Returns a function that restores stdout. */
1551
+ attach(next: Sink): () => void {
1552
+ const previous = sink;
1553
+ sink = next;
1554
+ return () => { sink = previous; };
1555
+ },
1556
+ };
1557
+
1558
+ /** Render one reported line the way the plain console would have. */
1559
+ export function formatLine(line: ReportLine): string {
1560
+ return line.args
1561
+ .map((a) => {
1562
+ if (typeof a === 'string') return a;
1563
+ if (a instanceof Error) return a.message;
1564
+ try { return JSON.stringify(a); } catch { return String(a); }
1565
+ })
1566
+ .join(' ');
1567
+ }
1568
+ `
1569
+ },
1570
+ {
1571
+ name: "src/tui.ts",
1572
+ content: `/**
1573
+ * The receiver's terminal dashboard.
1574
+ *
1575
+ * Watching a webhook receiver used to mean tailing a log and hoping. This shows
1576
+ * the three things an operator actually wants while an integration is live:
1577
+ * whether the server is up, whether the customer file is protected, and what
1578
+ * the last dispatch changed, old address and new, side by side.
1579
+ *
1580
+ * ## It is a VIEW, and holds no logic of its own
1581
+ *
1582
+ * Every line it renders arrives through \`report\` (\`src/report.ts\`), which the
1583
+ * protocol layer writes to. The dashboard never parses a webhook, never touches
1584
+ * the database except to read counts, and never decides anything. Pull it out
1585
+ * and the receiver behaves identically with its narration on stdout, which is
1586
+ * exactly what \`--headless\` does.
1587
+ *
1588
+ * ## blessed owns the screen
1589
+ *
1590
+ * Once this starts, NOTHING may write to stdout: a stray \`console.log\` lands on
1591
+ * top of the drawn screen and corrupts it. That is the whole reason \`report\`
1592
+ * exists. On exit the sink is detached before the screen is destroyed, so a
1593
+ * shutdown message still reaches the terminal.
1594
+ */
1595
+ import blessed from 'blessed';
1596
+ import { HEX, ONE_ROWS, ADDRESS_ROWS, terminalFitsFullMark } from './brand.js';
1597
+ import { formatLine, report, type ReportLine } from './report.js';
1598
+ import { allCustomers, customerCount, storeEncrypted, type StoredCustomer } from './store.js';
1599
+
1600
+ /** blessed takes colours as strings; these mirror the site's palette. */
1601
+ const AMBER = HEX.amber;
1602
+ const CREAM = HEX.cream;
1603
+ const DIM = '#8a7f6a';
1604
+
1605
+ /** Escape blessed's tag syntax so a customer's name can never inject markup. */
1606
+ const esc = (s: unknown): string => String(s ?? '').replace(/[{}]/g, '');
1607
+
1608
+ export interface TuiOptions {
1609
+ partnerName: string;
1610
+ port: number;
1611
+ /** Called when the operator quits, so the caller can close the server. */
1612
+ onQuit: () => void;
1613
+ }
1614
+
1615
+ /** One address as a single line, the way the change panel shows it. */
1616
+ function oneLine(a: Record<string, unknown>): string {
1617
+ const parts = [a.street, a.suburb, a.state, a.postcode].filter(Boolean).map(String);
1618
+ return parts.length > 0 ? parts.join(', ') : '(empty)';
1619
+ }
1620
+
1621
+ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void {
1622
+ const screen = blessed.screen({
1623
+ smartCSR: true,
1624
+ title: \`\${partnerName} \u2014 OneAddress receiver\`,
1625
+ fullUnicode: true,
1626
+ });
1627
+
1628
+ // \u2500\u2500 Masthead \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1629
+ // The full pixel-art wordmark needs 88 columns. Below that it wraps and shows
1630
+ // the logo half-cut, which looks broken rather than looking large, so a
1631
+ // narrow terminal gets the compact lockup instead. Same rule as the wizard.
1632
+ const wide = terminalFitsFullMark(Number(screen.width));
1633
+ const markHeight = wide ? 9 : 3;
1634
+
1635
+ const mark = blessed.box({
1636
+ parent: screen, top: 0, left: 0, width: '100%', height: markHeight,
1637
+ tags: true, padding: { left: 2 },
1638
+ content: wide
1639
+ ? ONE_ROWS.map((row, i) => \`{\${CREAM}-fg}\${row}{/} {\${AMBER}-fg}\${ADDRESS_ROWS[i]}{/}\`).join('\\n')
1640
+ : \`{\${CREAM}-fg}{bold}One{/bold}{/}{\${AMBER}-fg}{bold}Address{/bold}{/}\`,
1641
+ });
1642
+
1643
+ const status = blessed.box({
1644
+ parent: screen, top: markHeight, left: 0, width: '100%', height: 3,
1645
+ tags: true, padding: { left: 2 },
1646
+ border: { type: 'line' } as never,
1647
+ style: { border: { fg: DIM } },
1648
+ });
1649
+
1650
+ // \u2500\u2500 The change panel: what the last dispatch replaced \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1651
+ const changeBox = blessed.box({
1652
+ parent: screen, top: markHeight + 3, left: 0, width: '50%', bottom: 3,
1653
+ label: ' LAST CHANGE ', tags: true, padding: { left: 1, right: 1 },
1654
+ border: { type: 'line' } as never,
1655
+ style: { border: { fg: AMBER }, label: { fg: AMBER } },
1656
+ });
1657
+
1658
+ const logBox = blessed.log({
1659
+ parent: screen, top: markHeight + 3, left: '50%', width: '50%', bottom: 3,
1660
+ label: ' ACTIVITY ', tags: true, scrollable: true, alwaysScroll: true,
1661
+ padding: { left: 1, right: 1 },
1662
+ border: { type: 'line' } as never,
1663
+ style: { border: { fg: DIM }, label: { fg: DIM } },
1664
+ });
1665
+
1666
+ const footer = blessed.box({
1667
+ parent: screen, bottom: 0, left: 0, width: '100%', height: 3,
1668
+ tags: true, padding: { left: 2 },
1669
+ border: { type: 'line' } as never,
1670
+ style: { border: { fg: DIM } },
1671
+ });
1672
+
1673
+ // \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1674
+ let received = 0;
1675
+ let applied = 0;
1676
+ let failed = 0;
1677
+ let lastChange: { customer: StoredCustomer; previous: Record<string, unknown> } | null = null;
1678
+ let pendingPrevious: Record<string, unknown> = {};
1679
+
1680
+ // The server hands over the address a dispatch REPLACED. Kept as state here
1681
+ // rather than pushed through \`report\`, because the previous address is data
1682
+ // rather than narration and must never end up in a log line.
1683
+ setPrevious = (prev) => { pendingPrevious = prev; };
1684
+
1685
+ function renderStatus(): void {
1686
+ // Both states are shown, and the unprotected one is the loud colour. A
1687
+ // security property mentioned only when it holds is one nobody notices the
1688
+ // absence of.
1689
+ const vault = storeEncrypted
1690
+ ? \`{green-fg}{bold}ENCRYPTED{/}\`
1691
+ : \`{red-fg}{bold}UNENCRYPTED{/}\`;
1692
+ status.setContent(
1693
+ \`{\${AMBER}-fg}{bold}\${esc(partnerName)}{/} \` +
1694
+ \`{\${DIM}-fg}listening{/} {\${CREAM}-fg}:\${port}/webhook{/} \` +
1695
+ \`{\${DIM}-fg}customer file{/} \${vault} \` +
1696
+ \`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${customerCount().toLocaleString()}{/}\`,
1697
+ );
1698
+ }
1699
+
1700
+ function renderChange(): void {
1701
+ if (!lastChange) {
1702
+ changeBox.setContent(
1703
+ \`\\n {\${DIM}-fg}Waiting for a dispatch.{/}\\n\\n\` +
1704
+ \` {\${DIM}-fg}When one arrives, the address it replaced{/}\\n\` +
1705
+ \` {\${DIM}-fg}and the address that replaced it appear here.{/}\`,
1706
+ );
1707
+ return;
1708
+ }
1709
+ const { customer, previous } = lastChange;
1710
+ let now: Record<string, unknown> = {};
1711
+ try { now = JSON.parse(customer.address) as Record<string, unknown>; } catch { /* keep empty */ }
1712
+ changeBox.setContent(
1713
+ \`\\n {bold}\${esc(customer.name)}{/bold}\\n\` +
1714
+ \` {\${DIM}-fg}account{/} {\${AMBER}-fg}\${esc(customer.account_number)}{/}\\n\\n\` +
1715
+ \` {red-fg}was{/} \${esc(oneLine(previous))}\\n\\n\` +
1716
+ \` {green-fg}now{/} {\${CREAM}-fg}\${esc(oneLine(now))}{/}\\n\`,
1717
+ );
1718
+ }
1719
+
1720
+ function renderFooter(): void {
1721
+ footer.setContent(
1722
+ \`{\${DIM}-fg}received{/} {bold}\${received}{/bold} \` +
1723
+ \`{green-fg}applied{/} {bold}\${applied}{/bold} \` +
1724
+ \`{red-fg}failed{/} {bold}\${failed}{/bold}\` +
1725
+ \`{|}{\${DIM}-fg}[q] quit{/} \`,
1726
+ );
1727
+ }
1728
+
1729
+ function redraw(): void {
1730
+ renderStatus();
1731
+ renderChange();
1732
+ renderFooter();
1733
+ screen.render();
1734
+ }
1735
+
1736
+ // \u2500\u2500 The feed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1737
+ // Counters are derived from the reported lines rather than from a second
1738
+ // channel the server would have to remember to update. One source, so the
1739
+ // footer cannot disagree with the log beside it.
1740
+ const detach = report.attach((line: ReportLine) => {
1741
+ const text = formatLine(line);
1742
+ const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
1743
+ const time = new Date(line.at).toTimeString().slice(0, 8);
1744
+
1745
+ if (/address\\.updated for /.test(text)) received++;
1746
+ if (/REFUSED|decryption failed|Decryption failed/.test(text)) failed++;
1747
+ if (/\\[store\\] saved address for /.test(text)) {
1748
+ applied++;
1749
+ // The store logs the account key and never the address, so the panel is
1750
+ // refreshed from the DATABASE rather than parsed out of the log line.
1751
+ const acct = /saved address for (\\S+)/.exec(text)?.[1];
1752
+ const customer = allCustomers().find((c) => c.account_number === acct);
1753
+ if (customer) lastChange = { customer, previous: pendingPrevious };
1754
+ }
1755
+
1756
+ logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
1757
+ redraw();
1758
+ });
1759
+
1760
+ screen.key(['q', 'C-c'], () => {
1761
+ detach();
1762
+ screen.destroy();
1763
+ onQuit();
1764
+ process.exit(0);
1765
+ });
1766
+
1767
+ redraw();
1768
+ }
1769
+
1770
+ /**
1771
+ * Handed the address a dispatch replaced, so the dashboard can show both halves.
1772
+ *
1773
+ * A no-op until the dashboard starts, which is what lets the protocol layer
1774
+ * call it unconditionally without knowing whether a UI exists. That is the same
1775
+ * reason \`report\` has a default sink: \`--headless\` must not need a second code
1776
+ * path through the handler.
1777
+ */
1778
+ let setPrevious: (prev: Record<string, unknown>) => void = () => {};
1779
+
1780
+ export function notePreviousAddress(prev: Record<string, unknown>): void {
1781
+ setPrevious(prev);
1782
+ }
1783
+ `
1784
+ },
1785
+ {
1786
+ name: "src/index.ts",
1787
+ content: `#!/usr/bin/env node
1788
+ /**
1789
+ * Your OneAddress receiver.
1790
+ *
1791
+ * npm start the terminal dashboard
1792
+ * npm start -- --headless log to stdout, for a service unit
1793
+ *
1794
+ * ## Why this file exists rather than starting the server directly
1795
+ *
1796
+ * Two things have to happen in a strict order, and both are easy to get wrong:
1797
+ *
1798
+ * 1. The at-rest passphrase must be known BEFORE anything opens the database.
1799
+ * \`src/db.ts\` derives its keys when it is first imported, so this prompts
1800
+ * and sets the environment variable, then imports the rest DYNAMICALLY.
1801
+ * A plain top-level import would open the database before the prompt ran.
1802
+ *
1803
+ * 2. The dashboard must own the terminal BEFORE the server narrates anything,
1804
+ * or the first log line lands on top of the drawn screen.
1805
+ *
1806
+ * \`--headless\` skips both: no prompt (a service has nobody to ask), no screen.
1807
+ */
1808
+ import { printBanner } from './brand.js';
1809
+ import { WrongPassphraseError } from './vault.js';
1810
+
1811
+ const headless =
1812
+ process.argv.includes('--headless') ||
1813
+ process.env.ONEADDRESS_HEADLESS === '1';
1814
+
1815
+ /**
1816
+ * Where the at-rest passphrase comes from.
1817
+ *
1818
+ * \`ONEADDRESS_DB_PASSPHRASE\` first, so a service unit or a repeat run never
1819
+ * stops to ask. Otherwise prompt, but ONLY when a human is actually there: a
1820
+ * headless deployment or a piped stdin has nobody to answer, and blocking on a
1821
+ * prompt nobody can see is the worst of the three outcomes.
1822
+ *
1823
+ * An empty answer is legitimate, not a failure. It runs the database in the
1824
+ * clear, which is what this receiver did before at-rest encryption existed, and
1825
+ * the dashboard shows that state in red rather than letting it pass unnoticed.
1826
+ */
1827
+ async function resolvePassphrase(): Promise<string | null> {
1828
+ const fromEnv = process.env.ONEADDRESS_DB_PASSPHRASE;
1829
+ if (fromEnv && fromEnv.trim()) return fromEnv;
1830
+ if (headless || !process.stdin.isTTY) return null;
1831
+
1832
+ const { createInterface } = await import('node:readline/promises');
1833
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1834
+ try {
1835
+ process.stdout.write(' Your customer records can be encrypted at rest.\\n');
1836
+ process.stdout.write(' This passphrase is YOURS. It is not the OneAddress private key,\\n');
1837
+ process.stdout.write(' and OneAddress never sees it and cannot recover it.\\n');
1838
+ process.stdout.write(' Leave blank to store records unencrypted.\\n\\n');
1839
+ const answer = (await rl.question(' Passphrase: ')).trim();
1840
+ return answer || null;
1841
+ } finally {
1842
+ rl.close();
1843
+ }
1844
+ }
1845
+
1846
+ async function main(): Promise<void> {
1847
+ printBanner(headless ? 'Partner receiver \u2014 headless' : 'Partner receiver');
1848
+
1849
+ const passphrase = await resolvePassphrase();
1850
+ if (passphrase) process.env.ONEADDRESS_DB_PASSPHRASE = passphrase;
1851
+
1852
+ // Dynamic, and this is the whole point of the file: importing the server
1853
+ // pulls in the store, which pulls in the database, which derives its keys on
1854
+ // import. The passphrase has to be set before that chain starts.
1855
+ let config: { partnerName: string; port: number };
1856
+ try {
1857
+ const server = await import('./server.js');
1858
+ config = { partnerName: server.PARTNER_NAME, port: server.PORT };
1859
+ } catch (err) {
1860
+ if (err instanceof WrongPassphraseError) {
1861
+ // Named for what it is. Without this the first symptom is a decryption
1862
+ // error on a live dispatch, which reads as a corrupt database and sends
1863
+ // people to delete a file that is perfectly intact.
1864
+ console.error('\\n That passphrase does not open this database.');
1865
+ console.error(' Try again, or delete data.db to start fresh.\\n');
1866
+ process.exit(1);
1867
+ }
1868
+ throw err;
1869
+ }
1870
+
1871
+ if (headless) return; // The server is listening and reporting to stdout.
1872
+
1873
+ const { startDashboard } = await import('./tui.js');
1874
+ startDashboard({
1875
+ partnerName: config.partnerName,
1876
+ port: config.port,
1877
+ onQuit: () => { /* the process exits; the OS closes the socket */ },
1878
+ });
1879
+ }
1880
+
1881
+ main().catch((err) => {
1882
+ console.error('[startup]', err instanceof Error ? err.message : err);
1883
+ process.exit(1);
1884
+ });
1070
1885
  `
1071
1886
  },
1072
1887
  {
@@ -1190,7 +2005,7 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
1190
2005
  */
1191
2006
  import { readFileSync } from 'node:fs';
1192
2007
  import { join } from 'node:path';
1193
- import db from './db.js';
2008
+ import db, { accountKey, dec, enc, encrypted, once } from './db.js';
1194
2009
 
1195
2010
  export type Address = Record<string, unknown>;
1196
2011
 
@@ -1200,7 +2015,14 @@ export type Address = Record<string, unknown>;
1200
2015
  // change you apply, so you have an audit trail.
1201
2016
  db.exec(\`
1202
2017
  CREATE TABLE IF NOT EXISTS customers (
1203
- account_number TEXT PRIMARY KEY,
2018
+ -- How a row is FOUND. A blind index of the account number when the database
2019
+ -- is locked, the lower-cased number when it is not. It is the key rather
2020
+ -- than the number itself because AES-GCM uses a fresh IV per write, so two
2021
+ -- encryptions of one account number differ and a primary key over the
2022
+ -- ciphertext would enforce nothing while looking like it did.
2023
+ account_key TEXT PRIMARY KEY,
2024
+ -- What is DISPLAYED. Ciphertext when locked.
2025
+ account_number TEXT NOT NULL,
1204
2026
  name TEXT NOT NULL,
1205
2027
  address TEXT NOT NULL DEFAULT '{}',
1206
2028
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -1208,10 +2030,14 @@ db.exec(\`
1208
2030
 
1209
2031
  CREATE TABLE IF NOT EXISTS address_history (
1210
2032
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1211
- account_number TEXT NOT NULL,
2033
+ account_key TEXT NOT NULL,
2034
+ -- Both sides of the change, so you can show what an address REPLACED
2035
+ -- rather than only what it became.
2036
+ prev_address TEXT NOT NULL DEFAULT '{}',
1212
2037
  address TEXT NOT NULL,
1213
2038
  recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
1214
2039
  );
2040
+ CREATE INDEX IF NOT EXISTS idx_history_account ON address_history(account_key, id DESC);
1215
2041
  \`);
1216
2042
 
1217
2043
  // Self-migrate. An older data.db may already hold a \`customers\` table WITHOUT the
@@ -1228,6 +2054,36 @@ function ensureColumn(table: string, column: string, definition: string): void {
1228
2054
  }
1229
2055
  ensureColumn('customers', 'address', "TEXT NOT NULL DEFAULT '{}'");
1230
2056
  ensureColumn('customers', 'updated_at', 'TEXT'); // nullable on migrate; set on write
2057
+ ensureColumn('customers', 'account_key', 'TEXT');
2058
+ ensureColumn('address_history', 'account_key', 'TEXT');
2059
+ ensureColumn('address_history', 'prev_address', "TEXT NOT NULL DEFAULT '{}'");
2060
+
2061
+ // A database written before at-rest encryption existed holds plaintext rows and
2062
+ // no account_key. Backfill the key and encrypt in place.
2063
+ //
2064
+ // Safe to run on every start: \`once\` encrypts only what is not already
2065
+ // encrypted, and \`accountKey\` is derived from the DECRYPTED number, so a second
2066
+ // pass produces the same key rather than hashing a ciphertext and orphaning the
2067
+ // row. A row orphaned that way still reads fine in a listing and is invisible
2068
+ // to every dispatch, which is the worst kind of broken.
2069
+ {
2070
+ const rows = db.prepare('SELECT rowid AS rid, account_number, name, address FROM customers').all() as Array<
2071
+ { rid: number; account_number: string; name: string; address: string }
2072
+ >;
2073
+ const relabel = db.prepare(
2074
+ 'UPDATE customers SET account_key = ?, account_number = ?, name = ?, address = ? WHERE rowid = ?',
2075
+ );
2076
+ for (const r of rows) {
2077
+ const plainAccount = dec('account_number', r.account_number) ?? r.account_number;
2078
+ relabel.run(
2079
+ accountKey(plainAccount),
2080
+ once('account_number', r.account_number),
2081
+ once('name', r.name),
2082
+ once('address', r.address),
2083
+ r.rid,
2084
+ );
2085
+ }
2086
+ }
1231
2087
 
1232
2088
  /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1233
2089
  * YOUR CUSTOMER ROSTER \u2014 data, not code (customers.json)
@@ -1286,11 +2142,17 @@ function loadRoster(): RosterEntry[] {
1286
2142
  const ROSTER = loadRoster();
1287
2143
  {
1288
2144
  const upsert = db.prepare(\`
1289
- INSERT INTO customers (account_number, name, address) VALUES ($account_number, $name, $address)
1290
- ON CONFLICT(account_number) DO UPDATE SET name = excluded.name
2145
+ INSERT INTO customers (account_key, account_number, name, address)
2146
+ VALUES ($account_key, $account_number, $name, $address)
2147
+ ON CONFLICT(account_key) DO UPDATE SET name = excluded.name
1291
2148
  \`);
1292
2149
  for (const c of ROSTER) {
1293
- upsert.run({ account_number: c.account_number, name: c.name, address: JSON.stringify(c.address) });
2150
+ upsert.run({
2151
+ account_key: accountKey(c.account_number),
2152
+ account_number: enc('account_number', c.account_number),
2153
+ name: enc('name', c.name),
2154
+ address: enc('address', JSON.stringify(c.address)),
2155
+ });
1294
2156
  }
1295
2157
  console.log(\`[store] roster ready (\${ROSTER.length} customers)\`);
1296
2158
  }
@@ -1327,18 +2189,57 @@ function canonicalAddress(a: Address): string {
1327
2189
  function findCustomer(accountNumber: string | undefined, name: string): { account_number: string; name: string; address: string } | undefined {
1328
2190
  const acct = (accountNumber ?? '').trim();
1329
2191
  if (acct) {
1330
- const byAcct = db.prepare('SELECT account_number, name, address FROM customers WHERE account_number = ?').get(acct);
2192
+ const byAcct = decodeRow(
2193
+ db.prepare('SELECT account_number, name, address FROM customers WHERE account_key = ?').get(accountKey(acct)),
2194
+ );
1331
2195
  if (byAcct) return byAcct as { account_number: string; name: string; address: string };
1332
2196
  }
1333
2197
  const n = name.trim().toLowerCase();
1334
2198
  if (n) {
1335
- const byName = db.prepare('SELECT account_number, name, address FROM customers WHERE LOWER(name) = ?').get(n);
2199
+ // Scanned and decrypted rather than matched in SQL: LOWER(name) cannot see
2200
+ // inside a ciphertext, so an equality here would match nothing and quietly
2201
+ // return "no such customer" forever. A roster is small; decrypting it is
2202
+ // microseconds.
2203
+ const byName = allCustomers().find((c) => c.name.trim().toLowerCase() === n);
1336
2204
  if (byName) return byName as { account_number: string; name: string; address: string };
1337
2205
  }
1338
2206
  return undefined;
1339
2207
  }
1340
2208
 
1341
2209
  // \u2500\u2500 account.verify: pre-payment account check \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2210
+ /** One stored row, with the personal columns decrypted. */
2211
+ export interface StoredCustomer {
2212
+ account_number: string;
2213
+ name: string;
2214
+ address: string;
2215
+ }
2216
+
2217
+ /** Decrypt a row read straight out of SQLite. */
2218
+ function decodeRow(raw: unknown): StoredCustomer | undefined {
2219
+ if (!raw) return undefined;
2220
+ const r = raw as StoredCustomer;
2221
+ return {
2222
+ account_number: dec('account_number', r.account_number)!,
2223
+ name: dec('name', r.name)!,
2224
+ address: dec('address', r.address)!,
2225
+ };
2226
+ }
2227
+
2228
+ /** Every customer, decrypted. Used where SQL cannot see inside the ciphertext. */
2229
+ export function allCustomers(): StoredCustomer[] {
2230
+ return (db.prepare('SELECT account_number, name, address FROM customers').all() as unknown[])
2231
+ .map((r) => decodeRow(r)!)
2232
+ .filter(Boolean);
2233
+ }
2234
+
2235
+ /** How many customers are on file. */
2236
+ export function customerCount(): number {
2237
+ return (db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }).n;
2238
+ }
2239
+
2240
+ /** Is the file on disk protected? Surfaced in the dashboard, in both states. */
2241
+ export const storeEncrypted = encrypted;
2242
+
1342
2243
  export type AccountVerdict = 'match' | 'no_match' | 'no_account';
1343
2244
 
1344
2245
  /**
@@ -1352,7 +2253,10 @@ export type AccountVerdict = 'match' | 'no_match' | 'no_account';
1352
2253
  export function verifyAccount(accountNumber: string | null, name: string, knownNames: string[] = []): AccountVerdict {
1353
2254
  const acct = (accountNumber ?? '').trim();
1354
2255
  if (!acct) return 'no_account';
1355
- const row = db.prepare('SELECT name FROM customers WHERE account_number = ?').get(acct) as { name: string } | undefined;
2256
+ const onFile = db.prepare('SELECT name FROM customers WHERE account_key = ?').get(accountKey(acct)) as
2257
+ | { name: string }
2258
+ | undefined;
2259
+ const row = onFile ? { name: dec('name', onFile.name)! } : undefined;
1356
2260
  if (!row) return 'no_account';
1357
2261
  const stored = row.name.trim().toLowerCase();
1358
2262
  const candidates = [name, ...knownNames].map(v => (v ?? '').trim().toLowerCase()).filter(Boolean);
@@ -1385,31 +2289,60 @@ export async function verifyAddress(customer: Customer, incoming: Address): Prom
1385
2289
 
1386
2290
  // \u2500\u2500 address.updated: apply the new address \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1387
2291
  /**
1388
- * Applies a new address to the customer's record and logs the change. Keyed on
1389
- * the account number (falling back to name); this is called only after the
1390
- * consumer paid and IDV passed, and only for an account that matched.
2292
+ * Applies a new address to the customer's record and logs the change.
2293
+ *
2294
+ * Keyed on the account number, falling back to name. The caller enforces the
2295
+ * part that makes that safe: when you verify account references, server.ts
2296
+ * refuses anything that is not a \`match\` before reaching here, so the fallback
2297
+ * is unreachable in that mode and the key is always a real account of yours.
2298
+ *
2299
+ * IF YOU DO NOT VERIFY ACCOUNT REFERENCES the fallback is live and the row is
2300
+ * keyed on the customer's name, which is your identification scheme rather than
2301
+ * ours \u2014 but be aware two customers who share a name share a row. If that is
2302
+ * possible in your data, give this a key of your own instead.
1391
2303
  */
1392
- export async function saveAddress(customer: Customer, incoming: Address): Promise<void> {
2304
+ export async function saveAddress(customer: Customer, incoming: Address): Promise<Address> {
1393
2305
  const acct = (customer.accountNumber ?? '').trim() || customer.name.trim();
1394
2306
  const addressJson = JSON.stringify(incoming);
2307
+ const key = accountKey(acct);
2308
+
2309
+ // Read what we are about to replace. After the UPDATE nothing can reconstruct
2310
+ // it, and "what did this address replace" is the question an operator asks
2311
+ // first when a change looks wrong.
2312
+ const existing = decodeRow(
2313
+ db.prepare('SELECT account_number, name, address FROM customers WHERE account_key = ?').get(key),
2314
+ );
2315
+ const previous: Address = existing ? (JSON.parse(existing.address) as Address) : {};
1395
2316
 
1396
2317
  db.prepare(\`
1397
- INSERT INTO customers (account_number, name, address, updated_at)
1398
- VALUES ($account_number, $name, $address, datetime('now'))
1399
- ON CONFLICT(account_number) DO UPDATE SET
2318
+ INSERT INTO customers (account_key, account_number, name, address, updated_at)
2319
+ VALUES ($account_key, $account_number, $name, $address, datetime('now'))
2320
+ ON CONFLICT(account_key) DO UPDATE SET
1400
2321
  name = excluded.name,
1401
2322
  address = excluded.address,
1402
2323
  updated_at = excluded.updated_at
1403
- \`).run({ account_number: acct, name: customer.name, address: addressJson });
2324
+ \`).run({
2325
+ account_key: key,
2326
+ account_number: enc('account_number', acct),
2327
+ name: enc('name', customer.name),
2328
+ address: enc('address', addressJson),
2329
+ });
1404
2330
 
1405
2331
  db.prepare(\`
1406
- INSERT INTO address_history (account_number, address) VALUES ($account_number, $address)
1407
- \`).run({ account_number: acct, address: addressJson });
2332
+ INSERT INTO address_history (account_key, prev_address, address)
2333
+ VALUES ($account_key, $prev_address, $address)
2334
+ \`).run({
2335
+ account_key: key,
2336
+ prev_address: enc('address', JSON.stringify(previous)),
2337
+ address: enc('address', addressJson),
2338
+ });
1408
2339
 
1409
- // Metadata only \u2014 the address itself is PII, so we log the key, never the
1410
- // address. Centralised log aggregation turns every log line into a
1411
- // data-exposure surface for customer addresses.
2340
+ // Metadata only \u2014 the address itself is personal information, so the key is
2341
+ // logged and the address never is. Centralised log aggregation turns every
2342
+ // log line into a place customer addresses can be read.
1412
2343
  console.log(\`[store] saved address for \${acct}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
2344
+
2345
+ return previous;
1413
2346
  }
1414
2347
  `
1415
2348
  },
@@ -1430,12 +2363,13 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
1430
2363
  // Fail fast with a clear message rather than a cryptic ERR_UNSUPPORTED_FEATURE later.
1431
2364
  const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number);
1432
2365
  if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 5)) {
1433
- console.error(\`[startup] Node.js 22.5+ is required (you are running \${process.version}).\`);
1434
- console.error('[startup] Upgrade Node.js: https://nodejs.org/en/download');
2366
+ report.error(\`[startup] Node.js 22.5+ is required (you are running \${process.version}).\`);
2367
+ report.error('[startup] Upgrade Node.js: https://nodejs.org/en/download');
1435
2368
  process.exit(1);
1436
2369
  }
1437
2370
 
1438
2371
  import 'dotenv/config';
2372
+ import { report } from './report.js';
1439
2373
  import express, { Request, Response } from 'express';
1440
2374
  import rateLimit from 'express-rate-limit';
1441
2375
  import { createPrivateKey, createHmac } from 'node:crypto';
@@ -1450,6 +2384,7 @@ import {
1450
2384
  type OneAddressD5LOA,
1451
2385
  } from '@oneaddress/partner-sdk';
1452
2386
  import { saveAddress, verifyAddress, verifyAccount } from './store.js';
2387
+ import { notePreviousAddress } from './tui.js';
1453
2388
  import { config } from './config.js';
1454
2389
  import { safeOneAddressCallbackUrl } from './callback-url.js';
1455
2390
 
@@ -1472,7 +2407,7 @@ const ONEADDRESS_API = config.oneAddressApi;
1472
2407
  const CONFIRM_SECRET = process.env.CONFIRM_SECRET || WEBHOOK_SECRET;
1473
2408
 
1474
2409
  if (!WEBHOOK_SECRET || !PARTNER_PRIVATE_KEY || !PARTNER_ID) {
1475
- console.error('[startup] Missing required env vars. Check your .env file.');
2410
+ report.error('[startup] Missing required env vars. Check your .env file.');
1476
2411
  process.exit(1);
1477
2412
  }
1478
2413
 
@@ -1485,8 +2420,8 @@ if (!WEBHOOK_SECRET || !PARTNER_PRIVATE_KEY || !PARTNER_ID) {
1485
2420
  // fails only when the first payload arrives.
1486
2421
  // 2. A private key that does not parse (empty, truncated, or header stripped).
1487
2422
  if (typeof decryptSession !== 'function') {
1488
- console.error('[startup] @oneaddress/partner-sdk does not export decryptSession. Your installed SDK is too old for D5 dispatches.');
1489
- console.error('[startup] Fix: npm install "@oneaddress/partner-sdk@^1.8.0", then restart.');
2423
+ report.error('[startup] @oneaddress/partner-sdk does not export decryptSession. Your installed SDK is too old for D5 dispatches.');
2424
+ report.error('[startup] Fix: npm install "@oneaddress/partner-sdk@^1.8.0", then restart.');
1490
2425
  process.exit(1);
1491
2426
  }
1492
2427
  // Only PEM keys are parseable this way; a post-quantum key is a base64 secret,
@@ -1495,8 +2430,8 @@ if (PARTNER_PRIVATE_KEY.includes('BEGIN')) {
1495
2430
  try {
1496
2431
  createPrivateKey(PARTNER_PRIVATE_KEY);
1497
2432
  } catch {
1498
- console.error('[startup] PARTNER_PRIVATE_KEY_PEM does not parse as a private key.');
1499
- console.error('[startup] Paste the FULL PEM, including the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines.');
2433
+ report.error('[startup] PARTNER_PRIVATE_KEY_PEM does not parse as a private key.');
2434
+ report.error('[startup] Paste the FULL PEM, including the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines.');
1500
2435
  process.exit(1);
1501
2436
  }
1502
2437
  }
@@ -1560,16 +2495,16 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
1560
2495
  body: bodyStr,
1561
2496
  });
1562
2497
  if (confirmRes.ok) {
1563
- console.log(\`[confirm] dispatch \${dispatchId} \u2192 \${status}: acknowledged by OneAddress\`);
2498
+ report.info(\`[confirm] dispatch \${dispatchId} \u2192 \${status}: acknowledged by OneAddress\`);
1564
2499
  } else {
1565
2500
  const detail = await confirmRes.text().catch(() => '');
1566
- console.error(\`[confirm] dispatch \${dispatchId} confirm FAILED \u2014 HTTP \${confirmRes.status} \${detail}\`);
2501
+ report.error(\`[confirm] dispatch \${dispatchId} confirm FAILED \u2014 HTTP \${confirmRes.status} \${detail}\`);
1567
2502
  if (confirmRes.status === 401) {
1568
- console.error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET in .env to it (from the portal Webhook screen); otherwise your webhook signing secret should work.');
2503
+ report.error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET in .env to it (from the portal Webhook screen); otherwise your webhook signing secret should work.');
1569
2504
  }
1570
2505
  }
1571
2506
  } catch (err) {
1572
- console.error('[confirm] confirm request error:', err);
2507
+ report.error('[confirm] confirm request error:', err);
1573
2508
  }
1574
2509
  }
1575
2510
 
@@ -1653,7 +2588,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
1653
2588
  // checked" rather than a match/no_match you don't actually compute \u2014 this is
1654
2589
  // the setup declaration reaching the running handler.
1655
2590
  if (!config.verifiesAccountReference) {
1656
- console.log('[webhook] account.verify \u2192 skipped (verifiesAccountReference is false in oneaddress.config.json)');
2591
+ report.info('[webhook] account.verify \u2192 skipped (verifiesAccountReference is false in oneaddress.config.json)');
1657
2592
  return res.status(200).json({ ok: true, skipped: true });
1658
2593
  }
1659
2594
 
@@ -1666,7 +2601,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
1666
2601
  try {
1667
2602
  cust = await decryptAddress(enc, PARTNER_PRIVATE_KEY, PARTNER_ID);
1668
2603
  } catch (err) {
1669
- console.error('[webhook] account.verify decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM:', err);
2604
+ report.error('[webhook] account.verify decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM:', err);
1670
2605
  return res.status(422).json({ ok: false, error: 'decryption_failed' });
1671
2606
  }
1672
2607
 
@@ -1675,7 +2610,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
1675
2610
  const knownNames = Array.isArray(cust.known_names) ? cust.known_names.map(String) : [];
1676
2611
 
1677
2612
  const status = verifyAccount(accountNumber, name, knownNames);
1678
- console.log(\`[webhook] account.verify \u2192 \${status} for account \${accountNumber ?? '(none)'} (\${name})\`);
2613
+ report.info(\`[webhook] account.verify \u2192 \${status} for account \${accountNumber ?? '(none)'} (\${name})\`);
1679
2614
  return res.status(200).json({ status });
1680
2615
  }
1681
2616
 
@@ -1687,7 +2622,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
1687
2622
  // the 422 below, because it IS a dispatch event.
1688
2623
  const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
1689
2624
  if (!DISPATCH_EVENTS.includes(event)) {
1690
- console.log(\`[webhook] "\${event}" acknowledged (no address payload to decrypt)\`);
2625
+ report.info(\`[webhook] "\${event}" acknowledged (no address payload to decrypt)\`);
1691
2626
  return res.status(200).json({ ok: true, skipped: true });
1692
2627
  }
1693
2628
 
@@ -1726,7 +2661,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
1726
2661
  decAccount = typeof data.account_number === 'string' ? data.account_number : '';
1727
2662
  decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
1728
2663
  } catch (err) {
1729
- console.error('[webhook] D5 decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM matches key_id', sessionKeyShare.key_id, ':', err);
2664
+ report.error('[webhook] D5 decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM matches key_id', sessionKeyShare.key_id, ':', err);
1730
2665
  return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
1731
2666
  }
1732
2667
  } else if (legacyPayload) {
@@ -1736,7 +2671,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
1736
2671
  decAccount = typeof address.accountReference === 'string' ? address.accountReference : '';
1737
2672
  decKnownNames = Array.isArray(address.knownNames) ? address.knownNames as string[] : [];
1738
2673
  } catch (err) {
1739
- console.error('[webhook] Decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM in .env:', err);
2674
+ report.error('[webhook] Decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM in .env:', err);
1740
2675
  return res.status(422).json({ ok: false, error: 'Decryption failed \u2014 partner key mismatch' });
1741
2676
  }
1742
2677
  } else {
@@ -1758,7 +2693,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
1758
2693
  const loa: OneAddressD5LOA = await decryptLoaEncrypted(loaEncrypted, PARTNER_PRIVATE_KEY, PARTNER_ID);
1759
2694
  loaRef = d5LoaRef(loa);
1760
2695
  } catch (err) {
1761
- console.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
2696
+ report.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
1762
2697
  }
1763
2698
  }
1764
2699
 
@@ -1776,8 +2711,48 @@ app.post('/webhook', async (req: Request, res: Response) => {
1776
2711
 
1777
2712
  // \u2500\u2500 address.updated: consumer changed their address \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1778
2713
  if (event === 'address.updated') {
1779
- console.log(\`[webhook] address.updated for \${ctx.accountNumber || ctx.name}\`);
1780
- await saveAddress(ctx, address);
2714
+ report.info(\`[webhook] address.updated for \${ctx.accountNumber || ctx.name}\`);
2715
+
2716
+ // AN ACCOUNT REFERENCE THAT MATCHES NOTHING MUST BE A HARD NO-MATCH.
2717
+ //
2718
+ // This runs BEFORE saveAddress, and it is the whole reason saveAddress can
2719
+ // be trusted. Until 13 Sep 2026 this handler applied every dispatch it
2720
+ // could decrypt, with no check that the account was one of yours: an
2721
+ // account number matching nobody created a BRAND NEW customer row, and a
2722
+ // dispatch with no account number at all was keyed on the customer's NAME,
2723
+ // so two customers who share a name collapse onto one record and the
2724
+ // second one's address overwrites the first's.
2725
+ //
2726
+ // Do not "simplify" this by falling back to a name match when the account
2727
+ // number misses. A name matching a DIFFERENT customer's record is not
2728
+ // evidence the two are the same person; it is the most likely way to write
2729
+ // one customer's address onto another customer's account.
2730
+ //
2731
+ // Gated on your own portal declaration, exactly like account.verify above:
2732
+ // if you told the portal you do not verify account references, you identify
2733
+ // customers some other way and this check cannot speak for you.
2734
+ //
2735
+ // Refusing is reported two ways, and both matter. The \`ok: false\` tells
2736
+ // OneAddress this delivery failed (a 200 carrying ok:true would be read as
2737
+ // success and the consumer would be told their address had landed); the
2738
+ // \`failed\` confirm callback puts the same answer on the record they see.
2739
+ // OneAddress conformance check "Refuses an account reference that matches
2740
+ // no record" tests exactly this.
2741
+ if (config.verifiesAccountReference) {
2742
+ const verdict = verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
2743
+ if (verdict !== 'match') {
2744
+ report.warn(\`[webhook] address.updated REFUSED (\${verdict}) for account \${ctx.accountNumber ?? '(none)'} \u2014 nothing applied\`);
2745
+ void confirmToOneAddress(dispatch, 'failed');
2746
+ return res.status(200).json({ ok: false, error: 'account_not_matched', verdict });
2747
+ }
2748
+ }
2749
+
2750
+ // \`saveAddress\` returns the address it replaced. Handed to the dashboard so
2751
+ // it can show both halves; a no-op under --headless. Passed directly rather
2752
+ // than reported, because the previous address is a customer's address and
2753
+ // must never reach a log line.
2754
+ const replaced = await saveAddress(ctx, address);
2755
+ notePreviousAddress(replaced);
1781
2756
  if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
1782
2757
  // Close the loop back to OneAddress so the service flips to "Confirmed".
1783
2758
  // Fire-and-forget: it must not delay this 200 (which acks the delivery).
@@ -1799,13 +2774,13 @@ app.post('/webhook', async (req: Request, res: Response) => {
1799
2774
  // network position into an SSRF primitive.
1800
2775
  const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
1801
2776
  if (!safeCallbackUrl) {
1802
- console.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
2777
+ report.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
1803
2778
  return res.status(400).json({ error: 'Invalid callback_url host' });
1804
2779
  }
1805
2780
 
1806
- console.log(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
2781
+ report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
1807
2782
  const result = await verifyAddress(ctx, address);
1808
- console.log(\`[webhook] address.verify \u2192 \${result}\`);
2783
+ report.info(\`[webhook] address.verify \u2192 \${result}\`);
1809
2784
 
1810
2785
  await fetch(safeCallbackUrl, {
1811
2786
  method: 'POST',
@@ -1841,21 +2816,26 @@ app.post('/webhook', async (req: Request, res: Response) => {
1841
2816
  const a = address as { street?: unknown; suburb?: unknown; state?: unknown; postcode?: unknown };
1842
2817
  const oneLine = [a.street, [a.suburb, a.state, a.postcode].filter(Boolean).join(' ')]
1843
2818
  .filter(Boolean).join(', ');
1844
- console.log(\`[webhook] \${event} verification probe decrypted OK\`);
1845
- console.log(\`[verification] \${index ?? '?'} | \${ctx.name} | \${oneLine}\`);
2819
+ report.info(\`[webhook] \${event} verification probe decrypted OK\`);
2820
+ report.info(\`[verification] \${index ?? '?'} | \${ctx.name} | \${oneLine}\`);
1846
2821
  return res.status(200).json({ verification: true, index });
1847
2822
  }
1848
2823
 
1849
2824
  // Unknown event \u2014 acknowledge (forward compatibility)
1850
- console.log(\`[webhook] Unknown event "\${event}" \u2014 acknowledged\`);
2825
+ report.info(\`[webhook] Unknown event "\${event}" \u2014 acknowledged\`);
1851
2826
  return res.status(200).json({ ok: true, skipped: true });
1852
2827
  });
1853
2828
 
1854
2829
  app.get('/health', (_req, res) => res.json({ status: 'ok' }));
1855
2830
 
1856
2831
  app.listen(PORT, () =>
1857
- console.log(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`),
2832
+ report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`),
1858
2833
  );
2834
+
2835
+ // Read by src/index.ts to label the dashboard. Exported rather than re-derived
2836
+ // there, so the port the UI claims is the port the server actually bound.
2837
+ export { PORT };
2838
+ export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
1859
2839
  `
1860
2840
  },
1861
2841
  {
@@ -2259,8 +3239,15 @@ def _verify_account(account_number: str, name: str, known_names: list[str]) -> s
2259
3239
  return "match" if stored in candidates else "no_match"
2260
3240
 
2261
3241
  def _save_address(account_number: str, name: str, address: dict[str, Any], dispatch_id: str) -> None:
2262
- """Persist on address.updated. Upserts the customer's on-file address (keyed
2263
- on the decrypted account number, falling back to name) + appends history."""
3242
+ """Persist on address.updated. Upserts the customer's on-file address + appends
3243
+ history.
3244
+
3245
+ Keyed on the decrypted account number, falling back to name. The caller
3246
+ enforces what makes that safe: when you verify account references, the
3247
+ handler refuses anything that is not a 'match' before reaching here, so the
3248
+ fallback is unreachable in that mode. If you do NOT verify account
3249
+ references the fallback is live and the row is keyed on the customer's name
3250
+ - be aware two customers sharing a name share a row."""
2264
3251
  acct = (account_number or "").strip() or (name or "").strip()
2265
3252
  address_json = json.dumps(address, sort_keys=True)
2266
3253
  _db.execute("""
@@ -2579,6 +3566,39 @@ async def webhook(request: Request) -> Response:
2579
3566
  # production and writing PII there turns every log reader into a
2580
3567
  # data-exposure surface.
2581
3568
  print(f"[webhook] address.updated for {account_number or verified_name or '?'} (dispatch={dispatch})")
3569
+
3570
+ # AN ACCOUNT REFERENCE THAT MATCHES NOTHING MUST BE A HARD NO-MATCH.
3571
+ #
3572
+ # Runs BEFORE _save_address, and it is what makes _save_address safe.
3573
+ # Until 13 Sep 2026 this handler applied every dispatch it could
3574
+ # decrypt with no check that the account was one of yours: an account
3575
+ # number matching nobody created a BRAND NEW customer row, and a
3576
+ # dispatch with no account number was keyed on the customer's NAME, so
3577
+ # two customers sharing a name collapse onto one record.
3578
+ #
3579
+ # Do NOT fall back to a name match when the account number misses. A
3580
+ # name matching a DIFFERENT customer's record is not evidence they are
3581
+ # the same person; it is the likeliest way to write one customer's
3582
+ # address onto another's account.
3583
+ #
3584
+ # Gated on your portal declaration, exactly like account.verify below.
3585
+ # Refusing is reported twice and both matter: ok:false tells OneAddress
3586
+ # the delivery failed (a 200 with ok:true is read as success and the
3587
+ # consumer is told their address landed), and the "failed" confirm puts
3588
+ # the same answer on the record they see.
3589
+ if VERIFIES_ACCOUNT_REFERENCE:
3590
+ verdict = _verify_account(account_number, verified_name, known_names)
3591
+ if verdict != "match":
3592
+ print(f"[webhook] address.updated REFUSED ({verdict}) for account "
3593
+ f"{account_number or '(none)'} - nothing applied")
3594
+ _t = asyncio.create_task(_confirm_to_oneaddress(dispatch, "failed"))
3595
+ _background_tasks.add(_t)
3596
+ _t.add_done_callback(_background_tasks.discard)
3597
+ return Response(
3598
+ content='{"ok":false,"error":"account_not_matched","verdict":"' + verdict + '"}',
3599
+ media_type="application/json",
3600
+ )
3601
+
2582
3602
  _save_address(account_number, verified_name, address, dispatch)
2583
3603
  if dispatch:
2584
3604
  seen_dispatches.add(dispatch) # remember only after it is stored
@@ -3300,8 +4320,15 @@ public class OneAddressWebhookController {
3300
4320
  // Close the loop back to OneAddress so the service flips to
3301
4321
  // "Confirmed". Fire-and-forget (async) so a slow confirm never
3302
4322
  // delays this 200. Only when we actually applied the update.
3303
- if ("applied".equals(outcome)) confirmToOneAddress(dispatchId, "confirmed");
3304
- return ResponseEntity.ok("{\\"ok\\":true,\\"outcome\\":\\"" + outcome + "\\"}");
4323
+ // \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true here (which it
4324
+ // did until 13 Sep 2026) reports a no-match as a successful
4325
+ // delivery: the matching above is correct and refuses to write,
4326
+ // the confirm is correctly withheld, and then the wire says the
4327
+ // opposite. OneAddress reads THIS, so the consumer was told
4328
+ // their address had landed on an account that does not exist.
4329
+ boolean applied = "applied".equals(outcome);
4330
+ confirmToOneAddress(dispatchId, applied ? "confirmed" : "failed");
4331
+ return ResponseEntity.ok("{\\"ok\\":" + applied + ",\\"outcome\\":\\"" + outcome + "\\"}");
3305
4332
  } else if ("address.verify".equals(event)) {
3306
4333
  handleAddressVerify(body, address, accountNumber, verifiedName, knownNames);
3307
4334
  if (dispatchId != null && !dispatchId.isBlank()) store.markProcessed(dispatchId, "verify");
@@ -3644,6 +4671,16 @@ public class OneAddressStore {
3644
4671
  private Long findCustomerId(String accountNumber, String verifiedName, List<String> knownNames) {
3645
4672
  List<String> candidates = allNames(verifiedName, knownNames);
3646
4673
 
4674
+ // AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
4675
+ // function is where someone will try to make that more forgiving. Do not.
4676
+ // When an account number is supplied it decides the answer alone: if no row
4677
+ // carries it, or the row it carries disagrees with the name, this returns
4678
+ // nothing and does NOT fall through to the name lookup below. A name
4679
+ // matching a DIFFERENT customer's record is not evidence the two are the
4680
+ // same person; it is the likeliest way to write one customer's address onto
4681
+ // another customer's account. The name lookup exists only for partners who
4682
+ // do not use account references at all.
4683
+
3647
4684
  if (accountNumber != null && !accountNumber.isBlank()) {
3648
4685
  List<Map<String, Object>> rows = jdbc.queryForList(
3649
4686
  "SELECT id, full_name FROM customers WHERE account_number = ?", accountNumber.trim());
@@ -4112,6 +5149,16 @@ public sealed class OneAddressStore
4112
5149
  if (!string.IsNullOrWhiteSpace(verifiedName)) candidates.Add(verifiedName.Trim());
4113
5150
  candidates.AddRange(knownNames.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n.Trim()));
4114
5151
 
5152
+ // AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
5153
+ // function is where someone will try to make that more forgiving. Do not.
5154
+ // When an account number is supplied it decides the answer alone: if no row
5155
+ // carries it, or the row it carries disagrees with the name, this returns
5156
+ // nothing and does NOT fall through to the name lookup below. A name
5157
+ // matching a DIFFERENT customer's record is not evidence the two are the
5158
+ // same person; it is the likeliest way to write one customer's address onto
5159
+ // another customer's account. The name lookup exists only for partners who
5160
+ // do not use account references at all.
5161
+
4115
5162
  if (!string.IsNullOrWhiteSpace(accountNumber))
4116
5163
  {
4117
5164
  using var byAcct = conn.CreateCommand();
@@ -4385,9 +5432,14 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
4385
5432
  // Close the loop back to OneAddress so the service flips to "Confirmed".
4386
5433
  // Fire-and-forget (discard the Task) so a slow confirm never delays this
4387
5434
  // 200. Only when we actually applied the update.
4388
- if (outcome == "applied")
4389
- _ = ConfirmToOneAddress(oneAddressApi, confirmSecret, partnerId, dispatchId, "confirmed", app.Logger);
4390
- return Results.Json(new { ok = true, outcome });
5435
+ // \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true (which it did until
5436
+ // 13 Sep 2026) reports a no-match as a successful delivery: the match
5437
+ // above correctly refuses to write and correctly withholds the confirm,
5438
+ // then the wire says the opposite. OneAddress reads THIS.
5439
+ var applied = outcome == "applied";
5440
+ _ = ConfirmToOneAddress(oneAddressApi, confirmSecret, partnerId, dispatchId,
5441
+ applied ? "confirmed" : "failed", app.Logger);
5442
+ return Results.Json(new { ok = applied, outcome });
4391
5443
  }
4392
5444
  else // address.verify
4393
5445
  {
@@ -5078,6 +6130,15 @@ func (s *Store) findCustomerID(accountNumber, verifiedName string, knownNames []
5078
6130
  }
5079
6131
  }
5080
6132
 
6133
+ // AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
6134
+ // function is where someone will try to make that more forgiving. Do not.
6135
+ // When an account number is supplied it decides the answer alone: if no row
6136
+ // carries it, or the row it carries disagrees with the name, this returns
6137
+ // nothing and does NOT fall through to the name lookup below. A name
6138
+ // matching a DIFFERENT customer's record is not evidence the two are the
6139
+ // same person; it is the likeliest way to write one customer's address onto
6140
+ // another customer's account. The name lookup exists only for partners who
6141
+ // do not use account references at all.
5081
6142
  if strings.TrimSpace(accountNumber) != "" {
5082
6143
  var id int64
5083
6144
  var fullName string
@@ -5396,10 +6457,17 @@ func main() {
5396
6457
  // Close the loop back to OneAddress so the service flips to "Confirmed".
5397
6458
  // Fire-and-forget in a goroutine: a slow confirm must not delay this 200
5398
6459
  // (which acks the delivery). Only when we actually applied the update.
5399
- if outcome == "applied" {
6460
+ // \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true (which it did until
6461
+ // 13 Sep 2026) reports a no-match as a successful delivery: the match
6462
+ // above correctly refuses to write and correctly withholds the confirm,
6463
+ // then the wire says the opposite. OneAddress reads THIS.
6464
+ applied := outcome == "applied"
6465
+ if applied {
5400
6466
  go confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, "confirmed")
6467
+ } else {
6468
+ go confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, "failed")
5401
6469
  }
5402
- jsonResp(w, 200, map[string]any{"ok": true, "outcome": outcome})
6470
+ jsonResp(w, 200, map[string]any{"ok": applied, "outcome": outcome})
5403
6471
  return
5404
6472
 
5405
6473
  case "address.verify":
@@ -6158,6 +7226,16 @@ class OneAddressStore
6158
7226
  {
6159
7227
  $candidates = array_filter(array_merge([$verifiedName], $knownNames), fn ($n) => is_string($n) && trim($n) !== '');
6160
7228
 
7229
+ // AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
7230
+ // function is where someone will try to make that more forgiving. Do not.
7231
+ // When an account number is supplied it decides the answer alone: if no row
7232
+ // carries it, or the row it carries disagrees with the name, this returns
7233
+ // nothing and does NOT fall through to the name lookup below. A name
7234
+ // matching a DIFFERENT customer's record is not evidence the two are the
7235
+ // same person; it is the likeliest way to write one customer's address onto
7236
+ // another customer's account. The name lookup exists only for partners who
7237
+ // do not use account references at all.
7238
+
6161
7239
  if ($accountNumber !== null && trim($accountNumber) !== '') {
6162
7240
  $row = DB::table('customers')->where('account_number', trim($accountNumber))->first();
6163
7241
  if (!$row) {
@@ -6602,10 +7680,14 @@ Route::post('/webhook', function (Request $request): Response {
6602
7680
  ]);
6603
7681
  // Close the loop back to OneAddress so the service flips to "Confirmed",
6604
7682
  // but only when we actually applied the update. Never affects this 200.
6605
- if ($outcome === 'applied') {
6606
- oaConfirmToOneAddress($oneAddressApi, $confirmSecret, $partnerId, $dispatchId, 'confirmed');
6607
- }
6608
- return response()->json(['ok' => true, 'outcome' => $outcome]);
7683
+ // \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true (which it did until
7684
+ // 13 Sep 2026) reports a no-match as a successful delivery: the match
7685
+ // above correctly refuses to write and correctly withholds the confirm,
7686
+ // then the wire says the opposite. OneAddress reads THIS.
7687
+ $applied = $outcome === 'applied';
7688
+ oaConfirmToOneAddress($oneAddressApi, $confirmSecret, $partnerId, $dispatchId,
7689
+ $applied ? 'confirmed' : 'failed');
7690
+ return response()->json(['ok' => $applied, 'outcome' => $outcome]);
6609
7691
  } elseif ($eventType === 'address.verify') {
6610
7692
  $callbackUrl = $body['callback_url'] ?? '';
6611
7693
  $callbackToken= $body['callback_token'] ?? '';
@@ -6796,7 +7878,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
6796
7878
 
6797
7879
  // src/register.ts
6798
7880
  var import_node_crypto = require("crypto");
6799
- var PKG_VERSION = true ? "1.6.2" : "dev";
7881
+ var PKG_VERSION = true ? "2.0.0" : "dev";
6800
7882
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
6801
7883
  function hmacSha256(secret, message) {
6802
7884
  return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");