@oneaddress/setup 2.4.0 → 2.6.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 +1070 -173
  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.4.0" : "?";
859
+ var WIZARD_VERSION = true ? "2.6.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,9 @@ 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%%",
1017
+ "exportDir": ""
1015
1018
  }
1016
1019
  `
1017
1020
  },
@@ -1711,7 +1714,9 @@ import { formatLine, report, type ReportLine } from './report.js';
1711
1714
  // reads, with nothing here to change.
1712
1715
  import { store } from './store.js';
1713
1716
  import { pendingConfirmCount } from './confirm-queue.js';
1714
- import { exportHeld, heldCount, heldSummary } from './quarantine.js';
1717
+ import { dismissHeld, drillCount, exportHeld, heldCount, heldSummary } from './quarantine.js';
1718
+ import { config } from './config.js';
1719
+ import { reachability } from './reachable.js';
1715
1720
 
1716
1721
  /** blessed takes colours as strings; these mirror the site's palette. */
1717
1722
  const AMBER = HEX.amber.toLowerCase();
@@ -1870,6 +1875,8 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1870
1875
  // nor UNENCRYPTED is true and both would mislead. The receiver holds
1871
1876
  // ciphertext it cannot open; the customers live in the partner's own
1872
1877
  // database, behind their own controls.
1878
+ const reach = reachability();
1879
+ const drills = drillCount();
1873
1880
  const vault = inbox
1874
1881
  ? \`{\${AMBER}-fg}{bold}NONE (inbox){/}\`
1875
1882
  : store.encrypted
@@ -1883,7 +1890,21 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1883
1890
  // claim ("you have no customers") and would be a lie on a receiver
1884
1891
  // pointed at a real customer table, where counting every row forty times
1885
1892
  // a minute is the thing the store is right to refuse.
1886
- \`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}\`,
1893
+ \`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}\`
1894
+ // REACHABLE IS DRAWN ONLY WHEN IT IS KNOWN, and that is the point of the
1895
+ // third state. A receiver with no publicUrl configured cannot answer the
1896
+ // question, and a green tick or a red cross would both be inventing an
1897
+ // answer. Silence is the honest rendering of "not checked".
1898
+ // DRILLS ARE NAMED, not hidden. A conformance run leaves a trace and the
1899
+ // partner should be able to see it; what it must not do is look like a
1900
+ // fault. Absent when there are none, because a permanent "drills 0" is a
1901
+ // counter for a thing that has not happened.
1902
+ + (drills > 0 ? \` {\${DIM}-fg}conformance probes{/} {\${CREAM}-fg}\${drills}{/}\` : '')
1903
+ + (reach.state === 'unknown'
1904
+ ? ''
1905
+ : reach.state === 'reachable'
1906
+ ? \` {\${DIM}-fg}reachable{/} {green-fg}{bold}YES{/}\`
1907
+ : \` {\${DIM}-fg}reachable{/} {red-fg}{bold}NO{/}\`),
1887
1908
  );
1888
1909
  }
1889
1910
 
@@ -1926,6 +1947,16 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1926
1947
 
1927
1948
  /** Shown while a replay is running, so [r] does not look like it did nothing. */
1928
1949
  let replayNote = '';
1950
+ /**
1951
+ * The export confirmation, kept apart from \`replayNote\`.
1952
+ *
1953
+ * They are both one-line notes under the fault panel and they answer
1954
+ * different questions, so sharing a slot means a press of [r] silently wipes
1955
+ * the only place the export path was written down. The PATH is the entire
1956
+ * value of the message: a receiver started from a shortcut or a service
1957
+ * manager has a working directory the partner has never seen.
1958
+ */
1959
+ let exportNote = '';
1929
1960
 
1930
1961
  /**
1931
1962
  * The customer count, refreshed out of band.
@@ -1947,7 +1978,14 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1947
1978
 
1948
1979
  function renderFaults(): void {
1949
1980
  const held = heldCount();
1950
- const show = held > 0;
1981
+ const reach = reachability();
1982
+ // UNREACHABLE IS A FAULT, and it belongs in this panel rather than only in
1983
+ // the status bar, because it is the one failure where NOTHING ELSE MOVES.
1984
+ // A wrong key at least produces held dispatches to look at; a stopped
1985
+ // tunnel produces a receiver that looks perfectly healthy and simply never
1986
+ // hears from anyone again.
1987
+ const unreachable = reach.state === 'unreachable';
1988
+ const show = held > 0 || unreachable;
1951
1989
  if (show === Boolean(faultBox.hidden)) {
1952
1990
  // Visibility is changing, so the panels above have to give back or take
1953
1991
  // back the rows. Assigning \`bottom\` is how blessed re-lays-out; it reads
@@ -1962,6 +2000,15 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1962
2000
  // Grouped, never one line per dispatch. The realistic shape of this table
1963
2001
  // is forty rows with ONE cause between them, and forty identical lines hide
1964
2002
  // the single fact that matters.
2003
+ if (unreachable && held === 0) {
2004
+ faultBox.setContent(
2005
+ \`\\n {bold}Nothing on the internet is reaching this receiver.{/bold}\\n\` +
2006
+ \` {red-fg}\${esc(reach.state === 'unreachable' ? reach.detail : '')}{/}\\n\` +
2007
+ \` {\${DIM}-fg}Dispatches cannot arrive while this is true. Check your tunnel is still running.{/}\`,
2008
+ );
2009
+ return;
2010
+ }
2011
+
1965
2012
  const lines = heldSummary().slice(0, 2).map((l) => \` {red-fg}\${esc(l)}{/}\`);
1966
2013
  faultBox.setContent(
1967
2014
  \`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open. \` +
@@ -1969,7 +2016,8 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1969
2016
  lines.join('\\n') + '\\n' +
1970
2017
  (replayNote
1971
2018
  ? \` {\${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.{/}\`),
2019
+ : \` {\${DIM}-fg}Fix the cause, then{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply,{/} {\${AMBER}-fg}[e]{/} {\${DIM}-fg}to export,{/} \` +
2020
+ \`{\${AMBER}-fg}[d]{/} {\${DIM}-fg}to dismiss.{/}\`),
1973
2021
  );
1974
2022
  }
1975
2023
 
@@ -2003,7 +2051,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2003
2051
  \`{red-fg}failed{/} {bold}\${failed}{/bold} \` +
2004
2052
  awaitingYou +
2005
2053
  \`{\${awaitingColour}-fg}awaiting confirm{/} {bold}\${awaiting}{/bold}\` +
2006
- \`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [q] quit{/} \`,
2054
+ \`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [d] dismiss [q] quit{/} \`,
2007
2055
  );
2008
2056
  }
2009
2057
 
@@ -2071,10 +2119,71 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2071
2119
  return;
2072
2120
  }
2073
2121
  try {
2074
- const { path, count } = exportHeld(process.cwd());
2075
- replayNote = \`Exported \${count} to \${path}\`;
2122
+ // NO ARGUMENT. \`exportHeld\` resolves \`config.exportDir\`, then
2123
+ // \`ONEADDRESS_EXPORT_DIR\`, then the working directory, and returns an
2124
+ // ABSOLUTE path. Passing \`process.cwd()\` here is what made the setting
2125
+ // unreachable from the one key that uses it.
2126
+ const { path, count } = exportHeld();
2127
+ exportNote = \`Saved \${count} to \${path}\`;
2128
+ replayNote = '';
2129
+ } catch (err) {
2130
+ // NAMES THE DIRECTORY IT TRIED. "EACCES" on its own sends a partner to
2131
+ // search for a file that was never written.
2132
+ exportNote = \`Export FAILED writing to \${config.exportDir || process.cwd()}: \` +
2133
+ \`\${err instanceof Error ? err.message : String(err)}\`;
2134
+ replayNote = '';
2135
+ }
2136
+ redraw();
2137
+ });
2138
+
2139
+ // [d] DISMISSES. TWO PRESSES, and the first one only asks.
2140
+ //
2141
+ // WHY THIS KEY EXISTS AT ALL. The conformance suite deliberately sends a
2142
+ // dispatch this receiver cannot open (check-11: a session key wrapped to a
2143
+ // throwaway key pair) and passes the receiver for HOLDING it. So passing
2144
+ // conformance leaves a red fault panel up permanently, on a receiver that is
2145
+ // working correctly, with nothing on screen that clears it. Reported from a
2146
+ // real run: "I do wish the conformance wouldn't come up as a fault."
2147
+ //
2148
+ // WHY THE RECEIVER DOES NOT JUST RECOGNISE THE PROBE. Everything that
2149
+ // identifies one is chosen by the sender. See the \`dismissed_at\` docstring in
2150
+ // \`quarantine.ts\`: suppressing a fault panel on a sender-supplied value hides
2151
+ // real held dispatches from the one person whose job is to notice them.
2152
+ //
2153
+ // IRREVERSIBLE, SO IT IS CONFIRMED. Dismissal discards the held payload, which
2154
+ // is the point (see \`dismissHeld\`) and also means a mistaken press cannot be
2155
+ // undone by pressing [r]. The count is named in the question rather than after
2156
+ // it, because "dismiss 47" and "dismiss 1" are different decisions.
2157
+ //
2158
+ // THE ARM REMEMBERS A COUNT, NOT A BOOLEAN, and that is the load-bearing part
2159
+ // rather than a nicety. A boolean armed at 14:02 is still armed at 16:30, so a
2160
+ // partner who armed it against one conformance probe and wandered off would
2161
+ // come back and dismiss whatever real backlog had arrived in between - with a
2162
+ // single keystroke, discarding the payloads. Confirming only the exact set
2163
+ // that was described makes the question and the action the same question.
2164
+ let dismissArmedFor: number | null = null;
2165
+ screen.key(['d'], () => {
2166
+ const held = heldCount();
2167
+ if (held === 0) {
2168
+ dismissArmedFor = null;
2169
+ replayNote = 'Nothing held, so nothing to dismiss.';
2170
+ redraw();
2171
+ return;
2172
+ }
2173
+ if (dismissArmedFor !== held) {
2174
+ const changed = dismissArmedFor !== null;
2175
+ dismissArmedFor = held;
2176
+ replayNote = (changed ? \`That changed: \${held} held now. \` : '') +
2177
+ \`Press [d] again to dismiss \${held}: not applied, payloads discarded, cannot be undone.\`;
2178
+ redraw();
2179
+ return;
2180
+ }
2181
+ dismissArmedFor = null;
2182
+ try {
2183
+ const n = dismissHeld();
2184
+ replayNote = \`Dismissed \${n}. Nothing left held.\`;
2076
2185
  } catch (err) {
2077
- replayNote = \`Export failed: \${err instanceof Error ? err.message : String(err)}\`;
2186
+ replayNote = \`Dismiss failed: \${err instanceof Error ? err.message : String(err)}\`;
2078
2187
  }
2079
2188
  redraw();
2080
2189
  });
@@ -2141,6 +2250,10 @@ import { printBanner } from './brand.js';
2141
2250
  // after the passphrase is resolved. \`config.js\` reads a JSON file and the
2142
2251
  // environment and touches neither.
2143
2252
  import { config } from './config.js';
2253
+ // Same rule as \`config.js\`: \`unlock.js\` deliberately does not import the
2254
+ // database, so it can be loaded statically here and still run before the keys
2255
+ // are derived. See its own header.
2256
+ import { ask, databaseIsLocked } from './unlock.js';
2144
2257
  import { PassphraseRequiredError, WrongPassphraseError } from './vault.js';
2145
2258
 
2146
2259
  /**
@@ -2163,122 +2276,6 @@ const headless =
2163
2276
  process.env.ONEADDRESS_HEADLESS === '1' ||
2164
2277
  !process.stdout.isTTY;
2165
2278
 
2166
- /**
2167
- * Has this database already been locked?
2168
- *
2169
- * Asked BEFORE the passphrase, and without one, so the prompt can say which of
2170
- * two completely different things it is doing. \`db_meta.verifier\` is written the
2171
- * first time a passphrase is set, so its presence is the whole answer.
2172
- *
2173
- * Read through its own connection rather than importing \`db.ts\`, which derives
2174
- * its keys the moment it is imported and would therefore have to run BEFORE we
2175
- * know what to ask for.
2176
- */
2177
- async function databaseIsLocked(): Promise<boolean> {
2178
- try {
2179
- const { DatabaseSync } = await import('node:sqlite');
2180
- const { join } = await import('node:path');
2181
- const path = process.env.DB_PATH ?? join(process.cwd(), 'data.db');
2182
- const db = new DatabaseSync(path, { readOnly: true });
2183
- try {
2184
- const row = db
2185
- .prepare("SELECT value FROM db_meta WHERE key = 'verifier'")
2186
- .get() as { value?: string } | undefined;
2187
- return Boolean(row?.value);
2188
- } finally {
2189
- db.close();
2190
- }
2191
- } catch {
2192
- // No file yet, or no db_meta table yet. Either way: not locked.
2193
- return false;
2194
- }
2195
- }
2196
-
2197
- /**
2198
- * Ask for a passphrase without echoing it to the screen.
2199
- *
2200
- * A passphrase typed in clear on a shared screen, in a screen-share, or into a
2201
- * terminal that keeps scrollback is not much of a secret. readline echoes by
2202
- * default, so its output hook is replaced for the duration of the question.
2203
- *
2204
- * Degrades to a visible prompt rather than failing: on a terminal where the
2205
- * hook is not available, being asked in the clear beats not being asked.
2206
- */
2207
- async function ask(prompt: string): Promise<string> {
2208
- process.stdout.write(prompt);
2209
-
2210
- // NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
2211
- // mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
2212
- // by driving a real terminal and reading what came back. The first filtered
2213
- // on whether the chunk contained the prompt, not knowing readline repaints
2214
- // prompt and input together on every keystroke, so the condition was always
2215
- // true. The second repainted the line and still leaked, because the echo was
2216
- // never coming from that hook at all.
2217
- //
2218
- // Reading the keys directly removes the guessing. Raw mode turns the
2219
- // terminal's own echo OFF, so the ONLY thing that can reach the screen is
2220
- // what is written below: one asterisk per character, which is what a partner
2221
- // asked for and what every other passphrase prompt does.
2222
- const stdin = process.stdin;
2223
- if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
2224
- // No terminal to control. Being asked in the clear beats not being asked,
2225
- // and this path is only reached where nothing is watching anyway.
2226
- const { createInterface } = await import('node:readline/promises');
2227
- const rl = createInterface({ input: stdin, output: process.stdout });
2228
- try {
2229
- const answer = await rl.question('');
2230
- return answer.trim();
2231
- } finally { rl.close(); }
2232
- }
2233
-
2234
- const wasRaw = stdin.isRaw === true;
2235
- stdin.setRawMode(true);
2236
- stdin.resume();
2237
- stdin.setEncoding('utf8');
2238
-
2239
- return new Promise<string>((resolve) => {
2240
- let typed = '';
2241
- const restore = (): void => {
2242
- stdin.removeListener('data', onData);
2243
- stdin.setRawMode(wasRaw);
2244
- stdin.pause();
2245
- };
2246
- const onData = (chunk: string): void => {
2247
- for (const ch of chunk) {
2248
- if (ch === '\\r' || ch === '\\n') {
2249
- restore();
2250
- process.stdout.write('\\n');
2251
- resolve(typed.trim());
2252
- return;
2253
- }
2254
- if (ch === '\\u0003') { // Ctrl+C
2255
- restore();
2256
- process.stdout.write('\\n');
2257
- process.exit(130);
2258
- }
2259
- if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
2260
- restore();
2261
- process.stdout.write('\\n');
2262
- resolve(typed.trim());
2263
- return;
2264
- }
2265
- if (ch === '\\u007f' || ch === '\\b') {
2266
- // Backspace has to move the asterisks too, or the mask stops matching
2267
- // what is actually in the buffer and the count misleads.
2268
- if (typed.length > 0) {
2269
- typed = typed.slice(0, -1);
2270
- process.stdout.write('\\b \\b');
2271
- }
2272
- continue;
2273
- }
2274
- if (ch < ' ') continue; // ignore the rest of the control range
2275
- typed += ch;
2276
- process.stdout.write('*');
2277
- }
2278
- };
2279
- stdin.on('data', onData);
2280
- });
2281
- }
2282
2279
 
2283
2280
  /**
2284
2281
  * Where the at-rest passphrase comes from.
@@ -2332,9 +2329,14 @@ async function resolvePassphrase(): Promise<string | null> {
2332
2329
  // credentials, so assuming exactly that is the natural reading. Reported as
2333
2330
  // confusing, and the confusion was ours.
2334
2331
  //
2335
- // It therefore leads with the decision, says plainly that the answer is
2336
- // INVENTED HERE, and names the three secrets it is not, because those are the
2337
- // three things a partner at this point in setup might reach for.
2332
+ // It therefore leads with the decision and says plainly that the answer is
2333
+ // INVENTED HERE. It used to go on to name the three secrets it is NOT, which
2334
+ // was accurate and was four more lines of denial in a prompt that is already
2335
+ // the longest question in setup; reported as over-explained, and cut back to
2336
+ // the three facts an answer depends on (new, what it protects, unrecoverable).
2337
+ // The UNLOCK branch keeps its version of that list, because there the partner
2338
+ // is being asked for a password they may not remember choosing and naming the
2339
+ // wrong candidates is the whole help.
2338
2340
  // NOT ASKED IN INBOX MODE, because the honest answer to "protect your customer
2339
2341
  // records" is that there are none here: the connector holds them. The
2340
2342
  // database in this mode carries dispatch ciphertext - already encrypted to a
@@ -2354,14 +2356,10 @@ async function resolvePassphrase(): Promise<string | null> {
2354
2356
  }
2355
2357
 
2356
2358
  process.stdout.write('\\n \u2500\u2500 Protect this receiver\\'s customer database? \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\n');
2357
- process.stdout.write(' MAKE UP A NEW PASSWORD now. You are NOT being asked for anything\\n');
2358
- process.stdout.write(' you already have: not your Webhook signing secret, not your ECDH\\n');
2359
- process.stdout.write(' private key, not your OneAddress sign-in. This one is invented\\n');
2360
- process.stdout.write(' here and used only on this machine.\\n\\n');
2361
- process.stdout.write(' It encrypts the customer records this receiver keeps in data.db.\\n');
2362
- process.stdout.write(' OneAddress never sees it and cannot reset it, so if you lose it\\n');
2363
- process.stdout.write(' those records cannot be read again.\\n\\n');
2364
- process.stdout.write(' OPTIONAL. Press Enter to skip and leave the database readable.\\n');
2359
+ process.stdout.write(' MAKE UP A NEW PASSWORD. Invented here, not one you already\\n');
2360
+ process.stdout.write(' hold. It encrypts the customer records this receiver keeps in\\n');
2361
+ process.stdout.write(' data.db. OneAddress never sees it and cannot reset it.\\n\\n');
2362
+ process.stdout.write(' Optional. Press Enter to skip and leave the database readable.\\n');
2365
2363
  // ACCURATE ABOUT "LATER", because the obvious reading is wrong. Setting a
2366
2364
  // password afterwards works and the receiver reports itself encrypted, but
2367
2365
  // rows written before it stay in the clear until something rewrites them -
@@ -2758,7 +2756,7 @@ import express, { type Request, type Response } from 'express';
2758
2756
  import rateLimit from 'express-rate-limit';
2759
2757
  import { timingSafeEqual } from 'node:crypto';
2760
2758
  import { report } from './report.js';
2761
- import { acknowledge, drawable, markDrawn, type InboxOutcome } from './inbox.js';
2759
+ import { acknowledge, drawable, hold, markDrawn, type InboxOutcome } from './inbox.js';
2762
2760
 
2763
2761
  const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
2764
2762
  /**
@@ -2851,18 +2849,41 @@ export function startDrawApi(): { port: number } | null {
2851
2849
  * confirm, which is the honest answer when the partner's own system refused
2852
2850
  * the change: the consumer is told it did not land rather than being told it
2853
2851
  * did.
2852
+ *
2853
+ * \`held\` QUEUES NOTHING, and that is the whole point of it. It means the
2854
+ * connector could not OPEN the dispatch, which is a fact about a key rather
2855
+ * than about the consumer's record - and a \`failed\` confirm is read as the
2856
+ * latter all the way down: \`partner_failed\`, terminal, refund denied. The
2857
+ * item stays unacknowledged so the next draw retries it once the key is
2858
+ * fixed. See \`hold\` in inbox.ts for why nothing else recovers this.
2854
2859
  */
2855
2860
  app.post('/ack', (req: Request, res: Response) => {
2856
2861
  if (!authorised(req, res)) return;
2857
2862
  const body = req.body as { id?: unknown; outcome?: unknown; detail?: unknown };
2858
2863
  const id = typeof body.id === 'string' ? body.id : '';
2859
- const outcome: InboxOutcome | null =
2860
- body.outcome === 'applied' || body.outcome === 'failed' ? body.outcome : null;
2864
+ const outcome: InboxOutcome | 'held' | null =
2865
+ body.outcome === 'applied' || body.outcome === 'failed' || body.outcome === 'held'
2866
+ ? body.outcome
2867
+ : null;
2861
2868
  if (!id || !outcome) {
2862
- return res.status(400).json({ error: 'id and outcome (applied|failed) are required' });
2869
+ return res.status(400).json({ error: 'id and outcome (applied|failed|held) are required' });
2863
2870
  }
2864
2871
  const detail = typeof body.detail === 'string' ? body.detail : null;
2865
2872
 
2873
+ // HELD RETURNS BEFORE \`acknowledge\`, so there is no path from here to a
2874
+ // confirm. Ordering rather than a flag: a held item that fell through to
2875
+ // the code below would be stamped terminal and reported to OneAddress as
2876
+ // the consumer's problem, which is the defect this exists to remove.
2877
+ if (outcome === 'held') {
2878
+ const heldResult = hold(id, detail, LEASE_SECONDS);
2879
+ if (!heldResult) return res.status(404).json({ error: 'unknown or already acknowledged id' });
2880
+ return res.json({
2881
+ ok: true, held: true,
2882
+ attempts: heldResult.attempts,
2883
+ retryInSeconds: heldResult.retryInSeconds,
2884
+ });
2885
+ }
2886
+
2866
2887
  const result = acknowledge(id, outcome, detail);
2867
2888
  if (!result) return res.status(404).json({ error: 'unknown id' });
2868
2889
  if (result.alreadyAcknowledged) {
@@ -2962,9 +2983,16 @@ export function setAcknowledgementHandler(
2962
2983
  * the claim has to become atomic, the way \`confirm-queue.ts\` already does it.
2963
2984
  * Written down because a lease LOOKS like it handles concurrency and does not.
2964
2985
  */
2965
- import db from './db.js';
2986
+ import db, { ensureColumn } from './db.js';
2966
2987
  import { report } from './report.js';
2967
2988
 
2989
+ /**
2990
+ * The connector's TERMINAL verdicts. Both end the item and both tell OneAddress.
2991
+ *
2992
+ * \`held\` is deliberately NOT one of these, and lives in its own function. See
2993
+ * \`hold\` below for why a dispatch the connector could not OPEN must never
2994
+ * become one of these two.
2995
+ */
2968
2996
  export type InboxOutcome = 'applied' | 'failed';
2969
2997
 
2970
2998
  db.exec(\`
@@ -2983,6 +3011,30 @@ db.exec(\`
2983
3011
  ON inbox(applied_at, drawn_at, received_at);
2984
3012
  \`);
2985
3013
 
3014
+ /**
3015
+ * Why a held item needs its own two columns, matching \`quarantine\`'s.
3016
+ *
3017
+ * A dispatch the connector cannot OPEN is not finished with, so it keeps
3018
+ * \`applied_at IS NULL\` and stays in the undrawn count. But an operator then
3019
+ * needs to know it is BLOCKED rather than merely queued, and by what: without
3020
+ * these the dashboard can say a number and nothing else, and the connector's
3021
+ * message - which names the exact environment variable to set - is only in a
3022
+ * log somebody has to still have.
3023
+ *
3024
+ * Same names as the quarantine's, because it is the same idea one process over
3025
+ * and a second vocabulary for it would be a second thing to learn.
3026
+ */
3027
+ ensureColumn('inbox', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
3028
+ ensureColumn('inbox', 'last_error', 'TEXT');
3029
+
3030
+ /**
3031
+ * First retry for a held item, multiplied by the attempt count and capped at
3032
+ * the lease. Thirty seconds because the overwhelming case is a partner who has
3033
+ * just corrected a key and restarted the connector, and making them wait out a
3034
+ * lease built for crash recovery would be answering a question nobody asked.
3035
+ */
3036
+ const HELD_RETRY_BASE_SECONDS = 30;
3037
+
2986
3038
  export interface AcceptInput {
2987
3039
  key: string;
2988
3040
  dispatchId: string | null;
@@ -3064,6 +3116,110 @@ export function acknowledge(
3064
3116
  return { dispatchId: row.dispatch_id, alreadyAcknowledged: false };
3065
3117
  }
3066
3118
 
3119
+ /**
3120
+ * The connector could not OPEN this one, so it is not finished with.
3121
+ *
3122
+ * ## Why this is not \`failed\`
3123
+ *
3124
+ * \`failed\` is a statement about the CONSUMER'S RECORD - the partner's system
3125
+ * received the change and refused it - and OneAddress reads it exactly that
3126
+ * way. \`/api/confirm\` resolves every non-success to \`partner_failed\`, that row
3127
+ * is terminal (a later SUCCESSFUL confirm cannot overwrite it), and the refund
3128
+ * classifier maps it to \`no_match\`: "the partner received it and said no", so
3129
+ * the refund is DENIED and the consumer is emailed that it did not land.
3130
+ *
3131
+ * "I could not read it" is not that statement. It is a fact about the
3132
+ * partner's key configuration and has nothing to do with the consumer. Sending
3133
+ * it as \`failed\` charged a consumer five dollars, told them a provider had
3134
+ * rejected them, and refused them a refund, because a key was wrong in a
3135
+ * process they have never heard of.
3136
+ *
3137
+ * ## Why there is no retry to fall back on
3138
+ *
3139
+ * \`draw-loop.ts\` used to reason that "OneAddress's own retry of the dispatch is
3140
+ * the recovery path". That is true in write-through, where an unopenable
3141
+ * dispatch answers 422 and the dispatch row stays \`failed\` for the retry cron
3142
+ * to pick up. It is FALSE here, for the reason this mode exists: the receiver
3143
+ * already answered 200 at arrival, so the dispatch row is \`delivered\`, and
3144
+ * \`retry-webhooks\` only ever re-sends rows that are \`pending\` or \`failed\`.
3145
+ * Nothing retries. The confirm was the only remaining signal and it was being
3146
+ * spent on a verdict that was not true.
3147
+ *
3148
+ * ## What happens instead
3149
+ *
3150
+ * The item stays unacknowledged, so it stays in the undrawn count, which the
3151
+ * dashboard already reports as a fault - and that is the behaviour recorded in
3152
+ * the new-matter register for this mode: an undrawn update is held INDEFINITELY
3153
+ * and reported as a fault, deliberately unlike the quarantine's 30-day purge,
3154
+ * because an undrawn update is a consumer whose address did not land and ageing
3155
+ * it out loses it silently.
3156
+ *
3157
+ * Re-stamping \`drawn_at\` is the backoff and costs no new mechanism: \`drawable\`
3158
+ * re-offers an item once its stamp is older than the lease, so the next attempt
3159
+ * is one lease away and the poll loop cannot spin on a key that is still wrong.
3160
+ * When the key is fixed the next draw applies it, with no operator command -
3161
+ * which is the half that \`[r]\` has to be pressed for in write-through.
3162
+ *
3163
+ * Returns null for an unknown id or one already terminally acknowledged, the
3164
+ * same tolerance \`acknowledge\` has and for the same reason.
3165
+ */
3166
+ export function hold(
3167
+ id: string,
3168
+ detail: string | null,
3169
+ leaseSeconds: number,
3170
+ ): { attempts: number; retryInSeconds: number } | null {
3171
+ const row = db.prepare(
3172
+ 'SELECT applied_at, attempts FROM inbox WHERE id = ?',
3173
+ ).get(id) as { applied_at: string | null; attempts: number } | undefined;
3174
+ if (!row) return null;
3175
+ if (row.applied_at !== null) return null;
3176
+
3177
+ const attempts = (row.attempts ?? 0) + 1;
3178
+ // \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
3179
+ //
3180
+ // The lease is 300s and is generous ON PURPOSE: re-offering a DRAWN item too
3181
+ // early risks applying the same update twice. A held item carries none of
3182
+ // that risk, because nothing was applied - it could not even be opened. So
3183
+ // reusing the lease would make a partner who has just fixed their key wait
3184
+ // five minutes for a safety property that is not in play.
3185
+ //
3186
+ // \`drawable\` re-offers a row once \`drawn_at < now - lease\`, so a stamp of
3187
+ // \`now - lease + backoff\` becomes drawable in exactly \`backoff\` seconds.
3188
+ // That is the whole mechanism: no column, no second query, no timer. Pinned
3189
+ // by a test that checks it is NOT drawable before the backoff and IS after,
3190
+ // because arithmetic written backwards still produces a plausible timestamp.
3191
+ //
3192
+ // Capped at the lease so a repeatedly-failing item can never retry FASTER
3193
+ // than a crashed connector's item, which would be the wrong way round.
3194
+ const retryInSeconds = Math.min(HELD_RETRY_BASE_SECONDS * attempts, leaseSeconds);
3195
+ const stamp = new Date(Date.now() - leaseSeconds * 1000 + retryInSeconds * 1000).toISOString();
3196
+ db.prepare(
3197
+ 'UPDATE inbox SET attempts = ?, last_error = ?, drawn_at = ? WHERE id = ?',
3198
+ ).run(attempts, detail?.slice(0, 500) ?? null, stamp, id);
3199
+ report.warn(
3200
+ \`[inbox] \${id} HELD after \${attempts} attempt(s), retrying in \${retryInSeconds}s, not failed: \` +
3201
+ \`\${detail ?? 'the connector could not open it'}\`,
3202
+ );
3203
+ return { attempts, retryInSeconds };
3204
+ }
3205
+
3206
+ /**
3207
+ * The held items, for the dashboard's faults band.
3208
+ *
3209
+ * Separate from \`undrawnCount\` because the two mean different things to an
3210
+ * operator: undrawn is "your connector has not got to it yet", held is "your
3211
+ * connector tried and cannot". Only the second one needs somebody to do
3212
+ * something, and it is the one carrying the message that says what.
3213
+ */
3214
+ export function heldItems(): { id: string; attempts: number; lastError: string | null }[] {
3215
+ return db.prepare(
3216
+ \`SELECT id, attempts, last_error AS lastError
3217
+ FROM inbox
3218
+ WHERE applied_at IS NULL AND attempts > 0
3219
+ ORDER BY received_at\`,
3220
+ ).all() as unknown as { id: string; attempts: number; lastError: string | null }[];
3221
+ }
3222
+
3067
3223
  /** How many updates are sitting here unapplied. Shown as a fault when non-zero. */
3068
3224
  export function undrawnCount(): number {
3069
3225
  const row = db.prepare(
@@ -3238,8 +3394,9 @@ export function resetTally(): void {
3238
3394
  import db, { ensureColumn, parseStoredTime } from './db.js';
3239
3395
  import { report } from './report.js';
3240
3396
  import { createHash } from 'node:crypto';
3241
- import { writeFileSync } from 'node:fs';
3242
- import { join } from 'node:path';
3397
+ import { mkdirSync, writeFileSync } from 'node:fs';
3398
+ import { join, resolve } from 'node:path';
3399
+ import { config } from './config.js';
3243
3400
 
3244
3401
  /**
3245
3402
  * Why a dispatch could not be applied. Shown verbatim on the dashboard.
@@ -3297,6 +3454,75 @@ export function dispatchKey(dispatchId: string | null, rawBody: string): string
3297
3454
  */
3298
3455
  ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
3299
3456
 
3457
+ /**
3458
+ * When the operator decided this one is not going to be applied.
3459
+ *
3460
+ * ## Why a dashboard needs this at all
3461
+ *
3462
+ * The conformance suite sends a dispatch that CANNOT be opened, deliberately:
3463
+ * check-11 wraps a session key to a throwaway key pair and passes only if the
3464
+ * receiver reports the failure instead of answering \`200 { ok: true }\`. Holding
3465
+ * it is the correct behaviour and is how the check is passed. The receiver then
3466
+ * draws a red FAULTS panel about it for the rest of the database's life, and a
3467
+ * partner who has just been told they passed is looking at a permanent fault
3468
+ * they cannot clear. Reported from a real run.
3469
+ *
3470
+ * ## Why the receiver does NOT recognise the probe itself
3471
+ *
3472
+ * The tempting fix is to spot the probe and not count it. Everything that
3473
+ * identifies one comes from the SENDER: the dispatch id is a header, and the
3474
+ * ciphertext is opaque by construction. Suppressing an operator's fault panel on
3475
+ * a value the sender chose is a way to hide a real held dispatch from the person
3476
+ * whose job is to notice it, and it would be reachable by anyone holding the
3477
+ * webhook secret. The signal stays; the OPERATOR gets a way to answer it.
3478
+ *
3479
+ * ## Why the payload goes with it
3480
+ *
3481
+ * Dismissing says "I am not applying this". A held payload is a consumer's
3482
+ * encrypted address sitting on a third party's disk, kept only because applying
3483
+ * it later is still on the table. Once it is not, the reason to keep it is gone,
3484
+ * so \`raw_body\` is emptied in the same statement. That also makes dismissal
3485
+ * mean what it says: \`[r]\` cannot quietly bring it back.
3486
+ */
3487
+ ensureColumn('quarantine', 'dismissed_at', 'TEXT');
3488
+
3489
+ /**
3490
+ * Was this a conformance DRILL rather than a real dispatch?
3491
+ *
3492
+ * ## The problem it solves
3493
+ *
3494
+ * Conformance check 11 sends a dispatch this receiver CANNOT open, on purpose,
3495
+ * and passes it for refusing one. So a receiver that quarantines ends a
3496
+ * SUCCESSFUL run with a red fault panel, a \`failed\` count that never returns to
3497
+ * zero, and a \`decrypt_failed\` line naming the partner's real \`key_id\` - telling
3498
+ * them to fix the one thing that is not broken. Reported from a real run.
3499
+ *
3500
+ * ## Why this is trusted and the dispatch id is not
3501
+ *
3502
+ * The flag comes from \`conformance: true\` in the body, and the HMAC covers
3503
+ * \`\${timestamp}.\${rawBody}\`. The body is signed; the headers are not. The
3504
+ * conformance dispatch also carries a recognisable \`X-OneAddress-Dispatch\`
3505
+ * prefix, and keying off THAT would be a mistake: it sits outside the signature,
3506
+ * so anything on the path could set it and change how an operator's fault panel
3507
+ * behaves. Same reasoning as \`crossover_key_ids\`, which is already a signed-body
3508
+ * field that tells a receiver how to treat a payload it cannot decrypt.
3509
+ *
3510
+ * ## Classified, NOT suppressed
3511
+ *
3512
+ * A drill is still recorded, still counted, still visible on the dashboard. It
3513
+ * is drawn calmly instead of as an alarm. Suppression is what creates a silent
3514
+ * failure; classification does not, and if the marker is ever wrong the operator
3515
+ * still sees the event, in the wrong colour rather than not at all.
3516
+ *
3517
+ * ## A drill holds NO payload
3518
+ *
3519
+ * \`raw_body\` is empty for a drill, which is a property rather than a saving.
3520
+ * The probe is sealed to a throwaway key pair the conformance run generated and
3521
+ * discarded, so no key that will ever exist can open it: replay cannot succeed,
3522
+ * and keeping the bytes would put an unopenable blob in the replay set forever.
3523
+ */
3524
+ ensureColumn('quarantine', 'drill', 'INTEGER NOT NULL DEFAULT 0');
3525
+
3300
3526
  export interface QuarantineInput {
3301
3527
  dispatchId: string | null;
3302
3528
  event: string;
@@ -3304,6 +3530,10 @@ export interface QuarantineInput {
3304
3530
  keyId: string | null;
3305
3531
  rawBody: string;
3306
3532
  detail: string;
3533
+ /** A conformance probe rather than a consumer's dispatch. See the
3534
+ * \`drill\` column's docstring. Defaults to false, so a caller that does
3535
+ * not know about drills still records a real fault. */
3536
+ drill?: boolean;
3307
3537
  }
3308
3538
 
3309
3539
  /**
@@ -3315,27 +3545,44 @@ export interface QuarantineInput {
3315
3545
  */
3316
3546
  export function quarantine(input: QuarantineInput): void {
3317
3547
  const id = dispatchKey(input.dispatchId, input.rawBody);
3548
+ const drill = input.drill === true;
3318
3549
  try {
3319
3550
  db.prepare(
3320
- \`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
3321
- VALUES (?, ?, ?, ?, ?, ?, ?)
3551
+ \`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail, drill)
3552
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3322
3553
  ON CONFLICT(id) DO UPDATE SET
3323
3554
  reason = excluded.reason,
3324
3555
  key_id = excluded.key_id,
3325
- detail = excluded.detail\`,
3556
+ detail = excluded.detail,
3557
+ drill = excluded.drill\`,
3326
3558
  ).run(
3327
3559
  id,
3328
3560
  input.dispatchId,
3329
3561
  input.event,
3330
3562
  input.reason,
3331
3563
  input.keyId,
3332
- input.rawBody,
3564
+ // NO PAYLOAD FOR A DRILL. The probe is sealed to a throwaway key pair the
3565
+ // conformance run generated and discarded, so nothing that will ever exist
3566
+ // can open it. Keeping the bytes would put a permanently unopenable blob
3567
+ // in the replay set.
3568
+ drill ? '' : input.rawBody,
3333
3569
  input.detail.slice(0, 500),
3570
+ drill ? 1 : 0,
3334
3571
  );
3335
- report.warn(
3336
- \`[quarantine] \${input.event} held (\${input.reason}\${input.keyId ? \`, key_id \${input.keyId}\` : ''}). \` +
3337
- 'Fix the cause and press [r] on the dashboard, or run \`npm run replay\`, to apply it.',
3338
- );
3572
+ if (drill) {
3573
+ // INFO, NOT WARN, and it says what happened rather than what to fix. The
3574
+ // old line ended a PASSING conformance run by telling the partner to go
3575
+ // and correct a key that is perfectly correct.
3576
+ report.info(
3577
+ \`[quarantine] \${input.event} was a conformance probe and was refused, which is how that \` +
3578
+ 'check is passed. Nothing is wrong with your key.',
3579
+ );
3580
+ } else {
3581
+ report.warn(
3582
+ \`[quarantine] \${input.event} held (\${input.reason}\${input.keyId ? \`, key_id \${input.keyId}\` : ''}). \` +
3583
+ 'Fix the cause and press [r] on the dashboard, or run \`npm run replay\`, to apply it.',
3584
+ );
3585
+ }
3339
3586
  } catch (err) {
3340
3587
  report.error('[quarantine] could not hold this dispatch:', err instanceof Error ? err.message : err);
3341
3588
  }
@@ -3360,16 +3607,30 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
3360
3607
  \`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
3361
3608
  received_at, last_error, attempts
3362
3609
  FROM quarantine
3363
- WHERE replayed_at IS NULL
3610
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0
3364
3611
  ORDER BY received_at
3365
3612
  LIMIT ?\`,
3366
3613
  ).all(limit) as unknown as HeldDispatch[];
3367
3614
  }
3368
3615
 
3616
+ /**
3617
+ * How many conformance probes this receiver has refused.
3618
+ *
3619
+ * Its own figure rather than a slice of \`heldCount\`, because the two answer
3620
+ * different questions. Held means "waiting on you". A drill means "a check ran
3621
+ * and this receiver behaved correctly", which is not a queue and not work.
3622
+ */
3623
+ export function drillCount(): number {
3624
+ const row = db.prepare(
3625
+ 'SELECT count(*) AS n FROM quarantine WHERE drill = 1 AND dismissed_at IS NULL',
3626
+ ).get() as { n: number };
3627
+ return row.n;
3628
+ }
3629
+
3369
3630
  /** How many dispatches are held. Shown on the dashboard. */
3370
3631
  export function heldCount(): number {
3371
3632
  const row = db.prepare(
3372
- 'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL',
3633
+ 'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0',
3373
3634
  ).get() as { n: number };
3374
3635
  return row.n;
3375
3636
  }
@@ -3386,7 +3647,7 @@ export function heldSummary(): string[] {
3386
3647
  \`SELECT reason, key_id, count(*) AS n,
3387
3648
  max(attempts) AS tries, min(received_at) AS oldest
3388
3649
  FROM quarantine
3389
- WHERE replayed_at IS NULL
3650
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0
3390
3651
  GROUP BY reason, key_id
3391
3652
  ORDER BY n DESC\`,
3392
3653
  ).all() as unknown as {
@@ -3430,6 +3691,38 @@ export function markReplayFailed(id: string, error: string): void {
3430
3691
  .run(error.slice(0, 500), id);
3431
3692
  }
3432
3693
 
3694
+ /**
3695
+ * The operator's answer to the fault panel: not applying these, stop counting them.
3696
+ *
3697
+ * Takes everything currently held rather than one row, because the panel groups
3698
+ * by cause and offers no way to point at a single dispatch. The realistic use is
3699
+ * one decision about one cause, which is the shape the panel already shows.
3700
+ *
3701
+ * \`raw_body\` is emptied in the same statement. See the \`dismissed_at\` docstring
3702
+ * for why: once the operator has said it will not be applied, a consumer's
3703
+ * encrypted address is being kept for no reason, and a dismissal that leaves the
3704
+ * payload behind is one \`[r]\` away from not being a dismissal.
3705
+ *
3706
+ * The row itself STAYS. It is the record that a dispatch arrived and was
3707
+ * consciously dropped, which is the fact an audit wants, and \`purgeQuarantine\`
3708
+ * ages it out on the same window as everything else.
3709
+ */
3710
+ export function dismissHeld(): number {
3711
+ const info = db.prepare(
3712
+ \`UPDATE quarantine
3713
+ SET dismissed_at = ?, raw_body = ''
3714
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0\`,
3715
+ ).run(new Date().toISOString());
3716
+ const n = Number(info.changes ?? 0);
3717
+ if (n > 0) {
3718
+ report.warn(
3719
+ \`[quarantine] dismissed \${n} held dispatch(es). They will NOT be applied and their \` +
3720
+ 'payloads have been discarded. OneAddress was already told each one failed.',
3721
+ );
3722
+ }
3723
+ return n;
3724
+ }
3725
+
3433
3726
  /**
3434
3727
  * Drop held payloads past the retention window, replayed or not.
3435
3728
  *
@@ -3498,10 +3791,25 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
3498
3791
  * what diagnoses a key problem, and it is safe to paste into a support ticket
3499
3792
  * or send to us, which is what an export is for.
3500
3793
  */
3501
- export function exportHeld(directory: string): { path: string; count: number } {
3794
+ export function exportHeld(directory?: string): { path: string; count: number } {
3502
3795
  const rows = heldDispatches(500);
3503
3796
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3504
- const path = join(directory, \`oneaddress-faults-\${stamp}.json\`);
3797
+ // RESOLVED TO AN ABSOLUTE PATH, ALWAYS, and that is the point of the change.
3798
+ // "Exported 3 to oneaddress-faults-\u2026.json" is not an answer to "where is it";
3799
+ // a receiver is routinely started by a shortcut or a service manager whose
3800
+ // working directory is not where the partner thinks it is. Reported after a
3801
+ // real run: the file was written and could not be found.
3802
+ //
3803
+ // The explicit argument still wins, so a caller (and every existing test) can
3804
+ // name a directory; \`config.exportDir\` is the persistent choice and
3805
+ // \`ONEADDRESS_EXPORT_DIR\` the one-run override. Empty resolves to the working
3806
+ // directory, which is what this always did.
3807
+ const target = resolve(directory ?? config.exportDir ?? '');
3808
+ // Created rather than failed on. A partner who set \`exportDir\` to a folder
3809
+ // they have not made yet wants the file, not a lecture, and a directory they
3810
+ // named is one they have already consented to.
3811
+ mkdirSync(target, { recursive: true });
3812
+ const path = join(target, \`oneaddress-faults-\${stamp}.json\`);
3505
3813
  writeFileSync(path, JSON.stringify({
3506
3814
  exported_at: new Date().toISOString(),
3507
3815
  note: 'Metadata only. The held payloads are deliberately not included: they are encrypted consumer addresses and stay under the receiver retention window.',
@@ -3518,7 +3826,11 @@ export function exportHeld(directory: string): { path: string; count: number } {
3518
3826
  last_error: r.last_error,
3519
3827
  })),
3520
3828
  }, null, 2));
3521
- report.info(\`[quarantine] exported \${rows.length} held dispatch(es) to \${path}\`);
3829
+ // THE PATH IS THE MESSAGE. Logged as well as shown on the dashboard, because
3830
+ // the dashboard line is gone the next time anything is pressed while the log
3831
+ // survives a scrollback, a \`npm start | tee\`, and a headless run where there
3832
+ // is no dashboard at all.
3833
+ report.info(\`[quarantine] exported \${rows.length} held dispatch(es) to \${path} - safe to send to support, no payloads inside\`);
3522
3834
  return { path, count: rows.length };
3523
3835
  }
3524
3836
 
@@ -3915,6 +4227,27 @@ export type ReceiverConfig = {
3915
4227
  oneAddressApi: string;
3916
4228
  verifiesAccountReference: boolean;
3917
4229
  mode: ReceiverMode;
4230
+ /**
4231
+ * Where \`[e]\` writes the fault export.
4232
+ *
4233
+ * Empty means the working directory, which is what it always did. Named
4234
+ * because "it saved a file" is useless if you cannot find it: the receiver is
4235
+ * routinely started by a double-clicked shortcut or a service manager whose
4236
+ * working directory is not where the partner thinks it is.
4237
+ *
4238
+ * \`ONEADDRESS_EXPORT_DIR\` overrides it for one run without editing the file.
4239
+ */
4240
+ exportDir: string;
4241
+ /**
4242
+ * The webhook URL registered with OneAddress: where dispatches actually
4243
+ * arrive from the internet.
4244
+ *
4245
+ * Written by the wizard, which is the only thing that knows it - the receiver
4246
+ * binds a local port and has no idea what hostname reaches it. Empty disables
4247
+ * the reachability check rather than guessing, because a wrong URL here would
4248
+ * report a healthy receiver as unreachable, which is worse than silence.
4249
+ */
4250
+ publicUrl: string;
3918
4251
  };
3919
4252
 
3920
4253
  const DEFAULTS: ReceiverConfig = {
@@ -3929,6 +4262,11 @@ const DEFAULTS: ReceiverConfig = {
3929
4262
  // running and a key living somewhere else; a receiver that silently switched
3930
4263
  // into it would accept dispatches nothing ever collects.
3931
4264
  mode: 'write-through',
4265
+ // No guess. See the field's docstring.
4266
+ publicUrl: '',
4267
+ // Empty resolves to the working directory at use, not here, so a config file
4268
+ // written on one machine does not pin an absolute path from another.
4269
+ exportDir: '',
3932
4270
  };
3933
4271
 
3934
4272
  function parseMode(value: unknown): ReceiverMode | undefined {
@@ -3947,6 +4285,8 @@ function loadConfigFile(): Partial<ReceiverConfig> {
3947
4285
  if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
3948
4286
  if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
3949
4287
  if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
4288
+ if (typeof parsed.publicUrl === 'string') out.publicUrl = parsed.publicUrl;
4289
+ if (typeof parsed.exportDir === 'string') out.exportDir = parsed.exportDir;
3950
4290
  const mode = parseMode(parsed.mode);
3951
4291
  if (mode) out.mode = mode;
3952
4292
  return out;
@@ -3970,6 +4310,8 @@ export const config: ReceiverConfig = {
3970
4310
  // up as "not the mode I asked for" rather than as a receiver that will not
3971
4311
  // start. Silent is the thing to avoid, not strict.
3972
4312
  mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
4313
+ publicUrl: stripTrailingSlash(process.env.PUBLIC_URL || fromFile.publicUrl || DEFAULTS.publicUrl),
4314
+ exportDir: process.env.ONEADDRESS_EXPORT_DIR || fromFile.exportDir || DEFAULTS.exportDir,
3973
4315
  };
3974
4316
 
3975
4317
  report.info(
@@ -3977,6 +4319,188 @@ report.info(
3977
4319
  ', oneAddressApi=' + config.oneAddressApi +
3978
4320
  ', verifiesAccountReference=' + config.verifiesAccountReference + ')',
3979
4321
  );
4322
+ `
4323
+ },
4324
+ {
4325
+ name: "src/reachable.ts",
4326
+ content: `/**
4327
+ * IS ANYTHING ON THE INTERNET STILL REACHING THIS RECEIVER?
4328
+ *
4329
+ * ## The problem, and why the receiver cannot simply notice
4330
+ *
4331
+ * A partner running behind a tunnel stops the tunnel - closes the laptop, ends
4332
+ * the terminal, lets the trial lapse - and the receiver notices nothing. That is
4333
+ * not an oversight: the tunnel DIALS IN to this process. Nothing here holds a
4334
+ * connection outward, so from inside, "the tunnel is down" and "nobody sent me
4335
+ * anything" are the same observation, which is silence. And silence is not a
4336
+ * fault: a small partner legitimately goes days between dispatches, so a
4337
+ * watchdog that fired on quiet would cry wolf at exactly the partners least able
4338
+ * to tell the difference.
4339
+ *
4340
+ * Meanwhile OneAddress is failing to deliver, marking the endpoint unhealthy,
4341
+ * and the only place that says so is a portal nobody has open.
4342
+ *
4343
+ * ## How this answers it
4344
+ *
4345
+ * The receiver fetches ITS OWN PUBLIC URL and checks that a nonce minted at
4346
+ * startup comes back. Only this running process knows that value, which is what
4347
+ * makes the answer unambiguous.
4348
+ *
4349
+ * "DID ANYTHING ANSWER" IS NOT GOOD ENOUGH, and that is the whole reason for
4350
+ * the nonce. A stopped Cloudflare tunnel does not refuse the connection: the
4351
+ * edge is still there and serves an error page (1033, or a 502). That is a
4352
+ * perfectly valid HTTP response, so a check written \`if (res.ok)\` - or even
4353
+ * \`if (res.status < 500)\` - reports a dead tunnel as healthy. A check that
4354
+ * demands the nonce cannot be satisfied by anything except this process.
4355
+ *
4356
+ * ## What it deliberately does not do
4357
+ *
4358
+ * It does not fail the receiver, retry aggressively, or call OneAddress. It is
4359
+ * a statement on the dashboard and a log line. A receiver that shut itself down
4360
+ * because it could not see itself would turn a DNS blip into an outage, and the
4361
+ * failure it is reporting is one only a human can fix.
4362
+ *
4363
+ * It is also not a security control. The nonce proves identity of the process,
4364
+ * not authorisation: the endpoint returns it to anyone who asks. It is a random
4365
+ * value with no meaning outside this check, and knowing it grants nothing - the
4366
+ * webhook still requires a valid HMAC.
4367
+ */
4368
+ import { randomBytes } from 'node:crypto';
4369
+ import { config } from './config.js';
4370
+ import { report } from './report.js';
4371
+
4372
+ /** Minted once per process. See the header: this is what makes the answer
4373
+ * unambiguous, and it is deliberately not a secret. */
4374
+ export const ALIVE_NONCE = randomBytes(16).toString('hex');
4375
+
4376
+ /** The path the check hits. Deliberately NOT \`/webhook\`: that is POST-only and
4377
+ * would 404 or 405 through a perfectly healthy tunnel. */
4378
+ export const ALIVE_PATH = '/oa-receiver-alive';
4379
+
4380
+ export type Reachability =
4381
+ | { state: 'unknown'; detail: string }
4382
+ | { state: 'reachable'; checkedAt: string }
4383
+ | { state: 'unreachable'; detail: string; since: string };
4384
+
4385
+ let current: Reachability = { state: 'unknown', detail: 'not checked yet' };
4386
+
4387
+ /** What the dashboard renders. Never throws. */
4388
+ export function reachability(): Reachability {
4389
+ return current;
4390
+ }
4391
+
4392
+ /**
4393
+ * The ORIGIN to probe, or null when there is nothing usable to probe.
4394
+ *
4395
+ * TWO THINGS THIS FIXES, both found by reading the value the wizard actually
4396
+ * writes rather than the one this function wanted.
4397
+ *
4398
+ * 1. \`publicUrl\` is the REGISTERED WEBHOOK URL, so it ends in \`/webhook\`.
4399
+ * Appending the probe path to it gives \`\u2026/webhook/oa-receiver-alive\`, which
4400
+ * 404s through a perfectly healthy tunnel and would report every working
4401
+ * receiver as unreachable. The origin is what is wanted.
4402
+ * 2. The scaffold substitutes a PLACEHOLDER (\`<your-webhook-url>\`) when setup
4403
+ * ran without a registered URL. Probing that fails forever, so a partner who
4404
+ * skipped registration would get a permanent red panel about a fault they do
4405
+ * not have.
4406
+ *
4407
+ * Anything that is not an absolute http(s) URL therefore reads as NOT
4408
+ * CONFIGURED rather than as unreachable: the failure mode to avoid here is
4409
+ * crying wolf, since the whole value of the check is that it is believed.
4410
+ */
4411
+ export function probeOrigin(raw: string): string | null {
4412
+ if (!raw) return null;
4413
+ try {
4414
+ const u = new URL(raw);
4415
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
4416
+ return u.origin;
4417
+ } catch {
4418
+ return null;
4419
+ }
4420
+ }
4421
+
4422
+ /**
4423
+ * One check. Returns the new state rather than only setting it, so a test can
4424
+ * drive it directly without reaching into module state.
4425
+ */
4426
+ export async function checkReachable(fetchImpl: typeof fetch = fetch): Promise<Reachability> {
4427
+ const origin = probeOrigin(config.publicUrl);
4428
+ if (!origin) {
4429
+ current = {
4430
+ state: 'unknown',
4431
+ detail: config.publicUrl
4432
+ ? 'publicUrl in oneaddress.config.json is not a usable http(s) URL'
4433
+ : 'no publicUrl in oneaddress.config.json',
4434
+ };
4435
+ return current;
4436
+ }
4437
+
4438
+ const url = \`\${origin}\${ALIVE_PATH}\`;
4439
+ const since = current.state === 'unreachable' ? current.since : new Date().toISOString();
4440
+
4441
+ try {
4442
+ const res = await fetchImpl(url, {
4443
+ method: 'GET',
4444
+ // Short: this is a liveness probe, not a download. A tunnel that takes
4445
+ // longer than this to answer is already failing dispatches, which time
4446
+ // out on OneAddress's side too.
4447
+ signal: AbortSignal.timeout(10_000),
4448
+ headers: { 'cache-control': 'no-cache' },
4449
+ });
4450
+ const body = await res.text().catch(() => '');
4451
+ // THE NONCE, not the status. See the header: a stopped tunnel answers with
4452
+ // a real HTTP response carrying an error page.
4453
+ if (body.trim() === ALIVE_NONCE) {
4454
+ current = { state: 'reachable', checkedAt: new Date().toISOString() };
4455
+ return current;
4456
+ }
4457
+ current = {
4458
+ state: 'unreachable',
4459
+ detail: \`\${url} answered HTTP \${res.status} but not from this receiver (tunnel down, or the URL points elsewhere)\`,
4460
+ since,
4461
+ };
4462
+ } catch (err) {
4463
+ current = {
4464
+ state: 'unreachable',
4465
+ detail: \`\${url} could not be reached: \${err instanceof Error ? err.message : String(err)}\`,
4466
+ since,
4467
+ };
4468
+ }
4469
+ return current;
4470
+ }
4471
+
4472
+ /**
4473
+ * Check on a timer, and say so ONCE per transition rather than every tick.
4474
+ *
4475
+ * A line every five minutes for a tunnel that has been down all night is how a
4476
+ * log stops being read. The transition is the news.
4477
+ */
4478
+ export function startReachabilityWatch(everyMs = 5 * 60_000): NodeJS.Timeout | null {
4479
+ if (!probeOrigin(config.publicUrl)) {
4480
+ report.info('[reachable] no usable publicUrl configured, so this receiver cannot check whether the internet can reach it.');
4481
+ return null;
4482
+ }
4483
+ let last: Reachability['state'] = 'unknown';
4484
+ const tick = (): void => {
4485
+ void checkReachable().then((r) => {
4486
+ if (r.state === last) return;
4487
+ last = r.state;
4488
+ if (r.state === 'unreachable') {
4489
+ report.error(\`[reachable] NOTHING IS REACHING THIS RECEIVER. \${r.detail}. Dispatches cannot arrive while this is true.\`);
4490
+ } else if (r.state === 'reachable') {
4491
+ report.info('[reachable] your public URL reaches this receiver.');
4492
+ }
4493
+ });
4494
+ };
4495
+ // A first check soon after startup, not immediately: a tunnel started
4496
+ // alongside the receiver needs a moment before it answers, and reporting it
4497
+ // down on second one would be wrong every single time.
4498
+ const first = setTimeout(tick, 15_000);
4499
+ first.unref?.();
4500
+ const timer = setInterval(tick, everyMs);
4501
+ timer.unref?.();
4502
+ return timer;
4503
+ }
3980
4504
  `
3981
4505
  },
