@oneaddress/setup 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +669 -211
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -828,7 +828,7 @@ var Y2 = ({ indicator: t = "dots" } = {}) => {
|
|
|
828
828
|
};
|
|
829
829
|
|
|
830
830
|
// src/prompts.ts
|
|
831
|
-
var
|
|
831
|
+
var import_node_crypto6 = require("crypto");
|
|
832
832
|
var import_node_net = require("net");
|
|
833
833
|
var import_node_fs3 = require("fs");
|
|
834
834
|
var import_node_os2 = require("os");
|
|
@@ -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 ? "2.
|
|
859
|
+
var WIZARD_VERSION = true ? "2.4.0" : "?";
|
|
860
860
|
function printCompactHeader() {
|
|
861
861
|
const INNER = 42;
|
|
862
862
|
const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
|
|
@@ -909,6 +909,7 @@ function stripAnsi(s) {
|
|
|
909
909
|
|
|
910
910
|
// src/scaffold.ts
|
|
911
911
|
var import_promises = require("fs/promises");
|
|
912
|
+
var import_node_crypto = require("crypto");
|
|
912
913
|
var import_node_fs = require("fs");
|
|
913
914
|
var import_node_path = require("path");
|
|
914
915
|
|
|
@@ -1006,10 +1007,11 @@ data.db-shm
|
|
|
1006
1007
|
{
|
|
1007
1008
|
name: "oneaddress.config.json",
|
|
1008
1009
|
content: `{
|
|
1009
|
-
"//": "Written by npx @oneaddress/setup, read by src/config.ts. Non-secret config only \u2014 secrets live in .env. Any field can be overridden by an env var of the matching name (PARTNER_ID / ONEADDRESS_API / VERIFIES_ACCOUNT_REFERENCE).",
|
|
1010
|
+
"//": "Written by npx @oneaddress/setup, read by src/config.ts. Non-secret config only \u2014 secrets live in .env. Any field can be overridden by an env var of the matching name (PARTNER_ID / ONEADDRESS_API / VERIFIES_ACCOUNT_REFERENCE / RECEIVER_MODE).",
|
|
1010
1011
|
"partnerId": "%%PARTNER_ID%%",
|
|
1011
1012
|
"oneAddressApi": "%%ONEADDRESS_API%%",
|
|
1012
|
-
"verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE
|
|
1013
|
+
"verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE%%,
|
|
1014
|
+
"mode": "%%RECEIVER_MODE%%"
|
|
1013
1015
|
}
|
|
1014
1016
|
`
|
|
1015
1017
|
},
|
|
@@ -1231,6 +1233,34 @@ export { PassphraseRequiredError, WrongPassphraseError };
|
|
|
1231
1233
|
*/
|
|
1232
1234
|
export { isEncrypted };
|
|
1233
1235
|
|
|
1236
|
+
/**
|
|
1237
|
+
* Read a timestamp out of this database, whichever of the two formats it is in.
|
|
1238
|
+
*
|
|
1239
|
+
* THIS DATABASE HOLDS TWO, AND NOTHING DECLARED WHICH WAS WHICH.
|
|
1240
|
+
*
|
|
1241
|
+
* a SQL default, \`datetime('now')\` -> "2026-09-14 22:39:52" UTC, no zone
|
|
1242
|
+
* a JS write, \`toISOString()\` -> "2026-09-14T22:39:52.695Z"
|
|
1243
|
+
*
|
|
1244
|
+
* Both appear in the SAME table: \`quarantine.received_at\` is the first and
|
|
1245
|
+
* \`quarantine.replayed_at\` is the second.
|
|
1246
|
+
*
|
|
1247
|
+
* The first is not valid ISO-8601, so \`new Date(...)\` falls to implementation
|
|
1248
|
+
* -defined parsing and V8 reads it as LOCAL time. A row written a minute ago
|
|
1249
|
+
* therefore reads as an hour old for every hour the machine is from UTC. It
|
|
1250
|
+
* reported \`8h ago\` for a two-minute-old row on a partner's machine in Perth,
|
|
1251
|
+
* which is exactly UTC+8 and is how it was found.
|
|
1252
|
+
*
|
|
1253
|
+
* TOLERANT RATHER THAN MIGRATING, deliberately: a receiver that has been
|
|
1254
|
+
* running has rows in the old shape and rewriting somebody's database to fix a
|
|
1255
|
+
* display bug is the wrong trade. This is the same shape as the transition
|
|
1256
|
+
* -tolerant blind-index reads elsewhere in this project.
|
|
1257
|
+
*/
|
|
1258
|
+
export function parseStoredTime(value: string): Date {
|
|
1259
|
+
// Zone-less \`YYYY-MM-DD HH:MM:SS\` is SQLite's, and SQLite's \`now\` is UTC.
|
|
1260
|
+
const sqlite = /^(\\d{4}-\\d{2}-\\d{2}) (\\d{2}:\\d{2}:\\d{2})$/.exec(value.trim());
|
|
1261
|
+
return new Date(sqlite ? \`\${sqlite[1]}T\${sqlite[2]}Z\` : value);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1234
1264
|
/**
|
|
1235
1265
|
* Add a column to an existing table if it is not already there.
|
|
1236
1266
|
*
|
|
@@ -1680,7 +1710,6 @@ import { formatLine, report, type ReportLine } from './report.js';
|
|
|
1680
1710
|
// One import, same as server.ts. Swapping the store swaps what the dashboard
|
|
1681
1711
|
// reads, with nothing here to change.
|
|
1682
1712
|
import { store } from './store.js';
|
|
1683
|
-
import type { StoredCustomer } from './customer-store.js';
|
|
1684
1713
|
import { pendingConfirmCount } from './confirm-queue.js';
|
|
1685
1714
|
import { exportHeld, heldCount, heldSummary } from './quarantine.js';
|
|
1686
1715
|
|
|
@@ -1701,7 +1730,7 @@ export interface TuiOptions {
|
|
|
1701
1730
|
* Re-apply everything the receiver could not open, bound to [r].
|
|
1702
1731
|
*
|
|
1703
1732
|
* Passed IN rather than imported: \`server.ts\` already imports this file for
|
|
1704
|
-
* \`
|
|
1733
|
+
* \`noteChange\`, so importing it back would be a cycle. Optional so
|
|
1705
1734
|
* the dashboard still renders for a caller that has no replay to offer.
|
|
1706
1735
|
*/
|
|
1707
1736
|
onReplay?: () => Promise<{ applied: number; failed: number }>;
|
|
@@ -1722,10 +1751,26 @@ export interface TuiOptions {
|
|
|
1722
1751
|
};
|
|
1723
1752
|
}
|
|
1724
1753
|
|
|
1725
|
-
/**
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1754
|
+
/**
|
|
1755
|
+
* What the server reports about a dispatch it applied.
|
|
1756
|
+
*
|
|
1757
|
+
* DELIBERATELY CARRIES NO ADDRESS AND NO NAME. This is the whole interface
|
|
1758
|
+
* between the protocol layer and the screen, so anything absent from this type
|
|
1759
|
+
* is something the dashboard cannot render however it is rewritten later. The
|
|
1760
|
+
* address-formatting helper that used to live here went with it: a function
|
|
1761
|
+
* that turns an address into a display string, sitting in the UI module, is an
|
|
1762
|
+
* invitation.
|
|
1763
|
+
*/
|
|
1764
|
+
export interface ChangeFacts {
|
|
1765
|
+
/** The partner's own identifier for their own record. Never the consumer's name. */
|
|
1766
|
+
accountNumber: string;
|
|
1767
|
+
/** Did the account-reference guard run and pass? False means you do not verify them. */
|
|
1768
|
+
accountChecked: boolean;
|
|
1769
|
+
/** Did the record actually move, or was it already current? */
|
|
1770
|
+
changed: boolean;
|
|
1771
|
+
/** Proof of consent, for your audit trail. */
|
|
1772
|
+
loaRef: string | null;
|
|
1773
|
+
dispatchId: string | null;
|
|
1729
1774
|
}
|
|
1730
1775
|
|
|
1731
1776
|
export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: TuiOptions): void {
|
|
@@ -1808,13 +1853,13 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1808
1853
|
});
|
|
1809
1854
|
|
|
1810
1855
|
// \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1811
|
-
let lastChange:
|
|
1812
|
-
let pendingPrevious: Record<string, unknown> = {};
|
|
1856
|
+
let lastChange: ChangeFacts | null = null;
|
|
1813
1857
|
|
|
1814
|
-
// The server hands over
|
|
1815
|
-
//
|
|
1816
|
-
//
|
|
1817
|
-
|
|
1858
|
+
// The server hands over FACTS about a dispatch, never the address. See
|
|
1859
|
+
// \`ChangeFacts\` and the note at the call site in server.ts: this module is
|
|
1860
|
+
// the screen, and a screen is the one place a consumer's home address is
|
|
1861
|
+
// most likely to be photographed.
|
|
1862
|
+
setChange = (facts) => { lastChange = facts; redraw(); };
|
|
1818
1863
|
|
|
1819
1864
|
function renderStatus(): void {
|
|
1820
1865
|
// Both states are shown, and the unprotected one is the loud colour. A
|
|
@@ -1846,31 +1891,36 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1846
1891
|
if (!lastChange) {
|
|
1847
1892
|
changeBox.setContent(
|
|
1848
1893
|
\`\\n {\${DIM}-fg}Waiting for a dispatch.{/}\\n\\n\` +
|
|
1849
|
-
\` {\${DIM}-fg}When one arrives,
|
|
1850
|
-
\` {\${DIM}-fg}
|
|
1894
|
+
\` {\${DIM}-fg}When one arrives, what happened to it appears here.{/}\\n\` +
|
|
1895
|
+
\` {\${DIM}-fg}The address itself never does \u2014 see below.{/}\`,
|
|
1851
1896
|
);
|
|
1852
1897
|
return;
|
|
1853
1898
|
}
|
|
1854
|
-
const
|
|
1855
|
-
|
|
1856
|
-
try { now = JSON.parse(customer.address) as Record<string, unknown>; } catch { /* keep empty */ }
|
|
1857
|
-
// COMPACT, AND THAT IS A BUG FIX RATHER THAN A TIDY-UP.
|
|
1899
|
+
const f = lastChange;
|
|
1900
|
+
// WHAT HAPPENED, NOT WHAT IT SAYS.
|
|
1858
1901
|
//
|
|
1859
|
-
//
|
|
1860
|
-
//
|
|
1861
|
-
//
|
|
1862
|
-
//
|
|
1863
|
-
// it
|
|
1902
|
+
// Every line here is a fact about the dispatch and none of them is content.
|
|
1903
|
+
// An operator watching this can tell that the envelope opened, that the
|
|
1904
|
+
// account was one of theirs, and whether the record actually moved - which
|
|
1905
|
+
// is everything the panel was ever for. To read the address itself, look in
|
|
1906
|
+
// your own database, which is where it lives and which is a deliberate act
|
|
1907
|
+
// rather than a screen left open.
|
|
1864
1908
|
//
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
//
|
|
1868
|
-
//
|
|
1909
|
+
// COMPACT ON PURPOSE. The faults band takes rows off this panel when it
|
|
1910
|
+
// appears and blessed clips silently, so a tall panel loses its last line
|
|
1911
|
+
// and reads as a dispatch that half worked. That cost a real end-to-end run
|
|
1912
|
+
// an hour of doubt.
|
|
1913
|
+
const check = f.accountChecked
|
|
1914
|
+
? \`{green-fg}account matched{/}\`
|
|
1915
|
+
: \`{\${DIM}-fg}account not checked{/}\`;
|
|
1916
|
+
const moved = f.changed
|
|
1917
|
+
? \`{green-fg}address changed{/}\`
|
|
1918
|
+
: \`{\${AMBER}-fg}already current{/}\`;
|
|
1869
1919
|
changeBox.setContent(
|
|
1870
|
-
\`\\n {bold}\${esc(
|
|
1871
|
-
\` {
|
|
1872
|
-
\` {
|
|
1873
|
-
\` {
|
|
1920
|
+
\`\\n {\${DIM}-fg}account{/} {\${AMBER}-fg}{bold}\${esc(f.accountNumber)}{/}{/}\\n\\n\` +
|
|
1921
|
+
\` {green-fg}decrypted{/} \${check} \${moved}\\n\\n\` +
|
|
1922
|
+
\` {\${DIM}-fg}loa_ref{/} {\${CREAM}-fg}\${esc(f.loaRef ?? '(none)')}{/}\\n\` +
|
|
1923
|
+
\` {\${DIM}-fg}dispatch{/} {\${CREAM}-fg}\${esc(f.dispatchId ?? '(none)')}{/}\`,
|
|
1874
1924
|
);
|
|
1875
1925
|
}
|
|
1876
1926
|
|
|
@@ -1979,22 +2029,12 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1979
2029
|
const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
|
|
1980
2030
|
const time = new Date(line.at).toTimeString().slice(0, 8);
|
|
1981
2031
|
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
// million, which is the scale a real store is pointed at.
|
|
1989
|
-
const prev = pendingPrevious;
|
|
1990
|
-
if (acct) {
|
|
1991
|
-
void Promise.resolve(store.find(acct)).then((customer) => {
|
|
1992
|
-
if (!customer) return;
|
|
1993
|
-
lastChange = { customer, previous: prev };
|
|
1994
|
-
redraw();
|
|
1995
|
-
});
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
2032
|
+
// NOTHING IS PARSED OUT OF A LOG LINE HERE ANY MORE, and nothing is looked
|
|
2033
|
+
// up from the store. The panel used to scrape the account out of \`[store]
|
|
2034
|
+
// saved address for ...\` and then fetch that customer's record to render
|
|
2035
|
+
// it, which is how a customer's name and address reached the screen. The
|
|
2036
|
+
// server calls \`noteChange\` with facts instead, so this module has no path
|
|
2037
|
+
// to a customer record at all.
|
|
1998
2038
|
|
|
1999
2039
|
logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
|
|
2000
2040
|
redraw();
|
|
@@ -2065,10 +2105,10 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2065
2105
|
* reason \`report\` has a default sink: \`--headless\` must not need a second code
|
|
2066
2106
|
* path through the handler.
|
|
2067
2107
|
*/
|
|
2068
|
-
let
|
|
2108
|
+
let setChange: (facts: ChangeFacts) => void = () => {};
|
|
2069
2109
|
|
|
2070
|
-
export function
|
|
2071
|
-
|
|
2110
|
+
export function noteChange(facts: ChangeFacts): void {
|
|
2111
|
+
setChange(facts);
|
|
2072
2112
|
}
|
|
2073
2113
|
`
|
|
2074
2114
|
},
|
|
@@ -2096,6 +2136,11 @@ export function notePreviousAddress(prev: Record<string, unknown>): void {
|
|
|
2096
2136
|
* \`--headless\` skips both: no prompt (a service has nobody to ask), no screen.
|
|
2097
2137
|
*/
|
|
2098
2138
|
import { printBanner } from './brand.js';
|
|
2139
|
+
// Config only - NOT the server, the store or the database. Those derive keys on
|
|
2140
|
+
// import, which is the whole reason this file loads them dynamically below,
|
|
2141
|
+
// after the passphrase is resolved. \`config.js\` reads a JSON file and the
|
|
2142
|
+
// environment and touches neither.
|
|
2143
|
+
import { config } from './config.js';
|
|
2099
2144
|
import { PassphraseRequiredError, WrongPassphraseError } from './vault.js';
|
|
2100
2145
|
|
|
2101
2146
|
/**
|
|
@@ -2270,29 +2315,71 @@ async function resolvePassphrase(): Promise<string | null> {
|
|
|
2270
2315
|
// Reported by exactly that partner: "there was no opportunity to set a
|
|
2271
2316
|
// passphrase". Without this line the only options are guess or search the
|
|
2272
2317
|
// internet, and the answer is one command.
|
|
2273
|
-
process.stdout.write('\\n This customer database is encrypted.\\n');
|
|
2274
|
-
process.stdout.write('
|
|
2275
|
-
process.stdout.write('
|
|
2276
|
-
process.stdout.write(' OneAddress
|
|
2277
|
-
|
|
2318
|
+
process.stdout.write('\\n This customer database is encrypted and needs its password.\\n\\n');
|
|
2319
|
+
process.stdout.write(' It is the one you chose on this receiver. It is NOT your Webhook\\n');
|
|
2320
|
+
process.stdout.write(' signing secret, NOT your ECDH private key, and NOT your\\n');
|
|
2321
|
+
process.stdout.write(' OneAddress sign-in.\\n\\n');
|
|
2322
|
+
process.stdout.write(' If you do not have it, delete data.db and start again: it holds\\n');
|
|
2323
|
+
process.stdout.write(' your demo roster and test dispatches, nothing OneAddress needs.\\n\\n');
|
|
2324
|
+
const answer = await ask(' Password to unlock: ');
|
|
2278
2325
|
return answer || null;
|
|
2279
2326
|
}
|
|
2280
2327
|
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2328
|
+
// SAYS WHAT IS BEING ASKED BEFORE IT WARNS ABOUT IT. The first version opened
|
|
2329
|
+
// with SET A PASSPHRASE and four lines of consequence, which reads as an
|
|
2330
|
+
// instruction to produce a passphrase you are assumed to already have - and
|
|
2331
|
+
// this prompt arrives moments after the partner has pasted two real
|
|
2332
|
+
// credentials, so assuming exactly that is the natural reading. Reported as
|
|
2333
|
+
// confusing, and the confusion was ours.
|
|
2334
|
+
//
|
|
2335
|
+
// It therefore leads with the decision, says plainly that the answer is
|
|
2336
|
+
// INVENTED HERE, and names the three secrets it is not, because those are the
|
|
2337
|
+
// three things a partner at this point in setup might reach for.
|
|
2338
|
+
// NOT ASKED IN INBOX MODE, because the honest answer to "protect your customer
|
|
2339
|
+
// records" is that there are none here: the connector holds them. The
|
|
2340
|
+
// database in this mode carries dispatch ciphertext - already encrypted to a
|
|
2341
|
+
// key this process does not have - and a queue of dispatch ids. Asking anyway
|
|
2342
|
+
// is the confusing-prompt problem twice over: a question about data that is
|
|
2343
|
+
// absent, put to somebody who has just been told this receiver cannot read
|
|
2344
|
+
// anything.
|
|
2345
|
+
//
|
|
2346
|
+
// The UNLOCK branch above is deliberately NOT skipped. A database encrypted
|
|
2347
|
+
// while the receiver ran write-through is still encrypted after a switch to
|
|
2348
|
+
// inbox, and skipping the question there would refuse to open a file whose
|
|
2349
|
+
// owner has the password.
|
|
2350
|
+
if (config.mode === 'inbox') {
|
|
2351
|
+
process.stdout.write('\\n No password needed: in inbox mode this receiver holds no customer records.\\n');
|
|
2352
|
+
process.stdout.write(' Your connector holds them, and the key.\\n\\n');
|
|
2353
|
+
return null;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
process.stdout.write('\\n \u2500\u2500 Protect this receiver\\'s customer database? \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\n');
|
|
2357
|
+
process.stdout.write(' MAKE UP A NEW PASSWORD now. You are NOT being asked for anything\\n');
|
|
2358
|
+
process.stdout.write(' you already have: not your Webhook signing secret, not your ECDH\\n');
|
|
2359
|
+
process.stdout.write(' private key, not your OneAddress sign-in. This one is invented\\n');
|
|
2360
|
+
process.stdout.write(' here and used only on this machine.\\n\\n');
|
|
2361
|
+
process.stdout.write(' It encrypts the customer records this receiver keeps in data.db.\\n');
|
|
2362
|
+
process.stdout.write(' OneAddress never sees it and cannot reset it, so if you lose it\\n');
|
|
2363
|
+
process.stdout.write(' those records cannot be read again.\\n\\n');
|
|
2364
|
+
process.stdout.write(' OPTIONAL. Press Enter to skip and leave the database readable.\\n');
|
|
2365
|
+
// ACCURATE ABOUT "LATER", because the obvious reading is wrong. Setting a
|
|
2366
|
+
// password afterwards works and the receiver reports itself encrypted, but
|
|
2367
|
+
// rows written before it stay in the clear until something rewrites them -
|
|
2368
|
+
// confirmed by finding a seeded customer name in the database file after
|
|
2369
|
+
// exactly that sequence.
|
|
2370
|
+
process.stdout.write(' You can set one later with ONEADDRESS_DB_PASSPHRASE, but records\\n');
|
|
2371
|
+
process.stdout.write(' written before then stay readable until they change.\\n\\n');
|
|
2286
2372
|
|
|
2287
2373
|
for (;;) {
|
|
2288
|
-
const first = await ask(' New
|
|
2374
|
+
const first = await ask(' New password (or Enter to skip): ');
|
|
2289
2375
|
if (!first) {
|
|
2290
|
-
process.stdout.write(' Continuing WITHOUT encryption
|
|
2376
|
+
process.stdout.write(' Continuing WITHOUT encryption: anyone who can read data.db can\\n');
|
|
2377
|
+
process.stdout.write(' read your customer records.\\n\\n');
|
|
2291
2378
|
return null;
|
|
2292
2379
|
}
|
|
2293
|
-
const again = await ask('
|
|
2380
|
+
const again = await ask(' Type it again: ');
|
|
2294
2381
|
if (first === again) {
|
|
2295
|
-
process.stdout.write('
|
|
2382
|
+
process.stdout.write(' Password set. Keep it somewhere you will still have it.\\n\\n');
|
|
2296
2383
|
return first;
|
|
2297
2384
|
}
|
|
2298
2385
|
process.stdout.write(' Those do not match. Try again.\\n\\n');
|
|
@@ -2431,6 +2518,22 @@ import type {
|
|
|
2431
2518
|
* if anything awaited between the set and the read, which is why they are
|
|
2432
2519
|
* adjacent and why this comment exists.
|
|
2433
2520
|
*/
|
|
2521
|
+
/**
|
|
2522
|
+
* The connector could not be reached, so the question was never answered.
|
|
2523
|
+
*
|
|
2524
|
+
* A THROW RATHER THAN A VERDICT, because every value this function can return
|
|
2525
|
+
* is a statement about the customer's record and none of them means "we could
|
|
2526
|
+
* not ask". The handler turns this into a non-2xx, which the relay already
|
|
2527
|
+
* classifies as \`unreachable\` and shows the consumer as a transient network
|
|
2528
|
+
* problem rather than as a missing account.
|
|
2529
|
+
*/
|
|
2530
|
+
export class ConnectorUnreachableError extends Error {
|
|
2531
|
+
constructor(public readonly kind: string) {
|
|
2532
|
+
super(\`the connector could not be reached to answer \${kind}\`);
|
|
2533
|
+
this.name = 'ConnectorUnreachableError';
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2434
2537
|
let currentRawBody = '';
|
|
2435
2538
|
|
|
2436
2539
|
export function setCurrentRawBody(raw: string): void {
|
|
@@ -2456,14 +2559,26 @@ export const connectorStore = {
|
|
|
2456
2559
|
): Promise<AccountVerdict> {
|
|
2457
2560
|
const answer = await askConnector('account.verify', currentRawBody);
|
|
2458
2561
|
if (!answer.reached) {
|
|
2459
|
-
// NEVER GUESSED
|
|
2460
|
-
// a customer's account.
|
|
2461
|
-
//
|
|
2562
|
+
// NEVER GUESSED: a fabricated match authorises a stranger's address onto
|
|
2563
|
+
// a customer's account.
|
|
2564
|
+
//
|
|
2565
|
+
// BUT NOT \`no_account\` EITHER, which is what this returned. That is a
|
|
2566
|
+
// POSITIVE CLAIM - the consumer is shown "\u2717 No account found / This
|
|
2567
|
+
// service couldn't find an account with these details" and goes off to
|
|
2568
|
+
// re-check an account number that was correct all along, or concludes
|
|
2569
|
+
// they have no account with a provider they do.
|
|
2570
|
+
//
|
|
2571
|
+
// The trade was accepted for its safety, and the safety is real. What was
|
|
2572
|
+
// missed is that it is available WITHOUT the false claim: the relay
|
|
2573
|
+
// already classifies a failed check as \`unreachable\` and tells the
|
|
2574
|
+
// consumer "we couldn't reach this service just now". That stops them
|
|
2575
|
+
// before they pay AND is true. Throwing gets us there, because a
|
|
2576
|
+
// non-2xx is exactly what the relay reads as unreachable.
|
|
2462
2577
|
report.warn(
|
|
2463
2578
|
\`[connector] account check for \${accountNumber ?? '(none)'} could not be answered; \` +
|
|
2464
|
-
'
|
|
2579
|
+
'reporting the service as unreachable rather than claiming no account exists',
|
|
2465
2580
|
);
|
|
2466
|
-
|
|
2581
|
+
throw new ConnectorUnreachableError('account.verify');
|
|
2467
2582
|
}
|
|
2468
2583
|
const status = verdictOf(answer.body, 'status');
|
|
2469
2584
|
if (status === 'match' || status === 'no_match' || status === 'no_account') return status;
|
|
@@ -2473,7 +2588,9 @@ export const connectorStore = {
|
|
|
2473
2588
|
|
|
2474
2589
|
async verifyAddress(_customer: Customer, _incoming: Address): Promise<VerifyResult> {
|
|
2475
2590
|
const answer = await askConnector('address.verify', currentRawBody);
|
|
2476
|
-
|
|
2591
|
+
// Same reasoning as \`verifyAccount\`: \`not_found\` is a claim about the
|
|
2592
|
+
// customer's record, and "we could not ask" is not that claim.
|
|
2593
|
+
if (!answer.reached) throw new ConnectorUnreachableError('address.verify');
|
|
2477
2594
|
const result = verdictOf(answer.body, 'result');
|
|
2478
2595
|
if (result === 'match' || result === 'mismatch' || result === 'not_found') return result;
|
|
2479
2596
|
report.warn(\`[connector] address.verify answered "\${result ?? '(nothing)'}", which is not a result\`);
|
|
@@ -3118,7 +3235,7 @@ export function resetTally(): void {
|
|
|
3118
3235
|
* replay would apply an address for an account they do not recognise. It stays
|
|
3119
3236
|
* a refusal.
|
|
3120
3237
|
*/
|
|
3121
|
-
import db, { ensureColumn } from './db.js';
|
|
3238
|
+
import db, { ensureColumn, parseStoredTime } from './db.js';
|
|
3122
3239
|
import { report } from './report.js';
|
|
3123
3240
|
import { createHash } from 'node:crypto';
|
|
3124
3241
|
import { writeFileSync } from 'node:fs';
|
|
@@ -3288,7 +3405,10 @@ export function heldSummary(): string[] {
|
|
|
3288
3405
|
|
|
3289
3406
|
/** "3m ago", "2h ago". Coarse on purpose: nobody acts on seconds. */
|
|
3290
3407
|
export function describeAge(iso: string): string {
|
|
3291
|
-
|
|
3408
|
+
// \`parseStoredTime\`, not \`new Date\`. See its docstring: this column is written
|
|
3409
|
+
// by a SQL default in SQLite's zone-less UTC format, which V8 parses as local
|
|
3410
|
+
// time, so a fresh row read as hours old by the machine's UTC offset.
|
|
3411
|
+
const ms = Date.now() - parseStoredTime(iso).getTime();
|
|
3292
3412
|
if (!Number.isFinite(ms) || ms < 0) return 'just now';
|
|
3293
3413
|
const mins = Math.floor(ms / 60_000);
|
|
3294
3414
|
if (mins < 1) return 'moments ago';
|
|
@@ -3324,13 +3444,27 @@ export function markReplayFailed(id: string, error: string): void {
|
|
|
3324
3444
|
* is an update the partner never applied and is now no longer able to.
|
|
3325
3445
|
*/
|
|
3326
3446
|
export function purgeQuarantine(days: number): { replayed: number; unreplayed: number } {
|
|
3327
|
-
|
|
3447
|
+
// THE COMPARISON HAPPENS INSIDE SQLITE, IN SQLITE'S OWN FORMAT, and that is
|
|
3448
|
+
// the fix rather than a tidy-up.
|
|
3449
|
+
//
|
|
3450
|
+
// It used to build an ISO cutoff in JS and compare it against a column
|
|
3451
|
+
// written by a SQL default. SQLite compares those as STRINGS, and the two
|
|
3452
|
+
// formats differ at the separator: ' ' is 0x20 and 'T' is 0x54. So on the
|
|
3453
|
+
// boundary DAY a row still inside the window sorted BEFORE the cutoff and was
|
|
3454
|
+
// deleted, up to about a day early. The table holds a consumer's encrypted
|
|
3455
|
+
// address under a window we have written down, so "saved by the date prefix
|
|
3456
|
+
// usually differing" is not good enough.
|
|
3457
|
+
//
|
|
3458
|
+
// \`datetime('now', ?)\` keeps both sides in one format and one clock. The
|
|
3459
|
+
// modifier is a bound parameter rather than interpolated, so \`days\` cannot
|
|
3460
|
+
// reach the SQL text.
|
|
3461
|
+
const cutoffExpr = \`-\${Math.max(0, Math.floor(days))} days\`;
|
|
3328
3462
|
const doomed = db.prepare(
|
|
3329
|
-
|
|
3330
|
-
).all(
|
|
3463
|
+
"SELECT id, replayed_at FROM quarantine WHERE received_at < datetime('now', ?)",
|
|
3464
|
+
).all(cutoffExpr) as unknown as { id: string; replayed_at: string | null }[];
|
|
3331
3465
|
if (doomed.length === 0) return { replayed: 0, unreplayed: 0 };
|
|
3332
3466
|
|
|
3333
|
-
db.prepare(
|
|
3467
|
+
db.prepare("DELETE FROM quarantine WHERE received_at < datetime('now', ?)").run(cutoffExpr);
|
|
3334
3468
|
|
|
3335
3469
|
const unreplayed = doomed.filter((d) => d.replayed_at === null).length;
|
|
3336
3470
|
const replayed = doomed.length - unreplayed;
|
|
@@ -3956,6 +4090,30 @@ export interface StoredCustomer {
|
|
|
3956
4090
|
address: string;
|
|
3957
4091
|
}
|
|
3958
4092
|
|
|
4093
|
+
/**
|
|
4094
|
+
* Did this dispatch actually change anything?
|
|
4095
|
+
*
|
|
4096
|
+
* THE ONLY QUESTION THE RECEIVER MAY ASK ABOUT AN ADDRESS, and the reason it
|
|
4097
|
+
* exists here rather than inside a store implementation is that the answer is
|
|
4098
|
+
* a single boolean the dashboard can show, where the two addresses are a
|
|
4099
|
+
* customer's home and must not travel any further than the apply.
|
|
4100
|
+
*
|
|
4101
|
+
* Order- and case-insensitive over the WHOLE object, so it keeps working
|
|
4102
|
+
* whatever fields OneAddress adds. \`saveAddress\` returns the address it
|
|
4103
|
+
* replaced; feed that and the incoming one in here, show the boolean, and let
|
|
4104
|
+
* both go.
|
|
4105
|
+
*/
|
|
4106
|
+
export function sameAddress(a: Address, b: Address): boolean {
|
|
4107
|
+
const canonical = (x: Address): string =>
|
|
4108
|
+
JSON.stringify(
|
|
4109
|
+
Object.entries(x)
|
|
4110
|
+
.filter(([, v]) => typeof v === 'string' && (v as string).trim() !== '')
|
|
4111
|
+
.map(([k, v]) => [k.toLowerCase(), (v as string).trim().toLowerCase().replace(/\\s+/g, ' ')] as [string, string])
|
|
4112
|
+
.sort((p, q) => p[0].localeCompare(q[0])),
|
|
4113
|
+
);
|
|
4114
|
+
return canonical(a) === canonical(b);
|
|
4115
|
+
}
|
|
4116
|
+
|
|
3959
4117
|
export type AccountVerdict = 'match' | 'no_match' | 'no_account';
|
|
3960
4118
|
export type VerifyResult = 'match' | 'mismatch' | 'not_found';
|
|
3961
4119
|
|
|
@@ -4096,6 +4254,7 @@ export interface CustomerStore {
|
|
|
4096
4254
|
import { readFileSync } from 'node:fs';
|
|
4097
4255
|
import { join } from 'node:path';
|
|
4098
4256
|
import { report } from './report.js';
|
|
4257
|
+
import { config } from './config.js';
|
|
4099
4258
|
import db, { accountKey, dec, enc, encrypted, ensureColumn, isEncrypted, once } from './db.js';
|
|
4100
4259
|
import type {
|
|
4101
4260
|
AccountVerdict,
|
|
@@ -4275,8 +4434,30 @@ function loadRoster(): RosterEntry[] {
|
|
|
4275
4434
|
}
|
|
4276
4435
|
}
|
|
4277
4436
|
|
|
4278
|
-
|
|
4279
|
-
|
|
4437
|
+
/**
|
|
4438
|
+
* IN INBOX MODE NOTHING IS SEEDED, and that is a privacy fix rather than a
|
|
4439
|
+
* saving.
|
|
4440
|
+
*
|
|
4441
|
+
* This block runs on IMPORT, and \`server.ts\` imports this module in both modes
|
|
4442
|
+
* because it picks the store at runtime. So an inbox receiver - the process that
|
|
4443
|
+
* exists specifically so it cannot read what it holds - was writing a full
|
|
4444
|
+
* customer roster to its own database on startup: names, account numbers and
|
|
4445
|
+
* the address on file for each, in the clear.
|
|
4446
|
+
*
|
|
4447
|
+
* The dispatches were never the leak. Those are stored as the ciphertext they
|
|
4448
|
+
* arrived as, and an end-to-end run confirms the new address appears nowhere in
|
|
4449
|
+
* the receiver's database file. The ROSTER was, and it was there before a
|
|
4450
|
+
* single dispatch arrived. Found by searching the receiver's database bytes for
|
|
4451
|
+
* a customer name after that run, which is the only check that would have found
|
|
4452
|
+
* it: every other one asks the receiver what it thinks it is holding.
|
|
4453
|
+
*
|
|
4454
|
+
* In inbox mode the connector owns the roster and answers every account
|
|
4455
|
+
* question over the loopback channel, so the receiver needs none of it.
|
|
4456
|
+
*/
|
|
4457
|
+
const ROSTER = config.mode === 'inbox' ? [] : loadRoster();
|
|
4458
|
+
if (config.mode === 'inbox') {
|
|
4459
|
+
report.info('[store] no roster held: in inbox mode your connector owns customer records');
|
|
4460
|
+
} else {
|
|
4280
4461
|
const upsert = db.prepare(\`
|
|
4281
4462
|
INSERT INTO customers (account_key, account_number, name, address)
|
|
4282
4463
|
VALUES ($account_key, $account_number, $name, $address)
|
|
@@ -4449,7 +4630,12 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
|
|
|
4449
4630
|
// Metadata only \u2014 the address itself is personal information, so the key is
|
|
4450
4631
|
// logged and the address never is. Centralised log aggregation turns every
|
|
4451
4632
|
// log line into a place customer addresses can be read.
|
|
4452
|
-
|
|
4633
|
+
// The ACCOUNT REFERENCE, or a marker - never the name \`acct\` may have fallen
|
|
4634
|
+
// back to. The row is legitimately keyed on the name when you do not verify
|
|
4635
|
+
// account references (see the docstring above); logging it is a different
|
|
4636
|
+
// decision, and the wrong one.
|
|
4637
|
+
const loggable = (customer.accountNumber ?? '').trim() || '(name-keyed record)';
|
|
4638
|
+
report.info(\`[store] saved address for \${loggable}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
|
|
4453
4639
|
|
|
4454
4640
|
return previous;
|
|
4455
4641
|
}
|
|
@@ -4537,10 +4723,11 @@ import {
|
|
|
4537
4723
|
// exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
|
|
4538
4724
|
// the protocol layer changes.
|
|
4539
4725
|
import { store as writeThroughStore } from './store.js';
|
|
4540
|
-
import { connectorStore, setCurrentRawBody } from './connector-store.js';
|
|
4541
|
-
import {
|
|
4726
|
+
import { ConnectorUnreachableError, connectorStore, setCurrentRawBody } from './connector-store.js';
|
|
4727
|
+
import { noteChange } from './tui.js';
|
|
4542
4728
|
import { config } from './config.js';
|
|
4543
4729
|
import { safeOneAddressCallbackUrl } from './callback-url.js';
|
|
4730
|
+
import { sameAddress } from './customer-store.js';
|
|
4544
4731
|
import { configuredKeyIds, describeKeys, keyFailureAdvice, resolvePrivateKey } from './keys.js';
|
|
4545
4732
|
import {
|
|
4546
4733
|
drainConfirms,
|
|
@@ -4674,12 +4861,22 @@ report.info(
|
|
|
4674
4861
|
// lock out every install that has never set a passphrase - but choosing it by
|
|
4675
4862
|
// not being asked is not a choice, so the absence is stated as loudly as the
|
|
4676
4863
|
// presence.
|
|
4864
|
+
//
|
|
4865
|
+
// AND IN INBOX MODE IT SAYS SOMETHING ELSE, for the same reason the keys line
|
|
4866
|
+
// above does. There are no customer records in this mode - the connector holds
|
|
4867
|
+
// them - so "Customer records are readable by anyone who can read the file" was
|
|
4868
|
+
// alarming about data that is not here, and sent the partner to set a passphrase
|
|
4869
|
+
// protecting nothing. \`connectorStore.encrypted\` was already hardcoded false
|
|
4870
|
+
// with a comment saying exactly that; this line had simply never read it.
|
|
4677
4871
|
report.info(
|
|
4678
|
-
|
|
4679
|
-
? \`[startup] store: \${store.name}
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4872
|
+
config.mode === 'inbox'
|
|
4873
|
+
? \`[startup] store: \${store.name} \u2014 no customer records here by design. This database holds \` +
|
|
4874
|
+
'dispatch ciphertext your connector decrypts, and the confirm queue.'
|
|
4875
|
+
: store.encrypted
|
|
4876
|
+
? \`[startup] store: \${store.name} (encrypted at rest)\`
|
|
4877
|
+
: \`[startup] store: \${store.name} \u2014 NOT ENCRYPTED AT REST. \` +
|
|
4878
|
+
'Customer records are readable by anyone who can read the file. ' +
|
|
4879
|
+
'Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` in a terminal to be asked.',
|
|
4683
4880
|
);
|
|
4684
4881
|
|
|
4685
4882
|
// In-memory dedup cache. Records a dispatch id only once it has been fully
|
|
@@ -4719,11 +4916,17 @@ async function confirmToOneAddress(dispatchId: number, status: ConfirmStatus): P
|
|
|
4719
4916
|
// and it drops non-numeric dispatch ids, so by the time a row is drained it
|
|
4720
4917
|
// is a real dispatch. Keeping a second copy of that rule would mean a probe
|
|
4721
4918
|
// could be queued forever and silently skipped on every drain.
|
|
4919
|
+
// REPORTED, not "Applied". The note is fixed text sent with EVERY confirm,
|
|
4920
|
+
// including \`failed\` ones - so a dispatch the connector could not open, or
|
|
4921
|
+
// one refused because the account reference matched nobody, arrived at
|
|
4922
|
+
// OneAddress reading "Applied by the OneAddress webhook receiver". That is
|
|
4923
|
+
// read by whoever is working out what happened, and it said the opposite of
|
|
4924
|
+
// \`status\` sitting beside it.
|
|
4722
4925
|
const bodyStr = JSON.stringify({
|
|
4723
4926
|
dispatch_id: dispatchId,
|
|
4724
4927
|
partner_id: PARTNER_ID,
|
|
4725
4928
|
status,
|
|
4726
|
-
note: '
|
|
4929
|
+
note: 'Reported by the OneAddress webhook receiver',
|
|
4727
4930
|
});
|
|
4728
4931
|
const ts = String(Math.floor(Date.now() / 1000));
|
|
4729
4932
|
const sig = createHmac('sha256', CONFIRM_SECRET).update(\`\${ts}.\${bodyStr}\`).digest('hex');
|
|
@@ -4874,7 +5077,19 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
4874
5077
|
// and the customer records, so it answers. \`connectorStore\` sends the raw
|
|
4875
5078
|
// body; the arguments below are the contract's shape, not its source.
|
|
4876
5079
|
if (config.mode === 'inbox') {
|
|
4877
|
-
|
|
5080
|
+
let status: Awaited<ReturnType<typeof store.verifyAccount>>;
|
|
5081
|
+
try {
|
|
5082
|
+
status = await store.verifyAccount(null, '', []);
|
|
5083
|
+
} catch (err) {
|
|
5084
|
+
// 503 RATHER THAN A VERDICT. Every verdict is a claim about the
|
|
5085
|
+
// customer's record; "the connector is down" is not one of them. A
|
|
5086
|
+
// non-2xx is what OneAddress reads as \`unreachable\`, which the consumer
|
|
5087
|
+
// sees as a transient network problem instead of "no account found".
|
|
5088
|
+
if (err instanceof ConnectorUnreachableError) {
|
|
5089
|
+
return res.status(503).json({ ok: false, error: 'connector_unreachable' });
|
|
5090
|
+
}
|
|
5091
|
+
throw err;
|
|
5092
|
+
}
|
|
4878
5093
|
report.info(\`[webhook] account.verify \u2192 \${status} (answered by the connector)\`);
|
|
4879
5094
|
return res.status(200).json({ status });
|
|
4880
5095
|
}
|
|
@@ -5003,7 +5218,17 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
5003
5218
|
if (config.mode === 'inbox' && event === 'address.verify') {
|
|
5004
5219
|
// The contract's arguments, not its source: \`connectorStore\` sends the raw
|
|
5005
5220
|
// body it was parked with and ignores these.
|
|
5006
|
-
|
|
5221
|
+
let result: Awaited<ReturnType<typeof store.verifyAddress>>;
|
|
5222
|
+
try {
|
|
5223
|
+
result = await store.verifyAddress({ email: null, name: '' }, {});
|
|
5224
|
+
} catch (err) {
|
|
5225
|
+
// As above, and the callback is deliberately NOT posted: a verdict posted
|
|
5226
|
+
// here would be a claim about a record nobody read.
|
|
5227
|
+
if (err instanceof ConnectorUnreachableError) {
|
|
5228
|
+
return res.status(503).json({ ok: false, error: 'connector_unreachable' });
|
|
5229
|
+
}
|
|
5230
|
+
throw err;
|
|
5231
|
+
}
|
|
5007
5232
|
report.info(\`[webhook] address.verify \u2192 \${result} (answered by the connector)\`);
|
|
5008
5233
|
const refused = await postVerifyVerdict(result);
|
|
5009
5234
|
if (refused) return refused;
|
|
@@ -5157,7 +5382,12 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
5157
5382
|
|
|
5158
5383
|
// \u2500\u2500 address.updated: consumer changed their address \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
5159
5384
|
if (event === 'address.updated') {
|
|
5160
|
-
|
|
5385
|
+
// THE ACCOUNT, NEVER THE NAME. This used to fall back to \`ctx.name\`, so a
|
|
5386
|
+
// partner who does not verify account references wrote a real consumer's
|
|
5387
|
+
// name into every log line, and from there into whatever aggregates their
|
|
5388
|
+
// logs. The account reference is the partner's own identifier for their own
|
|
5389
|
+
// record; the name is the consumer's.
|
|
5390
|
+
report.info(\`[webhook] address.updated for \${ctx.accountNumber || '(no account reference)'}\`);
|
|
5161
5391
|
|
|
5162
5392
|
// AN ACCOUNT REFERENCE THAT MATCHES NOTHING MUST BE A HARD NO-MATCH.
|
|
5163
5393
|
//
|
|
@@ -5196,12 +5426,32 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
5196
5426
|
}
|
|
5197
5427
|
}
|
|
5198
5428
|
|
|
5199
|
-
//
|
|
5200
|
-
//
|
|
5201
|
-
//
|
|
5202
|
-
//
|
|
5429
|
+
// THE DASHBOARD IS HANDED FACTS, NOT THE ADDRESS, and that is structural
|
|
5430
|
+
// rather than a formatting choice.
|
|
5431
|
+
//
|
|
5432
|
+
// It used to receive the address this dispatch REPLACED and look the new
|
|
5433
|
+
// one up from the store, and it painted both on a panel. Everything around
|
|
5434
|
+
// here is careful that an address never reaches a log line - two comments
|
|
5435
|
+
// one screen apart say so - and then it was drawn in large text on a screen
|
|
5436
|
+
// that gets screenshotted, screen-shared and left open in an office.
|
|
5437
|
+
//
|
|
5438
|
+
// The receiver does not need to show an address to prove it works. It needs
|
|
5439
|
+
// to show that the envelope opened, that the account was one of yours, and
|
|
5440
|
+
// whether anything actually changed. Those are the facts below, and nothing
|
|
5441
|
+
// that reaches \`tui.ts\` can be turned back into a consumer's home.
|
|
5442
|
+
//
|
|
5443
|
+
// There is nothing here to sneak past, either, which is why this is better
|
|
5444
|
+
// than marking test dispatches as safe to display: a marker is something an
|
|
5445
|
+
// attacker can try to forge onto a real dispatch, and a panel that never
|
|
5446
|
+
// renders an address has nothing to forge it into.
|
|
5203
5447
|
const replaced = await store.saveAddress(ctx, address);
|
|
5204
|
-
|
|
5448
|
+
noteChange({
|
|
5449
|
+
accountNumber: ctx.accountNumber || '(no account reference)',
|
|
5450
|
+
accountChecked: config.verifiesAccountReference,
|
|
5451
|
+
changed: !sameAddress(replaced, address),
|
|
5452
|
+
loaRef,
|
|
5453
|
+
dispatchId: dispatch || null,
|
|
5454
|
+
});
|
|
5205
5455
|
if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
|
|
5206
5456
|
// Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
5207
5457
|
// QUEUED, not sent: this is a local INSERT, so it cannot delay the 200 that
|
|
@@ -5214,7 +5464,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
5214
5464
|
|
|
5215
5465
|
// \u2500\u2500 address.verify: consumer is running an address check \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
5216
5466
|
if (event === 'address.verify') {
|
|
5217
|
-
report.info(\`[webhook] address.verify for \${ctx.accountNumber ||
|
|
5467
|
+
report.info(\`[webhook] address.verify for \${ctx.accountNumber || '(no account reference)'}\`);
|
|
5218
5468
|
const result = await store.verifyAddress(ctx, address);
|
|
5219
5469
|
report.info(\`[webhook] address.verify \u2192 \${result}\`);
|
|
5220
5470
|
const refused = await postVerifyVerdict(result);
|
|
@@ -5442,6 +5692,7 @@ export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
|
|
|
5442
5692
|
import 'dotenv/config';
|
|
5443
5693
|
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
|
5444
5694
|
import { spawnSync } from 'node:child_process';
|
|
5695
|
+
import { readFileSync } from 'node:fs';
|
|
5445
5696
|
|
|
5446
5697
|
const secret = process.env.WEBHOOK_SECRET ?? '';
|
|
5447
5698
|
const partnerId = process.env.PARTNER_ID ?? '%%PARTNER_ID%%';
|
|
@@ -5449,21 +5700,73 @@ const keyPem = (process.env.PARTNER_PRIVATE_KEY_PEM ?? '').replace(/\\\\n/g,
|
|
|
5449
5700
|
const port = process.env.PORT ?? '3001';
|
|
5450
5701
|
const targetUrl = process.argv[2] ?? \`http://localhost:\${port}/webhook\`;
|
|
5451
5702
|
|
|
5452
|
-
|
|
5453
|
-
|
|
5703
|
+
/**
|
|
5704
|
+
* Which shape of receiver is this?
|
|
5705
|
+
*
|
|
5706
|
+
* Read from the same config file the server reads, so the two cannot disagree.
|
|
5707
|
+
* An inbox receiver holds NO private key - that is the property it exists for -
|
|
5708
|
+
* so this runner used to die on its own env gate with "Missing required env
|
|
5709
|
+
* vars. Check your .env file", telling the partner to fix a file that was
|
|
5710
|
+
* exactly as the wizard wrote it. It was never run in that mode.
|
|
5711
|
+
*/
|
|
5712
|
+
let receiverMode = 'write-through';
|
|
5713
|
+
try {
|
|
5714
|
+
const cfg = JSON.parse(readFileSync(new URL('../oneaddress.config.json', import.meta.url), 'utf8'));
|
|
5715
|
+
if (cfg && typeof cfg.mode === 'string') receiverMode = cfg.mode;
|
|
5716
|
+
} catch {
|
|
5717
|
+
// No config, or unreadable: treat it as the default, which is what every
|
|
5718
|
+
// scaffold before the mode existed was.
|
|
5719
|
+
}
|
|
5720
|
+
const isInbox = receiverMode === 'inbox';
|
|
5721
|
+
|
|
5722
|
+
if (!secret || !partnerId) {
|
|
5723
|
+
console.error('[test] Missing WEBHOOK_SECRET or PARTNER_ID. Check your .env file.');
|
|
5454
5724
|
process.exit(1);
|
|
5455
5725
|
}
|
|
5456
5726
|
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5727
|
+
/**
|
|
5728
|
+
* The public key the conformance suite encrypts to.
|
|
5729
|
+
*
|
|
5730
|
+
* Write-through derives it from the private key it already holds. Inbox mode
|
|
5731
|
+
* has no private key to derive from, so it takes the PUBLIC half directly:
|
|
5732
|
+
* a public key is not a secret, and keeping one here does not give this process
|
|
5733
|
+
* the ability to read anything.
|
|
5734
|
+
*
|
|
5735
|
+
* Copy it from partners.oneaddress.io -> My Profile.
|
|
5736
|
+
*/
|
|
5737
|
+
const publicKeyFromEnv = (process.env.PARTNER_PUBLIC_KEY_B64 ?? '').trim();
|
|
5738
|
+
|
|
5739
|
+
if (isInbox && !publicKeyFromEnv) {
|
|
5740
|
+
console.log('[test] Inbox mode: this receiver holds no private key, so the encrypted checks');
|
|
5741
|
+
console.log('[test] cannot be built here. Two ways to run them:');
|
|
5742
|
+
console.log('[test]');
|
|
5743
|
+
console.log('[test] 1. Set PARTNER_PUBLIC_KEY_B64 in .env (copy it from My Profile in the');
|
|
5744
|
+
console.log('[test] portal). It is a public value and safe to keep on this machine.');
|
|
5745
|
+
console.log('[test] 2. Or run conformance from your connector host, which has the key.');
|
|
5746
|
+
console.log('[test]');
|
|
5747
|
+
console.log('[test] Signing and replay are covered by the wizard\\'s own checks either way.');
|
|
5748
|
+
process.exit(0);
|
|
5749
|
+
}
|
|
5750
|
+
|
|
5751
|
+
if (!isInbox && !keyPem) {
|
|
5752
|
+
console.error('[test] Missing PARTNER_PRIVATE_KEY_PEM. Check your .env file.');
|
|
5464
5753
|
process.exit(1);
|
|
5465
5754
|
}
|
|
5466
5755
|
|
|
5756
|
+
let publicKeyB64: string;
|
|
5757
|
+
if (publicKeyFromEnv) {
|
|
5758
|
+
publicKeyB64 = publicKeyFromEnv;
|
|
5759
|
+
} else {
|
|
5760
|
+
try {
|
|
5761
|
+
const priv = createPrivateKey({ key: keyPem, format: 'pem' });
|
|
5762
|
+
const pub = createPublicKey(priv);
|
|
5763
|
+
publicKeyB64 = pub.export({ type: 'spki', format: 'der' }).toString('base64');
|
|
5764
|
+
} catch (err) {
|
|
5765
|
+
console.error('[test] Failed to derive public key from PARTNER_PRIVATE_KEY_PEM:', err);
|
|
5766
|
+
process.exit(1);
|
|
5767
|
+
}
|
|
5768
|
+
}
|
|
5769
|
+
|
|
5467
5770
|
console.log(\`[test] Running conformance against \${targetUrl}\\n\`);
|
|
5468
5771
|
|
|
5469
5772
|
/**
|
|
@@ -5484,9 +5787,14 @@ console.log(\`[test] Running conformance against \${targetUrl}\\n\`);
|
|
|
5484
5787
|
* the check would be asking a question your code is not the one answering.
|
|
5485
5788
|
*/
|
|
5486
5789
|
const verifiesAccountReference =
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5790
|
+
// NEVER in inbox mode. The receiver cannot read an account reference there,
|
|
5791
|
+
// so check-14 asks it a question only the connector can answer, and a
|
|
5792
|
+
// correctly built system fails a check it was never the subject of.
|
|
5793
|
+
!isInbox && (
|
|
5794
|
+
process.env.OA_VERIFIES_ACCOUNT_REFERENCE === '1' ||
|
|
5795
|
+
process.env.OA_VERIFIES_ACCOUNT_REFERENCE === 'true' ||
|
|
5796
|
+
%%VERIFIES_ACCOUNT_REFERENCE%%
|
|
5797
|
+
);
|
|
5490
5798
|
|
|
5491
5799
|
const result = spawnSync(
|
|
5492
5800
|
'npx',
|
|
@@ -6198,7 +6506,7 @@ async def _confirm_to_oneaddress(dispatch: str, status: str) -> None:
|
|
|
6198
6506
|
"dispatch_id": dispatch_id,
|
|
6199
6507
|
"partner_id": PARTNER_ID,
|
|
6200
6508
|
"status": status,
|
|
6201
|
-
"note": "
|
|
6509
|
+
"note": "Reported by the OneAddress webhook receiver",
|
|
6202
6510
|
})
|
|
6203
6511
|
ts = str(int(time.time()))
|
|
6204
6512
|
sig = hmac_lib.new(CONFIRM_SECRET.encode(), f"{ts}.{body_str}".encode(), hashlib.sha256).hexdigest()
|
|
@@ -7213,7 +7521,7 @@ public class OneAddressWebhookController {
|
|
|
7213
7521
|
confirmBody.put("dispatch_id", dispatchId);
|
|
7214
7522
|
confirmBody.put("partner_id", partnerId);
|
|
7215
7523
|
confirmBody.put("status", status);
|
|
7216
|
-
confirmBody.put("note", "
|
|
7524
|
+
confirmBody.put("note", "Reported by the OneAddress webhook receiver");
|
|
7217
7525
|
String bodyStr = MAPPER.writeValueAsString(confirmBody);
|
|
7218
7526
|
String ts = String.valueOf(System.currentTimeMillis() / 1000L);
|
|
7219
7527
|
String sig = OneAddressVerifier.sign(ts + "." + bodyStr, confirmSecret);
|
|
@@ -8259,7 +8567,7 @@ async Task ConfirmToOneAddress(string oneAddressApi, string confirmSecret, strin
|
|
|
8259
8567
|
dispatch_id = dispatchId,
|
|
8260
8568
|
partner_id = pid,
|
|
8261
8569
|
status,
|
|
8262
|
-
note = "
|
|
8570
|
+
note = "Reported by the OneAddress webhook receiver",
|
|
8263
8571
|
});
|
|
8264
8572
|
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
|
8265
8573
|
var sig = Convert.ToHexString(
|
|
@@ -9361,7 +9669,7 @@ func confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, stat
|
|
|
9361
9669
|
"dispatch_id": dispatchID,
|
|
9362
9670
|
"partner_id": partnerID,
|
|
9363
9671
|
"status": status,
|
|
9364
|
-
"note": "
|
|
9672
|
+
"note": "Reported by the OneAddress webhook receiver",
|
|
9365
9673
|
})
|
|
9366
9674
|
bodyStr := string(bodyBytes)
|
|
9367
9675
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
|
@@ -10145,7 +10453,7 @@ function oaConfirmToOneAddress(string $oneAddressApi, string $confirmSecret, str
|
|
|
10145
10453
|
'dispatch_id' => (int) $dispatch,
|
|
10146
10454
|
'partner_id' => $partnerId,
|
|
10147
10455
|
'status' => $status,
|
|
10148
|
-
'note' => '
|
|
10456
|
+
'note' => 'Reported by the OneAddress webhook receiver',
|
|
10149
10457
|
]);
|
|
10150
10458
|
$ts = (string) time();
|
|
10151
10459
|
$sig = hash_hmac('sha256', $ts . '.' . $bodyStr, $confirmSecret);
|
|
@@ -10623,11 +10931,56 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
|
|
|
10623
10931
|
};
|
|
10624
10932
|
|
|
10625
10933
|
// src/scaffold.ts
|
|
10626
|
-
function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi) {
|
|
10627
|
-
|
|
10934
|
+
function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken, port) {
|
|
10935
|
+
const filled = content.replaceAll("%%PARTNER_ID%%", partnerId).replaceAll("%%WEBHOOK_SECRET%%", webhookSecret).replaceAll("%%WEBHOOK_URL%%", webhookUrl || "<your-webhook-url>").replaceAll("%%PRIVATE_KEY%%", privateKey || "<paste your PKCS8 PEM private key here>").replaceAll("%%ONEADDRESS_API%%", oneAddressApi || "https://oneaddress.io").replaceAll("%%VERIFIES_ACCOUNT_REFERENCE%%", verifiesAccountReference ? "true" : "false").replaceAll("%%RECEIVER_MODE%%", mode === "inbox" ? "inbox" : "write-through");
|
|
10936
|
+
const withPort = applyPort(filled, port);
|
|
10937
|
+
if (mode !== "inbox") return withPort;
|
|
10938
|
+
const withReadme = withPort.replace(
|
|
10939
|
+
"## Architecture",
|
|
10940
|
+
`## Inbox mode
|
|
10941
|
+
|
|
10942
|
+
This receiver holds dispatches as ciphertext and keeps **no private key** - it refuses to
|
|
10943
|
+
start with one - so it cannot read what it stores. A **connector** you run separately draws
|
|
10944
|
+
from it, decrypts, applies the change to your systems and acknowledges; only then does
|
|
10945
|
+
OneAddress confirm the update to the consumer.
|
|
10946
|
+
|
|
10947
|
+
Until that connector runs, updates are held and the dashboard counts them as awaiting your
|
|
10948
|
+
systems. Nothing is lost and nothing is applied.
|
|
10949
|
+
|
|
10950
|
+
| What | Where |
|
|
10951
|
+
|------|-------|
|
|
10952
|
+
| The decryption key | Your connector's environment, never this one |
|
|
10953
|
+
| \`CONNECTOR_TOKEN\` | \`.env\` here, and the same value in your connector |
|
|
10954
|
+
| The draw channel | \`http://127.0.0.1:3002\`, loopback only |
|
|
10955
|
+
|
|
10956
|
+
\`CONNECTOR_TOKEN\` is its own credential: **not** your webhook secret and **not** your
|
|
10957
|
+
confirm secret. Those two are shared with OneAddress, and a leak of either must not also
|
|
10958
|
+
hand somebody your customers' addresses.
|
|
10959
|
+
|
|
10960
|
+
## Architecture`
|
|
10961
|
+
);
|
|
10962
|
+
return withReadme.replace(
|
|
10963
|
+
/^PARTNER_PRIVATE_KEY_PEM=.*$/m,
|
|
10964
|
+
`# No PARTNER_PRIVATE_KEY_PEM here, on purpose: this receiver runs in inbox
|
|
10965
|
+
# mode and refuses to start with one. The key belongs in your connector.
|
|
10966
|
+
|
|
10967
|
+
# The credential your connector presents to draw from this inbox. It must
|
|
10968
|
+
# match CONNECTOR_TOKEN in the connector's own environment, and it must not
|
|
10969
|
+
# be your webhook secret or your confirm secret.
|
|
10970
|
+
CONNECTOR_TOKEN=${connectorToken || generateConnectorToken()}`
|
|
10971
|
+
);
|
|
10972
|
+
}
|
|
10973
|
+
function applyPort(content, port) {
|
|
10974
|
+
if (!Number.isInteger(port) || port === DEFAULT_PORT) return content;
|
|
10975
|
+
return content.replace(/^PORT=\d+$/m, `PORT=${port}`);
|
|
10976
|
+
}
|
|
10977
|
+
var DEFAULT_PORT = 3001;
|
|
10978
|
+
var RESERVED_PORTS = [3002, 3003];
|
|
10979
|
+
function generateConnectorToken() {
|
|
10980
|
+
return (0, import_node_crypto.randomBytes)(24).toString("hex");
|
|
10628
10981
|
}
|
|
10629
10982
|
var SENSITIVE_FILES = /* @__PURE__ */ new Set([".env"]);
|
|
10630
|
-
async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io") {
|
|
10983
|
+
async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io", mode = "write-through", connectorToken = "", port = DEFAULT_PORT) {
|
|
10631
10984
|
const templates = TEMPLATES[platform];
|
|
10632
10985
|
if (!templates) throw new Error(`Unknown platform: ${platform}`);
|
|
10633
10986
|
const written = [];
|
|
@@ -10637,7 +10990,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
10637
10990
|
if (!(0, import_node_fs.existsSync)(dir)) {
|
|
10638
10991
|
await (0, import_promises.mkdir)(dir, { recursive: true });
|
|
10639
10992
|
}
|
|
10640
|
-
const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi);
|
|
10993
|
+
const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken, port);
|
|
10641
10994
|
const isSensitive = SENSITIVE_FILES.has((0, import_node_path.basename)(name));
|
|
10642
10995
|
await (0, import_promises.writeFile)(dest, filled, { encoding: "utf8", mode: isSensitive ? 384 : 420 });
|
|
10643
10996
|
if (isSensitive && process.platform !== "win32") {
|
|
@@ -10652,11 +11005,11 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
10652
11005
|
}
|
|
10653
11006
|
|
|
10654
11007
|
// src/register.ts
|
|
10655
|
-
var
|
|
10656
|
-
var PKG_VERSION = true ? "2.
|
|
11008
|
+
var import_node_crypto2 = require("crypto");
|
|
11009
|
+
var PKG_VERSION = true ? "2.4.0" : "dev";
|
|
10657
11010
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
10658
11011
|
function hmacSha256(secret, message) {
|
|
10659
|
-
return (0,
|
|
11012
|
+
return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
|
|
10660
11013
|
}
|
|
10661
11014
|
async function registerInstall(installId, partnerId, webhookSecret, platform, webhookUrl) {
|
|
10662
11015
|
try {
|
|
@@ -10689,10 +11042,10 @@ async function registerInstall(installId, partnerId, webhookSecret, platform, we
|
|
|
10689
11042
|
}
|
|
10690
11043
|
|
|
10691
11044
|
// src/conformance.ts
|
|
10692
|
-
var
|
|
11045
|
+
var import_node_crypto3 = require("crypto");
|
|
10693
11046
|
async function runConformance(webhookUrl, _partnerId, webhookSecret) {
|
|
10694
11047
|
function sign(ts, body) {
|
|
10695
|
-
return (0,
|
|
11048
|
+
return (0, import_node_crypto3.createHmac)("sha256", webhookSecret).update(`${ts}.${body}`).digest("hex");
|
|
10696
11049
|
}
|
|
10697
11050
|
const validBody = JSON.stringify({ event: "conformance.ping", test: true });
|
|
10698
11051
|
const now = String(Math.floor(Date.now() / 1e3));
|
|
@@ -10733,7 +11086,7 @@ async function runConformance(webhookUrl, _partnerId, webhookSecret) {
|
|
|
10733
11086
|
// Unique per test run so a partner re-running `npm test` doesn't
|
|
10734
11087
|
// hit the scaffolded server's dedup cache and short-circuit the
|
|
10735
11088
|
// "accepted" assertion with { ok: true, duplicate: true }.
|
|
10736
|
-
"X-OneAddress-Dispatch": `conformance-${(0,
|
|
11089
|
+
"X-OneAddress-Dispatch": `conformance-${(0, import_node_crypto3.randomUUID)()}`
|
|
10737
11090
|
},
|
|
10738
11091
|
body: validBody,
|
|
10739
11092
|
signal: AbortSignal.timeout(8e3)
|
|
@@ -10785,7 +11138,7 @@ async function runDecryptCheck(webhookSecret, partnerId) {
|
|
|
10785
11138
|
async function callOnce() {
|
|
10786
11139
|
try {
|
|
10787
11140
|
const ts = String(Math.floor(Date.now() / 1e3));
|
|
10788
|
-
const sig = (0,
|
|
11141
|
+
const sig = (0, import_node_crypto3.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
|
|
10789
11142
|
const res2 = await fetch(VERIFY_DECRYPT_API, {
|
|
10790
11143
|
method: "POST",
|
|
10791
11144
|
headers: {
|
|
@@ -10860,37 +11213,61 @@ var COMMANDS = {
|
|
|
10860
11213
|
function installDependencies(platform, outputDir) {
|
|
10861
11214
|
const spec = COMMANDS[platform];
|
|
10862
11215
|
if (!spec) {
|
|
10863
|
-
return { ok: true, output: "", manualCommand: "" };
|
|
11216
|
+
return Promise.resolve({ ok: true, output: "", manualCommand: "" });
|
|
10864
11217
|
}
|
|
10865
11218
|
const cwd = spec.cwd ? (0, import_node_path2.join)(outputDir, spec.cwd) : outputDir;
|
|
10866
11219
|
const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
|
|
10867
|
-
|
|
10868
|
-
|
|
10869
|
-
|
|
10870
|
-
|
|
10871
|
-
|
|
10872
|
-
|
|
10873
|
-
|
|
11220
|
+
return new Promise((resolve2) => {
|
|
11221
|
+
const child = (0, import_node_child_process.spawn)(spec.cmd, spec.args, {
|
|
11222
|
+
cwd,
|
|
11223
|
+
stdio: "pipe",
|
|
11224
|
+
shell: process.platform === "win32"
|
|
11225
|
+
});
|
|
11226
|
+
let output = "";
|
|
11227
|
+
const collect = (chunk) => {
|
|
11228
|
+
if (output.length < 64e3) output += chunk.toString("utf8");
|
|
11229
|
+
};
|
|
11230
|
+
child.stdout?.on("data", collect);
|
|
11231
|
+
child.stderr?.on("data", collect);
|
|
11232
|
+
let settled = false;
|
|
11233
|
+
const finish = (result) => {
|
|
11234
|
+
if (settled) return;
|
|
11235
|
+
settled = true;
|
|
11236
|
+
clearTimeout(timer);
|
|
11237
|
+
resolve2(result);
|
|
11238
|
+
};
|
|
11239
|
+
const timer = setTimeout(() => {
|
|
11240
|
+
child.kill("SIGKILL");
|
|
11241
|
+
finish({
|
|
11242
|
+
ok: false,
|
|
11243
|
+
output: `${output.trim()}
|
|
11244
|
+
|
|
11245
|
+
Timed out after 5 minutes.`.trim(),
|
|
11246
|
+
manualCommand
|
|
11247
|
+
});
|
|
11248
|
+
}, 5 * 60 * 1e3);
|
|
11249
|
+
child.on("error", (err) => {
|
|
11250
|
+
finish({ ok: false, output: `${output}${err.message}`.trim(), manualCommand });
|
|
11251
|
+
});
|
|
11252
|
+
child.on("close", (code) => {
|
|
11253
|
+
finish({ ok: code === 0, output: output.trim(), manualCommand });
|
|
11254
|
+
});
|
|
10874
11255
|
});
|
|
10875
|
-
const output = [result.stdout ?? "", result.stderr ?? ""].join("").trim();
|
|
10876
|
-
return {
|
|
10877
|
-
ok: result.status === 0 && !result.error,
|
|
10878
|
-
output,
|
|
10879
|
-
manualCommand
|
|
10880
|
-
};
|
|
10881
11256
|
}
|
|
10882
11257
|
|
|
10883
11258
|
// src/autostart.ts
|
|
10884
11259
|
var import_node_child_process2 = require("child_process");
|
|
10885
|
-
|
|
10886
|
-
|
|
10887
|
-
|
|
10888
|
-
|
|
10889
|
-
|
|
10890
|
-
|
|
10891
|
-
|
|
10892
|
-
|
|
10893
|
-
|
|
11260
|
+
function commandsFor(port) {
|
|
11261
|
+
return {
|
|
11262
|
+
"ts-node": { cmd: "npx", args: ["tsx", "src/server.ts"] },
|
|
11263
|
+
"python": { cmd: "uvicorn", args: ["app:app", "--port", String(port)] },
|
|
11264
|
+
"go-http": { cmd: "go", args: ["run", "."] },
|
|
11265
|
+
"php-laravel": { cmd: "php", args: ["artisan", "serve", `--port=${port}`] },
|
|
11266
|
+
"csharp-aspnet": { cmd: "dotnet", args: ["run"] },
|
|
11267
|
+
"java-spring": { cmd: "mvn", args: ["spring-boot:run"] }
|
|
11268
|
+
// no mvnw wrapper is scaffolded
|
|
11269
|
+
};
|
|
11270
|
+
}
|
|
10894
11271
|
var DASHBOARD_COMMANDS = {
|
|
10895
11272
|
"ts-node": { cmd: "npx", args: ["tsx", "src/index.ts"] }
|
|
10896
11273
|
};
|
|
@@ -10992,13 +11369,13 @@ async function pollHealth(port, timeoutMs) {
|
|
|
10992
11369
|
return false;
|
|
10993
11370
|
}
|
|
10994
11371
|
async function startServer(platform, outputDir, port = 3001, secrets = {}) {
|
|
10995
|
-
const spec =
|
|
11372
|
+
const spec = commandsFor(port)[platform];
|
|
10996
11373
|
if (!spec) {
|
|
10997
11374
|
return { ok: false, output: `No start command defined for platform: ${platform}`, manualCommand: "" };
|
|
10998
11375
|
}
|
|
10999
11376
|
serverOutput = "";
|
|
11000
11377
|
const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
|
|
11001
|
-
serverProcess = spawnReceiver(spec, outputDir, ["ignore", "pipe", "pipe"], secrets);
|
|
11378
|
+
serverProcess = spawnReceiver(spec, outputDir, ["ignore", "pipe", "pipe"], { ...secrets, PORT: String(port) });
|
|
11002
11379
|
serverProcess.stdout?.on("data", (d3) => {
|
|
11003
11380
|
serverOutput += d3.toString();
|
|
11004
11381
|
});
|
|
@@ -11045,7 +11422,7 @@ async function handOverTerminal(platform, outputDir, port, secrets = {}) {
|
|
|
11045
11422
|
}
|
|
11046
11423
|
}
|
|
11047
11424
|
process.stdin.pause();
|
|
11048
|
-
const child = spawnReceiver(spec, outputDir, "inherit", secrets);
|
|
11425
|
+
const child = spawnReceiver(spec, outputDir, "inherit", { ...secrets, PORT: String(port) });
|
|
11049
11426
|
serverProcess = child;
|
|
11050
11427
|
return new Promise((resolve2) => {
|
|
11051
11428
|
child.once("error", (err) => {
|
|
@@ -11062,7 +11439,7 @@ async function handOverTerminal(platform, outputDir, port, secrets = {}) {
|
|
|
11062
11439
|
var import_node_child_process3 = require("child_process");
|
|
11063
11440
|
var import_promises2 = require("fs/promises");
|
|
11064
11441
|
var import_node_fs2 = require("fs");
|
|
11065
|
-
var
|
|
11442
|
+
var import_node_crypto4 = require("crypto");
|
|
11066
11443
|
var import_node_path3 = require("path");
|
|
11067
11444
|
var import_node_os = __toESM(require("os"));
|
|
11068
11445
|
var tunnelProcess = null;
|
|
@@ -11101,7 +11478,7 @@ function platformKey() {
|
|
|
11101
11478
|
return "linux-x64";
|
|
11102
11479
|
}
|
|
11103
11480
|
async function sha256File(path) {
|
|
11104
|
-
const hash = (0,
|
|
11481
|
+
const hash = (0, import_node_crypto4.createHash)("sha256");
|
|
11105
11482
|
hash.update(await (0, import_promises2.readFile)(path));
|
|
11106
11483
|
return hash.digest("hex");
|
|
11107
11484
|
}
|
|
@@ -11235,7 +11612,7 @@ async function startTunnel(port) {
|
|
|
11235
11612
|
}
|
|
11236
11613
|
|
|
11237
11614
|
// src/register-url.ts
|
|
11238
|
-
var
|
|
11615
|
+
var import_node_crypto5 = require("crypto");
|
|
11239
11616
|
var PORTAL_API = "https://partners.oneaddress.io/api/partner/webhook-url";
|
|
11240
11617
|
function describeReadFailure(status) {
|
|
11241
11618
|
if (status === 401) return "the portal rejected the signature (HTTP 401), which means your Webhook signing secret does not match the one on your profile. Copy it again from partners.oneaddress.io \u2192 Webhook \u2192 Webhook signing secret.";
|
|
@@ -11246,7 +11623,7 @@ function describeReadFailure(status) {
|
|
|
11246
11623
|
async function getExistingWebhookUrl(partnerId, webhookSecret, onError) {
|
|
11247
11624
|
try {
|
|
11248
11625
|
const ts = String(Math.floor(Date.now() / 1e3));
|
|
11249
|
-
const sig = (0,
|
|
11626
|
+
const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
|
|
11250
11627
|
const res = await fetch(PORTAL_API, {
|
|
11251
11628
|
method: "GET",
|
|
11252
11629
|
headers: {
|
|
@@ -11270,7 +11647,7 @@ async function getExistingWebhookUrl(partnerId, webhookSecret, onError) {
|
|
|
11270
11647
|
async function getVerifiesAccountReference(partnerId, webhookSecret, onError) {
|
|
11271
11648
|
try {
|
|
11272
11649
|
const ts = String(Math.floor(Date.now() / 1e3));
|
|
11273
|
-
const sig = (0,
|
|
11650
|
+
const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
|
|
11274
11651
|
const res = await fetch(PORTAL_API, {
|
|
11275
11652
|
method: "GET",
|
|
11276
11653
|
headers: {
|
|
@@ -11297,7 +11674,7 @@ async function getVerifiesAccountReference(partnerId, webhookSecret, onError) {
|
|
|
11297
11674
|
async function verifyWebhookSecret(partnerId, webhookSecret) {
|
|
11298
11675
|
try {
|
|
11299
11676
|
const ts = String(Math.floor(Date.now() / 1e3));
|
|
11300
|
-
const sig = (0,
|
|
11677
|
+
const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
|
|
11301
11678
|
const res = await fetch(PORTAL_API, {
|
|
11302
11679
|
method: "GET",
|
|
11303
11680
|
headers: {
|
|
@@ -11318,7 +11695,7 @@ async function registerWebhookUrl(partnerId, webhookSecret, webhookUrl) {
|
|
|
11318
11695
|
try {
|
|
11319
11696
|
const body = JSON.stringify({ webhook_url: webhookUrl });
|
|
11320
11697
|
const ts = String(Math.floor(Date.now() / 1e3));
|
|
11321
|
-
const sig = (0,
|
|
11698
|
+
const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.${body}`).digest("hex");
|
|
11322
11699
|
const res = await fetch(PORTAL_API, {
|
|
11323
11700
|
method: "PATCH",
|
|
11324
11701
|
headers: {
|
|
@@ -11515,7 +11892,7 @@ ${wrapped}
|
|
|
11515
11892
|
-----END EC PRIVATE KEY-----
|
|
11516
11893
|
`;
|
|
11517
11894
|
try {
|
|
11518
|
-
const pkcs8 = (0,
|
|
11895
|
+
const pkcs8 = (0, import_node_crypto6.createPrivateKey)({ key: sec1Pem, format: "pem", type: "sec1" }).export({ type: "pkcs8", format: "pem" });
|
|
11519
11896
|
return { pem: pkcs8 };
|
|
11520
11897
|
} catch (err) {
|
|
11521
11898
|
return { pem: "", error: translateKeyError(err) };
|
|
@@ -11559,7 +11936,7 @@ function translateKeyError(err) {
|
|
|
11559
11936
|
function validatePrivateKey(pem) {
|
|
11560
11937
|
let key;
|
|
11561
11938
|
try {
|
|
11562
|
-
key = (0,
|
|
11939
|
+
key = (0, import_node_crypto6.createPrivateKey)({ key: pem, format: "pem", type: "pkcs8" });
|
|
11563
11940
|
} catch (err) {
|
|
11564
11941
|
return translateKeyError(err);
|
|
11565
11942
|
}
|
|
@@ -11575,6 +11952,25 @@ function validatePrivateKey(pem) {
|
|
|
11575
11952
|
function stripControlChars(line) {
|
|
11576
11953
|
return line.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
|
|
11577
11954
|
}
|
|
11955
|
+
function validatePortChoice(v2) {
|
|
11956
|
+
const raw = (v2 ?? "").trim();
|
|
11957
|
+
if (!raw) return void 0;
|
|
11958
|
+
if (!/^\d+$/.test(raw)) return `Enter a port number, or leave blank for ${DEFAULT_PORT}`;
|
|
11959
|
+
const n = Number(raw);
|
|
11960
|
+
if (n < 1024 || n > 65535) return "Choose a port between 1024 and 65535";
|
|
11961
|
+
if (RESERVED_PORTS.includes(n)) {
|
|
11962
|
+
return `${n} is used by the connector channel. Choose another port.`;
|
|
11963
|
+
}
|
|
11964
|
+
return void 0;
|
|
11965
|
+
}
|
|
11966
|
+
function childEnvForMode(mode, v2) {
|
|
11967
|
+
return {
|
|
11968
|
+
WEBHOOK_SECRET: v2.secret,
|
|
11969
|
+
PARTNER_ID: v2.partnerId,
|
|
11970
|
+
VERIFIES_ACCOUNT_REFERENCE: String(v2.verifiesAccountReference),
|
|
11971
|
+
...mode === "inbox" ? { CONNECTOR_TOKEN: v2.connectorToken } : { PARTNER_PRIVATE_KEY_PEM: v2.privateKey }
|
|
11972
|
+
};
|
|
11973
|
+
}
|
|
11578
11974
|
async function main() {
|
|
11579
11975
|
printHeader();
|
|
11580
11976
|
Ie("OneAddress Partner Setup");
|
|
@@ -11624,23 +12020,6 @@ async function main() {
|
|
|
11624
12020
|
"The portal rejected that secret (HTTP 401): it does not match the one on your profile.\nRegenerate it at partners.oneaddress.io -> Webhook -> Webhook signing secret\n(use Revoke and regenerate, not Rotate), copy the value shown once, and paste it here."
|
|
11625
12021
|
);
|
|
11626
12022
|
}
|
|
11627
|
-
const privateKeyRaw = await he({
|
|
11628
|
-
message: "Your ECDH Private Key",
|
|
11629
|
-
placeholder: "Paste the base64 key body from My Profile, or enter a path to the .pem file",
|
|
11630
|
-
validate: (v2) => {
|
|
11631
|
-
if (!v2.trim()) return "Private key is required";
|
|
11632
|
-
const { pem, error } = normalisePrivateKey(v2);
|
|
11633
|
-
if (error) return error;
|
|
11634
|
-
return validatePrivateKey(pem);
|
|
11635
|
-
}
|
|
11636
|
-
});
|
|
11637
|
-
assertNotCancelled(privateKeyRaw);
|
|
11638
|
-
const normalised = normalisePrivateKey(privateKeyRaw.trim());
|
|
11639
|
-
if (normalised.error) {
|
|
11640
|
-
xe(`Private key parse failed unexpectedly: ${normalised.error}`);
|
|
11641
|
-
process.exit(1);
|
|
11642
|
-
}
|
|
11643
|
-
const privateKey = normalised.pem;
|
|
11644
12023
|
const platform = await ve({
|
|
11645
12024
|
message: "Which platform?",
|
|
11646
12025
|
options: [
|
|
@@ -11657,6 +12036,79 @@ async function main() {
|
|
|
11657
12036
|
]
|
|
11658
12037
|
});
|
|
11659
12038
|
assertNotCancelled(platform);
|
|
12039
|
+
const modeChoice = platform !== "ts-node" ? "write-through" : await ve({
|
|
12040
|
+
message: "How should this receiver handle updates?",
|
|
12041
|
+
options: [
|
|
12042
|
+
{
|
|
12043
|
+
value: "write-through",
|
|
12044
|
+
label: "Apply them itself (simplest)",
|
|
12045
|
+
hint: "Decrypts and writes to a customer store on this machine. Right for a sole trader, or to try it out."
|
|
12046
|
+
},
|
|
12047
|
+
{
|
|
12048
|
+
value: "inbox",
|
|
12049
|
+
label: "Hold them for your own systems to collect",
|
|
12050
|
+
hint: "Holds ciphertext and keeps NO private key, so this process cannot read what it stores. Needs a connector you run separately."
|
|
12051
|
+
}
|
|
12052
|
+
],
|
|
12053
|
+
initialValue: "write-through"
|
|
12054
|
+
});
|
|
12055
|
+
assertNotCancelled(modeChoice);
|
|
12056
|
+
const receiverMode = modeChoice;
|
|
12057
|
+
const connectorToken = receiverMode === "inbox" ? generateConnectorToken() : "";
|
|
12058
|
+
if (receiverMode === "inbox") {
|
|
12059
|
+
M2.warn(
|
|
12060
|
+
"Inbox mode needs a second process. This receiver will hold dispatches as ciphertext and\nkeep NO private key \u2014 it refuses to start with one \u2014 so a connector of yours must draw,\ndecrypt and apply them, then acknowledge. Until that connector runs, updates are held and\nthe dashboard counts them as awaiting your systems."
|
|
12061
|
+
);
|
|
12062
|
+
M2.info(
|
|
12063
|
+
`Your connector credential (also written to .env as CONNECTOR_TOKEN):
|
|
12064
|
+
|
|
12065
|
+
${connectorToken}
|
|
12066
|
+
|
|
12067
|
+
Set the same value as CONNECTOR_TOKEN in your connector. It is its own credential:
|
|
12068
|
+
not your webhook secret and not your confirm secret, because those are shared with
|
|
12069
|
+
OneAddress and a leak of either must not also hand somebody your customers' addresses.`
|
|
12070
|
+
);
|
|
12071
|
+
}
|
|
12072
|
+
let privateKey = "";
|
|
12073
|
+
if (receiverMode === "inbox") {
|
|
12074
|
+
M2.info(
|
|
12075
|
+
"No private key is collected for an inbox receiver. It belongs in your connector,\nwhich is the only process that decrypts. This one is meant never to hold one, and\nit refuses to start if it finds one in its environment."
|
|
12076
|
+
);
|
|
12077
|
+
} else {
|
|
12078
|
+
const privateKeyRaw = await he({
|
|
12079
|
+
message: "Your ECDH Private Key",
|
|
12080
|
+
placeholder: "Paste the base64 key body from My Profile, or enter a path to the .pem file",
|
|
12081
|
+
validate: (v2) => {
|
|
12082
|
+
if (!v2.trim()) return "Private key is required";
|
|
12083
|
+
const { pem, error } = normalisePrivateKey(v2);
|
|
12084
|
+
if (error) return error;
|
|
12085
|
+
return validatePrivateKey(pem);
|
|
12086
|
+
}
|
|
12087
|
+
});
|
|
12088
|
+
assertNotCancelled(privateKeyRaw);
|
|
12089
|
+
const normalised = normalisePrivateKey(privateKeyRaw.trim());
|
|
12090
|
+
if (normalised.error) {
|
|
12091
|
+
xe(`Private key parse failed unexpectedly: ${normalised.error}`);
|
|
12092
|
+
process.exit(1);
|
|
12093
|
+
}
|
|
12094
|
+
privateKey = normalised.pem;
|
|
12095
|
+
}
|
|
12096
|
+
const portAnswer = await he({
|
|
12097
|
+
message: "Which port should the receiver listen on?",
|
|
12098
|
+
placeholder: String(DEFAULT_PORT),
|
|
12099
|
+
defaultValue: String(DEFAULT_PORT),
|
|
12100
|
+
initialValue: String(DEFAULT_PORT),
|
|
12101
|
+
validate: validatePortChoice
|
|
12102
|
+
});
|
|
12103
|
+
assertNotCancelled(portAnswer);
|
|
12104
|
+
const chosenPort = Number(String(portAnswer).trim() || DEFAULT_PORT);
|
|
12105
|
+
if (!await isPortFree(chosenPort)) {
|
|
12106
|
+
M2.warn(
|
|
12107
|
+
`Port ${chosenPort} looks busy. Free it (lsof -i :${chosenPort} on macOS or Linux,
|
|
12108
|
+
netstat -ano | findstr :${chosenPort} on Windows), or change PORT in the generated
|
|
12109
|
+
.env afterwards. Setup will carry on either way.`
|
|
12110
|
+
);
|
|
12111
|
+
}
|
|
11660
12112
|
let outDir;
|
|
11661
12113
|
for (; ; ) {
|
|
11662
12114
|
const outputDir = await he({
|
|
@@ -11697,7 +12149,11 @@ async function main() {
|
|
|
11697
12149
|
"",
|
|
11698
12150
|
// webhookUrl — filled in after server starts
|
|
11699
12151
|
privateKey,
|
|
11700
|
-
verifiesAccountReference
|
|
12152
|
+
verifiesAccountReference,
|
|
12153
|
+
void 0,
|
|
12154
|
+
receiverMode,
|
|
12155
|
+
connectorToken,
|
|
12156
|
+
chosenPort
|
|
11701
12157
|
);
|
|
11702
12158
|
s1.stop(`Scaffolded ${written.length} files in ${outDir}`);
|
|
11703
12159
|
for (const f of written) M2.success(` ${f}`);
|
|
@@ -11709,7 +12165,7 @@ async function main() {
|
|
|
11709
12165
|
for (; ; ) {
|
|
11710
12166
|
const s2 = Y2();
|
|
11711
12167
|
s2.start("Installing dependencies (this can take 30\u201360 s)");
|
|
11712
|
-
const install = installDependencies(platform, outDir);
|
|
12168
|
+
const install = await installDependencies(platform, outDir);
|
|
11713
12169
|
if (install.ok) {
|
|
11714
12170
|
s2.stop("Dependencies installed");
|
|
11715
12171
|
break;
|
|
@@ -11734,22 +12190,18 @@ async function main() {
|
|
|
11734
12190
|
}
|
|
11735
12191
|
if (choice === "skip") break;
|
|
11736
12192
|
}
|
|
11737
|
-
const
|
|
11738
|
-
|
|
11739
|
-
|
|
11740
|
-
|
|
11741
|
-
|
|
11742
|
-
|
|
11743
|
-
}
|
|
12193
|
+
const childSecrets = childEnvForMode(receiverMode, {
|
|
12194
|
+
secret,
|
|
12195
|
+
partnerId: pid,
|
|
12196
|
+
verifiesAccountReference,
|
|
12197
|
+
privateKey,
|
|
12198
|
+
connectorToken
|
|
12199
|
+
});
|
|
12200
|
+
const SERVER_PORT = chosenPort;
|
|
11744
12201
|
const s3 = Y2();
|
|
11745
12202
|
s3.start(`Starting server (waiting up to 15 s for /health on :${SERVER_PORT})`);
|
|
11746
12203
|
onCleanup(stopServer);
|
|
11747
|
-
const start = await startServer(platform, outDir, SERVER_PORT,
|
|
11748
|
-
WEBHOOK_SECRET: secret,
|
|
11749
|
-
PARTNER_ID: pid,
|
|
11750
|
-
PARTNER_PRIVATE_KEY_PEM: privateKey,
|
|
11751
|
-
VERIFIES_ACCOUNT_REFERENCE: String(verifiesAccountReference)
|
|
11752
|
-
});
|
|
12204
|
+
const start = await startServer(platform, outDir, SERVER_PORT, childSecrets);
|
|
11753
12205
|
let serverRunning = false;
|
|
11754
12206
|
if (start.ok) {
|
|
11755
12207
|
s3.stop(`Server is healthy on port ${SERVER_PORT}`);
|
|
@@ -11823,7 +12275,7 @@ Continuing will overwrite it.`
|
|
|
11823
12275
|
s4.start("Starting Cloudflare Tunnel");
|
|
11824
12276
|
onCleanup(stopTunnel);
|
|
11825
12277
|
try {
|
|
11826
|
-
let tunnel = await startTunnel(
|
|
12278
|
+
let tunnel = await startTunnel(SERVER_PORT);
|
|
11827
12279
|
webhookUrl = `${tunnel.url}/webhook`;
|
|
11828
12280
|
tunnelBase = tunnel.url;
|
|
11829
12281
|
s4.stop(`Tunnel active: ${tunnel.url}`);
|
|
@@ -11836,7 +12288,7 @@ Continuing will overwrite it.`
|
|
|
11836
12288
|
if (!webhookReady) {
|
|
11837
12289
|
s4b.stop("First tunnel is slow to route, trying a fresh one");
|
|
11838
12290
|
stopTunnel();
|
|
11839
|
-
tunnel = await startTunnel(
|
|
12291
|
+
tunnel = await startTunnel(SERVER_PORT);
|
|
11840
12292
|
webhookUrl = `${tunnel.url}/webhook`;
|
|
11841
12293
|
tunnelBase = tunnel.url;
|
|
11842
12294
|
const s4c = Y2();
|
|
@@ -11849,7 +12301,7 @@ Continuing will overwrite it.`
|
|
|
11849
12301
|
if (!webhookReady) {
|
|
11850
12302
|
M2.warn(
|
|
11851
12303
|
`The tunnel edge is still not routing after a retry. This is almost always
|
|
11852
|
-
one of two things, and neither is your server (it is running on
|
|
12304
|
+
one of two things, and neither is your server (it is running on :${SERVER_PORT}):
|
|
11853
12305
|
1. Open ${tunnel.url}/health in a browser. If it never loads, your network
|
|
11854
12306
|
is blocking cloudflared and no wait will fix it, so try a different network.
|
|
11855
12307
|
2. The tunnel only lives while this wizard runs, so keep this window open.`
|
|
@@ -11859,7 +12311,8 @@ one of two things, and neither is your server (it is running on :3001):
|
|
|
11859
12311
|
s4.stop("Tunnel setup failed");
|
|
11860
12312
|
M2.warn(`Could not start tunnel: ${err instanceof Error ? err.message : String(err)}`);
|
|
11861
12313
|
M2.warn(
|
|
11862
|
-
|
|
12314
|
+
`To get a public URL manually, try: npx cloudflared tunnel --url http://localhost:${SERVER_PORT}
|
|
12315
|
+
Or deploy to any server and set the URL in partners.oneaddress.io \u2192 My Profile.`
|
|
11863
12316
|
);
|
|
11864
12317
|
}
|
|
11865
12318
|
}
|
|
@@ -11879,7 +12332,7 @@ one of two things, and neither is your server (it is running on :3001):
|
|
|
11879
12332
|
);
|
|
11880
12333
|
}
|
|
11881
12334
|
registerInstall(
|
|
11882
|
-
`OA-${(0,
|
|
12335
|
+
`OA-${(0, import_node_crypto6.randomUUID)()}`,
|
|
11883
12336
|
pid,
|
|
11884
12337
|
secret,
|
|
11885
12338
|
platform,
|
|
@@ -11897,7 +12350,7 @@ one of two things, and neither is your server (it is running on :3001):
|
|
|
11897
12350
|
if (!webhookReady) {
|
|
11898
12351
|
M2.warn(
|
|
11899
12352
|
`Skipping the conformance checks: the tunnel edge is not routing.
|
|
11900
|
-
Your server is fine, it is running on
|
|
12353
|
+
Your server is fine, it is running on :${SERVER_PORT}. This is either your network
|
|
11901
12354
|
blocking cloudflared (open ${tunnelBase}/health in a browser to check) or the
|
|
11902
12355
|
tunnel needing this wizard to stay open. Re-run once that URL loads in a browser.`
|
|
11903
12356
|
);
|
|
@@ -11908,18 +12361,28 @@ tunnel needing this wizard to stay open. Re-run once that URL loads in a browser
|
|
|
11908
12361
|
await new Promise((r2) => setTimeout(r2, 200));
|
|
11909
12362
|
s6.stop("Conformance checks:");
|
|
11910
12363
|
await runConformance(webhookUrl, pid, secret);
|
|
11911
|
-
|
|
11912
|
-
|
|
12364
|
+
if (receiverMode === "inbox") {
|
|
12365
|
+
M2.info(
|
|
12366
|
+
"Decrypt check skipped: this receiver holds no private key, which is what inbox\nmode is for. Your connector is the process that decrypts, so prove the key there:\nstart it against this receiver and confirm it draws and applies a dispatch."
|
|
12367
|
+
);
|
|
12368
|
+
} else {
|
|
12369
|
+
await runDecryptCheck(secret, pid);
|
|
12370
|
+
}
|
|
12371
|
+
if (verifiesAccountReference && receiverMode !== "inbox") {
|
|
11913
12372
|
M2.info(
|
|
11914
12373
|
"You have declared that your receiver matches the account reference itself.\nRun `npm test` to include check-14, which sends an account reference that matches\nnothing and fails a receiver that reports it as applied. The three checks above\ncover signing and replay only."
|
|
11915
12374
|
);
|
|
12375
|
+
} else if (verifiesAccountReference) {
|
|
12376
|
+
M2.info(
|
|
12377
|
+
"You have declared that your receiver matches the account reference itself.\nIn inbox mode your CONNECTOR does that, before it applies anything, and it\nrefuses a dispatch whose reference matches none of your records. The three checks\nabove cover signing and replay only."
|
|
12378
|
+
);
|
|
11916
12379
|
}
|
|
11917
12380
|
}
|
|
11918
12381
|
}
|
|
11919
12382
|
const summary = [
|
|
11920
12383
|
`Partner ID: ${pid}`,
|
|
11921
12384
|
`Webhook URL: ${webhookUrl || "(not set \u2014 update in portal)"}`,
|
|
11922
|
-
`Server port:
|
|
12385
|
+
`Server port: ${SERVER_PORT}`,
|
|
11923
12386
|
`Files in: ${outDir}`
|
|
11924
12387
|
].join("\n");
|
|
11925
12388
|
const nextSteps = [
|
|
@@ -11934,12 +12397,7 @@ tunnel needing this wizard to stay open. Re-run once that URL loads in a browser
|
|
|
11934
12397
|
if (serverRunning) {
|
|
11935
12398
|
const openDashboard = platformHasDashboard(platform) ? await ye({ message: "Open the receiver dashboard now?", initialValue: true }) : false;
|
|
11936
12399
|
if (openDashboard === true) {
|
|
11937
|
-
const handover = await handOverTerminal(platform, outDir, SERVER_PORT,
|
|
11938
|
-
WEBHOOK_SECRET: secret,
|
|
11939
|
-
PARTNER_ID: pid,
|
|
11940
|
-
PARTNER_PRIVATE_KEY_PEM: privateKey,
|
|
11941
|
-
VERIFIES_ACCOUNT_REFERENCE: String(verifiesAccountReference)
|
|
11942
|
-
});
|
|
12400
|
+
const handover = await handOverTerminal(platform, outDir, SERVER_PORT, childSecrets);
|
|
11943
12401
|
if (!handover.ok) {
|
|
11944
12402
|
M2.warn(
|
|
11945
12403
|
`${handover.reason ?? "The dashboard could not start."}
|
|
@@ -11953,7 +12411,7 @@ Your receiver is set up and working. Start it yourself with:
|
|
|
11953
12411
|
console.log("");
|
|
11954
12412
|
console.log(` ${DIM2}\u250C${"\u2500".repeat(BOX_WIDTH)}\u2510${R3}`);
|
|
11955
12413
|
for (const inner of [
|
|
11956
|
-
` ${GRN}\u25C8${R3} ${CRM2}Server live${R3} ${MID2}on http://localhost
|
|
12414
|
+
` ${GRN}\u25C8${R3} ${CRM2}Server live${R3} ${MID2}on http://localhost:${SERVER_PORT}/webhook${R3}`,
|
|
11957
12415
|
` ${AMB2}\u25C8${R3} ${MID2}Edit ${CRM2}src/store.ts${R3} ${MID2}to wire your database${R3}`,
|
|
11958
12416
|
` ${MID2}Press Ctrl+C to stop the server and exit.${R3}`,
|
|
11959
12417
|
...platformHasDashboard(platform) ? [` ${MID2}Run ${CRM2}npm start${R3} ${MID2}here any time for the dashboard.${R3}`] : []
|