@oneaddress/setup 1.4.1 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +562 -158
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
|
|
|
856
856
|
var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
|
|
857
857
|
var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
|
|
858
858
|
var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
|
|
859
|
-
var WIZARD_VERSION = true ? "1.
|
|
859
|
+
var WIZARD_VERSION = true ? "1.6.1" : "?";
|
|
860
860
|
function printCompactHeader() {
|
|
861
861
|
const INNER = 42;
|
|
862
862
|
const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
|
|
@@ -950,6 +950,10 @@ PORT=3001
|
|
|
950
950
|
content: `.env
|
|
951
951
|
node_modules/
|
|
952
952
|
dist/
|
|
953
|
+
# The SQLite database holds customer addresses (PII) \u2014 never commit it.
|
|
954
|
+
data.db
|
|
955
|
+
data.db-wal
|
|
956
|
+
data.db-shm
|
|
953
957
|
`
|
|
954
958
|
},
|
|
955
959
|
{
|
|
@@ -968,7 +972,8 @@ dist/
|
|
|
968
972
|
"dependencies": {
|
|
969
973
|
"@oneaddress/partner-sdk": "^1.6.3",
|
|
970
974
|
"dotenv": "^16.0.0",
|
|
971
|
-
"express": "^4.18.0"
|
|
975
|
+
"express": "^4.18.0",
|
|
976
|
+
"express-rate-limit": "^8.6.2"
|
|
972
977
|
},
|
|
973
978
|
"devDependencies": {
|
|
974
979
|
"@types/express": "^4.17.0",
|
|
@@ -979,6 +984,28 @@ dist/
|
|
|
979
984
|
},
|
|
980
985
|
"engines": { "node": ">=22.5" }
|
|
981
986
|
}
|
|
987
|
+
`
|
|
988
|
+
},
|
|
989
|
+
{
|
|
990
|
+
name: "oneaddress.config.json",
|
|
991
|
+
content: `{
|
|
992
|
+
"//": "Written by npx @oneaddress/setup, read by src/config.ts. Non-secret config only \u2014 secrets live in .env. Any field can be overridden by an env var of the matching name (PARTNER_ID / ONEADDRESS_API / VERIFIES_ACCOUNT_REFERENCE).",
|
|
993
|
+
"partnerId": "%%PARTNER_ID%%",
|
|
994
|
+
"oneAddressApi": "%%ONEADDRESS_API%%",
|
|
995
|
+
"verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE%%
|
|
996
|
+
}
|
|
997
|
+
`
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
name: "customers.json",
|
|
1001
|
+
content: `{
|
|
1002
|
+
"//": "YOUR CUSTOMER ROSTER \u2014 edit this file to your customers (account number, name, and the address you hold on file today). src/store.ts seeds from here on startup; point loadRoster at your real customer database when you outgrow the file. Override the path with the ONEADDRESS_CUSTOMERS env var.",
|
|
1003
|
+
"customers": [
|
|
1004
|
+
{ "account_number": "AUR-583920", "name": "Tim Hooper", "address": { "street": "12 Old Mill Road", "suburb": "Parramatta", "state": "NSW", "postcode": "2150" } },
|
|
1005
|
+
{ "account_number": "AUR-104772", "name": "Olivia Robinson", "address": { "street": "5 Rosewood Lane", "suburb": "Brisbane", "state": "QLD", "postcode": "4000" } },
|
|
1006
|
+
{ "account_number": "AUR-296815", "name": "Marcus Chen", "address": { "street": "31 Goldfield Drive", "suburb": "Richmond", "state": "VIC", "postcode": "3121" } }
|
|
1007
|
+
]
|
|
1008
|
+
}
|
|
982
1009
|
`
|
|
983
1010
|
},
|
|
984
1011
|
{
|
|
@@ -1019,9 +1046,10 @@ dist/
|
|
|
1019
1046
|
* Uses the Node.js built-in \`node:sqlite\` module (available in Node 22.5+).
|
|
1020
1047
|
* No extra packages or native compilation required.
|
|
1021
1048
|
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1049
|
+
* This module only OPENS the database and exports the handle. The schema \u2014 the
|
|
1050
|
+
* customer roster plus the append-only address-history audit trail \u2014 is created
|
|
1051
|
+
* by src/store.ts, which OWNS those tables, so everything about how your data is
|
|
1052
|
+
* shaped lives in the one file you are meant to edit.
|
|
1025
1053
|
*
|
|
1026
1054
|
* The database file lives at DB_PATH (default: data.db in the project root).
|
|
1027
1055
|
* Change the location with the DB_PATH environment variable.
|
|
@@ -1036,36 +1064,109 @@ const db = new DatabaseSync(DB_PATH);
|
|
|
1036
1064
|
// WAL mode \u2014 better performance for concurrent reads
|
|
1037
1065
|
db.exec("PRAGMA journal_mode = WAL");
|
|
1038
1066
|
|
|
1039
|
-
// customer_key is the row identity. The consumer's email never travels in the
|
|
1040
|
-
// clear on either path \u2014 identity comes from the decrypted payload, which
|
|
1041
|
-
// carries account_number and name but no email (2026.2 removed the last
|
|
1042
|
-
// cleartext block, on address.verify). So we key on account_number when
|
|
1043
|
-
// present, falling back to email only for legacy rows. email is kept as a
|
|
1044
|
-
// descriptive column, nullable, no longer the primary key.
|
|
1045
|
-
db.exec(\`
|
|
1046
|
-
CREATE TABLE IF NOT EXISTS addresses (
|
|
1047
|
-
customer_key TEXT PRIMARY KEY,
|
|
1048
|
-
email TEXT,
|
|
1049
|
-
name TEXT NOT NULL DEFAULT '',
|
|
1050
|
-
address TEXT NOT NULL DEFAULT '{}',
|
|
1051
|
-
dispatch_id TEXT,
|
|
1052
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1053
|
-
);
|
|
1054
|
-
|
|
1055
|
-
CREATE TABLE IF NOT EXISTS address_history (
|
|
1056
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1057
|
-
customer_key TEXT NOT NULL,
|
|
1058
|
-
email TEXT,
|
|
1059
|
-
name TEXT NOT NULL DEFAULT '',
|
|
1060
|
-
address TEXT NOT NULL DEFAULT '{}',
|
|
1061
|
-
dispatch_id TEXT,
|
|
1062
|
-
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1063
|
-
);
|
|
1064
|
-
\`);
|
|
1065
|
-
|
|
1066
1067
|
console.log(\`[db] SQLite database ready \u2192 \${DB_PATH}\`);
|
|
1067
1068
|
|
|
1068
1069
|
export default db;
|
|
1070
|
+
`
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
name: "src/config.ts",
|
|
1074
|
+
content: `/**
|
|
1075
|
+
* src/config.ts \u2014 the configuration you set in SETUP, read by the handler.
|
|
1076
|
+
*
|
|
1077
|
+
* \`npx @oneaddress/setup\` writes oneaddress.config.json next to your .env. This
|
|
1078
|
+
* module is the ONE place the receiver reads it, so what you chose during setup
|
|
1079
|
+
* drives the running handler with no code to edit. Secrets stay in .env; this
|
|
1080
|
+
* file is non-secret behaviour only.
|
|
1081
|
+
*
|
|
1082
|
+
* Precedence for every field: an explicit environment variable wins (a one-off
|
|
1083
|
+
* override), then oneaddress.config.json (what setup wrote), then a built-in
|
|
1084
|
+
* default. A missing config file is not an error \u2014 the handler still runs.
|
|
1085
|
+
*/
|
|
1086
|
+
import { readFileSync } from 'node:fs';
|
|
1087
|
+
import { join } from 'node:path';
|
|
1088
|
+
|
|
1089
|
+
export type ReceiverConfig = {
|
|
1090
|
+
partnerId: string;
|
|
1091
|
+
oneAddressApi: string;
|
|
1092
|
+
verifiesAccountReference: boolean;
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
const DEFAULTS: ReceiverConfig = {
|
|
1096
|
+
partnerId: '',
|
|
1097
|
+
oneAddressApi: 'https://oneaddress.io',
|
|
1098
|
+
// A receiver that can match, matches. The wizard writes an EXPLICIT value here
|
|
1099
|
+
// from your portal declaration, so this default only bites if the config file
|
|
1100
|
+
// is missing entirely \u2014 in which case answering account.verify is the safer,
|
|
1101
|
+
// more useful default than silently returning "not checked".
|
|
1102
|
+
verifiesAccountReference: true,
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
function stripTrailingSlash(s: string): string {
|
|
1106
|
+
return s.endsWith('/') ? s.slice(0, -1) : s;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function loadConfigFile(): Partial<ReceiverConfig> {
|
|
1110
|
+
const path = process.env.ONEADDRESS_CONFIG ?? join(process.cwd(), 'oneaddress.config.json');
|
|
1111
|
+
try {
|
|
1112
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
|
|
1113
|
+
const out: Partial<ReceiverConfig> = {};
|
|
1114
|
+
if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
|
|
1115
|
+
if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
|
|
1116
|
+
if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
|
|
1117
|
+
return out;
|
|
1118
|
+
} catch {
|
|
1119
|
+
// No config file (or unreadable / malformed): fall back to env + defaults.
|
|
1120
|
+
return {};
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const fromFile = loadConfigFile();
|
|
1125
|
+
|
|
1126
|
+
export const config: ReceiverConfig = {
|
|
1127
|
+
partnerId: process.env.PARTNER_ID || fromFile.partnerId || DEFAULTS.partnerId,
|
|
1128
|
+
oneAddressApi: stripTrailingSlash(process.env.ONEADDRESS_API || fromFile.oneAddressApi || DEFAULTS.oneAddressApi),
|
|
1129
|
+
verifiesAccountReference:
|
|
1130
|
+
process.env.VERIFIES_ACCOUNT_REFERENCE != null
|
|
1131
|
+
? process.env.VERIFIES_ACCOUNT_REFERENCE === 'true'
|
|
1132
|
+
: (fromFile.verifiesAccountReference ?? DEFAULTS.verifiesAccountReference),
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
console.log(
|
|
1136
|
+
'[config] loaded (oneAddressApi=' + config.oneAddressApi +
|
|
1137
|
+
', verifiesAccountReference=' + config.verifiesAccountReference + ')',
|
|
1138
|
+
);
|
|
1139
|
+
`
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
name: "src/callback-url.ts",
|
|
1143
|
+
content: `/**
|
|
1144
|
+
* src/callback-url.ts \u2014 the address.verify callback host allowlist.
|
|
1145
|
+
*
|
|
1146
|
+
* OneAddress sends the \`callback_url\` inside the signed webhook body, so HMAC
|
|
1147
|
+
* verification already proves it came from OneAddress. This host allowlist is a
|
|
1148
|
+
* belt-and-braces against a leaked-webhook-secret SSRF: an attacker who could
|
|
1149
|
+
* forge a webhook must not be able to coerce this server into POSTing to an
|
|
1150
|
+
* internal URL (a cloud metadata service, a database admin port, \u2026).
|
|
1151
|
+
*
|
|
1152
|
+
* It returns the validated URL string (or null) rather than a boolean on
|
|
1153
|
+
* purpose: the caller fetches the RETURN VALUE, so the allowlist sits directly
|
|
1154
|
+
* on the taint path and is a provable barrier for CodeQL's request-forgery
|
|
1155
|
+
* query (see .github/codeql/extensions/oneaddress-js-models \u2014 the same shape as
|
|
1156
|
+
* the redirect sanitiser). A boolean guard would leave the raw, still-tainted
|
|
1157
|
+
* value flowing to fetch, which reads as an SSRF whether or not the guard runs.
|
|
1158
|
+
*/
|
|
1159
|
+
export function safeOneAddressCallbackUrl(raw: string): string | null {
|
|
1160
|
+
try {
|
|
1161
|
+
const u = new URL(raw);
|
|
1162
|
+
if (u.protocol !== 'https:') return null;
|
|
1163
|
+
const host = u.hostname.toLowerCase();
|
|
1164
|
+
if (host === 'oneaddress.io' || host.endsWith('.oneaddress.io')) return raw;
|
|
1165
|
+
return null;
|
|
1166
|
+
} catch {
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1069
1170
|
`
|
|
1070
1171
|
},
|
|
1071
1172
|
{
|
|
@@ -1074,109 +1175,241 @@ export default db;
|
|
|
1074
1175
|
* \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
|
|
1075
1176
|
* \u2551 src/store.ts \u2014 DATABASE INTEGRATION \u2551
|
|
1076
1177
|
* \u2551 \u2551
|
|
1077
|
-
* \u2551
|
|
1078
|
-
* \u2551
|
|
1079
|
-
* \u2551
|
|
1178
|
+
* \u2551 A realistic partner-side store: a CUSTOMER ROSTER (who your \u2551
|
|
1179
|
+
* \u2551 customers are + the address you hold on file for each), and the \u2551
|
|
1180
|
+
* \u2551 three hooks OneAddress calls: \u2551
|
|
1181
|
+
* \u2551 verifyAccount \u2014 pre-payment account check (account.verify) \u2551
|
|
1182
|
+
* \u2551 verifyAddress \u2014 is your on-file address current? (address.verify)
|
|
1183
|
+
* \u2551 saveAddress \u2014 apply a new address (address.updated) \u2551
|
|
1080
1184
|
* \u2551 \u2551
|
|
1081
|
-
* \u2551
|
|
1082
|
-
* \u2551
|
|
1185
|
+
* \u2551 Swap the SQLite queries for your real customer database when \u2551
|
|
1186
|
+
* \u2551 you're ready \u2014 the shapes below are what OneAddress hands you. \u2551
|
|
1083
1187
|
* \u2551 server.ts calls these after verifying and decrypting each event \u2551
|
|
1084
1188
|
* \u2551 \u2014 the protocol layer is handled for you, never touch it. \u2551
|
|
1085
1189
|
* \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
|
|
1086
1190
|
*/
|
|
1191
|
+
import { readFileSync } from 'node:fs';
|
|
1192
|
+
import { join } from 'node:path';
|
|
1087
1193
|
import db from './db.js';
|
|
1088
1194
|
|
|
1089
1195
|
export type Address = Record<string, unknown>;
|
|
1090
1196
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1197
|
+
// \u2500\u2500 Schema \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1198
|
+
// \`customers\` is your roster: the account number, the customer's name, and the
|
|
1199
|
+
// address YOU currently hold on file for them. \`address_history\` logs every
|
|
1200
|
+
// change you apply, so you have an audit trail.
|
|
1201
|
+
db.exec(\`
|
|
1202
|
+
CREATE TABLE IF NOT EXISTS customers (
|
|
1203
|
+
account_number TEXT PRIMARY KEY,
|
|
1204
|
+
name TEXT NOT NULL,
|
|
1205
|
+
address TEXT NOT NULL DEFAULT '{}',
|
|
1206
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1207
|
+
);
|
|
1208
|
+
|
|
1209
|
+
CREATE TABLE IF NOT EXISTS address_history (
|
|
1210
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1211
|
+
account_number TEXT NOT NULL,
|
|
1212
|
+
address TEXT NOT NULL,
|
|
1213
|
+
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1214
|
+
);
|
|
1215
|
+
\`);
|
|
1216
|
+
|
|
1217
|
+
// Self-migrate. An older data.db may already hold a \`customers\` table WITHOUT the
|
|
1218
|
+
// newer columns, and \`CREATE TABLE IF NOT EXISTS\` never alters an existing table.
|
|
1219
|
+
// Add any missing columns here so the handler upgrades its own schema instead of
|
|
1220
|
+
// forcing you to delete the database on every change \u2014 the behaviour a
|
|
1221
|
+
// production integration needs.
|
|
1222
|
+
function ensureColumn(table: string, column: string, definition: string): void {
|
|
1223
|
+
const cols = db.prepare(\`PRAGMA table_info(\${table})\`).all() as Array<{ name: string }>;
|
|
1224
|
+
if (!cols.some((c) => c.name === column)) {
|
|
1225
|
+
db.exec(\`ALTER TABLE \${table} ADD COLUMN \${column} \${definition}\`);
|
|
1226
|
+
console.log(\`[store] migrated: added column \${table}.\${column}\`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
ensureColumn('customers', 'address', "TEXT NOT NULL DEFAULT '{}'");
|
|
1230
|
+
ensureColumn('customers', 'updated_at', 'TEXT'); // nullable on migrate; set on write
|
|
1231
|
+
|
|
1232
|
+
/* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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
|
+
* YOUR CUSTOMER ROSTER \u2014 data, not code (customers.json)
|
|
1093
1234
|
*
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1235
|
+
* Your customers live in customers.json (written by \`npx @oneaddress/setup\`),
|
|
1236
|
+
* so you EDIT THAT FILE \u2014 never this one \u2014 to change who you know. Each entry
|
|
1237
|
+
* is an account number, the customer's name, and the address you hold on file
|
|
1238
|
+
* today, so a first update from OneAddress shows as a real change (mismatch \u2192
|
|
1239
|
+
* update \u2192 match) rather than magically already matching. Point \`loadRoster\`
|
|
1240
|
+
* at your real customer database when you outgrow the file.
|
|
1098
1241
|
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
|
|
1242
|
+
* Seeding rule (deliberate): the NAME is refreshed on every start, but the
|
|
1243
|
+
* ADDRESS is only seeded the first time a customer is inserted \u2014 so an update
|
|
1244
|
+
* you actually RECEIVE survives a restart and is not clobbered back to the
|
|
1245
|
+
* seed. To reset a customer's on-file address for a fresh test, delete its row
|
|
1246
|
+
* (or delete data.db) and restart.
|
|
1247
|
+
* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
1248
|
+
type RosterEntry = { account_number: string; name: string; address: Address };
|
|
1249
|
+
|
|
1250
|
+
// Used only if customers.json is missing or unreadable, so the handler still
|
|
1251
|
+
// runs out of the box. The wizard writes customers.json with these same demo
|
|
1252
|
+
// customers \u2014 edit that file, not this list.
|
|
1253
|
+
const DEFAULT_ROSTER: RosterEntry[] = [
|
|
1254
|
+
{ account_number: 'AUR-583920', name: 'Tim Hooper', address: { street: '12 Old Mill Road', suburb: 'Parramatta', state: 'NSW', postcode: '2150' } },
|
|
1255
|
+
{ account_number: 'AUR-104772', name: 'Olivia Robinson', address: { street: '5 Rosewood Lane', suburb: 'Brisbane', state: 'QLD', postcode: '4000' } },
|
|
1256
|
+
{ account_number: 'AUR-296815', name: 'Marcus Chen', address: { street: '31 Goldfield Drive', suburb: 'Richmond', state: 'VIC', postcode: '3121' } },
|
|
1257
|
+
];
|
|
1258
|
+
|
|
1259
|
+
function loadRoster(): RosterEntry[] {
|
|
1260
|
+
const path = process.env.ONEADDRESS_CUSTOMERS ?? join(process.cwd(), 'customers.json');
|
|
1261
|
+
try {
|
|
1262
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
|
1263
|
+
const list = Array.isArray(parsed)
|
|
1264
|
+
? parsed
|
|
1265
|
+
: (parsed && typeof parsed === 'object' && Array.isArray((parsed as { customers?: unknown }).customers))
|
|
1266
|
+
? (parsed as { customers: unknown[] }).customers
|
|
1267
|
+
: null;
|
|
1268
|
+
if (!list) throw new Error('expected an array or { "customers": [ ... ] }');
|
|
1269
|
+
const roster = list
|
|
1270
|
+
.map((c) => c as Record<string, unknown>)
|
|
1271
|
+
.filter((c) => typeof c.account_number === 'string' && typeof c.name === 'string')
|
|
1272
|
+
.map((c) => ({
|
|
1273
|
+
account_number: String(c.account_number),
|
|
1274
|
+
name: String(c.name),
|
|
1275
|
+
address: (c.address && typeof c.address === 'object') ? c.address as Address : {},
|
|
1276
|
+
}));
|
|
1277
|
+
if (roster.length === 0) throw new Error('no valid customers in the file');
|
|
1278
|
+
return roster;
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1281
|
+
console.warn(\`[store] customers.json not loaded (\${msg}) \u2014 using the built-in demo roster. Edit customers.json to set your customers.\`);
|
|
1282
|
+
return DEFAULT_ROSTER;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const ROSTER = loadRoster();
|
|
1287
|
+
{
|
|
1288
|
+
const upsert = db.prepare(\`
|
|
1289
|
+
INSERT INTO customers (account_number, name, address) VALUES ($account_number, $name, $address)
|
|
1290
|
+
ON CONFLICT(account_number) DO UPDATE SET name = excluded.name
|
|
1291
|
+
\`);
|
|
1292
|
+
for (const c of ROSTER) {
|
|
1293
|
+
upsert.run({ account_number: c.account_number, name: c.name, address: JSON.stringify(c.address) });
|
|
1294
|
+
}
|
|
1295
|
+
console.log(\`[store] roster ready (\${ROSTER.length} customers)\`);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// \u2500\u2500 Identity handed in by server.ts after it verifies + decrypts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1102
1299
|
export type Customer = {
|
|
1103
|
-
email:
|
|
1104
|
-
name:
|
|
1105
|
-
accountNumber
|
|
1106
|
-
knownNames?:
|
|
1300
|
+
email: string | null;
|
|
1301
|
+
name: string;
|
|
1302
|
+
accountNumber?: string;
|
|
1303
|
+
knownNames?: string[];
|
|
1107
1304
|
/**
|
|
1108
1305
|
* D5 LOA reference \u2014 the base64url SHA-256 of the signed consent, recomputed
|
|
1109
1306
|
* by server.ts after decrypting \`loa_encrypted\`. A production integration
|
|
1110
1307
|
* echoes this in the \`/api/confirm\` callback so OneAddress can verify
|
|
1111
1308
|
* proof-of-receipt. Null on legacy dispatches that carry no encrypted LOA.
|
|
1309
|
+
* The store ignores it; it rides along on the identity object for convenience.
|
|
1112
1310
|
*/
|
|
1113
|
-
loaRef?:
|
|
1311
|
+
loaRef?: string | null;
|
|
1114
1312
|
};
|
|
1115
1313
|
|
|
1116
|
-
export type VerifyResult = 'match' | 'mismatch' | 'not_found';
|
|
1117
|
-
|
|
1118
|
-
/** Row identity: account number when we have one, else email. */
|
|
1119
|
-
function customerKey(customer: Customer): string {
|
|
1120
|
-
return customer.accountNumber || customer.email || '';
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
1314
|
/**
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
* to \`address_history\` for the audit trail.
|
|
1315
|
+
* Canonical, order- and case-insensitive form of an address, so two addresses
|
|
1316
|
+
* compare equal iff they mean the same thing regardless of key order or casing.
|
|
1317
|
+
* Compares the WHOLE object, so it works whatever fields OneAddress sends.
|
|
1128
1318
|
*/
|
|
1129
|
-
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1319
|
+
function canonicalAddress(a: Address): string {
|
|
1320
|
+
const entries = Object.entries(a)
|
|
1321
|
+
.filter(([, v]) => typeof v === 'string' && (v as string).trim() !== '')
|
|
1322
|
+
.map(([k, v]) => [k.toLowerCase(), (v as string).trim().toLowerCase().replace(/\\s+/g, ' ')] as [string, string])
|
|
1323
|
+
.sort((x, y) => x[0].localeCompare(y[0]));
|
|
1324
|
+
return JSON.stringify(entries);
|
|
1325
|
+
}
|
|
1132
1326
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1327
|
+
function findCustomer(accountNumber: string | undefined, name: string): { account_number: string; name: string; address: string } | undefined {
|
|
1328
|
+
const acct = (accountNumber ?? '').trim();
|
|
1329
|
+
if (acct) {
|
|
1330
|
+
const byAcct = db.prepare('SELECT account_number, name, address FROM customers WHERE account_number = ?').get(acct);
|
|
1331
|
+
if (byAcct) return byAcct as { account_number: string; name: string; address: string };
|
|
1332
|
+
}
|
|
1333
|
+
const n = name.trim().toLowerCase();
|
|
1334
|
+
if (n) {
|
|
1335
|
+
const byName = db.prepare('SELECT account_number, name, address FROM customers WHERE LOWER(name) = ?').get(n);
|
|
1336
|
+
if (byName) return byName as { account_number: string; name: string; address: string };
|
|
1337
|
+
}
|
|
1338
|
+
return undefined;
|
|
1339
|
+
}
|
|
1142
1340
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
VALUES ($key, $email, $name, $address, datetime('now'))
|
|
1146
|
-
\`).run({ key, email: customer.email, name: customer.name, address: addressJson });
|
|
1341
|
+
// \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
|
|
1342
|
+
export type AccountVerdict = 'match' | 'no_match' | 'no_account';
|
|
1147
1343
|
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1344
|
+
/**
|
|
1345
|
+
* Confirms the typed account number is really one of yours and the name agrees,
|
|
1346
|
+
* BEFORE the consumer pays. The boundary that stops someone pushing an update to
|
|
1347
|
+
* an account that isn't theirs.
|
|
1348
|
+
* 'match' \u2014 account number found and the name agrees
|
|
1349
|
+
* 'no_match' \u2014 account number found but the name does not agree
|
|
1350
|
+
* 'no_account' \u2014 no such account number
|
|
1351
|
+
*/
|
|
1352
|
+
export function verifyAccount(accountNumber: string | null, name: string, knownNames: string[] = []): AccountVerdict {
|
|
1353
|
+
const acct = (accountNumber ?? '').trim();
|
|
1354
|
+
if (!acct) return 'no_account';
|
|
1355
|
+
const row = db.prepare('SELECT name FROM customers WHERE account_number = ?').get(acct) as { name: string } | undefined;
|
|
1356
|
+
if (!row) return 'no_account';
|
|
1357
|
+
const stored = row.name.trim().toLowerCase();
|
|
1358
|
+
const candidates = [name, ...knownNames].map(v => (v ?? '').trim().toLowerCase()).filter(Boolean);
|
|
1359
|
+
return candidates.includes(stored) ? 'match' : 'no_match';
|
|
1151
1360
|
}
|
|
1152
1361
|
|
|
1362
|
+
// \u2500\u2500 address.verify: is your on-file address current? \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1363
|
+
export type VerifyResult = 'match' | 'mismatch' | 'not_found';
|
|
1364
|
+
|
|
1153
1365
|
/**
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
* 'match' \u2014 the address matches your records exactly
|
|
1161
|
-
* 'mismatch' \u2014 you have a record but the address differs
|
|
1162
|
-
* 'not_found' \u2014 you have no record for this customer at all
|
|
1366
|
+
* Compares the consumer's current OneAddress address against what YOU hold.
|
|
1367
|
+
* 'match' \u2014 you already hold this exact address (no update needed)
|
|
1368
|
+
* 'mismatch' \u2014 you know the customer, but hold a different address (send me one)
|
|
1369
|
+
* 'not_found' \u2014 this account is not one of yours at all
|
|
1370
|
+
* A brand-new connection you have never updated returns 'mismatch', which is the
|
|
1371
|
+
* honest answer: you know the customer, you just don't have THIS address yet.
|
|
1163
1372
|
*/
|
|
1164
|
-
export async function verifyAddress(customer: Customer,
|
|
1165
|
-
const
|
|
1166
|
-
const row = db.prepare(\`SELECT address FROM addresses WHERE customer_key = ?\`)
|
|
1167
|
-
.get(key) as { address: string } | undefined;
|
|
1168
|
-
|
|
1373
|
+
export async function verifyAddress(customer: Customer, incoming: Address): Promise<VerifyResult> {
|
|
1374
|
+
const row = findCustomer(customer.accountNumber, customer.name);
|
|
1169
1375
|
if (!row) {
|
|
1170
|
-
console.log(\`[store] verifyAddress \u2192 not_found (\${
|
|
1376
|
+
console.log(\`[store] verifyAddress \u2192 not_found (\${customer.accountNumber || customer.name || '(none)'})\`);
|
|
1171
1377
|
return 'not_found';
|
|
1172
1378
|
}
|
|
1379
|
+
let stored: Address = {};
|
|
1380
|
+
try { stored = JSON.parse(row.address) as Address; } catch { /* corrupt row \u2192 treat as empty */ }
|
|
1381
|
+
const result: VerifyResult = canonicalAddress(stored) === canonicalAddress(incoming) ? 'match' : 'mismatch';
|
|
1382
|
+
console.log(\`[store] verifyAddress \u2192 \${result} (\${row.account_number})\`);
|
|
1383
|
+
return result;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// \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
|
+
/**
|
|
1388
|
+
* Applies a new address to the customer's record and logs the change. Keyed on
|
|
1389
|
+
* the account number (falling back to name); this is called only after the
|
|
1390
|
+
* consumer paid and IDV passed, and only for an account that matched.
|
|
1391
|
+
*/
|
|
1392
|
+
export async function saveAddress(customer: Customer, incoming: Address): Promise<void> {
|
|
1393
|
+
const acct = (customer.accountNumber ?? '').trim() || customer.name.trim();
|
|
1394
|
+
const addressJson = JSON.stringify(incoming);
|
|
1173
1395
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1396
|
+
db.prepare(\`
|
|
1397
|
+
INSERT INTO customers (account_number, name, address, updated_at)
|
|
1398
|
+
VALUES ($account_number, $name, $address, datetime('now'))
|
|
1399
|
+
ON CONFLICT(account_number) DO UPDATE SET
|
|
1400
|
+
name = excluded.name,
|
|
1401
|
+
address = excluded.address,
|
|
1402
|
+
updated_at = excluded.updated_at
|
|
1403
|
+
\`).run({ account_number: acct, name: customer.name, address: addressJson });
|
|
1177
1404
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1405
|
+
db.prepare(\`
|
|
1406
|
+
INSERT INTO address_history (account_number, address) VALUES ($account_number, $address)
|
|
1407
|
+
\`).run({ account_number: acct, address: addressJson });
|
|
1408
|
+
|
|
1409
|
+
// Metadata only \u2014 the address itself is PII, so we log the key, never the
|
|
1410
|
+
// address. Centralised log aggregation turns every log line into a
|
|
1411
|
+
// data-exposure surface for customer addresses.
|
|
1412
|
+
console.log(\`[store] saved address for \${acct}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
|
|
1180
1413
|
}
|
|
1181
1414
|
`
|
|
1182
1415
|
},
|
|
@@ -1204,7 +1437,8 @@ if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 5)) {
|
|
|
1204
1437
|
|
|
1205
1438
|
import 'dotenv/config';
|
|
1206
1439
|
import express, { Request, Response } from 'express';
|
|
1207
|
-
import
|
|
1440
|
+
import rateLimit from 'express-rate-limit';
|
|
1441
|
+
import { createPrivateKey, createHmac } from 'node:crypto';
|
|
1208
1442
|
import {
|
|
1209
1443
|
verifySignature,
|
|
1210
1444
|
decryptAddress,
|
|
@@ -1215,7 +1449,9 @@ import {
|
|
|
1215
1449
|
type SessionKeyShare,
|
|
1216
1450
|
type OneAddressD5LOA,
|
|
1217
1451
|
} from '@oneaddress/partner-sdk';
|
|
1218
|
-
import { saveAddress, verifyAddress } from './store.js';
|
|
1452
|
+
import { saveAddress, verifyAddress, verifyAccount } from './store.js';
|
|
1453
|
+
import { config } from './config.js';
|
|
1454
|
+
import { safeOneAddressCallbackUrl } from './callback-url.js';
|
|
1219
1455
|
|
|
1220
1456
|
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? '';
|
|
1221
1457
|
const PARTNER_PRIVATE_KEY = process.env.PARTNER_PRIVATE_KEY_PEM ?? '';
|
|
@@ -1225,6 +1461,16 @@ const PARTNER_PRIVATE_KEY = process.env.PARTNER_PRIVATE_KEY_PEM ?? '';
|
|
|
1225
1461
|
const PARTNER_ID = process.env.PARTNER_ID ?? '';
|
|
1226
1462
|
const PORT = Number(process.env.PORT ?? 3001);
|
|
1227
1463
|
|
|
1464
|
+
// The confirm-callback target and its signing secret.
|
|
1465
|
+
// ONEADDRESS_API \u2014 comes from oneaddress.config.json (what setup wrote); an
|
|
1466
|
+
// ONEADDRESS_API env var overrides it for a one-off.
|
|
1467
|
+
// CONFIRM_SECRET \u2014 authenticates /api/confirm. It is a SECRET, so it lives in
|
|
1468
|
+
// .env, never the config file: your separate confirm secret
|
|
1469
|
+
// if you have one, otherwise your webhook signing secret
|
|
1470
|
+
// (correct for most partners).
|
|
1471
|
+
const ONEADDRESS_API = config.oneAddressApi;
|
|
1472
|
+
const CONFIRM_SECRET = process.env.CONFIRM_SECRET || WEBHOOK_SECRET;
|
|
1473
|
+
|
|
1228
1474
|
if (!WEBHOOK_SECRET || !PARTNER_PRIVATE_KEY || !PARTNER_ID) {
|
|
1229
1475
|
console.error('[startup] Missing required env vars. Check your .env file.');
|
|
1230
1476
|
process.exit(1);
|
|
@@ -1274,29 +1520,79 @@ function rememberDispatch(id: string): void {
|
|
|
1274
1520
|
seenDispatches.add(id);
|
|
1275
1521
|
}
|
|
1276
1522
|
|
|
1277
|
-
const app = express();
|
|
1278
|
-
app.disable('x-powered-by'); // don't fingerprint the framework
|
|
1279
|
-
app.use('/webhook', express.text({ type: 'application/json', limit: '1mb' }));
|
|
1280
|
-
|
|
1281
1523
|
/**
|
|
1282
|
-
*
|
|
1283
|
-
*
|
|
1284
|
-
*
|
|
1285
|
-
*
|
|
1286
|
-
*
|
|
1524
|
+
* Close the loop: tell OneAddress you have applied an update, so the consumer's
|
|
1525
|
+
* dashboard flips the service to "Confirmed". Fire-and-forget so it never delays
|
|
1526
|
+
* the webhook 200 (a slow confirm must not make OneAddress time the DISPATCH out
|
|
1527
|
+
* and mark it failed). Logs its own outcome.
|
|
1528
|
+
*
|
|
1529
|
+
* Auth for /api/confirm (all three required):
|
|
1530
|
+
* Authorization: Bearer <secret>
|
|
1531
|
+
* X-OneAddress-Timestamp: <unix seconds>
|
|
1532
|
+
* X-OneAddress-Signature: HMAC-SHA256(secret, \`\${timestamp}.\${rawBody}\`)
|
|
1533
|
+
* The same secret signs the Bearer and the body.
|
|
1287
1534
|
*/
|
|
1288
|
-
function
|
|
1535
|
+
async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'failed'): Promise<void> {
|
|
1536
|
+
// Only real dispatches carry a numeric id in X-OneAddress-Dispatch. Probes
|
|
1537
|
+
// (e.g. the go-live "address.test") carry a non-numeric id and have nothing to
|
|
1538
|
+
// confirm, so skip them.
|
|
1539
|
+
const dispatchId = Number(dispatch);
|
|
1540
|
+
if (!Number.isInteger(dispatchId) || dispatchId <= 0) return;
|
|
1541
|
+
|
|
1542
|
+
const bodyStr = JSON.stringify({
|
|
1543
|
+
dispatch_id: dispatchId,
|
|
1544
|
+
partner_id: PARTNER_ID,
|
|
1545
|
+
status,
|
|
1546
|
+
note: 'Applied by the OneAddress webhook receiver',
|
|
1547
|
+
});
|
|
1548
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
1549
|
+
const sig = createHmac('sha256', CONFIRM_SECRET).update(\`\${ts}.\${bodyStr}\`).digest('hex');
|
|
1550
|
+
|
|
1289
1551
|
try {
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1552
|
+
const confirmRes = await fetch(\`\${ONEADDRESS_API}/api/confirm\`, {
|
|
1553
|
+
method: 'POST',
|
|
1554
|
+
headers: {
|
|
1555
|
+
'Content-Type': 'application/json',
|
|
1556
|
+
'Authorization': \`Bearer \${CONFIRM_SECRET}\`,
|
|
1557
|
+
'X-OneAddress-Timestamp': ts,
|
|
1558
|
+
'X-OneAddress-Signature': sig,
|
|
1559
|
+
},
|
|
1560
|
+
body: bodyStr,
|
|
1561
|
+
});
|
|
1562
|
+
if (confirmRes.ok) {
|
|
1563
|
+
console.log(\`[confirm] dispatch \${dispatchId} \u2192 \${status}: acknowledged by OneAddress\`);
|
|
1564
|
+
} else {
|
|
1565
|
+
const detail = await confirmRes.text().catch(() => '');
|
|
1566
|
+
console.error(\`[confirm] dispatch \${dispatchId} confirm FAILED \u2014 HTTP \${confirmRes.status} \${detail}\`);
|
|
1567
|
+
if (confirmRes.status === 401) {
|
|
1568
|
+
console.error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET in .env to it (from the portal Webhook screen); otherwise your webhook signing secret should work.');
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
console.error('[confirm] confirm request error:', err);
|
|
1296
1573
|
}
|
|
1297
1574
|
}
|
|
1298
1575
|
|
|
1299
|
-
app
|
|
1576
|
+
const app = express();
|
|
1577
|
+
app.disable('x-powered-by'); // don't fingerprint the framework
|
|
1578
|
+
|
|
1579
|
+
// DoS backstop on the public webhook. The endpoint already rejects anything
|
|
1580
|
+
// without a valid HMAC (401), but a signature check still costs CPU, so a flood
|
|
1581
|
+
// of junk requests is worth bounding. The ceiling is deliberately GENEROUS \u2014
|
|
1582
|
+
// far above any real OneAddress dispatch volume to one partner \u2014 so legitimate
|
|
1583
|
+
// signed dispatches are never throttled; raise \`max\` (or disable this) if you
|
|
1584
|
+
// run a very high-volume integration, and prefer a shared store (Redis) if you
|
|
1585
|
+
// run more than one instance. Per-IP, fixed 1-minute window.
|
|
1586
|
+
const webhookLimiter = rateLimit({
|
|
1587
|
+
windowMs: 60_000,
|
|
1588
|
+
max: 300,
|
|
1589
|
+
standardHeaders: true,
|
|
1590
|
+
legacyHeaders: false,
|
|
1591
|
+
message: { error: 'Too many requests' },
|
|
1592
|
+
});
|
|
1593
|
+
app.use('/webhook', webhookLimiter, express.text({ type: 'application/json', limit: '1mb' }));
|
|
1594
|
+
|
|
1595
|
+
app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
|
|
1300
1596
|
const rawBody = req.body as string;
|
|
1301
1597
|
const signature = req.headers['x-oneaddress-signature'] as string ?? '';
|
|
1302
1598
|
const timestamp = req.headers['x-oneaddress-timestamp'] as string ?? '';
|
|
@@ -1335,6 +1631,54 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1335
1631
|
|
|
1336
1632
|
const event = body.event as string;
|
|
1337
1633
|
|
|
1634
|
+
// \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
|
|
1635
|
+
// Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
|
|
1636
|
+
// no address, no session envelope \u2014 so it is handled HERE, above the address-
|
|
1637
|
+
// decrypt section below. Answer SYNCHRONOUSLY with { status }.
|
|
1638
|
+
if (event === 'account.verify') {
|
|
1639
|
+
// You told the portal whether you verify account references; the wizard
|
|
1640
|
+
// wrote that into oneaddress.config.json. If you do NOT, answer "not
|
|
1641
|
+
// checked" rather than a match/no_match you don't actually compute \u2014 this is
|
|
1642
|
+
// the setup declaration reaching the running handler.
|
|
1643
|
+
if (!config.verifiesAccountReference) {
|
|
1644
|
+
console.log('[webhook] account.verify \u2192 skipped (verifiesAccountReference is false in oneaddress.config.json)');
|
|
1645
|
+
return res.status(200).json({ ok: true, skipped: true });
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
const enc = body.customer_encrypted as {
|
|
1649
|
+
ephemeralPublicKey: string; iv: string; ciphertext: string; hkdfSalt?: string;
|
|
1650
|
+
} | undefined;
|
|
1651
|
+
if (!enc) return res.status(400).json({ error: 'Missing customer_encrypted' });
|
|
1652
|
+
|
|
1653
|
+
let cust: Record<string, unknown>;
|
|
1654
|
+
try {
|
|
1655
|
+
cust = await decryptAddress(enc, PARTNER_PRIVATE_KEY, PARTNER_ID);
|
|
1656
|
+
} catch (err) {
|
|
1657
|
+
console.error('[webhook] account.verify decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM:', err);
|
|
1658
|
+
return res.status(422).json({ ok: false, error: 'decryption_failed' });
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const accountNumber = typeof cust.account_number === 'string' ? cust.account_number : null;
|
|
1662
|
+
const name = typeof cust.name === 'string' ? cust.name : '';
|
|
1663
|
+
const knownNames = Array.isArray(cust.known_names) ? cust.known_names.map(String) : [];
|
|
1664
|
+
|
|
1665
|
+
const status = verifyAccount(accountNumber, name, knownNames);
|
|
1666
|
+
console.log(\`[webhook] account.verify \u2192 \${status} for account \${accountNumber ?? '(none)'} (\${name})\`);
|
|
1667
|
+
return res.status(200).json({ status });
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// A valid signed request that carries no address payload \u2014 a conformance ping,
|
|
1671
|
+
// or any event added after this receiver was generated \u2014 has already passed
|
|
1672
|
+
// timestamp + signature above, which is exactly what such a probe tests.
|
|
1673
|
+
// Acknowledge it here; only the real dispatch events below require decryption.
|
|
1674
|
+
// A real address.updated that arrives WITHOUT a payload still falls through to
|
|
1675
|
+
// the 422 below, because it IS a dispatch event.
|
|
1676
|
+
const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
|
|
1677
|
+
if (!DISPATCH_EVENTS.includes(event)) {
|
|
1678
|
+
console.log(\`[webhook] "\${event}" acknowledged (no address payload to decrypt)\`);
|
|
1679
|
+
return res.status(200).json({ ok: true, skipped: true });
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1338
1682
|
// 5. Decrypt the address payload. Two shapes possible, exactly one per event:
|
|
1339
1683
|
// (a) D5 (2026+): body.session_envelope + body.session_key_share. Under D5,
|
|
1340
1684
|
// address.updated no longer carries a cleartext \`customer\` block \u2014
|
|
@@ -1423,6 +1767,9 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1423
1767
|
console.log(\`[webhook] address.updated for \${ctx.accountNumber || ctx.name}\`);
|
|
1424
1768
|
await saveAddress(ctx, address);
|
|
1425
1769
|
if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
|
|
1770
|
+
// Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
1771
|
+
// Fire-and-forget: it must not delay this 200 (which acks the delivery).
|
|
1772
|
+
void confirmToOneAddress(dispatch, 'confirmed');
|
|
1426
1773
|
return res.status(200).json({ ok: true });
|
|
1427
1774
|
}
|
|
1428
1775
|
|
|
@@ -1438,7 +1785,8 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1438
1785
|
// could otherwise coerce this server into POSTing to any internal
|
|
1439
1786
|
// URL (database admin, cloud metadata service, \u2026) \u2014 turning the partner's
|
|
1440
1787
|
// network position into an SSRF primitive.
|
|
1441
|
-
|
|
1788
|
+
const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
|
|
1789
|
+
if (!safeCallbackUrl) {
|
|
1442
1790
|
console.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
|
|
1443
1791
|
return res.status(400).json({ error: 'Invalid callback_url host' });
|
|
1444
1792
|
}
|
|
@@ -1447,7 +1795,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
1447
1795
|
const result = await verifyAddress(ctx, address);
|
|
1448
1796
|
console.log(\`[webhook] address.verify \u2192 \${result}\`);
|
|
1449
1797
|
|
|
1450
|
-
await fetch(
|
|
1798
|
+
await fetch(safeCallbackUrl, {
|
|
1451
1799
|
method: 'POST',
|
|
1452
1800
|
headers: { 'Content-Type': 'application/json' },
|
|
1453
1801
|
body: JSON.stringify({
|
|
@@ -1593,8 +1941,11 @@ To view it, open \`data.db\` with any SQLite client (e.g. [DB Browser for SQLite
|
|
|
1593
1941
|
| File | Role |
|
|
1594
1942
|
|------|------|
|
|
1595
1943
|
| \`src/server.ts\` | **Protocol layer \u2014 do not edit.** HMAC verification, timestamp replay protection, D5 session + legacy ECDH decryption, LOA decryption, deduplication. |
|
|
1596
|
-
| \`src/
|
|
1597
|
-
| \`src/
|
|
1944
|
+
| \`src/config.ts\` | **Reads \`oneaddress.config.json\`** (what the wizard wrote) so what you set in setup drives the handler. Non-secret config only; an env var of the matching name overrides for a one-off. |
|
|
1945
|
+
| \`src/db.ts\` | **SQLite setup.** Opens (or creates) \`data.db\`. Change \`DB_PATH\` env var to move the file. |
|
|
1946
|
+
| \`src/store.ts\` | **Your integration.** \`verifyAccount\`, \`verifyAddress\` and \`saveAddress\`, seeded from your \`customers.json\` roster. Swap for Postgres/MySQL/etc. when ready. |
|
|
1947
|
+
| \`oneaddress.config.json\` | **Config from setup** \u2014 \`partnerId\`, \`oneAddressApi\`, \`verifiesAccountReference\`. Edit and restart; no code change. |
|
|
1948
|
+
| \`customers.json\` | **Your customer roster as data** \u2014 account number, name, and the address you hold on file. Edit this, not \`src/store.ts\`. |
|
|
1598
1949
|
|
|
1599
1950
|
## Identity & the encrypted payload
|
|
1600
1951
|
|
|
@@ -1614,12 +1965,13 @@ computes the LOA reference with \`d5LoaRef\`, and hands it to your store as
|
|
|
1614
1965
|
## Database tables
|
|
1615
1966
|
|
|
1616
1967
|
\`\`\`sql
|
|
1617
|
-
--
|
|
1618
|
-
--
|
|
1619
|
-
|
|
1968
|
+
-- Your customer roster: who you know + the address you hold on file today.
|
|
1969
|
+
-- Keyed on the account number. Seeded on startup from customers.json (edit that
|
|
1970
|
+
-- file, or point loadRoster in src/store.ts at your real customer table).
|
|
1971
|
+
customers(account_number, name, address JSON, updated_at)
|
|
1620
1972
|
|
|
1621
|
-
-- Full history of every address change (append-only audit trail)
|
|
1622
|
-
address_history(id,
|
|
1973
|
+
-- Full history of every address change you apply (append-only audit trail).
|
|
1974
|
+
address_history(id, account_number, address JSON, recorded_at)
|
|
1623
1975
|
\`\`\`
|
|
1624
1976
|
|
|
1625
1977
|
## Swapping to a production database
|
|
@@ -1639,10 +1991,19 @@ await prisma.customer.upsert({
|
|
|
1639
1991
|
|
|
1640
1992
|
| Event | What OneAddress sends | What you must do |
|
|
1641
1993
|
|-------|-----------------------|------------------|
|
|
1642
|
-
| \`
|
|
1994
|
+
| \`account.verify\` | Encrypted \`{ name, known_names, account_number }\` \u2014 a **pre-payment** account check, no address | If \`verifiesAccountReference\` is true: decrypt \u2192 \`verifyAccount()\` \u2192 answer \`{ status }\`. If false: answer \`{ ok: true, skipped: true }\` ("not checked"). |
|
|
1995
|
+
| \`address.updated\` | Encrypted new address + encrypted identity + encrypted LOA | Decrypt \u2192 \`saveAddress()\` \u2192 return 200 \u2192 **confirm callback** to OneAddress |
|
|
1643
1996
|
| \`address.verify\` | Encrypted address + encrypted identity to check | Decrypt \u2192 \`verifyAddress()\` \u2192 POST result to \`callback_url\` |
|
|
1644
1997
|
|
|
1645
|
-
Valid
|
|
1998
|
+
Valid account-check results: \`"match"\` \xB7 \`"no_match"\` \xB7 \`"no_account"\`
|
|
1999
|
+
Valid address-verify results: \`"match"\` \xB7 \`"mismatch"\` \xB7 \`"not_found"\`
|
|
2000
|
+
|
|
2001
|
+
### Confirm callback (closing the loop)
|
|
2002
|
+
|
|
2003
|
+
After an \`address.updated\` is stored, the receiver POSTs to \`\${ONEADDRESS_API}/api/confirm\`
|
|
2004
|
+
(HMAC-SHA256 over \`\${timestamp}.\${rawBody}\`, Bearer + \`X-OneAddress-Signature\` headers)
|
|
2005
|
+
so the consumer's dashboard flips the service to **Confirmed**. It is
|
|
2006
|
+
fire-and-forget, so a slow confirm never delays the webhook \`200\`.
|
|
1646
2007
|
|
|
1647
2008
|
## Conformance check
|
|
1648
2009
|
|
|
@@ -1654,8 +2015,32 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
|
|
|
1654
2015
|
|
|
1655
2016
|
## Configuration
|
|
1656
2017
|
|
|
1657
|
-
|
|
1658
|
-
|
|
2018
|
+
There are three places, split by what belongs where \u2014 configure once, the
|
|
2019
|
+
handler reads it:
|
|
2020
|
+
|
|
2021
|
+
**\`.env\` \u2014 secrets** (written by the wizard, never commit it): \`PARTNER_ID\`,
|
|
2022
|
+
\`WEBHOOK_SECRET\`, \`PARTNER_PRIVATE_KEY_PEM\`, \`PORT\`. Rotate keys and update your
|
|
2023
|
+
webhook URL at [partners.oneaddress.io](https://partners.oneaddress.io).
|
|
2024
|
+
|
|
2025
|
+
**\`oneaddress.config.json\` \u2014 non-secret config** (written by the wizard, read by
|
|
2026
|
+
\`src/config.ts\`). What you set in setup, so the handler behaves accordingly:
|
|
2027
|
+
|
|
2028
|
+
| Field | Default | Purpose |
|
|
2029
|
+
|-------|---------|---------|
|
|
2030
|
+
| \`partnerId\` | \`(from setup)\` | Your partner UUID (mirrors \`.env\`; informational). |
|
|
2031
|
+
| \`oneAddressApi\` | \`https://oneaddress.io\` | Base URL the confirm callback posts to. |
|
|
2032
|
+
| \`verifiesAccountReference\` | \`(your portal declaration)\` | Whether you answer \`account.verify\` with a real match, or reply "not checked". |
|
|
2033
|
+
|
|
2034
|
+
Each field can be overridden for a one-off by an environment variable of the
|
|
2035
|
+
matching name (\`PARTNER_ID\` / \`ONEADDRESS_API\` / \`VERIFIES_ACCOUNT_REFERENCE\`).
|
|
2036
|
+
One secret has no config-file home because it must stay out of a non-secret
|
|
2037
|
+
file: \`CONFIRM_SECRET\` (the \`/api/confirm\` signing secret) lives in \`.env\`, and
|
|
2038
|
+
defaults to your \`WEBHOOK_SECRET\` \u2014 set it only if your partner has a separate
|
|
2039
|
+
confirm secret.
|
|
2040
|
+
|
|
2041
|
+
**\`customers.json\` \u2014 your customer roster** (data, not code). Edit this file to
|
|
2042
|
+
your customers; \`src/store.ts\` seeds from it on startup. Override its path with
|
|
2043
|
+
the \`ONEADDRESS_CUSTOMERS\` env var, or point \`loadRoster\` at your real database.
|
|
1659
2044
|
`
|
|
1660
2045
|
}
|
|
1661
2046
|
],
|
|
@@ -1943,6 +2328,15 @@ async def webhook(request: Request) -> Response:
|
|
|
1943
2328
|
if dispatch:
|
|
1944
2329
|
seen_dispatches.add(dispatch)
|
|
1945
2330
|
|
|
2331
|
+
# A valid signed request that carries no address payload \u2014 a conformance
|
|
2332
|
+
# ping, or any event added after this receiver was generated \u2014 has already
|
|
2333
|
+
# passed timestamp + signature above, which is exactly what such a probe
|
|
2334
|
+
# tests. Acknowledge it here; only the real dispatch events below require
|
|
2335
|
+
# decryption. A real address.updated with no payload still 422s below.
|
|
2336
|
+
if event not in ("address.updated", "address.verify", "address.test", "address.test-dispatch"):
|
|
2337
|
+
print(f"[webhook] '{event}' acknowledged (no address payload to decrypt)")
|
|
2338
|
+
return Response(content='{"ok":true,"skipped":true}', media_type="application/json")
|
|
2339
|
+
|
|
1946
2340
|
# Two possible payload shapes:
|
|
1947
2341
|
# (a) D5 \u2014 body.session_envelope (str) + body.session_key_share (dict)
|
|
1948
2342
|
# (b) Legacy \u2014 body.address_encrypted (dict)
|
|
@@ -4209,6 +4603,19 @@ func main() {
|
|
|
4209
4603
|
|
|
4210
4604
|
event, _ := parsed["event"].(string)
|
|
4211
4605
|
|
|
4606
|
+
// A valid signed request that carries no address payload \u2014 a conformance
|
|
4607
|
+
// ping, or any event added after this receiver was generated \u2014 has already
|
|
4608
|
+
// passed timestamp + signature above, which is exactly what such a probe
|
|
4609
|
+
// tests. Acknowledge it here; only the real dispatch events below require
|
|
4610
|
+
// decryption. A real address.updated with no payload still 422s below.
|
|
4611
|
+
switch event {
|
|
4612
|
+
case "address.updated", "address.verify", "address.test", "address.test-dispatch":
|
|
4613
|
+
default:
|
|
4614
|
+
log.Printf("[webhook] %q acknowledged (no address payload to decrypt)", event)
|
|
4615
|
+
jsonResp(w, 200, map[string]any{"ok": true, "skipped": true})
|
|
4616
|
+
return
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4212
4619
|
// Two possible payload shapes \u2014 D5 takes precedence when both fields
|
|
4213
4620
|
// are present. Production deployments holding multiple key rotations
|
|
4214
4621
|
// should look up the right private key by session_key_share.key_id;
|
|
@@ -5379,11 +5786,11 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
|
|
|
5379
5786
|
};
|
|
5380
5787
|
|
|
5381
5788
|
// src/scaffold.ts
|
|
5382
|
-
function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference) {
|
|
5383
|
-
return content.replaceAll("%%PARTNER_ID%%", partnerId).replaceAll("%%WEBHOOK_SECRET%%", webhookSecret).replaceAll("%%WEBHOOK_URL%%", webhookUrl || "<your-webhook-url>").replaceAll("%%PRIVATE_KEY%%", privateKey || "<paste your PKCS8 PEM private key here>").replaceAll("%%VERIFIES_ACCOUNT_REFERENCE%%", verifiesAccountReference ? "true" : "false");
|
|
5789
|
+
function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi) {
|
|
5790
|
+
return content.replaceAll("%%PARTNER_ID%%", partnerId).replaceAll("%%WEBHOOK_SECRET%%", webhookSecret).replaceAll("%%WEBHOOK_URL%%", webhookUrl || "<your-webhook-url>").replaceAll("%%PRIVATE_KEY%%", privateKey || "<paste your PKCS8 PEM private key here>").replaceAll("%%ONEADDRESS_API%%", oneAddressApi || "https://oneaddress.io").replaceAll("%%VERIFIES_ACCOUNT_REFERENCE%%", verifiesAccountReference ? "true" : "false");
|
|
5384
5791
|
}
|
|
5385
5792
|
var SENSITIVE_FILES = /* @__PURE__ */ new Set([".env"]);
|
|
5386
|
-
async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false) {
|
|
5793
|
+
async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io") {
|
|
5387
5794
|
const templates = TEMPLATES[platform];
|
|
5388
5795
|
if (!templates) throw new Error(`Unknown platform: ${platform}`);
|
|
5389
5796
|
const written = [];
|
|
@@ -5393,7 +5800,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
5393
5800
|
if (!(0, import_node_fs.existsSync)(dir)) {
|
|
5394
5801
|
await (0, import_promises.mkdir)(dir, { recursive: true });
|
|
5395
5802
|
}
|
|
5396
|
-
const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference);
|
|
5803
|
+
const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi);
|
|
5397
5804
|
const isSensitive = SENSITIVE_FILES.has((0, import_node_path.basename)(name));
|
|
5398
5805
|
await (0, import_promises.writeFile)(dest, filled, { encoding: "utf8", mode: isSensitive ? 384 : 420 });
|
|
5399
5806
|
if (isSensitive && process.platform !== "win32") {
|
|
@@ -5409,7 +5816,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
5409
5816
|
|
|
5410
5817
|
// src/register.ts
|
|
5411
5818
|
var import_node_crypto = require("crypto");
|
|
5412
|
-
var PKG_VERSION = true ? "1.
|
|
5819
|
+
var PKG_VERSION = true ? "1.6.1" : "dev";
|
|
5413
5820
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
5414
5821
|
function hmacSha256(secret, message) {
|
|
5415
5822
|
return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
|
|
@@ -6368,26 +6775,23 @@ async function main() {
|
|
|
6368
6775
|
]
|
|
6369
6776
|
});
|
|
6370
6777
|
assertNotCancelled(platform);
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
if (
|
|
6381
|
-
|
|
6382
|
-
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
process.exit(0);
|
|
6389
|
-
}
|
|
6390
|
-
}
|
|
6778
|
+
let outDir;
|
|
6779
|
+
for (; ; ) {
|
|
6780
|
+
const outputDir = await he({
|
|
6781
|
+
message: "Where to write the files?",
|
|
6782
|
+
placeholder: "./oneaddress-webhook",
|
|
6783
|
+
defaultValue: "./oneaddress-webhook"
|
|
6784
|
+
});
|
|
6785
|
+
assertNotCancelled(outputDir);
|
|
6786
|
+
outDir = outputDir.trim() || "./oneaddress-webhook";
|
|
6787
|
+
if (!(0, import_node_fs5.existsSync)(outDir) || (0, import_node_fs5.readdirSync)(outDir).length === 0) break;
|
|
6788
|
+
const overwrite = await ye({
|
|
6789
|
+
message: `${outDir} already has files \u2014 overwrite?`,
|
|
6790
|
+
initialValue: false
|
|
6791
|
+
});
|
|
6792
|
+
assertNotCancelled(overwrite);
|
|
6793
|
+
if (overwrite) break;
|
|
6794
|
+
M2.info(`Keeping the files in ${outDir}. Enter a different directory to write into.`);
|
|
6391
6795
|
}
|
|
6392
6796
|
let accountRefReason = "";
|
|
6393
6797
|
const accountRefDeclaration = await getVerifiesAccountReference(pid, secret, (r2) => {
|