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