@oneaddress/setup 2.5.0 → 2.7.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 +443 -50
  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.5.0" : "?";
859
+ var WIZARD_VERSION = true ? "2.7.0" : "?";
860
860
  function printCompactHeader() {
861
861
  const INNER = 42;
862
862
  const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
@@ -1013,7 +1013,8 @@ data.db-shm
1013
1013
  "oneAddressApi": "%%ONEADDRESS_API%%",
1014
1014
  "verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE%%,
1015
1015
  "mode": "%%RECEIVER_MODE%%",
1016
- "publicUrl": "%%WEBHOOK_URL%%"
1016
+ "publicUrl": "%%WEBHOOK_URL%%",
1017
+ "exportDir": ""
1017
1018
  }
1018
1019
  `
1019
1020
  },
@@ -1713,7 +1714,8 @@ import { formatLine, report, type ReportLine } from './report.js';
1713
1714
  // reads, with nothing here to change.
1714
1715
  import { store } from './store.js';
1715
1716
  import { pendingConfirmCount } from './confirm-queue.js';
1716
- import { dismissHeld, exportHeld, heldCount, heldSummary } from './quarantine.js';
1717
+ import { dismissHeld, drillCount, exportHeld, heldCount, heldSummary } from './quarantine.js';
1718
+ import { config } from './config.js';
1717
1719
  import { reachability } from './reachable.js';
1718
1720
 
1719
1721
  /** blessed takes colours as strings; these mirror the site's palette. */
@@ -1874,6 +1876,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1874
1876
  // ciphertext it cannot open; the customers live in the partner's own
1875
1877
  // database, behind their own controls.
1876
1878
  const reach = reachability();
1879
+ const drills = drillCount();
1877
1880
  const vault = inbox
1878
1881
  ? \`{\${AMBER}-fg}{bold}NONE (inbox){/}\`
1879
1882
  : store.encrypted
@@ -1892,6 +1895,11 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1892
1895
  // third state. A receiver with no publicUrl configured cannot answer the
1893
1896
  // question, and a green tick or a red cross would both be inventing an
1894
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}{/}\` : '')
1895
1903
  + (reach.state === 'unknown'
1896
1904
  ? ''
1897
1905
  : reach.state === 'reachable'
@@ -1939,6 +1947,16 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1939
1947
 
1940
1948
  /** Shown while a replay is running, so [r] does not look like it did nothing. */
1941
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 = '';
1942
1960
 
1943
1961
  /**
1944
1962
  * The customer count, refreshed out of band.
@@ -2075,6 +2093,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2075
2093
  // nothing, which is indistinguishable from a wedged UI.
2076
2094
  let replaying = false;
2077
2095
  screen.key(['r'], () => {
2096
+ if (promptOpen) return;
2078
2097
  if (!onReplay || replaying) return;
2079
2098
  replaying = true;
2080
2099
  replayNote = 'Replaying\u2026';
@@ -2091,23 +2110,84 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2091
2110
  .finally(() => { replaying = false; redraw(); });
2092
2111
  });
2093
2112
 
2113
+ // A ONE-LINE PATH PROMPT DRAWN OVER THE DASHBOARD.
2114
+ //
2115
+ // \`screen.key\` handlers are GLOBAL in blessed and keep firing while a textbox
2116
+ // has focus, so [r], [d] and [q] would all act on keystrokes meant for the
2117
+ // path. \`promptOpen\` gates every one of them. Without it, typing a Windows
2118
+ // path containing a \`d\` arms a dismissal behind the prompt, and the \`q\` in
2119
+ // \`C:\\\\Users\\\\qmark\` quits the receiver mid-sentence.
2120
+ let promptOpen = false;
2121
+ function askForPath(initial: string, onAnswer: (dir: string) => void): void {
2122
+ promptOpen = true;
2123
+ const box = blessed.textbox({
2124
+ parent: screen,
2125
+ top: 'center', left: 'center', width: '80%', height: 3,
2126
+ border: { type: 'line' },
2127
+ style: { border: { fg: AMBER }, fg: CREAM },
2128
+ label: ' Export to which folder? Enter accepts, Esc cancels ',
2129
+ inputOnFocus: true,
2130
+ keys: true,
2131
+ });
2132
+ // PRE-FILLED WITH THE RESOLVED DEFAULT, not left empty. The answer to
2133
+ // "where does this go" has to be visible BEFORE the write, and an empty box
2134
+ // asks the partner to know something the dashboard never told them.
2135
+ box.setValue(initial);
2136
+ screen.render();
2137
+ box.readInput((_err, value) => {
2138
+ promptOpen = false;
2139
+ box.destroy();
2140
+ screen.render();
2141
+ // Escape gives no value. Treated as cancel, NOT as "use the default":
2142
+ // a partner who hits Esc has changed their mind about writing a file.
2143
+ if (typeof value !== 'string') { exportNote = 'Export cancelled.'; redraw(); return; }
2144
+ onAnswer(value.trim());
2145
+ });
2146
+ }
2147
+
2094
2148
  // [e] EXPORTS THE FAULT LIST. Metadata only, no payloads: see exportHeld.
2095
2149
  // Bound unconditionally for the same reason [r] is, so pressing it on a clean
2096
2150
  // receiver says there is nothing to export rather than appearing to hang.
2151
+ //
2152
+ // IT ASKS WHERE, EVERY TIME (Tim's call). \`exportDir\` in
2153
+ // \`oneaddress.config.json\` and \`ONEADDRESS_EXPORT_DIR\` still decide the
2154
+ // DEFAULT, but they were the only ways to choose and neither is visible from
2155
+ // the dashboard - reported from a real run as "it looks like export location
2156
+ // cannot be picked". Pressing Enter on the pre-filled prompt is byte-for-byte
2157
+ // the old behaviour, so nothing that worked before needs changing.
2097
2158
  screen.key(['e'], () => {
2159
+ if (promptOpen) return;
2098
2160
  if (heldCount() === 0) {
2099
2161
  replayNote = 'Nothing held, so nothing to export.';
2100
2162
  redraw();
2101
2163
  return;
2102
2164
  }
2165
+ askForPath(config.exportDir || process.cwd(), (dir) => { writeExport(dir); redraw(); });
2166
+ });
2167
+
2168
+ /**
2169
+ * Write the fault export, and say where it landed.
2170
+ *
2171
+ * Shared by [e] and [d] rather than duplicated, because [d] exports FIRST and
2172
+ * the two must agree on the destination and on the message. Returns whether
2173
+ * it succeeded, which is what lets [d] refuse to discard anything after a
2174
+ * failed write.
2175
+ */
2176
+ function writeExport(dir: string): boolean {
2103
2177
  try {
2104
- const { path, count } = exportHeld(process.cwd());
2105
- replayNote = \`Exported \${count} to \${path}\`;
2178
+ const { path, count } = exportHeld(dir);
2179
+ exportNote = \`Saved \${count} to \${path}\`;
2180
+ replayNote = '';
2181
+ return true;
2106
2182
  } catch (err) {
2107
- replayNote = \`Export failed: \${err instanceof Error ? err.message : String(err)}\`;
2183
+ // NAMES THE DIRECTORY IT TRIED. "EACCES" on its own sends a partner to
2184
+ // search for a file that was never written.
2185
+ exportNote = \`Export FAILED writing to \${dir || process.cwd()}: \` +
2186
+ \`\${err instanceof Error ? err.message : String(err)}\`;
2187
+ replayNote = '';
2188
+ return false;
2108
2189
  }
2109
- redraw();
2110
- });
2190
+ }
2111
2191
 
2112
2192
  // [d] DISMISSES. TWO PRESSES, and the first one only asks.
2113
2193
  //
@@ -2118,10 +2198,19 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2118
2198
  // working correctly, with nothing on screen that clears it. Reported from a
2119
2199
  // real run: "I do wish the conformance wouldn't come up as a fault."
2120
2200
  //
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.
2201
+ // THE RECEIVER DOES RECOGNISE THE PROBE NOW, AND THIS PARAGRAPH USED TO SAY
2202
+ // OTHERWISE. It argued the receiver could not, because "everything that
2203
+ // identifies one is chosen by the sender". That over-weighted the attacker:
2204
+ // anyone able to forge a signed dispatch already holds the webhook secret and
2205
+ // can make this receiver APPLY a bogus address change, so hiding a quarantine
2206
+ // row is not the prize. The real question was signed versus unsigned, and
2207
+ // \`conformance: true\` rides inside the signed body. A drill is classified, not
2208
+ // suppressed - still written, still counted, drawn calmly rather than as an
2209
+ // alarm - so [d] is no longer the only way to clear a passing run.
2210
+ //
2211
+ // IT STILL EXISTS, because a drill is not the only thing that gets held: a
2212
+ // genuinely undecryptable dispatch from a key that has since been rotated is
2213
+ // never going to replay, and without this the panel stays red forever.
2125
2214
  //
2126
2215
  // IRREVERSIBLE, SO IT IS CONFIRMED. Dismissal discards the held payload, which
2127
2216
  // is the point (see \`dismissHeld\`) and also means a mistaken press cannot be
@@ -2136,6 +2225,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2136
2225
  // that was described makes the question and the action the same question.
2137
2226
  let dismissArmedFor: number | null = null;
2138
2227
  screen.key(['d'], () => {
2228
+ if (promptOpen) return;
2139
2229
  const held = heldCount();
2140
2230
  if (held === 0) {
2141
2231
  dismissArmedFor = null;
@@ -2152,9 +2242,29 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2152
2242
  return;
2153
2243
  }
2154
2244
  dismissArmedFor = null;
2245
+ // EXPORTED BEFORE IT IS DISCARDED, ALWAYS.
2246
+ //
2247
+ // \`exportHeld\` reads \`heldDispatches()\`, which filters out dismissed rows,
2248
+ // so [d] then [e] exported NOTHING and the record was unrecoverable from the
2249
+ // UI. The payload is meant to go - it is an encrypted consumer address on a
2250
+ // third party's disk - but the metadata is the part you would send to
2251
+ // support, and dismissing destroyed exactly that. Reported from a real run:
2252
+ // "if they dismiss a fault, does that mean they cant export and record it?"
2253
+ //
2254
+ // WRITTEN WITHOUT ASKING, unlike [e]. The partner is one keypress from
2255
+ // discarding the evidence and an extra prompt here is a prompt at the worst
2256
+ // possible moment; the default destination is the right answer for a record
2257
+ // nobody asked for. A FAILED write ABORTS the dismissal rather than
2258
+ // proceeding, because "we could not save the record, so we destroyed it" is
2259
+ // the one outcome nothing recovers from.
2260
+ if (!writeExport(config.exportDir || process.cwd())) {
2261
+ replayNote = 'Dismiss ABORTED: nothing was discarded because the record could not be saved.';
2262
+ redraw();
2263
+ return;
2264
+ }
2155
2265
  try {
2156
2266
  const n = dismissHeld();
2157
- replayNote = \`Dismissed \${n}. Nothing left held.\`;
2267
+ replayNote = \`Dismissed \${n}. Nothing left held - the record was exported first.\`;
2158
2268
  } catch (err) {
2159
2269
  replayNote = \`Dismiss failed: \${err instanceof Error ? err.message : String(err)}\`;
2160
2270
  }
@@ -2162,6 +2272,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2162
2272
  });
2163
2273
 
2164
2274
  screen.key(['q', 'C-c'], () => {
2275
+ if (promptOpen) return;
2165
2276
  detach();
2166
2277
  screen.destroy();
2167
2278
  onQuit();
@@ -3367,8 +3478,9 @@ export function resetTally(): void {
3367
3478
  import db, { ensureColumn, parseStoredTime } from './db.js';
3368
3479
  import { report } from './report.js';
3369
3480
  import { createHash } from 'node:crypto';
3370
- import { writeFileSync } from 'node:fs';
3371
- import { join } from 'node:path';
3481
+ import { mkdirSync, writeFileSync } from 'node:fs';
3482
+ import { join, resolve } from 'node:path';
3483
+ import { config } from './config.js';
3372
3484
 
3373
3485
  /**
3374
3486
  * Why a dispatch could not be applied. Shown verbatim on the dashboard.
@@ -3458,6 +3570,43 @@ ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
3458
3570
  */
3459
3571
  ensureColumn('quarantine', 'dismissed_at', 'TEXT');
3460
3572
 
3573
+ /**
3574
+ * Was this a conformance DRILL rather than a real dispatch?
3575
+ *
3576
+ * ## The problem it solves
3577
+ *
3578
+ * Conformance check 11 sends a dispatch this receiver CANNOT open, on purpose,
3579
+ * and passes it for refusing one. So a receiver that quarantines ends a
3580
+ * SUCCESSFUL run with a red fault panel, a \`failed\` count that never returns to
3581
+ * zero, and a \`decrypt_failed\` line naming the partner's real \`key_id\` - telling
3582
+ * them to fix the one thing that is not broken. Reported from a real run.
3583
+ *
3584
+ * ## Why this is trusted and the dispatch id is not
3585
+ *
3586
+ * The flag comes from \`conformance: true\` in the body, and the HMAC covers
3587
+ * \`\${timestamp}.\${rawBody}\`. The body is signed; the headers are not. The
3588
+ * conformance dispatch also carries a recognisable \`X-OneAddress-Dispatch\`
3589
+ * prefix, and keying off THAT would be a mistake: it sits outside the signature,
3590
+ * so anything on the path could set it and change how an operator's fault panel
3591
+ * behaves. Same reasoning as \`crossover_key_ids\`, which is already a signed-body
3592
+ * field that tells a receiver how to treat a payload it cannot decrypt.
3593
+ *
3594
+ * ## Classified, NOT suppressed
3595
+ *
3596
+ * A drill is still recorded, still counted, still visible on the dashboard. It
3597
+ * is drawn calmly instead of as an alarm. Suppression is what creates a silent
3598
+ * failure; classification does not, and if the marker is ever wrong the operator
3599
+ * still sees the event, in the wrong colour rather than not at all.
3600
+ *
3601
+ * ## A drill holds NO payload
3602
+ *
3603
+ * \`raw_body\` is empty for a drill, which is a property rather than a saving.
3604
+ * The probe is sealed to a throwaway key pair the conformance run generated and
3605
+ * discarded, so no key that will ever exist can open it: replay cannot succeed,
3606
+ * and keeping the bytes would put an unopenable blob in the replay set forever.
3607
+ */
3608
+ ensureColumn('quarantine', 'drill', 'INTEGER NOT NULL DEFAULT 0');
3609
+
3461
3610
  export interface QuarantineInput {
3462
3611
  dispatchId: string | null;
3463
3612
  event: string;
@@ -3465,6 +3614,10 @@ export interface QuarantineInput {
3465
3614
  keyId: string | null;
3466
3615
  rawBody: string;
3467
3616
  detail: string;
3617
+ /** A conformance probe rather than a consumer's dispatch. See the
3618
+ * \`drill\` column's docstring. Defaults to false, so a caller that does
3619
+ * not know about drills still records a real fault. */
3620
+ drill?: boolean;
3468
3621
  }
3469
3622
 
3470
3623
  /**
@@ -3476,27 +3629,44 @@ export interface QuarantineInput {
3476
3629
  */
3477
3630
  export function quarantine(input: QuarantineInput): void {
3478
3631
  const id = dispatchKey(input.dispatchId, input.rawBody);
3632
+ const drill = input.drill === true;
3479
3633
  try {
3480
3634
  db.prepare(
3481
- \`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
3482
- VALUES (?, ?, ?, ?, ?, ?, ?)
3635
+ \`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail, drill)
3636
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3483
3637
  ON CONFLICT(id) DO UPDATE SET
3484
3638
  reason = excluded.reason,
3485
3639
  key_id = excluded.key_id,
3486
- detail = excluded.detail\`,
3640
+ detail = excluded.detail,
3641
+ drill = excluded.drill\`,
3487
3642
  ).run(
3488
3643
  id,
3489
3644
  input.dispatchId,
3490
3645
  input.event,
3491
3646
  input.reason,
3492
3647
  input.keyId,
3493
- input.rawBody,
3648
+ // NO PAYLOAD FOR A DRILL. The probe is sealed to a throwaway key pair the
3649
+ // conformance run generated and discarded, so nothing that will ever exist
3650
+ // can open it. Keeping the bytes would put a permanently unopenable blob
3651
+ // in the replay set.
3652
+ drill ? '' : input.rawBody,
3494
3653
  input.detail.slice(0, 500),
3654
+ drill ? 1 : 0,
3495
3655
  );
3496
- report.warn(
3497
- \`[quarantine] \${input.event} held (\${input.reason}\${input.keyId ? \`, key_id \${input.keyId}\` : ''}). \` +
3498
- 'Fix the cause and press [r] on the dashboard, or run \`npm run replay\`, to apply it.',
3499
- );
3656
+ if (drill) {
3657
+ // INFO, NOT WARN, and it says what happened rather than what to fix. The
3658
+ // old line ended a PASSING conformance run by telling the partner to go
3659
+ // and correct a key that is perfectly correct.
3660
+ report.info(
3661
+ \`[quarantine] \${input.event} was a conformance probe and was refused, which is how that \` +
3662
+ 'check is passed. Nothing is wrong with your key.',
3663
+ );
3664
+ } else {
3665
+ report.warn(
3666
+ \`[quarantine] \${input.event} held (\${input.reason}\${input.keyId ? \`, key_id \${input.keyId}\` : ''}). \` +
3667
+ 'Fix the cause and press [r] on the dashboard, or run \`npm run replay\`, to apply it.',
3668
+ );
3669
+ }
3500
3670
  } catch (err) {
3501
3671
  report.error('[quarantine] could not hold this dispatch:', err instanceof Error ? err.message : err);
3502
3672
  }
@@ -3521,16 +3691,30 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
3521
3691
  \`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
3522
3692
  received_at, last_error, attempts
3523
3693
  FROM quarantine
3524
- WHERE replayed_at IS NULL AND dismissed_at IS NULL
3694
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0
3525
3695
  ORDER BY received_at
3526
3696
  LIMIT ?\`,
3527
3697
  ).all(limit) as unknown as HeldDispatch[];
3528
3698
  }
3529
3699
 
3700
+ /**
3701
+ * How many conformance probes this receiver has refused.
3702
+ *
3703
+ * Its own figure rather than a slice of \`heldCount\`, because the two answer
3704
+ * different questions. Held means "waiting on you". A drill means "a check ran
3705
+ * and this receiver behaved correctly", which is not a queue and not work.
3706
+ */
3707
+ export function drillCount(): number {
3708
+ const row = db.prepare(
3709
+ 'SELECT count(*) AS n FROM quarantine WHERE drill = 1 AND dismissed_at IS NULL',
3710
+ ).get() as { n: number };
3711
+ return row.n;
3712
+ }
3713
+
3530
3714
  /** How many dispatches are held. Shown on the dashboard. */
3531
3715
  export function heldCount(): number {
3532
3716
  const row = db.prepare(
3533
- 'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL AND dismissed_at IS NULL',
3717
+ 'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0',
3534
3718
  ).get() as { n: number };
3535
3719
  return row.n;
3536
3720
  }
@@ -3547,7 +3731,7 @@ export function heldSummary(): string[] {
3547
3731
  \`SELECT reason, key_id, count(*) AS n,
3548
3732
  max(attempts) AS tries, min(received_at) AS oldest
3549
3733
  FROM quarantine
3550
- WHERE replayed_at IS NULL AND dismissed_at IS NULL
3734
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0
3551
3735
  GROUP BY reason, key_id
3552
3736
  ORDER BY n DESC\`,
3553
3737
  ).all() as unknown as {
@@ -3611,7 +3795,7 @@ export function dismissHeld(): number {
3611
3795
  const info = db.prepare(
3612
3796
  \`UPDATE quarantine
3613
3797
  SET dismissed_at = ?, raw_body = ''
3614
- WHERE replayed_at IS NULL AND dismissed_at IS NULL\`,
3798
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0\`,
3615
3799
  ).run(new Date().toISOString());
3616
3800
  const n = Number(info.changes ?? 0);
3617
3801
  if (n > 0) {
@@ -3691,10 +3875,25 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
3691
3875
  * what diagnoses a key problem, and it is safe to paste into a support ticket
3692
3876
  * or send to us, which is what an export is for.
3693
3877
  */
3694
- export function exportHeld(directory: string): { path: string; count: number } {
3878
+ export function exportHeld(directory?: string): { path: string; count: number } {
3695
3879
  const rows = heldDispatches(500);
3696
3880
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3697
- const path = join(directory, \`oneaddress-faults-\${stamp}.json\`);
3881
+ // RESOLVED TO AN ABSOLUTE PATH, ALWAYS, and that is the point of the change.
3882
+ // "Exported 3 to oneaddress-faults-\u2026.json" is not an answer to "where is it";
3883
+ // a receiver is routinely started by a shortcut or a service manager whose
3884
+ // working directory is not where the partner thinks it is. Reported after a
3885
+ // real run: the file was written and could not be found.
3886
+ //
3887
+ // The explicit argument still wins, so a caller (and every existing test) can
3888
+ // name a directory; \`config.exportDir\` is the persistent choice and
3889
+ // \`ONEADDRESS_EXPORT_DIR\` the one-run override. Empty resolves to the working
3890
+ // directory, which is what this always did.
3891
+ const target = resolve(directory ?? config.exportDir ?? '');
3892
+ // Created rather than failed on. A partner who set \`exportDir\` to a folder
3893
+ // they have not made yet wants the file, not a lecture, and a directory they
3894
+ // named is one they have already consented to.
3895
+ mkdirSync(target, { recursive: true });
3896
+ const path = join(target, \`oneaddress-faults-\${stamp}.json\`);
3698
3897
  writeFileSync(path, JSON.stringify({
3699
3898
  exported_at: new Date().toISOString(),
3700
3899
  note: 'Metadata only. The held payloads are deliberately not included: they are encrypted consumer addresses and stay under the receiver retention window.',
@@ -3711,7 +3910,11 @@ export function exportHeld(directory: string): { path: string; count: number } {
3711
3910
  last_error: r.last_error,
3712
3911
  })),
3713
3912
  }, null, 2));
3714
- report.info(\`[quarantine] exported \${rows.length} held dispatch(es) to \${path}\`);
3913
+ // THE PATH IS THE MESSAGE. Logged as well as shown on the dashboard, because
3914
+ // the dashboard line is gone the next time anything is pressed while the log
3915
+ // survives a scrollback, a \`npm start | tee\`, and a headless run where there
3916
+ // is no dashboard at all.
3917
+ report.info(\`[quarantine] exported \${rows.length} held dispatch(es) to \${path} - safe to send to support, no payloads inside\`);
3715
3918
  return { path, count: rows.length };
3716
3919
  }
3717
3920
 
@@ -4108,6 +4311,17 @@ export type ReceiverConfig = {
4108
4311
  oneAddressApi: string;
4109
4312
  verifiesAccountReference: boolean;
4110
4313
  mode: ReceiverMode;
4314
+ /**
4315
+ * Where \`[e]\` writes the fault export.
4316
+ *
4317
+ * Empty means the working directory, which is what it always did. Named
4318
+ * because "it saved a file" is useless if you cannot find it: the receiver is
4319
+ * routinely started by a double-clicked shortcut or a service manager whose
4320
+ * working directory is not where the partner thinks it is.
4321
+ *
4322
+ * \`ONEADDRESS_EXPORT_DIR\` overrides it for one run without editing the file.
4323
+ */
4324
+ exportDir: string;
4111
4325
  /**
4112
4326
  * The webhook URL registered with OneAddress: where dispatches actually
4113
4327
  * arrive from the internet.
@@ -4134,6 +4348,9 @@ const DEFAULTS: ReceiverConfig = {
4134
4348
  mode: 'write-through',
4135
4349
  // No guess. See the field's docstring.
4136
4350
  publicUrl: '',
4351
+ // Empty resolves to the working directory at use, not here, so a config file
4352
+ // written on one machine does not pin an absolute path from another.
4353
+ exportDir: '',
4137
4354
  };
4138
4355
 
4139
4356
  function parseMode(value: unknown): ReceiverMode | undefined {
@@ -4153,6 +4370,7 @@ function loadConfigFile(): Partial<ReceiverConfig> {
4153
4370
  if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
4154
4371
  if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
4155
4372
  if (typeof parsed.publicUrl === 'string') out.publicUrl = parsed.publicUrl;
4373
+ if (typeof parsed.exportDir === 'string') out.exportDir = parsed.exportDir;
4156
4374
  const mode = parseMode(parsed.mode);
4157
4375
  if (mode) out.mode = mode;
4158
4376
  return out;
@@ -4177,6 +4395,7 @@ export const config: ReceiverConfig = {
4177
4395
  // start. Silent is the thing to avoid, not strict.
4178
4396
  mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
4179
4397
  publicUrl: stripTrailingSlash(process.env.PUBLIC_URL || fromFile.publicUrl || DEFAULTS.publicUrl),
4398
+ exportDir: process.env.ONEADDRESS_EXPORT_DIR || fromFile.exportDir || DEFAULTS.exportDir,
4180
4399
  };
4181
4400
 
4182
4401
  report.info(
@@ -5595,9 +5814,27 @@ app.post('/webhook', async (req: Request, res: Response) => {
5595
5814
  */
5596
5815
  const key = dispatchKey(dispatch || null, rawBody);
5597
5816
 
5817
+ /**
5818
+ * A DRILL, declared in the SIGNED body. See \`quarantine.ts\`'s \`drill\` column.
5819
+ *
5820
+ * Read from \`body\`, which the HMAC covers, and never from the dispatch-id
5821
+ * header, which it does not. The conformance run already puts a recognisable
5822
+ * prefix on that header and using it would let anything on the path decide
5823
+ * whether this receiver raises an alarm.
5824
+ */
5825
+ const isDrill = (body as Record<string, unknown>).conformance === true;
5826
+
5598
5827
  const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void => {
5599
- quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
5600
- recordOutcome(key, 'failed');
5828
+ quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail, drill: isDrill });
5829
+ // NOT COUNTED AS A FAILURE WHEN IT IS A DRILL. \`failed\` on the footer is a
5830
+ // figure about this partner's dispatches; a probe that was refused exactly
5831
+ // as the check requires is not one of them, and leaving it in is what made a
5832
+ // passing conformance run read as \`failed 1\` forever.
5833
+ //
5834
+ // Recorded as NOTHING rather than as a third outcome, deliberately:
5835
+ // \`tally.ts\` holds \`received === applied + failed\` as an invariant and a
5836
+ // drill is not a dispatch to this partner's business at all.
5837
+ if (!isDrill) recordOutcome(key, 'failed');
5601
5838
  };
5602
5839
 
5603
5840
  /**
@@ -5754,11 +5991,24 @@ app.post('/webhook', async (req: Request, res: Response) => {
5754
5991
  decAccount = typeof data.account_number === 'string' ? data.account_number : '';
5755
5992
  decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
5756
5993
  } catch (err) {
5757
- // Names WHICH key answered. A rotation used to surface here as an
5758
- // authentication-tag error indistinguishable from corruption, with the
5759
- // single-key fallback having silently answered for a key id it never held.
5760
- report.error(\`[webhook] D5 decryption failed for key_id \${keyId ?? '(none)'}: \${keyFailureAdvice(keyId, resolved)}\`);
5761
- report.error('[webhook] underlying error:', err);
5994
+ // A DRILL SAYS SO INSTEAD OF SHOUTING. Conformance check 11 wraps its
5995
+ // session key to a throwaway pair so that decryption MUST fail; the two
5996
+ // lines below would then name the partner's real key_id, print a raw
5997
+ // crypto error, and advise them to go and fix a key that is correct - at
5998
+ // the end of a run that just passed. The refusal below is unchanged,
5999
+ // because the refusal is what the check is testing.
6000
+ if (isDrill) {
6001
+ report.info(
6002
+ \`[webhook] conformance probe for key_id \${keyId ?? '(none)'} could not be decrypted, which is \` +
6003
+ 'what that check requires. Your key is fine.',
6004
+ );
6005
+ } else {
6006
+ // Names WHICH key answered. A rotation used to surface here as an
6007
+ // authentication-tag error indistinguishable from corruption, with the
6008
+ // single-key fallback having silently answered for a key id it never held.
6009
+ report.error(\`[webhook] D5 decryption failed for key_id \${keyId ?? '(none)'}: \${keyFailureAdvice(keyId, resolved)}\`);
6010
+ report.error('[webhook] underlying error:', err);
6011
+ }
5762
6012
  hold('decrypt_failed', keyId, keyFailureAdvice(keyId, resolved));
5763
6013
  return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
5764
6014
  }
@@ -6290,8 +6540,9 @@ export async function ask(prompt: string): Promise<string> {
6290
6540
  * Show what this receiver holds: the roster, and every change it has applied.
6291
6541
  *
6292
6542
  * Usage:
6293
- * npm run show \u2014 the roster and the last 20 changes
6294
- * npm run show 100 \u2014 the last 100 changes
6543
+ * npm run show \u2014 a LIVE branded view, refreshing as changes land
6544
+ * npm run show 100 \u2014 keep 100 changes on screen instead of 20
6545
+ * npm run show > f.txt \u2014 plain one-shot text, for a file or a pipe
6295
6546
  *
6296
6547
  * ## Why this script exists
6297
6548
  *
@@ -6302,6 +6553,23 @@ export async function ask(prompt: string): Promise<string> {
6302
6553
  * dispatch: "where do I look to prove it end to end?" The dashboard's LAST
6303
6554
  * CHANGE panel shows one change and nothing showed the rest.
6304
6555
  *
6556
+ * ## Why it STREAMS rather than printing once
6557
+ *
6558
+ * The first version printed a snapshot and exited, which answers "did it land"
6559
+ * only if you already knew to run it again. Watching a dispatch arrive meant
6560
+ * re-running it by hand and diffing two screenfuls in your head. It holds the
6561
+ * screen now and polls, so a change appears as it is applied.
6562
+ *
6563
+ * POLLED, NOT SUBSCRIBED, and that is forced rather than lazy: this is a
6564
+ * SEPARATE PROCESS from the receiver, so there is no event to listen to. SQLite
6565
+ * readers do not block writers, so a poll costs the running receiver nothing.
6566
+ * The screen is only redrawn when the rendered content actually CHANGES, so an
6567
+ * idle receiver is not repainting a terminal once a second.
6568
+ *
6569
+ * NON-TTY FALLS BACK TO ONE-SHOT TEXT. \`npm run show > out.txt\`, a pipe, and a
6570
+ * CI log have no terminal to hold, and a blessed screen drawn into a pipe
6571
+ * produces escape-code soup. Same rule the dashboard itself follows.
6572
+ *
6305
6573
  * It prompts for the password for the same reason \`src/index.ts\` does, and for
6306
6574
  * the same reason it must do so BEFORE importing the store: the database
6307
6575
  * derives its keys on import.
@@ -6312,8 +6580,17 @@ export async function ask(prompt: string): Promise<string> {
6312
6580
  * in the connector's own store.
6313
6581
  */
6314
6582
  import 'dotenv/config';
6583
+ import blessed from 'blessed';
6315
6584
  import { config } from '../src/config.js';
6316
6585
  import { ask, databaseIsLocked } from '../src/unlock.js';
6586
+ import { HEX, ONE_ROWS, ADDRESS_ROWS, terminalFitsFullMark } from '../src/brand.js';
6587
+
6588
+ const AMBER = HEX.amber.toLowerCase();
6589
+ const CREAM = HEX.cream.toLowerCase();
6590
+ const DIM = '#8a7f6a';
6591
+
6592
+ /** Escape blessed's tag syntax so a customer's name can never inject markup. */
6593
+ const esc = (s: unknown): string => String(s ?? '').replace(/[{}]/g, '');
6317
6594
 
6318
6595
  function fmt(a: Record<string, string> | null): string {
6319
6596
  if (!a) return '(nothing on file)';
@@ -6329,18 +6606,32 @@ async function askPassword(): Promise<void> {
6329
6606
  if (answer) process.env.ONEADDRESS_DB_PASSPHRASE = answer;
6330
6607
  }
6331
6608
 
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;
6337
- }
6609
+ type Store = typeof import('../src/store.js');
6338
6610
 
6339
- await askPassword();
6611
+ /** The roster, as display rows. */
6612
+ function rosterRows(store: Store): string[] {
6613
+ return store.allCustomers().map((c) => {
6614
+ let addr: Record<string, string> | null = null;
6615
+ try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
6616
+ return \` \${esc(c.account_number).padEnd(20)} \${esc(c.name).padEnd(24)} \${esc(fmt(addr))}\`;
6617
+ });
6618
+ }
6340
6619
 
6341
- const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
6342
- const store = await import('../src/store.js');
6620
+ /** Applied changes, newest first, as display rows with both sides. */
6621
+ function changeRows(store: Store, limit: number): string[] {
6622
+ const out: string[] = [];
6623
+ for (const h of store.addressHistory(limit)) {
6624
+ const prev = h.prev_address as unknown as Record<string, string> | null;
6625
+ const now = h.address as unknown as Record<string, string>;
6626
+ out.push(\` {\${DIM}-fg}\${esc(h.recorded_at)}{/} {\${CREAM}-fg}\${esc(h.account_number)}{/} \${esc(h.name)}\`);
6627
+ out.push(\` {\${DIM}-fg}was:{/} \${esc(fmt(prev))}\`);
6628
+ out.push(\` {\${AMBER}-fg}now:{/} \${esc(fmt(now))}\`);
6629
+ }
6630
+ return out;
6631
+ }
6343
6632
 
6633
+ /** One-shot plain text. The pipe, the file, the CI log. */
6634
+ function printOnce(store: Store, limit: number): void {
6344
6635
  const roster = store.allCustomers();
6345
6636
  console.log(\`\\n ON FILE (\${roster.length})\\n\`);
6346
6637
  for (const c of roster) {
@@ -6348,7 +6639,6 @@ async function main(): Promise<void> {
6348
6639
  try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
6349
6640
  console.log(\` \${c.account_number.padEnd(14)} \${c.name.padEnd(24)} \${fmt(addr)}\`);
6350
6641
  }
6351
-
6352
6642
  const changes = store.addressHistory(limit);
6353
6643
  console.log(\`\\n APPLIED CHANGES (\${changes.length}, newest first)\\n\`);
6354
6644
  if (changes.length === 0) {
@@ -6363,6 +6653,86 @@ async function main(): Promise<void> {
6363
6653
  console.log('');
6364
6654
  }
6365
6655
 
6656
+ /** The live view: the mark, the roster, the changes, refreshed as they land. */
6657
+ function stream(store: Store, limit: number): void {
6658
+ const screen = blessed.screen({ smartCSR: true, title: 'What this receiver holds \u2014 OneAddress' });
6659
+
6660
+ // Same rule as the dashboard: the pixel wordmark needs 88 columns, and below
6661
+ // that it wraps and reads as broken rather than as large.
6662
+ const wide = terminalFitsFullMark(Number(screen.width));
6663
+ const markHeight = wide ? 9 : 3;
6664
+ blessed.box({
6665
+ parent: screen, top: 0, left: 0, width: '100%', height: markHeight,
6666
+ tags: true, padding: { left: 2 },
6667
+ content: wide
6668
+ ? ONE_ROWS.map((row, i) => \`{\${CREAM}-fg}\${row}{/} {\${AMBER}-fg}\${ADDRESS_ROWS[i]}{/}\`).join('\\n')
6669
+ : \`{\${CREAM}-fg}{bold}One{/bold}{/}{\${AMBER}-fg}{bold}Address{/bold}{/}\`,
6670
+ });
6671
+
6672
+ const rosterBox = blessed.box({
6673
+ parent: screen, top: markHeight, left: 0, width: '100%', height: 'shrink',
6674
+ tags: true, padding: { left: 1, right: 1 },
6675
+ border: { type: 'line' }, style: { border: { fg: AMBER } },
6676
+ label: ' ON FILE ',
6677
+ });
6678
+
6679
+ const changesBox = blessed.box({
6680
+ parent: screen, top: markHeight + 3, left: 0, width: '100%', bottom: 1,
6681
+ tags: true, padding: { left: 1, right: 1 }, scrollable: true, alwaysScroll: true,
6682
+ border: { type: 'line' }, style: { border: { fg: AMBER } },
6683
+ label: ' APPLIED CHANGES \u2014 newest first ',
6684
+ });
6685
+
6686
+ blessed.box({
6687
+ parent: screen, bottom: 0, left: 0, width: '100%', height: 1,
6688
+ tags: true, padding: { left: 2 },
6689
+ content: \`{\${DIM}-fg}live \u2014 updates as dispatches land{/} {\${CREAM}-fg}[q]{/} quit\`,
6690
+ });
6691
+
6692
+ // ONLY REDRAWN WHEN THE CONTENT CHANGES. A poll that repaints unconditionally
6693
+ // makes an idle receiver flicker once a second and destroys any scrollback
6694
+ // position the operator had.
6695
+ let last = '';
6696
+ const tick = (): void => {
6697
+ const roster = rosterRows(store);
6698
+ const changes = changeRows(store, limit);
6699
+ const next = roster.join('\\n') + '\\u0000' + changes.join('\\n');
6700
+ if (next === last) return;
6701
+ last = next;
6702
+ rosterBox.height = roster.length + 2;
6703
+ rosterBox.setContent(roster.join('\\n') || ' (nobody on file yet)');
6704
+ changesBox.top = markHeight + roster.length + 2;
6705
+ changesBox.setContent(changes.join('\\n') || ' None yet. Send a dispatch and this fills in.');
6706
+ screen.render();
6707
+ };
6708
+
6709
+ screen.key(['q', 'C-c', 'escape'], () => { screen.destroy(); process.exit(0); });
6710
+ tick();
6711
+ screen.render();
6712
+ // DELIBERATELY NOT unref'd. An unref'd timer does not hold the event loop
6713
+ // open, and with the screen drawn there is nothing else pending, so the
6714
+ // process would exit the instant it finished painting.
6715
+ setInterval(tick, 1000);
6716
+ }
6717
+
6718
+ async function main(): Promise<void> {
6719
+ if (config.mode === 'inbox') {
6720
+ console.log('\\n This receiver is in inbox mode: it holds no customer records and no key.');
6721
+ console.log(' Your connector applied the change and holds the result. Look there.\\n');
6722
+ return;
6723
+ }
6724
+
6725
+ await askPassword();
6726
+
6727
+ const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
6728
+ const store = await import('../src/store.js');
6729
+
6730
+ // A pipe, a file or a CI log has no terminal to hold, and a blessed screen
6731
+ // drawn into one produces escape-code soup rather than output.
6732
+ if (!process.stdout.isTTY) { printOnce(store, limit); return; }
6733
+ stream(store, limit);
6734
+ }
6735
+
6366
6736
  void main().catch((err: unknown) => {
6367
6737
  console.error(err instanceof Error ? err.message : String(err));
6368
6738
  process.exit(1);
@@ -6563,6 +6933,26 @@ customers(account_key, account_number, name, address JSON, updated_at)
6563
6933
  address_history(id, account_key, prev_address JSON, address JSON, recorded_at)
6564
6934
  \`\`\`
6565
6935
 
6936
+ ### Where the fault export lands
6937
+
6938
+ \`[e]\` on the dashboard writes a metadata-only file (no payloads) you can send
6939
+ straight to support. It goes to the working directory by default, which is not
6940
+ always where you think it is when the receiver was started by a shortcut or a
6941
+ service manager, so you can name somewhere:
6942
+
6943
+ \`\`\`jsonc
6944
+ // oneaddress.config.json
6945
+ { "exportDir": "/home/you/oneaddress-exports" }
6946
+ \`\`\`
6947
+
6948
+ \`\`\`bash
6949
+ ONEADDRESS_EXPORT_DIR=/tmp npm start # one run, without editing the file
6950
+ \`\`\`
6951
+
6952
+ The directory is created if it does not exist. The confirmation on the dashboard
6953
+ and the line in the log both name the **full path**, and the dashboard keeps it
6954
+ in its own slot so pressing \`[r]\` does not wipe it.
6955
+
6566
6956
  ### Seeing what changed
6567
6957
 
6568
6958
  \`\`\`bash
@@ -11717,7 +12107,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
11717
12107
 
11718
12108
  // src/register.ts
11719
12109
  var import_node_crypto2 = require("crypto");
11720
- var PKG_VERSION = true ? "2.5.0" : "dev";
12110
+ var PKG_VERSION = true ? "2.7.0" : "dev";
11721
12111
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
11722
12112
  function hmacSha256(secret, message) {
11723
12113
  return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
@@ -12786,6 +13176,9 @@ OneAddress and a leak of either must not also hand somebody your customers' addr
12786
13176
  "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
13177
  );
12788
13178
  } else {
13179
+ M2.info(
13180
+ "It looks like one of these:\n -----BEGIN PRIVATE KEY----- (a .pem file, paste the whole thing)\n MIGHAgEAMB... (the base64 body, copied from My Profile)\n C:\\keys\\oneaddress.pem (or just the path to the file)\nThis is your PRIVATE key, not the public one shown next to it in the portal."
13181
+ );
12789
13182
  const privateKeyRaw = await he({
12790
13183
  message: "Your ECDH Private Key",
12791
13184
  placeholder: "Paste the base64 key body from My Profile, or enter a path to the .pem file",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneaddress/setup",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "Interactive setup wizard for OneAddress partner webhook integrations",
5
5
  "main": "dist/index.js",
6
6
  "bin": {