@oneaddress/setup 1.6.2 → 2.0.1
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.
- package/dist/index.js +1281 -87
- 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 ? "
|
|
859
|
+
var WIZARD_VERSION = true ? "2.0.1" : "?";
|
|
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":
|
|
967
|
-
"start":
|
|
968
|
-
"
|
|
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":
|
|
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,954 @@ data.db-shm
|
|
|
1041
1044
|
{
|
|
1042
1045
|
name: "src/db.ts",
|
|
1043
1046
|
content: `/**
|
|
1044
|
-
* SQLite database
|
|
1047
|
+
* SQLite database, and the at-rest encryption layer over it.
|
|
1045
1048
|
*
|
|
1046
|
-
* Uses the
|
|
1047
|
-
*
|
|
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
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
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
|
-
*
|
|
1055
|
-
*
|
|
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
|
+
}
|
|
1063
1108
|
|
|
1064
|
-
|
|
1065
|
-
db.
|
|
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;
|
|
1133
|
+
|
|
1134
|
+
const keys = deriveKeys(passphrase, saltForThisDatabase());
|
|
1066
1135
|
|
|
1067
|
-
|
|
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
|
+
}
|
|
1068
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
|
+
}
|
|
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.toLowerCase();
|
|
1602
|
+
const CREAM = HEX.cream.toLowerCase();
|
|
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
|
+
// WITHOUT THIS THE BRAND COMES OUT RED ON WINDOWS. blessed resolves a hex
|
|
1627
|
+
// colour against whatever palette it believes the terminal has, and it
|
|
1628
|
+
// infers that from TERM, which Windows does not set. With no TERM it
|
|
1629
|
+
// assumes a tiny palette and snaps #e0a248 to the nearest thing it thinks
|
|
1630
|
+
// exists, which is red. Windows Terminal and PowerShell both do 256 colours
|
|
1631
|
+
// and more, so naming the capability is simply telling it the truth.
|
|
1632
|
+
// Anything that DOES set TERM keeps its own value.
|
|
1633
|
+
terminal: process.env.TERM || 'xterm-256color',
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
// \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
|
|
1637
|
+
// The full pixel-art wordmark needs 88 columns. Below that it wraps and shows
|
|
1638
|
+
// the logo half-cut, which looks broken rather than looking large, so a
|
|
1639
|
+
// narrow terminal gets the compact lockup instead. Same rule as the wizard.
|
|
1640
|
+
const wide = terminalFitsFullMark(Number(screen.width));
|
|
1641
|
+
const markHeight = wide ? 9 : 3;
|
|
1642
|
+
|
|
1643
|
+
const mark = blessed.box({
|
|
1644
|
+
parent: screen, top: 0, left: 0, width: '100%', height: markHeight,
|
|
1645
|
+
tags: true, padding: { left: 2 },
|
|
1646
|
+
content: wide
|
|
1647
|
+
? ONE_ROWS.map((row, i) => \`{\${CREAM}-fg}\${row}{/} {\${AMBER}-fg}\${ADDRESS_ROWS[i]}{/}\`).join('\\n')
|
|
1648
|
+
: \`{\${CREAM}-fg}{bold}One{/bold}{/}{\${AMBER}-fg}{bold}Address{/bold}{/}\`,
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
const status = blessed.box({
|
|
1652
|
+
parent: screen, top: markHeight, left: 0, width: '100%', height: 3,
|
|
1653
|
+
tags: true, padding: { left: 2 },
|
|
1654
|
+
border: { type: 'line' } as never,
|
|
1655
|
+
style: { border: { fg: DIM } },
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
// \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
|
|
1659
|
+
const changeBox = blessed.box({
|
|
1660
|
+
parent: screen, top: markHeight + 3, left: 0, width: '50%', bottom: 3,
|
|
1661
|
+
label: ' LAST CHANGE ', tags: true, padding: { left: 1, right: 1 },
|
|
1662
|
+
border: { type: 'line' } as never,
|
|
1663
|
+
style: { border: { fg: AMBER }, label: { fg: AMBER } },
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
const logBox = blessed.log({
|
|
1667
|
+
parent: screen, top: markHeight + 3, left: '50%', width: '50%', bottom: 3,
|
|
1668
|
+
label: ' ACTIVITY ', tags: true, scrollable: true, alwaysScroll: true,
|
|
1669
|
+
padding: { left: 1, right: 1 },
|
|
1670
|
+
border: { type: 'line' } as never,
|
|
1671
|
+
style: { border: { fg: DIM }, label: { fg: DIM } },
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
const footer = blessed.box({
|
|
1675
|
+
parent: screen, bottom: 0, left: 0, width: '100%', height: 3,
|
|
1676
|
+
tags: true, padding: { left: 2 },
|
|
1677
|
+
border: { type: 'line' } as never,
|
|
1678
|
+
style: { border: { fg: DIM } },
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
// \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
|
|
1682
|
+
let received = 0;
|
|
1683
|
+
let applied = 0;
|
|
1684
|
+
let failed = 0;
|
|
1685
|
+
let lastChange: { customer: StoredCustomer; previous: Record<string, unknown> } | null = null;
|
|
1686
|
+
let pendingPrevious: Record<string, unknown> = {};
|
|
1687
|
+
|
|
1688
|
+
// The server hands over the address a dispatch REPLACED. Kept as state here
|
|
1689
|
+
// rather than pushed through \`report\`, because the previous address is data
|
|
1690
|
+
// rather than narration and must never end up in a log line.
|
|
1691
|
+
setPrevious = (prev) => { pendingPrevious = prev; };
|
|
1692
|
+
|
|
1693
|
+
function renderStatus(): void {
|
|
1694
|
+
// Both states are shown, and the unprotected one is the loud colour. A
|
|
1695
|
+
// security property mentioned only when it holds is one nobody notices the
|
|
1696
|
+
// absence of.
|
|
1697
|
+
const vault = storeEncrypted
|
|
1698
|
+
? \`{green-fg}{bold}ENCRYPTED{/}\`
|
|
1699
|
+
: \`{red-fg}{bold}UNENCRYPTED{/}\`;
|
|
1700
|
+
status.setContent(
|
|
1701
|
+
\`{\${AMBER}-fg}{bold}\${esc(partnerName)}{/} \` +
|
|
1702
|
+
\`{\${DIM}-fg}listening{/} {\${CREAM}-fg}:\${port}/webhook{/} \` +
|
|
1703
|
+
\`{\${DIM}-fg}customer file{/} \${vault} \` +
|
|
1704
|
+
\`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${customerCount().toLocaleString()}{/}\`,
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function renderChange(): void {
|
|
1709
|
+
if (!lastChange) {
|
|
1710
|
+
changeBox.setContent(
|
|
1711
|
+
\`\\n {\${DIM}-fg}Waiting for a dispatch.{/}\\n\\n\` +
|
|
1712
|
+
\` {\${DIM}-fg}When one arrives, the address it replaced{/}\\n\` +
|
|
1713
|
+
\` {\${DIM}-fg}and the address that replaced it appear here.{/}\`,
|
|
1714
|
+
);
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
const { customer, previous } = lastChange;
|
|
1718
|
+
let now: Record<string, unknown> = {};
|
|
1719
|
+
try { now = JSON.parse(customer.address) as Record<string, unknown>; } catch { /* keep empty */ }
|
|
1720
|
+
changeBox.setContent(
|
|
1721
|
+
\`\\n {bold}\${esc(customer.name)}{/bold}\\n\` +
|
|
1722
|
+
\` {\${DIM}-fg}account{/} {\${AMBER}-fg}\${esc(customer.account_number)}{/}\\n\\n\` +
|
|
1723
|
+
\` {red-fg}was{/} \${esc(oneLine(previous))}\\n\\n\` +
|
|
1724
|
+
\` {green-fg}now{/} {\${CREAM}-fg}\${esc(oneLine(now))}{/}\\n\`,
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function renderFooter(): void {
|
|
1729
|
+
footer.setContent(
|
|
1730
|
+
\`{\${DIM}-fg}received{/} {bold}\${received}{/bold} \` +
|
|
1731
|
+
\`{green-fg}applied{/} {bold}\${applied}{/bold} \` +
|
|
1732
|
+
\`{red-fg}failed{/} {bold}\${failed}{/bold}\` +
|
|
1733
|
+
\`{|}{\${DIM}-fg}[q] quit{/} \`,
|
|
1734
|
+
);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function redraw(): void {
|
|
1738
|
+
renderStatus();
|
|
1739
|
+
renderChange();
|
|
1740
|
+
renderFooter();
|
|
1741
|
+
screen.render();
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// \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
|
|
1745
|
+
// Counters are derived from the reported lines rather than from a second
|
|
1746
|
+
// channel the server would have to remember to update. One source, so the
|
|
1747
|
+
// footer cannot disagree with the log beside it.
|
|
1748
|
+
const detach = report.attach((line: ReportLine) => {
|
|
1749
|
+
const text = formatLine(line);
|
|
1750
|
+
const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
|
|
1751
|
+
const time = new Date(line.at).toTimeString().slice(0, 8);
|
|
1752
|
+
|
|
1753
|
+
if (/address\\.updated for /.test(text)) received++;
|
|
1754
|
+
if (/REFUSED|decryption failed|Decryption failed/.test(text)) failed++;
|
|
1755
|
+
if (/\\[store\\] saved address for /.test(text)) {
|
|
1756
|
+
applied++;
|
|
1757
|
+
// The store logs the account key and never the address, so the panel is
|
|
1758
|
+
// refreshed from the DATABASE rather than parsed out of the log line.
|
|
1759
|
+
const acct = /saved address for (\\S+)/.exec(text)?.[1];
|
|
1760
|
+
const customer = allCustomers().find((c) => c.account_number === acct);
|
|
1761
|
+
if (customer) lastChange = { customer, previous: pendingPrevious };
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
|
|
1765
|
+
redraw();
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
screen.key(['q', 'C-c'], () => {
|
|
1769
|
+
detach();
|
|
1770
|
+
screen.destroy();
|
|
1771
|
+
onQuit();
|
|
1772
|
+
process.exit(0);
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
redraw();
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* Handed the address a dispatch replaced, so the dashboard can show both halves.
|
|
1780
|
+
*
|
|
1781
|
+
* A no-op until the dashboard starts, which is what lets the protocol layer
|
|
1782
|
+
* call it unconditionally without knowing whether a UI exists. That is the same
|
|
1783
|
+
* reason \`report\` has a default sink: \`--headless\` must not need a second code
|
|
1784
|
+
* path through the handler.
|
|
1785
|
+
*/
|
|
1786
|
+
let setPrevious: (prev: Record<string, unknown>) => void = () => {};
|
|
1787
|
+
|
|
1788
|
+
export function notePreviousAddress(prev: Record<string, unknown>): void {
|
|
1789
|
+
setPrevious(prev);
|
|
1790
|
+
}
|
|
1791
|
+
`
|
|
1792
|
+
},
|
|
1793
|
+
{
|
|
1794
|
+
name: "src/index.ts",
|
|
1795
|
+
content: `#!/usr/bin/env node
|
|
1796
|
+
/**
|
|
1797
|
+
* Your OneAddress receiver.
|
|
1798
|
+
*
|
|
1799
|
+
* npm start the terminal dashboard
|
|
1800
|
+
* npm start -- --headless log to stdout, for a service unit
|
|
1801
|
+
*
|
|
1802
|
+
* ## Why this file exists rather than starting the server directly
|
|
1803
|
+
*
|
|
1804
|
+
* Two things have to happen in a strict order, and both are easy to get wrong:
|
|
1805
|
+
*
|
|
1806
|
+
* 1. The at-rest passphrase must be known BEFORE anything opens the database.
|
|
1807
|
+
* \`src/db.ts\` derives its keys when it is first imported, so this prompts
|
|
1808
|
+
* and sets the environment variable, then imports the rest DYNAMICALLY.
|
|
1809
|
+
* A plain top-level import would open the database before the prompt ran.
|
|
1810
|
+
*
|
|
1811
|
+
* 2. The dashboard must own the terminal BEFORE the server narrates anything,
|
|
1812
|
+
* or the first log line lands on top of the drawn screen.
|
|
1813
|
+
*
|
|
1814
|
+
* \`--headless\` skips both: no prompt (a service has nobody to ask), no screen.
|
|
1815
|
+
*/
|
|
1816
|
+
import { printBanner } from './brand.js';
|
|
1817
|
+
import { WrongPassphraseError } from './vault.js';
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* No dashboard without a terminal to draw it in.
|
|
1821
|
+
*
|
|
1822
|
+
* FOUND THE FIRST TIME A PARTNER RAN THIS, 14 Sep 2026. The setup wizard starts
|
|
1823
|
+
* your receiver for you as a CHILD PROCESS with its output piped, so there is no
|
|
1824
|
+
* TTY. The passphrase prompt already handled that correctly by skipping, but the
|
|
1825
|
+
* dashboard went ahead and started anyway, drawing into a pipe: the server ran
|
|
1826
|
+
* fine and conformance passed while the UI rendered to nobody.
|
|
1827
|
+
*
|
|
1828
|
+
* \`process.stdout.isTTY\` is the honest test. It is false under the wizard's
|
|
1829
|
+
* autostart, under a systemd or pm2 unit, in CI, and behind any pipe \u2014 every
|
|
1830
|
+
* case where a full-screen UI is the wrong answer and plain log lines are the
|
|
1831
|
+
* right one. Run \`npm start\` yourself in a real terminal and you get the
|
|
1832
|
+
* dashboard; anything else gets readable output instead of escape codes.
|
|
1833
|
+
*/
|
|
1834
|
+
const headless =
|
|
1835
|
+
process.argv.includes('--headless') ||
|
|
1836
|
+
process.env.ONEADDRESS_HEADLESS === '1' ||
|
|
1837
|
+
!process.stdout.isTTY;
|
|
1838
|
+
|
|
1839
|
+
/**
|
|
1840
|
+
* Has this database already been locked?
|
|
1841
|
+
*
|
|
1842
|
+
* Asked BEFORE the passphrase, and without one, so the prompt can say which of
|
|
1843
|
+
* two completely different things it is doing. \`db_meta.verifier\` is written the
|
|
1844
|
+
* first time a passphrase is set, so its presence is the whole answer.
|
|
1845
|
+
*
|
|
1846
|
+
* Read through its own connection rather than importing \`db.ts\`, which derives
|
|
1847
|
+
* its keys the moment it is imported and would therefore have to run BEFORE we
|
|
1848
|
+
* know what to ask for.
|
|
1849
|
+
*/
|
|
1850
|
+
async function databaseIsLocked(): Promise<boolean> {
|
|
1851
|
+
try {
|
|
1852
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
1853
|
+
const { join } = await import('node:path');
|
|
1854
|
+
const path = process.env.DB_PATH ?? join(process.cwd(), 'data.db');
|
|
1855
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
1856
|
+
try {
|
|
1857
|
+
const row = db
|
|
1858
|
+
.prepare("SELECT value FROM db_meta WHERE key = 'verifier'")
|
|
1859
|
+
.get() as { value?: string } | undefined;
|
|
1860
|
+
return Boolean(row?.value);
|
|
1861
|
+
} finally {
|
|
1862
|
+
db.close();
|
|
1863
|
+
}
|
|
1864
|
+
} catch {
|
|
1865
|
+
// No file yet, or no db_meta table yet. Either way: not locked.
|
|
1866
|
+
return false;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* Ask for a passphrase without echoing it to the screen.
|
|
1872
|
+
*
|
|
1873
|
+
* A passphrase typed in clear on a shared screen, in a screen-share, or into a
|
|
1874
|
+
* terminal that keeps scrollback is not much of a secret. readline echoes by
|
|
1875
|
+
* default, so its output hook is replaced for the duration of the question.
|
|
1876
|
+
*
|
|
1877
|
+
* Degrades to a visible prompt rather than failing: on a terminal where the
|
|
1878
|
+
* hook is not available, being asked in the clear beats not being asked.
|
|
1879
|
+
*/
|
|
1880
|
+
async function ask(prompt: string): Promise<string> {
|
|
1881
|
+
const { createInterface } = await import('node:readline/promises');
|
|
1882
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
1883
|
+
const hooked = rl as unknown as { _writeToOutput?: (s: string) => void };
|
|
1884
|
+
const original = hooked._writeToOutput;
|
|
1885
|
+
try {
|
|
1886
|
+
hooked._writeToOutput = function (chunk: string): void {
|
|
1887
|
+
// Echo the prompt itself, mask everything the user types.
|
|
1888
|
+
if (chunk.includes(prompt)) process.stdout.write(chunk);
|
|
1889
|
+
else process.stdout.write('*');
|
|
1890
|
+
};
|
|
1891
|
+
} catch { /* keep the visible prompt */ }
|
|
1892
|
+
try {
|
|
1893
|
+
const answer = await rl.question(prompt);
|
|
1894
|
+
process.stdout.write('\\n');
|
|
1895
|
+
return answer.trim();
|
|
1896
|
+
} finally {
|
|
1897
|
+
if (original) hooked._writeToOutput = original;
|
|
1898
|
+
rl.close();
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* Where the at-rest passphrase comes from.
|
|
1904
|
+
*
|
|
1905
|
+
* \`ONEADDRESS_DB_PASSPHRASE\` first, so a service unit or a repeat run never
|
|
1906
|
+
* stops to ask. Otherwise prompt, but ONLY when a human is actually there: a
|
|
1907
|
+
* headless deployment or a piped stdin has nobody to answer, and blocking on a
|
|
1908
|
+
* prompt nobody can see is the worst of the three outcomes.
|
|
1909
|
+
*
|
|
1910
|
+
* SETTING AND UNLOCKING ARE DIFFERENT QUESTIONS AND ARE ASKED DIFFERENTLY.
|
|
1911
|
+
* Until 14 Sep 2026 both said the same thing, so the first run gave no way to
|
|
1912
|
+
* tell whether a passphrase was being created or checked, and no confirmation.
|
|
1913
|
+
* Getting a new passphrase wrong twice in agreement is recoverable; getting it
|
|
1914
|
+
* wrong once, silently, means a database nothing can open.
|
|
1915
|
+
*
|
|
1916
|
+
* An empty answer is legitimate, not a failure. It runs the database in the
|
|
1917
|
+
* clear, which is what this receiver did before at-rest encryption existed, and
|
|
1918
|
+
* the dashboard shows that state in red rather than letting it pass unnoticed.
|
|
1919
|
+
*/
|
|
1920
|
+
async function resolvePassphrase(): Promise<string | null> {
|
|
1921
|
+
const fromEnv = process.env.ONEADDRESS_DB_PASSPHRASE;
|
|
1922
|
+
if (fromEnv && fromEnv.trim()) return fromEnv;
|
|
1923
|
+
// Both streams are checked, and they are not the same question: output can be
|
|
1924
|
+
// a pipe while input is still a terminal (\`npm start | tee log\`), and stdin
|
|
1925
|
+
// can be closed while stdout is a terminal. Prompting needs stdin; drawing
|
|
1926
|
+
// needs stdout.
|
|
1927
|
+
if (headless || !process.stdin.isTTY) return null;
|
|
1928
|
+
|
|
1929
|
+
if (await databaseIsLocked()) {
|
|
1930
|
+
process.stdout.write('\\n This customer database is encrypted.\\n\\n');
|
|
1931
|
+
const answer = await ask(' Passphrase to unlock: ');
|
|
1932
|
+
return answer || null;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
process.stdout.write('\\n SET A PASSPHRASE to encrypt your customer records at rest.\\n');
|
|
1936
|
+
process.stdout.write(' It is YOURS. It is not the OneAddress private key, and OneAddress\\n');
|
|
1937
|
+
process.stdout.write(' never sees it and CANNOT RECOVER IT. Lose it and the records in\\n');
|
|
1938
|
+
process.stdout.write(' data.db cannot be read again.\\n');
|
|
1939
|
+
process.stdout.write(' Press Enter to skip and store records unencrypted.\\n\\n');
|
|
1940
|
+
|
|
1941
|
+
for (;;) {
|
|
1942
|
+
const first = await ask(' New passphrase: ');
|
|
1943
|
+
if (!first) {
|
|
1944
|
+
process.stdout.write(' Continuing WITHOUT encryption.\\n\\n');
|
|
1945
|
+
return null;
|
|
1946
|
+
}
|
|
1947
|
+
const again = await ask(' Confirm passphrase: ');
|
|
1948
|
+
if (first === again) {
|
|
1949
|
+
process.stdout.write(' Passphrase set. Keep it somewhere you will still have it.\\n\\n');
|
|
1950
|
+
return first;
|
|
1951
|
+
}
|
|
1952
|
+
process.stdout.write(' Those do not match. Try again.\\n\\n');
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
async function main(): Promise<void> {
|
|
1957
|
+
printBanner(headless ? 'Partner receiver \u2014 headless' : 'Partner receiver');
|
|
1958
|
+
|
|
1959
|
+
const passphrase = await resolvePassphrase();
|
|
1960
|
+
if (passphrase) process.env.ONEADDRESS_DB_PASSPHRASE = passphrase;
|
|
1961
|
+
|
|
1962
|
+
// Dynamic, and this is the whole point of the file: importing the server
|
|
1963
|
+
// pulls in the store, which pulls in the database, which derives its keys on
|
|
1964
|
+
// import. The passphrase has to be set before that chain starts.
|
|
1965
|
+
let config: { partnerName: string; port: number };
|
|
1966
|
+
try {
|
|
1967
|
+
const server = await import('./server.js');
|
|
1968
|
+
config = { partnerName: server.PARTNER_NAME, port: server.PORT };
|
|
1969
|
+
} catch (err) {
|
|
1970
|
+
if (err instanceof WrongPassphraseError) {
|
|
1971
|
+
// Named for what it is. Without this the first symptom is a decryption
|
|
1972
|
+
// error on a live dispatch, which reads as a corrupt database and sends
|
|
1973
|
+
// people to delete a file that is perfectly intact.
|
|
1974
|
+
console.error('\\n That passphrase does not open this database.');
|
|
1975
|
+
console.error(' Try again, or delete data.db to start fresh.\\n');
|
|
1976
|
+
process.exit(1);
|
|
1977
|
+
}
|
|
1978
|
+
throw err;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
if (headless) return; // The server is listening and reporting to stdout.
|
|
1982
|
+
|
|
1983
|
+
const { startDashboard } = await import('./tui.js');
|
|
1984
|
+
startDashboard({
|
|
1985
|
+
partnerName: config.partnerName,
|
|
1986
|
+
port: config.port,
|
|
1987
|
+
onQuit: () => { /* the process exits; the OS closes the socket */ },
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
main().catch((err) => {
|
|
1992
|
+
console.error('[startup]', err instanceof Error ? err.message : err);
|
|
1993
|
+
process.exit(1);
|
|
1994
|
+
});
|
|
1070
1995
|
`
|
|
1071
1996
|
},
|
|
1072
1997
|
{
|
|
@@ -1083,6 +2008,7 @@ export default db;
|
|
|
1083
2008
|
* override), then oneaddress.config.json (what setup wrote), then a built-in
|
|
1084
2009
|
* default. A missing config file is not an error \u2014 the handler still runs.
|
|
1085
2010
|
*/
|
|
2011
|
+
import { report } from './report.js';
|
|
1086
2012
|
import { readFileSync } from 'node:fs';
|
|
1087
2013
|
import { join } from 'node:path';
|
|
1088
2014
|
|
|
@@ -1132,7 +2058,7 @@ export const config: ReceiverConfig = {
|
|
|
1132
2058
|
: (fromFile.verifiesAccountReference ?? DEFAULTS.verifiesAccountReference),
|
|
1133
2059
|
};
|
|
1134
2060
|
|
|
1135
|
-
|
|
2061
|
+
report.info(
|
|
1136
2062
|
'[config] loaded (oneAddressApi=' + config.oneAddressApi +
|
|
1137
2063
|
', verifiesAccountReference=' + config.verifiesAccountReference + ')',
|
|
1138
2064
|
);
|
|
@@ -1190,7 +2116,8 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
|
|
|
1190
2116
|
*/
|
|
1191
2117
|
import { readFileSync } from 'node:fs';
|
|
1192
2118
|
import { join } from 'node:path';
|
|
1193
|
-
import
|
|
2119
|
+
import { report } from './report.js';
|
|
2120
|
+
import db, { accountKey, dec, enc, encrypted, once } from './db.js';
|
|
1194
2121
|
|
|
1195
2122
|
export type Address = Record<string, unknown>;
|
|
1196
2123
|
|
|
@@ -1200,7 +2127,14 @@ export type Address = Record<string, unknown>;
|
|
|
1200
2127
|
// change you apply, so you have an audit trail.
|
|
1201
2128
|
db.exec(\`
|
|
1202
2129
|
CREATE TABLE IF NOT EXISTS customers (
|
|
1203
|
-
|
|
2130
|
+
-- How a row is FOUND. A blind index of the account number when the database
|
|
2131
|
+
-- is locked, the lower-cased number when it is not. It is the key rather
|
|
2132
|
+
-- than the number itself because AES-GCM uses a fresh IV per write, so two
|
|
2133
|
+
-- encryptions of one account number differ and a primary key over the
|
|
2134
|
+
-- ciphertext would enforce nothing while looking like it did.
|
|
2135
|
+
account_key TEXT PRIMARY KEY,
|
|
2136
|
+
-- What is DISPLAYED. Ciphertext when locked.
|
|
2137
|
+
account_number TEXT NOT NULL,
|
|
1204
2138
|
name TEXT NOT NULL,
|
|
1205
2139
|
address TEXT NOT NULL DEFAULT '{}',
|
|
1206
2140
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
@@ -1208,10 +2142,14 @@ db.exec(\`
|
|
|
1208
2142
|
|
|
1209
2143
|
CREATE TABLE IF NOT EXISTS address_history (
|
|
1210
2144
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1211
|
-
|
|
2145
|
+
account_key TEXT NOT NULL,
|
|
2146
|
+
-- Both sides of the change, so you can show what an address REPLACED
|
|
2147
|
+
-- rather than only what it became.
|
|
2148
|
+
prev_address TEXT NOT NULL DEFAULT '{}',
|
|
1212
2149
|
address TEXT NOT NULL,
|
|
1213
2150
|
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1214
2151
|
);
|
|
2152
|
+
CREATE INDEX IF NOT EXISTS idx_history_account ON address_history(account_key, id DESC);
|
|
1215
2153
|
\`);
|
|
1216
2154
|
|
|
1217
2155
|
// Self-migrate. An older data.db may already hold a \`customers\` table WITHOUT the
|
|
@@ -1223,11 +2161,41 @@ function ensureColumn(table: string, column: string, definition: string): void {
|
|
|
1223
2161
|
const cols = db.prepare(\`PRAGMA table_info(\${table})\`).all() as Array<{ name: string }>;
|
|
1224
2162
|
if (!cols.some((c) => c.name === column)) {
|
|
1225
2163
|
db.exec(\`ALTER TABLE \${table} ADD COLUMN \${column} \${definition}\`);
|
|
1226
|
-
|
|
2164
|
+
report.info(\`[store] migrated: added column \${table}.\${column}\`);
|
|
1227
2165
|
}
|
|
1228
2166
|
}
|
|
1229
2167
|
ensureColumn('customers', 'address', "TEXT NOT NULL DEFAULT '{}'");
|
|
1230
2168
|
ensureColumn('customers', 'updated_at', 'TEXT'); // nullable on migrate; set on write
|
|
2169
|
+
ensureColumn('customers', 'account_key', 'TEXT');
|
|
2170
|
+
ensureColumn('address_history', 'account_key', 'TEXT');
|
|
2171
|
+
ensureColumn('address_history', 'prev_address', "TEXT NOT NULL DEFAULT '{}'");
|
|
2172
|
+
|
|
2173
|
+
// A database written before at-rest encryption existed holds plaintext rows and
|
|
2174
|
+
// no account_key. Backfill the key and encrypt in place.
|
|
2175
|
+
//
|
|
2176
|
+
// Safe to run on every start: \`once\` encrypts only what is not already
|
|
2177
|
+
// encrypted, and \`accountKey\` is derived from the DECRYPTED number, so a second
|
|
2178
|
+
// pass produces the same key rather than hashing a ciphertext and orphaning the
|
|
2179
|
+
// row. A row orphaned that way still reads fine in a listing and is invisible
|
|
2180
|
+
// to every dispatch, which is the worst kind of broken.
|
|
2181
|
+
{
|
|
2182
|
+
const rows = db.prepare('SELECT rowid AS rid, account_number, name, address FROM customers').all() as Array<
|
|
2183
|
+
{ rid: number; account_number: string; name: string; address: string }
|
|
2184
|
+
>;
|
|
2185
|
+
const relabel = db.prepare(
|
|
2186
|
+
'UPDATE customers SET account_key = ?, account_number = ?, name = ?, address = ? WHERE rowid = ?',
|
|
2187
|
+
);
|
|
2188
|
+
for (const r of rows) {
|
|
2189
|
+
const plainAccount = dec('account_number', r.account_number) ?? r.account_number;
|
|
2190
|
+
relabel.run(
|
|
2191
|
+
accountKey(plainAccount),
|
|
2192
|
+
once('account_number', r.account_number),
|
|
2193
|
+
once('name', r.name),
|
|
2194
|
+
once('address', r.address),
|
|
2195
|
+
r.rid,
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
1231
2199
|
|
|
1232
2200
|
/* \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
2201
|
* YOUR CUSTOMER ROSTER \u2014 data, not code (customers.json)
|
|
@@ -1278,7 +2246,7 @@ function loadRoster(): RosterEntry[] {
|
|
|
1278
2246
|
return roster;
|
|
1279
2247
|
} catch (err) {
|
|
1280
2248
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1281
|
-
|
|
2249
|
+
report.warn(\`[store] customers.json not loaded (\${msg}) \u2014 using the built-in demo roster. Edit customers.json to set your customers.\`);
|
|
1282
2250
|
return DEFAULT_ROSTER;
|
|
1283
2251
|
}
|
|
1284
2252
|
}
|
|
@@ -1286,13 +2254,19 @@ function loadRoster(): RosterEntry[] {
|
|
|
1286
2254
|
const ROSTER = loadRoster();
|
|
1287
2255
|
{
|
|
1288
2256
|
const upsert = db.prepare(\`
|
|
1289
|
-
INSERT INTO customers (
|
|
1290
|
-
|
|
2257
|
+
INSERT INTO customers (account_key, account_number, name, address)
|
|
2258
|
+
VALUES ($account_key, $account_number, $name, $address)
|
|
2259
|
+
ON CONFLICT(account_key) DO UPDATE SET name = excluded.name
|
|
1291
2260
|
\`);
|
|
1292
2261
|
for (const c of ROSTER) {
|
|
1293
|
-
upsert.run({
|
|
2262
|
+
upsert.run({
|
|
2263
|
+
account_key: accountKey(c.account_number),
|
|
2264
|
+
account_number: enc('account_number', c.account_number),
|
|
2265
|
+
name: enc('name', c.name),
|
|
2266
|
+
address: enc('address', JSON.stringify(c.address)),
|
|
2267
|
+
});
|
|
1294
2268
|
}
|
|
1295
|
-
|
|
2269
|
+
report.info(\`[store] roster ready (\${ROSTER.length} customers)\`);
|
|
1296
2270
|
}
|
|
1297
2271
|
|
|
1298
2272
|
// \u2500\u2500 Identity handed in by server.ts after it verifies + decrypts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
@@ -1327,18 +2301,57 @@ function canonicalAddress(a: Address): string {
|
|
|
1327
2301
|
function findCustomer(accountNumber: string | undefined, name: string): { account_number: string; name: string; address: string } | undefined {
|
|
1328
2302
|
const acct = (accountNumber ?? '').trim();
|
|
1329
2303
|
if (acct) {
|
|
1330
|
-
const byAcct =
|
|
2304
|
+
const byAcct = decodeRow(
|
|
2305
|
+
db.prepare('SELECT account_number, name, address FROM customers WHERE account_key = ?').get(accountKey(acct)),
|
|
2306
|
+
);
|
|
1331
2307
|
if (byAcct) return byAcct as { account_number: string; name: string; address: string };
|
|
1332
2308
|
}
|
|
1333
2309
|
const n = name.trim().toLowerCase();
|
|
1334
2310
|
if (n) {
|
|
1335
|
-
|
|
2311
|
+
// Scanned and decrypted rather than matched in SQL: LOWER(name) cannot see
|
|
2312
|
+
// inside a ciphertext, so an equality here would match nothing and quietly
|
|
2313
|
+
// return "no such customer" forever. A roster is small; decrypting it is
|
|
2314
|
+
// microseconds.
|
|
2315
|
+
const byName = allCustomers().find((c) => c.name.trim().toLowerCase() === n);
|
|
1336
2316
|
if (byName) return byName as { account_number: string; name: string; address: string };
|
|
1337
2317
|
}
|
|
1338
2318
|
return undefined;
|
|
1339
2319
|
}
|
|
1340
2320
|
|
|
1341
2321
|
// \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
|
|
2322
|
+
/** One stored row, with the personal columns decrypted. */
|
|
2323
|
+
export interface StoredCustomer {
|
|
2324
|
+
account_number: string;
|
|
2325
|
+
name: string;
|
|
2326
|
+
address: string;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/** Decrypt a row read straight out of SQLite. */
|
|
2330
|
+
function decodeRow(raw: unknown): StoredCustomer | undefined {
|
|
2331
|
+
if (!raw) return undefined;
|
|
2332
|
+
const r = raw as StoredCustomer;
|
|
2333
|
+
return {
|
|
2334
|
+
account_number: dec('account_number', r.account_number)!,
|
|
2335
|
+
name: dec('name', r.name)!,
|
|
2336
|
+
address: dec('address', r.address)!,
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
/** Every customer, decrypted. Used where SQL cannot see inside the ciphertext. */
|
|
2341
|
+
export function allCustomers(): StoredCustomer[] {
|
|
2342
|
+
return (db.prepare('SELECT account_number, name, address FROM customers').all() as unknown[])
|
|
2343
|
+
.map((r) => decodeRow(r)!)
|
|
2344
|
+
.filter(Boolean);
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/** How many customers are on file. */
|
|
2348
|
+
export function customerCount(): number {
|
|
2349
|
+
return (db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }).n;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/** Is the file on disk protected? Surfaced in the dashboard, in both states. */
|
|
2353
|
+
export const storeEncrypted = encrypted;
|
|
2354
|
+
|
|
1342
2355
|
export type AccountVerdict = 'match' | 'no_match' | 'no_account';
|
|
1343
2356
|
|
|
1344
2357
|
/**
|
|
@@ -1352,7 +2365,10 @@ export type AccountVerdict = 'match' | 'no_match' | 'no_account';
|
|
|
1352
2365
|
export function verifyAccount(accountNumber: string | null, name: string, knownNames: string[] = []): AccountVerdict {
|
|
1353
2366
|
const acct = (accountNumber ?? '').trim();
|
|
1354
2367
|
if (!acct) return 'no_account';
|
|
1355
|
-
const
|
|
2368
|
+
const onFile = db.prepare('SELECT name FROM customers WHERE account_key = ?').get(accountKey(acct)) as
|
|
2369
|
+
| { name: string }
|
|
2370
|
+
| undefined;
|
|
2371
|
+
const row = onFile ? { name: dec('name', onFile.name)! } : undefined;
|
|
1356
2372
|
if (!row) return 'no_account';
|
|
1357
2373
|
const stored = row.name.trim().toLowerCase();
|
|
1358
2374
|
const candidates = [name, ...knownNames].map(v => (v ?? '').trim().toLowerCase()).filter(Boolean);
|
|
@@ -1373,43 +2389,72 @@ export type VerifyResult = 'match' | 'mismatch' | 'not_found';
|
|
|
1373
2389
|
export async function verifyAddress(customer: Customer, incoming: Address): Promise<VerifyResult> {
|
|
1374
2390
|
const row = findCustomer(customer.accountNumber, customer.name);
|
|
1375
2391
|
if (!row) {
|
|
1376
|
-
|
|
2392
|
+
report.info(\`[store] verifyAddress \u2192 not_found (\${customer.accountNumber || customer.name || '(none)'})\`);
|
|
1377
2393
|
return 'not_found';
|
|
1378
2394
|
}
|
|
1379
2395
|
let stored: Address = {};
|
|
1380
2396
|
try { stored = JSON.parse(row.address) as Address; } catch { /* corrupt row \u2192 treat as empty */ }
|
|
1381
2397
|
const result: VerifyResult = canonicalAddress(stored) === canonicalAddress(incoming) ? 'match' : 'mismatch';
|
|
1382
|
-
|
|
2398
|
+
report.info(\`[store] verifyAddress \u2192 \${result} (\${row.account_number})\`);
|
|
1383
2399
|
return result;
|
|
1384
2400
|
}
|
|
1385
2401
|
|
|
1386
2402
|
// \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
2403
|
/**
|
|
1388
|
-
* Applies a new address to the customer's record and logs the change.
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
2404
|
+
* Applies a new address to the customer's record and logs the change.
|
|
2405
|
+
*
|
|
2406
|
+
* Keyed on the account number, falling back to name. The caller enforces the
|
|
2407
|
+
* part that makes that safe: when you verify account references, server.ts
|
|
2408
|
+
* refuses anything that is not a \`match\` before reaching here, so the fallback
|
|
2409
|
+
* is unreachable in that mode and the key is always a real account of yours.
|
|
2410
|
+
*
|
|
2411
|
+
* IF YOU DO NOT VERIFY ACCOUNT REFERENCES the fallback is live and the row is
|
|
2412
|
+
* keyed on the customer's name, which is your identification scheme rather than
|
|
2413
|
+
* ours \u2014 but be aware two customers who share a name share a row. If that is
|
|
2414
|
+
* possible in your data, give this a key of your own instead.
|
|
1391
2415
|
*/
|
|
1392
|
-
export async function saveAddress(customer: Customer, incoming: Address): Promise<
|
|
2416
|
+
export async function saveAddress(customer: Customer, incoming: Address): Promise<Address> {
|
|
1393
2417
|
const acct = (customer.accountNumber ?? '').trim() || customer.name.trim();
|
|
1394
2418
|
const addressJson = JSON.stringify(incoming);
|
|
2419
|
+
const key = accountKey(acct);
|
|
2420
|
+
|
|
2421
|
+
// Read what we are about to replace. After the UPDATE nothing can reconstruct
|
|
2422
|
+
// it, and "what did this address replace" is the question an operator asks
|
|
2423
|
+
// first when a change looks wrong.
|
|
2424
|
+
const existing = decodeRow(
|
|
2425
|
+
db.prepare('SELECT account_number, name, address FROM customers WHERE account_key = ?').get(key),
|
|
2426
|
+
);
|
|
2427
|
+
const previous: Address = existing ? (JSON.parse(existing.address) as Address) : {};
|
|
1395
2428
|
|
|
1396
2429
|
db.prepare(\`
|
|
1397
|
-
INSERT INTO customers (account_number, name, address, updated_at)
|
|
1398
|
-
VALUES ($account_number, $name, $address, datetime('now'))
|
|
1399
|
-
ON CONFLICT(
|
|
2430
|
+
INSERT INTO customers (account_key, account_number, name, address, updated_at)
|
|
2431
|
+
VALUES ($account_key, $account_number, $name, $address, datetime('now'))
|
|
2432
|
+
ON CONFLICT(account_key) DO UPDATE SET
|
|
1400
2433
|
name = excluded.name,
|
|
1401
2434
|
address = excluded.address,
|
|
1402
2435
|
updated_at = excluded.updated_at
|
|
1403
|
-
\`).run({
|
|
2436
|
+
\`).run({
|
|
2437
|
+
account_key: key,
|
|
2438
|
+
account_number: enc('account_number', acct),
|
|
2439
|
+
name: enc('name', customer.name),
|
|
2440
|
+
address: enc('address', addressJson),
|
|
2441
|
+
});
|
|
1404
2442
|
|
|
1405
2443
|
db.prepare(\`
|
|
1406
|
-
INSERT INTO address_history (
|
|
1407
|
-
|
|
2444
|
+
INSERT INTO address_history (account_key, prev_address, address)
|
|
2445
|
+
VALUES ($account_key, $prev_address, $address)
|
|
2446
|
+
\`).run({
|
|
2447
|
+
account_key: key,
|
|
2448
|
+
prev_address: enc('address', JSON.stringify(previous)),
|
|
2449
|
+
address: enc('address', addressJson),
|
|
2450
|
+
});
|
|
2451
|
+
|
|
2452
|
+
// Metadata only \u2014 the address itself is personal information, so the key is
|
|
2453
|
+
// logged and the address never is. Centralised log aggregation turns every
|
|
2454
|
+
// log line into a place customer addresses can be read.
|
|
2455
|
+
report.info(\`[store] saved address for \${acct}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
|
|
1408
2456
|
|
|
1409
|
-
|
|
1410
|
-
// address. Centralised log aggregation turns every log line into a
|
|
1411
|
-
// data-exposure surface for customer addresses.
|
|
1412
|
-
console.log(\`[store] saved address for \${acct}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
|
|
2457
|
+
return previous;
|
|
1413
2458
|
}
|
|
1414
2459
|
`
|
|
1415
2460
|
},
|
|
@@ -1430,12 +2475,13 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
|
|
|
1430
2475
|
// Fail fast with a clear message rather than a cryptic ERR_UNSUPPORTED_FEATURE later.
|
|
1431
2476
|
const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number);
|
|
1432
2477
|
if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 5)) {
|
|
1433
|
-
|
|
1434
|
-
|
|
2478
|
+
report.error(\`[startup] Node.js 22.5+ is required (you are running \${process.version}).\`);
|
|
2479
|
+
report.error('[startup] Upgrade Node.js: https://nodejs.org/en/download');
|
|
1435
2480
|
process.exit(1);
|
|
1436
2481
|
}
|
|
1437
2482
|
|
|
1438
2483
|
import 'dotenv/config';
|
|
2484
|
+
import { report } from './report.js';
|
|
1439
2485
|
import express, { Request, Response } from 'express';
|
|
1440
2486
|
import rateLimit from 'express-rate-limit';
|
|
1441
2487
|
import { createPrivateKey, createHmac } from 'node:crypto';
|
|
@@ -1450,6 +2496,7 @@ import {
|
|
|
1450
2496
|
type OneAddressD5LOA,
|
|
1451
2497
|
} from '@oneaddress/partner-sdk';
|
|
1452
2498
|
import { saveAddress, verifyAddress, verifyAccount } from './store.js';
|
|
2499
|
+
import { notePreviousAddress } from './tui.js';
|
|
1453
2500
|
import { config } from './config.js';
|
|
1454
2501
|
import { safeOneAddressCallbackUrl } from './callback-url.js';
|
|
1455
2502
|
|
|
@@ -1472,7 +2519,7 @@ const ONEADDRESS_API = config.oneAddressApi;
|
|
|
1472
2519
|
const CONFIRM_SECRET = process.env.CONFIRM_SECRET || WEBHOOK_SECRET;
|
|
1473
2520
|
|
|
1474
2521
|
if (!WEBHOOK_SECRET || !PARTNER_PRIVATE_KEY || !PARTNER_ID) {
|
|
1475
|
-
|
|
2522
|
+
report.error('[startup] Missing required env vars. Check your .env file.');
|
|
1476
2523
|
process.exit(1);
|
|
1477
2524
|
}
|
|
1478
2525
|
|
|
@@ -1485,8 +2532,8 @@ if (!WEBHOOK_SECRET || !PARTNER_PRIVATE_KEY || !PARTNER_ID) {
|
|
|
1485
2532
|
// fails only when the first payload arrives.
|
|
1486
2533
|
// 2. A private key that does not parse (empty, truncated, or header stripped).
|
|
1487
2534
|
if (typeof decryptSession !== 'function') {
|
|
1488
|
-
|
|
1489
|
-
|
|
2535
|
+
report.error('[startup] @oneaddress/partner-sdk does not export decryptSession. Your installed SDK is too old for D5 dispatches.');
|
|
2536
|
+
report.error('[startup] Fix: npm install "@oneaddress/partner-sdk@^1.8.0", then restart.');
|
|
1490
2537
|
process.exit(1);
|
|
1491
2538
|
}
|
|
1492
2539
|
// Only PEM keys are parseable this way; a post-quantum key is a base64 secret,
|
|
@@ -1495,8 +2542,8 @@ if (PARTNER_PRIVATE_KEY.includes('BEGIN')) {
|
|
|
1495
2542
|
try {
|
|
1496
2543
|
createPrivateKey(PARTNER_PRIVATE_KEY);
|
|
1497
2544
|
} catch {
|
|
1498
|
-
|
|
1499
|
-
|
|
2545
|
+
report.error('[startup] PARTNER_PRIVATE_KEY_PEM does not parse as a private key.');
|
|
2546
|
+
report.error('[startup] Paste the FULL PEM, including the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines.');
|
|
1500
2547
|
process.exit(1);
|
|
1501
2548
|
}
|
|
1502
2549
|
}
|
|
@@ -1560,16 +2607,16 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
|
|
|
1560
2607
|
body: bodyStr,
|
|
1561
2608
|
});
|
|
1562
2609
|
if (confirmRes.ok) {
|
|
1563
|
-
|
|
2610
|
+
report.info(\`[confirm] dispatch \${dispatchId} \u2192 \${status}: acknowledged by OneAddress\`);
|
|
1564
2611
|
} else {
|
|
1565
2612
|
const detail = await confirmRes.text().catch(() => '');
|
|
1566
|
-
|
|
2613
|
+
report.error(\`[confirm] dispatch \${dispatchId} confirm FAILED \u2014 HTTP \${confirmRes.status} \${detail}\`);
|
|
1567
2614
|
if (confirmRes.status === 401) {
|
|
1568
|
-
|
|
2615
|
+
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
2616
|
}
|
|
1570
2617
|
}
|
|
1571
2618
|
} catch (err) {
|
|
1572
|
-
|
|
2619
|
+
report.error('[confirm] confirm request error:', err);
|
|
1573
2620
|
}
|
|
1574
2621
|
}
|
|
1575
2622
|
|
|
@@ -1653,7 +2700,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1653
2700
|
// checked" rather than a match/no_match you don't actually compute \u2014 this is
|
|
1654
2701
|
// the setup declaration reaching the running handler.
|
|
1655
2702
|
if (!config.verifiesAccountReference) {
|
|
1656
|
-
|
|
2703
|
+
report.info('[webhook] account.verify \u2192 skipped (verifiesAccountReference is false in oneaddress.config.json)');
|
|
1657
2704
|
return res.status(200).json({ ok: true, skipped: true });
|
|
1658
2705
|
}
|
|
1659
2706
|
|
|
@@ -1666,7 +2713,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1666
2713
|
try {
|
|
1667
2714
|
cust = await decryptAddress(enc, PARTNER_PRIVATE_KEY, PARTNER_ID);
|
|
1668
2715
|
} catch (err) {
|
|
1669
|
-
|
|
2716
|
+
report.error('[webhook] account.verify decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM:', err);
|
|
1670
2717
|
return res.status(422).json({ ok: false, error: 'decryption_failed' });
|
|
1671
2718
|
}
|
|
1672
2719
|
|
|
@@ -1675,7 +2722,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1675
2722
|
const knownNames = Array.isArray(cust.known_names) ? cust.known_names.map(String) : [];
|
|
1676
2723
|
|
|
1677
2724
|
const status = verifyAccount(accountNumber, name, knownNames);
|
|
1678
|
-
|
|
2725
|
+
report.info(\`[webhook] account.verify \u2192 \${status} for account \${accountNumber ?? '(none)'} (\${name})\`);
|
|
1679
2726
|
return res.status(200).json({ status });
|
|
1680
2727
|
}
|
|
1681
2728
|
|
|
@@ -1687,7 +2734,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1687
2734
|
// the 422 below, because it IS a dispatch event.
|
|
1688
2735
|
const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
|
|
1689
2736
|
if (!DISPATCH_EVENTS.includes(event)) {
|
|
1690
|
-
|
|
2737
|
+
report.info(\`[webhook] "\${event}" acknowledged (no address payload to decrypt)\`);
|
|
1691
2738
|
return res.status(200).json({ ok: true, skipped: true });
|
|
1692
2739
|
}
|
|
1693
2740
|
|
|
@@ -1726,7 +2773,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1726
2773
|
decAccount = typeof data.account_number === 'string' ? data.account_number : '';
|
|
1727
2774
|
decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
|
|
1728
2775
|
} catch (err) {
|
|
1729
|
-
|
|
2776
|
+
report.error('[webhook] D5 decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM matches key_id', sessionKeyShare.key_id, ':', err);
|
|
1730
2777
|
return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
|
|
1731
2778
|
}
|
|
1732
2779
|
} else if (legacyPayload) {
|
|
@@ -1736,7 +2783,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1736
2783
|
decAccount = typeof address.accountReference === 'string' ? address.accountReference : '';
|
|
1737
2784
|
decKnownNames = Array.isArray(address.knownNames) ? address.knownNames as string[] : [];
|
|
1738
2785
|
} catch (err) {
|
|
1739
|
-
|
|
2786
|
+
report.error('[webhook] Decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM in .env:', err);
|
|
1740
2787
|
return res.status(422).json({ ok: false, error: 'Decryption failed \u2014 partner key mismatch' });
|
|
1741
2788
|
}
|
|
1742
2789
|
} else {
|
|
@@ -1758,7 +2805,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1758
2805
|
const loa: OneAddressD5LOA = await decryptLoaEncrypted(loaEncrypted, PARTNER_PRIVATE_KEY, PARTNER_ID);
|
|
1759
2806
|
loaRef = d5LoaRef(loa);
|
|
1760
2807
|
} catch (err) {
|
|
1761
|
-
|
|
2808
|
+
report.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
|
|
1762
2809
|
}
|
|
1763
2810
|
}
|
|
1764
2811
|
|
|
@@ -1776,8 +2823,48 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1776
2823
|
|
|
1777
2824
|
// \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
2825
|
if (event === 'address.updated') {
|
|
1779
|
-
|
|
1780
|
-
|
|
2826
|
+
report.info(\`[webhook] address.updated for \${ctx.accountNumber || ctx.name}\`);
|
|
2827
|
+
|
|
2828
|
+
// AN ACCOUNT REFERENCE THAT MATCHES NOTHING MUST BE A HARD NO-MATCH.
|
|
2829
|
+
//
|
|
2830
|
+
// This runs BEFORE saveAddress, and it is the whole reason saveAddress can
|
|
2831
|
+
// be trusted. Until 13 Sep 2026 this handler applied every dispatch it
|
|
2832
|
+
// could decrypt, with no check that the account was one of yours: an
|
|
2833
|
+
// account number matching nobody created a BRAND NEW customer row, and a
|
|
2834
|
+
// dispatch with no account number at all was keyed on the customer's NAME,
|
|
2835
|
+
// so two customers who share a name collapse onto one record and the
|
|
2836
|
+
// second one's address overwrites the first's.
|
|
2837
|
+
//
|
|
2838
|
+
// Do not "simplify" this by falling back to a name match when the account
|
|
2839
|
+
// number misses. A name matching a DIFFERENT customer's record is not
|
|
2840
|
+
// evidence the two are the same person; it is the most likely way to write
|
|
2841
|
+
// one customer's address onto another customer's account.
|
|
2842
|
+
//
|
|
2843
|
+
// Gated on your own portal declaration, exactly like account.verify above:
|
|
2844
|
+
// if you told the portal you do not verify account references, you identify
|
|
2845
|
+
// customers some other way and this check cannot speak for you.
|
|
2846
|
+
//
|
|
2847
|
+
// Refusing is reported two ways, and both matter. The \`ok: false\` tells
|
|
2848
|
+
// OneAddress this delivery failed (a 200 carrying ok:true would be read as
|
|
2849
|
+
// success and the consumer would be told their address had landed); the
|
|
2850
|
+
// \`failed\` confirm callback puts the same answer on the record they see.
|
|
2851
|
+
// OneAddress conformance check "Refuses an account reference that matches
|
|
2852
|
+
// no record" tests exactly this.
|
|
2853
|
+
if (config.verifiesAccountReference) {
|
|
2854
|
+
const verdict = verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
|
|
2855
|
+
if (verdict !== 'match') {
|
|
2856
|
+
report.warn(\`[webhook] address.updated REFUSED (\${verdict}) for account \${ctx.accountNumber ?? '(none)'} \u2014 nothing applied\`);
|
|
2857
|
+
void confirmToOneAddress(dispatch, 'failed');
|
|
2858
|
+
return res.status(200).json({ ok: false, error: 'account_not_matched', verdict });
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
// \`saveAddress\` returns the address it replaced. Handed to the dashboard so
|
|
2863
|
+
// it can show both halves; a no-op under --headless. Passed directly rather
|
|
2864
|
+
// than reported, because the previous address is a customer's address and
|
|
2865
|
+
// must never reach a log line.
|
|
2866
|
+
const replaced = await saveAddress(ctx, address);
|
|
2867
|
+
notePreviousAddress(replaced);
|
|
1781
2868
|
if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
|
|
1782
2869
|
// Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
1783
2870
|
// Fire-and-forget: it must not delay this 200 (which acks the delivery).
|
|
@@ -1799,13 +2886,13 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1799
2886
|
// network position into an SSRF primitive.
|
|
1800
2887
|
const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
|
|
1801
2888
|
if (!safeCallbackUrl) {
|
|
1802
|
-
|
|
2889
|
+
report.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
|
|
1803
2890
|
return res.status(400).json({ error: 'Invalid callback_url host' });
|
|
1804
2891
|
}
|
|
1805
2892
|
|
|
1806
|
-
|
|
2893
|
+
report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
|
|
1807
2894
|
const result = await verifyAddress(ctx, address);
|
|
1808
|
-
|
|
2895
|
+
report.info(\`[webhook] address.verify \u2192 \${result}\`);
|
|
1809
2896
|
|
|
1810
2897
|
await fetch(safeCallbackUrl, {
|
|
1811
2898
|
method: 'POST',
|
|
@@ -1841,21 +2928,26 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1841
2928
|
const a = address as { street?: unknown; suburb?: unknown; state?: unknown; postcode?: unknown };
|
|
1842
2929
|
const oneLine = [a.street, [a.suburb, a.state, a.postcode].filter(Boolean).join(' ')]
|
|
1843
2930
|
.filter(Boolean).join(', ');
|
|
1844
|
-
|
|
1845
|
-
|
|
2931
|
+
report.info(\`[webhook] \${event} verification probe decrypted OK\`);
|
|
2932
|
+
report.info(\`[verification] \${index ?? '?'} | \${ctx.name} | \${oneLine}\`);
|
|
1846
2933
|
return res.status(200).json({ verification: true, index });
|
|
1847
2934
|
}
|
|
1848
2935
|
|
|
1849
2936
|
// Unknown event \u2014 acknowledge (forward compatibility)
|
|
1850
|
-
|
|
2937
|
+
report.info(\`[webhook] Unknown event "\${event}" \u2014 acknowledged\`);
|
|
1851
2938
|
return res.status(200).json({ ok: true, skipped: true });
|
|
1852
2939
|
});
|
|
1853
2940
|
|
|
1854
2941
|
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
|
1855
2942
|
|
|
1856
2943
|
app.listen(PORT, () =>
|
|
1857
|
-
|
|
2944
|
+
report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`),
|
|
1858
2945
|
);
|
|
2946
|
+
|
|
2947
|
+
// Read by src/index.ts to label the dashboard. Exported rather than re-derived
|
|
2948
|
+
// there, so the port the UI claims is the port the server actually bound.
|
|
2949
|
+
export { PORT };
|
|
2950
|
+
export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
|
|
1859
2951
|
`
|
|
1860
2952
|
},
|
|
1861
2953
|
{
|
|
@@ -2259,8 +3351,15 @@ def _verify_account(account_number: str, name: str, known_names: list[str]) -> s
|
|
|
2259
3351
|
return "match" if stored in candidates else "no_match"
|
|
2260
3352
|
|
|
2261
3353
|
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
|
|
2263
|
-
|
|
3354
|
+
"""Persist on address.updated. Upserts the customer's on-file address + appends
|
|
3355
|
+
history.
|
|
3356
|
+
|
|
3357
|
+
Keyed on the decrypted account number, falling back to name. The caller
|
|
3358
|
+
enforces what makes that safe: when you verify account references, the
|
|
3359
|
+
handler refuses anything that is not a 'match' before reaching here, so the
|
|
3360
|
+
fallback is unreachable in that mode. If you do NOT verify account
|
|
3361
|
+
references the fallback is live and the row is keyed on the customer's name
|
|
3362
|
+
- be aware two customers sharing a name share a row."""
|
|
2264
3363
|
acct = (account_number or "").strip() or (name or "").strip()
|
|
2265
3364
|
address_json = json.dumps(address, sort_keys=True)
|
|
2266
3365
|
_db.execute("""
|
|
@@ -2579,6 +3678,39 @@ async def webhook(request: Request) -> Response:
|
|
|
2579
3678
|
# production and writing PII there turns every log reader into a
|
|
2580
3679
|
# data-exposure surface.
|
|
2581
3680
|
print(f"[webhook] address.updated for {account_number or verified_name or '?'} (dispatch={dispatch})")
|
|
3681
|
+
|
|
3682
|
+
# AN ACCOUNT REFERENCE THAT MATCHES NOTHING MUST BE A HARD NO-MATCH.
|
|
3683
|
+
#
|
|
3684
|
+
# Runs BEFORE _save_address, and it is what makes _save_address safe.
|
|
3685
|
+
# Until 13 Sep 2026 this handler applied every dispatch it could
|
|
3686
|
+
# decrypt with no check that the account was one of yours: an account
|
|
3687
|
+
# number matching nobody created a BRAND NEW customer row, and a
|
|
3688
|
+
# dispatch with no account number was keyed on the customer's NAME, so
|
|
3689
|
+
# two customers sharing a name collapse onto one record.
|
|
3690
|
+
#
|
|
3691
|
+
# Do NOT fall back to a name match when the account number misses. A
|
|
3692
|
+
# name matching a DIFFERENT customer's record is not evidence they are
|
|
3693
|
+
# the same person; it is the likeliest way to write one customer's
|
|
3694
|
+
# address onto another's account.
|
|
3695
|
+
#
|
|
3696
|
+
# Gated on your portal declaration, exactly like account.verify below.
|
|
3697
|
+
# Refusing is reported twice and both matter: ok:false tells OneAddress
|
|
3698
|
+
# the delivery failed (a 200 with ok:true is read as success and the
|
|
3699
|
+
# consumer is told their address landed), and the "failed" confirm puts
|
|
3700
|
+
# the same answer on the record they see.
|
|
3701
|
+
if VERIFIES_ACCOUNT_REFERENCE:
|
|
3702
|
+
verdict = _verify_account(account_number, verified_name, known_names)
|
|
3703
|
+
if verdict != "match":
|
|
3704
|
+
print(f"[webhook] address.updated REFUSED ({verdict}) for account "
|
|
3705
|
+
f"{account_number or '(none)'} - nothing applied")
|
|
3706
|
+
_t = asyncio.create_task(_confirm_to_oneaddress(dispatch, "failed"))
|
|
3707
|
+
_background_tasks.add(_t)
|
|
3708
|
+
_t.add_done_callback(_background_tasks.discard)
|
|
3709
|
+
return Response(
|
|
3710
|
+
content='{"ok":false,"error":"account_not_matched","verdict":"' + verdict + '"}',
|
|
3711
|
+
media_type="application/json",
|
|
3712
|
+
)
|
|
3713
|
+
|
|
2582
3714
|
_save_address(account_number, verified_name, address, dispatch)
|
|
2583
3715
|
if dispatch:
|
|
2584
3716
|
seen_dispatches.add(dispatch) # remember only after it is stored
|
|
@@ -3300,8 +4432,15 @@ public class OneAddressWebhookController {
|
|
|
3300
4432
|
// Close the loop back to OneAddress so the service flips to
|
|
3301
4433
|
// "Confirmed". Fire-and-forget (async) so a slow confirm never
|
|
3302
4434
|
// delays this 200. Only when we actually applied the update.
|
|
3303
|
-
|
|
3304
|
-
|
|
4435
|
+
// \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true here (which it
|
|
4436
|
+
// did until 13 Sep 2026) reports a no-match as a successful
|
|
4437
|
+
// delivery: the matching above is correct and refuses to write,
|
|
4438
|
+
// the confirm is correctly withheld, and then the wire says the
|
|
4439
|
+
// opposite. OneAddress reads THIS, so the consumer was told
|
|
4440
|
+
// their address had landed on an account that does not exist.
|
|
4441
|
+
boolean applied = "applied".equals(outcome);
|
|
4442
|
+
confirmToOneAddress(dispatchId, applied ? "confirmed" : "failed");
|
|
4443
|
+
return ResponseEntity.ok("{\\"ok\\":" + applied + ",\\"outcome\\":\\"" + outcome + "\\"}");
|
|
3305
4444
|
} else if ("address.verify".equals(event)) {
|
|
3306
4445
|
handleAddressVerify(body, address, accountNumber, verifiedName, knownNames);
|
|
3307
4446
|
if (dispatchId != null && !dispatchId.isBlank()) store.markProcessed(dispatchId, "verify");
|
|
@@ -3644,6 +4783,16 @@ public class OneAddressStore {
|
|
|
3644
4783
|
private Long findCustomerId(String accountNumber, String verifiedName, List<String> knownNames) {
|
|
3645
4784
|
List<String> candidates = allNames(verifiedName, knownNames);
|
|
3646
4785
|
|
|
4786
|
+
// AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
|
|
4787
|
+
// function is where someone will try to make that more forgiving. Do not.
|
|
4788
|
+
// When an account number is supplied it decides the answer alone: if no row
|
|
4789
|
+
// carries it, or the row it carries disagrees with the name, this returns
|
|
4790
|
+
// nothing and does NOT fall through to the name lookup below. A name
|
|
4791
|
+
// matching a DIFFERENT customer's record is not evidence the two are the
|
|
4792
|
+
// same person; it is the likeliest way to write one customer's address onto
|
|
4793
|
+
// another customer's account. The name lookup exists only for partners who
|
|
4794
|
+
// do not use account references at all.
|
|
4795
|
+
|
|
3647
4796
|
if (accountNumber != null && !accountNumber.isBlank()) {
|
|
3648
4797
|
List<Map<String, Object>> rows = jdbc.queryForList(
|
|
3649
4798
|
"SELECT id, full_name FROM customers WHERE account_number = ?", accountNumber.trim());
|
|
@@ -4112,6 +5261,16 @@ public sealed class OneAddressStore
|
|
|
4112
5261
|
if (!string.IsNullOrWhiteSpace(verifiedName)) candidates.Add(verifiedName.Trim());
|
|
4113
5262
|
candidates.AddRange(knownNames.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n.Trim()));
|
|
4114
5263
|
|
|
5264
|
+
// AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
|
|
5265
|
+
// function is where someone will try to make that more forgiving. Do not.
|
|
5266
|
+
// When an account number is supplied it decides the answer alone: if no row
|
|
5267
|
+
// carries it, or the row it carries disagrees with the name, this returns
|
|
5268
|
+
// nothing and does NOT fall through to the name lookup below. A name
|
|
5269
|
+
// matching a DIFFERENT customer's record is not evidence the two are the
|
|
5270
|
+
// same person; it is the likeliest way to write one customer's address onto
|
|
5271
|
+
// another customer's account. The name lookup exists only for partners who
|
|
5272
|
+
// do not use account references at all.
|
|
5273
|
+
|
|
4115
5274
|
if (!string.IsNullOrWhiteSpace(accountNumber))
|
|
4116
5275
|
{
|
|
4117
5276
|
using var byAcct = conn.CreateCommand();
|
|
@@ -4385,9 +5544,14 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
|
|
|
4385
5544
|
// Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
4386
5545
|
// Fire-and-forget (discard the Task) so a slow confirm never delays this
|
|
4387
5546
|
// 200. Only when we actually applied the update.
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
5547
|
+
// \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true (which it did until
|
|
5548
|
+
// 13 Sep 2026) reports a no-match as a successful delivery: the match
|
|
5549
|
+
// above correctly refuses to write and correctly withholds the confirm,
|
|
5550
|
+
// then the wire says the opposite. OneAddress reads THIS.
|
|
5551
|
+
var applied = outcome == "applied";
|
|
5552
|
+
_ = ConfirmToOneAddress(oneAddressApi, confirmSecret, partnerId, dispatchId,
|
|
5553
|
+
applied ? "confirmed" : "failed", app.Logger);
|
|
5554
|
+
return Results.Json(new { ok = applied, outcome });
|
|
4391
5555
|
}
|
|
4392
5556
|
else // address.verify
|
|
4393
5557
|
{
|
|
@@ -5078,6 +6242,15 @@ func (s *Store) findCustomerID(accountNumber, verifiedName string, knownNames []
|
|
|
5078
6242
|
}
|
|
5079
6243
|
}
|
|
5080
6244
|
|
|
6245
|
+
// AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
|
|
6246
|
+
// function is where someone will try to make that more forgiving. Do not.
|
|
6247
|
+
// When an account number is supplied it decides the answer alone: if no row
|
|
6248
|
+
// carries it, or the row it carries disagrees with the name, this returns
|
|
6249
|
+
// nothing and does NOT fall through to the name lookup below. A name
|
|
6250
|
+
// matching a DIFFERENT customer's record is not evidence the two are the
|
|
6251
|
+
// same person; it is the likeliest way to write one customer's address onto
|
|
6252
|
+
// another customer's account. The name lookup exists only for partners who
|
|
6253
|
+
// do not use account references at all.
|
|
5081
6254
|
if strings.TrimSpace(accountNumber) != "" {
|
|
5082
6255
|
var id int64
|
|
5083
6256
|
var fullName string
|
|
@@ -5396,10 +6569,17 @@ func main() {
|
|
|
5396
6569
|
// Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
5397
6570
|
// Fire-and-forget in a goroutine: a slow confirm must not delay this 200
|
|
5398
6571
|
// (which acks the delivery). Only when we actually applied the update.
|
|
5399
|
-
|
|
6572
|
+
// \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true (which it did until
|
|
6573
|
+
// 13 Sep 2026) reports a no-match as a successful delivery: the match
|
|
6574
|
+
// above correctly refuses to write and correctly withholds the confirm,
|
|
6575
|
+
// then the wire says the opposite. OneAddress reads THIS.
|
|
6576
|
+
applied := outcome == "applied"
|
|
6577
|
+
if applied {
|
|
5400
6578
|
go confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, "confirmed")
|
|
6579
|
+
} else {
|
|
6580
|
+
go confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, "failed")
|
|
5401
6581
|
}
|
|
5402
|
-
jsonResp(w, 200, map[string]any{"ok":
|
|
6582
|
+
jsonResp(w, 200, map[string]any{"ok": applied, "outcome": outcome})
|
|
5403
6583
|
return
|
|
5404
6584
|
|
|
5405
6585
|
case "address.verify":
|
|
@@ -6158,6 +7338,16 @@ class OneAddressStore
|
|
|
6158
7338
|
{
|
|
6159
7339
|
$candidates = array_filter(array_merge([$verifiedName], $knownNames), fn ($n) => is_string($n) && trim($n) !== '');
|
|
6160
7340
|
|
|
7341
|
+
// AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
|
|
7342
|
+
// function is where someone will try to make that more forgiving. Do not.
|
|
7343
|
+
// When an account number is supplied it decides the answer alone: if no row
|
|
7344
|
+
// carries it, or the row it carries disagrees with the name, this returns
|
|
7345
|
+
// nothing and does NOT fall through to the name lookup below. A name
|
|
7346
|
+
// matching a DIFFERENT customer's record is not evidence the two are the
|
|
7347
|
+
// same person; it is the likeliest way to write one customer's address onto
|
|
7348
|
+
// another customer's account. The name lookup exists only for partners who
|
|
7349
|
+
// do not use account references at all.
|
|
7350
|
+
|
|
6161
7351
|
if ($accountNumber !== null && trim($accountNumber) !== '') {
|
|
6162
7352
|
$row = DB::table('customers')->where('account_number', trim($accountNumber))->first();
|
|
6163
7353
|
if (!$row) {
|
|
@@ -6602,10 +7792,14 @@ Route::post('/webhook', function (Request $request): Response {
|
|
|
6602
7792
|
]);
|
|
6603
7793
|
// Close the loop back to OneAddress so the service flips to "Confirmed",
|
|
6604
7794
|
// but only when we actually applied the update. Never affects this 200.
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
7795
|
+
// \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true (which it did until
|
|
7796
|
+
// 13 Sep 2026) reports a no-match as a successful delivery: the match
|
|
7797
|
+
// above correctly refuses to write and correctly withholds the confirm,
|
|
7798
|
+
// then the wire says the opposite. OneAddress reads THIS.
|
|
7799
|
+
$applied = $outcome === 'applied';
|
|
7800
|
+
oaConfirmToOneAddress($oneAddressApi, $confirmSecret, $partnerId, $dispatchId,
|
|
7801
|
+
$applied ? 'confirmed' : 'failed');
|
|
7802
|
+
return response()->json(['ok' => $applied, 'outcome' => $outcome]);
|
|
6609
7803
|
} elseif ($eventType === 'address.verify') {
|
|
6610
7804
|
$callbackUrl = $body['callback_url'] ?? '';
|
|
6611
7805
|
$callbackToken= $body['callback_token'] ?? '';
|
|
@@ -6796,7 +7990,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
6796
7990
|
|
|
6797
7991
|
// src/register.ts
|
|
6798
7992
|
var import_node_crypto = require("crypto");
|
|
6799
|
-
var PKG_VERSION = true ? "
|
|
7993
|
+
var PKG_VERSION = true ? "2.0.1" : "dev";
|
|
6800
7994
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
6801
7995
|
function hmacSha256(secret, message) {
|
|
6802
7996
|
return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
|