@oneaddress/setup 2.3.0 → 2.5.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 +1336 -375
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -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.3.0" : "?";
859
+ var WIZARD_VERSION = true ? "2.5.0" : "?";
860
860
  function printCompactHeader() {
861
861
  const INNER = 42;
862
862
  const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
@@ -983,6 +983,7 @@ data.db-shm
983
983
  "headless": "node --disable-warning=ExperimentalWarning node_modules/tsx/dist/cli.mjs src/index.ts -- --headless",
984
984
  "build": "tsup src/index.ts --format esm --no-dts --outDir dist",
985
985
  "type-check": "tsc --noEmit",
986
+ "show": "tsx scripts/show.ts",
986
987
  "test": "tsx scripts/test.ts"
987
988
  },
988
989
  "dependencies": {
@@ -1011,7 +1012,8 @@ data.db-shm
1011
1012
  "partnerId": "%%PARTNER_ID%%",
1012
1013
  "oneAddressApi": "%%ONEADDRESS_API%%",
1013
1014
  "verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE%%,
1014
- "mode": "%%RECEIVER_MODE%%"
1015
+ "mode": "%%RECEIVER_MODE%%",
1016
+ "publicUrl": "%%WEBHOOK_URL%%"
1015
1017
  }
1016
1018
  `
1017
1019
  },
@@ -1711,7 +1713,8 @@ import { formatLine, report, type ReportLine } from './report.js';
1711
1713
  // reads, with nothing here to change.
1712
1714
  import { store } from './store.js';
1713
1715
  import { pendingConfirmCount } from './confirm-queue.js';
1714
- import { exportHeld, heldCount, heldSummary } from './quarantine.js';
1716
+ import { dismissHeld, exportHeld, heldCount, heldSummary } from './quarantine.js';
1717
+ import { reachability } from './reachable.js';
1715
1718
 
1716
1719
  /** blessed takes colours as strings; these mirror the site's palette. */
1717
1720
  const AMBER = HEX.amber.toLowerCase();
@@ -1870,6 +1873,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1870
1873
  // nor UNENCRYPTED is true and both would mislead. The receiver holds
1871
1874
  // ciphertext it cannot open; the customers live in the partner's own
1872
1875
  // database, behind their own controls.
1876
+ const reach = reachability();
1873
1877
  const vault = inbox
1874
1878
  ? \`{\${AMBER}-fg}{bold}NONE (inbox){/}\`
1875
1879
  : store.encrypted
@@ -1883,7 +1887,16 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1883
1887
  // claim ("you have no customers") and would be a lie on a receiver
1884
1888
  // pointed at a real customer table, where counting every row forty times
1885
1889
  // a minute is the thing the store is right to refuse.
1886
- \`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}\`,
1890
+ \`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}\`
1891
+ // REACHABLE IS DRAWN ONLY WHEN IT IS KNOWN, and that is the point of the
1892
+ // third state. A receiver with no publicUrl configured cannot answer the
1893
+ // question, and a green tick or a red cross would both be inventing an
1894
+ // answer. Silence is the honest rendering of "not checked".
1895
+ + (reach.state === 'unknown'
1896
+ ? ''
1897
+ : reach.state === 'reachable'
1898
+ ? \` {\${DIM}-fg}reachable{/} {green-fg}{bold}YES{/}\`
1899
+ : \` {\${DIM}-fg}reachable{/} {red-fg}{bold}NO{/}\`),
1887
1900
  );
1888
1901
  }
1889
1902
 
@@ -1947,7 +1960,14 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1947
1960
 
1948
1961
  function renderFaults(): void {
1949
1962
  const held = heldCount();
1950
- const show = held > 0;
1963
+ const reach = reachability();
1964
+ // UNREACHABLE IS A FAULT, and it belongs in this panel rather than only in
1965
+ // the status bar, because it is the one failure where NOTHING ELSE MOVES.
1966
+ // A wrong key at least produces held dispatches to look at; a stopped
1967
+ // tunnel produces a receiver that looks perfectly healthy and simply never
1968
+ // hears from anyone again.
1969
+ const unreachable = reach.state === 'unreachable';
1970
+ const show = held > 0 || unreachable;
1951
1971
  if (show === Boolean(faultBox.hidden)) {
1952
1972
  // Visibility is changing, so the panels above have to give back or take
1953
1973
  // back the rows. Assigning \`bottom\` is how blessed re-lays-out; it reads
@@ -1962,6 +1982,15 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1962
1982
  // Grouped, never one line per dispatch. The realistic shape of this table
1963
1983
  // is forty rows with ONE cause between them, and forty identical lines hide
1964
1984
  // the single fact that matters.
1985
+ if (unreachable && held === 0) {
1986
+ faultBox.setContent(
1987
+ \`\\n {bold}Nothing on the internet is reaching this receiver.{/bold}\\n\` +
1988
+ \` {red-fg}\${esc(reach.state === 'unreachable' ? reach.detail : '')}{/}\\n\` +
1989
+ \` {\${DIM}-fg}Dispatches cannot arrive while this is true. Check your tunnel is still running.{/}\`,
1990
+ );
1991
+ return;
1992
+ }
1993
+
1965
1994
  const lines = heldSummary().slice(0, 2).map((l) => \` {red-fg}\${esc(l)}{/}\`);
1966
1995
  faultBox.setContent(
1967
1996
  \`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open. \` +
@@ -1969,7 +1998,8 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1969
1998
  lines.join('\\n') + '\\n' +
1970
1999
  (replayNote
1971
2000
  ? \` {\${AMBER}-fg}\${esc(replayNote)}{/}\`
1972
- : \` {\${DIM}-fg}Fix the cause, then{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply,{/} {\${AMBER}-fg}[e]{/} {\${DIM}-fg}to export the list.{/}\`),
2001
+ : \` {\${DIM}-fg}Fix the cause, then{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply,{/} {\${AMBER}-fg}[e]{/} {\${DIM}-fg}to export,{/} \` +
2002
+ \`{\${AMBER}-fg}[d]{/} {\${DIM}-fg}to dismiss.{/}\`),
1973
2003
  );
1974
2004
  }
1975
2005
 
@@ -2003,7 +2033,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2003
2033
  \`{red-fg}failed{/} {bold}\${failed}{/bold} \` +
2004
2034
  awaitingYou +
2005
2035
  \`{\${awaitingColour}-fg}awaiting confirm{/} {bold}\${awaiting}{/bold}\` +
2006
- \`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [q] quit{/} \`,
2036
+ \`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [d] dismiss [q] quit{/} \`,
2007
2037
  );
2008
2038
  }
2009
2039
 
@@ -2079,6 +2109,58 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2079
2109
  redraw();
2080
2110
  });
2081
2111
 
2112
+ // [d] DISMISSES. TWO PRESSES, and the first one only asks.
2113
+ //
2114
+ // WHY THIS KEY EXISTS AT ALL. The conformance suite deliberately sends a
2115
+ // dispatch this receiver cannot open (check-11: a session key wrapped to a
2116
+ // throwaway key pair) and passes the receiver for HOLDING it. So passing
2117
+ // conformance leaves a red fault panel up permanently, on a receiver that is
2118
+ // working correctly, with nothing on screen that clears it. Reported from a
2119
+ // real run: "I do wish the conformance wouldn't come up as a fault."
2120
+ //
2121
+ // WHY THE RECEIVER DOES NOT JUST RECOGNISE THE PROBE. Everything that
2122
+ // identifies one is chosen by the sender. See the \`dismissed_at\` docstring in
2123
+ // \`quarantine.ts\`: suppressing a fault panel on a sender-supplied value hides
2124
+ // real held dispatches from the one person whose job is to notice them.
2125
+ //
2126
+ // IRREVERSIBLE, SO IT IS CONFIRMED. Dismissal discards the held payload, which
2127
+ // is the point (see \`dismissHeld\`) and also means a mistaken press cannot be
2128
+ // undone by pressing [r]. The count is named in the question rather than after
2129
+ // it, because "dismiss 47" and "dismiss 1" are different decisions.
2130
+ //
2131
+ // THE ARM REMEMBERS A COUNT, NOT A BOOLEAN, and that is the load-bearing part
2132
+ // rather than a nicety. A boolean armed at 14:02 is still armed at 16:30, so a
2133
+ // partner who armed it against one conformance probe and wandered off would
2134
+ // come back and dismiss whatever real backlog had arrived in between - with a
2135
+ // single keystroke, discarding the payloads. Confirming only the exact set
2136
+ // that was described makes the question and the action the same question.
2137
+ let dismissArmedFor: number | null = null;
2138
+ screen.key(['d'], () => {
2139
+ const held = heldCount();
2140
+ if (held === 0) {
2141
+ dismissArmedFor = null;
2142
+ replayNote = 'Nothing held, so nothing to dismiss.';
2143
+ redraw();
2144
+ return;
2145
+ }
2146
+ if (dismissArmedFor !== held) {
2147
+ const changed = dismissArmedFor !== null;
2148
+ dismissArmedFor = held;
2149
+ replayNote = (changed ? \`That changed: \${held} held now. \` : '') +
2150
+ \`Press [d] again to dismiss \${held}: not applied, payloads discarded, cannot be undone.\`;
2151
+ redraw();
2152
+ return;
2153
+ }
2154
+ dismissArmedFor = null;
2155
+ try {
2156
+ const n = dismissHeld();
2157
+ replayNote = \`Dismissed \${n}. Nothing left held.\`;
2158
+ } catch (err) {
2159
+ replayNote = \`Dismiss failed: \${err instanceof Error ? err.message : String(err)}\`;
2160
+ }
2161
+ redraw();
2162
+ });
2163
+
2082
2164
  screen.key(['q', 'C-c'], () => {
2083
2165
  detach();
2084
2166
  screen.destroy();
@@ -2136,6 +2218,15 @@ export function noteChange(facts: ChangeFacts): void {
2136
2218
  * \`--headless\` skips both: no prompt (a service has nobody to ask), no screen.
2137
2219
  */
2138
2220
  import { printBanner } from './brand.js';
2221
+ // Config only - NOT the server, the store or the database. Those derive keys on
2222
+ // import, which is the whole reason this file loads them dynamically below,
2223
+ // after the passphrase is resolved. \`config.js\` reads a JSON file and the
2224
+ // environment and touches neither.
2225
+ import { config } from './config.js';
2226
+ // Same rule as \`config.js\`: \`unlock.js\` deliberately does not import the
2227
+ // database, so it can be loaded statically here and still run before the keys
2228
+ // are derived. See its own header.
2229
+ import { ask, databaseIsLocked } from './unlock.js';
2139
2230
  import { PassphraseRequiredError, WrongPassphraseError } from './vault.js';
2140
2231
 
2141
2232
  /**
@@ -2158,122 +2249,6 @@ const headless =
2158
2249
  process.env.ONEADDRESS_HEADLESS === '1' ||
2159
2250
  !process.stdout.isTTY;
2160
2251
 
2161
- /**
2162
- * Has this database already been locked?
2163
- *
2164
- * Asked BEFORE the passphrase, and without one, so the prompt can say which of
2165
- * two completely different things it is doing. \`db_meta.verifier\` is written the
2166
- * first time a passphrase is set, so its presence is the whole answer.
2167
- *
2168
- * Read through its own connection rather than importing \`db.ts\`, which derives
2169
- * its keys the moment it is imported and would therefore have to run BEFORE we
2170
- * know what to ask for.
2171
- */
2172
- async function databaseIsLocked(): Promise<boolean> {
2173
- try {
2174
- const { DatabaseSync } = await import('node:sqlite');
2175
- const { join } = await import('node:path');
2176
- const path = process.env.DB_PATH ?? join(process.cwd(), 'data.db');
2177
- const db = new DatabaseSync(path, { readOnly: true });
2178
- try {
2179
- const row = db
2180
- .prepare("SELECT value FROM db_meta WHERE key = 'verifier'")
2181
- .get() as { value?: string } | undefined;
2182
- return Boolean(row?.value);
2183
- } finally {
2184
- db.close();
2185
- }
2186
- } catch {
2187
- // No file yet, or no db_meta table yet. Either way: not locked.
2188
- return false;
2189
- }
2190
- }
2191
-
2192
- /**
2193
- * Ask for a passphrase without echoing it to the screen.
2194
- *
2195
- * A passphrase typed in clear on a shared screen, in a screen-share, or into a
2196
- * terminal that keeps scrollback is not much of a secret. readline echoes by
2197
- * default, so its output hook is replaced for the duration of the question.
2198
- *
2199
- * Degrades to a visible prompt rather than failing: on a terminal where the
2200
- * hook is not available, being asked in the clear beats not being asked.
2201
- */
2202
- async function ask(prompt: string): Promise<string> {
2203
- process.stdout.write(prompt);
2204
-
2205
- // NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
2206
- // mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
2207
- // by driving a real terminal and reading what came back. The first filtered
2208
- // on whether the chunk contained the prompt, not knowing readline repaints
2209
- // prompt and input together on every keystroke, so the condition was always
2210
- // true. The second repainted the line and still leaked, because the echo was
2211
- // never coming from that hook at all.
2212
- //
2213
- // Reading the keys directly removes the guessing. Raw mode turns the
2214
- // terminal's own echo OFF, so the ONLY thing that can reach the screen is
2215
- // what is written below: one asterisk per character, which is what a partner
2216
- // asked for and what every other passphrase prompt does.
2217
- const stdin = process.stdin;
2218
- if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
2219
- // No terminal to control. Being asked in the clear beats not being asked,
2220
- // and this path is only reached where nothing is watching anyway.
2221
- const { createInterface } = await import('node:readline/promises');
2222
- const rl = createInterface({ input: stdin, output: process.stdout });
2223
- try {
2224
- const answer = await rl.question('');
2225
- return answer.trim();
2226
- } finally { rl.close(); }
2227
- }
2228
-
2229
- const wasRaw = stdin.isRaw === true;
2230
- stdin.setRawMode(true);
2231
- stdin.resume();
2232
- stdin.setEncoding('utf8');
2233
-
2234
- return new Promise<string>((resolve) => {
2235
- let typed = '';
2236
- const restore = (): void => {
2237
- stdin.removeListener('data', onData);
2238
- stdin.setRawMode(wasRaw);
2239
- stdin.pause();
2240
- };
2241
- const onData = (chunk: string): void => {
2242
- for (const ch of chunk) {
2243
- if (ch === '\\r' || ch === '\\n') {
2244
- restore();
2245
- process.stdout.write('\\n');
2246
- resolve(typed.trim());
2247
- return;
2248
- }
2249
- if (ch === '\\u0003') { // Ctrl+C
2250
- restore();
2251
- process.stdout.write('\\n');
2252
- process.exit(130);
2253
- }
2254
- if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
2255
- restore();
2256
- process.stdout.write('\\n');
2257
- resolve(typed.trim());
2258
- return;
2259
- }
2260
- if (ch === '\\u007f' || ch === '\\b') {
2261
- // Backspace has to move the asterisks too, or the mask stops matching
2262
- // what is actually in the buffer and the count misleads.
2263
- if (typed.length > 0) {
2264
- typed = typed.slice(0, -1);
2265
- process.stdout.write('\\b \\b');
2266
- }
2267
- continue;
2268
- }
2269
- if (ch < ' ') continue; // ignore the rest of the control range
2270
- typed += ch;
2271
- process.stdout.write('*');
2272
- }
2273
- };
2274
- stdin.on('data', onData);
2275
- });
2276
- }
2277
2252
 
2278
2253
  /**
2279
2254
  * Where the at-rest passphrase comes from.
@@ -2310,29 +2285,72 @@ async function resolvePassphrase(): Promise<string | null> {
2310
2285
  // Reported by exactly that partner: "there was no opportunity to set a
2311
2286
  // passphrase". Without this line the only options are guess or search the
2312
2287
  // internet, and the answer is one command.
2313
- process.stdout.write('\\n This customer database is encrypted.\\n');
2314
- process.stdout.write(' If you do not know the passphrase, delete data.db and start\\n');
2315
- process.stdout.write(' again: it holds your demo roster and test dispatches, nothing\\n');
2316
- process.stdout.write(' OneAddress needs.\\n\\n');
2317
- const answer = await ask(' Passphrase to unlock: ');
2288
+ process.stdout.write('\\n This customer database is encrypted and needs its password.\\n\\n');
2289
+ process.stdout.write(' It is the one you chose on this receiver. It is NOT your Webhook\\n');
2290
+ process.stdout.write(' signing secret, NOT your ECDH private key, and NOT your\\n');
2291
+ process.stdout.write(' OneAddress sign-in.\\n\\n');
2292
+ process.stdout.write(' If you do not have it, delete data.db and start again: it holds\\n');
2293
+ process.stdout.write(' your demo roster and test dispatches, nothing OneAddress needs.\\n\\n');
2294
+ const answer = await ask(' Password to unlock: ');
2318
2295
  return answer || null;
2319
2296
  }
2320
2297
 
2321
- process.stdout.write('\\n SET A PASSPHRASE to encrypt your customer records at rest.\\n');
2322
- process.stdout.write(' It is YOURS. It is not the OneAddress private key, and OneAddress\\n');
2323
- process.stdout.write(' never sees it and CANNOT RECOVER IT. Lose it and the records in\\n');
2324
- process.stdout.write(' data.db cannot be read again.\\n');
2325
- process.stdout.write(' Press Enter to skip and store records unencrypted.\\n\\n');
2298
+ // SAYS WHAT IS BEING ASKED BEFORE IT WARNS ABOUT IT. The first version opened
2299
+ // with SET A PASSPHRASE and four lines of consequence, which reads as an
2300
+ // instruction to produce a passphrase you are assumed to already have - and
2301
+ // this prompt arrives moments after the partner has pasted two real
2302
+ // credentials, so assuming exactly that is the natural reading. Reported as
2303
+ // confusing, and the confusion was ours.
2304
+ //
2305
+ // It therefore leads with the decision and says plainly that the answer is
2306
+ // INVENTED HERE. It used to go on to name the three secrets it is NOT, which
2307
+ // was accurate and was four more lines of denial in a prompt that is already
2308
+ // the longest question in setup; reported as over-explained, and cut back to
2309
+ // the three facts an answer depends on (new, what it protects, unrecoverable).
2310
+ // The UNLOCK branch keeps its version of that list, because there the partner
2311
+ // is being asked for a password they may not remember choosing and naming the
2312
+ // wrong candidates is the whole help.
2313
+ // NOT ASKED IN INBOX MODE, because the honest answer to "protect your customer
2314
+ // records" is that there are none here: the connector holds them. The
2315
+ // database in this mode carries dispatch ciphertext - already encrypted to a
2316
+ // key this process does not have - and a queue of dispatch ids. Asking anyway
2317
+ // is the confusing-prompt problem twice over: a question about data that is
2318
+ // absent, put to somebody who has just been told this receiver cannot read
2319
+ // anything.
2320
+ //
2321
+ // The UNLOCK branch above is deliberately NOT skipped. A database encrypted
2322
+ // while the receiver ran write-through is still encrypted after a switch to
2323
+ // inbox, and skipping the question there would refuse to open a file whose
2324
+ // owner has the password.
2325
+ if (config.mode === 'inbox') {
2326
+ process.stdout.write('\\n No password needed: in inbox mode this receiver holds no customer records.\\n');
2327
+ process.stdout.write(' Your connector holds them, and the key.\\n\\n');
2328
+ return null;
2329
+ }
2330
+
2331
+ 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');
2332
+ process.stdout.write(' MAKE UP A NEW PASSWORD. Invented here, not one you already\\n');
2333
+ process.stdout.write(' hold. It encrypts the customer records this receiver keeps in\\n');
2334
+ process.stdout.write(' data.db. OneAddress never sees it and cannot reset it.\\n\\n');
2335
+ process.stdout.write(' Optional. Press Enter to skip and leave the database readable.\\n');
2336
+ // ACCURATE ABOUT "LATER", because the obvious reading is wrong. Setting a
2337
+ // password afterwards works and the receiver reports itself encrypted, but
2338
+ // rows written before it stay in the clear until something rewrites them -
2339
+ // confirmed by finding a seeded customer name in the database file after
2340
+ // exactly that sequence.
2341
+ process.stdout.write(' You can set one later with ONEADDRESS_DB_PASSPHRASE, but records\\n');
2342
+ process.stdout.write(' written before then stay readable until they change.\\n\\n');
2326
2343
 
2327
2344
  for (;;) {
2328
- const first = await ask(' New passphrase: ');
2345
+ const first = await ask(' New password (or Enter to skip): ');
2329
2346
  if (!first) {
2330
- process.stdout.write(' Continuing WITHOUT encryption.\\n\\n');
2347
+ process.stdout.write(' Continuing WITHOUT encryption: anyone who can read data.db can\\n');
2348
+ process.stdout.write(' read your customer records.\\n\\n');
2331
2349
  return null;
2332
2350
  }
2333
- const again = await ask(' Confirm passphrase: ');
2351
+ const again = await ask(' Type it again: ');
2334
2352
  if (first === again) {
2335
- process.stdout.write(' Passphrase set. Keep it somewhere you will still have it.\\n\\n');
2353
+ process.stdout.write(' Password set. Keep it somewhere you will still have it.\\n\\n');
2336
2354
  return first;
2337
2355
  }
2338
2356
  process.stdout.write(' Those do not match. Try again.\\n\\n');
@@ -2471,6 +2489,22 @@ import type {
2471
2489
  * if anything awaited between the set and the read, which is why they are
2472
2490
  * adjacent and why this comment exists.
2473
2491
  */
2492
+ /**
2493
+ * The connector could not be reached, so the question was never answered.
2494
+ *
2495
+ * A THROW RATHER THAN A VERDICT, because every value this function can return
2496
+ * is a statement about the customer's record and none of them means "we could
2497
+ * not ask". The handler turns this into a non-2xx, which the relay already
2498
+ * classifies as \`unreachable\` and shows the consumer as a transient network
2499
+ * problem rather than as a missing account.
2500
+ */
2501
+ export class ConnectorUnreachableError extends Error {
2502
+ constructor(public readonly kind: string) {
2503
+ super(\`the connector could not be reached to answer \${kind}\`);
2504
+ this.name = 'ConnectorUnreachableError';
2505
+ }
2506
+ }
2507
+
2474
2508
  let currentRawBody = '';
2475
2509
 
2476
2510
  export function setCurrentRawBody(raw: string): void {
@@ -2496,14 +2530,26 @@ export const connectorStore = {
2496
2530
  ): Promise<AccountVerdict> {
2497
2531
  const answer = await askConnector('account.verify', currentRawBody);
2498
2532
  if (!answer.reached) {
2499
- // NEVER GUESSED. A fabricated match authorises a stranger's address onto
2500
- // a customer's account. \`no_account\` is the safe answer and stops the
2501
- // consumer before they pay, which is the trade this design accepted.
2533
+ // NEVER GUESSED: a fabricated match authorises a stranger's address onto
2534
+ // a customer's account.
2535
+ //
2536
+ // BUT NOT \`no_account\` EITHER, which is what this returned. That is a
2537
+ // POSITIVE CLAIM - the consumer is shown "\u2717 No account found / This
2538
+ // service couldn't find an account with these details" and goes off to
2539
+ // re-check an account number that was correct all along, or concludes
2540
+ // they have no account with a provider they do.
2541
+ //
2542
+ // The trade was accepted for its safety, and the safety is real. What was
2543
+ // missed is that it is available WITHOUT the false claim: the relay
2544
+ // already classifies a failed check as \`unreachable\` and tells the
2545
+ // consumer "we couldn't reach this service just now". That stops them
2546
+ // before they pay AND is true. Throwing gets us there, because a
2547
+ // non-2xx is exactly what the relay reads as unreachable.
2502
2548
  report.warn(
2503
2549
  \`[connector] account check for \${accountNumber ?? '(none)'} could not be answered; \` +
2504
- 'refusing rather than guessing',
2550
+ 'reporting the service as unreachable rather than claiming no account exists',
2505
2551
  );
2506
- return 'no_account';
2552
+ throw new ConnectorUnreachableError('account.verify');
2507
2553
  }
2508
2554
  const status = verdictOf(answer.body, 'status');
2509
2555
  if (status === 'match' || status === 'no_match' || status === 'no_account') return status;
@@ -2513,7 +2559,9 @@ export const connectorStore = {
2513
2559
 
2514
2560
  async verifyAddress(_customer: Customer, _incoming: Address): Promise<VerifyResult> {
2515
2561
  const answer = await askConnector('address.verify', currentRawBody);
2516
- if (!answer.reached) return 'not_found';
2562
+ // Same reasoning as \`verifyAccount\`: \`not_found\` is a claim about the
2563
+ // customer's record, and "we could not ask" is not that claim.
2564
+ if (!answer.reached) throw new ConnectorUnreachableError('address.verify');
2517
2565
  const result = verdictOf(answer.body, 'result');
2518
2566
  if (result === 'match' || result === 'mismatch' || result === 'not_found') return result;
2519
2567
  report.warn(\`[connector] address.verify answered "\${result ?? '(nothing)'}", which is not a result\`);
@@ -2681,7 +2729,7 @@ import express, { type Request, type Response } from 'express';
2681
2729
  import rateLimit from 'express-rate-limit';
2682
2730
  import { timingSafeEqual } from 'node:crypto';
2683
2731
  import { report } from './report.js';
2684
- import { acknowledge, drawable, markDrawn, type InboxOutcome } from './inbox.js';
2732
+ import { acknowledge, drawable, hold, markDrawn, type InboxOutcome } from './inbox.js';
2685
2733
 
2686
2734
  const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
2687
2735
  /**
@@ -2774,18 +2822,41 @@ export function startDrawApi(): { port: number } | null {
2774
2822
  * confirm, which is the honest answer when the partner's own system refused
2775
2823
  * the change: the consumer is told it did not land rather than being told it
2776
2824
  * did.
2825
+ *
2826
+ * \`held\` QUEUES NOTHING, and that is the whole point of it. It means the
2827
+ * connector could not OPEN the dispatch, which is a fact about a key rather
2828
+ * than about the consumer's record - and a \`failed\` confirm is read as the
2829
+ * latter all the way down: \`partner_failed\`, terminal, refund denied. The
2830
+ * item stays unacknowledged so the next draw retries it once the key is
2831
+ * fixed. See \`hold\` in inbox.ts for why nothing else recovers this.
2777
2832
  */
2778
2833
  app.post('/ack', (req: Request, res: Response) => {
2779
2834
  if (!authorised(req, res)) return;
2780
2835
  const body = req.body as { id?: unknown; outcome?: unknown; detail?: unknown };
2781
2836
  const id = typeof body.id === 'string' ? body.id : '';
2782
- const outcome: InboxOutcome | null =
2783
- body.outcome === 'applied' || body.outcome === 'failed' ? body.outcome : null;
2837
+ const outcome: InboxOutcome | 'held' | null =
2838
+ body.outcome === 'applied' || body.outcome === 'failed' || body.outcome === 'held'
2839
+ ? body.outcome
2840
+ : null;
2784
2841
  if (!id || !outcome) {
2785
- return res.status(400).json({ error: 'id and outcome (applied|failed) are required' });
2842
+ return res.status(400).json({ error: 'id and outcome (applied|failed|held) are required' });
2786
2843
  }
2787
2844
  const detail = typeof body.detail === 'string' ? body.detail : null;
2788
2845
 
2846
+ // HELD RETURNS BEFORE \`acknowledge\`, so there is no path from here to a
2847
+ // confirm. Ordering rather than a flag: a held item that fell through to
2848
+ // the code below would be stamped terminal and reported to OneAddress as
2849
+ // the consumer's problem, which is the defect this exists to remove.
2850
+ if (outcome === 'held') {
2851
+ const heldResult = hold(id, detail, LEASE_SECONDS);
2852
+ if (!heldResult) return res.status(404).json({ error: 'unknown or already acknowledged id' });
2853
+ return res.json({
2854
+ ok: true, held: true,
2855
+ attempts: heldResult.attempts,
2856
+ retryInSeconds: heldResult.retryInSeconds,
2857
+ });
2858
+ }
2859
+
2789
2860
  const result = acknowledge(id, outcome, detail);
2790
2861
  if (!result) return res.status(404).json({ error: 'unknown id' });
2791
2862
  if (result.alreadyAcknowledged) {
@@ -2885,9 +2956,16 @@ export function setAcknowledgementHandler(
2885
2956
  * the claim has to become atomic, the way \`confirm-queue.ts\` already does it.
2886
2957
  * Written down because a lease LOOKS like it handles concurrency and does not.
2887
2958
  */
2888
- import db from './db.js';
2959
+ import db, { ensureColumn } from './db.js';
2889
2960
  import { report } from './report.js';
2890
2961
 
2962
+ /**
2963
+ * The connector's TERMINAL verdicts. Both end the item and both tell OneAddress.
2964
+ *
2965
+ * \`held\` is deliberately NOT one of these, and lives in its own function. See
2966
+ * \`hold\` below for why a dispatch the connector could not OPEN must never
2967
+ * become one of these two.
2968
+ */
2891
2969
  export type InboxOutcome = 'applied' | 'failed';
2892
2970
 
2893
2971
  db.exec(\`
@@ -2906,6 +2984,30 @@ db.exec(\`
2906
2984
  ON inbox(applied_at, drawn_at, received_at);
2907
2985
  \`);
2908
2986
 
2987
+ /**
2988
+ * Why a held item needs its own two columns, matching \`quarantine\`'s.
2989
+ *
2990
+ * A dispatch the connector cannot OPEN is not finished with, so it keeps
2991
+ * \`applied_at IS NULL\` and stays in the undrawn count. But an operator then
2992
+ * needs to know it is BLOCKED rather than merely queued, and by what: without
2993
+ * these the dashboard can say a number and nothing else, and the connector's
2994
+ * message - which names the exact environment variable to set - is only in a
2995
+ * log somebody has to still have.
2996
+ *
2997
+ * Same names as the quarantine's, because it is the same idea one process over
2998
+ * and a second vocabulary for it would be a second thing to learn.
2999
+ */
3000
+ ensureColumn('inbox', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
3001
+ ensureColumn('inbox', 'last_error', 'TEXT');
3002
+
3003
+ /**
3004
+ * First retry for a held item, multiplied by the attempt count and capped at
3005
+ * the lease. Thirty seconds because the overwhelming case is a partner who has
3006
+ * just corrected a key and restarted the connector, and making them wait out a
3007
+ * lease built for crash recovery would be answering a question nobody asked.
3008
+ */
3009
+ const HELD_RETRY_BASE_SECONDS = 30;
3010
+
2909
3011
  export interface AcceptInput {
2910
3012
  key: string;
2911
3013
  dispatchId: string | null;
@@ -2987,6 +3089,110 @@ export function acknowledge(
2987
3089
  return { dispatchId: row.dispatch_id, alreadyAcknowledged: false };
2988
3090
  }
2989
3091
 
3092
+ /**
3093
+ * The connector could not OPEN this one, so it is not finished with.
3094
+ *
3095
+ * ## Why this is not \`failed\`
3096
+ *
3097
+ * \`failed\` is a statement about the CONSUMER'S RECORD - the partner's system
3098
+ * received the change and refused it - and OneAddress reads it exactly that
3099
+ * way. \`/api/confirm\` resolves every non-success to \`partner_failed\`, that row
3100
+ * is terminal (a later SUCCESSFUL confirm cannot overwrite it), and the refund
3101
+ * classifier maps it to \`no_match\`: "the partner received it and said no", so
3102
+ * the refund is DENIED and the consumer is emailed that it did not land.
3103
+ *
3104
+ * "I could not read it" is not that statement. It is a fact about the
3105
+ * partner's key configuration and has nothing to do with the consumer. Sending
3106
+ * it as \`failed\` charged a consumer five dollars, told them a provider had
3107
+ * rejected them, and refused them a refund, because a key was wrong in a
3108
+ * process they have never heard of.
3109
+ *
3110
+ * ## Why there is no retry to fall back on
3111
+ *
3112
+ * \`draw-loop.ts\` used to reason that "OneAddress's own retry of the dispatch is
3113
+ * the recovery path". That is true in write-through, where an unopenable
3114
+ * dispatch answers 422 and the dispatch row stays \`failed\` for the retry cron
3115
+ * to pick up. It is FALSE here, for the reason this mode exists: the receiver
3116
+ * already answered 200 at arrival, so the dispatch row is \`delivered\`, and
3117
+ * \`retry-webhooks\` only ever re-sends rows that are \`pending\` or \`failed\`.
3118
+ * Nothing retries. The confirm was the only remaining signal and it was being
3119
+ * spent on a verdict that was not true.
3120
+ *
3121
+ * ## What happens instead
3122
+ *
3123
+ * The item stays unacknowledged, so it stays in the undrawn count, which the
3124
+ * dashboard already reports as a fault - and that is the behaviour recorded in
3125
+ * the new-matter register for this mode: an undrawn update is held INDEFINITELY
3126
+ * and reported as a fault, deliberately unlike the quarantine's 30-day purge,
3127
+ * because an undrawn update is a consumer whose address did not land and ageing
3128
+ * it out loses it silently.
3129
+ *
3130
+ * Re-stamping \`drawn_at\` is the backoff and costs no new mechanism: \`drawable\`
3131
+ * re-offers an item once its stamp is older than the lease, so the next attempt
3132
+ * is one lease away and the poll loop cannot spin on a key that is still wrong.
3133
+ * When the key is fixed the next draw applies it, with no operator command -
3134
+ * which is the half that \`[r]\` has to be pressed for in write-through.
3135
+ *
3136
+ * Returns null for an unknown id or one already terminally acknowledged, the
3137
+ * same tolerance \`acknowledge\` has and for the same reason.
3138
+ */
3139
+ export function hold(
3140
+ id: string,
3141
+ detail: string | null,
3142
+ leaseSeconds: number,
3143
+ ): { attempts: number; retryInSeconds: number } | null {
3144
+ const row = db.prepare(
3145
+ 'SELECT applied_at, attempts FROM inbox WHERE id = ?',
3146
+ ).get(id) as { applied_at: string | null; attempts: number } | undefined;
3147
+ if (!row) return null;
3148
+ if (row.applied_at !== null) return null;
3149
+
3150
+ const attempts = (row.attempts ?? 0) + 1;
3151
+ // \u2500\u2500 THE BACKOFF IS NOT THE LEASE, AND THAT IS THE POINT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3152
+ //
3153
+ // The lease is 300s and is generous ON PURPOSE: re-offering a DRAWN item too
3154
+ // early risks applying the same update twice. A held item carries none of
3155
+ // that risk, because nothing was applied - it could not even be opened. So
3156
+ // reusing the lease would make a partner who has just fixed their key wait
3157
+ // five minutes for a safety property that is not in play.
3158
+ //
3159
+ // \`drawable\` re-offers a row once \`drawn_at < now - lease\`, so a stamp of
3160
+ // \`now - lease + backoff\` becomes drawable in exactly \`backoff\` seconds.
3161
+ // That is the whole mechanism: no column, no second query, no timer. Pinned
3162
+ // by a test that checks it is NOT drawable before the backoff and IS after,
3163
+ // because arithmetic written backwards still produces a plausible timestamp.
3164
+ //
3165
+ // Capped at the lease so a repeatedly-failing item can never retry FASTER
3166
+ // than a crashed connector's item, which would be the wrong way round.
3167
+ const retryInSeconds = Math.min(HELD_RETRY_BASE_SECONDS * attempts, leaseSeconds);
3168
+ const stamp = new Date(Date.now() - leaseSeconds * 1000 + retryInSeconds * 1000).toISOString();
3169
+ db.prepare(
3170
+ 'UPDATE inbox SET attempts = ?, last_error = ?, drawn_at = ? WHERE id = ?',
3171
+ ).run(attempts, detail?.slice(0, 500) ?? null, stamp, id);
3172
+ report.warn(
3173
+ \`[inbox] \${id} HELD after \${attempts} attempt(s), retrying in \${retryInSeconds}s, not failed: \` +
3174
+ \`\${detail ?? 'the connector could not open it'}\`,
3175
+ );
3176
+ return { attempts, retryInSeconds };
3177
+ }
3178
+
3179
+ /**
3180
+ * The held items, for the dashboard's faults band.
3181
+ *
3182
+ * Separate from \`undrawnCount\` because the two mean different things to an
3183
+ * operator: undrawn is "your connector has not got to it yet", held is "your
3184
+ * connector tried and cannot". Only the second one needs somebody to do
3185
+ * something, and it is the one carrying the message that says what.
3186
+ */
3187
+ export function heldItems(): { id: string; attempts: number; lastError: string | null }[] {
3188
+ return db.prepare(
3189
+ \`SELECT id, attempts, last_error AS lastError
3190
+ FROM inbox
3191
+ WHERE applied_at IS NULL AND attempts > 0
3192
+ ORDER BY received_at\`,
3193
+ ).all() as unknown as { id: string; attempts: number; lastError: string | null }[];
3194
+ }
3195
+
2990
3196
  /** How many updates are sitting here unapplied. Shown as a fault when non-zero. */
2991
3197
  export function undrawnCount(): number {
2992
3198
  const row = db.prepare(
@@ -3220,6 +3426,38 @@ export function dispatchKey(dispatchId: string | null, rawBody: string): string
3220
3426
  */
3221
3427
  ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
3222
3428
 
3429
+ /**
3430
+ * When the operator decided this one is not going to be applied.
3431
+ *
3432
+ * ## Why a dashboard needs this at all
3433
+ *
3434
+ * The conformance suite sends a dispatch that CANNOT be opened, deliberately:
3435
+ * check-11 wraps a session key to a throwaway key pair and passes only if the
3436
+ * receiver reports the failure instead of answering \`200 { ok: true }\`. Holding
3437
+ * it is the correct behaviour and is how the check is passed. The receiver then
3438
+ * draws a red FAULTS panel about it for the rest of the database's life, and a
3439
+ * partner who has just been told they passed is looking at a permanent fault
3440
+ * they cannot clear. Reported from a real run.
3441
+ *
3442
+ * ## Why the receiver does NOT recognise the probe itself
3443
+ *
3444
+ * The tempting fix is to spot the probe and not count it. Everything that
3445
+ * identifies one comes from the SENDER: the dispatch id is a header, and the
3446
+ * ciphertext is opaque by construction. Suppressing an operator's fault panel on
3447
+ * a value the sender chose is a way to hide a real held dispatch from the person
3448
+ * whose job is to notice it, and it would be reachable by anyone holding the
3449
+ * webhook secret. The signal stays; the OPERATOR gets a way to answer it.
3450
+ *
3451
+ * ## Why the payload goes with it
3452
+ *
3453
+ * Dismissing says "I am not applying this". A held payload is a consumer's
3454
+ * encrypted address sitting on a third party's disk, kept only because applying
3455
+ * it later is still on the table. Once it is not, the reason to keep it is gone,
3456
+ * so \`raw_body\` is emptied in the same statement. That also makes dismissal
3457
+ * mean what it says: \`[r]\` cannot quietly bring it back.
3458
+ */
3459
+ ensureColumn('quarantine', 'dismissed_at', 'TEXT');
3460
+
3223
3461
  export interface QuarantineInput {
3224
3462
  dispatchId: string | null;
3225
3463
  event: string;
@@ -3283,7 +3521,7 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
3283
3521
  \`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
3284
3522
  received_at, last_error, attempts
3285
3523
  FROM quarantine
3286
- WHERE replayed_at IS NULL
3524
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL
3287
3525
  ORDER BY received_at
3288
3526
  LIMIT ?\`,
3289
3527
  ).all(limit) as unknown as HeldDispatch[];
@@ -3292,7 +3530,7 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
3292
3530
  /** How many dispatches are held. Shown on the dashboard. */
3293
3531
  export function heldCount(): number {
3294
3532
  const row = db.prepare(
3295
- 'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL',
3533
+ 'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL AND dismissed_at IS NULL',
3296
3534
  ).get() as { n: number };
3297
3535
  return row.n;
3298
3536
  }
@@ -3309,7 +3547,7 @@ export function heldSummary(): string[] {
3309
3547
  \`SELECT reason, key_id, count(*) AS n,
3310
3548
  max(attempts) AS tries, min(received_at) AS oldest
3311
3549
  FROM quarantine
3312
- WHERE replayed_at IS NULL
3550
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL
3313
3551
  GROUP BY reason, key_id
3314
3552
  ORDER BY n DESC\`,
3315
3553
  ).all() as unknown as {
@@ -3353,6 +3591,38 @@ export function markReplayFailed(id: string, error: string): void {
3353
3591
  .run(error.slice(0, 500), id);
3354
3592
  }
3355
3593
 
3594
+ /**
3595
+ * The operator's answer to the fault panel: not applying these, stop counting them.
3596
+ *
3597
+ * Takes everything currently held rather than one row, because the panel groups
3598
+ * by cause and offers no way to point at a single dispatch. The realistic use is
3599
+ * one decision about one cause, which is the shape the panel already shows.
3600
+ *
3601
+ * \`raw_body\` is emptied in the same statement. See the \`dismissed_at\` docstring
3602
+ * for why: once the operator has said it will not be applied, a consumer's
3603
+ * encrypted address is being kept for no reason, and a dismissal that leaves the
3604
+ * payload behind is one \`[r]\` away from not being a dismissal.
3605
+ *
3606
+ * The row itself STAYS. It is the record that a dispatch arrived and was
3607
+ * consciously dropped, which is the fact an audit wants, and \`purgeQuarantine\`
3608
+ * ages it out on the same window as everything else.
3609
+ */
3610
+ export function dismissHeld(): number {
3611
+ const info = db.prepare(
3612
+ \`UPDATE quarantine
3613
+ SET dismissed_at = ?, raw_body = ''
3614
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL\`,
3615
+ ).run(new Date().toISOString());
3616
+ const n = Number(info.changes ?? 0);
3617
+ if (n > 0) {
3618
+ report.warn(
3619
+ \`[quarantine] dismissed \${n} held dispatch(es). They will NOT be applied and their \` +
3620
+ 'payloads have been discarded. OneAddress was already told each one failed.',
3621
+ );
3622
+ }
3623
+ return n;
3624
+ }
3625
+
3356
3626
  /**
3357
3627
  * Drop held payloads past the retention window, replayed or not.
3358
3628
  *
@@ -3838,6 +4108,16 @@ export type ReceiverConfig = {
3838
4108
  oneAddressApi: string;
3839
4109
  verifiesAccountReference: boolean;
3840
4110
  mode: ReceiverMode;
4111
+ /**
4112
+ * The webhook URL registered with OneAddress: where dispatches actually
4113
+ * arrive from the internet.
4114
+ *
4115
+ * Written by the wizard, which is the only thing that knows it - the receiver
4116
+ * binds a local port and has no idea what hostname reaches it. Empty disables
4117
+ * the reachability check rather than guessing, because a wrong URL here would
4118
+ * report a healthy receiver as unreachable, which is worse than silence.
4119
+ */
4120
+ publicUrl: string;
3841
4121
  };
3842
4122
 
3843
4123
  const DEFAULTS: ReceiverConfig = {
@@ -3852,6 +4132,8 @@ const DEFAULTS: ReceiverConfig = {
3852
4132
  // running and a key living somewhere else; a receiver that silently switched
3853
4133
  // into it would accept dispatches nothing ever collects.
3854
4134
  mode: 'write-through',
4135
+ // No guess. See the field's docstring.
4136
+ publicUrl: '',
3855
4137
  };
3856
4138
 
3857
4139
  function parseMode(value: unknown): ReceiverMode | undefined {
@@ -3870,6 +4152,7 @@ function loadConfigFile(): Partial<ReceiverConfig> {
3870
4152
  if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
3871
4153
  if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
3872
4154
  if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
4155
+ if (typeof parsed.publicUrl === 'string') out.publicUrl = parsed.publicUrl;
3873
4156
  const mode = parseMode(parsed.mode);
3874
4157
  if (mode) out.mode = mode;
3875
4158
  return out;
@@ -3893,6 +4176,7 @@ export const config: ReceiverConfig = {
3893
4176
  // up as "not the mode I asked for" rather than as a receiver that will not
3894
4177
  // start. Silent is the thing to avoid, not strict.
3895
4178
  mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
4179
+ publicUrl: stripTrailingSlash(process.env.PUBLIC_URL || fromFile.publicUrl || DEFAULTS.publicUrl),
3896
4180
  };
3897
4181
 
3898
4182
  report.info(
@@ -3900,6 +4184,188 @@ report.info(
3900
4184
  ', oneAddressApi=' + config.oneAddressApi +
3901
4185
  ', verifiesAccountReference=' + config.verifiesAccountReference + ')',
3902
4186
  );
4187
+ `
4188
+ },
4189
+ {
4190
+ name: "src/reachable.ts",
4191
+ content: `/**
4192
+ * IS ANYTHING ON THE INTERNET STILL REACHING THIS RECEIVER?
4193
+ *
4194
+ * ## The problem, and why the receiver cannot simply notice
4195
+ *
4196
+ * A partner running behind a tunnel stops the tunnel - closes the laptop, ends
4197
+ * the terminal, lets the trial lapse - and the receiver notices nothing. That is
4198
+ * not an oversight: the tunnel DIALS IN to this process. Nothing here holds a
4199
+ * connection outward, so from inside, "the tunnel is down" and "nobody sent me
4200
+ * anything" are the same observation, which is silence. And silence is not a
4201
+ * fault: a small partner legitimately goes days between dispatches, so a
4202
+ * watchdog that fired on quiet would cry wolf at exactly the partners least able
4203
+ * to tell the difference.
4204
+ *
4205
+ * Meanwhile OneAddress is failing to deliver, marking the endpoint unhealthy,
4206
+ * and the only place that says so is a portal nobody has open.
4207
+ *
4208
+ * ## How this answers it
4209
+ *
4210
+ * The receiver fetches ITS OWN PUBLIC URL and checks that a nonce minted at
4211
+ * startup comes back. Only this running process knows that value, which is what
4212
+ * makes the answer unambiguous.
4213
+ *
4214
+ * "DID ANYTHING ANSWER" IS NOT GOOD ENOUGH, and that is the whole reason for
4215
+ * the nonce. A stopped Cloudflare tunnel does not refuse the connection: the
4216
+ * edge is still there and serves an error page (1033, or a 502). That is a
4217
+ * perfectly valid HTTP response, so a check written \`if (res.ok)\` - or even
4218
+ * \`if (res.status < 500)\` - reports a dead tunnel as healthy. A check that
4219
+ * demands the nonce cannot be satisfied by anything except this process.
4220
+ *
4221
+ * ## What it deliberately does not do
4222
+ *
4223
+ * It does not fail the receiver, retry aggressively, or call OneAddress. It is
4224
+ * a statement on the dashboard and a log line. A receiver that shut itself down
4225
+ * because it could not see itself would turn a DNS blip into an outage, and the
4226
+ * failure it is reporting is one only a human can fix.
4227
+ *
4228
+ * It is also not a security control. The nonce proves identity of the process,
4229
+ * not authorisation: the endpoint returns it to anyone who asks. It is a random
4230
+ * value with no meaning outside this check, and knowing it grants nothing - the
4231
+ * webhook still requires a valid HMAC.
4232
+ */
4233
+ import { randomBytes } from 'node:crypto';
4234
+ import { config } from './config.js';
4235
+ import { report } from './report.js';
4236
+
4237
+ /** Minted once per process. See the header: this is what makes the answer
4238
+ * unambiguous, and it is deliberately not a secret. */
4239
+ export const ALIVE_NONCE = randomBytes(16).toString('hex');
4240
+
4241
+ /** The path the check hits. Deliberately NOT \`/webhook\`: that is POST-only and
4242
+ * would 404 or 405 through a perfectly healthy tunnel. */
4243
+ export const ALIVE_PATH = '/oa-receiver-alive';
4244
+
4245
+ export type Reachability =
4246
+ | { state: 'unknown'; detail: string }
4247
+ | { state: 'reachable'; checkedAt: string }
4248
+ | { state: 'unreachable'; detail: string; since: string };
4249
+
4250
+ let current: Reachability = { state: 'unknown', detail: 'not checked yet' };
4251
+
4252
+ /** What the dashboard renders. Never throws. */
4253
+ export function reachability(): Reachability {
4254
+ return current;
4255
+ }
4256
+
4257
+ /**
4258
+ * The ORIGIN to probe, or null when there is nothing usable to probe.
4259
+ *
4260
+ * TWO THINGS THIS FIXES, both found by reading the value the wizard actually
4261
+ * writes rather than the one this function wanted.
4262
+ *
4263
+ * 1. \`publicUrl\` is the REGISTERED WEBHOOK URL, so it ends in \`/webhook\`.
4264
+ * Appending the probe path to it gives \`\u2026/webhook/oa-receiver-alive\`, which
4265
+ * 404s through a perfectly healthy tunnel and would report every working
4266
+ * receiver as unreachable. The origin is what is wanted.
4267
+ * 2. The scaffold substitutes a PLACEHOLDER (\`<your-webhook-url>\`) when setup
4268
+ * ran without a registered URL. Probing that fails forever, so a partner who
4269
+ * skipped registration would get a permanent red panel about a fault they do
4270
+ * not have.
4271
+ *
4272
+ * Anything that is not an absolute http(s) URL therefore reads as NOT
4273
+ * CONFIGURED rather than as unreachable: the failure mode to avoid here is
4274
+ * crying wolf, since the whole value of the check is that it is believed.
4275
+ */
4276
+ export function probeOrigin(raw: string): string | null {
4277
+ if (!raw) return null;
4278
+ try {
4279
+ const u = new URL(raw);
4280
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
4281
+ return u.origin;
4282
+ } catch {
4283
+ return null;
4284
+ }
4285
+ }
4286
+
4287
+ /**
4288
+ * One check. Returns the new state rather than only setting it, so a test can
4289
+ * drive it directly without reaching into module state.
4290
+ */
4291
+ export async function checkReachable(fetchImpl: typeof fetch = fetch): Promise<Reachability> {
4292
+ const origin = probeOrigin(config.publicUrl);
4293
+ if (!origin) {
4294
+ current = {
4295
+ state: 'unknown',
4296
+ detail: config.publicUrl
4297
+ ? 'publicUrl in oneaddress.config.json is not a usable http(s) URL'
4298
+ : 'no publicUrl in oneaddress.config.json',
4299
+ };
4300
+ return current;
4301
+ }
4302
+
4303
+ const url = \`\${origin}\${ALIVE_PATH}\`;
4304
+ const since = current.state === 'unreachable' ? current.since : new Date().toISOString();
4305
+
4306
+ try {
4307
+ const res = await fetchImpl(url, {
4308
+ method: 'GET',
4309
+ // Short: this is a liveness probe, not a download. A tunnel that takes
4310
+ // longer than this to answer is already failing dispatches, which time
4311
+ // out on OneAddress's side too.
4312
+ signal: AbortSignal.timeout(10_000),
4313
+ headers: { 'cache-control': 'no-cache' },
4314
+ });
4315
+ const body = await res.text().catch(() => '');
4316
+ // THE NONCE, not the status. See the header: a stopped tunnel answers with
4317
+ // a real HTTP response carrying an error page.
4318
+ if (body.trim() === ALIVE_NONCE) {
4319
+ current = { state: 'reachable', checkedAt: new Date().toISOString() };
4320
+ return current;
4321
+ }
4322
+ current = {
4323
+ state: 'unreachable',
4324
+ detail: \`\${url} answered HTTP \${res.status} but not from this receiver (tunnel down, or the URL points elsewhere)\`,
4325
+ since,
4326
+ };
4327
+ } catch (err) {
4328
+ current = {
4329
+ state: 'unreachable',
4330
+ detail: \`\${url} could not be reached: \${err instanceof Error ? err.message : String(err)}\`,
4331
+ since,
4332
+ };
4333
+ }
4334
+ return current;
4335
+ }
4336
+
4337
+ /**
4338
+ * Check on a timer, and say so ONCE per transition rather than every tick.
4339
+ *
4340
+ * A line every five minutes for a tunnel that has been down all night is how a
4341
+ * log stops being read. The transition is the news.
4342
+ */
4343
+ export function startReachabilityWatch(everyMs = 5 * 60_000): NodeJS.Timeout | null {
4344
+ if (!probeOrigin(config.publicUrl)) {
4345
+ report.info('[reachable] no usable publicUrl configured, so this receiver cannot check whether the internet can reach it.');
4346
+ return null;
4347
+ }
4348
+ let last: Reachability['state'] = 'unknown';
4349
+ const tick = (): void => {
4350
+ void checkReachable().then((r) => {
4351
+ if (r.state === last) return;
4352
+ last = r.state;
4353
+ if (r.state === 'unreachable') {
4354
+ report.error(\`[reachable] NOTHING IS REACHING THIS RECEIVER. \${r.detail}. Dispatches cannot arrive while this is true.\`);
4355
+ } else if (r.state === 'reachable') {
4356
+ report.info('[reachable] your public URL reaches this receiver.');
4357
+ }
4358
+ });
4359
+ };
4360
+ // A first check soon after startup, not immediately: a tunnel started
4361
+ // alongside the receiver needs a moment before it answers, and reporting it
4362
+ // down on second one would be wrong every single time.
4363
+ const first = setTimeout(tick, 15_000);
4364
+ first.unref?.();
4365
+ const timer = setInterval(tick, everyMs);
4366
+ timer.unref?.();
4367
+ return timer;
4368
+ }
3903
4369
  `
3904
4370
  },