3982
4506
  {
@@ -4531,6 +5055,66 @@ export function customerCount(): number {
4531
5055
  return (db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }).n;
4532
5056
  }
4533
5057
 
5058
+ /** One applied change: what this receiver held before, and what it holds now. */
5059
+ export interface AddressChange {
5060
+ account_number: string;
5061
+ name: string;
5062
+ prev_address: Address | null;
5063
+ address: Address;
5064
+ recorded_at: string;
5065
+ }
5066
+
5067
+ /**
5068
+ * The applied changes, newest first, decrypted and joined back to the customer.
5069
+ *
5070
+ * ## Why this is exported rather than left to \`sqlite3\`
5071
+ *
5072
+ * Because \`sqlite3 data.db\` cannot answer it. Every text column here is
5073
+ * AES-GCM ciphertext whenever the database has a password, which is the state
5074
+ * this receiver asks for on its first run and reports in green on the
5075
+ * dashboard. So the one question a partner has after their first real dispatch
5076
+ * - "show me it actually changed" - had no answer short of writing code against
5077
+ * the store. The dashboard's LAST CHANGE panel shows the most recent one and
5078
+ * nothing shows the rest.
5079
+ *
5080
+ * \`prev_address\` is NULL for a row written before the column existed
5081
+ * (\`ensureColumn\` defaults it to \`'{}'\`, which decodes to an empty object, not
5082
+ * to an address) and for the first change on an account that had no address on
5083
+ * file. Those are different facts and both are honestly "nothing to compare
5084
+ * against", so both come back as null rather than as an empty address.
5085
+ */
5086
+ export function addressHistory(limit = 20): AddressChange[] {
5087
+ const rows = db.prepare(
5088
+ \`SELECT h.prev_address AS prev_address,
5089
+ h.address AS address,
5090
+ h.recorded_at AS recorded_at,
5091
+ c.account_number AS account_number,
5092
+ c.name AS name
5093
+ FROM address_history h
5094
+ LEFT JOIN customers c ON c.account_key = h.account_key
5095
+ ORDER BY h.id DESC
5096
+ LIMIT ?\`,
5097
+ ).all(limit) as unknown[];
5098
+
5099
+ return rows.map((raw) => {
5100
+ const r = raw as Record<string, string | null>;
5101
+ const prevJson = dec('address', r.prev_address);
5102
+ let prev: Address | null = null;
5103
+ try {
5104
+ const parsed = prevJson ? JSON.parse(prevJson) as Address : null;
5105
+ // \`{}\` is the column default, not an address anyone held.
5106
+ prev = parsed && Object.keys(parsed).length > 0 ? parsed : null;
5107
+ } catch { prev = null; }
5108
+ return {
5109
+ account_number: dec('account_number', r.account_number) ?? '(unknown)',
5110
+ name: dec('name', r.name) ?? '(unknown)',
5111
+ prev_address: prev,
5112
+ address: JSON.parse(dec('address', r.address) ?? '{}') as Address,
5113
+ recorded_at: r.recorded_at ?? '',
5114
+ };
5115
+ });
5116
+ }
5117
+
4534
5118
  /** Is the file on disk protected? Surfaced in the dashboard, in both states. */
4535
5119
  export const storeEncrypted = encrypted;
4536
5120
 
@@ -4723,6 +5307,7 @@ import {
4723
5307
  // exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
4724
5308
  // the protocol layer changes.
4725
5309
  import { store as writeThroughStore } from './store.js';
5310
+ import { ALIVE_NONCE, ALIVE_PATH, startReachabilityWatch } from './reachable.js';
4726
5311
  import { ConnectorUnreachableError, connectorStore, setCurrentRawBody } from './connector-store.js';
4727
5312
  import { noteChange } from './tui.js';
4728
5313
  import { config } from './config.js';
@@ -5145,9 +5730,27 @@ app.post('/webhook', async (req: Request, res: Response) => {
5145
5730
  */
5146
5731
  const key = dispatchKey(dispatch || null, rawBody);
5147
5732
 
5733
+ /**
5734
+ * A DRILL, declared in the SIGNED body. See \`quarantine.ts\`'s \`drill\` column.
5735
+ *
5736
+ * Read from \`body\`, which the HMAC covers, and never from the dispatch-id
5737
+ * header, which it does not. The conformance run already puts a recognisable
5738
+ * prefix on that header and using it would let anything on the path decide
5739
+ * whether this receiver raises an alarm.
5740
+ */
5741
+ const isDrill = (body as Record<string, unknown>).conformance === true;
5742
+
5148
5743
  const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void => {
5149
- quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
5150
- recordOutcome(key, 'failed');
5744
+ quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail, drill: isDrill });
5745
+ // NOT COUNTED AS A FAILURE WHEN IT IS A DRILL. \`failed\` on the footer is a
5746
+ // figure about this partner's dispatches; a probe that was refused exactly
5747
+ // as the check requires is not one of them, and leaving it in is what made a
5748
+ // passing conformance run read as \`failed 1\` forever.
5749
+ //
5750
+ // Recorded as NOTHING rather than as a third outcome, deliberately:
5751
+ // \`tally.ts\` holds \`received === applied + failed\` as an invariant and a
5752
+ // drill is not a dispatch to this partner's business at all.
5753
+ if (!isDrill) recordOutcome(key, 'failed');
5151
5754
  };
5152
5755
 
5153
5756
  /**
@@ -5304,11 +5907,24 @@ app.post('/webhook', async (req: Request, res: Response) => {
5304
5907
  decAccount = typeof data.account_number === 'string' ? data.account_number : '';
5305
5908
  decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
5306
5909
  } catch (err) {
5307
- // Names WHICH key answered. A rotation used to surface here as an
5308
- // authentication-tag error indistinguishable from corruption, with the
5309
- // single-key fallback having silently answered for a key id it never held.
5310
- report.error(\`[webhook] D5 decryption failed for key_id \${keyId ?? '(none)'}: \${keyFailureAdvice(keyId, resolved)}\`);
5311
- report.error('[webhook] underlying error:', err);
5910
+ // A DRILL SAYS SO INSTEAD OF SHOUTING. Conformance check 11 wraps its
5911
+ // session key to a throwaway pair so that decryption MUST fail; the two
5912
+ // lines below would then name the partner's real key_id, print a raw
5913
+ // crypto error, and advise them to go and fix a key that is correct - at
5914
+ // the end of a run that just passed. The refusal below is unchanged,
5915
+ // because the refusal is what the check is testing.
5916
+ if (isDrill) {
5917
+ report.info(
5918
+ \`[webhook] conformance probe for key_id \${keyId ?? '(none)'} could not be decrypted, which is \` +
5919
+ 'what that check requires. Your key is fine.',
5920
+ );
5921
+ } else {
5922
+ // Names WHICH key answered. A rotation used to surface here as an
5923
+ // authentication-tag error indistinguishable from corruption, with the
5924
+ // single-key fallback having silently answered for a key id it never held.
5925
+ report.error(\`[webhook] D5 decryption failed for key_id \${keyId ?? '(none)'}: \${keyFailureAdvice(keyId, resolved)}\`);
5926
+ report.error('[webhook] underlying error:', err);
5927
+ }
5312
5928
  hold('decrypt_failed', keyId, keyFailureAdvice(keyId, resolved));
5313
5929
  return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
5314
5930
  }
@@ -5518,9 +6134,26 @@ app.get('/health', (_req, res) => res.json({
5518
6134
  encryptedAtRest: store.encrypted,
5519
6135
  }));
5520
6136
 
5521
- app.listen(PORT, () =>
5522
- report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`),
5523
- );
6137
+ /**
6138
+ * The reachability probe's target. Returns this process's startup nonce as
6139
+ * plain text, and nothing else.
6140
+ *
6141
+ * WHY A NONCE AND NOT \`/health\`. A stopped Cloudflare tunnel still ANSWERS -
6142
+ * the edge serves a 1033 or a 502 error page - so a check that accepts any HTTP
6143
+ * response reports a dead tunnel as healthy. Only this running process knows
6144
+ * this value, so receiving it back is proof the whole path is open. See
6145
+ * \`src/reachable.ts\`.
6146
+ *
6147
+ * NOT A SECRET, and nothing is gated on it: it proves which process answered,
6148
+ * not who asked. The webhook still requires a valid HMAC.
6149
+ */
6150
+ app.get(ALIVE_PATH, (_req, res) => res.type('text/plain').send(ALIVE_NONCE));
6151
+
6152
+ app.listen(PORT, () => {
6153
+ report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`);
6154
+ // Started AFTER listen, or the first probe races the server it is probing.
6155
+ startReachabilityWatch();
6156
+ });
5524
6157
 
5525
6158
  /**
5526
6159
  * Drain the confirm queue, forever.
@@ -5678,6 +6311,228 @@ export function dashboardStats(): {
5678
6311
  // there, so the port the UI claims is the port the server actually bound.
5679
6312
  export { PORT };
5680
6313
  export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
6314
+ `
6315
+ },
6316
+ {
6317
+ name: "src/unlock.ts",
6318
+ content: `/**
6319
+ * Opening a locked database: is it locked, and asking for the password.
6320
+ *
6321
+ * ## Why these two live in their own file
6322
+ *
6323
+ * Both are needed by \`src/index.ts\` (which prompts before starting the
6324
+ * receiver) and by \`scripts/show.ts\` (which prompts before reading the roster),
6325
+ * and a second copy of a masked-input routine is the last thing this codebase
6326
+ * needs: the comment inside \`ask\` records TWO earlier versions that echoed the
6327
+ * passphrase in clear, neither of which was caught by reading the code. One
6328
+ * implementation, driven by one test.
6329
+ *
6330
+ * NOTHING HERE IMPORTS \`db.ts\`, and that is a constraint rather than tidiness.
6331
+ * Importing the database derives its keys on import, so anything asking WHICH
6332
+ * QUESTION TO PUT has to run first. \`databaseIsLocked\` therefore opens its own
6333
+ * read-only connection.
6334
+ */
6335
+ /**
6336
+ * Has this database already been locked?
6337
+ *
6338
+ * Asked BEFORE the passphrase, and without one, so the prompt can say which of
6339
+ * two completely different things it is doing. \`db_meta.verifier\` is written the
6340
+ * first time a passphrase is set, so its presence is the whole answer.
6341
+ *
6342
+ * Read through its own connection rather than importing \`db.ts\`, which derives
6343
+ * its keys the moment it is imported and would therefore have to run BEFORE we
6344
+ * know what to ask for.
6345
+ */
6346
+ export async function databaseIsLocked(): Promise<boolean> {
6347
+ try {
6348
+ const { DatabaseSync } = await import('node:sqlite');
6349
+ const { join } = await import('node:path');
6350
+ const path = process.env.DB_PATH ?? join(process.cwd(), 'data.db');
6351
+ const db = new DatabaseSync(path, { readOnly: true });
6352
+ try {
6353
+ const row = db
6354
+ .prepare("SELECT value FROM db_meta WHERE key = 'verifier'")
6355
+ .get() as { value?: string } | undefined;
6356
+ return Boolean(row?.value);
6357
+ } finally {
6358
+ db.close();
6359
+ }
6360
+ } catch {
6361
+ // No file yet, or no db_meta table yet. Either way: not locked.
6362
+ return false;
6363
+ }
6364
+ }
6365
+
6366
+ /**
6367
+ * Ask for a passphrase without echoing it to the screen.
6368
+ *
6369
+ * A passphrase typed in clear on a shared screen, in a screen-share, or into a
6370
+ * terminal that keeps scrollback is not much of a secret. readline echoes by
6371
+ * default, so its output hook is replaced for the duration of the question.
6372
+ *
6373
+ * Degrades to a visible prompt rather than failing: on a terminal where the
6374
+ * hook is not available, being asked in the clear beats not being asked.
6375
+ */
6376
+ export async function ask(prompt: string): Promise<string> {
6377
+ process.stdout.write(prompt);
6378
+
6379
+ // NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
6380
+ // mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
6381
+ // by driving a real terminal and reading what came back. The first filtered
6382
+ // on whether the chunk contained the prompt, not knowing readline repaints
6383
+ // prompt and input together on every keystroke, so the condition was always
6384
+ // true. The second repainted the line and still leaked, because the echo was
6385
+ // never coming from that hook at all.
6386
+ //
6387
+ // Reading the keys directly removes the guessing. Raw mode turns the
6388
+ // terminal's own echo OFF, so the ONLY thing that can reach the screen is
6389
+ // what is written below: one asterisk per character, which is what a partner
6390
+ // asked for and what every other passphrase prompt does.
6391
+ const stdin = process.stdin;
6392
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
6393
+ // No terminal to control. Being asked in the clear beats not being asked,
6394
+ // and this path is only reached where nothing is watching anyway.
6395
+ const { createInterface } = await import('node:readline/promises');
6396
+ const rl = createInterface({ input: stdin, output: process.stdout });
6397
+ try {
6398
+ const answer = await rl.question('');
6399
+ return answer.trim();
6400
+ } finally { rl.close(); }
6401
+ }
6402
+
6403
+ const wasRaw = stdin.isRaw === true;
6404
+ stdin.setRawMode(true);
6405
+ stdin.resume();
6406
+ stdin.setEncoding('utf8');
6407
+
6408
+ return new Promise<string>((resolve) => {
6409
+ let typed = '';
6410
+ const restore = (): void => {
6411
+ stdin.removeListener('data', onData);
6412
+ stdin.setRawMode(wasRaw);
6413
+ stdin.pause();
6414
+ };
6415
+ const onData = (chunk: string): void => {
6416
+ for (const ch of chunk) {
6417
+ if (ch === '\\r' || ch === '\\n') {
6418
+ restore();
6419
+ process.stdout.write('\\n');
6420
+ resolve(typed.trim());
6421
+ return;
6422
+ }
6423
+ if (ch === '\\u0003') { // Ctrl+C
6424
+ restore();
6425
+ process.stdout.write('\\n');
6426
+ process.exit(130);
6427
+ }
6428
+ if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
6429
+ restore();
6430
+ process.stdout.write('\\n');
6431
+ resolve(typed.trim());
6432
+ return;
6433
+ }
6434
+ if (ch === '\\u007f' || ch === '\\b') {
6435
+ // Backspace has to move the asterisks too, or the mask stops matching
6436
+ // what is actually in the buffer and the count misleads.
6437
+ if (typed.length > 0) {
6438
+ typed = typed.slice(0, -1);
6439
+ process.stdout.write('\\b \\b');
6440
+ }
6441
+ continue;
6442
+ }
6443
+ if (ch < ' ') continue; // ignore the rest of the control range
6444
+ typed += ch;
6445
+ process.stdout.write('*');
6446
+ }
6447
+ };
6448
+ stdin.on('data', onData);
6449
+ });
6450
+ }
6451
+ `
6452
+ },
6453
+ {
6454
+ name: "scripts/show.ts",
6455
+ content: `/**
6456
+ * Show what this receiver holds: the roster, and every change it has applied.
6457
+ *
6458
+ * Usage:
6459
+ * npm run show \u2014 the roster and the last 20 changes
6460
+ * npm run show 100 \u2014 the last 100 changes
6461
+ *
6462
+ * ## Why this script exists
6463
+ *
6464
+ * The obvious way to check that a dispatch really landed is \`sqlite3 data.db\`,
6465
+ * and it does not work: the roster and the history are AES-GCM ciphertext
6466
+ * whenever the database has a password, which is what this receiver asks for on
6467
+ * its first run. Reported from a real run, immediately after a first successful
6468
+ * dispatch: "where do I look to prove it end to end?" The dashboard's LAST
6469
+ * CHANGE panel shows one change and nothing showed the rest.
6470
+ *
6471
+ * It prompts for the password for the same reason \`src/index.ts\` does, and for
6472
+ * the same reason it must do so BEFORE importing the store: the database
6473
+ * derives its keys on import.
6474
+ *
6475
+ * IN INBOX MODE THERE IS NOTHING HERE TO SHOW, and saying so is the answer
6476
+ * rather than printing an empty table. This receiver holds no key and no
6477
+ * customer records in that mode; the connector holds both, and the change is
6478
+ * in the connector's own store.
6479
+ */
6480
+ import 'dotenv/config';
6481
+ import { config } from '../src/config.js';
6482
+ import { ask, databaseIsLocked } from '../src/unlock.js';
6483
+
6484
+ function fmt(a: Record<string, string> | null): string {
6485
+ if (!a) return '(nothing on file)';
6486
+ const parts = [a.street, a.suburb, a.state, a.postcode, a.country].filter(Boolean);
6487
+ return parts.join(', ') || '(empty)';
6488
+ }
6489
+
6490
+ async function askPassword(): Promise<void> {
6491
+ if (process.env.ONEADDRESS_DB_PASSPHRASE?.trim()) return;
6492
+ if (!process.stdin.isTTY) return;
6493
+ if (!(await databaseIsLocked())) return;
6494
+ const answer = await ask(' Password to unlock: ');
6495
+ if (answer) process.env.ONEADDRESS_DB_PASSPHRASE = answer;
6496
+ }
6497
+
6498
+ async function main(): Promise<void> {
6499
+ if (config.mode === 'inbox') {
6500
+ console.log('\\n This receiver is in inbox mode: it holds no customer records and no key.');
6501
+ console.log(' Your connector applied the change and holds the result. Look there.\\n');
6502
+ return;
6503
+ }
6504
+
6505
+ await askPassword();
6506
+
6507
+ const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
6508
+ const store = await import('../src/store.js');
6509
+
6510
+ const roster = store.allCustomers();
6511
+ console.log(\`\\n ON FILE (\${roster.length})\\n\`);
6512
+ for (const c of roster) {
6513
+ let addr: Record<string, string> | null = null;
6514
+ try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
6515
+ console.log(\` \${c.account_number.padEnd(14)} \${c.name.padEnd(24)} \${fmt(addr)}\`);
6516
+ }
6517
+
6518
+ const changes = store.addressHistory(limit);
6519
+ console.log(\`\\n APPLIED CHANGES (\${changes.length}, newest first)\\n\`);
6520
+ if (changes.length === 0) {
6521
+ console.log(' None yet. Send a dispatch and run this again.\\n');
6522
+ return;
6523
+ }
6524
+ for (const h of changes) {
6525
+ console.log(\` \${h.recorded_at} \${h.account_number} \${h.name}\`);
6526
+ console.log(\` was: \${fmt(h.prev_address as unknown as Record<string, string> | null)}\`);
6527
+ console.log(\` now: \${fmt(h.address as unknown as Record<string, string>)}\`);
6528
+ }
6529
+ console.log('');
6530
+ }
6531
+
6532
+ void main().catch((err: unknown) => {
6533
+ console.error(err instanceof Error ? err.message : String(err));
6534
+ process.exit(1);
6535
+ });
5681
6536
  `
5682
6537
  },
5683
6538
  {
@@ -5858,14 +6713,56 @@ computes the LOA reference with \`d5LoaRef\`, and hands it to your store as
5858
6713
 
5859
6714
  \`\`\`sql
5860
6715
  -- Your customer roster: who you know + the address you hold on file today.
5861
- -- Keyed on the account number. Seeded on startup from customers.json (edit that
5862
- -- file, or point loadRoster in src/store.ts at your real customer table).
5863
- customers(account_number, name, address JSON, updated_at)
6716
+ -- Seeded on startup from customers.json (edit that file, or point loadRoster in
6717
+ -- src/store.ts at your real customer table).
6718
+ --
6719
+ -- account_key is how a row is FOUND: a blind index of the account number when
6720
+ -- the database has a password, the lower-cased number when it does not. It is
6721
+ -- the key rather than the number itself because AES-GCM uses a fresh IV per
6722
+ -- write, so two encryptions of one account number differ and a primary key over
6723
+ -- the ciphertext would enforce nothing while looking like it did.
6724
+ customers(account_key, account_number, name, address JSON, updated_at)
5864
6725
 
5865
6726
  -- Full history of every address change you apply (append-only audit trail).
5866
- address_history(id, account_number, address JSON, recorded_at)
6727
+ -- BOTH SIDES of each change, so you can show what an address REPLACED and not
6728
+ -- only what it became.
6729
+ address_history(id, account_key, prev_address JSON, address JSON, recorded_at)
5867
6730
  \`\`\`
5868
6731
 
6732
+ ### Where the fault export lands
6733
+
6734
+ \`[e]\` on the dashboard writes a metadata-only file (no payloads) you can send
6735
+ straight to support. It goes to the working directory by default, which is not
6736
+ always where you think it is when the receiver was started by a shortcut or a
6737
+ service manager, so you can name somewhere:
6738
+
6739
+ \`\`\`jsonc
6740
+ // oneaddress.config.json
6741
+ { "exportDir": "/home/you/oneaddress-exports" }
6742
+ \`\`\`
6743
+
6744
+ \`\`\`bash
6745
+ ONEADDRESS_EXPORT_DIR=/tmp npm start # one run, without editing the file
6746
+ \`\`\`
6747
+
6748
+ The directory is created if it does not exist. The confirmation on the dashboard
6749
+ and the line in the log both name the **full path**, and the dashboard keeps it
6750
+ in its own slot so pressing \`[r]\` does not wipe it.
6751
+
6752
+ ### Seeing what changed
6753
+
6754
+ \`\`\`bash
6755
+ npm run show # the roster, and the last 20 changes (was: / now:)
6756
+ npm run show 100 # the last 100
6757
+ \`\`\`
6758
+
6759
+ \`sqlite3 data.db\` will NOT answer this once the database has a password: every
6760
+ text column is ciphertext, which is the point. \`npm run show\` prompts for the
6761
+ same password \`npm start\` does and prints the decrypted rows.
6762
+
6763
+ In **inbox mode** there is nothing here to show. This receiver holds no key and
6764
+ no customer records; your connector applied the change and holds the result.
6765
+
5869
6766
  ## Swapping to a production database
5870
6767
 
5871
6768
  Open \`src/store.ts\` and replace the \`db\` calls with your ORM/driver of choice:
@@ -11006,7 +11903,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
11006
11903
 
11007
11904
  // src/register.ts
11008
11905
  var import_node_crypto2 = require("crypto");
11009
- var PKG_VERSION = true ? "2.4.0" : "dev";
11906
+ var PKG_VERSION = true ? "2.6.0" : "dev";
11010
11907
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
11011
11908
  function hmacSha256(secret, message) {
11012
11909
  return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");