@oneaddress/setup 2.1.1 → 2.1.3

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 +1643 -110
  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.1" : "?";
859
+ var WIZARD_VERSION = true ? "2.1.3" : "?";
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
  `
@@ -964,8 +978,8 @@ data.db-shm
964
978
  "private": true,
965
979
  "scripts": {
966
980
  "dev": "tsx watch src/index.ts",
967
- "start": "tsx src/index.ts",
968
- "headless": "tsx src/index.ts -- --headless",
981
+ "start": "node --disable-warning=ExperimentalWarning node_modules/tsx/dist/cli.mjs src/index.ts",
982
+ "headless": "node --disable-warning=ExperimentalWarning node_modules/tsx/dist/cli.mjs src/index.ts -- --headless",
969
983
  "build": "tsup src/index.ts --format esm --no-dts --outDir dist",
970
984
  "type-check": "tsc --noEmit",
971
985
  "test": "tsx scripts/test.ts"
@@ -1081,6 +1095,7 @@ import {
1081
1095
  encryptField,
1082
1096
  isEncrypted,
1083
1097
  newSalt,
1098
+ PassphraseRequiredError,
1084
1099
  verifierMatches,
1085
1100
  WrongPassphraseError,
1086
1101
  type VaultKeys,
@@ -1128,14 +1143,36 @@ function saltForThisDatabase(): Buffer {
1128
1143
  }
1129
1144
 
1130
1145
  function openKeys(): VaultKeys | null {
1146
+ // THE VERIFIER IS READ FIRST, and the order is the whole fix.
1147
+ //
1148
+ // This used to return null the moment no passphrase was set, without ever
1149
+ // asking whether the database was encrypted. An encrypted store opened with
1150
+ // no passphrase therefore ran IN THE CLEAR over the top of itself: it could
1151
+ // not decrypt the existing rows, so their blind indexes never matched, so it
1152
+ // seeded a SECOND copy of the roster in plaintext beside the first. The next
1153
+ // unlock then tried to converge both copies onto one key and died on a
1154
+ // UNIQUE constraint, permanently, with the correct passphrase in hand.
1155
+ //
1156
+ // Reported by a partner on 2.1.1 as "it is locking me out", and the lockout
1157
+ // was the last step of three. Two things were wrong and the quiet one was
1158
+ // worse: plaintext customer records written into a database its owner had
1159
+ // encrypted.
1160
+ const stored = readMeta('verifier');
1131
1161
  const passphrase = process.env.ONEADDRESS_DB_PASSPHRASE;
1132
- if (!passphrase || !passphrase.trim()) return null;
1162
+
1163
+ if (!passphrase || !passphrase.trim()) {
1164
+ // Never silently. A database with no verifier has never been locked and
1165
+ // running it in the clear is the documented, chosen behaviour; one WITH a
1166
+ // verifier is somebody's encrypted store and this is not the process that
1167
+ // gets to open it.
1168
+ if (stored !== null) throw new PassphraseRequiredError();
1169
+ return null;
1170
+ }
1133
1171
 
1134
1172
  const keys = deriveKeys(passphrase, saltForThisDatabase());
1135
1173
 
1136
1174
  // Checked BEFORE anything is written. A mistyped passphrase must not be able
1137
1175
  // to write a single row of ciphertext that nothing can ever read back.
1138
- const stored = readMeta('verifier');
1139
1176
  if (stored === null) writeMeta('verifier', buildVerifier(keys));
1140
1177
  else if (!verifierMatches(keys, stored)) throw new WrongPassphraseError();
1141
1178
 
@@ -1182,7 +1219,16 @@ export function once(column: string, value: string | null): string | null {
1182
1219
  return isEncrypted(value) ? value : encryptField(keys, column, value);
1183
1220
  }
1184
1221
 
1185
- export { WrongPassphraseError };
1222
+ export { PassphraseRequiredError, WrongPassphraseError };
1223
+
1224
+ /**
1225
+ * Is this stored value ciphertext? Exported for the repair pass in \`store.ts\`.
1226
+ *
1227
+ * The repair has to tell an encrypted row from a plaintext one written beside
1228
+ * it by a run that had no passphrase, and that is the only question that
1229
+ * separates them.
1230
+ */
1231
+ export { isEncrypted };
1186
1232
  export default db;
1187
1233
  `
1188
1234
  },
@@ -1363,6 +1409,25 @@ export class WrongPassphraseError extends Error {
1363
1409
  }
1364
1410
  }
1365
1411
 
