@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.
- package/dist/index.js +1158 -148
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -160,7 +160,7 @@ var require_picocolors = __commonJS({
|
|
|
160
160
|
});
|
|
161
161
|
|
|
162
162
|
// src/index.ts
|
|
163
|
-
var
|
|
163
|
+
var import_node_fs5 = require("fs");
|
|
164
164
|
|
|
165
165
|
// node_modules/@clack/prompts/dist/index.mjs
|
|
166
166
|
var import_node_util = require("util");
|
|
@@ -830,9 +830,9 @@ var Y2 = ({ indicator: t = "dots" } = {}) => {
|
|
|
830
830
|
// src/prompts.ts
|
|
831
831
|
var import_node_crypto5 = require("crypto");
|
|
832
832
|
var import_node_net = require("net");
|
|
833
|
-
var
|
|
833
|
+
var import_node_fs3 = require("fs");
|
|
834
834
|
var import_node_os2 = require("os");
|
|
835
|
-
var
|
|
835
|
+
var import_node_path4 = require("path");
|
|
836
836
|
|
|
837
837
|
// src/header.ts
|
|
838
838
|
var R2 = "\x1B[0m";
|
|
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
|
|
|
856
856
|
var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
|
|
857
857
|
var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
|
|
858
858
|
var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
|
|
859
|
-
var WIZARD_VERSION = true ? "1.6.
|
|
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.
|
|
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',
|
|
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
|
-
#
|
|
2108
|
-
#
|
|
2109
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
2122
|
-
|
|
2123
|
-
name
|
|
2124
|
-
address
|
|
2125
|
-
|
|
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
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
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
|
-
|
|
2142
|
-
|
|
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
|
|
2146
|
-
VALUES (?, ?, ?,
|
|
2147
|
-
ON CONFLICT(
|
|
2148
|
-
name
|
|
2149
|
-
address
|
|
2150
|
-
|
|
2151
|
-
|
|
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 (
|
|
2155
|
-
VALUES (?, ?, ?,
|
|
2156
|
-
""", (
|
|
2157
|
-
|
|
2158
|
-
def _verify_address(
|
|
2159
|
-
"""Returns 'match' | 'mismatch' | 'not_found' for an address.verify event.
|
|
2160
|
-
|
|
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[
|
|
2288
|
+
stored = json.loads(row[2])
|
|
2165
2289
|
except (json.JSONDecodeError, TypeError):
|
|
2166
|
-
|
|
2167
|
-
|
|
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
|
-
|
|
2317
|
-
|
|
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
|
-
#
|
|
2329
|
-
#
|
|
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
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
#
|
|
2359
|
-
#
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
if
|
|
2363
|
-
|
|
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
|
|
2383
|
-
#
|
|
2384
|
-
|
|
2385
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
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
|
|
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 "<timestamp>.<rawBody>" \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 <secret>
|
|
3395
|
+
* X-OneAddress-Timestamp: <unix seconds>
|
|
3396
|
+
* X-OneAddress-Signature: HMAC-SHA256(secret, "<timestamp>.<rawBody>")
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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: "
|
|
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
|
|
7156
|
+
var import_node_fs2 = require("fs");
|
|
6156
7157
|
var import_node_crypto3 = require("crypto");
|
|
6157
|
-
var
|
|
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,
|
|
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,
|
|
6208
|
-
if (!spec.archive && (0,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
6584
|
-
if (!(0,
|
|
6585
|
-
return normalisePrivateKey((0,
|
|
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
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
if (
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
}
|