@oneaddress/setup 2.2.0 → 2.3.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 +314 -106
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -828,7 +828,7 @@ var Y2 = ({ indicator: t = "dots" } = {}) => {
828
828
  };
829
829
 
830
830
  // src/prompts.ts
831
- var import_node_crypto5 = require("crypto");
831
+ var import_node_crypto6 = require("crypto");
832
832
  var import_node_net = require("net");
833
833
  var import_node_fs3 = require("fs");
834
834
  var import_node_os2 = require("os");
@@ -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.2.0" : "?";
859
+ var WIZARD_VERSION = true ? "2.3.0" : "?";
860
860
  function printCompactHeader() {
861
861
  const INNER = 42;
862
862
  const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
@@ -909,6 +909,7 @@ function stripAnsi(s) {
909
909
 
910
910
  // src/scaffold.ts
911
911
  var import_promises = require("fs/promises");
912
+ var import_node_crypto = require("crypto");
912
913
  var import_node_fs = require("fs");
913
914
  var import_node_path = require("path");
914
915
 
@@ -1006,10 +1007,11 @@ data.db-shm
1006
1007
  {
1007
1008
  name: "oneaddress.config.json",
1008
1009
  content: `{
1009
- "//": "Written by npx @oneaddress/setup, read by src/config.ts. Non-secret config only \u2014 secrets live in .env. Any field can be overridden by an env var of the matching name (PARTNER_ID / ONEADDRESS_API / VERIFIES_ACCOUNT_REFERENCE).",
1010
+ "//": "Written by npx @oneaddress/setup, read by src/config.ts. Non-secret config only \u2014 secrets live in .env. Any field can be overridden by an env var of the matching name (PARTNER_ID / ONEADDRESS_API / VERIFIES_ACCOUNT_REFERENCE / RECEIVER_MODE).",
1010
1011
  "partnerId": "%%PARTNER_ID%%",
1011
1012
  "oneAddressApi": "%%ONEADDRESS_API%%",
1012
- "verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE%%
1013
+ "verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE%%,
1014
+ "mode": "%%RECEIVER_MODE%%"
1013
1015
  }
1014
1016
  `
1015
1017
  },
@@ -1231,6 +1233,34 @@ export { PassphraseRequiredError, WrongPassphraseError };
1231
1233
  */
1232
1234
  export { isEncrypted };
1233
1235
 
1236
+ /**
1237
+ * Read a timestamp out of this database, whichever of the two formats it is in.
1238
+ *
1239
+ * THIS DATABASE HOLDS TWO, AND NOTHING DECLARED WHICH WAS WHICH.
1240
+ *
1241
+ * a SQL default, \`datetime('now')\` -> "2026-09-14 22:39:52" UTC, no zone
1242
+ * a JS write, \`toISOString()\` -> "2026-09-14T22:39:52.695Z"
1243
+ *
1244
+ * Both appear in the SAME table: \`quarantine.received_at\` is the first and
1245
+ * \`quarantine.replayed_at\` is the second.
1246
+ *
1247
+ * The first is not valid ISO-8601, so \`new Date(...)\` falls to implementation
1248
+ * -defined parsing and V8 reads it as LOCAL time. A row written a minute ago
1249
+ * therefore reads as an hour old for every hour the machine is from UTC. It
1250
+ * reported \`8h ago\` for a two-minute-old row on a partner's machine in Perth,
1251
+ * which is exactly UTC+8 and is how it was found.
1252
+ *
1253
+ * TOLERANT RATHER THAN MIGRATING, deliberately: a receiver that has been
1254
+ * running has rows in the old shape and rewriting somebody's database to fix a
1255
+ * display bug is the wrong trade. This is the same shape as the transition
1256
+ * -tolerant blind-index reads elsewhere in this project.
1257
+ */
1258
+ export function parseStoredTime(value: string): Date {
1259
+ // Zone-less \`YYYY-MM-DD HH:MM:SS\` is SQLite's, and SQLite's \`now\` is UTC.
1260
+ const sqlite = /^(\\d{4}-\\d{2}-\\d{2}) (\\d{2}:\\d{2}:\\d{2})$/.exec(value.trim());
1261
+ return new Date(sqlite ? \`\${sqlite[1]}T\${sqlite[2]}Z\` : value);
1262
+ }
1263
+
1234
1264
  /**
1235
1265
  * Add a column to an existing table if it is not already there.
1236
1266
  *
@@ -1680,7 +1710,6 @@ import { formatLine, report, type ReportLine } from './report.js';
1680
1710
  // One import, same as server.ts. Swapping the store swaps what the dashboard
1681
1711
  // reads, with nothing here to change.
1682
1712
  import { store } from './store.js';
1683
- import type { StoredCustomer } from './customer-store.js';
1684
1713
  import { pendingConfirmCount } from './confirm-queue.js';
1685
1714
  import { exportHeld, heldCount, heldSummary } from './quarantine.js';
1686
1715
 
@@ -1701,7 +1730,7 @@ export interface TuiOptions {
1701
1730
  * Re-apply everything the receiver could not open, bound to [r].
1702
1731
  *
1703
1732
  * Passed IN rather than imported: \`server.ts\` already imports this file for
1704
- * \`notePreviousAddress\`, so importing it back would be a cycle. Optional so
1733
+ * \`noteChange\`, so importing it back would be a cycle. Optional so
1705
1734
  * the dashboard still renders for a caller that has no replay to offer.
1706
1735
  */
1707
1736
  onReplay?: () => Promise<{ applied: number; failed: number }>;
@@ -1722,10 +1751,26 @@ export interface TuiOptions {
1722
1751
  };
1723
1752
  }
1724
1753
 
1725
- /** One address as a single line, the way the change panel shows it. */
1726
- function oneLine(a: Record<string, unknown>): string {
1727
- const parts = [a.street, a.suburb, a.state, a.postcode].filter(Boolean).map(String);
1728
- return parts.length > 0 ? parts.join(', ') : '(empty)';
1754
+ /**
1755
+ * What the server reports about a dispatch it applied.
1756
+ *
1757
+ * DELIBERATELY CARRIES NO ADDRESS AND NO NAME. This is the whole interface
1758
+ * between the protocol layer and the screen, so anything absent from this type
1759
+ * is something the dashboard cannot render however it is rewritten later. The
1760
+ * address-formatting helper that used to live here went with it: a function
1761
+ * that turns an address into a display string, sitting in the UI module, is an
1762
+ * invitation.
1763
+ */
1764
+ export interface ChangeFacts {
1765
+ /** The partner's own identifier for their own record. Never the consumer's name. */
1766
+ accountNumber: string;
1767
+ /** Did the account-reference guard run and pass? False means you do not verify them. */
1768
+ accountChecked: boolean;
1769
+ /** Did the record actually move, or was it already current? */
1770
+ changed: boolean;
1771
+ /** Proof of consent, for your audit trail. */
1772
+ loaRef: string | null;
1773
+ dispatchId: string | null;
1729
1774
  }
1730
1775
 
1731
1776
  export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: TuiOptions): void {
@@ -1808,13 +1853,13 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1808
1853
  });
1809
1854
 
1810
1855
  // \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1811
- let lastChange: { customer: StoredCustomer; previous: Record<string, unknown> } | null = null;
1812
- let pendingPrevious: Record<string, unknown> = {};
1856
+ let lastChange: ChangeFacts | null = null;
1813
1857
 
1814
- // The server hands over the address a dispatch REPLACED. Kept as state here
1815
- // rather than pushed through \`report\`, because the previous address is data
1816
- // rather than narration and must never end up in a log line.
1817
- setPrevious = (prev) => { pendingPrevious = prev; };
1858
+ // The server hands over FACTS about a dispatch, never the address. See
1859
+ // \`ChangeFacts\` and the note at the call site in server.ts: this module is
1860
+ // the screen, and a screen is the one place a consumer's home address is
1861
+ // most likely to be photographed.
1862
+ setChange = (facts) => { lastChange = facts; redraw(); };
1818
1863
 
1819
1864
  function renderStatus(): void {
1820
1865
  // Both states are shown, and the unprotected one is the loud colour. A
@@ -1846,31 +1891,36 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1846
1891
  if (!lastChange) {
1847
1892
  changeBox.setContent(
1848
1893
  \`\\n {\${DIM}-fg}Waiting for a dispatch.{/}\\n\\n\` +
1849
- \` {\${DIM}-fg}When one arrives, the address it replaced{/}\\n\` +
1850
- \` {\${DIM}-fg}and the address that replaced it appear here.{/}\`,
1894
+ \` {\${DIM}-fg}When one arrives, what happened to it appears here.{/}\\n\` +
1895
+ \` {\${DIM}-fg}The address itself never does \u2014 see below.{/}\`,
1851
1896
  );
1852
1897
  return;
1853
1898
  }
1854
- const { customer, previous } = lastChange;
1855
- let now: Record<string, unknown> = {};
1856
- try { now = JSON.parse(customer.address) as Record<string, unknown>; } catch { /* keep empty */ }
1857
- // COMPACT, AND THAT IS A BUG FIX RATHER THAN A TIDY-UP.
1899
+ const f = lastChange;
1900
+ // WHAT HAPPENED, NOT WHAT IT SAYS.
1858
1901
  //
1859
- // This used to space the lines out with blanks, which needed eight rows.
1860
- // The faults band takes seven rows off this panel when it appears, so on a
1861
- // real terminal the last line fell off the bottom - and the last line is
1862
- // \`now\`, the one thing the panel exists to show. blessed clips silently, so
1863
- // it read as a dispatch that had half worked.
1902
+ // Every line here is a fact about the dispatch and none of them is content.
1903
+ // An operator watching this can tell that the envelope opened, that the
1904
+ // account was one of theirs, and whether the record actually moved - which
1905
+ // is everything the panel was ever for. To read the address itself, look in
1906
+ // your own database, which is where it lives and which is a deliberate act
1907
+ // rather than a screen left open.
1864
1908
  //
1865
- // It cost a real end-to-end run an hour of doubt: the address had been
1866
- // applied, confirmed and acknowledged by OneAddress, and the screen showed
1867
- // only what it used to be. Adjacent is better anyway, because comparing two
1868
- // lines a blank apart is harder than comparing two lines.
1909
+ // COMPACT ON PURPOSE. The faults band takes rows off this panel when it
1910
+ // appears and blessed clips silently, so a tall panel loses its last line
1911
+ // and reads as a dispatch that half worked. That cost a real end-to-end run
1912
+ // an hour of doubt.
1913
+ const check = f.accountChecked
1914
+ ? \`{green-fg}account matched{/}\`
1915
+ : \`{\${DIM}-fg}account not checked{/}\`;
1916
+ const moved = f.changed
1917
+ ? \`{green-fg}address changed{/}\`
1918
+ : \`{\${AMBER}-fg}already current{/}\`;
1869
1919
  changeBox.setContent(
1870
- \`\\n {bold}\${esc(customer.name)}{/bold}\` +
1871
- \` {\${DIM}-fg}account{/} {\${AMBER}-fg}\${esc(customer.account_number)}{/}\\n\\n\` +
1872
- \` {red-fg}was{/} \${esc(oneLine(previous))}\\n\` +
1873
- \` {green-fg}now{/} {\${CREAM}-fg}\${esc(oneLine(now))}{/}\`,
1920
+ \`\\n {\${DIM}-fg}account{/} {\${AMBER}-fg}{bold}\${esc(f.accountNumber)}{/}{/}\\n\\n\` +
1921
+ \` {green-fg}decrypted{/} \${check} \${moved}\\n\\n\` +
1922
+ \` {\${DIM}-fg}loa_ref{/} {\${CREAM}-fg}\${esc(f.loaRef ?? '(none)')}{/}\\n\` +
1923
+ \` {\${DIM}-fg}dispatch{/} {\${CREAM}-fg}\${esc(f.dispatchId ?? '(none)')}{/}\`,
1874
1924
  );
1875
1925
  }
1876
1926
 
@@ -1979,22 +2029,12 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
1979
2029
  const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
1980
2030
  const time = new Date(line.at).toTimeString().slice(0, 8);
1981
2031
 
1982
- if (/\\[store\\] saved address for /.test(text)) {
1983
- // The store logs the account key and never the address, so the panel is
1984
- // refreshed from the DATABASE rather than parsed out of the log line.
1985
- const acct = /saved address for (\\S+)/.exec(text)?.[1];
1986
- // An indexed lookup of the one customer, not a decrypt of the whole
1987
- // roster to find them. Invisible at three rows and absurd at four
1988
- // million, which is the scale a real store is pointed at.
1989
- const prev = pendingPrevious;
1990
- if (acct) {
1991
- void Promise.resolve(store.find(acct)).then((customer) => {
1992
- if (!customer) return;
1993
- lastChange = { customer, previous: prev };
1994
- redraw();
1995
- });
1996
- }
1997
- }
2032
+ // NOTHING IS PARSED OUT OF A LOG LINE HERE ANY MORE, and nothing is looked
2033
+ // up from the store. The panel used to scrape the account out of \`[store]
2034
+ // saved address for ...\` and then fetch that customer's record to render
2035
+ // it, which is how a customer's name and address reached the screen. The
2036
+ // server calls \`noteChange\` with facts instead, so this module has no path
2037
+ // to a customer record at all.
1998
2038
 
1999
2039
  logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
2000
2040
  redraw();
@@ -2065,10 +2105,10 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
2065
2105
  * reason \`report\` has a default sink: \`--headless\` must not need a second code
2066
2106
  * path through the handler.
2067
2107
  */
2068
- let setPrevious: (prev: Record<string, unknown>) => void = () => {};
2108
+ let setChange: (facts: ChangeFacts) => void = () => {};
2069
2109
 
2070
- export function notePreviousAddress(prev: Record<string, unknown>): void {
2071
- setPrevious(prev);
2110
+ export function noteChange(facts: ChangeFacts): void {
2111
+ setChange(facts);
2072
2112
  }
2073
2113
  `
2074
2114
  },
@@ -3118,7 +3158,7 @@ export function resetTally(): void {
3118
3158
  * replay would apply an address for an account they do not recognise. It stays
3119
3159
  * a refusal.
3120
3160
  */
3121
- import db, { ensureColumn } from './db.js';
3161
+ import db, { ensureColumn, parseStoredTime } from './db.js';
3122
3162
  import { report } from './report.js';
3123
3163
  import { createHash } from 'node:crypto';
3124
3164
  import { writeFileSync } from 'node:fs';
@@ -3288,7 +3328,10 @@ export function heldSummary(): string[] {
3288
3328
 
3289
3329
  /** "3m ago", "2h ago". Coarse on purpose: nobody acts on seconds. */
3290
3330
  export function describeAge(iso: string): string {
3291
- const ms = Date.now() - new Date(iso).getTime();
3331
+ // \`parseStoredTime\`, not \`new Date\`. See its docstring: this column is written
3332
+ // by a SQL default in SQLite's zone-less UTC format, which V8 parses as local
3333
+ // time, so a fresh row read as hours old by the machine's UTC offset.
3334
+ const ms = Date.now() - parseStoredTime(iso).getTime();
3292
3335
  if (!Number.isFinite(ms) || ms < 0) return 'just now';
3293
3336
  const mins = Math.floor(ms / 60_000);
3294
3337
  if (mins < 1) return 'moments ago';
@@ -3324,13 +3367,27 @@ export function markReplayFailed(id: string, error: string): void {
3324
3367
  * is an update the partner never applied and is now no longer able to.
3325
3368
  */
3326
3369
  export function purgeQuarantine(days: number): { replayed: number; unreplayed: number } {
3327
- const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
3370
+ // THE COMPARISON HAPPENS INSIDE SQLITE, IN SQLITE'S OWN FORMAT, and that is
3371
+ // the fix rather than a tidy-up.
3372
+ //
3373
+ // It used to build an ISO cutoff in JS and compare it against a column
3374
+ // written by a SQL default. SQLite compares those as STRINGS, and the two
3375
+ // formats differ at the separator: ' ' is 0x20 and 'T' is 0x54. So on the
3376
+ // boundary DAY a row still inside the window sorted BEFORE the cutoff and was
3377
+ // deleted, up to about a day early. The table holds a consumer's encrypted
3378
+ // address under a window we have written down, so "saved by the date prefix
3379
+ // usually differing" is not good enough.
3380
+ //
3381
+ // \`datetime('now', ?)\` keeps both sides in one format and one clock. The
3382
+ // modifier is a bound parameter rather than interpolated, so \`days\` cannot
3383
+ // reach the SQL text.
3384
+ const cutoffExpr = \`-\${Math.max(0, Math.floor(days))} days\`;
3328
3385
  const doomed = db.prepare(
3329
- 'SELECT id, replayed_at FROM quarantine WHERE received_at < ?',
3330
- ).all(cutoff) as unknown as { id: string; replayed_at: string | null }[];
3386
+ "SELECT id, replayed_at FROM quarantine WHERE received_at < datetime('now', ?)",
3387
+ ).all(cutoffExpr) as unknown as { id: string; replayed_at: string | null }[];
3331
3388
  if (doomed.length === 0) return { replayed: 0, unreplayed: 0 };
3332
3389
 
3333
- db.prepare('DELETE FROM quarantine WHERE received_at < ?').run(cutoff);
3390
+ db.prepare("DELETE FROM quarantine WHERE received_at < datetime('now', ?)").run(cutoffExpr);
3334
3391
 
3335
3392
  const unreplayed = doomed.filter((d) => d.replayed_at === null).length;
3336
3393
  const replayed = doomed.length - unreplayed;
@@ -3956,6 +4013,30 @@ export interface StoredCustomer {
3956
4013
  address: string;
3957
4014
  }
3958
4015
 
4016
+ /**
4017
+ * Did this dispatch actually change anything?
4018
+ *
4019
+ * THE ONLY QUESTION THE RECEIVER MAY ASK ABOUT AN ADDRESS, and the reason it
4020
+ * exists here rather than inside a store implementation is that the answer is
4021
+ * a single boolean the dashboard can show, where the two addresses are a
4022
+ * customer's home and must not travel any further than the apply.
4023
+ *
4024
+ * Order- and case-insensitive over the WHOLE object, so it keeps working
4025
+ * whatever fields OneAddress adds. \`saveAddress\` returns the address it
4026
+ * replaced; feed that and the incoming one in here, show the boolean, and let
4027
+ * both go.
4028
+ */
4029
+ export function sameAddress(a: Address, b: Address): boolean {
4030
+ const canonical = (x: Address): string =>
4031
+ JSON.stringify(
4032
+ Object.entries(x)
4033
+ .filter(([, v]) => typeof v === 'string' && (v as string).trim() !== '')
4034
+ .map(([k, v]) => [k.toLowerCase(), (v as string).trim().toLowerCase().replace(/\\s+/g, ' ')] as [string, string])
4035
+ .sort((p, q) => p[0].localeCompare(q[0])),
4036
+ );
4037
+ return canonical(a) === canonical(b);
4038
+ }
4039
+
3959
4040
  export type AccountVerdict = 'match' | 'no_match' | 'no_account';
3960
4041
  export type VerifyResult = 'match' | 'mismatch' | 'not_found';
3961
4042
 
@@ -4449,7 +4530,12 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
4449
4530
  // Metadata only \u2014 the address itself is personal information, so the key is
4450
4531
  // logged and the address never is. Centralised log aggregation turns every
4451
4532
  // log line into a place customer addresses can be read.
4452
- report.info(\`[store] saved address for \${acct}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
4533
+ // The ACCOUNT REFERENCE, or a marker - never the name \`acct\` may have fallen
4534
+ // back to. The row is legitimately keyed on the name when you do not verify
4535
+ // account references (see the docstring above); logging it is a different
4536
+ // decision, and the wrong one.
4537
+ const loggable = (customer.accountNumber ?? '').trim() || '(name-keyed record)';
4538
+ report.info(\`[store] saved address for \${loggable}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
4453
4539
 
4454
4540
  return previous;
4455
4541
  }
@@ -4538,9 +4624,10 @@ import {
4538
4624
  // the protocol layer changes.
4539
4625
  import { store as writeThroughStore } from './store.js';
4540
4626
  import { connectorStore, setCurrentRawBody } from './connector-store.js';
4541
- import { notePreviousAddress } from './tui.js';
4627
+ import { noteChange } from './tui.js';
4542
4628
  import { config } from './config.js';
4543
4629
  import { safeOneAddressCallbackUrl } from './callback-url.js';
4630
+ import { sameAddress } from './customer-store.js';
4544
4631
  import { configuredKeyIds, describeKeys, keyFailureAdvice, resolvePrivateKey } from './keys.js';
4545
4632
  import {
4546
4633
  drainConfirms,
@@ -5157,7 +5244,12 @@ app.post('/webhook', async (req: Request, res: Response) => {
5157
5244
 
5158
5245
  // \u2500\u2500 address.updated: consumer changed their address \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5159
5246
  if (event === 'address.updated') {
5160
- report.info(\`[webhook] address.updated for \${ctx.accountNumber || ctx.name}\`);
5247
+ // THE ACCOUNT, NEVER THE NAME. This used to fall back to \`ctx.name\`, so a
5248
+ // partner who does not verify account references wrote a real consumer's
5249
+ // name into every log line, and from there into whatever aggregates their
5250
+ // logs. The account reference is the partner's own identifier for their own
5251
+ // record; the name is the consumer's.
5252
+ report.info(\`[webhook] address.updated for \${ctx.accountNumber || '(no account reference)'}\`);
5161
5253
 
5162
5254
  // AN ACCOUNT REFERENCE THAT MATCHES NOTHING MUST BE A HARD NO-MATCH.
5163
5255
  //
@@ -5196,12 +5288,32 @@ app.post('/webhook', async (req: Request, res: Response) => {
5196
5288
  }
5197
5289
  }
5198
5290
 
5199
- // \`saveAddress\` returns the address it replaced. Handed to the dashboard so
5200
- // it can show both halves; a no-op under --headless. Passed directly rather
5201
- // than reported, because the previous address is a customer's address and
5202
- // must never reach a log line.
5291
+ // THE DASHBOARD IS HANDED FACTS, NOT THE ADDRESS, and that is structural
5292
+ // rather than a formatting choice.
5293
+ //
5294
+ // It used to receive the address this dispatch REPLACED and look the new
5295
+ // one up from the store, and it painted both on a panel. Everything around
5296
+ // here is careful that an address never reaches a log line - two comments
5297
+ // one screen apart say so - and then it was drawn in large text on a screen
5298
+ // that gets screenshotted, screen-shared and left open in an office.
5299
+ //
5300
+ // The receiver does not need to show an address to prove it works. It needs
5301
+ // to show that the envelope opened, that the account was one of yours, and
5302
+ // whether anything actually changed. Those are the facts below, and nothing
5303
+ // that reaches \`tui.ts\` can be turned back into a consumer's home.
5304
+ //
5305
+ // There is nothing here to sneak past, either, which is why this is better
5306
+ // than marking test dispatches as safe to display: a marker is something an
5307
+ // attacker can try to forge onto a real dispatch, and a panel that never
5308
+ // renders an address has nothing to forge it into.
5203
5309
  const replaced = await store.saveAddress(ctx, address);
5204
- notePreviousAddress(replaced);
5310
+ noteChange({
5311
+ accountNumber: ctx.accountNumber || '(no account reference)',
5312
+ accountChecked: config.verifiesAccountReference,
5313
+ changed: !sameAddress(replaced, address),
5314
+ loaRef,
5315
+ dispatchId: dispatch || null,
5316
+ });
5205
5317
  if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
5206
5318
  // Close the loop back to OneAddress so the service flips to "Confirmed".
5207
5319
  // QUEUED, not sent: this is a local INSERT, so it cannot delay the 200 that
@@ -5214,7 +5326,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
5214
5326
 
5215
5327
  // \u2500\u2500 address.verify: consumer is running an address check \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5216
5328
  if (event === 'address.verify') {
5217
- report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
5329
+ report.info(\`[webhook] address.verify for \${ctx.accountNumber || '(no account reference)'}\`);
5218
5330
  const result = await store.verifyAddress(ctx, address);
5219
5331
  report.info(\`[webhook] address.verify \u2192 \${result}\`);
5220
5332
  const refused = await postVerifyVerdict(result);
@@ -10623,11 +10735,49 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
10623
10735
  };
10624
10736
 
10625
10737
  // src/scaffold.ts
10626
- function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi) {
10627
- return content.replaceAll("%%PARTNER_ID%%", partnerId).replaceAll("%%WEBHOOK_SECRET%%", webhookSecret).replaceAll("%%WEBHOOK_URL%%", webhookUrl || "<your-webhook-url>").replaceAll("%%PRIVATE_KEY%%", privateKey || "<paste your PKCS8 PEM private key here>").replaceAll("%%ONEADDRESS_API%%", oneAddressApi || "https://oneaddress.io").replaceAll("%%VERIFIES_ACCOUNT_REFERENCE%%", verifiesAccountReference ? "true" : "false");
10738
+ function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken) {
10739
+ const filled = content.replaceAll("%%PARTNER_ID%%", partnerId).replaceAll("%%WEBHOOK_SECRET%%", webhookSecret).replaceAll("%%WEBHOOK_URL%%", webhookUrl || "<your-webhook-url>").replaceAll("%%PRIVATE_KEY%%", privateKey || "<paste your PKCS8 PEM private key here>").replaceAll("%%ONEADDRESS_API%%", oneAddressApi || "https://oneaddress.io").replaceAll("%%VERIFIES_ACCOUNT_REFERENCE%%", verifiesAccountReference ? "true" : "false").replaceAll("%%RECEIVER_MODE%%", mode === "inbox" ? "inbox" : "write-through");
10740
+ if (mode !== "inbox") return filled;
10741
+ const withReadme = filled.replace(
10742
+ "## Architecture",
10743
+ `## Inbox mode
10744
+
10745
+ This receiver holds dispatches as ciphertext and keeps **no private key** - it refuses to
10746
+ start with one - so it cannot read what it stores. A **connector** you run separately draws
10747
+ from it, decrypts, applies the change to your systems and acknowledges; only then does
10748
+ OneAddress confirm the update to the consumer.
10749
+
10750
+ Until that connector runs, updates are held and the dashboard counts them as awaiting your
10751
+ systems. Nothing is lost and nothing is applied.
10752
+
10753
+ | What | Where |
10754
+ |------|-------|
10755
+ | The decryption key | Your connector's environment, never this one |
10756
+ | \`CONNECTOR_TOKEN\` | \`.env\` here, and the same value in your connector |
10757
+ | The draw channel | \`http://127.0.0.1:3002\`, loopback only |
10758
+
10759
+ \`CONNECTOR_TOKEN\` is its own credential: **not** your webhook secret and **not** your
10760
+ confirm secret. Those two are shared with OneAddress, and a leak of either must not also
10761
+ hand somebody your customers' addresses.
10762
+
10763
+ ## Architecture`
10764
+ );
10765
+ return withReadme.replace(
10766
+ /^PARTNER_PRIVATE_KEY_PEM=.*$/m,
10767
+ `# No PARTNER_PRIVATE_KEY_PEM here, on purpose: this receiver runs in inbox
10768
+ # mode and refuses to start with one. The key belongs in your connector.
10769
+
10770
+ # The credential your connector presents to draw from this inbox. It must
10771
+ # match CONNECTOR_TOKEN in the connector's own environment, and it must not
10772
+ # be your webhook secret or your confirm secret.
10773
+ CONNECTOR_TOKEN=${connectorToken || generateConnectorToken()}`
10774
+ );
10775
+ }
10776
+ function generateConnectorToken() {
10777
+ return (0, import_node_crypto.randomBytes)(24).toString("hex");
10628
10778
  }
10629
10779
  var SENSITIVE_FILES = /* @__PURE__ */ new Set([".env"]);
10630
- async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io") {
10780
+ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io", mode = "write-through", connectorToken = "") {
10631
10781
  const templates = TEMPLATES[platform];
10632
10782
  if (!templates) throw new Error(`Unknown platform: ${platform}`);
10633
10783
  const written = [];
@@ -10637,7 +10787,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
10637
10787
  if (!(0, import_node_fs.existsSync)(dir)) {
10638
10788
  await (0, import_promises.mkdir)(dir, { recursive: true });
10639
10789
  }
10640
- const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi);
10790
+ const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken);
10641
10791
  const isSensitive = SENSITIVE_FILES.has((0, import_node_path.basename)(name));
10642
10792
  await (0, import_promises.writeFile)(dest, filled, { encoding: "utf8", mode: isSensitive ? 384 : 420 });
10643
10793
  if (isSensitive && process.platform !== "win32") {
@@ -10652,11 +10802,11 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
10652
10802
  }
10653
10803
 
10654
10804
  // src/register.ts
10655
- var import_node_crypto = require("crypto");
10656
- var PKG_VERSION = true ? "2.2.0" : "dev";
10805
+ var import_node_crypto2 = require("crypto");
10806
+ var PKG_VERSION = true ? "2.3.0" : "dev";
10657
10807
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
10658
10808
  function hmacSha256(secret, message) {
10659
- return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
10809
+ return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
10660
10810
  }
10661
10811
  async function registerInstall(installId, partnerId, webhookSecret, platform, webhookUrl) {
10662
10812
  try {
@@ -10689,10 +10839,10 @@ async function registerInstall(installId, partnerId, webhookSecret, platform, we
10689
10839
  }
10690
10840
 
10691
10841
  // src/conformance.ts
10692
- var import_node_crypto2 = require("crypto");
10842
+ var import_node_crypto3 = require("crypto");
10693
10843
  async function runConformance(webhookUrl, _partnerId, webhookSecret) {
10694
10844
  function sign(ts, body) {
10695
- return (0, import_node_crypto2.createHmac)("sha256", webhookSecret).update(`${ts}.${body}`).digest("hex");
10845
+ return (0, import_node_crypto3.createHmac)("sha256", webhookSecret).update(`${ts}.${body}`).digest("hex");
10696
10846
  }
10697
10847
  const validBody = JSON.stringify({ event: "conformance.ping", test: true });
10698
10848
  const now = String(Math.floor(Date.now() / 1e3));
@@ -10733,7 +10883,7 @@ async function runConformance(webhookUrl, _partnerId, webhookSecret) {
10733
10883
  // Unique per test run so a partner re-running `npm test` doesn't
10734
10884
  // hit the scaffolded server's dedup cache and short-circuit the
10735
10885
  // "accepted" assertion with { ok: true, duplicate: true }.
10736
- "X-OneAddress-Dispatch": `conformance-${(0, import_node_crypto2.randomUUID)()}`
10886
+ "X-OneAddress-Dispatch": `conformance-${(0, import_node_crypto3.randomUUID)()}`
10737
10887
  },
10738
10888
  body: validBody,
10739
10889
  signal: AbortSignal.timeout(8e3)
@@ -10785,7 +10935,7 @@ async function runDecryptCheck(webhookSecret, partnerId) {
10785
10935
  async function callOnce() {
10786
10936
  try {
10787
10937
  const ts = String(Math.floor(Date.now() / 1e3));
10788
- const sig = (0, import_node_crypto2.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
10938
+ const sig = (0, import_node_crypto3.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
10789
10939
  const res2 = await fetch(VERIFY_DECRYPT_API, {
10790
10940
  method: "POST",
10791
10941
  headers: {
@@ -10860,24 +11010,46 @@ var COMMANDS = {
10860
11010
  function installDependencies(platform, outputDir) {
10861
11011
  const spec = COMMANDS[platform];
10862
11012
  if (!spec) {
10863
- return { ok: true, output: "", manualCommand: "" };
11013
+ return Promise.resolve({ ok: true, output: "", manualCommand: "" });
10864
11014
  }
10865
11015
  const cwd = spec.cwd ? (0, import_node_path2.join)(outputDir, spec.cwd) : outputDir;
10866
11016
  const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
10867
- const result = (0, import_node_child_process.spawnSync)(spec.cmd, spec.args, {
10868
- cwd,
10869
- stdio: "pipe",
10870
- encoding: "utf8",
10871
- timeout: 5 * 60 * 1e3,
10872
- // 5 min max
10873
- shell: process.platform === "win32"
11017
+ return new Promise((resolve2) => {
11018
+ const child = (0, import_node_child_process.spawn)(spec.cmd, spec.args, {
11019
+ cwd,
11020
+ stdio: "pipe",
11021
+ shell: process.platform === "win32"
11022
+ });
11023
+ let output = "";
11024
+ const collect = (chunk) => {
11025
+ if (output.length < 64e3) output += chunk.toString("utf8");
11026
+ };
11027
+ child.stdout?.on("data", collect);
11028
+ child.stderr?.on("data", collect);
11029
+ let settled = false;
11030
+ const finish = (result) => {
11031
+ if (settled) return;
11032
+ settled = true;
11033
+ clearTimeout(timer);
11034
+ resolve2(result);
11035
+ };
11036
+ const timer = setTimeout(() => {
11037
+ child.kill("SIGKILL");
11038
+ finish({
11039
+ ok: false,
11040
+ output: `${output.trim()}
11041
+
11042
+ Timed out after 5 minutes.`.trim(),
11043
+ manualCommand
11044
+ });
11045
+ }, 5 * 60 * 1e3);
11046
+ child.on("error", (err) => {
11047
+ finish({ ok: false, output: `${output}${err.message}`.trim(), manualCommand });
11048
+ });
11049
+ child.on("close", (code) => {
11050
+ finish({ ok: code === 0, output: output.trim(), manualCommand });
11051
+ });
10874
11052
  });
10875
- const output = [result.stdout ?? "", result.stderr ?? ""].join("").trim();
10876
- return {
10877
- ok: result.status === 0 && !result.error,
10878
- output,
10879
- manualCommand
10880
- };
10881
11053
  }
10882
11054
 
10883
11055
  // src/autostart.ts
@@ -11062,7 +11234,7 @@ async function handOverTerminal(platform, outputDir, port, secrets = {}) {
11062
11234
  var import_node_child_process3 = require("child_process");
11063
11235
  var import_promises2 = require("fs/promises");
11064
11236
  var import_node_fs2 = require("fs");
11065
- var import_node_crypto3 = require("crypto");
11237
+ var import_node_crypto4 = require("crypto");
11066
11238
  var import_node_path3 = require("path");
11067
11239
  var import_node_os = __toESM(require("os"));
11068
11240
  var tunnelProcess = null;
@@ -11101,7 +11273,7 @@ function platformKey() {
11101
11273
  return "linux-x64";
11102
11274
  }
11103
11275
  async function sha256File(path) {
11104
- const hash = (0, import_node_crypto3.createHash)("sha256");
11276
+ const hash = (0, import_node_crypto4.createHash)("sha256");
11105
11277
  hash.update(await (0, import_promises2.readFile)(path));
11106
11278
  return hash.digest("hex");
11107
11279
  }
@@ -11235,7 +11407,7 @@ async function startTunnel(port) {
11235
11407
  }
11236
11408
 
11237
11409
  // src/register-url.ts
11238
- var import_node_crypto4 = require("crypto");
11410
+ var import_node_crypto5 = require("crypto");
11239
11411
  var PORTAL_API = "https://partners.oneaddress.io/api/partner/webhook-url";
11240
11412
  function describeReadFailure(status) {
11241
11413
  if (status === 401) return "the portal rejected the signature (HTTP 401), which means your Webhook signing secret does not match the one on your profile. Copy it again from partners.oneaddress.io \u2192 Webhook \u2192 Webhook signing secret.";
@@ -11246,7 +11418,7 @@ function describeReadFailure(status) {
11246
11418
  async function getExistingWebhookUrl(partnerId, webhookSecret, onError) {
11247
11419
  try {
11248
11420
  const ts = String(Math.floor(Date.now() / 1e3));
11249
- const sig = (0, import_node_crypto4.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
11421
+ const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
11250
11422
  const res = await fetch(PORTAL_API, {
11251
11423
  method: "GET",
11252
11424
  headers: {
@@ -11270,7 +11442,7 @@ async function getExistingWebhookUrl(partnerId, webhookSecret, onError) {
11270
11442
  async function getVerifiesAccountReference(partnerId, webhookSecret, onError) {
11271
11443
  try {
11272
11444
  const ts = String(Math.floor(Date.now() / 1e3));
11273
- const sig = (0, import_node_crypto4.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
11445
+ const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
11274
11446
  const res = await fetch(PORTAL_API, {
11275
11447
  method: "GET",
11276
11448
  headers: {
@@ -11297,7 +11469,7 @@ async function getVerifiesAccountReference(partnerId, webhookSecret, onError) {
11297
11469
  async function verifyWebhookSecret(partnerId, webhookSecret) {
11298
11470
  try {
11299
11471
  const ts = String(Math.floor(Date.now() / 1e3));
11300
- const sig = (0, import_node_crypto4.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
11472
+ const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
11301
11473
  const res = await fetch(PORTAL_API, {
11302
11474
  method: "GET",
11303
11475
  headers: {
@@ -11318,7 +11490,7 @@ async function registerWebhookUrl(partnerId, webhookSecret, webhookUrl) {
11318
11490
  try {
11319
11491
  const body = JSON.stringify({ webhook_url: webhookUrl });
11320
11492
  const ts = String(Math.floor(Date.now() / 1e3));
11321
- const sig = (0, import_node_crypto4.createHmac)("sha256", webhookSecret).update(`${ts}.${body}`).digest("hex");
11493
+ const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.${body}`).digest("hex");
11322
11494
  const res = await fetch(PORTAL_API, {
11323
11495
  method: "PATCH",
11324
11496
  headers: {
@@ -11515,7 +11687,7 @@ ${wrapped}
11515
11687
  -----END EC PRIVATE KEY-----
11516
11688
  `;
11517
11689
  try {
11518
- const pkcs8 = (0, import_node_crypto5.createPrivateKey)({ key: sec1Pem, format: "pem", type: "sec1" }).export({ type: "pkcs8", format: "pem" });
11690
+ const pkcs8 = (0, import_node_crypto6.createPrivateKey)({ key: sec1Pem, format: "pem", type: "sec1" }).export({ type: "pkcs8", format: "pem" });
11519
11691
  return { pem: pkcs8 };
11520
11692
  } catch (err) {
11521
11693
  return { pem: "", error: translateKeyError(err) };
@@ -11559,7 +11731,7 @@ function translateKeyError(err) {
11559
11731
  function validatePrivateKey(pem) {
11560
11732
  let key;
11561
11733
  try {
11562
- key = (0, import_node_crypto5.createPrivateKey)({ key: pem, format: "pem", type: "pkcs8" });
11734
+ key = (0, import_node_crypto6.createPrivateKey)({ key: pem, format: "pem", type: "pkcs8" });
11563
11735
  } catch (err) {
11564
11736
  return translateKeyError(err);
11565
11737
  }
@@ -11680,6 +11852,39 @@ async function main() {
11680
11852
  accountRefReason = r2;
11681
11853
  });
11682
11854
  const verifiesAccountReference = accountRefDeclaration === true;
11855
+ const modeChoice = platform !== "ts-node" ? "write-through" : await ve({
11856
+ message: "How should this receiver handle updates?",
11857
+ options: [
11858
+ {
11859
+ value: "write-through",
11860
+ label: "Apply them itself (simplest)",
11861
+ hint: "Decrypts and writes to a customer store on this machine. Right for a sole trader, or to try it out."
11862
+ },
11863
+ {
11864
+ value: "inbox",
11865
+ label: "Hold them for your own systems to collect",
11866
+ hint: "Holds ciphertext and keeps NO private key, so this process cannot read what it stores. Needs a connector you run separately."
11867
+ }
11868
+ ],
11869
+ initialValue: "write-through"
11870
+ });
11871
+ assertNotCancelled(modeChoice);
11872
+ const receiverMode = modeChoice;
11873
+ const connectorToken = receiverMode === "inbox" ? generateConnectorToken() : "";
11874
+ if (receiverMode === "inbox") {
11875
+ M2.warn(
11876
+ "Inbox mode needs a second process. This receiver will hold dispatches as ciphertext and\nkeep NO private key \u2014 it refuses to start with one \u2014 so a connector of yours must draw,\ndecrypt and apply them, then acknowledge. Until that connector runs, updates are held and\nthe dashboard counts them as awaiting your systems."
11877
+ );
11878
+ M2.info(
11879
+ `Your connector credential (also written to .env as CONNECTOR_TOKEN):
11880
+
11881
+ ${connectorToken}
11882
+
11883
+ Set the same value as CONNECTOR_TOKEN in your connector. It is its own credential:
11884
+ not your webhook secret and not your confirm secret, because those are shared with
11885
+ OneAddress and a leak of either must not also hand somebody your customers' addresses.`
11886
+ );
11887
+ }
11683
11888
  if (accountRefDeclaration === void 0) {
11684
11889
  M2.warn(
11685
11890
  "Could not read your account-reference declaration from the portal.\n" + (accountRefReason ? "Reason: " + accountRefReason + "\n" : "") + "Conformance check-14 has been left out of the generated `npm test`, so a\npassing run does NOT mean your receiver matches account references correctly.\nSet it at partners.oneaddress.io \u2192 My Profile, then re-run this wizard."
@@ -11697,7 +11902,10 @@ async function main() {
11697
11902
  "",
11698
11903
  // webhookUrl — filled in after server starts
11699
11904
  privateKey,
11700
- verifiesAccountReference
11905
+ verifiesAccountReference,
11906
+ void 0,
11907
+ receiverMode,
11908
+ connectorToken
11701
11909
  );
11702
11910
  s1.stop(`Scaffolded ${written.length} files in ${outDir}`);
11703
11911
  for (const f of written) M2.success(` ${f}`);
@@ -11709,7 +11917,7 @@ async function main() {
11709
11917
  for (; ; ) {
11710
11918
  const s2 = Y2();
11711
11919
  s2.start("Installing dependencies (this can take 30\u201360 s)");
11712
- const install = installDependencies(platform, outDir);
11920
+ const install = await installDependencies(platform, outDir);
11713
11921
  if (install.ok) {
11714
11922
  s2.stop("Dependencies installed");
11715
11923
  break;
@@ -11879,7 +12087,7 @@ one of two things, and neither is your server (it is running on :3001):
11879
12087
  );
11880
12088
  }
11881
12089
  registerInstall(
11882
- `OA-${(0, import_node_crypto5.randomUUID)()}`,
12090
+ `OA-${(0, import_node_crypto6.randomUUID)()}`,
11883
12091
  pid,
11884
12092
  secret,
11885
12093
  platform,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneaddress/setup",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Interactive setup wizard for OneAddress partner webhook integrations",
5
5
  "main": "dist/index.js",
6
6
  "bin": {