3905
4371
  {
@@ -4177,6 +4643,7 @@ export interface CustomerStore {
4177
4643
  import { readFileSync } from 'node:fs';
4178
4644
  import { join } from 'node:path';
4179
4645
  import { report } from './report.js';
4646
+ import { config } from './config.js';
4180
4647
  import db, { accountKey, dec, enc, encrypted, ensureColumn, isEncrypted, once } from './db.js';
4181
4648
  import type {
4182
4649
  AccountVerdict,
@@ -4356,8 +4823,30 @@ function loadRoster(): RosterEntry[] {
4356
4823
  }
4357
4824
  }
4358
4825
 
4359
- const ROSTER = loadRoster();
4360
- {
4826
+ /**
4827
+ * IN INBOX MODE NOTHING IS SEEDED, and that is a privacy fix rather than a
4828
+ * saving.
4829
+ *
4830
+ * This block runs on IMPORT, and \`server.ts\` imports this module in both modes
4831
+ * because it picks the store at runtime. So an inbox receiver - the process that
4832
+ * exists specifically so it cannot read what it holds - was writing a full
4833
+ * customer roster to its own database on startup: names, account numbers and
4834
+ * the address on file for each, in the clear.
4835
+ *
4836
+ * The dispatches were never the leak. Those are stored as the ciphertext they
4837
+ * arrived as, and an end-to-end run confirms the new address appears nowhere in
4838
+ * the receiver's database file. The ROSTER was, and it was there before a
4839
+ * single dispatch arrived. Found by searching the receiver's database bytes for
4840
+ * a customer name after that run, which is the only check that would have found
4841
+ * it: every other one asks the receiver what it thinks it is holding.
4842
+ *
4843
+ * In inbox mode the connector owns the roster and answers every account
4844
+ * question over the loopback channel, so the receiver needs none of it.
4845
+ */
4846
+ const ROSTER = config.mode === 'inbox' ? [] : loadRoster();
4847
+ if (config.mode === 'inbox') {
4848
+ report.info('[store] no roster held: in inbox mode your connector owns customer records');
4849
+ } else {
4361
4850
  const upsert = db.prepare(\`
4362
4851
  INSERT INTO customers (account_key, account_number, name, address)
4363
4852
  VALUES ($account_key, $account_number, $name, $address)
@@ -4431,6 +4920,66 @@ export function customerCount(): number {
4431
4920
  return (db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }).n;
4432
4921
  }
4433
4922
 
4923
+ /** One applied change: what this receiver held before, and what it holds now. */
4924
+ export interface AddressChange {
4925
+ account_number: string;
4926
+ name: string;
4927
+ prev_address: Address | null;
4928
+ address: Address;
4929
+ recorded_at: string;
4930
+ }
4931
+
4932
+ /**
4933
+ * The applied changes, newest first, decrypted and joined back to the customer.
4934
+ *
4935
+ * ## Why this is exported rather than left to \`sqlite3\`
4936
+ *
4937
+ * Because \`sqlite3 data.db\` cannot answer it. Every text column here is
4938
+ * AES-GCM ciphertext whenever the database has a password, which is the state
4939
+ * this receiver asks for on its first run and reports in green on the
4940
+ * dashboard. So the one question a partner has after their first real dispatch
4941
+ * - "show me it actually changed" - had no answer short of writing code against
4942
+ * the store. The dashboard's LAST CHANGE panel shows the most recent one and
4943
+ * nothing shows the rest.
4944
+ *
4945
+ * \`prev_address\` is NULL for a row written before the column existed
4946
+ * (\`ensureColumn\` defaults it to \`'{}'\`, which decodes to an empty object, not
4947
+ * to an address) and for the first change on an account that had no address on
4948
+ * file. Those are different facts and both are honestly "nothing to compare
4949
+ * against", so both come back as null rather than as an empty address.
4950
+ */
4951
+ export function addressHistory(limit = 20): AddressChange[] {
4952
+ const rows = db.prepare(
4953
+ \`SELECT h.prev_address AS prev_address,
4954
+ h.address AS address,
4955
+ h.recorded_at AS recorded_at,
4956
+ c.account_number AS account_number,
4957
+ c.name AS name
4958
+ FROM address_history h
4959
+ LEFT JOIN customers c ON c.account_key = h.account_key
4960
+ ORDER BY h.id DESC
4961
+ LIMIT ?\`,
4962
+ ).all(limit) as unknown[];
4963
+
4964
+ return rows.map((raw) => {
4965
+ const r = raw as Record<string, string | null>;
4966
+ const prevJson = dec('address', r.prev_address);
4967
+ let prev: Address | null = null;
4968
+ try {
4969
+ const parsed = prevJson ? JSON.parse(prevJson) as Address : null;
4970
+ // \`{}\` is the column default, not an address anyone held.
4971
+ prev = parsed && Object.keys(parsed).length > 0 ? parsed : null;
4972
+ } catch { prev = null; }
4973
+ return {
4974
+ account_number: dec('account_number', r.account_number) ?? '(unknown)',
4975
+ name: dec('name', r.name) ?? '(unknown)',
4976
+ prev_address: prev,
4977
+ address: JSON.parse(dec('address', r.address) ?? '{}') as Address,
4978
+ recorded_at: r.recorded_at ?? '',
4979
+ };
4980
+ });
4981
+ }
4982
+
4434
4983
  /** Is the file on disk protected? Surfaced in the dashboard, in both states. */
4435
4984
  export const storeEncrypted = encrypted;
4436
4985
 
@@ -4623,7 +5172,8 @@ import {
4623
5172
  // exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
4624
5173
  // the protocol layer changes.
4625
5174
  import { store as writeThroughStore } from './store.js';
4626
- import { connectorStore, setCurrentRawBody } from './connector-store.js';
5175
+ import { ALIVE_NONCE, ALIVE_PATH, startReachabilityWatch } from './reachable.js';
5176
+ import { ConnectorUnreachableError, connectorStore, setCurrentRawBody } from './connector-store.js';
4627
5177
  import { noteChange } from './tui.js';
4628
5178
  import { config } from './config.js';
4629
5179
  import { safeOneAddressCallbackUrl } from './callback-url.js';
@@ -4761,12 +5311,22 @@ report.info(
4761
5311
  // lock out every install that has never set a passphrase - but choosing it by
4762
5312
  // not being asked is not a choice, so the absence is stated as loudly as the
4763
5313
  // presence.
5314
+ //
5315
+ // AND IN INBOX MODE IT SAYS SOMETHING ELSE, for the same reason the keys line
5316
+ // above does. There are no customer records in this mode - the connector holds
5317
+ // them - so "Customer records are readable by anyone who can read the file" was
5318
+ // alarming about data that is not here, and sent the partner to set a passphrase
5319
+ // protecting nothing. \`connectorStore.encrypted\` was already hardcoded false
5320
+ // with a comment saying exactly that; this line had simply never read it.
4764
5321
  report.info(
4765
- store.encrypted
4766
- ? \`[startup] store: \${store.name} (encrypted at rest)\`
4767
- : \`[startup] store: \${store.name} \u2014 NOT ENCRYPTED AT REST. \` +
4768
- 'Customer records are readable by anyone who can read the file. ' +
4769
- 'Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` in a terminal to be asked.',
5322
+ config.mode === 'inbox'
5323
+ ? \`[startup] store: \${store.name} \u2014 no customer records here by design. This database holds \` +
5324
+ 'dispatch ciphertext your connector decrypts, and the confirm queue.'
5325
+ : store.encrypted
5326
+ ? \`[startup] store: \${store.name} (encrypted at rest)\`
5327
+ : \`[startup] store: \${store.name} \u2014 NOT ENCRYPTED AT REST. \` +
5328
+ 'Customer records are readable by anyone who can read the file. ' +
5329
+ 'Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` in a terminal to be asked.',
4770
5330
  );
4771
5331
 
4772
5332
  // In-memory dedup cache. Records a dispatch id only once it has been fully
@@ -4806,11 +5366,17 @@ async function confirmToOneAddress(dispatchId: number, status: ConfirmStatus): P
4806
5366
  // and it drops non-numeric dispatch ids, so by the time a row is drained it
4807
5367
  // is a real dispatch. Keeping a second copy of that rule would mean a probe
4808
5368
  // could be queued forever and silently skipped on every drain.
5369
+ // REPORTED, not "Applied". The note is fixed text sent with EVERY confirm,
5370
+ // including \`failed\` ones - so a dispatch the connector could not open, or
5371
+ // one refused because the account reference matched nobody, arrived at
5372
+ // OneAddress reading "Applied by the OneAddress webhook receiver". That is
5373
+ // read by whoever is working out what happened, and it said the opposite of
5374
+ // \`status\` sitting beside it.
4809
5375
  const bodyStr = JSON.stringify({
4810
5376
  dispatch_id: dispatchId,
4811
5377
  partner_id: PARTNER_ID,
4812
5378
  status,
4813
- note: 'Applied by the OneAddress webhook receiver',
5379
+ note: 'Reported by the OneAddress webhook receiver',
4814
5380
  });
4815
5381
  const ts = String(Math.floor(Date.now() / 1000));
4816
5382
  const sig = createHmac('sha256', CONFIRM_SECRET).update(\`\${ts}.\${bodyStr}\`).digest('hex');
@@ -4961,7 +5527,19 @@ app.post('/webhook', async (req: Request, res: Response) => {
4961
5527
  // and the customer records, so it answers. \`connectorStore\` sends the raw
4962
5528
  // body; the arguments below are the contract's shape, not its source.
4963
5529
  if (config.mode === 'inbox') {
4964
- const status = await store.verifyAccount(null, '', []);
5530
+ let status: Awaited<ReturnType<typeof store.verifyAccount>>;
5531
+ try {
5532
+ status = await store.verifyAccount(null, '', []);
5533
+ } catch (err) {
5534
+ // 503 RATHER THAN A VERDICT. Every verdict is a claim about the
5535
+ // customer's record; "the connector is down" is not one of them. A
5536
+ // non-2xx is what OneAddress reads as \`unreachable\`, which the consumer
5537
+ // sees as a transient network problem instead of "no account found".
5538
+ if (err instanceof ConnectorUnreachableError) {
5539
+ return res.status(503).json({ ok: false, error: 'connector_unreachable' });
5540
+ }
5541
+ throw err;
5542
+ }
4965
5543
  report.info(\`[webhook] account.verify \u2192 \${status} (answered by the connector)\`);
4966
5544
  return res.status(200).json({ status });
4967
5545
  }
@@ -5090,7 +5668,17 @@ app.post('/webhook', async (req: Request, res: Response) => {
5090
5668
  if (config.mode === 'inbox' && event === 'address.verify') {
5091
5669
  // The contract's arguments, not its source: \`connectorStore\` sends the raw
5092
5670
  // body it was parked with and ignores these.
5093
- const result = await store.verifyAddress({ email: null, name: '' }, {});
5671
+ let result: Awaited<ReturnType<typeof store.verifyAddress>>;
5672
+ try {
5673
+ result = await store.verifyAddress({ email: null, name: '' }, {});
5674
+ } catch (err) {
5675
+ // As above, and the callback is deliberately NOT posted: a verdict posted
5676
+ // here would be a claim about a record nobody read.
5677
+ if (err instanceof ConnectorUnreachableError) {
5678
+ return res.status(503).json({ ok: false, error: 'connector_unreachable' });
5679
+ }
5680
+ throw err;
5681
+ }
5094
5682
  report.info(\`[webhook] address.verify \u2192 \${result} (answered by the connector)\`);
5095
5683
  const refused = await postVerifyVerdict(result);
5096
5684
  if (refused) return refused;
@@ -5380,9 +5968,26 @@ app.get('/health', (_req, res) => res.json({
5380
5968
  encryptedAtRest: store.encrypted,
5381
5969
  }));
5382
5970
 
5383
- app.listen(PORT, () =>
5384
- report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`),
5385
- );
5971
+ /**
5972
+ * The reachability probe's target. Returns this process's startup nonce as
5973
+ * plain text, and nothing else.
5974
+ *
5975
+ * WHY A NONCE AND NOT \`/health\`. A stopped Cloudflare tunnel still ANSWERS -
5976
+ * the edge serves a 1033 or a 502 error page - so a check that accepts any HTTP
5977
+ * response reports a dead tunnel as healthy. Only this running process knows
5978
+ * this value, so receiving it back is proof the whole path is open. See
5979
+ * \`src/reachable.ts\`.
5980
+ *
5981
+ * NOT A SECRET, and nothing is gated on it: it proves which process answered,
5982
+ * not who asked. The webhook still requires a valid HMAC.
5983
+ */
5984
+ app.get(ALIVE_PATH, (_req, res) => res.type('text/plain').send(ALIVE_NONCE));
5985
+
5986
+ app.listen(PORT, () => {
5987
+ report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`);
5988
+ // Started AFTER listen, or the first probe races the server it is probing.
5989
+ startReachabilityWatch();
5990
+ });
5386
5991
 
5387
5992
  /**
5388
5993
  * Drain the confirm queue, forever.
@@ -5425,121 +6030,343 @@ setInterval(() => {
5425
6030
  * dispatch is verified, parsed, decrypted, applied and confirmed by exactly the
5426
6031
  * code a live one is.
5427
6032
  *
5428
- * The body is byte-for-byte what arrived; only the timestamp and signature are
5429
- * new, because the original pair is outside the \xB15-minute window by the time
5430
- * anyone has fixed anything. We hold the secret, so re-signing is not a bypass:
5431
- * it is the same proof, re-stated now.
6033
+ * The body is byte-for-byte what arrived; only the timestamp and signature are
6034
+ * new, because the original pair is outside the \xB15-minute window by the time
6035
+ * anyone has fixed anything. We hold the secret, so re-signing is not a bypass:
6036
+ * it is the same proof, re-stated now.
6037
+ */
6038
+ async function redeliver(rawBody: string, heldDispatchId: string | null): Promise<void> {
6039
+ const ts = String(Math.floor(Date.now() / 1000));
6040
+ const sig = createHmac('sha256', WEBHOOK_SECRET).update(\`\${ts}.\${rawBody}\`).digest('hex');
6041
+ // THE ID THE DISPATCH ARRIVED WITH, handed over by the row being replayed.
6042
+ // Re-deriving it from the body is what made one held dispatch become two.
6043
+ const dispatchId = (heldDispatchId ?? '').trim();
6044
+
6045
+ const res = await fetch(\`http://127.0.0.1:\${PORT}/webhook\`, {
6046
+ method: 'POST',
6047
+ headers: {
6048
+ 'Content-Type': 'application/json',
6049
+ 'X-OneAddress-Timestamp': ts,
6050
+ 'X-OneAddress-Signature': sig,
6051
+ ...(dispatchId ? { 'X-OneAddress-Dispatch': dispatchId } : {}),
6052
+ },
6053
+ body: rawBody,
6054
+ });
6055
+ const text = (await res.text().catch(() => '')).slice(0, 200);
6056
+ if (!res.ok) throw new Error(\`HTTP \${res.status} \${text}\`);
6057
+ // A 200 is not on its own success here: the handler answers \`ok: false\` with
6058
+ // 200 when it REFUSES a dispatch (an account reference matching nobody), and
6059
+ // treating that as applied would clear the row for an update that was never
6060
+ // stored.
6061
+ if (!text.includes('"ok":true')) throw new Error(\`refused: \${text}\`);
6062
+ }
6063
+
6064
+ /** Apply everything held. Bound to [r] on the dashboard and run once at boot. */
6065
+ export async function replayQuarantined(): Promise<{ applied: number; failed: number }> {
6066
+ return replayHeld(redeliver);
6067
+ }
6068
+
6069
+ /**
6070
+ * Try the backlog once, a moment after boot.
6071
+ *
6072
+ * Because the realistic sequence is: a key is wrong, dispatches pile up, the
6073
+ * partner edits \`.env\`, the partner restarts. Making them find a command after
6074
+ * that is making the recovery depend on reading documentation at the exact
6075
+ * moment they are least inclined to. The delay lets \`listen\` settle, since this
6076
+ * goes back in through the port.
6077
+ */
6078
+ // \u2500\u2500 The connector channel, in inbox mode only \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
6079
+ //
6080
+ // Wired here rather than in draw-api.ts because this file owns the confirm
6081
+ // queue: an acknowledgement from the connector is the moment OneAddress gets
6082
+ // told, and that has to go through the same durable queue every other confirm
6083
+ // uses so a failure to reach OneAddress is retried rather than lost.
6084
+ if (config.mode === 'inbox') {
6085
+ setAcknowledgementHandler((dispatchId, outcome) => {
6086
+ queueConfirm(dispatchId, outcome === 'applied' ? 'confirmed' : 'failed');
6087
+ recordOutcome(\`d:\${dispatchId}\`, outcome === 'applied' ? 'applied' : 'failed');
6088
+ });
6089
+ const started = startDrawApi();
6090
+ if (!started) {
6091
+ // AN INBOX WITH NO WAY TO DRAIN IT IS WORSE THAN NOT STARTING. In
6092
+ // write-through mode a missing connector token is irrelevant and the
6093
+ // receiver runs; here it means every dispatch would be accepted and held
6094
+ // with nothing able to collect it, and the consumer would be told nothing
6095
+ // for as long as that lasted.
6096
+ report.error('[startup] mode is \`inbox\` but the connector channel could not open. Refusing to start.');
6097
+ process.exit(1);
6098
+ }
6099
+ }
6100
+
6101
+ setTimeout(() => {
6102
+ if (heldCount() === 0) return;
6103
+ void replayQuarantined().catch((err: unknown) =>
6104
+ report.error('[replay] could not run at startup:', err),
6105
+ );
6106
+ }, 1_500).unref();
6107
+
6108
+ // Held payloads age out whether or not they were ever applied, which is the
6109
+ // opposite of the confirm queue one block up. The reason is what the row holds:
6110
+ // a confirm record names a dispatch, a quarantined payload is a consumer's
6111
+ // encrypted address on someone else's disk.
6112
+ const QUARANTINE_KEEP_DAYS = Number(process.env.QUARANTINE_KEEP_DAYS ?? 30);
6113
+ setInterval(() => { purgeQuarantine(QUARANTINE_KEEP_DAYS); }, 3_600_000).unref();
6114
+
6115
+ /** How many confirms are still owed, and how the dispatches went. Read by the dashboard. */
6116
+ export { pendingConfirmCount, tally };
6117
+
6118
+ /**
6119
+ * Everything the dashboard footer needs, in one call.
6120
+ *
6121
+ * One hook rather than three, because three would be three chances for the
6122
+ * footer to show figures from different moments.
6123
+ */
6124
+ export function dashboardStats(): {
6125
+ received: number;
6126
+ applied: number;
6127
+ failed: number;
6128
+ mode: string;
6129
+ awaitingConnector: number;
6130
+ oldestUndrawn: string | null;
6131
+ } {
6132
+ const t = tally();
6133
+ const inbox = config.mode === 'inbox'
6134
+ ? { count: undrawnCount(), oldest: oldestUndrawn() }
6135
+ : { count: 0, oldest: null };
6136
+ return {
6137
+ ...t,
6138
+ mode: config.mode,
6139
+ awaitingConnector: inbox.count,
6140
+ oldestUndrawn: inbox.oldest,
6141
+ };
6142
+ }
6143
+
6144
+ // Read by src/index.ts to label the dashboard. Exported rather than re-derived
6145
+ // there, so the port the UI claims is the port the server actually bound.
6146
+ export { PORT };
6147
+ export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
6148
+ `
6149
+ },
6150
+ {
6151
+ name: "src/unlock.ts",
6152
+ content: `/**
6153
+ * Opening a locked database: is it locked, and asking for the password.
6154
+ *
6155
+ * ## Why these two live in their own file
6156
+ *
6157
+ * Both are needed by \`src/index.ts\` (which prompts before starting the
6158
+ * receiver) and by \`scripts/show.ts\` (which prompts before reading the roster),
6159
+ * and a second copy of a masked-input routine is the last thing this codebase
6160
+ * needs: the comment inside \`ask\` records TWO earlier versions that echoed the
6161
+ * passphrase in clear, neither of which was caught by reading the code. One
6162
+ * implementation, driven by one test.
6163
+ *
6164
+ * NOTHING HERE IMPORTS \`db.ts\`, and that is a constraint rather than tidiness.
6165
+ * Importing the database derives its keys on import, so anything asking WHICH
6166
+ * QUESTION TO PUT has to run first. \`databaseIsLocked\` therefore opens its own
6167
+ * read-only connection.
6168
+ */
6169
+ /**
6170
+ * Has this database already been locked?
6171
+ *
6172
+ * Asked BEFORE the passphrase, and without one, so the prompt can say which of
6173
+ * two completely different things it is doing. \`db_meta.verifier\` is written the
6174
+ * first time a passphrase is set, so its presence is the whole answer.
6175
+ *
6176
+ * Read through its own connection rather than importing \`db.ts\`, which derives
6177
+ * its keys the moment it is imported and would therefore have to run BEFORE we
6178
+ * know what to ask for.
6179
+ */
6180
+ export async function databaseIsLocked(): Promise<boolean> {
6181
+ try {
6182
+ const { DatabaseSync } = await import('node:sqlite');
6183
+ const { join } = await import('node:path');
6184
+ const path = process.env.DB_PATH ?? join(process.cwd(), 'data.db');
6185
+ const db = new DatabaseSync(path, { readOnly: true });
6186
+ try {
6187
+ const row = db
6188
+ .prepare("SELECT value FROM db_meta WHERE key = 'verifier'")
6189
+ .get() as { value?: string } | undefined;
6190
+ return Boolean(row?.value);
6191
+ } finally {
6192
+ db.close();
6193
+ }
6194
+ } catch {
6195
+ // No file yet, or no db_meta table yet. Either way: not locked.
6196
+ return false;
6197
+ }
6198
+ }
6199
+
6200
+ /**
6201
+ * Ask for a passphrase without echoing it to the screen.
6202
+ *
6203
+ * A passphrase typed in clear on a shared screen, in a screen-share, or into a
6204
+ * terminal that keeps scrollback is not much of a secret. readline echoes by
6205
+ * default, so its output hook is replaced for the duration of the question.
6206
+ *
6207
+ * Degrades to a visible prompt rather than failing: on a terminal where the
6208
+ * hook is not available, being asked in the clear beats not being asked.
6209
+ */
6210
+ export async function ask(prompt: string): Promise<string> {
6211
+ process.stdout.write(prompt);
6212
+
6213
+ // NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
6214
+ // mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
6215
+ // by driving a real terminal and reading what came back. The first filtered
6216
+ // on whether the chunk contained the prompt, not knowing readline repaints
6217
+ // prompt and input together on every keystroke, so the condition was always
6218
+ // true. The second repainted the line and still leaked, because the echo was
6219
+ // never coming from that hook at all.
6220
+ //
6221
+ // Reading the keys directly removes the guessing. Raw mode turns the
6222
+ // terminal's own echo OFF, so the ONLY thing that can reach the screen is
6223
+ // what is written below: one asterisk per character, which is what a partner
6224
+ // asked for and what every other passphrase prompt does.
6225
+ const stdin = process.stdin;
6226
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
6227
+ // No terminal to control. Being asked in the clear beats not being asked,
6228
+ // and this path is only reached where nothing is watching anyway.
6229
+ const { createInterface } = await import('node:readline/promises');
6230
+ const rl = createInterface({ input: stdin, output: process.stdout });
6231
+ try {
6232
+ const answer = await rl.question('');
6233
+ return answer.trim();
6234
+ } finally { rl.close(); }
6235
+ }
6236
+
6237
+ const wasRaw = stdin.isRaw === true;
6238
+ stdin.setRawMode(true);
6239
+ stdin.resume();
6240
+ stdin.setEncoding('utf8');
6241
+
6242
+ return new Promise<string>((resolve) => {
6243
+ let typed = '';
6244
+ const restore = (): void => {
6245
+ stdin.removeListener('data', onData);
6246
+ stdin.setRawMode(wasRaw);
6247
+ stdin.pause();
6248
+ };
6249
+ const onData = (chunk: string): void => {
6250
+ for (const ch of chunk) {
6251
+ if (ch === '\\r' || ch === '\\n') {
6252
+ restore();
6253
+ process.stdout.write('\\n');
6254
+ resolve(typed.trim());
6255
+ return;
6256
+ }
6257
+ if (ch === '\\u0003') { // Ctrl+C
6258
+ restore();
6259
+ process.stdout.write('\\n');
6260
+ process.exit(130);
6261
+ }
6262
+ if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
6263
+ restore();
6264
+ process.stdout.write('\\n');
6265
+ resolve(typed.trim());
6266
+ return;
6267
+ }
6268
+ if (ch === '\\u007f' || ch === '\\b') {
6269
+ // Backspace has to move the asterisks too, or the mask stops matching
6270
+ // what is actually in the buffer and the count misleads.
6271
+ if (typed.length > 0) {
6272
+ typed = typed.slice(0, -1);
6273
+ process.stdout.write('\\b \\b');
6274
+ }
6275
+ continue;
6276
+ }
6277
+ if (ch < ' ') continue; // ignore the rest of the control range
6278
+ typed += ch;
6279
+ process.stdout.write('*');
6280
+ }
6281
+ };
6282
+ stdin.on('data', onData);
6283
+ });
6284
+ }
6285
+ `
6286
+ },
6287
+ {
6288
+ name: "scripts/show.ts",
6289
+ content: `/**
6290
+ * Show what this receiver holds: the roster, and every change it has applied.
6291
+ *
6292
+ * Usage:
6293
+ * npm run show \u2014 the roster and the last 20 changes
6294
+ * npm run show 100 \u2014 the last 100 changes
6295
+ *
6296
+ * ## Why this script exists
6297
+ *
6298
+ * The obvious way to check that a dispatch really landed is \`sqlite3 data.db\`,
6299
+ * and it does not work: the roster and the history are AES-GCM ciphertext
6300
+ * whenever the database has a password, which is what this receiver asks for on
6301
+ * its first run. Reported from a real run, immediately after a first successful
6302
+ * dispatch: "where do I look to prove it end to end?" The dashboard's LAST
6303
+ * CHANGE panel shows one change and nothing showed the rest.
6304
+ *
6305
+ * It prompts for the password for the same reason \`src/index.ts\` does, and for
6306
+ * the same reason it must do so BEFORE importing the store: the database
6307
+ * derives its keys on import.
6308
+ *
6309
+ * IN INBOX MODE THERE IS NOTHING HERE TO SHOW, and saying so is the answer
6310
+ * rather than printing an empty table. This receiver holds no key and no
6311
+ * customer records in that mode; the connector holds both, and the change is
6312
+ * in the connector's own store.
5432
6313
  */
5433
- async function redeliver(rawBody: string, heldDispatchId: string | null): Promise<void> {
5434
- const ts = String(Math.floor(Date.now() / 1000));
5435
- const sig = createHmac('sha256', WEBHOOK_SECRET).update(\`\${ts}.\${rawBody}\`).digest('hex');
5436
- // THE ID THE DISPATCH ARRIVED WITH, handed over by the row being replayed.
5437
- // Re-deriving it from the body is what made one held dispatch become two.
5438
- const dispatchId = (heldDispatchId ?? '').trim();
6314
+ import 'dotenv/config';
6315
+ import { config } from '../src/config.js';
6316
+ import { ask, databaseIsLocked } from '../src/unlock.js';
5439
6317
 
5440
- const res = await fetch(\`http://127.0.0.1:\${PORT}/webhook\`, {
5441
- method: 'POST',
5442
- headers: {
5443
- 'Content-Type': 'application/json',
5444
- 'X-OneAddress-Timestamp': ts,
5445
- 'X-OneAddress-Signature': sig,
5446
- ...(dispatchId ? { 'X-OneAddress-Dispatch': dispatchId } : {}),
5447
- },
5448
- body: rawBody,
5449
- });
5450
- const text = (await res.text().catch(() => '')).slice(0, 200);
5451
- if (!res.ok) throw new Error(\`HTTP \${res.status} \${text}\`);
5452
- // A 200 is not on its own success here: the handler answers \`ok: false\` with
5453
- // 200 when it REFUSES a dispatch (an account reference matching nobody), and
5454
- // treating that as applied would clear the row for an update that was never
5455
- // stored.
5456
- if (!text.includes('"ok":true')) throw new Error(\`refused: \${text}\`);
6318
+ function fmt(a: Record<string, string> | null): string {
6319
+ if (!a) return '(nothing on file)';
6320
+ const parts = [a.street, a.suburb, a.state, a.postcode, a.country].filter(Boolean);
6321
+ return parts.join(', ') || '(empty)';
5457
6322
  }
5458
6323
 
5459
- /** Apply everything held. Bound to [r] on the dashboard and run once at boot. */
5460
- export async function replayQuarantined(): Promise<{ applied: number; failed: number }> {
5461
- return replayHeld(redeliver);
6324
+ async function askPassword(): Promise<void> {
6325
+ if (process.env.ONEADDRESS_DB_PASSPHRASE?.trim()) return;
6326
+ if (!process.stdin.isTTY) return;
6327
+ if (!(await databaseIsLocked())) return;
6328
+ const answer = await ask(' Password to unlock: ');
6329
+ if (answer) process.env.ONEADDRESS_DB_PASSPHRASE = answer;
5462
6330
  }
5463
6331
 
5464
- /**
5465
- * Try the backlog once, a moment after boot.
5466
- *
5467
- * Because the realistic sequence is: a key is wrong, dispatches pile up, the
5468
- * partner edits \`.env\`, the partner restarts. Making them find a command after
5469
- * that is making the recovery depend on reading documentation at the exact
5470
- * moment they are least inclined to. The delay lets \`listen\` settle, since this
5471
- * goes back in through the port.
5472
- */
5473
- // \u2500\u2500 The connector channel, in inbox mode only \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5474
- //
5475
- // Wired here rather than in draw-api.ts because this file owns the confirm
5476
- // queue: an acknowledgement from the connector is the moment OneAddress gets
5477
- // told, and that has to go through the same durable queue every other confirm
5478
- // uses so a failure to reach OneAddress is retried rather than lost.
5479
- if (config.mode === 'inbox') {
5480
- setAcknowledgementHandler((dispatchId, outcome) => {
5481
- queueConfirm(dispatchId, outcome === 'applied' ? 'confirmed' : 'failed');
5482
- recordOutcome(\`d:\${dispatchId}\`, outcome === 'applied' ? 'applied' : 'failed');
5483
- });
5484
- const started = startDrawApi();
5485
- if (!started) {
5486
- // AN INBOX WITH NO WAY TO DRAIN IT IS WORSE THAN NOT STARTING. In
5487
- // write-through mode a missing connector token is irrelevant and the
5488
- // receiver runs; here it means every dispatch would be accepted and held
5489
- // with nothing able to collect it, and the consumer would be told nothing
5490
- // for as long as that lasted.
5491
- report.error('[startup] mode is \`inbox\` but the connector channel could not open. Refusing to start.');
5492
- process.exit(1);
6332
+ async function main(): Promise<void> {
6333
+ if (config.mode === 'inbox') {
6334
+ console.log('\\n This receiver is in inbox mode: it holds no customer records and no key.');
6335
+ console.log(' Your connector applied the change and holds the result. Look there.\\n');
6336
+ return;
5493
6337
  }
5494
- }
5495
6338
 
5496
- setTimeout(() => {
5497
- if (heldCount() === 0) return;
5498
- void replayQuarantined().catch((err: unknown) =>
5499
- report.error('[replay] could not run at startup:', err),
5500
- );
5501
- }, 1_500).unref();
6339
+ await askPassword();
5502
6340
 
5503
- // Held payloads age out whether or not they were ever applied, which is the
5504
- // opposite of the confirm queue one block up. The reason is what the row holds:
5505
- // a confirm record names a dispatch, a quarantined payload is a consumer's
5506
- // encrypted address on someone else's disk.
5507
- const QUARANTINE_KEEP_DAYS = Number(process.env.QUARANTINE_KEEP_DAYS ?? 30);
5508
- setInterval(() => { purgeQuarantine(QUARANTINE_KEEP_DAYS); }, 3_600_000).unref();
6341
+ const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
6342
+ const store = await import('../src/store.js');
5509
6343
 
5510
- /** How many confirms are still owed, and how the dispatches went. Read by the dashboard. */
5511
- export { pendingConfirmCount, tally };
6344
+ const roster = store.allCustomers();
6345
+ console.log(\`\\n ON FILE (\${roster.length})\\n\`);
6346
+ for (const c of roster) {
6347
+ let addr: Record<string, string> | null = null;
6348
+ try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
6349
+ console.log(\` \${c.account_number.padEnd(14)} \${c.name.padEnd(24)} \${fmt(addr)}\`);
6350
+ }
5512
6351
 
5513
- /**
5514
- * Everything the dashboard footer needs, in one call.
5515
- *
5516
- * One hook rather than three, because three would be three chances for the
5517
- * footer to show figures from different moments.
5518
- */
5519
- export function dashboardStats(): {
5520
- received: number;
5521
- applied: number;
5522
- failed: number;
5523
- mode: string;
5524
- awaitingConnector: number;
5525
- oldestUndrawn: string | null;
5526
- } {
5527
- const t = tally();
5528
- const inbox = config.mode === 'inbox'
5529
- ? { count: undrawnCount(), oldest: oldestUndrawn() }
5530
- : { count: 0, oldest: null };
5531
- return {
5532
- ...t,
5533
- mode: config.mode,
5534
- awaitingConnector: inbox.count,
5535
- oldestUndrawn: inbox.oldest,
5536
- };
6352
+ const changes = store.addressHistory(limit);
6353
+ console.log(\`\\n APPLIED CHANGES (\${changes.length}, newest first)\\n\`);
6354
+ if (changes.length === 0) {
6355
+ console.log(' None yet. Send a dispatch and run this again.\\n');
6356
+ return;
6357
+ }
6358
+ for (const h of changes) {
6359
+ console.log(\` \${h.recorded_at} \${h.account_number} \${h.name}\`);
6360
+ console.log(\` was: \${fmt(h.prev_address as unknown as Record<string, string> | null)}\`);
6361
+ console.log(\` now: \${fmt(h.address as unknown as Record<string, string>)}\`);
6362
+ }
6363
+ console.log('');
5537
6364
  }
5538
6365
 
5539
- // Read by src/index.ts to label the dashboard. Exported rather than re-derived
5540
- // there, so the port the UI claims is the port the server actually bound.
5541
- export { PORT };
5542
- export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
6366
+ void main().catch((err: unknown) => {
6367
+ console.error(err instanceof Error ? err.message : String(err));
6368
+ process.exit(1);
6369
+ });
5543
6370
  `
5544
6371
  },
5545
6372
  {
@@ -5554,6 +6381,7 @@ export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
5554
6381
  import 'dotenv/config';
5555
6382
  import { createPrivateKey, createPublicKey } from 'node:crypto';
5556
6383
  import { spawnSync } from 'node:child_process';
6384
+ import { readFileSync } from 'node:fs';
5557
6385
 
5558
6386
  const secret = process.env.WEBHOOK_SECRET ?? '';
5559
6387
  const partnerId = process.env.PARTNER_ID ?? '%%PARTNER_ID%%';
@@ -5561,21 +6389,73 @@ const keyPem = (process.env.PARTNER_PRIVATE_KEY_PEM ?? '').replace(/\\\\n/g,
5561
6389
  const port = process.env.PORT ?? '3001';
5562
6390
  const targetUrl = process.argv[2] ?? \`http://localhost:\${port}/webhook\`;
5563
6391
 
5564
- if (!secret || !partnerId || !keyPem) {
5565
- console.error('[test] Missing required env vars. Check your .env file.');
6392
+ /**
6393
+ * Which shape of receiver is this?
6394
+ *
6395
+ * Read from the same config file the server reads, so the two cannot disagree.
6396
+ * An inbox receiver holds NO private key - that is the property it exists for -
6397
+ * so this runner used to die on its own env gate with "Missing required env
6398
+ * vars. Check your .env file", telling the partner to fix a file that was
6399
+ * exactly as the wizard wrote it. It was never run in that mode.
6400
+ */
6401
+ let receiverMode = 'write-through';
6402
+ try {
6403
+ const cfg = JSON.parse(readFileSync(new URL('../oneaddress.config.json', import.meta.url), 'utf8'));
6404
+ if (cfg && typeof cfg.mode === 'string') receiverMode = cfg.mode;
6405
+ } catch {
6406
+ // No config, or unreadable: treat it as the default, which is what every
6407
+ // scaffold before the mode existed was.
6408
+ }
6409
+ const isInbox = receiverMode === 'inbox';
6410
+
6411
+ if (!secret || !partnerId) {
6412
+ console.error('[test] Missing WEBHOOK_SECRET or PARTNER_ID. Check your .env file.');
5566
6413
  process.exit(1);
5567
6414
  }
5568
6415
 
5569
- let publicKeyB64: string;
5570
- try {
5571
- const priv = createPrivateKey({ key: keyPem, format: 'pem' });
5572
- const pub = createPublicKey(priv);
5573
- publicKeyB64 = pub.export({ type: 'spki', format: 'der' }).toString('base64');
5574
- } catch (err) {
5575
- console.error('[test] Failed to derive public key from PARTNER_PRIVATE_KEY_PEM:', err);
6416
+ /**
6417
+ * The public key the conformance suite encrypts to.
6418
+ *
6419
+ * Write-through derives it from the private key it already holds. Inbox mode
6420
+ * has no private key to derive from, so it takes the PUBLIC half directly:
6421
+ * a public key is not a secret, and keeping one here does not give this process
6422
+ * the ability to read anything.
6423
+ *
6424
+ * Copy it from partners.oneaddress.io -> My Profile.
6425
+ */
6426
+ const publicKeyFromEnv = (process.env.PARTNER_PUBLIC_KEY_B64 ?? '').trim();
6427
+
6428
+ if (isInbox && !publicKeyFromEnv) {
6429
+ console.log('[test] Inbox mode: this receiver holds no private key, so the encrypted checks');
6430
+ console.log('[test] cannot be built here. Two ways to run them:');
6431
+ console.log('[test]');
6432
+ console.log('[test] 1. Set PARTNER_PUBLIC_KEY_B64 in .env (copy it from My Profile in the');
6433
+ console.log('[test] portal). It is a public value and safe to keep on this machine.');
6434
+ console.log('[test] 2. Or run conformance from your connector host, which has the key.');
6435
+ console.log('[test]');
6436
+ console.log('[test] Signing and replay are covered by the wizard\\'s own checks either way.');
6437
+ process.exit(0);
6438
+ }
6439
+
6440
+ if (!isInbox && !keyPem) {
6441
+ console.error('[test] Missing PARTNER_PRIVATE_KEY_PEM. Check your .env file.');
5576
6442
  process.exit(1);
5577
6443
  }
5578
6444
 
6445
+ let publicKeyB64: string;
6446
+ if (publicKeyFromEnv) {
6447
+ publicKeyB64 = publicKeyFromEnv;
6448
+ } else {
6449
+ try {
6450
+ const priv = createPrivateKey({ key: keyPem, format: 'pem' });
6451
+ const pub = createPublicKey(priv);
6452
+ publicKeyB64 = pub.export({ type: 'spki', format: 'der' }).toString('base64');
6453
+ } catch (err) {
6454
+ console.error('[test] Failed to derive public key from PARTNER_PRIVATE_KEY_PEM:', err);
6455
+ process.exit(1);
6456
+ }
6457
+ }
6458
+
5579
6459
  console.log(\`[test] Running conformance against \${targetUrl}\\n\`);
5580
6460
 
5581
6461
  /**
@@ -5596,9 +6476,14 @@ console.log(\`[test] Running conformance against \${targetUrl}\\n\`);
5596
6476
  * the check would be asking a question your code is not the one answering.
5597
6477
  */
5598
6478
  const verifiesAccountReference =
5599
- process.env.OA_VERIFIES_ACCOUNT_REFERENCE === '1' ||
5600
- process.env.OA_VERIFIES_ACCOUNT_REFERENCE === 'true' ||
5601
- %%VERIFIES_ACCOUNT_REFERENCE%%;
6479
+ // NEVER in inbox mode. The receiver cannot read an account reference there,
6480
+ // so check-14 asks it a question only the connector can answer, and a
6481
+ // correctly built system fails a check it was never the subject of.
6482
+ !isInbox && (
6483
+ process.env.OA_VERIFIES_ACCOUNT_REFERENCE === '1' ||
6484
+ process.env.OA_VERIFIES_ACCOUNT_REFERENCE === 'true' ||
6485
+ %%VERIFIES_ACCOUNT_REFERENCE%%
6486
+ );
5602
6487
 
5603
6488
  const result = spawnSync(
5604
6489
  'npx',
@@ -5662,14 +6547,36 @@ computes the LOA reference with \`d5LoaRef\`, and hands it to your store as
5662
6547
 
5663
6548
  \`\`\`sql
5664
6549
  -- Your customer roster: who you know + the address you hold on file today.
5665
- -- Keyed on the account number. Seeded on startup from customers.json (edit that
5666
- -- file, or point loadRoster in src/store.ts at your real customer table).
5667
- customers(account_number, name, address JSON, updated_at)
6550
+ -- Seeded on startup from customers.json (edit that file, or point loadRoster in
6551
+ -- src/store.ts at your real customer table).
6552
+ --
6553
+ -- account_key is how a row is FOUND: a blind index of the account number when
6554
+ -- the database has a password, the lower-cased number when it does not. It is
6555
+ -- the key rather than the number itself because AES-GCM uses a fresh IV per
6556
+ -- write, so two encryptions of one account number differ and a primary key over
6557
+ -- the ciphertext would enforce nothing while looking like it did.
6558
+ customers(account_key, account_number, name, address JSON, updated_at)
5668
6559
 
5669
6560
  -- Full history of every address change you apply (append-only audit trail).
5670
- address_history(id, account_number, address JSON, recorded_at)
6561
+ -- BOTH SIDES of each change, so you can show what an address REPLACED and not
6562
+ -- only what it became.
6563
+ address_history(id, account_key, prev_address JSON, address JSON, recorded_at)
6564
+ \`\`\`
6565
+
6566
+ ### Seeing what changed
6567
+
6568
+ \`\`\`bash
6569
+ npm run show # the roster, and the last 20 changes (was: / now:)
6570
+ npm run show 100 # the last 100
5671
6571
  \`\`\`
5672
6572
 
6573
+ \`sqlite3 data.db\` will NOT answer this once the database has a password: every
6574
+ text column is ciphertext, which is the point. \`npm run show\` prompts for the
6575
+ same password \`npm start\` does and prints the decrypted rows.
6576
+
6577
+ In **inbox mode** there is nothing here to show. This receiver holds no key and
6578
+ no customer records; your connector applied the change and holds the result.
6579
+
5673
6580
  ## Swapping to a production database
5674
6581
 
5675
6582
  Open \`src/store.ts\` and replace the \`db\` calls with your ORM/driver of choice:
@@ -6310,7 +7217,7 @@ async def _confirm_to_oneaddress(dispatch: str, status: str) -> None:
6310
7217
  "dispatch_id": dispatch_id,
6311
7218
  "partner_id": PARTNER_ID,
6312
7219
  "status": status,
6313
- "note": "Applied by the OneAddress webhook receiver",
7220
+ "note": "Reported by the OneAddress webhook receiver",
6314
7221
  })
6315
7222
  ts = str(int(time.time()))
6316
7223
  sig = hmac_lib.new(CONFIRM_SECRET.encode(), f"{ts}.{body_str}".encode(), hashlib.sha256).hexdigest()
@@ -7325,7 +8232,7 @@ public class OneAddressWebhookController {
7325
8232
  confirmBody.put("dispatch_id", dispatchId);
7326
8233
  confirmBody.put("partner_id", partnerId);
7327
8234
  confirmBody.put("status", status);
7328
- confirmBody.put("note", "Applied by the OneAddress webhook receiver");
8235
+ confirmBody.put("note", "Reported by the OneAddress webhook receiver");
7329
8236
  String bodyStr = MAPPER.writeValueAsString(confirmBody);
7330
8237
  String ts = String.valueOf(System.currentTimeMillis() / 1000L);
7331
8238
  String sig = OneAddressVerifier.sign(ts + "." + bodyStr, confirmSecret);
@@ -8371,7 +9278,7 @@ async Task ConfirmToOneAddress(string oneAddressApi, string confirmSecret, strin
8371
9278
  dispatch_id = dispatchId,
8372
9279
  partner_id = pid,
8373
9280
  status,
8374
- note = "Applied by the OneAddress webhook receiver",
9281
+ note = "Reported by the OneAddress webhook receiver",
8375
9282
  });
8376
9283
  var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
8377
9284
  var sig = Convert.ToHexString(
@@ -9473,7 +10380,7 @@ func confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, stat
9473
10380
  "dispatch_id": dispatchID,
9474
10381
  "partner_id": partnerID,
9475
10382
  "status": status,
9476
- "note": "Applied by the OneAddress webhook receiver",
10383
+ "note": "Reported by the OneAddress webhook receiver",
9477
10384
  })
9478
10385
  bodyStr := string(bodyBytes)
9479
10386
  ts := strconv.FormatInt(time.Now().Unix(), 10)
@@ -10257,7 +11164,7 @@ function oaConfirmToOneAddress(string $oneAddressApi, string $confirmSecret, str
10257
11164
  'dispatch_id' => (int) $dispatch,
10258
11165
  'partner_id' => $partnerId,
10259
11166
  'status' => $status,
10260
- 'note' => 'Applied by the OneAddress webhook receiver',
11167
+ 'note' => 'Reported by the OneAddress webhook receiver',
10261
11168
  ]);
10262
11169
  $ts = (string) time();
10263
11170
  $sig = hash_hmac('sha256', $ts . '.' . $bodyStr, $confirmSecret);
@@ -10735,10 +11642,11 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
10735
11642
  };
10736
11643
 
10737
11644
  // src/scaffold.ts
10738
- function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken) {
11645
+ function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken, port) {
10739
11646
  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");
10740
- if (mode !== "inbox") return filled;
10741
- const withReadme = filled.replace(
11647
+ const withPort = applyPort(filled, port);
11648
+ if (mode !== "inbox") return withPort;
11649
+ const withReadme = withPort.replace(
10742
11650
  "## Architecture",
10743
11651
  `## Inbox mode
10744
11652
 
@@ -10773,11 +11681,17 @@ hand somebody your customers' addresses.
10773
11681
  CONNECTOR_TOKEN=${connectorToken || generateConnectorToken()}`
10774
11682
  );
10775
11683
  }
11684
+ function applyPort(content, port) {
11685
+ if (!Number.isInteger(port) || port === DEFAULT_PORT) return content;
11686
+ return content.replace(/^PORT=\d+$/m, `PORT=${port}`);
11687
+ }
11688
+ var DEFAULT_PORT = 3001;
11689
+ var RESERVED_PORTS = [3002, 3003];
10776
11690
  function generateConnectorToken() {
10777
11691
  return (0, import_node_crypto.randomBytes)(24).toString("hex");
10778
11692
  }
10779
11693
  var SENSITIVE_FILES = /* @__PURE__ */ new Set([".env"]);
10780
- async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io", mode = "write-through", connectorToken = "") {
11694
+ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io", mode = "write-through", connectorToken = "", port = DEFAULT_PORT) {
10781
11695
  const templates = TEMPLATES[platform];
10782
11696
  if (!templates) throw new Error(`Unknown platform: ${platform}`);
10783
11697
  const written = [];
@@ -10787,7 +11701,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
10787
11701
  if (!(0, import_node_fs.existsSync)(dir)) {
10788
11702
  await (0, import_promises.mkdir)(dir, { recursive: true });
10789
11703
  }
10790
- const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken);
11704
+ const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken, port);
10791
11705
  const isSensitive = SENSITIVE_FILES.has((0, import_node_path.basename)(name));
10792
11706
  await (0, import_promises.writeFile)(dest, filled, { encoding: "utf8", mode: isSensitive ? 384 : 420 });
10793
11707
  if (isSensitive && process.platform !== "win32") {
@@ -10803,7 +11717,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
10803
11717
 
10804
11718
  // src/register.ts
10805
11719
  var import_node_crypto2 = require("crypto");
10806
- var PKG_VERSION = true ? "2.3.0" : "dev";
11720
+ var PKG_VERSION = true ? "2.5.0" : "dev";
10807
11721
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
10808
11722
  function hmacSha256(secret, message) {
10809
11723
  return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
@@ -11054,15 +11968,17 @@ Timed out after 5 minutes.`.trim(),
11054
11968
 
11055
11969
  // src/autostart.ts
11056
11970
  var import_node_child_process2 = require("child_process");
11057
- var COMMANDS2 = {
11058
- "ts-node": { cmd: "npx", args: ["tsx", "src/server.ts"] },
11059
- "python": { cmd: "uvicorn", args: ["app:app", "--port", "3001"] },
11060
- "go-http": { cmd: "go", args: ["run", "."] },
11061
- "php-laravel": { cmd: "php", args: ["artisan", "serve", "--port=3001"] },
11062
- "csharp-aspnet": { cmd: "dotnet", args: ["run"] },
11063
- "java-spring": { cmd: "mvn", args: ["spring-boot:run"] }
11064
- // no mvnw wrapper is scaffolded
11065
- };
11971
+ function commandsFor(port) {
11972
+ return {
11973
+ "ts-node": { cmd: "npx", args: ["tsx", "src/server.ts"] },
11974
+ "python": { cmd: "uvicorn", args: ["app:app", "--port", String(port)] },
11975
+ "go-http": { cmd: "go", args: ["run", "."] },
11976
+ "php-laravel": { cmd: "php", args: ["artisan", "serve", `--port=${port}`] },
11977
+ "csharp-aspnet": { cmd: "dotnet", args: ["run"] },
11978
+ "java-spring": { cmd: "mvn", args: ["spring-boot:run"] }
11979
+ // no mvnw wrapper is scaffolded
11980
+ };
11981
+ }
11066
11982
  var DASHBOARD_COMMANDS = {
11067
11983
  "ts-node": { cmd: "npx", args: ["tsx", "src/index.ts"] }
11068
11984
  };
@@ -11164,13 +12080,13 @@ async function pollHealth(port, timeoutMs) {
11164
12080
  return false;
11165
12081
  }
11166
12082
  async function startServer(platform, outputDir, port = 3001, secrets = {}) {
11167
- const spec = COMMANDS2[platform];
12083
+ const spec = commandsFor(port)[platform];
11168
12084
  if (!spec) {
11169
12085
  return { ok: false, output: `No start command defined for platform: ${platform}`, manualCommand: "" };
11170
12086
  }
11171
12087
  serverOutput = "";
11172
12088
  const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
11173
- serverProcess = spawnReceiver(spec, outputDir, ["ignore", "pipe", "pipe"], secrets);
12089
+ serverProcess = spawnReceiver(spec, outputDir, ["ignore", "pipe", "pipe"], { ...secrets, PORT: String(port) });
11174
12090
  serverProcess.stdout?.on("data", (d3) => {
11175
12091
  serverOutput += d3.toString();
11176
12092
  });
@@ -11217,7 +12133,7 @@ async function handOverTerminal(platform, outputDir, port, secrets = {}) {
11217
12133
  }
11218
12134
  }
11219
12135
  process.stdin.pause();
11220
- const child = spawnReceiver(spec, outputDir, "inherit", secrets);
12136
+ const child = spawnReceiver(spec, outputDir, "inherit", { ...secrets, PORT: String(port) });
11221
12137
  serverProcess = child;
11222
12138
  return new Promise((resolve2) => {
11223
12139
  child.once("error", (err) => {
@@ -11747,6 +12663,25 @@ function validatePrivateKey(pem) {
11747
12663
  function stripControlChars(line) {
11748
12664
  return line.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
11749
12665
  }
12666
+ function validatePortChoice(v2) {
12667
+ const raw = (v2 ?? "").trim();
12668
+ if (!raw) return void 0;
12669
+ if (!/^\d+$/.test(raw)) return `Enter a port number, or leave blank for ${DEFAULT_PORT}`;
12670
+ const n = Number(raw);
12671
+ if (n < 1024 || n > 65535) return "Choose a port between 1024 and 65535";
12672
+ if (RESERVED_PORTS.includes(n)) {
12673
+ return `${n} is used by the connector channel. Choose another port.`;
12674
+ }
12675
+ return void 0;
12676
+ }
12677
+ function childEnvForMode(mode, v2) {
12678
+ return {
12679
+ WEBHOOK_SECRET: v2.secret,
12680
+ PARTNER_ID: v2.partnerId,
12681
+ VERIFIES_ACCOUNT_REFERENCE: String(v2.verifiesAccountReference),
12682
+ ...mode === "inbox" ? { CONNECTOR_TOKEN: v2.connectorToken } : { PARTNER_PRIVATE_KEY_PEM: v2.privateKey }
12683
+ };
12684
+ }
11750
12685
  async function main() {
11751
12686
  printHeader();
11752
12687
  Ie("OneAddress Partner Setup");
@@ -11796,23 +12731,6 @@ async function main() {
11796
12731
  "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."
11797
12732
  );
11798
12733
  }
11799
- const privateKeyRaw = await he({
11800
- message: "Your ECDH Private Key",
11801
- placeholder: "Paste the base64 key body from My Profile, or enter a path to the .pem file",
11802
- validate: (v2) => {
11803
- if (!v2.trim()) return "Private key is required";
11804
- const { pem, error } = normalisePrivateKey(v2);
11805
- if (error) return error;
11806
- return validatePrivateKey(pem);
11807
- }
11808
- });
11809
- assertNotCancelled(privateKeyRaw);
11810
- const normalised = normalisePrivateKey(privateKeyRaw.trim());
11811
- if (normalised.error) {
11812
- xe(`Private key parse failed unexpectedly: ${normalised.error}`);
11813
- process.exit(1);
11814
- }
11815
- const privateKey = normalised.pem;
11816
12734
  const platform = await ve({
11817
12735
  message: "Which platform?",
11818
12736
  options: [
@@ -11829,29 +12747,6 @@ async function main() {
11829
12747
  ]
11830
12748
  });
11831
12749
  assertNotCancelled(platform);
11832
- let outDir;
11833
- for (; ; ) {
11834
- const outputDir = await he({
11835
- message: "Where to write the files?",
11836
- placeholder: "./oneaddress-webhook",
11837
- defaultValue: "./oneaddress-webhook"
11838
- });
11839
- assertNotCancelled(outputDir);
11840
- outDir = outputDir.trim() || "./oneaddress-webhook";
11841
- if (!(0, import_node_fs3.existsSync)(outDir) || (0, import_node_fs3.readdirSync)(outDir).length === 0) break;
11842
- const overwrite = await ye({
11843
- message: `${outDir} already has files \u2014 overwrite?`,
11844
- initialValue: false
11845
- });
11846
- assertNotCancelled(overwrite);
11847
- if (overwrite) break;
11848
- M2.info(`Keeping the files in ${outDir}. Enter a different directory to write into.`);
11849
- }
11850
- let accountRefReason = "";
11851
- const accountRefDeclaration = await getVerifiesAccountReference(pid, secret, (r2) => {
11852
- accountRefReason = r2;
11853
- });
11854
- const verifiesAccountReference = accountRefDeclaration === true;
11855
12750
  const modeChoice = platform !== "ts-node" ? "write-through" : await ve({
11856
12751
  message: "How should this receiver handle updates?",
11857
12752
  options: [
@@ -11885,6 +12780,69 @@ not your webhook secret and not your confirm secret, because those are shared wi
11885
12780
  OneAddress and a leak of either must not also hand somebody your customers' addresses.`
11886
12781
  );
11887
12782
  }
12783
+ let privateKey = "";
12784
+ if (receiverMode === "inbox") {
12785
+ M2.info(
12786
+ "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."
12787
+ );
12788
+ } else {
12789
+ const privateKeyRaw = await he({
12790
+ message: "Your ECDH Private Key",
12791
+ placeholder: "Paste the base64 key body from My Profile, or enter a path to the .pem file",
12792
+ validate: (v2) => {
12793
+ if (!v2.trim()) return "Private key is required";
12794
+ const { pem, error } = normalisePrivateKey(v2);
12795
+ if (error) return error;
12796
+ return validatePrivateKey(pem);
12797
+ }
12798
+ });
12799
+ assertNotCancelled(privateKeyRaw);
12800
+ const normalised = normalisePrivateKey(privateKeyRaw.trim());
12801
+ if (normalised.error) {
12802
+ xe(`Private key parse failed unexpectedly: ${normalised.error}`);
12803
+ process.exit(1);
12804
+ }
12805
+ privateKey = normalised.pem;
12806
+ }
12807
+ const portAnswer = await he({
12808
+ message: "Which port should the receiver listen on?",
12809
+ placeholder: String(DEFAULT_PORT),
12810
+ defaultValue: String(DEFAULT_PORT),
12811
+ initialValue: String(DEFAULT_PORT),
12812
+ validate: validatePortChoice
12813
+ });
12814
+ assertNotCancelled(portAnswer);
12815
+ const chosenPort = Number(String(portAnswer).trim() || DEFAULT_PORT);
12816
+ if (!await isPortFree(chosenPort)) {
12817
+ M2.warn(
12818
+ `Port ${chosenPort} looks busy. Free it (lsof -i :${chosenPort} on macOS or Linux,
12819
+ netstat -ano | findstr :${chosenPort} on Windows), or change PORT in the generated
12820
+ .env afterwards. Setup will carry on either way.`
12821
+ );
12822
+ }
12823
+ let outDir;
12824
+ for (; ; ) {
12825
+ const outputDir = await he({
12826
+ message: "Where to write the files?",
12827
+ placeholder: "./oneaddress-webhook",
12828
+ defaultValue: "./oneaddress-webhook"
12829
+ });
12830
+ assertNotCancelled(outputDir);
12831
+ outDir = outputDir.trim() || "./oneaddress-webhook";
12832
+ if (!(0, import_node_fs3.existsSync)(outDir) || (0, import_node_fs3.readdirSync)(outDir).length === 0) break;
12833
+ const overwrite = await ye({
12834
+ message: `${outDir} already has files \u2014 overwrite?`,
12835
+ initialValue: false
12836
+ });
12837
+ assertNotCancelled(overwrite);
12838
+ if (overwrite) break;
12839
+ M2.info(`Keeping the files in ${outDir}. Enter a different directory to write into.`);
12840
+ }
12841
+ let accountRefReason = "";
12842
+ const accountRefDeclaration = await getVerifiesAccountReference(pid, secret, (r2) => {
12843
+ accountRefReason = r2;
12844
+ });
12845
+ const verifiesAccountReference = accountRefDeclaration === true;
11888
12846
  if (accountRefDeclaration === void 0) {
11889
12847
  M2.warn(
11890
12848
  "Could not read your account-reference declaration from the portal.\n" + (accountRefReason ? "Reason: " + accountRefReason + "\n" : "") + "Conformance check-14 has been left out of the generated `npm test`, so a\npassing run does NOT mean your receiver matches account references correctly.\nSet it at partners.oneaddress.io \u2192 My Profile, then re-run this wizard."
@@ -11905,7 +12863,8 @@ OneAddress and a leak of either must not also hand somebody your customers' addr
11905
12863
  verifiesAccountReference,
11906
12864
  void 0,
11907
12865
  receiverMode,
11908
- connectorToken
12866
+ connectorToken,
12867
+ chosenPort
11909
12868
  );
11910
12869
  s1.stop(`Scaffolded ${written.length} files in ${outDir}`);
11911
12870
  for (const f of written) M2.success(` ${f}`);
@@ -11942,22 +12901,18 @@ OneAddress and a leak of either must not also hand somebody your customers' addr
11942
12901
  }
11943
12902
  if (choice === "skip") break;
11944
12903
  }
11945
- const SERVER_PORT = 3001;
11946
- const portFree = await isPortFree(SERVER_PORT);
11947
- if (!portFree) {
11948
- M2.warn(
11949
- `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.`
11950
- );
11951
- }
12904
+ const childSecrets = childEnvForMode(receiverMode, {
12905
+ secret,
12906
+ partnerId: pid,
12907
+ verifiesAccountReference,
12908
+ privateKey,
12909
+ connectorToken
12910
+ });
12911
+ const SERVER_PORT = chosenPort;
11952
12912
  const s3 = Y2();
11953
12913
  s3.start(`Starting server (waiting up to 15 s for /health on :${SERVER_PORT})`);
11954
12914
  onCleanup(stopServer);
11955
- const start = await startServer(platform, outDir, SERVER_PORT, {
11956
- WEBHOOK_SECRET: secret,
11957
- PARTNER_ID: pid,
11958
- PARTNER_PRIVATE_KEY_PEM: privateKey,
11959
- VERIFIES_ACCOUNT_REFERENCE: String(verifiesAccountReference)
11960
- });
12915
+ const start = await startServer(platform, outDir, SERVER_PORT, childSecrets);
11961
12916
  let serverRunning = false;
11962
12917
  if (start.ok) {
11963
12918
  s3.stop(`Server is healthy on port ${SERVER_PORT}`);
@@ -12031,7 +12986,7 @@ Continuing will overwrite it.`
12031
12986
  s4.start("Starting Cloudflare Tunnel");
12032
12987
  onCleanup(stopTunnel);
12033
12988
  try {
12034
- let tunnel = await startTunnel(3001);
12989
+ let tunnel = await startTunnel(SERVER_PORT);
12035
12990
  webhookUrl = `${tunnel.url}/webhook`;
12036
12991
  tunnelBase = tunnel.url;
12037
12992
  s4.stop(`Tunnel active: ${tunnel.url}`);
@@ -12044,7 +12999,7 @@ Continuing will overwrite it.`
12044
12999
  if (!webhookReady) {
12045
13000
  s4b.stop("First tunnel is slow to route, trying a fresh one");
12046
13001
  stopTunnel();
12047
- tunnel = await startTunnel(3001);
13002
+ tunnel = await startTunnel(SERVER_PORT);
12048
13003
  webhookUrl = `${tunnel.url}/webhook`;
12049
13004
  tunnelBase = tunnel.url;
12050
13005
  const s4c = Y2();
@@ -12057,7 +13012,7 @@ Continuing will overwrite it.`
12057
13012
  if (!webhookReady) {
12058
13013
  M2.warn(
12059
13014
  `The tunnel edge is still not routing after a retry. This is almost always
12060
- one of two things, and neither is your server (it is running on :3001):
13015
+ one of two things, and neither is your server (it is running on :${SERVER_PORT}):
12061
13016
  1. Open ${tunnel.url}/health in a browser. If it never loads, your network
12062
13017
  is blocking cloudflared and no wait will fix it, so try a different network.
12063
13018
  2. The tunnel only lives while this wizard runs, so keep this window open.`
@@ -12067,7 +13022,8 @@ one of two things, and neither is your server (it is running on :3001):
12067
13022
  s4.stop("Tunnel setup failed");
12068
13023
  M2.warn(`Could not start tunnel: ${err instanceof Error ? err.message : String(err)}`);
12069
13024
  M2.warn(
12070
- "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."
13025
+ `To get a public URL manually, try: npx cloudflared tunnel --url http://localhost:${SERVER_PORT}
13026
+ Or deploy to any server and set the URL in partners.oneaddress.io \u2192 My Profile.`
12071
13027
  );
12072
13028
  }
12073
13029
  }
@@ -12105,7 +13061,7 @@ one of two things, and neither is your server (it is running on :3001):
12105
13061
  if (!webhookReady) {
12106
13062
  M2.warn(
12107
13063
  `Skipping the conformance checks: the tunnel edge is not routing.
12108
- Your server is fine, it is running on :3001. This is either your network
13064
+ Your server is fine, it is running on :${SERVER_PORT}. This is either your network
12109
13065
  blocking cloudflared (open ${tunnelBase}/health in a browser to check) or the
12110
13066
  tunnel needing this wizard to stay open. Re-run once that URL loads in a browser.`
12111
13067
  );
@@ -12116,18 +13072,28 @@ tunnel needing this wizard to stay open. Re-run once that URL loads in a browser
12116
13072
  await new Promise((r2) => setTimeout(r2, 200));
12117
13073
  s6.stop("Conformance checks:");
12118
13074
  await runConformance(webhookUrl, pid, secret);
12119
- await runDecryptCheck(secret, pid);
12120
- if (verifiesAccountReference) {
13075
+ if (receiverMode === "inbox") {
13076
+ M2.info(
13077
+ "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."
13078
+ );
13079
+ } else {
13080
+ await runDecryptCheck(secret, pid);
13081
+ }
13082
+ if (verifiesAccountReference && receiverMode !== "inbox") {
12121
13083
  M2.info(
12122
13084
  "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."
12123
13085
  );
13086
+ } else if (verifiesAccountReference) {
13087
+ M2.info(
13088
+ "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."
13089
+ );
12124
13090
  }
12125
13091
  }
12126
13092
  }
12127
13093
  const summary = [
12128
13094
  `Partner ID: ${pid}`,
12129
13095
  `Webhook URL: ${webhookUrl || "(not set \u2014 update in portal)"}`,
12130
- `Server port: 3001`,
13096
+ `Server port: ${SERVER_PORT}`,
12131
13097
  `Files in: ${outDir}`
12132
13098
  ].join("\n");
12133
13099
  const nextSteps = [
@@ -12142,12 +13108,7 @@ tunnel needing this wizard to stay open. Re-run once that URL loads in a browser
12142
13108
  if (serverRunning) {
12143
13109
  const openDashboard = platformHasDashboard(platform) ? await ye({ message: "Open the receiver dashboard now?", initialValue: true }) : false;
12144
13110
  if (openDashboard === true) {
12145
- const handover = await handOverTerminal(platform, outDir, SERVER_PORT, {
12146
- WEBHOOK_SECRET: secret,
12147
- PARTNER_ID: pid,
12148
- PARTNER_PRIVATE_KEY_PEM: privateKey,
12149
- VERIFIES_ACCOUNT_REFERENCE: String(verifiesAccountReference)
12150
- });
13111
+ const handover = await handOverTerminal(platform, outDir, SERVER_PORT, childSecrets);
12151
13112
  if (!handover.ok) {
12152
13113
  M2.warn(
12153
13114
  `${handover.reason ?? "The dashboard could not start."}
@@ -12161,7 +13122,7 @@ Your receiver is set up and working. Start it yourself with:
12161
13122
  console.log("");
12162
13123
  console.log(` ${DIM2}\u250C${"\u2500".repeat(BOX_WIDTH)}\u2510${R3}`);
12163
13124
  for (const inner of [
12164
- ` ${GRN}\u25C8${R3} ${CRM2}Server live${R3} ${MID2}on http://localhost:3001/webhook${R3}`,
13125
+ ` ${GRN}\u25C8${R3} ${CRM2}Server live${R3} ${MID2}on http://localhost:${SERVER_PORT}/webhook${R3}`,
12165
13126
  ` ${AMB2}\u25C8${R3} ${MID2}Edit ${CRM2}src/store.ts${R3} ${MID2}to wire your database${R3}`,
12166
13127
  ` ${MID2}Press Ctrl+C to stop the server and exit.${R3}`,
12167
13128
  ...platformHasDashboard(platform) ? [` ${MID2}Run ${CRM2}npm start${R3} ${MID2}here any time for the dashboard.${R3}`] : []