@oneaddress/setup 2.5.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 +217 -31
  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.6.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.
@@ -2101,10 +2119,19 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2101
2119
  return;
2102
2120
  }
2103
2121
  try {
2104
- const { path, count } = exportHeld(process.cwd());
2105
- 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 = '';
2106
2129
  } catch (err) {
2107
- replayNote = \`Export failed: \${err instanceof Error ? err.message : String(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 = '';
2108
2135
  }
2109
2136
  redraw();
2110
2137
  });
@@ -3367,8 +3394,9 @@ export function resetTally(): void {
3367
3394
  import db, { ensureColumn, parseStoredTime } from './db.js';
3368
3395
  import { report } from './report.js';
3369
3396
  import { createHash } from 'node:crypto';
3370
- import { writeFileSync } from 'node:fs';
3371
- 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';
3372
3400
 
3373
3401
  /**
3374
3402
  * Why a dispatch could not be applied. Shown verbatim on the dashboard.
@@ -3458,6 +3486,43 @@ ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
3458
3486
  */
3459
3487
  ensureColumn('quarantine', 'dismissed_at', 'TEXT');
3460
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
+
3461
3526
  export interface QuarantineInput {
3462
3527
  dispatchId: string | null;
3463
3528
  event: string;
@@ -3465,6 +3530,10 @@ export interface QuarantineInput {
3465
3530
  keyId: string | null;
3466
3531
  rawBody: string;
3467
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;
3468
3537
  }
3469
3538
 
3470
3539
  /**
@@ -3476,27 +3545,44 @@ export interface QuarantineInput {
3476
3545
  */
3477
3546
  export function quarantine(input: QuarantineInput): void {
3478
3547
  const id = dispatchKey(input.dispatchId, input.rawBody);
3548
+ const drill = input.drill === true;
3479
3549
  try {
3480
3550
  db.prepare(
3481
- \`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
3482
- VALUES (?, ?, ?, ?, ?, ?, ?)
3551
+ \`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail, drill)
3552
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3483
3553
  ON CONFLICT(id) DO UPDATE SET
3484
3554
  reason = excluded.reason,
3485
3555
  key_id = excluded.key_id,
3486
- detail = excluded.detail\`,
3556
+ detail = excluded.detail,
3557
+ drill = excluded.drill\`,
3487
3558
  ).run(
3488
3559
  id,
3489
3560
  input.dispatchId,
3490
3561
  input.event,
3491
3562
  input.reason,
3492
3563
  input.keyId,
3493
- 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,
3494
3569
  input.detail.slice(0, 500),
3570
+ drill ? 1 : 0,
3495
3571
  );
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
- );
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
+ }
3500
3586
  } catch (err) {
3501
3587
  report.error('[quarantine] could not hold this dispatch:', err instanceof Error ? err.message : err);
3502
3588
  }
@@ -3521,16 +3607,30 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
3521
3607
  \`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
3522
3608
  received_at, last_error, attempts
3523
3609
  FROM quarantine
3524
- WHERE replayed_at IS NULL AND dismissed_at IS NULL
3610
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0
3525
3611
  ORDER BY received_at
3526
3612
  LIMIT ?\`,
3527
3613
  ).all(limit) as unknown as HeldDispatch[];
3528
3614
  }
3529
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
+
3530
3630
  /** How many dispatches are held. Shown on the dashboard. */
3531
3631
  export function heldCount(): number {
3532
3632
  const row = db.prepare(
3533
- 'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL AND dismissed_at IS NULL',
3633
+ 'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0',
3534
3634
  ).get() as { n: number };
3535
3635
  return row.n;
3536
3636
  }
@@ -3547,7 +3647,7 @@ export function heldSummary(): string[] {
3547
3647
  \`SELECT reason, key_id, count(*) AS n,
3548
3648
  max(attempts) AS tries, min(received_at) AS oldest
3549
3649
  FROM quarantine
3550
- WHERE replayed_at IS NULL AND dismissed_at IS NULL
3650
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0
3551
3651
  GROUP BY reason, key_id
3552
3652
  ORDER BY n DESC\`,
3553
3653
  ).all() as unknown as {
@@ -3611,7 +3711,7 @@ export function dismissHeld(): number {
3611
3711
  const info = db.prepare(
3612
3712
  \`UPDATE quarantine
3613
3713
  SET dismissed_at = ?, raw_body = ''
3614
- WHERE replayed_at IS NULL AND dismissed_at IS NULL\`,
3714
+ WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0\`,
3615
3715
  ).run(new Date().toISOString());
3616
3716
  const n = Number(info.changes ?? 0);
3617
3717
  if (n > 0) {
@@ -3691,10 +3791,25 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
3691
3791
  * what diagnoses a key problem, and it is safe to paste into a support ticket
3692
3792
  * or send to us, which is what an export is for.
3693
3793
  */
3694
- export function exportHeld(directory: string): { path: string; count: number } {
3794
+ export function exportHeld(directory?: string): { path: string; count: number } {
3695
3795
  const rows = heldDispatches(500);
3696
3796
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3697
- 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\`);
3698
3813
  writeFileSync(path, JSON.stringify({
3699
3814
  exported_at: new Date().toISOString(),
3700
3815
  note: 'Metadata only. The held payloads are deliberately not included: they are encrypted consumer addresses and stay under the receiver retention window.',
@@ -3711,7 +3826,11 @@ export function exportHeld(directory: string): { path: string; count: number } {
3711
3826
  last_error: r.last_error,
3712
3827
  })),
3713
3828
  }, null, 2));
3714
- 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\`);
3715
3834
  return { path, count: rows.length };
3716
3835
  }
3717
3836
 
@@ -4108,6 +4227,17 @@ export type ReceiverConfig = {
4108
4227
  oneAddressApi: string;
4109
4228
  verifiesAccountReference: boolean;
4110
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;
4111
4241
  /**
4112
4242
  * The webhook URL registered with OneAddress: where dispatches actually
4113
4243
  * arrive from the internet.
@@ -4134,6 +4264,9 @@ const DEFAULTS: ReceiverConfig = {
4134
4264
  mode: 'write-through',
4135
4265
  // No guess. See the field's docstring.
4136
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: '',
4137
4270
  };
4138
4271
 
4139
4272
  function parseMode(value: unknown): ReceiverMode | undefined {
@@ -4153,6 +4286,7 @@ function loadConfigFile(): Partial<ReceiverConfig> {
4153
4286
  if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
4154
4287
  if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
4155
4288
  if (typeof parsed.publicUrl === 'string') out.publicUrl = parsed.publicUrl;
4289
+ if (typeof parsed.exportDir === 'string') out.exportDir = parsed.exportDir;
4156
4290
  const mode = parseMode(parsed.mode);
4157
4291
  if (mode) out.mode = mode;
4158
4292
  return out;
@@ -4177,6 +4311,7 @@ export const config: ReceiverConfig = {
4177
4311
  // start. Silent is the thing to avoid, not strict.
4178
4312
  mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
4179
4313
  publicUrl: stripTrailingSlash(process.env.PUBLIC_URL || fromFile.publicUrl || DEFAULTS.publicUrl),
4314
+ exportDir: process.env.ONEADDRESS_EXPORT_DIR || fromFile.exportDir || DEFAULTS.exportDir,
4180
4315
  };
4181
4316
 
4182
4317
  report.info(
@@ -5595,9 +5730,27 @@ app.post('/webhook', async (req: Request, res: Response) => {
5595
5730
  */
5596
5731
  const key = dispatchKey(dispatch || null, rawBody);
5597
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
+
5598
5743
  const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void => {
5599
- quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
5600
- 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');
5601
5754
  };
5602
5755
 
5603
5756
  /**
@@ -5754,11 +5907,24 @@ app.post('/webhook', async (req: Request, res: Response) => {
5754
5907
  decAccount = typeof data.account_number === 'string' ? data.account_number : '';
5755
5908
  decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
5756
5909
  } 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);
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
+ }
5762
5928
  hold('decrypt_failed', keyId, keyFailureAdvice(keyId, resolved));
5763
5929
  return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
5764
5930
  }
@@ -6563,6 +6729,26 @@ customers(account_key, account_number, name, address JSON, updated_at)
6563
6729
  address_history(id, account_key, prev_address JSON, address JSON, recorded_at)
6564
6730
  \`\`\`
6565
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
+
6566
6752
  ### Seeing what changed
6567
6753
 
6568
6754
  \`\`\`bash
@@ -11717,7 +11903,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
11717
11903
 
11718
11904
  // src/register.ts
11719
11905
  var import_node_crypto2 = require("crypto");
11720
- var PKG_VERSION = true ? "2.5.0" : "dev";
11906
+ var PKG_VERSION = true ? "2.6.0" : "dev";
11721
11907
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
11722
11908
  function hmacSha256(secret, message) {
11723
11909
  return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneaddress/setup",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Interactive setup wizard for OneAddress partner webhook integrations",
5
5
  "main": "dist/index.js",
6
6
  "bin": {