@oneaddress/setup 1.6.1 → 1.6.2

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 +1108 -129
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -160,7 +160,7 @@ var require_picocolors = __commonJS({
160
160
  });
161
161
 
162
162
  // src/index.ts
163
- var import_node_fs7 = require("fs");
163
+ var import_node_fs5 = require("fs");
164
164
 
165
165
  // node_modules/@clack/prompts/dist/index.mjs
166
166
  var import_node_util = require("util");
@@ -830,9 +830,9 @@ var Y2 = ({ indicator: t = "dots" } = {}) => {
830
830
  // src/prompts.ts
831
831
  var import_node_crypto5 = require("crypto");
832
832
  var import_node_net = require("net");
833
- var import_node_fs5 = require("fs");
833
+ var import_node_fs3 = require("fs");
834
834
  var import_node_os2 = require("os");
835
- var import_node_path5 = require("path");
835
+ var import_node_path4 = require("path");
836
836
 
837
837
  // src/header.ts
838
838
  var R2 = "\x1B[0m";
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
856
856
  var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
857
857
  var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
858
858
  var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
859
- var WIZARD_VERSION = true ? "1.6.1" : "?";
859
+ var WIZARD_VERSION = true ? "1.6.2" : "?";
860
860
  function printCompactHeader() {
861
861
  const INNER = 42;
862
862
  const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
@@ -970,7 +970,7 @@ data.db-shm
970
970
  "test": "tsx scripts/test.ts"
971
971
  },
972
972
  "dependencies": {
973
- "@oneaddress/partner-sdk": "^1.6.3",
973
+ "@oneaddress/partner-sdk": "^1.8.0",
974
974
  "dotenv": "^16.0.0",
975
975
  "express": "^4.18.0",
976
976
  "express-rate-limit": "^8.6.2"
@@ -1576,6 +1576,14 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
1576
1576
  const app = express();
1577
1577
  app.disable('x-powered-by'); // don't fingerprint the framework
1578
1578
 
1579
+ // Trust the reverse proxy / tunnel in front of this server (Cloudflare Tunnel,
1580
+ // nginx, a load balancer). It sets X-Forwarded-For, and without this the rate
1581
+ // limiter below cannot read the real client IP: express-rate-limit throws
1582
+ // ERR_ERL_UNEXPECTED_X_FORWARDED_FOR on the first proxied request. '1' trusts a
1583
+ // single hop, which is the usual setup; raise it if you run more proxies in
1584
+ // front, or set specific proxy addresses for stricter handling.
1585
+ app.set('trust proxy', 1);
1586
+
1579
1587
  // DoS backstop on the public webhook. The endpoint already rejects anything
1580
1588
  // without a valid HMAC (401), but a signature check still costs CPU, so a flood
1581
1589
  // of junk requests is worth bounding. The ceiling is deliberately GENEROUS \u2014
@@ -1590,9 +1598,13 @@ const webhookLimiter = rateLimit({
1590
1598
  legacyHeaders: false,
1591
1599
  message: { error: 'Too many requests' },
1592
1600
  });
1601
+ // The limiter is attached ONCE, here on the route's middleware chain (the form
1602
+ // CodeQL's missing-rate-limiting query recognises). Do NOT also pass it to
1603
+ // app.post below: two references to the same limiter instance count every
1604
+ // request twice and silently halve the ceiling.
1593
1605
  app.use('/webhook', webhookLimiter, express.text({ type: 'application/json', limit: '1mb' }));
1594
1606
 
1595
- app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
1607
+ app.post('/webhook', async (req: Request, res: Response) => {
1596
1608
  const rawBody = req.body as string;
1597
1609
  const signature = req.headers['x-oneaddress-signature'] as string ?? '';
1598
1610
  const timestamp = req.headers['x-oneaddress-timestamp'] as string ?? '';
@@ -2051,6 +2063,20 @@ the \`ONEADDRESS_CUSTOMERS\` env var, or point \`loadRoster\` at your real datab
2051
2063
  content: `OA_PARTNER_ID=%%PARTNER_ID%%
2052
2064
  OA_WEBHOOK_SECRET=%%WEBHOOK_SECRET%%
2053
2065
  OA_PRIVATE_KEY_PEM="%%PRIVATE_KEY%%"
2066
+
2067
+ # Where the confirm callback is POSTed after you apply an address update. This is
2068
+ # the CUSTOMER app (oneaddress.io), NOT the partner portal \u2014 /api/confirm lives
2069
+ # on the former. Written by the setup wizard.
2070
+ ONEADDRESS_API=%%ONEADDRESS_API%%
2071
+
2072
+ # Secret that signs the /api/confirm callback. Leave BLANK to reuse
2073
+ # OA_WEBHOOK_SECRET (correct for most partners); set it only if your partner has
2074
+ # a separate confirm secret in the portal Webhook screen.
2075
+ CONFIRM_SECRET=
2076
+
2077
+ # Whether this receiver answers the pre-payment account.verify check. The wizard
2078
+ # writes your portal declaration here; set "true"/"false" to override.
2079
+ VERIFIES_ACCOUNT_REFERENCE=%%VERIFIES_ACCOUNT_REFERENCE%%
2054
2080
  `
2055
2081
  },
2056
2082
  {
@@ -2079,6 +2105,7 @@ Quick start:
2079
2105
  uvicorn app:app --port 3001
2080
2106
  """
2081
2107
 
2108
+ import asyncio
2082
2109
  import base64
2083
2110
  import hashlib
2084
2111
  import hmac as hmac_lib
@@ -2111,77 +2138,157 @@ WEBHOOK_SECRET = os.environ["OA_WEBHOOK_SECRET"]
2111
2138
  PRIVATE_KEY = os.environ["OA_PRIVATE_KEY_PEM"].replace("\\\\n", "\\n")
2112
2139
  DB_PATH = os.environ.get("DB_PATH", str(Path.cwd() / "data.db"))
2113
2140
 
2141
+ # Confirm-callback config. ONEADDRESS_API is the CUSTOMER app (/api/confirm lives
2142
+ # there, NOT the partner portal); CONFIRM_SECRET signs the callback and falls
2143
+ # back to the webhook secret, which is correct for most partners.
2144
+ ONEADDRESS_API = (os.environ.get("ONEADDRESS_API") or "https://oneaddress.io").rstrip("/")
2145
+ CONFIRM_SECRET = os.environ.get("CONFIRM_SECRET") or WEBHOOK_SECRET
2146
+
2147
+ # Whether this receiver answers the pre-payment account.verify check. The setup
2148
+ # wizard bakes your portal declaration into the default below (the %% token
2149
+ # renders to the string "true"/"false"); the env var overrides it at runtime.
2150
+ _DEFAULT_VERIFIES_ACCOUNT_REFERENCE = ("%%VERIFIES_ACCOUNT_REFERENCE%%" == "true")
2151
+ VERIFIES_ACCOUNT_REFERENCE = (
2152
+ os.environ["VERIFIES_ACCOUNT_REFERENCE"].strip().lower() == "true"
2153
+ if os.environ.get("VERIFIES_ACCOUNT_REFERENCE")
2154
+ else _DEFAULT_VERIFIES_ACCOUNT_REFERENCE
2155
+ )
2156
+
2114
2157
  app = FastAPI()
2115
2158
  seen_dispatches: set[str] = set()
2159
+ # Keeps a reference to fire-and-forget confirm tasks so they aren't garbage-
2160
+ # collected before they run (asyncio only holds a weak reference to a task).
2161
+ _background_tasks: set[asyncio.Task[Any]] = set()
2116
2162
 
2117
2163
  # \u2500\u2500 SQLite store \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2118
2164
  #
2119
- # Uses Python's stdlib sqlite3 \u2014 no extra package install needed. Two tables
2120
- # mirror the TypeScript scaffold layout exactly so partners with mixed-stack
2121
- # integrations can run the same query against either DB:
2165
+ # ROSTER-BASED, mirroring the Go / Java / TypeScript scaffolds. The handler calls
2166
+ # _verify_account (pre-payment account check), _verify_address (is your on-file
2167
+ # address current?) and _save_address (apply an update) with the identity it
2168
+ # DECRYPTED from the payload \u2014 under D5 there is NO cleartext customer block on
2169
+ # the wire, so we key on the decrypted account number / verified name, never a
2170
+ # cleartext email.
2171
+ #
2172
+ # Two tables:
2122
2173
  #
2123
- # addresses \u2014 one row per customer (UNIQUE on email); latest address only
2174
+ # customers \u2014 your roster: account number, name, and the address you
2175
+ # hold on file today. Replace with YOUR customer table.
2124
2176
  # address_history \u2014 append-only audit trail of every address.updated event
2125
2177
  #
2126
- # Replace these with your real database (Postgres, MySQL, internal API)
2127
- # when ready to ship. The webhook handler below only calls _save_address()
2128
- # and _verify_address() \u2014 point those at your DB and the rest stays the same.
2178
+ # Replace these with your real database (Postgres, MySQL, internal API) when
2179
+ # ready to ship. Uses Python's stdlib sqlite3 \u2014 no extra package to install.
2129
2180
 
2130
2181
  _db = sqlite3.connect(DB_PATH, check_same_thread=False, isolation_level=None)
2131
2182
  _db.execute("PRAGMA journal_mode = WAL")
2132
2183
  _db.execute("""
2133
- CREATE TABLE IF NOT EXISTS addresses (
2134
- email TEXT PRIMARY KEY,
2135
- name TEXT NOT NULL DEFAULT '',
2136
- address TEXT NOT NULL DEFAULT '{}',
2137
- dispatch_id TEXT,
2138
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
2184
+ CREATE TABLE IF NOT EXISTS customers (
2185
+ account_number TEXT PRIMARY KEY,
2186
+ name TEXT NOT NULL DEFAULT '',
2187
+ address TEXT NOT NULL DEFAULT '{}',
2188
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
2139
2189
  )
2140
2190
  """)
2141
2191
  _db.execute("""
2142
2192
  CREATE TABLE IF NOT EXISTS address_history (
2143
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2144
- email TEXT NOT NULL,
2145
- name TEXT NOT NULL DEFAULT '',
2146
- address TEXT NOT NULL DEFAULT '{}',
2147
- dispatch_id TEXT,
2148
- recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
2193
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2194
+ account_number TEXT NOT NULL,
2195
+ address TEXT NOT NULL DEFAULT '{}',
2196
+ dispatch_id TEXT,
2197
+ recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
2149
2198
  )
2150
2199
  """)
2151
2200
  print(f"[db] SQLite database ready -> {DB_PATH}")
2152
2201
 
2153
- def _save_address(email: str, name: str, address: dict[str, Any], dispatch_id: str) -> None:
2154
- """Persist on address.updated. Upserts addresses + appends history."""
2202
+ # Seed a demo roster the first time, so a first update from OneAddress shows as a
2203
+ # real change (mismatch -> update -> match) rather than magically already
2204
+ # matching. Edit this to your customers, or point _find_customer at your real DB.
2205
+ _DEFAULT_ROSTER = [
2206
+ {"account_number": "DEMO-0001", "name": "Test Customer",
2207
+ "address": {"street": "1 Example Street", "suburb": "Sydney", "state": "NSW", "postcode": "2000"}},
2208
+ ]
2209
+ if _db.execute("SELECT COUNT(*) FROM customers").fetchone()[0] == 0:
2210
+ for _c in _DEFAULT_ROSTER:
2211
+ _db.execute(
2212
+ "INSERT INTO customers (account_number, name, address) VALUES (?, ?, ?)",
2213
+ (_c["account_number"], _c["name"], json.dumps(_c["address"], sort_keys=True)))
2214
+ print(f"[store] seeded {len(_DEFAULT_ROSTER)} demo customer(s)")
2215
+
2216
+ def _canonical_address(a: dict[str, Any]) -> str:
2217
+ """Order- and case-insensitive canonical form, so two addresses compare equal
2218
+ iff they mean the same thing regardless of key order or casing."""
2219
+ items = sorted(
2220
+ (str(k).lower(), " ".join(str(v).strip().lower().split()))
2221
+ for k, v in a.items()
2222
+ if isinstance(v, str) and v.strip() != ""
2223
+ )
2224
+ return json.dumps(items)
2225
+
2226
+ def _find_customer(account_number: str, name: str):
2227
+ """Match on account number first (authoritative), then name. Returns the row
2228
+ (account_number, name, address) or None."""
2229
+ acct = (account_number or "").strip()
2230
+ if acct:
2231
+ row = _db.execute(
2232
+ "SELECT account_number, name, address FROM customers WHERE account_number = ?", (acct,)
2233
+ ).fetchone()
2234
+ if row:
2235
+ return row
2236
+ n = (name or "").strip().lower()
2237
+ if n:
2238
+ row = _db.execute(
2239
+ "SELECT account_number, name, address FROM customers WHERE LOWER(name) = ?", (n,)
2240
+ ).fetchone()
2241
+ if row:
2242
+ return row
2243
+ return None
2244
+
2245
+ def _verify_account(account_number: str, name: str, known_names: list[str]) -> str:
2246
+ """Pre-payment account check behind account.verify.
2247
+ 'match' account number found and the name (or a known name) agrees
2248
+ 'no_match' account number found but the name does not agree
2249
+ 'no_account' no such account number
2250
+ """
2251
+ acct = (account_number or "").strip()
2252
+ if not acct:
2253
+ return "no_account"
2254
+ row = _db.execute("SELECT name FROM customers WHERE account_number = ?", (acct,)).fetchone()
2255
+ if not row:
2256
+ return "no_account"
2257
+ stored = (row[0] or "").strip().lower()
2258
+ candidates = [c for c in ((v or "").strip().lower() for v in [name, *known_names]) if c]
2259
+ return "match" if stored in candidates else "no_match"
2260
+
2261
+ def _save_address(account_number: str, name: str, address: dict[str, Any], dispatch_id: str) -> None:
2262
+ """Persist on address.updated. Upserts the customer's on-file address (keyed
2263
+ on the decrypted account number, falling back to name) + appends history."""
2264
+ acct = (account_number or "").strip() or (name or "").strip()
2155
2265
  address_json = json.dumps(address, sort_keys=True)
2156
2266
  _db.execute("""
2157
- INSERT INTO addresses (email, name, address, dispatch_id, updated_at)
2158
- VALUES (?, ?, ?, ?, datetime('now'))
2159
- ON CONFLICT(email) DO UPDATE SET
2160
- name = excluded.name,
2161
- address = excluded.address,
2162
- dispatch_id = excluded.dispatch_id,
2163
- updated_at = excluded.updated_at
2164
- """, (email, name, address_json, dispatch_id))
2267
+ INSERT INTO customers (account_number, name, address, updated_at)
2268
+ VALUES (?, ?, ?, datetime('now'))
2269
+ ON CONFLICT(account_number) DO UPDATE SET
2270
+ name = excluded.name,
2271
+ address = excluded.address,
2272
+ updated_at = excluded.updated_at
2273
+ """, (acct, name, address_json))
2165
2274
  _db.execute("""
2166
- INSERT INTO address_history (email, name, address, dispatch_id, recorded_at)
2167
- VALUES (?, ?, ?, ?, datetime('now'))
2168
- """, (email, name, address_json, dispatch_id))
2169
-
2170
- def _verify_address(email: str, address: dict[str, Any]) -> str:
2171
- """Returns 'match' | 'mismatch' | 'not_found' for an address.verify event."""
2172
- row = _db.execute("SELECT address FROM addresses WHERE email = ?", (email,)).fetchone()
2275
+ INSERT INTO address_history (account_number, address, dispatch_id, recorded_at)
2276
+ VALUES (?, ?, ?, datetime('now'))
2277
+ """, (acct, address_json, dispatch_id))
2278
+
2279
+ def _verify_address(account_number: str, name: str, address: dict[str, Any]) -> str:
2280
+ """Returns 'match' | 'mismatch' | 'not_found' for an address.verify event.
2281
+ Compares the WHOLE address canonically against the on-file record. A customer
2282
+ on your roster you have never updated returns 'mismatch' \u2014 you know them, you
2283
+ just don't hold THIS address yet."""
2284
+ row = _find_customer(account_number, name)
2173
2285
  if not row:
2174
2286
  return "not_found"
2175
2287
  try:
2176
- stored = json.loads(row[0])
2288
+ stored = json.loads(row[2])
2177
2289
  except (json.JSONDecodeError, TypeError):
2178
- return "not_found"
2179
- # Compare all address fields the partner cares about. Address.verify
2180
- # ignores fields the consumer didn't supply (e.g. country missing on the
2181
- # incoming check is treated as "any country acceptable") \u2014 partners that
2182
- # need stricter matching should adjust this comparison.
2183
- matches = all(stored.get(k) == v for k, v in address.items())
2184
- return "match" if matches else "mismatch"
2290
+ stored = {}
2291
+ return "match" if _canonical_address(stored) == _canonical_address(address) else "mismatch"
2185
2292
 
2186
2293
  # \u2500\u2500 Crypto helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2187
2294
 
@@ -2303,6 +2410,61 @@ def _decrypt_session(share: dict[str, Any], envelope_b64: str,
2303
2410
  plaintext = AESGCM(sk).decrypt(session_iv, session_ct, None)
2304
2411
  return json.loads(plaintext) # type: ignore[return-value]
2305
2412
 
2413
+ # \u2500\u2500 Confirm callback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2414
+
2415
+ async def _confirm_to_oneaddress(dispatch: str, status: str) -> None:
2416
+ """Close the loop after an address.updated apply, so the consumer's dashboard
2417
+ flips the service to 'Confirmed'. Scheduled as a background task
2418
+ (fire-and-forget) so a slow confirm never delays the webhook's own 200 \u2014 a
2419
+ slow confirm must not make OneAddress time the DISPATCH out and mark it failed.
2420
+
2421
+ Only real dispatches carry a POSITIVE-INTEGER id in X-OneAddress-Dispatch;
2422
+ probes (the go-live 'address.test') carry a non-numeric id and have nothing to
2423
+ confirm, so they are skipped.
2424
+
2425
+ Auth for /api/confirm (all three required):
2426
+ Authorization: Bearer <secret>
2427
+ X-OneAddress-Timestamp: <unix seconds>
2428
+ X-OneAddress-Signature: HMAC-SHA256(secret, "<timestamp>.<rawBody>")
2429
+ The same secret signs the Bearer and the body, over the EXACT bytes POSTed.
2430
+ """
2431
+ d = (dispatch or "").strip()
2432
+ if not d.isdigit() or int(d) <= 0:
2433
+ return
2434
+ dispatch_id = int(d)
2435
+ body_str = json.dumps({
2436
+ "dispatch_id": dispatch_id,
2437
+ "partner_id": PARTNER_ID,
2438
+ "status": status,
2439
+ "note": "Applied by the OneAddress webhook receiver",
2440
+ })
2441
+ ts = str(int(time.time()))
2442
+ sig = hmac_lib.new(CONFIRM_SECRET.encode(), f"{ts}.{body_str}".encode(), hashlib.sha256).hexdigest()
2443
+ try:
2444
+ async with httpx.AsyncClient(timeout=10) as client:
2445
+ # content= sends these exact bytes \u2014 the ones we signed. Do NOT use
2446
+ # json=, which would re-serialise and break the signature.
2447
+ resp = await client.post(
2448
+ f"{ONEADDRESS_API}/api/confirm",
2449
+ content=body_str,
2450
+ headers={
2451
+ "Content-Type": "application/json",
2452
+ "Authorization": f"Bearer {CONFIRM_SECRET}",
2453
+ "X-OneAddress-Timestamp": ts,
2454
+ "X-OneAddress-Signature": sig,
2455
+ },
2456
+ )
2457
+ if resp.status_code // 100 == 2:
2458
+ print(f"[confirm] dispatch {dispatch_id} -> {status}: acknowledged by OneAddress")
2459
+ else:
2460
+ print(f"[confirm] dispatch {dispatch_id} confirm FAILED \u2014 HTTP {resp.status_code}")
2461
+ if resp.status_code == 401:
2462
+ print("[confirm] 401 means the wrong secret. If your partner has a separate "
2463
+ "confirm secret, set CONFIRM_SECRET to it (from the portal Webhook screen); "
2464
+ "otherwise your webhook signing secret should work.")
2465
+ except Exception as e:
2466
+ print(f"[confirm] confirm request error: {e}")
2467
+
2306
2468
  # \u2500\u2500 Webhook handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2307
2469
 
2308
2470
  @app.post("/webhook")
@@ -2325,8 +2487,38 @@ async def webhook(request: Request) -> Response:
2325
2487
 
2326
2488
  if dispatch and dispatch in seen_dispatches:
2327
2489
  return Response(content='{"ok":true,"duplicate":true}', media_type="application/json")
2328
- if dispatch:
2329
- seen_dispatches.add(dispatch)
2490
+ # A dispatch is remembered only AFTER it has been fully handled (see the
2491
+ # success paths below), never here. Remembering on arrival would mark a
2492
+ # dispatch that then fails to decrypt (422) as "seen", so OneAddress's retry
2493
+ # after you fix the key would be dismissed as a duplicate and the update lost.
2494
+ # The address.test probe is never remembered, so re-running go-live re-tests.
2495
+
2496
+ # \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
2497
+ # Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
2498
+ # no address, no session envelope \u2014 handled HERE, ABOVE the payload-less
2499
+ # acknowledge below (account.verify is not a dispatch event, so it would
2500
+ # otherwise be acknowledged as "no payload" and never actually check).
2501
+ if event == "account.verify":
2502
+ if not VERIFIES_ACCOUNT_REFERENCE:
2503
+ print("[webhook] account.verify -> skipped (VERIFIES_ACCOUNT_REFERENCE is false)")
2504
+ return Response(content='{"ok":true,"skipped":true}', media_type="application/json")
2505
+ enc_cust = body.get("customer_encrypted")
2506
+ if not isinstance(enc_cust, dict):
2507
+ return Response(status_code=400, content='{"error":"Missing customer_encrypted"}',
2508
+ media_type="application/json")
2509
+ try:
2510
+ cust = _decrypt_address(enc_cust, PRIVATE_KEY, PARTNER_ID)
2511
+ except Exception as e:
2512
+ print(f"[webhook] account.verify decryption failed \u2014 check OA_PRIVATE_KEY_PEM: {e}")
2513
+ return Response(status_code=422, content='{"ok":false,"error":"decryption_failed"}',
2514
+ media_type="application/json")
2515
+ acct = str(cust.get("account_number") or "")
2516
+ cname = str(cust.get("name") or "")
2517
+ ckn = cust.get("known_names") or []
2518
+ ckn = [str(k) for k in ckn] if isinstance(ckn, list) else []
2519
+ status = _verify_account(acct, cname, ckn)
2520
+ print(f"[webhook] account.verify -> {status} for account {acct or '(none)'}")
2521
+ return Response(content=json.dumps({"status": status}), media_type="application/json")
2330
2522
 
2331
2523
  # A valid signed request that carries no address payload \u2014 a conformance
2332
2524
  # ping, or any event added after this receiver was generated \u2014 has already
@@ -2346,15 +2538,20 @@ async def webhook(request: Request) -> Response:
2346
2538
  session_share = body.get("session_key_share")
2347
2539
  legacy_enc = body.get("address_encrypted")
2348
2540
 
2349
- # Decrypted display name, captured for the connection-verification probe
2350
- # below. Identity comes ONLY from decryption, never a cleartext wire field.
2541
+ # Identity comes ONLY from decryption, never a cleartext wire field: under D5
2542
+ # the name / account number / known names live INSIDE the decrypted payload.
2351
2543
  verified_name = ""
2544
+ account_number = ""
2545
+ known_names: list[str] = []
2352
2546
 
2353
2547
  if isinstance(session_envelope, str) and isinstance(session_share, dict):
2354
2548
  try:
2355
2549
  data = _decrypt_session(session_share, session_envelope, PRIVATE_KEY, PARTNER_ID)
2356
2550
  address = data.get("new_address", {})
2357
2551
  verified_name = data.get("verified_name", "") or ""
2552
+ account_number = data.get("account_number", "") or ""
2553
+ _kn = data.get("known_names", [])
2554
+ known_names = [str(x) for x in _kn] if isinstance(_kn, list) else []
2358
2555
  except Exception as e:
2359
2556
  print(f"[webhook] D5 decryption failed \u2014 check OA_PRIVATE_KEY_PEM matches key_id "
2360
2557
  f"{session_share.get('key_id', '?')}: {e}")
@@ -2364,6 +2561,9 @@ async def webhook(request: Request) -> Response:
2364
2561
  try:
2365
2562
  address = _decrypt_address(legacy_enc, PRIVATE_KEY, PARTNER_ID)
2366
2563
  verified_name = address.get("fullName", "") or ""
2564
+ account_number = address.get("accountReference", "") or ""
2565
+ _kn = address.get("knownNames", [])
2566
+ known_names = [str(x) for x in _kn] if isinstance(_kn, list) else []
2367
2567
  except Exception as e:
2368
2568
  print(f"[webhook] Decryption error (check OA_PRIVATE_KEY_PEM): {e}")
2369
2569
  return Response(content='{"ok":false,"error":"decryption failed - partner key mismatch"}',
@@ -2373,18 +2573,24 @@ async def webhook(request: Request) -> Response:
2373
2573
  media_type="application/json")
2374
2574
 
2375
2575
  if event == "address.updated":
2376
- customer = body.get("customer", {})
2377
- email = customer.get("email", "")
2378
- name = customer.get("name", "")
2379
- # Log metadata only \u2014 never the decrypted address. stdout is captured
2380
- # by uvicorn / Docker / systemd-journal in production and writing PII
2381
- # to those streams turns every log reader into a data-exposure surface.
2382
- print(f"[webhook] address.updated for {email or '?'} (dispatch={dispatch})")
2383
- if email:
2384
- _save_address(email, name, address, dispatch)
2576
+ # Identity comes ONLY from decryption \u2014 there is no cleartext customer
2577
+ # block on the wire under D5. Log metadata only \u2014 never the decrypted
2578
+ # address. stdout is captured by uvicorn / Docker / systemd-journal in
2579
+ # production and writing PII there turns every log reader into a
2580
+ # data-exposure surface.
2581
+ print(f"[webhook] address.updated for {account_number or verified_name or '?'} (dispatch={dispatch})")
2582
+ _save_address(account_number, verified_name, address, dispatch)
2583
+ if dispatch:
2584
+ seen_dispatches.add(dispatch) # remember only after it is stored
2585
+ # Close the loop back to OneAddress so the service flips to "Confirmed".
2586
+ # Fire-and-forget so it never delays this 200 (keep a reference so the
2587
+ # task isn't garbage-collected before it runs).
2588
+ _t = asyncio.create_task(_confirm_to_oneaddress(dispatch, "confirmed"))
2589
+ _background_tasks.add(_t)
2590
+ _t.add_done_callback(_background_tasks.discard)
2591
+ return Response(content='{"ok":true}', media_type="application/json")
2385
2592
 
2386
2593
  elif event == "address.verify":
2387
- customer = body.get("customer", {})
2388
2594
  callback_url = body["callback_url"]
2389
2595
  callback_token= body["callback_token"]
2390
2596
  batch_id = body["batch_id"]
@@ -2400,17 +2606,16 @@ async def webhook(request: Request) -> Response:
2400
2606
  content='{"error":"Invalid callback_url host"}',
2401
2607
  media_type="application/json")
2402
2608
 
2403
- # Real DB lookup \u2014 mirrors the TypeScript scaffold's verifyAddress.
2404
- # Returns "not_found" for a fresh DB (no prior address.updated for
2405
- # this email), "match" / "mismatch" once the customer is on file.
2406
- email = customer.get("email", "")
2407
- result = _verify_address(email, address) if email else "not_found"
2408
- print(f"[webhook] address.verify -> {result} for {email or '?'}")
2609
+ # Real roster lookup \u2014 identity comes ONLY from decryption. "not_found"
2610
+ # for a customer not on your roster, "match" / "mismatch" otherwise.
2611
+ result = _verify_address(account_number, verified_name, address)
2612
+ print(f"[webhook] address.verify -> {result} for {account_number or verified_name or '?'}")
2409
2613
 
2410
2614
  payload = {
2615
+ # 2026.2 \u2014 no member_name echo; OneAddress keys the result on
2616
+ # (batch_id, partner_id) and validates the opaque token alone.
2411
2617
  "batch_id": batch_id,
2412
2618
  "partner_id": PARTNER_ID,
2413
- "member_name": customer.get("name", ""),
2414
2619
  "result": result,
2415
2620
  "token": callback_token,
2416
2621
  }
@@ -2419,6 +2624,8 @@ async def webhook(request: Request) -> Response:
2419
2624
  await client.post(callback_url, json=payload)
2420
2625
  except Exception as e:
2421
2626
  print(f"[webhook] Callback POST failed: {e}")
2627
+ if dispatch:
2628
+ seen_dispatches.add(dispatch) # remember only after the callback posted
2422
2629
 
2423
2630
  elif event in ("address.test", "address.test-dispatch"):
2424
2631
  # OneAddress connection-verification probe. Reaching here means the D5
@@ -2466,24 +2673,27 @@ Your webhook endpoint: \`POST http://localhost:3001/webhook\`
2466
2673
 
2467
2674
  ## Events handled
2468
2675
 
2676
+ ### account.verify (pre-payment account check)
2677
+ Receive \u2192 verify HMAC \u2192 decrypt the customer block \u2192 \`_verify_account\` \u2192
2678
+ answer \`match\` / \`no_match\` / \`no_account\`. Gated on \`VERIFIES_ACCOUNT_REFERENCE\`
2679
+ (the wizard writes your portal declaration): when off, the receiver answers
2680
+ \`{ "ok": true, "skipped": true }\` ("not checked").
2681
+
2469
2682
  ### address.updated
2470
- Receive \u2192 verify HMAC \u2192 decrypt \u2192 persist to your DB.
2683
+ Receive \u2192 verify HMAC \u2192 decrypt \u2192 \`_save_address\` (roster upsert + history) \u2192
2684
+ fire-and-forget \`/api/confirm\` callback so the consumer's dashboard flips the
2685
+ service to "Confirmed".
2471
2686
 
2472
2687
  ### address.verify
2473
- Receive \u2192 verify HMAC \u2192 decrypt \u2192 compare to your records \u2192 POST callback_url.
2474
-
2475
- The stub in \`app.py\` always returns \`"match"\`. Replace it with a real DB lookup:
2688
+ Receive \u2192 verify HMAC \u2192 decrypt \u2192 \`_verify_address\` against your roster \u2192 POST
2689
+ \`callback_url\`.
2476
2690
 
2477
- \`\`\`python
2478
- # In the address.verify branch of app.py, replace:
2479
- result = "match"
2480
-
2481
- # With something like:
2482
- record = db.get_customer(customer.get("email"))
2483
- result = "not_found" if not record else (
2484
- "match" if addresses_match(record.address, address) else "mismatch"
2485
- )
2486
- \`\`\`
2691
+ \`app.py\` ships a **working roster store** (SQLite, seeded with one demo
2692
+ customer), not a stub \u2014 \`_verify_address\` does a real lookup and returns
2693
+ \`"match"\` / \`"mismatch"\` / \`"not_found"\`, keyed on the account number and name
2694
+ the payload was **decrypted** to (there is no cleartext customer block on the
2695
+ wire under D5). Point \`_find_customer\` / \`_save_address\` at your real customer
2696
+ database when you outgrow the file.
2487
2697
 
2488
2698
  Valid results: \`"match"\` | \`"mismatch"\` | \`"not_found"\`
2489
2699
 
@@ -2495,7 +2705,17 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
2495
2705
 
2496
2706
  ## Configuration
2497
2707
 
2498
- Credentials are in \`.env\` (written by the setup wizard). Never commit \`.env\` to source control.
2708
+ Credentials and config are in \`.env\` (written by the setup wizard). Never commit
2709
+ \`.env\` to source control.
2710
+
2711
+ | Env var | Description |
2712
+ |---------|-------------|
2713
+ | \`OA_PARTNER_ID\` | Your partner UUID |
2714
+ | \`OA_WEBHOOK_SECRET\` | HMAC-SHA256 webhook signing secret |
2715
+ | \`OA_PRIVATE_KEY_PEM\` | PKCS#8 PEM key \u2014 \`\\n\` between PEM lines |
2716
+ | \`ONEADDRESS_API\` | Confirm-callback target \u2014 the customer app (default \`https://oneaddress.io\`), NOT the partner portal |
2717
+ | \`CONFIRM_SECRET\` | Signs the \`/api/confirm\` callback. Leave blank to reuse \`OA_WEBHOOK_SECRET\` |
2718
+ | \`VERIFIES_ACCOUNT_REFERENCE\` | Whether the receiver answers \`account.verify\` (\`true\`/\`false\`) |
2499
2719
  `
2500
2720
  }
2501
2721
  ],
@@ -2625,6 +2845,27 @@ public final class OneAddressVerifier {
2625
2845
  }
2626
2846
  }
2627
2847
 
2848
+ /**
2849
+ * HMAC-SHA256 of {@code payload} under {@code secret}, lowercase hex. Used to
2850
+ * SIGN the /api/confirm callback, over "&lt;timestamp&gt;.&lt;rawBody&gt;" \u2014 the
2851
+ * same construction {@link #verify} checks on the way in.
2852
+ */
2853
+ public static String sign(String payload, String secret) {
2854
+ try {
2855
+ Mac mac = Mac.getInstance("HmacSHA256");
2856
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
2857
+ byte[] out = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
2858
+ StringBuilder sb = new StringBuilder(out.length * 2);
2859
+ for (byte b : out) {
2860
+ sb.append(Character.forDigit((b >> 4) & 0xF, 16));
2861
+ sb.append(Character.forDigit(b & 0xF, 16));
2862
+ }
2863
+ return sb.toString();
2864
+ } catch (Exception e) {
2865
+ throw new IllegalStateException("HMAC signing failed", e);
2866
+ }
2867
+ }
2868
+
2628
2869
  private static byte[] hexToBytes(String hex) {
2629
2870
  int len = hex.length();
2630
2871
  byte[] out = new byte[len / 2];
@@ -2882,9 +3123,19 @@ public class OneAddressWebhookController {
2882
3123
  this.store = store;
2883
3124
  }
2884
3125
 
3126
+ // Baked in by the setup wizard from your portal declaration
3127
+ // (partners.verifies_account_reference). Decides whether this receiver
3128
+ // ANSWERS the pre-payment account.verify check. Override with the
3129
+ // VERIFIES_ACCOUNT_REFERENCE env var ("true"/"false").
3130
+ private static final boolean DEFAULT_VERIFIES_ACCOUNT_REFERENCE = %%VERIFIES_ACCOUNT_REFERENCE%%;
3131
+
2885
3132
  private String webhookSecret;
2886
3133
  private String privateKeyPem;
2887
3134
  private String partnerId;
3135
+ // Confirm-callback config.
3136
+ private String oneAddressApi;
3137
+ private String confirmSecret;
3138
+ private boolean verifiesAccountReference;
2888
3139
 
2889
3140
  @PostConstruct
2890
3141
  void init() {
@@ -2900,6 +3151,22 @@ public class OneAddressWebhookController {
2900
3151
  partnerId = System.getenv("ONEADDRESS_PARTNER_ID");
2901
3152
  if (partnerId == null || partnerId.isBlank())
2902
3153
  throw new IllegalStateException("ONEADDRESS_PARTNER_ID is required");
3154
+
3155
+ // ONEADDRESS_API is the CUSTOMER app (/api/confirm lives there, NOT the
3156
+ // partner portal); default to production.
3157
+ String api = System.getenv("ONEADDRESS_API");
3158
+ oneAddressApi = (api == null || api.isBlank()) ? "https://oneaddress.io" : api.trim();
3159
+ while (oneAddressApi.endsWith("/")) oneAddressApi = oneAddressApi.substring(0, oneAddressApi.length() - 1);
3160
+
3161
+ // CONFIRM_SECRET signs the callback; falls back to the webhook secret,
3162
+ // which is correct for most partners.
3163
+ String cs = System.getenv("CONFIRM_SECRET");
3164
+ confirmSecret = (cs == null || cs.isBlank()) ? webhookSecret : cs;
3165
+
3166
+ String vr = System.getenv("VERIFIES_ACCOUNT_REFERENCE");
3167
+ verifiesAccountReference = (vr == null || vr.isBlank())
3168
+ ? DEFAULT_VERIFIES_ACCOUNT_REFERENCE
3169
+ : vr.equals("true");
2903
3170
  }
2904
3171
 
2905
3172
  @PostMapping("/oneaddress")
@@ -2924,6 +3191,44 @@ public class OneAddressWebhookController {
2924
3191
  Map<String, Object> body = MAPPER.readValue(rawBody, Map.class);
2925
3192
  String event = (String) body.get("event");
2926
3193
 
3194
+ // \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
3195
+ // Carries an encrypted CUSTOMER block { name, known_names,
3196
+ // account_number } \u2014 no address, no session envelope \u2014 handled HERE,
3197
+ // before the address-decrypt section below (account.verify carries no
3198
+ // address_encrypted, so it would otherwise fall to the "no encrypted
3199
+ // payload" acknowledge and never actually check the account).
3200
+ if ("account.verify".equals(event)) {
3201
+ // Your portal declaration, baked in by the wizard. If you do not
3202
+ // verify account references, answer "not checked".
3203
+ if (!verifiesAccountReference) {
3204
+ log.info("[OneAddress] account.verify -> skipped (VERIFIES_ACCOUNT_REFERENCE is false)");
3205
+ return ResponseEntity.ok("{\\"ok\\":true,\\"skipped\\":true}");
3206
+ }
3207
+ @SuppressWarnings("unchecked")
3208
+ Map<String, Object> custEnc = (body.get("customer_encrypted") instanceof Map cm)
3209
+ ? (Map<String, Object>) cm : null;
3210
+ if (custEnc == null)
3211
+ return ResponseEntity.badRequest().body("{\\"error\\":\\"Missing customer_encrypted\\"}");
3212
+ Map<String, Object> cust;
3213
+ try {
3214
+ cust = OneAddressDecryptor.decrypt(custEnc, privateKeyPem, partnerId);
3215
+ } catch (Exception e) {
3216
+ log.error("[OneAddress] account.verify decryption failed \u2014 check ONEADDRESS_PRIVATE_KEY", e);
3217
+ return ResponseEntity.unprocessableEntity().body("{\\"ok\\":false,\\"error\\":\\"decryption_failed\\"}");
3218
+ }
3219
+ String acct = cust.get("account_number") instanceof String an2 ? an2 : null;
3220
+ String acctName = cust.get("name") instanceof String nm2 ? nm2 : "";
3221
+ java.util.List<String> acctKnownNames = java.util.List.of();
3222
+ if (cust.get("known_names") instanceof java.util.List<?> aknl) {
3223
+ java.util.ArrayList<String> tmp = new java.util.ArrayList<>();
3224
+ for (Object o : aknl) if (o != null) tmp.add(String.valueOf(o));
3225
+ acctKnownNames = tmp;
3226
+ }
3227
+ String accountStatus = store.verifyAccount(acct, acctName, acctKnownNames);
3228
+ log.info("[OneAddress] account.verify -> {} for account {}", accountStatus, acct == null ? "(none)" : acct);
3229
+ return ResponseEntity.ok("{\\"status\\":\\"" + accountStatus + "\\"}");
3230
+ }
3231
+
2927
3232
  // Two possible payload shapes:
2928
3233
  // (a) D5 \u2014 body.session_envelope (String) + body.session_key_share (Map)
2929
3234
  // (b) Legacy \u2014 body.address_encrypted (Map)
@@ -2992,6 +3297,10 @@ public class OneAddressWebhookController {
2992
3297
  // will never retry.
2993
3298
  String outcome = store.applyAddress(accountNumber, verifiedName, knownNames, address);
2994
3299
  if (dispatchId != null && !dispatchId.isBlank()) store.markProcessed(dispatchId, outcome);
3300
+ // Close the loop back to OneAddress so the service flips to
3301
+ // "Confirmed". Fire-and-forget (async) so a slow confirm never
3302
+ // delays this 200. Only when we actually applied the update.
3303
+ if ("applied".equals(outcome)) confirmToOneAddress(dispatchId, "confirmed");
2995
3304
  return ResponseEntity.ok("{\\"ok\\":true,\\"outcome\\":\\"" + outcome + "\\"}");
2996
3305
  } else if ("address.verify".equals(event)) {
2997
3306
  handleAddressVerify(body, address, accountNumber, verifiedName, knownNames);
@@ -3070,6 +3379,67 @@ public class OneAddressWebhookController {
3070
3379
  }
3071
3380
  }
3072
3381
 
3382
+ /**
3383
+ * Close the loop after an address.updated is applied, so the consumer's
3384
+ * dashboard flips the service to "Confirmed". Runs on a background thread
3385
+ * (CompletableFuture.runAsync) so a slow confirm never delays the webhook's
3386
+ * own 200 \u2014 a slow confirm must not make OneAddress time the DISPATCH out and
3387
+ * mark it failed.
3388
+ *
3389
+ * Only real dispatches carry a POSITIVE-INTEGER id in X-OneAddress-Dispatch;
3390
+ * probes (the go-live "address.test") carry a non-numeric id and have nothing
3391
+ * to confirm, so they are skipped.
3392
+ *
3393
+ * Auth for /api/confirm (all three required):
3394
+ * Authorization: Bearer &lt;secret&gt;
3395
+ * X-OneAddress-Timestamp: &lt;unix seconds&gt;
3396
+ * X-OneAddress-Signature: HMAC-SHA256(secret, "&lt;timestamp&gt;.&lt;rawBody&gt;")
3397
+ * The same secret signs the Bearer and the body, over the EXACT bytes POSTed.
3398
+ */
3399
+ private void confirmToOneAddress(String dispatch, String status) {
3400
+ final long dispatchId;
3401
+ try {
3402
+ dispatchId = Long.parseLong(dispatch == null ? "" : dispatch.trim());
3403
+ } catch (NumberFormatException e) {
3404
+ return; // probe or non-numeric id \u2014 nothing to confirm
3405
+ }
3406
+ if (dispatchId <= 0) return;
3407
+
3408
+ java.util.concurrent.CompletableFuture.runAsync(() -> {
3409
+ try {
3410
+ java.util.Map<String, Object> confirmBody = new java.util.LinkedHashMap<>();
3411
+ confirmBody.put("dispatch_id", dispatchId);
3412
+ confirmBody.put("partner_id", partnerId);
3413
+ confirmBody.put("status", status);
3414
+ confirmBody.put("note", "Applied by the OneAddress webhook receiver");
3415
+ String bodyStr = MAPPER.writeValueAsString(confirmBody);
3416
+ String ts = String.valueOf(System.currentTimeMillis() / 1000L);
3417
+ String sig = OneAddressVerifier.sign(ts + "." + bodyStr, confirmSecret);
3418
+
3419
+ HttpResponse<String> resp = HttpClient.newHttpClient().send(
3420
+ HttpRequest.newBuilder()
3421
+ .uri(URI.create(oneAddressApi + "/api/confirm"))
3422
+ .header("Content-Type", "application/json")
3423
+ .header("Authorization", "Bearer " + confirmSecret)
3424
+ .header("X-OneAddress-Timestamp", ts)
3425
+ .header("X-OneAddress-Signature", sig)
3426
+ .POST(HttpRequest.BodyPublishers.ofString(bodyStr))
3427
+ .build(),
3428
+ HttpResponse.BodyHandlers.ofString()
3429
+ );
3430
+ if (resp.statusCode() >= 200 && resp.statusCode() < 300) {
3431
+ log.info("[confirm] dispatch {} -> {}: acknowledged by OneAddress", dispatchId, status);
3432
+ } else {
3433
+ log.error("[confirm] dispatch {} confirm FAILED \u2014 HTTP {}", dispatchId, resp.statusCode());
3434
+ if (resp.statusCode() == 401)
3435
+ log.error("[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET to it (from the portal Webhook screen); otherwise your webhook signing secret should work.");
3436
+ }
3437
+ } catch (Exception e) {
3438
+ log.error("[confirm] confirm request error", e);
3439
+ }
3440
+ });
3441
+ }
3442
+
3073
3443
  private static String getHeader(Map<String, String> headers, String name) {
3074
3444
  for (Map.Entry<String, String> entry : headers.entrySet())
3075
3445
  if (entry.getKey().equalsIgnoreCase(name)) return entry.getValue();
@@ -3311,6 +3681,24 @@ public class OneAddressStore {
3311
3681
  return same ? "match" : "mismatch";
3312
3682
  }
3313
3683
 
3684
+ /**
3685
+ * Pre-payment account check behind account.verify: confirm the typed
3686
+ * account number is really one of yours and the name agrees, BEFORE the
3687
+ * consumer pays.
3688
+ * "match" account number found and the name (or a known name) agrees
3689
+ * "no_match" account number found but the name does not agree
3690
+ * "no_account" no such account number
3691
+ */
3692
+ public String verifyAccount(String accountNumber, String verifiedName, List<String> knownNames) {
3693
+ if (accountNumber == null || accountNumber.isBlank()) return "no_account";
3694
+ List<Map<String, Object>> rows = jdbc.queryForList(
3695
+ "SELECT full_name FROM customers WHERE account_number = ?", accountNumber.trim());
3696
+ if (rows.isEmpty()) return "no_account";
3697
+ String storedName = String.valueOf(rows.get(0).get("full_name"));
3698
+ List<String> candidates = allNames(verifiedName, knownNames);
3699
+ return candidates.stream().anyMatch(c -> c.equalsIgnoreCase(storedName)) ? "match" : "no_match";
3700
+ }
3701
+
3314
3702
  // \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3315
3703
 
3316
3704
  private static List<String> allNames(String verifiedName, List<String> knownNames) {
@@ -3435,6 +3823,23 @@ name matching needs maiden names, initials and word order \u2014 see
3435
3823
  | \`ONEADDRESS_WEBHOOK_SECRET\` | Webhook secret from the Partner Portal |
3436
3824
  | \`ONEADDRESS_PRIVATE_KEY\` | PKCS#8 PEM private key (use \`\\n\` between lines) |
3437
3825
  | \`ONEADDRESS_PARTNER_ID\` | Your partner UUID |
3826
+ | \`ONEADDRESS_API\` | Confirm-callback target \u2014 the customer app (default \`https://oneaddress.io\`), NOT the partner portal |
3827
+ | \`CONFIRM_SECRET\` | Signs the \`/api/confirm\` callback. Leave unset to reuse the webhook secret (correct for most partners) |
3828
+ | \`VERIFIES_ACCOUNT_REFERENCE\` | Override the wizard's baked-in \`account.verify\` declaration (\`true\`/\`false\`) |
3829
+
3830
+ ### account.verify
3831
+
3832
+ If you verify account references, the receiver decrypts the pre-payment
3833
+ \`account.verify\` probe and answers \`match\` / \`no_match\` / \`no_account\` from your
3834
+ roster. If not, it answers \`{ "ok": true, "skipped": true }\` ("not checked").
3835
+ The setup wizard bakes your portal declaration in; \`VERIFIES_ACCOUNT_REFERENCE\`
3836
+ overrides it.
3837
+
3838
+ ### Confirm callback
3839
+
3840
+ After applying an \`address.updated\`, the receiver POSTs \`/api/confirm\` on
3841
+ \`ONEADDRESS_API\` (fire-and-forget, HMAC-signed with \`CONFIRM_SECRET\`) so the
3842
+ consumer's dashboard flips the service to "Confirmed".
3438
3843
 
3439
3844
  ## Run conformance check
3440
3845
 
@@ -3660,6 +4065,32 @@ public sealed class OneAddressStore
3660
4065
  return same ? "match" : "mismatch";
3661
4066
  }
3662
4067
 
4068
+ /// <summary>
4069
+ /// Pre-payment account check behind account.verify: confirm the typed
4070
+ /// account number is really one of yours and the name agrees, BEFORE the
4071
+ /// consumer pays.
4072
+ /// "match" account number found and the name (or a known name) agrees
4073
+ /// "no_match" account number found but the name does not agree
4074
+ /// "no_account" no such account number
4075
+ /// </summary>
4076
+ public string VerifyAccount(string? accountNumber, string verifiedName, List<string> knownNames)
4077
+ {
4078
+ if (string.IsNullOrWhiteSpace(accountNumber)) return "no_account";
4079
+ using var conn = Open();
4080
+ using var cmd = conn.CreateCommand();
4081
+ cmd.CommandText = "SELECT full_name FROM customers WHERE account_number = $a";
4082
+ cmd.Parameters.AddWithValue("$a", accountNumber.Trim());
4083
+ var v = cmd.ExecuteScalar();
4084
+ if (v is null or DBNull) return "no_account";
4085
+ var storedName = Convert.ToString(v) ?? "";
4086
+
4087
+ var candidates = new List<string>();
4088
+ if (!string.IsNullOrWhiteSpace(verifiedName)) candidates.Add(verifiedName.Trim());
4089
+ candidates.AddRange(knownNames.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n.Trim()));
4090
+ return candidates.Any(c => string.Equals(c, storedName, StringComparison.OrdinalIgnoreCase))
4091
+ ? "match" : "no_match";
4092
+ }
4093
+
3663
4094
  /// <summary>
3664
4095
  /// Account number first (authoritative), then name.
3665
4096
  ///
@@ -3750,6 +4181,22 @@ var privateKeyPem = (Environment.GetEnvironmentVariable("ONEADDRESS_PRIVATE_KEY"
3750
4181
  .Replace("\\\\n", "\\n");
3751
4182
  var partnerId = Environment.GetEnvironmentVariable("ONEADDRESS_PARTNER_ID") ?? "";
3752
4183
 
4184
+ // Confirm-callback config. ONEADDRESS_API is the CUSTOMER app (/api/confirm
4185
+ // lives there, NOT the partner portal); CONFIRM_SECRET signs the callback and
4186
+ // falls back to the webhook secret, which is correct for most partners.
4187
+ var oneAddressApiRaw = Environment.GetEnvironmentVariable("ONEADDRESS_API");
4188
+ var oneAddressApi = string.IsNullOrEmpty(oneAddressApiRaw) ? "https://oneaddress.io" : oneAddressApiRaw.TrimEnd('/');
4189
+ var confirmSecret = Environment.GetEnvironmentVariable("CONFIRM_SECRET");
4190
+ if (string.IsNullOrEmpty(confirmSecret)) confirmSecret = webhookSecret;
4191
+
4192
+ // Whether this receiver answers the pre-payment account.verify check. The setup
4193
+ // wizard bakes your portal declaration into the default below; override it at
4194
+ // runtime with the VERIFIES_ACCOUNT_REFERENCE env var ("true"/"false").
4195
+ const bool DefaultVerifiesAccountReference = %%VERIFIES_ACCOUNT_REFERENCE%%;
4196
+ var verifiesAccountReference = Environment.GetEnvironmentVariable("VERIFIES_ACCOUNT_REFERENCE") is { } vr
4197
+ ? vr == "true"
4198
+ : DefaultVerifiesAccountReference;
4199
+
3753
4200
  var builder = WebApplication.CreateBuilder(args);
3754
4201
  var app = builder.Build();
3755
4202
 
@@ -3784,6 +4231,45 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
3784
4231
 
3785
4232
  var eventType = body.TryGetProperty("event", out var evtEl) ? evtEl.GetString() : null;
3786
4233
 
4234
+ // \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
4235
+ // Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
4236
+ // no address, no session envelope \u2014 handled HERE, ABOVE the payload-less
4237
+ // acknowledge below (account.verify is not a dispatch event, so it would
4238
+ // otherwise be acknowledged as "no payload" and never checked). Answer
4239
+ // synchronously with { status }.
4240
+ if (eventType == "account.verify")
4241
+ {
4242
+ // You told the portal whether you verify account references; the wizard
4243
+ // baked that into DefaultVerifiesAccountReference. If you do NOT, answer
4244
+ // "not checked" rather than a match/no_match you don't compute.
4245
+ if (!verifiesAccountReference)
4246
+ {
4247
+ app.Logger.LogInformation("[OneAddress] account.verify \u2192 skipped (VERIFIES_ACCOUNT_REFERENCE is false)");
4248
+ return Results.Json(new { ok = true, skipped = true });
4249
+ }
4250
+ if (!body.TryGetProperty("customer_encrypted", out var custEnc) || custEnc.ValueKind != JsonValueKind.Object)
4251
+ return Results.Json(new { error = "Missing customer_encrypted" }, statusCode: 400);
4252
+ byte[] custPlain;
4253
+ try { custPlain = DecryptAddress(custEnc, privateKeyPem, partnerId); }
4254
+ catch (Exception ex)
4255
+ {
4256
+ app.Logger.LogError(ex, "[OneAddress] account.verify decryption failed \u2014 check ONEADDRESS_PRIVATE_KEY");
4257
+ return Results.Json(new { ok = false, error = "decryption_failed" }, statusCode: 422);
4258
+ }
4259
+ var cust = JsonSerializer.Deserialize<JsonElement>(custPlain);
4260
+ string? acct = cust.TryGetProperty("account_number", out var acctEl) && acctEl.ValueKind == JsonValueKind.String
4261
+ ? acctEl.GetString() : null;
4262
+ var acctName = cust.TryGetProperty("name", out var anmEl) && anmEl.ValueKind == JsonValueKind.String
4263
+ ? anmEl.GetString() ?? "" : "";
4264
+ var acctKnownNames = new List<string>();
4265
+ if (cust.TryGetProperty("known_names", out var aknEl) && aknEl.ValueKind == JsonValueKind.Array)
4266
+ foreach (var n in aknEl.EnumerateArray())
4267
+ if (n.ValueKind == JsonValueKind.String) acctKnownNames.Add(n.GetString()!);
4268
+ var accountStatus = store.VerifyAccount(acct, acctName, acctKnownNames);
4269
+ app.Logger.LogInformation("[OneAddress] account.verify \u2192 {Status} for account {Acct}", accountStatus, acct ?? "(none)");
4270
+ return Results.Json(new { status = accountStatus });
4271
+ }
4272
+
3787
4273
  // address.test / address.test-dispatch (the connection-verification probe)
3788
4274
  // must pass this guard so it reaches the decrypt below; a wrong key then
3789
4275
  // returns 422 and only a real decrypt reaches the verification answer.
@@ -3896,6 +4382,11 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
3896
4382
  // marks the dispatch delivered on a failure that is never retried.
3897
4383
  var outcome = store.ApplyAddress(accountNumber, verifiedName, knownNames, address);
3898
4384
  if (!string.IsNullOrEmpty(dispatchId)) store.MarkProcessed(dispatchId, outcome);
4385
+ // Close the loop back to OneAddress so the service flips to "Confirmed".
4386
+ // Fire-and-forget (discard the Task) so a slow confirm never delays this
4387
+ // 200. Only when we actually applied the update.
4388
+ if (outcome == "applied")
4389
+ _ = ConfirmToOneAddress(oneAddressApi, confirmSecret, partnerId, dispatchId, "confirmed", app.Logger);
3899
4390
  return Results.Json(new { ok = true, outcome });
3900
4391
  }
3901
4392
  else // address.verify
@@ -3909,7 +4400,68 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
3909
4400
 
3910
4401
  app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
3911
4402
 
3912
- app.Run("http://localhost:3001");
4403
+ // PORT is read from the environment (default 3001) rather than hardcoded, so
4404
+ // the same build runs behind whatever port your tunnel / process manager sets.
4405
+ var port = Environment.GetEnvironmentVariable("PORT");
4406
+ if (string.IsNullOrEmpty(port)) port = "3001";
4407
+ app.Run($"http://localhost:{port}");
4408
+
4409
+ // \u2500\u2500 Confirm callback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4410
+
4411
+ // Close the loop after an address.updated is applied, so the consumer's
4412
+ // dashboard flips the service to "Confirmed". Called with the Task discarded
4413
+ // (fire-and-forget) so a slow confirm never delays the webhook's own 200 \u2014 a
4414
+ // slow confirm must not make OneAddress time the DISPATCH out and mark it
4415
+ // failed.
4416
+ //
4417
+ // Only real dispatches carry a POSITIVE-INTEGER id in X-OneAddress-Dispatch.
4418
+ // Probes (the go-live "address.test") carry a non-numeric id and have nothing
4419
+ // to confirm, so they are skipped.
4420
+ //
4421
+ // Auth for /api/confirm (all three required):
4422
+ // Authorization: Bearer <secret>
4423
+ // X-OneAddress-Timestamp: <unix seconds>
4424
+ // X-OneAddress-Signature: HMAC-SHA256(secret, "<timestamp>.<rawBody>")
4425
+ // The same secret signs the Bearer and the body, over the EXACT bytes POSTed.
4426
+ async Task ConfirmToOneAddress(string oneAddressApi, string confirmSecret, string pid,
4427
+ string? dispatch, string status, ILogger logger)
4428
+ {
4429
+ if (!long.TryParse((dispatch ?? "").Trim(), out var dispatchId) || dispatchId <= 0) return;
4430
+
4431
+ var bodyStr = JsonSerializer.Serialize(new {
4432
+ dispatch_id = dispatchId,
4433
+ partner_id = pid,
4434
+ status,
4435
+ note = "Applied by the OneAddress webhook receiver",
4436
+ });
4437
+ var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
4438
+ var sig = Convert.ToHexString(
4439
+ HMACSHA256.HashData(Encoding.UTF8.GetBytes(confirmSecret), Encoding.UTF8.GetBytes($"{ts}.{bodyStr}"))
4440
+ ).ToLowerInvariant();
4441
+
4442
+ try
4443
+ {
4444
+ using var http = new HttpClient();
4445
+ using var content = new StringContent(bodyStr, Encoding.UTF8, "application/json");
4446
+ using var req = new HttpRequestMessage(HttpMethod.Post, $"{oneAddressApi}/api/confirm") { Content = content };
4447
+ req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {confirmSecret}");
4448
+ req.Headers.TryAddWithoutValidation("X-OneAddress-Timestamp", ts);
4449
+ req.Headers.TryAddWithoutValidation("X-OneAddress-Signature", sig);
4450
+ using var resp = await http.SendAsync(req);
4451
+ if (resp.IsSuccessStatusCode)
4452
+ logger.LogInformation("[confirm] dispatch {Id} \u2192 {Status}: acknowledged by OneAddress", dispatchId, status);
4453
+ else
4454
+ {
4455
+ logger.LogError("[confirm] dispatch {Id} confirm FAILED \u2014 HTTP {Code}", dispatchId, (int)resp.StatusCode);
4456
+ if ((int)resp.StatusCode == 401)
4457
+ logger.LogError("[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET to it (from the portal Webhook screen); otherwise your webhook signing secret should work.");
4458
+ }
4459
+ }
4460
+ catch (Exception ex)
4461
+ {
4462
+ logger.LogError(ex, "[confirm] confirm request error");
4463
+ }
4464
+ }
3913
4465
 
3914
4466
  // \u2500\u2500 Crypto helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3915
4467
 
@@ -4176,6 +4728,24 @@ name matching needs maiden names, initials and word order \u2014 see
4176
4728
  | \`ONEADDRESS_WEBHOOK_SECRET\` | Webhook secret from the Partner Portal |
4177
4729
  | \`ONEADDRESS_PRIVATE_KEY\` | PKCS#8 PEM key \u2014 use \`\\n\` between PEM lines |
4178
4730
  | \`ONEADDRESS_PARTNER_ID\` | Your partner UUID |
4731
+ | \`ONEADDRESS_API\` | Confirm-callback target \u2014 the customer app (default \`https://oneaddress.io\`), NOT the partner portal |
4732
+ | \`CONFIRM_SECRET\` | Signs the \`/api/confirm\` callback. Leave unset to reuse the webhook secret (correct for most partners) |
4733
+ | \`VERIFIES_ACCOUNT_REFERENCE\` | Override the wizard's baked-in \`account.verify\` declaration (\`true\`/\`false\`) |
4734
+ | \`PORT\` | HTTP port (default \`3001\`) |
4735
+
4736
+ ### account.verify
4737
+
4738
+ If you verify account references, the receiver decrypts the pre-payment
4739
+ \`account.verify\` probe and answers \`match\` / \`no_match\` / \`no_account\` from your
4740
+ roster. If not, it answers \`{ ok: true, skipped: true }\` ("not checked"). The
4741
+ setup wizard bakes your portal declaration in; \`VERIFIES_ACCOUNT_REFERENCE\`
4742
+ overrides it.
4743
+
4744
+ ### Confirm callback
4745
+
4746
+ After applying an \`address.updated\`, the receiver POSTs \`/api/confirm\` on
4747
+ \`ONEADDRESS_API\` (fire-and-forget, HMAC-signed with \`CONFIRM_SECRET\`) so the
4748
+ consumer's dashboard flips the service to "Confirmed".
4179
4749
 
4180
4750
  ## Run conformance check
4181
4751
 
@@ -4193,6 +4763,21 @@ npx @oneaddress/conformance test http://localhost:3001/webhooks/oneaddress
4193
4763
  WEBHOOK_SECRET=%%WEBHOOK_SECRET%%
4194
4764
  PARTNER_PRIVATE_KEY_PEM="%%PRIVATE_KEY%%"
4195
4765
  PORT=3001
4766
+
4767
+ # Where the confirm callback is POSTed after you apply an address update. This
4768
+ # is the CUSTOMER app (oneaddress.io), NOT the partner portal \u2014 /api/confirm
4769
+ # lives on the former. Written by the setup wizard.
4770
+ ONEADDRESS_API=%%ONEADDRESS_API%%
4771
+
4772
+ # Secret that signs the /api/confirm callback. Leave BLANK to reuse
4773
+ # WEBHOOK_SECRET (correct for most partners); set it only if your partner has a
4774
+ # separate confirm secret in the portal Webhook screen.
4775
+ CONFIRM_SECRET=
4776
+
4777
+ # Whether this receiver answers the pre-payment account.verify check. The wizard
4778
+ # bakes your portal declaration into a default in main.go; set this to
4779
+ # "true"/"false" only to override that at runtime.
4780
+ # VERIFIES_ACCOUNT_REFERENCE=
4196
4781
  `
4197
4782
  },
4198
4783
  {
@@ -4213,6 +4798,15 @@ PARTNER_PRIVATE_KEY_PEM=
4213
4798
 
4214
4799
  # HTTP port (default 3001)
4215
4800
  PORT=3001
4801
+
4802
+ # Confirm-callback target \u2014 the customer app, not the partner portal.
4803
+ ONEADDRESS_API=https://oneaddress.io
4804
+
4805
+ # Secret signing the /api/confirm callback. Blank = reuse WEBHOOK_SECRET.
4806
+ CONFIRM_SECRET=
4807
+
4808
+ # Override the wizard's baked-in account.verify declaration ("true"/"false").
4809
+ # VERIFIES_ACCOUNT_REFERENCE=
4216
4810
  `
4217
4811
  },
4218
4812
  {
@@ -4429,6 +5023,38 @@ func (s *Store) VerifyAddress(accountNumber, verifiedName string, knownNames []s
4429
5023
  return "mismatch"
4430
5024
  }
4431
5025
 
5026
+ // VerifyAccount is the pre-payment account check behind account.verify: confirm
5027
+ // the typed account number is really one of yours and the name agrees, BEFORE
5028
+ // the consumer pays. The boundary that stops someone pushing an update to an
5029
+ // account that isn't theirs.
5030
+ //
5031
+ // "match" account number found and the name (or a known name) agrees
5032
+ // "no_match" account number found but the name does not agree
5033
+ // "no_account" no such account number
5034
+ func (s *Store) VerifyAccount(accountNumber, verifiedName string, knownNames []string) string {
5035
+ acct := strings.TrimSpace(accountNumber)
5036
+ if acct == "" {
5037
+ return "no_account"
5038
+ }
5039
+ var fullName string
5040
+ err := s.db.QueryRow("SELECT full_name FROM customers WHERE account_number = ?", acct).Scan(&fullName)
5041
+ if err != nil {
5042
+ // sql.ErrNoRows or any read failure \u2192 we cannot confirm the account.
5043
+ return "no_account"
5044
+ }
5045
+ candidates := []string{}
5046
+ if strings.TrimSpace(verifiedName) != "" {
5047
+ candidates = append(candidates, verifiedName)
5048
+ }
5049
+ candidates = append(candidates, knownNames...)
5050
+ for _, c := range candidates {
5051
+ if strings.TrimSpace(c) != "" && strings.EqualFold(strings.TrimSpace(c), strings.TrimSpace(fullName)) {
5052
+ return "match"
5053
+ }
5054
+ }
5055
+ return "no_match"
5056
+ }
5057
+
4432
5058
  // findCustomerID matches on account number first (authoritative), then name.
4433
5059
  //
4434
5060
  // The name pass is deliberately simple. Real matching needs maiden names,
@@ -4548,6 +5174,12 @@ import (
4548
5174
 
4549
5175
  // \u2500\u2500 Config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4550
5176
 
5177
+ // defaultVerifiesAccountReference is baked in by the setup wizard from your
5178
+ // portal declaration (partners.verifies_account_reference). It decides whether
5179
+ // this receiver ANSWERS the pre-payment account.verify check or replies "not
5180
+ // checked". Override it at runtime with the VERIFIES_ACCOUNT_REFERENCE env var.
5181
+ const defaultVerifiesAccountReference = %%VERIFIES_ACCOUNT_REFERENCE%%
5182
+
4551
5183
  func main() {
4552
5184
  webhookSecret := os.Getenv("WEBHOOK_SECRET")
4553
5185
  privateKeyPEM := strings.ReplaceAll(os.Getenv("PARTNER_PRIVATE_KEY_PEM"), \`\\n\`, "\\n")
@@ -4561,6 +5193,23 @@ func main() {
4561
5193
  log.Fatal("[startup] Missing required env vars: WEBHOOK_SECRET, PARTNER_PRIVATE_KEY_PEM, PARTNER_ID")
4562
5194
  }
4563
5195
 
5196
+ // Confirm-callback config. ONEADDRESS_API is the customer app (/api/confirm
5197
+ // lives there, NOT on the partner portal); CONFIRM_SECRET signs the callback
5198
+ // and falls back to the webhook secret, which is correct for most partners.
5199
+ oneAddressAPI := strings.TrimRight(os.Getenv("ONEADDRESS_API"), "/")
5200
+ if oneAddressAPI == "" {
5201
+ oneAddressAPI = "https://oneaddress.io"
5202
+ }
5203
+ confirmSecret := os.Getenv("CONFIRM_SECRET")
5204
+ if confirmSecret == "" {
5205
+ confirmSecret = webhookSecret
5206
+ }
5207
+ // Whether we answer account.verify \u2014 the wizard's default, env-overridable.
5208
+ verifiesAccountReference := defaultVerifiesAccountReference
5209
+ if v := os.Getenv("VERIFIES_ACCOUNT_REFERENCE"); v != "" {
5210
+ verifiesAccountReference = v == "true"
5211
+ }
5212
+
4564
5213
  // Durable store: schema, seed data, and the delivery log. Replaces the
4565
5214
  // in-memory dedup map this scaffold used to carry \u2014 that was per-process
4566
5215
  // (useless behind a load balancer), lost on restart, and marked on arrival,
@@ -4603,6 +5252,48 @@ func main() {
4603
5252
 
4604
5253
  event, _ := parsed["event"].(string)
4605
5254
 
5255
+ // \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
5256
+ // Carries an encrypted CUSTOMER block { name, known_names, account_number }
5257
+ // \u2014 no address, no session envelope \u2014 so it is handled HERE, ABOVE the
5258
+ // payload-less acknowledge switch below (account.verify is not a dispatch
5259
+ // event, so it would otherwise fall through to "no payload \u2192 skipped" and
5260
+ // never actually check the account). Answer synchronously with { status }.
5261
+ if event == "account.verify" {
5262
+ // You told the portal whether you verify account references; the wizard
5263
+ // baked that into defaultVerifiesAccountReference. If you do NOT, answer
5264
+ // "not checked" rather than a match/no_match you don't compute.
5265
+ if !verifiesAccountReference {
5266
+ log.Print("[webhook] account.verify \u2192 skipped (VERIFIES_ACCOUNT_REFERENCE is false)")
5267
+ jsonResp(w, 200, map[string]any{"ok": true, "skipped": true})
5268
+ return
5269
+ }
5270
+ encCust, ok := parsed["customer_encrypted"].(map[string]any)
5271
+ if !ok {
5272
+ jsonResp(w, 400, map[string]any{"error": "Missing customer_encrypted"})
5273
+ return
5274
+ }
5275
+ cust, derr := decryptAddress(encCust, privateKeyPEM, partnerID)
5276
+ if derr != nil {
5277
+ log.Printf("[webhook] account.verify decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM: %v", derr)
5278
+ jsonResp(w, 422, map[string]any{"ok": false, "error": "decryption_failed"})
5279
+ return
5280
+ }
5281
+ acct, _ := cust["account_number"].(string)
5282
+ name, _ := cust["name"].(string)
5283
+ var knownNames []string
5284
+ if raw, ok := cust["known_names"].([]any); ok {
5285
+ for _, n := range raw {
5286
+ if s, ok := n.(string); ok {
5287
+ knownNames = append(knownNames, s)
5288
+ }
5289
+ }
5290
+ }
5291
+ status := store.VerifyAccount(acct, name, knownNames)
5292
+ log.Printf("[webhook] account.verify \u2192 %s for account %s (%s)", status, acct, name)
5293
+ jsonResp(w, 200, map[string]any{"status": status})
5294
+ return
5295
+ }
5296
+
4606
5297
  // A valid signed request that carries no address payload \u2014 a conformance
4607
5298
  // ping, or any event added after this receiver was generated \u2014 has already
4608
5299
  // passed timestamp + signature above, which is exactly what such a probe
@@ -4702,6 +5393,12 @@ func main() {
4702
5393
  if dispatch != "" {
4703
5394
  _ = store.MarkProcessed(dispatch, outcome)
4704
5395
  }
5396
+ // Close the loop back to OneAddress so the service flips to "Confirmed".
5397
+ // Fire-and-forget in a goroutine: a slow confirm must not delay this 200
5398
+ // (which acks the delivery). Only when we actually applied the update.
5399
+ if outcome == "applied" {
5400
+ go confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, "confirmed")
5401
+ }
4705
5402
  jsonResp(w, 200, map[string]any{"ok": true, "outcome": outcome})
4706
5403
  return
4707
5404
 
@@ -4794,6 +5491,67 @@ func main() {
4794
5491
  log.Fatal(http.ListenAndServe(":"+port, nil))
4795
5492
  }
4796
5493
 
5494
+ // \u2500\u2500 Confirm callback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5495
+
5496
+ // confirmToOneAddress closes the loop after an address.updated is applied, so
5497
+ // the consumer's dashboard flips the service to "Confirmed". Called in a
5498
+ // goroutine (fire-and-forget) so it never delays the webhook's own 200 \u2014 a slow
5499
+ // confirm must not make OneAddress time the DISPATCH out and mark it failed.
5500
+ //
5501
+ // Only real dispatches carry a POSITIVE-INTEGER id in X-OneAddress-Dispatch.
5502
+ // Probes (the go-live "address.test") carry a non-numeric id and have nothing
5503
+ // to confirm, so they are skipped.
5504
+ //
5505
+ // Auth for /api/confirm (all three required):
5506
+ //
5507
+ // Authorization: Bearer <secret>
5508
+ // X-OneAddress-Timestamp: <unix seconds>
5509
+ // X-OneAddress-Signature: HMAC-SHA256(secret, "<timestamp>.<rawBody>")
5510
+ //
5511
+ // The same secret signs the Bearer and the body, over the EXACT bytes POSTed.
5512
+ func confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, status string) {
5513
+ dispatchID, err := strconv.ParseInt(strings.TrimSpace(dispatch), 10, 64)
5514
+ if err != nil || dispatchID <= 0 {
5515
+ return
5516
+ }
5517
+ bodyBytes, _ := json.Marshal(map[string]any{
5518
+ "dispatch_id": dispatchID,
5519
+ "partner_id": partnerID,
5520
+ "status": status,
5521
+ "note": "Applied by the OneAddress webhook receiver",
5522
+ })
5523
+ bodyStr := string(bodyBytes)
5524
+ ts := strconv.FormatInt(time.Now().Unix(), 10)
5525
+ mac := hmac.New(sha256.New, []byte(confirmSecret))
5526
+ mac.Write([]byte(ts + "." + bodyStr))
5527
+ sig := hex.EncodeToString(mac.Sum(nil))
5528
+
5529
+ req, err := http.NewRequest(http.MethodPost, oneAddressAPI+"/api/confirm", strings.NewReader(bodyStr))
5530
+ if err != nil {
5531
+ log.Printf("[confirm] request build error: %v", err)
5532
+ return
5533
+ }
5534
+ req.Header.Set("Content-Type", "application/json")
5535
+ req.Header.Set("Authorization", "Bearer "+confirmSecret)
5536
+ req.Header.Set("X-OneAddress-Timestamp", ts)
5537
+ req.Header.Set("X-OneAddress-Signature", sig)
5538
+
5539
+ resp, err := http.DefaultClient.Do(req)
5540
+ if err != nil {
5541
+ log.Printf("[confirm] confirm request error: %v", err)
5542
+ return
5543
+ }
5544
+ defer resp.Body.Close()
5545
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
5546
+ log.Printf("[confirm] dispatch %d \u2192 %s: acknowledged by OneAddress", dispatchID, status)
5547
+ return
5548
+ }
5549
+ log.Printf("[confirm] dispatch %d confirm FAILED \u2014 HTTP %d", dispatchID, resp.StatusCode)
5550
+ if resp.StatusCode == 401 {
5551
+ log.Print("[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET in .env to it (from the portal Webhook screen); otherwise your webhook signing secret should work.")
5552
+ }
5553
+ }
5554
+
4797
5555
  // \u2500\u2500 Crypto \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4798
5556
 
4799
5557
  func verifyHMAC(rawBody, signature, timestamp, secret string) bool {
@@ -5178,6 +5936,20 @@ Credentials are in \`.env\` (written by the setup wizard). Never commit \`.env\`
5178
5936
  OA_PARTNER_ID=%%PARTNER_ID%%
5179
5937
  OA_WEBHOOK_SECRET=%%WEBHOOK_SECRET%%
5180
5938
  OA_PRIVATE_KEY_PEM="%%PRIVATE_KEY%%"
5939
+
5940
+ # Where the confirm callback is POSTed after you apply an address update. This is
5941
+ # the CUSTOMER app (oneaddress.io), NOT the partner portal \u2014 /api/confirm lives
5942
+ # on the former. Written by the setup wizard.
5943
+ ONEADDRESS_API=%%ONEADDRESS_API%%
5944
+
5945
+ # Secret that signs the /api/confirm callback. Leave BLANK to reuse
5946
+ # OA_WEBHOOK_SECRET (correct for most partners); set it only if your partner has
5947
+ # a separate confirm secret in the portal Webhook screen.
5948
+ CONFIRM_SECRET=
5949
+
5950
+ # Whether this receiver answers the pre-payment account.verify check. The wizard
5951
+ # writes your portal declaration here; set true/false to override.
5952
+ VERIFIES_ACCOUNT_REFERENCE=%%VERIFIES_ACCOUNT_REFERENCE%%
5181
5953
  `
5182
5954
  },
5183
5955
  {
@@ -5343,6 +6115,31 @@ class OneAddressStore
5343
6115
  return $same ? 'match' : 'mismatch';
5344
6116
  }
5345
6117
 
6118
+ /**
6119
+ * Pre-payment account check behind account.verify: confirm the typed account
6120
+ * number is really one of yours and the name agrees, BEFORE the consumer pays.
6121
+ * 'match' account number found and the name (or a known name) agrees
6122
+ * 'no_match' account number found but the name does not agree
6123
+ * 'no_account' no such account number
6124
+ */
6125
+ public function verifyAccount(?string $accountNumber, string $verifiedName, array $knownNames): string
6126
+ {
6127
+ if ($accountNumber === null || trim($accountNumber) === '') {
6128
+ return 'no_account';
6129
+ }
6130
+ $row = DB::table('customers')->where('account_number', trim($accountNumber))->first();
6131
+ if (!$row) {
6132
+ return 'no_account';
6133
+ }
6134
+ $candidates = array_filter(array_merge([$verifiedName], $knownNames), fn ($n) => is_string($n) && trim($n) !== '');
6135
+ foreach ($candidates as $name) {
6136
+ if (strcasecmp(trim($name), $row->full_name) === 0) {
6137
+ return 'match';
6138
+ }
6139
+ }
6140
+ return 'no_match';
6141
+ }
6142
+
5346
6143
  /**
5347
6144
  * Account number first (authoritative), then name.
5348
6145
  *
@@ -5412,11 +6209,135 @@ class OneAddressStore
5412
6209
  }
5413
6210
  }
5414
6211
 
6212
+ /**
6213
+ * Load a partner private key from a PEM (or bare base64 DER) string.
6214
+ * Returns an OpenSSL key resource/object, or false on failure.
6215
+ */
6216
+ function oaLoadPrivateKey(string $raw)
6217
+ {
6218
+ $keyStr = trim($raw);
6219
+ if (strpos($keyStr, '-----') === false) {
6220
+ $keyStr = "-----BEGIN PRIVATE KEY-----\\n"
6221
+ . chunk_split($keyStr, 64, "\\n")
6222
+ . "-----END PRIVATE KEY-----";
6223
+ }
6224
+ return openssl_pkey_get_private($keyStr);
6225
+ }
6226
+
6227
+ /**
6228
+ * Legacy (non-D5) ECDH + HKDF-SHA256 + AES-256-GCM decrypt of a { ephemeralPublicKey,
6229
+ * iv, ciphertext, hkdfSalt } block \u2014 the same primitive address_encrypted uses, and
6230
+ * the shape account.verify's customer_encrypted block arrives in. Returns the decoded
6231
+ * object, or null on any failure.
6232
+ */
6233
+ function oaDecryptLegacy(array $enc, $privateKey, string $partnerId): ?array
6234
+ {
6235
+ $b64 = fn (string $s): string => base64_decode(strtr($s, '-_', '+/'));
6236
+ $spkiPrefix = "\\x30\\x59\\x30\\x13\\x06\\x07\\x2a\\x86\\x48\\xce\\x3d\\x02\\x01"
6237
+ . "\\x06\\x08\\x2a\\x86\\x48\\xce\\x3d\\x03\\x01\\x07\\x03\\x42\\x00";
6238
+
6239
+ $ephRaw = $b64($enc['ephemeralPublicKey'] ?? '');
6240
+ if (strlen($ephRaw) !== 65 || ord($ephRaw[0]) !== 0x04) {
6241
+ return null;
6242
+ }
6243
+ $ephPem = "-----BEGIN PUBLIC KEY-----\\n"
6244
+ . chunk_split(base64_encode($spkiPrefix . $ephRaw), 64, "\\n")
6245
+ . "-----END PUBLIC KEY-----";
6246
+ $ephKey = openssl_pkey_get_public($ephPem);
6247
+ if (!$ephKey) {
6248
+ return null;
6249
+ }
6250
+ $shared = openssl_pkey_derive($ephKey, $privateKey);
6251
+ if ($shared === false) {
6252
+ return null;
6253
+ }
6254
+ $saltB64 = $enc['hkdfSalt'] ?? null;
6255
+ $salt = $saltB64 ? $b64($saltB64) : str_repeat("\\x00", 32);
6256
+ $aesKey = hash_hkdf('sha256', $shared, 32, 'oneaddress:' . $partnerId, $salt);
6257
+
6258
+ $ctFull = $b64($enc['ciphertext'] ?? '');
6259
+ $tag = substr($ctFull, -16);
6260
+ $ct = substr($ctFull, 0, -16);
6261
+ $plaintext = openssl_decrypt($ct, 'aes-256-gcm', $aesKey, OPENSSL_RAW_DATA, $b64($enc['iv'] ?? ''), $tag);
6262
+ if ($plaintext === false) {
6263
+ return null;
6264
+ }
6265
+ $data = json_decode($plaintext, true);
6266
+ return is_array($data) ? $data : null;
6267
+ }
6268
+
6269
+ /**
6270
+ * Close the loop after an address.updated is applied, so the consumer's dashboard
6271
+ * flips the service to "Confirmed".
6272
+ *
6273
+ * Only real dispatches carry a POSITIVE-INTEGER id in X-OneAddress-Dispatch;
6274
+ * probes (the go-live "address.test") carry a non-numeric id and have nothing to
6275
+ * confirm, so they are skipped. Never throws and never affects the webhook's own
6276
+ * 200 \u2014 a confirm failure is logged, not surfaced. PHP-FPM has no true
6277
+ * fire-and-forget without a queue; for high volume move this into a queued job
6278
+ * (dispatch(...)->afterResponse()).
6279
+ *
6280
+ * Auth for /api/confirm (all three required):
6281
+ * Authorization: Bearer <secret>
6282
+ * X-OneAddress-Timestamp: <unix seconds>
6283
+ * X-OneAddress-Signature: HMAC-SHA256(secret, "<timestamp>.<rawBody>")
6284
+ * The same secret signs the Bearer and the body, over the EXACT bytes POSTed.
6285
+ */
6286
+ function oaConfirmToOneAddress(string $oneAddressApi, string $confirmSecret, string $partnerId, string $dispatch, string $status): void
6287
+ {
6288
+ if (!ctype_digit($dispatch) || (int) $dispatch <= 0) {
6289
+ return;
6290
+ }
6291
+ $bodyStr = json_encode([
6292
+ 'dispatch_id' => (int) $dispatch,
6293
+ 'partner_id' => $partnerId,
6294
+ 'status' => $status,
6295
+ 'note' => 'Applied by the OneAddress webhook receiver',
6296
+ ]);
6297
+ $ts = (string) time();
6298
+ $sig = hash_hmac('sha256', $ts . '.' . $bodyStr, $confirmSecret);
6299
+
6300
+ $ch = curl_init($oneAddressApi . '/api/confirm');
6301
+ curl_setopt_array($ch, [
6302
+ CURLOPT_RETURNTRANSFER => true,
6303
+ CURLOPT_POST => true,
6304
+ CURLOPT_POSTFIELDS => $bodyStr,
6305
+ CURLOPT_HTTPHEADER => [
6306
+ 'Content-Type: application/json',
6307
+ 'Authorization: Bearer ' . $confirmSecret,
6308
+ 'X-OneAddress-Timestamp: ' . $ts,
6309
+ 'X-OneAddress-Signature: ' . $sig,
6310
+ ],
6311
+ CURLOPT_TIMEOUT => 10,
6312
+ ]);
6313
+ curl_exec($ch);
6314
+ $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
6315
+ curl_close($ch);
6316
+
6317
+ if ($code >= 200 && $code < 300) {
6318
+ \\Log::info('[confirm] acknowledged by OneAddress', ['dispatch_id' => (int) $dispatch, 'status' => $status]);
6319
+ } else {
6320
+ \\Log::error('[confirm] confirm FAILED', ['dispatch_id' => (int) $dispatch, 'http' => $code]);
6321
+ if ($code === 401) {
6322
+ \\Log::error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET to it (from the portal Webhook screen); otherwise your webhook signing secret should work.');
6323
+ }
6324
+ }
6325
+ }
6326
+
5415
6327
  Route::post('/webhook', function (Request $request): Response {
5416
6328
  $partnerId = env('OA_PARTNER_ID', '%%PARTNER_ID%%');
5417
6329
  $webhookSecret = env('OA_WEBHOOK_SECRET', '');
5418
6330
  $privateKeyB64 = env('OA_PRIVATE_KEY_PEM', '');
5419
6331
 
6332
+ // Confirm-callback config. ONEADDRESS_API is the CUSTOMER app (/api/confirm
6333
+ // lives there, NOT the partner portal); CONFIRM_SECRET signs the callback and
6334
+ // falls back to the webhook secret, which is correct for most partners.
6335
+ $oneAddressApi = rtrim(env('ONEADDRESS_API', 'https://oneaddress.io'), '/');
6336
+ $confirmSecret = env('CONFIRM_SECRET') ?: $webhookSecret;
6337
+ // Whether this receiver answers the pre-payment account.verify check. The
6338
+ // wizard bakes your portal declaration into the default; env overrides it.
6339
+ $verifiesAccountReference = filter_var(env('VERIFIES_ACCOUNT_REFERENCE', %%VERIFIES_ACCOUNT_REFERENCE%%), FILTER_VALIDATE_BOOLEAN);
6340
+
5420
6341
  $rawBody = $request->getContent();
5421
6342
  $timestamp = $request->header('X-OneAddress-Timestamp', '');
5422
6343
  $signature = $request->header('X-OneAddress-Signature', '');
@@ -5441,6 +6362,40 @@ Route::post('/webhook', function (Request $request): Response {
5441
6362
 
5442
6363
  $eventType = $body['event'] ?? '';
5443
6364
 
6365
+ // \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
6366
+ // Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
6367
+ // no address, no session envelope \u2014 handled HERE, ABOVE the payload-less
6368
+ // acknowledge below (account.verify is not a dispatch event, so it would
6369
+ // otherwise be acknowledged as "no payload" and never actually check).
6370
+ if ($eventType === 'account.verify') {
6371
+ // Your portal declaration, baked in by the wizard. If you do not verify
6372
+ // account references, answer "not checked".
6373
+ if (!$verifiesAccountReference) {
6374
+ \\Log::info('[OneAddress] account.verify -> skipped (VERIFIES_ACCOUNT_REFERENCE is false)');
6375
+ return response()->json(['ok' => true, 'skipped' => true]);
6376
+ }
6377
+ $custEnc = $body['customer_encrypted'] ?? null;
6378
+ if (!is_array($custEnc)) {
6379
+ return response()->json(['error' => 'Missing customer_encrypted'], 400);
6380
+ }
6381
+ $accountKey = oaLoadPrivateKey($privateKeyB64);
6382
+ if (!$accountKey) {
6383
+ \\Log::error('[OneAddress] account.verify: failed to load private key');
6384
+ return response()->json(['ok' => false, 'error' => 'decryption_failed'], 422);
6385
+ }
6386
+ $cust = oaDecryptLegacy($custEnc, $accountKey, $partnerId);
6387
+ if ($cust === null) {
6388
+ \\Log::error('[OneAddress] account.verify decryption failed \u2014 check OA_PRIVATE_KEY_PEM');
6389
+ return response()->json(['ok' => false, 'error' => 'decryption_failed'], 422);
6390
+ }
6391
+ $acct = is_string($cust['account_number'] ?? null) ? $cust['account_number'] : null;
6392
+ $name = is_string($cust['name'] ?? null) ? $cust['name'] : '';
6393
+ $known = is_array($cust['known_names'] ?? null) ? $cust['known_names'] : [];
6394
+ $accountStatus = (new OneAddressStore())->verifyAccount($acct, $name, $known);
6395
+ \\Log::info('[OneAddress] account.verify', ['status' => $accountStatus, 'account' => $acct ?? '(none)']);
6396
+ return response()->json(['status' => $accountStatus]);
6397
+ }
6398
+
5444
6399
  // address.test / address.test-dispatch (the connection-verification probe)
5445
6400
  // must pass this guard so it reaches the decrypt below; a wrong key then
5446
6401
  // returns 422 and only a real decrypt reaches the verification answer.
@@ -5645,6 +6600,11 @@ Route::post('/webhook', function (Request $request): Response {
5645
6600
  'partner_id' => $partnerId,
5646
6601
  'outcome' => $outcome,
5647
6602
  ]);
6603
+ // Close the loop back to OneAddress so the service flips to "Confirmed",
6604
+ // but only when we actually applied the update. Never affects this 200.
6605
+ if ($outcome === 'applied') {
6606
+ oaConfirmToOneAddress($oneAddressApi, $confirmSecret, $partnerId, $dispatchId, 'confirmed');
6607
+ }
5648
6608
  return response()->json(['ok' => true, 'outcome' => $outcome]);
5649
6609
  } elseif ($eventType === 'address.verify') {
5650
6610
  $callbackUrl = $body['callback_url'] ?? '';
@@ -5700,6 +6660,15 @@ Route::get('/health', function (): \\Illuminate\\Http\\JsonResponse {
5700
6660
  OA_PARTNER_ID=your-partner-uuid-here
5701
6661
  OA_WEBHOOK_SECRET=your-webhook-secret-here
5702
6662
  OA_PRIVATE_KEY_PEM=<paste PKCS8 PEM private key here - use \\n between lines>
6663
+
6664
+ # Confirm-callback target \u2014 the customer app, NOT the partner portal.
6665
+ ONEADDRESS_API=https://oneaddress.io
6666
+
6667
+ # Signs the /api/confirm callback. Blank = reuse OA_WEBHOOK_SECRET.
6668
+ CONFIRM_SECRET=
6669
+
6670
+ # Whether the receiver answers the pre-payment account.verify check (true/false).
6671
+ VERIFIES_ACCOUNT_REFERENCE=false
5703
6672
  `
5704
6673
  },
5705
6674
  {
@@ -5729,8 +6698,16 @@ Your webhook endpoint: \`POST http://localhost:3001/api/webhook\`
5729
6698
 
5730
6699
  ## Events handled
5731
6700
 
6701
+ ### account.verify (pre-payment account check)
6702
+ Receive \u2192 verify HMAC \u2192 decrypt the customer block \u2192 \`verifyAccount\` \u2192
6703
+ answer \`match\` / \`no_match\` / \`no_account\`. Gated on \`VERIFIES_ACCOUNT_REFERENCE\`
6704
+ (the wizard writes your portal declaration): when off, the receiver answers
6705
+ \`{ "ok": true, "skipped": true }\` ("not checked").
6706
+
5732
6707
  ### address.updated
5733
- Receive \u2192 verify HMAC \u2192 decrypt \u2192 persist to your DB.
6708
+ Receive \u2192 verify HMAC \u2192 decrypt \u2192 persist to your DB \u2192 POST \`/api/confirm\` on
6709
+ \`ONEADDRESS_API\` (HMAC-signed with \`CONFIRM_SECRET\`) so the consumer's dashboard
6710
+ flips the service to "Confirmed".
5734
6711
 
5735
6712
  ### address.verify
5736
6713
  Receive \u2192 verify HMAC \u2192 decrypt \u2192 compare to records \u2192 POST callback_url.
@@ -5780,6 +6757,9 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
5780
6757
  | \`OA_PARTNER_ID\` | Your partner UUID |
5781
6758
  | \`OA_WEBHOOK_SECRET\` | HMAC-SHA256 signing secret |
5782
6759
  | \`OA_PRIVATE_KEY_PEM\` | PKCS#8 PEM private key (use \`\\\\n\` between lines) |
6760
+ | \`ONEADDRESS_API\` | Confirm-callback target \u2014 the customer app (default \`https://oneaddress.io\`), NOT the partner portal |
6761
+ | \`CONFIRM_SECRET\` | Signs the \`/api/confirm\` callback. Blank = reuse \`OA_WEBHOOK_SECRET\` |
6762
+ | \`VERIFIES_ACCOUNT_REFERENCE\` | Whether the receiver answers \`account.verify\` (\`true\`/\`false\`) |
5783
6763
  `
5784
6764
  }
5785
6765
  ]
@@ -5816,7 +6796,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
5816
6796
 
5817
6797
  // src/register.ts
5818
6798
  var import_node_crypto = require("crypto");
5819
- var PKG_VERSION = true ? "1.6.1" : "dev";
6799
+ var PKG_VERSION = true ? "1.6.2" : "dev";
5820
6800
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
5821
6801
  function hmacSha256(secret, message) {
5822
6802
  return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
@@ -6007,7 +6987,6 @@ async function runDecryptCheck(webhookSecret, partnerId) {
6007
6987
 
6008
6988
  // src/install.ts
6009
6989
  var import_node_child_process = require("child_process");
6010
- var import_node_fs2 = require("fs");
6011
6990
  var import_node_path2 = require("path");
6012
6991
  var COMMANDS = {
6013
6992
  "ts-node": { cmd: "npm", args: ["install"] },
@@ -6015,22 +6994,17 @@ var COMMANDS = {
6015
6994
  "go-http": { cmd: "go", args: ["mod", "tidy"] },
6016
6995
  "php-laravel": { cmd: "composer", args: ["install"] },
6017
6996
  "csharp-aspnet": { cmd: "dotnet", args: ["restore"] },
6018
- "java-spring": { cmd: "./mvnw", args: ["dependency:resolve", "-q"] }
6997
+ // Use `mvn`, not `./mvnw` the Java scaffold ships no Maven wrapper, so
6998
+ // `./mvnw` fails ENOENT for every Java partner. A missing `mvn` on PATH is
6999
+ // reported cleanly (ok=false) with the manual command, which is the honest
7000
+ // failure rather than a cryptic one.
7001
+ "java-spring": { cmd: "mvn", args: ["dependency:resolve", "-q"] }
6019
7002
  };
6020
7003
  function installDependencies(platform, outputDir) {
6021
7004
  const spec = COMMANDS[platform];
6022
7005
  if (!spec) {
6023
7006
  return { ok: true, output: "", manualCommand: "" };
6024
7007
  }
6025
- if (platform === "java-spring") {
6026
- const mvnw = (0, import_node_path2.join)(outputDir, "mvnw");
6027
- if ((0, import_node_fs2.existsSync)(mvnw)) {
6028
- try {
6029
- (0, import_node_fs2.chmodSync)(mvnw, 493);
6030
- } catch {
6031
- }
6032
- }
6033
- }
6034
7008
  const cwd = spec.cwd ? (0, import_node_path2.join)(outputDir, spec.cwd) : outputDir;
6035
7009
  const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
6036
7010
  const result = (0, import_node_child_process.spawnSync)(spec.cmd, spec.args, {
@@ -6051,15 +7025,14 @@ function installDependencies(platform, outputDir) {
6051
7025
 
6052
7026
  // src/autostart.ts
6053
7027
  var import_node_child_process2 = require("child_process");
6054
- var import_node_path3 = require("path");
6055
- var import_node_fs3 = require("fs");
6056
7028
  var COMMANDS2 = {
6057
7029
  "ts-node": { cmd: "npx", args: ["tsx", "src/server.ts"] },
6058
7030
  "python": { cmd: "uvicorn", args: ["app:app", "--port", "3001"] },
6059
7031
  "go-http": { cmd: "go", args: ["run", "."] },
6060
7032
  "php-laravel": { cmd: "php", args: ["artisan", "serve", "--port=3001"] },
6061
7033
  "csharp-aspnet": { cmd: "dotnet", args: ["run"] },
6062
- "java-spring": { cmd: "./mvnw", args: ["spring-boot:run"] }
7034
+ "java-spring": { cmd: "mvn", args: ["spring-boot:run"] }
7035
+ // no mvnw wrapper is scaffolded
6063
7036
  };
6064
7037
  var serverProcess = null;
6065
7038
  var serverOutput = "";
@@ -6106,20 +7079,11 @@ async function pollHealth(port, timeoutMs) {
6106
7079
  }
6107
7080
  return false;
6108
7081
  }
6109
- async function startServer(platform, outputDir, port = 3001) {
7082
+ async function startServer(platform, outputDir, port = 3001, secrets = {}) {
6110
7083
  const spec = COMMANDS2[platform];
6111
7084
  if (!spec) {
6112
7085
  return { ok: false, output: `No start command defined for platform: ${platform}`, manualCommand: "" };
6113
7086
  }
6114
- if (platform === "java-spring") {
6115
- const mvnw = (0, import_node_path3.join)(outputDir, "mvnw");
6116
- if ((0, import_node_fs3.existsSync)(mvnw)) {
6117
- try {
6118
- (0, import_node_fs3.chmodSync)(mvnw, 493);
6119
- } catch {
6120
- }
6121
- }
6122
- }
6123
7087
  serverOutput = "";
6124
7088
  const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
6125
7089
  const PASSTHROUGH_KEYS = [
@@ -6157,6 +7121,9 @@ async function startServer(platform, outputDir, port = 3001) {
6157
7121
  const v2 = process.env[key];
6158
7122
  if (typeof v2 === "string") childEnv[key] = v2;
6159
7123
  }
7124
+ for (const [key, value] of Object.entries(secrets)) {
7125
+ if (value) childEnv[key] = value;
7126
+ }
6160
7127
  const isWin = process.platform === "win32";
6161
7128
  serverProcess = (0, import_node_child_process2.spawn)(
6162
7129
  isWin ? "cmd.exe" : spec.cmd,
@@ -6186,9 +7153,9 @@ async function startServer(platform, outputDir, port = 3001) {
6186
7153
  // src/tunnel.ts
6187
7154
  var import_node_child_process3 = require("child_process");
6188
7155
  var import_promises2 = require("fs/promises");
6189
- var import_node_fs4 = require("fs");
7156
+ var import_node_fs2 = require("fs");
6190
7157
  var import_node_crypto3 = require("crypto");
6191
- var import_node_path4 = require("path");
7158
+ var import_node_path3 = require("path");
6192
7159
  var import_node_os = __toESM(require("os"));
6193
7160
  var tunnelProcess = null;
6194
7161
  function stopTunnel() {
@@ -6236,19 +7203,19 @@ async function downloadCloudflared() {
6236
7203
  if (!spec) {
6237
7204
  throw new Error(`No cloudflared asset known for ${key}. Install cloudflared manually or provide your own HTTPS URL when prompted.`);
6238
7205
  }
6239
- const cacheDir = (0, import_node_path4.join)(import_node_os.default.tmpdir(), `oneaddress-cloudflared-${CLOUDFLARED_VERSION}`);
7206
+ const cacheDir = (0, import_node_path3.join)(import_node_os.default.tmpdir(), `oneaddress-cloudflared-${CLOUDFLARED_VERSION}`);
6240
7207
  const binaryName = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
6241
- const finalPath = (0, import_node_path4.join)(cacheDir, binaryName);
6242
- if (!spec.archive && (0, import_node_fs4.existsSync)(finalPath)) {
7208
+ const finalPath = (0, import_node_path3.join)(cacheDir, binaryName);
7209
+ if (!spec.archive && (0, import_node_fs2.existsSync)(finalPath)) {
6243
7210
  try {
6244
7211
  const cachedHash = await sha256File(finalPath);
6245
7212
  if (cachedHash === spec.sha256) return finalPath;
6246
7213
  } catch {
6247
7214
  }
6248
7215
  }
6249
- if (spec.archive && (0, import_node_fs4.existsSync)(finalPath)) return finalPath;
7216
+ if (spec.archive && (0, import_node_fs2.existsSync)(finalPath)) return finalPath;
6250
7217
  await (0, import_promises2.mkdir)(cacheDir, { recursive: true });
6251
- const downloadPath = (0, import_node_path4.join)(cacheDir, spec.asset);
7218
+ const downloadPath = (0, import_node_path3.join)(cacheDir, spec.asset);
6252
7219
  const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${spec.asset}`;
6253
7220
  const res = await fetch(url, { signal: AbortSignal.timeout(6e4) });
6254
7221
  if (!res.ok) throw new Error(`Failed to download cloudflared from ${url}: HTTP ${res.status}`);
@@ -6282,7 +7249,7 @@ Refusing to execute. Try re-running setup, or install cloudflared manually.`
6282
7249
  `cloudflared archive extraction failed (tar exit ${tarResult.status}): ${tarResult.stderr || tarResult.stdout || "no output"}`
6283
7250
  );
6284
7251
  }
6285
- if (!(0, import_node_fs4.existsSync)(finalPath)) {
7252
+ if (!(0, import_node_fs2.existsSync)(finalPath)) {
6286
7253
  throw new Error(`cloudflared archive extracted but ${finalPath} not found \u2014 Cloudflare may have changed the tarball layout.`);
6287
7254
  }
6288
7255
  try {
@@ -6471,6 +7438,7 @@ async function registerWebhookUrl(partnerId, webhookSecret, webhookUrl) {
6471
7438
 
6472
7439
  // src/cli-gate.ts
6473
7440
  var MAX_ATTEMPTS = 3;
7441
+ var MAX_TRANSIENT_FAILURES = 5;
6474
7442
  var AUTH_URL = "https://partners.oneaddress.io/api/cli/auth";
6475
7443
  async function checkToken(token) {
6476
7444
  try {
@@ -6504,6 +7472,7 @@ async function checkToken(token) {
6504
7472
  }
6505
7473
  }
6506
7474
  async function cliGate() {
7475
+ let transientFailures = 0;
6507
7476
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
6508
7477
  const token = await ge({
6509
7478
  message: attempt === 1 ? "OneAddress CLI token (Profile \u2192 CLI Access in the portal)" : `OneAddress CLI token (attempt ${attempt}/${MAX_ATTEMPTS})`,
@@ -6516,6 +7485,11 @@ async function cliGate() {
6516
7485
  const result = await checkToken(token);
6517
7486
  if (result.ok) return { partnerId: result.partnerId };
6518
7487
  if (result.retryable) {
7488
+ transientFailures++;
7489
+ if (transientFailures >= MAX_TRANSIENT_FAILURES) {
7490
+ console.error("\n \x1B[38;2;240;80;80m\u2717\x1B[0m Could not reach the portal after several tries. Check your connection to partners.oneaddress.io and re-run.\n");
7491
+ process.exit(1);
7492
+ }
6519
7493
  M2.warn(` ${result.reason}, retry the same token.`);
6520
7494
  attempt--;
6521
7495
  continue;
@@ -6614,9 +7588,9 @@ function normalisePrivateKey(raw) {
6614
7588
  const trimmed = raw.trim();
6615
7589
  const isPath = /^(\/|\.\/|\.\.\/|~\/|[A-Za-z]:[/\\])/.test(trimmed) || trimmed.endsWith(".pem");
6616
7590
  if (isPath) {
6617
- const abs = trimmed.startsWith("~/") ? (0, import_node_path5.resolve)((0, import_node_os2.homedir)(), trimmed.slice(2)) : (0, import_node_path5.resolve)(trimmed);
6618
- if (!(0, import_node_fs5.existsSync)(abs)) return { pem: "", error: `File not found: ${abs}` };
6619
- return normalisePrivateKey((0, import_node_fs5.readFileSync)(abs, "utf8"));
7591
+ const abs = trimmed.startsWith("~/") ? (0, import_node_path4.resolve)((0, import_node_os2.homedir)(), trimmed.slice(2)) : (0, import_node_path4.resolve)(trimmed);
7592
+ if (!(0, import_node_fs3.existsSync)(abs)) return { pem: "", error: `File not found: ${abs}` };
7593
+ return normalisePrivateKey((0, import_node_fs3.readFileSync)(abs, "utf8"));
6620
7594
  }
6621
7595
  const unescaped = trimmed.replaceAll("\\n", "\n");
6622
7596
  if (unescaped.includes("-----BEGIN PRIVATE KEY-----")) {
@@ -6784,7 +7758,7 @@ async function main() {
6784
7758
  });
6785
7759
  assertNotCancelled(outputDir);
6786
7760
  outDir = outputDir.trim() || "./oneaddress-webhook";
6787
- if (!(0, import_node_fs5.existsSync)(outDir) || (0, import_node_fs5.readdirSync)(outDir).length === 0) break;
7761
+ if (!(0, import_node_fs3.existsSync)(outDir) || (0, import_node_fs3.readdirSync)(outDir).length === 0) break;
6788
7762
  const overwrite = await ye({
6789
7763
  message: `${outDir} already has files \u2014 overwrite?`,
6790
7764
  initialValue: false
@@ -6862,7 +7836,12 @@ async function main() {
6862
7836
  const s3 = Y2();
6863
7837
  s3.start(`Starting server (waiting up to 15 s for /health on :${SERVER_PORT})`);
6864
7838
  onCleanup(stopServer);
6865
- const start = await startServer(platform, outDir, SERVER_PORT);
7839
+ const start = await startServer(platform, outDir, SERVER_PORT, {
7840
+ WEBHOOK_SECRET: secret,
7841
+ PARTNER_ID: pid,
7842
+ PARTNER_PRIVATE_KEY_PEM: privateKey,
7843
+ VERIFIES_ACCOUNT_REFERENCE: String(verifiesAccountReference)
7844
+ });
6866
7845
  let serverRunning = false;
6867
7846
  if (start.ok) {
6868
7847
  s3.stop(`Server is healthy on port ${SERVER_PORT}`);
@@ -7083,7 +8062,7 @@ ${DIM2} Stopped.${R3}
7083
8062
  }
7084
8063
 
7085
8064
  // src/non-interactive.ts
7086
- var import_node_fs6 = require("fs");
8065
+ var import_node_fs4 = require("fs");
7087
8066
  var PLATFORMS = [
7088
8067
  "ts-node",
7089
8068
  "python",
@@ -7136,7 +8115,7 @@ function parseArgs(argv2) {
7136
8115
  }
7137
8116
  return out;
7138
8117
  }
7139
- function resolveConfig(args, env, normalisePrivateKey2, readFile2 = (p2) => (0, import_node_fs6.readFileSync)(p2, "utf8")) {
8118
+ function resolveConfig(args, env, normalisePrivateKey2, readFile2 = (p2) => (0, import_node_fs4.readFileSync)(p2, "utf8")) {
7140
8119
  const errors = [];
7141
8120
  for (const flag of args.secretFlagsUsed) {
7142
8121
  errors.push(
@@ -7238,7 +8217,7 @@ ${usage()}`);
7238
8217
  process.exit(1);
7239
8218
  }
7240
8219
  const { partnerId, secret, privateKey, platform, outDir, webhookUrl, force } = resolved.config;
7241
- if ((0, import_node_fs7.existsSync)(outDir) && (0, import_node_fs7.readdirSync)(outDir).length > 0 && !force) {
8220
+ if ((0, import_node_fs5.existsSync)(outDir) && (0, import_node_fs5.readdirSync)(outDir).length > 0 && !force) {
7242
8221
  console.error(`[oneaddress/setup] ${outDir} is not empty. Pass --force to overwrite.`);
7243
8222
  process.exit(1);
7244
8223
  }