@oneaddress/setup 2.1.2 → 2.2.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 +2756 -259
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
856
856
  var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
857
857
  var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
858
858
  var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
859
- var WIZARD_VERSION = true ? "2.1.2" : "?";
859
+ var WIZARD_VERSION = true ? "2.2.0" : "?";
860
860
  function printCompactHeader() {
861
861
  const INNER = 42;
862
862
  const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
@@ -941,6 +941,20 @@ WEBHOOK_SECRET=your-webhook-secret-here
941
941
  # -----BEGIN PRIVATE KEY-----\\nMIGHAgEA...\\n-----END PRIVATE KEY-----
942
942
  PARTNER_PRIVATE_KEY_PEM=
943
943
 
944
+ # ROTATING A KEY? Every dispatch names the key it was encrypted to, and both
945
+ # keys are valid during the overlap window. Set one variable per key, named for
946
+ # its key_id with dashes as underscores, upper-cased, and keep the OLD one set
947
+ # until the window closes:
948
+ #
949
+ # PARTNER_PRIVATE_KEY_PEM_04032299_4B04_4842_AA29_5095500C8ECE=...
950
+ #
951
+ # PARTNER_PRIVATE_KEY_PEM above is then a fallback, used for any key_id with no
952
+ # variable of its own. Convenient with one key and risky with two, because it
953
+ # answers for an id it does not hold and the failure looks like corruption
954
+ # rather than like a rotation. Set PARTNER_KEYS_STRICT=1 to turn it off once you
955
+ # have more than one key.
956
+ PARTNER_KEYS_STRICT=
957
+
944
958
  # HTTP port for the local webhook server
945
959
  PORT=3001
946
960
  `
@@ -1073,6 +1087,7 @@ data.db-shm
1073
1087
  */
1074
1088
  import { DatabaseSync } from 'node:sqlite';
1075
1089
  import { join } from 'node:path';
1090
+ import { report } from './report.js';
1076
1091
  import {
1077
1092
  accountIndex,
1078
1093
  buildVerifier,
@@ -1215,6 +1230,22 @@ export { PassphraseRequiredError, WrongPassphraseError };
1215
1230
  * separates them.
1216
1231
  */
1217
1232
  export { isEncrypted };
1233
+
1234
+ /**
1235
+ * Add a column to an existing table if it is not already there.
1236
+ *
1237
+ * SQLite has no \`ADD COLUMN IF NOT EXISTS\`, and a receiver that has been
1238
+ * running since before a column existed still has to start. Lives here rather
1239
+ * than in one of the two files that needs it, because the second copy is how
1240
+ * the two drift.
1241
+ */
1242
+ export function ensureColumn(table: string, column: string, definition: string): void {
1243
+ const cols = db.prepare(\`PRAGMA table_info(\${table})\`).all() as Array<{ name: string }>;
1244
+ if (cols.some((c) => c.name === column)) return;
1245
+ db.exec(\`ALTER TABLE \${table} ADD COLUMN \${column} \${definition}\`);
1246
+ report.info(\`[db] migrated: added column \${table}.\${column}\`);
1247
+ }
1248
+
1218
1249
  export default db;
1219
1250
  `
1220
1251
  },
