@oneaddress/setup 1.6.0 → 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 +1158 -148
  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.0" : "?";
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 ?? '';
@@ -1667,6 +1679,18 @@ app.post('/webhook', webhookLimiter, async (req: Request, res: Response) => {
1667
1679
  return res.status(200).json({ status });
1668
1680
  }
1669
1681
 
1682
+ // A valid signed request that carries no address payload \u2014 a conformance ping,
1683
+ // or any event added after this receiver was generated \u2014 has already passed
1684
+ // timestamp + signature above, which is exactly what such a probe tests.
1685
+ // Acknowledge it here; only the real dispatch events below require decryption.
1686
+ // A real address.updated that arrives WITHOUT a payload still falls through to
1687
+ // the 422 below, because it IS a dispatch event.
1688
+ const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
1689
+ if (!DISPATCH_EVENTS.includes(event)) {
1690
+ console.log(\`[webhook] "\${event}" acknowledged (no address payload to decrypt)\`);
1691
+ return res.status(200).json({ ok: true, skipped: true });
1692
+ }
1693
+
1670
1694
  // 5. Decrypt the address payload. Two shapes possible, exactly one per event:
1671
1695
  // (a) D5 (2026+): body.session_envelope + body.session_key_share. Under D5,
1672
1696
  // address.updated no longer carries a cleartext \`customer\` block \u2014
@@ -2039,6 +2063,20 @@ the \`ONEADDRESS_CUSTOMERS\` env var, or point \`loadRoster\` at your real datab
2039
2063
  content: `OA_PARTNER_ID=%%PARTNER_ID%%
2040
2064
  OA_WEBHOOK_SECRET=%%WEBHOOK_SECRET%%
2041
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%%
2042
2080
  `
2043
2081
  },
2044
2082
  {
@@ -2067,6 +2105,7 @@ Quick start:
2067
2105
  uvicorn app:app --port 3001
2068
2106
  """
2069
2107
 
2108
+ import asyncio
2070
2109
  import base64
2071
2110
  import hashlib
2072
2111
  import hmac as hmac_lib
@@ -2099,77 +2138,157 @@ WEBHOOK_SECRET = os.environ["OA_WEBHOOK_SECRET"]
2099
2138
  PRIVATE_KEY = os.environ["OA_PRIVATE_KEY_PEM"].replace("\\\\n", "\\n")
2100
2139
  DB_PATH = os.environ.get("DB_PATH", str(Path.cwd() / "data.db"))
2101
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
+
2102
2157
  app = FastAPI()
2103
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()
2104
2162
 
2105
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
2106
2164
  #
2107
- # Uses Python's stdlib sqlite3 \u2014 no extra package install needed. Two tables
2108
- # mirror the TypeScript scaffold layout exactly so partners with mixed-stack
2109
- # 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.
2110
2171
  #
2111
- # addresses \u2014 one row per customer (UNIQUE on email); latest address only
2172
+ # Two tables:
2173
+ #
2174
+ # customers \u2014 your roster: account number, name, and the address you
2175
+ # hold on file today. Replace with YOUR customer table.
2112
2176
  # address_history \u2014 append-only audit trail of every address.updated event
2113
2177
  #
2114
- # Replace these with your real database (Postgres, MySQL, internal API)
2115
- # when ready to ship. The webhook handler below only calls _save_address()
2116
- # 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.
2117
2180
 
2118
2181
  _db = sqlite3.connect(DB_PATH, check_same_thread=False, isolation_level=None)
2119
2182
  _db.execute("PRAGMA journal_mode = WAL")
2120
2183
  _db.execute("""
2121
- CREATE TABLE IF NOT EXISTS addresses (
2122
- email TEXT PRIMARY KEY,
2123
- name TEXT NOT NULL DEFAULT '',
2124
- address TEXT NOT NULL DEFAULT '{}',
2125
- dispatch_id TEXT,
2126
- 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'))
2127
2189
  )
2128
2190
  """)
2129
2191
  _db.execute("""
2130
2192
  CREATE TABLE IF NOT EXISTS address_history (
2131
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2132
- email TEXT NOT NULL,
2133
- name TEXT NOT NULL DEFAULT '',
2134
- address TEXT NOT NULL DEFAULT '{}',
2135
- dispatch_id TEXT,
2136
- 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'))
2137
2198
  )
2138
2199
  """)
2139
2200
  print(f"[db] SQLite database ready -> {DB_PATH}")
2140
2201
 
2141
- def _save_address(email: str, name: str, address: dict[str, Any], dispatch_id: str) -> None:
2142
- """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()
2143
2265
  address_json = json.dumps(address, sort_keys=True)
2144
2266
  _db.execute("""
2145
- INSERT INTO addresses (email, name, address, dispatch_id, updated_at)
2146
- VALUES (?, ?, ?, ?, datetime('now'))
2147
- ON CONFLICT(email) DO UPDATE SET
2148
- name = excluded.name,
2149
- address = excluded.address,
2150
- dispatch_id = excluded.dispatch_id,
2151
- updated_at = excluded.updated_at
2152
- """, (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))
2153
2274
  _db.execute("""
2154
- INSERT INTO address_history (email, name, address, dispatch_id, recorded_at)
2155
- VALUES (?, ?, ?, ?, datetime('now'))
2156
- """, (email, name, address_json, dispatch_id))
2157
-
2158
- def _verify_address(email: str, address: dict[str, Any]) -> str:
2159
- """Returns 'match' | 'mismatch' | 'not_found' for an address.verify event."""
2160
- 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)
2161
2285
  if not row:
2162
2286
  return "not_found"
2163
2287
  try:
2164
- stored = json.loads(row[0])
2288
+ stored = json.loads(row[2])
2165
2289
  except (json.JSONDecodeError, TypeError):
2166
- return "not_found"
2167
- # Compare all address fields the partner cares about. Address.verify
2168
- # ignores fields the consumer didn't supply (e.g. country missing on the
2169
- # incoming check is treated as "any country acceptable") \u2014 partners that
2170
- # need stricter matching should adjust this comparison.
2171
- matches = all(stored.get(k) == v for k, v in address.items())
2172
- return "match" if matches else "mismatch"
2290
+ stored = {}
2291
+ return "match" if _canonical_address(stored) == _canonical_address(address) else "mismatch"
2173
2292
 
2174
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
2175
2294
 
@@ -2291,6 +2410,61 @@ def _decrypt_session(share: dict[str, Any], envelope_b64: str,
2291
2410
  plaintext = AESGCM(sk).decrypt(session_iv, session_ct, None)
2292
2411
  return json.loads(plaintext) # type: ignore[return-value]
2293
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
+
2294
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
2295
2469
 
2296
2470
  @app.post("/webhook")
@@ -2313,8 +2487,47 @@ async def webhook(request: Request) -> Response:
2313
2487
 
2314
2488
  if dispatch and dispatch in seen_dispatches:
2315
2489
  return Response(content='{"ok":true,"duplicate":true}', media_type="application/json")
2316
- if dispatch:
2317
- 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")
2522
+
2523
+ # A valid signed request that carries no address payload \u2014 a conformance
2524
+ # ping, or any event added after this receiver was generated \u2014 has already
2525
+ # passed timestamp + signature above, which is exactly what such a probe
2526
+ # tests. Acknowledge it here; only the real dispatch events below require
2527
+ # decryption. A real address.updated with no payload still 422s below.
2528
+ if event not in ("address.updated", "address.verify", "address.test", "address.test-dispatch"):
2529
+ print(f"[webhook] '{event}' acknowledged (no address payload to decrypt)")
2530
+ return Response(content='{"ok":true,"skipped":true}', media_type="application/json")
2318
2531
 
2319
2532
  # Two possible payload shapes:
2320
2533
  # (a) D5 \u2014 body.session_envelope (str) + body.session_key_share (dict)
@@ -2325,15 +2538,20 @@ async def webhook(request: Request) -> Response:
2325
2538
  session_share = body.get("session_key_share")
2326
2539
  legacy_enc = body.get("address_encrypted")
2327
2540
 
2328
- # Decrypted display name, captured for the connection-verification probe
2329
- # 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.
2330
2543
  verified_name = ""
2544
+ account_number = ""
2545
+ known_names: list[str] = []
2331
2546
 
2332
2547
  if isinstance(session_envelope, str) and isinstance(session_share, dict):
2333
2548
  try:
2334
2549
  data = _decrypt_session(session_share, session_envelope, PRIVATE_KEY, PARTNER_ID)
2335
2550
  address = data.get("new_address", {})
2336
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 []
2337
2555
  except Exception as e:
2338
2556
  print(f"[webhook] D5 decryption failed \u2014 check OA_PRIVATE_KEY_PEM matches key_id "
2339
2557
  f"{session_share.get('key_id', '?')}: {e}")
@@ -2343,6 +2561,9 @@ async def webhook(request: Request) -> Response:
2343
2561
  try:
2344
2562
  address = _decrypt_address(legacy_enc, PRIVATE_KEY, PARTNER_ID)
2345
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 []
2346
2567
  except Exception as e:
2347
2568
  print(f"[webhook] Decryption error (check OA_PRIVATE_KEY_PEM): {e}")
2348
2569
  return Response(content='{"ok":false,"error":"decryption failed - partner key mismatch"}',
@@ -2352,18 +2573,24 @@ async def webhook(request: Request) -> Response:
2352
2573
  media_type="application/json")
2353
2574
 
2354
2575
  if event == "address.updated":
2355
- customer = body.get("customer", {})
2356
- email = customer.get("email", "")
2357
- name = customer.get("name", "")
2358
- # Log metadata only \u2014 never the decrypted address. stdout is captured
2359
- # by uvicorn / Docker / systemd-journal in production and writing PII
2360
- # to those streams turns every log reader into a data-exposure surface.
2361
- print(f"[webhook] address.updated for {email or '?'} (dispatch={dispatch})")
2362
- if email:
2363
- _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")
2364
2592
 
2365
2593
  elif event == "address.verify":
2366
- customer = body.get("customer", {})
2367
2594
  callback_url = body["callback_url"]
2368
2595
  callback_token= body["callback_token"]
2369
2596
  batch_id = body["batch_id"]
@@ -2379,17 +2606,16 @@ async def webhook(request: Request) -> Response:
2379
2606
  content='{"error":"Invalid callback_url host"}',
2380
2607
  media_type="application/json")
2381
2608
 
2382
- # Real DB lookup \u2014 mirrors the TypeScript scaffold's verifyAddress.
2383
- # Returns "not_found" for a fresh DB (no prior address.updated for
2384
- # this email), "match" / "mismatch" once the customer is on file.
2385
- email = customer.get("email", "")
2386
- result = _verify_address(email, address) if email else "not_found"
2387
- 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 '?'}")
2388
2613
 
2389
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.
2390
2617
  "batch_id": batch_id,
2391
2618
  "partner_id": PARTNER_ID,
2392
- "member_name": customer.get("name", ""),
2393
2619
  "result": result,
2394
2620
  "token": callback_token,
2395
2621
  }
@@ -2398,6 +2624,8 @@ async def webhook(request: Request) -> Response:
2398
2624
  await client.post(callback_url, json=payload)
2399
2625
  except Exception as e:
2400
2626
  print(f"[webhook] Callback POST failed: {e}")
2627
+ if dispatch:
2628
+ seen_dispatches.add(dispatch) # remember only after the callback posted
2401
2629
 
2402
2630
  elif event in ("address.test", "address.test-dispatch"):
2403
2631
  # OneAddress connection-verification probe. Reaching here means the D5
@@ -2445,24 +2673,27 @@ Your webhook endpoint: \`POST http://localhost:3001/webhook\`
2445
2673
 
2446
2674
  ## Events handled
2447
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
+
2448
2682
  ### address.updated
2449
- 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".
2450
2686
 
2451
2687
  ### address.verify
2452
- Receive \u2192 verify HMAC \u2192 decrypt \u2192 compare to your records \u2192 POST callback_url.
2453
-
2454
- 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\`.
2455
2690
 
2456
- \`\`\`python
2457
- # In the address.verify branch of app.py, replace:
2458
- result = "match"
2459
-
2460
- # With something like:
2461
- record = db.get_customer(customer.get("email"))
2462
- result = "not_found" if not record else (
2463
- "match" if addresses_match(record.address, address) else "mismatch"
2464
- )
2465
- \`\`\`
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.
2466
2697
 
2467
2698
  Valid results: \`"match"\` | \`"mismatch"\` | \`"not_found"\`
2468
2699
 
@@ -2474,7 +2705,17 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
2474
2705
 
2475
2706
  ## Configuration
2476
2707
 
2477
- 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\`) |
2478
2719
  `
2479
2720
  }
2480
2721
  ],
@@ -2604,6 +2845,27 @@ public final class OneAddressVerifier {
2604
2845
  }
2605
2846
  }
