@oneaddress/setup 2.1.2 → 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 +1451 -84
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
856
856
  var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
857
857
  var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
858
858
  var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
859
- var WIZARD_VERSION = true ? "2.1.2" : "?";
859
+ var WIZARD_VERSION = true ? "2.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
  `
@@ -1646,7 +1660,12 @@ export function formatLine(line: ReportLine): string {
1646
1660
  import blessed from 'blessed';
1647
1661
  import { HEX, ONE_ROWS, ADDRESS_ROWS, terminalFitsFullMark } from './brand.js';
1648
1662
  import { formatLine, report, type ReportLine } from './report.js';
1649
- 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';
1650
1669
 
1651
1670
  /** blessed takes colours as strings; these mirror the site's palette. */
1652
1671
  const AMBER = HEX.amber.toLowerCase();
@@ -1661,6 +1680,14 @@ export interface TuiOptions {
1661
1680
  port: number;
1662
1681
  /** Called when the operator quits, so the caller can close the server. */
1663
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 }>;
1664
1691
  }
1665
1692
 
1666
1693
  /** One address as a single line, the way the change panel shows it. */
@@ -1669,7 +1696,7 @@ function oneLine(a: Record<string, unknown>): string {
1669
1696
  return parts.length > 0 ? parts.join(', ') : '(empty)';
1670
1697
  }
1671
1698
 
1672
- export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void {
1699
+ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptions): void {
1673
1700
  const screen = blessed.screen({
1674
1701
  smartCSR: true,
1675
1702
  title: \`\${partnerName} \u2014 OneAddress receiver\`,
@@ -1722,6 +1749,23 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1722
1749
  style: { border: { fg: DIM }, label: { fg: DIM } },
1723
1750
  });
1724
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
+
1725
1769
  const footer = blessed.box({
1726
1770
  parent: screen, bottom: 0, left: 0, width: '100%', height: 3,
1727
1771
  tags: true, padding: { left: 2 },
@@ -1745,14 +1789,18 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1745
1789
  // Both states are shown, and the unprotected one is the loud colour. A
1746
1790
  // security property mentioned only when it holds is one nobody notices the
1747
1791
  // absence of.
1748
- const vault = storeEncrypted
1792
+ const vault = store.encrypted
1749
1793
  ? \`{green-fg}{bold}ENCRYPTED{/}\`
1750
1794
  : \`{red-fg}{bold}UNENCRYPTED{/}\`;
1751
1795
  status.setContent(
1752
1796
  \`{\${AMBER}-fg}{bold}\${esc(partnerName)}{/} \` +
1753
1797
  \`{\${DIM}-fg}listening{/} {\${CREAM}-fg}:\${port}/webhook{/} \` +
1754
1798
  \`{\${DIM}-fg}customer file{/} \${vault} \` +
1755
- \`{\${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()}{/}\`,
1756
1804
  );
1757
1805
  }
1758
1806
 
@@ -1776,18 +1824,76 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1776
1824
  );
1777
1825
  }
1778
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
+
1779
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;
1780
1884
  footer.setContent(
1781
1885
  \`{\${DIM}-fg}received{/} {bold}\${received}{/bold} \` +
1782
1886
  \`{green-fg}applied{/} {bold}\${applied}{/bold} \` +
1783
- \`{red-fg}failed{/} {bold}\${failed}{/bold}\` +
1784
- \`{|}{\${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{/} \`,
1785
1890
  );
1786
1891
  }
1787
1892
 
1788
1893
  function redraw(): void {
1789
1894
  renderStatus();
1790
1895
  renderChange();
1896
+ renderFaults();
1791
1897
  renderFooter();
1792
1898
  screen.render();
1793
1899
  }
@@ -1801,21 +1907,64 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1801
1907
  const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
1802
1908
  const time = new Date(line.at).toTimeString().slice(0, 8);
1803
1909
 
1804
- if (/address\\.updated for /.test(text)) received++;
1805
- 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++;
1806
1925
  if (/\\[store\\] saved address for /.test(text)) {
1807
1926
  applied++;
1808
1927
  // The store logs the account key and never the address, so the panel is
1809
1928
  // refreshed from the DATABASE rather than parsed out of the log line.
1810
1929
  const acct = /saved address for (\\S+)/.exec(text)?.[1];
1811
- const customer = allCustomers().find((c) => c.account_number === acct);
1812
- if (customer) lastChange = { customer, previous: pendingPrevious };
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
+ }
1813
1941
  }
1814
1942
 
1815
1943
  logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
1816
1944
  redraw();
1817
1945
  });
1818
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
+
1819
1968
  screen.key(['q', 'C-c'], () => {
1820
1969
  detach();
1821
1970
  screen.destroy();
@@ -1823,6 +1972,14 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
1823
1972
  process.exit(0);
1824
1973
  });
1825
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
+
1826
1983
  redraw();
1827
1984
  }
1828
1985
 
@@ -2077,10 +2234,22 @@ async function main(): Promise<void> {
2077
2234
  // Dynamic, and this is the whole point of the file: importing the server
2078
2235
  // pulls in the store, which pulls in the database, which derives its keys on
2079
2236
  // import. The passphrase has to be set before that chain starts.
2080
- let config: { partnerName: string; port: number };
2237
+ let config: {
2238
+ partnerName: string;
2239
+ port: number;
2240
+ replay: () => Promise<{ applied: number; failed: number }>;
2241
+ };
2081
2242
  try {
2082
2243
  const server = await import('./server.js');
2083
- 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
+ };
2084
2253
  } catch (err) {
2085
2254
  if (err instanceof PassphraseRequiredError) {
2086
2255
  // Reached when nobody could be asked: a service unit, a piped stdin, or
@@ -2108,6 +2277,7 @@ async function main(): Promise<void> {
2108
2277
  startDashboard({
2109
2278
  partnerName: config.partnerName,
2110
2279
  port: config.port,
2280
+ onReplay: config.replay,
2111
2281
  onQuit: () => { /* the process exits; the OS closes the socket */ },
2112
2282
  });
2113
2283
  }
@@ -2116,6 +2286,581 @@ main().catch((err) => {
2116
2286
  console.error('[startup]', err instanceof Error ? err.message : err);
2117
2287
  process.exit(1);
2118
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
+ }
2119
2864
  `
