@oneaddress/setup 2.1.3 → 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 +1533 -195
  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.1.3" : "?";
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
  },
@@ -1087,6 +1089,7 @@ data.db-shm
1087
1089
  */
1088
1090
  import { DatabaseSync } from 'node:sqlite';
1089
1091
  import { join } from 'node:path';
1092
+ import { report } from './report.js';
1090
1093
  import {
1091
1094
  accountIndex,
1092
1095
  buildVerifier,
@@ -1229,6 +1232,50 @@ export { PassphraseRequiredError, WrongPassphraseError };
1229
1232
  * separates them.
1230
1233
  */
1231
1234
  export { isEncrypted };
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
+
1264
+ /**
1265
+ * Add a column to an existing table if it is not already there.
1266
+ *
1267
+ * SQLite has no \`ADD COLUMN IF NOT EXISTS\`, and a receiver that has been
1268
+ * running since before a column existed still has to start. Lives here rather
1269
+ * than in one of the two files that needs it, because the second copy is how
1270
+ * the two drift.
1271
+ */
1272
+ export function ensureColumn(table: string, column: string, definition: string): void {
1273
+ const cols = db.prepare(\`PRAGMA table_info(\${table})\`).all() as Array<{ name: string }>;
1274
+ if (cols.some((c) => c.name === column)) return;
1275
+ db.exec(\`ALTER TABLE \${table} ADD COLUMN \${column} \${definition}\`);
1276
+ report.info(\`[db] migrated: added column \${table}.\${column}\`);
1277
+ }
1278
+
1232
1279
  export default db;
1233
1280
  `
1234
1281
  },
@@ -1663,9 +1710,8 @@ import { formatLine, report, type ReportLine } from './report.js';
1663
1710
  // One import, same as server.ts. Swapping the store swaps what the dashboard
1664
1711
  // reads, with nothing here to change.
1665
1712
  import { store } from './store.js';
1666
- import type { StoredCustomer } from './customer-store.js';
1667
1713
  import { pendingConfirmCount } from './confirm-queue.js';
1668
- import { heldCount, heldSummary } from './quarantine.js';
1714
+ import { exportHeld, heldCount, heldSummary } from './quarantine.js';
1669
1715
 
1670
1716
  /** blessed takes colours as strings; these mirror the site's palette. */
1671
1717
  const AMBER = HEX.amber.toLowerCase();
@@ -1684,19 +1730,50 @@ export interface TuiOptions {
1684
1730
  * Re-apply everything the receiver could not open, bound to [r].
1685
1731
  *
1686
1732
  * Passed IN rather than imported: \`server.ts\` already imports this file for
1687
- * \`notePreviousAddress\`, so importing it back would be a cycle. Optional so
1733
+ * \`noteChange\`, so importing it back would be a cycle. Optional so
1688
1734
  * the dashboard still renders for a caller that has no replay to offer.
1689
1735
  */
1690
1736
  onReplay?: () => Promise<{ applied: number; failed: number }>;
1737
+ /**
1738
+ * How the dispatches went, asked of the receiver rather than counted here.
1739
+ *
1740
+ * Same reason as \`onReplay\`: \`server.ts\` already imports this file, so this
1741
+ * file cannot import it back. Optional, and a caller that omits it gets
1742
+ * zeroes, which is honest for a dashboard driving nothing.
1743
+ */
1744
+ stats?: () => {
1745
+ received: number;
1746
+ applied: number;
1747
+ failed: number;
1748
+ mode: string;
1749
+ awaitingConnector: number;
1750
+ oldestUndrawn: string | null;
1751
+ };
1691
1752
  }
1692
1753
 
1693
- /** One address as a single line, the way the change panel shows it. */
1694
- function oneLine(a: Record<string, unknown>): string {
1695
- const parts = [a.street, a.suburb, a.state, a.postcode].filter(Boolean).map(String);
1696
- 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;
1697
1774
  }
1698
1775
 
1699
- export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptions): void {
1776
+ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: TuiOptions): void {
1700
1777
  const screen = blessed.screen({
1701
1778
  smartCSR: true,
1702
1779
  title: \`\${partnerName} \u2014 OneAddress receiver\`,
@@ -1758,7 +1835,9 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1758
1835
  // is the other case: a permanent empty box teaches the eye to skip that
1759
1836
  // region, which is precisely the region that has to be noticed the one day it
1760
1837
  // fills. So it appears, and the two panels above give up the rows.
1761
- const FAULT_HEIGHT = 7;
1838
+ // Six, not seven. Every row this takes comes off the two panels above it, and
1839
+ // the one above left cannot afford to lose any: see renderChange.
1840
+ const FAULT_HEIGHT = 6;
1762
1841
  const faultBox = blessed.box({
1763
1842
  parent: screen, bottom: 3, left: 0, width: '100%', height: FAULT_HEIGHT,
1764
1843
  label: ' FAULTS ', tags: true, padding: { left: 1, right: 1 }, hidden: true,
@@ -1774,24 +1853,28 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1774
1853
  });
1775
1854
 
1776
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
1777
- let received = 0;
1778
- let applied = 0;
1779
- let failed = 0;
1780
- let lastChange: { customer: StoredCustomer; previous: Record<string, unknown> } | null = null;
1781
- let pendingPrevious: Record<string, unknown> = {};
1856
+ let lastChange: ChangeFacts | null = null;
1782
1857
 
1783
- // The server hands over the address a dispatch REPLACED. Kept as state here
1784
- // rather than pushed through \`report\`, because the previous address is data
1785
- // rather than narration and must never end up in a log line.
1786
- 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(); };
1787
1863
 
1788
1864
  function renderStatus(): void {
1789
1865
  // Both states are shown, and the unprotected one is the loud colour. A
1790
1866
  // security property mentioned only when it holds is one nobody notices the
1791
1867
  // absence of.
1792
- const vault = store.encrypted
1793
- ? \`{green-fg}{bold}ENCRYPTED{/}\`
1794
- : \`{red-fg}{bold}UNENCRYPTED{/}\`;
1868
+ const inbox = statsMode() === 'inbox';
1869
+ // IN INBOX MODE THERE IS NO CUSTOMER FILE HERE AT ALL, so neither ENCRYPTED
1870
+ // nor UNENCRYPTED is true and both would mislead. The receiver holds
1871
+ // ciphertext it cannot open; the customers live in the partner's own
1872
+ // database, behind their own controls.
1873
+ const vault = inbox
1874
+ ? \`{\${AMBER}-fg}{bold}NONE (inbox){/}\`
1875
+ : store.encrypted
1876
+ ? \`{green-fg}{bold}ENCRYPTED{/}\`
1877
+ : \`{red-fg}{bold}UNENCRYPTED{/}\`;
1795
1878
  status.setContent(
1796
1879
  \`{\${AMBER}-fg}{bold}\${esc(partnerName)}{/} \` +
1797
1880
  \`{\${DIM}-fg}listening{/} {\${CREAM}-fg}:\${port}/webhook{/} \` +
@@ -1808,19 +1891,36 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1808
1891
  if (!lastChange) {
1809
1892
  changeBox.setContent(
1810
1893
  \`\\n {\${DIM}-fg}Waiting for a dispatch.{/}\\n\\n\` +
1811
- \` {\${DIM}-fg}When one arrives, the address it replaced{/}\\n\` +
1812
- \` {\${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.{/}\`,
1813
1896
  );
1814
1897
  return;
1815
1898
  }
1816
- const { customer, previous } = lastChange;
1817
- let now: Record<string, unknown> = {};
1818
- try { now = JSON.parse(customer.address) as Record<string, unknown>; } catch { /* keep empty */ }
1899
+ const f = lastChange;
1900
+ // WHAT HAPPENED, NOT WHAT IT SAYS.
1901
+ //
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.
1908
+ //
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{/}\`;
1819
1919
  changeBox.setContent(
1820
- \`\\n {bold}\${esc(customer.name)}{/bold}\\n\` +
1821
- \` {\${DIM}-fg}account{/} {\${AMBER}-fg}\${esc(customer.account_number)}{/}\\n\\n\` +
1822
- \` {red-fg}was{/} \${esc(oneLine(previous))}\\n\\n\` +
1823
- \` {green-fg}now{/} {\${CREAM}-fg}\${esc(oneLine(now))}{/}\\n\`,
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)')}{/}\`,
1824
1924
  );
1825
1925
  }
1826
1926
 
@@ -1835,6 +1935,8 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1835
1935
  * declines to count returns forever, so the dash below is the honest initial
1836
1936
  * state rather than a placeholder that happens to look the same.
1837
1937
  */
1938
+ const statsMode = (): string => stats?.().mode ?? 'write-through';
1939
+
1838
1940
  let onFile: number | null = null;
1839
1941
  function refreshCount(): void {
1840
1942
  void Promise.resolve(store.count())
@@ -1860,14 +1962,14 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1860
1962
  // Grouped, never one line per dispatch. The realistic shape of this table
1861
1963
  // is forty rows with ONE cause between them, and forty identical lines hide
1862
1964
  // the single fact that matters.
1863
- const lines = heldSummary().slice(0, 3).map((l) => \` {red-fg}\${esc(l)}{/}\`);
1965
+ const lines = heldSummary().slice(0, 2).map((l) => \` {red-fg}\${esc(l)}{/}\`);
1864
1966
  faultBox.setContent(
1865
- \`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open.\\n\` +
1866
- \` {\${DIM}-fg}They are held, encrypted, exactly as they arrived. Nothing is lost yet.{/}\\n\\n\` +
1867
- lines.join('\\n') + '\\n\\n' +
1967
+ \`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open. \` +
1968
+ \`{\${DIM}-fg}Held, encrypted, exactly as they arrived.{/}\\n\` +
1969
+ lines.join('\\n') + '\\n' +
1868
1970
  (replayNote
1869
1971
  ? \` {\${AMBER}-fg}\${esc(replayNote)}{/}\`
1870
- : \` {\${DIM}-fg}Fix the cause (usually a key in .env), then press{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply them.{/}\`),
1972
+ : \` {\${DIM}-fg}Fix the cause, then{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply,{/} {\${AMBER}-fg}[e]{/} {\${DIM}-fg}to export the list.{/}\`),
1871
1973
  );
1872
1974
  }
1873
1975
 
@@ -1881,12 +1983,27 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1881
1983
  // with a backlog shows the backlog instead of zero.
1882
1984
  const awaiting = pendingConfirmCount();
1883
1985
  const awaitingColour = awaiting > 0 ? AMBER : DIM;
1986
+ // ASKED, not accumulated. The server counts per dispatch, so a redelivery
1987
+ // updates a record rather than adding one.
1988
+ const s = stats?.() ?? {
1989
+ received: 0, applied: 0, failed: 0,
1990
+ mode: 'write-through', awaitingConnector: 0, oldestUndrawn: null,
1991
+ };
1992
+ const { received, applied, failed } = s;
1993
+ // AWAITING YOUR SYSTEMS, in inbox mode only. Drawn whether or not it is
1994
+ // zero there, and absent entirely in write-through, because a counter for a
1995
+ // thing that cannot happen is noise that teaches the eye to skip the row.
1996
+ const awaitingYou = s.mode === 'inbox'
1997
+ ? \`{\${s.awaitingConnector > 0 ? AMBER : DIM}-fg}awaiting your systems{/} \` +
1998
+ \`{bold}\${s.awaitingConnector}{/bold} \`
1999
+ : '';
1884
2000
  footer.setContent(
1885
2001
  \`{\${DIM}-fg}received{/} {bold}\${received}{/bold} \` +
1886
2002
  \`{green-fg}applied{/} {bold}\${applied}{/bold} \` +
1887
2003
  \`{red-fg}failed{/} {bold}\${failed}{/bold} \` +
2004
+ awaitingYou +
1888
2005
  \`{\${awaitingColour}-fg}awaiting confirm{/} {bold}\${awaiting}{/bold}\` +
1889
- \`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[q] quit{/} \`,
2006
+ \`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [q] quit{/} \`,
1890
2007
  );
1891
2008
  }
1892
2009
 
@@ -1899,46 +2016,25 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1899
2016
  }
1900
2017
 
1901
2018
  // \u2500\u2500 The feed \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
1902
- // Counters are derived from the reported lines rather than from a second
1903
- // channel the server would have to remember to update. One source, so the
1904
- // footer cannot disagree with the log beside it.
2019
+ // NARRATION ONLY. It used to derive the footer's counters from these lines
2020
+ // too, on the reasoning that one source cannot disagree with itself. That
2021
+ // held until replay shipped: a replay produces exactly the lines an arrival
2022
+ // does, so pressing [r] against a key that could never work drove \`received\`
2023
+ // from 5 to 67 while nothing arrived. A log line says what happened and not
2024
+ // what it happened TO, so two lines about one dispatch are indistinguishable
2025
+ // from two dispatches. The counters key on the dispatch now; see
2026
+ // \`src/tally.ts\`.
1905
2027
  const detach = report.attach((line: ReportLine) => {
1906
2028
  const text = formatLine(line);
1907
2029
  const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
1908
2030
  const time = new Date(line.at).toTimeString().slice(0, 8);
1909
2031
 
1910
- // COUNTED ON THE OUTCOME LINE, WHICHEVER IT IS.
1911
- //
1912
- // \`received\` used to increment only on \`address.updated for <account>\`,
1913
- // which is logged AFTER decryption because the account number comes out of
1914
- // the decrypted envelope. A dispatch that failed to decrypt returned 422
1915
- // before that line ran, so it was never counted as having arrived: a
1916
- // partner with a wrong key saw \`received 0 \xB7 failed 12\` and went looking at
1917
- // their tunnel and their webhook URL. Everything was arriving. The error
1918
- // line said "check your key" and the counter, louder, said "nothing here".
1919
- //
1920
- // Every dispatch produces exactly one of these, so counting either as an
1921
- // arrival makes received = applied + failed hold.
1922
- const failure = /REFUSED|decryption failed|Decryption failed|no private key for/.test(text);
1923
- if (/address\\.updated for /.test(text) || failure) received++;
1924
- if (failure) failed++;
1925
- if (/\\[store\\] saved address for /.test(text)) {
1926
- applied++;
1927
- // The store logs the account key and never the address, so the panel is
1928
- // refreshed from the DATABASE rather than parsed out of the log line.
1929
- const acct = /saved address for (\\S+)/.exec(text)?.[1];
1930
- // An indexed lookup of the one customer, not a decrypt of the whole
1931
- // roster to find them. Invisible at three rows and absurd at four
1932
- // million, which is the scale a real store is pointed at.
1933
- const prev = pendingPrevious;
1934
- if (acct) {
1935
- void Promise.resolve(store.find(acct)).then((customer) => {
1936
- if (!customer) return;
1937
- lastChange = { customer, previous: prev };
1938
- redraw();
1939
- });
1940
- }
1941
- }
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.
1942
2038
 
1943
2039
  logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
1944
2040
  redraw();
@@ -1965,6 +2061,24 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1965
2061
  .finally(() => { replaying = false; redraw(); });
1966
2062
  });
1967
2063
 
2064
+ // [e] EXPORTS THE FAULT LIST. Metadata only, no payloads: see exportHeld.
2065
+ // Bound unconditionally for the same reason [r] is, so pressing it on a clean
2066
+ // receiver says there is nothing to export rather than appearing to hang.
2067
+ screen.key(['e'], () => {
2068
+ if (heldCount() === 0) {
2069
+ replayNote = 'Nothing held, so nothing to export.';
2070
+ redraw();
2071
+ return;
2072
+ }
2073
+ try {
2074
+ const { path, count } = exportHeld(process.cwd());
2075
+ replayNote = \`Exported \${count} to \${path}\`;
2076
+ } catch (err) {
2077
+ replayNote = \`Export failed: \${err instanceof Error ? err.message : String(err)}\`;
2078
+ }
2079
+ redraw();
2080
+ });
2081
+
1968
2082
  screen.key(['q', 'C-c'], () => {
1969
2083
  detach();
1970
2084
  screen.destroy();
@@ -1991,10 +2105,10 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1991
2105
  * reason \`report\` has a default sink: \`--headless\` must not need a second code
1992
2106
  * path through the handler.
1993
2107
  */
1994
- let setPrevious: (prev: Record<string, unknown>) => void = () => {};
2108
+ let setChange: (facts: ChangeFacts) => void = () => {};
1995
2109
 
1996
- export function notePreviousAddress(prev: Record<string, unknown>): void {
1997
- setPrevious(prev);
2110
+ export function noteChange(facts: ChangeFacts): void {
2111
+ setChange(facts);
1998
2112
  }
1999
2113
  `
2000
2114
  },
@@ -2238,6 +2352,14 @@ async function main(): Promise<void> {
2238
2352
  partnerName: string;
2239
2353
  port: number;
2240
2354
  replay: () => Promise<{ applied: number; failed: number }>;
2355
+ stats: () => {
2356
+ received: number;
2357
+ applied: number;
2358
+ failed: number;
2359
+ mode: string;
2360
+ awaitingConnector: number;
2361
+ oldestUndrawn: string | null;
2362
+ };
2241
2363
  };
2242
2364
  try {
2243
2365
  const server = await import('./server.js');
@@ -2249,6 +2371,7 @@ async function main(): Promise<void> {
2249
2371
  // second edge the other way is a cycle. This file is the one place that
2250
2372
  // holds both.
2251
2373
  replay: server.replayQuarantined,
2374
+ stats: server.dashboardStats,
2252
2375
  };
2253
2376
  } catch (err) {
2254
2377
  if (err instanceof PassphraseRequiredError) {
@@ -2278,6 +2401,7 @@ async function main(): Promise<void> {
2278
2401
  partnerName: config.partnerName,
2279
2402
  port: config.port,
2280
2403
  onReplay: config.replay,
2404
+ stats: config.stats,
2281
2405
  onQuit: () => { /* the process exits; the OS closes the socket */ },
2282
2406
  });
2283
2407
  }
@@ -2286,6 +2410,708 @@ main().catch((err) => {
2286
2410
  console.error('[startup]', err instanceof Error ? err.message : err);
2287
2411
  process.exit(1);
2288
2412
  });
2413
+ `
2414
+ },
2415
+ {
2416
+ name: "src/connector-store.ts",
2417
+ content: `/**
2418
+ * A \`CustomerStore\` whose answers come from the connector.
2419
+ *
2420
+ * ## Why the mode swaps the STORE rather than branching the handler
2421
+ *
2422
+ * The obvious way to build inbox mode is an \`if (mode === 'inbox')\` beside each
2423
+ * verify event in \`server.ts\`. That works and it puts the mode into the middle
2424
+ * of the protocol layer, where it has to be got right twice and stay right
2425
+ * every time either handler changes.
2426
+ *
2427
+ * The contract in \`customer-store.ts\` already says exactly what the receiver
2428
+ * needs from a partner: verify an account, verify an address, apply a change.
2429
+ * In inbox mode the answers come from a different place. That is an
2430
+ * IMPLEMENTATION of the contract, not a special case in the caller, so the
2431
+ * handler is untouched and there is one place the mode lives.
2432
+ *
2433
+ * ## Three methods answer, two deliberately refuse
2434
+ *
2435
+ * \`verifyAccount\` and \`verifyAddress\` ask the connector and wait, because a
2436
+ * consumer is mid-payment on the other end of the first one.
2437
+ *
2438
+ * \`saveAddress\` THROWS. In inbox mode an \`address.updated\` never reaches the
2439
+ * store at all: it is held for the connector to draw, and the connector applies
2440
+ * it against the partner's own database. If this is ever called, the mode
2441
+ * switch has been bypassed and the right answer is a loud failure rather than a
2442
+ * quiet write into a store that should not exist.
2443
+ *
2444
+ * \`find\` and \`count\` return nothing, honestly, because this receiver holds no
2445
+ * customer records in this mode. The dashboard draws a dash rather than a zero,
2446
+ * which is the difference between "not asked" and "none".
2447
+ */
2448
+ import { report } from './report.js';
2449
+ import { askConnector } from './connector-client.js';
2450
+ import type {
2451
+ AccountVerdict,
2452
+ Address,
2453
+ Customer,
2454
+ CustomerStore,
2455
+ StoredCustomer,
2456
+ VerifyResult,
2457
+ } from './customer-store.js';
2458
+
2459
+ /**
2460
+ * The raw body of the request currently being handled.
2461
+ *
2462
+ * SET BY THE HANDLER, read here. The contract passes a decoded \`Customer\`,
2463
+ * which is exactly what this store does not have and cannot produce: the
2464
+ * receiver holds no private key in this mode, so the only thing it can send the
2465
+ * connector is the ciphertext that arrived. Rather than widen the contract for
2466
+ * one implementation, the handler parks the bytes here for the length of the
2467
+ * request.
2468
+ *
2469
+ * Safe because Node runs one request's synchronous path at a time and this is
2470
+ * read immediately, in the same tick the handler sets it. It would NOT be safe
2471
+ * if anything awaited between the set and the read, which is why they are
2472
+ * adjacent and why this comment exists.
2473
+ */
2474
+ let currentRawBody = '';
2475
+
2476
+ export function setCurrentRawBody(raw: string): void {
2477
+ currentRawBody = raw;
2478
+ }
2479
+
2480
+ function verdictOf(body: Record<string, unknown> | undefined, key: string): string | null {
2481
+ const value = body?.[key];
2482
+ return typeof value === 'string' ? value : null;
2483
+ }
2484
+
2485
+ export const connectorStore = {
2486
+ name: 'connector',
2487
+ // The receiver holds no customer records in this mode, so "is the customer
2488
+ // file encrypted" has no true answer. False is the honest one: there is no
2489
+ // protected customer store here, because there is no customer store here.
2490
+ encrypted: false,
2491
+
2492
+ async verifyAccount(
2493
+ accountNumber: string | null,
2494
+ _name: string,
2495
+ _knownNames: string[] = [],
2496
+ ): Promise<AccountVerdict> {
2497
+ const answer = await askConnector('account.verify', currentRawBody);
2498
+ if (!answer.reached) {
2499
+ // NEVER GUESSED. A fabricated match authorises a stranger's address onto
2500
+ // a customer's account. \`no_account\` is the safe answer and stops the
2501
+ // consumer before they pay, which is the trade this design accepted.
2502
+ report.warn(
2503
+ \`[connector] account check for \${accountNumber ?? '(none)'} could not be answered; \` +
2504
+ 'refusing rather than guessing',
2505
+ );
2506
+ return 'no_account';
2507
+ }
2508
+ const status = verdictOf(answer.body, 'status');
2509
+ if (status === 'match' || status === 'no_match' || status === 'no_account') return status;
2510
+ report.warn(\`[connector] account.verify answered "\${status ?? '(nothing)'}", which is not a verdict\`);
2511
+ return 'no_account';
2512
+ },
2513
+
2514
+ async verifyAddress(_customer: Customer, _incoming: Address): Promise<VerifyResult> {
2515
+ const answer = await askConnector('address.verify', currentRawBody);
2516
+ if (!answer.reached) return 'not_found';
2517
+ const result = verdictOf(answer.body, 'result');
2518
+ if (result === 'match' || result === 'mismatch' || result === 'not_found') return result;
2519
+ report.warn(\`[connector] address.verify answered "\${result ?? '(nothing)'}", which is not a result\`);
2520
+ return 'not_found';
2521
+ },
2522
+
2523
+ saveAddress(_customer: Customer, _incoming: Address): Promise<Address> {
2524
+ // Unreachable by design: see the header. Loud rather than quiet.
2525
+ return Promise.reject(new Error(
2526
+ 'saveAddress was called in inbox mode. Updates are held for the connector to draw and ' +
2527
+ 'apply against your own database; nothing should write through this receiver.',
2528
+ ));
2529
+ },
2530
+
2531
+ find(_accountNumber: string): StoredCustomer | null { return null; },
2532
+ count(): null { return null; },
2533
+ } satisfies CustomerStore;
2534
+ `
2535
+ },
2536
+ {
2537
+ name: "src/connector-client.ts",
2538
+ content: `/**
2539
+ * Asking the connector a question that cannot wait.
2540
+ *
2541
+ * ## Why this direction exists at all
2542
+ *
2543
+ * Everything else between the receiver and the connector is a PULL: the
2544
+ * connector draws work when it is ready. That is the right shape, because the
2545
+ * partner's system should control its own pace.
2546
+ *
2547
+ * The two verify events cannot work that way. \`account.verify\` runs BEFORE the
2548
+ * consumer pays and has to answer in the same request with match, no match, or
2549
+ * no account; \`address.verify\` is the same shape. Both need a decrypt and a
2550
+ * customer lookup, and in inbox mode both of those live in the connector. So
2551
+ * for these, and only these, the receiver calls out and waits.
2552
+ *
2553
+ * ## A CORRECTION TO THE DESIGN DOCUMENT, recorded where it matters
2554
+ *
2555
+ * \`docs/architecture/receiver-connector-design.md\` says the connector "accepts
2556
+ * no inbound connections". That is not achievable alongside a synchronous
2557
+ * verify, and the verify decision is the one that was taken deliberately. So
2558
+ * the connector DOES listen, on loopback only, and the honest form of the
2559
+ * property is: no inbound port reachable from any NETWORK. The difference
2560
+ * matters the day a partner puts the two on separate hosts, which is what the
2561
+ * transport configuration below exists for.
2562
+ *
2563
+ * ## What happens when the connector is down
2564
+ *
2565
+ * The receiver tells OneAddress honestly that it could not check, and the
2566
+ * consumer is stopped BEFORE they pay rather than after. That is the cost of
2567
+ * choosing this over a verify-only key in the receiver: such a key would keep
2568
+ * answers flowing during an outage and would make "the receiver cannot read
2569
+ * addresses" untrue, which is the whole point of the split.
2570
+ *
2571
+ * It is NEVER answered with a guess. A fabricated match authorises a stranger's
2572
+ * address onto a customer's account; a fabricated no-match costs a support
2573
+ * call. Neither is ours to invent.
2574
+ */
2575
+ import { report } from './report.js';
2576
+
2577
+ /**
2578
+ * Where the connector listens.
2579
+ *
2580
+ * 3003, NOT 3002. Two loopback listeners exist in this design and swapping them
2581
+ * is silent: the receiver's own draw channel is on 3002, so a receiver pointed
2582
+ * at 3002 for verify asks ITSELF the question and gets a 404 that reads exactly
2583
+ * like a connector that is down.
2584
+ */
2585
+ const CONNECTOR_URL = process.env.CONNECTOR_URL ?? 'http://127.0.0.1:3003';
2586
+ const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
2587
+ /**
2588
+ * Short, because a consumer is watching a spinner on the other end of this.
2589
+ * A verify that takes eight seconds has already failed as far as they are
2590
+ * concerned, and an honest "could not check" beats a long hang.
2591
+ */
2592
+ const TIMEOUT_MS = Number(process.env.CONNECTOR_TIMEOUT_MS ?? 5000);
2593
+
2594
+ export type VerifyKind = 'account.verify' | 'address.verify';
2595
+
2596
+ export interface ConnectorVerdict {
2597
+ reached: boolean;
2598
+ /** Whatever the connector answered. Passed through untouched. */
2599
+ body?: Record<string, unknown>;
2600
+ error?: string;
2601
+ }
2602
+
2603
+ /**
2604
+ * Hand the connector an encrypted verify event and wait for its answer.
2605
+ *
2606
+ * The RAW BODY goes over, not anything decoded: the receiver holds no private
2607
+ * key in this mode and has nothing to decode it with. The connector decrypts,
2608
+ * looks the customer up in the partner's database, and answers.
2609
+ */
2610
+ export async function askConnector(kind: VerifyKind, rawBody: string): Promise<ConnectorVerdict> {
2611
+ const controller = new AbortController();
2612
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
2613
+ try {
2614
+ const res = await fetch(\`\${CONNECTOR_URL}/verify\`, {
2615
+ method: 'POST',
2616
+ headers: {
2617
+ 'Content-Type': 'application/json',
2618
+ 'Authorization': \`Bearer \${TOKEN}\`,
2619
+ 'X-OneAddress-Verify-Kind': kind,
2620
+ },
2621
+ body: rawBody,
2622
+ signal: controller.signal,
2623
+ });
2624
+ const text = await res.text().catch(() => '');
2625
+ if (!res.ok) {
2626
+ report.error(\`[connector] \${kind} refused: HTTP \${res.status} \${text.slice(0, 200)}\`);
2627
+ return { reached: false, error: \`HTTP \${res.status}\` };
2628
+ }
2629
+ try {
2630
+ return { reached: true, body: JSON.parse(text) as Record<string, unknown> };
2631
+ } catch {
2632
+ report.error(\`[connector] \${kind} answered with something that is not JSON\`);
2633
+ return { reached: false, error: 'bad response' };
2634
+ }
2635
+ } catch (err) {
2636
+ const message = err instanceof Error ? err.message : String(err);
2637
+ // NAMES THE REMEDY. This is the failure a partner will actually see, and
2638
+ // "fetch failed" on its own sends them looking at OneAddress.
2639
+ report.error(
2640
+ \`[connector] could not reach the connector for \${kind} (\${message}). \` +
2641
+ \`Account checks will fail until it is back. Is it running, and is CONNECTOR_URL (\${CONNECTOR_URL}) right?\`,
2642
+ );
2643
+ return { reached: false, error: message };
2644
+ } finally {
2645
+ clearTimeout(timer);
2646
+ }
2647
+ }
2648
+ `
2649
+ },
2650
+ {
2651
+ name: "src/draw-api.ts",
2652
+ content: `/**
2653
+ * The channel your connector draws from.
2654
+ *
2655
+ * ## The most dangerous surface in this receiver
2656
+ *
2657
+ * Everything else here handles ciphertext. This hands out dispatches a
2658
+ * connector will decrypt, and takes its word for what was applied. An exposed
2659
+ * or unauthenticated version of it is precisely the oracle the whole
2660
+ * architecture exists to prevent, so three things are not configurable:
2661
+ *
2662
+ * 1. **It binds to 127.0.0.1 and nothing else.** There is deliberately no
2663
+ * option to bind it to an interface. A partner who needs the connector on
2664
+ * another host needs a transport with mutual authentication, not this one
2665
+ * with a wider bind, and giving them a knob that looks like it does the
2666
+ * job is how that ends up on a LAN.
2667
+ * 2. **It runs on a SEPARATE PORT from the webhook.** Same process, different
2668
+ * listener, so nothing routed from the internet can reach these paths even
2669
+ * by mistake in a reverse proxy.
2670
+ * 3. **It needs its own credential.** \`CONNECTOR_TOKEN\`, not the webhook
2671
+ * secret and not the confirm secret. Those are shared with OneAddress; a
2672
+ * leak of either must not also hand somebody your customers' addresses.
2673
+ * Compared in constant time, and the API refuses to start without it.
2674
+ *
2675
+ * ## What it does NOT decrypt
2676
+ *
2677
+ * Nothing. It hands over \`raw_body\`, the bytes as they arrived. The private key
2678
+ * lives in the connector, which is the only component that can read any of it.
2679
+ */
2680
+ import express, { type Request, type Response } from 'express';
2681
+ import rateLimit from 'express-rate-limit';
2682
+ import { timingSafeEqual } from 'node:crypto';
2683
+ import { report } from './report.js';
2684
+ import { acknowledge, drawable, markDrawn, type InboxOutcome } from './inbox.js';
2685
+
2686
+ const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
2687
+ /**
2688
+ * The RECEIVER's loopback listener, which is not the connector's.
2689
+ *
2690
+ * Named \`DRAW_PORT\` rather than \`CONNECTOR_PORT\` because the connector has a
2691
+ * listener of its own, on \`CONNECTOR_PORT\` (3003), for the verify questions
2692
+ * that cannot wait for a draw. One name for two ports on one host is a
2693
+ * misconfiguration nobody would find: each end would bind or dial the other's.
2694
+ */
2695
+ const DRAW_PORT = Number(process.env.DRAW_PORT ?? 3002);
2696
+ /**
2697
+ * How long a drawn item stays claimed before it is offered again.
2698
+ *
2699
+ * Crash recovery for ONE connector, not concurrency for two. See \`inbox.ts\`.
2700
+ * Generous, because the cost of re-offering too early is a duplicate apply and
2701
+ * the cost of re-offering too late is a delay nobody dies of.
2702
+ */
2703
+ const LEASE_SECONDS = Number(process.env.CONNECTOR_LEASE_SECONDS ?? 300);
2704
+
2705
+ /** Constant time, and length-safe: \`timingSafeEqual\` throws on a length mismatch. */
2706
+ function tokenMatches(presented: string): boolean {
2707
+ const a = Buffer.from(presented);
2708
+ const b = Buffer.from(TOKEN);
2709
+ if (a.length !== b.length) return false;
2710
+ return timingSafeEqual(a, b);
2711
+ }
2712
+
2713
+ function authorised(req: Request, res: Response): boolean {
2714
+ const header = req.headers.authorization ?? '';
2715
+ const presented = header.startsWith('Bearer ') ? header.slice(7) : '';
2716
+ if (presented && tokenMatches(presented)) return true;
2717
+ // No detail, deliberately. This endpoint should tell an unauthorised caller
2718
+ // nothing at all about whether it exists or what it holds.
2719
+ res.status(401).json({ error: 'Unauthorised' });
2720
+ return false;
2721
+ }
2722
+
2723
+ export function startDrawApi(): { port: number } | null {
2724
+ if (connectorTokenMissing()) return null;
2725
+
2726
+ const app = express();
2727
+ app.disable('x-powered-by');
2728
+
2729
+ // A BACKSTOP, AND NOT WHAT KEEPS THIS SHUT.
2730
+ //
2731
+ // What keeps it shut is the loopback bind and the credential, compared in
2732
+ // constant time. This bounds what an attacker who is already on the box can
2733
+ // spend, and it is deliberately far above any real connector: a draw every
2734
+ // two seconds plus an acknowledgement per item is a few hundred a minute at
2735
+ // worst, so a legitimate connector never meets this and a partner who tunes
2736
+ // \`CONNECTOR_POLL_MS\` down still has room.
2737
+ //
2738
+ // No \`trust proxy\` here, unlike the webhook app. There is no proxy in front
2739
+ // of a loopback listener, so \`X-Forwarded-For\` is somebody trying it on, and
2740
+ // trusting it would let them key the limiter on an address of their choosing.
2741
+ //
2742
+ // Attached ONCE, on the app rather than per route. Two references to the same
2743
+ // limiter instance count a request twice and silently halve the ceiling.
2744
+ app.use(rateLimit({
2745
+ windowMs: 60_000,
2746
+ max: 2000,
2747
+ standardHeaders: true,
2748
+ legacyHeaders: false,
2749
+ message: { error: 'Too many requests' },
2750
+ }));
2751
+
2752
+ app.use(express.json({ limit: '2mb' }));
2753
+
2754
+ /**
2755
+ * Take the next batch of work.
2756
+ *
2757
+ * A GET with no side effects would be tidier and would be wrong: drawing has
2758
+ * to stamp the lease, or a connector that asks twice gets the same items and
2759
+ * applies them twice.
2760
+ */
2761
+ app.post('/draw', (req: Request, res: Response) => {
2762
+ if (!authorised(req, res)) return;
2763
+ const limit = Math.min(Number((req.body as { limit?: unknown }).limit ?? 20) || 20, 100);
2764
+ const items = drawable(limit, LEASE_SECONDS);
2765
+ markDrawn(items.map((i) => i.id));
2766
+ if (items.length > 0) report.info(\`[draw] connector took \${items.length} dispatch(es)\`);
2767
+ return res.json({ items });
2768
+ });
2769
+
2770
+ /**
2771
+ * What the connector did with one item.
2772
+ *
2773
+ * \`applied\` queues the confirm to OneAddress. \`failed\` queues a failed
2774
+ * confirm, which is the honest answer when the partner's own system refused
2775
+ * the change: the consumer is told it did not land rather than being told it
2776
+ * did.
2777
+ */
2778
+ app.post('/ack', (req: Request, res: Response) => {
2779
+ if (!authorised(req, res)) return;
2780
+ const body = req.body as { id?: unknown; outcome?: unknown; detail?: unknown };
2781
+ const id = typeof body.id === 'string' ? body.id : '';
2782
+ const outcome: InboxOutcome | null =
2783
+ body.outcome === 'applied' || body.outcome === 'failed' ? body.outcome : null;
2784
+ if (!id || !outcome) {
2785
+ return res.status(400).json({ error: 'id and outcome (applied|failed) are required' });
2786
+ }
2787
+ const detail = typeof body.detail === 'string' ? body.detail : null;
2788
+
2789
+ const result = acknowledge(id, outcome, detail);
2790
+ if (!result) return res.status(404).json({ error: 'unknown id' });
2791
+ if (result.alreadyAcknowledged) {
2792
+ // NOT AN ERROR. A connector that is unsure its acknowledgement landed
2793
+ // should retry, and punishing that is how an update ends up applied and
2794
+ // never confirmed.
2795
+ return res.json({ ok: true, duplicate: true });
2796
+ }
2797
+
2798
+ if (result.dispatchId) onAcknowledged(result.dispatchId, outcome);
2799
+ return res.json({ ok: true });
2800
+ });
2801
+
2802
+ /** Enough for the connector to know it is talking to the right receiver. */
2803
+ app.get('/connector/health', (req: Request, res: Response) => {
2804
+ if (!authorised(req, res)) return;
2805
+ return res.json({ status: 'ok', mode: 'inbox' });
2806
+ });
2807
+
2808
+ app.listen(DRAW_PORT, '127.0.0.1', () =>
2809
+ report.info(\`[draw] connector channel \u2192 http://127.0.0.1:\${DRAW_PORT} (loopback only)\`),
2810
+ );
2811
+ return { port: DRAW_PORT };
2812
+ }
2813
+
2814
+ /**
2815
+ * Refuse to open the channel without a credential, and say why.
2816
+ *
2817
+ * Returning null rather than throwing, because a receiver in write-through mode
2818
+ * has no connector and must start perfectly well without one. In inbox mode the
2819
+ * caller turns this into a hard failure, since an inbox with no way to drain it
2820
+ * is worse than a receiver that will not start.
2821
+ */
2822
+ function connectorTokenMissing(): boolean {
2823
+ if (TOKEN.trim().length >= 16) return false;
2824
+ report.error(
2825
+ '[draw] CONNECTOR_TOKEN is missing or shorter than 16 characters, so the connector ' +
2826
+ 'channel was NOT opened. This is its own credential on purpose: it must not be your ' +
2827
+ 'webhook secret or your confirm secret, because those are shared with OneAddress and a ' +
2828
+ 'leak of either must not also hand somebody your customers\\' addresses.',
2829
+ );
2830
+ return true;
2831
+ }
2832
+
2833
+ /**
2834
+ * Told when an item is acknowledged, so the receiver can confirm to OneAddress.
2835
+ *
2836
+ * A setter rather than an import, because \`server.ts\` owns the confirm queue
2837
+ * and already imports this module: importing it back would be a cycle.
2838
+ */
2839
+ let onAcknowledged: (dispatchId: string, outcome: InboxOutcome) => void = () => {};
2840
+
2841
+ export function setAcknowledgementHandler(
2842
+ handler: (dispatchId: string, outcome: InboxOutcome) => void,
2843
+ ): void {
2844
+ onAcknowledged = handler;
2845
+ }
2846
+ `
2847
+ },
2848
+ {
2849
+ name: "src/inbox.ts",
2850
+ content: `/**
2851
+ * Dispatches waiting for YOUR system to collect them.
2852
+ *
2853
+ * ## What this is, and why it is not the quarantine
2854
+ *
2855
+ * Both tables hold a signature-verified dispatch as the ciphertext that
2856
+ * arrived. They mean opposite things.
2857
+ *
2858
+ * A QUARANTINED dispatch is one the receiver could not open: something is
2859
+ * wrong, usually a key, and it is purged on a window because holding a
2860
+ * consumer's encrypted address forever is a retention decision nobody made.
2861
+ *
2862
+ * An INBOX dispatch is one nothing is wrong with. It arrived intact and is
2863
+ * waiting for the partner's own systems to draw it, apply it in their database
2864
+ * and say so. **It is NEVER aged out**, and that asymmetry is deliberate: an
2865
+ * undrawn update is a consumer whose address has not landed, and deleting it on
2866
+ * a timer loses it silently. The dashboard reports a growing undrawn count as a
2867
+ * fault instead, which is the honest way to make an operator deal with it.
2868
+ *
2869
+ * ## Nothing here is readable
2870
+ *
2871
+ * The rows hold the bytes as they arrived. In inbox mode the receiver holds no
2872
+ * private key at all, so it could not decrypt these if it wanted to. That is
2873
+ * the property the whole split exists for: the component reachable from the
2874
+ * internet cannot read what it stores.
2875
+ *
2876
+ * ## The lease, and what it is NOT for
2877
+ *
2878
+ * A connector that draws a batch and then crashes must not strand it. So a draw
2879
+ * stamps \`drawn_at\`, and an item becomes drawable again once that stamp is
2880
+ * older than the lease. That is CRASH RECOVERY for one connector.
2881
+ *
2882
+ * It is not a design for two connectors drawing at once. Two would each get
2883
+ * their own view of what is drawable between leases, and nothing here stops
2884
+ * them applying the same update twice. If a second connector is ever wanted,
2885
+ * the claim has to become atomic, the way \`confirm-queue.ts\` already does it.
2886
+ * Written down because a lease LOOKS like it handles concurrency and does not.
2887
+ */
2888
+ import db from './db.js';
2889
+ import { report } from './report.js';
2890
+
2891
+ export type InboxOutcome = 'applied' | 'failed';
2892
+
2893
+ db.exec(\`
2894
+ CREATE TABLE IF NOT EXISTS inbox (
2895
+ id TEXT PRIMARY KEY,
2896
+ dispatch_id TEXT,
2897
+ event TEXT NOT NULL,
2898
+ raw_body TEXT NOT NULL,
2899
+ received_at TEXT NOT NULL DEFAULT (datetime('now')),
2900
+ drawn_at TEXT,
2901
+ applied_at TEXT,
2902
+ outcome TEXT,
2903
+ detail TEXT
2904
+ );
2905
+ CREATE INDEX IF NOT EXISTS idx_inbox_open
2906
+ ON inbox(applied_at, drawn_at, received_at);
2907
+ \`);
2908
+
2909
+ export interface AcceptInput {
2910
+ key: string;
2911
+ dispatchId: string | null;
2912
+ event: string;
2913
+ rawBody: string;
2914
+ }
2915
+
2916
+ /**
2917
+ * Take a dispatch for the connector to collect.
2918
+ *
2919
+ * \`INSERT OR IGNORE\`, keyed on the same dispatch identity everything else here
2920
+ * uses, so a OneAddress retry of a delivery we already hold does not queue the
2921
+ * same update twice.
2922
+ */
2923
+ export function accept(input: AcceptInput): void {
2924
+ db.prepare(
2925
+ \`INSERT OR IGNORE INTO inbox (id, dispatch_id, event, raw_body)
2926
+ VALUES (?, ?, ?, ?)\`,
2927
+ ).run(input.key, input.dispatchId, input.event, input.rawBody);
2928
+ }
2929
+
2930
+ export interface InboxItem {
2931
+ id: string;
2932
+ dispatch_id: string | null;
2933
+ event: string;
2934
+ raw_body: string;
2935
+ received_at: string;
2936
+ }
2937
+
2938
+ /**
2939
+ * What the connector may take now.
2940
+ *
2941
+ * Anything never drawn, plus anything drawn longer ago than the lease and still
2942
+ * unacknowledged, which is the crashed-connector case.
2943
+ */
2944
+ export function drawable(limit: number, leaseSeconds: number): InboxItem[] {
2945
+ const cutoff = new Date(Date.now() - leaseSeconds * 1000).toISOString();
2946
+ return db.prepare(
2947
+ \`SELECT id, dispatch_id, event, raw_body, received_at
2948
+ FROM inbox
2949
+ WHERE applied_at IS NULL
2950
+ AND (drawn_at IS NULL OR drawn_at < ?)
2951
+ ORDER BY received_at
2952
+ LIMIT ?\`,
2953
+ ).all(cutoff, limit) as unknown as InboxItem[];
2954
+ }
2955
+
2956
+ export function markDrawn(ids: string[]): void {
2957
+ if (ids.length === 0) return;
2958
+ const stamp = new Date().toISOString();
2959
+ const stmt = db.prepare('UPDATE inbox SET drawn_at = ? WHERE id = ?');
2960
+ for (const id of ids) stmt.run(stamp, id);
2961
+ }
2962
+
2963
+ /**
2964
+ * The connector's verdict on one item.
2965
+ *
2966
+ * Returns the dispatch id to confirm, or null when there is nothing to tell
2967
+ * OneAddress: an unknown id, or one already acknowledged. Deliberately not an
2968
+ * error, because a connector retrying an acknowledgement it is unsure landed is
2969
+ * doing the right thing and must not be punished for it.
2970
+ */
2971
+ export function acknowledge(
2972
+ id: string,
2973
+ outcome: InboxOutcome,
2974
+ detail: string | null,
2975
+ ): { dispatchId: string | null; alreadyAcknowledged: boolean } | null {
2976
+ const row = db.prepare(
2977
+ 'SELECT dispatch_id, applied_at FROM inbox WHERE id = ?',
2978
+ ).get(id) as { dispatch_id: string | null; applied_at: string | null } | undefined;
2979
+ if (!row) return null;
2980
+ if (row.applied_at !== null) {
2981
+ return { dispatchId: row.dispatch_id, alreadyAcknowledged: true };
2982
+ }
2983
+ db.prepare(
2984
+ 'UPDATE inbox SET applied_at = ?, outcome = ?, detail = ? WHERE id = ?',
2985
+ ).run(new Date().toISOString(), outcome, detail?.slice(0, 500) ?? null, id);
2986
+ report.info(\`[inbox] \${id} acknowledged by the connector: \${outcome}\`);
2987
+ return { dispatchId: row.dispatch_id, alreadyAcknowledged: false };
2988
+ }
2989
+
2990
+ /** How many updates are sitting here unapplied. Shown as a fault when non-zero. */
2991
+ export function undrawnCount(): number {
2992
+ const row = db.prepare(
2993
+ 'SELECT count(*) AS n FROM inbox WHERE applied_at IS NULL',
2994
+ ).get() as { n: number };
2995
+ return row.n;
2996
+ }
2997
+
2998
+ /** When the oldest unapplied item arrived, for the age on the dashboard. */
2999
+ export function oldestUndrawn(): string | null {
3000
+ const row = db.prepare(
3001
+ 'SELECT min(received_at) AS oldest FROM inbox WHERE applied_at IS NULL',
3002
+ ).get() as { oldest: string | null };
3003
+ return row.oldest;
3004
+ }
3005
+
3006
+ /**
3007
+ * NOTHING PURGES THIS TABLE, and the absence is the design.
3008
+ *
3009
+ * \`purgeQuarantine\` and \`purgeDelivered\` both exist one file over. There is no
3010
+ * \`purgeInbox\`, and there should not be: every row here is an address change a
3011
+ * consumer paid for that has not reached the partner's system yet. Ageing one
3012
+ * out would delete the only copy anybody still has.
3013
+ *
3014
+ * Rows that HAVE been applied are kept too, because they are the partner's own
3015
+ * record of what they were sent and when they acted on it. If that ever needs
3016
+ * bounding, bound the APPLIED rows and never the open ones.
3017
+ */
3018
+ export function appliedCount(): number {
3019
+ const row = db.prepare(
3020
+ 'SELECT count(*) AS n FROM inbox WHERE applied_at IS NOT NULL',
3021
+ ).get() as { n: number };
3022
+ return row.n;
3023
+ }
3024
+ `
3025
+ },
3026
+ {
3027
+ name: "src/tally.ts",
3028
+ content: `/**
3029
+ * How many dispatches arrived, landed, and did not.
3030
+ *
3031
+ * ## THE BUG THIS REPLACES, WHICH A PARTNER FOUND IN ABOUT A MINUTE
3032
+ *
3033
+ * The dashboard used to derive these by reading its own log feed: a line
3034
+ * matching a failure pattern counted as one arrival and one failure. The
3035
+ * comment beside it asserted the invariant that made that sound: "every
3036
+ * dispatch produces exactly one of these".
3037
+ *
3038
+ * Then replay shipped. A replay produces exactly the same lines an arrival
3039
+ * does, because it IS a delivery, back in through the front door. Somebody
3040
+ * pressed [r] sixty times against a key that was never going to work and
3041
+ * watched \`received\` climb from 5 to 67 and \`failed\` from 1 to 63. Nothing was
3042
+ * arriving. The receiver was counting its own attempts to fix itself.
3043
+ *
3044
+ * The same fault was always there for a cause nobody had to trigger by hand:
3045
+ * OneAddress RETRIES a 422, so a wrong key inflated these counters on its own,
3046
+ * quietly, every few minutes.
3047
+ *
3048
+ * ## Why counting here fixes the class rather than the instance
3049
+ *
3050
+ * Narration is the wrong source. A log line says what happened, not what it
3051
+ * happened TO, so two lines about one dispatch are indistinguishable from two
3052
+ * dispatches. This keys on the DISPATCH, so every re-delivery of it - a
3053
+ * replay, a OneAddress retry, a partner curling the same body twice - lands on
3054
+ * the entry that is already there.
3055
+ *
3056
+ * That also makes a replay that finally WORKS do the right thing on its own:
3057
+ * the entry flips from failed to applied, and the totals move without anything
3058
+ * having to know a replay was involved.
3059
+ *
3060
+ * ## What is counted, stated because the footer does not have room to
3061
+ *
3062
+ * An \`address.updated\` that was stored is \`applied\`. Anything that could not be
3063
+ * opened, or was refused, is \`failed\`. \`received\` is how many distinct
3064
+ * dispatches reached one of those two, so \`received = applied + failed\` holds
3065
+ * by construction rather than by hoping.
3066
+ *
3067
+ * An \`address.verify\` is deliberately none of them: it answers a question and
3068
+ * changes nothing, so counting it as an application would overstate what this
3069
+ * receiver has done. It still shows in the activity log.
3070
+ *
3071
+ * Since boot, like every other figure on that footer. The held count beside
3072
+ * them is not: the quarantine is on disk and survives a restart.
3073
+ */
3074
+
3075
+ export type Outcome = 'applied' | 'failed';
3076
+
3077
+ /**
3078
+ * Bounded, because this is memory and a busy receiver runs for months.
3079
+ *
3080
+ * At the cap the oldest entry goes, exactly as \`seenDispatches\` does, and the
3081
+ * totals then describe the most recent 5000 dispatches rather than all of them.
3082
+ * That is the honest trade for a footer: an operator reads it to see whether
3083
+ * things are working now, and nobody audits from it.
3084
+ */
3085
+ const MAX_TRACKED = 5000;
3086
+ const outcomes = new Map<string, Outcome>();
3087
+
3088
+ export function recordOutcome(dispatchKey: string, outcome: Outcome): void {
3089
+ if (!outcomes.has(dispatchKey) && outcomes.size >= MAX_TRACKED) {
3090
+ const oldest = outcomes.keys().next().value;
3091
+ if (oldest !== undefined) outcomes.delete(oldest);
3092
+ }
3093
+ // \`set\` on an existing key overwrites in place and keeps its insertion order,
3094
+ // which is what makes a replay that succeeds flip failed to applied rather
3095
+ // than adding a second entry.
3096
+ outcomes.set(dispatchKey, outcome);
3097
+ }
3098
+
3099
+ export interface Tally {
3100
+ received: number;
3101
+ applied: number;
3102
+ failed: number;
3103
+ }
3104
+
3105
+ export function tally(): Tally {
3106
+ let applied = 0;
3107
+ for (const outcome of outcomes.values()) if (outcome === 'applied') applied += 1;
3108
+ return { received: outcomes.size, applied, failed: outcomes.size - applied };
3109
+ }
3110
+
3111
+ /** Test seam. Never called by the receiver. */
3112
+ export function resetTally(): void {
3113
+ outcomes.clear();
3114
+ }
2289
3115
  `
2290
3116
  },
2291
3117
  {
@@ -2332,9 +3158,11 @@ main().catch((err) => {
2332
3158
  * replay would apply an address for an account they do not recognise. It stays
2333
3159
  * a refusal.
2334
3160
  */
2335
- import db from './db.js';
3161
+ import db, { ensureColumn, parseStoredTime } from './db.js';
2336
3162
  import { report } from './report.js';
2337
3163
  import { createHash } from 'node:crypto';
3164
+ import { writeFileSync } from 'node:fs';
3165
+ import { join } from 'node:path';
2338
3166
 
2339
3167
  /**
2340
3168
  * Why a dispatch could not be applied. Shown verbatim on the dashboard.
@@ -2376,11 +3204,22 @@ db.exec(\`
2376
3204
  * the dispatch header where there is one, and on a hash of the body where there
2377
3205
  * is not, so a retry updates the existing row instead of adding to a pile.
2378
3206
  */
2379
- function rowId(dispatchId: string | null, rawBody: string): string {
3207
+ export function dispatchKey(dispatchId: string | null, rawBody: string): string {
2380
3208
  if (dispatchId && dispatchId.trim()) return \`d:\${dispatchId.trim()}\`;
2381
3209
  return \`h:\${createHash('sha256').update(rawBody).digest('hex').slice(0, 32)}\`;
2382
3210
  }
2383
3211
 
3212
+ /**
3213
+ * Attempts, so the panel can say how hard this has been tried.
3214
+ *
3215
+ * Added after the quarantine shipped, hence the migration: a partner already
3216
+ * running 2.1.3 has the table without it. Somebody pressed [r] sixty times
3217
+ * against a key that could never work, and the only figure that moved was one
3218
+ * the dashboard was computing wrongly. An attempt count on the row would have
3219
+ * said so immediately.
3220
+ */
3221
+ ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
3222
+
2384
3223
  export interface QuarantineInput {
2385
3224
  dispatchId: string | null;
2386
3225
  event: string;
@@ -2398,7 +3237,7 @@ export interface QuarantineInput {
2398
3237
  * caller must be able to answer OneAddress whatever happens here.
2399
3238
  */
2400
3239
  export function quarantine(input: QuarantineInput): void {
2401
- const id = rowId(input.dispatchId, input.rawBody);
3240
+ const id = dispatchKey(input.dispatchId, input.rawBody);
2402
3241
  try {
2403
3242
  db.prepare(
2404
3243
  \`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
@@ -2435,12 +3274,14 @@ export interface HeldDispatch {
2435
3274
  detail: string | null;
2436
3275
  received_at: string;
2437
3276
  last_error: string | null;
3277
+ attempts: number;
2438
3278
  }
2439
3279
 
2440
3280
  /** Everything still held, oldest first. */
2441
3281
  export function heldDispatches(limit = 50): HeldDispatch[] {
2442
3282
  return db.prepare(
2443
- \`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail, received_at, last_error
3283
+ \`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
3284
+ received_at, last_error, attempts
2444
3285
  FROM quarantine
2445
3286
  WHERE replayed_at IS NULL
2446
3287
  ORDER BY received_at
@@ -2465,15 +3306,39 @@ export function heldCount(): number {
2465
3306
  */
2466
3307
  export function heldSummary(): string[] {
2467
3308
  const rows = db.prepare(
2468
- \`SELECT reason, key_id, count(*) AS n
3309
+ \`SELECT reason, key_id, count(*) AS n,
3310
+ max(attempts) AS tries, min(received_at) AS oldest
2469
3311
  FROM quarantine
2470
3312
  WHERE replayed_at IS NULL
2471
3313
  GROUP BY reason, key_id
2472
3314
  ORDER BY n DESC\`,
2473
- ).all() as unknown as { reason: string; key_id: string | null; n: number }[];
2474
- return rows.map((r) =>
2475
- \`\${r.n} \xD7 \${r.reason}\${r.key_id ? \` (key_id \${r.key_id})\` : ''}\`,
2476
- );
3315
+ ).all() as unknown as {
3316
+ reason: string; key_id: string | null; n: number; tries: number; oldest: string;
3317
+ }[];
3318
+ return rows.map((r) => {
3319
+ const cause = \`\${r.n} \xD7 \${r.reason}\${r.key_id ? \` (key_id \${r.key_id})\` : ''}\`;
3320
+ // TRIED AND AGE, because the count alone does not say whether anything is
3321
+ // being done about it. A partner watching "1 \xD7 decrypt_failed" with no
3322
+ // other figure moving cannot tell a replay that is working from one that
3323
+ // is not; "61 tries" answers that without reading the log.
3324
+ const tried = r.tries > 0 ? \`, \${r.tries} \${r.tries === 1 ? 'try' : 'tries'}\` : '';
3325
+ return \`\${cause}\${tried}, first seen \${describeAge(r.oldest)}\`;
3326
+ });
3327
+ }
3328
+
3329
+ /** "3m ago", "2h ago". Coarse on purpose: nobody acts on seconds. */
3330
+ export function describeAge(iso: string): string {
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();
3335
+ if (!Number.isFinite(ms) || ms < 0) return 'just now';
3336
+ const mins = Math.floor(ms / 60_000);
3337
+ if (mins < 1) return 'moments ago';
3338
+ if (mins < 60) return \`\${mins}m ago\`;
3339
+ const hours = Math.floor(mins / 60);
3340
+ if (hours < 48) return \`\${hours}h ago\`;
3341
+ return \`\${Math.floor(hours / 24)}d ago\`;
2477
3342
  }
2478
3343
 
2479
3344
  export function markReplayed(id: string): void {
@@ -2484,7 +3349,7 @@ export function markReplayed(id: string): void {
2484
3349
 
2485
3350
  /** A replay that failed the same way stays held, with the new reason recorded. */
2486
3351
  export function markReplayFailed(id: string, error: string): void {
2487
- db.prepare('UPDATE quarantine SET last_error = ? WHERE id = ?')
3352
+ db.prepare('UPDATE quarantine SET last_error = ?, attempts = attempts + 1 WHERE id = ?')
2488
3353
  .run(error.slice(0, 500), id);
2489
3354
  }
2490
3355
 
@@ -2502,13 +3367,27 @@ export function markReplayFailed(id: string, error: string): void {
2502
3367
  * is an update the partner never applied and is now no longer able to.
2503
3368
  */
2504
3369
  export function purgeQuarantine(days: number): { replayed: number; unreplayed: number } {
2505
- 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\`;
2506
3385
  const doomed = db.prepare(
2507
- 'SELECT id, replayed_at FROM quarantine WHERE received_at < ?',
2508
- ).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 }[];
2509
3388
  if (doomed.length === 0) return { replayed: 0, unreplayed: 0 };
2510
3389
 
2511
- db.prepare('DELETE FROM quarantine WHERE received_at < ?').run(cutoff);
3390
+ db.prepare("DELETE FROM quarantine WHERE received_at < datetime('now', ?)").run(cutoffExpr);
2512
3391
 
2513
3392
  const unreplayed = doomed.filter((d) => d.replayed_at === null).length;
2514
3393
  const replayed = doomed.length - unreplayed;
@@ -2524,6 +3403,48 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
2524
3403
  return { replayed, unreplayed };
2525
3404
  }
2526
3405
 
3406
+ /**
3407
+ * Write the held list to a file, WITHOUT the payloads.
3408
+ *
3409
+ * ## Why the ciphertext is not in here
3410
+ *
3411
+ * The obvious export carries the payload so the fault can be replayed
3412
+ * somewhere else. It also puts a copy of a consumer's encrypted address in a
3413
+ * file that the retention window cannot reach: the quarantine purges its rows,
3414
+ * and nothing purges an export somebody emailed to support and left in a
3415
+ * downloads folder. The whole point of the retention rule is that a held
3416
+ * address does not sit anywhere indefinitely, and an export that leaks past it
3417
+ * quietly undoes that.
3418
+ *
3419
+ * Nothing in here is personal. A dispatch id, what kind of event it was, why it
3420
+ * could not be opened, which key it asked for, and when. That is the whole of
3421
+ * what diagnoses a key problem, and it is safe to paste into a support ticket
3422
+ * or send to us, which is what an export is for.
3423
+ */
3424
+ export function exportHeld(directory: string): { path: string; count: number } {
3425
+ const rows = heldDispatches(500);
3426
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3427
+ const path = join(directory, \`oneaddress-faults-\${stamp}.json\`);
3428
+ writeFileSync(path, JSON.stringify({
3429
+ exported_at: new Date().toISOString(),
3430
+ note: 'Metadata only. The held payloads are deliberately not included: they are encrypted consumer addresses and stay under the receiver retention window.',
3431
+ held: rows.map((r) => ({
3432
+ id: r.id,
3433
+ dispatch_id: r.dispatch_id,
3434
+ event: r.event,
3435
+ reason: r.reason,
3436
+ key_id: r.key_id,
3437
+ received_at: r.received_at,
3438
+ age: describeAge(r.received_at),
3439
+ attempts: r.attempts,
3440
+ detail: r.detail,
3441
+ last_error: r.last_error,
3442
+ })),
3443
+ }, null, 2));
3444
+ report.info(\`[quarantine] exported \${rows.length} held dispatch(es) to \${path}\`);
3445
+ return { path, count: rows.length };
3446
+ }
3447
+
2527
3448
  /**
2528
3449
  * Re-run every held dispatch through the handler.
2529
3450
  *
@@ -2533,14 +3454,25 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
2533
3454
  * on the second call.
2534
3455
  */
2535
3456
  export async function replayHeld(
2536
- apply: (rawBody: string) => Promise<void>,
3457
+ apply: (rawBody: string, dispatchId: string | null) => Promise<void>,
2537
3458
  limit = 50,
2538
3459
  ): Promise<{ applied: number; failed: number }> {
2539
3460
  let applied = 0;
2540
3461
  let failed = 0;
2541
3462
  for (const row of heldDispatches(limit)) {
2542
3463
  try {
2543
- await apply(row.raw_body);
3464
+ // THE ROW'S OWN DISPATCH ID, not one re-derived from the body.
3465
+ //
3466
+ // \`redeliver\` used to parse \`dispatch_id\` out of the JSON and send that
3467
+ // as the header. A dispatch that arrived WITHOUT the header was keyed on
3468
+ // a hash of its body; the replay then supplied a header from the body,
3469
+ // so the re-arrival was keyed by id instead, landed in a DIFFERENT row,
3470
+ // and one held dispatch became two. Seen on a real receiver: \`1 \xD7
3471
+ // decrypt_failed\` became \`2 \xD7\` on the first press of [r].
3472
+ //
3473
+ // Identity has to come from one place. This row already knows what it
3474
+ // arrived as.
3475
+ await apply(row.raw_body, row.dispatch_id);
2544
3476
  markReplayed(row.id);
2545
3477
  applied += 1;
2546
3478
  report.info(\`[replay] applied \${row.event} \${row.dispatch_id ?? row.id}\`);
@@ -2881,10 +3813,31 @@ import { report } from './report.js';
2881
3813
  import { readFileSync } from 'node:fs';
2882
3814
  import { join } from 'node:path';
2883
3815
 
3816
+ /**
3817
+ * WRITE-THROUGH or INBOX, and both are supported on purpose.
3818
+ *
3819
+ * \`write-through\` is what this receiver has always done: decrypt the dispatch,
3820
+ * apply it through \`CustomerStore\`, confirm to OneAddress. Right for a sole
3821
+ * trader and for anyone happy for the receiver to reach their data directly.
3822
+ *
3823
+ * \`inbox\` is for a company with change control over its customer master. The
3824
+ * receiver verifies the signature, stores the dispatch AS IT ARRIVED, and holds
3825
+ * no private key at all. The partner's own connector draws it, decrypts it,
3826
+ * applies it in their database, and acknowledges; only then does the receiver
3827
+ * confirm to OneAddress. The component reachable from the internet cannot read
3828
+ * what it holds.
3829
+ *
3830
+ * Nothing on the wire differs between them. The protocol already separates
3831
+ * delivery from application: the webhook 200 acknowledges delivery and the
3832
+ * confirm reports application. Write-through simply collapses the two.
3833
+ */
3834
+ export type ReceiverMode = 'write-through' | 'inbox';
3835
+
2884
3836
  export type ReceiverConfig = {
2885
3837
  partnerId: string;
2886
3838
  oneAddressApi: string;
2887
3839
  verifiesAccountReference: boolean;
3840
+ mode: ReceiverMode;
2888
3841
  };
2889
3842
 
2890
3843
  const DEFAULTS: ReceiverConfig = {
@@ -2895,8 +3848,16 @@ const DEFAULTS: ReceiverConfig = {
2895
3848
  // is missing entirely \u2014 in which case answering account.verify is the safer,
2896
3849
  // more useful default than silently returning "not checked".
2897
3850
  verifiesAccountReference: true,
3851
+ // DEFAULTS TO WHAT THE RECEIVER HAS ALWAYS DONE. Inbox mode needs a connector
3852
+ // running and a key living somewhere else; a receiver that silently switched
3853
+ // into it would accept dispatches nothing ever collects.
3854
+ mode: 'write-through',
2898
3855
  };
2899
3856
 
3857
+ function parseMode(value: unknown): ReceiverMode | undefined {
3858
+ return value === 'inbox' || value === 'write-through' ? value : undefined;
3859
+ }
3860
+
2900
3861
  function stripTrailingSlash(s: string): string {
2901
3862
  return s.endsWith('/') ? s.slice(0, -1) : s;
2902
3863
  }
@@ -2909,6 +3870,8 @@ function loadConfigFile(): Partial<ReceiverConfig> {
2909
3870
  if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
2910
3871
  if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
2911
3872
  if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
3873
+ const mode = parseMode(parsed.mode);
3874
+ if (mode) out.mode = mode;
2912
3875
  return out;
2913
3876
  } catch {
2914
3877
  // No config file (or unreadable / malformed): fall back to env + defaults.
@@ -2925,10 +3888,16 @@ export const config: ReceiverConfig = {
2925
3888
  process.env.VERIFIES_ACCOUNT_REFERENCE != null
2926
3889
  ? process.env.VERIFIES_ACCOUNT_REFERENCE === 'true'
2927
3890
  : (fromFile.verifiesAccountReference ?? DEFAULTS.verifiesAccountReference),
3891
+ // An UNRECOGNISED value falls back to write-through rather than failing, and
3892
+ // the startup line below says which mode is live either way, so a typo shows
3893
+ // up as "not the mode I asked for" rather than as a receiver that will not
3894
+ // start. Silent is the thing to avoid, not strict.
3895
+ mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
2928
3896
  };
2929
3897
 
2930
3898
  report.info(
2931
- '[config] loaded (oneAddressApi=' + config.oneAddressApi +
3899
+ '[config] loaded (mode=' + config.mode +
3900
+ ', oneAddressApi=' + config.oneAddressApi +
2932
3901
  ', verifiesAccountReference=' + config.verifiesAccountReference + ')',
2933
3902
  );
2934
3903
  `
@@ -3044,6 +4013,30 @@ export interface StoredCustomer {
3044
4013
  address: string;
3045
4014
  }
3046
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
+
3047
4040
  export type AccountVerdict = 'match' | 'no_match' | 'no_account';
3048
4041
  export type VerifyResult = 'match' | 'mismatch' | 'not_found';
3049
4042
 
@@ -3184,7 +4177,7 @@ export interface CustomerStore {
3184
4177
  import { readFileSync } from 'node:fs';
3185
4178
  import { join } from 'node:path';
3186
4179
  import { report } from './report.js';
3187
- import db, { accountKey, dec, enc, encrypted, isEncrypted, once } from './db.js';
4180
+ import db, { accountKey, dec, enc, encrypted, ensureColumn, isEncrypted, once } from './db.js';
3188
4181
  import type {
3189
4182
  AccountVerdict,
3190
4183
  Address,
@@ -3234,13 +4227,6 @@ db.exec(\`
3234
4227
  // Add any missing columns here so the handler upgrades its own schema instead of
3235
4228
  // forcing you to delete the database on every change \u2014 the behaviour a
3236
4229
  // production integration needs.
3237
- function ensureColumn(table: string, column: string, definition: string): void {
3238
- const cols = db.prepare(\`PRAGMA table_info(\${table})\`).all() as Array<{ name: string }>;
3239
- if (!cols.some((c) => c.name === column)) {
3240
- db.exec(\`ALTER TABLE \${table} ADD COLUMN \${column} \${definition}\`);
3241
- report.info(\`[store] migrated: added column \${table}.\${column}\`);
3242
- }
3243
- }
3244
4230
  ensureColumn('customers', 'address', "TEXT NOT NULL DEFAULT '{}'");
3245
4231
  ensureColumn('customers', 'updated_at', 'TEXT'); // nullable on migrate; set on write
3246
4232
  ensureColumn('customers', 'account_key', 'TEXT');
@@ -3544,7 +4530,12 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
3544
4530
  // Metadata only \u2014 the address itself is personal information, so the key is
3545
4531
  // logged and the address never is. Centralised log aggregation turns every
3546
4532
  // log line into a place customer addresses can be read.
3547
- 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})\` : ''}\`);
3548
4539
 
3549
4540
  return previous;
3550
4541
  }
@@ -3631,11 +4622,13 @@ import {
3631
4622
  // THE ONLY LINE THAT NAMES AN IMPLEMENTATION. Point this at your own module
3632
4623
  // exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
3633
4624
  // the protocol layer changes.
3634
- import { store } from './store.js';
3635
- import { notePreviousAddress } from './tui.js';
4625
+ import { store as writeThroughStore } from './store.js';
4626
+ import { connectorStore, setCurrentRawBody } from './connector-store.js';
4627
+ import { noteChange } from './tui.js';
3636
4628
  import { config } from './config.js';
3637
4629
  import { safeOneAddressCallbackUrl } from './callback-url.js';
3638
- import { describeKeys, keyFailureAdvice, resolvePrivateKey } from './keys.js';
4630
+ import { sameAddress } from './customer-store.js';
4631
+ import { configuredKeyIds, describeKeys, keyFailureAdvice, resolvePrivateKey } from './keys.js';
3639
4632
  import {
3640
4633
  drainConfirms,
3641
4634
  enqueueConfirm,
@@ -3644,12 +4637,27 @@ import {
3644
4637
  type ConfirmStatus,
3645
4638
  } from './confirm-queue.js';
3646
4639
  import {
4640
+ dispatchKey,
3647
4641
  heldCount,
3648
4642
  purgeQuarantine,
3649
4643
  quarantine,
3650
4644
  replayHeld,
3651
4645
  type QuarantineReason,
3652
4646
  } from './quarantine.js';
4647
+ import { recordOutcome, tally } from './tally.js';
4648
+ import { accept as acceptIntoInbox, oldestUndrawn, undrawnCount } from './inbox.js';
4649
+ import { setAcknowledgementHandler, startDrawApi } from './draw-api.js';
4650
+
4651
+ /**
4652
+ * WHICH STORE ANSWERS, chosen once at startup.
4653
+ *
4654
+ * Inbox mode does not branch the handler. It swaps the implementation of the
4655
+ * contract in \`customer-store.ts\`, so every call site below is identical in
4656
+ * both modes and the mode lives in exactly one place. \`connectorStore\` asks the
4657
+ * partner's connector and refuses to write; \`writeThroughStore\` is the bundled
4658
+ * SQLite one this receiver has always used.
4659
+ */
4660
+ const store = config.mode === 'inbox' ? connectorStore : writeThroughStore;
3653
4661
 
3654
4662
  const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? '';
3655
4663
  const PARTNER_PRIVATE_KEY = process.env.PARTNER_PRIVATE_KEY_PEM ?? '';
@@ -3669,11 +4677,38 @@ const PORT = Number(process.env.PORT ?? 3001);
3669
4677
  const ONEADDRESS_API = config.oneAddressApi;
3670
4678
  const CONFIRM_SECRET = process.env.CONFIRM_SECRET || WEBHOOK_SECRET;
3671
4679
 
3672
- if (!WEBHOOK_SECRET || !PARTNER_PRIVATE_KEY || !PARTNER_ID) {
4680
+ if (!WEBHOOK_SECRET || !PARTNER_ID) {
3673
4681
  report.error('[startup] Missing required env vars. Check your .env file.');
3674
4682
  process.exit(1);
3675
4683
  }
3676
4684
 
4685
+ // THE KEY REQUIREMENT INVERTS WITH THE MODE, and this is the assertion that
4686
+ // makes inbox mode mean something.
4687
+ //
4688
+ // Write-through cannot open a single dispatch without a private key, so a
4689
+ // missing one is fatal and always was. Inbox mode's entire property is that the
4690
+ // component reachable from the internet CANNOT read what it holds, and a key
4691
+ // sitting in this process's environment makes that false \u2014 silently, while
4692
+ // every test still passes and every dispatch still lands. Nothing would ever
4693
+ // surface it, because in inbox mode nothing here attempts a decrypt to fail.
4694
+ //
4695
+ // So it refuses to start and names the fix. A partner moving from write-through
4696
+ // to inbox has one edit to make, and being told about it once beats believing a
4697
+ // property they do not have.
4698
+ if (config.mode === 'inbox') {
4699
+ const strayKeys = configuredKeyIds();
4700
+ if (PARTNER_PRIVATE_KEY || strayKeys.length > 0) {
4701
+ report.error('[startup] mode is \`inbox\`, but this receiver has a private key in its environment.');
4702
+ report.error('[startup] Inbox mode exists so the internet-facing process CANNOT read what it stores.');
4703
+ report.error('[startup] Move PARTNER_PRIVATE_KEY_PEM' + (strayKeys.length > 0 ? ' (and the per-key-id variables)' : '') + ' to the connector\\'s .env and remove it here.');
4704
+ process.exit(1);
4705
+ }
4706
+ } else if (!PARTNER_PRIVATE_KEY) {
4707
+ report.error('[startup] PARTNER_PRIVATE_KEY_PEM is missing. Check your .env file.');
4708
+ report.error('[startup] (A receiver that should hold no key at all wants RECEIVER_MODE=inbox.)');
4709
+ process.exit(1);
4710
+ }
4711
+
3677
4712
  // Startup self-check. Two faults were historically INVISIBLE until a live
3678
4713
  // dispatch, and both read afterwards as "partner key mismatch" deep inside a
3679
4714
  // try/catch, which is a day of reading webhook logs. Assert them here so the
@@ -3703,7 +4738,15 @@ if (PARTNER_PRIVATE_KEY.includes('BEGIN')) {
3703
4738
  // variable is set when it is not, and the second commonest is still holding one
3704
4739
  // key after a rotation. Both are visible in this one line, and neither is
3705
4740
  // visible anywhere else until a dispatch fails.
3706
- report.info(\`[startup] keys: \${describeKeys()}\`);
4741
+ // In inbox mode "no key" is the CORRECT state, and \`describeKeys\` would report
4742
+ // it as "every dispatch will fail to decrypt", which is true of a write-through
4743
+ // receiver and alarming nonsense here. An operator who reads a red line every
4744
+ // boot stops reading the line.
4745
+ report.info(
4746
+ config.mode === 'inbox'
4747
+ ? '[startup] keys: none, by design \u2014 the connector holds them and this process cannot read a dispatch'
4748
+ : \`[startup] keys: \${describeKeys()}\`,
4749
+ );
3707
4750
  // WHICH STORE IS LIVE, said out loud at every boot. A receiver pointed at a
3708
4751
  // partner's own database and one still writing to the bundled demo file behave
3709
4752
  // identically until the first dispatch, and the difference is where a customer's
@@ -3880,6 +4923,21 @@ app.post('/webhook', async (req: Request, res: Response) => {
3880
4923
 
3881
4924
  const event = body.event as string;
3882
4925
 
4926
+ // PARKED FOR THE CONNECTOR STORE, and read in the same tick it is set.
4927
+ // In inbox mode the receiver holds no private key, so the only thing it can
4928
+ // hand the connector for a verify is the ciphertext that arrived. The
4929
+ // CustomerStore contract passes a decoded customer, which this process cannot
4930
+ // produce, so the bytes travel out of band rather than widening the contract
4931
+ // for one implementation. See connector-store.ts for why this is safe and
4932
+ // what would make it unsafe.
4933
+ //
4934
+ // ABOVE EVERY HANDLER THAT READS IT, and that position is the fix rather than
4935
+ // a tidy-up: this sat below \`account.verify\`, which returns long before it, so
4936
+ // in inbox mode the connector was handed the PREVIOUS request's ciphertext, or
4937
+ // an empty string on the first request of the process's life. It would have
4938
+ // answered honestly about the wrong consumer.
4939
+ setCurrentRawBody(rawBody);
4940
+
3883
4941
  // \u2500\u2500 account.verify: pre-payment account check \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
3884
4942
  // Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
3885
4943
  // no address, no session envelope \u2014 so it is handled HERE, above the address-
@@ -3894,6 +4952,20 @@ app.post('/webhook', async (req: Request, res: Response) => {
3894
4952
  return res.status(200).json({ ok: true, skipped: true });
3895
4953
  }
3896
4954
 
4955
+ // \u2500\u2500 INBOX MODE: the receiver cannot open this, and must not try \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4956
+ //
4957
+ // The decrypt below is the reason this branch exists. In inbox mode there
4958
+ // is no private key in this process, so \`decryptAddress\` would fail and
4959
+ // answer 422 \u2014 and the consumer, who is mid-payment, would be told the
4960
+ // account check failed when nothing is wrong. The connector holds the key
4961
+ // and the customer records, so it answers. \`connectorStore\` sends the raw
4962
+ // body; the arguments below are the contract's shape, not its source.
4963
+ if (config.mode === 'inbox') {
4964
+ const status = await store.verifyAccount(null, '', []);
4965
+ report.info(\`[webhook] account.verify \u2192 \${status} (answered by the connector)\`);
4966
+ return res.status(200).json({ status });
4967
+ }
4968
+
3897
4969
  const enc = body.customer_encrypted as {
3898
4970
  ephemeralPublicKey: string; iv: string; ciphertext: string; hkdfSalt?: string;
3899
4971
  } | undefined;
@@ -3936,8 +5008,113 @@ app.post('/webhook', async (req: Request, res: Response) => {
3936
5008
  * stops the quarantine being a way for anyone who can reach this port to fill
3937
5009
  * a partner's disk.
3938
5010
  */
3939
- const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void =>
5011
+ /**
5012
+ * What this dispatch IS, for anything that has to recognise it again.
5013
+ *
5014
+ * The same value the quarantine keys on, so a re-delivery - a OneAddress
5015
+ * retry of a 422, a press of [r], the same body posted twice - lands on the
5016
+ * record that is already there instead of looking like a new arrival.
5017
+ */
5018
+ const key = dispatchKey(dispatch || null, rawBody);
5019
+
5020
+ const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void => {
3940
5021
  quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
5022
+ recordOutcome(key, 'failed');
5023
+ };
5024
+
5025
+ /**
5026
+ * Post an \`address.verify\` verdict back to OneAddress.
5027
+ *
5028
+ * ONE IMPLEMENTATION, TWO CALLERS, and the second one is why it is a function.
5029
+ * Write-through decrypts the envelope and asks its own store; inbox mode
5030
+ * cannot decrypt anything and asks the connector. Everything from the verdict
5031
+ * onward is identical, including the callback-host check, and a second copy of
5032
+ * that check is a second place for it to be dropped.
5033
+ *
5034
+ * Returns a Response when it REFUSED to post and has already answered the
5035
+ * caller, and null when the verdict went out. Deliberately does NOT remember
5036
+ * the dispatch: the two callers differ on that, and burying the difference in
5037
+ * here is how one of them would get it silently wrong.
5038
+ */
5039
+ const postVerifyVerdict = async (result: string): Promise<Response | null> => {
5040
+ const callbackUrl = body.callback_url as string;
5041
+ const callbackToken = body.callback_token as string;
5042
+ const batchId = body.batch_id as string;
5043
+
5044
+ // Callback URL is signed inside the body, so HMAC verify already proves it
5045
+ // came from OneAddress. We still validate the host as defence in depth:
5046
+ // if the webhook secret ever leaks, an attacker who can forge a webhook
5047
+ // could otherwise coerce this server into POSTing to any internal
5048
+ // URL (database admin, cloud metadata service, \u2026) \u2014 turning the partner's
5049
+ // network position into an SSRF primitive.
5050
+ const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
5051
+ if (!safeCallbackUrl) {
5052
+ report.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
5053
+ return res.status(400).json({ error: 'Invalid callback_url host' });
5054
+ }
5055
+
5056
+ await fetch(safeCallbackUrl, {
5057
+ method: 'POST',
5058
+ headers: { 'Content-Type': 'application/json' },
5059
+ body: JSON.stringify({
5060
+ // 2026.2 \u2014 no member_name echo; OneAddress keys the result on
5061
+ // (batch_id, partner_id) and validates the opaque token alone.
5062
+ batch_id: batchId,
5063
+ partner_id: PARTNER_ID,
5064
+ result,
5065
+ token: callbackToken,
5066
+ }),
5067
+ });
5068
+ return null;
5069
+ };
5070
+
5071
+ // \u2500\u2500 INBOX MODE: hold it, do not open it \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
5072
+ //
5073
+ // An \`address.updated\` is stored exactly as it arrived and the connector is
5074
+ // left to collect it. The 200 below is honest and is not a claim that anything
5075
+ // was applied: the protocol already separates the two, and the CONFIRM is what
5076
+ // reports application. It fires when the connector says so, which may be
5077
+ // hours later, and \`user_services.state\` has carried \`awaiting_confirm\` for
5078
+ // that gap since long before this mode existed.
5079
+ //
5080
+ // The verify events deliberately fall through to the code below, because they
5081
+ // must be answered NOW, before a consumer pays. In inbox mode that answer
5082
+ // comes from the connector.
5083
+ // \u2500\u2500 INBOX MODE, the other half: a verify the receiver cannot open \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5084
+ //
5085
+ // \`address.verify\` has to be answered now, like \`account.verify\` above, but it
5086
+ // is answered by POSTing a callback rather than in the response body. The
5087
+ // callback URL and token travel in the CLEARTEXT body, not the envelope, so
5088
+ // this process can still post it; only the VERDICT needs a key, and that comes
5089
+ // from the connector.
5090
+ if (config.mode === 'inbox' && event === 'address.verify') {
5091
+ // The contract's arguments, not its source: \`connectorStore\` sends the raw
5092
+ // body it was parked with and ignores these.
5093
+ const result = await store.verifyAddress({ email: null, name: '' }, {});
5094
+ report.info(\`[webhook] address.verify \u2192 \${result} (answered by the connector)\`);
5095
+ const refused = await postVerifyVerdict(result);
5096
+ if (refused) return refused;
5097
+ // DELIBERATELY NOT \`rememberDispatch\`, for the same reason as the inbox
5098
+ // \`address.updated\` branch below: nothing was decrypted here, so there is
5099
+ // no state a retry would duplicate. OneAddress keys a verify result on
5100
+ // (batch_id, partner_id), so re-answering a retried check is a no-op \u2014 and
5101
+ // NOT remembering is the safer half of the trade, because a check we failed
5102
+ // to answer still gets answered on the retry instead of being dismissed.
5103
+ return res.status(200).json({ ok: true });
5104
+ }
5105
+
5106
+ if (config.mode === 'inbox' && event === 'address.updated') {
5107
+ acceptIntoInbox({ key, dispatchId: dispatch || null, event, rawBody });
5108
+ report.info(\`[inbox] held \${dispatch || key} for the connector\`);
5109
+ // DELIBERATELY NOT \`rememberDispatch\`. A guard in templates.test.ts asserts
5110
+ // that nothing is remembered before the decrypt is attempted, because a
5111
+ // dispatch marked seen too early makes OneAddress's retry look like a
5112
+ // duplicate and loses the update. Adding an exception here for a branch
5113
+ // that happens not to decrypt would weaken the guard for the branch that
5114
+ // does. It is not needed anyway: \`accept\` is INSERT OR IGNORE on the same
5115
+ // dispatch identity, so a redelivery is already harmless.
5116
+ return res.status(200).json({ ok: true, queued: true });
5117
+ }
3941
5118
 
3942
5119
  const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
3943
5120
  if (!DISPATCH_EVENTS.includes(event)) {
@@ -4016,6 +5193,9 @@ app.post('/webhook', async (req: Request, res: Response) => {
4016
5193
  return res.status(422).json({ ok: false, error: 'Decryption failed \u2014 partner key mismatch' });
4017
5194
  }
4018
5195
  } else {
5196
+ // No \`hold\` here: there is no payload to hold, so there is nothing a fix
5197
+ // could later apply. It still counts as a dispatch that did not land.
5198
+ recordOutcome(key, 'failed');
4019
5199
  return res.status(422).json({ ok: false, error: 'No encrypted payload on dispatch' });
4020
5200
  }
4021
5201
 
@@ -4030,11 +5210,23 @@ app.post('/webhook', async (req: Request, res: Response) => {
4030
5210
  const loaEncrypted = body.loa_encrypted as
4031
5211
  { session_envelope: string; session_key_share: SessionKeyShare } | null | undefined;
4032
5212
  if (loaEncrypted && typeof loaEncrypted === 'object') {
4033
- try {
4034
- const loa: OneAddressD5LOA = await decryptLoaEncrypted(loaEncrypted, PARTNER_PRIVATE_KEY, PARTNER_ID);
4035
- loaRef = d5LoaRef(loa);
4036
- } catch (err) {
4037
- report.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
5213
+ // RESOLVED BY THE LOA'S OWN key_id, not by the single PARTNER_PRIVATE_KEY_PEM.
5214
+ // It is wrapped to the same key id as the address envelope, but it carries
5215
+ // its own share, so that is what is read. Using the single key meant that
5216
+ // after a rotation with PARTNER_KEYS_STRICT=1 the address opened and the LOA
5217
+ // silently did not, losing the proof-of-consent on exactly the changes a
5218
+ // partner most wants it for, with nothing anyone would see.
5219
+ const loaKeyId = loaEncrypted.session_key_share?.key_id ?? null;
5220
+ const loaKey = resolvePrivateKey(loaKeyId);
5221
+ if (!loaKey) {
5222
+ report.warn(\`[webhook] no key for the LOA's key_id \${loaKeyId ?? '(none)'} \u2014 applying without a consent reference\`);
5223
+ } else {
5224
+ try {
5225
+ const loa: OneAddressD5LOA = await decryptLoaEncrypted(loaEncrypted, loaKey.pem, PARTNER_ID);
5226
+ loaRef = d5LoaRef(loa);
5227
+ } catch (err) {
5228
+ report.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
5229
+ }
4038
5230
  }
4039
5231
  }
4040
5232
 
@@ -4052,7 +5244,12 @@ app.post('/webhook', async (req: Request, res: Response) => {
4052
5244
 
4053
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
4054
5246
  if (event === 'address.updated') {
4055
- 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)'}\`);
4056
5253
 
4057
5254
  // AN ACCOUNT REFERENCE THAT MATCHES NOTHING MUST BE A HARD NO-MATCH.
4058
5255
  //
@@ -4084,59 +5281,56 @@ app.post('/webhook', async (req: Request, res: Response) => {
4084
5281
  if (verdict !== 'match') {
4085
5282
  report.warn(\`[webhook] address.updated REFUSED (\${verdict}) for account \${ctx.accountNumber ?? '(none)'} \u2014 nothing applied\`);
4086
5283
  queueConfirm(dispatch, 'failed');
5284
+ // A refusal is not a fault and is never held, but it IS a dispatch that
5285
+ // did not land, so it counts. See quarantine.ts for why the two differ.
5286
+ recordOutcome(key, 'failed');
4087
5287
  return res.status(200).json({ ok: false, error: 'account_not_matched', verdict });
4088
5288
  }
4089
5289
  }
4090
5290
 
4091
- // \`saveAddress\` returns the address it replaced. Handed to the dashboard so
4092
- // it can show both halves; a no-op under --headless. Passed directly rather
4093
- // than reported, because the previous address is a customer's address and
4094
- // 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.
4095
5309
  const replaced = await store.saveAddress(ctx, address);
4096
- 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
+ });
4097
5317
  if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
4098
5318
  // Close the loop back to OneAddress so the service flips to "Confirmed".
4099
5319
  // QUEUED, not sent: this is a local INSERT, so it cannot delay the 200 that
4100
5320
  // acks the delivery, and it survives a restart. The drain loop does the
4101
5321
  // network part and retries it until OneAddress answers.
4102
5322
  queueConfirm(dispatch, 'confirmed');
5323
+ recordOutcome(key, 'applied');
4103
5324
  return res.status(200).json({ ok: true });
4104
5325
  }
4105
5326
 
4106
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
4107
5328
  if (event === 'address.verify') {
4108
- const callbackUrl = body.callback_url as string;
4109
- const callbackToken = body.callback_token as string;
4110
- const batchId = body.batch_id as string;
4111
-
4112
- // Callback URL is signed inside the body, so HMAC verify already proves it
4113
- // came from OneAddress. We still validate the host as defence in depth:
4114
- // if the webhook secret ever leaks, an attacker who can forge a webhook
4115
- // could otherwise coerce this server into POSTing to any internal
4116
- // URL (database admin, cloud metadata service, \u2026) \u2014 turning the partner's
4117
- // network position into an SSRF primitive.
4118
- const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
4119
- if (!safeCallbackUrl) {
4120
- report.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
4121
- return res.status(400).json({ error: 'Invalid callback_url host' });
4122
- }
4123
-
4124
- report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
5329
+ report.info(\`[webhook] address.verify for \${ctx.accountNumber || '(no account reference)'}\`);
4125
5330
  const result = await store.verifyAddress(ctx, address);
4126
5331
  report.info(\`[webhook] address.verify \u2192 \${result}\`);
4127
-
4128
- await fetch(safeCallbackUrl, {
4129
- method: 'POST',
4130
- headers: { 'Content-Type': 'application/json' },
4131
- body: JSON.stringify({
4132
- // 2026.2 \u2014 no member_name echo; OneAddress keys the result on
4133
- // (batch_id, partner_id) and validates the opaque token alone.
4134
- batch_id: batchId,
4135
- partner_id: PARTNER_ID,
4136
- result,
4137
- token: callbackToken,
4138
- }),
4139
- });
5332
+ const refused = await postVerifyVerdict(result);
5333
+ if (refused) return refused;
4140
5334
  if (dispatch) rememberDispatch(dispatch); // remember only after the callback posted
4141
5335
  return res.status(200).json({ ok: true });
4142
5336
  }
@@ -4236,13 +5430,12 @@ setInterval(() => {
4236
5430
  * anyone has fixed anything. We hold the secret, so re-signing is not a bypass:
4237
5431
  * it is the same proof, re-stated now.
4238
5432
  */
4239
- async function redeliver(rawBody: string): Promise<void> {
5433
+ async function redeliver(rawBody: string, heldDispatchId: string | null): Promise<void> {
4240
5434
  const ts = String(Math.floor(Date.now() / 1000));
4241
5435
  const sig = createHmac('sha256', WEBHOOK_SECRET).update(\`\${ts}.\${rawBody}\`).digest('hex');
4242
- let dispatchId = '';
4243
- try {
4244
- dispatchId = String((JSON.parse(rawBody) as { dispatch_id?: unknown }).dispatch_id ?? '');
4245
- } catch { /* the handler will reject it as bad JSON, which is the right answer */ }
5436
+ // THE ID THE DISPATCH ARRIVED WITH, handed over by the row being replayed.
5437
+ // Re-deriving it from the body is what made one held dispatch become two.
5438
+ const dispatchId = (heldDispatchId ?? '').trim();
4246
5439
 
4247
5440
  const res = await fetch(\`http://127.0.0.1:\${PORT}/webhook\`, {
4248
5441
  method: 'POST',
@@ -4277,6 +5470,29 @@ export async function replayQuarantined(): Promise<{ applied: number; failed: nu
4277
5470
  * moment they are least inclined to. The delay lets \`listen\` settle, since this
4278
5471
  * goes back in through the port.
4279
5472
  */
5473
+ // \u2500\u2500 The connector channel, in inbox mode only \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
5474
+ //
5475
+ // Wired here rather than in draw-api.ts because this file owns the confirm
5476
+ // queue: an acknowledgement from the connector is the moment OneAddress gets
5477
+ // told, and that has to go through the same durable queue every other confirm
5478
+ // uses so a failure to reach OneAddress is retried rather than lost.
5479
+ if (config.mode === 'inbox') {
5480
+ setAcknowledgementHandler((dispatchId, outcome) => {
5481
+ queueConfirm(dispatchId, outcome === 'applied' ? 'confirmed' : 'failed');
5482
+ recordOutcome(\`d:\${dispatchId}\`, outcome === 'applied' ? 'applied' : 'failed');
5483
+ });
5484
+ const started = startDrawApi();
5485
+ if (!started) {
5486
+ // AN INBOX WITH NO WAY TO DRAIN IT IS WORSE THAN NOT STARTING. In
5487
+ // write-through mode a missing connector token is irrelevant and the
5488
+ // receiver runs; here it means every dispatch would be accepted and held
5489
+ // with nothing able to collect it, and the consumer would be told nothing
5490
+ // for as long as that lasted.
5491
+ report.error('[startup] mode is \`inbox\` but the connector channel could not open. Refusing to start.');
5492
+ process.exit(1);
5493
+ }
5494
+ }
5495
+
4280
5496
  setTimeout(() => {
4281
5497
  if (heldCount() === 0) return;
4282
5498
  void replayQuarantined().catch((err: unknown) =>
@@ -4291,8 +5507,34 @@ setTimeout(() => {
4291
5507
  const QUARANTINE_KEEP_DAYS = Number(process.env.QUARANTINE_KEEP_DAYS ?? 30);
4292
5508
  setInterval(() => { purgeQuarantine(QUARANTINE_KEEP_DAYS); }, 3_600_000).unref();
4293
5509
 
4294
- /** How many confirms are still owed. Read by the dashboard. */
4295
- export { pendingConfirmCount };
5510
+ /** How many confirms are still owed, and how the dispatches went. Read by the dashboard. */
5511
+ export { pendingConfirmCount, tally };
5512
+
5513
+ /**
5514
+ * Everything the dashboard footer needs, in one call.
5515
+ *
5516
+ * One hook rather than three, because three would be three chances for the
5517
+ * footer to show figures from different moments.
5518
+ */
5519
+ export function dashboardStats(): {
5520
+ received: number;
5521
+ applied: number;
5522
+ failed: number;
5523
+ mode: string;
5524
+ awaitingConnector: number;
5525
+ oldestUndrawn: string | null;
5526
+ } {
5527
+ const t = tally();
5528
+ const inbox = config.mode === 'inbox'
5529
+ ? { count: undrawnCount(), oldest: oldestUndrawn() }
5530
+ : { count: 0, oldest: null };
5531
+ return {
5532
+ ...t,
5533
+ mode: config.mode,
5534
+ awaitingConnector: inbox.count,
5535
+ oldestUndrawn: inbox.oldest,
5536
+ };
5537
+ }
4296
5538
 
4297
5539
  // Read by src/index.ts to label the dashboard. Exported rather than re-derived
4298
5540
  // there, so the port the UI claims is the port the server actually bound.
@@ -9493,11 +10735,49 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
9493
10735
  };
9494
10736
 
9495
10737
  // src/scaffold.ts
9496
- function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi) {
9497
- 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");
9498
10778
  }
9499
10779
  var SENSITIVE_FILES = /* @__PURE__ */ new Set([".env"]);
9500
- 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 = "") {
9501
10781
  const templates = TEMPLATES[platform];
9502
10782
  if (!templates) throw new Error(`Unknown platform: ${platform}`);
9503
10783
  const written = [];
@@ -9507,7 +10787,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
9507
10787
  if (!(0, import_node_fs.existsSync)(dir)) {
9508
10788
  await (0, import_promises.mkdir)(dir, { recursive: true });
9509
10789
  }
9510
- const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi);
10790
+ const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken);
9511
10791
  const isSensitive = SENSITIVE_FILES.has((0, import_node_path.basename)(name));
9512
10792
  await (0, import_promises.writeFile)(dest, filled, { encoding: "utf8", mode: isSensitive ? 384 : 420 });
9513
10793
  if (isSensitive && process.platform !== "win32") {
@@ -9522,11 +10802,11 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
9522
10802
  }
9523
10803
 
9524
10804
  // src/register.ts
9525
- var import_node_crypto = require("crypto");
9526
- var PKG_VERSION = true ? "2.1.3" : "dev";
10805
+ var import_node_crypto2 = require("crypto");
10806
+ var PKG_VERSION = true ? "2.3.0" : "dev";
9527
10807
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
9528
10808
  function hmacSha256(secret, message) {
9529
- 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");
9530
10810
  }
9531
10811
  async function registerInstall(installId, partnerId, webhookSecret, platform, webhookUrl) {
9532
10812
  try {
@@ -9559,10 +10839,10 @@ async function registerInstall(installId, partnerId, webhookSecret, platform, we
9559
10839
  }
9560
10840
 
9561
10841
  // src/conformance.ts
9562
- var import_node_crypto2 = require("crypto");
10842
+ var import_node_crypto3 = require("crypto");
9563
10843
  async function runConformance(webhookUrl, _partnerId, webhookSecret) {
9564
10844
  function sign(ts, body) {
9565
- 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");
9566
10846
  }
9567
10847
  const validBody = JSON.stringify({ event: "conformance.ping", test: true });
9568
10848
  const now = String(Math.floor(Date.now() / 1e3));
@@ -9603,7 +10883,7 @@ async function runConformance(webhookUrl, _partnerId, webhookSecret) {
9603
10883
  // Unique per test run so a partner re-running `npm test` doesn't
9604
10884
  // hit the scaffolded server's dedup cache and short-circuit the
9605
10885
  // "accepted" assertion with { ok: true, duplicate: true }.
9606
- "X-OneAddress-Dispatch": `conformance-${(0, import_node_crypto2.randomUUID)()}`
10886
+ "X-OneAddress-Dispatch": `conformance-${(0, import_node_crypto3.randomUUID)()}`
9607
10887
  },
9608
10888
  body: validBody,
9609
10889
  signal: AbortSignal.timeout(8e3)
@@ -9655,7 +10935,7 @@ async function runDecryptCheck(webhookSecret, partnerId) {
9655
10935
  async function callOnce() {
9656
10936
  try {
9657
10937
  const ts = String(Math.floor(Date.now() / 1e3));
9658
- 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");
9659
10939
  const res2 = await fetch(VERIFY_DECRYPT_API, {
9660
10940
  method: "POST",
9661
10941
  headers: {
@@ -9730,24 +11010,46 @@ var COMMANDS = {
9730
11010
  function installDependencies(platform, outputDir) {
9731
11011
  const spec = COMMANDS[platform];
9732
11012
  if (!spec) {
9733
- return { ok: true, output: "", manualCommand: "" };
11013
+ return Promise.resolve({ ok: true, output: "", manualCommand: "" });
9734
11014
  }
9735
11015
  const cwd = spec.cwd ? (0, import_node_path2.join)(outputDir, spec.cwd) : outputDir;
9736
11016
  const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
9737
- const result = (0, import_node_child_process.spawnSync)(spec.cmd, spec.args, {
9738
- cwd,
9739
- stdio: "pipe",
9740
- encoding: "utf8",
9741
- timeout: 5 * 60 * 1e3,
9742
- // 5 min max
9743
- 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
+ });
9744
11052
  });
9745
- const output = [result.stdout ?? "", result.stderr ?? ""].join("").trim();
9746
- return {
9747
- ok: result.status === 0 && !result.error,
9748
- output,
9749
- manualCommand
9750
- };
9751
11053
  }
9752
11054
 
9753
11055
  // src/autostart.ts
@@ -9932,7 +11234,7 @@ async function handOverTerminal(platform, outputDir, port, secrets = {}) {
9932
11234
  var import_node_child_process3 = require("child_process");
9933
11235
  var import_promises2 = require("fs/promises");
9934
11236
  var import_node_fs2 = require("fs");
9935
- var import_node_crypto3 = require("crypto");
11237
+ var import_node_crypto4 = require("crypto");
9936
11238
  var import_node_path3 = require("path");
9937
11239
  var import_node_os = __toESM(require("os"));
9938
11240
  var tunnelProcess = null;
@@ -9971,7 +11273,7 @@ function platformKey() {
9971
11273
  return "linux-x64";
9972
11274
  }
9973
11275
  async function sha256File(path) {
9974
- const hash = (0, import_node_crypto3.createHash)("sha256");
11276
+ const hash = (0, import_node_crypto4.createHash)("sha256");
9975
11277
  hash.update(await (0, import_promises2.readFile)(path));
9976
11278
  return hash.digest("hex");
9977
11279
  }
@@ -10105,7 +11407,7 @@ async function startTunnel(port) {
10105
11407
  }
10106
11408
 
10107
11409
  // src/register-url.ts
10108
- var import_node_crypto4 = require("crypto");
11410
+ var import_node_crypto5 = require("crypto");
10109
11411
  var PORTAL_API = "https://partners.oneaddress.io/api/partner/webhook-url";
10110
11412
  function describeReadFailure(status) {
10111
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.";
@@ -10116,7 +11418,7 @@ function describeReadFailure(status) {
10116
11418
  async function getExistingWebhookUrl(partnerId, webhookSecret, onError) {
10117
11419
  try {
10118
11420
  const ts = String(Math.floor(Date.now() / 1e3));
10119
- 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");
10120
11422
  const res = await fetch(PORTAL_API, {
10121
11423
  method: "GET",
10122
11424
  headers: {
@@ -10140,7 +11442,7 @@ async function getExistingWebhookUrl(partnerId, webhookSecret, onError) {
10140
11442
  async function getVerifiesAccountReference(partnerId, webhookSecret, onError) {
10141
11443
  try {
10142
11444
  const ts = String(Math.floor(Date.now() / 1e3));
10143
- 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");
10144
11446
  const res = await fetch(PORTAL_API, {
10145
11447
  method: "GET",
10146
11448
  headers: {
@@ -10167,7 +11469,7 @@ async function getVerifiesAccountReference(partnerId, webhookSecret, onError) {
10167
11469
  async function verifyWebhookSecret(partnerId, webhookSecret) {
10168
11470
  try {
10169
11471
  const ts = String(Math.floor(Date.now() / 1e3));
10170
- 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");
10171
11473
  const res = await fetch(PORTAL_API, {
10172
11474
  method: "GET",
10173
11475
  headers: {
@@ -10188,7 +11490,7 @@ async function registerWebhookUrl(partnerId, webhookSecret, webhookUrl) {
10188
11490
  try {
10189
11491
  const body = JSON.stringify({ webhook_url: webhookUrl });
10190
11492
  const ts = String(Math.floor(Date.now() / 1e3));
10191
- 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");
10192
11494
  const res = await fetch(PORTAL_API, {
10193
11495
  method: "PATCH",
10194
11496
  headers: {
@@ -10385,7 +11687,7 @@ ${wrapped}
10385
11687
  -----END EC PRIVATE KEY-----
10386
11688
  `;
10387
11689
  try {
10388
- 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" });
10389
11691
  return { pem: pkcs8 };
10390
11692
  } catch (err) {
10391
11693
  return { pem: "", error: translateKeyError(err) };
@@ -10429,7 +11731,7 @@ function translateKeyError(err) {
10429
11731
  function validatePrivateKey(pem) {
10430
11732
  let key;
10431
11733
  try {
10432
- 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" });
10433
11735
  } catch (err) {
10434
11736
  return translateKeyError(err);
10435
11737
  }
@@ -10550,6 +11852,39 @@ async function main() {
10550
11852
  accountRefReason = r2;
10551
11853
  });
10552
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
+ }
10553
11888
  if (accountRefDeclaration === void 0) {
10554
11889
  M2.warn(
10555
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."
@@ -10567,7 +11902,10 @@ async function main() {
10567
11902
  "",
10568
11903
  // webhookUrl — filled in after server starts
10569
11904
  privateKey,
10570
- verifiesAccountReference
11905
+ verifiesAccountReference,
11906
+ void 0,
11907
+ receiverMode,
11908
+ connectorToken
10571
11909
  );
10572
11910
  s1.stop(`Scaffolded ${written.length} files in ${outDir}`);
10573
11911
  for (const f of written) M2.success(` ${f}`);
@@ -10579,7 +11917,7 @@ async function main() {
10579
11917
  for (; ; ) {
10580
11918
  const s2 = Y2();
10581
11919
  s2.start("Installing dependencies (this can take 30\u201360 s)");
10582
- const install = installDependencies(platform, outDir);
11920
+ const install = await installDependencies(platform, outDir);
10583
11921
  if (install.ok) {
10584
11922
  s2.stop("Dependencies installed");
10585
11923
  break;
@@ -10749,7 +12087,7 @@ one of two things, and neither is your server (it is running on :3001):
10749
12087
  );
10750
12088
  }
10751
12089
  registerInstall(
10752
- `OA-${(0, import_node_crypto5.randomUUID)()}`,
12090
+ `OA-${(0, import_node_crypto6.randomUUID)()}`,
10753
12091
  pid,
10754
12092
  secret,
10755
12093
  platform,