2606
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
+
2607
2869
  private static byte[] hexToBytes(String hex) {
2608
2870
  int len = hex.length();
2609
2871
  byte[] out = new byte[len / 2];
@@ -2861,9 +3123,19 @@ public class OneAddressWebhookController {
2861
3123
  this.store = store;
2862
3124
  }
2863
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
+
2864
3132
  private String webhookSecret;
2865
3133
  private String privateKeyPem;
2866
3134
  private String partnerId;
3135
+ // Confirm-callback config.
3136
+ private String oneAddressApi;
3137
+ private String confirmSecret;
3138
+ private boolean verifiesAccountReference;
2867
3139
 
2868
3140
  @PostConstruct
2869
3141
  void init() {
@@ -2879,6 +3151,22 @@ public class OneAddressWebhookController {
2879
3151
  partnerId = System.getenv("ONEADDRESS_PARTNER_ID");
2880
3152
  if (partnerId == null || partnerId.isBlank())
2881
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");
2882
3170
  }
2883
3171
 
2884
3172
  @PostMapping("/oneaddress")
@@ -2903,6 +3191,44 @@ public class OneAddressWebhookController {
2903
3191
  Map<String, Object> body = MAPPER.readValue(rawBody, Map.class);
2904
3192
  String event = (String) body.get("event");
2905
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
+
2906
3232
  // Two possible payload shapes:
2907
3233
  // (a) D5 \u2014 body.session_envelope (String) + body.session_key_share (Map)
2908
3234
  // (b) Legacy \u2014 body.address_encrypted (Map)
@@ -2971,6 +3297,10 @@ public class OneAddressWebhookController {
2971
3297
  // will never retry.
2972
3298
  String outcome = store.applyAddress(accountNumber, verifiedName, knownNames, address);
2973
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");
2974
3304
  return ResponseEntity.ok("{\\"ok\\":true,\\"outcome\\":\\"" + outcome + "\\"}");
2975
3305
  } else if ("address.verify".equals(event)) {
2976
3306
  handleAddressVerify(body, address, accountNumber, verifiedName, knownNames);
@@ -3049,6 +3379,67 @@ public class OneAddressWebhookController {
3049
3379
  }
3050
3380
  }
3051
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
+
3052
3443
  private static String getHeader(Map<String, String> headers, String name) {
3053
3444
  for (Map.Entry<String, String> entry : headers.entrySet())
3054
3445
  if (entry.getKey().equalsIgnoreCase(name)) return entry.getValue();
@@ -3290,6 +3681,24 @@ public class OneAddressStore {
3290
3681
  return same ? "match" : "mismatch";
3291
3682
  }
3292
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
+
3293
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
3294
3703
 
3295
3704
  private static List<String> allNames(String verifiedName, List<String> knownNames) {
@@ -3414,6 +3823,23 @@ name matching needs maiden names, initials and word order \u2014 see
3414
3823
  | \`ONEADDRESS_WEBHOOK_SECRET\` | Webhook secret from the Partner Portal |
3415
3824
  | \`ONEADDRESS_PRIVATE_KEY\` | PKCS#8 PEM private key (use \`\\n\` between lines) |
3416
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".
3417
3843
 
3418
3844
  ## Run conformance check
3419
3845
 
@@ -3639,6 +4065,32 @@ public sealed class OneAddressStore
3639
4065
  return same ? "match" : "mismatch";
3640
4066
  }
3641
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
+
3642
4094
  /// <summary>
3643
4095
  /// Account number first (authoritative), then name.
3644
4096
  ///
@@ -3729,6 +4181,22 @@ var privateKeyPem = (Environment.GetEnvironmentVariable("ONEADDRESS_PRIVATE_KEY"
3729
4181
  .Replace("\\\\n", "\\n");
3730
4182
  var partnerId = Environment.GetEnvironmentVariable("ONEADDRESS_PARTNER_ID") ?? "";
3731
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
+
3732
4200
  var builder = WebApplication.CreateBuilder(args);
3733
4201
  var app = builder.Build();
3734
4202
 
@@ -3763,6 +4231,45 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
3763
4231
 
3764
4232
  var eventType = body.TryGetProperty("event", out var evtEl) ? evtEl.GetString() : null;
3765
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
+
3766
4273
  // address.test / address.test-dispatch (the connection-verification probe)
3767
4274
  // must pass this guard so it reaches the decrypt below; a wrong key then
3768
4275
  // returns 422 and only a real decrypt reaches the verification answer.
@@ -3875,6 +4382,11 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
3875
4382
  // marks the dispatch delivered on a failure that is never retried.
3876
4383
  var outcome = store.ApplyAddress(accountNumber, verifiedName, knownNames, address);
3877
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);
3878
4390
  return Results.Json(new { ok = true, outcome });
3879
4391
  }
3880
4392
  else // address.verify
@@ -3888,7 +4400,68 @@ app.MapPost("/webhooks/oneaddress", async (HttpRequest request) =>
3888
4400
 
3889
4401
  app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
3890
4402
 
3891
- 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
+ }
3892
4465
 
3893
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
3894
4467
 
@@ -4155,6 +4728,24 @@ name matching needs maiden names, initials and word order \u2014 see
4155
4728
  | \`ONEADDRESS_WEBHOOK_SECRET\` | Webhook secret from the Partner Portal |
4156
4729
  | \`ONEADDRESS_PRIVATE_KEY\` | PKCS#8 PEM key \u2014 use \`\\n\` between PEM lines |
4157
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".
4158
4749
 
4159
4750
  ## Run conformance check
4160
4751
 
@@ -4172,6 +4763,21 @@ npx @oneaddress/conformance test http://localhost:3001/webhooks/oneaddress
4172
4763
  WEBHOOK_SECRET=%%WEBHOOK_SECRET%%
4173
4764
  PARTNER_PRIVATE_KEY_PEM="%%PRIVATE_KEY%%"
4174
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=
4175
4781
  `
4176
4782
  },
4177
4783
  {
@@ -4192,6 +4798,15 @@ PARTNER_PRIVATE_KEY_PEM=
4192
4798
 
4193
4799
  # HTTP port (default 3001)
4194
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=
4195
4810
  `
4196
4811
  },
4197
4812
  {
@@ -4408,6 +5023,38 @@ func (s *Store) VerifyAddress(accountNumber, verifiedName string, knownNames []s
4408
5023
  return "mismatch"
4409
5024
  }
4410
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
+
4411
5058
  // findCustomerID matches on account number first (authoritative), then name.
4412
5059
  //
4413
5060
  // The name pass is deliberately simple. Real matching needs maiden names,
@@ -4527,6 +5174,12 @@ import (
4527
5174
 
4528
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
4529
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
+
4530
5183
  func main() {
4531
5184
  webhookSecret := os.Getenv("WEBHOOK_SECRET")
4532
5185
  privateKeyPEM := strings.ReplaceAll(os.Getenv("PARTNER_PRIVATE_KEY_PEM"), \`\\n\`, "\\n")
@@ -4540,6 +5193,23 @@ func main() {
4540
5193
  log.Fatal("[startup] Missing required env vars: WEBHOOK_SECRET, PARTNER_PRIVATE_KEY_PEM, PARTNER_ID")
4541
5194
  }
4542
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
+
4543
5213
  // Durable store: schema, seed data, and the delivery log. Replaces the
4544
5214
  // in-memory dedup map this scaffold used to carry \u2014 that was per-process
4545
5215
  // (useless behind a load balancer), lost on restart, and marked on arrival,
@@ -4582,6 +5252,61 @@ func main() {
4582
5252
 
4583
5253
  event, _ := parsed["event"].(string)
4584
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
+
5297
+ // A valid signed request that carries no address payload \u2014 a conformance
5298
+ // ping, or any event added after this receiver was generated \u2014 has already
5299
+ // passed timestamp + signature above, which is exactly what such a probe
5300
+ // tests. Acknowledge it here; only the real dispatch events below require
5301
+ // decryption. A real address.updated with no payload still 422s below.
5302
+ switch event {
5303
+ case "address.updated", "address.verify", "address.test", "address.test-dispatch":
5304
+ default:
5305
+ log.Printf("[webhook] %q acknowledged (no address payload to decrypt)", event)
5306
+ jsonResp(w, 200, map[string]any{"ok": true, "skipped": true})
5307
+ return
5308
+ }
5309
+
4585
5310
  // Two possible payload shapes \u2014 D5 takes precedence when both fields
4586
5311
  // are present. Production deployments holding multiple key rotations
4587
5312
  // should look up the right private key by session_key_share.key_id;
@@ -4668,6 +5393,12 @@ func main() {
4668
5393
  if dispatch != "" {
4669
5394
  _ = store.MarkProcessed(dispatch, outcome)
4670
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
+ }
4671
5402
  jsonResp(w, 200, map[string]any{"ok": true, "outcome": outcome})
4672
5403
  return
4673
5404
 
@@ -4760,6 +5491,67 @@ func main() {
4760
5491
  log.Fatal(http.ListenAndServe(":"+port, nil))
4761
5492
  }
4762
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
+
4763
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
4764
5556
 
4765
5557
  func verifyHMAC(rawBody, signature, timestamp, secret string) bool {
@@ -5144,6 +5936,20 @@ Credentials are in \`.env\` (written by the setup wizard). Never commit \`.env\`
5144
5936
  OA_PARTNER_ID=%%PARTNER_ID%%
5145
5937
  OA_WEBHOOK_SECRET=%%WEBHOOK_SECRET%%
5146
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%%
5147
5953
  `
5148
5954
  },
5149
5955
  {
@@ -5309,6 +6115,31 @@ class OneAddressStore
5309
6115
  return $same ? 'match' : 'mismatch';
5310
6116
  }
5311
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
+
5312
6143
  /**
5313
6144
  * Account number first (authoritative), then name.
5314
6145
  *
@@ -5378,11 +6209,135 @@ class OneAddressStore
5378
6209
  }
5379
6210
  }
5380
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
+
5381
6327
  Route::post('/webhook', function (Request $request): Response {
5382
6328
  $partnerId = env('OA_PARTNER_ID', '%%PARTNER_ID%%');
5383
6329
  $webhookSecret = env('OA_WEBHOOK_SECRET', '');
5384
6330
  $privateKeyB64 = env('OA_PRIVATE_KEY_PEM', '');
5385
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
+
5386
6341
  $rawBody = $request->getContent();
5387
6342
  $timestamp = $request->header('X-OneAddress-Timestamp', '');
5388
6343
  $signature = $request->header('X-OneAddress-Signature', '');
@@ -5407,6 +6362,40 @@ Route::post('/webhook', function (Request $request): Response {
5407
6362
 
5408
6363
  $eventType = $body['event'] ?? '';
5409
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
+
5410
6399
  // address.test / address.test-dispatch (the connection-verification probe)
5411
6400
  // must pass this guard so it reaches the decrypt below; a wrong key then
5412
6401
  // returns 422 and only a real decrypt reaches the verification answer.
@@ -5611,6 +6600,11 @@ Route::post('/webhook', function (Request $request): Response {
5611
6600
  'partner_id' => $partnerId,
5612
6601
  'outcome' => $outcome,
5613
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
+ }
5614
6608
  return response()->json(['ok' => true, 'outcome' => $outcome]);
5615
6609
  } elseif ($eventType === 'address.verify') {
5616
6610
  $callbackUrl = $body['callback_url'] ?? '';
@@ -5666,6 +6660,15 @@ Route::get('/health', function (): \\Illuminate\\Http\\JsonResponse {
5666
6660
  OA_PARTNER_ID=your-partner-uuid-here
5667
6661
  OA_WEBHOOK_SECRET=your-webhook-secret-here
5668
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
5669
6672
  `
5670
6673
  },
5671
6674
  {
@@ -5695,8 +6698,16 @@ Your webhook endpoint: \`POST http://localhost:3001/api/webhook\`
5695
6698
 
5696
6699
  ## Events handled
5697
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
+
5698
6707
  ### address.updated
5699
- 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".
5700
6711
 
5701
6712
  ### address.verify
5702
6713
  Receive \u2192 verify HMAC \u2192 decrypt \u2192 compare to records \u2192 POST callback_url.
@@ -5746,6 +6757,9 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
5746
6757
  | \`OA_PARTNER_ID\` | Your partner UUID |
5747
6758
  | \`OA_WEBHOOK_SECRET\` | HMAC-SHA256 signing secret |
5748
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\`) |
5749
6763
  `
5750
6764
  }
5751
6765
  ]
@@ -5782,7 +6796,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
5782
6796
 
5783
6797
  // src/register.ts
5784
6798
  var import_node_crypto = require("crypto");
5785
- var PKG_VERSION = true ? "1.6.0" : "dev";
6799
+ var PKG_VERSION = true ? "1.6.2" : "dev";
5786
6800
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
5787
6801
  function hmacSha256(secret, message) {
5788
6802
  return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
@@ -5973,7 +6987,6 @@ async function runDecryptCheck(webhookSecret, partnerId) {
5973
6987
 
5974
6988
  // src/install.ts
5975
6989
  var import_node_child_process = require("child_process");
5976
- var import_node_fs2 = require("fs");
5977
6990
  var import_node_path2 = require("path");
5978
6991
  var COMMANDS = {
5979
6992
  "ts-node": { cmd: "npm", args: ["install"] },
@@ -5981,22 +6994,17 @@ var COMMANDS = {
5981
6994
  "go-http": { cmd: "go", args: ["mod", "tidy"] },
5982
6995
  "php-laravel": { cmd: "composer", args: ["install"] },
5983
6996
  "csharp-aspnet": { cmd: "dotnet", args: ["restore"] },
5984
- "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"] }
5985
7002
  };
5986
7003
  function installDependencies(platform, outputDir) {
5987
7004
  const spec = COMMANDS[platform];
5988
7005
  if (!spec) {
5989
7006
  return { ok: true, output: "", manualCommand: "" };
5990
7007
  }
5991
- if (platform === "java-spring") {
5992
- const mvnw = (0, import_node_path2.join)(outputDir, "mvnw");
5993
- if ((0, import_node_fs2.existsSync)(mvnw)) {
5994
- try {
5995
- (0, import_node_fs2.chmodSync)(mvnw, 493);
5996
- } catch {
5997
- }
5998
- }
5999
- }
6000
7008
  const cwd = spec.cwd ? (0, import_node_path2.join)(outputDir, spec.cwd) : outputDir;
6001
7009
  const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
6002
7010
  const result = (0, import_node_child_process.spawnSync)(spec.cmd, spec.args, {
@@ -6017,15 +7025,14 @@ function installDependencies(platform, outputDir) {
6017
7025
 
6018
7026
  // src/autostart.ts
6019
7027
  var import_node_child_process2 = require("child_process");
6020
- var import_node_path3 = require("path");
6021
- var import_node_fs3 = require("fs");
6022
7028
  var COMMANDS2 = {
6023
7029
  "ts-node": { cmd: "npx", args: ["tsx", "src/server.ts"] },
6024
7030
  "python": { cmd: "uvicorn", args: ["app:app", "--port", "3001"] },
6025
7031
  "go-http": { cmd: "go", args: ["run", "."] },
6026
7032
  "php-laravel": { cmd: "php", args: ["artisan", "serve", "--port=3001"] },
6027
7033
  "csharp-aspnet": { cmd: "dotnet", args: ["run"] },
6028
- "java-spring": { cmd: "./mvnw", args: ["spring-boot:run"] }
7034
+ "java-spring": { cmd: "mvn", args: ["spring-boot:run"] }
7035
+ // no mvnw wrapper is scaffolded
6029
7036
  };
6030
7037
  var serverProcess = null;
6031
7038
  var serverOutput = "";
@@ -6072,20 +7079,11 @@ async function pollHealth(port, timeoutMs) {
6072
7079
  }
6073
7080
  return false;
6074
7081
  }
6075
- async function startServer(platform, outputDir, port = 3001) {
7082
+ async function startServer(platform, outputDir, port = 3001, secrets = {}) {
6076
7083
  const spec = COMMANDS2[platform];
6077
7084
  if (!spec) {
6078
7085
  return { ok: false, output: `No start command defined for platform: ${platform}`, manualCommand: "" };
6079
7086
  }
6080
- if (platform === "java-spring") {
6081
- const mvnw = (0, import_node_path3.join)(outputDir, "mvnw");
6082
- if ((0, import_node_fs3.existsSync)(mvnw)) {
6083
- try {
6084
- (0, import_node_fs3.chmodSync)(mvnw, 493);
6085
- } catch {
6086
- }
6087
- }
6088
- }
6089
7087
  serverOutput = "";
6090
7088
  const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
6091
7089
  const PASSTHROUGH_KEYS = [
@@ -6123,6 +7121,9 @@ async function startServer(platform, outputDir, port = 3001) {
6123
7121
  const v2 = process.env[key];
6124
7122
  if (typeof v2 === "string") childEnv[key] = v2;
6125
7123
  }
7124
+ for (const [key, value] of Object.entries(secrets)) {
7125
+ if (value) childEnv[key] = value;
7126
+ }
6126
7127
  const isWin = process.platform === "win32";
6127
7128
  serverProcess = (0, import_node_child_process2.spawn)(
6128
7129
  isWin ? "cmd.exe" : spec.cmd,
@@ -6152,9 +7153,9 @@ async function startServer(platform, outputDir, port = 3001) {
6152
7153
  // src/tunnel.ts
6153
7154
  var import_node_child_process3 = require("child_process");
6154
7155
  var import_promises2 = require("fs/promises");
6155
- var import_node_fs4 = require("fs");
7156
+ var import_node_fs2 = require("fs");
6156
7157
  var import_node_crypto3 = require("crypto");
6157
- var import_node_path4 = require("path");
7158
+ var import_node_path3 = require("path");
6158
7159
  var import_node_os = __toESM(require("os"));
6159
7160
  var tunnelProcess = null;
6160
7161
  function stopTunnel() {
@@ -6202,19 +7203,19 @@ async function downloadCloudflared() {
6202
7203
  if (!spec) {
6203
7204
  throw new Error(`No cloudflared asset known for ${key}. Install cloudflared manually or provide your own HTTPS URL when prompted.`);
6204
7205
  }
6205
- 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}`);
6206
7207
  const binaryName = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
6207
- const finalPath = (0, import_node_path4.join)(cacheDir, binaryName);
6208
- 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)) {
6209
7210
  try {
6210
7211
  const cachedHash = await sha256File(finalPath);
6211
7212
  if (cachedHash === spec.sha256) return finalPath;
6212
7213
  } catch {
6213
7214
  }
6214
7215
  }
6215
- if (spec.archive && (0, import_node_fs4.existsSync)(finalPath)) return finalPath;
7216
+ if (spec.archive && (0, import_node_fs2.existsSync)(finalPath)) return finalPath;
6216
7217
  await (0, import_promises2.mkdir)(cacheDir, { recursive: true });
6217
- const downloadPath = (0, import_node_path4.join)(cacheDir, spec.asset);
7218
+ const downloadPath = (0, import_node_path3.join)(cacheDir, spec.asset);
6218
7219
  const url = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${spec.asset}`;
6219
7220
  const res = await fetch(url, { signal: AbortSignal.timeout(6e4) });
6220
7221
  if (!res.ok) throw new Error(`Failed to download cloudflared from ${url}: HTTP ${res.status}`);
@@ -6248,7 +7249,7 @@ Refusing to execute. Try re-running setup, or install cloudflared manually.`
6248
7249
  `cloudflared archive extraction failed (tar exit ${tarResult.status}): ${tarResult.stderr || tarResult.stdout || "no output"}`
6249
7250
  );
6250
7251
  }
6251
- if (!(0, import_node_fs4.existsSync)(finalPath)) {
7252
+ if (!(0, import_node_fs2.existsSync)(finalPath)) {
6252
7253
  throw new Error(`cloudflared archive extracted but ${finalPath} not found \u2014 Cloudflare may have changed the tarball layout.`);
6253
7254
  }
6254
7255
  try {
@@ -6437,6 +7438,7 @@ async function registerWebhookUrl(partnerId, webhookSecret, webhookUrl) {
6437
7438
 
6438
7439
  // src/cli-gate.ts
6439
7440
  var MAX_ATTEMPTS = 3;
7441
+ var MAX_TRANSIENT_FAILURES = 5;
6440
7442
  var AUTH_URL = "https://partners.oneaddress.io/api/cli/auth";
6441
7443
  async function checkToken(token) {
6442
7444
  try {
@@ -6470,6 +7472,7 @@ async function checkToken(token) {
6470
7472
  }
6471
7473
  }
6472
7474
  async function cliGate() {
7475
+ let transientFailures = 0;
6473
7476
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
6474
7477
  const token = await ge({
6475
7478
  message: attempt === 1 ? "OneAddress CLI token (Profile \u2192 CLI Access in the portal)" : `OneAddress CLI token (attempt ${attempt}/${MAX_ATTEMPTS})`,
@@ -6482,6 +7485,11 @@ async function cliGate() {
6482
7485
  const result = await checkToken(token);
6483
7486
  if (result.ok) return { partnerId: result.partnerId };
6484
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
+ }
6485
7493
  M2.warn(` ${result.reason}, retry the same token.`);
6486
7494
  attempt--;
6487
7495
  continue;
@@ -6580,9 +7588,9 @@ function normalisePrivateKey(raw) {
6580
7588
  const trimmed = raw.trim();
6581
7589
  const isPath = /^(\/|\.\/|\.\.\/|~\/|[A-Za-z]:[/\\])/.test(trimmed) || trimmed.endsWith(".pem");
6582
7590
  if (isPath) {
6583
- const abs = trimmed.startsWith("~/") ? (0, import_node_path5.resolve)((0, import_node_os2.homedir)(), trimmed.slice(2)) : (0, import_node_path5.resolve)(trimmed);
6584
- if (!(0, import_node_fs5.existsSync)(abs)) return { pem: "", error: `File not found: ${abs}` };
6585
- 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"));
6586
7594
  }
6587
7595
  const unescaped = trimmed.replaceAll("\\n", "\n");
6588
7596
  if (unescaped.includes("-----BEGIN PRIVATE KEY-----")) {
@@ -6741,26 +7749,23 @@ async function main() {
6741
7749
  ]
6742
7750
  });
6743
7751
  assertNotCancelled(platform);
6744
- const outputDir = await he({
6745
- message: "Where to write the files?",
6746
- placeholder: "./oneaddress-webhook",
6747
- defaultValue: "./oneaddress-webhook"
6748
- });
6749
- assertNotCancelled(outputDir);
6750
- const outDir = outputDir.trim() || "./oneaddress-webhook";
6751
- if ((0, import_node_fs5.existsSync)(outDir)) {
6752
- const entries = (0, import_node_fs5.readdirSync)(outDir);
6753
- if (entries.length > 0) {
6754
- const overwrite = await ye({
6755
- message: `${outDir} already has files \u2014 overwrite?`,
6756
- initialValue: false
6757
- });
6758
- assertNotCancelled(overwrite);
6759
- if (!overwrite) {
6760
- xe("Choose a different output directory and run again.");
6761
- process.exit(0);
6762
- }
6763
- }
7752
+ let outDir;
7753
+ for (; ; ) {
7754
+ const outputDir = await he({
7755
+ message: "Where to write the files?",
7756
+ placeholder: "./oneaddress-webhook",
7757
+ defaultValue: "./oneaddress-webhook"
7758
+ });
7759
+ assertNotCancelled(outputDir);
7760
+ outDir = outputDir.trim() || "./oneaddress-webhook";
7761
+ if (!(0, import_node_fs3.existsSync)(outDir) || (0, import_node_fs3.readdirSync)(outDir).length === 0) break;
7762
+ const overwrite = await ye({
7763
+ message: `${outDir} already has files \u2014 overwrite?`,
7764
+ initialValue: false
7765
+ });
7766
+ assertNotCancelled(overwrite);
7767
+ if (overwrite) break;
7768
+ M2.info(`Keeping the files in ${outDir}. Enter a different directory to write into.`);
6764
7769
  }
6765
7770
  let accountRefReason = "";
6766
7771
  const accountRefDeclaration = await getVerifiesAccountReference(pid, secret, (r2) => {
@@ -6831,7 +7836,12 @@ async function main() {
6831
7836
  const s3 = Y2();
6832
7837
  s3.start(`Starting server (waiting up to 15 s for /health on :${SERVER_PORT})`);
6833
7838
  onCleanup(stopServer);
6834
- 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
+ });
6835
7845
  let serverRunning = false;
6836
7846
  if (start.ok) {
6837
7847
  s3.stop(`Server is healthy on port ${SERVER_PORT}`);
@@ -7052,7 +8062,7 @@ ${DIM2} Stopped.${R3}
7052
8062
  }
7053
8063
 
7054
8064
  // src/non-interactive.ts
7055
- var import_node_fs6 = require("fs");
8065
+ var import_node_fs4 = require("fs");
7056
8066
  var PLATFORMS = [
7057
8067
  "ts-node",
7058
8068
  "python",
@@ -7105,7 +8115,7 @@ function parseArgs(argv2) {
7105
8115
  }
7106
8116
  return out;
7107
8117
  }
7108
- 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")) {
7109
8119
  const errors = [];
7110
8120
  for (const flag of args.secretFlagsUsed) {
7111
8121
  errors.push(
@@ -7207,7 +8217,7 @@ ${usage()}`);
7207
8217
  process.exit(1);
7208
8218
  }
7209
8219
  const { partnerId, secret, privateKey, platform, outDir, webhookUrl, force } = resolved.config;
7210
- 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) {
7211
8221
  console.error(`[oneaddress/setup] ${outDir} is not empty. Pass --force to overwrite.`);
7212
8222
  process.exit(1);
7213
8223
  }