@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.
Files changed (2) hide show
  1. package/dist/index.js +669 -211
  2. 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 import_node_crypto5 = require("crypto");
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.2.0" : "?";
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
- * \`notePreviousAddress\`, so importing it back would be a cycle. Optional so
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
- /** One address as a single line, the way the change panel shows it. */
1726
- function oneLine(a: Record<string, unknown>): string {
1727
- const parts = [a.street, a.suburb, a.state, a.postcode].filter(Boolean).map(String);
1728
- return parts.length > 0 ? parts.join(', ') : '(empty)';
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: { customer: StoredCustomer; previous: Record<string, unknown> } | null = null;
1812
- let pendingPrevious: Record<string, unknown> = {};
1856
+ let lastChange: ChangeFacts | null = null;
1813
1857
 
1814
- // The server hands over the address a dispatch REPLACED. Kept as state here
1815
- // rather than pushed through \`report\`, because the previous address is data
1816
- // rather than narration and must never end up in a log line.
1817
- setPrevious = (prev) => { pendingPrevious = prev; };
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, the address it replaced{/}\\n\` +
1850
- \` {\${DIM}-fg}and the address that replaced it appear here.{/}\`,
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 { customer, previous } = lastChange;
1855
- let now: Record<string, unknown> = {};
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
- // This used to space the lines out with blanks, which needed eight rows.
1860
- // The faults band takes seven rows off this panel when it appears, so on a
1861
- // real terminal the last line fell off the bottom - and the last line is
1862
- // \`now\`, the one thing the panel exists to show. blessed clips silently, so
1863
- // it read as a dispatch that had half worked.
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
- // It cost a real end-to-end run an hour of doubt: the address had been
1866
- // applied, confirmed and acknowledged by OneAddress, and the screen showed
1867
- // only what it used to be. Adjacent is better anyway, because comparing two
1868
- // lines a blank apart is harder than comparing two lines.
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(customer.name)}{/bold}\` +
1871
- \` {\${DIM}-fg}account{/} {\${AMBER}-fg}\${esc(customer.account_number)}{/}\\n\\n\` +
1872
- \` {red-fg}was{/} \${esc(oneLine(previous))}\\n\` +
1873
- \` {green-fg}now{/} {\${CREAM}-fg}\${esc(oneLine(now))}{/}\`,
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
- if (/\\[store\\] saved address for /.test(text)) {
1983
- // The store logs the account key and never the address, so the panel is
1984
- // refreshed from the DATABASE rather than parsed out of the log line.
1985
- const acct = /saved address for (\\S+)/.exec(text)?.[1];
1986
- // An indexed lookup of the one customer, not a decrypt of the whole
1987
- // roster to find them. Invisible at three rows and absurd at four
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 setPrevious: (prev: Record<string, unknown>) => void = () => {};
2108
+ let setChange: (facts: ChangeFacts) => void = () => {};
2069
2109
 
2070
- export function notePreviousAddress(prev: Record<string, unknown>): void {
2071
- setPrevious(prev);
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(' If you do not know the passphrase, delete data.db and start\\n');
2275
- process.stdout.write(' again: it holds your demo roster and test dispatches, nothing\\n');
2276
- process.stdout.write(' OneAddress needs.\\n\\n');
2277
- const answer = await ask(' Passphrase to unlock: ');
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
- process.stdout.write('\\n SET A PASSPHRASE to encrypt your customer records at rest.\\n');
2282
- process.stdout.write(' It is YOURS. It is not the OneAddress private key, and OneAddress\\n');
2283
- process.stdout.write(' never sees it and CANNOT RECOVER IT. Lose it and the records in\\n');
2284
- process.stdout.write(' data.db cannot be read again.\\n');
2285
- process.stdout.write(' Press Enter to skip and store records unencrypted.\\n\\n');
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 passphrase: ');
2374
+ const first = await ask(' New password (or Enter to skip): ');
2289
2375
  if (!first) {
2290
- process.stdout.write(' Continuing WITHOUT encryption.\\n\\n');
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(' Confirm passphrase: ');
2380
+ const again = await ask(' Type it again: ');
2294
2381
  if (first === again) {
2295
- process.stdout.write(' Passphrase set. Keep it somewhere you will still have it.\\n\\n');
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. A fabricated match authorises a stranger's address onto
2460
- // a customer's account. \`no_account\` is the safe answer and stops the
2461
- // consumer before they pay, which is the trade this design accepted.
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
- 'refusing rather than guessing',
2579
+ 'reporting the service as unreachable rather than claiming no account exists',
2465
2580
  );
2466
- return 'no_account';
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
- if (!answer.reached) return 'not_found';
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
- const ms = Date.now() - new Date(iso).getTime();
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
- const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
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
- 'SELECT id, replayed_at FROM quarantine WHERE received_at < ?',
3330
- ).all(cutoff) as unknown as { id: string; replayed_at: string | null }[];
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('DELETE FROM quarantine WHERE received_at < ?').run(cutoff);
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
- const ROSTER = loadRoster();
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
- report.info(\`[store] saved address for \${acct}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
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 { notePreviousAddress } from './tui.js';
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
- store.encrypted
4679
- ? \`[startup] store: \${store.name} (encrypted at rest)\`
4680
- : \`[startup] store: \${store.name} \u2014 NOT ENCRYPTED AT REST. \` +
4681
- 'Customer records are readable by anyone who can read the file. ' +
4682
- 'Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` in a terminal to be asked.',
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: 'Applied by the OneAddress webhook receiver',
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
- const status = await store.verifyAccount(null, '', []);
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
- const result = await store.verifyAddress({ email: null, name: '' }, {});
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
- report.info(\`[webhook] address.updated for \${ctx.accountNumber || ctx.name}\`);
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
- // \`saveAddress\` returns the address it replaced. Handed to the dashboard so
5200
- // it can show both halves; a no-op under --headless. Passed directly rather
5201
- // than reported, because the previous address is a customer's address and
5202
- // must never reach a log line.
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
- notePreviousAddress(replaced);
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 || ctx.name}\`);
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
- if (!secret || !partnerId || !keyPem) {
5453
- console.error('[test] Missing required env vars. Check your .env file.');
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
- let publicKeyB64: string;
5458
- try {
5459
- const priv = createPrivateKey({ key: keyPem, format: 'pem' });
5460
- const pub = createPublicKey(priv);
5461
- publicKeyB64 = pub.export({ type: 'spki', format: 'der' }).toString('base64');
5462
- } catch (err) {
5463
- console.error('[test] Failed to derive public key from PARTNER_PRIVATE_KEY_PEM:', err);
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
- process.env.OA_VERIFIES_ACCOUNT_REFERENCE === '1' ||
5488
- process.env.OA_VERIFIES_ACCOUNT_REFERENCE === 'true' ||
5489
- %%VERIFIES_ACCOUNT_REFERENCE%%;
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": "Applied by the OneAddress webhook receiver",
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", "Applied by the OneAddress webhook receiver");
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 = "Applied by the OneAddress webhook receiver",
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": "Applied by the OneAddress webhook receiver",
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' => 'Applied by the OneAddress webhook receiver',
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
- return 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");
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 import_node_crypto = require("crypto");
10656
- var PKG_VERSION = true ? "2.2.0" : "dev";
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, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
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 import_node_crypto2 = require("crypto");
11045
+ var import_node_crypto3 = require("crypto");
10693
11046
  async function runConformance(webhookUrl, _partnerId, webhookSecret) {
10694
11047
  function sign(ts, body) {
10695
- return (0, import_node_crypto2.createHmac)("sha256", webhookSecret).update(`${ts}.${body}`).digest("hex");
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, import_node_crypto2.randomUUID)()}`
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, import_node_crypto2.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
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
- const result = (0, import_node_child_process.spawnSync)(spec.cmd, spec.args, {
10868
- cwd,
10869
- stdio: "pipe",
10870
- encoding: "utf8",
10871
- timeout: 5 * 60 * 1e3,
10872
- // 5 min max
10873
- shell: process.platform === "win32"
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
- var COMMANDS2 = {
10886
- "ts-node": { cmd: "npx", args: ["tsx", "src/server.ts"] },
10887
- "python": { cmd: "uvicorn", args: ["app:app", "--port", "3001"] },
10888
- "go-http": { cmd: "go", args: ["run", "."] },
10889
- "php-laravel": { cmd: "php", args: ["artisan", "serve", "--port=3001"] },
10890
- "csharp-aspnet": { cmd: "dotnet", args: ["run"] },
10891
- "java-spring": { cmd: "mvn", args: ["spring-boot:run"] }
10892
- // no mvnw wrapper is scaffolded
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 = COMMANDS2[platform];
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 import_node_crypto3 = require("crypto");
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, import_node_crypto3.createHash)("sha256");
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 import_node_crypto4 = require("crypto");
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, import_node_crypto4.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
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, import_node_crypto4.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
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, import_node_crypto4.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
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, import_node_crypto4.createHmac)("sha256", webhookSecret).update(`${ts}.${body}`).digest("hex");
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, import_node_crypto5.createPrivateKey)({ key: sec1Pem, format: "pem", type: "sec1" }).export({ type: "pkcs8", format: "pem" });
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, import_node_crypto5.createPrivateKey)({ key: pem, format: "pem", type: "pkcs8" });
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 SERVER_PORT = 3001;
11738
- const portFree = await isPortFree(SERVER_PORT);
11739
- if (!portFree) {
11740
- M2.warn(
11741
- `Port ${SERVER_PORT} is in use. Free it (lsof -i :${SERVER_PORT} on macOS/Linux, netstat -ano | findstr :${SERVER_PORT} on Windows) and re-run the wizard, or change the PORT in ${outDir}/.env after setup completes.`
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(3001);
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(3001);
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 :3001):
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
- "To get a public URL manually, try: npx cloudflared tunnel --url http://localhost:3001\nOr deploy to any server and set the URL in partners.oneaddress.io \u2192 My Profile."
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, import_node_crypto5.randomUUID)()}`,
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 :3001. This is either your network
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
- await runDecryptCheck(secret, pid);
11912
- if (verifiesAccountReference) {
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: 3001`,
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:3001/webhook${R3}`,
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}`] : []