@oneaddress/setup 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +511 -138
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
856
856
  var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
857
857
  var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
858
858
  var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
859
- var WIZARD_VERSION = true ? "1.4.1" : "?";
859
+ var WIZARD_VERSION = true ? "1.6.0" : "?";
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
- * Two tables:
1023
- * addresses \u2014 one row per customer, holds their latest address
1024
- * address_history \u2014 append-only audit trail of every change
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 Uses Node.js built-in SQLite (node:sqlite) \u2014 no install \u2551
1078
- * \u2551 or native compilation needed. Requires Node.js 22.5+. \u2551
1079
- * \u2551 The database file is created automatically as data.db \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 To use a different database (Postgres, MySQL, etc.): \u2551
1082
- * \u2551 Replace the db calls in saveAddress and verifyAddress below. \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
- * Identity handed to the store after server.ts decrypts an event.
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
- * Identity comes from the decrypted payload on both paths: \`name\` +
1095
- * \`accountNumber\` (+ optional \`knownNames\`). There is no cleartext email on
1096
- * either path, so \`email\` is null (2026.2 removed the last cleartext block, on
1097
- * address.verify).
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
- * The row is keyed on \`accountNumber || email\` (see \`customerKey\`); on the
1100
- * verify path \`accountNumber\` is recovered from the decrypted blob.
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: string | null;
1104
- name: string;
1105
- accountNumber: string;
1106
- knownNames?: string[];
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?: string | null;
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
- * Called when a consumer updates their address (address.updated event).
1125
- *
1126
- * Upserts the address into the \`addresses\` table and appends a row
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
- export async function saveAddress(customer: Customer, address: Address): Promise<void> {
1130
- const addressJson = JSON.stringify(address);
1131
- const key = customerKey(customer);
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
- db.prepare(\`
1134
- INSERT INTO addresses (customer_key, email, name, address, updated_at)
1135
- VALUES ($key, $email, $name, $address, datetime('now'))
1136
- ON CONFLICT(customer_key) DO UPDATE SET
1137
- email = excluded.email,
1138
- name = excluded.name,
1139
- address = excluded.address,
1140
- updated_at = excluded.updated_at
1141
- \`).run({ key, email: customer.email, name: customer.name, address: addressJson });
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
- db.prepare(\`
1144
- INSERT INTO address_history (customer_key, email, name, address, recorded_at)
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
- // Metadata only \u2014 address is PII. Centralised log aggregation tools turn
1149
- // every log line into a data-exposure surface for customer addresses.
1150
- console.log(\`[store] Saved address for \${key}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
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
- * Called during an address verification check (address.verify event).
1155
- *
1156
- * Looks up the customer and compares the incoming (decrypted) address
1157
- * against what is stored in the database.
1158
- *
1159
- * Return values:
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, address: Address): Promise<VerifyResult> {
1165
- const key = customerKey(customer);
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 (\${key} has no record)\`);
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
+ }
1173
1385
 
1174
- const stored = JSON.parse(row.address) as Address;
1175
- const isMatch = Object.entries(address).every(([k, v]) => stored[k] === v);
1176
- const result = isMatch ? 'match' : 'mismatch';
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);
1177
1395
 
1178
- console.log(\`[store] verifyAddress \u2192 \${result} for \${key}\`);
1179
- return result;
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 });
1404
+
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 { createPrivateKey } from 'node:crypto';
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
- * True iff \`raw\` parses as an https URL whose hostname is exactly
1283
- * oneaddress.io or *.oneaddress.io. Used for the address.verify callback
1284
- * \u2014 the URL comes from inside the signed webhook body so HMAC already
1285
- * gates it, but the host check is a cheap belt-and-braces against a
1286
- * leaked-webhook-secret SSRF.
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 isOneAddressCallbackUrl(raw: string): boolean {
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 u = new URL(raw);
1291
- if (u.protocol !== 'https:') return false;
1292
- const host = u.hostname.toLowerCase();
1293
- return host === 'oneaddress.io' || host.endsWith('.oneaddress.io');
1294
- } catch {
1295
- return false;
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.post('/webhook', async (req: Request, res: Response) => {
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,42 @@ 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
+
1338
1670
  // 5. Decrypt the address payload. Two shapes possible, exactly one per event:
1339
1671
  // (a) D5 (2026+): body.session_envelope + body.session_key_share. Under D5,
1340
1672
  // address.updated no longer carries a cleartext \`customer\` block \u2014
@@ -1423,6 +1755,9 @@ app.post('/webhook', async (req: Request, res: Response) => {
1423
1755
  console.log(\`[webhook] address.updated for \${ctx.accountNumber || ctx.name}\`);
1424
1756
  await saveAddress(ctx, address);
1425
1757
  if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
1758
+ // Close the loop back to OneAddress so the service flips to "Confirmed".
1759
+ // Fire-and-forget: it must not delay this 200 (which acks the delivery).
1760
+ void confirmToOneAddress(dispatch, 'confirmed');
1426
1761
  return res.status(200).json({ ok: true });
1427
1762
  }
1428
1763
 
@@ -1438,7 +1773,8 @@ app.post('/webhook', async (req: Request, res: Response) => {
1438
1773
  // could otherwise coerce this server into POSTing to any internal
1439
1774
  // URL (database admin, cloud metadata service, \u2026) \u2014 turning the partner's
1440
1775
  // network position into an SSRF primitive.
1441
- if (!isOneAddressCallbackUrl(callbackUrl)) {
1776
+ const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
1777
+ if (!safeCallbackUrl) {
1442
1778
  console.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
1443
1779
  return res.status(400).json({ error: 'Invalid callback_url host' });
1444
1780
  }
@@ -1447,7 +1783,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
1447
1783
  const result = await verifyAddress(ctx, address);
1448
1784
  console.log(\`[webhook] address.verify \u2192 \${result}\`);
1449
1785
 
1450
- await fetch(callbackUrl, {
1786
+ await fetch(safeCallbackUrl, {
1451
1787
  method: 'POST',
1452
1788
  headers: { 'Content-Type': 'application/json' },
1453
1789
  body: JSON.stringify({
@@ -1593,8 +1929,11 @@ To view it, open \`data.db\` with any SQLite client (e.g. [DB Browser for SQLite
1593
1929
  | File | Role |
1594
1930
  |------|------|
1595
1931
  | \`src/server.ts\` | **Protocol layer \u2014 do not edit.** HMAC verification, timestamp replay protection, D5 session + legacy ECDH decryption, LOA decryption, deduplication. |
1596
- | \`src/db.ts\` | **SQLite setup.** Opens (or creates) \`data.db\` and defines the schema. Change \`DB_PATH\` env var to move the file. |
1597
- | \`src/store.ts\` | **Your integration.** \`saveAddress\` and \`verifyAddress\` \u2014 wired to SQLite out of the box. Swap for Postgres/MySQL/etc. when ready. |
1932
+ | \`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. |
1933
+ | \`src/db.ts\` | **SQLite setup.** Opens (or creates) \`data.db\`. Change \`DB_PATH\` env var to move the file. |
1934
+ | \`src/store.ts\` | **Your integration.** \`verifyAccount\`, \`verifyAddress\` and \`saveAddress\`, seeded from your \`customers.json\` roster. Swap for Postgres/MySQL/etc. when ready. |
1935
+ | \`oneaddress.config.json\` | **Config from setup** \u2014 \`partnerId\`, \`oneAddressApi\`, \`verifiesAccountReference\`. Edit and restart; no code change. |
1936
+ | \`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
1937
 
1599
1938
  ## Identity & the encrypted payload
1600
1939
 
@@ -1614,12 +1953,13 @@ computes the LOA reference with \`d5LoaRef\`, and hands it to your store as
1614
1953
  ## Database tables
1615
1954
 
1616
1955
  \`\`\`sql
1617
- -- Current address per customer (upserted on every address.updated event).
1618
- -- customer_key = account number (from the decrypted payload on both paths).
1619
- addresses(customer_key, email, name, address JSON, updated_at)
1956
+ -- Your customer roster: who you know + the address you hold on file today.
1957
+ -- Keyed on the account number. Seeded on startup from customers.json (edit that
1958
+ -- file, or point loadRoster in src/store.ts at your real customer table).
1959
+ customers(account_number, name, address JSON, updated_at)
1620
1960
 
1621
- -- Full history of every address change (append-only audit trail)
1622
- address_history(id, customer_key, email, name, address JSON, recorded_at)
1961
+ -- Full history of every address change you apply (append-only audit trail).
1962
+ address_history(id, account_number, address JSON, recorded_at)
1623
1963
  \`\`\`
1624
1964
 
1625
1965
  ## Swapping to a production database
@@ -1639,10 +1979,19 @@ await prisma.customer.upsert({
1639
1979
 
1640
1980
  | Event | What OneAddress sends | What you must do |
1641
1981
  |-------|-----------------------|------------------|
1642
- | \`address.updated\` | Encrypted new address + encrypted identity + encrypted LOA | Decrypt \u2192 \`saveAddress()\` \u2192 return 200 |
1982
+ | \`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"). |
1983
+ | \`address.updated\` | Encrypted new address + encrypted identity + encrypted LOA | Decrypt \u2192 \`saveAddress()\` \u2192 return 200 \u2192 **confirm callback** to OneAddress |
1643
1984
  | \`address.verify\` | Encrypted address + encrypted identity to check | Decrypt \u2192 \`verifyAddress()\` \u2192 POST result to \`callback_url\` |
1644
1985
 
1645
- Valid verify results: \`"match"\` \xB7 \`"mismatch"\` \xB7 \`"not_found"\`
1986
+ Valid account-check results: \`"match"\` \xB7 \`"no_match"\` \xB7 \`"no_account"\`
1987
+ Valid address-verify results: \`"match"\` \xB7 \`"mismatch"\` \xB7 \`"not_found"\`
1988
+
1989
+ ### Confirm callback (closing the loop)
1990
+
1991
+ After an \`address.updated\` is stored, the receiver POSTs to \`\${ONEADDRESS_API}/api/confirm\`
1992
+ (HMAC-SHA256 over \`\${timestamp}.\${rawBody}\`, Bearer + \`X-OneAddress-Signature\` headers)
1993
+ so the consumer's dashboard flips the service to **Confirmed**. It is
1994
+ fire-and-forget, so a slow confirm never delays the webhook \`200\`.
1646
1995
 
1647
1996
  ## Conformance check
1648
1997
 
@@ -1654,8 +2003,32 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
1654
2003
 
1655
2004
  ## Configuration
1656
2005
 
1657
- All credentials are in \`.env\` (written by the setup wizard \u2014 never commit it).
1658
- Rotate keys and update your webhook URL at [partners.oneaddress.io](https://partners.oneaddress.io).
2006
+ There are three places, split by what belongs where \u2014 configure once, the
2007
+ handler reads it:
2008
+
2009
+ **\`.env\` \u2014 secrets** (written by the wizard, never commit it): \`PARTNER_ID\`,
2010
+ \`WEBHOOK_SECRET\`, \`PARTNER_PRIVATE_KEY_PEM\`, \`PORT\`. Rotate keys and update your
2011
+ webhook URL at [partners.oneaddress.io](https://partners.oneaddress.io).
2012
+
2013
+ **\`oneaddress.config.json\` \u2014 non-secret config** (written by the wizard, read by
2014
+ \`src/config.ts\`). What you set in setup, so the handler behaves accordingly:
2015
+
2016
+ | Field | Default | Purpose |
2017
+ |-------|---------|---------|
2018
+ | \`partnerId\` | \`(from setup)\` | Your partner UUID (mirrors \`.env\`; informational). |
2019
+ | \`oneAddressApi\` | \`https://oneaddress.io\` | Base URL the confirm callback posts to. |
2020
+ | \`verifiesAccountReference\` | \`(your portal declaration)\` | Whether you answer \`account.verify\` with a real match, or reply "not checked". |
2021
+
2022
+ Each field can be overridden for a one-off by an environment variable of the
2023
+ matching name (\`PARTNER_ID\` / \`ONEADDRESS_API\` / \`VERIFIES_ACCOUNT_REFERENCE\`).
2024
+ One secret has no config-file home because it must stay out of a non-secret
2025
+ file: \`CONFIRM_SECRET\` (the \`/api/confirm\` signing secret) lives in \`.env\`, and
2026
+ defaults to your \`WEBHOOK_SECRET\` \u2014 set it only if your partner has a separate
2027
+ confirm secret.
2028
+
2029
+ **\`customers.json\` \u2014 your customer roster** (data, not code). Edit this file to
2030
+ your customers; \`src/store.ts\` seeds from it on startup. Override its path with
2031
+ the \`ONEADDRESS_CUSTOMERS\` env var, or point \`loadRoster\` at your real database.
1659
2032
  `