2120
2865
  },
2121
2866
  {
@@ -2217,6 +2962,194 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
2217
2962
  return null;
2218
2963
  }
2219
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
+ }
2220
3153
  `
2221
3154
  },
2222
3155
  {
@@ -2225,25 +3158,45 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
2225
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
2226
3159
  * \u2551 src/store.ts \u2014 DATABASE INTEGRATION \u2551
2227
3160
  * \u2551 \u2551
2228
- * \u2551 A realistic partner-side store: a CUSTOMER ROSTER (who your \u2551
2229
- * \u2551 customers are + the address you hold on file for each), and the \u2551
2230
- * \u2551 three hooks OneAddress calls: \u2551
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
2231
3165
  * \u2551 verifyAccount \u2014 pre-payment account check (account.verify) \u2551
2232
3166
  * \u2551 verifyAddress \u2014 is your on-file address current? (address.verify)
2233
3167
  * \u2551 saveAddress \u2014 apply a new address (address.updated) \u2551
2234
3168
  * \u2551 \u2551
2235
- * \u2551 Swap the SQLite queries for your real customer database when \u2551
2236
- * \u2551 you're ready \u2014 the shapes below are what OneAddress hands you. \u2551
2237
- * \u2551 server.ts calls these after verifying and decrypting each event \u2551
2238
- * \u2551 \u2014 the protocol layer is handled for you, never touch it. \u2551
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
2239
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
2240
3183
  */
2241
3184
  import { readFileSync } from 'node:fs';
2242
3185
  import { join } from 'node:path';
2243
3186
  import { report } from './report.js';
2244
3187
  import db, { accountKey, dec, enc, encrypted, isEncrypted, once } from './db.js';
2245
-
2246
- export type Address = Record<string, unknown>;
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 };
2247
3200
 
2248
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
2249
3202
  // \`customers\` is your roster: the account number, the customer's name, and the
@@ -2435,22 +3388,6 @@ const ROSTER = loadRoster();
2435
3388
  report.info(\`[store] roster ready (\${ROSTER.length} customers)\`);
2436
3389
  }
2437
3390
 
2438
- // \u2500\u2500 Identity handed in by server.ts after it verifies + decrypts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2439
- export type Customer = {
2440
- email: string | null;
2441
- name: string;
2442
- accountNumber?: string;
2443
- knownNames?: string[];
2444
- /**
2445
- * D5 LOA reference \u2014 the base64url SHA-256 of the signed consent, recomputed
2446
- * by server.ts after decrypting \`loa_encrypted\`. A production integration
2447
- * echoes this in the \`/api/confirm\` callback so OneAddress can verify
2448
- * proof-of-receipt. Null on legacy dispatches that carry no encrypted LOA.
2449
- * The store ignores it; it rides along on the identity object for convenience.
2450
- */
2451
- loaRef?: string | null;
2452
- };
2453
-
2454
3391
  /**
2455
3392
  * Canonical, order- and case-insensitive form of an address, so two addresses
2456
3393
  * compare equal iff they mean the same thing regardless of key order or casing.
@@ -2485,13 +3422,6 @@ function findCustomer(accountNumber: string | undefined, name: string): { accoun
2485
3422
  }
2486
3423
 
2487
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
2488
- /** One stored row, with the personal columns decrypted. */
2489
- export interface StoredCustomer {
2490
- account_number: string;
2491
- name: string;
2492
- address: string;
2493
- }
2494
-
2495
3425
  /** Decrypt a row read straight out of SQLite. */
2496
3426
  function decodeRow(raw: unknown): StoredCustomer | undefined {
2497
3427
  if (!raw) return undefined;
@@ -2518,8 +3448,6 @@ export function customerCount(): number {
2518
3448
  /** Is the file on disk protected? Surfaced in the dashboard, in both states. */
2519
3449
  export const storeEncrypted = encrypted;
2520
3450
 
2521
- export type AccountVerdict = 'match' | 'no_match' | 'no_account';
2522
-
2523
3451
  /**
2524
3452
  * Confirms the typed account number is really one of yours and the name agrees,
2525
3453
  * BEFORE the consumer pays. The boundary that stops someone pushing an update to
@@ -2542,8 +3470,6 @@ export function verifyAccount(accountNumber: string | null, name: string, knownN
2542
3470
  }
2543
3471
 
2544
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
2545
- export type VerifyResult = 'match' | 'mismatch' | 'not_found';
2546
-
2547
3473
  /**
2548
3474
  * Compares the consumer's current OneAddress address against what YOU hold.
2549
3475
  * 'match' \u2014 you already hold this exact address (no update needed)
@@ -2622,6 +3548,47 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
2622
3548
 
2623
3549
  return previous;
2624
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;
2625
3592
  `