1412
+ /**
1413
+ * The database is encrypted and nobody supplied a passphrase.
1414
+ *
1415
+ * NAMED SEPARATELY FROM \\\`WrongPassphraseError\\\` because the remedy is different:
1416
+ * one means try again, the other means you have not been asked yet. Before this
1417
+ * existed the case had no error at all - opening an encrypted database with no
1418
+ * passphrase returned no keys and the receiver carried on IN THE CLEAR over the
1419
+ * top of it, which cost a partner their database. See the refusal in db.ts.
1420
+ */
1421
+ export class PassphraseRequiredError extends Error {
1422
+ constructor() {
1423
+ super(
1424
+ 'This database is encrypted. Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` ' +
1425
+ 'in a terminal to be asked for it.',
1426
+ );
1427
+ this.name = 'PassphraseRequiredError';
1428
+ }
1429
+ }
1430
+
1366
1431
  export interface VaultKeys {
1367
1432
  /** AES-256-GCM key for the personal columns. */
1368
1433
  cipherKey: Buffer;
@@ -1595,7 +1660,12 @@ export function formatLine(line: ReportLine): string {
1595
1660
  import blessed from 'blessed';
1596
1661
  import { HEX, ONE_ROWS, ADDRESS_ROWS, terminalFitsFullMark } from './brand.js';
1597
1662
  import { formatLine, report, type ReportLine } from './report.js';
1598
- import { allCustomers, customerCount, storeEncrypted, type StoredCustomer } from './store.js';
1663
+ // One import, same as server.ts. Swapping the store swaps what the dashboard
1664
+ // reads, with nothing here to change.
1665
+ import { store } from './store.js';
1666
+ import type { StoredCustomer } from './customer-store.js';
1667
+ import { pendingConfirmCount } from './confirm-queue.js';
1668
+ import { heldCount, heldSummary } from './quarantine.js';
1599
1669
 
1600
1670
  /** blessed takes colours as strings; these mirror the site's palette. */
1601
1671
  const AMBER = HEX.amber.toLowerCase();
@@ -1610,6 +1680,14 @@ export interface TuiOptions {
1610
1680
  port: number;
1611
1681
  /** Called when the operator quits, so the caller can close the server. */
1612
1682
  onQuit: () => void;
1683
+ /**
1684
+ * Re-apply everything the receiver could not open, bound to [r].
1685
+ *
1686
+ * Passed IN rather than imported: \`server.ts\` already imports this file for
1687
+ * \`notePreviousAddress\`, so importing it back would be a cycle. Optional so
1688
+ * the dashboard still renders for a caller that has no replay to offer.
1689
+ */
1690
+ onReplay?: () => Promise<{ applied: number; failed: number }>;
1613
1691
  }
1614
1692
 
1615
1693
  /** One address as a single line, the way the change panel shows it. */
@@ -1618,7 +1696,7 @@ function oneLine(a: Record<string, unknown>): string {
1618
1696
  return parts.length > 0 ? parts.join(', ') : '(empty)';
1619
1697
  }
1620
1698
 
1621
- export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void {
1699
+ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptions): void {
1622
1700
  const screen = blessed.screen({
1623
1701
  smartCSR: true,
1624
1702
  title: \`\${partnerName} \u2014 OneAddress receiver\`,
@@ -1671,6 +1749,23 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1671
1749
  style: { border: { fg: DIM }, label: { fg: DIM } },
1672
1750
  });
1673
1751
 
1752
+ // \u2500\u2500 The faults band: dispatches that arrived and could not be opened \u2500\u2500\u2500\u2500\u2500\u2500
1753
+ //
1754
+ // HIDDEN WHEN THERE IS NOTHING WRONG, and that is a deliberate departure from
1755
+ // how the rest of this screen works. The other panels are always drawn,
1756
+ // including the ENCRYPTED/UNENCRYPTED line, because a property you only
1757
+ // mention when it holds is one nobody notices the absence of. A faults panel
1758
+ // is the other case: a permanent empty box teaches the eye to skip that
1759
+ // region, which is precisely the region that has to be noticed the one day it
1760
+ // fills. So it appears, and the two panels above give up the rows.
1761
+ const FAULT_HEIGHT = 7;
1762
+ const faultBox = blessed.box({
1763
+ parent: screen, bottom: 3, left: 0, width: '100%', height: FAULT_HEIGHT,
1764
+ label: ' FAULTS ', tags: true, padding: { left: 1, right: 1 }, hidden: true,
1765
+ border: { type: 'line' } as never,
1766
+ style: { border: { fg: 'red' }, label: { fg: 'red' } },
1767
+ });
1768
+
1674
1769
  const footer = blessed.box({
1675
1770
  parent: screen, bottom: 0, left: 0, width: '100%', height: 3,
1676
1771
  tags: true, padding: { left: 2 },
@@ -1694,14 +1789,18 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1694
1789
  // Both states are shown, and the unprotected one is the loud colour. A
1695
1790
  // security property mentioned only when it holds is one nobody notices the
1696
1791
  // absence of.
1697
- const vault = storeEncrypted
1792
+ const vault = store.encrypted
1698
1793
  ? \`{green-fg}{bold}ENCRYPTED{/}\`
1699
1794
  : \`{red-fg}{bold}UNENCRYPTED{/}\`;
1700
1795
  status.setContent(
1701
1796
  \`{\${AMBER}-fg}{bold}\${esc(partnerName)}{/} \` +
1702
1797
  \`{\${DIM}-fg}listening{/} {\${CREAM}-fg}:\${port}/webhook{/} \` +
1703
1798
  \`{\${DIM}-fg}customer file{/} \${vault} \` +
1704
- \`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${customerCount().toLocaleString()}{/}\`,
1799
+ // A DASH RATHER THAN A ZERO when the store declines to count. Zero is a
1800
+ // claim ("you have no customers") and would be a lie on a receiver
1801
+ // pointed at a real customer table, where counting every row forty times
1802
+ // a minute is the thing the store is right to refuse.
1803
+ \`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}\`,
1705
1804
  );
1706
1805
  }
1707
1806
 
@@ -1725,18 +1824,76 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1725
1824
  );
1726
1825
  }
1727
1826
 
1827
+ /** Shown while a replay is running, so [r] does not look like it did nothing. */
1828
+ let replayNote = '';
1829
+
1830
+ /**
1831
+ * The customer count, refreshed out of band.
1832
+ *
1833
+ * CACHED RATHER THAN READ DURING RENDER, because \`count()\` may be async and a
1834
+ * render cannot await. It starts null, which is also what a store that
1835
+ * declines to count returns forever, so the dash below is the honest initial
1836
+ * state rather than a placeholder that happens to look the same.
1837
+ */
1838
+ let onFile: number | null = null;
1839
+ function refreshCount(): void {
1840
+ void Promise.resolve(store.count())
1841
+ .then((n) => { onFile = n; })
1842
+ .catch(() => { onFile = null; });
1843
+ }
1844
+ refreshCount();
1845
+
1846
+ function renderFaults(): void {
1847
+ const held = heldCount();
1848
+ const show = held > 0;
1849
+ if (show === Boolean(faultBox.hidden)) {
1850
+ // Visibility is changing, so the panels above have to give back or take
1851
+ // back the rows. Assigning \`bottom\` is how blessed re-lays-out; it reads
1852
+ // the value on the next render rather than caching a computed box.
1853
+ if (show) faultBox.show(); else faultBox.hide();
1854
+ const edge = show ? 3 + FAULT_HEIGHT : 3;
1855
+ (changeBox as unknown as { bottom: number }).bottom = edge;
1856
+ (logBox as unknown as { bottom: number }).bottom = edge;
1857
+ }
1858
+ if (!show) return;
1859
+
1860
+ // Grouped, never one line per dispatch. The realistic shape of this table
1861
+ // is forty rows with ONE cause between them, and forty identical lines hide
1862
+ // the single fact that matters.
1863
+ const lines = heldSummary().slice(0, 3).map((l) => \` {red-fg}\${esc(l)}{/}\`);
1864
+ faultBox.setContent(
1865
+ \`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open.\\n\` +
1866
+ \` {\${DIM}-fg}They are held, encrypted, exactly as they arrived. Nothing is lost yet.{/}\\n\\n\` +
1867
+ lines.join('\\n') + '\\n\\n' +
1868
+ (replayNote
1869
+ ? \` {\${AMBER}-fg}\${esc(replayNote)}{/}\`
1870
+ : \` {\${DIM}-fg}Fix the cause (usually a key in .env), then press{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply them.{/}\`),
1871
+ );
1872
+ }
1873
+
1728
1874
  function renderFooter(): void {
1875
+ // AWAITING IS THE ONE AN OPERATOR CANNOT AFFORD TO MISS, so it is drawn
1876
+ // whether or not it is zero. A dispatch that was applied here but never
1877
+ // acknowledged back to OneAddress shows the consumer a FAILED delivery for
1878
+ // an update that in fact succeeded, and the auto-refund cron treats a
1879
+ // terminal failure as refundable: the work is done and the money goes back.
1880
+ // Read from the queue rather than counted here, so a receiver restarted
1881
+ // with a backlog shows the backlog instead of zero.
1882
+ const awaiting = pendingConfirmCount();
1883
+ const awaitingColour = awaiting > 0 ? AMBER : DIM;
1729
1884
  footer.setContent(
1730
1885
  \`{\${DIM}-fg}received{/} {bold}\${received}{/bold} \` +
1731
1886
  \`{green-fg}applied{/} {bold}\${applied}{/bold} \` +
1732
- \`{red-fg}failed{/} {bold}\${failed}{/bold}\` +
1733
- \`{|}{\${DIM}-fg}[q] quit{/} \`,
1887
+ \`{red-fg}failed{/} {bold}\${failed}{/bold} \` +
1888
+ \`{\${awaitingColour}-fg}awaiting confirm{/} {bold}\${awaiting}{/bold}\` +
1889
+ \`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[q] quit{/} \`,
1734
1890
  );
1735
1891
  }
1736
1892
 
1737
1893
  function redraw(): void {
1738
1894
  renderStatus();
1739
1895
  renderChange();
1896
+ renderFaults();
1740
1897
  renderFooter();
1741
1898
  screen.render();
1742
1899
  }
@@ -1750,21 +1907,64 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1750
1907
  const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
1751
1908
  const time = new Date(line.at).toTimeString().slice(0, 8);
1752
1909
 
1753
- if (/address\\.updated for /.test(text)) received++;
1754
- if (/REFUSED|decryption failed|Decryption failed/.test(text)) failed++;
1910
+ // COUNTED ON THE OUTCOME LINE, WHICHEVER IT IS.
1911
+ //
1912
+ // \`received\` used to increment only on \`address.updated for <account>\`,
1913
+ // which is logged AFTER decryption because the account number comes out of
1914
+ // the decrypted envelope. A dispatch that failed to decrypt returned 422
1915
+ // before that line ran, so it was never counted as having arrived: a
1916
+ // partner with a wrong key saw \`received 0 \xB7 failed 12\` and went looking at
1917
+ // their tunnel and their webhook URL. Everything was arriving. The error
1918
+ // line said "check your key" and the counter, louder, said "nothing here".
1919
+ //
1920
+ // Every dispatch produces exactly one of these, so counting either as an
1921
+ // arrival makes received = applied + failed hold.
1922
+ const failure = /REFUSED|decryption failed|Decryption failed|no private key for/.test(text);
1923
+ if (/address\\.updated for /.test(text) || failure) received++;
1924
+ if (failure) failed++;
1755
1925
  if (/\\[store\\] saved address for /.test(text)) {
1756
1926
  applied++;
1757
1927
  // The store logs the account key and never the address, so the panel is
1758
1928
  // refreshed from the DATABASE rather than parsed out of the log line.
1759
1929
  const acct = /saved address for (\\S+)/.exec(text)?.[1];
1760
- const customer = allCustomers().find((c) => c.account_number === acct);
1761
- if (customer) lastChange = { customer, previous: pendingPrevious };
1930
+ // An indexed lookup of the one customer, not a decrypt of the whole
1931
+ // roster to find them. Invisible at three rows and absurd at four
1932
+ // million, which is the scale a real store is pointed at.
1933
+ const prev = pendingPrevious;
1934
+ if (acct) {
1935
+ void Promise.resolve(store.find(acct)).then((customer) => {
1936
+ if (!customer) return;
1937
+ lastChange = { customer, previous: prev };
1938
+ redraw();
1939
+ });
1940
+ }
1762
1941
  }
1763
1942
 
1764
1943
  logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
1765
1944
  redraw();
1766
1945
  });
1767
1946
 
1947
+ // [r] REPLAYS. Bound unconditionally rather than only while faults exist, so
1948
+ // pressing it on a clean receiver says "nothing held" instead of doing
1949
+ // nothing, which is indistinguishable from a wedged UI.
1950
+ let replaying = false;
1951
+ screen.key(['r'], () => {
1952
+ if (!onReplay || replaying) return;
1953
+ replaying = true;
1954
+ replayNote = 'Replaying\u2026';
1955
+ redraw();
1956
+ void onReplay()
1957
+ .then(({ applied, failed }) => {
1958
+ replayNote = failed > 0
1959
+ ? \`Applied \${applied}, \${failed} still failing \u2014 the cause is not fixed yet.\`
1960
+ : \`Applied \${applied}. Nothing left held.\`;
1961
+ })
1962
+ .catch((err: unknown) => {
1963
+ replayNote = \`Replay failed: \${err instanceof Error ? err.message : String(err)}\`;
1964
+ })
1965
+ .finally(() => { replaying = false; redraw(); });
1966
+ });
1967
+
1768
1968
  screen.key(['q', 'C-c'], () => {
1769
1969
  detach();
1770
1970
  screen.destroy();
@@ -1772,6 +1972,14 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1772
1972
  process.exit(0);
1773
1973
  });
1774
1974
 
1975
+ // EVERY OTHER REDRAW IS TRIGGERED BY A REPORTED LINE, and the awaiting count
1976
+ // is the one number that can change without one. The confirm backoff caps at
1977
+ // an hour, so a confirm stuck against a wrong secret would sit on screen at
1978
+ // its hour-old value and read as settled. Cheap: one indexed COUNT, and only
1979
+ // while a terminal is attached.
1980
+ const tick = setInterval(() => { refreshCount(); redraw(); }, 2_000);
1981
+ tick.unref();
1982
+
1775
1983
  redraw();
1776
1984
  }
1777
1985
 
@@ -1814,7 +2022,7 @@ export function notePreviousAddress(prev: Record<string, unknown>): void {
1814
2022
  * \`--headless\` skips both: no prompt (a service has nobody to ask), no screen.
1815
2023
  */
1816
2024
  import { printBanner } from './brand.js';
1817
- import { WrongPassphraseError } from './vault.js';
2025
+ import { PassphraseRequiredError, WrongPassphraseError } from './vault.js';
1818
2026
 
1819
2027
  /**
1820
2028
  * No dashboard without a terminal to draw it in.
@@ -1878,25 +2086,79 @@ async function databaseIsLocked(): Promise<boolean> {
1878
2086
  * hook is not available, being asked in the clear beats not being asked.
1879
2087
  */
1880
2088
  async function ask(prompt: string): Promise<string> {
1881
- const { createInterface } = await import('node:readline/promises');
1882
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
1883
- const hooked = rl as unknown as { _writeToOutput?: (s: string) => void };
1884
- const original = hooked._writeToOutput;
1885
- try {
1886
- hooked._writeToOutput = function (chunk: string): void {
1887
- // Echo the prompt itself, mask everything the user types.
1888
- if (chunk.includes(prompt)) process.stdout.write(chunk);
1889
- else process.stdout.write('*');
2089
+ process.stdout.write(prompt);
2090
+
2091
+ // NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
2092
+ // mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
2093
+ // by driving a real terminal and reading what came back. The first filtered
2094
+ // on whether the chunk contained the prompt, not knowing readline repaints
2095
+ // prompt and input together on every keystroke, so the condition was always
2096
+ // true. The second repainted the line and still leaked, because the echo was
2097
+ // never coming from that hook at all.
2098
+ //
2099
+ // Reading the keys directly removes the guessing. Raw mode turns the
2100
+ // terminal's own echo OFF, so the ONLY thing that can reach the screen is
2101
+ // what is written below: one asterisk per character, which is what a partner
2102
+ // asked for and what every other passphrase prompt does.
2103
+ const stdin = process.stdin;
2104
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
2105
+ // No terminal to control. Being asked in the clear beats not being asked,
2106
+ // and this path is only reached where nothing is watching anyway.
2107
+ const { createInterface } = await import('node:readline/promises');
2108
+ const rl = createInterface({ input: stdin, output: process.stdout });
2109
+ try {
2110
+ const answer = await rl.question('');
2111
+ return answer.trim();
2112
+ } finally { rl.close(); }
2113
+ }
2114
+
2115
+ const wasRaw = stdin.isRaw === true;
2116
+ stdin.setRawMode(true);
2117
+ stdin.resume();
2118
+ stdin.setEncoding('utf8');
2119
+
2120
+ return new Promise<string>((resolve) => {
2121
+ let typed = '';
2122
+ const restore = (): void => {
2123
+ stdin.removeListener('data', onData);
2124
+ stdin.setRawMode(wasRaw);
2125
+ stdin.pause();
1890
2126
  };
1891
- } catch { /* keep the visible prompt */ }
1892
- try {
1893
- const answer = await rl.question(prompt);
1894
- process.stdout.write('\\n');
1895
- return answer.trim();
1896
- } finally {
1897
- if (original) hooked._writeToOutput = original;
1898
- rl.close();
1899
- }
2127
+ const onData = (chunk: string): void => {
2128
+ for (const ch of chunk) {
2129
+ if (ch === '\\r' || ch === '\\n') {
2130
+ restore();
2131
+ process.stdout.write('\\n');
2132
+ resolve(typed.trim());
2133
+ return;
2134
+ }
2135
+ if (ch === '\\u0003') { // Ctrl+C
2136
+ restore();
2137
+ process.stdout.write('\\n');
2138
+ process.exit(130);
2139
+ }
2140
+ if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
2141
+ restore();
2142
+ process.stdout.write('\\n');
2143
+ resolve(typed.trim());
2144
+ return;
2145
+ }
2146
+ if (ch === '\\u007f' || ch === '\\b') {
2147
+ // Backspace has to move the asterisks too, or the mask stops matching
2148
+ // what is actually in the buffer and the count misleads.
2149
+ if (typed.length > 0) {
2150
+ typed = typed.slice(0, -1);
2151
+ process.stdout.write('\\b \\b');
2152
+ }
2153
+ continue;
2154
+ }
2155
+ if (ch < ' ') continue; // ignore the rest of the control range
2156
+ typed += ch;
2157
+ process.stdout.write('*');
2158
+ }
2159
+ };
2160
+ stdin.on('data', onData);
2161
+ });
1900
2162
  }
1901
2163
 
1902
2164
  /**
@@ -1927,7 +2189,17 @@ async function resolvePassphrase(): Promise<string | null> {
1927
2189
  if (headless || !process.stdin.isTTY) return null;
1928
2190
 
1929
2191
  if (await databaseIsLocked()) {
1930
- process.stdout.write('\\n This customer database is encrypted.\\n\\n');
2192
+ // NAMES THE WAY OUT, because a partner can arrive here having never
2193
+ // knowingly set a passphrase. Versions before 2.0.1 asked for one with a
2194
+ // prompt that did not say whether it was creating or checking, so a
2195
+ // database can carry a verifier its owner does not remember agreeing to.
2196
+ // Reported by exactly that partner: "there was no opportunity to set a
2197
+ // passphrase". Without this line the only options are guess or search the
2198
+ // internet, and the answer is one command.
2199
+ process.stdout.write('\\n This customer database is encrypted.\\n');
2200
+ process.stdout.write(' If you do not know the passphrase, delete data.db and start\\n');
2201
+ process.stdout.write(' again: it holds your demo roster and test dispatches, nothing\\n');
2202
+ process.stdout.write(' OneAddress needs.\\n\\n');
1931
2203
  const answer = await ask(' Passphrase to unlock: ');
1932
2204
  return answer || null;
1933
2205
  }
@@ -1962,11 +2234,32 @@ async function main(): Promise<void> {
1962
2234
  // Dynamic, and this is the whole point of the file: importing the server
1963
2235
  // pulls in the store, which pulls in the database, which derives its keys on
1964
2236
  // import. The passphrase has to be set before that chain starts.
1965
- let config: { partnerName: string; port: number };
2237
+ let config: {
2238
+ partnerName: string;
2239
+ port: number;
2240
+ replay: () => Promise<{ applied: number; failed: number }>;
2241
+ };
1966
2242
  try {
1967
2243
  const server = await import('./server.js');
1968
- config = { partnerName: server.PARTNER_NAME, port: server.PORT };
2244
+ config = {
2245
+ partnerName: server.PARTNER_NAME,
2246
+ port: server.PORT,
2247
+ // Handed through rather than imported by the dashboard, because the
2248
+ // server already imports the dashboard (for notePreviousAddress) and a
2249
+ // second edge the other way is a cycle. This file is the one place that
2250
+ // holds both.
2251
+ replay: server.replayQuarantined,
2252
+ };
1969
2253
  } catch (err) {
2254
+ if (err instanceof PassphraseRequiredError) {
2255
+ // Reached when nobody could be asked: a service unit, a piped stdin, or
2256
+ // the setup wizard's own health-check autostart. Before the refusal in
2257
+ // db.ts this case ran in the clear and corrupted the database instead.
2258
+ console.error('\\n This database is encrypted and no passphrase was supplied.');
2259
+ console.error(' Run \`npm start\` in a terminal to be asked for it, or set');
2260
+ console.error(' ONEADDRESS_DB_PASSPHRASE for an unattended start.\\n');
2261
+ process.exit(1);
2262
+ }
1970
2263
  if (err instanceof WrongPassphraseError) {
1971
2264
  // Named for what it is. Without this the first symptom is a decryption
1972
2265
  // error on a live dispatch, which reads as a corrupt database and sends
@@ -1984,6 +2277,7 @@ async function main(): Promise<void> {
1984
2277
  startDashboard({
1985
2278
  partnerName: config.partnerName,
1986
2279
  port: config.port,
2280
+ onReplay: config.replay,
1987
2281
  onQuit: () => { /* the process exits; the OS closes the socket */ },
1988
2282
  });
1989
2283
  }
@@ -1992,6 +2286,581 @@ main().catch((err) => {
1992
2286
  console.error('[startup]', err instanceof Error ? err.message : err);
1993
2287
  process.exit(1);
1994
2288
  });
2289
+ `
2290
+ },
2291
+ {
2292
+ name: "src/quarantine.ts",
2293
+ content: `/**
2294
+ * Dispatches that arrived intact and could not be opened.
2295
+ *
2296
+ * ## What this is for
2297
+ *
2298
+ * A dispatch whose signature verifies but whose payload will not decrypt used
2299
+ * to get an HTTP 422 and a log line, and nothing was kept. OneAddress retries a
2300
+ * 422, so the update survived exactly as long as its retry window. A partner
2301
+ * who worked out on Thursday that the wrong key was installed on Monday had
2302
+ * lost it, with nothing local to point at.
2303
+ *
2304
+ * So the bytes are kept. Fix the key, replay, and the same handler runs against
2305
+ * the same request it already received.
2306
+ *
2307
+ * ## THREE PROPERTIES THAT ARE THE DESIGN, NOT DECORATION
2308
+ *
2309
+ * **Nothing is decrypted here.** The row holds exactly the ciphertext that
2310
+ * arrived. It cannot contain a plaintext address, because at the moment it is
2311
+ * written the receiver does not have one - that is the entire reason the row
2312
+ * exists. Replay decrypts in memory in the handler, the same as a live dispatch.
2313
+ *
2314
+ * **Only a SIGNATURE-VERIFIED request is ever quarantined.** The call sites are
2315
+ * all below the HMAC check. Quarantining before it would let anyone who can
2316
+ * reach the webhook fill the disk, which turns a diagnostic into a
2317
+ * denial-of-service surface.
2318
+ *
2319
+ * **The retention rule is the OPPOSITE of the confirm queue's, deliberately.**
2320
+ * A confirm record is content-free, so keeping an outstanding one forever costs
2321
+ * nothing and dropping it loses an update; nothing ages those out. A
2322
+ * quarantined payload is a consumer's encrypted address on a partner's disk,
2323
+ * and keeping it indefinitely is a retention nobody agreed to. These are purged
2324
+ * on a window (30 days by default), replayed or not, and purging one that was
2325
+ * never replayed says so out loud rather than quietly.
2326
+ *
2327
+ * ## What is NOT quarantined, and why
2328
+ *
2329
+ * An \`address.updated\` refused because the account reference matches no
2330
+ * customer is a DECISION, not a fault. It is already reported to OneAddress as
2331
+ * a failed confirm, the partner's data is exactly as they intended, and a
2332
+ * replay would apply an address for an account they do not recognise. It stays
2333
+ * a refusal.
2334
+ */
2335
+ import db from './db.js';
2336
+ import { report } from './report.js';
2337
+ import { createHash } from 'node:crypto';
2338
+
2339
+ /**
2340
+ * Why a dispatch could not be applied. Shown verbatim on the dashboard.
2341
+ *
2342
+ * TWO VALUES, AND THE DISTINCTION IS THE DIAGNOSIS. \`no_key\` is a line missing
2343
+ * from \`.env\`; \`decrypt_failed\` is a key that is present and wrong. AES-GCM
2344
+ * cannot tell a wrong key from a tampered ciphertext, so this record of which
2345
+ * branch refused is the only thing that separates them afterwards.
2346
+ *
2347
+ * There is deliberately no \`handler_error\`. A fault inside the handler is a
2348
+ * bug in code the partner can edit, not a dispatch waiting on a configuration
2349
+ * change, and holding a payload for it would suggest replaying is the remedy.
2350
+ */
2351
+ export type QuarantineReason = 'no_key' | 'decrypt_failed';
2352
+
2353
+ db.exec(\`
2354
+ CREATE TABLE IF NOT EXISTS quarantine (
2355
+ id TEXT PRIMARY KEY,
2356
+ dispatch_id TEXT,
2357
+ event TEXT NOT NULL,
2358
+ reason TEXT NOT NULL,
2359
+ key_id TEXT,
2360
+ raw_body TEXT NOT NULL,
2361
+ detail TEXT,
2362
+ received_at TEXT NOT NULL DEFAULT (datetime('now')),
2363
+ replayed_at TEXT,
2364
+ last_error TEXT
2365
+ );
2366
+ CREATE INDEX IF NOT EXISTS idx_quarantine_open
2367
+ ON quarantine(replayed_at, received_at);
2368
+ \`);
2369
+
2370
+ /**
2371
+ * One row per dispatch, not one per delivery attempt.
2372
+ *
2373
+ * OneAddress retries a 422, so the SAME broken dispatch arrives again every few
2374
+ * minutes while the key is still wrong. Without a stable id a single
2375
+ * misconfigured key would write a row a minute until someone noticed. Keyed on
2376
+ * the dispatch header where there is one, and on a hash of the body where there
2377
+ * is not, so a retry updates the existing row instead of adding to a pile.
2378
+ */
2379
+ function rowId(dispatchId: string | null, rawBody: string): string {
2380
+ if (dispatchId && dispatchId.trim()) return \`d:\${dispatchId.trim()}\`;
2381
+ return \`h:\${createHash('sha256').update(rawBody).digest('hex').slice(0, 32)}\`;
2382
+ }
2383
+
2384
+ export interface QuarantineInput {
2385
+ dispatchId: string | null;
2386
+ event: string;
2387
+ reason: QuarantineReason;
2388
+ keyId: string | null;
2389
+ rawBody: string;
2390
+ detail: string;
2391
+ }
2392
+
2393
+ /**
2394
+ * Keep a dispatch that could not be applied.
2395
+ *
2396
+ * Best-effort by design: this runs on a path that is ALREADY failing, and a
2397
+ * quarantine write that throws would turn a recoverable 422 into a 500. The
2398
+ * caller must be able to answer OneAddress whatever happens here.
2399
+ */
2400
+ export function quarantine(input: QuarantineInput): void {
2401
+ const id = rowId(input.dispatchId, input.rawBody);
2402
+ try {
2403
+ db.prepare(
2404
+ \`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
2405
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2406
+ ON CONFLICT(id) DO UPDATE SET
2407
+ reason = excluded.reason,
2408
+ key_id = excluded.key_id,
2409
+ detail = excluded.detail\`,
2410
+ ).run(
2411
+ id,
2412
+ input.dispatchId,
2413
+ input.event,
2414
+ input.reason,
2415
+ input.keyId,
2416
+ input.rawBody,
2417
+ input.detail.slice(0, 500),
2418
+ );
2419
+ report.warn(
2420
+ \`[quarantine] \${input.event} held (\${input.reason}\${input.keyId ? \`, key_id \${input.keyId}\` : ''}). \` +
2421
+ 'Fix the cause and press [r] on the dashboard, or run \`npm run replay\`, to apply it.',
2422
+ );
2423
+ } catch (err) {
2424
+ report.error('[quarantine] could not hold this dispatch:', err instanceof Error ? err.message : err);
2425
+ }
2426
+ }
2427
+
2428
+ export interface HeldDispatch {
2429
+ id: string;
2430
+ dispatch_id: string | null;
2431
+ event: string;
2432
+ reason: QuarantineReason;
2433
+ key_id: string | null;
2434
+ raw_body: string;
2435
+ detail: string | null;
2436
+ received_at: string;
2437
+ last_error: string | null;
2438
+ }
2439
+
2440
+ /** Everything still held, oldest first. */
2441
+ export function heldDispatches(limit = 50): HeldDispatch[] {
2442
+ return db.prepare(
2443
+ \`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail, received_at, last_error
2444
+ FROM quarantine
2445
+ WHERE replayed_at IS NULL
2446
+ ORDER BY received_at
2447
+ LIMIT ?\`,
2448
+ ).all(limit) as unknown as HeldDispatch[];
2449
+ }
2450
+
2451
+ /** How many dispatches are held. Shown on the dashboard. */
2452
+ export function heldCount(): number {
2453
+ const row = db.prepare(
2454
+ 'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL',
2455
+ ).get() as { n: number };
2456
+ return row.n;
2457
+ }
2458
+
2459
+ /**
2460
+ * A one-line summary per held dispatch, grouped by cause.
2461
+ *
2462
+ * Grouped because the realistic shape of this table is fifty rows with ONE
2463
+ * cause between them: a key was wrong for an afternoon. Fifty identical lines
2464
+ * hide that; "47 held: no key for key_id 0403\u2026" is the whole diagnosis.
2465
+ */
2466
+ export function heldSummary(): string[] {
2467
+ const rows = db.prepare(
2468
+ \`SELECT reason, key_id, count(*) AS n
2469
+ FROM quarantine
2470
+ WHERE replayed_at IS NULL
2471
+ GROUP BY reason, key_id
2472
+ ORDER BY n DESC\`,
2473
+ ).all() as unknown as { reason: string; key_id: string | null; n: number }[];
2474
+ return rows.map((r) =>
2475
+ \`\${r.n} \xD7 \${r.reason}\${r.key_id ? \` (key_id \${r.key_id})\` : ''}\`,
2476
+ );
2477
+ }
2478
+
2479
+ export function markReplayed(id: string): void {
2480
+ db.prepare(
2481
+ 'UPDATE quarantine SET replayed_at = ?, last_error = NULL WHERE id = ?',
2482
+ ).run(new Date().toISOString(), id);
2483
+ }
2484
+
2485
+ /** A replay that failed the same way stays held, with the new reason recorded. */
2486
+ export function markReplayFailed(id: string, error: string): void {
2487
+ db.prepare('UPDATE quarantine SET last_error = ? WHERE id = ?')
2488
+ .run(error.slice(0, 500), id);
2489
+ }
2490
+
2491
+ /**
2492
+ * Drop held payloads past the retention window, replayed or not.
2493
+ *
2494
+ * DELIBERATELY UNLIKE \`purgeDelivered\` IN THE CONFIRM QUEUE, which never ages
2495
+ * out an outstanding row. The difference is what the row contains. A confirm
2496
+ * record names a dispatch and an outcome and nothing else, so holding it costs
2497
+ * a consumer nothing. A quarantined payload is that consumer's address,
2498
+ * encrypted, on a third party's disk: an unbounded hold is a retention decision
2499
+ * made on their behalf by nobody.
2500
+ *
2501
+ * An unreplayed row going out is reported separately and loudly, because that
2502
+ * is an update the partner never applied and is now no longer able to.
2503
+ */
2504
+ export function purgeQuarantine(days: number): { replayed: number; unreplayed: number } {
2505
+ const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
2506
+ const doomed = db.prepare(
2507
+ 'SELECT id, replayed_at FROM quarantine WHERE received_at < ?',
2508
+ ).all(cutoff) as unknown as { id: string; replayed_at: string | null }[];
2509
+ if (doomed.length === 0) return { replayed: 0, unreplayed: 0 };
2510
+
2511
+ db.prepare('DELETE FROM quarantine WHERE received_at < ?').run(cutoff);
2512
+
2513
+ const unreplayed = doomed.filter((d) => d.replayed_at === null).length;
2514
+ const replayed = doomed.length - unreplayed;
2515
+ if (replayed > 0) {
2516
+ report.info(\`[quarantine] purged \${replayed} replayed payload(s) older than \${days}d\`);
2517
+ }
2518
+ if (unreplayed > 0) {
2519
+ report.warn(
2520
+ \`[quarantine] purged \${unreplayed} payload(s) older than \${days}d that were NEVER APPLIED. \` +
2521
+ 'Those address updates are gone from this receiver. Raise them with OneAddress if you need them re-sent.',
2522
+ );
2523
+ }
2524
+ return { replayed, unreplayed };
2525
+ }
2526
+
2527
+ /**
2528
+ * Re-run every held dispatch through the handler.
2529
+ *
2530
+ * Takes the handler rather than importing it, for the same reason \`drainConfirms\`
2531
+ * takes its sender: this module then needs no knowledge of Express, decryption
2532
+ * or keys, and a test can drive it with a function that fails once and succeeds
2533
+ * on the second call.
2534
+ */
2535
+ export async function replayHeld(
2536
+ apply: (rawBody: string) => Promise<void>,
2537
+ limit = 50,
2538
+ ): Promise<{ applied: number; failed: number }> {
2539
+ let applied = 0;
2540
+ let failed = 0;
2541
+ for (const row of heldDispatches(limit)) {
2542
+ try {
2543
+ await apply(row.raw_body);
2544
+ markReplayed(row.id);
2545
+ applied += 1;
2546
+ report.info(\`[replay] applied \${row.event} \${row.dispatch_id ?? row.id}\`);
2547
+ } catch (err) {
2548
+ const message = err instanceof Error ? err.message : String(err);
2549
+ markReplayFailed(row.id, message);
2550
+ failed += 1;
2551
+ report.warn(\`[replay] \${row.dispatch_id ?? row.id} still failing: \${message}\`);
2552
+ }
2553
+ }
2554
+ if (applied === 0 && failed === 0) report.info('[replay] nothing held');
2555
+ return { applied, failed };
2556
+ }
2557
+ `
2558
+ },
2559
+ {
2560
+ name: "src/confirm-queue.ts",
2561
+ content: `/**
2562
+ * Telling OneAddress you applied an update, durably.
2563
+ *
2564
+ * ## The bug this replaces, which cost real money
2565
+ *
2566
+ * The confirm callback used to be one \`fetch\` with a \`catch\` that logged. If
2567
+ * \`/api/confirm\` was unreachable for thirty seconds, the address was applied
2568
+ * here and OneAddress never learned. The consumer was shown a failed delivery,
2569
+ * and the auto-refund cron could refund a dispatch that had in fact succeeded.
2570
+ * Nothing retried, nothing surfaced, and the receiver's own log said "applied".
2571
+ *
2572
+ * So the confirm is now a queued, durable fact rather than a best-effort call:
2573
+ * it survives a restart, retries with backoff, and is visible while it is
2574
+ * outstanding.
2575
+ *
2576
+ * ## Why the queue is keyed on dispatch_id
2577
+ *
2578
+ * One row per dispatch, \`INSERT OR IGNORE\`. OneAddress retries a dispatch it
2579
+ * has not heard about, so the same id can arrive more than once; enqueuing
2580
+ * twice would confirm twice. The primary key makes that impossible rather than
2581
+ * making it something every caller has to remember.
2582
+ *
2583
+ * ## Why a failing confirm is retried forever rather than given up on
2584
+ *
2585
+ * The commonest cause of a 401 here is a wrong or stale CONFIRM_SECRET, which
2586
+ * is fixed by editing \`.env\` and restarting. Abandoning the row would mean the
2587
+ * partner fixes the secret and the update stays lost. The backoff caps at an
2588
+ * hour, so a permanently-broken secret costs one request an hour and stays
2589
+ * visible in the queue depth, which is the signal that something needs a human.
2590
+ */
2591
+ import db from './db.js';
2592
+ import { report } from './report.js';
2593
+
2594
+ export type ConfirmStatus = 'confirmed' | 'failed';
2595
+
2596
+ db.exec(\`
2597
+ CREATE TABLE IF NOT EXISTS confirm_queue (
2598
+ dispatch_id INTEGER PRIMARY KEY,
2599
+ status TEXT NOT NULL,
2600
+ attempts INTEGER NOT NULL DEFAULT 0,
2601
+ next_attempt_at TEXT NOT NULL,
2602
+ last_error TEXT,
2603
+ delivered_at TEXT,
2604
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2605
+ );
2606
+ CREATE INDEX IF NOT EXISTS idx_confirm_due
2607
+ ON confirm_queue(delivered_at, next_attempt_at);
2608
+ \`);
2609
+
2610
+ /**
2611
+ * Backoff, in seconds, by attempt number.
2612
+ *
2613
+ * Fast at first because the overwhelming majority of failures are a blip and
2614
+ * clear on the second try; then long, because the ones that do not clear are
2615
+ * configuration and need a person, not a tighter loop.
2616
+ */
2617
+ const BACKOFF_SECONDS = [5, 15, 60, 300, 900, 3600];
2618
+
2619
+ function delayFor(attempts: number): number {
2620
+ return BACKOFF_SECONDS[Math.min(attempts, BACKOFF_SECONDS.length - 1)];
2621
+ }
2622
+
2623
+ function isoIn(seconds: number): string {
2624
+ return new Date(Date.now() + seconds * 1000).toISOString();
2625
+ }
2626
+
2627
+ /**
2628
+ * Record that this dispatch needs confirming. Safe to call twice.
2629
+ *
2630
+ * Deliberately synchronous and local: the webhook handler must not wait on a
2631
+ * network round trip to OneAddress before answering the dispatch, or a slow
2632
+ * confirm turns into a timed-out webhook and a pointless redelivery.
2633
+ */
2634
+ export function enqueueConfirm(dispatchId: number, status: ConfirmStatus): void {
2635
+ db.prepare(
2636
+ \`INSERT OR IGNORE INTO confirm_queue (dispatch_id, status, next_attempt_at)
2637
+ VALUES (?, ?, ?)\`,
2638
+ ).run(dispatchId, status, new Date().toISOString());
2639
+ }
2640
+
2641
+ export interface PendingConfirm {
2642
+ dispatch_id: number;
2643
+ status: ConfirmStatus;
2644
+ attempts: number;
2645
+ }
2646
+
2647
+ /** Everything due to be sent now, oldest first. */
2648
+ export function dueConfirms(limit = 20): PendingConfirm[] {
2649
+ return db.prepare(
2650
+ \`SELECT dispatch_id, status, attempts
2651
+ FROM confirm_queue
2652
+ WHERE delivered_at IS NULL
2653
+ AND next_attempt_at <= ?
2654
+ ORDER BY created_at
2655
+ LIMIT ?\`,
2656
+ ).all(new Date().toISOString(), limit) as unknown as PendingConfirm[];
2657
+ }
2658
+
2659
+ /** How many confirms are still outstanding. Shown on the dashboard. */
2660
+ export function pendingConfirmCount(): number {
2661
+ const row = db.prepare(
2662
+ 'SELECT count(*) AS n FROM confirm_queue WHERE delivered_at IS NULL',
2663
+ ).get() as { n: number };
2664
+ return row.n;
2665
+ }
2666
+
2667
+ export function markDelivered(dispatchId: number): void {
2668
+ db.prepare(
2669
+ \`UPDATE confirm_queue SET delivered_at = ?, last_error = NULL WHERE dispatch_id = ?\`,
2670
+ ).run(new Date().toISOString(), dispatchId);
2671
+ }
2672
+
2673
+ /** Back off and record why, so the queue explains itself without a log dig. */
2674
+ export function markFailed(dispatchId: number, attempts: number, error: string): void {
2675
+ const next = delayFor(attempts);
2676
+ db.prepare(
2677
+ \`UPDATE confirm_queue
2678
+ SET attempts = attempts + 1, next_attempt_at = ?, last_error = ?
2679
+ WHERE dispatch_id = ?\`,
2680
+ ).run(isoIn(next), error.slice(0, 500), dispatchId);
2681
+ }
2682
+
2683
+ /**
2684
+ * Drop delivered rows older than the retention window.
2685
+ *
2686
+ * Delivered ONLY. An outstanding confirm is never purged on age: a receiver
2687
+ * that was off for a month must still tell OneAddress what it applied, and
2688
+ * silently dropping those is the original bug with extra steps.
2689
+ */
2690
+ export function purgeDelivered(days: number): number {
2691
+ const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
2692
+ const res = db.prepare(
2693
+ \`DELETE FROM confirm_queue WHERE delivered_at IS NOT NULL AND delivered_at < ?\`,
2694
+ ).run(cutoff);
2695
+ return Number(res.changes ?? 0);
2696
+ }
2697
+
2698
+ /**
2699
+ * Drain the queue once.
2700
+ *
2701
+ * Takes the sender so this module needs no knowledge of HTTP, secrets or
2702
+ * signing, which is what lets it be tested without a server: the test passes a
2703
+ * function that fails twice and then succeeds, and asserts the row survives.
2704
+ */
2705
+ export async function drainConfirms(
2706
+ send: (dispatchId: number, status: ConfirmStatus) => Promise<void>,
2707
+ limit = 20,
2708
+ ): Promise<{ delivered: number; failed: number }> {
2709
+ let delivered = 0;
2710
+ let failed = 0;
2711
+ for (const row of dueConfirms(limit)) {
2712
+ try {
2713
+ await send(row.dispatch_id, row.status);
2714
+ markDelivered(row.dispatch_id);
2715
+ delivered += 1;
2716
+ } catch (err) {
2717
+ const message = err instanceof Error ? err.message : String(err);
2718
+ markFailed(row.dispatch_id, row.attempts, message);
2719
+ failed += 1;
2720
+ // Said once per attempt rather than once per drain, because the attempt
2721
+ // count and the reason are what tell a partner whether to wait or act.
2722
+ report.warn(
2723
+ \`[confirm] dispatch \${row.dispatch_id} not acknowledged (attempt \${row.attempts + 1}): \${message}\`,
2724
+ );
2725
+ }
2726
+ }
2727
+ return { delivered, failed };
2728
+ }
2729
+ `
2730
+ },
2731
+ {
2732
+ name: "src/keys.ts",
2733
+ content: `/**
2734
+ * Which private key opens this dispatch.
2735
+ *
2736
+ * ## Why this file exists
2737
+ *
2738
+ * OneAddress supports key rotation: a new key is registered, both are valid for
2739
+ * an overlap window, and every dispatch names the key it was encrypted to in
2740
+ * \`session_key_share.key_id\`. A receiver holding ONE key therefore has a window
2741
+ * where perfectly good dispatches arrive that it cannot open.
2742
+ *
2743
+ * Until this existed the scaffold read a single \`PARTNER_PRIVATE_KEY_PEM\` and
2744
+ * used it for every dispatch whatever key id was named. That fails in the worst
2745
+ * possible way: AES-GCM cannot tell "wrong key" from "tampered ciphertext", so
2746
+ * the error is an authentication-tag failure that reads like corruption. A real
2747
+ * partner hit exactly this and had no way to tell which of the two it was.
2748
+ *
2749
+ * ## The convention, and why it matches the production receiver
2750
+ *
2751
+ * PARTNER_PRIVATE_KEY_PEM_<KEY_ID> dashes to underscores, upper-cased
2752
+ * PARTNER_PRIVATE_KEY_PEM the single-key fallback
2753
+ *
2754
+ * This is the same shape \`@oneaddress/receiver\` uses for \`OA_PRIVATE_KEY_<ID>\`,
2755
+ * deliberately: a partner who outgrows this scaffold and moves to the
2756
+ * production receiver should not have to learn a second way of naming keys.
2757
+ *
2758
+ * ## The fallback is convenient and slightly dangerous
2759
+ *
2760
+ * It answers for ANY key id, which is what made a rotation look like corruption.
2761
+ * It is kept because removing it would break every existing single-key partner
2762
+ * on upgrade, but two things now make it safe to hold:
2763
+ *
2764
+ * - \`PARTNER_KEYS_STRICT=1\` disables it, and any partner holding more than one
2765
+ * key should set that
2766
+ * - when it answers and the decrypt then fails, the error SAYS it was the
2767
+ * fallback and names the key id it was asked for, so the next step is
2768
+ * obvious rather than a guess
2769
+ */
2770
+
2771
+ /** Where a key came from. Carried into the error message when a decrypt fails. */
2772
+ export type KeySource = 'exact' | 'fallback';
2773
+
2774
+ export interface ResolvedKey {
2775
+ pem: string;
2776
+ source: KeySource;
2777
+ }
2778
+
2779
+ const PREFIX = 'PARTNER_PRIVATE_KEY_PEM';
2780
+
2781
+ /** \`04032299-4b04-...\` becomes \`04032299_4B04_...\`, matching the env convention. */
2782
+ function envSuffix(keyId: string): string {
2783
+ return keyId.replace(/-/g, '_').toUpperCase();
2784
+ }
2785
+
2786
+ /**
2787
+ * A PEM written into \`.env\` on one line arrives with literal backslash-n.
2788
+ * Both forms are accepted so a key pasted either way works.
2789
+ */
2790
+ function unescapeNewlines(pem: string): string {
2791
+ return pem.includes('\\\\n') ? pem.replace(/\\\\n/g, '\\n') : pem;
2792
+ }
2793
+
2794
+ /** Every key id this receiver has a key for, lower-cased. */
2795
+ export function configuredKeyIds(env: NodeJS.ProcessEnv = process.env): string[] {
2796
+ return Object.keys(env)
2797
+ .filter((k) => k.startsWith(\`\${PREFIX}_\`) && (env[k] ?? '').trim() !== '')
2798
+ .map((k) => k.slice(PREFIX.length + 1).toLowerCase().replaceAll('_', '-'));
2799
+ }
2800
+
2801
+ /** Is the single-key fallback turned off? */
2802
+ export function strictKeys(env: NodeJS.ProcessEnv = process.env): boolean {
2803
+ return (env.PARTNER_KEYS_STRICT ?? '').trim() === '1';
2804
+ }
2805
+
2806
+ /**
2807
+ * The key for this dispatch, or null when nothing can open it.
2808
+ *
2809
+ * Null is a real answer, not an error: it means "I do not hold this key", and
2810
+ * the caller turns that into a message naming the key id, which is the one
2811
+ * thing the partner needs in order to fix it.
2812
+ */
2813
+ export function resolvePrivateKey(
2814
+ keyId: string | null | undefined,
2815
+ env: NodeJS.ProcessEnv = process.env,
2816
+ ): ResolvedKey | null {
2817
+ if (keyId) {
2818
+ const exact = env[\`\${PREFIX}_\${envSuffix(keyId)}\`];
2819
+ if (exact && exact.trim()) return { pem: unescapeNewlines(exact), source: 'exact' };
2820
+ }
2821
+ if (strictKeys(env)) return null;
2822
+ const fallback = env[PREFIX];
2823
+ if (fallback && fallback.trim()) return { pem: unescapeNewlines(fallback), source: 'fallback' };
2824
+ return null;
2825
+ }
2826
+
2827
+ /**
2828
+ * What to tell the partner when a decrypt fails, given which key answered.
2829
+ *
2830
+ * SEPARATE FROM THE CATCH so the wording is one thing rather than three copies
2831
+ * drifting apart, and so it can be tested without staging a failed decrypt.
2832
+ */
2833
+ export function keyFailureAdvice(keyId: string | null | undefined, resolved: ResolvedKey | null): string {
2834
+ const named = keyId ?? '(none named)';
2835
+ if (!resolved) {
2836
+ return strictKeys()
2837
+ ? \`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.)\`
2838
+ : \`no key configured for key_id \${named}, and PARTNER_PRIVATE_KEY_PEM is empty. Set one in .env.\`;
2839
+ }
2840
+ if (resolved.source === 'fallback') {
2841
+ 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.\`;
2842
+ }
2843
+ 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.\`;
2844
+ }
2845
+
2846
+ /**
2847
+ * A line for the startup log: what this receiver can open.
2848
+ *
2849
+ * Printed every boot because the commonest key problem is believing a variable
2850
+ * is set when it is not, and the second commonest is holding one key after a
2851
+ * rotation. Both are visible in one line.
2852
+ */
2853
+ export function describeKeys(env: NodeJS.ProcessEnv = process.env): string {
2854
+ const ids = configuredKeyIds(env);
2855
+ const hasFallback = !strictKeys(env) && (env[PREFIX] ?? '').trim() !== '';
2856
+ if (ids.length === 0 && !hasFallback) {
2857
+ return 'NO private key configured \u2014 every dispatch will fail to decrypt';
2858
+ }
2859
+ const parts: string[] = [];
2860
+ if (ids.length > 0) parts.push(\`\${ids.length} key id(s): \${ids.join(', ')}\`);
2861
+ if (hasFallback) parts.push(ids.length > 0 ? 'plus the single-key fallback' : 'single-key fallback only');
2862
+ return parts.join(' ');
2863
+ }
1995
2864
  `
1996
2865
  },
1997
2866
  {
@@ -2093,6 +2962,194 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
2093
2962
  return null;
2094
2963
  }
2095
2964
  }
2965
+ `
2966
+ },
2967
+ {
2968
+ name: "src/customer-store.ts",
2969
+ content: `/**
2970
+ * \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
2971
+ * \u2551 src/customer-store.ts \u2014 THE CONTRACT \u2551
2972
+ * \u2551 \u2551
2973
+ * \u2551 Everything OneAddress needs from your systems, in one file you \u2551
2974
+ * \u2551 can hand to a DBA. \`src/store.ts\` implements it against the \u2551
2975
+ * \u2551 bundled SQLite file; you replace that with your own. \u2551
2976
+ * \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
2977
+ *
2978
+ * ## Why this is a separate file from the implementation
2979
+ *
2980
+ * Until this existed, replacing the store meant reading \`store.ts\` and
2981
+ * reverse-engineering the three functions \`server.ts\` happened to import, past
2982
+ * a schema, a roster seeder, an encryption layer and a repair pass that exist
2983
+ * for the DEMO and are no use to a company that already has a customer
2984
+ * database. The contract was real but it was implied, and an implied contract
2985
+ * is one you find out you got wrong in production.
2986
+ *
2987
+ * It is also the answer to the question a DBA asks first, which is not "how do
2988
+ * I write an adapter" but "what is this thing going to do to my table". That is
2989
+ * five methods and a paragraph each, and it is reviewable in ten minutes
2990
+ * without reading any of our code.
2991
+ *
2992
+ * ## What you are NOT obliged to keep
2993
+ *
2994
+ * The SQLite file, the at-rest encryption, the blind index, \`customers.json\`,
2995
+ * the repair pass and the address history are the DEFAULT implementation, not
2996
+ * the contract. A company whose customer data already lives in its own database
2997
+ * should delete every one of them: encrypting a copy of a record you already
2998
+ * hold protects nothing and gives you a second thing to key-manage. Implement
2999
+ * the five methods below against your own tables and your own controls.
3000
+ *
3001
+ * ## What the receiver still owns, and must keep owning
3002
+ *
3003
+ * The protocol half: signature verification, the replay window, decryption,
3004
+ * the confirm callback, the quarantine. None of it is in here, and a store
3005
+ * implementation is never given a chance to weaken any of it. By the time your
3006
+ * code is called, the dispatch has been proven to come from OneAddress and has
3007
+ * been decrypted in memory.
3008
+ *
3009
+ * Note that the receiver keeps a small local SQLite file even when your
3010
+ * customers live elsewhere, for the confirm queue and the quarantine. Neither
3011
+ * holds customer data: the confirm queue holds dispatch ids and outcomes, and
3012
+ * the quarantine holds ciphertext the receiver could not open. Your DBA is
3013
+ * entitled to ask, and that is the answer.
3014
+ */
3015
+
3016
+ /** An address as OneAddress sends it. Free-form so new fields are not lost. */
3017
+ export type Address = Record<string, unknown>;
3018
+
3019
+ /**
3020
+ * Who the dispatch is about, as \`server.ts\` recovered it.
3021
+ *
3022
+ * ALL OF THIS CAME OUT OF THE CIPHERTEXT, not off the wire. Under D5 there is
3023
+ * no cleartext customer block on a dispatch, so nothing here can be set by
3024
+ * anyone who has not proven they hold the key this partner registered.
3025
+ */
3026
+ export interface Customer {
3027
+ email: string | null;
3028
+ name: string;
3029
+ accountNumber?: string;
3030
+ knownNames?: string[];
3031
+ /**
3032
+ * D5 LOA reference \u2014 the base64url SHA-256 of the signed consent, recomputed
3033
+ * after decrypting \`loa_encrypted\`. Echo it in your own audit trail if you
3034
+ * want proof-of-consent alongside the change. Null on legacy dispatches.
3035
+ */
3036
+ loaRef?: string | null;
3037
+ }
3038
+
3039
+ /** One customer as the dashboard shows them. */
3040
+ export interface StoredCustomer {
3041
+ account_number: string;
3042
+ name: string;
3043
+ /** The address as a JSON string, the way it was stored. */
3044
+ address: string;
3045
+ }
3046
+
3047
+ export type AccountVerdict = 'match' | 'no_match' | 'no_account';
3048
+ export type VerifyResult = 'match' | 'mismatch' | 'not_found';
3049
+
3050
+ export interface CustomerStore {
3051
+ /** Shown on \`/health\` and in the startup line, so a swap is visible. */
3052
+ readonly name: string;
3053
+
3054
+ /**
3055
+ * Are the personal columns YOU hold protected at rest?
3056
+ *
3057
+ * Reported on the dashboard in both states, and the unprotected one is drawn
3058
+ * in red. A store backed by a database with its own encryption should return
3059
+ * \`true\`; one writing plaintext to a file should return \`false\` and mean it.
3060
+ * This is a claim the operator will read as true, so do not return \`true\`
3061
+ * because it feels tidier.
3062
+ */
3063
+ readonly encrypted: boolean;
3064
+
3065
+ /**
3066
+ * Pre-payment account check (\`account.verify\`). BEFORE the consumer pays.
3067
+ *
3068
+ * The boundary that stops somebody pushing an update to an account that is
3069
+ * not theirs, and the only one of these three that runs before money moves.
3070
+ *
3071
+ * 'match' \u2014 the account exists and the name agrees
3072
+ * 'no_match' \u2014 the account exists and the name does not
3073
+ * 'no_account' \u2014 no such account
3074
+ *
3075
+ * \`knownNames\` carries the other names the consumer has verified under, so a
3076
+ * married name on your record and a maiden name on their ID still match. Test
3077
+ * each of them, not just \`name\`.
3078
+ *
3079
+ * RETURN 'no_match' RATHER THAN 'match' WHEN YOU ARE UNSURE. A false 'match'
3080
+ * authorises a stranger's address onto a customer's account; a false
3081
+ * 'no_match' costs a support call.
3082
+ */
3083
+ verifyAccount(
3084
+ accountNumber: string | null,
3085
+ name: string,
3086
+ knownNames?: string[],
3087
+ ): Promise<AccountVerdict> | AccountVerdict;
3088
+
3089
+ /**
3090
+ * Is the address you hold the current one? (\`address.verify\`)
3091
+ *
3092
+ * 'match' \u2014 you already hold exactly this address
3093
+ * 'mismatch' \u2014 you know the customer and hold something different
3094
+ * 'not_found' \u2014 the account is not one of yours
3095
+ *
3096
+ * A customer you have never had an update for is 'mismatch', not 'match':
3097
+ * you know them, you simply do not have THIS address yet.
3098
+ *
3099
+ * READ-ONLY. Nothing here may write.
3100
+ */
3101
+ verifyAddress(customer: Customer, incoming: Address): Promise<VerifyResult>;
3102
+
3103
+ /**
3104
+ * Apply a new address (\`address.updated\`), and return the one it REPLACED.
3105
+ *
3106
+ * The return value is what the dashboard shows as "was", and it is the
3107
+ * question an operator asks first when a change looks wrong. Read it before
3108
+ * you write, because after the write nothing can reconstruct it. Return \`{}\`
3109
+ * if there was nothing.
3110
+ *
3111
+ * ## Called only once the caller is satisfied
3112
+ *
3113
+ * When you declare that you verify account references, \`server.ts\` refuses
3114
+ * anything that is not a 'match' before reaching here, so by this point the
3115
+ * account is a real one of yours. **If you do NOT verify account references,
3116
+ * that guard is not running and this is called with whatever identity the
3117
+ * envelope carried.** Key on something you trust in that mode; the default
3118
+ * implementation keys on the name, and two customers who share a name share a
3119
+ * row.
3120
+ *
3121
+ * ## Throwing is meaningful
3122
+ *
3123
+ * Throw and the dispatch is answered as failed, and OneAddress retries it.
3124
+ * That is the right thing to do if your database is unreachable. Do NOT
3125
+ * swallow a write failure and return normally: the consumer is then told the
3126
+ * address landed when it did not.
3127
+ */
3128
+ saveAddress(customer: Customer, incoming: Address): Promise<Address>;
3129
+
3130
+ /**
3131
+ * One customer, for the dashboard's change panel. \`null\` if unknown.
3132
+ *
3133
+ * SEPARATE FROM THE THREE HOOKS because it is cosmetic: returning \`null\`
3134
+ * always is a perfectly good implementation and costs you a panel, nothing
3135
+ * more. It exists as a method rather than being derived from a list-everyone
3136
+ * call BECAUSE of what that would mean at your scale - see \`count\` below.
3137
+ */
3138
+ find(accountNumber: string): Promise<StoredCustomer | null> | StoredCustomer | null;
3139
+
3140
+ /**
3141
+ * How many customers are on file, or \`null\` for "not worth asking".
3142
+ *
3143
+ * **RETURN \`null\` IF THIS IS A COUNT OVER A REAL CUSTOMER TABLE.** The
3144
+ * dashboard redraws every two seconds; a \`SELECT count(*)\` over four million
3145
+ * rows, forty times a minute, forever, is a load-bearing decision somebody
3146
+ * should make deliberately rather than inherit from a demo. The dashboard
3147
+ * draws a dash and is otherwise identical.
3148
+ *
3149
+ * The bundled SQLite store answers it because its roster is three rows.
3150
+ */
3151
+ count(): Promise<number | null> | number | null;
3152
+ }
2096
3153
  `
2097
3154
  },
2098
3155
  {
@@ -2101,25 +3158,45 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
2101
3158
  * \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
2102
3159
  * \u2551 src/store.ts \u2014 DATABASE INTEGRATION \u2551
2103
3160
  * \u2551 \u2551
2104
- * \u2551 A realistic partner-side store: a CUSTOMER ROSTER (who your \u2551
2105
- * \u2551 customers are + the address you hold on file for each), and the \u2551
2106
- * \u2551 three hooks OneAddress calls: \u2551
3161
+ * \u2551 THE DEFAULT implementation of \`CustomerStore\`, backed by the \u2551
3162
+ * \u2551 bundled SQLite file: a CUSTOMER ROSTER (who your customers are \u2551
3163
+ * \u2551 + the address you hold on file for each) and the three hooks \u2551
3164
+ * \u2551 OneAddress calls: \u2551
2107
3165
  * \u2551 verifyAccount \u2014 pre-payment account check (account.verify) \u2551
2108
3166
  * \u2551 verifyAddress \u2014 is your on-file address current? (address.verify)
2109
3167
  * \u2551 saveAddress \u2014 apply a new address (address.updated) \u2551
2110
3168
  * \u2551 \u2551
2111
- * \u2551 Swap the SQLite queries for your real customer database when \u2551
2112
- * \u2551 you're ready \u2014 the shapes below are what OneAddress hands you. \u2551
2113
- * \u2551 server.ts calls these after verifying and decrypting each event \u2551
2114
- * \u2551 \u2014 the protocol layer is handled for you, never touch it. \u2551
3169
+ * \u2551 THE CONTRACT IS \`src/customer-store.ts\`, not this file. Read \u2551
3170
+ * \u2551 that one first: it is five methods and a paragraph each, with \u2551
3171
+ * \u2551 no schema, no seeding and no encryption in the way, and it is \u2551
3172
+ * \u2551 what you implement against your own database. \u2551
3173
+ * \u2551 \u2551
3174
+ * \u2551 Everything below the contract \u2014 the SQLite file, the at-rest \u2551
3175
+ * \u2551 encryption, the blind index, customers.json, the repair pass, \u2551
3176
+ * \u2551 the history table \u2014 is THIS implementation, not the contract. \u2551
3177
+ * \u2551 A company whose customers already live in its own database \u2551
3178
+ * \u2551 should delete all of it rather than port it. \u2551
3179
+ * \u2551 \u2551
3180
+ * \u2551 server.ts calls the hooks after verifying and decrypting each \u2551
3181
+ * \u2551 event \u2014 the protocol layer is handled for you, never touch it. \u2551
2115
3182
  * \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
2116
3183
  */
2117
3184
  import { readFileSync } from 'node:fs';
2118
3185
  import { join } from 'node:path';
2119
3186
  import { report } from './report.js';
2120
- import db, { accountKey, dec, enc, encrypted, once } from './db.js';
2121
-
2122
- export type Address = Record<string, unknown>;
3187
+ import db, { accountKey, dec, enc, encrypted, isEncrypted, once } from './db.js';
3188
+ import type {
3189
+ AccountVerdict,
3190
+ Address,
3191
+ Customer,
3192
+ CustomerStore,
3193
+ StoredCustomer,
3194
+ VerifyResult,
3195
+ } from './customer-store.js';
3196
+
3197
+ // Re-exported so existing imports of these types from \`./store.js\` keep
3198
+ // working. They are DEFINED in the contract, which is the file to change.
3199
+ export type { AccountVerdict, Address, Customer, CustomerStore, StoredCustomer, VerifyResult };
2123
3200
 
2124
3201
  // \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
2125
3202
  // \`customers\` is your roster: the account number, the customer's name, and the
@@ -2178,6 +3255,48 @@ ensureColumn('address_history', 'prev_address', "TEXT NOT NULL DEFAULT '{}'");
2178
3255
  // pass produces the same key rather than hashing a ciphertext and orphaning the
2179
3256
  // row. A row orphaned that way still reads fine in a listing and is invisible
2180
3257
  // to every dispatch, which is the worst kind of broken.
3258
+ // REPAIR FIRST, because a database can already be in the state the refusal in
3259
+ // db.ts now prevents. Before that refusal existed, a run with no passphrase
3260
+ // against an encrypted store could not decrypt the roster, so its blind indexes
3261
+ // never matched and it seeded a SECOND copy of every customer in plaintext. The
3262
+ // relabel below then maps both copies onto one key and dies on UNIQUE, with the
3263
+ // correct passphrase in hand and no way forward but deleting the file.
3264
+ //
3265
+ // So the plaintext twin is dropped. WHICH ONE GOES IS NOT A GUESS: the
3266
+ // encrypted row is the one the owner's passphrase wrote, the plaintext row was
3267
+ // written by a process that could not read it. Only the second is discarded,
3268
+ // and only when an encrypted row for the same account exists.
3269
+ //
3270
+ // Scoped to an encrypted database. With no keys there is nothing to compare,
3271
+ // every row is plaintext, and there is no duplicate to find.
3272
+ if (encrypted) {
3273
+ const all = db.prepare('SELECT rowid AS rid, account_number FROM customers').all() as Array<
3274
+ { rid: number; account_number: string }
3275
+ >;
3276
+ const seen = new Map<string, boolean>(); // plain account -> has an encrypted row
3277
+ for (const r of all) {
3278
+ if (isEncrypted(r.account_number)) {
3279
+ const plain = dec('account_number', r.account_number);
3280
+ if (plain) seen.set(plain, true);
3281
+ }
3282
+ }
3283
+ const drop = db.prepare('DELETE FROM customers WHERE rowid = ?');
3284
+ let dropped = 0;
3285
+ for (const r of all) {
3286
+ if (isEncrypted(r.account_number)) continue;
3287
+ if (!seen.get(r.account_number)) continue;
3288
+ drop.run(r.rid);
3289
+ dropped += 1;
3290
+ }
3291
+ if (dropped > 0) {
3292
+ // Said out loud. A silent repair of somebody's customer table is the kind
3293
+ // of thing they should hear about from us rather than notice later.
3294
+ report.warn(
3295
+ \`[store] repaired \${dropped} plaintext duplicate customer row(s) left by a run with no passphrase\`,
3296
+ );
3297
+ }
3298
+ }
3299
+
2181
3300
  {
2182
3301
  const rows = db.prepare('SELECT rowid AS rid, account_number, name, address FROM customers').all() as Array<
2183
3302
  { rid: number; account_number: string; name: string; address: string }
@@ -2269,22 +3388,6 @@ const ROSTER = loadRoster();
2269
3388
  report.info(\`[store] roster ready (\${ROSTER.length} customers)\`);
2270
3389
  }
2271
3390
 
2272
- // \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
2273
- export type Customer = {
2274
- email: string | null;
2275
- name: string;
2276
- accountNumber?: string;
2277
- knownNames?: string[];
2278
- /**
2279
- * D5 LOA reference \u2014 the base64url SHA-256 of the signed consent, recomputed
2280
- * by server.ts after decrypting \`loa_encrypted\`. A production integration
2281
- * echoes this in the \`/api/confirm\` callback so OneAddress can verify
2282
- * proof-of-receipt. Null on legacy dispatches that carry no encrypted LOA.
2283
- * The store ignores it; it rides along on the identity object for convenience.
2284
- */
2285
- loaRef?: string | null;
2286
- };
2287
-
2288
3391
  /**
2289
3392
  * Canonical, order- and case-insensitive form of an address, so two addresses
2290
3393
  * compare equal iff they mean the same thing regardless of key order or casing.
@@ -2319,13 +3422,6 @@ function findCustomer(accountNumber: string | undefined, name: string): { accoun
2319
3422
  }
2320
3423
 
2321
3424
  // \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
2322
- /** One stored row, with the personal columns decrypted. */
2323
- export interface StoredCustomer {
2324
- account_number: string;
2325
- name: string;
2326
- address: string;
2327
- }
2328
-
2329
3425
  /** Decrypt a row read straight out of SQLite. */
2330
3426
  function decodeRow(raw: unknown): StoredCustomer | undefined {
2331
3427
  if (!raw) return undefined;
@@ -2352,8 +3448,6 @@ export function customerCount(): number {
2352
3448
  /** Is the file on disk protected? Surfaced in the dashboard, in both states. */
2353
3449
  export const storeEncrypted = encrypted;
2354
3450
 
2355
- export type AccountVerdict = 'match' | 'no_match' | 'no_account';
2356
-
2357
3451
  /**
2358
3452
  * Confirms the typed account number is really one of yours and the name agrees,
2359
3453
  * BEFORE the consumer pays. The boundary that stops someone pushing an update to
@@ -2376,8 +3470,6 @@ export function verifyAccount(accountNumber: string | null, name: string, knownN
2376
3470
  }
2377
3471
 
2378
3472
  // \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
2379
- export type VerifyResult = 'match' | 'mismatch' | 'not_found';
2380
-
2381
3473
  /**
2382
3474
  * Compares the consumer's current OneAddress address against what YOU hold.
2383
3475
  * 'match' \u2014 you already hold this exact address (no update needed)
@@ -2456,6 +3548,47 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
2456
3548
 
2457
3549
  return previous;
2458
3550
  }
3551
+
3552
+ /**
3553
+ * One customer by account number, for the dashboard.
3554
+ *
3555
+ * AN INDEXED LOOKUP, not a scan. The dashboard used to call \`allCustomers()\`
3556
+ * and \`.find()\` the one it wanted, which is invisible at three rows and absurd
3557
+ * at four million: decrypting an entire customer table to draw one panel. Doing
3558
+ * it here also means a real store implements a lookup, which is what its own
3559
+ * database is already good at, instead of being handed a list-everything
3560
+ * method it has no safe way to answer.
3561
+ */
3562
+ export function findByAccount(accountNumber: string): StoredCustomer | null {
3563
+ const acct = (accountNumber ?? '').trim();
3564
+ if (!acct) return null;
3565
+ return decodeRow(
3566
+ db.prepare('SELECT account_number, name, address FROM customers WHERE account_key = ?')
3567
+ .get(accountKey(acct)),
3568
+ ) ?? null;
3569
+ }
3570
+
3571
+ /**
3572
+ * THE OBJECT THE RECEIVER ACTUALLY TALKS TO.
3573
+ *
3574
+ * \`server.ts\` and \`tui.ts\` import this and nothing else from here, so pointing
3575
+ * the receiver at your own systems is one import: write a module exporting a
3576
+ * \`CustomerStore\` and change the two lines that name this one. The \`satisfies\`
3577
+ * is the part that makes that safe \u2014 miss a method, or change a signature the
3578
+ * receiver depends on, and this file stops compiling rather than failing on a
3579
+ * live dispatch.
3580
+ */
3581
+ export const store = {
3582
+ name: 'sqlite',
3583
+ encrypted: storeEncrypted,
3584
+ verifyAccount,
3585
+ verifyAddress,
3586
+ saveAddress,
3587
+ find: findByAccount,
3588
+ // Three rows, so a count is free. A store over a real customer table should
3589
+ // return null here; the contract file says why.
3590
+ count: customerCount,
3591
+ } satisfies CustomerStore;
2459
3592
  `
2460
3593
  },
2461
3594
  {
@@ -2495,10 +3628,28 @@ import {
2495
3628
  type SessionKeyShare,
2496
3629
  type OneAddressD5LOA,
2497
3630
  } from '@oneaddress/partner-sdk';
2498
- import { saveAddress, verifyAddress, verifyAccount } from './store.js';
3631
+ // THE ONLY LINE THAT NAMES AN IMPLEMENTATION. Point this at your own module
3632
+ // exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
3633
+ // the protocol layer changes.
3634
+ import { store } from './store.js';
2499
3635
  import { notePreviousAddress } from './tui.js';
2500
3636
  import { config } from './config.js';
2501
3637
  import { safeOneAddressCallbackUrl } from './callback-url.js';
3638
+ import { describeKeys, keyFailureAdvice, resolvePrivateKey } from './keys.js';
3639
+ import {
3640
+ drainConfirms,
3641
+ enqueueConfirm,
3642
+ pendingConfirmCount,
3643
+ purgeDelivered,
3644
+ type ConfirmStatus,
3645
+ } from './confirm-queue.js';
3646
+ import {
3647
+ heldCount,
3648
+ purgeQuarantine,
3649
+ quarantine,
3650
+ replayHeld,
3651
+ type QuarantineReason,
3652
+ } from './quarantine.js';
2502
3653
 
2503
3654
  const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? '';
2504
3655
  const PARTNER_PRIVATE_KEY = process.env.PARTNER_PRIVATE_KEY_PEM ?? '';
@@ -2548,6 +3699,33 @@ if (PARTNER_PRIVATE_KEY.includes('BEGIN')) {
2548
3699
  }
2549
3700
  }
2550
3701
 
3702
+ // SAID OUT LOUD ON EVERY BOOT, because the commonest key problem is believing a
3703
+ // variable is set when it is not, and the second commonest is still holding one
3704
+ // key after a rotation. Both are visible in this one line, and neither is
3705
+ // visible anywhere else until a dispatch fails.
3706
+ report.info(\`[startup] keys: \${describeKeys()}\`);
3707
+ // WHICH STORE IS LIVE, said out loud at every boot. A receiver pointed at a
3708
+ // partner's own database and one still writing to the bundled demo file behave
3709
+ // identically until the first dispatch, and the difference is where a customer's
3710
+ // address ends up. Naming it costs a line and removes the question.
3711
+ //
3712
+ // AND WHETHER IT IS PROTECTED, IN BOTH DIRECTIONS. The dashboard has always
3713
+ // drawn UNENCRYPTED in red, and a receiver running as a service has no
3714
+ // dashboard: stdin is not a terminal, so nobody is asked for a passphrase,
3715
+ // nobody answers, and it runs in the clear having said nothing. That is the
3716
+ // deployment most likely to hold real customers and the one least likely to
3717
+ // have a person looking at it. Encryption stays OPT-IN - requiring it would
3718
+ // lock out every install that has never set a passphrase - but choosing it by
3719
+ // not being asked is not a choice, so the absence is stated as loudly as the
3720
+ // presence.
3721
+ report.info(
3722
+ store.encrypted
3723
+ ? \`[startup] store: \${store.name} (encrypted at rest)\`
3724
+ : \`[startup] store: \${store.name} \u2014 NOT ENCRYPTED AT REST. \` +
3725
+ 'Customer records are readable by anyone who can read the file. ' +
3726
+ 'Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` in a terminal to be asked.',
3727
+ );
3728
+
2551
3729
  // In-memory dedup cache. Records a dispatch id only once it has been fully
2552
3730
  // HANDLED, so a dispatch that failed to decrypt is NOT remembered and a later
2553
3731
  // retry re-runs it rather than being dismissed as a duplicate. Bounded so a
@@ -2569,9 +3747,10 @@ function rememberDispatch(id: string): void {
2569
3747
 
2570
3748
  /**
2571
3749
  * Close the loop: tell OneAddress you have applied an update, so the consumer's
2572
- * dashboard flips the service to "Confirmed". Fire-and-forget so it never delays
2573
- * the webhook 200 (a slow confirm must not make OneAddress time the DISPATCH out
2574
- * and mark it failed). Logs its own outcome.
3750
+ * dashboard flips the service to "Confirmed". Called ONLY by the confirm
3751
+ * queue's drain, never from the webhook handler: a slow confirm must not delay
3752
+ * the webhook 200 (OneAddress would time the DISPATCH out and mark it failed),
3753
+ * and a failed one has to be retried rather than logged and lost.
2575
3754
  *
2576
3755
  * Auth for /api/confirm (all three required):
2577
3756
  * Authorization: Bearer <secret>
@@ -2579,13 +3758,11 @@ function rememberDispatch(id: string): void {
2579
3758
  * X-OneAddress-Signature: HMAC-SHA256(secret, \`\${timestamp}.\${rawBody}\`)
2580
3759
  * The same secret signs the Bearer and the body.
2581
3760
  */
2582
- async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'failed'): Promise<void> {
2583
- // Only real dispatches carry a numeric id in X-OneAddress-Dispatch. Probes
2584
- // (e.g. the go-live "address.test") carry a non-numeric id and have nothing to
2585
- // confirm, so skip them.
2586
- const dispatchId = Number(dispatch);
2587
- if (!Number.isInteger(dispatchId) || dispatchId <= 0) return;
2588
-
3761
+ async function confirmToOneAddress(dispatchId: number, status: ConfirmStatus): Promise<void> {
3762
+ // No probe check here any more. queueConfirm is the only door into the queue
3763
+ // and it drops non-numeric dispatch ids, so by the time a row is drained it
3764
+ // is a real dispatch. Keeping a second copy of that rule would mean a probe
3765
+ // could be queued forever and silently skipped on every drain.
2589
3766
  const bodyStr = JSON.stringify({
2590
3767
  dispatch_id: dispatchId,
2591
3768
  partner_id: PARTNER_ID,
@@ -2595,7 +3772,7 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
2595
3772
  const ts = String(Math.floor(Date.now() / 1000));
2596
3773
  const sig = createHmac('sha256', CONFIRM_SECRET).update(\`\${ts}.\${bodyStr}\`).digest('hex');
2597
3774
 
2598
- try {
3775
+ {
2599
3776
  const confirmRes = await fetch(\`\${ONEADDRESS_API}/api/confirm\`, {
2600
3777
  method: 'POST',
2601
3778
  headers: {
@@ -2608,18 +3785,31 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
2608
3785
  });
2609
3786
  if (confirmRes.ok) {
2610
3787
  report.info(\`[confirm] dispatch \${dispatchId} \u2192 \${status}: acknowledged by OneAddress\`);
2611
- } else {
2612
- const detail = await confirmRes.text().catch(() => '');
2613
- report.error(\`[confirm] dispatch \${dispatchId} confirm FAILED \u2014 HTTP \${confirmRes.status} \${detail}\`);
2614
- if (confirmRes.status === 401) {
2615
- 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.');
2616
- }
3788
+ return;
2617
3789
  }
2618
- } catch (err) {
2619
- report.error('[confirm] confirm request error:', err);
3790
+ const detail = (await confirmRes.text().catch(() => '')).slice(0, 200);
3791
+ if (confirmRes.status === 401) {
3792
+ 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.');
3793
+ }
3794
+ // THROWN, NOT LOGGED AND SWALLOWED. The queue is what decides to retry, and
3795
+ // it can only do that if failure reaches it. Returning quietly here is the
3796
+ // original bug: applied locally, never acknowledged, refunded upstream.
3797
+ throw new Error(\`HTTP \${confirmRes.status} \${detail}\`);
2620
3798
  }
2621
3799
  }
2622
3800
 
3801
+ /**
3802
+ * Hand a confirm to the queue.
3803
+ *
3804
+ * Probes (the go-live \`address.test\`) carry a non-numeric dispatch id and have
3805
+ * nothing to confirm, so they are dropped here rather than queued forever.
3806
+ */
3807
+ function queueConfirm(dispatch: string, status: ConfirmStatus): void {
3808
+ const dispatchId = Number(dispatch);
3809
+ if (!Number.isInteger(dispatchId) || dispatchId <= 0) return;
3810
+ enqueueConfirm(dispatchId, status);
3811
+ }
3812
+
2623
3813
  const app = express();
2624
3814
  app.disable('x-powered-by'); // don't fingerprint the framework
2625
3815
 
@@ -2721,7 +3911,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
2721
3911
  const name = typeof cust.name === 'string' ? cust.name : '';
2722
3912
  const knownNames = Array.isArray(cust.known_names) ? cust.known_names.map(String) : [];
2723
3913
 
2724
- const status = verifyAccount(accountNumber, name, knownNames);
3914
+ const status = await store.verifyAccount(accountNumber, name, knownNames);
2725
3915
  report.info(\`[webhook] account.verify \u2192 \${status} for account \${accountNumber ?? '(none)'} (\${name})\`);
2726
3916
  return res.status(200).json({ status });
2727
3917
  }
@@ -2732,6 +3922,23 @@ app.post('/webhook', async (req: Request, res: Response) => {
2732
3922
  // Acknowledge it here; only the real dispatch events below require decryption.
2733
3923
  // A real address.updated that arrives WITHOUT a payload still falls through to
2734
3924
  // the 422 below, because it IS a dispatch event.
3925
+ /**
3926
+ * Keep this dispatch so it can be applied once the cause is fixed.
3927
+ *
3928
+ * A closure rather than four calls passing the same three values, because the
3929
+ * value that must not be got wrong is \`rawBody\`: quarantine holds the bytes
3930
+ * AS THEY ARRIVED and never a re-serialised \`body\`. \`JSON.stringify(body)\` is
3931
+ * a different string with the same meaning, and replaying it would fail the
3932
+ * signature check it is about to be re-signed under, for a reason nobody
3933
+ * would find.
3934
+ *
3935
+ * Everything that calls this sits BELOW the HMAC check above. That is what
3936
+ * stops the quarantine being a way for anyone who can reach this port to fill
3937
+ * a partner's disk.
3938
+ */
3939
+ const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void =>
3940
+ quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
3941
+
2735
3942
  const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
2736
3943
  if (!DISPATCH_EVENTS.includes(event)) {
2737
3944
  report.info(\`[webhook] "\${event}" acknowledged (no address payload to decrypt)\`);
@@ -2765,25 +3972,47 @@ app.post('/webhook', async (req: Request, res: Response) => {
2765
3972
 
2766
3973
  if (sessionEnvelope && sessionKeyShare) {
2767
3974
  // D5 \u2014 call decryptSession. Wrong key_id / wrong partner_id / tampered
2768
- // ciphertext all surface as an AES-GCM authentication-tag error.
3975
+ // ciphertext all surface as an AES-GCM authentication-tag error, which is
3976
+ // why the key is chosen BY key_id here rather than assumed: the failure
3977
+ // cannot tell you which of those three it was, so the message has to.
3978
+ const keyId = sessionKeyShare.key_id ?? null;
3979
+ const resolved = resolvePrivateKey(keyId);
3980
+ if (!resolved) {
3981
+ report.error(\`[webhook] no private key for key_id \${keyId ?? '(none)'} \u2014 \${keyFailureAdvice(keyId, null)}\`);
3982
+ hold('no_key', keyId, keyFailureAdvice(keyId, null));
3983
+ return res.status(422).json({ ok: false, error: 'no private key for this key_id' });
3984
+ }
2769
3985
  try {
2770
- const data = await decryptSession(sessionKeyShare, sessionEnvelope, PARTNER_PRIVATE_KEY, PARTNER_ID);
3986
+ const data = await decryptSession(sessionKeyShare, sessionEnvelope, resolved.pem, PARTNER_ID);
2771
3987
  address = data.new_address as unknown as Record<string, unknown>;
2772
3988
  decName = typeof data.verified_name === 'string' ? data.verified_name : '';
2773
3989
  decAccount = typeof data.account_number === 'string' ? data.account_number : '';
2774
3990
  decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
2775
3991
  } catch (err) {
2776
- report.error('[webhook] D5 decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM matches key_id', sessionKeyShare.key_id, ':', err);
3992
+ // Names WHICH key answered. A rotation used to surface here as an
3993
+ // authentication-tag error indistinguishable from corruption, with the
3994
+ // single-key fallback having silently answered for a key id it never held.
3995
+ report.error(\`[webhook] D5 decryption failed for key_id \${keyId ?? '(none)'}: \${keyFailureAdvice(keyId, resolved)}\`);
3996
+ report.error('[webhook] underlying error:', err);
3997
+ hold('decrypt_failed', keyId, keyFailureAdvice(keyId, resolved));
2777
3998
  return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
2778
3999
  }
2779
4000
  } else if (legacyPayload) {
4001
+ // Pre-D5 dispatches name no key id, so only the fallback can apply.
4002
+ const resolved = resolvePrivateKey(null);
4003
+ if (!resolved) {
4004
+ report.error(\`[webhook] no private key for a legacy dispatch \u2014 \${keyFailureAdvice(null, null)}\`);
4005
+ hold('no_key', null, keyFailureAdvice(null, null));
4006
+ return res.status(422).json({ ok: false, error: 'no private key configured' });
4007
+ }
2780
4008
  try {
2781
- address = await decryptAddress(legacyPayload, PARTNER_PRIVATE_KEY, PARTNER_ID);
4009
+ address = await decryptAddress(legacyPayload, resolved.pem, PARTNER_ID);
2782
4010
  decName = typeof address.fullName === 'string' ? address.fullName : '';
2783
4011
  decAccount = typeof address.accountReference === 'string' ? address.accountReference : '';
2784
4012
  decKnownNames = Array.isArray(address.knownNames) ? address.knownNames as string[] : [];
2785
4013
  } catch (err) {
2786
4014
  report.error('[webhook] Decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM in .env:', err);
4015
+ hold('decrypt_failed', null, keyFailureAdvice(null, resolved));
2787
4016
  return res.status(422).json({ ok: false, error: 'Decryption failed \u2014 partner key mismatch' });
2788
4017
  }
2789
4018
  } else {
@@ -2851,10 +4080,10 @@ app.post('/webhook', async (req: Request, res: Response) => {
2851
4080
  // OneAddress conformance check "Refuses an account reference that matches
2852
4081
  // no record" tests exactly this.
2853
4082
  if (config.verifiesAccountReference) {
2854
- const verdict = verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
4083
+ const verdict = await store.verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
2855
4084
  if (verdict !== 'match') {
2856
4085
  report.warn(\`[webhook] address.updated REFUSED (\${verdict}) for account \${ctx.accountNumber ?? '(none)'} \u2014 nothing applied\`);
2857
- void confirmToOneAddress(dispatch, 'failed');
4086
+ queueConfirm(dispatch, 'failed');
2858
4087
  return res.status(200).json({ ok: false, error: 'account_not_matched', verdict });
2859
4088
  }
2860
4089
  }
@@ -2863,12 +4092,14 @@ app.post('/webhook', async (req: Request, res: Response) => {
2863
4092
  // it can show both halves; a no-op under --headless. Passed directly rather
2864
4093
  // than reported, because the previous address is a customer's address and
2865
4094
  // must never reach a log line.
2866
- const replaced = await saveAddress(ctx, address);
4095
+ const replaced = await store.saveAddress(ctx, address);
2867
4096
  notePreviousAddress(replaced);
2868
4097
  if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
2869
4098
  // Close the loop back to OneAddress so the service flips to "Confirmed".
2870
- // Fire-and-forget: it must not delay this 200 (which acks the delivery).
2871
- void confirmToOneAddress(dispatch, 'confirmed');
4099
+ // QUEUED, not sent: this is a local INSERT, so it cannot delay the 200 that
4100
+ // acks the delivery, and it survives a restart. The drain loop does the
4101
+ // network part and retries it until OneAddress answers.
4102
+ queueConfirm(dispatch, 'confirmed');
2872
4103
  return res.status(200).json({ ok: true });
2873
4104
  }
2874
4105
 
@@ -2891,7 +4122,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
2891
4122
  }
2892
4123
 
2893
4124
  report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
2894
- const result = await verifyAddress(ctx, address);
4125
+ const result = await store.verifyAddress(ctx, address);
2895
4126
  report.info(\`[webhook] address.verify \u2192 \${result}\`);
2896
4127
 
2897
4128
  await fetch(safeCallbackUrl, {
@@ -2938,12 +4169,131 @@ app.post('/webhook', async (req: Request, res: Response) => {
2938
4169
  return res.status(200).json({ ok: true, skipped: true });
2939
4170
  });
2940
4171
 
2941
- app.get('/health', (_req, res) => res.json({ status: 'ok' }));
4172
+ /**
4173
+ * Enough to answer "is this receiver healthy AND is it protected?" from a
4174
+ * monitoring system, without a terminal and without reading the logs.
4175
+ *
4176
+ * \`encryptedAtRest\` is here rather than only on the dashboard because the
4177
+ * deployment that most needs the answer is the one with no dashboard. Carrying
4178
+ * it makes the unprotected state alertable instead of merely visible.
4179
+ *
4180
+ * Deliberately NO counts, no customer data and no key material: this endpoint
4181
+ * is reachable by whatever can reach the webhook.
4182
+ */
4183
+ app.get('/health', (_req, res) => res.json({
4184
+ status: 'ok',
4185
+ store: store.name,
4186
+ encryptedAtRest: store.encrypted,
4187
+ }));
2942
4188
 
2943
4189
  app.listen(PORT, () =>
2944
4190
  report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`),
2945
4191
  );
2946
4192
 
4193
+ /**
4194
+ * Drain the confirm queue, forever.
4195
+ *
4196
+ * STARTED AT BOOT, not only after a dispatch, because the queue survives a
4197
+ * restart: a receiver that was down while OneAddress was unreachable comes back
4198
+ * with confirms owed, and nobody is going to send a fresh dispatch to trigger
4199
+ * them. On a healthy receiver this loop finds nothing and costs one indexed
4200
+ * SELECT every few seconds.
4201
+ */
4202
+ const CONFIRM_DRAIN_MS = Number(process.env.CONFIRM_DRAIN_MS ?? 5000);
4203
+ const CONFIRM_KEEP_DAYS = Number(process.env.CONFIRM_KEEP_DAYS ?? 30);
4204
+
4205
+ let draining = false;
4206
+ setInterval(() => {
4207
+ // Guarded rather than queued: a slow OneAddress must not start a second
4208
+ // drain over the same rows, which would confirm each one twice.
4209
+ if (draining) return;
4210
+ draining = true;
4211
+ void drainConfirms(confirmToOneAddress)
4212
+ .catch((err: unknown) => report.error('[confirm] drain error:', err))
4213
+ .finally(() => { draining = false; });
4214
+ }, CONFIRM_DRAIN_MS).unref();
4215
+
4216
+ // Delivered rows only, so an outstanding confirm is never aged out. Hourly is
4217
+ // far more often than needed and costs one DELETE over an index.
4218
+ setInterval(() => {
4219
+ const purged = purgeDelivered(CONFIRM_KEEP_DAYS);
4220
+ if (purged > 0) report.info(\`[confirm] purged \${purged} delivered confirm record(s) older than \${CONFIRM_KEEP_DAYS}d\`);
4221
+ }, 3_600_000).unref();
4222
+
4223
+ /**
4224
+ * Re-deliver a held dispatch TO THIS SERVER, re-signed.
4225
+ *
4226
+ * A self-POST rather than a second code path into the handler. The handler is
4227
+ * one 300-line route and the alternative is extracting it so replay can call it
4228
+ * directly, which would give two ways in and, in time, two behaviours: the one
4229
+ * partners hit and the one replay hits, differing in whichever branch was added
4230
+ * to only one of them. Going back in through the front door means a replayed
4231
+ * dispatch is verified, parsed, decrypted, applied and confirmed by exactly the
4232
+ * code a live one is.
4233
+ *
4234
+ * The body is byte-for-byte what arrived; only the timestamp and signature are
4235
+ * new, because the original pair is outside the \xB15-minute window by the time
4236
+ * anyone has fixed anything. We hold the secret, so re-signing is not a bypass:
4237
+ * it is the same proof, re-stated now.
4238
+ */
4239
+ async function redeliver(rawBody: string): Promise<void> {
4240
+ const ts = String(Math.floor(Date.now() / 1000));
4241
+ const sig = createHmac('sha256', WEBHOOK_SECRET).update(\`\${ts}.\${rawBody}\`).digest('hex');
4242
+ let dispatchId = '';
4243
+ try {
4244
+ dispatchId = String((JSON.parse(rawBody) as { dispatch_id?: unknown }).dispatch_id ?? '');
4245
+ } catch { /* the handler will reject it as bad JSON, which is the right answer */ }
4246
+
4247
+ const res = await fetch(\`http://127.0.0.1:\${PORT}/webhook\`, {
4248
+ method: 'POST',
4249
+ headers: {
4250
+ 'Content-Type': 'application/json',
4251
+ 'X-OneAddress-Timestamp': ts,
4252
+ 'X-OneAddress-Signature': sig,
4253
+ ...(dispatchId ? { 'X-OneAddress-Dispatch': dispatchId } : {}),
4254
+ },
4255
+ body: rawBody,
4256
+ });
4257
+ const text = (await res.text().catch(() => '')).slice(0, 200);
4258
+ if (!res.ok) throw new Error(\`HTTP \${res.status} \${text}\`);
4259
+ // A 200 is not on its own success here: the handler answers \`ok: false\` with
4260
+ // 200 when it REFUSES a dispatch (an account reference matching nobody), and
4261
+ // treating that as applied would clear the row for an update that was never
4262
+ // stored.
4263
+ if (!text.includes('"ok":true')) throw new Error(\`refused: \${text}\`);
4264
+ }
4265
+
4266
+ /** Apply everything held. Bound to [r] on the dashboard and run once at boot. */
4267
+ export async function replayQuarantined(): Promise<{ applied: number; failed: number }> {
4268
+ return replayHeld(redeliver);
4269
+ }
4270
+
4271
+ /**
4272
+ * Try the backlog once, a moment after boot.
4273
+ *
4274
+ * Because the realistic sequence is: a key is wrong, dispatches pile up, the
4275
+ * partner edits \`.env\`, the partner restarts. Making them find a command after
4276
+ * that is making the recovery depend on reading documentation at the exact
4277
+ * moment they are least inclined to. The delay lets \`listen\` settle, since this
4278
+ * goes back in through the port.
4279
+ */
4280
+ setTimeout(() => {
4281
+ if (heldCount() === 0) return;
4282
+ void replayQuarantined().catch((err: unknown) =>
4283
+ report.error('[replay] could not run at startup:', err),
4284
+ );
4285
+ }, 1_500).unref();
4286
+
4287
+ // Held payloads age out whether or not they were ever applied, which is the
4288
+ // opposite of the confirm queue one block up. The reason is what the row holds:
4289
+ // a confirm record names a dispatch, a quarantined payload is a consumer's
4290
+ // encrypted address on someone else's disk.
4291
+ const QUARANTINE_KEEP_DAYS = Number(process.env.QUARANTINE_KEEP_DAYS ?? 30);
4292
+ setInterval(() => { purgeQuarantine(QUARANTINE_KEEP_DAYS); }, 3_600_000).unref();
4293
+
4294
+ /** How many confirms are still owed. Read by the dashboard. */
4295
+ export { pendingConfirmCount };
4296
+
2947
4297
  // Read by src/index.ts to label the dashboard. Exported rather than re-derived
2948
4298
  // there, so the port the UI claims is the port the server actually bound.
2949
4299
  export { PORT };
@@ -3106,8 +4456,174 @@ Valid address-verify results: \`"match"\` \xB7 \`"mismatch"\` \xB7 \`"not_found"
3106
4456
 
3107
4457
  After an \`address.updated\` is stored, the receiver POSTs to \`\${ONEADDRESS_API}/api/confirm\`
3108
4458
  (HMAC-SHA256 over \`\${timestamp}.\${rawBody}\`, Bearer + \`X-OneAddress-Signature\` headers)
3109
- so the consumer's dashboard flips the service to **Confirmed**. It is
3110
- fire-and-forget, so a slow confirm never delays the webhook \`200\`.
4459
+ so the consumer's dashboard flips the service to **Confirmed**.
4460
+
4461
+ It is QUEUED, not fired and forgotten. The acknowledgement is a durable local
4462
+ row, retried with backoff and surviving a restart, so a local write is all the
4463
+ webhook waits on. That matters more than it sounds: an update OneAddress never
4464
+ hears about is shown to the consumer as a FAILED delivery, and a failed delivery
4465
+ can be refunded \u2014 so a thirty-second outage used to mean you did the work and the
4466
+ payment went back. The dashboard's \`awaiting confirm\` count is how many are still
4467
+ owed; a non-zero figure that will not come down is usually a wrong
4468
+ \`CONFIRM_SECRET\`, and the log says so.
4469
+
4470
+ ### When a dispatch will not decrypt
4471
+
4472
+ If a dispatch arrives with a valid signature and cannot be opened \u2014 the wrong key,
4473
+ or a \`key_id\` you have no variable for \u2014 it is answered \`422\` (so OneAddress keeps
4474
+ treating it as undelivered) and the encrypted payload is HELD exactly as it
4475
+ arrived. Nothing is decrypted on the way in; the receiver could not, which is the
4476
+ whole reason the record exists.
4477
+
4478
+ A \`FAULTS\` panel appears on the dashboard while anything is held and names the
4479
+ cause and the \`key_id\`. Fix the key and restart and the backlog applies itself,
4480
+ or press \`[r]\` without restarting.
4481
+
4482
+ ### Rotating a key
4483
+
4484
+ Every dispatch names the key it was encrypted to, and both keys stay valid for an
4485
+ overlap window. Give each its own variable, named for its \`key_id\` with dashes as
4486
+ underscores, upper-cased:
4487
+
4488
+ \`\`\`
4489
+ PARTNER_PRIVATE_KEY_PEM_04032299_4B04_4842_AA29_5095500C8ECE=...
4490
+ \`\`\`
4491
+
4492
+ \`PARTNER_PRIVATE_KEY_PEM\` is then a fallback for any \`key_id\` without a variable
4493
+ of its own. Convenient with one key and a trap with two: it answers for an id it
4494
+ does not hold, and AES-GCM cannot tell a wrong key from a tampered ciphertext, so
4495
+ the failure reads like corruption rather than like a rotation. **Set
4496
+ \`PARTNER_KEYS_STRICT=1\` once you hold more than one key.** The startup line tells
4497
+ you which keys this receiver can open.
4498
+
4499
+ ## Pointing this at your own database
4500
+
4501
+ Everything OneAddress needs from your systems is **five methods in
4502
+ \`src/customer-store.ts\`**. That file is the contract: no schema, no seeding, no
4503
+ encryption in the way, a paragraph per method. It is the file to hand a DBA, and
4504
+ it is readable in ten minutes without reading any of ours.
4505
+
4506
+ \`src/store.ts\` is the DEFAULT implementation of it, backed by the bundled SQLite
4507
+ file. The SQLite schema, the at-rest encryption, the blind index,
4508
+ \`customers.json\`, the repair pass and the history table all belong to that
4509
+ implementation and **not** to the contract. If your customers already live in
4510
+ your own database, delete them rather than port them: encrypting a second copy
4511
+ of a record you already hold protects nothing and gives you another key to
4512
+ manage.
4513
+
4514
+ Write a module exporting a \`CustomerStore\` and change one line:
4515
+
4516
+ \`\`\`typescript
4517
+ // src/server.ts and src/tui.ts
4518
+ import { store } from './my-customer-store.js';
4519
+ \`\`\`
4520
+
4521
+ The \`satisfies CustomerStore\` on your export is what keeps that safe \u2014 miss a
4522
+ method, or drift from a signature the receiver depends on, and it stops
4523
+ compiling rather than failing on a live dispatch. The receiver names the live
4524
+ store in its startup line, so you can see which one is running without reading
4525
+ code.
4526
+
4527
+ ### What it needs on your database
4528
+
4529
+ Less than people expect. The receiver never reads a customer's address for its
4530
+ own purposes and never lists your table:
4531
+
4532
+ | Method | Needs |
4533
+ |--------|-------|
4534
+ | \`verifyAccount\` | read the account number and the name |
4535
+ | \`verifyAddress\` | read the address you hold |
4536
+ | \`saveAddress\` | read the address, then write it; append your own audit row |
4537
+ | \`find\` | read one row by account number (optional, dashboard only) |
4538
+ | \`count\` | nothing \u2014 return \`null\` and it is never asked |
4539
+
4540
+ A least-privilege PostgreSQL grant for that is short enough to review:
4541
+
4542
+ \`\`\`sql
4543
+ CREATE ROLE oneaddress_receiver LOGIN PASSWORD '...';
4544
+ GRANT USAGE ON SCHEMA app TO oneaddress_receiver;
4545
+ GRANT SELECT (account_number, name, address) ON app.customers TO oneaddress_receiver;
4546
+ GRANT UPDATE (address, updated_at) ON app.customers TO oneaddress_receiver;
4547
+ GRANT INSERT ON app.address_history TO oneaddress_receiver;
4548
+ -- No DELETE, no DDL, no access to any other table.
4549
+ \`\`\`
4550
+
4551
+ **No INSERT on \`customers\`.** The receiver has no business creating customers:
4552
+ a dispatch for an account you do not have is one you should refuse, and a role
4553
+ that cannot create a row cannot be talked into it by a bug in ours.
4554
+
4555
+ ### What stays ours
4556
+
4557
+ The receiver keeps a small local SQLite file even when your customers live
4558
+ elsewhere, for the confirm queue and the quarantine. Neither holds customer
4559
+ data: the confirm queue holds dispatch ids and outcomes, the quarantine holds
4560
+ ciphertext the receiver could not open. Your DBA is entitled to ask, and that is
4561
+ the answer.
4562
+
4563
+ The protocol half stays ours too, and a store implementation is never given a
4564
+ chance to weaken it: signature verification, the replay window, decryption and
4565
+ the confirm callback all run before your code is called. By then the dispatch
4566
+ has been proven to come from OneAddress and decrypted in memory.
4567
+
4568
+ ### One thing to get right at your scale
4569
+
4570
+ \`count()\` is asked every two seconds while the dashboard is open. **Return
4571
+ \`null\` from it** if answering means a \`SELECT count(*)\` over a real customer
4572
+ table; the dashboard draws a dash and is otherwise identical. The bundled store
4573
+ answers it because its roster is three rows.
4574
+
4575
+ The same reasoning is why there is no "list every customer" method. The
4576
+ dashboard asks for the ONE customer a dispatch just changed, by account number,
4577
+ so your database does the thing it is already good at.
4578
+
4579
+ ## What this is, and where it stops
4580
+
4581
+ Honest limits, so you find them here rather than in production. Most are further
4582
+ away than people expect, and the last one is a hard edge rather than a slope.
4583
+
4584
+ **It is a real receiver, not a toy.** It verifies signatures, enforces the
4585
+ replay window, decrypts per-partner envelopes, holds what it cannot open,
4586
+ retries its acknowledgements durably and refuses accounts you do not recognise.
4587
+ Nothing in the protocol layer is stubbed.
4588
+
4589
+ **Throughput is not the constraint.** An address change is a rare event per
4590
+ customer. A single process on modest hardware handles far more than a consumer
4591
+ base generates, and the work per dispatch is one decrypt and one write.
4592
+
4593
+ **Its own bookkeeping is a local SQLite file**, whatever your customers live in.
4594
+ The confirm queue holds dispatch ids and outcomes; the quarantine holds
4595
+ ciphertext it could not open. Neither holds customer data, and both stay local
4596
+ even after you implement \`CustomerStore\` against your own database.
4597
+
4598
+ **The hard edge: run ONE of these.** Because that bookkeeping is a local file,
4599
+ two instances are not two workers sharing a queue - they are two separate
4600
+ receivers. Each acknowledges only what it received, and pressing \`[r]\` on one
4601
+ does nothing for the other's backlog. That is fine, and it is not a scaling
4602
+ problem at this event rate, but it is a thing to know before somebody sets
4603
+ \`replicas: 2\` and wonders why half the confirms never leave.
4604
+
4605
+ If you genuinely need more than one instance, or a metrics endpoint, or a
4606
+ different database behind the receiver's own bookkeeping, talk to us before
4607
+ building around it: that is a different piece of software and we would rather
4608
+ give it to you than watch you rebuild it.
4609
+
4610
+ ### The order to do things in
4611
+
4612
+ 1. **Run it as generated.** Point the wizard at a tunnel, pass conformance, see
4613
+ a real dispatch land in the demo roster.
4614
+ 2. **Implement \`CustomerStore\` against your own database.** The contract is five
4615
+ methods; the grant above is what your DBA needs to approve. Delete the SQLite
4616
+ roster, \`customers.json\`, the at-rest encryption and the history table when
4617
+ you do - they are ours, not the contract's.
4618
+ 3. **Decide the two retention windows.** \`CONFIRM_KEEP_DAYS\` and
4619
+ \`QUARANTINE_KEEP_DAYS\` both default to 30. The second one is holding your
4620
+ customers' encrypted addresses, so it is a decision your privacy people
4621
+ should make rather than inherit.
4622
+ 4. **Run it where it will live**, with \`ONEADDRESS_DB_PASSPHRASE\` set if you kept
4623
+ our store, and watch the startup line: it names which store is live and
4624
+ whether it is encrypted at rest, in both directions. \`/health\` carries the
4625
+ same two facts so you can alert on them.
4626
+ 5. **Set \`PARTNER_KEYS_STRICT=1\`** the day you hold a second key.
3111
4627
 
3112
4628
  ## Conformance check
3113
4629
 
@@ -3135,6 +4651,23 @@ webhook URL at [partners.oneaddress.io](https://partners.oneaddress.io).
3135
4651
  | \`oneAddressApi\` | \`https://oneaddress.io\` | Base URL the confirm callback posts to. |
3136
4652
  | \`verifiesAccountReference\` | \`(your portal declaration)\` | Whether you answer \`account.verify\` with a real match, or reply "not checked". |
3137
4653
 
4654
+ **Three optional environment variables** tune what is kept, and most partners
4655
+ set none of them:
4656
+
4657
+ | Var | Default | Purpose |
4658
+ |-----|---------|---------|
4659
+ | \`PARTNER_KEYS_STRICT\` | unset | \`1\` turns off the single-key fallback. Set it once you hold more than one key. |
4660
+ | \`CONFIRM_KEEP_DAYS\` | \`30\` | How long DELIVERED confirm records are kept. An OUTSTANDING one is never aged out. |
4661
+ | \`QUARANTINE_KEEP_DAYS\` | \`30\` | How long a held payload is kept, applied or not. |
4662
+
4663
+ The last two are deliberately opposite rules, and the difference is what the row
4664
+ holds. A confirm record names a dispatch and an outcome, so keeping an unanswered
4665
+ one forever costs nobody anything and dropping it loses an update. A held payload
4666
+ is one of your customers' addresses, encrypted, sitting on your disk: keeping that
4667
+ indefinitely is a retention decision nobody made, so it has a window, and a
4668
+ payload that ages out without ever being applied is logged loudly rather than
4669
+ quietly.
4670
+
3138
4671
  Each field can be overridden for a one-off by an environment variable of the
3139
4672
  matching name (\`PARTNER_ID\` / \`ONEADDRESS_API\` / \`VERIFIES_ACCOUNT_REFERENCE\`).
3140
4673
  One secret has no config-file home because it must stay out of a non-secret
@@ -7990,7 +9523,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
7990
9523
 
7991
9524
  // src/register.ts
7992
9525
  var import_node_crypto = require("crypto");
7993
- var PKG_VERSION = true ? "2.1.1" : "dev";
9526
+ var PKG_VERSION = true ? "2.1.3" : "dev";
7994
9527
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
7995
9528
  function hmacSha256(secret, message) {
7996
9529
  return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");