1660
2033
  }
1661
2034
  ],
@@ -5379,11 +5752,11 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
5379
5752
  };
5380
5753
 
5381
5754
  // 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");
5755
+ function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi) {
5756
+ 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
5757
  }
5385
5758
  var SENSITIVE_FILES = /* @__PURE__ */ new Set([".env"]);
5386
- async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false) {
5759
+ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io") {
5387
5760
  const templates = TEMPLATES[platform];
5388
5761
  if (!templates) throw new Error(`Unknown platform: ${platform}`);
5389
5762
  const written = [];
@@ -5393,7 +5766,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
5393
5766
  if (!(0, import_node_fs.existsSync)(dir)) {
5394
5767
  await (0, import_promises.mkdir)(dir, { recursive: true });
5395
5768
  }
5396
- const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference);
5769
+ const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi);
5397
5770
  const isSensitive = SENSITIVE_FILES.has((0, import_node_path.basename)(name));
5398
5771
  await (0, import_promises.writeFile)(dest, filled, { encoding: "utf8", mode: isSensitive ? 384 : 420 });
5399
5772
  if (isSensitive && process.platform !== "win32") {
@@ -5409,7 +5782,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
5409
5782
 
5410
5783
  // src/register.ts
5411
5784
  var import_node_crypto = require("crypto");
5412
- var PKG_VERSION = true ? "1.4.1" : "dev";
5785
+ var PKG_VERSION = true ? "1.6.0" : "dev";
5413
5786
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
5414
5787
  function hmacSha256(secret, message) {
5415
5788
  return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneaddress/setup",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Interactive setup wizard for OneAddress partner webhook integrations",
5
5
  "main": "dist/index.js",
6
6
  "bin": {