@oneaddress/setup 1.6.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2259 -198
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -160,7 +160,7 @@ var require_picocolors = __commonJS({
|
|
|
160
160
|
});
|
|
161
161
|
|
|
162
162
|
// src/index.ts
|
|
163
|
-
var
|
|
163
|
+
var import_node_fs5 = require("fs");
|
|
164
164
|
|
|
165
165
|
// node_modules/@clack/prompts/dist/index.mjs
|
|
166
166
|
var import_node_util = require("util");
|
|
@@ -830,9 +830,9 @@ var Y2 = ({ indicator: t = "dots" } = {}) => {
|
|
|
830
830
|
// src/prompts.ts
|
|
831
831
|
var import_node_crypto5 = require("crypto");
|
|
832
832
|
var import_node_net = require("net");
|
|
833
|
-
var
|
|
833
|
+
var import_node_fs3 = require("fs");
|
|
834
834
|
var import_node_os2 = require("os");
|
|
835
|
-
var
|
|
835
|
+
var import_node_path4 = require("path");
|
|
836
836
|
|
|
837
837
|
// src/header.ts
|
|
838
838
|
var R2 = "\x1B[0m";
|
|
@@ -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.0" : "?";
|
|
860
860
|
function printCompactHeader() {
|
|
861
861
|
const INNER = 42;
|
|
862
862
|
const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
|
|
@@ -963,19 +963,22 @@ data.db-shm
|
|
|
963
963
|
"version": "1.0.0",
|
|
964
964
|
"private": true,
|
|
965
965
|
"scripts": {
|
|
966
|
-
"dev":
|
|
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
|
-
"@oneaddress/partner-sdk": "^1.
|
|
974
|
+
"@oneaddress/partner-sdk": "^1.8.0",
|
|
975
|
+
"blessed": "^0.1.81",
|
|
974
976
|
"dotenv": "^16.0.0",
|
|
975
977
|
"express": "^4.18.0",
|
|
976
978
|
"express-rate-limit": "^8.6.2"
|
|
977
979
|
},
|
|
978
980
|
"devDependencies": {
|
|
981
|
+
"@types/blessed": "^0.1.25",
|
|
979
982
|
"@types/express": "^4.17.0",
|
|
980
983
|
"@types/node": "^22.5.0",
|
|
981
984
|
"tsup": "^8.0.0",
|
|
@@ -1041,32 +1044,844 @@ data.db-shm
|
|
|
1041
1044
|
{
|
|
1042
1045
|
name: "src/db.ts",
|
|
1043
1046
|
content: `/**
|
|
1044
|
-
* SQLite database
|
|
1047
|
+
* SQLite database, and the at-rest encryption layer over it.
|
|
1048
|
+
*
|
|
1049
|
+
* Uses the built-in \`node:sqlite\` module (Node 22.5+), so there is nothing to
|
|
1050
|
+
* compile and nothing extra to install.
|
|
1045
1051
|
*
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
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.
|
|
1048
1056
|
*
|
|
1049
|
-
*
|
|
1050
|
-
* customer roster plus the append-only address-history audit trail \u2014 is created
|
|
1051
|
-
* by src/store.ts, which OWNS those tables, so everything about how your data is
|
|
1052
|
-
* shaped lives in the one file you are meant to edit.
|
|
1057
|
+
* ## Where the passphrase comes from
|
|
1053
1058
|
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
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');
|
|
1063
1093
|
|
|
1064
|
-
//
|
|
1065
|
-
db.exec(
|
|
1094
|
+
// Holds the salt and the verifier. Created before anything else needs them.
|
|
1095
|
+
db.exec(\`
|
|
1096
|
+
CREATE TABLE IF NOT EXISTS db_meta (
|
|
1097
|
+
key TEXT PRIMARY KEY,
|
|
1098
|
+
value TEXT NOT NULL
|
|
1099
|
+
);
|
|
1100
|
+
\`);
|
|
1101
|
+
|
|
1102
|
+
function readMeta(key: string): string | null {
|
|
1103
|
+
const row = db.prepare('SELECT value FROM db_meta WHERE key = ?').get(key) as
|
|
1104
|
+
| { value: string }
|
|
1105
|
+
| undefined;
|
|
1106
|
+
return row?.value ?? null;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function writeMeta(key: string, value: string): void {
|
|
1110
|
+
db.prepare(
|
|
1111
|
+
'INSERT INTO db_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
|
1112
|
+
).run(key, value);
|
|
1113
|
+
}
|
|
1066
1114
|
|
|
1067
|
-
|
|
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
|
+
}
|
|
1068
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());
|
|
1135
|
+
|
|
1136
|
+
// Checked BEFORE anything is written. A mistyped passphrase must not be able
|
|
1137
|
+
// to write a single row of ciphertext that nothing can ever read back.
|
|
1138
|
+
const stored = readMeta('verifier');
|
|
1139
|
+
if (stored === null) writeMeta('verifier', buildVerifier(keys));
|
|
1140
|
+
else if (!verifierMatches(keys, stored)) throw new WrongPassphraseError();
|
|
1141
|
+
|
|
1142
|
+
return keys;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const keys = openKeys();
|
|
1146
|
+
|
|
1147
|
+
/** True when the personal columns in this file are ciphertext. */
|
|
1148
|
+
export const encrypted = keys !== null;
|
|
1149
|
+
|
|
1150
|
+
/** Encrypt one field for storage, or pass it through when running in the clear. */
|
|
1151
|
+
export function enc(column: string, value: string | null): string | null {
|
|
1152
|
+
return keys ? encryptField(keys, column, value) : value;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** Decrypt one stored field. Plaintext passes through, so a migrating file works. */
|
|
1156
|
+
export function dec(column: string, value: string | null): string | null {
|
|
1157
|
+
return keys ? decryptField(keys, column, value) : value;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* The value to store and query an account number by.
|
|
1162
|
+
*
|
|
1163
|
+
* A blind index when locked, the plain number otherwise. This is what keeps the
|
|
1164
|
+
* account lookup an exact indexed hit rather than a table scan, which matters
|
|
1165
|
+
* because every dispatch resolves on it.
|
|
1166
|
+
*/
|
|
1167
|
+
export function accountKey(accountNumber: string): string {
|
|
1168
|
+
return keys ? accountIndex(keys, accountNumber) : accountNumber.trim().toLowerCase();
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Encrypt a value unless it already is encrypted.
|
|
1173
|
+
*
|
|
1174
|
+
* \`encryptField\` cannot tell a ciphertext from a plaintext and will encrypt one
|
|
1175
|
+
* twice if asked, leaving a row that decrypts to an envelope rather than to an
|
|
1176
|
+
* address. Anything that re-writes existing rows must go through this, not
|
|
1177
|
+
* through \`enc\`, and then re-running a migration is harmless by construction
|
|
1178
|
+
* rather than by remembering to set a flag.
|
|
1179
|
+
*/
|
|
1180
|
+
export function once(column: string, value: string | null): string | null {
|
|
1181
|
+
if (!keys) return value;
|
|
1182
|
+
return isEncrypted(value) ? value : encryptField(keys, column, value);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
export { WrongPassphraseError };
|
|
1069
1186
|
export default db;
|
|
1187
|
+
`
|
|
1188
|
+
},
|
|
1189
|
+
{
|
|
1190
|
+
name: "src/brand.ts",
|
|
1191
|
+
content: `/**
|
|
1192
|
+
* OneAddress brand for the terminal.
|
|
1193
|
+
*
|
|
1194
|
+
* The same wordmark, palette and geometry the setup wizard prints, so the
|
|
1195
|
+
* receiver a partner ends up running looks like the tool that installed it and
|
|
1196
|
+
* like the site they signed up on. One brand, three surfaces.
|
|
1197
|
+
*
|
|
1198
|
+
* ## Why the mark is pixel art rather than an image
|
|
1199
|
+
*
|
|
1200
|
+
* A terminal has no images. The wordmark is drawn from 7-row glyphs at 7
|
|
1201
|
+
* columns each, which is the only representation that survives SSH, tmux, a
|
|
1202
|
+
* Windows console and a CI log unchanged. It is the same glyph set the wizard
|
|
1203
|
+
* uses, deliberately: a redrawn "close enough" version is how two marks that
|
|
1204
|
+
* are supposed to be one start to drift.
|
|
1205
|
+
*
|
|
1206
|
+
* ## The width rule that is easy to get wrong
|
|
1207
|
+
*
|
|
1208
|
+
* The full banner is 88 columns. A default Git Bash window is 80, so the full
|
|
1209
|
+
* mark wraps there and shows the logo half-cut, which looks broken rather than
|
|
1210
|
+
* looking large. Under 88 columns we print a compact framed wordmark instead:
|
|
1211
|
+
* it fits any real terminal and says the same thing. Check the width, never
|
|
1212
|
+
* assume it.
|
|
1213
|
+
*/
|
|
1214
|
+
|
|
1215
|
+
// \u2500\u2500 Palette \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1216
|
+
// Hex values are the design system's, not approximations: amber #E0A248 and
|
|
1217
|
+
// cream #F5EED8 are the same constants the web app's \`C\` object carries.
|
|
1218
|
+
export const HEX = {
|
|
1219
|
+
amber: '#E0A248',
|
|
1220
|
+
amberLight: '#F0C878',
|
|
1221
|
+
cream: '#F5EED8',
|
|
1222
|
+
ink: '#13151C',
|
|
1223
|
+
faint: '#82642D',
|
|
1224
|
+
dim: '#463A23',
|
|
1225
|
+
mid: '#9B8764',
|
|
1226
|
+
} as const;
|
|
1227
|
+
|
|
1228
|
+
// \u2500\u2500 ANSI, for the boot lines printed before the dashboard takes the screen \u2500\u2500\u2500
|
|
1229
|
+
const R = '\\x1b[0m';
|
|
1230
|
+
const B = '\\x1b[1m';
|
|
1231
|
+
const AMB = '\\x1b[38;2;224;162;72m';
|
|
1232
|
+
const CRM = '\\x1b[38;2;245;238;216m';
|
|
1233
|
+
const FNT = '\\x1b[38;2;130;100;45m';
|
|
1234
|
+
const DIM = '\\x1b[38;2;70;58;35m';
|
|
1235
|
+
|
|
1236
|
+
export const amber = (s: string) => \`\${B}\${AMB}\${s}\${R}\`;
|
|
1237
|
+
export const cream = (s: string) => \`\${B}\${CRM}\${s}\${R}\`;
|
|
1238
|
+
export const faint = (s: string) => \`\${FNT}\${s}\${R}\`;
|
|
1239
|
+
export const dim = (s: string) => \`\${DIM}\${s}\${R}\`;
|
|
1240
|
+
|
|
1241
|
+
// \u2500\u2500 Wordmark glyphs \u2014 7 rows, 7 columns each \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1242
|
+
/* eslint-disable no-multi-spaces */
|
|
1243
|
+
const _O = [' \u2588\u2588\u2588\u2588\u2588 ', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', ' \u2588\u2588\u2588\u2588\u2588 '];
|
|
1244
|
+
const _N = ['\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588\u2588\u2588\u2588\u2588', '\u2588\u2588 \u2588\u2588\u2588\u2588', '\u2588\u2588 \u2588\u2588\u2588', '\u2588\u2588 \u2588\u2588'];
|
|
1245
|
+
const _E = ['\u2588\u2588\u2588\u2588\u2588\u2588\u2588', '\u2588\u2588 ', '\u2588\u2588 ', '\u2588\u2588\u2588\u2588\u2588\u2588 ', '\u2588\u2588 ', '\u2588\u2588 ', '\u2588\u2588\u2588\u2588\u2588\u2588\u2588'];
|
|
1246
|
+
const _A = [' \u2588\u2588\u2588 ', ' \u2588\u2588 \u2588\u2588 ', '\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588\u2588\u2588\u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588'];
|
|
1247
|
+
const _D = ['\u2588\u2588\u2588\u2588\u2588\u2588 ', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588\u2588\u2588\u2588 '];
|
|
1248
|
+
const _RG = ['\u2588\u2588\u2588\u2588\u2588\u2588 ', '\u2588\u2588 \u2588\u2588', '\u2588\u2588 \u2588\u2588', '\u2588\u2588\u2588\u2588\u2588\u2588 ', '\u2588\u2588\u2588\u2588 ', '\u2588\u2588 \u2588\u2588 ', '\u2588\u2588 \u2588\u2588 '];
|
|
1249
|
+
const _S = [' \u2588\u2588\u2588\u2588\u2588\u2588', '\u2588\u2588 ', '\u2588\u2588 ', ' \u2588\u2588\u2588\u2588\u2588 ', ' \u2588\u2588', ' \u2588\u2588', '\u2588\u2588\u2588\u2588\u2588\u2588 '];
|
|
1250
|
+
/* eslint-enable no-multi-spaces */
|
|
1251
|
+
|
|
1252
|
+
/** "ONE" in cream, 23 columns wide. */
|
|
1253
|
+
export const ONE_ROWS = Array.from({ length: 7 }, (_, i) => [_O[i], _N[i], _E[i]].join(' '));
|
|
1254
|
+
|
|
1255
|
+
/** "ADDRESS" in amber, 55 columns wide. */
|
|
1256
|
+
export const ADDRESS_ROWS = Array.from(
|
|
1257
|
+
{ length: 7 },
|
|
1258
|
+
(_, i) => [_A[i], _D[i], _D[i], _RG[i], _E[i], _S[i], _S[i]].join(' '),
|
|
1259
|
+
);
|
|
1260
|
+
|
|
1261
|
+
/** Columns the full banner needs. Below this, use the compact mark. */
|
|
1262
|
+
export const FULL_BANNER_COLUMNS = 88;
|
|
1263
|
+
|
|
1264
|
+
/** Does this terminal have room for the full wordmark? */
|
|
1265
|
+
export function terminalFitsFullMark(columns = process.stdout.columns ?? 80): boolean {
|
|
1266
|
+
return columns >= FULL_BANNER_COLUMNS;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Print the boot banner to stdout, before any full-screen UI starts.
|
|
1271
|
+
*
|
|
1272
|
+
* Deliberately plain \`console.log\`: this runs while the terminal is still a
|
|
1273
|
+
* terminal. Once the dashboard starts, blessed owns the screen and nothing may
|
|
1274
|
+
* write to stdout behind its back.
|
|
1275
|
+
*/
|
|
1276
|
+
export function printBanner(subtitle: string): void {
|
|
1277
|
+
const wide = terminalFitsFullMark();
|
|
1278
|
+
console.log('');
|
|
1279
|
+
if (wide) {
|
|
1280
|
+
for (let i = 0; i < 7; i++) {
|
|
1281
|
+
console.log(' ' + cream(ONE_ROWS[i]) + ' ' + amber(ADDRESS_ROWS[i]));
|
|
1282
|
+
}
|
|
1283
|
+
} else {
|
|
1284
|
+
const INNER = 42;
|
|
1285
|
+
console.log(' ' + faint('\u250C') + dim('\u2500'.repeat(INNER)) + faint('\u2510'));
|
|
1286
|
+
const pad = (s: string, len: number) => s + ' '.repeat(Math.max(0, INNER - 2 - len));
|
|
1287
|
+
const mark = cream('One') + amber('Address');
|
|
1288
|
+
console.log(' ' + dim('\u2502') + ' ' + pad(mark, 10) + dim('\u2502'));
|
|
1289
|
+
console.log(' ' + faint('\u2514') + dim('\u2500'.repeat(INNER)) + faint('\u2518'));
|
|
1290
|
+
}
|
|
1291
|
+
console.log('');
|
|
1292
|
+
console.log(' ' + faint(subtitle));
|
|
1293
|
+
console.log('');
|
|
1294
|
+
}
|
|
1295
|
+
`
|
|
1296
|
+
},
|
|
1297
|
+
{
|
|
1298
|
+
name: "src/vault.ts",
|
|
1299
|
+
content: `/**
|
|
1300
|
+
* At-rest encryption for YOUR customer database.
|
|
1301
|
+
*
|
|
1302
|
+
* ## Two keys, and why they must not be one
|
|
1303
|
+
*
|
|
1304
|
+
* This receiver holds two secrets that do completely different jobs:
|
|
1305
|
+
*
|
|
1306
|
+
* - PARTNER_PRIVATE_KEY_PEM is the ECDH key OneAddress holds the public half
|
|
1307
|
+
* of. It decrypts what ARRIVES. OneAddress chose to send to it.
|
|
1308
|
+
* - the key derived here is yours alone, from a passphrase OneAddress has
|
|
1309
|
+
* never seen and cannot ask you for. It protects what is STORED.
|
|
1310
|
+
*
|
|
1311
|
+
* Using one key for both would mean the credential you share with a counterparty
|
|
1312
|
+
* is also the key to your own customer file. Keep them apart.
|
|
1313
|
+
*
|
|
1314
|
+
* ## Field-level, and what that leaves visible
|
|
1315
|
+
*
|
|
1316
|
+
* Whole-file encryption would need SQLCipher and a native build. This encrypts
|
|
1317
|
+
* the personal columns using Node's built-in crypto, so it installs anywhere
|
|
1318
|
+
* Node runs. Be clear-eyed about what the file still reveals:
|
|
1319
|
+
*
|
|
1320
|
+
* - how many customers there are
|
|
1321
|
+
* - when each record changed, and how often
|
|
1322
|
+
* - a stable per-account index, so rows can be told apart and watched
|
|
1323
|
+
*
|
|
1324
|
+
* What it protects: every name, address, email and phone number. Someone who
|
|
1325
|
+
* copies data.db off this machine gets none of those.
|
|
1326
|
+
*
|
|
1327
|
+
* ## It fails closed, and says why
|
|
1328
|
+
*
|
|
1329
|
+
* A known token is stored encrypted under the derived key, so a wrong passphrase
|
|
1330
|
+
* is caught when the database opens rather than as decryption errors on your
|
|
1331
|
+
* first real dispatch, which reads as data corruption instead of as a typo.
|
|
1332
|
+
*/
|
|
1333
|
+
import {
|
|
1334
|
+
createCipheriv,
|
|
1335
|
+
createDecipheriv,
|
|
1336
|
+
createHmac,
|
|
1337
|
+
randomBytes,
|
|
1338
|
+
scryptSync,
|
|
1339
|
+
timingSafeEqual,
|
|
1340
|
+
} from 'node:crypto';
|
|
1341
|
+
|
|
1342
|
+
/** Envelope version. A format change becomes detectable rather than silent. */
|
|
1343
|
+
const VERSION = 'v1';
|
|
1344
|
+
|
|
1345
|
+
/** scrypt cost. Paid once when the database opens, never per row. */
|
|
1346
|
+
const SCRYPT_N = 32768;
|
|
1347
|
+
const SCRYPT_KEYLEN = 64;
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* scrypt needs 128 * N * r bytes, which at N=32768 and the default r=8 is about
|
|
1351
|
+
* 33.5 MB, just over Node's default 32 MB cap. Raised deliberately rather than
|
|
1352
|
+
* lowering N, because N is the cost and reducing it to fit a default is
|
|
1353
|
+
* weakening the protection on purpose.
|
|
1354
|
+
*/
|
|
1355
|
+
const SCRYPT_MAXMEM = 64 * 1024 * 1024;
|
|
1356
|
+
|
|
1357
|
+
const VERIFIER_PLAINTEXT = 'oneaddress-receiver-at-rest-v1';
|
|
1358
|
+
|
|
1359
|
+
export class WrongPassphraseError extends Error {
|
|
1360
|
+
constructor() {
|
|
1361
|
+
super('That passphrase does not open this database.');
|
|
1362
|
+
this.name = 'WrongPassphraseError';
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
export interface VaultKeys {
|
|
1367
|
+
/** AES-256-GCM key for the personal columns. */
|
|
1368
|
+
cipherKey: Buffer;
|
|
1369
|
+
/** HMAC key for the account blind index. Separate on purpose. */
|
|
1370
|
+
macKey: Buffer;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Derive both keys from one passphrase.
|
|
1375
|
+
*
|
|
1376
|
+
* Two keys, not one used twice: an AES key doubling as an HMAC key is a
|
|
1377
|
+
* cross-protocol mistake, and splitting costs nothing.
|
|
1378
|
+
*/
|
|
1379
|
+
export function deriveKeys(passphrase: string, salt: Buffer): VaultKeys {
|
|
1380
|
+
const full = scryptSync(passphrase.normalize('NFKC'), salt, SCRYPT_KEYLEN, {
|
|
1381
|
+
N: SCRYPT_N,
|
|
1382
|
+
maxmem: SCRYPT_MAXMEM,
|
|
1383
|
+
});
|
|
1384
|
+
return { cipherKey: full.subarray(0, 32), macKey: full.subarray(32, 64) };
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/** A fresh per-database salt. Stored alongside the data; not a secret. */
|
|
1388
|
+
export function newSalt(): Buffer {
|
|
1389
|
+
return randomBytes(16);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Bind a ciphertext to the column it belongs in.
|
|
1394
|
+
*
|
|
1395
|
+
* AES-GCM authenticates bytes and has no opinion about where they were stored,
|
|
1396
|
+
* so without this a postcode ciphertext moved into the street column decrypts
|
|
1397
|
+
* cleanly and the row reads back wrong. With it, the tag check fails.
|
|
1398
|
+
*/
|
|
1399
|
+
function aad(column: string): Buffer {
|
|
1400
|
+
return Buffer.from(\`oa-receiver|\${VERSION}|\${column}\`, 'utf8');
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Encrypt one field.
|
|
1405
|
+
*
|
|
1406
|
+
* An absent value stays absent: encrypting null would give every missing phone
|
|
1407
|
+
* number a distinct-looking ciphertext that then renders as garbage.
|
|
1408
|
+
*/
|
|
1409
|
+
export function encryptField(keys: VaultKeys, column: string, plaintext: string | null): string | null {
|
|
1410
|
+
if (plaintext === null || plaintext === '') return plaintext;
|
|
1411
|
+
const iv = randomBytes(12);
|
|
1412
|
+
const cipher = createCipheriv('aes-256-gcm', keys.cipherKey, iv, { authTagLength: 16 });
|
|
1413
|
+
cipher.setAAD(aad(column));
|
|
1414
|
+
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
1415
|
+
return [
|
|
1416
|
+
VERSION,
|
|
1417
|
+
iv.toString('base64url'),
|
|
1418
|
+
cipher.getAuthTag().toString('base64url'),
|
|
1419
|
+
ct.toString('base64url'),
|
|
1420
|
+
].join('.');
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/** True when a stored value carries this module's envelope. */
|
|
1424
|
+
export function isEncrypted(value: string | null): boolean {
|
|
1425
|
+
return typeof value === 'string' && value.startsWith(\`\${VERSION}.\`) && value.split('.').length === 4;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Decrypt one field.
|
|
1430
|
+
*
|
|
1431
|
+
* A value that is not in the envelope format is returned unchanged, so a
|
|
1432
|
+
* database written before you set a passphrase keeps working while it migrates.
|
|
1433
|
+
*/
|
|
1434
|
+
export function decryptField(keys: VaultKeys, column: string, stored: string | null): string | null {
|
|
1435
|
+
if (stored === null || stored === '') return stored;
|
|
1436
|
+
if (!isEncrypted(stored)) return stored;
|
|
1437
|
+
|
|
1438
|
+
const [, ivB64, tagB64, ctB64] = stored.split('.');
|
|
1439
|
+
const decipher = createDecipheriv('aes-256-gcm', keys.cipherKey, Buffer.from(ivB64, 'base64url'), {
|
|
1440
|
+
authTagLength: 16,
|
|
1441
|
+
});
|
|
1442
|
+
decipher.setAAD(aad(column));
|
|
1443
|
+
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
|
|
1444
|
+
return Buffer.concat([
|
|
1445
|
+
decipher.update(Buffer.from(ctB64, 'base64url')),
|
|
1446
|
+
decipher.final(),
|
|
1447
|
+
]).toString('utf8');
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Deterministic index for the account-number lookup.
|
|
1452
|
+
*
|
|
1453
|
+
* The account number is the one column that must stay searchable by exact
|
|
1454
|
+
* equality, because every dispatch resolves on it. An HMAC under a key an
|
|
1455
|
+
* attacker does not hold keeps that lookup exact while leaving the number
|
|
1456
|
+
* itself unreadable in the file. Lower-cased, matching the case-insensitive
|
|
1457
|
+
* comparison it replaces.
|
|
1458
|
+
*/
|
|
1459
|
+
export function accountIndex(keys: VaultKeys, accountNumber: string): string {
|
|
1460
|
+
return createHmac('sha256', keys.macKey)
|
|
1461
|
+
.update(accountNumber.trim().toLowerCase(), 'utf8')
|
|
1462
|
+
.digest('hex');
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** The token stored when a database is first locked. */
|
|
1466
|
+
export function buildVerifier(keys: VaultKeys): string {
|
|
1467
|
+
return encryptField(keys, 'verifier', VERIFIER_PLAINTEXT)!;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** Check a passphrase against the stored verifier before any row is touched. */
|
|
1471
|
+
export function verifierMatches(keys: VaultKeys, storedVerifier: string): boolean {
|
|
1472
|
+
let decrypted: string | null;
|
|
1473
|
+
try {
|
|
1474
|
+
decrypted = decryptField(keys, 'verifier', storedVerifier);
|
|
1475
|
+
} catch {
|
|
1476
|
+
return false; // A tag failure is exactly what a wrong passphrase looks like.
|
|
1477
|
+
}
|
|
1478
|
+
if (decrypted === null) return false;
|
|
1479
|
+
const a = Buffer.from(decrypted, 'utf8');
|
|
1480
|
+
const b = Buffer.from(VERIFIER_PLAINTEXT, 'utf8');
|
|
1481
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
1482
|
+
}
|
|
1483
|
+
`
|
|
1484
|
+
},
|
|
1485
|
+
{
|
|
1486
|
+
name: "src/report.ts",
|
|
1487
|
+
content: `/**
|
|
1488
|
+
* Where this receiver's narration goes.
|
|
1489
|
+
*
|
|
1490
|
+
* ## Why this exists instead of \`console.log\`
|
|
1491
|
+
*
|
|
1492
|
+
* The dashboard (\`src/tui.ts\`) takes over the terminal. Anything written to
|
|
1493
|
+
* stdout while it is running lands on top of the drawn screen and corrupts it,
|
|
1494
|
+
* so the protocol layer cannot simply print. It reports, and whoever is running
|
|
1495
|
+
* decides where that goes: the dashboard renders it into the event log, and
|
|
1496
|
+
* \`--headless\` writes it to stdout exactly as this receiver always did.
|
|
1497
|
+
*
|
|
1498
|
+
* ## The signature is console's on purpose
|
|
1499
|
+
*
|
|
1500
|
+
* \`info\`, \`warn\` and \`error\` take the same variadic arguments as \`console.log\`,
|
|
1501
|
+
* \`console.warn\` and \`console.error\`, so a message reads identically at the call
|
|
1502
|
+
* site and the default sink can hand them straight through. That is deliberate:
|
|
1503
|
+
* the alternative is rewriting two dozen messages into some structured shape,
|
|
1504
|
+
* which changes what a partner sees in their logs for no benefit and makes the
|
|
1505
|
+
* diff impossible to review.
|
|
1506
|
+
*
|
|
1507
|
+
* ## Default is stdout, so nothing is ever silently swallowed
|
|
1508
|
+
*
|
|
1509
|
+
* With no sink attached this IS \`console\`. A receiver started without the
|
|
1510
|
+
* dashboard, a script that imports the server, a crash before the UI is up:
|
|
1511
|
+
* all of them print. The dashboard attaches a sink and detaches on exit.
|
|
1512
|
+
*/
|
|
1513
|
+
|
|
1514
|
+
export type ReportLevel = 'info' | 'warn' | 'error';
|
|
1515
|
+
|
|
1516
|
+
export interface ReportLine {
|
|
1517
|
+
/** Unix ms, so a sink can render its own timestamp. */
|
|
1518
|
+
at: number;
|
|
1519
|
+
level: ReportLevel;
|
|
1520
|
+
/** The arguments exactly as the call site passed them. */
|
|
1521
|
+
args: unknown[];
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
type Sink = (line: ReportLine) => void;
|
|
1525
|
+
|
|
1526
|
+
/** Write to stdout, matching what this receiver printed before the dashboard existed. */
|
|
1527
|
+
const consoleSink: Sink = ({ level, args }) => {
|
|
1528
|
+
if (level === 'error') console.error(...args);
|
|
1529
|
+
else if (level === 'warn') console.warn(...args);
|
|
1530
|
+
else console.log(...args);
|
|
1531
|
+
};
|
|
1532
|
+
|
|
1533
|
+
let sink: Sink = consoleSink;
|
|
1534
|
+
|
|
1535
|
+
function emit(level: ReportLevel, args: unknown[]): void {
|
|
1536
|
+
// A sink that throws must never take the server down with it. A broken UI is
|
|
1537
|
+
// a broken UI; a dropped dispatch is a customer's address not arriving.
|
|
1538
|
+
try {
|
|
1539
|
+
sink({ at: Date.now(), level, args });
|
|
1540
|
+
} catch {
|
|
1541
|
+
consoleSink({ at: Date.now(), level, args });
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
export const report = {
|
|
1546
|
+
info: (...args: unknown[]): void => emit('info', args),
|
|
1547
|
+
warn: (...args: unknown[]): void => emit('warn', args),
|
|
1548
|
+
error: (...args: unknown[]): void => emit('error', args),
|
|
1549
|
+
|
|
1550
|
+
/** Send lines somewhere else. Returns a function that restores stdout. */
|
|
1551
|
+
attach(next: Sink): () => void {
|
|
1552
|
+
const previous = sink;
|
|
1553
|
+
sink = next;
|
|
1554
|
+
return () => { sink = previous; };
|
|
1555
|
+
},
|
|
1556
|
+
};
|
|
1557
|
+
|
|
1558
|
+
/** Render one reported line the way the plain console would have. */
|
|
1559
|
+
export function formatLine(line: ReportLine): string {
|
|
1560
|
+
return line.args
|
|
1561
|
+
.map((a) => {
|
|
1562
|
+
if (typeof a === 'string') return a;
|
|
1563
|
+
if (a instanceof Error) return a.message;
|
|
1564
|
+
try { return JSON.stringify(a); } catch { return String(a); }
|
|
1565
|
+
})
|
|
1566
|
+
.join(' ');
|
|
1567
|
+
}
|
|
1568
|
+
`
|
|
1569
|
+
},
|
|
1570
|
+
{
|
|
1571
|
+
name: "src/tui.ts",
|
|
1572
|
+
content: `/**
|
|
1573
|
+
* The receiver's terminal dashboard.
|
|
1574
|
+
*
|
|
1575
|
+
* Watching a webhook receiver used to mean tailing a log and hoping. This shows
|
|
1576
|
+
* the three things an operator actually wants while an integration is live:
|
|
1577
|
+
* whether the server is up, whether the customer file is protected, and what
|
|
1578
|
+
* the last dispatch changed, old address and new, side by side.
|
|
1579
|
+
*
|
|
1580
|
+
* ## It is a VIEW, and holds no logic of its own
|
|
1581
|
+
*
|
|
1582
|
+
* Every line it renders arrives through \`report\` (\`src/report.ts\`), which the
|
|
1583
|
+
* protocol layer writes to. The dashboard never parses a webhook, never touches
|
|
1584
|
+
* the database except to read counts, and never decides anything. Pull it out
|
|
1585
|
+
* and the receiver behaves identically with its narration on stdout, which is
|
|
1586
|
+
* exactly what \`--headless\` does.
|
|
1587
|
+
*
|
|
1588
|
+
* ## blessed owns the screen
|
|
1589
|
+
*
|
|
1590
|
+
* Once this starts, NOTHING may write to stdout: a stray \`console.log\` lands on
|
|
1591
|
+
* top of the drawn screen and corrupts it. That is the whole reason \`report\`
|
|
1592
|
+
* exists. On exit the sink is detached before the screen is destroyed, so a
|
|
1593
|
+
* shutdown message still reaches the terminal.
|
|
1594
|
+
*/
|
|
1595
|
+
import blessed from 'blessed';
|
|
1596
|
+
import { HEX, ONE_ROWS, ADDRESS_ROWS, terminalFitsFullMark } from './brand.js';
|
|
1597
|
+
import { formatLine, report, type ReportLine } from './report.js';
|
|
1598
|
+
import { allCustomers, customerCount, storeEncrypted, type StoredCustomer } from './store.js';
|
|
1599
|
+
|
|
1600
|
+
/** blessed takes colours as strings; these mirror the site's palette. */
|
|
1601
|
+
const AMBER = HEX.amber;
|
|
1602
|
+
const CREAM = HEX.cream;
|
|
1603
|
+
const DIM = '#8a7f6a';
|
|
1604
|
+
|
|
1605
|
+
/** Escape blessed's tag syntax so a customer's name can never inject markup. */
|
|
1606
|
+
const esc = (s: unknown): string => String(s ?? '').replace(/[{}]/g, '');
|
|
1607
|
+
|
|
1608
|
+
export interface TuiOptions {
|
|
1609
|
+
partnerName: string;
|
|
1610
|
+
port: number;
|
|
1611
|
+
/** Called when the operator quits, so the caller can close the server. */
|
|
1612
|
+
onQuit: () => void;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/** One address as a single line, the way the change panel shows it. */
|
|
1616
|
+
function oneLine(a: Record<string, unknown>): string {
|
|
1617
|
+
const parts = [a.street, a.suburb, a.state, a.postcode].filter(Boolean).map(String);
|
|
1618
|
+
return parts.length > 0 ? parts.join(', ') : '(empty)';
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void {
|
|
1622
|
+
const screen = blessed.screen({
|
|
1623
|
+
smartCSR: true,
|
|
1624
|
+
title: \`\${partnerName} \u2014 OneAddress receiver\`,
|
|
1625
|
+
fullUnicode: true,
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
// \u2500\u2500 Masthead \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1629
|
+
// The full pixel-art wordmark needs 88 columns. Below that it wraps and shows
|
|
1630
|
+
// the logo half-cut, which looks broken rather than looking large, so a
|
|
1631
|
+
// narrow terminal gets the compact lockup instead. Same rule as the wizard.
|
|
1632
|
+
const wide = terminalFitsFullMark(Number(screen.width));
|
|
1633
|
+
const markHeight = wide ? 9 : 3;
|
|
1634
|
+
|
|
1635
|
+
const mark = blessed.box({
|
|
1636
|
+
parent: screen, top: 0, left: 0, width: '100%', height: markHeight,
|
|
1637
|
+
tags: true, padding: { left: 2 },
|
|
1638
|
+
content: wide
|
|
1639
|
+
? ONE_ROWS.map((row, i) => \`{\${CREAM}-fg}\${row}{/} {\${AMBER}-fg}\${ADDRESS_ROWS[i]}{/}\`).join('\\n')
|
|
1640
|
+
: \`{\${CREAM}-fg}{bold}One{/bold}{/}{\${AMBER}-fg}{bold}Address{/bold}{/}\`,
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
const status = blessed.box({
|
|
1644
|
+
parent: screen, top: markHeight, left: 0, width: '100%', height: 3,
|
|
1645
|
+
tags: true, padding: { left: 2 },
|
|
1646
|
+
border: { type: 'line' } as never,
|
|
1647
|
+
style: { border: { fg: DIM } },
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
// \u2500\u2500 The change panel: what the last dispatch replaced \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1651
|
+
const changeBox = blessed.box({
|
|
1652
|
+
parent: screen, top: markHeight + 3, left: 0, width: '50%', bottom: 3,
|
|
1653
|
+
label: ' LAST CHANGE ', tags: true, padding: { left: 1, right: 1 },
|
|
1654
|
+
border: { type: 'line' } as never,
|
|
1655
|
+
style: { border: { fg: AMBER }, label: { fg: AMBER } },
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
const logBox = blessed.log({
|
|
1659
|
+
parent: screen, top: markHeight + 3, left: '50%', width: '50%', bottom: 3,
|
|
1660
|
+
label: ' ACTIVITY ', tags: true, scrollable: true, alwaysScroll: true,
|
|
1661
|
+
padding: { left: 1, right: 1 },
|
|
1662
|
+
border: { type: 'line' } as never,
|
|
1663
|
+
style: { border: { fg: DIM }, label: { fg: DIM } },
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
const footer = blessed.box({
|
|
1667
|
+
parent: screen, bottom: 0, left: 0, width: '100%', height: 3,
|
|
1668
|
+
tags: true, padding: { left: 2 },
|
|
1669
|
+
border: { type: 'line' } as never,
|
|
1670
|
+
style: { border: { fg: DIM } },
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
// \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1674
|
+
let received = 0;
|
|
1675
|
+
let applied = 0;
|
|
1676
|
+
let failed = 0;
|
|
1677
|
+
let lastChange: { customer: StoredCustomer; previous: Record<string, unknown> } | null = null;
|
|
1678
|
+
let pendingPrevious: Record<string, unknown> = {};
|
|
1679
|
+
|
|
1680
|
+
// The server hands over the address a dispatch REPLACED. Kept as state here
|
|
1681
|
+
// rather than pushed through \`report\`, because the previous address is data
|
|
1682
|
+
// rather than narration and must never end up in a log line.
|
|
1683
|
+
setPrevious = (prev) => { pendingPrevious = prev; };
|
|
1684
|
+
|
|
1685
|
+
function renderStatus(): void {
|
|
1686
|
+
// Both states are shown, and the unprotected one is the loud colour. A
|
|
1687
|
+
// security property mentioned only when it holds is one nobody notices the
|
|
1688
|
+
// absence of.
|
|
1689
|
+
const vault = storeEncrypted
|
|
1690
|
+
? \`{green-fg}{bold}ENCRYPTED{/}\`
|
|
1691
|
+
: \`{red-fg}{bold}UNENCRYPTED{/}\`;
|
|
1692
|
+
status.setContent(
|
|
1693
|
+
\`{\${AMBER}-fg}{bold}\${esc(partnerName)}{/} \` +
|
|
1694
|
+
\`{\${DIM}-fg}listening{/} {\${CREAM}-fg}:\${port}/webhook{/} \` +
|
|
1695
|
+
\`{\${DIM}-fg}customer file{/} \${vault} \` +
|
|
1696
|
+
\`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${customerCount().toLocaleString()}{/}\`,
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function renderChange(): void {
|
|
1701
|
+
if (!lastChange) {
|
|
1702
|
+
changeBox.setContent(
|
|
1703
|
+
\`\\n {\${DIM}-fg}Waiting for a dispatch.{/}\\n\\n\` +
|
|
1704
|
+
\` {\${DIM}-fg}When one arrives, the address it replaced{/}\\n\` +
|
|
1705
|
+
\` {\${DIM}-fg}and the address that replaced it appear here.{/}\`,
|
|
1706
|
+
);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const { customer, previous } = lastChange;
|
|
1710
|
+
let now: Record<string, unknown> = {};
|
|
1711
|
+
try { now = JSON.parse(customer.address) as Record<string, unknown>; } catch { /* keep empty */ }
|
|
1712
|
+
changeBox.setContent(
|
|
1713
|
+
\`\\n {bold}\${esc(customer.name)}{/bold}\\n\` +
|
|
1714
|
+
\` {\${DIM}-fg}account{/} {\${AMBER}-fg}\${esc(customer.account_number)}{/}\\n\\n\` +
|
|
1715
|
+
\` {red-fg}was{/} \${esc(oneLine(previous))}\\n\\n\` +
|
|
1716
|
+
\` {green-fg}now{/} {\${CREAM}-fg}\${esc(oneLine(now))}{/}\\n\`,
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function renderFooter(): void {
|
|
1721
|
+
footer.setContent(
|
|
1722
|
+
\`{\${DIM}-fg}received{/} {bold}\${received}{/bold} \` +
|
|
1723
|
+
\`{green-fg}applied{/} {bold}\${applied}{/bold} \` +
|
|
1724
|
+
\`{red-fg}failed{/} {bold}\${failed}{/bold}\` +
|
|
1725
|
+
\`{|}{\${DIM}-fg}[q] quit{/} \`,
|
|
1726
|
+
);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function redraw(): void {
|
|
1730
|
+
renderStatus();
|
|
1731
|
+
renderChange();
|
|
1732
|
+
renderFooter();
|
|
1733
|
+
screen.render();
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// \u2500\u2500 The feed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1737
|
+
// Counters are derived from the reported lines rather than from a second
|
|
1738
|
+
// channel the server would have to remember to update. One source, so the
|
|
1739
|
+
// footer cannot disagree with the log beside it.
|
|
1740
|
+
const detach = report.attach((line: ReportLine) => {
|
|
1741
|
+
const text = formatLine(line);
|
|
1742
|
+
const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
|
|
1743
|
+
const time = new Date(line.at).toTimeString().slice(0, 8);
|
|
1744
|
+
|
|
1745
|
+
if (/address\\.updated for /.test(text)) received++;
|
|
1746
|
+
if (/REFUSED|decryption failed|Decryption failed/.test(text)) failed++;
|
|
1747
|
+
if (/\\[store\\] saved address for /.test(text)) {
|
|
1748
|
+
applied++;
|
|
1749
|
+
// The store logs the account key and never the address, so the panel is
|
|
1750
|
+
// refreshed from the DATABASE rather than parsed out of the log line.
|
|
1751
|
+
const acct = /saved address for (\\S+)/.exec(text)?.[1];
|
|
1752
|
+
const customer = allCustomers().find((c) => c.account_number === acct);
|
|
1753
|
+
if (customer) lastChange = { customer, previous: pendingPrevious };
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
|
|
1757
|
+
redraw();
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
screen.key(['q', 'C-c'], () => {
|
|
1761
|
+
detach();
|
|
1762
|
+
screen.destroy();
|
|
1763
|
+
onQuit();
|
|
1764
|
+
process.exit(0);
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
redraw();
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Handed the address a dispatch replaced, so the dashboard can show both halves.
|
|
1772
|
+
*
|
|
1773
|
+
* A no-op until the dashboard starts, which is what lets the protocol layer
|
|
1774
|
+
* call it unconditionally without knowing whether a UI exists. That is the same
|
|
1775
|
+
* reason \`report\` has a default sink: \`--headless\` must not need a second code
|
|
1776
|
+
* path through the handler.
|
|
1777
|
+
*/
|
|
1778
|
+
let setPrevious: (prev: Record<string, unknown>) => void = () => {};
|
|
1779
|
+
|
|
1780
|
+
export function notePreviousAddress(prev: Record<string, unknown>): void {
|
|
1781
|
+
setPrevious(prev);
|
|
1782
|
+
}
|
|
1783
|
+
`
|
|
1784
|
+
},
|
|
1785
|
+
{
|
|
1786
|
+
name: "src/index.ts",
|
|
1787
|
+
content: `#!/usr/bin/env node
|
|
1788
|
+
/**
|
|
1789
|
+
* Your OneAddress receiver.
|
|
1790
|
+
*
|
|
1791
|
+
* npm start the terminal dashboard
|
|
1792
|
+
* npm start -- --headless log to stdout, for a service unit
|
|
1793
|
+
*
|
|
1794
|
+
* ## Why this file exists rather than starting the server directly
|
|
1795
|
+
*
|
|
1796
|
+
* Two things have to happen in a strict order, and both are easy to get wrong:
|
|
1797
|
+
*
|
|
1798
|
+
* 1. The at-rest passphrase must be known BEFORE anything opens the database.
|
|
1799
|
+
* \`src/db.ts\` derives its keys when it is first imported, so this prompts
|
|
1800
|
+
* and sets the environment variable, then imports the rest DYNAMICALLY.
|
|
1801
|
+
* A plain top-level import would open the database before the prompt ran.
|
|
1802
|
+
*
|
|
1803
|
+
* 2. The dashboard must own the terminal BEFORE the server narrates anything,
|
|
1804
|
+
* or the first log line lands on top of the drawn screen.
|
|
1805
|
+
*
|
|
1806
|
+
* \`--headless\` skips both: no prompt (a service has nobody to ask), no screen.
|
|
1807
|
+
*/
|
|
1808
|
+
import { printBanner } from './brand.js';
|
|
1809
|
+
import { WrongPassphraseError } from './vault.js';
|
|
1810
|
+
|
|
1811
|
+
const headless =
|
|
1812
|
+
process.argv.includes('--headless') ||
|
|
1813
|
+
process.env.ONEADDRESS_HEADLESS === '1';
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Where the at-rest passphrase comes from.
|
|
1817
|
+
*
|
|
1818
|
+
* \`ONEADDRESS_DB_PASSPHRASE\` first, so a service unit or a repeat run never
|
|
1819
|
+
* stops to ask. Otherwise prompt, but ONLY when a human is actually there: a
|
|
1820
|
+
* headless deployment or a piped stdin has nobody to answer, and blocking on a
|
|
1821
|
+
* prompt nobody can see is the worst of the three outcomes.
|
|
1822
|
+
*
|
|
1823
|
+
* An empty answer is legitimate, not a failure. It runs the database in the
|
|
1824
|
+
* clear, which is what this receiver did before at-rest encryption existed, and
|
|
1825
|
+
* the dashboard shows that state in red rather than letting it pass unnoticed.
|
|
1826
|
+
*/
|
|
1827
|
+
async function resolvePassphrase(): Promise<string | null> {
|
|
1828
|
+
const fromEnv = process.env.ONEADDRESS_DB_PASSPHRASE;
|
|
1829
|
+
if (fromEnv && fromEnv.trim()) return fromEnv;
|
|
1830
|
+
if (headless || !process.stdin.isTTY) return null;
|
|
1831
|
+
|
|
1832
|
+
const { createInterface } = await import('node:readline/promises');
|
|
1833
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1834
|
+
try {
|
|
1835
|
+
process.stdout.write(' Your customer records can be encrypted at rest.\\n');
|
|
1836
|
+
process.stdout.write(' This passphrase is YOURS. It is not the OneAddress private key,\\n');
|
|
1837
|
+
process.stdout.write(' and OneAddress never sees it and cannot recover it.\\n');
|
|
1838
|
+
process.stdout.write(' Leave blank to store records unencrypted.\\n\\n');
|
|
1839
|
+
const answer = (await rl.question(' Passphrase: ')).trim();
|
|
1840
|
+
return answer || null;
|
|
1841
|
+
} finally {
|
|
1842
|
+
rl.close();
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
async function main(): Promise<void> {
|
|
1847
|
+
printBanner(headless ? 'Partner receiver \u2014 headless' : 'Partner receiver');
|
|
1848
|
+
|
|
1849
|
+
const passphrase = await resolvePassphrase();
|
|
1850
|
+
if (passphrase) process.env.ONEADDRESS_DB_PASSPHRASE = passphrase;
|
|
1851
|
+
|
|
1852
|
+
// Dynamic, and this is the whole point of the file: importing the server
|
|
1853
|
+
// pulls in the store, which pulls in the database, which derives its keys on
|
|
1854
|
+
// import. The passphrase has to be set before that chain starts.
|
|
1855
|
+
let config: { partnerName: string; port: number };
|
|
1856
|
+
try {
|
|
1857
|
+
const server = await import('./server.js');
|
|
1858
|
+
config = { partnerName: server.PARTNER_NAME, port: server.PORT };
|
|
1859
|
+
} catch (err) {
|
|
1860
|
+
if (err instanceof WrongPassphraseError) {
|
|
1861
|
+
// Named for what it is. Without this the first symptom is a decryption
|
|
1862
|
+
// error on a live dispatch, which reads as a corrupt database and sends
|
|
1863
|
+
// people to delete a file that is perfectly intact.
|
|
1864
|
+
console.error('\\n That passphrase does not open this database.');
|
|
1865
|
+
console.error(' Try again, or delete data.db to start fresh.\\n');
|
|
1866
|
+
process.exit(1);
|
|
1867
|
+
}
|
|
1868
|
+
throw err;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
if (headless) return; // The server is listening and reporting to stdout.
|
|
1872
|
+
|
|
1873
|
+
const { startDashboard } = await import('./tui.js');
|
|
1874
|
+
startDashboard({
|
|
1875
|
+
partnerName: config.partnerName,
|
|
1876
|
+
port: config.port,
|
|
1877
|
+
onQuit: () => { /* the process exits; the OS closes the socket */ },
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
main().catch((err) => {
|
|
1882
|
+
console.error('[startup]', err instanceof Error ? err.message : err);
|
|
1883
|
+
process.exit(1);
|
|
1884
|
+
});
|
|
1070
1885
|
`
|
|
1071
1886
|
},
|
|
1072
1887
|
{
|
|
@@ -1190,7 +2005,7 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
|
|
|
1190
2005
|
*/
|
|
1191
2006
|
import { readFileSync } from 'node:fs';
|
|
1192
2007
|
import { join } from 'node:path';
|
|
1193
|
-
import db from './db.js';
|
|
2008
|
+
import db, { accountKey, dec, enc, encrypted, once } from './db.js';
|
|
1194
2009
|
|
|
1195
2010
|
export type Address = Record<string, unknown>;
|
|
1196
2011
|
|
|
@@ -1200,7 +2015,14 @@ export type Address = Record<string, unknown>;
|
|
|
1200
2015
|
// change you apply, so you have an audit trail.
|
|
1201
2016
|
db.exec(\`
|
|
1202
2017
|
CREATE TABLE IF NOT EXISTS customers (
|
|
1203
|
-
|
|
2018
|
+
-- How a row is FOUND. A blind index of the account number when the database
|
|
2019
|
+
-- is locked, the lower-cased number when it is not. It is the key rather
|
|
2020
|
+
-- than the number itself because AES-GCM uses a fresh IV per write, so two
|
|
2021
|
+
-- encryptions of one account number differ and a primary key over the
|
|
2022
|
+
-- ciphertext would enforce nothing while looking like it did.
|
|
2023
|
+
account_key TEXT PRIMARY KEY,
|
|
2024
|
+
-- What is DISPLAYED. Ciphertext when locked.
|
|
2025
|
+
account_number TEXT NOT NULL,
|
|
1204
2026
|
name TEXT NOT NULL,
|
|
1205
2027
|
address TEXT NOT NULL DEFAULT '{}',
|
|
1206
2028
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
@@ -1208,10 +2030,14 @@ db.exec(\`
|
|
|
1208
2030
|
|
|
1209
2031
|
CREATE TABLE IF NOT EXISTS address_history (
|
|
1210
2032
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1211
|
-
|
|
2033
|
+
account_key TEXT NOT NULL,
|
|
2034
|
+
-- Both sides of the change, so you can show what an address REPLACED
|
|
2035
|
+
-- rather than only what it became.
|
|
2036
|
+
prev_address TEXT NOT NULL DEFAULT '{}',
|
|
1212
2037
|
address TEXT NOT NULL,
|
|
1213
2038
|
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1214
2039
|
);
|
|
2040
|
+
CREATE INDEX IF NOT EXISTS idx_history_account ON address_history(account_key, id DESC);
|
|
1215
2041
|
\`);
|
|
1216
2042
|
|
|
1217
2043
|
// Self-migrate. An older data.db may already hold a \`customers\` table WITHOUT the
|
|
@@ -1228,6 +2054,36 @@ function ensureColumn(table: string, column: string, definition: string): void {
|
|
|
1228
2054
|
}
|
|
1229
2055
|
ensureColumn('customers', 'address', "TEXT NOT NULL DEFAULT '{}'");
|
|
1230
2056
|
ensureColumn('customers', 'updated_at', 'TEXT'); // nullable on migrate; set on write
|
|
2057
|
+
ensureColumn('customers', 'account_key', 'TEXT');
|
|
2058
|
+
ensureColumn('address_history', 'account_key', 'TEXT');
|
|
2059
|
+
ensureColumn('address_history', 'prev_address', "TEXT NOT NULL DEFAULT '{}'");
|
|
2060
|
+
|
|
2061
|
+
// A database written before at-rest encryption existed holds plaintext rows and
|
|
2062
|
+
// no account_key. Backfill the key and encrypt in place.
|
|
2063
|
+
//
|
|
2064
|
+
// Safe to run on every start: \`once\` encrypts only what is not already
|
|
2065
|
+
// encrypted, and \`accountKey\` is derived from the DECRYPTED number, so a second
|
|
2066
|
+
// pass produces the same key rather than hashing a ciphertext and orphaning the
|
|
2067
|
+
// row. A row orphaned that way still reads fine in a listing and is invisible
|
|
2068
|
+
// to every dispatch, which is the worst kind of broken.
|
|
2069
|
+
{
|
|
2070
|
+
const rows = db.prepare('SELECT rowid AS rid, account_number, name, address FROM customers').all() as Array<
|
|
2071
|
+
{ rid: number; account_number: string; name: string; address: string }
|
|
2072
|
+
>;
|
|
2073
|
+
const relabel = db.prepare(
|
|
2074
|
+
'UPDATE customers SET account_key = ?, account_number = ?, name = ?, address = ? WHERE rowid = ?',
|
|
2075
|
+
);
|
|
2076
|
+
for (const r of rows) {
|
|
2077
|
+
const plainAccount = dec('account_number', r.account_number) ?? r.account_number;
|
|
2078
|
+
relabel.run(
|
|
2079
|
+
accountKey(plainAccount),
|
|
2080
|
+
once('account_number', r.account_number),
|
|
2081
|
+
once('name', r.name),
|
|
2082
|
+
once('address', r.address),
|
|
2083
|
+
r.rid,
|
|
2084
|
+
);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
1231
2087
|
|
|
1232
2088
|
/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1233
2089
|
* YOUR CUSTOMER ROSTER \u2014 data, not code (customers.json)
|
|
@@ -1286,11 +2142,17 @@ function loadRoster(): RosterEntry[] {
|
|
|
1286
2142
|
const ROSTER = loadRoster();
|
|
1287
2143
|
{
|
|
1288
2144
|
const upsert = db.prepare(\`
|
|
1289
|
-
INSERT INTO customers (
|
|
1290
|
-
|
|
2145
|
+
INSERT INTO customers (account_key, account_number, name, address)
|
|
2146
|
+
VALUES ($account_key, $account_number, $name, $address)
|
|
2147
|
+
ON CONFLICT(account_key) DO UPDATE SET name = excluded.name
|
|
1291
2148
|
\`);
|
|
1292
2149
|
for (const c of ROSTER) {
|
|
1293
|
-
upsert.run({
|
|
2150
|
+
upsert.run({
|
|
2151
|
+
account_key: accountKey(c.account_number),
|
|
2152
|
+
account_number: enc('account_number', c.account_number),
|
|
2153
|
+
name: enc('name', c.name),
|
|
2154
|
+
address: enc('address', JSON.stringify(c.address)),
|
|
2155
|
+
});
|
|
1294
2156
|
}
|
|
1295
2157
|
console.log(\`[store] roster ready (\${ROSTER.length} customers)\`);
|
|
1296
2158
|
}
|
|
@@ -1327,18 +2189,57 @@ function canonicalAddress(a: Address): string {
|
|
|
1327
2189
|
function findCustomer(accountNumber: string | undefined, name: string): { account_number: string; name: string; address: string } | undefined {
|
|
1328
2190
|
const acct = (accountNumber ?? '').trim();
|
|
1329
2191
|
if (acct) {
|
|
1330
|
-
const byAcct =
|
|
2192
|
+
const byAcct = decodeRow(
|
|
2193
|
+
db.prepare('SELECT account_number, name, address FROM customers WHERE account_key = ?').get(accountKey(acct)),
|
|
2194
|
+
);
|
|
1331
2195
|
if (byAcct) return byAcct as { account_number: string; name: string; address: string };
|
|
1332
2196
|
}
|
|
1333
2197
|
const n = name.trim().toLowerCase();
|
|
1334
2198
|
if (n) {
|
|
1335
|
-
|
|
2199
|
+
// Scanned and decrypted rather than matched in SQL: LOWER(name) cannot see
|
|
2200
|
+
// inside a ciphertext, so an equality here would match nothing and quietly
|
|
2201
|
+
// return "no such customer" forever. A roster is small; decrypting it is
|
|
2202
|
+
// microseconds.
|
|
2203
|
+
const byName = allCustomers().find((c) => c.name.trim().toLowerCase() === n);
|
|
1336
2204
|
if (byName) return byName as { account_number: string; name: string; address: string };
|
|
1337
2205
|
}
|
|
1338
2206
|
return undefined;
|
|
1339
2207
|
}
|
|
1340
2208
|
|
|
1341
2209
|
// \u2500\u2500 account.verify: pre-payment account check \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2210
|
+
/** One stored row, with the personal columns decrypted. */
|
|
2211
|
+
export interface StoredCustomer {
|
|
2212
|
+
account_number: string;
|
|
2213
|
+
name: string;
|
|
2214
|
+
address: string;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/** Decrypt a row read straight out of SQLite. */
|
|
2218
|
+
function decodeRow(raw: unknown): StoredCustomer | undefined {
|
|
2219
|
+
if (!raw) return undefined;
|
|
2220
|
+
const r = raw as StoredCustomer;
|
|
2221
|
+
return {
|
|
2222
|
+
account_number: dec('account_number', r.account_number)!,
|
|
2223
|
+
name: dec('name', r.name)!,
|
|
2224
|
+
address: dec('address', r.address)!,
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/** Every customer, decrypted. Used where SQL cannot see inside the ciphertext. */
|
|
2229
|
+
export function allCustomers(): StoredCustomer[] {
|
|
2230
|
+
return (db.prepare('SELECT account_number, name, address FROM customers').all() as unknown[])
|
|
2231
|
+
.map((r) => decodeRow(r)!)
|
|
2232
|
+
.filter(Boolean);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
/** How many customers are on file. */
|
|
2236
|
+
export function customerCount(): number {
|
|
2237
|
+
return (db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }).n;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
/** Is the file on disk protected? Surfaced in the dashboard, in both states. */
|
|
2241
|
+
export const storeEncrypted = encrypted;
|
|
2242
|
+
|
|
1342
2243
|
export type AccountVerdict = 'match' | 'no_match' | 'no_account';
|
|
1343
2244
|
|
|
1344
2245
|
/**
|
|
@@ -1352,7 +2253,10 @@ export type AccountVerdict = 'match' | 'no_match' | 'no_account';
|
|
|
1352
2253
|
export function verifyAccount(accountNumber: string | null, name: string, knownNames: string[] = []): AccountVerdict {
|
|
1353
2254
|
const acct = (accountNumber ?? '').trim();
|
|
1354
2255
|
if (!acct) return 'no_account';
|
|
1355
|
-
const
|
|
2256
|
+
const onFile = db.prepare('SELECT name FROM customers WHERE account_key = ?').get(accountKey(acct)) as
|
|
2257
|
+
| { name: string }
|
|
2258
|
+
| undefined;
|
|
2259
|
+
const row = onFile ? { name: dec('name', onFile.name)! } : undefined;
|
|
1356
2260
|
if (!row) return 'no_account';
|
|
1357
2261
|
const stored = row.name.trim().toLowerCase();
|
|
1358
2262
|
const candidates = [name, ...knownNames].map(v => (v ?? '').trim().toLowerCase()).filter(Boolean);
|
|
@@ -1385,31 +2289,60 @@ export async function verifyAddress(customer: Customer, incoming: Address): Prom
|
|
|
1385
2289
|
|
|
1386
2290
|
// \u2500\u2500 address.updated: apply the new address \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1387
2291
|
/**
|
|
1388
|
-
* Applies a new address to the customer's record and logs the change.
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
2292
|
+
* Applies a new address to the customer's record and logs the change.
|
|
2293
|
+
*
|
|
2294
|
+
* Keyed on the account number, falling back to name. The caller enforces the
|
|
2295
|
+
* part that makes that safe: when you verify account references, server.ts
|
|
2296
|
+
* refuses anything that is not a \`match\` before reaching here, so the fallback
|
|
2297
|
+
* is unreachable in that mode and the key is always a real account of yours.
|
|
2298
|
+
*
|
|
2299
|
+
* IF YOU DO NOT VERIFY ACCOUNT REFERENCES the fallback is live and the row is
|
|
2300
|
+
* keyed on the customer's name, which is your identification scheme rather than
|
|
2301
|
+
* ours \u2014 but be aware two customers who share a name share a row. If that is
|
|
2302
|
+
* possible in your data, give this a key of your own instead.
|
|
1391
2303
|
*/
|
|
1392
|
-
export async function saveAddress(customer: Customer, incoming: Address): Promise<
|
|
2304
|
+
export async function saveAddress(customer: Customer, incoming: Address): Promise<Address> {
|
|
1393
2305
|
const acct = (customer.accountNumber ?? '').trim() || customer.name.trim();
|
|
1394
2306
|
const addressJson = JSON.stringify(incoming);
|
|
2307
|
+
const key = accountKey(acct);
|
|
2308
|
+
|
|
2309
|
+
// Read what we are about to replace. After the UPDATE nothing can reconstruct
|
|
2310
|
+
// it, and "what did this address replace" is the question an operator asks
|
|
2311
|
+
// first when a change looks wrong.
|
|
2312
|
+
const existing = decodeRow(
|
|
2313
|
+
db.prepare('SELECT account_number, name, address FROM customers WHERE account_key = ?').get(key),
|
|
2314
|
+
);
|
|
2315
|
+
const previous: Address = existing ? (JSON.parse(existing.address) as Address) : {};
|
|
1395
2316
|
|
|
1396
2317
|
db.prepare(\`
|
|
1397
|
-
INSERT INTO customers (account_number, name, address, updated_at)
|
|
1398
|
-
VALUES ($account_number, $name, $address, datetime('now'))
|
|
1399
|
-
ON CONFLICT(
|
|
2318
|
+
INSERT INTO customers (account_key, account_number, name, address, updated_at)
|
|
2319
|
+
VALUES ($account_key, $account_number, $name, $address, datetime('now'))
|
|
2320
|
+
ON CONFLICT(account_key) DO UPDATE SET
|
|
1400
2321
|
name = excluded.name,
|
|
1401
2322
|
address = excluded.address,
|
|
1402
2323
|
updated_at = excluded.updated_at
|
|
1403
|
-
\`).run({
|
|
2324
|
+
\`).run({
|
|
2325
|
+
account_key: key,
|
|
2326
|
+
account_number: enc('account_number', acct),
|
|
2327
|
+
name: enc('name', customer.name),
|
|
2328
|
+
address: enc('address', addressJson),
|
|
2329
|
+
});
|
|
1404
2330
|
|
|
1405
2331
|
db.prepare(\`
|
|
1406
|
-
INSERT INTO address_history (
|
|
1407
|
-
|
|
2332
|
+
INSERT INTO address_history (account_key, prev_address, address)
|
|
2333
|
+
VALUES ($account_key, $prev_address, $address)
|
|
2334
|
+
\`).run({
|
|
2335
|
+
account_key: key,
|
|
2336
|
+
prev_address: enc('address', JSON.stringify(previous)),
|
|
2337
|
+
address: enc('address', addressJson),
|
|
2338
|
+
});
|
|
1408
2339
|
|
|
1409
|
-
// Metadata only \u2014 the address itself is
|
|
1410
|
-
// address. Centralised log aggregation turns every
|
|
1411
|
-
//
|
|
2340
|
+
// Metadata only \u2014 the address itself is personal information, so the key is
|
|
2341
|
+
// logged and the address never is. Centralised log aggregation turns every
|
|
2342
|
+
// log line into a place customer addresses can be read.
|
|
1412
2343
|
console.log(\`[store] saved address for \${acct}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
|
|
2344
|
+
|
|
2345
|
+
return previous;
|
|
1413
2346
|
}
|
|
1414
2347
|
`
|
|
1415
2348
|
},
|
|
@@ -1430,12 +2363,13 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
|
|
|
1430
2363
|
// Fail fast with a clear message rather than a cryptic ERR_UNSUPPORTED_FEATURE later.
|
|
1431
2364
|
const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number);
|
|
1432
2365
|
if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 5)) {
|
|
1433
|
-
|
|
1434
|
-
|
|
2366
|
+
report.error(\`[startup] Node.js 22.5+ is required (you are running \${process.version}).\`);
|
|
2367
|
+
report.error('[startup] Upgrade Node.js: https://nodejs.org/en/download');
|
|
1435
2368
|
process.exit(1);
|
|
1436
2369
|
}
|
|
1437
2370
|
|
|
1438
2371
|
import 'dotenv/config';
|
|
2372
|
+
import { report } from './report.js';
|
|
1439
2373
|
import express, { Request, Response } from 'express';
|
|
1440
2374
|
import rateLimit from 'express-rate-limit';
|
|
1441
2375
|
import { createPrivateKey, createHmac } from 'node:crypto';
|
|
@@ -1450,6 +2384,7 @@ import {
|
|
|
1450
2384
|
type OneAddressD5LOA,
|
|
1451
2385
|
} from '@oneaddress/partner-sdk';
|
|
1452
2386
|
import { saveAddress, verifyAddress, verifyAccount } from './store.js';
|
|
2387
|
+
import { notePreviousAddress } from './tui.js';
|
|
1453
2388
|
import { config } from './config.js';
|
|
1454
2389
|
import { safeOneAddressCallbackUrl } from './callback-url.js';
|
|
1455
2390
|
|
|
@@ -1472,7 +2407,7 @@ const ONEADDRESS_API = config.oneAddressApi;
|
|
|
1472
2407
|
const CONFIRM_SECRET = process.env.CONFIRM_SECRET || WEBHOOK_SECRET;
|
|
1473
2408
|
|
|
1474
2409
|
if (!WEBHOOK_SECRET || !PARTNER_PRIVATE_KEY || !PARTNER_ID) {
|
|
1475
|
-
|
|
2410
|
+
report.error('[startup] Missing required env vars. Check your .env file.');
|
|
1476
2411
|
process.exit(1);
|
|
1477
2412
|
}
|
|
1478
2413
|
|
|
@@ -1485,8 +2420,8 @@ if (!WEBHOOK_SECRET || !PARTNER_PRIVATE_KEY || !PARTNER_ID) {
|
|
|
1485
2420
|
// fails only when the first payload arrives.
|
|
1486
2421
|
// 2. A private key that does not parse (empty, truncated, or header stripped).
|
|
1487
2422
|
if (typeof decryptSession !== 'function') {
|
|
1488
|
-
|
|
1489
|
-
|
|
2423
|
+
report.error('[startup] @oneaddress/partner-sdk does not export decryptSession. Your installed SDK is too old for D5 dispatches.');
|
|
2424
|
+
report.error('[startup] Fix: npm install "@oneaddress/partner-sdk@^1.8.0", then restart.');
|
|
1490
2425
|
process.exit(1);
|
|
1491
2426
|
}
|
|
1492
2427
|
// Only PEM keys are parseable this way; a post-quantum key is a base64 secret,
|
|
@@ -1495,8 +2430,8 @@ if (PARTNER_PRIVATE_KEY.includes('BEGIN')) {
|
|
|
1495
2430
|
try {
|
|
1496
2431
|
createPrivateKey(PARTNER_PRIVATE_KEY);
|
|
1497
2432
|
} catch {
|
|
1498
|
-
|
|
1499
|
-
|
|
2433
|
+
report.error('[startup] PARTNER_PRIVATE_KEY_PEM does not parse as a private key.');
|
|
2434
|
+
report.error('[startup] Paste the FULL PEM, including the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines.');
|
|
1500
2435
|
process.exit(1);
|
|
1501
2436
|
}
|
|
1502
2437
|
}
|
|
@@ -1560,22 +2495,30 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
|
|
|
1560
2495
|
body: bodyStr,
|
|
1561
2496
|
});
|
|
1562
2497
|
if (confirmRes.ok) {
|
|
1563
|
-
|
|
2498
|
+
report.info(\`[confirm] dispatch \${dispatchId} \u2192 \${status}: acknowledged by OneAddress\`);
|
|
1564
2499
|
} else {
|
|
1565
2500
|
const detail = await confirmRes.text().catch(() => '');
|
|
1566
|
-
|
|
2501
|
+
report.error(\`[confirm] dispatch \${dispatchId} confirm FAILED \u2014 HTTP \${confirmRes.status} \${detail}\`);
|
|
1567
2502
|
if (confirmRes.status === 401) {
|
|
1568
|
-
|
|
2503
|
+
report.error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET in .env to it (from the portal Webhook screen); otherwise your webhook signing secret should work.');
|
|
1569
2504
|
}
|
|
1570
2505
|
}
|
|
1571
2506
|
} catch (err) {
|
|
1572
|
-
|
|
2507
|
+
report.error('[confirm] confirm request error:', err);
|
|
1573
2508
|
}
|
|
1574
2509
|
}
|
|
1575
2510
|
|
|
1576
2511
|
const app = express();
|
|
1577
2512
|
app.disable('x-powered-by'); // don't fingerprint the framework
|
|
1578
2513
|
|
|
2514
|
+
// Trust the reverse proxy / tunnel in front of this server (Cloudflare Tunnel,
|
|
2515
|
+
// nginx, a load balancer). It sets X-Forwarded-For, and without this the rate
|
|
2516
|
+
// limiter below cannot read the real client IP: express-rate-limit throws
|
|
2517
|
+
// ERR_ERL_UNEXPECTED_X_FORWARDED_FOR on the first proxied request. '1' trusts a
|
|
2518
|
+
// single hop, which is the usual setup; raise it if you run more proxies in
|
|
2519
|
+
// front, or set specific proxy addresses for stricter handling.
|
|
2520
|
+
app.set('trust proxy', 1);
|
|
2521
|
+
|
|
1579
2522
|
// DoS backstop on the public webhook. The endpoint already rejects anything
|
|
1580
2523
|
// without a valid HMAC (401), but a signature check still costs CPU, so a flood
|
|
1581
2524
|
// of junk requests is worth bounding. The ceiling is deliberately GENEROUS \u2014
|
|
@@ -1590,9 +2533,13 @@ const webhookLimiter = rateLimit({
|
|
|
1590
2533
|
legacyHeaders: false,
|
|
1591
2534
|
message: { error: 'Too many requests' },
|
|
1592
2535
|
});
|
|
2536
|
+
// The limiter is attached ONCE, here on the route's middleware chain (the form
|
|
2537
|
+
// CodeQL's missing-rate-limiting query recognises). Do NOT also pass it to
|
|
2538
|
+
// app.post below: two references to the same limiter instance count every
|
|
2539
|
+
// request twice and silently halve the ceiling.
|
|
1593
2540
|
app.use('/webhook', webhookLimiter, express.text({ type: 'application/json', limit: '1mb' }));
|
|
1594
2541
|
|
|
1595
|
-
app.post('/webhook',
|
|
2542
|
+
app.post('/webhook', async (req: Request, res: Response) => {
|
|
1596
2543
|
const rawBody = req.body as string;
|
|
1597
2544
|
const signature = req.headers['x-oneaddress-signature'] as string ?? '';
|
|
1598
2545
|
const timestamp = req.headers['x-oneaddress-timestamp'] as string ?? '';
|
|
@@ -1641,7 +2588,7 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
|
1641
2588
|
// checked" rather than a match/no_match you don't actually compute \u2014 this is
|
|
1642
2589
|
// the setup declaration reaching the running handler.
|
|
1643
2590
|
if (!config.verifiesAccountReference) {
|
|
1644
|
-
|
|
2591
|
+
report.info('[webhook] account.verify \u2192 skipped (verifiesAccountReference is false in oneaddress.config.json)');
|
|
1645
2592
|
return res.status(200).json({ ok: true, skipped: true });
|
|
1646
2593
|
}
|
|
1647
2594
|
|
|
@@ -1654,7 +2601,7 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
|
1654
2601
|
try {
|
|
1655
2602
|
cust = await decryptAddress(enc, PARTNER_PRIVATE_KEY, PARTNER_ID);
|
|
1656
2603
|
} catch (err) {
|
|
1657
|
-
|
|
2604
|
+
report.error('[webhook] account.verify decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM:', err);
|
|
1658
2605
|
return res.status(422).json({ ok: false, error: 'decryption_failed' });
|
|
1659
2606
|
}
|
|
1660
2607
|
|
|
@@ -1663,7 +2610,7 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
|
1663
2610
|
const knownNames = Array.isArray(cust.known_names) ? cust.known_names.map(String) : [];
|
|
1664
2611
|
|
|
1665
2612
|
const status = verifyAccount(accountNumber, name, knownNames);
|
|
1666
|
-
|
|
2613
|
+
report.info(\`[webhook] account.verify \u2192 \${status} for account \${accountNumber ?? '(none)'} (\${name})\`);
|
|
1667
2614
|
return res.status(200).json({ status });
|
|
1668
2615
|
}
|
|
1669
2616
|
|
|
@@ -1675,7 +2622,7 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
|
1675
2622
|
// the 422 below, because it IS a dispatch event.
|
|
1676
2623
|
const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
|
|
1677
2624
|
if (!DISPATCH_EVENTS.includes(event)) {
|
|
1678
|
-
|
|
2625
|
+
report.info(\`[webhook] "\${event}" acknowledged (no address payload to decrypt)\`);
|
|
1679
2626
|
return res.status(200).json({ ok: true, skipped: true });
|
|
1680
2627
|
}
|
|
1681
2628
|
|
|
@@ -1714,7 +2661,7 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
|
1714
2661
|
decAccount = typeof data.account_number === 'string' ? data.account_number : '';
|
|
1715
2662
|
decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
|
|
1716
2663
|
} catch (err) {
|
|
1717
|
-
|
|
2664
|
+
report.error('[webhook] D5 decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM matches key_id', sessionKeyShare.key_id, ':', err);
|
|
1718
2665
|
return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
|
|
1719
2666
|
}
|
|
1720
2667
|
} else if (legacyPayload) {
|
|
@@ -1724,7 +2671,7 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
|
1724
2671
|
decAccount = typeof address.accountReference === 'string' ? address.accountReference : '';
|
|
1725
2672
|
decKnownNames = Array.isArray(address.knownNames) ? address.knownNames as string[] : [];
|
|
1726
2673
|
} catch (err) {
|
|
1727
|
-
|
|
2674
|
+
report.error('[webhook] Decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM in .env:', err);
|
|
1728
2675
|
return res.status(422).json({ ok: false, error: 'Decryption failed \u2014 partner key mismatch' });
|
|
1729
2676
|
}
|
|
1730
2677
|
} else {
|
|
@@ -1746,7 +2693,7 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
|
1746
2693
|
const loa: OneAddressD5LOA = await decryptLoaEncrypted(loaEncrypted, PARTNER_PRIVATE_KEY, PARTNER_ID);
|
|
1747
2694
|
loaRef = d5LoaRef(loa);
|
|
1748
2695
|
} catch (err) {
|
|
1749
|
-
|
|
2696
|
+
report.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
|
|
1750
2697
|
}
|
|
1751
2698
|
}
|
|
1752
2699
|
|
|
@@ -1764,8 +2711,48 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
|
1764
2711
|
|
|
1765
2712
|
// \u2500\u2500 address.updated: consumer changed their address \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1766
2713
|
if (event === 'address.updated') {
|
|
1767
|
-
|
|
1768
|
-
|
|
2714
|
+
report.info(\`[webhook] address.updated for \${ctx.accountNumber || ctx.name}\`);
|
|
2715
|
+
|
|
2716
|
+
// AN ACCOUNT REFERENCE THAT MATCHES NOTHING MUST BE A HARD NO-MATCH.
|
|
2717
|
+
//
|
|
2718
|
+
// This runs BEFORE saveAddress, and it is the whole reason saveAddress can
|
|
2719
|
+
// be trusted. Until 13 Sep 2026 this handler applied every dispatch it
|
|
2720
|
+
// could decrypt, with no check that the account was one of yours: an
|
|
2721
|
+
// account number matching nobody created a BRAND NEW customer row, and a
|
|
2722
|
+
// dispatch with no account number at all was keyed on the customer's NAME,
|
|
2723
|
+
// so two customers who share a name collapse onto one record and the
|
|
2724
|
+
// second one's address overwrites the first's.
|
|
2725
|
+
//
|
|
2726
|
+
// Do not "simplify" this by falling back to a name match when the account
|
|
2727
|
+
// number misses. A name matching a DIFFERENT customer's record is not
|
|
2728
|
+
// evidence the two are the same person; it is the most likely way to write
|
|
2729
|
+
// one customer's address onto another customer's account.
|
|
2730
|
+
//
|
|
2731
|
+
// Gated on your own portal declaration, exactly like account.verify above:
|
|
2732
|
+
// if you told the portal you do not verify account references, you identify
|
|
2733
|
+
// customers some other way and this check cannot speak for you.
|
|
2734
|
+
//
|
|
2735
|
+
// Refusing is reported two ways, and both matter. The \`ok: false\` tells
|
|
2736
|
+
// OneAddress this delivery failed (a 200 carrying ok:true would be read as
|
|
2737
|
+
// success and the consumer would be told their address had landed); the
|
|
2738
|
+
// \`failed\` confirm callback puts the same answer on the record they see.
|
|
2739
|
+
// OneAddress conformance check "Refuses an account reference that matches
|
|
2740
|
+
// no record" tests exactly this.
|
|
2741
|
+
if (config.verifiesAccountReference) {
|
|
2742
|
+
const verdict = verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
|
|
2743
|
+
if (verdict !== 'match') {
|
|
2744
|
+
report.warn(\`[webhook] address.updated REFUSED (\${verdict}) for account \${ctx.accountNumber ?? '(none)'} \u2014 nothing applied\`);
|
|
2745
|
+
void confirmToOneAddress(dispatch, 'failed');
|
|
2746
|
+
return res.status(200).json({ ok: false, error: 'account_not_matched', verdict });
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// \`saveAddress\` returns the address it replaced. Handed to the dashboard so
|
|
2751
|
+
// it can show both halves; a no-op under --headless. Passed directly rather
|
|
2752
|
+
// than reported, because the previous address is a customer's address and
|
|
2753
|
+
// must never reach a log line.
|
|
2754
|
+
const replaced = await saveAddress(ctx, address);
|
|
2755
|
+
notePreviousAddress(replaced);
|
|
1769
2756
|
if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
|
|
1770
2757
|
// Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
1771
2758
|
// Fire-and-forget: it must not delay this 200 (which acks the delivery).
|
|
@@ -1787,13 +2774,13 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
|
1787
2774
|
// network position into an SSRF primitive.
|
|
1788
2775
|
const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
|
|
1789
2776
|
if (!safeCallbackUrl) {
|
|
1790
|
-
|
|
2777
|
+
report.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
|
|
1791
2778
|
return res.status(400).json({ error: 'Invalid callback_url host' });
|
|
1792
2779
|
}
|
|
1793
2780
|
|
|
1794
|
-
|
|
2781
|
+
report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
|
|
1795
2782
|
const result = await verifyAddress(ctx, address);
|
|
1796
|
-
|
|
2783
|
+
report.info(\`[webhook] address.verify \u2192 \${result}\`);
|
|
1797
2784
|
|
|
1798
2785
|
await fetch(safeCallbackUrl, {
|
|
1799
2786
|
method: 'POST',
|
|
@@ -1829,21 +2816,26 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
|
1829
2816
|
const a = address as { street?: unknown; suburb?: unknown; state?: unknown; postcode?: unknown };
|
|
1830
2817
|
const oneLine = [a.street, [a.suburb, a.state, a.postcode].filter(Boolean).join(' ')]
|
|
1831
2818
|
.filter(Boolean).join(', ');
|
|
1832
|
-
|
|
1833
|
-
|
|
2819
|
+
report.info(\`[webhook] \${event} verification probe decrypted OK\`);
|
|
2820
|
+
report.info(\`[verification] \${index ?? '?'} | \${ctx.name} | \${oneLine}\`);
|
|
1834
2821
|
return res.status(200).json({ verification: true, index });
|
|
1835
2822
|
}
|
|
1836
2823
|
|
|
1837
2824
|
// Unknown event \u2014 acknowledge (forward compatibility)
|
|
1838
|
-
|
|
2825
|
+
report.info(\`[webhook] Unknown event "\${event}" \u2014 acknowledged\`);
|
|
1839
2826
|
return res.status(200).json({ ok: true, skipped: true });
|
|
1840
2827
|
});
|
|
1841
2828
|
|
|
1842
2829
|
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
|
1843
2830
|
|
|
1844
2831
|
app.listen(PORT, () =>
|
|
1845
|
-
|
|
2832
|
+
report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`),
|
|
1846
2833
|
);
|
|
2834
|
+
|
|
2835
|
+
// Read by src/index.ts to label the dashboard. Exported rather than re-derived
|
|
2836
|
+
// there, so the port the UI claims is the port the server actually bound.
|
|
2837
|
+
export { PORT };
|
|
2838
|
+
export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
|
|
1847
2839
|
`
|
|
1848
2840
|
},
|
|
1849
2841
|
{
|
|
@@ -2051,6 +3043,20 @@ the \`ONEADDRESS_CUSTOMERS\` env var, or point \`loadRoster\` at your real datab
|
|
|
2051
3043
|
content: `OA_PARTNER_ID=%%PARTNER_ID%%
|
|
2052
3044
|
OA_WEBHOOK_SECRET=%%WEBHOOK_SECRET%%
|
|
2053
3045
|
OA_PRIVATE_KEY_PEM="%%PRIVATE_KEY%%"
|
|
3046
|
+
|
|
3047
|
+
# Where the confirm callback is POSTed after you apply an address update. This is
|
|
3048
|
+
# the CUSTOMER app (oneaddress.io), NOT the partner portal \u2014 /api/confirm lives
|
|
3049
|
+
# on the former. Written by the setup wizard.
|
|
3050
|
+
ONEADDRESS_API=%%ONEADDRESS_API%%
|
|
3051
|
+
|
|
3052
|
+
# Secret that signs the /api/confirm callback. Leave BLANK to reuse
|
|
3053
|
+
# OA_WEBHOOK_SECRET (correct for most partners); set it only if your partner has
|
|
3054
|
+
# a separate confirm secret in the portal Webhook screen.
|
|
3055
|
+
CONFIRM_SECRET=
|
|
3056
|
+
|
|
3057
|
+
# Whether this receiver answers the pre-payment account.verify check. The wizard
|
|
3058
|
+
# writes your portal declaration here; set "true"/"false" to override.
|
|
3059
|
+
VERIFIES_ACCOUNT_REFERENCE=%%VERIFIES_ACCOUNT_REFERENCE%%
|
|
2054
3060
|
`
|
|
2055
3061
|
},
|
|
2056
3062
|
{
|
|
@@ -2079,6 +3085,7 @@ Quick start:
|
|
|
2079
3085
|
uvicorn app:app --port 3001
|
|
2080
3086
|
"""
|
|
2081
3087
|
|
|
3088
|
+
import asyncio
|
|
2082
3089
|
import base64
|
|
2083
3090
|
import hashlib
|
|
2084
3091
|
import hmac as hmac_lib
|
|
@@ -2111,77 +3118,164 @@ WEBHOOK_SECRET = os.environ["OA_WEBHOOK_SECRET"]
|
|
|
2111
3118
|
PRIVATE_KEY = os.environ["OA_PRIVATE_KEY_PEM"].replace("\\\\n", "\\n")
|
|
2112
3119
|
DB_PATH = os.environ.get("DB_PATH", str(Path.cwd() / "data.db"))
|
|
2113
3120
|
|
|
3121
|
+
# Confirm-callback config. ONEADDRESS_API is the CUSTOMER app (/api/confirm lives
|
|
3122
|
+
# there, NOT the partner portal); CONFIRM_SECRET signs the callback and falls
|
|
3123
|
+
# back to the webhook secret, which is correct for most partners.
|
|
3124
|
+
ONEADDRESS_API = (os.environ.get("ONEADDRESS_API") or "https://oneaddress.io").rstrip("/")
|
|
3125
|
+
CONFIRM_SECRET = os.environ.get("CONFIRM_SECRET") or WEBHOOK_SECRET
|
|
3126
|
+
|
|
3127
|
+
# Whether this receiver answers the pre-payment account.verify check. The setup
|
|
3128
|
+
# wizard bakes your portal declaration into the default below (the %% token
|
|
3129
|
+
# renders to the string "true"/"false"); the env var overrides it at runtime.
|
|
3130
|
+
_DEFAULT_VERIFIES_ACCOUNT_REFERENCE = ("%%VERIFIES_ACCOUNT_REFERENCE%%" == "true")
|
|
3131
|
+
VERIFIES_ACCOUNT_REFERENCE = (
|
|
3132
|
+
os.environ["VERIFIES_ACCOUNT_REFERENCE"].strip().lower() == "true"
|
|
3133
|
+
if os.environ.get("VERIFIES_ACCOUNT_REFERENCE")
|
|
3134
|
+
else _DEFAULT_VERIFIES_ACCOUNT_REFERENCE
|
|
3135
|
+
)
|
|
3136
|
+
|
|
2114
3137
|
app = FastAPI()
|
|
2115
3138
|
seen_dispatches: set[str] = set()
|
|
3139
|
+
# Keeps a reference to fire-and-forget confirm tasks so they aren't garbage-
|
|
3140
|
+
# collected before they run (asyncio only holds a weak reference to a task).
|
|
3141
|
+
_background_tasks: set[asyncio.Task[Any]] = set()
|
|
2116
3142
|
|
|
2117
3143
|
# \u2500\u2500 SQLite store \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2118
3144
|
#
|
|
2119
|
-
#
|
|
2120
|
-
#
|
|
2121
|
-
#
|
|
3145
|
+
# ROSTER-BASED, mirroring the Go / Java / TypeScript scaffolds. The handler calls
|
|
3146
|
+
# _verify_account (pre-payment account check), _verify_address (is your on-file
|
|
3147
|
+
# address current?) and _save_address (apply an update) with the identity it
|
|
3148
|
+
# DECRYPTED from the payload \u2014 under D5 there is NO cleartext customer block on
|
|
3149
|
+
# the wire, so we key on the decrypted account number / verified name, never a
|
|
3150
|
+
# cleartext email.
|
|
3151
|
+
#
|
|
3152
|
+
# Two tables:
|
|
2122
3153
|
#
|
|
2123
|
-
#
|
|
3154
|
+
# customers \u2014 your roster: account number, name, and the address you
|
|
3155
|
+
# hold on file today. Replace with YOUR customer table.
|
|
2124
3156
|
# address_history \u2014 append-only audit trail of every address.updated event
|
|
2125
3157
|
#
|
|
2126
|
-
# Replace these with your real database (Postgres, MySQL, internal API)
|
|
2127
|
-
#
|
|
2128
|
-
# and _verify_address() \u2014 point those at your DB and the rest stays the same.
|
|
3158
|
+
# Replace these with your real database (Postgres, MySQL, internal API) when
|
|
3159
|
+
# ready to ship. Uses Python's stdlib sqlite3 \u2014 no extra package to install.
|
|
2129
3160
|
|
|
2130
3161
|
_db = sqlite3.connect(DB_PATH, check_same_thread=False, isolation_level=None)
|
|
2131
3162
|
_db.execute("PRAGMA journal_mode = WAL")
|
|
2132
3163
|
_db.execute("""
|
|
2133
|
-
CREATE TABLE IF NOT EXISTS
|
|
2134
|
-
|
|
2135
|
-
name
|
|
2136
|
-
address
|
|
2137
|
-
|
|
2138
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
3164
|
+
CREATE TABLE IF NOT EXISTS customers (
|
|
3165
|
+
account_number TEXT PRIMARY KEY,
|
|
3166
|
+
name TEXT NOT NULL DEFAULT '',
|
|
3167
|
+
address TEXT NOT NULL DEFAULT '{}',
|
|
3168
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2139
3169
|
)
|
|
2140
3170
|
""")
|
|
2141
3171
|
_db.execute("""
|
|
2142
3172
|
CREATE TABLE IF NOT EXISTS address_history (
|
|
2143
|
-
id
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
3173
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3174
|
+
account_number TEXT NOT NULL,
|
|
3175
|
+
address TEXT NOT NULL DEFAULT '{}',
|
|
3176
|
+
dispatch_id TEXT,
|
|
3177
|
+
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2149
3178
|
)
|
|
2150
3179
|
""")
|
|
2151
3180
|
print(f"[db] SQLite database ready -> {DB_PATH}")
|
|
2152
3181
|
|
|
2153
|
-
|
|
2154
|
-
|
|
3182
|
+
# Seed a demo roster the first time, so a first update from OneAddress shows as a
|
|
3183
|
+
# real change (mismatch -> update -> match) rather than magically already
|
|
3184
|
+
# matching. Edit this to your customers, or point _find_customer at your real DB.
|
|
3185
|
+
_DEFAULT_ROSTER = [
|
|
3186
|
+
{"account_number": "DEMO-0001", "name": "Test Customer",
|
|
3187
|
+
"address": {"street": "1 Example Street", "suburb": "Sydney", "state": "NSW", "postcode": "2000"}},
|
|
3188
|
+
]
|
|
3189
|
+
if _db.execute("SELECT COUNT(*) FROM customers").fetchone()[0] == 0:
|
|
3190
|
+
for _c in _DEFAULT_ROSTER:
|
|
3191
|
+
_db.execute(
|
|
3192
|
+
"INSERT INTO customers (account_number, name, address) VALUES (?, ?, ?)",
|
|
3193
|
+
(_c["account_number"], _c["name"], json.dumps(_c["address"], sort_keys=True)))
|
|
3194
|
+
print(f"[store] seeded {len(_DEFAULT_ROSTER)} demo customer(s)")
|
|
3195
|
+
|
|
3196
|
+
def _canonical_address(a: dict[str, Any]) -> str:
|
|
3197
|
+
"""Order- and case-insensitive canonical form, so two addresses compare equal
|
|
3198
|
+
iff they mean the same thing regardless of key order or casing."""
|
|
3199
|
+
items = sorted(
|
|
3200
|
+
(str(k).lower(), " ".join(str(v).strip().lower().split()))
|
|
3201
|
+
for k, v in a.items()
|
|
3202
|
+
if isinstance(v, str) and v.strip() != ""
|
|
3203
|
+
)
|
|
3204
|
+
return json.dumps(items)
|
|
3205
|
+
|
|
3206
|
+
def _find_customer(account_number: str, name: str):
|
|
3207
|
+
"""Match on account number first (authoritative), then name. Returns the row
|
|
3208
|
+
(account_number, name, address) or None."""
|
|
3209
|
+
acct = (account_number or "").strip()
|
|
3210
|
+
if acct:
|
|
3211
|
+
row = _db.execute(
|
|
3212
|
+
"SELECT account_number, name, address FROM customers WHERE account_number = ?", (acct,)
|
|
3213
|
+
).fetchone()
|
|
3214
|
+
if row:
|
|
3215
|
+
return row
|
|
3216
|
+
n = (name or "").strip().lower()
|
|
3217
|
+
if n:
|
|
3218
|
+
row = _db.execute(
|
|
3219
|
+
"SELECT account_number, name, address FROM customers WHERE LOWER(name) = ?", (n,)
|
|
3220
|
+
).fetchone()
|
|
3221
|
+
if row:
|
|
3222
|
+
return row
|
|
3223
|
+
return None
|
|
3224
|
+
|
|
3225
|
+
def _verify_account(account_number: str, name: str, known_names: list[str]) -> str:
|
|
3226
|
+
"""Pre-payment account check behind account.verify.
|
|
3227
|
+
'match' account number found and the name (or a known name) agrees
|
|
3228
|
+
'no_match' account number found but the name does not agree
|
|
3229
|
+
'no_account' no such account number
|
|
3230
|
+
"""
|
|
3231
|
+
acct = (account_number or "").strip()
|
|
3232
|
+
if not acct:
|
|
3233
|
+
return "no_account"
|
|
3234
|
+
row = _db.execute("SELECT name FROM customers WHERE account_number = ?", (acct,)).fetchone()
|
|
3235
|
+
if not row:
|
|
3236
|
+
return "no_account"
|
|
3237
|
+
stored = (row[0] or "").strip().lower()
|
|
3238
|
+
candidates = [c for c in ((v or "").strip().lower() for v in [name, *known_names]) if c]
|
|
3239
|
+
return "match" if stored in candidates else "no_match"
|
|
3240
|
+
|
|
3241
|
+
def _save_address(account_number: str, name: str, address: dict[str, Any], dispatch_id: str) -> None:
|
|
3242
|
+
"""Persist on address.updated. Upserts the customer's on-file address + appends
|
|
3243
|
+
history.
|
|
3244
|
+
|
|
3245
|
+
Keyed on the decrypted account number, falling back to name. The caller
|
|
3246
|
+
enforces what makes that safe: when you verify account references, the
|
|
3247
|
+
handler refuses anything that is not a 'match' before reaching here, so the
|
|
3248
|
+
fallback is unreachable in that mode. If you do NOT verify account
|
|
3249
|
+
references the fallback is live and the row is keyed on the customer's name
|
|
3250
|
+
- be aware two customers sharing a name share a row."""
|
|
3251
|
+
acct = (account_number or "").strip() or (name or "").strip()
|
|
2155
3252
|
address_json = json.dumps(address, sort_keys=True)
|
|
2156
3253
|
_db.execute("""
|
|
2157
|
-
INSERT INTO
|
|
2158
|
-
VALUES (?, ?, ?,
|
|
2159
|
-
ON CONFLICT(
|
|
2160
|
-
name
|
|
2161
|
-
address
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
""", (email, name, address_json, dispatch_id))
|
|
3254
|
+
INSERT INTO customers (account_number, name, address, updated_at)
|
|
3255
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
3256
|
+
ON CONFLICT(account_number) DO UPDATE SET
|
|
3257
|
+
name = excluded.name,
|
|
3258
|
+
address = excluded.address,
|
|
3259
|
+
updated_at = excluded.updated_at
|
|
3260
|
+
""", (acct, name, address_json))
|
|
2165
3261
|
_db.execute("""
|
|
2166
|
-
INSERT INTO address_history (
|
|
2167
|
-
VALUES (?, ?, ?,
|
|
2168
|
-
""", (
|
|
2169
|
-
|
|
2170
|
-
def _verify_address(
|
|
2171
|
-
"""Returns 'match' | 'mismatch' | 'not_found' for an address.verify event.
|
|
2172
|
-
|
|
3262
|
+
INSERT INTO address_history (account_number, address, dispatch_id, recorded_at)
|
|
3263
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
3264
|
+
""", (acct, address_json, dispatch_id))
|
|
3265
|
+
|
|
3266
|
+
def _verify_address(account_number: str, name: str, address: dict[str, Any]) -> str:
|
|
3267
|
+
"""Returns 'match' | 'mismatch' | 'not_found' for an address.verify event.
|
|
3268
|
+
Compares the WHOLE address canonically against the on-file record. A customer
|
|
3269
|
+
on your roster you have never updated returns 'mismatch' \u2014 you know them, you
|
|
3270
|
+
just don't hold THIS address yet."""
|
|
3271
|
+
row = _find_customer(account_number, name)
|
|
2173
3272
|
if not row:
|
|
2174
3273
|
return "not_found"
|
|
2175
3274
|
try:
|
|
2176
|
-
stored = json.loads(row[
|
|
3275
|
+
stored = json.loads(row[2])
|
|
2177
3276
|
except (json.JSONDecodeError, TypeError):
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
# ignores fields the consumer didn't supply (e.g. country missing on the
|
|
2181
|
-
# incoming check is treated as "any country acceptable") \u2014 partners that
|
|
2182
|
-
# need stricter matching should adjust this comparison.
|
|
2183
|
-
matches = all(stored.get(k) == v for k, v in address.items())
|
|
2184
|
-
return "match" if matches else "mismatch"
|
|
3277
|
+
stored = {}
|
|
3278
|
+
return "match" if _canonical_address(stored) == _canonical_address(address) else "mismatch"
|
|
2185
3279
|
|
|
2186
3280
|
# \u2500\u2500 Crypto helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2187
3281
|
|
|
@@ -2303,6 +3397,61 @@ def _decrypt_session(share: dict[str, Any], envelope_b64: str,
|
|
|
2303
3397
|
plaintext = AESGCM(sk).decrypt(session_iv, session_ct, None)
|
|
2304
3398
|
return json.loads(plaintext) # type: ignore[return-value]
|
|
2305
3399
|
|
|
3400
|
+
# \u2500\u2500 Confirm callback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3401
|
+
|
|
3402
|
+
async def _confirm_to_oneaddress(dispatch: str, status: str) -> None:
|
|
3403
|
+
"""Close the loop after an address.updated apply, so the consumer's dashboard
|
|
3404
|
+
flips the service to 'Confirmed'. Scheduled as a background task
|
|
3405
|
+
(fire-and-forget) so a slow confirm never delays the webhook's own 200 \u2014 a
|
|
3406
|
+
slow confirm must not make OneAddress time the DISPATCH out and mark it failed.
|
|
3407
|
+
|
|
3408
|
+
Only real dispatches carry a POSITIVE-INTEGER id in X-OneAddress-Dispatch;
|
|
3409
|
+
probes (the go-live 'address.test') carry a non-numeric id and have nothing to
|
|
3410
|
+
confirm, so they are skipped.
|
|
3411
|
+
|
|
3412
|
+
Auth for /api/confirm (all three required):
|
|
3413
|
+
Authorization: Bearer <secret>
|
|
3414
|
+
X-OneAddress-Timestamp: <unix seconds>
|
|
3415
|
+
X-OneAddress-Signature: HMAC-SHA256(secret, "<timestamp>.<rawBody>")
|
|
3416
|
+
The same secret signs the Bearer and the body, over the EXACT bytes POSTed.
|
|
3417
|
+
"""
|
|
3418
|
+
d = (dispatch or "").strip()
|
|
3419
|
+
if not d.isdigit() or int(d) <= 0:
|
|
3420
|
+
return
|
|
3421
|
+
dispatch_id = int(d)
|
|
3422
|
+
body_str = json.dumps({
|
|
3423
|
+
"dispatch_id": dispatch_id,
|
|
3424
|
+
"partner_id": PARTNER_ID,
|
|
3425
|
+
"status": status,
|
|
3426
|
+
"note": "Applied by the OneAddress webhook receiver",
|
|
3427
|
+
})
|
|
3428
|
+
ts = str(int(time.time()))
|
|
3429
|
+
sig = hmac_lib.new(CONFIRM_SECRET.encode(), f"{ts}.{body_str}".encode(), hashlib.sha256).hexdigest()
|
|
3430
|
+
try:
|
|
3431
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
3432
|
+
# content= sends these exact bytes \u2014 the ones we signed. Do NOT use
|
|
3433
|
+
# json=, which would re-serialise and break the signature.
|
|
3434
|
+
resp = await client.post(
|
|
3435
|
+
f"{ONEADDRESS_API}/api/confirm",
|
|
3436
|
+
content=body_str,
|
|
3437
|
+
headers={
|
|
3438
|
+
"Content-Type": "application/json",
|
|
3439
|
+
"Authorization": f"Bearer {CONFIRM_SECRET}",
|
|
3440
|
+
"X-OneAddress-Timestamp": ts,
|
|
3441
|
+
"X-OneAddress-Signature": sig,
|
|
3442
|
+
},
|
|
3443
|
+
)
|
|
3444
|
+
if resp.status_code // 100 == 2:
|
|
3445
|
+
print(f"[confirm] dispatch {dispatch_id} -> {status}: acknowledged by OneAddress")
|
|
3446
|
+
else:
|
|
3447
|
+
print(f"[confirm] dispatch {dispatch_id} confirm FAILED \u2014 HTTP {resp.status_code}")
|
|
3448
|
+
if resp.status_code == 401:
|
|
3449
|
+
print("[confirm] 401 means the wrong secret. If your partner has a separate "
|
|
3450
|
+
"confirm secret, set CONFIRM_SECRET to it (from the portal Webhook screen); "
|
|
3451
|
+
"otherwise your webhook signing secret should work.")
|
|
3452
|
+
except Exception as e:
|
|
3453
|
+
print(f"[confirm] confirm request error: {e}")
|
|
3454
|
+
|
|
2306
3455
|
# \u2500\u2500 Webhook handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2307
3456
|
|
|
2308
3457
|
@app.post("/webhook")
|
|
@@ -2325,8 +3474,38 @@ async def webhook(request: Request) -> Response:
|
|
|
2325
3474
|
|
|
2326
3475
|
if dispatch and dispatch in seen_dispatches:
|
|
2327
3476
|
return Response(content='{"ok":true,"duplicate":true}', media_type="application/json")
|
|
2328
|
-
|
|
2329
|
-
|
|
3477
|
+
# A dispatch is remembered only AFTER it has been fully handled (see the
|
|
3478
|
+
# success paths below), never here. Remembering on arrival would mark a
|
|
3479
|
+
# dispatch that then fails to decrypt (422) as "seen", so OneAddress's retry
|
|
3480
|
+
# after you fix the key would be dismissed as a duplicate and the update lost.
|
|
3481
|
+
# The address.test probe is never remembered, so re-running go-live re-tests.
|
|
3482
|
+
|
|
3483
|
+
# \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
|
|
3484
|
+
# Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
|
|
3485
|
+
# no address, no session envelope \u2014 handled HERE, ABOVE the payload-less
|
|
3486
|
+
# acknowledge below (account.verify is not a dispatch event, so it would
|
|
3487
|
+
# otherwise be acknowledged as "no payload" and never actually check).
|
|
3488
|
+
if event == "account.verify":
|
|
3489
|
+
if not VERIFIES_ACCOUNT_REFERENCE:
|
|
3490
|
+
print("[webhook] account.verify -> skipped (VERIFIES_ACCOUNT_REFERENCE is false)")
|
|
3491
|
+
return Response(content='{"ok":true,"skipped":true}', media_type="application/json")
|
|
3492
|
+
enc_cust = body.get("customer_encrypted")
|
|
3493
|
+
if not isinstance(enc_cust, dict):
|
|
3494
|
+
return Response(status_code=400, content='{"error":"Missing customer_encrypted"}',
|
|
3495
|
+
media_type="application/json")
|
|
3496
|
+
try:
|
|
3497
|
+
cust = _decrypt_address(enc_cust, PRIVATE_KEY, PARTNER_ID)
|
|
3498
|
+
except Exception as e:
|
|
3499
|
+
print(f"[webhook] account.verify decryption failed \u2014 check OA_PRIVATE_KEY_PEM: {e}")
|
|
3500
|
+
return Response(status_code=422, content='{"ok":false,"error":"decryption_failed"}',
|
|
3501
|
+
media_type="application/json")
|
|
3502
|
+
acct = str(cust.get("account_number") or "")
|
|
3503
|
+
cname = str(cust.get("name") or "")
|
|
3504
|
+
ckn = cust.get("known_names") or []
|
|
3505
|
+
ckn = [str(k) for k in ckn] if isinstance(ckn, list) else []
|
|
3506
|
+
status = _verify_account(acct, cname, ckn)
|
|
3507
|
+
print(f"[webhook] account.verify -> {status} for account {acct or '(none)'}")
|
|
3508
|
+
return Response(content=json.dumps({"status": status}), media_type="application/json")
|
|
2330
3509
|
|
|
2331
3510
|
# A valid signed request that carries no address payload \u2014 a conformance
|
|
2332
3511
|
# ping, or any event added after this receiver was generated \u2014 has already
|
|
@@ -2346,15 +3525,20 @@ async def webhook(request: Request) -> Response:
|
|
|
2346
3525
|
session_share = body.get("session_key_share")
|
|
2347
3526
|
legacy_enc = body.get("address_encrypted")
|
|
2348
3527
|
|
|
2349
|
-
#
|
|
2350
|
-
#
|
|
3528
|
+
# Identity comes ONLY from decryption, never a cleartext wire field: under D5
|
|
3529
|
+
# the name / account number / known names live INSIDE the decrypted payload.
|
|
2351
3530
|
verified_name = ""
|
|
3531
|
+
account_number = ""
|
|
3532
|
+
known_names: list[str] = []
|
|
2352
3533
|
|
|
2353
3534
|
if isinstance(session_envelope, str) and isinstance(session_share, dict):
|
|
2354
3535
|
try:
|
|
2355
3536
|
data = _decrypt_session(session_share, session_envelope, PRIVATE_KEY, PARTNER_ID)
|
|
2356
3537
|
address = data.get("new_address", {})
|
|
2357
3538
|
verified_name = data.get("verified_name", "") or ""
|
|
3539
|
+
account_number = data.get("account_number", "") or ""
|
|
3540
|
+
_kn = data.get("known_names", [])
|
|
3541
|
+
known_names = [str(x) for x in _kn] if isinstance(_kn, list) else []
|
|
2358
3542
|
except Exception as e:
|
|
2359
3543
|
print(f"[webhook] D5 decryption failed \u2014 check OA_PRIVATE_KEY_PEM matches key_id "
|
|
2360
3544
|
f"{session_share.get('key_id', '?')}: {e}")
|
|
@@ -2364,6 +3548,9 @@ async def webhook(request: Request) -> Response:
|
|
|
2364
3548
|
try:
|
|
2365
3549
|
address = _decrypt_address(legacy_enc, PRIVATE_KEY, PARTNER_ID)
|
|
2366
3550
|
verified_name = address.get("fullName", "") or ""
|
|
3551
|
+
account_number = address.get("accountReference", "") or ""
|
|
3552
|
+
_kn = address.get("knownNames", [])
|
|
3553
|
+
known_names = [str(x) for x in _kn] if isinstance(_kn, list) else []
|
|
2367
3554
|
except Exception as e:
|
|
2368
3555
|
print(f"[webhook] Decryption error (check OA_PRIVATE_KEY_PEM): {e}")
|
|
2369
3556
|
return Response(content='{"ok":false,"error":"decryption failed - partner key mismatch"}',
|
|
@@ -2373,18 +3560,57 @@ async def webhook(request: Request) -> Response:
|
|
|
2373
3560
|
media_type="application/json")
|
|
2374
3561
|
|
|
2375
3562
|
if event == "address.updated":
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
#
|
|
2380
|
-
#
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
3563
|
+
# Identity comes ONLY from decryption \u2014 there is no cleartext customer
|
|
3564
|
+
# block on the wire under D5. Log metadata only \u2014 never the decrypted
|
|
3565
|
+
# address. stdout is captured by uvicorn / Docker / systemd-journal in
|
|
3566
|
+
# production and writing PII there turns every log reader into a
|
|
3567
|
+
# data-exposure surface.
|
|
3568
|
+
print(f"[webhook] address.updated for {account_number or verified_name or '?'} (dispatch={dispatch})")
|
|
3569
|
+
|
|
3570
|
+
# AN ACCOUNT REFERENCE THAT MATCHES NOTHING MUST BE A HARD NO-MATCH.
|
|
3571
|
+
#
|
|
3572
|
+
# Runs BEFORE _save_address, and it is what makes _save_address safe.
|
|
3573
|
+
# Until 13 Sep 2026 this handler applied every dispatch it could
|
|
3574
|
+
# decrypt with no check that the account was one of yours: an account
|
|
3575
|
+
# number matching nobody created a BRAND NEW customer row, and a
|
|
3576
|
+
# dispatch with no account number was keyed on the customer's NAME, so
|
|
3577
|
+
# two customers sharing a name collapse onto one record.
|
|
3578
|
+
#
|
|
3579
|
+
# Do NOT fall back to a name match when the account number misses. A
|
|
3580
|
+
# name matching a DIFFERENT customer's record is not evidence they are
|
|
3581
|
+
# the same person; it is the likeliest way to write one customer's
|
|
3582
|
+
# address onto another's account.
|
|
3583
|
+
#
|
|
3584
|
+
# Gated on your portal declaration, exactly like account.verify below.
|
|
3585
|
+
# Refusing is reported twice and both matter: ok:false tells OneAddress
|
|
3586
|
+
# the delivery failed (a 200 with ok:true is read as success and the
|
|
3587
|
+
# consumer is told their address landed), and the "failed" confirm puts
|
|
3588
|
+
# the same answer on the record they see.
|
|
3589
|
+
if VERIFIES_ACCOUNT_REFERENCE:
|
|
3590
|
+
verdict = _verify_account(account_number, verified_name, known_names)
|
|
3591
|
+
if verdict != "match":
|
|
3592
|
+
print(f"[webhook] address.updated REFUSED ({verdict}) for account "
|
|
3593
|
+
f"{account_number or '(none)'} - nothing applied")
|
|
3594
|
+
_t = asyncio.create_task(_confirm_to_oneaddress(dispatch, "failed"))
|
|
3595
|
+
_background_tasks.add(_t)
|
|
3596
|
+
_t.add_done_callback(_background_tasks.discard)
|
|
3597
|
+
return Response(
|
|
3598
|
+
content='{"ok":false,"error":"account_not_matched","verdict":"' + verdict + '"}',
|
|
3599
|
+
media_type="application/json",
|
|
3600
|
+
)
|
|
3601
|
+
|
|
3602
|
+
_save_address(account_number, verified_name, address, dispatch)
|
|
3603
|
+
if dispatch:
|
|
3604
|
+
seen_dispatches.add(dispatch) # remember only after it is stored
|
|
3605
|
+
# Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
3606
|
+
# Fire-and-forget so it never delays this 200 (keep a reference so the
|
|
3607
|
+
# task isn't garbage-collected before it runs).
|
|
3608
|
+
_t = asyncio.create_task(_confirm_to_oneaddress(dispatch, "confirmed"))
|
|
3609
|
+
_background_tasks.add(_t)
|
|
3610
|
+
_t.add_done_callback(_background_tasks.discard)
|
|
3611
|
+
return Response(content='{"ok":true}', media_type="application/json")
|
|
2385
3612
|
|
|
2386
3613
|
elif event == "address.verify":
|
|
2387
|
-
customer = body.get("customer", {})
|
|
2388
3614
|
callback_url = body["callback_url"]
|
|
2389
3615
|
callback_token= body["callback_token"]
|
|
2390
3616
|
batch_id = body["batch_id"]
|
|
@@ -2400,17 +3626,16 @@ async def webhook(request: Request) -> Response:
|
|
|
2400
3626
|
content='{"error":"Invalid callback_url host"}',
|
|
2401
3627
|
media_type="application/json")
|
|
2402
3628
|
|
|
2403
|
-
# Real
|
|
2404
|
-
#
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
result = _verify_address(email, address) if email else "not_found"
|
|
2408
|
-
print(f"[webhook] address.verify -> {result} for {email or '?'}")
|
|
3629
|
+
# Real roster lookup \u2014 identity comes ONLY from decryption. "not_found"
|
|
3630
|
+
# for a customer not on your roster, "match" / "mismatch" otherwise.
|
|
3631
|
+
result = _verify_address(account_number, verified_name, address)
|
|
3632
|
+
print(f"[webhook] address.verify -> {result} for {account_number or verified_name or '?'}")
|
|
2409
3633
|
|
|
2410
3634
|
payload = {
|
|
3635
|
+
# 2026.2 \u2014 no member_name echo; OneAddress keys the result on
|
|
3636
|
+
# (batch_id, partner_id) and validates the opaque token alone.
|
|
2411
3637
|
"batch_id": batch_id,
|
|
2412
3638
|
"partner_id": PARTNER_ID,
|
|
2413
|
-
"member_name": customer.get("name", ""),
|
|
2414
3639
|
"result": result,
|
|
2415
3640
|
"token": callback_token,
|
|
2416
3641
|
}
|
|
@@ -2419,6 +3644,8 @@ async def webhook(request: Request) -> Response:
|
|
|
2419
3644
|
await client.post(callback_url, json=payload)
|
|
2420
3645
|
except Exception as e:
|
|
2421
3646
|
print(f"[webhook] Callback POST failed: {e}")
|
|
3647
|
+
if dispatch:
|
|
3648
|
+
seen_dispatches.add(dispatch) # remember only after the callback posted
|
|
2422
3649
|
|
|
2423
3650
|
elif event in ("address.test", "address.test-dispatch"):
|
|
2424
3651
|
# OneAddress connection-verification probe. Reaching here means the D5
|
|
@@ -2466,24 +3693,27 @@ Your webhook endpoint: \`POST http://localhost:3001/webhook\`
|
|
|
2466
3693
|
|
|
2467
3694
|
## Events handled
|
|
2468
3695
|
|
|
3696
|
+
### account.verify (pre-payment account check)
|
|
3697
|
+
Receive \u2192 verify HMAC \u2192 decrypt the customer block \u2192 \`_verify_account\` \u2192
|
|
3698
|
+
answer \`match\` / \`no_match\` / \`no_account\`. Gated on \`VERIFIES_ACCOUNT_REFERENCE\`
|
|
3699
|
+
(the wizard writes your portal declaration): when off, the receiver answers
|
|
3700
|
+
\`{ "ok": true, "skipped": true }\` ("not checked").
|
|
3701
|
+
|
|
2469
3702
|
### address.updated
|
|
2470
|
-
Receive \u2192 verify HMAC \u2192 decrypt \u2192
|
|
3703
|
+
Receive \u2192 verify HMAC \u2192 decrypt \u2192 \`_save_address\` (roster upsert + history) \u2192
|
|
3704
|
+
fire-and-forget \`/api/confirm\` callback so the consumer's dashboard flips the
|
|
3705
|
+
service to "Confirmed".
|
|
2471
3706
|
|
|
2472
3707
|
### address.verify
|
|
2473
|
-
Receive \u2192 verify HMAC \u2192 decrypt \u2192
|
|
3708
|
+
Receive \u2192 verify HMAC \u2192 decrypt \u2192 \`_verify_address\` against your roster \u2192 POST
|
|
3709
|
+
\`callback_url\`.
|
|
2474
3710
|
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
# With something like:
|
|
2482
|
-
record = db.get_customer(customer.get("email"))
|
|
2483
|
-
result = "not_found" if not record else (
|
|
2484
|
-
"match" if addresses_match(record.address, address) else "mismatch"
|
|
2485
|
-
)
|
|
2486
|
-
\`\`\`
|
|
3711
|
+
\`app.py\` ships a **working roster store** (SQLite, seeded with one demo
|
|
3712
|
+
customer), not a stub \u2014 \`_verify_address\` does a real lookup and returns
|
|
3713
|
+
\`"match"\` / \`"mismatch"\` / \`"not_found"\`, keyed on the account number and name
|
|
3714
|
+
the payload was **decrypted** to (there is no cleartext customer block on the
|
|
3715
|
+
wire under D5). Point \`_find_customer\` / \`_save_address\` at your real customer
|
|
3716
|
+
database when you outgrow the file.
|
|
2487
3717
|
|
|
2488
3718
|
Valid results: \`"match"\` | \`"mismatch"\` | \`"not_found"\`
|
|
2489
3719
|
|
|
@@ -2495,7 +3725,17 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
|
|
|
2495
3725
|
|
|
2496
3726
|
## Configuration
|
|
2497
3727
|
|
|
2498
|
-
Credentials are in \`.env\` (written by the setup wizard). Never commit
|
|
3728
|
+
Credentials and config are in \`.env\` (written by the setup wizard). Never commit
|
|
3729
|
+
\`.env\` to source control.
|
|
3730
|
+
|
|
3731
|
+
| Env var | Description |
|
|
3732
|
+
|---------|-------------|
|
|
3733
|
+
| \`OA_PARTNER_ID\` | Your partner UUID |
|
|
3734
|
+
| \`OA_WEBHOOK_SECRET\` | HMAC-SHA256 webhook signing secret |
|
|
3735
|
+
| \`OA_PRIVATE_KEY_PEM\` | PKCS#8 PEM key \u2014 \`\\n\` between PEM lines |
|
|
3736
|
+
| \`ONEADDRESS_API\` | Confirm-callback target \u2014 the customer app (default \`https://oneaddress.io\`), NOT the partner portal |
|
|
3737
|
+
| \`CONFIRM_SECRET\` | Signs the \`/api/confirm\` callback. Leave blank to reuse \`OA_WEBHOOK_SECRET\` |
|
|
3738
|
+
| \`VERIFIES_ACCOUNT_REFERENCE\` | Whether the receiver answers \`account.verify\` (\`true\`/\`false\`) |
|
|
2499
3739
|
`
|
|
2500
3740
|
}
|
|
2501
3741
|
],
|
|
@@ -2625,6 +3865,27 @@ public final class OneAddressVerifier {
|
|
|
2625
3865
|
}
|
|
2626
3866
|
}
|
|
2627
3867
|
|
|
3868
|
+
/**
|
|
3869
|
+
* HMAC-SHA256 of {@code payload} under {@code secret}, lowercase hex. Used to
|
|
3870
|
+
* SIGN the /api/confirm callback, over "<timestamp>.<rawBody>" \u2014 the
|
|
3871
|
+
* same construction {@link #verify} checks on the way in.
|
|
3872
|
+
*/
|
|
3873
|
+
public static String sign(String payload, String secret) {
|
|
3874
|
+
try {
|
|
3875
|
+
Mac mac = Mac.getInstance("HmacSHA256");
|
|
3876
|
+
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
|
3877
|
+
byte[] out = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
|
|
3878
|
+
StringBuilder sb = new StringBuilder(out.length * 2);
|
|
3879
|
+
for (byte b : out) {
|
|
3880
|
+
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
|
|
3881
|
+
sb.append(Character.forDigit(b & 0xF, 16));
|
|
3882
|
+
}
|
|
3883
|
+
return sb.toString();
|
|
3884
|
+
} catch (Exception e) {
|
|
3885
|
+
throw new IllegalStateException("HMAC signing failed", e);
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
|
|
2628
3889
|
private static byte[] hexToBytes(String hex) {
|
|
2629
3890
|
int len = hex.length();
|
|
2630
3891
|
byte[] out = new byte[len / 2];
|
|
@@ -2882,9 +4143,19 @@ public class OneAddressWebhookController {
|
|
|
2882
4143
|
this.store = store;
|
|
2883
4144
|
}
|
|
2884
4145
|
|
|
4146
|
+
// Baked in by the setup wizard from your portal declaration
|
|
4147
|
+
// (partners.verifies_account_reference). Decides whether this receiver
|
|
4148
|
+
// ANSWERS the pre-payment account.verify check. Override with the
|
|
4149
|
+
// VERIFIES_ACCOUNT_REFERENCE env var ("true"/"false").
|
|
4150
|
+
private static final boolean DEFAULT_VERIFIES_ACCOUNT_REFERENCE = %%VERIFIES_ACCOUNT_REFERENCE%%;
|
|
4151
|
+
|
|
2885
4152
|
private String webhookSecret;
|
|
2886
4153
|
private String privateKeyPem;
|
|
2887
4154
|
private String partnerId;
|
|
4155
|
+
// Confirm-callback config.
|
|
4156
|
+
private String oneAddressApi;
|
|
4157
|
+
private String confirmSecret;
|
|
4158
|
+
private boolean verifiesAccountReference;
|
|
2888
4159
|
|
|
2889
4160
|
@PostConstruct
|
|
2890
4161
|
void init() {
|
|
@@ -2900,6 +4171,22 @@ public class OneAddressWebhookController {
|
|
|
2900
4171
|
partnerId = System.getenv("ONEADDRESS_PARTNER_ID");
|
|
2901
4172
|
if (partnerId == null || partnerId.isBlank())
|
|
2902
4173
|
throw new IllegalStateException("ONEADDRESS_PARTNER_ID is required");
|
|
4174
|
+
|
|
4175
|
+
// ONEADDRESS_API is the CUSTOMER app (/api/confirm lives there, NOT the
|
|
4176
|
+
// partner portal); default to production.
|
|
4177
|
+
String api = System.getenv("ONEADDRESS_API");
|
|
4178
|
+
oneAddressApi = (api == null || api.isBlank()) ? "https://oneaddress.io" : api.trim();
|
|
4179
|
+
while (oneAddressApi.endsWith("/")) oneAddressApi = oneAddressApi.substring(0, oneAddressApi.length() - 1);
|
|
4180
|
+
|
|
4181
|
+
// CONFIRM_SECRET signs the callback; falls back to the webhook secret,
|
|
4182
|
+
// which is correct for most partners.
|
|
4183
|
+
String cs = System.getenv("CONFIRM_SECRET");
|
|
4184
|
+
confirmSecret = (cs == null || cs.isBlank()) ? webhookSecret : cs;
|
|
4185
|
+
|
|
4186
|
+
String vr = System.getenv("VERIFIES_ACCOUNT_REFERENCE");
|
|
4187
|
+
verifiesAccountReference = (vr == null || vr.isBlank())
|
|
4188
|
+
? DEFAULT_VERIFIES_ACCOUNT_REFERENCE
|
|
4189
|
+
: vr.equals("true");
|
|
2903
4190
|
}
|
|
2904
4191
|
|
|
2905
4192
|
@PostMapping("/oneaddress")
|
|
@@ -2924,6 +4211,44 @@ public class OneAddressWebhookController {
|
|
|
2924
4211
|
Map<String, Object> body = MAPPER.readValue(rawBody, Map.class);
|
|
2925
4212
|
String event = (String) body.get("event");
|
|
2926
4213
|
|
|
4214
|
+
// \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
|
|
4215
|
+
// Carries an encrypted CUSTOMER block { name, known_names,
|
|
4216
|
+
// account_number } \u2014 no address, no session envelope \u2014 handled HERE,
|
|
4217
|
+
// before the address-decrypt section below (account.verify carries no
|
|
4218
|
+
// address_encrypted, so it would otherwise fall to the "no encrypted
|
|
4219
|
+
// payload" acknowledge and never actually check the account).
|
|
4220
|
+
if ("account.verify".equals(event)) {
|
|
4221
|
+
// Your portal declaration, baked in by the wizard. If you do not
|
|
4222
|
+
// verify account references, answer "not checked".
|
|
4223
|
+
if (!verifiesAccountReference) {
|
|
4224
|
+
log.info("[OneAddress] account.verify -> skipped (VERIFIES_ACCOUNT_REFERENCE is false)");
|
|
4225
|
+
return ResponseEntity.ok("{\\"ok\\":true,\\"skipped\\":true}");
|
|
4226
|
+
}
|
|
4227
|
+
@SuppressWarnings("unchecked")
|
|
4228
|
+
Map<String, Object> custEnc = (body.get("customer_encrypted") instanceof Map cm)
|
|
4229
|
+
? (Map<String, Object>) cm : null;
|
|
4230
|
+
if (custEnc == null)
|
|
4231
|
+
return ResponseEntity.badRequest().body("{\\"error\\":\\"Missing customer_encrypted\\"}");
|
|
4232
|
+
Map<String, Object> cust;
|
|
4233
|
+
try {
|
|
4234
|
+
cust = OneAddressDecryptor.decrypt(custEnc, privateKeyPem, partnerId);
|
|
4235
|
+
} catch (Exception e) {
|
|
4236
|
+
log.error("[OneAddress] account.verify decryption failed \u2014 check ONEADDRESS_PRIVATE_KEY", e);
|
|
4237
|
+
return ResponseEntity.unprocessableEntity().body("{\\"ok\\":false,\\"error\\":\\"decryption_failed\\"}");
|
|
4238
|
+
}
|
|
4239
|
+
String acct = cust.get("account_number") instanceof String an2 ? an2 : null;
|
|
4240
|
+
String acctName = cust.get("name") instanceof String nm2 ? nm2 : "";
|
|
4241
|
+
java.util.List<String> acctKnownNames = java.util.List.of();
|
|
4242
|
+
if (cust.get("known_names") instanceof java.util.List<?> aknl) {
|
|
4243
|
+
java.util.ArrayList<String> tmp = new java.util.ArrayList<>();
|
|
4244
|
+
for (Object o : aknl) if (o != null) tmp.add(String.valueOf(o));
|
|
4245
|
+
acctKnownNames = tmp;
|
|
4246
|
+
}
|
|
4247
|
+
String accountStatus = store.verifyAccount(acct, acctName, acctKnownNames);
|
|
4248
|
+
log.info("[OneAddress] account.verify -> {} for account {}", accountStatus, acct == null ? "(none)" : acct);
|
|
4249
|
+
return ResponseEntity.ok("{\\"status\\":\\"" + accountStatus + "\\"}");
|
|
4250
|
+
}
|
|
4251
|
+
|
|
2927
4252
|
// Two possible payload shapes:
|
|
2928
4253
|
// (a) D5 \u2014 body.session_envelope (String) + body.session_key_share (Map)
|
|
2929
4254
|
// (b) Legacy \u2014 body.address_encrypted (Map)
|
|
@@ -2992,7 +4317,18 @@ public class OneAddressWebhookController {
|
|
|
2992
4317
|
// will never retry.
|
|
2993
4318
|
String outcome = store.applyAddress(accountNumber, verifiedName, knownNames, address);
|
|
2994
4319
|
if (dispatchId != null && !dispatchId.isBlank()) store.markProcessed(dispatchId, outcome);
|
|
2995
|
-
|
|
4320
|
+
// Close the loop back to OneAddress so the service flips to
|
|
4321
|
+
// "Confirmed". Fire-and-forget (async) so a slow confirm never
|
|
4322
|
+
// delays this 200. Only when we actually applied the update.
|
|
4323
|
+
// \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true here (which it
|
|
4324
|
+
// did until 13 Sep 2026) reports a no-match as a successful
|
|
4325
|
+
// delivery: the matching above is correct and refuses to write,
|
|
4326
|
+
// the confirm is correctly withheld, and then the wire says the
|
|
4327
|
+
// opposite. OneAddress reads THIS, so the consumer was told
|
|
4328
|
+
// their address had landed on an account that does not exist.
|
|
4329
|
+
boolean applied = "applied".equals(outcome);
|
|
4330
|
+
confirmToOneAddress(dispatchId, applied ? "confirmed" : "failed");
|
|
4331
|
+
return ResponseEntity.ok("{\\"ok\\":" + applied + ",\\"outcome\\":\\"" + outcome + "\\"}");
|
|
2996
4332
|
} else if ("address.verify".equals(event)) {
|
|
2997
4333
|
handleAddressVerify(body, address, accountNumber, verifiedName, knownNames);
|
|
2998
4334
|
if (dispatchId != null && !dispatchId.isBlank()) store.markProcessed(dispatchId, "verify");
|
|
@@ -3070,6 +4406,67 @@ public class OneAddressWebhookController {
|
|
|
3070
4406
|
}
|
|
3071
4407
|
}
|
|
3072
4408
|
|
|
4409
|
+
/**
|
|
4410
|
+
* Close the loop after an address.updated is applied, so the consumer's
|
|
4411
|
+
* dashboard flips the service to "Confirmed". Runs on a background thread
|
|
4412
|
+
* (CompletableFuture.runAsync) so a slow confirm never delays the webhook's
|
|
4413
|
+
* own 200 \u2014 a slow confirm must not make OneAddress time the DISPATCH out and
|
|
4414
|
+
* mark it failed.
|
|
4415
|
+
*
|
|
4416
|
+
* Only real dispatches carry a POSITIVE-INTEGER id in X-OneAddress-Dispatch;
|
|
4417
|
+
* probes (the go-live "address.test") carry a non-numeric id and have nothing
|
|
4418
|
+
* to confirm, so they are skipped.
|
|
4419
|
+
*
|
|
4420
|
+
* Auth for /api/confirm (all three required):
|
|
4421
|
+
* Authorization: Bearer <secret>
|
|
4422
|
+
* X-OneAddress-Timestamp: <unix seconds>
|
|
4423
|
+
* X-OneAddress-Signature: HMAC-SHA256(secret, "<timestamp>.<rawBody>")
|
|
4424
|
+
* The same secret signs the Bearer and the body, over the EXACT bytes POSTed.
|
|
4425
|
+
*/
|
|
4426
|
+
private void confirmToOneAddress(String dispatch, String status) {
|
|
4427
|
+
final long dispatchId;
|
|
4428
|
+
try {
|
|
4429
|
+
dispatchId = Long.parseLong(dispatch == null ? "" : dispatch.trim());
|
|
4430
|
+
} catch (NumberFormatException e) {
|
|
4431
|
+
return; // probe or non-numeric id \u2014 nothing to confirm
|
|
4432
|
+
}
|
|
4433
|
+
if (dispatchId <= 0) return;
|
|
4434
|
+
|
|
4435
|
+
java.util.concurrent.CompletableFuture.runAsync(() -> {
|
|
4436
|
+
try {
|
|
4437
|
+
java.util.Map<String, Object> confirmBody = new java.util.LinkedHashMap<>();
|
|
4438
|
+
confirmBody.put("dispatch_id", dispatchId);
|
|
4439
|
+
confirmBody.put("partner_id", partnerId);
|
|
4440
|
+
confirmBody.put("status", status);
|
|
4441
|
+
confirmBody.put("note", "Applied by the OneAddress webhook receiver");
|
|
4442
|
+
String bodyStr = MAPPER.writeValueAsString(confirmBody);
|
|
4443
|
+
String ts = String.valueOf(System.currentTimeMillis() / 1000L);
|
|
4444
|
+
String sig = OneAddressVerifier.sign(ts + "." + bodyStr, confirmSecret);
|
|
4445
|
+
|
|
4446
|
+
HttpResponse<String> resp = HttpClient.newHttpClient().send(
|
|
4447
|
+
HttpRequest.newBuilder()
|
|
4448
|
+
.uri(URI.create(oneAddressApi + "/api/confirm"))
|
|
4449
|
+
.header("Content-Type", "application/json")
|
|
4450
|
+
.header("Authorization", "Bearer " + confirmSecret)
|
|
4451
|
+
.header("X-OneAddress-Timestamp", ts)
|
|
4452
|
+
.header("X-OneAddress-Signature", sig)
|
|
4453
|
+
.POST(HttpRequest.BodyPublishers.ofString(bodyStr))
|
|
4454
|
+
.build(),
|
|
4455
|
+
HttpResponse.BodyHandlers.ofString()
|
|
4456
|
+
);
|
|
4457
|
+
if (resp.statusCode() >= 200 && resp.statusCode() < 300) {
|
|
4458
|
+
log.info("[confirm] dispatch {} -> {}: acknowledged by OneAddress", dispatchId, status);
|
|
4459
|
+
} else {
|
|
4460
|
+
log.error("[confirm] dispatch {} confirm FAILED \u2014 HTTP {}", dispatchId, resp.statusCode());
|
|
4461
|
+
if (resp.statusCode() == 401)
|
|
4462
|
+
log.error("[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET to it (from the portal Webhook screen); otherwise your webhook signing secret should work.");
|
|
4463
|
+
}
|
|
4464
|
+
} catch (Exception e) {
|
|
4465
|
+
log.error("[confirm] confirm request error", e);
|
|
4466
|
+
}
|
|
4467
|
+
});
|
|
4468
|
+
}
|
|
4469
|
+
|
|
3073
4470
|
private static String getHeader(Map<String, String> headers, String name) {
|
|
3074
4471
|
for (Map.Entry<String, String> entry : headers.entrySet())
|
|
3075
4472
|
if (entry.getKey().equalsIgnoreCase(name)) return entry.getValue();
|
|
@@ -3274,6 +4671,16 @@ public class OneAddressStore {
|
|
|
3274
4671
|
private Long findCustomerId(String accountNumber, String verifiedName, List<String> knownNames) {
|
|
3275
4672
|
List<String> candidates = allNames(verifiedName, knownNames);
|
|
3276
4673
|
|
|
4674
|
+
// AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
|
|
4675
|
+
// function is where someone will try to make that more forgiving. Do not.
|
|
4676
|
+
// When an account number is supplied it decides the answer alone: if no row
|
|
4677
|
+
// carries it, or the row it carries disagrees with the name, this returns
|
|
4678
|
+
// nothing and does NOT fall through to the name lookup below. A name
|
|
4679
|
+
// matching a DIFFERENT customer's record is not evidence the two are the
|
|
4680
|
+
// same person; it is the likeliest way to write one customer's address onto
|
|
4681
|
+
// another customer's account. The name lookup exists only for partners who
|
|
4682
|
+
// do not use account references at all.
|
|
4683
|
+
|
|
3277
4684
|
if (accountNumber != null && !accountNumber.isBlank()) {
|
|
3278
4685
|
List<Map<String, Object>> rows = jdbc.queryForList(
|
|
3279
4686
|
"SELECT id, full_name FROM customers WHERE account_number = ?", accountNumber.trim());
|
|
@@ -3311,6 +4718,24 @@ public class OneAddressStore {
|
|
|
3311
4718
|
return same ? "match" : "mismatch";
|
|
3312
4719
|
}
|
|
3313
4720
|
|
|
4721
|
+
/**
|
|
4722
|
+
* Pre-payment account check behind account.verify: confirm the typed
|
|
4723
|
+
* account number is really one of yours and the name agrees, BEFORE the
|
|
4724
|
+
* consumer pays.
|
|
4725
|
+
* "match" account number found and the name (or a known name) agrees
|
|
4726
|
+
* "no_match" account number found but the name does not agree
|
|
4727
|
+
* "no_account" no such account number
|
|
4728
|
+
*/
|
|
4729
|
+
public String verifyAccount(String accountNumber, String verifiedName, List<String> knownNames) {
|
|
4730
|
+
if (accountNumber == null || accountNumber.isBlank()) return "no_account";
|
|
4731
|
+
List<Map<String, Object>> rows = jdbc.queryForList(
|
|
4732
|
+
"SELECT full_name FROM customers WHERE account_number = ?", accountNumber.trim());
|
|
4733
|
+
if (rows.isEmpty()) return "no_account";
|
|
4734
|
+
String storedName = String.valueOf(rows.get(0).get("full_name"));
|
|
4735
|
+
List<String> candidates = allNames(verifiedName, knownNames);
|
|
4736
|
+
return candidates.stream().anyMatch(c -> c.equalsIgnoreCase(storedName)) ? "match" : "no_match";
|
|
4737
|
+
}
|
|
4738
|
+
|
|
3314
4739
|
// \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3315
4740
|
|
|
3316
4741
|
private static List<String> allNames(String verifiedName, List<String> knownNames) {
|
|
@@ -3435,6 +4860,23 @@ name matching needs maiden names, initials and word order \u2014 see
|
|
|
3435
4860
|
| \`ONEADDRESS_WEBHOOK_SECRET\` | Webhook secret from the Partner Portal |
|
|
3436
4861
|
| \`ONEADDRESS_PRIVATE_KEY\` | PKCS#8 PEM private key (use \`\\n\` between lines) |
|
|
3437
4862
|
| \`ONEADDRESS_PARTNER_ID\` | Your partner UUID |
|
|
4863
|
+
| \`ONEADDRESS_API\` | Confirm-callback target \u2014 the customer app (default \`https://oneaddress.io\`), NOT the partner portal |
|
|
4864
|
+
| \`CONFIRM_SECRET\` | Signs the \`/api/confirm\` callback. Leave unset to reuse the webhook secret (correct for most partners) |
|
|
4865
|
+
| \`VERIFIES_ACCOUNT_REFERENCE\` | Override the wizard's baked-in \`account.verify\` declaration (\`true\`/\`false\`) |
|
|
4866
|
+
|
|
4867
|
+
### account.verify
|
|
4868
|
+
|
|
4869
|
+
If you verify account references, the receiver decrypts the pre-payment
|
|
4870
|
+
\`account.verify\` probe and answers \`match\` / \`no_match\` / \`no_account\` from your
|
|
4871
|
+
roster. If not, it answers \`{ "ok": true, "skipped": true }\` ("not checked").
|
|
4872
|
+
The setup wizard bakes your portal declaration in; \`VERIFIES_ACCOUNT_REFERENCE\`
|
|
4873
|
+
overrides it.
|
|
4874
|
+
|
|
4875
|
+
### Confirm callback
|
|
4876
|
+
|
|
4877
|
+
After applying an \`address.updated\`, the receiver POSTs \`/api/confirm\` on
|
|
4878
|
+
\`ONEADDRESS_API\` (fire-and-forget, HMAC-signed with \`CONFIRM_SECRET\`) so the
|
|
4879
|
+
consumer's dashboard flips the service to "Confirmed".
|
|
3438
4880
|
|
|
3439
4881
|
## Run conformance check
|
|
3440
4882
|
|
|
@@ -3660,6 +5102,32 @@ public sealed class OneAddressStore
|
|
|
3660
5102
|
return same ? "match" : "mismatch";
|
|
3661
5103
|
}
|
|
3662
5104
|
|
|
5105
|
+
/// <summary>
|
|
5106
|
+
/// Pre-payment account check behind account.verify: confirm the typed
|
|
5107
|
+
/// account number is really one of yours and the name agrees, BEFORE the
|
|
5108
|
+
/// consumer pays.
|
|
5109
|
+
/// "match" account number found and the name (or a known name) agrees
|
|
5110
|
+
/// "no_match" account number found but the name does not agree
|
|
5111
|
+
/// "no_account" no such account number
|
|
5112
|
+
/// </summary>
|
|
5113
|
+
public string VerifyAccount(string? accountNumber, string verifiedName, List<string> knownNames)
|
|
5114
|
+
{
|
|
5115
|
+
if (string.IsNullOrWhiteSpace(accountNumber)) return "no_account";
|
|
5116
|
+
using var conn = Open();
|
|
5117
|
+
using var cmd = conn.CreateCommand();
|
|
5118
|
+
cmd.CommandText = "SELECT full_name FROM customers WHERE account_number = $a";
|
|
5119
|
+
cmd.Parameters.AddWithValue("$a", accountNumber.Trim());
|
|
5120
|
+
var v = cmd.ExecuteScalar();
|
|
5121
|
+
if (v is null or DBNull) return "no_account";
|
|
5122
|
+
var storedName = Convert.ToString(v) ?? "";
|
|
5123
|
+
|
|
5124
|
+
var candidates = new List<string>();
|
|
5125
|
+
if (!string.IsNullOrWhiteSpace(verifiedName)) candidates.Add(verifiedName.Trim());
|
|
5126
|
+
candidates.AddRange(knownNames.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n.Trim()));
|
|
5127
|
+
return candidates.Any(c => string.Equals(c, storedName, StringComparison.OrdinalIgnoreCase))
|
|
5128
|
+
? "match" : "no_match";
|
|
5129
|
+
}
|
|
5130
|
+
|
|
3663
5131
|
/// <summary>
|
|
3664
5132
|
/// Account number first (authoritative), then name.
|
|
3665
5133
|
///
|
|
@@ -3681,6 +5149,16 @@ public sealed class OneAddressStore
|
|
|
3681
5149
|
if (!string.IsNullOrWhiteSpace(verifiedName)) candidates.Add(verifiedName.Trim());
|
|
3682
5150
|
candidates.AddRange(knownNames.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n.Trim()));
|
|
3683
5151
|
|
|
5152
|
+
// AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
|
|
5153
|
+
// function is where someone will try to make that more forgiving. Do not.
|
|
5154
|
+
// When an account number is supplied it decides the answer alone: if no row
|
|
5155
|
+
// carries it, or the row it carries disagrees with the name, this returns
|
|
5156
|
+
// nothing and does NOT fall through to the name lookup below. A name
|
|
5157
|
+
// matching a DIFFERENT customer's record is not evidence the two are the
|
|
5158
|
+
// same person; it is the likeliest way to write one customer's address onto
|
|
5159
|
+
// another customer's account. The name lookup exists only for partners who
|
|
5160
|
+
// do not use account references at all.
|
|
5161
|
+
|
|
3684
5162
|
if (!string.IsNullOrWhiteSpace(accountNumber))
|
|
3685
5163
|
{
|
|
3686
5164
|
using var byAcct = conn.CreateCommand();
|
|
@@ -3750,6 +5228,22 @@ var privateKeyPem = (Environment.GetEnvironmentVariable("ONEADDRESS_PRIVATE_KEY"
|
|
|
3750
5228
|
.Replace("\\\\n", "\\n");
|
|
3751
5229
|
var partnerId = Environment.GetEnvironmentVariable("ONEADDRESS_PARTNER_ID") ?? "";
|
|
3752
5230
|
|
|
5231
|
+
// Confirm-callback config. ONEADDRESS_API is the CUSTOMER app (/api/confirm
|
|
5232
|
+
// lives there, NOT the partner portal); CONFIRM_SECRET signs the callback and
|
|
5233
|
+
// falls back to the webhook secret, which is correct for most partners.
|
|
5234
|
+
var oneAddressApiRaw = Environment.GetEnvironmentVariable("ONEADDRESS_API");
|
|
5235
|
+
var oneAddressApi = string.IsNullOrEmpty(oneAddressApiRaw) ? "https://oneaddress.io" : oneAddressApiRaw.TrimEnd('/');
|
|
5236
|
+
var confirmSecret = Environment.GetEnvironmentVariable("CONFIRM_SECRET");
|
|
5237
|
+
if (string.IsNullOrEmpty(confirmSecret)) confirmSecret = webhookSecret;
|
|
5238
|
+
|
|
5239
|
+
// Whether this receiver answers the pre-payment account.verify check. The setup
|
|
5240
|
+
// wizard bakes your portal declaration into the default below; override it at
|
|
5241
|
+
// runtime with the VERIFIES_ACCOUNT_REFERENCE env var ("true"/"false").
|
|
5242
|
+
const bool DefaultVerifiesAccountReference = %%VERIFIES_ACCOUNT_REFERENCE%%;
|
|
5243
|
+
var verifiesAccountReference = Environment.GetEnvironmentVariable("VERIFIES_ACCOUNT_REFERENCE") is { } vr
|
|
5244
|
+
? vr == "true"
|
|
5245
|
+
: DefaultVerifiesAccountReference;
|
|
5246
|
+
|
|
3753
5247
|
var builder = WebApplication.CreateBuilder(args);
|
|
3754
5248
|
var app = builder.Build();
|
|
3755
5249
|
|
|
@@ -3784,6 +5278,45 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
|
|
|
3784
5278
|
|
|
3785
5279
|
var eventType = body.TryGetProperty("event", out var evtEl) ? evtEl.GetString() : null;
|
|
3786
5280
|
|
|
5281
|
+
// \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
|
|
5282
|
+
// Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
|
|
5283
|
+
// no address, no session envelope \u2014 handled HERE, ABOVE the payload-less
|
|
5284
|
+
// acknowledge below (account.verify is not a dispatch event, so it would
|
|
5285
|
+
// otherwise be acknowledged as "no payload" and never checked). Answer
|
|
5286
|
+
// synchronously with { status }.
|
|
5287
|
+
if (eventType == "account.verify")
|
|
5288
|
+
{
|
|
5289
|
+
// You told the portal whether you verify account references; the wizard
|
|
5290
|
+
// baked that into DefaultVerifiesAccountReference. If you do NOT, answer
|
|
5291
|
+
// "not checked" rather than a match/no_match you don't compute.
|
|
5292
|
+
if (!verifiesAccountReference)
|
|
5293
|
+
{
|
|
5294
|
+
app.Logger.LogInformation("[OneAddress] account.verify \u2192 skipped (VERIFIES_ACCOUNT_REFERENCE is false)");
|
|
5295
|
+
return Results.Json(new { ok = true, skipped = true });
|
|
5296
|
+
}
|
|
5297
|
+
if (!body.TryGetProperty("customer_encrypted", out var custEnc) || custEnc.ValueKind != JsonValueKind.Object)
|
|
5298
|
+
return Results.Json(new { error = "Missing customer_encrypted" }, statusCode: 400);
|
|
5299
|
+
byte[] custPlain;
|
|
5300
|
+
try { custPlain = DecryptAddress(custEnc, privateKeyPem, partnerId); }
|
|
5301
|
+
catch (Exception ex)
|
|
5302
|
+
{
|
|
5303
|
+
app.Logger.LogError(ex, "[OneAddress] account.verify decryption failed \u2014 check ONEADDRESS_PRIVATE_KEY");
|
|
5304
|
+
return Results.Json(new { ok = false, error = "decryption_failed" }, statusCode: 422);
|
|
5305
|
+
}
|
|
5306
|
+
var cust = JsonSerializer.Deserialize<JsonElement>(custPlain);
|
|
5307
|
+
string? acct = cust.TryGetProperty("account_number", out var acctEl) && acctEl.ValueKind == JsonValueKind.String
|
|
5308
|
+
? acctEl.GetString() : null;
|
|
5309
|
+
var acctName = cust.TryGetProperty("name", out var anmEl) && anmEl.ValueKind == JsonValueKind.String
|
|
5310
|
+
? anmEl.GetString() ?? "" : "";
|
|
5311
|
+
var acctKnownNames = new List<string>();
|
|
5312
|
+
if (cust.TryGetProperty("known_names", out var aknEl) && aknEl.ValueKind == JsonValueKind.Array)
|
|
5313
|
+
foreach (var n in aknEl.EnumerateArray())
|
|
5314
|
+
if (n.ValueKind == JsonValueKind.String) acctKnownNames.Add(n.GetString()!);
|
|
5315
|
+
var accountStatus = store.VerifyAccount(acct, acctName, acctKnownNames);
|
|
5316
|
+
app.Logger.LogInformation("[OneAddress] account.verify \u2192 {Status} for account {Acct}", accountStatus, acct ?? "(none)");
|
|
5317
|
+
return Results.Json(new { status = accountStatus });
|
|
5318
|
+
}
|
|
5319
|
+
|
|
3787
5320
|
// address.test / address.test-dispatch (the connection-verification probe)
|
|
3788
5321
|
// must pass this guard so it reaches the decrypt below; a wrong key then
|
|
3789
5322
|
// returns 422 and only a real decrypt reaches the verification answer.
|
|
@@ -3896,7 +5429,17 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
|
|
|
3896
5429
|
// marks the dispatch delivered on a failure that is never retried.
|
|
3897
5430
|
var outcome = store.ApplyAddress(accountNumber, verifiedName, knownNames, address);
|
|
3898
5431
|
if (!string.IsNullOrEmpty(dispatchId)) store.MarkProcessed(dispatchId, outcome);
|
|
3899
|
-
|
|
5432
|
+
// Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
5433
|
+
// Fire-and-forget (discard the Task) so a slow confirm never delays this
|
|
5434
|
+
// 200. Only when we actually applied the update.
|
|
5435
|
+
// \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true (which it did until
|
|
5436
|
+
// 13 Sep 2026) reports a no-match as a successful delivery: the match
|
|
5437
|
+
// above correctly refuses to write and correctly withholds the confirm,
|
|
5438
|
+
// then the wire says the opposite. OneAddress reads THIS.
|
|
5439
|
+
var applied = outcome == "applied";
|
|
5440
|
+
_ = ConfirmToOneAddress(oneAddressApi, confirmSecret, partnerId, dispatchId,
|
|
5441
|
+
applied ? "confirmed" : "failed", app.Logger);
|
|
5442
|
+
return Results.Json(new { ok = applied, outcome });
|
|
3900
5443
|
}
|
|
3901
5444
|
else // address.verify
|
|
3902
5445
|
{
|
|
@@ -3909,7 +5452,68 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
|
|
|
3909
5452
|
|
|
3910
5453
|
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
|
|
3911
5454
|
|
|
3912
|
-
|
|
5455
|
+
// PORT is read from the environment (default 3001) rather than hardcoded, so
|
|
5456
|
+
// the same build runs behind whatever port your tunnel / process manager sets.
|
|
5457
|
+
var port = Environment.GetEnvironmentVariable("PORT");
|
|
5458
|
+
if (string.IsNullOrEmpty(port)) port = "3001";
|
|
5459
|
+
app.Run($"http://localhost:{port}");
|
|
5460
|
+
|
|
5461
|
+
// \u2500\u2500 Confirm callback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
5462
|
+
|
|
5463
|
+
// Close the loop after an address.updated is applied, so the consumer's
|
|
5464
|
+
// dashboard flips the service to "Confirmed". Called with the Task discarded
|
|
5465
|
+
// (fire-and-forget) so a slow confirm never delays the webhook's own 200 \u2014 a
|
|
5466
|
+
// slow confirm must not make OneAddress time the DISPATCH out and mark it
|
|
5467
|
+
// failed.
|
|
5468
|
+
//
|
|
5469
|
+
// Only real dispatches carry a POSITIVE-INTEGER id in X-OneAddress-Dispatch.
|
|
5470
|
+
// Probes (the go-live "address.test") carry a non-numeric id and have nothing
|
|
5471
|
+
// to confirm, so they are skipped.
|
|
5472
|
+
//
|
|
5473
|
+
// Auth for /api/confirm (all three required):
|
|
5474
|
+
// Authorization: Bearer <secret>
|
|
5475
|
+
// X-OneAddress-Timestamp: <unix seconds>
|
|
5476
|
+
// X-OneAddress-Signature: HMAC-SHA256(secret, "<timestamp>.<rawBody>")
|
|
5477
|
+
// The same secret signs the Bearer and the body, over the EXACT bytes POSTed.
|
|
5478
|
+
async Task ConfirmToOneAddress(string oneAddressApi, string confirmSecret, string pid,
|
|
5479
|
+
string? dispatch, string status, ILogger logger)
|
|
5480
|
+
{
|
|
5481
|
+
if (!long.TryParse((dispatch ?? "").Trim(), out var dispatchId) || dispatchId <= 0) return;
|
|
5482
|
+
|
|
5483
|
+
var bodyStr = JsonSerializer.Serialize(new {
|
|
5484
|
+
dispatch_id = dispatchId,
|
|
5485
|
+
partner_id = pid,
|
|
5486
|
+
status,
|
|
5487
|
+
note = "Applied by the OneAddress webhook receiver",
|
|
5488
|
+
});
|
|
5489
|
+
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
|
5490
|
+
var sig = Convert.ToHexString(
|
|
5491
|
+
HMACSHA256.HashData(Encoding.UTF8.GetBytes(confirmSecret), Encoding.UTF8.GetBytes($"{ts}.{bodyStr}"))
|
|
5492
|
+
).ToLowerInvariant();
|
|
5493
|
+
|
|
5494
|
+
try
|
|
5495
|
+
{
|
|
5496
|
+
using var http = new HttpClient();
|
|
5497
|
+
using var content = new StringContent(bodyStr, Encoding.UTF8, "application/json");
|
|
5498
|
+
using var req = new HttpRequestMessage(HttpMethod.Post, $"{oneAddressApi}/api/confirm") { Content = content };
|
|
5499
|
+
req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {confirmSecret}");
|
|
5500
|
+
req.Headers.TryAddWithoutValidation("X-OneAddress-Timestamp", ts);
|
|
5501
|
+
req.Headers.TryAddWithoutValidation("X-OneAddress-Signature", sig);
|
|
5502
|
+
using var resp = await http.SendAsync(req);
|
|
5503
|
+
if (resp.IsSuccessStatusCode)
|
|
5504
|
+
logger.LogInformation("[confirm] dispatch {Id} \u2192 {Status}: acknowledged by OneAddress", dispatchId, status);
|
|
5505
|
+
else
|
|
5506
|
+
{
|
|
5507
|
+
logger.LogError("[confirm] dispatch {Id} confirm FAILED \u2014 HTTP {Code}", dispatchId, (int)resp.StatusCode);
|
|
5508
|
+
if ((int)resp.StatusCode == 401)
|
|
5509
|
+
logger.LogError("[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET to it (from the portal Webhook screen); otherwise your webhook signing secret should work.");
|
|
5510
|
+
}
|
|
5511
|
+
}
|
|
5512
|
+
catch (Exception ex)
|
|
5513
|
+
{
|
|
5514
|
+
logger.LogError(ex, "[confirm] confirm request error");
|
|
5515
|
+
}
|
|
5516
|
+
}
|
|
3913
5517
|
|
|
3914
5518
|
// \u2500\u2500 Crypto helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3915
5519
|
|
|
@@ -4176,6 +5780,24 @@ name matching needs maiden names, initials and word order \u2014 see
|
|
|
4176
5780
|
| \`ONEADDRESS_WEBHOOK_SECRET\` | Webhook secret from the Partner Portal |
|
|
4177
5781
|
| \`ONEADDRESS_PRIVATE_KEY\` | PKCS#8 PEM key \u2014 use \`\\n\` between PEM lines |
|
|
4178
5782
|
| \`ONEADDRESS_PARTNER_ID\` | Your partner UUID |
|
|
5783
|
+
| \`ONEADDRESS_API\` | Confirm-callback target \u2014 the customer app (default \`https://oneaddress.io\`), NOT the partner portal |
|
|
5784
|
+
| \`CONFIRM_SECRET\` | Signs the \`/api/confirm\` callback. Leave unset to reuse the webhook secret (correct for most partners) |
|
|
5785
|
+
| \`VERIFIES_ACCOUNT_REFERENCE\` | Override the wizard's baked-in \`account.verify\` declaration (\`true\`/\`false\`) |
|
|
5786
|
+
| \`PORT\` | HTTP port (default \`3001\`) |
|
|
5787
|
+
|
|
5788
|
+
### account.verify
|
|
5789
|
+
|
|
5790
|
+
If you verify account references, the receiver decrypts the pre-payment
|
|
5791
|
+
\`account.verify\` probe and answers \`match\` / \`no_match\` / \`no_account\` from your
|
|
5792
|
+
roster. If not, it answers \`{ ok: true, skipped: true }\` ("not checked"). The
|
|
5793
|
+
setup wizard bakes your portal declaration in; \`VERIFIES_ACCOUNT_REFERENCE\`
|
|
5794
|
+
overrides it.
|
|
5795
|
+
|
|
5796
|
+
### Confirm callback
|
|
5797
|
+
|
|
5798
|
+
After applying an \`address.updated\`, the receiver POSTs \`/api/confirm\` on
|
|
5799
|
+
\`ONEADDRESS_API\` (fire-and-forget, HMAC-signed with \`CONFIRM_SECRET\`) so the
|
|
5800
|
+
consumer's dashboard flips the service to "Confirmed".
|
|
4179
5801
|
|
|
4180
5802
|
## Run conformance check
|
|
4181
5803
|
|
|
@@ -4193,6 +5815,21 @@ npx @oneaddress/conformance test http://localhost:3001/webhooks/oneaddress
|
|
|
4193
5815
|
WEBHOOK_SECRET=%%WEBHOOK_SECRET%%
|
|
4194
5816
|
PARTNER_PRIVATE_KEY_PEM="%%PRIVATE_KEY%%"
|
|
4195
5817
|
PORT=3001
|
|
5818
|
+
|
|
5819
|
+
# Where the confirm callback is POSTed after you apply an address update. This
|
|
5820
|
+
# is the CUSTOMER app (oneaddress.io), NOT the partner portal \u2014 /api/confirm
|
|
5821
|
+
# lives on the former. Written by the setup wizard.
|
|
5822
|
+
ONEADDRESS_API=%%ONEADDRESS_API%%
|
|
5823
|
+
|
|
5824
|
+
# Secret that signs the /api/confirm callback. Leave BLANK to reuse
|
|
5825
|
+
# WEBHOOK_SECRET (correct for most partners); set it only if your partner has a
|
|
5826
|
+
# separate confirm secret in the portal Webhook screen.
|
|
5827
|
+
CONFIRM_SECRET=
|
|
5828
|
+
|
|
5829
|
+
# Whether this receiver answers the pre-payment account.verify check. The wizard
|
|
5830
|
+
# bakes your portal declaration into a default in main.go; set this to
|
|
5831
|
+
# "true"/"false" only to override that at runtime.
|
|
5832
|
+
# VERIFIES_ACCOUNT_REFERENCE=
|
|
4196
5833
|
`
|
|
4197
5834
|
},
|
|
4198
5835
|
{
|
|
@@ -4213,6 +5850,15 @@ PARTNER_PRIVATE_KEY_PEM=
|
|
|
4213
5850
|
|
|
4214
5851
|
# HTTP port (default 3001)
|
|
4215
5852
|
PORT=3001
|
|
5853
|
+
|
|
5854
|
+
# Confirm-callback target \u2014 the customer app, not the partner portal.
|
|
5855
|
+
ONEADDRESS_API=https://oneaddress.io
|
|
5856
|
+
|
|
5857
|
+
# Secret signing the /api/confirm callback. Blank = reuse WEBHOOK_SECRET.
|
|
5858
|
+
CONFIRM_SECRET=
|
|
5859
|
+
|
|
5860
|
+
# Override the wizard's baked-in account.verify declaration ("true"/"false").
|
|
5861
|
+
# VERIFIES_ACCOUNT_REFERENCE=
|
|
4216
5862
|
`
|
|
4217
5863
|
},
|
|
4218
5864
|
{
|
|
@@ -4429,6 +6075,38 @@ func (s *Store) VerifyAddress(accountNumber, verifiedName string, knownNames []s
|
|
|
4429
6075
|
return "mismatch"
|
|
4430
6076
|
}
|
|
4431
6077
|
|
|
6078
|
+
// VerifyAccount is the pre-payment account check behind account.verify: confirm
|
|
6079
|
+
// the typed account number is really one of yours and the name agrees, BEFORE
|
|
6080
|
+
// the consumer pays. The boundary that stops someone pushing an update to an
|
|
6081
|
+
// account that isn't theirs.
|
|
6082
|
+
//
|
|
6083
|
+
// "match" account number found and the name (or a known name) agrees
|
|
6084
|
+
// "no_match" account number found but the name does not agree
|
|
6085
|
+
// "no_account" no such account number
|
|
6086
|
+
func (s *Store) VerifyAccount(accountNumber, verifiedName string, knownNames []string) string {
|
|
6087
|
+
acct := strings.TrimSpace(accountNumber)
|
|
6088
|
+
if acct == "" {
|
|
6089
|
+
return "no_account"
|
|
6090
|
+
}
|
|
6091
|
+
var fullName string
|
|
6092
|
+
err := s.db.QueryRow("SELECT full_name FROM customers WHERE account_number = ?", acct).Scan(&fullName)
|
|
6093
|
+
if err != nil {
|
|
6094
|
+
// sql.ErrNoRows or any read failure \u2192 we cannot confirm the account.
|
|
6095
|
+
return "no_account"
|
|
6096
|
+
}
|
|
6097
|
+
candidates := []string{}
|
|
6098
|
+
if strings.TrimSpace(verifiedName) != "" {
|
|
6099
|
+
candidates = append(candidates, verifiedName)
|
|
6100
|
+
}
|
|
6101
|
+
candidates = append(candidates, knownNames...)
|
|
6102
|
+
for _, c := range candidates {
|
|
6103
|
+
if strings.TrimSpace(c) != "" && strings.EqualFold(strings.TrimSpace(c), strings.TrimSpace(fullName)) {
|
|
6104
|
+
return "match"
|
|
6105
|
+
}
|
|
6106
|
+
}
|
|
6107
|
+
return "no_match"
|
|
6108
|
+
}
|
|
6109
|
+
|
|
4432
6110
|
// findCustomerID matches on account number first (authoritative), then name.
|
|
4433
6111
|
//
|
|
4434
6112
|
// The name pass is deliberately simple. Real matching needs maiden names,
|
|
@@ -4452,6 +6130,15 @@ func (s *Store) findCustomerID(accountNumber, verifiedName string, knownNames []
|
|
|
4452
6130
|
}
|
|
4453
6131
|
}
|
|
4454
6132
|
|
|
6133
|
+
// AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
|
|
6134
|
+
// function is where someone will try to make that more forgiving. Do not.
|
|
6135
|
+
// When an account number is supplied it decides the answer alone: if no row
|
|
6136
|
+
// carries it, or the row it carries disagrees with the name, this returns
|
|
6137
|
+
// nothing and does NOT fall through to the name lookup below. A name
|
|
6138
|
+
// matching a DIFFERENT customer's record is not evidence the two are the
|
|
6139
|
+
// same person; it is the likeliest way to write one customer's address onto
|
|
6140
|
+
// another customer's account. The name lookup exists only for partners who
|
|
6141
|
+
// do not use account references at all.
|
|
4455
6142
|
if strings.TrimSpace(accountNumber) != "" {
|
|
4456
6143
|
var id int64
|
|
4457
6144
|
var fullName string
|
|
@@ -4548,6 +6235,12 @@ import (
|
|
|
4548
6235
|
|
|
4549
6236
|
// \u2500\u2500 Config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4550
6237
|
|
|
6238
|
+
// defaultVerifiesAccountReference is baked in by the setup wizard from your
|
|
6239
|
+
// portal declaration (partners.verifies_account_reference). It decides whether
|
|
6240
|
+
// this receiver ANSWERS the pre-payment account.verify check or replies "not
|
|
6241
|
+
// checked". Override it at runtime with the VERIFIES_ACCOUNT_REFERENCE env var.
|
|
6242
|
+
const defaultVerifiesAccountReference = %%VERIFIES_ACCOUNT_REFERENCE%%
|
|
6243
|
+
|
|
4551
6244
|
func main() {
|
|
4552
6245
|
webhookSecret := os.Getenv("WEBHOOK_SECRET")
|
|
4553
6246
|
privateKeyPEM := strings.ReplaceAll(os.Getenv("PARTNER_PRIVATE_KEY_PEM"), \`\\n\`, "\\n")
|
|
@@ -4561,6 +6254,23 @@ func main() {
|
|
|
4561
6254
|
log.Fatal("[startup] Missing required env vars: WEBHOOK_SECRET, PARTNER_PRIVATE_KEY_PEM, PARTNER_ID")
|
|
4562
6255
|
}
|
|
4563
6256
|
|
|
6257
|
+
// Confirm-callback config. ONEADDRESS_API is the customer app (/api/confirm
|
|
6258
|
+
// lives there, NOT on the partner portal); CONFIRM_SECRET signs the callback
|
|
6259
|
+
// and falls back to the webhook secret, which is correct for most partners.
|
|
6260
|
+
oneAddressAPI := strings.TrimRight(os.Getenv("ONEADDRESS_API"), "/")
|
|
6261
|
+
if oneAddressAPI == "" {
|
|
6262
|
+
oneAddressAPI = "https://oneaddress.io"
|
|
6263
|
+
}
|
|
6264
|
+
confirmSecret := os.Getenv("CONFIRM_SECRET")
|
|
6265
|
+
if confirmSecret == "" {
|
|
6266
|
+
confirmSecret = webhookSecret
|
|
6267
|
+
}
|
|
6268
|
+
// Whether we answer account.verify \u2014 the wizard's default, env-overridable.
|
|
6269
|
+
verifiesAccountReference := defaultVerifiesAccountReference
|
|
6270
|
+
if v := os.Getenv("VERIFIES_ACCOUNT_REFERENCE"); v != "" {
|
|
6271
|
+
verifiesAccountReference = v == "true"
|
|
6272
|
+
}
|
|
6273
|
+
|
|
4564
6274
|
// Durable store: schema, seed data, and the delivery log. Replaces the
|
|
4565
6275
|
// in-memory dedup map this scaffold used to carry \u2014 that was per-process
|
|
4566
6276
|
// (useless behind a load balancer), lost on restart, and marked on arrival,
|
|
@@ -4603,6 +6313,48 @@ func main() {
|
|
|
4603
6313
|
|
|
4604
6314
|
event, _ := parsed["event"].(string)
|
|
4605
6315
|
|
|
6316
|
+
// \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
|
|
6317
|
+
// Carries an encrypted CUSTOMER block { name, known_names, account_number }
|
|
6318
|
+
// \u2014 no address, no session envelope \u2014 so it is handled HERE, ABOVE the
|
|
6319
|
+
// payload-less acknowledge switch below (account.verify is not a dispatch
|
|
6320
|
+
// event, so it would otherwise fall through to "no payload \u2192 skipped" and
|
|
6321
|
+
// never actually check the account). Answer synchronously with { status }.
|
|
6322
|
+
if event == "account.verify" {
|
|
6323
|
+
// You told the portal whether you verify account references; the wizard
|
|
6324
|
+
// baked that into defaultVerifiesAccountReference. If you do NOT, answer
|
|
6325
|
+
// "not checked" rather than a match/no_match you don't compute.
|
|
6326
|
+
if !verifiesAccountReference {
|
|
6327
|
+
log.Print("[webhook] account.verify \u2192 skipped (VERIFIES_ACCOUNT_REFERENCE is false)")
|
|
6328
|
+
jsonResp(w, 200, map[string]any{"ok": true, "skipped": true})
|
|
6329
|
+
return
|
|
6330
|
+
}
|
|
6331
|
+
encCust, ok := parsed["customer_encrypted"].(map[string]any)
|
|
6332
|
+
if !ok {
|
|
6333
|
+
jsonResp(w, 400, map[string]any{"error": "Missing customer_encrypted"})
|
|
6334
|
+
return
|
|
6335
|
+
}
|
|
6336
|
+
cust, derr := decryptAddress(encCust, privateKeyPEM, partnerID)
|
|
6337
|
+
if derr != nil {
|
|
6338
|
+
log.Printf("[webhook] account.verify decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM: %v", derr)
|
|
6339
|
+
jsonResp(w, 422, map[string]any{"ok": false, "error": "decryption_failed"})
|
|
6340
|
+
return
|
|
6341
|
+
}
|
|
6342
|
+
acct, _ := cust["account_number"].(string)
|
|
6343
|
+
name, _ := cust["name"].(string)
|
|
6344
|
+
var knownNames []string
|
|
6345
|
+
if raw, ok := cust["known_names"].([]any); ok {
|
|
6346
|
+
for _, n := range raw {
|
|
6347
|
+
if s, ok := n.(string); ok {
|
|
6348
|
+
knownNames = append(knownNames, s)
|
|
6349
|
+
}
|
|
6350
|
+
}
|
|
6351
|
+
}
|
|
6352
|
+
status := store.VerifyAccount(acct, name, knownNames)
|
|
6353
|
+
log.Printf("[webhook] account.verify \u2192 %s for account %s (%s)", status, acct, name)
|
|
6354
|
+
jsonResp(w, 200, map[string]any{"status": status})
|
|
6355
|
+
return
|
|
6356
|
+
}
|
|
6357
|
+
|
|
4606
6358
|
// A valid signed request that carries no address payload \u2014 a conformance
|
|
4607
6359
|
// ping, or any event added after this receiver was generated \u2014 has already
|
|
4608
6360
|
// passed timestamp + signature above, which is exactly what such a probe
|
|
@@ -4702,7 +6454,20 @@ func main() {
|
|
|
4702
6454
|
if dispatch != "" {
|
|
4703
6455
|
_ = store.MarkProcessed(dispatch, outcome)
|
|
4704
6456
|
}
|
|
4705
|
-
|
|
6457
|
+
// Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
6458
|
+
// Fire-and-forget in a goroutine: a slow confirm must not delay this 200
|
|
6459
|
+
// (which acks the delivery). Only when we actually applied the update.
|
|
6460
|
+
// \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true (which it did until
|
|
6461
|
+
// 13 Sep 2026) reports a no-match as a successful delivery: the match
|
|
6462
|
+
// above correctly refuses to write and correctly withholds the confirm,
|
|
6463
|
+
// then the wire says the opposite. OneAddress reads THIS.
|
|
6464
|
+
applied := outcome == "applied"
|
|
6465
|
+
if applied {
|
|
6466
|
+
go confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, "confirmed")
|
|
6467
|
+
} else {
|
|
6468
|
+
go confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, "failed")
|
|
6469
|
+
}
|
|
6470
|
+
jsonResp(w, 200, map[string]any{"ok": applied, "outcome": outcome})
|
|
4706
6471
|
return
|
|
4707
6472
|
|
|
4708
6473
|
case "address.verify":
|
|
@@ -4794,6 +6559,67 @@ func main() {
|
|
|
4794
6559
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
|
4795
6560
|
}
|
|
4796
6561
|
|
|
6562
|
+
// \u2500\u2500 Confirm callback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
6563
|
+
|
|
6564
|
+
// confirmToOneAddress closes the loop after an address.updated is applied, so
|
|
6565
|
+
// the consumer's dashboard flips the service to "Confirmed". Called in a
|
|
6566
|
+
// goroutine (fire-and-forget) so it never delays the webhook's own 200 \u2014 a slow
|
|
6567
|
+
// confirm must not make OneAddress time the DISPATCH out and mark it failed.
|
|
6568
|
+
//
|
|
6569
|
+
// Only real dispatches carry a POSITIVE-INTEGER id in X-OneAddress-Dispatch.
|
|
6570
|
+
// Probes (the go-live "address.test") carry a non-numeric id and have nothing
|
|
6571
|
+
// to confirm, so they are skipped.
|
|
6572
|
+
//
|
|
6573
|
+
// Auth for /api/confirm (all three required):
|
|
6574
|
+
//
|
|
6575
|
+
// Authorization: Bearer <secret>
|
|
6576
|
+
// X-OneAddress-Timestamp: <unix seconds>
|
|
6577
|
+
// X-OneAddress-Signature: HMAC-SHA256(secret, "<timestamp>.<rawBody>")
|
|
6578
|
+
//
|
|
6579
|
+
// The same secret signs the Bearer and the body, over the EXACT bytes POSTed.
|
|
6580
|
+
func confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, status string) {
|
|
6581
|
+
dispatchID, err := strconv.ParseInt(strings.TrimSpace(dispatch), 10, 64)
|
|
6582
|
+
if err != nil || dispatchID <= 0 {
|
|
6583
|
+
return
|
|
6584
|
+
}
|
|
6585
|
+
bodyBytes, _ := json.Marshal(map[string]any{
|
|
6586
|
+
"dispatch_id": dispatchID,
|
|
6587
|
+
"partner_id": partnerID,
|
|
6588
|
+
"status": status,
|
|
6589
|
+
"note": "Applied by the OneAddress webhook receiver",
|
|
6590
|
+
})
|
|
6591
|
+
bodyStr := string(bodyBytes)
|
|
6592
|
+
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
|
6593
|
+
mac := hmac.New(sha256.New, []byte(confirmSecret))
|
|
6594
|
+
mac.Write([]byte(ts + "." + bodyStr))
|
|
6595
|
+
sig := hex.EncodeToString(mac.Sum(nil))
|
|
6596
|
+
|
|
6597
|
+
req, err := http.NewRequest(http.MethodPost, oneAddressAPI+"/api/confirm", strings.NewReader(bodyStr))
|
|
6598
|
+
if err != nil {
|
|
6599
|
+
log.Printf("[confirm] request build error: %v", err)
|
|
6600
|
+
return
|
|
6601
|
+
}
|
|
6602
|
+
req.Header.Set("Content-Type", "application/json")
|
|
6603
|
+
req.Header.Set("Authorization", "Bearer "+confirmSecret)
|
|
6604
|
+
req.Header.Set("X-OneAddress-Timestamp", ts)
|
|
6605
|
+
req.Header.Set("X-OneAddress-Signature", sig)
|
|
6606
|
+
|
|
6607
|
+
resp, err := http.DefaultClient.Do(req)
|
|
6608
|
+
if err != nil {
|
|
6609
|
+
log.Printf("[confirm] confirm request error: %v", err)
|
|
6610
|
+
return
|
|
6611
|
+
}
|
|
6612
|
+
defer resp.Body.Close()
|
|
6613
|
+
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
6614
|
+
log.Printf("[confirm] dispatch %d \u2192 %s: acknowledged by OneAddress", dispatchID, status)
|
|
6615
|
+
return
|
|
6616
|
+
}
|
|
6617
|
+
log.Printf("[confirm] dispatch %d confirm FAILED \u2014 HTTP %d", dispatchID, resp.StatusCode)
|
|
6618
|
+
if resp.StatusCode == 401 {
|
|
6619
|
+
log.Print("[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.")
|
|
6620
|
+
}
|
|
6621
|
+
}
|
|
6622
|
+
|
|
4797
6623
|
// \u2500\u2500 Crypto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4798
6624
|
|
|
4799
6625
|
func verifyHMAC(rawBody, signature, timestamp, secret string) bool {
|
|
@@ -5178,6 +7004,20 @@ Credentials are in \`.env\` (written by the setup wizard). Never commit \`.env\`
|
|
|
5178
7004
|
OA_PARTNER_ID=%%PARTNER_ID%%
|
|
5179
7005
|
OA_WEBHOOK_SECRET=%%WEBHOOK_SECRET%%
|
|
5180
7006
|
OA_PRIVATE_KEY_PEM="%%PRIVATE_KEY%%"
|
|
7007
|
+
|
|
7008
|
+
# Where the confirm callback is POSTed after you apply an address update. This is
|
|
7009
|
+
# the CUSTOMER app (oneaddress.io), NOT the partner portal \u2014 /api/confirm lives
|
|
7010
|
+
# on the former. Written by the setup wizard.
|
|
7011
|
+
ONEADDRESS_API=%%ONEADDRESS_API%%
|
|
7012
|
+
|
|
7013
|
+
# Secret that signs the /api/confirm callback. Leave BLANK to reuse
|
|
7014
|
+
# OA_WEBHOOK_SECRET (correct for most partners); set it only if your partner has
|
|
7015
|
+
# a separate confirm secret in the portal Webhook screen.
|
|
7016
|
+
CONFIRM_SECRET=
|
|
7017
|
+
|
|
7018
|
+
# Whether this receiver answers the pre-payment account.verify check. The wizard
|
|
7019
|
+
# writes your portal declaration here; set true/false to override.
|
|
7020
|
+
VERIFIES_ACCOUNT_REFERENCE=%%VERIFIES_ACCOUNT_REFERENCE%%
|
|
5181
7021
|
`
|
|
5182
7022
|
},
|
|
5183
7023
|
{
|
|
@@ -5343,6 +7183,31 @@ class OneAddressStore
|
|
|
5343
7183
|
return $same ? 'match' : 'mismatch';
|
|
5344
7184
|
}
|
|
5345
7185
|
|
|
7186
|
+
/**
|
|
7187
|
+
* Pre-payment account check behind account.verify: confirm the typed account
|
|
7188
|
+
* number is really one of yours and the name agrees, BEFORE the consumer pays.
|
|
7189
|
+
* 'match' account number found and the name (or a known name) agrees
|
|
7190
|
+
* 'no_match' account number found but the name does not agree
|
|
7191
|
+
* 'no_account' no such account number
|
|
7192
|
+
*/
|
|
7193
|
+
public function verifyAccount(?string $accountNumber, string $verifiedName, array $knownNames): string
|
|
7194
|
+
{
|
|
7195
|
+
if ($accountNumber === null || trim($accountNumber) === '') {
|
|
7196
|
+
return 'no_account';
|
|
7197
|
+
}
|
|
7198
|
+
$row = DB::table('customers')->where('account_number', trim($accountNumber))->first();
|
|
7199
|
+
if (!$row) {
|
|
7200
|
+
return 'no_account';
|
|
7201
|
+
}
|
|
7202
|
+
$candidates = array_filter(array_merge([$verifiedName], $knownNames), fn ($n) => is_string($n) && trim($n) !== '');
|
|
7203
|
+
foreach ($candidates as $name) {
|
|
7204
|
+
if (strcasecmp(trim($name), $row->full_name) === 0) {
|
|
7205
|
+
return 'match';
|
|
7206
|
+
}
|
|
7207
|
+
}
|
|
7208
|
+
return 'no_match';
|
|
7209
|
+
}
|
|
7210
|
+
|
|
5346
7211
|
/**
|
|
5347
7212
|
* Account number first (authoritative), then name.
|
|
5348
7213
|
*
|
|
@@ -5361,6 +7226,16 @@ class OneAddressStore
|
|
|
5361
7226
|
{
|
|
5362
7227
|
$candidates = array_filter(array_merge([$verifiedName], $knownNames), fn ($n) => is_string($n) && trim($n) !== '');
|
|
5363
7228
|
|
|
7229
|
+
// AN ACCOUNT NUMBER THAT MATCHES NOTHING IS A HARD NO-MATCH, and this
|
|
7230
|
+
// function is where someone will try to make that more forgiving. Do not.
|
|
7231
|
+
// When an account number is supplied it decides the answer alone: if no row
|
|
7232
|
+
// carries it, or the row it carries disagrees with the name, this returns
|
|
7233
|
+
// nothing and does NOT fall through to the name lookup below. A name
|
|
7234
|
+
// matching a DIFFERENT customer's record is not evidence the two are the
|
|
7235
|
+
// same person; it is the likeliest way to write one customer's address onto
|
|
7236
|
+
// another customer's account. The name lookup exists only for partners who
|
|
7237
|
+
// do not use account references at all.
|
|
7238
|
+
|
|
5364
7239
|
if ($accountNumber !== null && trim($accountNumber) !== '') {
|
|
5365
7240
|
$row = DB::table('customers')->where('account_number', trim($accountNumber))->first();
|
|
5366
7241
|
if (!$row) {
|
|
@@ -5412,11 +7287,135 @@ class OneAddressStore
|
|
|
5412
7287
|
}
|
|
5413
7288
|
}
|
|
5414
7289
|
|
|
7290
|
+
/**
|
|
7291
|
+
* Load a partner private key from a PEM (or bare base64 DER) string.
|
|
7292
|
+
* Returns an OpenSSL key resource/object, or false on failure.
|
|
7293
|
+
*/
|
|
7294
|
+
function oaLoadPrivateKey(string $raw)
|
|
7295
|
+
{
|
|
7296
|
+
$keyStr = trim($raw);
|
|
7297
|
+
if (strpos($keyStr, '-----') === false) {
|
|
7298
|
+
$keyStr = "-----BEGIN PRIVATE KEY-----\\n"
|
|
7299
|
+
. chunk_split($keyStr, 64, "\\n")
|
|
7300
|
+
. "-----END PRIVATE KEY-----";
|
|
7301
|
+
}
|
|
7302
|
+
return openssl_pkey_get_private($keyStr);
|
|
7303
|
+
}
|
|
7304
|
+
|
|
7305
|
+
/**
|
|
7306
|
+
* Legacy (non-D5) ECDH + HKDF-SHA256 + AES-256-GCM decrypt of a { ephemeralPublicKey,
|
|
7307
|
+
* iv, ciphertext, hkdfSalt } block \u2014 the same primitive address_encrypted uses, and
|
|
7308
|
+
* the shape account.verify's customer_encrypted block arrives in. Returns the decoded
|
|
7309
|
+
* object, or null on any failure.
|
|
7310
|
+
*/
|
|
7311
|
+
function oaDecryptLegacy(array $enc, $privateKey, string $partnerId): ?array
|
|
7312
|
+
{
|
|
7313
|
+
$b64 = fn (string $s): string => base64_decode(strtr($s, '-_', '+/'));
|
|
7314
|
+
$spkiPrefix = "\\x30\\x59\\x30\\x13\\x06\\x07\\x2a\\x86\\x48\\xce\\x3d\\x02\\x01"
|
|
7315
|
+
. "\\x06\\x08\\x2a\\x86\\x48\\xce\\x3d\\x03\\x01\\x07\\x03\\x42\\x00";
|
|
7316
|
+
|
|
7317
|
+
$ephRaw = $b64($enc['ephemeralPublicKey'] ?? '');
|
|
7318
|
+
if (strlen($ephRaw) !== 65 || ord($ephRaw[0]) !== 0x04) {
|
|
7319
|
+
return null;
|
|
7320
|
+
}
|
|
7321
|
+
$ephPem = "-----BEGIN PUBLIC KEY-----\\n"
|
|
7322
|
+
. chunk_split(base64_encode($spkiPrefix . $ephRaw), 64, "\\n")
|
|
7323
|
+
. "-----END PUBLIC KEY-----";
|
|
7324
|
+
$ephKey = openssl_pkey_get_public($ephPem);
|
|
7325
|
+
if (!$ephKey) {
|
|
7326
|
+
return null;
|
|
7327
|
+
}
|
|
7328
|
+
$shared = openssl_pkey_derive($ephKey, $privateKey);
|
|
7329
|
+
if ($shared === false) {
|
|
7330
|
+
return null;
|
|
7331
|
+
}
|
|
7332
|
+
$saltB64 = $enc['hkdfSalt'] ?? null;
|
|
7333
|
+
$salt = $saltB64 ? $b64($saltB64) : str_repeat("\\x00", 32);
|
|
7334
|
+
$aesKey = hash_hkdf('sha256', $shared, 32, 'oneaddress:' . $partnerId, $salt);
|
|
7335
|
+
|
|
7336
|
+
$ctFull = $b64($enc['ciphertext'] ?? '');
|
|
7337
|
+
$tag = substr($ctFull, -16);
|
|
7338
|
+
$ct = substr($ctFull, 0, -16);
|
|
7339
|
+
$plaintext = openssl_decrypt($ct, 'aes-256-gcm', $aesKey, OPENSSL_RAW_DATA, $b64($enc['iv'] ?? ''), $tag);
|
|
7340
|
+
if ($plaintext === false) {
|
|
7341
|
+
return null;
|
|
7342
|
+
}
|
|
7343
|
+
$data = json_decode($plaintext, true);
|
|
7344
|
+
return is_array($data) ? $data : null;
|
|
7345
|
+
}
|
|
7346
|
+
|
|
7347
|
+
/**
|
|
7348
|
+
* Close the loop after an address.updated is applied, so the consumer's dashboard
|
|
7349
|
+
* flips the service to "Confirmed".
|
|
7350
|
+
*
|
|
7351
|
+
* Only real dispatches carry a POSITIVE-INTEGER id in X-OneAddress-Dispatch;
|
|
7352
|
+
* probes (the go-live "address.test") carry a non-numeric id and have nothing to
|
|
7353
|
+
* confirm, so they are skipped. Never throws and never affects the webhook's own
|
|
7354
|
+
* 200 \u2014 a confirm failure is logged, not surfaced. PHP-FPM has no true
|
|
7355
|
+
* fire-and-forget without a queue; for high volume move this into a queued job
|
|
7356
|
+
* (dispatch(...)->afterResponse()).
|
|
7357
|
+
*
|
|
7358
|
+
* Auth for /api/confirm (all three required):
|
|
7359
|
+
* Authorization: Bearer <secret>
|
|
7360
|
+
* X-OneAddress-Timestamp: <unix seconds>
|
|
7361
|
+
* X-OneAddress-Signature: HMAC-SHA256(secret, "<timestamp>.<rawBody>")
|
|
7362
|
+
* The same secret signs the Bearer and the body, over the EXACT bytes POSTed.
|
|
7363
|
+
*/
|
|
7364
|
+
function oaConfirmToOneAddress(string $oneAddressApi, string $confirmSecret, string $partnerId, string $dispatch, string $status): void
|
|
7365
|
+
{
|
|
7366
|
+
if (!ctype_digit($dispatch) || (int) $dispatch <= 0) {
|
|
7367
|
+
return;
|
|
7368
|
+
}
|
|
7369
|
+
$bodyStr = json_encode([
|
|
7370
|
+
'dispatch_id' => (int) $dispatch,
|
|
7371
|
+
'partner_id' => $partnerId,
|
|
7372
|
+
'status' => $status,
|
|
7373
|
+
'note' => 'Applied by the OneAddress webhook receiver',
|
|
7374
|
+
]);
|
|
7375
|
+
$ts = (string) time();
|
|
7376
|
+
$sig = hash_hmac('sha256', $ts . '.' . $bodyStr, $confirmSecret);
|
|
7377
|
+
|
|
7378
|
+
$ch = curl_init($oneAddressApi . '/api/confirm');
|
|
7379
|
+
curl_setopt_array($ch, [
|
|
7380
|
+
CURLOPT_RETURNTRANSFER => true,
|
|
7381
|
+
CURLOPT_POST => true,
|
|
7382
|
+
CURLOPT_POSTFIELDS => $bodyStr,
|
|
7383
|
+
CURLOPT_HTTPHEADER => [
|
|
7384
|
+
'Content-Type: application/json',
|
|
7385
|
+
'Authorization: Bearer ' . $confirmSecret,
|
|
7386
|
+
'X-OneAddress-Timestamp: ' . $ts,
|
|
7387
|
+
'X-OneAddress-Signature: ' . $sig,
|
|
7388
|
+
],
|
|
7389
|
+
CURLOPT_TIMEOUT => 10,
|
|
7390
|
+
]);
|
|
7391
|
+
curl_exec($ch);
|
|
7392
|
+
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
7393
|
+
curl_close($ch);
|
|
7394
|
+
|
|
7395
|
+
if ($code >= 200 && $code < 300) {
|
|
7396
|
+
\\Log::info('[confirm] acknowledged by OneAddress', ['dispatch_id' => (int) $dispatch, 'status' => $status]);
|
|
7397
|
+
} else {
|
|
7398
|
+
\\Log::error('[confirm] confirm FAILED', ['dispatch_id' => (int) $dispatch, 'http' => $code]);
|
|
7399
|
+
if ($code === 401) {
|
|
7400
|
+
\\Log::error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET to it (from the portal Webhook screen); otherwise your webhook signing secret should work.');
|
|
7401
|
+
}
|
|
7402
|
+
}
|
|
7403
|
+
}
|
|
7404
|
+
|
|
5415
7405
|
Route::post('/webhook', function (Request $request): Response {
|
|
5416
7406
|
$partnerId = env('OA_PARTNER_ID', '%%PARTNER_ID%%');
|
|
5417
7407
|
$webhookSecret = env('OA_WEBHOOK_SECRET', '');
|
|
5418
7408
|
$privateKeyB64 = env('OA_PRIVATE_KEY_PEM', '');
|
|
5419
7409
|
|
|
7410
|
+
// Confirm-callback config. ONEADDRESS_API is the CUSTOMER app (/api/confirm
|
|
7411
|
+
// lives there, NOT the partner portal); CONFIRM_SECRET signs the callback and
|
|
7412
|
+
// falls back to the webhook secret, which is correct for most partners.
|
|
7413
|
+
$oneAddressApi = rtrim(env('ONEADDRESS_API', 'https://oneaddress.io'), '/');
|
|
7414
|
+
$confirmSecret = env('CONFIRM_SECRET') ?: $webhookSecret;
|
|
7415
|
+
// Whether this receiver answers the pre-payment account.verify check. The
|
|
7416
|
+
// wizard bakes your portal declaration into the default; env overrides it.
|
|
7417
|
+
$verifiesAccountReference = filter_var(env('VERIFIES_ACCOUNT_REFERENCE', %%VERIFIES_ACCOUNT_REFERENCE%%), FILTER_VALIDATE_BOOLEAN);
|
|
7418
|
+
|
|
5420
7419
|
$rawBody = $request->getContent();
|
|
5421
7420
|
$timestamp = $request->header('X-OneAddress-Timestamp', '');
|
|
5422
7421
|
$signature = $request->header('X-OneAddress-Signature', '');
|
|
@@ -5441,6 +7440,40 @@ Route::post('/webhook', function (Request $request): Response {
|
|
|
5441
7440
|
|
|
5442
7441
|
$eventType = $body['event'] ?? '';
|
|
5443
7442
|
|
|
7443
|
+
// \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
|
|
7444
|
+
// Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
|
|
7445
|
+
// no address, no session envelope \u2014 handled HERE, ABOVE the payload-less
|
|
7446
|
+
// acknowledge below (account.verify is not a dispatch event, so it would
|
|
7447
|
+
// otherwise be acknowledged as "no payload" and never actually check).
|
|
7448
|
+
if ($eventType === 'account.verify') {
|
|
7449
|
+
// Your portal declaration, baked in by the wizard. If you do not verify
|
|
7450
|
+
// account references, answer "not checked".
|
|
7451
|
+
if (!$verifiesAccountReference) {
|
|
7452
|
+
\\Log::info('[OneAddress] account.verify -> skipped (VERIFIES_ACCOUNT_REFERENCE is false)');
|
|
7453
|
+
return response()->json(['ok' => true, 'skipped' => true]);
|
|
7454
|
+
}
|
|
7455
|
+
$custEnc = $body['customer_encrypted'] ?? null;
|
|
7456
|
+
if (!is_array($custEnc)) {
|
|
7457
|
+
return response()->json(['error' => 'Missing customer_encrypted'], 400);
|
|
7458
|
+
}
|
|
7459
|
+
$accountKey = oaLoadPrivateKey($privateKeyB64);
|
|
7460
|
+
if (!$accountKey) {
|
|
7461
|
+
\\Log::error('[OneAddress] account.verify: failed to load private key');
|
|
7462
|
+
return response()->json(['ok' => false, 'error' => 'decryption_failed'], 422);
|
|
7463
|
+
}
|
|
7464
|
+
$cust = oaDecryptLegacy($custEnc, $accountKey, $partnerId);
|
|
7465
|
+
if ($cust === null) {
|
|
7466
|
+
\\Log::error('[OneAddress] account.verify decryption failed \u2014 check OA_PRIVATE_KEY_PEM');
|
|
7467
|
+
return response()->json(['ok' => false, 'error' => 'decryption_failed'], 422);
|
|
7468
|
+
}
|
|
7469
|
+
$acct = is_string($cust['account_number'] ?? null) ? $cust['account_number'] : null;
|
|
7470
|
+
$name = is_string($cust['name'] ?? null) ? $cust['name'] : '';
|
|
7471
|
+
$known = is_array($cust['known_names'] ?? null) ? $cust['known_names'] : [];
|
|
7472
|
+
$accountStatus = (new OneAddressStore())->verifyAccount($acct, $name, $known);
|
|
7473
|
+
\\Log::info('[OneAddress] account.verify', ['status' => $accountStatus, 'account' => $acct ?? '(none)']);
|
|
7474
|
+
return response()->json(['status' => $accountStatus]);
|
|
7475
|
+
}
|
|
7476
|
+
|
|
5444
7477
|
// address.test / address.test-dispatch (the connection-verification probe)
|
|
5445
7478
|
// must pass this guard so it reaches the decrypt below; a wrong key then
|
|
5446
7479
|
// returns 422 and only a real decrypt reaches the verification answer.
|
|
@@ -5645,7 +7678,16 @@ Route::post('/webhook', function (Request $request): Response {
|
|
|
5645
7678
|
'partner_id' => $partnerId,
|
|
5646
7679
|
'outcome' => $outcome,
|
|
5647
7680
|
]);
|
|
5648
|
-
|
|
7681
|
+
// Close the loop back to OneAddress so the service flips to "Confirmed",
|
|
7682
|
+
// but only when we actually applied the update. Never affects this 200.
|
|
7683
|
+
// \`ok\` MUST REFLECT THE OUTCOME. Hardcoding true (which it did until
|
|
7684
|
+
// 13 Sep 2026) reports a no-match as a successful delivery: the match
|
|
7685
|
+
// above correctly refuses to write and correctly withholds the confirm,
|
|
7686
|
+
// then the wire says the opposite. OneAddress reads THIS.
|
|
7687
|
+
$applied = $outcome === 'applied';
|
|
7688
|
+
oaConfirmToOneAddress($oneAddressApi, $confirmSecret, $partnerId, $dispatchId,
|
|
7689
|
+
$applied ? 'confirmed' : 'failed');
|
|
7690
|
+
return response()->json(['ok' => $applied, 'outcome' => $outcome]);
|
|
5649
7691
|
} elseif ($eventType === 'address.verify') {
|
|
5650
7692
|
$callbackUrl = $body['callback_url'] ?? '';
|
|
5651
7693
|
$callbackToken= $body['callback_token'] ?? '';
|
|
@@ -5700,6 +7742,15 @@ Route::get('/health', function (): \\Illuminate\\Http\\JsonResponse {
|
|
|
5700
7742
|
OA_PARTNER_ID=your-partner-uuid-here
|
|
5701
7743
|
OA_WEBHOOK_SECRET=your-webhook-secret-here
|
|
5702
7744
|
OA_PRIVATE_KEY_PEM=<paste PKCS8 PEM private key here - use \\n between lines>
|
|
7745
|
+
|
|
7746
|
+
# Confirm-callback target \u2014 the customer app, NOT the partner portal.
|
|
7747
|
+
ONEADDRESS_API=https://oneaddress.io
|
|
7748
|
+
|
|
7749
|
+
# Signs the /api/confirm callback. Blank = reuse OA_WEBHOOK_SECRET.
|
|
7750
|
+
CONFIRM_SECRET=
|
|
7751
|
+
|
|
7752
|
+
# Whether the receiver answers the pre-payment account.verify check (true/false).
|
|
7753
|
+
VERIFIES_ACCOUNT_REFERENCE=false
|
|
5703
7754
|
`
|
|
5704
7755
|
},
|
|
5705
7756
|
{
|
|
@@ -5729,8 +7780,16 @@ Your webhook endpoint: \`POST http://localhost:3001/api/webhook\`
|
|
|
5729
7780
|
|
|
5730
7781
|
## Events handled
|
|
5731
7782
|
|
|
7783
|
+
### account.verify (pre-payment account check)
|
|
7784
|
+
Receive \u2192 verify HMAC \u2192 decrypt the customer block \u2192 \`verifyAccount\` \u2192
|
|
7785
|
+
answer \`match\` / \`no_match\` / \`no_account\`. Gated on \`VERIFIES_ACCOUNT_REFERENCE\`
|
|
7786
|
+
(the wizard writes your portal declaration): when off, the receiver answers
|
|
7787
|
+
\`{ "ok": true, "skipped": true }\` ("not checked").
|
|
7788
|
+
|
|
5732
7789
|
### address.updated
|
|
5733
|
-
Receive \u2192 verify HMAC \u2192 decrypt \u2192 persist to your DB
|
|
7790
|
+
Receive \u2192 verify HMAC \u2192 decrypt \u2192 persist to your DB \u2192 POST \`/api/confirm\` on
|
|
7791
|
+
\`ONEADDRESS_API\` (HMAC-signed with \`CONFIRM_SECRET\`) so the consumer's dashboard
|
|
7792
|
+
flips the service to "Confirmed".
|
|
5734
7793
|
|
|
5735
7794
|
### address.verify
|
|
5736
7795
|
Receive \u2192 verify HMAC \u2192 decrypt \u2192 compare to records \u2192 POST callback_url.
|
|
@@ -5780,6 +7839,9 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
|
|
|
5780
7839
|
| \`OA_PARTNER_ID\` | Your partner UUID |
|
|
5781
7840
|
| \`OA_WEBHOOK_SECRET\` | HMAC-SHA256 signing secret |
|
|
5782
7841
|
| \`OA_PRIVATE_KEY_PEM\` | PKCS#8 PEM private key (use \`\\\\n\` between lines) |
|
|
7842
|
+
| \`ONEADDRESS_API\` | Confirm-callback target \u2014 the customer app (default \`https://oneaddress.io\`), NOT the partner portal |
|
|
7843
|
+
| \`CONFIRM_SECRET\` | Signs the \`/api/confirm\` callback. Blank = reuse \`OA_WEBHOOK_SECRET\` |
|
|
7844
|
+
| \`VERIFIES_ACCOUNT_REFERENCE\` | Whether the receiver answers \`account.verify\` (\`true\`/\`false\`) |
|
|
5783
7845
|
`
|
|
5784
7846
|
}
|
|
5785
7847
|
]
|
|
@@ -5816,7 +7878,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
5816
7878
|
|
|
5817
7879
|
// src/register.ts
|
|
5818
7880
|
var import_node_crypto = require("crypto");
|
|
5819
|
-
var PKG_VERSION = true ? "
|
|
7881
|
+
var PKG_VERSION = true ? "2.0.0" : "dev";
|
|
5820
7882
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
5821
7883
|
function hmacSha256(secret, message) {
|
|
5822
7884
|
return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
|
|
@@ -6007,7 +8069,6 @@ async function runDecryptCheck(webhookSecret, partnerId) {
|
|
|
6007
8069
|
|
|
6008
8070
|
// src/install.ts
|
|
6009
8071
|
var import_node_child_process = require("child_process");
|
|
6010
|
-
var import_node_fs2 = require("fs");
|
|
6011
8072
|
var import_node_path2 = require("path");
|
|
6012
8073
|
var COMMANDS = {
|
|
6013
8074
|
"ts-node": { cmd: "npm", args: ["install"] },
|
|
@@ -6015,22 +8076,17 @@ var COMMANDS = {
|
|
|
6015
8076
|
"go-http": { cmd: "go", args: ["mod", "tidy"] },
|
|
6016
8077
|
"php-laravel": { cmd: "composer", args: ["install"] },
|
|
6017
8078
|
"csharp-aspnet": { cmd: "dotnet", args: ["restore"] },
|
|
6018
|
-
|
|
8079
|
+
// Use `mvn`, not `./mvnw` — the Java scaffold ships no Maven wrapper, so
|
|
8080
|
+
// `./mvnw` fails ENOENT for every Java partner. A missing `mvn` on PATH is
|
|
8081
|
+
// reported cleanly (ok=false) with the manual command, which is the honest
|
|
8082
|
+
// failure rather than a cryptic one.
|
|
8083
|
+
"java-spring": { cmd: "mvn", args: ["dependency:resolve", "-q"] }
|
|
6019
8084
|
};
|
|
6020
8085
|
function installDependencies(platform, outputDir) {
|
|
6021
8086
|
const spec = COMMANDS[platform];
|
|
6022
8087
|
if (!spec) {
|
|
6023
8088
|
return { ok: true, output: "", manualCommand: "" };
|
|
6024
8089
|
}
|
|
6025
|
-
if (platform === "java-spring") {
|
|
6026
|
-
const mvnw = (0, import_node_path2.join)(outputDir, "mvnw");
|
|
6027
|
-
if ((0, import_node_fs2.existsSync)(mvnw)) {
|
|
6028
|
-
try {
|
|
6029
|
-
(0, import_node_fs2.chmodSync)(mvnw, 493);
|
|
6030
|
-
} catch {
|
|
6031
|
-
}
|
|
6032
|
-
}
|
|
6033
|
-
}
|
|
6034
8090
|
const cwd = spec.cwd ? (0, import_node_path2.join)(outputDir, spec.cwd) : outputDir;
|
|
6035
8091
|
const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
|
|
6036
8092
|
const result = (0, import_node_child_process.spawnSync)(spec.cmd, spec.args, {
|
|
@@ -6051,15 +8107,14 @@ function installDependencies(platform, outputDir) {
|
|
|
6051
8107
|
|
|
6052
8108
|
// src/autostart.ts
|
|
6053
8109
|
var import_node_child_process2 = require("child_process");
|
|
6054
|
-
var import_node_path3 = require("path");
|
|
6055
|
-
var import_node_fs3 = require("fs");
|
|
6056
8110
|
var COMMANDS2 = {
|
|
6057
8111
|
"ts-node": { cmd: "npx", args: ["tsx", "src/server.ts"] },
|
|
6058
8112
|
"python": { cmd: "uvicorn", args: ["app:app", "--port", "3001"] },
|
|
6059
8113
|
"go-http": { cmd: "go", args: ["run", "."] },
|
|
6060
8114
|
"php-laravel": { cmd: "php", args: ["artisan", "serve", "--port=3001"] },
|
|
6061
8115
|
"csharp-aspnet": { cmd: "dotnet", args: ["run"] },
|
|
6062
|
-
"java-spring": { cmd: "
|
|
8116
|
+
"java-spring": { cmd: "mvn", args: ["spring-boot:run"] }
|
|
8117
|
+
// no mvnw wrapper is scaffolded
|
|
6063
8118
|
};
|
|
6064
8119
|
var serverProcess = null;
|
|
6065
8120
|
var serverOutput = "";
|
|
@@ -6106,20 +8161,11 @@ async function pollHealth(port, timeoutMs) {
|
|
|
6106
8161
|
}
|
|
6107
8162
|
return false;
|
|
6108
8163
|
}
|
|
6109
|
-
async function startServer(platform, outputDir, port = 3001) {
|
|
8164
|
+
async function startServer(platform, outputDir, port = 3001, secrets = {}) {
|
|
6110
8165
|
const spec = COMMANDS2[platform];
|
|
6111
8166
|
if (!spec) {
|
|
6112
8167
|
return { ok: false, output: `No start command defined for platform: ${platform}`, manualCommand: "" };
|
|
6113
8168
|
}
|
|
6114
|
-
if (platform === "java-spring") {
|
|
6115
|
-
const mvnw = (0, import_node_path3.join)(outputDir, "mvnw");
|
|
6116
|
-
if ((0, import_node_fs3.existsSync)(mvnw)) {
|
|
6117
|
-
try {
|
|
6118
|
-
(0, import_node_fs3.chmodSync)(mvnw, 493);
|
|
6119
|
-
} catch {
|
|
6120
|
-
}
|
|
6121
|
-
}
|
|
6122
|
-
}
|
|
6123
8169
|
serverOutput = "";
|
|
6124
8170
|
const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
|
|
6125
8171
|
const PASSTHROUGH_KEYS = [
|
|
@@ -6157,6 +8203,9 @@ async function startServer(platform, outputDir, port = 3001) {
|
|
|
6157
8203
|
const v2 = process.env[key];
|
|
6158
8204
|
if (typeof v2 === "string") childEnv[key] = v2;
|
|
6159
8205
|
}
|
|
8206
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
8207
|
+
if (value) childEnv[key] = value;
|
|
8208
|
+
}
|
|
6160
8209
|
const isWin = process.platform === "win32";
|
|
6161
8210
|
serverProcess = (0, import_node_child_process2.spawn)(
|
|
6162
8211
|
isWin ? "cmd.exe" : spec.cmd,
|
|
@@ -6186,9 +8235,9 @@ async function startServer(platform, outputDir, port = 3001) {
|
|
|
6186
8235
|
// src/tunnel.ts
|
|
6187
8236
|
var import_node_child_process3 = require("child_process");
|
|
6188
8237
|
var import_promises2 = require("fs/promises");
|
|
6189
|
-
var
|
|
8238
|
+
var import_node_fs2 = require("fs");
|
|
6190
8239
|
var import_node_crypto3 = require("crypto");
|
|
6191
|
-
var
|
|
8240
|
+
var import_node_path3 = require("path");
|
|
6192
8241
|
var import_node_os = __toESM(require("os"));
|
|
6193
8242
|
var tunnelProcess = null;
|
|
6194
8243
|
function stopTunnel() {
|
|
@@ -6236,19 +8285,19 @@ async function downloadCloudflared() {
|
|
|
6236
8285
|
if (!spec) {
|
|
6237
8286
|
throw new Error(`No cloudflared asset known for ${key}. Install cloudflared manually or provide your own HTTPS URL when prompted.`);
|
|
6238
8287
|
}
|
|
6239
|
-
const cacheDir = (0,
|
|
8288
|
+
const cacheDir = (0, import_node_path3.join)(import_node_os.default.tmpdir(), `oneaddress-cloudflared-${CLOUDFLARED_VERSION}`);
|
|
6240
8289
|
const binaryName = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
6241
|
-
const finalPath = (0,
|
|
6242
|
-
if (!spec.archive && (0,
|
|
8290
|
+
const finalPath = (0, import_node_path3.join)(cacheDir, binaryName);
|
|
8291
|
+
if (!spec.archive && (0, import_node_fs2.existsSync)(finalPath)) {
|
|
6243
8292
|
try {
|
|
6244
8293
|
const cachedHash = await sha256File(finalPath);
|
|
6245
8294
|
if (cachedHash === spec.sha256) return finalPath;
|
|
6246
8295
|
} catch {
|
|
6247
8296
|
}
|
|
6248
8297
|
}
|
|
6249
|
-
if (spec.archive && (0,
|
|
8298
|
+
if (spec.archive && (0, import_node_fs2.existsSync)(finalPath)) return finalPath;
|
|
6250
8299
|
await (0, import_promises2.mkdir)(cacheDir, { recursive: true });
|
|
6251
|
-
const downloadPath = (0,
|
|
8300
|
+
const downloadPath = (0, import_node_path3.join)(cacheDir, spec.asset);
|
|
6252
8301
|
const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${spec.asset}`;
|
|
6253
8302
|
const res = await fetch(url, { signal: AbortSignal.timeout(6e4) });
|
|
6254
8303
|
if (!res.ok) throw new Error(`Failed to download cloudflared from ${url}: HTTP ${res.status}`);
|
|
@@ -6282,7 +8331,7 @@ Refusing to execute. Try re-running setup, or install cloudflared manually.`
|
|
|
6282
8331
|
`cloudflared archive extraction failed (tar exit ${tarResult.status}): ${tarResult.stderr || tarResult.stdout || "no output"}`
|
|
6283
8332
|
);
|
|
6284
8333
|
}
|
|
6285
|
-
if (!(0,
|
|
8334
|
+
if (!(0, import_node_fs2.existsSync)(finalPath)) {
|
|
6286
8335
|
throw new Error(`cloudflared archive extracted but ${finalPath} not found \u2014 Cloudflare may have changed the tarball layout.`);
|
|
6287
8336
|
}
|
|
6288
8337
|
try {
|
|
@@ -6471,6 +8520,7 @@ async function registerWebhookUrl(partnerId, webhookSecret, webhookUrl) {
|
|
|
6471
8520
|
|
|
6472
8521
|
// src/cli-gate.ts
|
|
6473
8522
|
var MAX_ATTEMPTS = 3;
|
|
8523
|
+
var MAX_TRANSIENT_FAILURES = 5;
|
|
6474
8524
|
var AUTH_URL = "https://partners.oneaddress.io/api/cli/auth";
|
|
6475
8525
|
async function checkToken(token) {
|
|
6476
8526
|
try {
|
|
@@ -6504,6 +8554,7 @@ async function checkToken(token) {
|
|
|
6504
8554
|
}
|
|
6505
8555
|
}
|
|
6506
8556
|
async function cliGate() {
|
|
8557
|
+
let transientFailures = 0;
|
|
6507
8558
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
6508
8559
|
const token = await ge({
|
|
6509
8560
|
message: attempt === 1 ? "OneAddress CLI token (Profile \u2192 CLI Access in the portal)" : `OneAddress CLI token (attempt ${attempt}/${MAX_ATTEMPTS})`,
|
|
@@ -6516,6 +8567,11 @@ async function cliGate() {
|
|
|
6516
8567
|
const result = await checkToken(token);
|
|
6517
8568
|
if (result.ok) return { partnerId: result.partnerId };
|
|
6518
8569
|
if (result.retryable) {
|
|
8570
|
+
transientFailures++;
|
|
8571
|
+
if (transientFailures >= MAX_TRANSIENT_FAILURES) {
|
|
8572
|
+
console.error("\n \x1B[38;2;240;80;80m\u2717\x1B[0m Could not reach the portal after several tries. Check your connection to partners.oneaddress.io and re-run.\n");
|
|
8573
|
+
process.exit(1);
|
|
8574
|
+
}
|
|
6519
8575
|
M2.warn(` ${result.reason}, retry the same token.`);
|
|
6520
8576
|
attempt--;
|
|
6521
8577
|
continue;
|
|
@@ -6614,9 +8670,9 @@ function normalisePrivateKey(raw) {
|
|
|
6614
8670
|
const trimmed = raw.trim();
|
|
6615
8671
|
const isPath = /^(\/|\.\/|\.\.\/|~\/|[A-Za-z]:[/\\])/.test(trimmed) || trimmed.endsWith(".pem");
|
|
6616
8672
|
if (isPath) {
|
|
6617
|
-
const abs = trimmed.startsWith("~/") ? (0,
|
|
6618
|
-
if (!(0,
|
|
6619
|
-
return normalisePrivateKey((0,
|
|
8673
|
+
const abs = trimmed.startsWith("~/") ? (0, import_node_path4.resolve)((0, import_node_os2.homedir)(), trimmed.slice(2)) : (0, import_node_path4.resolve)(trimmed);
|
|
8674
|
+
if (!(0, import_node_fs3.existsSync)(abs)) return { pem: "", error: `File not found: ${abs}` };
|
|
8675
|
+
return normalisePrivateKey((0, import_node_fs3.readFileSync)(abs, "utf8"));
|
|
6620
8676
|
}
|
|
6621
8677
|
const unescaped = trimmed.replaceAll("\\n", "\n");
|
|
6622
8678
|
if (unescaped.includes("-----BEGIN PRIVATE KEY-----")) {
|
|
@@ -6784,7 +8840,7 @@ async function main() {
|
|
|
6784
8840
|
});
|
|
6785
8841
|
assertNotCancelled(outputDir);
|
|
6786
8842
|
outDir = outputDir.trim() || "./oneaddress-webhook";
|
|
6787
|
-
if (!(0,
|
|
8843
|
+
if (!(0, import_node_fs3.existsSync)(outDir) || (0, import_node_fs3.readdirSync)(outDir).length === 0) break;
|
|
6788
8844
|
const overwrite = await ye({
|
|
6789
8845
|
message: `${outDir} already has files \u2014 overwrite?`,
|
|
6790
8846
|
initialValue: false
|
|
@@ -6862,7 +8918,12 @@ async function main() {
|
|
|
6862
8918
|
const s3 = Y2();
|
|
6863
8919
|
s3.start(`Starting server (waiting up to 15 s for /health on :${SERVER_PORT})`);
|
|
6864
8920
|
onCleanup(stopServer);
|
|
6865
|
-
const start = await startServer(platform, outDir, SERVER_PORT
|
|
8921
|
+
const start = await startServer(platform, outDir, SERVER_PORT, {
|
|
8922
|
+
WEBHOOK_SECRET: secret,
|
|
8923
|
+
PARTNER_ID: pid,
|
|
8924
|
+
PARTNER_PRIVATE_KEY_PEM: privateKey,
|
|
8925
|
+
VERIFIES_ACCOUNT_REFERENCE: String(verifiesAccountReference)
|
|
8926
|
+
});
|
|
6866
8927
|
let serverRunning = false;
|
|
6867
8928
|
if (start.ok) {
|
|
6868
8929
|
s3.stop(`Server is healthy on port ${SERVER_PORT}`);
|
|
@@ -7083,7 +9144,7 @@ ${DIM2} Stopped.${R3}
|
|
|
7083
9144
|
}
|
|
7084
9145
|
|
|
7085
9146
|
// src/non-interactive.ts
|
|
7086
|
-
var
|
|
9147
|
+
var import_node_fs4 = require("fs");
|
|
7087
9148
|
var PLATFORMS = [
|
|
7088
9149
|
"ts-node",
|
|
7089
9150
|
"python",
|
|
@@ -7136,7 +9197,7 @@ function parseArgs(argv2) {
|
|
|
7136
9197
|
}
|
|
7137
9198
|
return out;
|
|
7138
9199
|
}
|
|
7139
|
-
function resolveConfig(args, env, normalisePrivateKey2, readFile2 = (p2) => (0,
|
|
9200
|
+
function resolveConfig(args, env, normalisePrivateKey2, readFile2 = (p2) => (0, import_node_fs4.readFileSync)(p2, "utf8")) {
|
|
7140
9201
|
const errors = [];
|
|
7141
9202
|
for (const flag of args.secretFlagsUsed) {
|
|
7142
9203
|
errors.push(
|
|
@@ -7238,7 +9299,7 @@ ${usage()}`);
|
|
|
7238
9299
|
process.exit(1);
|
|
7239
9300
|
}
|
|
7240
9301
|
const { partnerId, secret, privateKey, platform, outDir, webhookUrl, force } = resolved.config;
|
|
7241
|
-
if ((0,
|
|
9302
|
+
if ((0, import_node_fs5.existsSync)(outDir) && (0, import_node_fs5.readdirSync)(outDir).length > 0 && !force) {
|
|
7242
9303
|
console.error(`[oneaddress/setup] ${outDir} is not empty. Pass --force to overwrite.`);
|
|
7243
9304
|
process.exit(1);
|
|
7244
9305
|
}
|