@oneaddress/setup 2.6.0 → 2.8.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 +379 -33
  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.8.0" : "?";
860
860
  function printCompactHeader() {
861
861
  const INNER = 42;
862
862
  const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
@@ -1023,9 +1023,30 @@ data.db-shm
1023
1023
  content: `{
1024
1024
  "//": "YOUR CUSTOMER ROSTER \u2014 edit this file to your customers (account number, name, and the address you hold on file today). src/store.ts seeds from here on startup; point loadRoster at your real customer database when you outgrow the file. Override the path with the ONEADDRESS_CUSTOMERS env var.",
1025
1025
  "customers": [
1026
- { "account_number": "AUR-583920", "name": "Tim Hooper", "address": { "street": "12 Old Mill Road", "suburb": "Parramatta", "state": "NSW", "postcode": "2150" } },
1027
- { "account_number": "AUR-104772", "name": "Olivia Robinson", "address": { "street": "5 Rosewood Lane", "suburb": "Brisbane", "state": "QLD", "postcode": "4000" } },
1028
- { "account_number": "AUR-296815", "name": "Marcus Chen", "address": { "street": "31 Goldfield Drive", "suburb": "Richmond", "state": "VIC", "postcode": "3121" } }
1026
+ { "account_number": "AUR-583920", "name": "Tim Hooper", "address": { "street": "12 Old Mill Road", "suburb": "Parramatta", "state": "NSW", "postcode": "2150" } },
1027
+ { "account_number": "AUR-104772", "name": "Olivia Robinson", "address": { "street": "5 Rosewood Lane", "suburb": "Brisbane", "state": "QLD", "postcode": "4000" } },
1028
+ { "account_number": "AUR-296815", "name": "Marcus Chen", "address": { "street": "31 Goldfield Drive", "suburb": "Richmond", "state": "VIC", "postcode": "3121" } },
1029
+ { "account_number": "AUR-337401", "name": "Priya Raghavan", "address": { "street": "8 Kurrajong Street", "suburb": "Newtown", "state": "NSW", "postcode": "2042" } },
1030
+ { "account_number": "AUR-418266", "name": "Daniel O'Sullivan", "address": { "street": "204/19 Marine Parade", "suburb": "Glenelg", "state": "SA", "postcode": "5045" } },
1031
+ { "account_number": "AUR-452093", "name": "Amara Okafor", "address": { "street": "77 Beaufort Street", "suburb": "Mount Lawley", "state": "WA", "postcode": "6050" } },
1032
+ { "account_number": "AUR-509118", "name": "Hannah Whitlock", "address": { "street": "3 Tasman Close", "suburb": "Sandy Bay", "state": "TAS", "postcode": "7005" } },
1033
+ { "account_number": "AUR-563740", "name": "Joshua Nguyen", "address": { "street": "145 Given Terrace", "suburb": "Paddington", "state": "QLD", "postcode": "4064" } },
1034
+ { "account_number": "AUR-617205", "name": "Isabella Moretti", "address": { "street": "62 Lygon Street", "suburb": "Carlton", "state": "VIC", "postcode": "3053" } },
1035
+ { "account_number": "AUR-668931", "name": "Samuel Achterberg", "address": { "street": "11 Hutt Street", "suburb": "Adelaide", "state": "SA", "postcode": "5000" } },
1036
+ { "account_number": "AUR-702884", "name": "Grace Tupou", "address": { "street": "9 Kingsford Smith Way", "suburb": "Belconnen", "state": "ACT", "postcode": "2617" } },
1037
+ { "account_number": "AUR-745160", "name": "Ethan Marsh", "address": { "street": "38 Smith Street", "suburb": "Darwin City", "state": "NT", "postcode": "0800" } },
1038
+ { "account_number": "AUR-781422", "name": "Chloe Bennett", "address": { "street": "26 Rundle Street", "suburb": "Kent Town", "state": "SA", "postcode": "5067" } },
1039
+ { "account_number": "AUR-816559", "name": "Liam Fitzgerald", "address": { "street": "410/88 Queen Street", "suburb": "Melbourne", "state": "VIC", "postcode": "3000" } },
1040
+ { "account_number": "AUR-849073", "name": "Sofia Delgado", "address": { "street": "17 Cavill Avenue", "suburb": "Surfers Paradise", "state": "QLD", "postcode": "4217" } },
1041
+ { "account_number": "AUR-873318", "name": "Noah Kirkwood", "address": { "street": "54 Hunter Street", "suburb": "Newcastle", "state": "NSW", "postcode": "2300" } },
1042
+ { "account_number": "AUR-905642", "name": "Mei Lin Tan", "address": { "street": "2/33 Rokeby Road", "suburb": "Subiaco", "state": "WA", "postcode": "6008" } },
1043
+ { "account_number": "AUR-938177", "name": "Jack Rasmussen", "address": { "street": "6 Elizabeth Street", "suburb": "Launceston", "state": "TAS", "postcode": "7250" } },
1044
+ { "account_number": "AUR-960845", "name": "Aaliyah Bennett", "address": { "street": "82 Charles Street", "suburb": "Northcote", "state": "VIC", "postcode": "3070" } },
1045
+ { "account_number": "AUR-984312", "name": "Oscar Petrov", "address": { "street": "19 Macquarie Street", "suburb": "Hobart", "state": "TAS", "postcode": "7000" } },
1046
+ { "account_number": "AUR-991026", "name": "Ruby Anderson", "address": { "street": "301/5 Cordelia Street", "suburb": "South Brisbane", "state": "QLD", "postcode": "4101" } },
1047
+ { "account_number": "AUR-995518", "name": "Henry Abebe", "address": { "street": "14 Wakefield Street", "suburb": "Kent Town", "state": "SA", "postcode": "5067" } },
1048
+ { "account_number": "AUR-997430", "name": "Zoe Castellano", "address": { "street": "67 Bay Street", "suburb": "Port Melbourne", "state": "VIC", "postcode": "3207" } },
1049
+ { "account_number": "AUR-999201", "name": "Felix Nakamura", "address": { "street": "23 Anzac Parade", "suburb": "Kensington", "state": "NSW", "postcode": "2033" } }
1029
1050
  ]
1030
1051
  }
1031
1052
  `
@@ -2093,6 +2114,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2093
2114
  // nothing, which is indistinguishable from a wedged UI.
2094
2115
  let replaying = false;
2095
2116
  screen.key(['r'], () => {
2117
+ if (promptOpen) return;
2096
2118
  if (!onReplay || replaying) return;
2097
2119
  replaying = true;
2098
2120
  replayNote = 'Replaying\u2026';
@@ -2109,32 +2131,84 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2109
2131
  .finally(() => { replaying = false; redraw(); });
2110
2132
  });
2111
2133
 
2134
+ // A ONE-LINE PATH PROMPT DRAWN OVER THE DASHBOARD.
2135
+ //
2136
+ // \`screen.key\` handlers are GLOBAL in blessed and keep firing while a textbox
2137
+ // has focus, so [r], [d] and [q] would all act on keystrokes meant for the
2138
+ // path. \`promptOpen\` gates every one of them. Without it, typing a Windows
2139
+ // path containing a \`d\` arms a dismissal behind the prompt, and the \`q\` in
2140
+ // \`C:\\\\Users\\\\qmark\` quits the receiver mid-sentence.
2141
+ let promptOpen = false;
2142
+ function askForPath(initial: string, onAnswer: (dir: string) => void): void {
2143
+ promptOpen = true;
2144
+ const box = blessed.textbox({
2145
+ parent: screen,
2146
+ top: 'center', left: 'center', width: '80%', height: 3,
2147
+ border: { type: 'line' },
2148
+ style: { border: { fg: AMBER }, fg: CREAM },
2149
+ label: ' Export to which folder? Enter accepts, Esc cancels ',
2150
+ inputOnFocus: true,
2151
+ keys: true,
2152
+ });
2153
+ // PRE-FILLED WITH THE RESOLVED DEFAULT, not left empty. The answer to
2154
+ // "where does this go" has to be visible BEFORE the write, and an empty box
2155
+ // asks the partner to know something the dashboard never told them.
2156
+ box.setValue(initial);
2157
+ screen.render();
2158
+ box.readInput((_err, value) => {
2159
+ promptOpen = false;
2160
+ box.destroy();
2161
+ screen.render();
2162
+ // Escape gives no value. Treated as cancel, NOT as "use the default":
2163
+ // a partner who hits Esc has changed their mind about writing a file.
2164
+ if (typeof value !== 'string') { exportNote = 'Export cancelled.'; redraw(); return; }
2165
+ onAnswer(value.trim());
2166
+ });
2167
+ }
2168
+
2112
2169
  // [e] EXPORTS THE FAULT LIST. Metadata only, no payloads: see exportHeld.
2113
2170
  // Bound unconditionally for the same reason [r] is, so pressing it on a clean
2114
2171
  // receiver says there is nothing to export rather than appearing to hang.
2172
+ //
2173
+ // IT ASKS WHERE, EVERY TIME (Tim's call). \`exportDir\` in
2174
+ // \`oneaddress.config.json\` and \`ONEADDRESS_EXPORT_DIR\` still decide the
2175
+ // DEFAULT, but they were the only ways to choose and neither is visible from
2176
+ // the dashboard - reported from a real run as "it looks like export location
2177
+ // cannot be picked". Pressing Enter on the pre-filled prompt is byte-for-byte
2178
+ // the old behaviour, so nothing that worked before needs changing.
2115
2179
  screen.key(['e'], () => {
2180
+ if (promptOpen) return;
2116
2181
  if (heldCount() === 0) {
2117
2182
  replayNote = 'Nothing held, so nothing to export.';
2118
2183
  redraw();
2119
2184
  return;
2120
2185
  }
2186
+ askForPath(config.exportDir || process.cwd(), (dir) => { writeExport(dir); redraw(); });
2187
+ });
2188
+
2189
+ /**
2190
+ * Write the fault export, and say where it landed.
2191
+ *
2192
+ * Shared by [e] and [d] rather than duplicated, because [d] exports FIRST and
2193
+ * the two must agree on the destination and on the message. Returns whether
2194
+ * it succeeded, which is what lets [d] refuse to discard anything after a
2195
+ * failed write.
2196
+ */
2197
+ function writeExport(dir: string): boolean {
2121
2198
  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();
2199
+ const { path, count } = exportHeld(dir);
2127
2200
  exportNote = \`Saved \${count} to \${path}\`;
2128
2201
  replayNote = '';
2202
+ return true;
2129
2203
  } catch (err) {
2130
2204
  // NAMES THE DIRECTORY IT TRIED. "EACCES" on its own sends a partner to
2131
2205
  // search for a file that was never written.
2132
- exportNote = \`Export FAILED writing to \${config.exportDir || process.cwd()}: \` +
2206
+ exportNote = \`Export FAILED writing to \${dir || process.cwd()}: \` +
2133
2207
  \`\${err instanceof Error ? err.message : String(err)}\`;
2134
2208
  replayNote = '';
2209
+ return false;
2135
2210
  }
2136
- redraw();
2137
- });
2211
+ }
2138
2212
 
2139
2213
  // [d] DISMISSES. TWO PRESSES, and the first one only asks.
2140
2214
  //
@@ -2145,10 +2219,19 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2145
2219
  // working correctly, with nothing on screen that clears it. Reported from a
2146
2220
  // real run: "I do wish the conformance wouldn't come up as a fault."
2147
2221
  //
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.
2222
+ // THE RECEIVER DOES RECOGNISE THE PROBE NOW, AND THIS PARAGRAPH USED TO SAY
2223
+ // OTHERWISE. It argued the receiver could not, because "everything that
2224
+ // identifies one is chosen by the sender". That over-weighted the attacker:
2225
+ // anyone able to forge a signed dispatch already holds the webhook secret and
2226
+ // can make this receiver APPLY a bogus address change, so hiding a quarantine
2227
+ // row is not the prize. The real question was signed versus unsigned, and
2228
+ // \`conformance: true\` rides inside the signed body. A drill is classified, not
2229
+ // suppressed - still written, still counted, drawn calmly rather than as an
2230
+ // alarm - so [d] is no longer the only way to clear a passing run.
2231
+ //
2232
+ // IT STILL EXISTS, because a drill is not the only thing that gets held: a
2233
+ // genuinely undecryptable dispatch from a key that has since been rotated is
2234
+ // never going to replay, and without this the panel stays red forever.
2152
2235
  //
2153
2236
  // IRREVERSIBLE, SO IT IS CONFIRMED. Dismissal discards the held payload, which
2154
2237
  // is the point (see \`dismissHeld\`) and also means a mistaken press cannot be
@@ -2163,6 +2246,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2163
2246
  // that was described makes the question and the action the same question.
2164
2247
  let dismissArmedFor: number | null = null;
2165
2248
  screen.key(['d'], () => {
2249
+ if (promptOpen) return;
2166
2250
  const held = heldCount();
2167
2251
  if (held === 0) {
2168
2252
  dismissArmedFor = null;
@@ -2179,9 +2263,29 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2179
2263
  return;
2180
2264
  }
2181
2265
  dismissArmedFor = null;
2266
+ // EXPORTED BEFORE IT IS DISCARDED, ALWAYS.
2267
+ //
2268
+ // \`exportHeld\` reads \`heldDispatches()\`, which filters out dismissed rows,
2269
+ // so [d] then [e] exported NOTHING and the record was unrecoverable from the
2270
+ // UI. The payload is meant to go - it is an encrypted consumer address on a
2271
+ // third party's disk - but the metadata is the part you would send to
2272
+ // support, and dismissing destroyed exactly that. Reported from a real run:
2273
+ // "if they dismiss a fault, does that mean they cant export and record it?"
2274
+ //
2275
+ // WRITTEN WITHOUT ASKING, unlike [e]. The partner is one keypress from
2276
+ // discarding the evidence and an extra prompt here is a prompt at the worst
2277
+ // possible moment; the default destination is the right answer for a record
2278
+ // nobody asked for. A FAILED write ABORTS the dismissal rather than
2279
+ // proceeding, because "we could not save the record, so we destroyed it" is
2280
+ // the one outcome nothing recovers from.
2281
+ if (!writeExport(config.exportDir || process.cwd())) {
2282
+ replayNote = 'Dismiss ABORTED: nothing was discarded because the record could not be saved.';
2283
+ redraw();
2284
+ return;
2285
+ }
2182
2286
  try {
2183
2287
  const n = dismissHeld();
2184
- replayNote = \`Dismissed \${n}. Nothing left held.\`;
2288
+ replayNote = \`Dismissed \${n}. Nothing left held - the record was exported first.\`;
2185
2289
  } catch (err) {
2186
2290
  replayNote = \`Dismiss failed: \${err instanceof Error ? err.message : String(err)}\`;
2187
2291
  }
@@ -2189,6 +2293,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2189
2293
  });
2190
2294
 
2191
2295
  screen.key(['q', 'C-c'], () => {
2296
+ if (promptOpen) return;
2192
2297
  detach();
2193
2298
  screen.destroy();
2194
2299
  onQuit();
@@ -4926,9 +5031,30 @@ type RosterEntry = { account_number: string; name: string; address: Address };
4926
5031
  // runs out of the box. The wizard writes customers.json with these same demo
4927
5032
  // customers \u2014 edit that file, not this list.
4928
5033
  const DEFAULT_ROSTER: RosterEntry[] = [
4929
- { account_number: 'AUR-583920', name: 'Tim Hooper', address: { street: '12 Old Mill Road', suburb: 'Parramatta', state: 'NSW', postcode: '2150' } },
4930
- { account_number: 'AUR-104772', name: 'Olivia Robinson', address: { street: '5 Rosewood Lane', suburb: 'Brisbane', state: 'QLD', postcode: '4000' } },
4931
- { account_number: 'AUR-296815', name: 'Marcus Chen', address: { street: '31 Goldfield Drive', suburb: 'Richmond', state: 'VIC', postcode: '3121' } },
5034
+ { account_number: 'AUR-583920', name: 'Tim Hooper', address: { street: '12 Old Mill Road', suburb: 'Parramatta', state: 'NSW', postcode: '2150' } },
5035
+ { account_number: 'AUR-104772', name: 'Olivia Robinson', address: { street: '5 Rosewood Lane', suburb: 'Brisbane', state: 'QLD', postcode: '4000' } },
5036
+ { account_number: 'AUR-296815', name: 'Marcus Chen', address: { street: '31 Goldfield Drive', suburb: 'Richmond', state: 'VIC', postcode: '3121' } },
5037
+ { account_number: 'AUR-337401', name: 'Priya Raghavan', address: { street: '8 Kurrajong Street', suburb: 'Newtown', state: 'NSW', postcode: '2042' } },
5038
+ { account_number: 'AUR-418266', name: 'Daniel O\\'Sullivan', address: { street: '204/19 Marine Parade', suburb: 'Glenelg', state: 'SA', postcode: '5045' } },
5039
+ { account_number: 'AUR-452093', name: 'Amara Okafor', address: { street: '77 Beaufort Street', suburb: 'Mount Lawley', state: 'WA', postcode: '6050' } },
5040
+ { account_number: 'AUR-509118', name: 'Hannah Whitlock', address: { street: '3 Tasman Close', suburb: 'Sandy Bay', state: 'TAS', postcode: '7005' } },
5041
+ { account_number: 'AUR-563740', name: 'Joshua Nguyen', address: { street: '145 Given Terrace', suburb: 'Paddington', state: 'QLD', postcode: '4064' } },
5042
+ { account_number: 'AUR-617205', name: 'Isabella Moretti', address: { street: '62 Lygon Street', suburb: 'Carlton', state: 'VIC', postcode: '3053' } },
5043
+ { account_number: 'AUR-668931', name: 'Samuel Achterberg', address: { street: '11 Hutt Street', suburb: 'Adelaide', state: 'SA', postcode: '5000' } },
5044
+ { account_number: 'AUR-702884', name: 'Grace Tupou', address: { street: '9 Kingsford Smith Way', suburb: 'Belconnen', state: 'ACT', postcode: '2617' } },
5045
+ { account_number: 'AUR-745160', name: 'Ethan Marsh', address: { street: '38 Smith Street', suburb: 'Darwin City', state: 'NT', postcode: '0800' } },
5046
+ { account_number: 'AUR-781422', name: 'Chloe Bennett', address: { street: '26 Rundle Street', suburb: 'Kent Town', state: 'SA', postcode: '5067' } },
5047
+ { account_number: 'AUR-816559', name: 'Liam Fitzgerald', address: { street: '410/88 Queen Street', suburb: 'Melbourne', state: 'VIC', postcode: '3000' } },
5048
+ { account_number: 'AUR-849073', name: 'Sofia Delgado', address: { street: '17 Cavill Avenue', suburb: 'Surfers Paradise', state: 'QLD', postcode: '4217' } },
5049
+ { account_number: 'AUR-873318', name: 'Noah Kirkwood', address: { street: '54 Hunter Street', suburb: 'Newcastle', state: 'NSW', postcode: '2300' } },
5050
+ { account_number: 'AUR-905642', name: 'Mei Lin Tan', address: { street: '2/33 Rokeby Road', suburb: 'Subiaco', state: 'WA', postcode: '6008' } },
5051
+ { account_number: 'AUR-938177', name: 'Jack Rasmussen', address: { street: '6 Elizabeth Street', suburb: 'Launceston', state: 'TAS', postcode: '7250' } },
5052
+ { account_number: 'AUR-960845', name: 'Aaliyah Bennett', address: { street: '82 Charles Street', suburb: 'Northcote', state: 'VIC', postcode: '3070' } },
5053
+ { account_number: 'AUR-984312', name: 'Oscar Petrov', address: { street: '19 Macquarie Street', suburb: 'Hobart', state: 'TAS', postcode: '7000' } },
5054
+ { account_number: 'AUR-991026', name: 'Ruby Anderson', address: { street: '301/5 Cordelia Street', suburb: 'South Brisbane', state: 'QLD', postcode: '4101' } },
5055
+ { account_number: 'AUR-995518', name: 'Henry Abebe', address: { street: '14 Wakefield Street', suburb: 'Kent Town', state: 'SA', postcode: '5067' } },
5056
+ { account_number: 'AUR-997430', name: 'Zoe Castellano', address: { street: '67 Bay Street', suburb: 'Port Melbourne', state: 'VIC', postcode: '3207' } },
5057
+ { account_number: 'AUR-999201', name: 'Felix Nakamura', address: { street: '23 Anzac Parade', suburb: 'Kensington', state: 'NSW', postcode: '2033' } },
4932
5058
  ];
4933
5059
 
4934
5060
  function loadRoster(): RosterEntry[] {
@@ -6456,8 +6582,9 @@ export async function ask(prompt: string): Promise<string> {
6456
6582
  * Show what this receiver holds: the roster, and every change it has applied.
6457
6583
  *
6458
6584
  * Usage:
6459
- * npm run show \u2014 the roster and the last 20 changes
6460
- * npm run show 100 \u2014 the last 100 changes
6585
+ * npm run show \u2014 a LIVE branded view, refreshing as changes land
6586
+ * npm run show 100 \u2014 keep 100 changes on screen instead of 20
6587
+ * npm run show > f.txt \u2014 plain one-shot text, for a file or a pipe
6461
6588
  *
6462
6589
  * ## Why this script exists
6463
6590
  *
@@ -6468,6 +6595,23 @@ export async function ask(prompt: string): Promise<string> {
6468
6595
  * dispatch: "where do I look to prove it end to end?" The dashboard's LAST
6469
6596
  * CHANGE panel shows one change and nothing showed the rest.
6470
6597
  *
6598
+ * ## Why it STREAMS rather than printing once
6599
+ *
6600
+ * The first version printed a snapshot and exited, which answers "did it land"
6601
+ * only if you already knew to run it again. Watching a dispatch arrive meant
6602
+ * re-running it by hand and diffing two screenfuls in your head. It holds the
6603
+ * screen now and polls, so a change appears as it is applied.
6604
+ *
6605
+ * POLLED, NOT SUBSCRIBED, and that is forced rather than lazy: this is a
6606
+ * SEPARATE PROCESS from the receiver, so there is no event to listen to. SQLite
6607
+ * readers do not block writers, so a poll costs the running receiver nothing.
6608
+ * The screen is only redrawn when the rendered content actually CHANGES, so an
6609
+ * idle receiver is not repainting a terminal once a second.
6610
+ *
6611
+ * NON-TTY FALLS BACK TO ONE-SHOT TEXT. \`npm run show > out.txt\`, a pipe, and a
6612
+ * CI log have no terminal to hold, and a blessed screen drawn into a pipe
6613
+ * produces escape-code soup. Same rule the dashboard itself follows.
6614
+ *
6471
6615
  * It prompts for the password for the same reason \`src/index.ts\` does, and for
6472
6616
  * the same reason it must do so BEFORE importing the store: the database
6473
6617
  * derives its keys on import.
@@ -6478,8 +6622,17 @@ export async function ask(prompt: string): Promise<string> {
6478
6622
  * in the connector's own store.
6479
6623
  */
6480
6624
  import 'dotenv/config';
6625
+ import blessed from 'blessed';
6481
6626
  import { config } from '../src/config.js';
6482
6627
  import { ask, databaseIsLocked } from '../src/unlock.js';
6628
+ import { HEX, ONE_ROWS, ADDRESS_ROWS, terminalFitsFullMark } from '../src/brand.js';
6629
+
6630
+ const AMBER = HEX.amber.toLowerCase();
6631
+ const CREAM = HEX.cream.toLowerCase();
6632
+ const DIM = '#8a7f6a';
6633
+
6634
+ /** Escape blessed's tag syntax so a customer's name can never inject markup. */
6635
+ const esc = (s: unknown): string => String(s ?? '').replace(/[{}]/g, '');
6483
6636
 
6484
6637
  function fmt(a: Record<string, string> | null): string {
6485
6638
  if (!a) return '(nothing on file)';
@@ -6495,18 +6648,48 @@ async function askPassword(): Promise<void> {
6495
6648
  if (answer) process.env.ONEADDRESS_DB_PASSPHRASE = answer;
6496
6649
  }
6497
6650
 
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
- }
6651
+ type Store = typeof import('../src/store.js');
6504
6652
 
6505
- await askPassword();
6653
+ /**
6654
+ * Does this row answer the filter?
6655
+ *
6656
+ * NAME OR ACCOUNT NUMBER, because a partner checking whether a dispatch landed
6657
+ * knows the person either way round, and typing "hoop" is faster than finding
6658
+ * the account number to type it exactly.
6659
+ */
6660
+ function matches(query: string, name: string, account: string): boolean {
6661
+ if (!query) return true;
6662
+ const q = query.toLowerCase();
6663
+ return name.toLowerCase().includes(q) || account.toLowerCase().includes(q);
6664
+ }
6665
+
6666
+ /** The roster, as display rows, optionally filtered. */
6667
+ function rosterRows(store: Store, query: string): string[] {
6668
+ return store.allCustomers()
6669
+ .filter((c) => matches(query, c.name, c.account_number))
6670
+ .map((c) => {
6671
+ let addr: Record<string, string> | null = null;
6672
+ try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
6673
+ return \` \${esc(c.account_number).padEnd(14)} \${esc(c.name).padEnd(20)} \${esc(fmt(addr))}\`;
6674
+ });
6675
+ }
6506
6676
 
6507
- const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
6508
- const store = await import('../src/store.js');
6677
+ /** Applied changes, newest first, as display rows with both sides. */
6678
+ function changeRows(store: Store, limit: number, query: string): string[] {
6679
+ const out: string[] = [];
6680
+ for (const h of store.addressHistory(limit)) {
6681
+ if (!matches(query, h.name, h.account_number)) continue;
6682
+ const prev = h.prev_address as unknown as Record<string, string> | null;
6683
+ const now = h.address as unknown as Record<string, string>;
6684
+ out.push(\` {\${DIM}-fg}\${esc(h.recorded_at)}{/} {\${CREAM}-fg}\${esc(h.account_number)}{/} \${esc(h.name)}\`);
6685
+ out.push(\` {\${DIM}-fg}was:{/} \${esc(fmt(prev))}\`);
6686
+ out.push(\` {\${AMBER}-fg}now:{/} \${esc(fmt(now))}\`);
6687
+ }
6688
+ return out;
6689
+ }
6509
6690
 
6691
+ /** One-shot plain text. The pipe, the file, the CI log. */
6692
+ function printOnce(store: Store, limit: number): void {
6510
6693
  const roster = store.allCustomers();
6511
6694
  console.log(\`\\n ON FILE (\${roster.length})\\n\`);
6512
6695
  for (const c of roster) {
@@ -6514,7 +6697,6 @@ async function main(): Promise<void> {
6514
6697
  try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
6515
6698
  console.log(\` \${c.account_number.padEnd(14)} \${c.name.padEnd(24)} \${fmt(addr)}\`);
6516
6699
  }
6517
-
6518
6700
  const changes = store.addressHistory(limit);
6519
6701
  console.log(\`\\n APPLIED CHANGES (\${changes.length}, newest first)\\n\`);
6520
6702
  if (changes.length === 0) {
@@ -6529,6 +6711,167 @@ async function main(): Promise<void> {
6529
6711
  console.log('');
6530
6712
  }
6531
6713
 
6714
+ /**
6715
+ * The live view: the mark, the roster, the changes, refreshed as they land.
6716
+ *
6717
+ * SEARCH IS THE REASON THIS IS NOT JUST A PRINT. A roster of three fits on a
6718
+ * screen and a realistic one does not, so "is my own record right" stopped
6719
+ * being answerable by looking. \`/\` filters both panels at once on name or
6720
+ * account number, because a partner checking a dispatch landed knows the person
6721
+ * either way round.
6722
+ */
6723
+ function stream(store: Store, limit: number): void {
6724
+ const screen = blessed.screen({
6725
+ smartCSR: true,
6726
+ title: 'What this receiver holds \u2014 OneAddress',
6727
+ fullUnicode: true,
6728
+ // WITHOUT THIS THE BRAND COMES OUT RED, and it came out red here for a
6729
+ // whole release because this is the SECOND blessed screen in the scaffold
6730
+ // and the fix lived only in the first. blessed resolves a hex colour
6731
+ // against the palette it infers from TERM, which Windows does not set, so
6732
+ // it assumes a tiny palette and snaps #e0a248 to the nearest thing it
6733
+ // believes exists. Naming the capability is telling it the truth.
6734
+ // \`scripts/__tests__\` pins that EVERY screen we open carries this.
6735
+ terminal: process.env.TERM || 'xterm-256color',
6736
+ });
6737
+
6738
+ const wide = terminalFitsFullMark(Number(screen.width));
6739
+ // THE MARK, PLUS A LINE SAYING WHICH SCREEN THIS IS. The dashboard and this
6740
+ // viewer open with the same wordmark, so without a subheader a screenshot of
6741
+ // one is indistinguishable from the other - and they answer different
6742
+ // questions. The dashboard is "is it running"; this is "what does it hold".
6743
+ const markHeight = (wide ? 9 : 3) + 1;
6744
+ blessed.box({
6745
+ parent: screen, top: 0, left: 0, width: '100%', height: markHeight,
6746
+ tags: true, padding: { left: 2 },
6747
+ content: (wide
6748
+ ? ONE_ROWS.map((row, i) => \`{\${CREAM}-fg}\${row}{/} {\${AMBER}-fg}\${ADDRESS_ROWS[i]}{/}\`).join('\\n')
6749
+ : \`{\${CREAM}-fg}{bold}One{/bold}{/}{\${AMBER}-fg}{bold}Address{/bold}{/}\`)
6750
+ + \`\\n{\${DIM}-fg}WHAT THIS RECEIVER HOLDS{/}\`,
6751
+ });
6752
+
6753
+ // THE ROSTER IS BOUNDED AND SCROLLS. It used to size itself to its contents,
6754
+ // which was fine for three customers and pushes the changes panel off the
6755
+ // bottom of the screen for a realistic roster.
6756
+ const usable = Math.max(8, Number(screen.height) - markHeight - 1);
6757
+ const rosterHeight = Math.max(5, Math.min(Math.floor(usable * 0.45), usable - 5));
6758
+
6759
+ const rosterBox = blessed.box({
6760
+ parent: screen, top: markHeight, left: 0, width: '100%', height: rosterHeight,
6761
+ tags: true, padding: { left: 1, right: 1 }, scrollable: true, alwaysScroll: true,
6762
+ border: { type: 'line' }, style: { border: { fg: AMBER } },
6763
+ label: ' ON FILE ',
6764
+ });
6765
+
6766
+ const changesBox = blessed.box({
6767
+ parent: screen, top: markHeight + rosterHeight, left: 0, width: '100%', bottom: 1,
6768
+ tags: true, padding: { left: 1, right: 1 }, scrollable: true, alwaysScroll: true,
6769
+ border: { type: 'line' }, style: { border: { fg: AMBER } },
6770
+ label: ' APPLIED CHANGES \u2014 newest first ',
6771
+ });
6772
+
6773
+ const footer = blessed.box({
6774
+ parent: screen, bottom: 0, left: 0, width: '100%', height: 1,
6775
+ tags: true, padding: { left: 2 },
6776
+ });
6777
+
6778
+ let query = '';
6779
+ let last = '';
6780
+
6781
+ const drawFooter = (): void => {
6782
+ footer.setContent(query
6783
+ ? \`{\${AMBER}-fg}filter: \${esc(query)}{/} {\${CREAM}-fg}[/]{/} change {\${CREAM}-fg}[esc]{/} clear {\${CREAM}-fg}[q]{/} quit\`
6784
+ : \`{\${DIM}-fg}live \u2014 updates as dispatches land{/} {\${CREAM}-fg}[/]{/} search {\${CREAM}-fg}[q]{/} quit\`);
6785
+ };
6786
+
6787
+ const tick = (force = false): void => {
6788
+ const all = rosterRows(store, '');
6789
+ const roster = rosterRows(store, query);
6790
+ const changes = changeRows(store, limit, query);
6791
+ const next = \`\${query}\\u0000\${roster.join('\\n')}\\u0000\${changes.join('\\n')}\`;
6792
+ if (next === last && !force) return;
6793
+ last = next;
6794
+
6795
+ rosterBox.setLabel(query
6796
+ ? \` ON FILE \u2014 \${roster.length} of \${all.length} matching "\${esc(query)}" \`
6797
+ : \` ON FILE \u2014 \${all.length} \`);
6798
+ rosterBox.setContent(roster.join('\\n') ||
6799
+ (query ? \` Nobody matching "\${esc(query)}".\` : ' (nobody on file yet)'));
6800
+
6801
+ changesBox.setLabel(query
6802
+ ? \` APPLIED CHANGES \u2014 matching "\${esc(query)}", newest first \`
6803
+ : ' APPLIED CHANGES \u2014 newest first ');
6804
+ changesBox.setContent(changes.join('\\n') || (query
6805
+ ? \` No applied change for "\${esc(query)}" yet.\`
6806
+ : ' None yet. Send a dispatch and this fills in.'));
6807
+
6808
+ drawFooter();
6809
+ screen.render();
6810
+ };
6811
+
6812
+ // \`/\` OPENS A LIVE FILTER. It filters as you type rather than on Enter,
6813
+ // because the question being asked is "is my record in here", and the answer
6814
+ // is usually visible three characters in.
6815
+ let searching = false;
6816
+ const openSearch = (): void => {
6817
+ searching = true;
6818
+ const box = blessed.textbox({
6819
+ parent: screen, bottom: 0, left: 0, width: '100%', height: 1,
6820
+ style: { fg: CREAM }, inputOnFocus: true, keys: true,
6821
+ });
6822
+ box.setValue(query);
6823
+ // The filter must update on every keystroke, and \`readInput\` only reports
6824
+ // the final value, so the live half is driven off keypress.
6825
+ box.on('keypress', () => {
6826
+ setImmediate(() => { query = String(box.getValue() ?? '').trim(); tick(); });
6827
+ });
6828
+ screen.render();
6829
+ box.readInput(() => {
6830
+ searching = false;
6831
+ query = String(box.getValue() ?? '').trim();
6832
+ box.destroy();
6833
+ tick(true);
6834
+ });
6835
+ };
6836
+
6837
+ screen.key(['/'], () => { if (!searching) openSearch(); });
6838
+ screen.key(['escape'], () => {
6839
+ if (searching) return;
6840
+ if (!query) return;
6841
+ query = '';
6842
+ tick(true);
6843
+ });
6844
+ screen.key(['q', 'C-c'], () => {
6845
+ if (searching) return;
6846
+ screen.destroy();
6847
+ process.exit(0);
6848
+ });
6849
+
6850
+ tick(true);
6851
+ // DELIBERATELY NOT unref'd. An unref'd timer does not hold the event loop
6852
+ // open, and with the screen drawn there is nothing else pending, so the
6853
+ // process would exit the instant it finished painting.
6854
+ setInterval(() => tick(), 1000);
6855
+ }
6856
+
6857
+ async function main(): Promise<void> {
6858
+ if (config.mode === 'inbox') {
6859
+ console.log('\\n This receiver is in inbox mode: it holds no customer records and no key.');
6860
+ console.log(' Your connector applied the change and holds the result. Look there.\\n');
6861
+ return;
6862
+ }
6863
+
6864
+ await askPassword();
6865
+
6866
+ const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
6867
+ const store = await import('../src/store.js');
6868
+
6869
+ // A pipe, a file or a CI log has no terminal to hold, and a blessed screen
6870
+ // drawn into one produces escape-code soup rather than output.
6871
+ if (!process.stdout.isTTY) { printOnce(store, limit); return; }
6872
+ stream(store, limit);
6873
+ }
6874
+
6532
6875
  void main().catch((err: unknown) => {
6533
6876
  console.error(err instanceof Error ? err.message : String(err));
6534
6877
  process.exit(1);
@@ -11903,7 +12246,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
11903
12246
 
11904
12247
  // src/register.ts
11905
12248
  var import_node_crypto2 = require("crypto");
11906
- var PKG_VERSION = true ? "2.6.0" : "dev";
12249
+ var PKG_VERSION = true ? "2.8.0" : "dev";
11907
12250
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
11908
12251
  function hmacSha256(secret, message) {
11909
12252
  return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
@@ -12972,6 +13315,9 @@ OneAddress and a leak of either must not also hand somebody your customers' addr
12972
13315
  "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
13316
  );
12974
13317
  } else {
13318
+ M2.info(
13319
+ "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."
13320
+ );
12975
13321
  const privateKeyRaw = await he({
12976
13322
  message: "Your ECDH Private Key",
12977
13323
  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.8.0",
4
4
  "description": "Interactive setup wizard for OneAddress partner webhook integrations",
5
5
  "main": "dist/index.js",
6
6
  "bin": {