@@ -1646,7 +1677,12 @@ export function formatLine(line: ReportLine): string {
1646
1677
  import blessed from 'blessed';
1647
1678
  import { HEX, ONE_ROWS, ADDRESS_ROWS, terminalFitsFullMark } from './brand.js';
1648
1679
  import { formatLine, report, type ReportLine } from './report.js';
1649
- import { allCustomers, customerCount, storeEncrypted, type StoredCustomer } from './store.js';
1680
+ // One import, same as server.ts. Swapping the store swaps what the dashboard
1681
+ // reads, with nothing here to change.
1682
+ import { store } from './store.js';
1683
+ import type { StoredCustomer } from './customer-store.js';
1684
+ import { pendingConfirmCount } from './confirm-queue.js';
1685
+ import { exportHeld, heldCount, heldSummary } from './quarantine.js';
1650
1686
 
1651
1687
  /** blessed takes colours as strings; these mirror the site's palette. */
1652
1688
  const AMBER = HEX.amber.toLowerCase();
@@ -1661,6 +1697,29 @@ export interface TuiOptions {
1661
1697
  port: number;
1662
1698
  /** Called when the operator quits, so the caller can close the server. */
1663
1699
  onQuit: () => void;
1700
+ /**
1701
+ * Re-apply everything the receiver could not open, bound to [r].
1702
+ *
1703
+ * Passed IN rather than imported: \`server.ts\` already imports this file for
1704
+ * \`notePreviousAddress\`, so importing it back would be a cycle. Optional so
1705
+ * the dashboard still renders for a caller that has no replay to offer.
1706
+ */
1707
+ onReplay?: () => Promise<{ applied: number; failed: number }>;
1708
+ /**
1709
+ * How the dispatches went, asked of the receiver rather than counted here.
1710
+ *
1711
+ * Same reason as \`onReplay\`: \`server.ts\` already imports this file, so this
1712
+ * file cannot import it back. Optional, and a caller that omits it gets
1713
+ * zeroes, which is honest for a dashboard driving nothing.
1714
+ */
1715
+ stats?: () => {
1716
+ received: number;
1717
+ applied: number;
1718
+ failed: number;
1719
+ mode: string;
1720
+ awaitingConnector: number;
1721
+ oldestUndrawn: string | null;
1722
+ };
1664
1723
  }
1665
1724
 
1666
1725
  /** One address as a single line, the way the change panel shows it. */
@@ -1669,7 +1728,7 @@ function oneLine(a: Record<string, unknown>): string {
1669
1728
  return parts.length > 0 ? parts.join(', ') : '(empty)';
1670
1729
  }
1671
1730
 
1672
- export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void {
1731
+ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: TuiOptions): void {
1673
1732
  const screen = blessed.screen({
1674
1733
  smartCSR: true,
1675
1734
  title: \`\${partnerName} \u2014 OneAddress receiver\`,
@@ -1722,6 +1781,25 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1722
1781
  style: { border: { fg: DIM }, label: { fg: DIM } },
1723
1782
  });
1724
1783
 
1784
+ // \u2500\u2500 The faults band: dispatches that arrived and could not be opened \u2500\u2500\u2500\u2500\u2500\u2500
1785
+ //
1786
+ // HIDDEN WHEN THERE IS NOTHING WRONG, and that is a deliberate departure from
1787
+ // how the rest of this screen works. The other panels are always drawn,
1788
+ // including the ENCRYPTED/UNENCRYPTED line, because a property you only
1789
+ // mention when it holds is one nobody notices the absence of. A faults panel
1790
+ // is the other case: a permanent empty box teaches the eye to skip that
1791
+ // region, which is precisely the region that has to be noticed the one day it
1792
+ // fills. So it appears, and the two panels above give up the rows.
1793
+ // Six, not seven. Every row this takes comes off the two panels above it, and
1794
+ // the one above left cannot afford to lose any: see renderChange.
1795
+ const FAULT_HEIGHT = 6;
1796
+ const faultBox = blessed.box({
1797
+ parent: screen, bottom: 3, left: 0, width: '100%', height: FAULT_HEIGHT,
1798
+ label: ' FAULTS ', tags: true, padding: { left: 1, right: 1 }, hidden: true,
1799
+ border: { type: 'line' } as never,
1800
+ style: { border: { fg: 'red' }, label: { fg: 'red' } },
1801
+ });
1802
+
1725
1803
  const footer = blessed.box({
1726
1804
  parent: screen, bottom: 0, left: 0, width: '100%', height: 3,
1727
1805
  tags: true, padding: { left: 2 },
@@ -1730,9 +1808,6 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1730
1808
  });
1731
1809
 
1732
1810
  // \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
1733
- let received = 0;
1734
- let applied = 0;
1735
- let failed = 0;
1736
1811
  let lastChange: { customer: StoredCustomer; previous: Record<string, unknown> } | null = null;
1737
1812
  let pendingPrevious: Record<string, unknown> = {};
1738
1813
 
@@ -1745,14 +1820,25 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1745
1820
  // Both states are shown, and the unprotected one is the loud colour. A
1746
1821
  // security property mentioned only when it holds is one nobody notices the
1747
1822
  // absence of.
1748
- const vault = storeEncrypted
1749
- ? \`{green-fg}{bold}ENCRYPTED{/}\`
1750
- : \`{red-fg}{bold}UNENCRYPTED{/}\`;
1823
+ const inbox = statsMode() === 'inbox';
1824
+ // IN INBOX MODE THERE IS NO CUSTOMER FILE HERE AT ALL, so neither ENCRYPTED
1825
+ // nor UNENCRYPTED is true and both would mislead. The receiver holds
1826
+ // ciphertext it cannot open; the customers live in the partner's own
1827
+ // database, behind their own controls.
1828
+ const vault = inbox
1829
+ ? \`{\${AMBER}-fg}{bold}NONE (inbox){/}\`
1830
+ : store.encrypted
1831
+ ? \`{green-fg}{bold}ENCRYPTED{/}\`
1832
+ : \`{red-fg}{bold}UNENCRYPTED{/}\`;
1751
1833
  status.setContent(
1752
1834
  \`{\${AMBER}-fg}{bold}\${esc(partnerName)}{/} \` +
1753
1835
  \`{\${DIM}-fg}listening{/} {\${CREAM}-fg}:\${port}/webhook{/} \` +
1754
1836
  \`{\${DIM}-fg}customer file{/} \${vault} \` +
1755
- \`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${customerCount().toLocaleString()}{/}\`,
1837
+ // A DASH RATHER THAN A ZERO when the store declines to count. Zero is a
1838
+ // claim ("you have no customers") and would be a lie on a receiver
1839
+ // pointed at a real customer table, where counting every row forty times
1840
+ // a minute is the thing the store is right to refuse.
1841
+ \`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}\`,
1756
1842
  );
1757
1843
  }
1758
1844
 
@@ -1768,54 +1854,191 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1768
1854
  const { customer, previous } = lastChange;
1769
1855
  let now: Record<string, unknown> = {};
1770
1856
  try { now = JSON.parse(customer.address) as Record<string, unknown>; } catch { /* keep empty */ }
1857
+ // COMPACT, AND THAT IS A BUG FIX RATHER THAN A TIDY-UP.
1858
+ //
1859
+ // This used to space the lines out with blanks, which needed eight rows.
1860
+ // The faults band takes seven rows off this panel when it appears, so on a
1861
+ // real terminal the last line fell off the bottom - and the last line is
1862
+ // \`now\`, the one thing the panel exists to show. blessed clips silently, so
1863
+ // it read as a dispatch that had half worked.
1864
+ //
1865
+ // It cost a real end-to-end run an hour of doubt: the address had been
1866
+ // applied, confirmed and acknowledged by OneAddress, and the screen showed
1867
+ // only what it used to be. Adjacent is better anyway, because comparing two
1868
+ // lines a blank apart is harder than comparing two lines.
1771
1869
  changeBox.setContent(
1772
- \`\\n {bold}\${esc(customer.name)}{/bold}\\n\` +
1870
+ \`\\n {bold}\${esc(customer.name)}{/bold}\` +
1773
1871
  \` {\${DIM}-fg}account{/} {\${AMBER}-fg}\${esc(customer.account_number)}{/}\\n\\n\` +
1774
- \` {red-fg}was{/} \${esc(oneLine(previous))}\\n\\n\` +
1775
- \` {green-fg}now{/} {\${CREAM}-fg}\${esc(oneLine(now))}{/}\\n\`,
1872
+ \` {red-fg}was{/} \${esc(oneLine(previous))}\\n\` +
1873
+ \` {green-fg}now{/} {\${CREAM}-fg}\${esc(oneLine(now))}{/}\`,
1874
+ );
1875
+ }
1876
+
1877
+ /** Shown while a replay is running, so [r] does not look like it did nothing. */
1878
+ let replayNote = '';
1879
+
1880
+ /**
1881
+ * The customer count, refreshed out of band.
1882
+ *
1883
+ * CACHED RATHER THAN READ DURING RENDER, because \`count()\` may be async and a
1884
+ * render cannot await. It starts null, which is also what a store that
1885
+ * declines to count returns forever, so the dash below is the honest initial
1886
+ * state rather than a placeholder that happens to look the same.
1887
+ */
1888
+ const statsMode = (): string => stats?.().mode ?? 'write-through';
1889
+
1890
+ let onFile: number | null = null;
1891
+ function refreshCount(): void {
1892
+ void Promise.resolve(store.count())
1893
+ .then((n) => { onFile = n; })
1894
+ .catch(() => { onFile = null; });
1895
+ }
1896
+ refreshCount();
1897
+
1898
+ function renderFaults(): void {
1899
+ const held = heldCount();
1900
+ const show = held > 0;
1901
+ if (show === Boolean(faultBox.hidden)) {
1902
+ // Visibility is changing, so the panels above have to give back or take
1903
+ // back the rows. Assigning \`bottom\` is how blessed re-lays-out; it reads
1904
+ // the value on the next render rather than caching a computed box.
1905
+ if (show) faultBox.show(); else faultBox.hide();
1906
+ const edge = show ? 3 + FAULT_HEIGHT : 3;
1907
+ (changeBox as unknown as { bottom: number }).bottom = edge;
1908
+ (logBox as unknown as { bottom: number }).bottom = edge;
1909
+ }
1910
+ if (!show) return;
1911
+
1912
+ // Grouped, never one line per dispatch. The realistic shape of this table
1913
+ // is forty rows with ONE cause between them, and forty identical lines hide
1914
+ // the single fact that matters.
1915
+ const lines = heldSummary().slice(0, 2).map((l) => \` {red-fg}\${esc(l)}{/}\`);
1916
+ faultBox.setContent(
1917
+ \`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open. \` +
1918
+ \`{\${DIM}-fg}Held, encrypted, exactly as they arrived.{/}\\n\` +
1919
+ lines.join('\\n') + '\\n' +
1920
+ (replayNote
1921
+ ? \` {\${AMBER}-fg}\${esc(replayNote)}{/}\`
1922
+ : \` {\${DIM}-fg}Fix the cause, then{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply,{/} {\${AMBER}-fg}[e]{/} {\${DIM}-fg}to export the list.{/}\`),
1776
1923
  );
1777
1924
  }
1778
1925
 
1779
1926
  function renderFooter(): void {
1927
+ // AWAITING IS THE ONE AN OPERATOR CANNOT AFFORD TO MISS, so it is drawn
1928
+ // whether or not it is zero. A dispatch that was applied here but never
1929
+ // acknowledged back to OneAddress shows the consumer a FAILED delivery for
1930
+ // an update that in fact succeeded, and the auto-refund cron treats a
1931
+ // terminal failure as refundable: the work is done and the money goes back.
1932
+ // Read from the queue rather than counted here, so a receiver restarted
1933
+ // with a backlog shows the backlog instead of zero.
1934
+ const awaiting = pendingConfirmCount();
1935
+ const awaitingColour = awaiting > 0 ? AMBER : DIM;
1936
+ // ASKED, not accumulated. The server counts per dispatch, so a redelivery
1937
+ // updates a record rather than adding one.
1938
+ const s = stats?.() ?? {
1939
+ received: 0, applied: 0, failed: 0,
1940
+ mode: 'write-through', awaitingConnector: 0, oldestUndrawn: null,
1941
+ };
1942
+ const { received, applied, failed } = s;
1943
+ // AWAITING YOUR SYSTEMS, in inbox mode only. Drawn whether or not it is
1944
+ // zero there, and absent entirely in write-through, because a counter for a
1945
+ // thing that cannot happen is noise that teaches the eye to skip the row.
1946
+ const awaitingYou = s.mode === 'inbox'
1947
+ ? \`{\${s.awaitingConnector > 0 ? AMBER : DIM}-fg}awaiting your systems{/} \` +
1948
+ \`{bold}\${s.awaitingConnector}{/bold} \`
1949
+ : '';
1780
1950
  footer.setContent(
1781
1951
  \`{\${DIM}-fg}received{/} {bold}\${received}{/bold} \` +
1782
1952
  \`{green-fg}applied{/} {bold}\${applied}{/bold} \` +
1783
- \`{red-fg}failed{/} {bold}\${failed}{/bold}\` +
1784
- \`{|}{\${DIM}-fg}[q] quit{/} \`,
1953
+ \`{red-fg}failed{/} {bold}\${failed}{/bold} \` +
1954
+ awaitingYou +
1955
+ \`{\${awaitingColour}-fg}awaiting confirm{/} {bold}\${awaiting}{/bold}\` +
1956
+ \`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [q] quit{/} \`,
1785
1957
  );
1786
1958
  }
1787
1959
 
1788
1960
  function redraw(): void {
1789
1961
  renderStatus();
1790
1962
  renderChange();
1963
+ renderFaults();
1791
1964
  renderFooter();
1792
1965
  screen.render();
1793
1966
  }
1794
1967
 
1795
1968
  // \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
1796
- // Counters are derived from the reported lines rather than from a second
1797
- // channel the server would have to remember to update. One source, so the
1798
- // footer cannot disagree with the log beside it.
1969
+ // NARRATION ONLY. It used to derive the footer's counters from these lines
1970
+ // too, on the reasoning that one source cannot disagree with itself. That
1971
+ // held until replay shipped: a replay produces exactly the lines an arrival
1972
+ // does, so pressing [r] against a key that could never work drove \`received\`
1973
+ // from 5 to 67 while nothing arrived. A log line says what happened and not
1974
+ // what it happened TO, so two lines about one dispatch are indistinguishable
1975
+ // from two dispatches. The counters key on the dispatch now; see
1976
+ // \`src/tally.ts\`.
1799
1977
  const detach = report.attach((line: ReportLine) => {
1800
1978
  const text = formatLine(line);
1801
1979
  const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
1802
1980
  const time = new Date(line.at).toTimeString().slice(0, 8);
1803
1981
 
1804
- if (/address\\.updated for /.test(text)) received++;
1805
- if (/REFUSED|decryption failed|Decryption failed/.test(text)) failed++;
1806
1982
  if (/\\[store\\] saved address for /.test(text)) {
1807
- applied++;
1808
1983
  // The store logs the account key and never the address, so the panel is
1809
1984
  // refreshed from the DATABASE rather than parsed out of the log line.
1810
1985
  const acct = /saved address for (\\S+)/.exec(text)?.[1];
1811
- const customer = allCustomers().find((c) => c.account_number === acct);
1812
- if (customer) lastChange = { customer, previous: pendingPrevious };
1986
+ // An indexed lookup of the one customer, not a decrypt of the whole
1987
+ // roster to find them. Invisible at three rows and absurd at four
1988
+ // million, which is the scale a real store is pointed at.
1989
+ const prev = pendingPrevious;
1990
+ if (acct) {
1991
+ void Promise.resolve(store.find(acct)).then((customer) => {
1992
+ if (!customer) return;
1993
+ lastChange = { customer, previous: prev };
1994
+ redraw();
1995
+ });
1996
+ }
1813
1997
  }
1814
1998
 
1815
1999
  logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
1816
2000
  redraw();
1817
2001
  });
1818
2002
 
2003
+ // [r] REPLAYS. Bound unconditionally rather than only while faults exist, so
2004
+ // pressing it on a clean receiver says "nothing held" instead of doing
2005
+ // nothing, which is indistinguishable from a wedged UI.
2006
+ let replaying = false;
2007
+ screen.key(['r'], () => {
2008
+ if (!onReplay || replaying) return;
2009
+ replaying = true;
2010
+ replayNote = 'Replaying\u2026';
2011
+ redraw();
2012
+ void onReplay()
2013
+ .then(({ applied, failed }) => {
2014
+ replayNote = failed > 0
2015
+ ? \`Applied \${applied}, \${failed} still failing \u2014 the cause is not fixed yet.\`
2016
+ : \`Applied \${applied}. Nothing left held.\`;
2017
+ })
2018
+ .catch((err: unknown) => {
2019
+ replayNote = \`Replay failed: \${err instanceof Error ? err.message : String(err)}\`;
2020
+ })
2021
+ .finally(() => { replaying = false; redraw(); });
2022
+ });
2023
+
2024
+ // [e] EXPORTS THE FAULT LIST. Metadata only, no payloads: see exportHeld.
2025
+ // Bound unconditionally for the same reason [r] is, so pressing it on a clean
2026
+ // receiver says there is nothing to export rather than appearing to hang.
2027
+ screen.key(['e'], () => {
2028
+ if (heldCount() === 0) {
2029
+ replayNote = 'Nothing held, so nothing to export.';
2030
+ redraw();
2031
+ return;
2032
+ }
2033
+ try {
2034
+ const { path, count } = exportHeld(process.cwd());
2035
+ replayNote = \`Exported \${count} to \${path}\`;
2036
+ } catch (err) {
2037
+ replayNote = \`Export failed: \${err instanceof Error ? err.message : String(err)}\`;
2038
+ }
2039
+ redraw();
2040
+ });
2041
+
1819
2042
  screen.key(['q', 'C-c'], () => {
1820
2043
  detach();
1821
2044
  screen.destroy();
@@ -1823,6 +2046,14 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1823
2046
  process.exit(0);
1824
2047
  });
1825
2048
 
2049
+ // EVERY OTHER REDRAW IS TRIGGERED BY A REPORTED LINE, and the awaiting count
2050
+ // is the one number that can change without one. The confirm backoff caps at
2051
+ // an hour, so a confirm stuck against a wrong secret would sit on screen at
2052
+ // its hour-old value and read as settled. Cheap: one indexed COUNT, and only
2053
+ // while a terminal is attached.
2054
+ const tick = setInterval(() => { refreshCount(); redraw(); }, 2_000);
2055
+ tick.unref();
2056
+
1826
2057
  redraw();
1827
2058
  }
1828
2059
 
@@ -2077,10 +2308,31 @@ async function main(): Promise<void> {
2077
2308
  // Dynamic, and this is the whole point of the file: importing the server
2078
2309
  // pulls in the store, which pulls in the database, which derives its keys on
2079
2310
  // import. The passphrase has to be set before that chain starts.
2080
- let config: { partnerName: string; port: number };
2311
+ let config: {
2312
+ partnerName: string;
2313
+ port: number;
2314
+ replay: () => Promise<{ applied: number; failed: number }>;
2315
+ stats: () => {
2316
+ received: number;
2317
+ applied: number;
2318
+ failed: number;
2319
+ mode: string;
2320
+ awaitingConnector: number;
2321
+ oldestUndrawn: string | null;
2322
+ };
2323
+ };
2081
2324
  try {
2082
2325
  const server = await import('./server.js');
2083
- config = { partnerName: server.PARTNER_NAME, port: server.PORT };
2326
+ config = {
2327
+ partnerName: server.PARTNER_NAME,
2328
+ port: server.PORT,
2329
+ // Handed through rather than imported by the dashboard, because the
2330
+ // server already imports the dashboard (for notePreviousAddress) and a
2331
+ // second edge the other way is a cycle. This file is the one place that
2332
+ // holds both.
2333
+ replay: server.replayQuarantined,
2334
+ stats: server.dashboardStats,
2335
+ };
2084
2336
  } catch (err) {
2085
2337
  if (err instanceof PassphraseRequiredError) {
2086
2338
  // Reached when nobody could be asked: a service unit, a piped stdin, or
@@ -2108,6 +2360,8 @@ async function main(): Promise<void> {
2108
2360
  startDashboard({
2109
2361
  partnerName: config.partnerName,
2110
2362
  port: config.port,
2363
+ onReplay: config.replay,
2364
+ stats: config.stats,
2111
2365
  onQuit: () => { /* the process exits; the OS closes the socket */ },
2112
2366
  });
2113
2367
  }
@@ -2119,175 +2373,1779 @@ main().catch((err) => {
2119
2373
  `
2120
2374
  },
2121
2375
  {
2122
- name: "src/config.ts",
2376
+ name: "src/connector-store.ts",
2123
2377
  content: `/**
2124
- * src/config.ts \u2014 the configuration you set in SETUP, read by the handler.
2378
+ * A \`CustomerStore\` whose answers come from the connector.
2125
2379
  *
2126
- * \`npx @oneaddress/setup\` writes oneaddress.config.json next to your .env. This
2127
- * module is the ONE place the receiver reads it, so what you chose during setup
2128
- * drives the running handler with no code to edit. Secrets stay in .env; this
2129
- * file is non-secret behaviour only.
2380
+ * ## Why the mode swaps the STORE rather than branching the handler
2130
2381
  *
2131
- * Precedence for every field: an explicit environment variable wins (a one-off
2132
- * override), then oneaddress.config.json (what setup wrote), then a built-in
2133
- * default. A missing config file is not an error \u2014 the handler still runs.
2382
+ * The obvious way to build inbox mode is an \`if (mode === 'inbox')\` beside each
2383
+ * verify event in \`server.ts\`. That works and it puts the mode into the middle
2384
+ * of the protocol layer, where it has to be got right twice and stay right
2385
+ * every time either handler changes.
2386
+ *
2387
+ * The contract in \`customer-store.ts\` already says exactly what the receiver
2388
+ * needs from a partner: verify an account, verify an address, apply a change.
2389
+ * In inbox mode the answers come from a different place. That is an
2390
+ * IMPLEMENTATION of the contract, not a special case in the caller, so the
2391
+ * handler is untouched and there is one place the mode lives.
2392
+ *
2393
+ * ## Three methods answer, two deliberately refuse
2394
+ *
2395
+ * \`verifyAccount\` and \`verifyAddress\` ask the connector and wait, because a
2396
+ * consumer is mid-payment on the other end of the first one.
2397
+ *
2398
+ * \`saveAddress\` THROWS. In inbox mode an \`address.updated\` never reaches the
2399
+ * store at all: it is held for the connector to draw, and the connector applies
2400
+ * it against the partner's own database. If this is ever called, the mode
2401
+ * switch has been bypassed and the right answer is a loud failure rather than a
2402
+ * quiet write into a store that should not exist.
2403
+ *
2404
+ * \`find\` and \`count\` return nothing, honestly, because this receiver holds no
2405
+ * customer records in this mode. The dashboard draws a dash rather than a zero,
2406
+ * which is the difference between "not asked" and "none".
2134
2407
  */
2135
2408
  import { report } from './report.js';
2136
- import { readFileSync } from 'node:fs';
2137
- import { join } from 'node:path';
2409
+ import { askConnector } from './connector-client.js';
2410
+ import type {
2411
+ AccountVerdict,
2412
+ Address,
2413
+ Customer,
2414
+ CustomerStore,
2415
+ StoredCustomer,
2416
+ VerifyResult,
2417
+ } from './customer-store.js';
2138
2418
 
2139
- export type ReceiverConfig = {
2140
- partnerId: string;
2141
- oneAddressApi: string;
2142
- verifiesAccountReference: boolean;
2143
- };
2144
-
2145
- const DEFAULTS: ReceiverConfig = {
2146
- partnerId: '',
2147
- oneAddressApi: 'https://oneaddress.io',
2148
- // A receiver that can match, matches. The wizard writes an EXPLICIT value here
2149
- // from your portal declaration, so this default only bites if the config file
2150
- // is missing entirely \u2014 in which case answering account.verify is the safer,
2151
- // more useful default than silently returning "not checked".
2152
- verifiesAccountReference: true,
2153
- };
2154
-
2155
- function stripTrailingSlash(s: string): string {
2156
- return s.endsWith('/') ? s.slice(0, -1) : s;
2157
- }
2158
-
2159
- function loadConfigFile(): Partial<ReceiverConfig> {
2160
- const path = process.env.ONEADDRESS_CONFIG ?? join(process.cwd(), 'oneaddress.config.json');
2161
- try {
2162
- const parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
2163
- const out: Partial<ReceiverConfig> = {};
2164
- if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
2165
- if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
2166
- if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
2167
- return out;
2168
- } catch {
2169
- // No config file (or unreadable / malformed): fall back to env + defaults.
2170
- return {};
2171
- }
2172
- }
2419
+ /**
2420
+ * The raw body of the request currently being handled.
2421
+ *
2422
+ * SET BY THE HANDLER, read here. The contract passes a decoded \`Customer\`,
2423
+ * which is exactly what this store does not have and cannot produce: the
2424
+ * receiver holds no private key in this mode, so the only thing it can send the
2425
+ * connector is the ciphertext that arrived. Rather than widen the contract for
2426
+ * one implementation, the handler parks the bytes here for the length of the
2427
+ * request.
2428
+ *
2429
+ * Safe because Node runs one request's synchronous path at a time and this is
2430
+ * read immediately, in the same tick the handler sets it. It would NOT be safe
2431
+ * if anything awaited between the set and the read, which is why they are
2432
+ * adjacent and why this comment exists.
2433
+ */
2434
+ let currentRawBody = '';
2435
+
2436
+ export function setCurrentRawBody(raw: string): void {
2437
+ currentRawBody = raw;
2438
+ }
2439
+
2440
+ function verdictOf(body: Record<string, unknown> | undefined, key: string): string | null {
2441
+ const value = body?.[key];
2442
+ return typeof value === 'string' ? value : null;
2443
+ }
2444
+
2445
+ export const connectorStore = {
2446
+ name: 'connector',
2447
+ // The receiver holds no customer records in this mode, so "is the customer
2448
+ // file encrypted" has no true answer. False is the honest one: there is no
2449
+ // protected customer store here, because there is no customer store here.
2450
+ encrypted: false,
2451
+
2452
+ async verifyAccount(
2453
+ accountNumber: string | null,
2454
+ _name: string,
2455
+ _knownNames: string[] = [],
2456
+ ): Promise<AccountVerdict> {
2457
+ const answer = await askConnector('account.verify', currentRawBody);
2458
+ if (!answer.reached) {
2459
+ // NEVER GUESSED. A fabricated match authorises a stranger's address onto
2460
+ // a customer's account. \`no_account\` is the safe answer and stops the
2461
+ // consumer before they pay, which is the trade this design accepted.
2462
+ report.warn(
2463
+ \`[connector] account check for \${accountNumber ?? '(none)'} could not be answered; \` +
2464
+ 'refusing rather than guessing',
2465
+ );
2466
+ return 'no_account';
2467
+ }
2468
+ const status = verdictOf(answer.body, 'status');
2469
+ if (status === 'match' || status === 'no_match' || status === 'no_account') return status;
2470
+ report.warn(\`[connector] account.verify answered "\${status ?? '(nothing)'}", which is not a verdict\`);
2471
+ return 'no_account';
2472
+ },
2173
2473
 
2174
- const fromFile = loadConfigFile();
2474
+ async verifyAddress(_customer: Customer, _incoming: Address): Promise<VerifyResult> {
2475
+ const answer = await askConnector('address.verify', currentRawBody);
2476
+ if (!answer.reached) return 'not_found';
2477
+ const result = verdictOf(answer.body, 'result');
2478
+ if (result === 'match' || result === 'mismatch' || result === 'not_found') return result;
2479
+ report.warn(\`[connector] address.verify answered "\${result ?? '(nothing)'}", which is not a result\`);
2480
+ return 'not_found';
2481
+ },
2175
2482
 
2176
- export const config: ReceiverConfig = {
2177
- partnerId: process.env.PARTNER_ID || fromFile.partnerId || DEFAULTS.partnerId,
2178
- oneAddressApi: stripTrailingSlash(process.env.ONEADDRESS_API || fromFile.oneAddressApi || DEFAULTS.oneAddressApi),
2179
- verifiesAccountReference:
2180
- process.env.VERIFIES_ACCOUNT_REFERENCE != null
2181
- ? process.env.VERIFIES_ACCOUNT_REFERENCE === 'true'
2182
- : (fromFile.verifiesAccountReference ?? DEFAULTS.verifiesAccountReference),
2183
- };
2483
+ saveAddress(_customer: Customer, _incoming: Address): Promise<Address> {
2484
+ // Unreachable by design: see the header. Loud rather than quiet.
2485
+ return Promise.reject(new Error(
2486
+ 'saveAddress was called in inbox mode. Updates are held for the connector to draw and ' +
2487
+ 'apply against your own database; nothing should write through this receiver.',
2488
+ ));
2489
+ },
2184
2490
 
2185
- report.info(
2186
- '[config] loaded (oneAddressApi=' + config.oneAddressApi +
2187
- ', verifiesAccountReference=' + config.verifiesAccountReference + ')',
2188
- );
2491
+ find(_accountNumber: string): StoredCustomer | null { return null; },
2492
+ count(): null { return null; },
2493
+ } satisfies CustomerStore;
2189
2494
  `
2190
2495
  },
2191
2496
  {
2192
- name: "src/callback-url.ts",
2497
+ name: "src/connector-client.ts",
2193
2498
  content: `/**
2194
- * src/callback-url.ts \u2014 the address.verify callback host allowlist.
2499
+ * Asking the connector a question that cannot wait.
2195
2500
  *
2196
- * OneAddress sends the \`callback_url\` inside the signed webhook body, so HMAC
2197
- * verification already proves it came from OneAddress. This host allowlist is a
2198
- * belt-and-braces against a leaked-webhook-secret SSRF: an attacker who could
2199
- * forge a webhook must not be able to coerce this server into POSTing to an
2200
- * internal URL (a cloud metadata service, a database admin port, \u2026).
2501
+ * ## Why this direction exists at all
2201
2502
  *
2202
- * It returns the validated URL string (or null) rather than a boolean on
2203
- * purpose: the caller fetches the RETURN VALUE, so the allowlist sits directly
2204
- * on the taint path and is a provable barrier for CodeQL's request-forgery
2205
- * query (see .github/codeql/extensions/oneaddress-js-models \u2014 the same shape as
2206
- * the redirect sanitiser). A boolean guard would leave the raw, still-tainted
2207
- * value flowing to fetch, which reads as an SSRF whether or not the guard runs.
2503
+ * Everything else between the receiver and the connector is a PULL: the
2504
+ * connector draws work when it is ready. That is the right shape, because the
2505
+ * partner's system should control its own pace.
2506
+ *
2507
+ * The two verify events cannot work that way. \`account.verify\` runs BEFORE the
2508
+ * consumer pays and has to answer in the same request with match, no match, or
2509
+ * no account; \`address.verify\` is the same shape. Both need a decrypt and a
2510
+ * customer lookup, and in inbox mode both of those live in the connector. So
2511
+ * for these, and only these, the receiver calls out and waits.
2512
+ *
2513
+ * ## A CORRECTION TO THE DESIGN DOCUMENT, recorded where it matters
2514
+ *
2515
+ * \`docs/architecture/receiver-connector-design.md\` says the connector "accepts
2516
+ * no inbound connections". That is not achievable alongside a synchronous
2517
+ * verify, and the verify decision is the one that was taken deliberately. So
2518
+ * the connector DOES listen, on loopback only, and the honest form of the
2519
+ * property is: no inbound port reachable from any NETWORK. The difference
2520
+ * matters the day a partner puts the two on separate hosts, which is what the
2521
+ * transport configuration below exists for.
2522
+ *
2523
+ * ## What happens when the connector is down
2524
+ *
2525
+ * The receiver tells OneAddress honestly that it could not check, and the
2526
+ * consumer is stopped BEFORE they pay rather than after. That is the cost of
2527
+ * choosing this over a verify-only key in the receiver: such a key would keep
2528
+ * answers flowing during an outage and would make "the receiver cannot read
2529
+ * addresses" untrue, which is the whole point of the split.
2530
+ *
2531
+ * It is NEVER answered with a guess. A fabricated match authorises a stranger's
2532
+ * address onto a customer's account; a fabricated no-match costs a support
2533
+ * call. Neither is ours to invent.
2208
2534
  */
2209
- export function safeOneAddressCallbackUrl(raw: string): string | null {
2535
+ import { report } from './report.js';
2536
+
2537
+ /**
2538
+ * Where the connector listens.
2539
+ *
2540
+ * 3003, NOT 3002. Two loopback listeners exist in this design and swapping them
2541
+ * is silent: the receiver's own draw channel is on 3002, so a receiver pointed
2542
+ * at 3002 for verify asks ITSELF the question and gets a 404 that reads exactly
2543
+ * like a connector that is down.
2544
+ */
2545
+ const CONNECTOR_URL = process.env.CONNECTOR_URL ?? 'http://127.0.0.1:3003';
2546
+ const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
2547
+ /**
2548
+ * Short, because a consumer is watching a spinner on the other end of this.
2549
+ * A verify that takes eight seconds has already failed as far as they are
2550
+ * concerned, and an honest "could not check" beats a long hang.
2551
+ */
2552
+ const TIMEOUT_MS = Number(process.env.CONNECTOR_TIMEOUT_MS ?? 5000);
2553
+
2554
+ export type VerifyKind = 'account.verify' | 'address.verify';
2555
+
2556
+ export interface ConnectorVerdict {
2557
+ reached: boolean;
2558
+ /** Whatever the connector answered. Passed through untouched. */
2559
+ body?: Record<string, unknown>;
2560
+ error?: string;
2561
+ }
2562
+
2563
+ /**
2564
+ * Hand the connector an encrypted verify event and wait for its answer.
2565
+ *
2566
+ * The RAW BODY goes over, not anything decoded: the receiver holds no private
2567
+ * key in this mode and has nothing to decode it with. The connector decrypts,
2568
+ * looks the customer up in the partner's database, and answers.
2569
+ */
2570
+ export async function askConnector(kind: VerifyKind, rawBody: string): Promise<ConnectorVerdict> {
2571
+ const controller = new AbortController();
2572
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
2210
2573
  try {
2211
- const u = new URL(raw);
2212
- if (u.protocol !== 'https:') return null;
2213
- const host = u.hostname.toLowerCase();
2214
- if (host === 'oneaddress.io' || host.endsWith('.oneaddress.io')) return raw;
2215
- return null;
2216
- } catch {
2217
- return null;
2574
+ const res = await fetch(\`\${CONNECTOR_URL}/verify\`, {
2575
+ method: 'POST',
2576
+ headers: {
2577
+ 'Content-Type': 'application/json',
2578
+ 'Authorization': \`Bearer \${TOKEN}\`,
2579
+ 'X-OneAddress-Verify-Kind': kind,
2580
+ },
2581
+ body: rawBody,
2582
+ signal: controller.signal,
2583
+ });
2584
+ const text = await res.text().catch(() => '');
2585
+ if (!res.ok) {
2586
+ report.error(\`[connector] \${kind} refused: HTTP \${res.status} \${text.slice(0, 200)}\`);
2587
+ return { reached: false, error: \`HTTP \${res.status}\` };
2588
+ }
2589
+ try {
2590
+ return { reached: true, body: JSON.parse(text) as Record<string, unknown> };
2591
+ } catch {
2592
+ report.error(\`[connector] \${kind} answered with something that is not JSON\`);
2593
+ return { reached: false, error: 'bad response' };
2594
+ }
2595
+ } catch (err) {
2596
+ const message = err instanceof Error ? err.message : String(err);
2597
+ // NAMES THE REMEDY. This is the failure a partner will actually see, and
2598
+ // "fetch failed" on its own sends them looking at OneAddress.
2599
+ report.error(
2600
+ \`[connector] could not reach the connector for \${kind} (\${message}). \` +
2601
+ \`Account checks will fail until it is back. Is it running, and is CONNECTOR_URL (\${CONNECTOR_URL}) right?\`,
2602
+ );
2603
+ return { reached: false, error: message };
2604
+ } finally {
2605
+ clearTimeout(timer);
2218
2606
  }
2219
2607
  }
2220
2608
  `
2221
2609
  },
2222
2610
  {
2223
- name: "src/store.ts",
2611
+ name: "src/draw-api.ts",
2224
2612
  content: `/**
2225
- * \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
2226
- * \u2551 src/store.ts \u2014 DATABASE INTEGRATION \u2551
2227
- * \u2551 \u2551
2228
- * \u2551 A realistic partner-side store: a CUSTOMER ROSTER (who your \u2551
2229
- * \u2551 customers are + the address you hold on file for each), and the \u2551
2230
- * \u2551 three hooks OneAddress calls: \u2551
2231
- * \u2551 verifyAccount \u2014 pre-payment account check (account.verify) \u2551
2232
- * \u2551 verifyAddress \u2014 is your on-file address current? (address.verify)
2233
- * \u2551 saveAddress \u2014 apply a new address (address.updated) \u2551
2234
- * \u2551 \u2551
2235
- * \u2551 Swap the SQLite queries for your real customer database when \u2551
2236
- * \u2551 you're ready \u2014 the shapes below are what OneAddress hands you. \u2551
2237
- * \u2551 server.ts calls these after verifying and decrypting each event \u2551
2238
- * \u2551 \u2014 the protocol layer is handled for you, never touch it. \u2551
2239
- * \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
2613
+ * The channel your connector draws from.
2614
+ *
2615
+ * ## The most dangerous surface in this receiver
2616
+ *
2617
+ * Everything else here handles ciphertext. This hands out dispatches a
2618
+ * connector will decrypt, and takes its word for what was applied. An exposed
2619
+ * or unauthenticated version of it is precisely the oracle the whole
2620
+ * architecture exists to prevent, so three things are not configurable:
2621
+ *
2622
+ * 1. **It binds to 127.0.0.1 and nothing else.** There is deliberately no
2623
+ * option to bind it to an interface. A partner who needs the connector on
2624
+ * another host needs a transport with mutual authentication, not this one
2625
+ * with a wider bind, and giving them a knob that looks like it does the
2626
+ * job is how that ends up on a LAN.
2627
+ * 2. **It runs on a SEPARATE PORT from the webhook.** Same process, different
2628
+ * listener, so nothing routed from the internet can reach these paths even
2629
+ * by mistake in a reverse proxy.
2630
+ * 3. **It needs its own credential.** \`CONNECTOR_TOKEN\`, not the webhook
2631
+ * secret and not the confirm secret. Those are shared with OneAddress; a
2632
+ * leak of either must not also hand somebody your customers' addresses.
2633
+ * Compared in constant time, and the API refuses to start without it.
2634
+ *
2635
+ * ## What it does NOT decrypt
2636
+ *
2637
+ * Nothing. It hands over \`raw_body\`, the bytes as they arrived. The private key
2638
+ * lives in the connector, which is the only component that can read any of it.
2240
2639
  */
2241
- import { readFileSync } from 'node:fs';
2242
- import { join } from 'node:path';
2640
+ import express, { type Request, type Response } from 'express';
2641
+ import rateLimit from 'express-rate-limit';
2642
+ import { timingSafeEqual } from 'node:crypto';
2243
2643
  import { report } from './report.js';
2244
- import db, { accountKey, dec, enc, encrypted, isEncrypted, once } from './db.js';
2644
+ import { acknowledge, drawable, markDrawn, type InboxOutcome } from './inbox.js';
2245
2645
 
2246
- export type Address = Record<string, unknown>;
2646
+ const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
2647
+ /**
2648
+ * The RECEIVER's loopback listener, which is not the connector's.
2649
+ *
2650
+ * Named \`DRAW_PORT\` rather than \`CONNECTOR_PORT\` because the connector has a
2651
+ * listener of its own, on \`CONNECTOR_PORT\` (3003), for the verify questions
2652
+ * that cannot wait for a draw. One name for two ports on one host is a
2653
+ * misconfiguration nobody would find: each end would bind or dial the other's.
2654
+ */
2655
+ const DRAW_PORT = Number(process.env.DRAW_PORT ?? 3002);
2656
+ /**
2657
+ * How long a drawn item stays claimed before it is offered again.
2658
+ *
2659
+ * Crash recovery for ONE connector, not concurrency for two. See \`inbox.ts\`.
2660
+ * Generous, because the cost of re-offering too early is a duplicate apply and
2661
+ * the cost of re-offering too late is a delay nobody dies of.
2662
+ */
2663
+ const LEASE_SECONDS = Number(process.env.CONNECTOR_LEASE_SECONDS ?? 300);
2664
+
2665
+ /** Constant time, and length-safe: \`timingSafeEqual\` throws on a length mismatch. */
2666
+ function tokenMatches(presented: string): boolean {
2667
+ const a = Buffer.from(presented);
2668
+ const b = Buffer.from(TOKEN);
2669
+ if (a.length !== b.length) return false;
2670
+ return timingSafeEqual(a, b);
2671
+ }
2672
+
2673
+ function authorised(req: Request, res: Response): boolean {
2674
+ const header = req.headers.authorization ?? '';
2675
+ const presented = header.startsWith('Bearer ') ? header.slice(7) : '';
2676
+ if (presented && tokenMatches(presented)) return true;
2677
+ // No detail, deliberately. This endpoint should tell an unauthorised caller
2678
+ // nothing at all about whether it exists or what it holds.
2679
+ res.status(401).json({ error: 'Unauthorised' });
2680
+ return false;
2681
+ }
2247
2682
 
2248
- // \u2500\u2500 Schema \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\u2500\u2500
2249
- // \`customers\` is your roster: the account number, the customer's name, and the
2250
- // address YOU currently hold on file for them. \`address_history\` logs every
2251
- // change you apply, so you have an audit trail.
2252
- db.exec(\`
2253
- CREATE TABLE IF NOT EXISTS customers (
2254
- -- How a row is FOUND. A blind index of the account number when the database
2255
- -- is locked, the lower-cased number when it is not. It is the key rather
2256
- -- than the number itself because AES-GCM uses a fresh IV per write, so two
2257
- -- encryptions of one account number differ and a primary key over the
2258
- -- ciphertext would enforce nothing while looking like it did.
2259
- account_key TEXT PRIMARY KEY,
2260
- -- What is DISPLAYED. Ciphertext when locked.
2261
- account_number TEXT NOT NULL,
2262
- name TEXT NOT NULL,
2263
- address TEXT NOT NULL DEFAULT '{}',
2264
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
2683
+ export function startDrawApi(): { port: number } | null {
2684
+ if (connectorTokenMissing()) return null;
2685
+
2686
+ const app = express();
2687
+ app.disable('x-powered-by');
2688
+
2689
+ // A BACKSTOP, AND NOT WHAT KEEPS THIS SHUT.
2690
+ //
2691
+ // What keeps it shut is the loopback bind and the credential, compared in
2692
+ // constant time. This bounds what an attacker who is already on the box can
2693
+ // spend, and it is deliberately far above any real connector: a draw every
2694
+ // two seconds plus an acknowledgement per item is a few hundred a minute at
2695
+ // worst, so a legitimate connector never meets this and a partner who tunes
2696
+ // \`CONNECTOR_POLL_MS\` down still has room.
2697
+ //
2698
+ // No \`trust proxy\` here, unlike the webhook app. There is no proxy in front
2699
+ // of a loopback listener, so \`X-Forwarded-For\` is somebody trying it on, and
2700
+ // trusting it would let them key the limiter on an address of their choosing.
2701
+ //
2702
+ // Attached ONCE, on the app rather than per route. Two references to the same
2703
+ // limiter instance count a request twice and silently halve the ceiling.
2704
+ app.use(rateLimit({
2705
+ windowMs: 60_000,
2706
+ max: 2000,
2707
+ standardHeaders: true,
2708
+ legacyHeaders: false,
2709
+ message: { error: 'Too many requests' },
2710
+ }));
2711
+
2712
+ app.use(express.json({ limit: '2mb' }));
2713
+
2714
+ /**
2715
+ * Take the next batch of work.
2716
+ *
2717
+ * A GET with no side effects would be tidier and would be wrong: drawing has
2718
+ * to stamp the lease, or a connector that asks twice gets the same items and
2719
+ * applies them twice.
2720
+ */
2721
+ app.post('/draw', (req: Request, res: Response) => {
2722
+ if (!authorised(req, res)) return;
2723
+ const limit = Math.min(Number((req.body as { limit?: unknown }).limit ?? 20) || 20, 100);
2724
+ const items = drawable(limit, LEASE_SECONDS);
2725
+ markDrawn(items.map((i) => i.id));
2726
+ if (items.length > 0) report.info(\`[draw] connector took \${items.length} dispatch(es)\`);
2727
+ return res.json({ items });
2728
+ });
2729
+
2730
+ /**
2731
+ * What the connector did with one item.
2732
+ *
2733
+ * \`applied\` queues the confirm to OneAddress. \`failed\` queues a failed
2734
+ * confirm, which is the honest answer when the partner's own system refused
2735
+ * the change: the consumer is told it did not land rather than being told it
2736
+ * did.
2737
+ */
2738
+ app.post('/ack', (req: Request, res: Response) => {
2739
+ if (!authorised(req, res)) return;
2740
+ const body = req.body as { id?: unknown; outcome?: unknown; detail?: unknown };
2741
+ const id = typeof body.id === 'string' ? body.id : '';
2742
+ const outcome: InboxOutcome | null =
2743
+ body.outcome === 'applied' || body.outcome === 'failed' ? body.outcome : null;
2744
+ if (!id || !outcome) {
2745
+ return res.status(400).json({ error: 'id and outcome (applied|failed) are required' });
2746
+ }
2747
+ const detail = typeof body.detail === 'string' ? body.detail : null;
2748
+
2749
+ const result = acknowledge(id, outcome, detail);
2750
+ if (!result) return res.status(404).json({ error: 'unknown id' });
2751
+ if (result.alreadyAcknowledged) {
2752
+ // NOT AN ERROR. A connector that is unsure its acknowledgement landed
2753
+ // should retry, and punishing that is how an update ends up applied and
2754
+ // never confirmed.
2755
+ return res.json({ ok: true, duplicate: true });
2756
+ }
2757
+
2758
+ if (result.dispatchId) onAcknowledged(result.dispatchId, outcome);
2759
+ return res.json({ ok: true });
2760
+ });
2761
+
2762
+ /** Enough for the connector to know it is talking to the right receiver. */
2763
+ app.get('/connector/health', (req: Request, res: Response) => {
2764
+ if (!authorised(req, res)) return;
2765
+ return res.json({ status: 'ok', mode: 'inbox' });
2766
+ });
2767
+
2768
+ app.listen(DRAW_PORT, '127.0.0.1', () =>
2769
+ report.info(\`[draw] connector channel \u2192 http://127.0.0.1:\${DRAW_PORT} (loopback only)\`),
2265
2770
  );
2771
+ return { port: DRAW_PORT };
2772
+ }
2266
2773
 
2267
- CREATE TABLE IF NOT EXISTS address_history (
2268
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2269
- account_key TEXT NOT NULL,
2270
- -- Both sides of the change, so you can show what an address REPLACED
2271
- -- rather than only what it became.
2272
- prev_address TEXT NOT NULL DEFAULT '{}',
2273
- address TEXT NOT NULL,
2274
- recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
2774
+ /**
2775
+ * Refuse to open the channel without a credential, and say why.
2776
+ *
2777
+ * Returning null rather than throwing, because a receiver in write-through mode
2778
+ * has no connector and must start perfectly well without one. In inbox mode the
2779
+ * caller turns this into a hard failure, since an inbox with no way to drain it
2780
+ * is worse than a receiver that will not start.
2781
+ */
2782
+ function connectorTokenMissing(): boolean {
2783
+ if (TOKEN.trim().length >= 16) return false;
2784
+ report.error(
2785
+ '[draw] CONNECTOR_TOKEN is missing or shorter than 16 characters, so the connector ' +
2786
+ 'channel was NOT opened. This is its own credential on purpose: it must not be your ' +
2787
+ 'webhook secret or your confirm secret, because those are shared with OneAddress and a ' +
2788
+ 'leak of either must not also hand somebody your customers\\' addresses.',
2275
2789
  );
2276
- CREATE INDEX IF NOT EXISTS idx_history_account ON address_history(account_key, id DESC);
2277
- \`);
2790
+ return true;
2791
+ }
2278
2792
 
2279
- // Self-migrate. An older data.db may already hold a \`customers\` table WITHOUT the
2280
- // newer columns, and \`CREATE TABLE IF NOT EXISTS\` never alters an existing table.
2281
- // Add any missing columns here so the handler upgrades its own schema instead of
2282
- // forcing you to delete the database on every change \u2014 the behaviour a
2283
- // production integration needs.
2284
- function ensureColumn(table: string, column: string, definition: string): void {
2285
- const cols = db.prepare(\`PRAGMA table_info(\${table})\`).all() as Array<{ name: string }>;
2286
- if (!cols.some((c) => c.name === column)) {
2287
- db.exec(\`ALTER TABLE \${table} ADD COLUMN \${column} \${definition}\`);
2288
- report.info(\`[store] migrated: added column \${table}.\${column}\`);
2289
- }
2793
+ /**
2794
+ * Told when an item is acknowledged, so the receiver can confirm to OneAddress.
2795
+ *
2796
+ * A setter rather than an import, because \`server.ts\` owns the confirm queue
2797
+ * and already imports this module: importing it back would be a cycle.
2798
+ */
2799
+ let onAcknowledged: (dispatchId: string, outcome: InboxOutcome) => void = () => {};
2800
+
2801
+ export function setAcknowledgementHandler(
2802
+ handler: (dispatchId: string, outcome: InboxOutcome) => void,
2803
+ ): void {
2804
+ onAcknowledged = handler;
2290
2805
  }
2806
+ `
2807
+ },
2808
+ {
2809
+ name: "src/inbox.ts",
2810
+ content: `/**
2811
+ * Dispatches waiting for YOUR system to collect them.
2812
+ *
2813
+ * ## What this is, and why it is not the quarantine
2814
+ *
2815
+ * Both tables hold a signature-verified dispatch as the ciphertext that
2816
+ * arrived. They mean opposite things.
2817
+ *
2818
+ * A QUARANTINED dispatch is one the receiver could not open: something is
2819
+ * wrong, usually a key, and it is purged on a window because holding a
2820
+ * consumer's encrypted address forever is a retention decision nobody made.
2821
+ *
2822
+ * An INBOX dispatch is one nothing is wrong with. It arrived intact and is
2823
+ * waiting for the partner's own systems to draw it, apply it in their database
2824
+ * and say so. **It is NEVER aged out**, and that asymmetry is deliberate: an
2825
+ * undrawn update is a consumer whose address has not landed, and deleting it on
2826
+ * a timer loses it silently. The dashboard reports a growing undrawn count as a
2827
+ * fault instead, which is the honest way to make an operator deal with it.
2828
+ *
2829
+ * ## Nothing here is readable
2830
+ *
2831
+ * The rows hold the bytes as they arrived. In inbox mode the receiver holds no
2832
+ * private key at all, so it could not decrypt these if it wanted to. That is
2833
+ * the property the whole split exists for: the component reachable from the
2834
+ * internet cannot read what it stores.
2835
+ *
2836
+ * ## The lease, and what it is NOT for
2837
+ *
2838
+ * A connector that draws a batch and then crashes must not strand it. So a draw
2839
+ * stamps \`drawn_at\`, and an item becomes drawable again once that stamp is
2840
+ * older than the lease. That is CRASH RECOVERY for one connector.
2841
+ *
2842
+ * It is not a design for two connectors drawing at once. Two would each get
2843
+ * their own view of what is drawable between leases, and nothing here stops
2844
+ * them applying the same update twice. If a second connector is ever wanted,
2845
+ * the claim has to become atomic, the way \`confirm-queue.ts\` already does it.
2846
+ * Written down because a lease LOOKS like it handles concurrency and does not.
2847
+ */
2848
+ import db from './db.js';
2849
+ import { report } from './report.js';
2850
+
2851
+ export type InboxOutcome = 'applied' | 'failed';
2852
+
2853
+ db.exec(\`
2854
+ CREATE TABLE IF NOT EXISTS inbox (
2855
+ id TEXT PRIMARY KEY,
2856
+ dispatch_id TEXT,
2857
+ event TEXT NOT NULL,
2858
+ raw_body TEXT NOT NULL,
2859
+ received_at TEXT NOT NULL DEFAULT (datetime('now')),
2860
+ drawn_at TEXT,
2861
+ applied_at TEXT,
2862
+ outcome TEXT,
2863
+ detail TEXT
2864
+ );
2865
+ CREATE INDEX IF NOT EXISTS idx_inbox_open
2866
+ ON inbox(applied_at, drawn_at, received_at);
2867
+ \`);
2868
+
2869
+ export interface AcceptInput {
2870
+ key: string;
2871
+ dispatchId: string | null;
2872
+ event: string;
2873
+ rawBody: string;
2874
+ }
2875
+
2876
+ /**
2877
+ * Take a dispatch for the connector to collect.
2878
+ *
2879
+ * \`INSERT OR IGNORE\`, keyed on the same dispatch identity everything else here
2880
+ * uses, so a OneAddress retry of a delivery we already hold does not queue the
2881
+ * same update twice.
2882
+ */
2883
+ export function accept(input: AcceptInput): void {
2884
+ db.prepare(
2885
+ \`INSERT OR IGNORE INTO inbox (id, dispatch_id, event, raw_body)
2886
+ VALUES (?, ?, ?, ?)\`,
2887
+ ).run(input.key, input.dispatchId, input.event, input.rawBody);
2888
+ }
2889
+
2890
+ export interface InboxItem {
2891
+ id: string;
2892
+ dispatch_id: string | null;
2893
+ event: string;
2894
+ raw_body: string;
2895
+ received_at: string;
2896
+ }
2897
+
2898
+ /**
2899
+ * What the connector may take now.
2900
+ *
2901
+ * Anything never drawn, plus anything drawn longer ago than the lease and still
2902
+ * unacknowledged, which is the crashed-connector case.
2903
+ */
2904
+ export function drawable(limit: number, leaseSeconds: number): InboxItem[] {
2905
+ const cutoff = new Date(Date.now() - leaseSeconds * 1000).toISOString();
2906
+ return db.prepare(
2907
+ \`SELECT id, dispatch_id, event, raw_body, received_at
2908
+ FROM inbox
2909
+ WHERE applied_at IS NULL
2910
+ AND (drawn_at IS NULL OR drawn_at < ?)
2911
+ ORDER BY received_at
2912
+ LIMIT ?\`,
2913
+ ).all(cutoff, limit) as unknown as InboxItem[];
2914
+ }
2915
+
2916
+ export function markDrawn(ids: string[]): void {
2917
+ if (ids.length === 0) return;
2918
+ const stamp = new Date().toISOString();
2919
+ const stmt = db.prepare('UPDATE inbox SET drawn_at = ? WHERE id = ?');
2920
+ for (const id of ids) stmt.run(stamp, id);
2921
+ }
2922
+
2923
+ /**
2924
+ * The connector's verdict on one item.
2925
+ *
2926
+ * Returns the dispatch id to confirm, or null when there is nothing to tell
2927
+ * OneAddress: an unknown id, or one already acknowledged. Deliberately not an
2928
+ * error, because a connector retrying an acknowledgement it is unsure landed is
2929
+ * doing the right thing and must not be punished for it.
2930
+ */
2931
+ export function acknowledge(
2932
+ id: string,
2933
+ outcome: InboxOutcome,
2934
+ detail: string | null,
2935
+ ): { dispatchId: string | null; alreadyAcknowledged: boolean } | null {
2936
+ const row = db.prepare(
2937
+ 'SELECT dispatch_id, applied_at FROM inbox WHERE id = ?',
2938
+ ).get(id) as { dispatch_id: string | null; applied_at: string | null } | undefined;
2939
+ if (!row) return null;
2940
+ if (row.applied_at !== null) {
2941
+ return { dispatchId: row.dispatch_id, alreadyAcknowledged: true };
2942
+ }
2943
+ db.prepare(
2944
+ 'UPDATE inbox SET applied_at = ?, outcome = ?, detail = ? WHERE id = ?',
2945
+ ).run(new Date().toISOString(), outcome, detail?.slice(0, 500) ?? null, id);
2946
+ report.info(\`[inbox] \${id} acknowledged by the connector: \${outcome}\`);
2947
+ return { dispatchId: row.dispatch_id, alreadyAcknowledged: false };
2948
+ }
2949
+
2950
+ /** How many updates are sitting here unapplied. Shown as a fault when non-zero. */
2951
+ export function undrawnCount(): number {
2952
+ const row = db.prepare(
2953
+ 'SELECT count(*) AS n FROM inbox WHERE applied_at IS NULL',
2954
+ ).get() as { n: number };
2955
+ return row.n;
2956
+ }
2957
+
2958
+ /** When the oldest unapplied item arrived, for the age on the dashboard. */
2959
+ export function oldestUndrawn(): string | null {
2960
+ const row = db.prepare(
2961
+ 'SELECT min(received_at) AS oldest FROM inbox WHERE applied_at IS NULL',
2962
+ ).get() as { oldest: string | null };
2963
+ return row.oldest;
2964
+ }
2965
+
2966
+ /**
2967
+ * NOTHING PURGES THIS TABLE, and the absence is the design.
2968
+ *
2969
+ * \`purgeQuarantine\` and \`purgeDelivered\` both exist one file over. There is no
2970
+ * \`purgeInbox\`, and there should not be: every row here is an address change a
2971
+ * consumer paid for that has not reached the partner's system yet. Ageing one
2972
+ * out would delete the only copy anybody still has.
2973
+ *
2974
+ * Rows that HAVE been applied are kept too, because they are the partner's own
2975
+ * record of what they were sent and when they acted on it. If that ever needs
2976
+ * bounding, bound the APPLIED rows and never the open ones.
2977
+ */
2978
+ export function appliedCount(): number {
2979
+ const row = db.prepare(
2980
+ 'SELECT count(*) AS n FROM inbox WHERE applied_at IS NOT NULL',
2981
+ ).get() as { n: number };
2982
+ return row.n;
2983
+ }
2984
+ `
2985
+ },
2986
+ {
2987
+ name: "src/tally.ts",
2988
+ content: `/**
2989
+ * How many dispatches arrived, landed, and did not.
2990
+ *
2991
+ * ## THE BUG THIS REPLACES, WHICH A PARTNER FOUND IN ABOUT A MINUTE
2992
+ *
2993
+ * The dashboard used to derive these by reading its own log feed: a line
2994
+ * matching a failure pattern counted as one arrival and one failure. The
2995
+ * comment beside it asserted the invariant that made that sound: "every
2996
+ * dispatch produces exactly one of these".
2997
+ *
2998
+ * Then replay shipped. A replay produces exactly the same lines an arrival
2999
+ * does, because it IS a delivery, back in through the front door. Somebody
3000
+ * pressed [r] sixty times against a key that was never going to work and
3001
+ * watched \`received\` climb from 5 to 67 and \`failed\` from 1 to 63. Nothing was
3002
+ * arriving. The receiver was counting its own attempts to fix itself.
3003
+ *
3004
+ * The same fault was always there for a cause nobody had to trigger by hand:
3005
+ * OneAddress RETRIES a 422, so a wrong key inflated these counters on its own,
3006
+ * quietly, every few minutes.
3007
+ *
3008
+ * ## Why counting here fixes the class rather than the instance
3009
+ *
3010
+ * Narration is the wrong source. A log line says what happened, not what it
3011
+ * happened TO, so two lines about one dispatch are indistinguishable from two
3012
+ * dispatches. This keys on the DISPATCH, so every re-delivery of it - a
3013
+ * replay, a OneAddress retry, a partner curling the same body twice - lands on
3014
+ * the entry that is already there.
3015
+ *
3016
+ * That also makes a replay that finally WORKS do the right thing on its own:
3017
+ * the entry flips from failed to applied, and the totals move without anything
3018
+ * having to know a replay was involved.
3019
+ *
3020
+ * ## What is counted, stated because the footer does not have room to
3021
+ *
3022
+ * An \`address.updated\` that was stored is \`applied\`. Anything that could not be
3023
+ * opened, or was refused, is \`failed\`. \`received\` is how many distinct
3024
+ * dispatches reached one of those two, so \`received = applied + failed\` holds
3025
+ * by construction rather than by hoping.
3026
+ *
3027
+ * An \`address.verify\` is deliberately none of them: it answers a question and
3028
+ * changes nothing, so counting it as an application would overstate what this
3029
+ * receiver has done. It still shows in the activity log.
3030
+ *
3031
+ * Since boot, like every other figure on that footer. The held count beside
3032
+ * them is not: the quarantine is on disk and survives a restart.
3033
+ */
3034
+
3035
+ export type Outcome = 'applied' | 'failed';
3036
+
3037
+ /**
3038
+ * Bounded, because this is memory and a busy receiver runs for months.
3039
+ *
3040
+ * At the cap the oldest entry goes, exactly as \`seenDispatches\` does, and the
3041
+ * totals then describe the most recent 5000 dispatches rather than all of them.
3042
+ * That is the honest trade for a footer: an operator reads it to see whether
3043
+ * things are working now, and nobody audits from it.
3044
+ */
3045
+ const MAX_TRACKED = 5000;
3046
+ const outcomes = new Map<string, Outcome>();
3047
+
3048
+ export function recordOutcome(dispatchKey: string, outcome: Outcome): void {
3049
+ if (!outcomes.has(dispatchKey) && outcomes.size >= MAX_TRACKED) {
3050
+ const oldest = outcomes.keys().next().value;
3051
+ if (oldest !== undefined) outcomes.delete(oldest);
3052
+ }
3053
+ // \`set\` on an existing key overwrites in place and keeps its insertion order,
3054
+ // which is what makes a replay that succeeds flip failed to applied rather
3055
+ // than adding a second entry.
3056
+ outcomes.set(dispatchKey, outcome);
3057
+ }
3058
+
3059
+ export interface Tally {
3060
+ received: number;
3061
+ applied: number;
3062
+ failed: number;
3063
+ }
3064
+
3065
+ export function tally(): Tally {
3066
+ let applied = 0;
3067
+ for (const outcome of outcomes.values()) if (outcome === 'applied') applied += 1;
3068
+ return { received: outcomes.size, applied, failed: outcomes.size - applied };
3069
+ }
3070
+
3071
+ /** Test seam. Never called by the receiver. */
3072
+ export function resetTally(): void {
3073
+ outcomes.clear();
3074
+ }
3075
+ `
3076
+ },
3077
+ {
3078
+ name: "src/quarantine.ts",
3079
+ content: `/**
3080
+ * Dispatches that arrived intact and could not be opened.
3081
+ *
3082
+ * ## What this is for
3083
+ *
3084
+ * A dispatch whose signature verifies but whose payload will not decrypt used
3085
+ * to get an HTTP 422 and a log line, and nothing was kept. OneAddress retries a
3086
+ * 422, so the update survived exactly as long as its retry window. A partner
3087
+ * who worked out on Thursday that the wrong key was installed on Monday had
3088
+ * lost it, with nothing local to point at.
3089
+ *
3090
+ * So the bytes are kept. Fix the key, replay, and the same handler runs against
3091
+ * the same request it already received.
3092
+ *
3093
+ * ## THREE PROPERTIES THAT ARE THE DESIGN, NOT DECORATION
3094
+ *
3095
+ * **Nothing is decrypted here.** The row holds exactly the ciphertext that
3096
+ * arrived. It cannot contain a plaintext address, because at the moment it is
3097
+ * written the receiver does not have one - that is the entire reason the row
3098
+ * exists. Replay decrypts in memory in the handler, the same as a live dispatch.
3099
+ *
3100
+ * **Only a SIGNATURE-VERIFIED request is ever quarantined.** The call sites are
3101
+ * all below the HMAC check. Quarantining before it would let anyone who can
3102
+ * reach the webhook fill the disk, which turns a diagnostic into a
3103
+ * denial-of-service surface.
3104
+ *
3105
+ * **The retention rule is the OPPOSITE of the confirm queue's, deliberately.**
3106
+ * A confirm record is content-free, so keeping an outstanding one forever costs
3107
+ * nothing and dropping it loses an update; nothing ages those out. A
3108
+ * quarantined payload is a consumer's encrypted address on a partner's disk,
3109
+ * and keeping it indefinitely is a retention nobody agreed to. These are purged
3110
+ * on a window (30 days by default), replayed or not, and purging one that was
3111
+ * never replayed says so out loud rather than quietly.
3112
+ *
3113
+ * ## What is NOT quarantined, and why
3114
+ *
3115
+ * An \`address.updated\` refused because the account reference matches no
3116
+ * customer is a DECISION, not a fault. It is already reported to OneAddress as
3117
+ * a failed confirm, the partner's data is exactly as they intended, and a
3118
+ * replay would apply an address for an account they do not recognise. It stays
3119
+ * a refusal.
3120
+ */
3121
+ import db, { ensureColumn } from './db.js';
3122
+ import { report } from './report.js';
3123
+ import { createHash } from 'node:crypto';
3124
+ import { writeFileSync } from 'node:fs';
3125
+ import { join } from 'node:path';
3126
+
3127
+ /**
3128
+ * Why a dispatch could not be applied. Shown verbatim on the dashboard.
3129
+ *
3130
+ * TWO VALUES, AND THE DISTINCTION IS THE DIAGNOSIS. \`no_key\` is a line missing
3131
+ * from \`.env\`; \`decrypt_failed\` is a key that is present and wrong. AES-GCM
3132
+ * cannot tell a wrong key from a tampered ciphertext, so this record of which
3133
+ * branch refused is the only thing that separates them afterwards.
3134
+ *
3135
+ * There is deliberately no \`handler_error\`. A fault inside the handler is a
3136
+ * bug in code the partner can edit, not a dispatch waiting on a configuration
3137
+ * change, and holding a payload for it would suggest replaying is the remedy.
3138
+ */
3139
+ export type QuarantineReason = 'no_key' | 'decrypt_failed';
3140
+
3141
+ db.exec(\`
3142
+ CREATE TABLE IF NOT EXISTS quarantine (
3143
+ id TEXT PRIMARY KEY,
3144
+ dispatch_id TEXT,
3145
+ event TEXT NOT NULL,
3146
+ reason TEXT NOT NULL,
3147
+ key_id TEXT,
3148
+ raw_body TEXT NOT NULL,
3149
+ detail TEXT,
3150
+ received_at TEXT NOT NULL DEFAULT (datetime('now')),
3151
+ replayed_at TEXT,
3152
+ last_error TEXT
3153
+ );
3154
+ CREATE INDEX IF NOT EXISTS idx_quarantine_open
3155
+ ON quarantine(replayed_at, received_at);
3156
+ \`);
3157
+
3158
+ /**
3159
+ * One row per dispatch, not one per delivery attempt.
3160
+ *
3161
+ * OneAddress retries a 422, so the SAME broken dispatch arrives again every few
3162
+ * minutes while the key is still wrong. Without a stable id a single
3163
+ * misconfigured key would write a row a minute until someone noticed. Keyed on
3164
+ * the dispatch header where there is one, and on a hash of the body where there
3165
+ * is not, so a retry updates the existing row instead of adding to a pile.
3166
+ */
3167
+ export function dispatchKey(dispatchId: string | null, rawBody: string): string {
3168
+ if (dispatchId && dispatchId.trim()) return \`d:\${dispatchId.trim()}\`;
3169
+ return \`h:\${createHash('sha256').update(rawBody).digest('hex').slice(0, 32)}\`;
3170
+ }
3171
+
3172
+ /**
3173
+ * Attempts, so the panel can say how hard this has been tried.
3174
+ *
3175
+ * Added after the quarantine shipped, hence the migration: a partner already
3176
+ * running 2.1.3 has the table without it. Somebody pressed [r] sixty times
3177
+ * against a key that could never work, and the only figure that moved was one
3178
+ * the dashboard was computing wrongly. An attempt count on the row would have
3179
+ * said so immediately.
3180
+ */
3181
+ ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
3182
+
3183
+ export interface QuarantineInput {
3184
+ dispatchId: string | null;
3185
+ event: string;
3186
+ reason: QuarantineReason;
3187
+ keyId: string | null;
3188
+ rawBody: string;
3189
+ detail: string;
3190
+ }
3191
+
3192
+ /**
3193
+ * Keep a dispatch that could not be applied.
3194
+ *
3195
+ * Best-effort by design: this runs on a path that is ALREADY failing, and a
3196
+ * quarantine write that throws would turn a recoverable 422 into a 500. The
3197
+ * caller must be able to answer OneAddress whatever happens here.
3198
+ */
3199
+ export function quarantine(input: QuarantineInput): void {
3200
+ const id = dispatchKey(input.dispatchId, input.rawBody);
3201
+ try {
3202
+ db.prepare(
3203
+ \`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
3204
+ VALUES (?, ?, ?, ?, ?, ?, ?)
3205
+ ON CONFLICT(id) DO UPDATE SET
3206
+ reason = excluded.reason,
3207
+ key_id = excluded.key_id,
3208
+ detail = excluded.detail\`,
3209
+ ).run(
3210
+ id,
3211
+ input.dispatchId,
3212
+ input.event,
3213
+ input.reason,
3214
+ input.keyId,
3215
+ input.rawBody,
3216
+ input.detail.slice(0, 500),
3217
+ );
3218
+ report.warn(
3219
+ \`[quarantine] \${input.event} held (\${input.reason}\${input.keyId ? \`, key_id \${input.keyId}\` : ''}). \` +
3220
+ 'Fix the cause and press [r] on the dashboard, or run \`npm run replay\`, to apply it.',
3221
+ );
3222
+ } catch (err) {
3223
+ report.error('[quarantine] could not hold this dispatch:', err instanceof Error ? err.message : err);
3224
+ }
3225
+ }
3226
+
3227
+ export interface HeldDispatch {
3228
+ id: string;
3229
+ dispatch_id: string | null;
3230
+ event: string;
3231
+ reason: QuarantineReason;
3232
+ key_id: string | null;
3233
+ raw_body: string;
3234
+ detail: string | null;
3235
+ received_at: string;
3236
+ last_error: string | null;
3237
+ attempts: number;
3238
+ }
3239
+
3240
+ /** Everything still held, oldest first. */
3241
+ export function heldDispatches(limit = 50): HeldDispatch[] {
3242
+ return db.prepare(
3243
+ \`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
3244
+ received_at, last_error, attempts
3245
+ FROM quarantine
3246
+ WHERE replayed_at IS NULL
3247
+ ORDER BY received_at
3248
+ LIMIT ?\`,
3249
+ ).all(limit) as unknown as HeldDispatch[];
3250
+ }
3251
+
3252
+ /** How many dispatches are held. Shown on the dashboard. */
3253
+ export function heldCount(): number {
3254
+ const row = db.prepare(
3255
+ 'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL',
3256
+ ).get() as { n: number };
3257
+ return row.n;
3258
+ }
3259
+
3260
+ /**
3261
+ * A one-line summary per held dispatch, grouped by cause.
3262
+ *
3263
+ * Grouped because the realistic shape of this table is fifty rows with ONE
3264
+ * cause between them: a key was wrong for an afternoon. Fifty identical lines
3265
+ * hide that; "47 held: no key for key_id 0403\u2026" is the whole diagnosis.
3266
+ */
3267
+ export function heldSummary(): string[] {
3268
+ const rows = db.prepare(
3269
+ \`SELECT reason, key_id, count(*) AS n,
3270
+ max(attempts) AS tries, min(received_at) AS oldest
3271
+ FROM quarantine
3272
+ WHERE replayed_at IS NULL
3273
+ GROUP BY reason, key_id
3274
+ ORDER BY n DESC\`,
3275
+ ).all() as unknown as {
3276
+ reason: string; key_id: string | null; n: number; tries: number; oldest: string;
3277
+ }[];
3278
+ return rows.map((r) => {
3279
+ const cause = \`\${r.n} \xD7 \${r.reason}\${r.key_id ? \` (key_id \${r.key_id})\` : ''}\`;
3280
+ // TRIED AND AGE, because the count alone does not say whether anything is
3281
+ // being done about it. A partner watching "1 \xD7 decrypt_failed" with no
3282
+ // other figure moving cannot tell a replay that is working from one that
3283
+ // is not; "61 tries" answers that without reading the log.
3284
+ const tried = r.tries > 0 ? \`, \${r.tries} \${r.tries === 1 ? 'try' : 'tries'}\` : '';
3285
+ return \`\${cause}\${tried}, first seen \${describeAge(r.oldest)}\`;
3286
+ });
3287
+ }
3288
+
3289
+ /** "3m ago", "2h ago". Coarse on purpose: nobody acts on seconds. */
3290
+ export function describeAge(iso: string): string {
3291
+ const ms = Date.now() - new Date(iso).getTime();
3292
+ if (!Number.isFinite(ms) || ms < 0) return 'just now';
3293
+ const mins = Math.floor(ms / 60_000);
3294
+ if (mins < 1) return 'moments ago';
3295
+ if (mins < 60) return \`\${mins}m ago\`;
3296
+ const hours = Math.floor(mins / 60);
3297
+ if (hours < 48) return \`\${hours}h ago\`;
3298
+ return \`\${Math.floor(hours / 24)}d ago\`;
3299
+ }
3300
+
3301
+ export function markReplayed(id: string): void {
3302
+ db.prepare(
3303
+ 'UPDATE quarantine SET replayed_at = ?, last_error = NULL WHERE id = ?',
3304
+ ).run(new Date().toISOString(), id);
3305
+ }
3306
+
3307
+ /** A replay that failed the same way stays held, with the new reason recorded. */
3308
+ export function markReplayFailed(id: string, error: string): void {
3309
+ db.prepare('UPDATE quarantine SET last_error = ?, attempts = attempts + 1 WHERE id = ?')
3310
+ .run(error.slice(0, 500), id);
3311
+ }
3312
+
3313
+ /**
3314
+ * Drop held payloads past the retention window, replayed or not.
3315
+ *
3316
+ * DELIBERATELY UNLIKE \`purgeDelivered\` IN THE CONFIRM QUEUE, which never ages
3317
+ * out an outstanding row. The difference is what the row contains. A confirm
3318
+ * record names a dispatch and an outcome and nothing else, so holding it costs
3319
+ * a consumer nothing. A quarantined payload is that consumer's address,
3320
+ * encrypted, on a third party's disk: an unbounded hold is a retention decision
3321
+ * made on their behalf by nobody.
3322
+ *
3323
+ * An unreplayed row going out is reported separately and loudly, because that
3324
+ * is an update the partner never applied and is now no longer able to.
3325
+ */
3326
+ export function purgeQuarantine(days: number): { replayed: number; unreplayed: number } {
3327
+ const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
3328
+ const doomed = db.prepare(
3329
+ 'SELECT id, replayed_at FROM quarantine WHERE received_at < ?',
3330
+ ).all(cutoff) as unknown as { id: string; replayed_at: string | null }[];
3331
+ if (doomed.length === 0) return { replayed: 0, unreplayed: 0 };
3332
+
3333
+ db.prepare('DELETE FROM quarantine WHERE received_at < ?').run(cutoff);
3334
+
3335
+ const unreplayed = doomed.filter((d) => d.replayed_at === null).length;
3336
+ const replayed = doomed.length - unreplayed;
3337
+ if (replayed > 0) {
3338
+ report.info(\`[quarantine] purged \${replayed} replayed payload(s) older than \${days}d\`);
3339
+ }
3340
+ if (unreplayed > 0) {
3341
+ report.warn(
3342
+ \`[quarantine] purged \${unreplayed} payload(s) older than \${days}d that were NEVER APPLIED. \` +
3343
+ 'Those address updates are gone from this receiver. Raise them with OneAddress if you need them re-sent.',
3344
+ );
3345
+ }
3346
+ return { replayed, unreplayed };
3347
+ }
3348
+
3349
+ /**
3350
+ * Write the held list to a file, WITHOUT the payloads.
3351
+ *
3352
+ * ## Why the ciphertext is not in here
3353
+ *
3354
+ * The obvious export carries the payload so the fault can be replayed
3355
+ * somewhere else. It also puts a copy of a consumer's encrypted address in a
3356
+ * file that the retention window cannot reach: the quarantine purges its rows,
3357
+ * and nothing purges an export somebody emailed to support and left in a
3358
+ * downloads folder. The whole point of the retention rule is that a held
3359
+ * address does not sit anywhere indefinitely, and an export that leaks past it
3360
+ * quietly undoes that.
3361
+ *
3362
+ * Nothing in here is personal. A dispatch id, what kind of event it was, why it
3363
+ * could not be opened, which key it asked for, and when. That is the whole of
3364
+ * what diagnoses a key problem, and it is safe to paste into a support ticket
3365
+ * or send to us, which is what an export is for.
3366
+ */
3367
+ export function exportHeld(directory: string): { path: string; count: number } {
3368
+ const rows = heldDispatches(500);
3369
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3370
+ const path = join(directory, \`oneaddress-faults-\${stamp}.json\`);
3371
+ writeFileSync(path, JSON.stringify({
3372
+ exported_at: new Date().toISOString(),
3373
+ note: 'Metadata only. The held payloads are deliberately not included: they are encrypted consumer addresses and stay under the receiver retention window.',
3374
+ held: rows.map((r) => ({
3375
+ id: r.id,
3376
+ dispatch_id: r.dispatch_id,
3377
+ event: r.event,
3378
+ reason: r.reason,
3379
+ key_id: r.key_id,
3380
+ received_at: r.received_at,
3381
+ age: describeAge(r.received_at),
3382
+ attempts: r.attempts,
3383
+ detail: r.detail,
3384
+ last_error: r.last_error,
3385
+ })),
3386
+ }, null, 2));
3387
+ report.info(\`[quarantine] exported \${rows.length} held dispatch(es) to \${path}\`);
3388
+ return { path, count: rows.length };
3389
+ }
3390
+
3391
+ /**
3392
+ * Re-run every held dispatch through the handler.
3393
+ *
3394
+ * Takes the handler rather than importing it, for the same reason \`drainConfirms\`
3395
+ * takes its sender: this module then needs no knowledge of Express, decryption
3396
+ * or keys, and a test can drive it with a function that fails once and succeeds
3397
+ * on the second call.
3398
+ */
3399
+ export async function replayHeld(
3400
+ apply: (rawBody: string, dispatchId: string | null) => Promise<void>,
3401
+ limit = 50,
3402
+ ): Promise<{ applied: number; failed: number }> {
3403
+ let applied = 0;
3404
+ let failed = 0;
3405
+ for (const row of heldDispatches(limit)) {
3406
+ try {
3407
+ // THE ROW'S OWN DISPATCH ID, not one re-derived from the body.
3408
+ //
3409
+ // \`redeliver\` used to parse \`dispatch_id\` out of the JSON and send that
3410
+ // as the header. A dispatch that arrived WITHOUT the header was keyed on
3411
+ // a hash of its body; the replay then supplied a header from the body,
3412
+ // so the re-arrival was keyed by id instead, landed in a DIFFERENT row,
3413
+ // and one held dispatch became two. Seen on a real receiver: \`1 \xD7
3414
+ // decrypt_failed\` became \`2 \xD7\` on the first press of [r].
3415
+ //
3416
+ // Identity has to come from one place. This row already knows what it
3417
+ // arrived as.
3418
+ await apply(row.raw_body, row.dispatch_id);
3419
+ markReplayed(row.id);
3420
+ applied += 1;
3421
+ report.info(\`[replay] applied \${row.event} \${row.dispatch_id ?? row.id}\`);
3422
+ } catch (err) {
3423
+ const message = err instanceof Error ? err.message : String(err);
3424
+ markReplayFailed(row.id, message);
3425
+ failed += 1;
3426
+ report.warn(\`[replay] \${row.dispatch_id ?? row.id} still failing: \${message}\`);
3427
+ }
3428
+ }
3429
+ if (applied === 0 && failed === 0) report.info('[replay] nothing held');
3430
+ return { applied, failed };
3431
+ }
3432
+ `
3433
+ },
3434
+ {
3435
+ name: "src/confirm-queue.ts",
3436
+ content: `/**
3437
+ * Telling OneAddress you applied an update, durably.
3438
+ *
3439
+ * ## The bug this replaces, which cost real money
3440
+ *
3441
+ * The confirm callback used to be one \`fetch\` with a \`catch\` that logged. If
3442
+ * \`/api/confirm\` was unreachable for thirty seconds, the address was applied
3443
+ * here and OneAddress never learned. The consumer was shown a failed delivery,
3444
+ * and the auto-refund cron could refund a dispatch that had in fact succeeded.
3445
+ * Nothing retried, nothing surfaced, and the receiver's own log said "applied".
3446
+ *
3447
+ * So the confirm is now a queued, durable fact rather than a best-effort call:
3448
+ * it survives a restart, retries with backoff, and is visible while it is
3449
+ * outstanding.
3450
+ *
3451
+ * ## Why the queue is keyed on dispatch_id
3452
+ *
3453
+ * One row per dispatch, \`INSERT OR IGNORE\`. OneAddress retries a dispatch it
3454
+ * has not heard about, so the same id can arrive more than once; enqueuing
3455
+ * twice would confirm twice. The primary key makes that impossible rather than
3456
+ * making it something every caller has to remember.
3457
+ *
3458
+ * ## Why a failing confirm is retried forever rather than given up on
3459
+ *
3460
+ * The commonest cause of a 401 here is a wrong or stale CONFIRM_SECRET, which
3461
+ * is fixed by editing \`.env\` and restarting. Abandoning the row would mean the
3462
+ * partner fixes the secret and the update stays lost. The backoff caps at an
3463
+ * hour, so a permanently-broken secret costs one request an hour and stays
3464
+ * visible in the queue depth, which is the signal that something needs a human.
3465
+ */
3466
+ import db from './db.js';
3467
+ import { report } from './report.js';
3468
+
3469
+ export type ConfirmStatus = 'confirmed' | 'failed';
3470
+
3471
+ db.exec(\`
3472
+ CREATE TABLE IF NOT EXISTS confirm_queue (
3473
+ dispatch_id INTEGER PRIMARY KEY,
3474
+ status TEXT NOT NULL,
3475
+ attempts INTEGER NOT NULL DEFAULT 0,
3476
+ next_attempt_at TEXT NOT NULL,
3477
+ last_error TEXT,
3478
+ delivered_at TEXT,
3479
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
3480
+ );
3481
+ CREATE INDEX IF NOT EXISTS idx_confirm_due
3482
+ ON confirm_queue(delivered_at, next_attempt_at);
3483
+ \`);
3484
+
3485
+ /**
3486
+ * Backoff, in seconds, by attempt number.
3487
+ *
3488
+ * Fast at first because the overwhelming majority of failures are a blip and
3489
+ * clear on the second try; then long, because the ones that do not clear are
3490
+ * configuration and need a person, not a tighter loop.
3491
+ */
3492
+ const BACKOFF_SECONDS = [5, 15, 60, 300, 900, 3600];
3493
+
3494
+ function delayFor(attempts: number): number {
3495
+ return BACKOFF_SECONDS[Math.min(attempts, BACKOFF_SECONDS.length - 1)];
3496
+ }
3497
+
3498
+ function isoIn(seconds: number): string {
3499
+ return new Date(Date.now() + seconds * 1000).toISOString();
3500
+ }
3501
+
3502
+ /**
3503
+ * Record that this dispatch needs confirming. Safe to call twice.
3504
+ *
3505
+ * Deliberately synchronous and local: the webhook handler must not wait on a
3506
+ * network round trip to OneAddress before answering the dispatch, or a slow
3507
+ * confirm turns into a timed-out webhook and a pointless redelivery.
3508
+ */
3509
+ export function enqueueConfirm(dispatchId: number, status: ConfirmStatus): void {
3510
+ db.prepare(
3511
+ \`INSERT OR IGNORE INTO confirm_queue (dispatch_id, status, next_attempt_at)
3512
+ VALUES (?, ?, ?)\`,
3513
+ ).run(dispatchId, status, new Date().toISOString());
3514
+ }
3515
+
3516
+ export interface PendingConfirm {
3517
+ dispatch_id: number;
3518
+ status: ConfirmStatus;
3519
+ attempts: number;
3520
+ }
3521
+
3522
+ /** Everything due to be sent now, oldest first. */
3523
+ export function dueConfirms(limit = 20): PendingConfirm[] {
3524
+ return db.prepare(
3525
+ \`SELECT dispatch_id, status, attempts
3526
+ FROM confirm_queue
3527
+ WHERE delivered_at IS NULL
3528
+ AND next_attempt_at <= ?
3529
+ ORDER BY created_at
3530
+ LIMIT ?\`,
3531
+ ).all(new Date().toISOString(), limit) as unknown as PendingConfirm[];
3532
+ }
3533
+
3534
+ /** How many confirms are still outstanding. Shown on the dashboard. */
3535
+ export function pendingConfirmCount(): number {
3536
+ const row = db.prepare(
3537
+ 'SELECT count(*) AS n FROM confirm_queue WHERE delivered_at IS NULL',
3538
+ ).get() as { n: number };
3539
+ return row.n;
3540
+ }
3541
+
3542
+ export function markDelivered(dispatchId: number): void {
3543
+ db.prepare(
3544
+ \`UPDATE confirm_queue SET delivered_at = ?, last_error = NULL WHERE dispatch_id = ?\`,
3545
+ ).run(new Date().toISOString(), dispatchId);
3546
+ }
3547
+
3548
+ /** Back off and record why, so the queue explains itself without a log dig. */
3549
+ export function markFailed(dispatchId: number, attempts: number, error: string): void {
3550
+ const next = delayFor(attempts);
3551
+ db.prepare(
3552
+ \`UPDATE confirm_queue
3553
+ SET attempts = attempts + 1, next_attempt_at = ?, last_error = ?
3554
+ WHERE dispatch_id = ?\`,
3555
+ ).run(isoIn(next), error.slice(0, 500), dispatchId);
3556
+ }
3557
+
3558
+ /**
3559
+ * Drop delivered rows older than the retention window.
3560
+ *
3561
+ * Delivered ONLY. An outstanding confirm is never purged on age: a receiver
3562
+ * that was off for a month must still tell OneAddress what it applied, and
3563
+ * silently dropping those is the original bug with extra steps.
3564
+ */
3565
+ export function purgeDelivered(days: number): number {
3566
+ const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
3567
+ const res = db.prepare(
3568
+ \`DELETE FROM confirm_queue WHERE delivered_at IS NOT NULL AND delivered_at < ?\`,
3569
+ ).run(cutoff);
3570
+ return Number(res.changes ?? 0);
3571
+ }
3572
+
3573
+ /**
3574
+ * Drain the queue once.
3575
+ *
3576
+ * Takes the sender so this module needs no knowledge of HTTP, secrets or
3577
+ * signing, which is what lets it be tested without a server: the test passes a
3578
+ * function that fails twice and then succeeds, and asserts the row survives.
3579
+ */
3580
+ export async function drainConfirms(
3581
+ send: (dispatchId: number, status: ConfirmStatus) => Promise<void>,
3582
+ limit = 20,
3583
+ ): Promise<{ delivered: number; failed: number }> {
3584
+ let delivered = 0;
3585
+ let failed = 0;
3586
+ for (const row of dueConfirms(limit)) {
3587
+ try {
3588
+ await send(row.dispatch_id, row.status);
3589
+ markDelivered(row.dispatch_id);
3590
+ delivered += 1;
3591
+ } catch (err) {
3592
+ const message = err instanceof Error ? err.message : String(err);
3593
+ markFailed(row.dispatch_id, row.attempts, message);
3594
+ failed += 1;
3595
+ // Said once per attempt rather than once per drain, because the attempt
3596
+ // count and the reason are what tell a partner whether to wait or act.
3597
+ report.warn(
3598
+ \`[confirm] dispatch \${row.dispatch_id} not acknowledged (attempt \${row.attempts + 1}): \${message}\`,
3599
+ );
3600
+ }
3601
+ }
3602
+ return { delivered, failed };
3603
+ }
3604
+ `
3605
+ },
3606
+ {
3607
+ name: "src/keys.ts",
3608
+ content: `/**
3609
+ * Which private key opens this dispatch.
3610
+ *
3611
+ * ## Why this file exists
3612
+ *
3613
+ * OneAddress supports key rotation: a new key is registered, both are valid for
3614
+ * an overlap window, and every dispatch names the key it was encrypted to in
3615
+ * \`session_key_share.key_id\`. A receiver holding ONE key therefore has a window
3616
+ * where perfectly good dispatches arrive that it cannot open.
3617
+ *
3618
+ * Until this existed the scaffold read a single \`PARTNER_PRIVATE_KEY_PEM\` and
3619
+ * used it for every dispatch whatever key id was named. That fails in the worst
3620
+ * possible way: AES-GCM cannot tell "wrong key" from "tampered ciphertext", so
3621
+ * the error is an authentication-tag failure that reads like corruption. A real
3622
+ * partner hit exactly this and had no way to tell which of the two it was.
3623
+ *
3624
+ * ## The convention, and why it matches the production receiver
3625
+ *
3626
+ * PARTNER_PRIVATE_KEY_PEM_<KEY_ID> dashes to underscores, upper-cased
3627
+ * PARTNER_PRIVATE_KEY_PEM the single-key fallback
3628
+ *
3629
+ * This is the same shape \`@oneaddress/receiver\` uses for \`OA_PRIVATE_KEY_<ID>\`,
3630
+ * deliberately: a partner who outgrows this scaffold and moves to the
3631
+ * production receiver should not have to learn a second way of naming keys.
3632
+ *
3633
+ * ## The fallback is convenient and slightly dangerous
3634
+ *
3635
+ * It answers for ANY key id, which is what made a rotation look like corruption.
3636
+ * It is kept because removing it would break every existing single-key partner
3637
+ * on upgrade, but two things now make it safe to hold:
3638
+ *
3639
+ * - \`PARTNER_KEYS_STRICT=1\` disables it, and any partner holding more than one
3640
+ * key should set that
3641
+ * - when it answers and the decrypt then fails, the error SAYS it was the
3642
+ * fallback and names the key id it was asked for, so the next step is
3643
+ * obvious rather than a guess
3644
+ */
3645
+
3646
+ /** Where a key came from. Carried into the error message when a decrypt fails. */
3647
+ export type KeySource = 'exact' | 'fallback';
3648
+
3649
+ export interface ResolvedKey {
3650
+ pem: string;
3651
+ source: KeySource;
3652
+ }
3653
+
3654
+ const PREFIX = 'PARTNER_PRIVATE_KEY_PEM';
3655
+
3656
+ /** \`04032299-4b04-...\` becomes \`04032299_4B04_...\`, matching the env convention. */
3657
+ function envSuffix(keyId: string): string {
3658
+ return keyId.replace(/-/g, '_').toUpperCase();
3659
+ }
3660
+
3661
+ /**
3662
+ * A PEM written into \`.env\` on one line arrives with literal backslash-n.
3663
+ * Both forms are accepted so a key pasted either way works.
3664
+ */
3665
+ function unescapeNewlines(pem: string): string {
3666
+ return pem.includes('\\\\n') ? pem.replace(/\\\\n/g, '\\n') : pem;
3667
+ }
3668
+
3669
+ /** Every key id this receiver has a key for, lower-cased. */
3670
+ export function configuredKeyIds(env: NodeJS.ProcessEnv = process.env): string[] {
3671
+ return Object.keys(env)
3672
+ .filter((k) => k.startsWith(\`\${PREFIX}_\`) && (env[k] ?? '').trim() !== '')
3673
+ .map((k) => k.slice(PREFIX.length + 1).toLowerCase().replaceAll('_', '-'));
3674
+ }
3675
+
3676
+ /** Is the single-key fallback turned off? */
3677
+ export function strictKeys(env: NodeJS.ProcessEnv = process.env): boolean {
3678
+ return (env.PARTNER_KEYS_STRICT ?? '').trim() === '1';
3679
+ }
3680
+
3681
+ /**
3682
+ * The key for this dispatch, or null when nothing can open it.
3683
+ *
3684
+ * Null is a real answer, not an error: it means "I do not hold this key", and
3685
+ * the caller turns that into a message naming the key id, which is the one
3686
+ * thing the partner needs in order to fix it.
3687
+ */
3688
+ export function resolvePrivateKey(
3689
+ keyId: string | null | undefined,
3690
+ env: NodeJS.ProcessEnv = process.env,
3691
+ ): ResolvedKey | null {
3692
+ if (keyId) {
3693
+ const exact = env[\`\${PREFIX}_\${envSuffix(keyId)}\`];
3694
+ if (exact && exact.trim()) return { pem: unescapeNewlines(exact), source: 'exact' };
3695
+ }
3696
+ if (strictKeys(env)) return null;
3697
+ const fallback = env[PREFIX];
3698
+ if (fallback && fallback.trim()) return { pem: unescapeNewlines(fallback), source: 'fallback' };
3699
+ return null;
3700
+ }
3701
+
3702
+ /**
3703
+ * What to tell the partner when a decrypt fails, given which key answered.
3704
+ *
3705
+ * SEPARATE FROM THE CATCH so the wording is one thing rather than three copies
3706
+ * drifting apart, and so it can be tested without staging a failed decrypt.
3707
+ */
3708
+ export function keyFailureAdvice(keyId: string | null | undefined, resolved: ResolvedKey | null): string {
3709
+ const named = keyId ?? '(none named)';
3710
+ if (!resolved) {
3711
+ return strictKeys()
3712
+ ? \`no key configured for key_id \${named}. Set PARTNER_PRIVATE_KEY_PEM_\${keyId ? envSuffix(keyId) : '<KEY_ID>'} in .env. (PARTNER_KEYS_STRICT=1 is set, so the single-key fallback is off.)\`
3713
+ : \`no key configured for key_id \${named}, and PARTNER_PRIVATE_KEY_PEM is empty. Set one in .env.\`;
3714
+ }
3715
+ if (resolved.source === 'fallback') {
3716
+ return \`the single-key PARTNER_PRIVATE_KEY_PEM was used for key_id \${named} and could not open it. If you have rotated keys, set PARTNER_PRIVATE_KEY_PEM_\${keyId ? envSuffix(keyId) : '<KEY_ID>'} to the matching private key. Both old and new stay valid during the overlap window, so keep the previous one set too.\`;
3717
+ }
3718
+ return \`PARTNER_PRIVATE_KEY_PEM_\${keyId ? envSuffix(keyId) : '<KEY_ID>'} is set but does not open this dispatch. Check it is the private half of the public key registered for that key_id in the portal.\`;
3719
+ }
3720
+
3721
+ /**
3722
+ * A line for the startup log: what this receiver can open.
3723
+ *
3724
+ * Printed every boot because the commonest key problem is believing a variable
3725
+ * is set when it is not, and the second commonest is holding one key after a
3726
+ * rotation. Both are visible in one line.
3727
+ */
3728
+ export function describeKeys(env: NodeJS.ProcessEnv = process.env): string {
3729
+ const ids = configuredKeyIds(env);
3730
+ const hasFallback = !strictKeys(env) && (env[PREFIX] ?? '').trim() !== '';
3731
+ if (ids.length === 0 && !hasFallback) {
3732
+ return 'NO private key configured \u2014 every dispatch will fail to decrypt';
3733
+ }
3734
+ const parts: string[] = [];
3735
+ if (ids.length > 0) parts.push(\`\${ids.length} key id(s): \${ids.join(', ')}\`);
3736
+ if (hasFallback) parts.push(ids.length > 0 ? 'plus the single-key fallback' : 'single-key fallback only');
3737
+ return parts.join(' ');
3738
+ }
3739
+ `
3740
+ },
3741
+ {
3742
+ name: "src/config.ts",
3743
+ content: `/**
3744
+ * src/config.ts \u2014 the configuration you set in SETUP, read by the handler.
3745
+ *
3746
+ * \`npx @oneaddress/setup\` writes oneaddress.config.json next to your .env. This
3747
+ * module is the ONE place the receiver reads it, so what you chose during setup
3748
+ * drives the running handler with no code to edit. Secrets stay in .env; this
3749
+ * file is non-secret behaviour only.
3750
+ *
3751
+ * Precedence for every field: an explicit environment variable wins (a one-off
3752
+ * override), then oneaddress.config.json (what setup wrote), then a built-in
3753
+ * default. A missing config file is not an error \u2014 the handler still runs.
3754
+ */
3755
+ import { report } from './report.js';
3756
+ import { readFileSync } from 'node:fs';
3757
+ import { join } from 'node:path';
3758
+
3759
+ /**
3760
+ * WRITE-THROUGH or INBOX, and both are supported on purpose.
3761
+ *
3762
+ * \`write-through\` is what this receiver has always done: decrypt the dispatch,
3763
+ * apply it through \`CustomerStore\`, confirm to OneAddress. Right for a sole
3764
+ * trader and for anyone happy for the receiver to reach their data directly.
3765
+ *
3766
+ * \`inbox\` is for a company with change control over its customer master. The
3767
+ * receiver verifies the signature, stores the dispatch AS IT ARRIVED, and holds
3768
+ * no private key at all. The partner's own connector draws it, decrypts it,
3769
+ * applies it in their database, and acknowledges; only then does the receiver
3770
+ * confirm to OneAddress. The component reachable from the internet cannot read
3771
+ * what it holds.
3772
+ *
3773
+ * Nothing on the wire differs between them. The protocol already separates
3774
+ * delivery from application: the webhook 200 acknowledges delivery and the
3775
+ * confirm reports application. Write-through simply collapses the two.
3776
+ */
3777
+ export type ReceiverMode = 'write-through' | 'inbox';
3778
+
3779
+ export type ReceiverConfig = {
3780
+ partnerId: string;
3781
+ oneAddressApi: string;
3782
+ verifiesAccountReference: boolean;
3783
+ mode: ReceiverMode;
3784
+ };
3785
+
3786
+ const DEFAULTS: ReceiverConfig = {
3787
+ partnerId: '',
3788
+ oneAddressApi: 'https://oneaddress.io',
3789
+ // A receiver that can match, matches. The wizard writes an EXPLICIT value here
3790
+ // from your portal declaration, so this default only bites if the config file
3791
+ // is missing entirely \u2014 in which case answering account.verify is the safer,
3792
+ // more useful default than silently returning "not checked".
3793
+ verifiesAccountReference: true,
3794
+ // DEFAULTS TO WHAT THE RECEIVER HAS ALWAYS DONE. Inbox mode needs a connector
3795
+ // running and a key living somewhere else; a receiver that silently switched
3796
+ // into it would accept dispatches nothing ever collects.
3797
+ mode: 'write-through',
3798
+ };
3799
+
3800
+ function parseMode(value: unknown): ReceiverMode | undefined {
3801
+ return value === 'inbox' || value === 'write-through' ? value : undefined;
3802
+ }
3803
+
3804
+ function stripTrailingSlash(s: string): string {
3805
+ return s.endsWith('/') ? s.slice(0, -1) : s;
3806
+ }
3807
+
3808
+ function loadConfigFile(): Partial<ReceiverConfig> {
3809
+ const path = process.env.ONEADDRESS_CONFIG ?? join(process.cwd(), 'oneaddress.config.json');
3810
+ try {
3811
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
3812
+ const out: Partial<ReceiverConfig> = {};
3813
+ if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
3814
+ if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
3815
+ if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
3816
+ const mode = parseMode(parsed.mode);
3817
+ if (mode) out.mode = mode;
3818
+ return out;
3819
+ } catch {
3820
+ // No config file (or unreadable / malformed): fall back to env + defaults.
3821
+ return {};
3822
+ }
3823
+ }
3824
+
3825
+ const fromFile = loadConfigFile();
3826
+
3827
+ export const config: ReceiverConfig = {
3828
+ partnerId: process.env.PARTNER_ID || fromFile.partnerId || DEFAULTS.partnerId,
3829
+ oneAddressApi: stripTrailingSlash(process.env.ONEADDRESS_API || fromFile.oneAddressApi || DEFAULTS.oneAddressApi),
3830
+ verifiesAccountReference:
3831
+ process.env.VERIFIES_ACCOUNT_REFERENCE != null
3832
+ ? process.env.VERIFIES_ACCOUNT_REFERENCE === 'true'
3833
+ : (fromFile.verifiesAccountReference ?? DEFAULTS.verifiesAccountReference),
3834
+ // An UNRECOGNISED value falls back to write-through rather than failing, and
3835
+ // the startup line below says which mode is live either way, so a typo shows
3836
+ // up as "not the mode I asked for" rather than as a receiver that will not
3837
+ // start. Silent is the thing to avoid, not strict.
3838
+ mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
3839
+ };
3840
+
3841
+ report.info(
3842
+ '[config] loaded (mode=' + config.mode +
3843
+ ', oneAddressApi=' + config.oneAddressApi +
3844
+ ', verifiesAccountReference=' + config.verifiesAccountReference + ')',
3845
+ );
3846
+ `
3847
+ },
3848
+ {
3849
+ name: "src/callback-url.ts",
3850
+ content: `/**
3851
+ * src/callback-url.ts \u2014 the address.verify callback host allowlist.
3852
+ *
3853
+ * OneAddress sends the \`callback_url\` inside the signed webhook body, so HMAC
3854
+ * verification already proves it came from OneAddress. This host allowlist is a
3855
+ * belt-and-braces against a leaked-webhook-secret SSRF: an attacker who could
3856
+ * forge a webhook must not be able to coerce this server into POSTing to an
3857
+ * internal URL (a cloud metadata service, a database admin port, \u2026).
3858
+ *
3859
+ * It returns the validated URL string (or null) rather than a boolean on
3860
+ * purpose: the caller fetches the RETURN VALUE, so the allowlist sits directly
3861
+ * on the taint path and is a provable barrier for CodeQL's request-forgery
3862
+ * query (see .github/codeql/extensions/oneaddress-js-models \u2014 the same shape as
3863
+ * the redirect sanitiser). A boolean guard would leave the raw, still-tainted
3864
+ * value flowing to fetch, which reads as an SSRF whether or not the guard runs.
3865
+ */
3866
+ export function safeOneAddressCallbackUrl(raw: string): string | null {
3867
+ try {
3868
+ const u = new URL(raw);
3869
+ if (u.protocol !== 'https:') return null;
3870
+ const host = u.hostname.toLowerCase();
3871
+ if (host === 'oneaddress.io' || host.endsWith('.oneaddress.io')) return raw;
3872
+ return null;
3873
+ } catch {
3874
+ return null;
3875
+ }
3876
+ }
3877
+ `
3878
+ },
3879
+ {
3880
+ name: "src/customer-store.ts",
3881
+ content: `/**
3882
+ * \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
3883
+ * \u2551 src/customer-store.ts \u2014 THE CONTRACT \u2551
3884
+ * \u2551 \u2551
3885
+ * \u2551 Everything OneAddress needs from your systems, in one file you \u2551
3886
+ * \u2551 can hand to a DBA. \`src/store.ts\` implements it against the \u2551
3887
+ * \u2551 bundled SQLite file; you replace that with your own. \u2551
3888
+ * \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
3889
+ *
3890
+ * ## Why this is a separate file from the implementation
3891
+ *
3892
+ * Until this existed, replacing the store meant reading \`store.ts\` and
3893
+ * reverse-engineering the three functions \`server.ts\` happened to import, past
3894
+ * a schema, a roster seeder, an encryption layer and a repair pass that exist
3895
+ * for the DEMO and are no use to a company that already has a customer
3896
+ * database. The contract was real but it was implied, and an implied contract
3897
+ * is one you find out you got wrong in production.
3898
+ *
3899
+ * It is also the answer to the question a DBA asks first, which is not "how do
3900
+ * I write an adapter" but "what is this thing going to do to my table". That is
3901
+ * five methods and a paragraph each, and it is reviewable in ten minutes
3902
+ * without reading any of our code.
3903
+ *
3904
+ * ## What you are NOT obliged to keep
3905
+ *
3906
+ * The SQLite file, the at-rest encryption, the blind index, \`customers.json\`,
3907
+ * the repair pass and the address history are the DEFAULT implementation, not
3908
+ * the contract. A company whose customer data already lives in its own database
3909
+ * should delete every one of them: encrypting a copy of a record you already
3910
+ * hold protects nothing and gives you a second thing to key-manage. Implement
3911
+ * the five methods below against your own tables and your own controls.
3912
+ *
3913
+ * ## What the receiver still owns, and must keep owning
3914
+ *
3915
+ * The protocol half: signature verification, the replay window, decryption,
3916
+ * the confirm callback, the quarantine. None of it is in here, and a store
3917
+ * implementation is never given a chance to weaken any of it. By the time your
3918
+ * code is called, the dispatch has been proven to come from OneAddress and has
3919
+ * been decrypted in memory.
3920
+ *
3921
+ * Note that the receiver keeps a small local SQLite file even when your
3922
+ * customers live elsewhere, for the confirm queue and the quarantine. Neither
3923
+ * holds customer data: the confirm queue holds dispatch ids and outcomes, and
3924
+ * the quarantine holds ciphertext the receiver could not open. Your DBA is
3925
+ * entitled to ask, and that is the answer.
3926
+ */
3927
+
3928
+ /** An address as OneAddress sends it. Free-form so new fields are not lost. */
3929
+ export type Address = Record<string, unknown>;
3930
+
3931
+ /**
3932
+ * Who the dispatch is about, as \`server.ts\` recovered it.
3933
+ *
3934
+ * ALL OF THIS CAME OUT OF THE CIPHERTEXT, not off the wire. Under D5 there is
3935
+ * no cleartext customer block on a dispatch, so nothing here can be set by
3936
+ * anyone who has not proven they hold the key this partner registered.
3937
+ */
3938
+ export interface Customer {
3939
+ email: string | null;
3940
+ name: string;
3941
+ accountNumber?: string;
3942
+ knownNames?: string[];
3943
+ /**
3944
+ * D5 LOA reference \u2014 the base64url SHA-256 of the signed consent, recomputed
3945
+ * after decrypting \`loa_encrypted\`. Echo it in your own audit trail if you
3946
+ * want proof-of-consent alongside the change. Null on legacy dispatches.
3947
+ */
3948
+ loaRef?: string | null;
3949
+ }
3950
+
3951
+ /** One customer as the dashboard shows them. */
3952
+ export interface StoredCustomer {
3953
+ account_number: string;
3954
+ name: string;
3955
+ /** The address as a JSON string, the way it was stored. */
3956
+ address: string;
3957
+ }
3958
+
3959
+ export type AccountVerdict = 'match' | 'no_match' | 'no_account';
3960
+ export type VerifyResult = 'match' | 'mismatch' | 'not_found';
3961
+
3962
+ export interface CustomerStore {
3963
+ /** Shown on \`/health\` and in the startup line, so a swap is visible. */
3964
+ readonly name: string;
3965
+
3966
+ /**
3967
+ * Are the personal columns YOU hold protected at rest?
3968
+ *
3969
+ * Reported on the dashboard in both states, and the unprotected one is drawn
3970
+ * in red. A store backed by a database with its own encryption should return
3971
+ * \`true\`; one writing plaintext to a file should return \`false\` and mean it.
3972
+ * This is a claim the operator will read as true, so do not return \`true\`
3973
+ * because it feels tidier.
3974
+ */
3975
+ readonly encrypted: boolean;
3976
+
3977
+ /**
3978
+ * Pre-payment account check (\`account.verify\`). BEFORE the consumer pays.
3979
+ *
3980
+ * The boundary that stops somebody pushing an update to an account that is
3981
+ * not theirs, and the only one of these three that runs before money moves.
3982
+ *
3983
+ * 'match' \u2014 the account exists and the name agrees
3984
+ * 'no_match' \u2014 the account exists and the name does not
3985
+ * 'no_account' \u2014 no such account
3986
+ *
3987
+ * \`knownNames\` carries the other names the consumer has verified under, so a
3988
+ * married name on your record and a maiden name on their ID still match. Test
3989
+ * each of them, not just \`name\`.
3990
+ *
3991
+ * RETURN 'no_match' RATHER THAN 'match' WHEN YOU ARE UNSURE. A false 'match'
3992
+ * authorises a stranger's address onto a customer's account; a false
3993
+ * 'no_match' costs a support call.
3994
+ */
3995
+ verifyAccount(
3996
+ accountNumber: string | null,
3997
+ name: string,
3998
+ knownNames?: string[],
3999
+ ): Promise<AccountVerdict> | AccountVerdict;
4000
+
4001
+ /**
4002
+ * Is the address you hold the current one? (\`address.verify\`)
4003
+ *
4004
+ * 'match' \u2014 you already hold exactly this address
4005
+ * 'mismatch' \u2014 you know the customer and hold something different
4006
+ * 'not_found' \u2014 the account is not one of yours
4007
+ *
4008
+ * A customer you have never had an update for is 'mismatch', not 'match':
4009
+ * you know them, you simply do not have THIS address yet.
4010
+ *
4011
+ * READ-ONLY. Nothing here may write.
4012
+ */
4013
+ verifyAddress(customer: Customer, incoming: Address): Promise<VerifyResult>;
4014
+
4015
+ /**
4016
+ * Apply a new address (\`address.updated\`), and return the one it REPLACED.
4017
+ *
4018
+ * The return value is what the dashboard shows as "was", and it is the
4019
+ * question an operator asks first when a change looks wrong. Read it before
4020
+ * you write, because after the write nothing can reconstruct it. Return \`{}\`
4021
+ * if there was nothing.
4022
+ *
4023
+ * ## Called only once the caller is satisfied
4024
+ *
4025
+ * When you declare that you verify account references, \`server.ts\` refuses
4026
+ * anything that is not a 'match' before reaching here, so by this point the
4027
+ * account is a real one of yours. **If you do NOT verify account references,
4028
+ * that guard is not running and this is called with whatever identity the
4029
+ * envelope carried.** Key on something you trust in that mode; the default
4030
+ * implementation keys on the name, and two customers who share a name share a
4031
+ * row.
4032
+ *
4033
+ * ## Throwing is meaningful
4034
+ *
4035
+ * Throw and the dispatch is answered as failed, and OneAddress retries it.
4036
+ * That is the right thing to do if your database is unreachable. Do NOT
4037
+ * swallow a write failure and return normally: the consumer is then told the
4038
+ * address landed when it did not.
4039
+ */
4040
+ saveAddress(customer: Customer, incoming: Address): Promise<Address>;
4041
+
4042
+ /**
4043
+ * One customer, for the dashboard's change panel. \`null\` if unknown.
4044
+ *
4045
+ * SEPARATE FROM THE THREE HOOKS because it is cosmetic: returning \`null\`
4046
+ * always is a perfectly good implementation and costs you a panel, nothing
4047
+ * more. It exists as a method rather than being derived from a list-everyone
4048
+ * call BECAUSE of what that would mean at your scale - see \`count\` below.
4049
+ */
4050
+ find(accountNumber: string): Promise<StoredCustomer | null> | StoredCustomer | null;
4051
+
4052
+ /**
4053
+ * How many customers are on file, or \`null\` for "not worth asking".
4054
+ *
4055
+ * **RETURN \`null\` IF THIS IS A COUNT OVER A REAL CUSTOMER TABLE.** The
4056
+ * dashboard redraws every two seconds; a \`SELECT count(*)\` over four million
4057
+ * rows, forty times a minute, forever, is a load-bearing decision somebody
4058
+ * should make deliberately rather than inherit from a demo. The dashboard
4059
+ * draws a dash and is otherwise identical.
4060
+ *
4061
+ * The bundled SQLite store answers it because its roster is three rows.
4062
+ */
4063
+ count(): Promise<number | null> | number | null;
4064
+ }
4065
+ `
4066
+ },
4067
+ {
4068
+ name: "src/store.ts",
4069
+ content: `/**
4070
+ * \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
4071
+ * \u2551 src/store.ts \u2014 DATABASE INTEGRATION \u2551
4072
+ * \u2551 \u2551
4073
+ * \u2551 THE DEFAULT implementation of \`CustomerStore\`, backed by the \u2551
4074
+ * \u2551 bundled SQLite file: a CUSTOMER ROSTER (who your customers are \u2551
4075
+ * \u2551 + the address you hold on file for each) and the three hooks \u2551
4076
+ * \u2551 OneAddress calls: \u2551
4077
+ * \u2551 verifyAccount \u2014 pre-payment account check (account.verify) \u2551
4078
+ * \u2551 verifyAddress \u2014 is your on-file address current? (address.verify)
4079
+ * \u2551 saveAddress \u2014 apply a new address (address.updated) \u2551
4080
+ * \u2551 \u2551
4081
+ * \u2551 THE CONTRACT IS \`src/customer-store.ts\`, not this file. Read \u2551
4082
+ * \u2551 that one first: it is five methods and a paragraph each, with \u2551
4083
+ * \u2551 no schema, no seeding and no encryption in the way, and it is \u2551
4084
+ * \u2551 what you implement against your own database. \u2551
4085
+ * \u2551 \u2551
4086
+ * \u2551 Everything below the contract \u2014 the SQLite file, the at-rest \u2551
4087
+ * \u2551 encryption, the blind index, customers.json, the repair pass, \u2551
4088
+ * \u2551 the history table \u2014 is THIS implementation, not the contract. \u2551
4089
+ * \u2551 A company whose customers already live in its own database \u2551
4090
+ * \u2551 should delete all of it rather than port it. \u2551
4091
+ * \u2551 \u2551
4092
+ * \u2551 server.ts calls the hooks after verifying and decrypting each \u2551
4093
+ * \u2551 event \u2014 the protocol layer is handled for you, never touch it. \u2551
4094
+ * \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
4095
+ */
4096
+ import { readFileSync } from 'node:fs';
4097
+ import { join } from 'node:path';
4098
+ import { report } from './report.js';
4099
+ import db, { accountKey, dec, enc, encrypted, ensureColumn, isEncrypted, once } from './db.js';
4100
+ import type {
4101
+ AccountVerdict,
4102
+ Address,
4103
+ Customer,
4104
+ CustomerStore,
4105
+ StoredCustomer,
4106
+ VerifyResult,
4107
+ } from './customer-store.js';
4108
+
4109
+ // Re-exported so existing imports of these types from \`./store.js\` keep
4110
+ // working. They are DEFINED in the contract, which is the file to change.
4111
+ export type { AccountVerdict, Address, Customer, CustomerStore, StoredCustomer, VerifyResult };
4112
+
4113
+ // \u2500\u2500 Schema \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\u2500\u2500
4114
+ // \`customers\` is your roster: the account number, the customer's name, and the
4115
+ // address YOU currently hold on file for them. \`address_history\` logs every
4116
+ // change you apply, so you have an audit trail.
4117
+ db.exec(\`
4118
+ CREATE TABLE IF NOT EXISTS customers (
4119
+ -- How a row is FOUND. A blind index of the account number when the database
4120
+ -- is locked, the lower-cased number when it is not. It is the key rather
4121
+ -- than the number itself because AES-GCM uses a fresh IV per write, so two
4122
+ -- encryptions of one account number differ and a primary key over the
4123
+ -- ciphertext would enforce nothing while looking like it did.
4124
+ account_key TEXT PRIMARY KEY,
4125
+ -- What is DISPLAYED. Ciphertext when locked.
4126
+ account_number TEXT NOT NULL,
4127
+ name TEXT NOT NULL,
4128
+ address TEXT NOT NULL DEFAULT '{}',
4129
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
4130
+ );
4131
+
4132
+ CREATE TABLE IF NOT EXISTS address_history (
4133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4134
+ account_key TEXT NOT NULL,
4135
+ -- Both sides of the change, so you can show what an address REPLACED
4136
+ -- rather than only what it became.
4137
+ prev_address TEXT NOT NULL DEFAULT '{}',
4138
+ address TEXT NOT NULL,
4139
+ recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
4140
+ );
4141
+ CREATE INDEX IF NOT EXISTS idx_history_account ON address_history(account_key, id DESC);
4142
+ \`);
4143
+
4144
+ // Self-migrate. An older data.db may already hold a \`customers\` table WITHOUT the
4145
+ // newer columns, and \`CREATE TABLE IF NOT EXISTS\` never alters an existing table.
4146
+ // Add any missing columns here so the handler upgrades its own schema instead of
4147
+ // forcing you to delete the database on every change \u2014 the behaviour a
4148
+ // production integration needs.
2291
4149
  ensureColumn('customers', 'address', "TEXT NOT NULL DEFAULT '{}'");
2292
4150
  ensureColumn('customers', 'updated_at', 'TEXT'); // nullable on migrate; set on write
2293
4151
  ensureColumn('customers', 'account_key', 'TEXT');
@@ -2435,22 +4293,6 @@ const ROSTER = loadRoster();
2435
4293
  report.info(\`[store] roster ready (\${ROSTER.length} customers)\`);
2436
4294
  }
2437
4295
 
2438
- // \u2500\u2500 Identity handed in by server.ts after it verifies + decrypts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2439
- export type Customer = {
2440
- email: string | null;
2441
- name: string;
2442
- accountNumber?: string;
2443
- knownNames?: string[];
2444
- /**
2445
- * D5 LOA reference \u2014 the base64url SHA-256 of the signed consent, recomputed
2446
- * by server.ts after decrypting \`loa_encrypted\`. A production integration
2447
- * echoes this in the \`/api/confirm\` callback so OneAddress can verify
2448
- * proof-of-receipt. Null on legacy dispatches that carry no encrypted LOA.
2449
- * The store ignores it; it rides along on the identity object for convenience.
2450
- */
2451
- loaRef?: string | null;
2452
- };
2453
-
2454
4296
  /**
2455
4297
  * Canonical, order- and case-insensitive form of an address, so two addresses
2456
4298
  * compare equal iff they mean the same thing regardless of key order or casing.
@@ -2485,13 +4327,6 @@ function findCustomer(accountNumber: string | undefined, name: string): { accoun
2485
4327
  }
2486
4328
 
2487
4329
  // \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\u2500\u2500\u2500
2488
- /** One stored row, with the personal columns decrypted. */
2489
- export interface StoredCustomer {
2490
- account_number: string;
2491
- name: string;
2492
- address: string;
2493
- }
2494
-
2495
4330
  /** Decrypt a row read straight out of SQLite. */
2496
4331
  function decodeRow(raw: unknown): StoredCustomer | undefined {
2497
4332
  if (!raw) return undefined;
@@ -2518,8 +4353,6 @@ export function customerCount(): number {
2518
4353
  /** Is the file on disk protected? Surfaced in the dashboard, in both states. */
2519
4354
  export const storeEncrypted = encrypted;
2520
4355
 
2521
- export type AccountVerdict = 'match' | 'no_match' | 'no_account';
2522
-
2523
4356
  /**
2524
4357
  * Confirms the typed account number is really one of yours and the name agrees,
2525
4358
  * BEFORE the consumer pays. The boundary that stops someone pushing an update to
@@ -2542,8 +4375,6 @@ export function verifyAccount(accountNumber: string | null, name: string, knownN
2542
4375
  }
2543
4376
 
2544
4377
  // \u2500\u2500 address.verify: is your on-file address current? \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
2545
- export type VerifyResult = 'match' | 'mismatch' | 'not_found';
2546
-
2547
4378
  /**
2548
4379
  * Compares the consumer's current OneAddress address against what YOU hold.
2549
4380
  * 'match' \u2014 you already hold this exact address (no update needed)
@@ -2622,6 +4453,47 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
2622
4453
 
2623
4454
  return previous;
2624
4455
  }
4456
+
4457
+ /**
4458
+ * One customer by account number, for the dashboard.
4459
+ *
4460
+ * AN INDEXED LOOKUP, not a scan. The dashboard used to call \`allCustomers()\`
4461
+ * and \`.find()\` the one it wanted, which is invisible at three rows and absurd
4462
+ * at four million: decrypting an entire customer table to draw one panel. Doing
4463
+ * it here also means a real store implements a lookup, which is what its own
4464
+ * database is already good at, instead of being handed a list-everything
4465
+ * method it has no safe way to answer.
4466
+ */
4467
+ export function findByAccount(accountNumber: string): StoredCustomer | null {
4468
+ const acct = (accountNumber ?? '').trim();
4469
+ if (!acct) return null;
4470
+ return decodeRow(
4471
+ db.prepare('SELECT account_number, name, address FROM customers WHERE account_key = ?')
4472
+ .get(accountKey(acct)),
4473
+ ) ?? null;
4474
+ }
4475
+
4476
+ /**
4477
+ * THE OBJECT THE RECEIVER ACTUALLY TALKS TO.
4478
+ *
4479
+ * \`server.ts\` and \`tui.ts\` import this and nothing else from here, so pointing
4480
+ * the receiver at your own systems is one import: write a module exporting a
4481
+ * \`CustomerStore\` and change the two lines that name this one. The \`satisfies\`
4482
+ * is the part that makes that safe \u2014 miss a method, or change a signature the
4483
+ * receiver depends on, and this file stops compiling rather than failing on a
4484
+ * live dispatch.
4485
+ */
4486
+ export const store = {
4487
+ name: 'sqlite',
4488
+ encrypted: storeEncrypted,
4489
+ verifyAccount,
4490
+ verifyAddress,
4491
+ saveAddress,
4492
+ find: findByAccount,
4493
+ // Three rows, so a count is free. A store over a real customer table should
4494
+ // return null here; the contract file says why.
4495
+ count: customerCount,
4496
+ } satisfies CustomerStore;
2625
4497
  `
2626
4498
  },
2627
4499
  {
@@ -2661,10 +4533,44 @@ import {
2661
4533
  type SessionKeyShare,
2662
4534
  type OneAddressD5LOA,
2663
4535
  } from '@oneaddress/partner-sdk';
2664
- import { saveAddress, verifyAddress, verifyAccount } from './store.js';
4536
+ // THE ONLY LINE THAT NAMES AN IMPLEMENTATION. Point this at your own module
4537
+ // exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
4538
+ // the protocol layer changes.
4539
+ import { store as writeThroughStore } from './store.js';
4540
+ import { connectorStore, setCurrentRawBody } from './connector-store.js';
2665
4541
  import { notePreviousAddress } from './tui.js';
2666
4542
  import { config } from './config.js';
2667
4543
  import { safeOneAddressCallbackUrl } from './callback-url.js';
4544
+ import { configuredKeyIds, describeKeys, keyFailureAdvice, resolvePrivateKey } from './keys.js';
4545
+ import {
4546
+ drainConfirms,
4547
+ enqueueConfirm,
4548
+ pendingConfirmCount,
4549
+ purgeDelivered,
4550
+ type ConfirmStatus,
4551
+ } from './confirm-queue.js';
4552
+ import {
4553
+ dispatchKey,
4554
+ heldCount,
4555
+ purgeQuarantine,
4556
+ quarantine,
4557
+ replayHeld,
4558
+ type QuarantineReason,
4559
+ } from './quarantine.js';
4560
+ import { recordOutcome, tally } from './tally.js';
4561
+ import { accept as acceptIntoInbox, oldestUndrawn, undrawnCount } from './inbox.js';
4562
+ import { setAcknowledgementHandler, startDrawApi } from './draw-api.js';
4563
+
4564
+ /**
4565
+ * WHICH STORE ANSWERS, chosen once at startup.
4566
+ *
4567
+ * Inbox mode does not branch the handler. It swaps the implementation of the
4568
+ * contract in \`customer-store.ts\`, so every call site below is identical in
4569
+ * both modes and the mode lives in exactly one place. \`connectorStore\` asks the
4570
+ * partner's connector and refuses to write; \`writeThroughStore\` is the bundled
4571
+ * SQLite one this receiver has always used.
4572
+ */
4573
+ const store = config.mode === 'inbox' ? connectorStore : writeThroughStore;
2668
4574
 
2669
4575
  const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? '';
2670
4576
  const PARTNER_PRIVATE_KEY = process.env.PARTNER_PRIVATE_KEY_PEM ?? '';
@@ -2684,11 +4590,38 @@ const PORT = Number(process.env.PORT ?? 3001);
2684
4590
  const ONEADDRESS_API = config.oneAddressApi;
2685
4591
  const CONFIRM_SECRET = process.env.CONFIRM_SECRET || WEBHOOK_SECRET;
2686
4592
 
2687
- if (!WEBHOOK_SECRET || !PARTNER_PRIVATE_KEY || !PARTNER_ID) {
4593
+ if (!WEBHOOK_SECRET || !PARTNER_ID) {
2688
4594
  report.error('[startup] Missing required env vars. Check your .env file.');
2689
4595
  process.exit(1);
2690
4596
  }
2691
4597
 
4598
+ // THE KEY REQUIREMENT INVERTS WITH THE MODE, and this is the assertion that
4599
+ // makes inbox mode mean something.
4600
+ //
4601
+ // Write-through cannot open a single dispatch without a private key, so a
4602
+ // missing one is fatal and always was. Inbox mode's entire property is that the
4603
+ // component reachable from the internet CANNOT read what it holds, and a key
4604
+ // sitting in this process's environment makes that false \u2014 silently, while
4605
+ // every test still passes and every dispatch still lands. Nothing would ever
4606
+ // surface it, because in inbox mode nothing here attempts a decrypt to fail.
4607
+ //
4608
+ // So it refuses to start and names the fix. A partner moving from write-through
4609
+ // to inbox has one edit to make, and being told about it once beats believing a
4610
+ // property they do not have.
4611
+ if (config.mode === 'inbox') {
4612
+ const strayKeys = configuredKeyIds();
4613
+ if (PARTNER_PRIVATE_KEY || strayKeys.length > 0) {
4614
+ report.error('[startup] mode is \`inbox\`, but this receiver has a private key in its environment.');
4615
+ report.error('[startup] Inbox mode exists so the internet-facing process CANNOT read what it stores.');
4616
+ 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.');
4617
+ process.exit(1);
4618
+ }
4619
+ } else if (!PARTNER_PRIVATE_KEY) {
4620
+ report.error('[startup] PARTNER_PRIVATE_KEY_PEM is missing. Check your .env file.');
4621
+ report.error('[startup] (A receiver that should hold no key at all wants RECEIVER_MODE=inbox.)');
4622
+ process.exit(1);
4623
+ }
4624
+
2692
4625
  // Startup self-check. Two faults were historically INVISIBLE until a live
2693
4626
  // dispatch, and both read afterwards as "partner key mismatch" deep inside a
2694
4627
  // try/catch, which is a day of reading webhook logs. Assert them here so the
@@ -2714,6 +4647,41 @@ if (PARTNER_PRIVATE_KEY.includes('BEGIN')) {
2714
4647
  }
2715
4648
  }
2716
4649
 
4650
+ // SAID OUT LOUD ON EVERY BOOT, because the commonest key problem is believing a
4651
+ // variable is set when it is not, and the second commonest is still holding one
4652
+ // key after a rotation. Both are visible in this one line, and neither is
4653
+ // visible anywhere else until a dispatch fails.
4654
+ // In inbox mode "no key" is the CORRECT state, and \`describeKeys\` would report
4655
+ // it as "every dispatch will fail to decrypt", which is true of a write-through
4656
+ // receiver and alarming nonsense here. An operator who reads a red line every
4657
+ // boot stops reading the line.
4658
+ report.info(
4659
+ config.mode === 'inbox'
4660
+ ? '[startup] keys: none, by design \u2014 the connector holds them and this process cannot read a dispatch'
4661
+ : \`[startup] keys: \${describeKeys()}\`,
4662
+ );
4663
+ // WHICH STORE IS LIVE, said out loud at every boot. A receiver pointed at a
4664
+ // partner's own database and one still writing to the bundled demo file behave
4665
+ // identically until the first dispatch, and the difference is where a customer's
4666
+ // address ends up. Naming it costs a line and removes the question.
4667
+ //
4668
+ // AND WHETHER IT IS PROTECTED, IN BOTH DIRECTIONS. The dashboard has always
4669
+ // drawn UNENCRYPTED in red, and a receiver running as a service has no
4670
+ // dashboard: stdin is not a terminal, so nobody is asked for a passphrase,
4671
+ // nobody answers, and it runs in the clear having said nothing. That is the
4672
+ // deployment most likely to hold real customers and the one least likely to
4673
+ // have a person looking at it. Encryption stays OPT-IN - requiring it would
4674
+ // lock out every install that has never set a passphrase - but choosing it by
4675
+ // not being asked is not a choice, so the absence is stated as loudly as the
4676
+ // presence.
4677
+ report.info(
4678
+ store.encrypted
4679
+ ? \`[startup] store: \${store.name} (encrypted at rest)\`
4680
+ : \`[startup] store: \${store.name} \u2014 NOT ENCRYPTED AT REST. \` +
4681
+ 'Customer records are readable by anyone who can read the file. ' +
4682
+ 'Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` in a terminal to be asked.',
4683
+ );
4684
+
2717
4685
  // In-memory dedup cache. Records a dispatch id only once it has been fully
2718
4686
  // HANDLED, so a dispatch that failed to decrypt is NOT remembered and a later
2719
4687
  // retry re-runs it rather than being dismissed as a duplicate. Bounded so a
@@ -2735,9 +4703,10 @@ function rememberDispatch(id: string): void {
2735
4703
 
2736
4704
  /**
2737
4705
  * Close the loop: tell OneAddress you have applied an update, so the consumer's
2738
- * dashboard flips the service to "Confirmed". Fire-and-forget so it never delays
2739
- * the webhook 200 (a slow confirm must not make OneAddress time the DISPATCH out
2740
- * and mark it failed). Logs its own outcome.
4706
+ * dashboard flips the service to "Confirmed". Called ONLY by the confirm
4707
+ * queue's drain, never from the webhook handler: a slow confirm must not delay
4708
+ * the webhook 200 (OneAddress would time the DISPATCH out and mark it failed),
4709
+ * and a failed one has to be retried rather than logged and lost.
2741
4710
  *
2742
4711
  * Auth for /api/confirm (all three required):
2743
4712
  * Authorization: Bearer <secret>
@@ -2745,13 +4714,11 @@ function rememberDispatch(id: string): void {
2745
4714
  * X-OneAddress-Signature: HMAC-SHA256(secret, \`\${timestamp}.\${rawBody}\`)
2746
4715
  * The same secret signs the Bearer and the body.
2747
4716
  */
2748
- async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'failed'): Promise<void> {
2749
- // Only real dispatches carry a numeric id in X-OneAddress-Dispatch. Probes
2750
- // (e.g. the go-live "address.test") carry a non-numeric id and have nothing to
2751
- // confirm, so skip them.
2752
- const dispatchId = Number(dispatch);
2753
- if (!Number.isInteger(dispatchId) || dispatchId <= 0) return;
2754
-
4717
+ async function confirmToOneAddress(dispatchId: number, status: ConfirmStatus): Promise<void> {
4718
+ // No probe check here any more. queueConfirm is the only door into the queue
4719
+ // and it drops non-numeric dispatch ids, so by the time a row is drained it
4720
+ // is a real dispatch. Keeping a second copy of that rule would mean a probe
4721
+ // could be queued forever and silently skipped on every drain.
2755
4722
  const bodyStr = JSON.stringify({
2756
4723
  dispatch_id: dispatchId,
2757
4724
  partner_id: PARTNER_ID,
@@ -2761,7 +4728,7 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
2761
4728
  const ts = String(Math.floor(Date.now() / 1000));
2762
4729
  const sig = createHmac('sha256', CONFIRM_SECRET).update(\`\${ts}.\${bodyStr}\`).digest('hex');
2763
4730
 
2764
- try {
4731
+ {
2765
4732
  const confirmRes = await fetch(\`\${ONEADDRESS_API}/api/confirm\`, {
2766
4733
  method: 'POST',
2767
4734
  headers: {
@@ -2774,18 +4741,31 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
2774
4741
  });
2775
4742
  if (confirmRes.ok) {
2776
4743
  report.info(\`[confirm] dispatch \${dispatchId} \u2192 \${status}: acknowledged by OneAddress\`);
2777
- } else {
2778
- const detail = await confirmRes.text().catch(() => '');
2779
- report.error(\`[confirm] dispatch \${dispatchId} confirm FAILED \u2014 HTTP \${confirmRes.status} \${detail}\`);
2780
- if (confirmRes.status === 401) {
2781
- report.error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET in .env to it (from the portal Webhook screen); otherwise your webhook signing secret should work.');
2782
- }
4744
+ return;
2783
4745
  }
2784
- } catch (err) {
2785
- report.error('[confirm] confirm request error:', err);
4746
+ const detail = (await confirmRes.text().catch(() => '')).slice(0, 200);
4747
+ if (confirmRes.status === 401) {
4748
+ report.error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET in .env to it (from the portal Webhook screen); otherwise your webhook signing secret should work. The confirm stays queued, so fixing .env and restarting will deliver it.');
4749
+ }
4750
+ // THROWN, NOT LOGGED AND SWALLOWED. The queue is what decides to retry, and
4751
+ // it can only do that if failure reaches it. Returning quietly here is the
4752
+ // original bug: applied locally, never acknowledged, refunded upstream.
4753
+ throw new Error(\`HTTP \${confirmRes.status} \${detail}\`);
2786
4754
  }
2787
4755
  }
2788
4756
 
4757
+ /**
4758
+ * Hand a confirm to the queue.
4759
+ *
4760
+ * Probes (the go-live \`address.test\`) carry a non-numeric dispatch id and have
4761
+ * nothing to confirm, so they are dropped here rather than queued forever.
4762
+ */
4763
+ function queueConfirm(dispatch: string, status: ConfirmStatus): void {
4764
+ const dispatchId = Number(dispatch);
4765
+ if (!Number.isInteger(dispatchId) || dispatchId <= 0) return;
4766
+ enqueueConfirm(dispatchId, status);
4767
+ }
4768
+
2789
4769
  const app = express();
2790
4770
  app.disable('x-powered-by'); // don't fingerprint the framework
2791
4771
 
@@ -2856,6 +4836,21 @@ app.post('/webhook', async (req: Request, res: Response) => {
2856
4836
 
2857
4837
  const event = body.event as string;
2858
4838
 
4839
+ // PARKED FOR THE CONNECTOR STORE, and read in the same tick it is set.
4840
+ // In inbox mode the receiver holds no private key, so the only thing it can
4841
+ // hand the connector for a verify is the ciphertext that arrived. The
4842
+ // CustomerStore contract passes a decoded customer, which this process cannot
4843
+ // produce, so the bytes travel out of band rather than widening the contract
4844
+ // for one implementation. See connector-store.ts for why this is safe and
4845
+ // what would make it unsafe.
4846
+ //
4847
+ // ABOVE EVERY HANDLER THAT READS IT, and that position is the fix rather than
4848
+ // a tidy-up: this sat below \`account.verify\`, which returns long before it, so
4849
+ // in inbox mode the connector was handed the PREVIOUS request's ciphertext, or
4850
+ // an empty string on the first request of the process's life. It would have
4851
+ // answered honestly about the wrong consumer.
4852
+ setCurrentRawBody(rawBody);
4853
+
2859
4854
  // \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
2860
4855
  // Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
2861
4856
  // no address, no session envelope \u2014 so it is handled HERE, above the address-
@@ -2870,6 +4865,20 @@ app.post('/webhook', async (req: Request, res: Response) => {
2870
4865
  return res.status(200).json({ ok: true, skipped: true });
2871
4866
  }
2872
4867
 
4868
+ // \u2500\u2500 INBOX MODE: the receiver cannot open this, and must not try \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4869
+ //
4870
+ // The decrypt below is the reason this branch exists. In inbox mode there
4871
+ // is no private key in this process, so \`decryptAddress\` would fail and
4872
+ // answer 422 \u2014 and the consumer, who is mid-payment, would be told the
4873
+ // account check failed when nothing is wrong. The connector holds the key
4874
+ // and the customer records, so it answers. \`connectorStore\` sends the raw
4875
+ // body; the arguments below are the contract's shape, not its source.
4876
+ if (config.mode === 'inbox') {
4877
+ const status = await store.verifyAccount(null, '', []);
4878
+ report.info(\`[webhook] account.verify \u2192 \${status} (answered by the connector)\`);
4879
+ return res.status(200).json({ status });
4880
+ }
4881
+
2873
4882
  const enc = body.customer_encrypted as {
2874
4883
  ephemeralPublicKey: string; iv: string; ciphertext: string; hkdfSalt?: string;
2875
4884
  } | undefined;
@@ -2887,7 +4896,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
2887
4896
  const name = typeof cust.name === 'string' ? cust.name : '';
2888
4897
  const knownNames = Array.isArray(cust.known_names) ? cust.known_names.map(String) : [];
2889
4898
 
2890
- const status = verifyAccount(accountNumber, name, knownNames);
4899
+ const status = await store.verifyAccount(accountNumber, name, knownNames);
2891
4900
  report.info(\`[webhook] account.verify \u2192 \${status} for account \${accountNumber ?? '(none)'} (\${name})\`);
2892
4901
  return res.status(200).json({ status });
2893
4902
  }
@@ -2898,6 +4907,128 @@ app.post('/webhook', async (req: Request, res: Response) => {
2898
4907
  // Acknowledge it here; only the real dispatch events below require decryption.
2899
4908
  // A real address.updated that arrives WITHOUT a payload still falls through to
2900
4909
  // the 422 below, because it IS a dispatch event.
4910
+ /**
4911
+ * Keep this dispatch so it can be applied once the cause is fixed.
4912
+ *
4913
+ * A closure rather than four calls passing the same three values, because the
4914
+ * value that must not be got wrong is \`rawBody\`: quarantine holds the bytes
4915
+ * AS THEY ARRIVED and never a re-serialised \`body\`. \`JSON.stringify(body)\` is
4916
+ * a different string with the same meaning, and replaying it would fail the
4917
+ * signature check it is about to be re-signed under, for a reason nobody
4918
+ * would find.
4919
+ *
4920
+ * Everything that calls this sits BELOW the HMAC check above. That is what
4921
+ * stops the quarantine being a way for anyone who can reach this port to fill
4922
+ * a partner's disk.
4923
+ */
4924
+ /**
4925
+ * What this dispatch IS, for anything that has to recognise it again.
4926
+ *
4927
+ * The same value the quarantine keys on, so a re-delivery - a OneAddress
4928
+ * retry of a 422, a press of [r], the same body posted twice - lands on the
4929
+ * record that is already there instead of looking like a new arrival.
4930
+ */
4931
+ const key = dispatchKey(dispatch || null, rawBody);
4932
+
4933
+ const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void => {
4934
+ quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
4935
+ recordOutcome(key, 'failed');
4936
+ };
4937
+
4938
+ /**
4939
+ * Post an \`address.verify\` verdict back to OneAddress.
4940
+ *
4941
+ * ONE IMPLEMENTATION, TWO CALLERS, and the second one is why it is a function.
4942
+ * Write-through decrypts the envelope and asks its own store; inbox mode
4943
+ * cannot decrypt anything and asks the connector. Everything from the verdict
4944
+ * onward is identical, including the callback-host check, and a second copy of
4945
+ * that check is a second place for it to be dropped.
4946
+ *
4947
+ * Returns a Response when it REFUSED to post and has already answered the
4948
+ * caller, and null when the verdict went out. Deliberately does NOT remember
4949
+ * the dispatch: the two callers differ on that, and burying the difference in
4950
+ * here is how one of them would get it silently wrong.
4951
+ */
4952
+ const postVerifyVerdict = async (result: string): Promise<Response | null> => {
4953
+ const callbackUrl = body.callback_url as string;
4954
+ const callbackToken = body.callback_token as string;
4955
+ const batchId = body.batch_id as string;
4956
+
4957
+ // Callback URL is signed inside the body, so HMAC verify already proves it
4958
+ // came from OneAddress. We still validate the host as defence in depth:
4959
+ // if the webhook secret ever leaks, an attacker who can forge a webhook
4960
+ // could otherwise coerce this server into POSTing to any internal
4961
+ // URL (database admin, cloud metadata service, \u2026) \u2014 turning the partner's
4962
+ // network position into an SSRF primitive.
4963
+ const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
4964
+ if (!safeCallbackUrl) {
4965
+ report.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
4966
+ return res.status(400).json({ error: 'Invalid callback_url host' });
4967
+ }
4968
+
4969
+ await fetch(safeCallbackUrl, {
4970
+ method: 'POST',
4971
+ headers: { 'Content-Type': 'application/json' },
4972
+ body: JSON.stringify({
4973
+ // 2026.2 \u2014 no member_name echo; OneAddress keys the result on
4974
+ // (batch_id, partner_id) and validates the opaque token alone.
4975
+ batch_id: batchId,
4976
+ partner_id: PARTNER_ID,
4977
+ result,
4978
+ token: callbackToken,
4979
+ }),
4980
+ });
4981
+ return null;
4982
+ };
4983
+
4984
+ // \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
4985
+ //
4986
+ // An \`address.updated\` is stored exactly as it arrived and the connector is
4987
+ // left to collect it. The 200 below is honest and is not a claim that anything
4988
+ // was applied: the protocol already separates the two, and the CONFIRM is what
4989
+ // reports application. It fires when the connector says so, which may be
4990
+ // hours later, and \`user_services.state\` has carried \`awaiting_confirm\` for
4991
+ // that gap since long before this mode existed.
4992
+ //
4993
+ // The verify events deliberately fall through to the code below, because they
4994
+ // must be answered NOW, before a consumer pays. In inbox mode that answer
4995
+ // comes from the connector.
4996
+ // \u2500\u2500 INBOX MODE, the other half: a verify the receiver cannot open \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4997
+ //
4998
+ // \`address.verify\` has to be answered now, like \`account.verify\` above, but it
4999
+ // is answered by POSTing a callback rather than in the response body. The
5000
+ // callback URL and token travel in the CLEARTEXT body, not the envelope, so
5001
+ // this process can still post it; only the VERDICT needs a key, and that comes
5002
+ // from the connector.
5003
+ if (config.mode === 'inbox' && event === 'address.verify') {
5004
+ // The contract's arguments, not its source: \`connectorStore\` sends the raw
5005
+ // body it was parked with and ignores these.
5006
+ const result = await store.verifyAddress({ email: null, name: '' }, {});
5007
+ report.info(\`[webhook] address.verify \u2192 \${result} (answered by the connector)\`);
5008
+ const refused = await postVerifyVerdict(result);
5009
+ if (refused) return refused;
5010
+ // DELIBERATELY NOT \`rememberDispatch\`, for the same reason as the inbox
5011
+ // \`address.updated\` branch below: nothing was decrypted here, so there is
5012
+ // no state a retry would duplicate. OneAddress keys a verify result on
5013
+ // (batch_id, partner_id), so re-answering a retried check is a no-op \u2014 and
5014
+ // NOT remembering is the safer half of the trade, because a check we failed
5015
+ // to answer still gets answered on the retry instead of being dismissed.
5016
+ return res.status(200).json({ ok: true });
5017
+ }
5018
+
5019
+ if (config.mode === 'inbox' && event === 'address.updated') {
5020
+ acceptIntoInbox({ key, dispatchId: dispatch || null, event, rawBody });
5021
+ report.info(\`[inbox] held \${dispatch || key} for the connector\`);
5022
+ // DELIBERATELY NOT \`rememberDispatch\`. A guard in templates.test.ts asserts
5023
+ // that nothing is remembered before the decrypt is attempted, because a
5024
+ // dispatch marked seen too early makes OneAddress's retry look like a
5025
+ // duplicate and loses the update. Adding an exception here for a branch
5026
+ // that happens not to decrypt would weaken the guard for the branch that
5027
+ // does. It is not needed anyway: \`accept\` is INSERT OR IGNORE on the same
5028
+ // dispatch identity, so a redelivery is already harmless.
5029
+ return res.status(200).json({ ok: true, queued: true });
5030
+ }
5031
+
2901
5032
  const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
2902
5033
  if (!DISPATCH_EVENTS.includes(event)) {
2903
5034
  report.info(\`[webhook] "\${event}" acknowledged (no address payload to decrypt)\`);
@@ -2931,28 +5062,53 @@ app.post('/webhook', async (req: Request, res: Response) => {
2931
5062
 
2932
5063
  if (sessionEnvelope && sessionKeyShare) {
2933
5064
  // D5 \u2014 call decryptSession. Wrong key_id / wrong partner_id / tampered
2934
- // ciphertext all surface as an AES-GCM authentication-tag error.
5065
+ // ciphertext all surface as an AES-GCM authentication-tag error, which is
5066
+ // why the key is chosen BY key_id here rather than assumed: the failure
5067
+ // cannot tell you which of those three it was, so the message has to.
5068
+ const keyId = sessionKeyShare.key_id ?? null;
5069
+ const resolved = resolvePrivateKey(keyId);
5070
+ if (!resolved) {
5071
+ report.error(\`[webhook] no private key for key_id \${keyId ?? '(none)'} \u2014 \${keyFailureAdvice(keyId, null)}\`);
5072
+ hold('no_key', keyId, keyFailureAdvice(keyId, null));
5073
+ return res.status(422).json({ ok: false, error: 'no private key for this key_id' });
5074
+ }
2935
5075
  try {
2936
- const data = await decryptSession(sessionKeyShare, sessionEnvelope, PARTNER_PRIVATE_KEY, PARTNER_ID);
5076
+ const data = await decryptSession(sessionKeyShare, sessionEnvelope, resolved.pem, PARTNER_ID);
2937
5077
  address = data.new_address as unknown as Record<string, unknown>;
2938
5078
  decName = typeof data.verified_name === 'string' ? data.verified_name : '';
2939
5079
  decAccount = typeof data.account_number === 'string' ? data.account_number : '';
2940
5080
  decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
2941
5081
  } catch (err) {
2942
- report.error('[webhook] D5 decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM matches key_id', sessionKeyShare.key_id, ':', err);
5082
+ // Names WHICH key answered. A rotation used to surface here as an
5083
+ // authentication-tag error indistinguishable from corruption, with the
5084
+ // single-key fallback having silently answered for a key id it never held.
5085
+ report.error(\`[webhook] D5 decryption failed for key_id \${keyId ?? '(none)'}: \${keyFailureAdvice(keyId, resolved)}\`);
5086
+ report.error('[webhook] underlying error:', err);
5087
+ hold('decrypt_failed', keyId, keyFailureAdvice(keyId, resolved));
2943
5088
  return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
2944
5089
  }
2945
5090
  } else if (legacyPayload) {
5091
+ // Pre-D5 dispatches name no key id, so only the fallback can apply.
5092
+ const resolved = resolvePrivateKey(null);
5093
+ if (!resolved) {
5094
+ report.error(\`[webhook] no private key for a legacy dispatch \u2014 \${keyFailureAdvice(null, null)}\`);
5095
+ hold('no_key', null, keyFailureAdvice(null, null));
5096
+ return res.status(422).json({ ok: false, error: 'no private key configured' });
5097
+ }
2946
5098
  try {
2947
- address = await decryptAddress(legacyPayload, PARTNER_PRIVATE_KEY, PARTNER_ID);
5099
+ address = await decryptAddress(legacyPayload, resolved.pem, PARTNER_ID);
2948
5100
  decName = typeof address.fullName === 'string' ? address.fullName : '';
2949
5101
  decAccount = typeof address.accountReference === 'string' ? address.accountReference : '';
2950
5102
  decKnownNames = Array.isArray(address.knownNames) ? address.knownNames as string[] : [];
2951
5103
  } catch (err) {
2952
5104
  report.error('[webhook] Decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM in .env:', err);
5105
+ hold('decrypt_failed', null, keyFailureAdvice(null, resolved));
2953
5106
  return res.status(422).json({ ok: false, error: 'Decryption failed \u2014 partner key mismatch' });
2954
5107
  }
2955
5108
  } else {
5109
+ // No \`hold\` here: there is no payload to hold, so there is nothing a fix
5110
+ // could later apply. It still counts as a dispatch that did not land.
5111
+ recordOutcome(key, 'failed');
2956
5112
  return res.status(422).json({ ok: false, error: 'No encrypted payload on dispatch' });
2957
5113
  }
2958
5114
 
@@ -2967,11 +5123,23 @@ app.post('/webhook', async (req: Request, res: Response) => {
2967
5123
  const loaEncrypted = body.loa_encrypted as
2968
5124
  { session_envelope: string; session_key_share: SessionKeyShare } | null | undefined;
2969
5125
  if (loaEncrypted && typeof loaEncrypted === 'object') {
2970
- try {
2971
- const loa: OneAddressD5LOA = await decryptLoaEncrypted(loaEncrypted, PARTNER_PRIVATE_KEY, PARTNER_ID);
2972
- loaRef = d5LoaRef(loa);
2973
- } catch (err) {
2974
- report.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
5126
+ // RESOLVED BY THE LOA'S OWN key_id, not by the single PARTNER_PRIVATE_KEY_PEM.
5127
+ // It is wrapped to the same key id as the address envelope, but it carries
5128
+ // its own share, so that is what is read. Using the single key meant that
5129
+ // after a rotation with PARTNER_KEYS_STRICT=1 the address opened and the LOA
5130
+ // silently did not, losing the proof-of-consent on exactly the changes a
5131
+ // partner most wants it for, with nothing anyone would see.
5132
+ const loaKeyId = loaEncrypted.session_key_share?.key_id ?? null;
5133
+ const loaKey = resolvePrivateKey(loaKeyId);
5134
+ if (!loaKey) {
5135
+ report.warn(\`[webhook] no key for the LOA's key_id \${loaKeyId ?? '(none)'} \u2014 applying without a consent reference\`);
5136
+ } else {
5137
+ try {
5138
+ const loa: OneAddressD5LOA = await decryptLoaEncrypted(loaEncrypted, loaKey.pem, PARTNER_ID);
5139
+ loaRef = d5LoaRef(loa);
5140
+ } catch (err) {
5141
+ report.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
5142
+ }
2975
5143
  }
2976
5144
  }
2977
5145
 
@@ -3017,10 +5185,13 @@ app.post('/webhook', async (req: Request, res: Response) => {
3017
5185
  // OneAddress conformance check "Refuses an account reference that matches
3018
5186
  // no record" tests exactly this.
3019
5187
  if (config.verifiesAccountReference) {
3020
- const verdict = verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
5188
+ const verdict = await store.verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
3021
5189
  if (verdict !== 'match') {
3022
5190
  report.warn(\`[webhook] address.updated REFUSED (\${verdict}) for account \${ctx.accountNumber ?? '(none)'} \u2014 nothing applied\`);
3023
- void confirmToOneAddress(dispatch, 'failed');
5191
+ queueConfirm(dispatch, 'failed');
5192
+ // A refusal is not a fault and is never held, but it IS a dispatch that
5193
+ // did not land, so it counts. See quarantine.ts for why the two differ.
5194
+ recordOutcome(key, 'failed');
3024
5195
  return res.status(200).json({ ok: false, error: 'account_not_matched', verdict });
3025
5196
  }
3026
5197
  }
@@ -3029,49 +5200,25 @@ app.post('/webhook', async (req: Request, res: Response) => {
3029
5200
  // it can show both halves; a no-op under --headless. Passed directly rather
3030
5201
  // than reported, because the previous address is a customer's address and
3031
5202
  // must never reach a log line.
3032
- const replaced = await saveAddress(ctx, address);
5203
+ const replaced = await store.saveAddress(ctx, address);
3033
5204
  notePreviousAddress(replaced);
3034
5205
  if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
3035
5206
  // Close the loop back to OneAddress so the service flips to "Confirmed".
3036
- // Fire-and-forget: it must not delay this 200 (which acks the delivery).
3037
- void confirmToOneAddress(dispatch, 'confirmed');
5207
+ // QUEUED, not sent: this is a local INSERT, so it cannot delay the 200 that
5208
+ // acks the delivery, and it survives a restart. The drain loop does the
5209
+ // network part and retries it until OneAddress answers.
5210
+ queueConfirm(dispatch, 'confirmed');
5211
+ recordOutcome(key, 'applied');
3038
5212
  return res.status(200).json({ ok: true });
3039
5213
  }
3040
5214
 
3041
5215
  // \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
3042
5216
  if (event === 'address.verify') {
3043
- const callbackUrl = body.callback_url as string;
3044
- const callbackToken = body.callback_token as string;
3045
- const batchId = body.batch_id as string;
3046
-
3047
- // Callback URL is signed inside the body, so HMAC verify already proves it
3048
- // came from OneAddress. We still validate the host as defence in depth:
3049
- // if the webhook secret ever leaks, an attacker who can forge a webhook
3050
- // could otherwise coerce this server into POSTing to any internal
3051
- // URL (database admin, cloud metadata service, \u2026) \u2014 turning the partner's
3052
- // network position into an SSRF primitive.
3053
- const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
3054
- if (!safeCallbackUrl) {
3055
- report.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
3056
- return res.status(400).json({ error: 'Invalid callback_url host' });
3057
- }
3058
-
3059
5217
  report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
3060
- const result = await verifyAddress(ctx, address);
5218
+ const result = await store.verifyAddress(ctx, address);
3061
5219
  report.info(\`[webhook] address.verify \u2192 \${result}\`);
3062
-
3063
- await fetch(safeCallbackUrl, {
3064
- method: 'POST',
3065
- headers: { 'Content-Type': 'application/json' },
3066
- body: JSON.stringify({
3067
- // 2026.2 \u2014 no member_name echo; OneAddress keys the result on
3068
- // (batch_id, partner_id) and validates the opaque token alone.
3069
- batch_id: batchId,
3070
- partner_id: PARTNER_ID,
3071
- result,
3072
- token: callbackToken,
3073
- }),
3074
- });
5220
+ const refused = await postVerifyVerdict(result);
5221
+ if (refused) return refused;
3075
5222
  if (dispatch) rememberDispatch(dispatch); // remember only after the callback posted
3076
5223
  return res.status(200).json({ ok: true });
3077
5224
  }
@@ -3104,12 +5251,179 @@ app.post('/webhook', async (req: Request, res: Response) => {
3104
5251
  return res.status(200).json({ ok: true, skipped: true });
3105
5252
  });
3106
5253
 
3107
- app.get('/health', (_req, res) => res.json({ status: 'ok' }));
5254
+ /**
5255
+ * Enough to answer "is this receiver healthy AND is it protected?" from a
5256
+ * monitoring system, without a terminal and without reading the logs.
5257
+ *
5258
+ * \`encryptedAtRest\` is here rather than only on the dashboard because the
5259
+ * deployment that most needs the answer is the one with no dashboard. Carrying
5260
+ * it makes the unprotected state alertable instead of merely visible.
5261
+ *
5262
+ * Deliberately NO counts, no customer data and no key material: this endpoint
5263
+ * is reachable by whatever can reach the webhook.
5264
+ */
5265
+ app.get('/health', (_req, res) => res.json({
5266
+ status: 'ok',
5267
+ store: store.name,
5268
+ encryptedAtRest: store.encrypted,
5269
+ }));
3108
5270
 
3109
5271
  app.listen(PORT, () =>
3110
5272
  report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`),
3111
5273
  );
3112
5274
 
5275
+ /**
5276
+ * Drain the confirm queue, forever.
5277
+ *
5278
+ * STARTED AT BOOT, not only after a dispatch, because the queue survives a
5279
+ * restart: a receiver that was down while OneAddress was unreachable comes back
5280
+ * with confirms owed, and nobody is going to send a fresh dispatch to trigger
5281
+ * them. On a healthy receiver this loop finds nothing and costs one indexed
5282
+ * SELECT every few seconds.
5283
+ */
5284
+ const CONFIRM_DRAIN_MS = Number(process.env.CONFIRM_DRAIN_MS ?? 5000);
5285
+ const CONFIRM_KEEP_DAYS = Number(process.env.CONFIRM_KEEP_DAYS ?? 30);
5286
+
5287
+ let draining = false;
5288
+ setInterval(() => {
5289
+ // Guarded rather than queued: a slow OneAddress must not start a second
5290
+ // drain over the same rows, which would confirm each one twice.
5291
+ if (draining) return;
5292
+ draining = true;
5293
+ void drainConfirms(confirmToOneAddress)
5294
+ .catch((err: unknown) => report.error('[confirm] drain error:', err))
5295
+ .finally(() => { draining = false; });
5296
+ }, CONFIRM_DRAIN_MS).unref();
5297
+
5298
+ // Delivered rows only, so an outstanding confirm is never aged out. Hourly is
5299
+ // far more often than needed and costs one DELETE over an index.
5300
+ setInterval(() => {
5301
+ const purged = purgeDelivered(CONFIRM_KEEP_DAYS);
5302
+ if (purged > 0) report.info(\`[confirm] purged \${purged} delivered confirm record(s) older than \${CONFIRM_KEEP_DAYS}d\`);
5303
+ }, 3_600_000).unref();
5304
+
5305
+ /**
5306
+ * Re-deliver a held dispatch TO THIS SERVER, re-signed.
5307
+ *
5308
+ * A self-POST rather than a second code path into the handler. The handler is
5309
+ * one 300-line route and the alternative is extracting it so replay can call it
5310
+ * directly, which would give two ways in and, in time, two behaviours: the one
5311
+ * partners hit and the one replay hits, differing in whichever branch was added
5312
+ * to only one of them. Going back in through the front door means a replayed
5313
+ * dispatch is verified, parsed, decrypted, applied and confirmed by exactly the
5314
+ * code a live one is.
5315
+ *
5316
+ * The body is byte-for-byte what arrived; only the timestamp and signature are
5317
+ * new, because the original pair is outside the \xB15-minute window by the time
5318
+ * anyone has fixed anything. We hold the secret, so re-signing is not a bypass:
5319
+ * it is the same proof, re-stated now.
5320
+ */
5321
+ async function redeliver(rawBody: string, heldDispatchId: string | null): Promise<void> {
5322
+ const ts = String(Math.floor(Date.now() / 1000));
5323
+ const sig = createHmac('sha256', WEBHOOK_SECRET).update(\`\${ts}.\${rawBody}\`).digest('hex');
5324
+ // THE ID THE DISPATCH ARRIVED WITH, handed over by the row being replayed.
5325
+ // Re-deriving it from the body is what made one held dispatch become two.
5326
+ const dispatchId = (heldDispatchId ?? '').trim();
5327
+
5328
+ const res = await fetch(\`http://127.0.0.1:\${PORT}/webhook\`, {
5329
+ method: 'POST',
5330
+ headers: {
5331
+ 'Content-Type': 'application/json',
5332
+ 'X-OneAddress-Timestamp': ts,
5333
+ 'X-OneAddress-Signature': sig,
5334
+ ...(dispatchId ? { 'X-OneAddress-Dispatch': dispatchId } : {}),
5335
+ },
5336
+ body: rawBody,
5337
+ });
5338
+ const text = (await res.text().catch(() => '')).slice(0, 200);
5339
+ if (!res.ok) throw new Error(\`HTTP \${res.status} \${text}\`);
5340
+ // A 200 is not on its own success here: the handler answers \`ok: false\` with
5341
+ // 200 when it REFUSES a dispatch (an account reference matching nobody), and
5342
+ // treating that as applied would clear the row for an update that was never
5343
+ // stored.
5344
+ if (!text.includes('"ok":true')) throw new Error(\`refused: \${text}\`);
5345
+ }
5346
+
5347
+ /** Apply everything held. Bound to [r] on the dashboard and run once at boot. */
5348
+ export async function replayQuarantined(): Promise<{ applied: number; failed: number }> {
5349
+ return replayHeld(redeliver);
5350
+ }
5351
+
5352
+ /**
5353
+ * Try the backlog once, a moment after boot.
5354
+ *
5355
+ * Because the realistic sequence is: a key is wrong, dispatches pile up, the
5356
+ * partner edits \`.env\`, the partner restarts. Making them find a command after
5357
+ * that is making the recovery depend on reading documentation at the exact
5358
+ * moment they are least inclined to. The delay lets \`listen\` settle, since this
5359
+ * goes back in through the port.
5360
+ */
5361
+ // \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
5362
+ //
5363
+ // Wired here rather than in draw-api.ts because this file owns the confirm
5364
+ // queue: an acknowledgement from the connector is the moment OneAddress gets
5365
+ // told, and that has to go through the same durable queue every other confirm
5366
+ // uses so a failure to reach OneAddress is retried rather than lost.
5367
+ if (config.mode === 'inbox') {
5368
+ setAcknowledgementHandler((dispatchId, outcome) => {
5369
+ queueConfirm(dispatchId, outcome === 'applied' ? 'confirmed' : 'failed');
5370
+ recordOutcome(\`d:\${dispatchId}\`, outcome === 'applied' ? 'applied' : 'failed');
5371
+ });
5372
+ const started = startDrawApi();
5373
+ if (!started) {
5374
+ // AN INBOX WITH NO WAY TO DRAIN IT IS WORSE THAN NOT STARTING. In
5375
+ // write-through mode a missing connector token is irrelevant and the
5376
+ // receiver runs; here it means every dispatch would be accepted and held
5377
+ // with nothing able to collect it, and the consumer would be told nothing
5378
+ // for as long as that lasted.
5379
+ report.error('[startup] mode is \`inbox\` but the connector channel could not open. Refusing to start.');
5380
+ process.exit(1);
5381
+ }
5382
+ }
5383
+
5384
+ setTimeout(() => {
5385
+ if (heldCount() === 0) return;
5386
+ void replayQuarantined().catch((err: unknown) =>
5387
+ report.error('[replay] could not run at startup:', err),
5388
+ );
5389
+ }, 1_500).unref();
5390
+
5391
+ // Held payloads age out whether or not they were ever applied, which is the
5392
+ // opposite of the confirm queue one block up. The reason is what the row holds:
5393
+ // a confirm record names a dispatch, a quarantined payload is a consumer's
5394
+ // encrypted address on someone else's disk.
5395
+ const QUARANTINE_KEEP_DAYS = Number(process.env.QUARANTINE_KEEP_DAYS ?? 30);
5396
+ setInterval(() => { purgeQuarantine(QUARANTINE_KEEP_DAYS); }, 3_600_000).unref();
5397
+
5398
+ /** How many confirms are still owed, and how the dispatches went. Read by the dashboard. */
5399
+ export { pendingConfirmCount, tally };
5400
+
5401
+ /**
5402
+ * Everything the dashboard footer needs, in one call.
5403
+ *
5404
+ * One hook rather than three, because three would be three chances for the
5405
+ * footer to show figures from different moments.
5406
+ */
5407
+ export function dashboardStats(): {
5408
+ received: number;
5409
+ applied: number;
5410
+ failed: number;
5411
+ mode: string;
5412
+ awaitingConnector: number;
5413
+ oldestUndrawn: string | null;
5414
+ } {
5415
+ const t = tally();
5416
+ const inbox = config.mode === 'inbox'
5417
+ ? { count: undrawnCount(), oldest: oldestUndrawn() }
5418
+ : { count: 0, oldest: null };
5419
+ return {
5420
+ ...t,
5421
+ mode: config.mode,
5422
+ awaitingConnector: inbox.count,
5423
+ oldestUndrawn: inbox.oldest,
5424
+ };
5425
+ }
5426
+
3113
5427
  // Read by src/index.ts to label the dashboard. Exported rather than re-derived
3114
5428
  // there, so the port the UI claims is the port the server actually bound.
3115
5429
  export { PORT };
@@ -3272,8 +5586,174 @@ Valid address-verify results: \`"match"\` \xB7 \`"mismatch"\` \xB7 \`"not_found"
3272
5586
 
3273
5587
  After an \`address.updated\` is stored, the receiver POSTs to \`\${ONEADDRESS_API}/api/confirm\`
3274
5588
  (HMAC-SHA256 over \`\${timestamp}.\${rawBody}\`, Bearer + \`X-OneAddress-Signature\` headers)
3275
- so the consumer's dashboard flips the service to **Confirmed**. It is
3276
- fire-and-forget, so a slow confirm never delays the webhook \`200\`.
5589
+ so the consumer's dashboard flips the service to **Confirmed**.
5590
+
5591
+ It is QUEUED, not fired and forgotten. The acknowledgement is a durable local
5592
+ row, retried with backoff and surviving a restart, so a local write is all the
5593
+ webhook waits on. That matters more than it sounds: an update OneAddress never
5594
+ hears about is shown to the consumer as a FAILED delivery, and a failed delivery
5595
+ can be refunded \u2014 so a thirty-second outage used to mean you did the work and the
5596
+ payment went back. The dashboard's \`awaiting confirm\` count is how many are still
5597
+ owed; a non-zero figure that will not come down is usually a wrong
5598
+ \`CONFIRM_SECRET\`, and the log says so.
5599
+
5600
+ ### When a dispatch will not decrypt
5601
+
5602
+ If a dispatch arrives with a valid signature and cannot be opened \u2014 the wrong key,
5603
+ or a \`key_id\` you have no variable for \u2014 it is answered \`422\` (so OneAddress keeps
5604
+ treating it as undelivered) and the encrypted payload is HELD exactly as it
5605
+ arrived. Nothing is decrypted on the way in; the receiver could not, which is the
5606
+ whole reason the record exists.
5607
+
5608
+ A \`FAULTS\` panel appears on the dashboard while anything is held and names the
5609
+ cause and the \`key_id\`. Fix the key and restart and the backlog applies itself,
5610
+ or press \`[r]\` without restarting.
5611
+
5612
+ ### Rotating a key
5613
+
5614
+ Every dispatch names the key it was encrypted to, and both keys stay valid for an
5615
+ overlap window. Give each its own variable, named for its \`key_id\` with dashes as
5616
+ underscores, upper-cased:
5617
+
5618
+ \`\`\`
5619
+ PARTNER_PRIVATE_KEY_PEM_04032299_4B04_4842_AA29_5095500C8ECE=...
5620
+ \`\`\`
5621
+
5622
+ \`PARTNER_PRIVATE_KEY_PEM\` is then a fallback for any \`key_id\` without a variable
5623
+ of its own. Convenient with one key and a trap with two: it answers for an id it
5624
+ does not hold, and AES-GCM cannot tell a wrong key from a tampered ciphertext, so
5625
+ the failure reads like corruption rather than like a rotation. **Set
5626
+ \`PARTNER_KEYS_STRICT=1\` once you hold more than one key.** The startup line tells
5627
+ you which keys this receiver can open.
5628
+
5629
+ ## Pointing this at your own database
5630
+
5631
+ Everything OneAddress needs from your systems is **five methods in
5632
+ \`src/customer-store.ts\`**. That file is the contract: no schema, no seeding, no
5633
+ encryption in the way, a paragraph per method. It is the file to hand a DBA, and
5634
+ it is readable in ten minutes without reading any of ours.
5635
+
5636
+ \`src/store.ts\` is the DEFAULT implementation of it, backed by the bundled SQLite
5637
+ file. The SQLite schema, the at-rest encryption, the blind index,
5638
+ \`customers.json\`, the repair pass and the history table all belong to that
5639
+ implementation and **not** to the contract. If your customers already live in
5640
+ your own database, delete them rather than port them: encrypting a second copy
5641
+ of a record you already hold protects nothing and gives you another key to
5642
+ manage.
5643
+
5644
+ Write a module exporting a \`CustomerStore\` and change one line:
5645
+
5646
+ \`\`\`typescript
5647
+ // src/server.ts and src/tui.ts
5648
+ import { store } from './my-customer-store.js';
5649
+ \`\`\`
5650
+
5651
+ The \`satisfies CustomerStore\` on your export is what keeps that safe \u2014 miss a
5652
+ method, or drift from a signature the receiver depends on, and it stops
5653
+ compiling rather than failing on a live dispatch. The receiver names the live
5654
+ store in its startup line, so you can see which one is running without reading
5655
+ code.
5656
+
5657
+ ### What it needs on your database
5658
+
5659
+ Less than people expect. The receiver never reads a customer's address for its
5660
+ own purposes and never lists your table:
5661
+
5662
+ | Method | Needs |
5663
+ |--------|-------|
5664
+ | \`verifyAccount\` | read the account number and the name |
5665
+ | \`verifyAddress\` | read the address you hold |
5666
+ | \`saveAddress\` | read the address, then write it; append your own audit row |
5667
+ | \`find\` | read one row by account number (optional, dashboard only) |
5668
+ | \`count\` | nothing \u2014 return \`null\` and it is never asked |
5669
+
5670
+ A least-privilege PostgreSQL grant for that is short enough to review:
5671
+
5672
+ \`\`\`sql
5673
+ CREATE ROLE oneaddress_receiver LOGIN PASSWORD '...';
5674
+ GRANT USAGE ON SCHEMA app TO oneaddress_receiver;
5675
+ GRANT SELECT (account_number, name, address) ON app.customers TO oneaddress_receiver;
5676
+ GRANT UPDATE (address, updated_at) ON app.customers TO oneaddress_receiver;
5677
+ GRANT INSERT ON app.address_history TO oneaddress_receiver;
5678
+ -- No DELETE, no DDL, no access to any other table.
5679
+ \`\`\`
5680
+
5681
+ **No INSERT on \`customers\`.** The receiver has no business creating customers:
5682
+ a dispatch for an account you do not have is one you should refuse, and a role
5683
+ that cannot create a row cannot be talked into it by a bug in ours.
5684
+
5685
+ ### What stays ours
5686
+
5687
+ The receiver keeps a small local SQLite file even when your customers live
5688
+ elsewhere, for the confirm queue and the quarantine. Neither holds customer
5689
+ data: the confirm queue holds dispatch ids and outcomes, the quarantine holds
5690
+ ciphertext the receiver could not open. Your DBA is entitled to ask, and that is
5691
+ the answer.
5692
+
5693
+ The protocol half stays ours too, and a store implementation is never given a
5694
+ chance to weaken it: signature verification, the replay window, decryption and
5695
+ the confirm callback all run before your code is called. By then the dispatch
5696
+ has been proven to come from OneAddress and decrypted in memory.
5697
+
5698
+ ### One thing to get right at your scale
5699
+
5700
+ \`count()\` is asked every two seconds while the dashboard is open. **Return
5701
+ \`null\` from it** if answering means a \`SELECT count(*)\` over a real customer
5702
+ table; the dashboard draws a dash and is otherwise identical. The bundled store
5703
+ answers it because its roster is three rows.
5704
+
5705
+ The same reasoning is why there is no "list every customer" method. The
5706
+ dashboard asks for the ONE customer a dispatch just changed, by account number,
5707
+ so your database does the thing it is already good at.
5708
+
5709
+ ## What this is, and where it stops
5710
+
5711
+ Honest limits, so you find them here rather than in production. Most are further
5712
+ away than people expect, and the last one is a hard edge rather than a slope.
5713
+
5714
+ **It is a real receiver, not a toy.** It verifies signatures, enforces the
5715
+ replay window, decrypts per-partner envelopes, holds what it cannot open,
5716
+ retries its acknowledgements durably and refuses accounts you do not recognise.
5717
+ Nothing in the protocol layer is stubbed.
5718
+
5719
+ **Throughput is not the constraint.** An address change is a rare event per
5720
+ customer. A single process on modest hardware handles far more than a consumer
5721
+ base generates, and the work per dispatch is one decrypt and one write.
5722
+
5723
+ **Its own bookkeeping is a local SQLite file**, whatever your customers live in.
5724
+ The confirm queue holds dispatch ids and outcomes; the quarantine holds
5725
+ ciphertext it could not open. Neither holds customer data, and both stay local
5726
+ even after you implement \`CustomerStore\` against your own database.
5727
+
5728
+ **The hard edge: run ONE of these.** Because that bookkeeping is a local file,
5729
+ two instances are not two workers sharing a queue - they are two separate
5730
+ receivers. Each acknowledges only what it received, and pressing \`[r]\` on one
5731
+ does nothing for the other's backlog. That is fine, and it is not a scaling
5732
+ problem at this event rate, but it is a thing to know before somebody sets
5733
+ \`replicas: 2\` and wonders why half the confirms never leave.
5734
+
5735
+ If you genuinely need more than one instance, or a metrics endpoint, or a
5736
+ different database behind the receiver's own bookkeeping, talk to us before
5737
+ building around it: that is a different piece of software and we would rather
5738
+ give it to you than watch you rebuild it.
5739
+
5740
+ ### The order to do things in
5741
+
5742
+ 1. **Run it as generated.** Point the wizard at a tunnel, pass conformance, see
5743
+ a real dispatch land in the demo roster.
5744
+ 2. **Implement \`CustomerStore\` against your own database.** The contract is five
5745
+ methods; the grant above is what your DBA needs to approve. Delete the SQLite
5746
+ roster, \`customers.json\`, the at-rest encryption and the history table when
5747
+ you do - they are ours, not the contract's.
5748
+ 3. **Decide the two retention windows.** \`CONFIRM_KEEP_DAYS\` and
5749
+ \`QUARANTINE_KEEP_DAYS\` both default to 30. The second one is holding your
5750
+ customers' encrypted addresses, so it is a decision your privacy people
5751
+ should make rather than inherit.
5752
+ 4. **Run it where it will live**, with \`ONEADDRESS_DB_PASSPHRASE\` set if you kept
5753
+ our store, and watch the startup line: it names which store is live and
5754
+ whether it is encrypted at rest, in both directions. \`/health\` carries the
5755
+ same two facts so you can alert on them.
5756
+ 5. **Set \`PARTNER_KEYS_STRICT=1\`** the day you hold a second key.
3277
5757
 
3278
5758
  ## Conformance check
3279
5759
 
@@ -3301,6 +5781,23 @@ webhook URL at [partners.oneaddress.io](https://partners.oneaddress.io).
3301
5781
  | \`oneAddressApi\` | \`https://oneaddress.io\` | Base URL the confirm callback posts to. |
3302
5782
  | \`verifiesAccountReference\` | \`(your portal declaration)\` | Whether you answer \`account.verify\` with a real match, or reply "not checked". |
3303
5783
 
5784
+ **Three optional environment variables** tune what is kept, and most partners
5785
+ set none of them:
5786
+
5787
+ | Var | Default | Purpose |
5788
+ |-----|---------|---------|
5789
+ | \`PARTNER_KEYS_STRICT\` | unset | \`1\` turns off the single-key fallback. Set it once you hold more than one key. |
5790
+ | \`CONFIRM_KEEP_DAYS\` | \`30\` | How long DELIVERED confirm records are kept. An OUTSTANDING one is never aged out. |
5791
+ | \`QUARANTINE_KEEP_DAYS\` | \`30\` | How long a held payload is kept, applied or not. |
5792
+
5793
+ The last two are deliberately opposite rules, and the difference is what the row
5794
+ holds. A confirm record names a dispatch and an outcome, so keeping an unanswered
5795
+ one forever costs nobody anything and dropping it loses an update. A held payload
5796
+ is one of your customers' addresses, encrypted, sitting on your disk: keeping that
5797
+ indefinitely is a retention decision nobody made, so it has a window, and a
5798
+ payload that ages out without ever being applied is logged loudly rather than
5799
+ quietly.
5800
+
3304
5801
  Each field can be overridden for a one-off by an environment variable of the
3305
5802
  matching name (\`PARTNER_ID\` / \`ONEADDRESS_API\` / \`VERIFIES_ACCOUNT_REFERENCE\`).
3306
5803
  One secret has no config-file home because it must stay out of a non-secret
@@ -8156,7 +10653,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
8156
10653
 
8157
10654
  // src/register.ts
8158
10655
  var import_node_crypto = require("crypto");
8159
- var PKG_VERSION = true ? "2.1.2" : "dev";
10656
+ var PKG_VERSION = true ? "2.2.0" : "dev";
8160
10657
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
8161
10658
  function hmacSha256(secret, message) {
8162
10659
  return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");