@oneaddress/setup 2.7.0 → 2.9.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 +450 -55
  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.7.0" : "?";
859
+ var WIZARD_VERSION = true ? "2.9.0" : "?";
860
860
  function printCompactHeader() {
861
861
  const INNER = 42;
862
862
  const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
@@ -984,6 +984,7 @@ data.db-shm
984
984
  "build": "tsup src/index.ts --format esm --no-dts --outDir dist",
985
985
  "type-check": "tsc --noEmit",
986
986
  "show": "tsx scripts/show.ts",
987
+ "audit": "tsx scripts/audit.ts",
987
988
  "test": "tsx scripts/test.ts"
988
989
  },
989
990
  "dependencies": {
@@ -1023,9 +1024,30 @@ data.db-shm
1023
1024
  content: `{
1024
1025
  "//": "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
1026
  "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" } }
1027
+ { "account_number": "AUR-583920", "name": "Tim Hooper", "address": { "street": "12 Old Mill Road", "suburb": "Parramatta", "state": "NSW", "postcode": "2150" } },
1028
+ { "account_number": "AUR-104772", "name": "Olivia Robinson", "address": { "street": "5 Rosewood Lane", "suburb": "Brisbane", "state": "QLD", "postcode": "4000" } },
1029
+ { "account_number": "AUR-296815", "name": "Marcus Chen", "address": { "street": "31 Goldfield Drive", "suburb": "Richmond", "state": "VIC", "postcode": "3121" } },
1030
+ { "account_number": "AUR-337401", "name": "Priya Raghavan", "address": { "street": "8 Kurrajong Street", "suburb": "Newtown", "state": "NSW", "postcode": "2042" } },
1031
+ { "account_number": "AUR-418266", "name": "Daniel O'Sullivan", "address": { "street": "204/19 Marine Parade", "suburb": "Glenelg", "state": "SA", "postcode": "5045" } },
1032
+ { "account_number": "AUR-452093", "name": "Amara Okafor", "address": { "street": "77 Beaufort Street", "suburb": "Mount Lawley", "state": "WA", "postcode": "6050" } },
1033
+ { "account_number": "AUR-509118", "name": "Hannah Whitlock", "address": { "street": "3 Tasman Close", "suburb": "Sandy Bay", "state": "TAS", "postcode": "7005" } },
1034
+ { "account_number": "AUR-563740", "name": "Joshua Nguyen", "address": { "street": "145 Given Terrace", "suburb": "Paddington", "state": "QLD", "postcode": "4064" } },
1035
+ { "account_number": "AUR-617205", "name": "Isabella Moretti", "address": { "street": "62 Lygon Street", "suburb": "Carlton", "state": "VIC", "postcode": "3053" } },
1036
+ { "account_number": "AUR-668931", "name": "Samuel Achterberg", "address": { "street": "11 Hutt Street", "suburb": "Adelaide", "state": "SA", "postcode": "5000" } },
1037
+ { "account_number": "AUR-702884", "name": "Grace Tupou", "address": { "street": "9 Kingsford Smith Way", "suburb": "Belconnen", "state": "ACT", "postcode": "2617" } },
1038
+ { "account_number": "AUR-745160", "name": "Ethan Marsh", "address": { "street": "38 Smith Street", "suburb": "Darwin City", "state": "NT", "postcode": "0800" } },
1039
+ { "account_number": "AUR-781422", "name": "Chloe Bennett", "address": { "street": "26 Rundle Street", "suburb": "Kent Town", "state": "SA", "postcode": "5067" } },
1040
+ { "account_number": "AUR-816559", "name": "Liam Fitzgerald", "address": { "street": "410/88 Queen Street", "suburb": "Melbourne", "state": "VIC", "postcode": "3000" } },
1041
+ { "account_number": "AUR-849073", "name": "Sofia Delgado", "address": { "street": "17 Cavill Avenue", "suburb": "Surfers Paradise", "state": "QLD", "postcode": "4217" } },
1042
+ { "account_number": "AUR-873318", "name": "Noah Kirkwood", "address": { "street": "54 Hunter Street", "suburb": "Newcastle", "state": "NSW", "postcode": "2300" } },
1043
+ { "account_number": "AUR-905642", "name": "Mei Lin Tan", "address": { "street": "2/33 Rokeby Road", "suburb": "Subiaco", "state": "WA", "postcode": "6008" } },
1044
+ { "account_number": "AUR-938177", "name": "Jack Rasmussen", "address": { "street": "6 Elizabeth Street", "suburb": "Launceston", "state": "TAS", "postcode": "7250" } },
1045
+ { "account_number": "AUR-960845", "name": "Aaliyah Bennett", "address": { "street": "82 Charles Street", "suburb": "Northcote", "state": "VIC", "postcode": "3070" } },
1046
+ { "account_number": "AUR-984312", "name": "Oscar Petrov", "address": { "street": "19 Macquarie Street", "suburb": "Hobart", "state": "TAS", "postcode": "7000" } },
1047
+ { "account_number": "AUR-991026", "name": "Ruby Anderson", "address": { "street": "301/5 Cordelia Street", "suburb": "South Brisbane", "state": "QLD", "postcode": "4101" } },
1048
+ { "account_number": "AUR-995518", "name": "Henry Abebe", "address": { "street": "14 Wakefield Street", "suburb": "Kent Town", "state": "SA", "postcode": "5067" } },
1049
+ { "account_number": "AUR-997430", "name": "Zoe Castellano", "address": { "street": "67 Bay Street", "suburb": "Port Melbourne", "state": "VIC", "postcode": "3207" } },
1050
+ { "account_number": "AUR-999201", "name": "Felix Nakamura", "address": { "street": "23 Anzac Parade", "suburb": "Kensington", "state": "NSW", "postcode": "2033" } }
1029
1051
  ]
1030
1052
  }
1031
1053
  `
@@ -4903,6 +4925,24 @@ db.exec(\`
4903
4925
  -- rather than only what it became.
4904
4926
  prev_address TEXT NOT NULL DEFAULT '{}',
4905
4927
  address TEXT NOT NULL,
4928
+ -- ON WHOSE AUTHORITY. The reference of the signed authorisation that
4929
+ -- produced this change, and the dispatch it arrived under.
4930
+ --
4931
+ -- WHY THEY ARE HERE AND NOT ONLY ON THE DASHBOARD. Both were computed on
4932
+ -- arrival and shown on the LAST CHANGE panel, and nowhere else: a second
4933
+ -- dispatch overwrote the panel and a restart cleared it. So "on whose
4934
+ -- authority did you change this customer's address on the 15th" could not
4935
+ -- be answered from this receiver at all, which is the first question an
4936
+ -- inspection asks about an applied change.
4937
+ --
4938
+ -- NOT ENCRYPTED, deliberately, unlike the addresses beside them. Neither is
4939
+ -- personal information: the reference is a digest of an authorisation and
4940
+ -- the dispatch id is an integer. Encrypting them would make the audit trail
4941
+ -- unreadable without the password in exactly the situation it exists for,
4942
+ -- and would buy nothing, since an attacker who can read this file can read
4943
+ -- the addresses too once they have what they came for.
4944
+ loa_ref TEXT,
4945
+ dispatch_id TEXT,
4906
4946
  recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
4907
4947
  );
4908
4948
  CREATE INDEX IF NOT EXISTS idx_history_account ON address_history(account_key, id DESC);
@@ -4918,6 +4958,11 @@ ensureColumn('customers', 'updated_at', 'TEXT'); // nullable on migrate; set on
4918
4958
  ensureColumn('customers', 'account_key', 'TEXT');
4919
4959
  ensureColumn('address_history', 'account_key', 'TEXT');
4920
4960
  ensureColumn('address_history', 'prev_address', "TEXT NOT NULL DEFAULT '{}'");
4961
+ // NULLABLE WITH NO DEFAULT, because a row written before this column existed
4962
+ // genuinely has no authority recorded and must not claim one. Null reads as
4963
+ // "not recorded", which is the truth; a default would read as a value.
4964
+ ensureColumn('address_history', 'loa_ref', 'TEXT');
4965
+ ensureColumn('address_history', 'dispatch_id', 'TEXT');
4921
4966
 
4922
4967
  // A database written before at-rest encryption existed holds plaintext rows and
4923
4968
  // no account_key. Backfill the key and encrypt in place.
@@ -5010,9 +5055,30 @@ type RosterEntry = { account_number: string; name: string; address: Address };
5010
5055
  // runs out of the box. The wizard writes customers.json with these same demo
5011
5056
  // customers \u2014 edit that file, not this list.
5012
5057
  const DEFAULT_ROSTER: RosterEntry[] = [
5013
- { account_number: 'AUR-583920', name: 'Tim Hooper', address: { street: '12 Old Mill Road', suburb: 'Parramatta', state: 'NSW', postcode: '2150' } },
5014
- { account_number: 'AUR-104772', name: 'Olivia Robinson', address: { street: '5 Rosewood Lane', suburb: 'Brisbane', state: 'QLD', postcode: '4000' } },
5015
- { account_number: 'AUR-296815', name: 'Marcus Chen', address: { street: '31 Goldfield Drive', suburb: 'Richmond', state: 'VIC', postcode: '3121' } },
5058
+ { account_number: 'AUR-583920', name: 'Tim Hooper', address: { street: '12 Old Mill Road', suburb: 'Parramatta', state: 'NSW', postcode: '2150' } },
5059
+ { account_number: 'AUR-104772', name: 'Olivia Robinson', address: { street: '5 Rosewood Lane', suburb: 'Brisbane', state: 'QLD', postcode: '4000' } },
5060
+ { account_number: 'AUR-296815', name: 'Marcus Chen', address: { street: '31 Goldfield Drive', suburb: 'Richmond', state: 'VIC', postcode: '3121' } },
5061
+ { account_number: 'AUR-337401', name: 'Priya Raghavan', address: { street: '8 Kurrajong Street', suburb: 'Newtown', state: 'NSW', postcode: '2042' } },
5062
+ { account_number: 'AUR-418266', name: 'Daniel O\\'Sullivan', address: { street: '204/19 Marine Parade', suburb: 'Glenelg', state: 'SA', postcode: '5045' } },
5063
+ { account_number: 'AUR-452093', name: 'Amara Okafor', address: { street: '77 Beaufort Street', suburb: 'Mount Lawley', state: 'WA', postcode: '6050' } },
5064
+ { account_number: 'AUR-509118', name: 'Hannah Whitlock', address: { street: '3 Tasman Close', suburb: 'Sandy Bay', state: 'TAS', postcode: '7005' } },
5065
+ { account_number: 'AUR-563740', name: 'Joshua Nguyen', address: { street: '145 Given Terrace', suburb: 'Paddington', state: 'QLD', postcode: '4064' } },
5066
+ { account_number: 'AUR-617205', name: 'Isabella Moretti', address: { street: '62 Lygon Street', suburb: 'Carlton', state: 'VIC', postcode: '3053' } },
5067
+ { account_number: 'AUR-668931', name: 'Samuel Achterberg', address: { street: '11 Hutt Street', suburb: 'Adelaide', state: 'SA', postcode: '5000' } },
5068
+ { account_number: 'AUR-702884', name: 'Grace Tupou', address: { street: '9 Kingsford Smith Way', suburb: 'Belconnen', state: 'ACT', postcode: '2617' } },
5069
+ { account_number: 'AUR-745160', name: 'Ethan Marsh', address: { street: '38 Smith Street', suburb: 'Darwin City', state: 'NT', postcode: '0800' } },
5070
+ { account_number: 'AUR-781422', name: 'Chloe Bennett', address: { street: '26 Rundle Street', suburb: 'Kent Town', state: 'SA', postcode: '5067' } },
5071
+ { account_number: 'AUR-816559', name: 'Liam Fitzgerald', address: { street: '410/88 Queen Street', suburb: 'Melbourne', state: 'VIC', postcode: '3000' } },
5072
+ { account_number: 'AUR-849073', name: 'Sofia Delgado', address: { street: '17 Cavill Avenue', suburb: 'Surfers Paradise', state: 'QLD', postcode: '4217' } },
5073
+ { account_number: 'AUR-873318', name: 'Noah Kirkwood', address: { street: '54 Hunter Street', suburb: 'Newcastle', state: 'NSW', postcode: '2300' } },
5074
+ { account_number: 'AUR-905642', name: 'Mei Lin Tan', address: { street: '2/33 Rokeby Road', suburb: 'Subiaco', state: 'WA', postcode: '6008' } },
5075
+ { account_number: 'AUR-938177', name: 'Jack Rasmussen', address: { street: '6 Elizabeth Street', suburb: 'Launceston', state: 'TAS', postcode: '7250' } },
5076
+ { account_number: 'AUR-960845', name: 'Aaliyah Bennett', address: { street: '82 Charles Street', suburb: 'Northcote', state: 'VIC', postcode: '3070' } },
5077
+ { account_number: 'AUR-984312', name: 'Oscar Petrov', address: { street: '19 Macquarie Street', suburb: 'Hobart', state: 'TAS', postcode: '7000' } },
5078
+ { account_number: 'AUR-991026', name: 'Ruby Anderson', address: { street: '301/5 Cordelia Street', suburb: 'South Brisbane', state: 'QLD', postcode: '4101' } },
5079
+ { account_number: 'AUR-995518', name: 'Henry Abebe', address: { street: '14 Wakefield Street', suburb: 'Kent Town', state: 'SA', postcode: '5067' } },
5080
+ { account_number: 'AUR-997430', name: 'Zoe Castellano', address: { street: '67 Bay Street', suburb: 'Port Melbourne', state: 'VIC', postcode: '3207' } },
5081
+ { account_number: 'AUR-999201', name: 'Felix Nakamura', address: { street: '23 Anzac Parade', suburb: 'Kensington', state: 'NSW', postcode: '2033' } },
5016
5082
  ];
5017
5083
 
5018
5084
  function loadRoster(): RosterEntry[] {
@@ -5145,6 +5211,10 @@ export interface AddressChange {
5145
5211
  name: string;
5146
5212
  prev_address: Address | null;
5147
5213
  address: Address;
5214
+ /** The authorisation this change was made under. Null on an older row. */
5215
+ loa_ref: string | null;
5216
+ /** The dispatch it arrived under. Null on an older row. */
5217
+ dispatch_id: string | null;
5148
5218
  recorded_at: string;
5149
5219
  }
5150
5220
 
@@ -5171,6 +5241,8 @@ export function addressHistory(limit = 20): AddressChange[] {
5171
5241
  const rows = db.prepare(
5172
5242
  \`SELECT h.prev_address AS prev_address,
5173
5243
  h.address AS address,
5244
+ h.loa_ref AS loa_ref,
5245
+ h.dispatch_id AS dispatch_id,
5174
5246
  h.recorded_at AS recorded_at,
5175
5247
  c.account_number AS account_number,
5176
5248
  c.name AS name
@@ -5194,11 +5266,62 @@ export function addressHistory(limit = 20): AddressChange[] {
5194
5266
  name: dec('name', r.name) ?? '(unknown)',
5195
5267
  prev_address: prev,
5196
5268
  address: JSON.parse(dec('address', r.address) ?? '{}') as Address,
5269
+ // NOT decrypted, because they were never encrypted. See the schema.
5270
+ loa_ref: r.loa_ref ?? null,
5271
+ dispatch_id: r.dispatch_id ?? null,
5197
5272
  recorded_at: r.recorded_at ?? '',
5198
5273
  };
5199
5274
  });
5200
5275
  }
5201
5276
 
5277
+ /**
5278
+ * Drop applied-change history older than \`days\`. Returns how many rows went.
5279
+ *
5280
+ * ## Why this exists, and why it is NOT wired to a timer
5281
+ *
5282
+ * The quarantine and the confirm queue both age out on their own, because both
5283
+ * hold work in progress and an unbounded hold is a retention decision made on
5284
+ * a consumer's behalf by nobody. \`address_history\` is different: it is the
5285
+ * AUDIT TRAIL, and the same argument runs the other way. Deleting the record
5286
+ * of a change you applied is not hygiene, it is losing the answer to "on whose
5287
+ * authority", which is the first thing an inspection asks.
5288
+ *
5289
+ * So the default is to keep it, and that default is now a DECISION rather than
5290
+ * an omission - which is the only thing that changed here. Before this
5291
+ * function existed, keeping it forever was simply what happened, and a partner
5292
+ * whose own retention policy required deletion had no lever to pull at all.
5293
+ *
5294
+ * ## If you do call it
5295
+ *
5296
+ * Your retention obligation and your evidentiary one point in opposite
5297
+ * directions and only you know which governs your business. Australian
5298
+ * financial-services records commonly run seven years; APP 11.2 says destroy
5299
+ * or de-identify personal information you no longer need. Those are compatible
5300
+ * only once someone decides what "no longer need" means here, and that someone
5301
+ * is not this file.
5302
+ *
5303
+ * Nothing calls this by default. Wire it to your own schedule, or run it by
5304
+ * hand, deliberately.
5305
+ */
5306
+ export function purgeAddressHistory(days: number): number {
5307
+ if (!Number.isFinite(days) || days <= 0) return 0;
5308
+ // THE COMPARISON HAPPENS INSIDE SQLITE, in SQLite's own datetime format,
5309
+ // for the same reason \`purgeQuarantine\` does it: \`recorded_at\` is written by
5310
+ // the column default \`datetime('now')\`, and comparing that to a JavaScript
5311
+ // ISO string works right up until it does not.
5312
+ const info = db.prepare(
5313
+ \`DELETE FROM address_history WHERE recorded_at < datetime('now', ?)\`,
5314
+ ).run(\`-\${Math.floor(days)} days\`);
5315
+ const n = Number(info.changes ?? 0);
5316
+ if (n > 0) {
5317
+ report.warn(
5318
+ \`[store] purged \${n} applied-change record(s) older than \${Math.floor(days)}d. \` +
5319
+ 'That is audit history and it is gone.',
5320
+ );
5321
+ }
5322
+ return n;
5323
+ }
5324
+
5202
5325
  /** Is the file on disk protected? Surfaced in the dashboard, in both states. */
5203
5326
  export const storeEncrypted = encrypted;
5204
5327
 
@@ -5259,7 +5382,18 @@ export async function verifyAddress(customer: Customer, incoming: Address): Prom
5259
5382
  * ours \u2014 but be aware two customers who share a name share a row. If that is
5260
5383
  * possible in your data, give this a key of your own instead.
5261
5384
  */
5262
- export async function saveAddress(customer: Customer, incoming: Address): Promise<Address> {
5385
+ export async function saveAddress(
5386
+ customer: Customer,
5387
+ incoming: Address,
5388
+ /**
5389
+ * On whose authority, recorded ALONGSIDE the change rather than beside it.
5390
+ *
5391
+ * Optional so an existing caller compiles, and the absence is honest: a row
5392
+ * written without it says "not recorded" rather than claiming an authority
5393
+ * it never had.
5394
+ */
5395
+ provenance?: { loaRef?: string | null; dispatchId?: string | null },
5396
+ ): Promise<Address> {
5263
5397
  const acct = (customer.accountNumber ?? '').trim() || customer.name.trim();
5264
5398
  const addressJson = JSON.stringify(incoming);
5265
5399
  const key = accountKey(acct);
@@ -5287,12 +5421,14 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
5287
5421
  });
5288
5422
 
5289
5423
  db.prepare(\`
5290
- INSERT INTO address_history (account_key, prev_address, address)
5291
- VALUES ($account_key, $prev_address, $address)
5424
+ INSERT INTO address_history (account_key, prev_address, address, loa_ref, dispatch_id)
5425
+ VALUES ($account_key, $prev_address, $address, $loa_ref, $dispatch_id)
5292
5426
  \`).run({
5293
5427
  account_key: key,
5294
5428
  prev_address: enc('address', JSON.stringify(previous)),
5295
5429
  address: enc('address', addressJson),
5430
+ loa_ref: provenance?.loaRef ?? null,
5431
+ dispatch_id: provenance?.dispatchId ?? null,
5296
5432
  });
5297
5433
 
5298
5434
  // Metadata only \u2014 the address itself is personal information, so the key is
@@ -5344,8 +5480,8 @@ export const store = {
5344
5480
  verifyAddress,
5345
5481
  saveAddress,
5346
5482
  find: findByAccount,
5347
- // Three rows, so a count is free. A store over a real customer table should
5348
- // return null here; the contract file says why.
5483
+ // A demo roster, so a count is free. A store over a real customer table
5484
+ // should return null here; the contract file says why.
5349
5485
  count: customerCount,
5350
5486
  } satisfies CustomerStore;
5351
5487
  `
@@ -6144,7 +6280,10 @@ app.post('/webhook', async (req: Request, res: Response) => {
6144
6280
  // than marking test dispatches as safe to display: a marker is something an
6145
6281
  // attacker can try to forge onto a real dispatch, and a panel that never
6146
6282
  // renders an address has nothing to forge it into.
6147
- const replaced = await store.saveAddress(ctx, address);
6283
+ // PROVENANCE GOES IN WITH THE CHANGE. Both values were already computed
6284
+ // here and went only to the dashboard panel, which one more dispatch or one
6285
+ // restart wiped.
6286
+ const replaced = await store.saveAddress(ctx, address, { loaRef, dispatchId: dispatch || null });
6148
6287
  noteChange({
6149
6288
  accountNumber: ctx.accountNumber || '(no account reference)',
6150
6289
  accountChecked: config.verifiesAccountReference,
@@ -6532,6 +6671,135 @@ export async function ask(prompt: string): Promise<string> {
6532
6671
  stdin.on('data', onData);
6533
6672
  });
6534
6673
  }
6674
+ `
6675
+ },
6676
+ {
6677
+ name: "scripts/audit.ts",
6678
+ content: `/**
6679
+ * Export the applied-change record: what changed, when, and on whose authority.
6680
+ *
6681
+ * Usage:
6682
+ * npm run audit \u2014 every applied change, as CSV
6683
+ * npm run audit -- --json \u2014 the same as JSON
6684
+ * npm run audit -- --from 2026-07-01 --to 2026-09-30
6685
+ * npm run audit -- --out C:\\reports\\q3.csv
6686
+ *
6687
+ * ## Why this exists
6688
+ *
6689
+ * \`npm run show\` answers "did it land", for a person, now. This answers "show
6690
+ * me every address change you applied last quarter and what authorised each
6691
+ * one", for somebody who is not you and is not at your desk. Those are
6692
+ * different questions and the second one is the one an inspection asks.
6693
+ *
6694
+ * ## What is in it, and what deliberately is not
6695
+ *
6696
+ * The account reference, the name, both sides of the change, the timestamp, the
6697
+ * LOA reference and the dispatch id. That is the full record of a change this
6698
+ * receiver applied.
6699
+ *
6700
+ * It is NOT redacted, unlike \`npm run show\`, and that is the point: this file
6701
+ * is the thing you hand to an auditor, and a redacted audit trail is not one.
6702
+ * It therefore contains personal information in cleartext the moment it is
6703
+ * written, which is why it writes where you tell it, says where it went, and
6704
+ * says so out loud.
6705
+ *
6706
+ * ## The one thing it cannot do
6707
+ *
6708
+ * It cannot prove the authority is genuine. \`loa_ref\` is recomputed by THIS
6709
+ * receiver from the LOA it decrypted, so it is evidence this receiver saw a
6710
+ * valid authorisation, not a signature anybody else can re-verify from the CSV.
6711
+ * Verifying it independently needs OneAddress's LOA public key, which is a
6712
+ * different exercise and is not what this file claims to be.
6713
+ */
6714
+ import 'dotenv/config';
6715
+ import { writeFileSync } from 'node:fs';
6716
+ import { resolve } from 'node:path';
6717
+ import { config } from '../src/config.js';
6718
+ import { ask, databaseIsLocked } from '../src/unlock.js';
6719
+
6720
+ function arg(name: string): string | null {
6721
+ const i = process.argv.indexOf(\`--\${name}\`);
6722
+ return i >= 0 && process.argv[i + 1] ? String(process.argv[i + 1]) : null;
6723
+ }
6724
+
6725
+ /** CSV with the quoting rules that stop a comma in a street name shifting every column. */
6726
+ function csvCell(v: unknown): string {
6727
+ const s = String(v ?? '');
6728
+ return /[",\\n]/.test(s) ? \`"\${s.replace(/"/g, '""')}"\` : s;
6729
+ }
6730
+
6731
+ function fmtAddress(a: Record<string, string> | null): string {
6732
+ if (!a) return '';
6733
+ return [a.street, a.suburb, a.state, a.postcode, a.country].filter(Boolean).join(', ');
6734
+ }
6735
+
6736
+ async function askPassword(): Promise<void> {
6737
+ if (process.env.ONEADDRESS_DB_PASSPHRASE?.trim()) return;
6738
+ if (!process.stdin.isTTY) return;
6739
+ if (!(await databaseIsLocked())) return;
6740
+ const answer = await ask(' Password to unlock: ');
6741
+ if (answer) process.env.ONEADDRESS_DB_PASSPHRASE = answer;
6742
+ }
6743
+
6744
+ async function main(): Promise<void> {
6745
+ if (config.mode === 'inbox') {
6746
+ console.log('\\n This receiver is in inbox mode: your connector applies changes and holds the record.');
6747
+ console.log(' Export from there instead.\\n');
6748
+ return;
6749
+ }
6750
+
6751
+ await askPassword();
6752
+ const store = await import('../src/store.js');
6753
+
6754
+ const from = arg('from');
6755
+ const to = arg('to');
6756
+ // EVERY row, then filtered here. The store's reader takes a limit rather than
6757
+ // a date range, and a range that silently truncated at 20 would be worse than
6758
+ // useless in an audit file.
6759
+ const all = store.addressHistory(Number.MAX_SAFE_INTEGER);
6760
+ const rows = all.filter((h) => {
6761
+ const at = String(h.recorded_at ?? '');
6762
+ if (from && at < from) return false;
6763
+ // Inclusive of the whole end day: a --to of 2026-09-30 must include changes
6764
+ // applied at 14:00 that day, and 'YYYY-MM-DD HH:MM:SS' > 'YYYY-MM-DD'.
6765
+ if (to && at > \`\${to} 99\`) return false;
6766
+ return true;
6767
+ });
6768
+
6769
+ const asJson = process.argv.includes('--json');
6770
+ const body = asJson
6771
+ ? JSON.stringify({
6772
+ exported_at: new Date().toISOString(),
6773
+ range: { from: from ?? null, to: to ?? null },
6774
+ note: 'Applied address changes recorded by this receiver. loa_ref is recomputed by this receiver from the authorisation it decrypted; verifying it independently needs the OneAddress LOA public key.',
6775
+ changes: rows,
6776
+ }, null, 2)
6777
+ : [
6778
+ ['recorded_at', 'account_number', 'name', 'previous_address', 'new_address', 'loa_ref', 'dispatch_id']
6779
+ .join(','),
6780
+ ...rows.map((h) => [
6781
+ h.recorded_at, h.account_number, h.name,
6782
+ fmtAddress(h.prev_address as unknown as Record<string, string> | null),
6783
+ fmtAddress(h.address as unknown as Record<string, string>),
6784
+ h.loa_ref ?? '', h.dispatch_id ?? '',
6785
+ ].map(csvCell).join(',')),
6786
+ ].join('\\n');
6787
+
6788
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
6789
+ const target = resolve(arg('out') ?? config.exportDir ?? '', arg('out')
6790
+ ? ''
6791
+ : \`oneaddress-audit-\${stamp}.\${asJson ? 'json' : 'csv'}\`);
6792
+ writeFileSync(target, body);
6793
+
6794
+ console.log(\`\\n Exported \${rows.length} applied change(s) to\\n \${target}\\n\`);
6795
+ console.log(' THIS FILE CONTAINS CUSTOMER ADDRESSES IN CLEARTEXT. It is the audit record,');
6796
+ console.log(' so it is deliberately not redacted. Store and send it accordingly.\\n');
6797
+ }
6798
+
6799
+ void main().catch((err: unknown) => {
6800
+ console.error(err instanceof Error ? err.message : String(err));
6801
+ process.exit(1);
6802
+ });
6535
6803
  `
6536
6804
  },
6537
6805
  {
@@ -6540,9 +6808,11 @@ export async function ask(prompt: string): Promise<string> {
6540
6808
  * Show what this receiver holds: the roster, and every change it has applied.
6541
6809
  *
6542
6810
  * Usage:
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
6811
+ * npm run show \u2014 a LIVE branded view, refreshing as changes land
6812
+ * npm run show 100 \u2014 keep 100 changes on screen instead of 20
6813
+ * npm run show -- --reveal \u2014 show street lines (hidden by default)
6814
+ * npm run show > f.txt \u2014 plain one-shot text, for a file or a pipe
6815
+ * npm run audit \u2014 export the applied-change record (see scripts/audit.ts)
6546
6816
  *
6547
6817
  * ## Why this script exists
6548
6818
  *
@@ -6592,10 +6862,32 @@ const DIM = '#8a7f6a';
6592
6862
  /** Escape blessed's tag syntax so a customer's name can never inject markup. */
6593
6863
  const esc = (s: unknown): string => String(s ?? '').replace(/[{}]/g, '');
6594
6864
 
6865
+ /**
6866
+ * REDACTED BY DEFAULT, revealed on purpose.
6867
+ *
6868
+ * This script's whole job is to show what the receiver holds, so refusing to
6869
+ * print addresses would defeat it. But the default run prints EVERY customer's
6870
+ * address into a terminal, where it lands in scrollback, in a \`| tee\` file and
6871
+ * in any screen recording, and the commonest reason to run it is to check one
6872
+ * record or prove one dispatch landed - neither of which needs the other 23.
6873
+ *
6874
+ * So the default shows enough to recognise a record (suburb, state, postcode)
6875
+ * and withholds the street line, and \`--reveal\` prints the lot. The suburb is
6876
+ * deliberately NOT withheld: an audit row you cannot tell apart from the next
6877
+ * one is not an audit row, and the suburb is what makes a change legible as a
6878
+ * change.
6879
+ */
6880
+ const REVEAL = process.argv.includes('--reveal') || process.env.ONEADDRESS_SHOW_REVEAL === '1';
6881
+
6595
6882
  function fmt(a: Record<string, string> | null): string {
6596
6883
  if (!a) return '(nothing on file)';
6597
- const parts = [a.street, a.suburb, a.state, a.postcode, a.country].filter(Boolean);
6598
- return parts.join(', ') || '(empty)';
6884
+ const rest = [a.suburb, a.state, a.postcode, a.country].filter(Boolean).join(', ');
6885
+ if (REVEAL) {
6886
+ const parts = [a.street, a.suburb, a.state, a.postcode, a.country].filter(Boolean);
6887
+ return parts.join(', ') || '(empty)';
6888
+ }
6889
+ if (!a.street) return rest || '(empty)';
6890
+ return \`[street hidden], \${rest}\` || '(empty)';
6599
6891
  }
6600
6892
 
6601
6893
  async function askPassword(): Promise<void> {
@@ -6608,24 +6900,46 @@ async function askPassword(): Promise<void> {
6608
6900
 
6609
6901
  type Store = typeof import('../src/store.js');
6610
6902
 
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
- });
6903
+ /**
6904
+ * Does this row answer the filter?
6905
+ *
6906
+ * NAME OR ACCOUNT NUMBER, because a partner checking whether a dispatch landed
6907
+ * knows the person either way round, and typing "hoop" is faster than finding
6908
+ * the account number to type it exactly.
6909
+ */
6910
+ function matches(query: string, name: string, account: string): boolean {
6911
+ if (!query) return true;
6912
+ const q = query.toLowerCase();
6913
+ return name.toLowerCase().includes(q) || account.toLowerCase().includes(q);
6914
+ }
6915
+
6916
+ /** The roster, as display rows, optionally filtered. */
6917
+ function rosterRows(store: Store, query: string): string[] {
6918
+ return store.allCustomers()
6919
+ .filter((c) => matches(query, c.name, c.account_number))
6920
+ .map((c) => {
6921
+ let addr: Record<string, string> | null = null;
6922
+ try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
6923
+ return \` \${esc(c.account_number).padEnd(14)} \${esc(c.name).padEnd(20)} \${esc(fmt(addr))}\`;
6924
+ });
6618
6925
  }
6619
6926
 
6620
6927
  /** Applied changes, newest first, as display rows with both sides. */
6621
- function changeRows(store: Store, limit: number): string[] {
6928
+ function changeRows(store: Store, limit: number, query: string): string[] {
6622
6929
  const out: string[] = [];
6623
6930
  for (const h of store.addressHistory(limit)) {
6931
+ if (!matches(query, h.name, h.account_number)) continue;
6624
6932
  const prev = h.prev_address as unknown as Record<string, string> | null;
6625
6933
  const now = h.address as unknown as Record<string, string>;
6626
6934
  out.push(\` {\${DIM}-fg}\${esc(h.recorded_at)}{/} {\${CREAM}-fg}\${esc(h.account_number)}{/} \${esc(h.name)}\`);
6627
6935
  out.push(\` {\${DIM}-fg}was:{/} \${esc(fmt(prev))}\`);
6628
6936
  out.push(\` {\${AMBER}-fg}now:{/} \${esc(fmt(now))}\`);
6937
+ // ON WHOSE AUTHORITY, on its own line. Null means the row predates the
6938
+ // column, which is not the same as a change nobody authorised, so it says
6939
+ // so rather than printing an empty field.
6940
+ out.push(h.loa_ref
6941
+ ? \` {\${DIM}-fg}auth:{/} \${esc(h.loa_ref)}\${h.dispatch_id ? \` {\${DIM}-fg}dispatch{/} \${esc(h.dispatch_id)}\` : ''}\`
6942
+ : \` {\${DIM}-fg}auth: not recorded (applied before this receiver kept it){/}\`);
6629
6943
  }
6630
6944
  return out;
6631
6945
  }
@@ -6653,66 +6967,147 @@ function printOnce(store: Store, limit: number): void {
6653
6967
  console.log('');
6654
6968
  }
6655
6969
 
6656
- /** The live view: the mark, the roster, the changes, refreshed as they land. */
6970
+ /**
6971
+ * The live view: the mark, the roster, the changes, refreshed as they land.
6972
+ *
6973
+ * SEARCH IS THE REASON THIS IS NOT JUST A PRINT. A roster of three fits on a
6974
+ * screen and a realistic one does not, so "is my own record right" stopped
6975
+ * being answerable by looking. \`/\` filters both panels at once on name or
6976
+ * account number, because a partner checking a dispatch landed knows the person
6977
+ * either way round.
6978
+ */
6657
6979
  function stream(store: Store, limit: number): void {
6658
- const screen = blessed.screen({ smartCSR: true, title: 'What this receiver holds \u2014 OneAddress' });
6980
+ const screen = blessed.screen({
6981
+ smartCSR: true,
6982
+ title: 'What this receiver holds \u2014 OneAddress',
6983
+ fullUnicode: true,
6984
+ // WITHOUT THIS THE BRAND COMES OUT RED, and it came out red here for a
6985
+ // whole release because this is the SECOND blessed screen in the scaffold
6986
+ // and the fix lived only in the first. blessed resolves a hex colour
6987
+ // against the palette it infers from TERM, which Windows does not set, so
6988
+ // it assumes a tiny palette and snaps #e0a248 to the nearest thing it
6989
+ // believes exists. Naming the capability is telling it the truth.
6990
+ // \`scripts/__tests__\` pins that EVERY screen we open carries this.
6991
+ terminal: process.env.TERM || 'xterm-256color',
6992
+ });
6659
6993
 
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
6994
  const wide = terminalFitsFullMark(Number(screen.width));
6663
- const markHeight = wide ? 9 : 3;
6995
+ // THE MARK, PLUS A LINE SAYING WHICH SCREEN THIS IS. The dashboard and this
6996
+ // viewer open with the same wordmark, so without a subheader a screenshot of
6997
+ // one is indistinguishable from the other - and they answer different
6998
+ // questions. The dashboard is "is it running"; this is "what does it hold".
6999
+ const markHeight = (wide ? 9 : 3) + 1;
6664
7000
  blessed.box({
6665
7001
  parent: screen, top: 0, left: 0, width: '100%', height: markHeight,
6666
7002
  tags: true, padding: { left: 2 },
6667
- content: wide
7003
+ content: (wide
6668
7004
  ? 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}{/}\`,
7005
+ : \`{\${CREAM}-fg}{bold}One{/bold}{/}{\${AMBER}-fg}{bold}Address{/bold}{/}\`)
7006
+ + \`\\n{\${DIM}-fg}WHAT THIS RECEIVER HOLDS{/}\`,
6670
7007
  });
6671
7008
 
7009
+ // THE ROSTER IS BOUNDED AND SCROLLS. It used to size itself to its contents,
7010
+ // which was fine for three customers and pushes the changes panel off the
7011
+ // bottom of the screen for a realistic roster.
7012
+ const usable = Math.max(8, Number(screen.height) - markHeight - 1);
7013
+ const rosterHeight = Math.max(5, Math.min(Math.floor(usable * 0.45), usable - 5));
7014
+
6672
7015
  const rosterBox = blessed.box({
6673
- parent: screen, top: markHeight, left: 0, width: '100%', height: 'shrink',
6674
- tags: true, padding: { left: 1, right: 1 },
7016
+ parent: screen, top: markHeight, left: 0, width: '100%', height: rosterHeight,
7017
+ tags: true, padding: { left: 1, right: 1 }, scrollable: true, alwaysScroll: true,
6675
7018
  border: { type: 'line' }, style: { border: { fg: AMBER } },
6676
7019
  label: ' ON FILE ',
6677
7020
  });
6678
7021
 
6679
7022
  const changesBox = blessed.box({
6680
- parent: screen, top: markHeight + 3, left: 0, width: '100%', bottom: 1,
7023
+ parent: screen, top: markHeight + rosterHeight, left: 0, width: '100%', bottom: 1,
6681
7024
  tags: true, padding: { left: 1, right: 1 }, scrollable: true, alwaysScroll: true,
6682
7025
  border: { type: 'line' }, style: { border: { fg: AMBER } },
6683
7026
  label: ' APPLIED CHANGES \u2014 newest first ',
6684
7027
  });
6685
7028
 
6686
- blessed.box({
7029
+ const footer = blessed.box({
6687
7030
  parent: screen, bottom: 0, left: 0, width: '100%', height: 1,
6688
7031
  tags: true, padding: { left: 2 },
6689
- content: \`{\${DIM}-fg}live \u2014 updates as dispatches land{/} {\${CREAM}-fg}[q]{/} quit\`,
6690
7032
  });
6691
7033
 
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.
7034
+ let query = '';
6695
7035
  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;
7036
+
7037
+ const drawFooter = (): void => {
7038
+ footer.setContent(query
7039
+ ? \`{\${AMBER}-fg}filter: \${esc(query)}{/} {\${CREAM}-fg}[/]{/} change {\${CREAM}-fg}[esc]{/} clear {\${CREAM}-fg}[q]{/} quit\`
7040
+ : \`{\${DIM}-fg}live\${REVEAL ? '' : ' \u2014 streets hidden, --reveal to show'}{/} {\${CREAM}-fg}[/]{/} search {\${CREAM}-fg}[q]{/} quit\`);
7041
+ };
7042
+
7043
+ const tick = (force = false): void => {
7044
+ const all = rosterRows(store, '');
7045
+ const roster = rosterRows(store, query);
7046
+ const changes = changeRows(store, limit, query);
7047
+ const next = \`\${query}\\u0000\${roster.join('\\n')}\\u0000\${changes.join('\\n')}\`;
7048
+ if (next === last && !force) return;
6701
7049
  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.');
7050
+
7051
+ rosterBox.setLabel(query
7052
+ ? \` ON FILE \u2014 \${roster.length} of \${all.length} matching "\${esc(query)}" \`
7053
+ : \` ON FILE \u2014 \${all.length} \`);
7054
+ rosterBox.setContent(roster.join('\\n') ||
7055
+ (query ? \` Nobody matching "\${esc(query)}".\` : ' (nobody on file yet)'));
7056
+
7057
+ changesBox.setLabel(query
7058
+ ? \` APPLIED CHANGES \u2014 matching "\${esc(query)}", newest first \`
7059
+ : ' APPLIED CHANGES \u2014 newest first ');
7060
+ changesBox.setContent(changes.join('\\n') || (query
7061
+ ? \` No applied change for "\${esc(query)}" yet.\`
7062
+ : ' None yet. Send a dispatch and this fills in.'));
7063
+
7064
+ drawFooter();
6706
7065
  screen.render();
6707
7066
  };
6708
7067
 
6709
- screen.key(['q', 'C-c', 'escape'], () => { screen.destroy(); process.exit(0); });
6710
- tick();
6711
- screen.render();
7068
+ // \`/\` OPENS A LIVE FILTER. It filters as you type rather than on Enter,
7069
+ // because the question being asked is "is my record in here", and the answer
7070
+ // is usually visible three characters in.
7071
+ let searching = false;
7072
+ const openSearch = (): void => {
7073
+ searching = true;
7074
+ const box = blessed.textbox({
7075
+ parent: screen, bottom: 0, left: 0, width: '100%', height: 1,
7076
+ style: { fg: CREAM }, inputOnFocus: true, keys: true,
7077
+ });
7078
+ box.setValue(query);
7079
+ // The filter must update on every keystroke, and \`readInput\` only reports
7080
+ // the final value, so the live half is driven off keypress.
7081
+ box.on('keypress', () => {
7082
+ setImmediate(() => { query = String(box.getValue() ?? '').trim(); tick(); });
7083
+ });
7084
+ screen.render();
7085
+ box.readInput(() => {
7086
+ searching = false;
7087
+ query = String(box.getValue() ?? '').trim();
7088
+ box.destroy();
7089
+ tick(true);
7090
+ });
7091
+ };
7092
+
7093
+ screen.key(['/'], () => { if (!searching) openSearch(); });
7094
+ screen.key(['escape'], () => {
7095
+ if (searching) return;
7096
+ if (!query) return;
7097
+ query = '';
7098
+ tick(true);
7099
+ });
7100
+ screen.key(['q', 'C-c'], () => {
7101
+ if (searching) return;
7102
+ screen.destroy();
7103
+ process.exit(0);
7104
+ });
7105
+
7106
+ tick(true);
6712
7107
  // DELIBERATELY NOT unref'd. An unref'd timer does not hold the event loop
6713
7108
  // open, and with the screen drawn there is nothing else pending, so the
6714
7109
  // process would exit the instant it finished painting.
6715
- setInterval(tick, 1000);
7110
+ setInterval(() => tick(), 1000);
6716
7111
  }
6717
7112
 
6718
7113
  async function main(): Promise<void> {
@@ -12107,7 +12502,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
12107
12502
 
12108
12503
  // src/register.ts
12109
12504
  var import_node_crypto2 = require("crypto");
12110
- var PKG_VERSION = true ? "2.7.0" : "dev";
12505
+ var PKG_VERSION = true ? "2.9.0" : "dev";
12111
12506
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
12112
12507
  function hmacSha256(secret, message) {
12113
12508
  return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneaddress/setup",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "Interactive setup wizard for OneAddress partner webhook integrations",
5
5
  "main": "dist/index.js",
6
6
  "bin": {