@oneaddress/setup 2.6.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 +234 -27
  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.6.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");
@@ -2093,6 +2093,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2093
2093
  // nothing, which is indistinguishable from a wedged UI.
2094
2094
  let replaying = false;
2095
2095
  screen.key(['r'], () => {
2096
+ if (promptOpen) return;
2096
2097
  if (!onReplay || replaying) return;
2097
2098
  replaying = true;
2098
2099
  replayNote = 'Replaying\u2026';
@@ -2109,32 +2110,84 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2109
2110
  .finally(() => { replaying = false; redraw(); });
2110
2111
  });
2111
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
+
2112
2148
  // [e] EXPORTS THE FAULT LIST. Metadata only, no payloads: see exportHeld.
2113
2149
  // Bound unconditionally for the same reason [r] is, so pressing it on a clean
2114
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.
2115
2158
  screen.key(['e'], () => {
2159
+ if (promptOpen) return;
2116
2160
  if (heldCount() === 0) {
2117
2161
  replayNote = 'Nothing held, so nothing to export.';
2118
2162
  redraw();
2119
2163
  return;
2120
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 {
2121
2177
  try {
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();
2178
+ const { path, count } = exportHeld(dir);
2127
2179
  exportNote = \`Saved \${count} to \${path}\`;
2128
2180
  replayNote = '';
2181
+ return true;
2129
2182
  } catch (err) {
2130
2183
  // NAMES THE DIRECTORY IT TRIED. "EACCES" on its own sends a partner to
2131
2184
  // search for a file that was never written.
2132
- exportNote = \`Export FAILED writing to \${config.exportDir || process.cwd()}: \` +
2185
+ exportNote = \`Export FAILED writing to \${dir || process.cwd()}: \` +
2133
2186
  \`\${err instanceof Error ? err.message : String(err)}\`;
2134
2187
  replayNote = '';
2188
+ return false;
2135
2189
  }
2136
- redraw();
2137
- });
2190
+ }
2138
2191
 
2139
2192
  // [d] DISMISSES. TWO PRESSES, and the first one only asks.
2140
2193
  //
@@ -2145,10 +2198,19 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2145
2198
  // working correctly, with nothing on screen that clears it. Reported from a
2146
2199
  // real run: "I do wish the conformance wouldn't come up as a fault."
2147
2200
  //
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.
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.
2152
2214
  //
2153
2215
  // IRREVERSIBLE, SO IT IS CONFIRMED. Dismissal discards the held payload, which
2154
2216
  // is the point (see \`dismissHeld\`) and also means a mistaken press cannot be
@@ -2163,6 +2225,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2163
2225
  // that was described makes the question and the action the same question.
2164
2226
  let dismissArmedFor: number | null = null;
2165
2227
  screen.key(['d'], () => {
2228
+ if (promptOpen) return;
2166
2229
  const held = heldCount();
2167
2230
  if (held === 0) {
2168
2231
  dismissArmedFor = null;
@@ -2179,9 +2242,29 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2179
2242
  return;
2180
2243
  }
2181
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
+ }
2182
2265
  try {
2183
2266
  const n = dismissHeld();
2184
- replayNote = \`Dismissed \${n}. Nothing left held.\`;
2267
+ replayNote = \`Dismissed \${n}. Nothing left held - the record was exported first.\`;
2185
2268
  } catch (err) {
2186
2269
  replayNote = \`Dismiss failed: \${err instanceof Error ? err.message : String(err)}\`;
2187
2270
  }
@@ -2189,6 +2272,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2189
2272
  });
2190
2273
 
2191
2274
  screen.key(['q', 'C-c'], () => {
2275
+ if (promptOpen) return;
2192
2276
  detach();
2193
2277
  screen.destroy();
2194
2278
  onQuit();
@@ -6456,8 +6540,9 @@ export async function ask(prompt: string): Promise<string> {
6456
6540
  * Show what this receiver holds: the roster, and every change it has applied.
6457
6541
  *
6458
6542
  * Usage:
6459
- * npm run show \u2014 the roster and the last 20 changes
6460
- * 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
6461
6546
  *
6462
6547
  * ## Why this script exists
6463
6548
  *
@@ -6468,6 +6553,23 @@ export async function ask(prompt: string): Promise<string> {
6468
6553
  * dispatch: "where do I look to prove it end to end?" The dashboard's LAST
6469
6554
  * CHANGE panel shows one change and nothing showed the rest.
6470
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
+ *
6471
6573
  * It prompts for the password for the same reason \`src/index.ts\` does, and for
6472
6574
  * the same reason it must do so BEFORE importing the store: the database
6473
6575
  * derives its keys on import.
@@ -6478,8 +6580,17 @@ export async function ask(prompt: string): Promise<string> {
6478
6580
  * in the connector's own store.
6479
6581
  */
6480
6582
  import 'dotenv/config';
6583
+ import blessed from 'blessed';
6481
6584
  import { config } from '../src/config.js';
6482
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, '');
6483
6594
 
6484
6595
  function fmt(a: Record<string, string> | null): string {
6485
6596
  if (!a) return '(nothing on file)';
@@ -6495,18 +6606,32 @@ async function askPassword(): Promise<void> {
6495
6606
  if (answer) process.env.ONEADDRESS_DB_PASSPHRASE = answer;
6496
6607
  }
6497
6608
 
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
- }
6609
+ type Store = typeof import('../src/store.js');
6504
6610
 
6505
- 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
+ }
6506
6619
 
6507
- const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
6508
- 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
+ }
6509
6632
 
6633
+ /** One-shot plain text. The pipe, the file, the CI log. */
6634
+ function printOnce(store: Store, limit: number): void {
6510
6635
  const roster = store.allCustomers();
6511
6636
  console.log(\`\\n ON FILE (\${roster.length})\\n\`);
6512
6637
  for (const c of roster) {
@@ -6514,7 +6639,6 @@ async function main(): Promise<void> {
6514
6639
  try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
6515
6640
  console.log(\` \${c.account_number.padEnd(14)} \${c.name.padEnd(24)} \${fmt(addr)}\`);
6516
6641
  }
6517
-
6518
6642
  const changes = store.addressHistory(limit);
6519
6643
  console.log(\`\\n APPLIED CHANGES (\${changes.length}, newest first)\\n\`);
6520
6644
  if (changes.length === 0) {
@@ -6529,6 +6653,86 @@ async function main(): Promise<void> {
6529
6653
  console.log('');
6530
6654
  }
6531
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
+
6532
6736
  void main().catch((err: unknown) => {
6533
6737
  console.error(err instanceof Error ? err.message : String(err));
6534
6738
  process.exit(1);
@@ -11903,7 +12107,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
11903
12107
 
11904
12108
  // src/register.ts
11905
12109
  var import_node_crypto2 = require("crypto");
11906
- var PKG_VERSION = true ? "2.6.0" : "dev";
12110
+ var PKG_VERSION = true ? "2.7.0" : "dev";
11907
12111
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
11908
12112
  function hmacSha256(secret, message) {
11909
12113
  return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
@@ -12972,6 +13176,9 @@ OneAddress and a leak of either must not also hand somebody your customers' addr
12972
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."
12973
13177
  );
12974
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
+ );
12975
13182
  const privateKeyRaw = await he({
12976
13183
  message: "Your ECDH Private Key",
12977
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.6.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": {