2626
3593
  },
2627
3594
  {
@@ -2661,10 +3628,28 @@ import {
2661
3628
  type SessionKeyShare,
2662
3629
  type OneAddressD5LOA,
2663
3630
  } from '@oneaddress/partner-sdk';
2664
- 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';
2665
3635
  import { notePreviousAddress } from './tui.js';
2666
3636
  import { config } from './config.js';
2667
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';
2668
3653
 
2669
3654
  const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? '';
2670
3655
  const PARTNER_PRIVATE_KEY = process.env.PARTNER_PRIVATE_KEY_PEM ?? '';
@@ -2714,6 +3699,33 @@ if (PARTNER_PRIVATE_KEY.includes('BEGIN')) {
2714
3699
  }
2715
3700
  }
2716
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
+
2717
3729
  // In-memory dedup cache. Records a dispatch id only once it has been fully
2718
3730
  // HANDLED, so a dispatch that failed to decrypt is NOT remembered and a later
2719
3731
  // retry re-runs it rather than being dismissed as a duplicate. Bounded so a
@@ -2735,9 +3747,10 @@ function rememberDispatch(id: string): void {
2735
3747
 
2736
3748
  /**
2737
3749
  * Close the loop: tell OneAddress you have applied an update, so the consumer's
2738
- * dashboard flips the service to "Confirmed". Fire-and-forget so it never delays
2739
- * the webhook 200 (a slow confirm must not make OneAddress time the DISPATCH out
2740
- * and mark it failed). Logs its own outcome.
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.
2741
3754
  *
2742
3755
  * Auth for /api/confirm (all three required):
2743
3756
  * Authorization: Bearer <secret>
@@ -2745,13 +3758,11 @@ function rememberDispatch(id: string): void {
2745
3758
  * X-OneAddress-Signature: HMAC-SHA256(secret, \`\${timestamp}.\${rawBody}\`)
2746
3759
  * The same secret signs the Bearer and the body.
2747
3760
  */
2748
- async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'failed'): Promise<void> {
2749
- // Only real dispatches carry a numeric id in X-OneAddress-Dispatch. Probes
2750
- // (e.g. the go-live "address.test") carry a non-numeric id and have nothing to
2751
- // confirm, so skip them.
2752
- const dispatchId = Number(dispatch);
2753
- if (!Number.isInteger(dispatchId) || dispatchId <= 0) return;
2754
-
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.
2755
3766
  const bodyStr = JSON.stringify({
2756
3767
  dispatch_id: dispatchId,
2757
3768
  partner_id: PARTNER_ID,
@@ -2761,7 +3772,7 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
2761
3772
  const ts = String(Math.floor(Date.now() / 1000));
2762
3773
  const sig = createHmac('sha256', CONFIRM_SECRET).update(\`\${ts}.\${bodyStr}\`).digest('hex');
2763
3774
 
2764
- try {
3775
+ {
2765
3776
  const confirmRes = await fetch(\`\${ONEADDRESS_API}/api/confirm\`, {
2766
3777
  method: 'POST',
2767
3778
  headers: {
@@ -2774,18 +3785,31 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
2774
3785
  });
2775
3786
  if (confirmRes.ok) {
2776
3787
  report.info(\`[confirm] dispatch \${dispatchId} \u2192 \${status}: acknowledged by OneAddress\`);
2777
- } else {
2778
- const detail = await confirmRes.text().catch(() => '');
2779
- report.error(\`[confirm] dispatch \${dispatchId} confirm FAILED \u2014 HTTP \${confirmRes.status} \${detail}\`);
2780
- if (confirmRes.status === 401) {
2781
- report.error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET in .env to it (from the portal Webhook screen); otherwise your webhook signing secret should work.');
2782
- }
3788
+ return;
2783
3789
  }
2784
- } catch (err) {
2785
- 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}\`);
2786
3798
  }
2787
3799
  }
2788
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
+
2789
3813
  const app = express();
2790
3814
  app.disable('x-powered-by'); // don't fingerprint the framework
2791
3815
 
@@ -2887,7 +3911,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
2887
3911
  const name = typeof cust.name === 'string' ? cust.name : '';
2888
3912
  const knownNames = Array.isArray(cust.known_names) ? cust.known_names.map(String) : [];
2889
3913
 
2890
- const status = verifyAccount(accountNumber, name, knownNames);
3914
+ const status = await store.verifyAccount(accountNumber, name, knownNames);
2891
3915
  report.info(\`[webhook] account.verify \u2192 \${status} for account \${accountNumber ?? '(none)'} (\${name})\`);
2892
3916
  return res.status(200).json({ status });
2893
3917
  }
@@ -2898,6 +3922,23 @@ app.post('/webhook', async (req: Request, res: Response) => {
2898
3922
  // Acknowledge it here; only the real dispatch events below require decryption.
2899
3923
  // A real address.updated that arrives WITHOUT a payload still falls through to
2900
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
+
2901
3942
  const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
2902
3943
  if (!DISPATCH_EVENTS.includes(event)) {
2903
3944
  report.info(\`[webhook] "\${event}" acknowledged (no address payload to decrypt)\`);
@@ -2931,25 +3972,47 @@ app.post('/webhook', async (req: Request, res: Response) => {
2931
3972
 
2932
3973
  if (sessionEnvelope && sessionKeyShare) {
2933
3974
  // D5 \u2014 call decryptSession. Wrong key_id / wrong partner_id / tampered
2934
- // 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
+ }
2935
3985
  try {
2936
- const data = await decryptSession(sessionKeyShare, sessionEnvelope, PARTNER_PRIVATE_KEY, PARTNER_ID);
3986
+ const data = await decryptSession(sessionKeyShare, sessionEnvelope, resolved.pem, PARTNER_ID);
2937
3987
  address = data.new_address as unknown as Record<string, unknown>;
2938
3988
  decName = typeof data.verified_name === 'string' ? data.verified_name : '';
2939
3989
  decAccount = typeof data.account_number === 'string' ? data.account_number : '';
2940
3990
  decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
2941
3991
  } catch (err) {
2942
- 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));
2943
3998
  return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
2944
3999
  }
2945
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
+ }
2946
4008
  try {
2947
- address = await decryptAddress(legacyPayload, PARTNER_PRIVATE_KEY, PARTNER_ID);
4009
+ address = await decryptAddress(legacyPayload, resolved.pem, PARTNER_ID);
2948
4010
  decName = typeof address.fullName === 'string' ? address.fullName : '';
2949
4011
  decAccount = typeof address.accountReference === 'string' ? address.accountReference : '';
2950
4012
  decKnownNames = Array.isArray(address.knownNames) ? address.knownNames as string[] : [];
2951
4013
  } catch (err) {
2952
4014
  report.error('[webhook] Decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM in .env:', err);
4015
+ hold('decrypt_failed', null, keyFailureAdvice(null, resolved));
2953
4016
  return res.status(422).json({ ok: false, error: 'Decryption failed \u2014 partner key mismatch' });
2954
4017
  }
2955
4018
  } else {
@@ -3017,10 +4080,10 @@ app.post('/webhook', async (req: Request, res: Response) => {
3017
4080
  // OneAddress conformance check "Refuses an account reference that matches
3018
4081
  // no record" tests exactly this.
3019
4082
  if (config.verifiesAccountReference) {
3020
- const verdict = verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
4083
+ const verdict = await store.verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
3021
4084
  if (verdict !== 'match') {
3022
4085
  report.warn(\`[webhook] address.updated REFUSED (\${verdict}) for account \${ctx.accountNumber ?? '(none)'} \u2014 nothing applied\`);
3023
- void confirmToOneAddress(dispatch, 'failed');
4086
+ queueConfirm(dispatch, 'failed');
3024
4087
  return res.status(200).json({ ok: false, error: 'account_not_matched', verdict });
3025
4088
  }
3026
4089
  }
@@ -3029,12 +4092,14 @@ app.post('/webhook', async (req: Request, res: Response) => {
3029
4092
  // it can show both halves; a no-op under --headless. Passed directly rather
3030
4093
  // than reported, because the previous address is a customer's address and
3031
4094
  // must never reach a log line.
3032
- const replaced = await saveAddress(ctx, address);
4095
+ const replaced = await store.saveAddress(ctx, address);
3033
4096
  notePreviousAddress(replaced);
3034
4097
  if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
3035
4098
  // Close the loop back to OneAddress so the service flips to "Confirmed".
3036
- // Fire-and-forget: it must not delay this 200 (which acks the delivery).
3037
- void confirmToOneAddress(dispatch, 'confirmed');
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');
3038
4103
  return res.status(200).json({ ok: true });
3039
4104
  }
3040
4105
 
@@ -3057,7 +4122,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
3057
4122
  }
3058
4123
 
3059
4124
  report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
3060
- const result = await verifyAddress(ctx, address);
4125
+ const result = await store.verifyAddress(ctx, address);
3061
4126
  report.info(\`[webhook] address.verify \u2192 \${result}\`);
3062
4127
 
3063
4128
  await fetch(safeCallbackUrl, {
@@ -3104,12 +4169,131 @@ app.post('/webhook', async (req: Request, res: Response) => {
3104
4169
  return res.status(200).json({ ok: true, skipped: true });
3105
4170
  });
3106
4171
 
3107
- 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
+ }));
3108
4188
 
3109
4189
  app.listen(PORT, () =>
3110
4190
  report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`),
3111
4191
  );
3112
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
+
3113
4297
  // Read by src/index.ts to label the dashboard. Exported rather than re-derived
3114
4298
  // there, so the port the UI claims is the port the server actually bound.
3115
4299
  export { PORT };
@@ -3272,8 +4456,174 @@ Valid address-verify results: \`"match"\` \xB7 \`"mismatch"\` \xB7 \`"not_found"
3272
4456
 
3273
4457
  After an \`address.updated\` is stored, the receiver POSTs to \`\${ONEADDRESS_API}/api/confirm\`
3274
4458
  (HMAC-SHA256 over \`\${timestamp}.\${rawBody}\`, Bearer + \`X-OneAddress-Signature\` headers)
3275
- so the consumer's dashboard flips the service to **Confirmed**. It is
3276
- fire-and-forget, so a slow confirm never delays the webhook \`200\`.
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.
3277
4627
 
3278
4628
  ## Conformance check
3279
4629
 
@@ -3301,6 +4651,23 @@ webhook URL at [partners.oneaddress.io](https://partners.oneaddress.io).
3301
4651
  | \`oneAddressApi\` | \`https://oneaddress.io\` | Base URL the confirm callback posts to. |
3302
4652
  | \`verifiesAccountReference\` | \`(your portal declaration)\` | Whether you answer \`account.verify\` with a real match, or reply "not checked". |
3303
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
+
3304
4671
  Each field can be overridden for a one-off by an environment variable of the
3305
4672
  matching name (\`PARTNER_ID\` / \`ONEADDRESS_API\` / \`VERIFIES_ACCOUNT_REFERENCE\`).
3306
4673
  One secret has no config-file home because it must stay out of a non-secret
@@ -8156,7 +9523,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
8156
9523
 
8157
9524
  // src/register.ts
8158
9525
  var import_node_crypto = require("crypto");
8159
- var PKG_VERSION = true ? "2.1.2" : "dev";
9526
+ var PKG_VERSION = true ? "2.1.3" : "dev";
8160
9527
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
8161
9528
  function hmacSha256(secret, message) {
8162
9529
  return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");