@oneaddress/setup 2.1.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1236 -106
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
856
856
  var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
857
857
  var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
858
858
  var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
859
- var WIZARD_VERSION = true ? "2.1.3" : "?";
859
+ var WIZARD_VERSION = true ? "2.2.0" : "?";
860
860
  function printCompactHeader() {
861
861
  const INNER = 42;
862
862
  const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
@@ -1087,6 +1087,7 @@ data.db-shm
1087
1087
  */
1088
1088
  import { DatabaseSync } from 'node:sqlite';
1089
1089
  import { join } from 'node:path';
1090
+ import { report } from './report.js';
1090
1091
  import {
1091
1092
  accountIndex,
1092
1093
  buildVerifier,
@@ -1229,6 +1230,22 @@ export { PassphraseRequiredError, WrongPassphraseError };
1229
1230
  * separates them.
1230
1231
  */
1231
1232
  export { isEncrypted };
1233
+
1234
+ /**
1235
+ * Add a column to an existing table if it is not already there.
1236
+ *
1237
+ * SQLite has no \`ADD COLUMN IF NOT EXISTS\`, and a receiver that has been
1238
+ * running since before a column existed still has to start. Lives here rather
1239
+ * than in one of the two files that needs it, because the second copy is how
1240
+ * the two drift.
1241
+ */
1242
+ export function ensureColumn(table: string, column: string, definition: string): void {
1243
+ const cols = db.prepare(\`PRAGMA table_info(\${table})\`).all() as Array<{ name: string }>;
1244
+ if (cols.some((c) => c.name === column)) return;
1245
+ db.exec(\`ALTER TABLE \${table} ADD COLUMN \${column} \${definition}\`);
1246
+ report.info(\`[db] migrated: added column \${table}.\${column}\`);
1247
+ }
1248
+
1232
1249
  export default db;
1233
1250
  `
1234
1251
  },
@@ -1665,7 +1682,7 @@ import { formatLine, report, type ReportLine } from './report.js';
1665
1682
  import { store } from './store.js';
1666
1683
  import type { StoredCustomer } from './customer-store.js';
1667
1684
  import { pendingConfirmCount } from './confirm-queue.js';
1668
- import { heldCount, heldSummary } from './quarantine.js';
1685
+ import { exportHeld, heldCount, heldSummary } from './quarantine.js';
1669
1686
 
1670
1687
  /** blessed takes colours as strings; these mirror the site's palette. */
1671
1688
  const AMBER = HEX.amber.toLowerCase();
@@ -1688,6 +1705,21 @@ export interface TuiOptions {
1688
1705
  * the dashboard still renders for a caller that has no replay to offer.
1689
1706
  */
1690
1707
  onReplay?: () => Promise<{ applied: number; failed: number }>;
1708
+ /**
1709
+ * How the dispatches went, asked of the receiver rather than counted here.
1710
+ *
1711
+ * Same reason as \`onReplay\`: \`server.ts\` already imports this file, so this
1712
+ * file cannot import it back. Optional, and a caller that omits it gets
1713
+ * zeroes, which is honest for a dashboard driving nothing.
1714
+ */
1715
+ stats?: () => {
1716
+ received: number;
1717
+ applied: number;
1718
+ failed: number;
1719
+ mode: string;
1720
+ awaitingConnector: number;
1721
+ oldestUndrawn: string | null;
1722
+ };
1691
1723
  }
1692
1724
 
1693
1725
  /** One address as a single line, the way the change panel shows it. */
@@ -1696,7 +1728,7 @@ function oneLine(a: Record<string, unknown>): string {
1696
1728
  return parts.length > 0 ? parts.join(', ') : '(empty)';
1697
1729
  }
1698
1730
 
1699
- export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptions): void {
1731
+ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: TuiOptions): void {
1700
1732
  const screen = blessed.screen({
1701
1733
  smartCSR: true,
1702
1734
  title: \`\${partnerName} \u2014 OneAddress receiver\`,
@@ -1758,7 +1790,9 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1758
1790
  // is the other case: a permanent empty box teaches the eye to skip that
1759
1791
  // region, which is precisely the region that has to be noticed the one day it
1760
1792
  // fills. So it appears, and the two panels above give up the rows.
1761
- const FAULT_HEIGHT = 7;
1793
+ // Six, not seven. Every row this takes comes off the two panels above it, and
1794
+ // the one above left cannot afford to lose any: see renderChange.
1795
+ const FAULT_HEIGHT = 6;
1762
1796
  const faultBox = blessed.box({
1763
1797
  parent: screen, bottom: 3, left: 0, width: '100%', height: FAULT_HEIGHT,
1764
1798
  label: ' FAULTS ', tags: true, padding: { left: 1, right: 1 }, hidden: true,
@@ -1774,9 +1808,6 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1774
1808
  });
1775
1809
 
1776
1810
  // \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1777
- let received = 0;
1778
- let applied = 0;
1779
- let failed = 0;
1780
1811
  let lastChange: { customer: StoredCustomer; previous: Record<string, unknown> } | null = null;
1781
1812
  let pendingPrevious: Record<string, unknown> = {};
1782
1813
 
@@ -1789,9 +1820,16 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1789
1820
  // Both states are shown, and the unprotected one is the loud colour. A
1790
1821
  // security property mentioned only when it holds is one nobody notices the
1791
1822
  // absence of.
1792
- const vault = store.encrypted
1793
- ? \`{green-fg}{bold}ENCRYPTED{/}\`
1794
- : \`{red-fg}{bold}UNENCRYPTED{/}\`;
1823
+ const inbox = statsMode() === 'inbox';
1824
+ // IN INBOX MODE THERE IS NO CUSTOMER FILE HERE AT ALL, so neither ENCRYPTED
1825
+ // nor UNENCRYPTED is true and both would mislead. The receiver holds
1826
+ // ciphertext it cannot open; the customers live in the partner's own
1827
+ // database, behind their own controls.
1828
+ const vault = inbox
1829
+ ? \`{\${AMBER}-fg}{bold}NONE (inbox){/}\`
1830
+ : store.encrypted
1831
+ ? \`{green-fg}{bold}ENCRYPTED{/}\`
1832
+ : \`{red-fg}{bold}UNENCRYPTED{/}\`;
1795
1833
  status.setContent(
1796
1834
  \`{\${AMBER}-fg}{bold}\${esc(partnerName)}{/} \` +
1797
1835
  \`{\${DIM}-fg}listening{/} {\${CREAM}-fg}:\${port}/webhook{/} \` +
@@ -1816,11 +1854,23 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1816
1854
  const { customer, previous } = lastChange;
1817
1855
  let now: Record<string, unknown> = {};
1818
1856
  try { now = JSON.parse(customer.address) as Record<string, unknown>; } catch { /* keep empty */ }
1857
+ // COMPACT, AND THAT IS A BUG FIX RATHER THAN A TIDY-UP.
1858
+ //
1859
+ // This used to space the lines out with blanks, which needed eight rows.
1860
+ // The faults band takes seven rows off this panel when it appears, so on a
1861
+ // real terminal the last line fell off the bottom - and the last line is
1862
+ // \`now\`, the one thing the panel exists to show. blessed clips silently, so
1863
+ // it read as a dispatch that had half worked.
1864
+ //
1865
+ // It cost a real end-to-end run an hour of doubt: the address had been
1866
+ // applied, confirmed and acknowledged by OneAddress, and the screen showed
1867
+ // only what it used to be. Adjacent is better anyway, because comparing two
1868
+ // lines a blank apart is harder than comparing two lines.
1819
1869
  changeBox.setContent(
1820
- \`\\n {bold}\${esc(customer.name)}{/bold}\\n\` +
1870
+ \`\\n {bold}\${esc(customer.name)}{/bold}\` +
1821
1871
  \` {\${DIM}-fg}account{/} {\${AMBER}-fg}\${esc(customer.account_number)}{/}\\n\\n\` +
1822
- \` {red-fg}was{/} \${esc(oneLine(previous))}\\n\\n\` +
1823
- \` {green-fg}now{/} {\${CREAM}-fg}\${esc(oneLine(now))}{/}\\n\`,
1872
+ \` {red-fg}was{/} \${esc(oneLine(previous))}\\n\` +
1873
+ \` {green-fg}now{/} {\${CREAM}-fg}\${esc(oneLine(now))}{/}\`,
1824
1874
  );
1825
1875
  }
1826
1876
 
@@ -1835,6 +1885,8 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1835
1885
  * declines to count returns forever, so the dash below is the honest initial
1836
1886
  * state rather than a placeholder that happens to look the same.
1837
1887
  */
1888
+ const statsMode = (): string => stats?.().mode ?? 'write-through';
1889
+
1838
1890
  let onFile: number | null = null;
1839
1891
  function refreshCount(): void {
1840
1892
  void Promise.resolve(store.count())
@@ -1860,14 +1912,14 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1860
1912
  // Grouped, never one line per dispatch. The realistic shape of this table
1861
1913
  // is forty rows with ONE cause between them, and forty identical lines hide
1862
1914
  // the single fact that matters.
1863
- const lines = heldSummary().slice(0, 3).map((l) => \` {red-fg}\${esc(l)}{/}\`);
1915
+ const lines = heldSummary().slice(0, 2).map((l) => \` {red-fg}\${esc(l)}{/}\`);
1864
1916
  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' +
1917
+ \`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open. \` +
1918
+ \`{\${DIM}-fg}Held, encrypted, exactly as they arrived.{/}\\n\` +
1919
+ lines.join('\\n') + '\\n' +
1868
1920
  (replayNote
1869
1921
  ? \` {\${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.{/}\`),
1922
+ : \` {\${DIM}-fg}Fix the cause, then{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply,{/} {\${AMBER}-fg}[e]{/} {\${DIM}-fg}to export the list.{/}\`),
1871
1923
  );
1872
1924
  }
1873
1925
 
@@ -1881,12 +1933,27 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1881
1933
  // with a backlog shows the backlog instead of zero.
1882
1934
  const awaiting = pendingConfirmCount();
1883
1935
  const awaitingColour = awaiting > 0 ? AMBER : DIM;
1936
+ // ASKED, not accumulated. The server counts per dispatch, so a redelivery
1937
+ // updates a record rather than adding one.
1938
+ const s = stats?.() ?? {
1939
+ received: 0, applied: 0, failed: 0,
1940
+ mode: 'write-through', awaitingConnector: 0, oldestUndrawn: null,
1941
+ };
1942
+ const { received, applied, failed } = s;
1943
+ // AWAITING YOUR SYSTEMS, in inbox mode only. Drawn whether or not it is
1944
+ // zero there, and absent entirely in write-through, because a counter for a
1945
+ // thing that cannot happen is noise that teaches the eye to skip the row.
1946
+ const awaitingYou = s.mode === 'inbox'
1947
+ ? \`{\${s.awaitingConnector > 0 ? AMBER : DIM}-fg}awaiting your systems{/} \` +
1948
+ \`{bold}\${s.awaitingConnector}{/bold} \`
1949
+ : '';
1884
1950
  footer.setContent(
1885
1951
  \`{\${DIM}-fg}received{/} {bold}\${received}{/bold} \` +
1886
1952
  \`{green-fg}applied{/} {bold}\${applied}{/bold} \` +
1887
1953
  \`{red-fg}failed{/} {bold}\${failed}{/bold} \` +
1954
+ awaitingYou +
1888
1955
  \`{\${awaitingColour}-fg}awaiting confirm{/} {bold}\${awaiting}{/bold}\` +
1889
- \`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[q] quit{/} \`,
1956
+ \`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [q] quit{/} \`,
1890
1957
  );
1891
1958
  }
1892
1959
 
@@ -1899,31 +1966,20 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1899
1966
  }
1900
1967
 
1901
1968
  // \u2500\u2500 The feed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1902
- // Counters are derived from the reported lines rather than from a second
1903
- // channel the server would have to remember to update. One source, so the
1904
- // footer cannot disagree with the log beside it.
1969
+ // NARRATION ONLY. It used to derive the footer's counters from these lines
1970
+ // too, on the reasoning that one source cannot disagree with itself. That
1971
+ // held until replay shipped: a replay produces exactly the lines an arrival
1972
+ // does, so pressing [r] against a key that could never work drove \`received\`
1973
+ // from 5 to 67 while nothing arrived. A log line says what happened and not
1974
+ // what it happened TO, so two lines about one dispatch are indistinguishable
1975
+ // from two dispatches. The counters key on the dispatch now; see
1976
+ // \`src/tally.ts\`.
1905
1977
  const detach = report.attach((line: ReportLine) => {
1906
1978
  const text = formatLine(line);
1907
1979
  const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
1908
1980
  const time = new Date(line.at).toTimeString().slice(0, 8);
1909
1981
 
1910
- // COUNTED ON THE OUTCOME LINE, WHICHEVER IT IS.
1911
- //
1912
- // \`received\` used to increment only on \`address.updated for <account>\`,
1913
- // which is logged AFTER decryption because the account number comes out of
1914
- // the decrypted envelope. A dispatch that failed to decrypt returned 422
1915
- // before that line ran, so it was never counted as having arrived: a
1916
- // partner with a wrong key saw \`received 0 \xB7 failed 12\` and went looking at
1917
- // their tunnel and their webhook URL. Everything was arriving. The error
1918
- // line said "check your key" and the counter, louder, said "nothing here".
1919
- //
1920
- // Every dispatch produces exactly one of these, so counting either as an
1921
- // arrival makes received = applied + failed hold.
1922
- const failure = /REFUSED|decryption failed|Decryption failed|no private key for/.test(text);
1923
- if (/address\\.updated for /.test(text) || failure) received++;
1924
- if (failure) failed++;
1925
1982
  if (/\\[store\\] saved address for /.test(text)) {
1926
- applied++;
1927
1983
  // The store logs the account key and never the address, so the panel is
1928
1984
  // refreshed from the DATABASE rather than parsed out of the log line.
1929
1985
  const acct = /saved address for (\\S+)/.exec(text)?.[1];
@@ -1965,6 +2021,24 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
1965
2021
  .finally(() => { replaying = false; redraw(); });
1966
2022
  });
1967
2023
 
2024
+ // [e] EXPORTS THE FAULT LIST. Metadata only, no payloads: see exportHeld.
2025
+ // Bound unconditionally for the same reason [r] is, so pressing it on a clean
2026
+ // receiver says there is nothing to export rather than appearing to hang.
2027
+ screen.key(['e'], () => {
2028
+ if (heldCount() === 0) {
2029
+ replayNote = 'Nothing held, so nothing to export.';
2030
+ redraw();
2031
+ return;
2032
+ }
2033
+ try {
2034
+ const { path, count } = exportHeld(process.cwd());
2035
+ replayNote = \`Exported \${count} to \${path}\`;
2036
+ } catch (err) {
2037
+ replayNote = \`Export failed: \${err instanceof Error ? err.message : String(err)}\`;
2038
+ }
2039
+ redraw();
2040
+ });
2041
+
1968
2042
  screen.key(['q', 'C-c'], () => {
1969
2043
  detach();
1970
2044
  screen.destroy();
@@ -2238,6 +2312,14 @@ async function main(): Promise<void> {
2238
2312
  partnerName: string;
2239
2313
  port: number;
2240
2314
  replay: () => Promise<{ applied: number; failed: number }>;
2315
+ stats: () => {
2316
+ received: number;
2317
+ applied: number;
2318
+ failed: number;
2319
+ mode: string;
2320
+ awaitingConnector: number;
2321
+ oldestUndrawn: string | null;
2322
+ };
2241
2323
  };
2242
2324
  try {
2243
2325
  const server = await import('./server.js');
@@ -2249,6 +2331,7 @@ async function main(): Promise<void> {
2249
2331
  // second edge the other way is a cycle. This file is the one place that
2250
2332
  // holds both.
2251
2333
  replay: server.replayQuarantined,
2334
+ stats: server.dashboardStats,
2252
2335
  };
2253
2336
  } catch (err) {
2254
2337
  if (err instanceof PassphraseRequiredError) {
@@ -2278,6 +2361,7 @@ async function main(): Promise<void> {
2278
2361
  partnerName: config.partnerName,
2279
2362
  port: config.port,
2280
2363
  onReplay: config.replay,
2364
+ stats: config.stats,
2281
2365
  onQuit: () => { /* the process exits; the OS closes the socket */ },
2282
2366
  });
2283
2367
  }
@@ -2286,6 +2370,708 @@ main().catch((err) => {
2286
2370
  console.error('[startup]', err instanceof Error ? err.message : err);
2287
2371
  process.exit(1);
2288
2372
  });
2373
+ `
2374
+ },
2375
+ {
2376
+ name: "src/connector-store.ts",
2377
+ content: `/**
2378
+ * A \`CustomerStore\` whose answers come from the connector.
2379
+ *
2380
+ * ## Why the mode swaps the STORE rather than branching the handler
2381
+ *
2382
+ * The obvious way to build inbox mode is an \`if (mode === 'inbox')\` beside each
2383
+ * verify event in \`server.ts\`. That works and it puts the mode into the middle
2384
+ * of the protocol layer, where it has to be got right twice and stay right
2385
+ * every time either handler changes.
2386
+ *
2387
+ * The contract in \`customer-store.ts\` already says exactly what the receiver
2388
+ * needs from a partner: verify an account, verify an address, apply a change.
2389
+ * In inbox mode the answers come from a different place. That is an
2390
+ * IMPLEMENTATION of the contract, not a special case in the caller, so the
2391
+ * handler is untouched and there is one place the mode lives.
2392
+ *
2393
+ * ## Three methods answer, two deliberately refuse
2394
+ *
2395
+ * \`verifyAccount\` and \`verifyAddress\` ask the connector and wait, because a
2396
+ * consumer is mid-payment on the other end of the first one.
2397
+ *
2398
+ * \`saveAddress\` THROWS. In inbox mode an \`address.updated\` never reaches the
2399
+ * store at all: it is held for the connector to draw, and the connector applies
2400
+ * it against the partner's own database. If this is ever called, the mode
2401
+ * switch has been bypassed and the right answer is a loud failure rather than a
2402
+ * quiet write into a store that should not exist.
2403
+ *
2404
+ * \`find\` and \`count\` return nothing, honestly, because this receiver holds no
2405
+ * customer records in this mode. The dashboard draws a dash rather than a zero,
2406
+ * which is the difference between "not asked" and "none".
2407
+ */
2408
+ import { report } from './report.js';
2409
+ import { askConnector } from './connector-client.js';
2410
+ import type {
2411
+ AccountVerdict,
2412
+ Address,
2413
+ Customer,
2414
+ CustomerStore,
2415
+ StoredCustomer,
2416
+ VerifyResult,
2417
+ } from './customer-store.js';
2418
+
2419
+ /**
2420
+ * The raw body of the request currently being handled.
2421
+ *
2422
+ * SET BY THE HANDLER, read here. The contract passes a decoded \`Customer\`,
2423
+ * which is exactly what this store does not have and cannot produce: the
2424
+ * receiver holds no private key in this mode, so the only thing it can send the
2425
+ * connector is the ciphertext that arrived. Rather than widen the contract for
2426
+ * one implementation, the handler parks the bytes here for the length of the
2427
+ * request.
2428
+ *
2429
+ * Safe because Node runs one request's synchronous path at a time and this is
2430
+ * read immediately, in the same tick the handler sets it. It would NOT be safe
2431
+ * if anything awaited between the set and the read, which is why they are
2432
+ * adjacent and why this comment exists.
2433
+ */
2434
+ let currentRawBody = '';
2435
+
2436
+ export function setCurrentRawBody(raw: string): void {
2437
+ currentRawBody = raw;
2438
+ }
2439
+
2440
+ function verdictOf(body: Record<string, unknown> | undefined, key: string): string | null {
2441
+ const value = body?.[key];
2442
+ return typeof value === 'string' ? value : null;
2443
+ }
2444
+
2445
+ export const connectorStore = {
2446
+ name: 'connector',
2447
+ // The receiver holds no customer records in this mode, so "is the customer
2448
+ // file encrypted" has no true answer. False is the honest one: there is no
2449
+ // protected customer store here, because there is no customer store here.
2450
+ encrypted: false,
2451
+
2452
+ async verifyAccount(
2453
+ accountNumber: string | null,
2454
+ _name: string,
2455
+ _knownNames: string[] = [],
2456
+ ): Promise<AccountVerdict> {
2457
+ const answer = await askConnector('account.verify', currentRawBody);
2458
+ if (!answer.reached) {
2459
+ // NEVER GUESSED. A fabricated match authorises a stranger's address onto
2460
+ // a customer's account. \`no_account\` is the safe answer and stops the
2461
+ // consumer before they pay, which is the trade this design accepted.
2462
+ report.warn(
2463
+ \`[connector] account check for \${accountNumber ?? '(none)'} could not be answered; \` +
2464
+ 'refusing rather than guessing',
2465
+ );
2466
+ return 'no_account';
2467
+ }
2468
+ const status = verdictOf(answer.body, 'status');
2469
+ if (status === 'match' || status === 'no_match' || status === 'no_account') return status;
2470
+ report.warn(\`[connector] account.verify answered "\${status ?? '(nothing)'}", which is not a verdict\`);
2471
+ return 'no_account';
2472
+ },
2473
+
2474
+ async verifyAddress(_customer: Customer, _incoming: Address): Promise<VerifyResult> {
2475
+ const answer = await askConnector('address.verify', currentRawBody);
2476
+ if (!answer.reached) return 'not_found';
2477
+ const result = verdictOf(answer.body, 'result');
2478
+ if (result === 'match' || result === 'mismatch' || result === 'not_found') return result;
2479
+ report.warn(\`[connector] address.verify answered "\${result ?? '(nothing)'}", which is not a result\`);
2480
+ return 'not_found';
2481
+ },
2482
+
2483
+ saveAddress(_customer: Customer, _incoming: Address): Promise<Address> {
2484
+ // Unreachable by design: see the header. Loud rather than quiet.
2485
+ return Promise.reject(new Error(
2486
+ 'saveAddress was called in inbox mode. Updates are held for the connector to draw and ' +
2487
+ 'apply against your own database; nothing should write through this receiver.',
2488
+ ));
2489
+ },
2490
+
2491
+ find(_accountNumber: string): StoredCustomer | null { return null; },
2492
+ count(): null { return null; },
2493
+ } satisfies CustomerStore;
2494
+ `
2495
+ },
2496
+ {
2497
+ name: "src/connector-client.ts",
2498
+ content: `/**
2499
+ * Asking the connector a question that cannot wait.
2500
+ *
2501
+ * ## Why this direction exists at all
2502
+ *
2503
+ * Everything else between the receiver and the connector is a PULL: the
2504
+ * connector draws work when it is ready. That is the right shape, because the
2505
+ * partner's system should control its own pace.
2506
+ *
2507
+ * The two verify events cannot work that way. \`account.verify\` runs BEFORE the
2508
+ * consumer pays and has to answer in the same request with match, no match, or
2509
+ * no account; \`address.verify\` is the same shape. Both need a decrypt and a
2510
+ * customer lookup, and in inbox mode both of those live in the connector. So
2511
+ * for these, and only these, the receiver calls out and waits.
2512
+ *
2513
+ * ## A CORRECTION TO THE DESIGN DOCUMENT, recorded where it matters
2514
+ *
2515
+ * \`docs/architecture/receiver-connector-design.md\` says the connector "accepts
2516
+ * no inbound connections". That is not achievable alongside a synchronous
2517
+ * verify, and the verify decision is the one that was taken deliberately. So
2518
+ * the connector DOES listen, on loopback only, and the honest form of the
2519
+ * property is: no inbound port reachable from any NETWORK. The difference
2520
+ * matters the day a partner puts the two on separate hosts, which is what the
2521
+ * transport configuration below exists for.
2522
+ *
2523
+ * ## What happens when the connector is down
2524
+ *
2525
+ * The receiver tells OneAddress honestly that it could not check, and the
2526
+ * consumer is stopped BEFORE they pay rather than after. That is the cost of
2527
+ * choosing this over a verify-only key in the receiver: such a key would keep
2528
+ * answers flowing during an outage and would make "the receiver cannot read
2529
+ * addresses" untrue, which is the whole point of the split.
2530
+ *
2531
+ * It is NEVER answered with a guess. A fabricated match authorises a stranger's
2532
+ * address onto a customer's account; a fabricated no-match costs a support
2533
+ * call. Neither is ours to invent.
2534
+ */
2535
+ import { report } from './report.js';
2536
+
2537
+ /**
2538
+ * Where the connector listens.
2539
+ *
2540
+ * 3003, NOT 3002. Two loopback listeners exist in this design and swapping them
2541
+ * is silent: the receiver's own draw channel is on 3002, so a receiver pointed
2542
+ * at 3002 for verify asks ITSELF the question and gets a 404 that reads exactly
2543
+ * like a connector that is down.
2544
+ */
2545
+ const CONNECTOR_URL = process.env.CONNECTOR_URL ?? 'http://127.0.0.1:3003';
2546
+ const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
2547
+ /**
2548
+ * Short, because a consumer is watching a spinner on the other end of this.
2549
+ * A verify that takes eight seconds has already failed as far as they are
2550
+ * concerned, and an honest "could not check" beats a long hang.
2551
+ */
2552
+ const TIMEOUT_MS = Number(process.env.CONNECTOR_TIMEOUT_MS ?? 5000);
2553
+
2554
+ export type VerifyKind = 'account.verify' | 'address.verify';
2555
+
2556
+ export interface ConnectorVerdict {
2557
+ reached: boolean;
2558
+ /** Whatever the connector answered. Passed through untouched. */
2559
+ body?: Record<string, unknown>;
2560
+ error?: string;
2561
+ }
2562
+
2563
+ /**
2564
+ * Hand the connector an encrypted verify event and wait for its answer.
2565
+ *
2566
+ * The RAW BODY goes over, not anything decoded: the receiver holds no private
2567
+ * key in this mode and has nothing to decode it with. The connector decrypts,
2568
+ * looks the customer up in the partner's database, and answers.
2569
+ */
2570
+ export async function askConnector(kind: VerifyKind, rawBody: string): Promise<ConnectorVerdict> {
2571
+ const controller = new AbortController();
2572
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
2573
+ try {
2574
+ const res = await fetch(\`\${CONNECTOR_URL}/verify\`, {
2575
+ method: 'POST',
2576
+ headers: {
2577
+ 'Content-Type': 'application/json',
2578
+ 'Authorization': \`Bearer \${TOKEN}\`,
2579
+ 'X-OneAddress-Verify-Kind': kind,
2580
+ },
2581
+ body: rawBody,
2582
+ signal: controller.signal,
2583
+ });
2584
+ const text = await res.text().catch(() => '');
2585
+ if (!res.ok) {
2586
+ report.error(\`[connector] \${kind} refused: HTTP \${res.status} \${text.slice(0, 200)}\`);
2587
+ return { reached: false, error: \`HTTP \${res.status}\` };
2588
+ }
2589
+ try {
2590
+ return { reached: true, body: JSON.parse(text) as Record<string, unknown> };
2591
+ } catch {
2592
+ report.error(\`[connector] \${kind} answered with something that is not JSON\`);
2593
+ return { reached: false, error: 'bad response' };
2594
+ }
2595
+ } catch (err) {
2596
+ const message = err instanceof Error ? err.message : String(err);
2597
+ // NAMES THE REMEDY. This is the failure a partner will actually see, and
2598
+ // "fetch failed" on its own sends them looking at OneAddress.
2599
+ report.error(
2600
+ \`[connector] could not reach the connector for \${kind} (\${message}). \` +
2601
+ \`Account checks will fail until it is back. Is it running, and is CONNECTOR_URL (\${CONNECTOR_URL}) right?\`,
2602
+ );
2603
+ return { reached: false, error: message };
2604
+ } finally {
2605
+ clearTimeout(timer);
2606
+ }
2607
+ }
2608
+ `
2609
+ },
2610
+ {
2611
+ name: "src/draw-api.ts",
2612
+ content: `/**
2613
+ * The channel your connector draws from.
2614
+ *
2615
+ * ## The most dangerous surface in this receiver
2616
+ *
2617
+ * Everything else here handles ciphertext. This hands out dispatches a
2618
+ * connector will decrypt, and takes its word for what was applied. An exposed
2619
+ * or unauthenticated version of it is precisely the oracle the whole
2620
+ * architecture exists to prevent, so three things are not configurable:
2621
+ *
2622
+ * 1. **It binds to 127.0.0.1 and nothing else.** There is deliberately no
2623
+ * option to bind it to an interface. A partner who needs the connector on
2624
+ * another host needs a transport with mutual authentication, not this one
2625
+ * with a wider bind, and giving them a knob that looks like it does the
2626
+ * job is how that ends up on a LAN.
2627
+ * 2. **It runs on a SEPARATE PORT from the webhook.** Same process, different
2628
+ * listener, so nothing routed from the internet can reach these paths even
2629
+ * by mistake in a reverse proxy.
2630
+ * 3. **It needs its own credential.** \`CONNECTOR_TOKEN\`, not the webhook
2631
+ * secret and not the confirm secret. Those are shared with OneAddress; a
2632
+ * leak of either must not also hand somebody your customers' addresses.
2633
+ * Compared in constant time, and the API refuses to start without it.
2634
+ *
2635
+ * ## What it does NOT decrypt
2636
+ *
2637
+ * Nothing. It hands over \`raw_body\`, the bytes as they arrived. The private key
2638
+ * lives in the connector, which is the only component that can read any of it.
2639
+ */
2640
+ import express, { type Request, type Response } from 'express';
2641
+ import rateLimit from 'express-rate-limit';
2642
+ import { timingSafeEqual } from 'node:crypto';
2643
+ import { report } from './report.js';
2644
+ import { acknowledge, drawable, markDrawn, type InboxOutcome } from './inbox.js';
2645
+
2646
+ const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
2647
+ /**
2648
+ * The RECEIVER's loopback listener, which is not the connector's.
2649
+ *
2650
+ * Named \`DRAW_PORT\` rather than \`CONNECTOR_PORT\` because the connector has a
2651
+ * listener of its own, on \`CONNECTOR_PORT\` (3003), for the verify questions
2652
+ * that cannot wait for a draw. One name for two ports on one host is a
2653
+ * misconfiguration nobody would find: each end would bind or dial the other's.
2654
+ */
2655
+ const DRAW_PORT = Number(process.env.DRAW_PORT ?? 3002);
2656
+ /**
2657
+ * How long a drawn item stays claimed before it is offered again.
2658
+ *
2659
+ * Crash recovery for ONE connector, not concurrency for two. See \`inbox.ts\`.
2660
+ * Generous, because the cost of re-offering too early is a duplicate apply and
2661
+ * the cost of re-offering too late is a delay nobody dies of.
2662
+ */
2663
+ const LEASE_SECONDS = Number(process.env.CONNECTOR_LEASE_SECONDS ?? 300);
2664
+
2665
+ /** Constant time, and length-safe: \`timingSafeEqual\` throws on a length mismatch. */
2666
+ function tokenMatches(presented: string): boolean {
2667
+ const a = Buffer.from(presented);
2668
+ const b = Buffer.from(TOKEN);
2669
+ if (a.length !== b.length) return false;
2670
+ return timingSafeEqual(a, b);
2671
+ }
2672
+
2673
+ function authorised(req: Request, res: Response): boolean {
2674
+ const header = req.headers.authorization ?? '';
2675
+ const presented = header.startsWith('Bearer ') ? header.slice(7) : '';
2676
+ if (presented && tokenMatches(presented)) return true;
2677
+ // No detail, deliberately. This endpoint should tell an unauthorised caller
2678
+ // nothing at all about whether it exists or what it holds.
2679
+ res.status(401).json({ error: 'Unauthorised' });
2680
+ return false;
2681
+ }
2682
+
2683
+ export function startDrawApi(): { port: number } | null {
2684
+ if (connectorTokenMissing()) return null;
2685
+
2686
+ const app = express();
2687
+ app.disable('x-powered-by');
2688
+
2689
+ // A BACKSTOP, AND NOT WHAT KEEPS THIS SHUT.
2690
+ //
2691
+ // What keeps it shut is the loopback bind and the credential, compared in
2692
+ // constant time. This bounds what an attacker who is already on the box can
2693
+ // spend, and it is deliberately far above any real connector: a draw every
2694
+ // two seconds plus an acknowledgement per item is a few hundred a minute at
2695
+ // worst, so a legitimate connector never meets this and a partner who tunes
2696
+ // \`CONNECTOR_POLL_MS\` down still has room.
2697
+ //
2698
+ // No \`trust proxy\` here, unlike the webhook app. There is no proxy in front
2699
+ // of a loopback listener, so \`X-Forwarded-For\` is somebody trying it on, and
2700
+ // trusting it would let them key the limiter on an address of their choosing.
2701
+ //
2702
+ // Attached ONCE, on the app rather than per route. Two references to the same
2703
+ // limiter instance count a request twice and silently halve the ceiling.
2704
+ app.use(rateLimit({
2705
+ windowMs: 60_000,
2706
+ max: 2000,
2707
+ standardHeaders: true,
2708
+ legacyHeaders: false,
2709
+ message: { error: 'Too many requests' },
2710
+ }));
2711
+
2712
+ app.use(express.json({ limit: '2mb' }));
2713
+
2714
+ /**
2715
+ * Take the next batch of work.
2716
+ *
2717
+ * A GET with no side effects would be tidier and would be wrong: drawing has
2718
+ * to stamp the lease, or a connector that asks twice gets the same items and
2719
+ * applies them twice.
2720
+ */
2721
+ app.post('/draw', (req: Request, res: Response) => {
2722
+ if (!authorised(req, res)) return;
2723
+ const limit = Math.min(Number((req.body as { limit?: unknown }).limit ?? 20) || 20, 100);
2724
+ const items = drawable(limit, LEASE_SECONDS);
2725
+ markDrawn(items.map((i) => i.id));
2726
+ if (items.length > 0) report.info(\`[draw] connector took \${items.length} dispatch(es)\`);
2727
+ return res.json({ items });
2728
+ });
2729
+
2730
+ /**
2731
+ * What the connector did with one item.
2732
+ *
2733
+ * \`applied\` queues the confirm to OneAddress. \`failed\` queues a failed
2734
+ * confirm, which is the honest answer when the partner's own system refused
2735
+ * the change: the consumer is told it did not land rather than being told it
2736
+ * did.
2737
+ */
2738
+ app.post('/ack', (req: Request, res: Response) => {
2739
+ if (!authorised(req, res)) return;
2740
+ const body = req.body as { id?: unknown; outcome?: unknown; detail?: unknown };
2741
+ const id = typeof body.id === 'string' ? body.id : '';
2742
+ const outcome: InboxOutcome | null =
2743
+ body.outcome === 'applied' || body.outcome === 'failed' ? body.outcome : null;
2744
+ if (!id || !outcome) {
2745
+ return res.status(400).json({ error: 'id and outcome (applied|failed) are required' });
2746
+ }
2747
+ const detail = typeof body.detail === 'string' ? body.detail : null;
2748
+
2749
+ const result = acknowledge(id, outcome, detail);
2750
+ if (!result) return res.status(404).json({ error: 'unknown id' });
2751
+ if (result.alreadyAcknowledged) {
2752
+ // NOT AN ERROR. A connector that is unsure its acknowledgement landed
2753
+ // should retry, and punishing that is how an update ends up applied and
2754
+ // never confirmed.
2755
+ return res.json({ ok: true, duplicate: true });
2756
+ }
2757
+
2758
+ if (result.dispatchId) onAcknowledged(result.dispatchId, outcome);
2759
+ return res.json({ ok: true });
2760
+ });
2761
+
2762
+ /** Enough for the connector to know it is talking to the right receiver. */
2763
+ app.get('/connector/health', (req: Request, res: Response) => {
2764
+ if (!authorised(req, res)) return;
2765
+ return res.json({ status: 'ok', mode: 'inbox' });
2766
+ });
2767
+
2768
+ app.listen(DRAW_PORT, '127.0.0.1', () =>
2769
+ report.info(\`[draw] connector channel \u2192 http://127.0.0.1:\${DRAW_PORT} (loopback only)\`),
2770
+ );
2771
+ return { port: DRAW_PORT };
2772
+ }
2773
+
2774
+ /**
2775
+ * Refuse to open the channel without a credential, and say why.
2776
+ *
2777
+ * Returning null rather than throwing, because a receiver in write-through mode
2778
+ * has no connector and must start perfectly well without one. In inbox mode the
2779
+ * caller turns this into a hard failure, since an inbox with no way to drain it
2780
+ * is worse than a receiver that will not start.
2781
+ */
2782
+ function connectorTokenMissing(): boolean {
2783
+ if (TOKEN.trim().length >= 16) return false;
2784
+ report.error(
2785
+ '[draw] CONNECTOR_TOKEN is missing or shorter than 16 characters, so the connector ' +
2786
+ 'channel was NOT opened. This is its own credential on purpose: it must not be your ' +
2787
+ 'webhook secret or your confirm secret, because those are shared with OneAddress and a ' +
2788
+ 'leak of either must not also hand somebody your customers\\' addresses.',
2789
+ );
2790
+ return true;
2791
+ }
2792
+
2793
+ /**
2794
+ * Told when an item is acknowledged, so the receiver can confirm to OneAddress.
2795
+ *
2796
+ * A setter rather than an import, because \`server.ts\` owns the confirm queue
2797
+ * and already imports this module: importing it back would be a cycle.
2798
+ */
2799
+ let onAcknowledged: (dispatchId: string, outcome: InboxOutcome) => void = () => {};
2800
+
2801
+ export function setAcknowledgementHandler(
2802
+ handler: (dispatchId: string, outcome: InboxOutcome) => void,
2803
+ ): void {
2804
+ onAcknowledged = handler;
2805
+ }
2806
+ `
2807
+ },
2808
+ {
2809
+ name: "src/inbox.ts",
2810
+ content: `/**
2811
+ * Dispatches waiting for YOUR system to collect them.
2812
+ *
2813
+ * ## What this is, and why it is not the quarantine
2814
+ *
2815
+ * Both tables hold a signature-verified dispatch as the ciphertext that
2816
+ * arrived. They mean opposite things.
2817
+ *
2818
+ * A QUARANTINED dispatch is one the receiver could not open: something is
2819
+ * wrong, usually a key, and it is purged on a window because holding a
2820
+ * consumer's encrypted address forever is a retention decision nobody made.
2821
+ *
2822
+ * An INBOX dispatch is one nothing is wrong with. It arrived intact and is
2823
+ * waiting for the partner's own systems to draw it, apply it in their database
2824
+ * and say so. **It is NEVER aged out**, and that asymmetry is deliberate: an
2825
+ * undrawn update is a consumer whose address has not landed, and deleting it on
2826
+ * a timer loses it silently. The dashboard reports a growing undrawn count as a
2827
+ * fault instead, which is the honest way to make an operator deal with it.
2828
+ *
2829
+ * ## Nothing here is readable
2830
+ *
2831
+ * The rows hold the bytes as they arrived. In inbox mode the receiver holds no
2832
+ * private key at all, so it could not decrypt these if it wanted to. That is
2833
+ * the property the whole split exists for: the component reachable from the
2834
+ * internet cannot read what it stores.
2835
+ *
2836
+ * ## The lease, and what it is NOT for
2837
+ *
2838
+ * A connector that draws a batch and then crashes must not strand it. So a draw
2839
+ * stamps \`drawn_at\`, and an item becomes drawable again once that stamp is
2840
+ * older than the lease. That is CRASH RECOVERY for one connector.
2841
+ *
2842
+ * It is not a design for two connectors drawing at once. Two would each get
2843
+ * their own view of what is drawable between leases, and nothing here stops
2844
+ * them applying the same update twice. If a second connector is ever wanted,
2845
+ * the claim has to become atomic, the way \`confirm-queue.ts\` already does it.
2846
+ * Written down because a lease LOOKS like it handles concurrency and does not.
2847
+ */
2848
+ import db from './db.js';
2849
+ import { report } from './report.js';
2850
+
2851
+ export type InboxOutcome = 'applied' | 'failed';
2852
+
2853
+ db.exec(\`
2854
+ CREATE TABLE IF NOT EXISTS inbox (
2855
+ id TEXT PRIMARY KEY,
2856
+ dispatch_id TEXT,
2857
+ event TEXT NOT NULL,
2858
+ raw_body TEXT NOT NULL,
2859
+ received_at TEXT NOT NULL DEFAULT (datetime('now')),
2860
+ drawn_at TEXT,
2861
+ applied_at TEXT,
2862
+ outcome TEXT,
2863
+ detail TEXT
2864
+ );
2865
+ CREATE INDEX IF NOT EXISTS idx_inbox_open
2866
+ ON inbox(applied_at, drawn_at, received_at);
2867
+ \`);
2868
+
2869
+ export interface AcceptInput {
2870
+ key: string;
2871
+ dispatchId: string | null;
2872
+ event: string;
2873
+ rawBody: string;
2874
+ }
2875
+
2876
+ /**
2877
+ * Take a dispatch for the connector to collect.
2878
+ *
2879
+ * \`INSERT OR IGNORE\`, keyed on the same dispatch identity everything else here
2880
+ * uses, so a OneAddress retry of a delivery we already hold does not queue the
2881
+ * same update twice.
2882
+ */
2883
+ export function accept(input: AcceptInput): void {
2884
+ db.prepare(
2885
+ \`INSERT OR IGNORE INTO inbox (id, dispatch_id, event, raw_body)
2886
+ VALUES (?, ?, ?, ?)\`,
2887
+ ).run(input.key, input.dispatchId, input.event, input.rawBody);
2888
+ }
2889
+
2890
+ export interface InboxItem {
2891
+ id: string;
2892
+ dispatch_id: string | null;
2893
+ event: string;
2894
+ raw_body: string;
2895
+ received_at: string;
2896
+ }
2897
+
2898
+ /**
2899
+ * What the connector may take now.
2900
+ *
2901
+ * Anything never drawn, plus anything drawn longer ago than the lease and still
2902
+ * unacknowledged, which is the crashed-connector case.
2903
+ */
2904
+ export function drawable(limit: number, leaseSeconds: number): InboxItem[] {
2905
+ const cutoff = new Date(Date.now() - leaseSeconds * 1000).toISOString();
2906
+ return db.prepare(
2907
+ \`SELECT id, dispatch_id, event, raw_body, received_at
2908
+ FROM inbox
2909
+ WHERE applied_at IS NULL
2910
+ AND (drawn_at IS NULL OR drawn_at < ?)
2911
+ ORDER BY received_at
2912
+ LIMIT ?\`,
2913
+ ).all(cutoff, limit) as unknown as InboxItem[];
2914
+ }
2915
+
2916
+ export function markDrawn(ids: string[]): void {
2917
+ if (ids.length === 0) return;
2918
+ const stamp = new Date().toISOString();
2919
+ const stmt = db.prepare('UPDATE inbox SET drawn_at = ? WHERE id = ?');
2920
+ for (const id of ids) stmt.run(stamp, id);
2921
+ }
2922
+
2923
+ /**
2924
+ * The connector's verdict on one item.
2925
+ *
2926
+ * Returns the dispatch id to confirm, or null when there is nothing to tell
2927
+ * OneAddress: an unknown id, or one already acknowledged. Deliberately not an
2928
+ * error, because a connector retrying an acknowledgement it is unsure landed is
2929
+ * doing the right thing and must not be punished for it.
2930
+ */
2931
+ export function acknowledge(
2932
+ id: string,
2933
+ outcome: InboxOutcome,
2934
+ detail: string | null,
2935
+ ): { dispatchId: string | null; alreadyAcknowledged: boolean } | null {
2936
+ const row = db.prepare(
2937
+ 'SELECT dispatch_id, applied_at FROM inbox WHERE id = ?',
2938
+ ).get(id) as { dispatch_id: string | null; applied_at: string | null } | undefined;
2939
+ if (!row) return null;
2940
+ if (row.applied_at !== null) {
2941
+ return { dispatchId: row.dispatch_id, alreadyAcknowledged: true };
2942
+ }
2943
+ db.prepare(
2944
+ 'UPDATE inbox SET applied_at = ?, outcome = ?, detail = ? WHERE id = ?',
2945
+ ).run(new Date().toISOString(), outcome, detail?.slice(0, 500) ?? null, id);
2946
+ report.info(\`[inbox] \${id} acknowledged by the connector: \${outcome}\`);
2947
+ return { dispatchId: row.dispatch_id, alreadyAcknowledged: false };
2948
+ }
2949
+
2950
+ /** How many updates are sitting here unapplied. Shown as a fault when non-zero. */
2951
+ export function undrawnCount(): number {
2952
+ const row = db.prepare(
2953
+ 'SELECT count(*) AS n FROM inbox WHERE applied_at IS NULL',
2954
+ ).get() as { n: number };
2955
+ return row.n;
2956
+ }
2957
+
2958
+ /** When the oldest unapplied item arrived, for the age on the dashboard. */
2959
+ export function oldestUndrawn(): string | null {
2960
+ const row = db.prepare(
2961
+ 'SELECT min(received_at) AS oldest FROM inbox WHERE applied_at IS NULL',
2962
+ ).get() as { oldest: string | null };
2963
+ return row.oldest;
2964
+ }
2965
+
2966
+ /**
2967
+ * NOTHING PURGES THIS TABLE, and the absence is the design.
2968
+ *
2969
+ * \`purgeQuarantine\` and \`purgeDelivered\` both exist one file over. There is no
2970
+ * \`purgeInbox\`, and there should not be: every row here is an address change a
2971
+ * consumer paid for that has not reached the partner's system yet. Ageing one
2972
+ * out would delete the only copy anybody still has.
2973
+ *
2974
+ * Rows that HAVE been applied are kept too, because they are the partner's own
2975
+ * record of what they were sent and when they acted on it. If that ever needs
2976
+ * bounding, bound the APPLIED rows and never the open ones.
2977
+ */
2978
+ export function appliedCount(): number {
2979
+ const row = db.prepare(
2980
+ 'SELECT count(*) AS n FROM inbox WHERE applied_at IS NOT NULL',
2981
+ ).get() as { n: number };
2982
+ return row.n;
2983
+ }
2984
+ `
2985
+ },
2986
+ {
2987
+ name: "src/tally.ts",
2988
+ content: `/**
2989
+ * How many dispatches arrived, landed, and did not.
2990
+ *
2991
+ * ## THE BUG THIS REPLACES, WHICH A PARTNER FOUND IN ABOUT A MINUTE
2992
+ *
2993
+ * The dashboard used to derive these by reading its own log feed: a line
2994
+ * matching a failure pattern counted as one arrival and one failure. The
2995
+ * comment beside it asserted the invariant that made that sound: "every
2996
+ * dispatch produces exactly one of these".
2997
+ *
2998
+ * Then replay shipped. A replay produces exactly the same lines an arrival
2999
+ * does, because it IS a delivery, back in through the front door. Somebody
3000
+ * pressed [r] sixty times against a key that was never going to work and
3001
+ * watched \`received\` climb from 5 to 67 and \`failed\` from 1 to 63. Nothing was
3002
+ * arriving. The receiver was counting its own attempts to fix itself.
3003
+ *
3004
+ * The same fault was always there for a cause nobody had to trigger by hand:
3005
+ * OneAddress RETRIES a 422, so a wrong key inflated these counters on its own,
3006
+ * quietly, every few minutes.
3007
+ *
3008
+ * ## Why counting here fixes the class rather than the instance
3009
+ *
3010
+ * Narration is the wrong source. A log line says what happened, not what it
3011
+ * happened TO, so two lines about one dispatch are indistinguishable from two
3012
+ * dispatches. This keys on the DISPATCH, so every re-delivery of it - a
3013
+ * replay, a OneAddress retry, a partner curling the same body twice - lands on
3014
+ * the entry that is already there.
3015
+ *
3016
+ * That also makes a replay that finally WORKS do the right thing on its own:
3017
+ * the entry flips from failed to applied, and the totals move without anything
3018
+ * having to know a replay was involved.
3019
+ *
3020
+ * ## What is counted, stated because the footer does not have room to
3021
+ *
3022
+ * An \`address.updated\` that was stored is \`applied\`. Anything that could not be
3023
+ * opened, or was refused, is \`failed\`. \`received\` is how many distinct
3024
+ * dispatches reached one of those two, so \`received = applied + failed\` holds
3025
+ * by construction rather than by hoping.
3026
+ *
3027
+ * An \`address.verify\` is deliberately none of them: it answers a question and
3028
+ * changes nothing, so counting it as an application would overstate what this
3029
+ * receiver has done. It still shows in the activity log.
3030
+ *
3031
+ * Since boot, like every other figure on that footer. The held count beside
3032
+ * them is not: the quarantine is on disk and survives a restart.
3033
+ */
3034
+
3035
+ export type Outcome = 'applied' | 'failed';
3036
+
3037
+ /**
3038
+ * Bounded, because this is memory and a busy receiver runs for months.
3039
+ *
3040
+ * At the cap the oldest entry goes, exactly as \`seenDispatches\` does, and the
3041
+ * totals then describe the most recent 5000 dispatches rather than all of them.
3042
+ * That is the honest trade for a footer: an operator reads it to see whether
3043
+ * things are working now, and nobody audits from it.
3044
+ */
3045
+ const MAX_TRACKED = 5000;
3046
+ const outcomes = new Map<string, Outcome>();
3047
+
3048
+ export function recordOutcome(dispatchKey: string, outcome: Outcome): void {
3049
+ if (!outcomes.has(dispatchKey) && outcomes.size >= MAX_TRACKED) {
3050
+ const oldest = outcomes.keys().next().value;
3051
+ if (oldest !== undefined) outcomes.delete(oldest);
3052
+ }
3053
+ // \`set\` on an existing key overwrites in place and keeps its insertion order,
3054
+ // which is what makes a replay that succeeds flip failed to applied rather
3055
+ // than adding a second entry.
3056
+ outcomes.set(dispatchKey, outcome);
3057
+ }
3058
+
3059
+ export interface Tally {
3060
+ received: number;
3061
+ applied: number;
3062
+ failed: number;
3063
+ }
3064
+
3065
+ export function tally(): Tally {
3066
+ let applied = 0;
3067
+ for (const outcome of outcomes.values()) if (outcome === 'applied') applied += 1;
3068
+ return { received: outcomes.size, applied, failed: outcomes.size - applied };
3069
+ }
3070
+
3071
+ /** Test seam. Never called by the receiver. */
3072
+ export function resetTally(): void {
3073
+ outcomes.clear();
3074
+ }
2289
3075
  `
2290
3076
  },
2291
3077
  {
@@ -2332,9 +3118,11 @@ main().catch((err) => {
2332
3118
  * replay would apply an address for an account they do not recognise. It stays
2333
3119
  * a refusal.
2334
3120
  */
2335
- import db from './db.js';
3121
+ import db, { ensureColumn } from './db.js';
2336
3122
  import { report } from './report.js';
2337
3123
  import { createHash } from 'node:crypto';
3124
+ import { writeFileSync } from 'node:fs';
3125
+ import { join } from 'node:path';
2338
3126
 
2339
3127
  /**
2340
3128
  * Why a dispatch could not be applied. Shown verbatim on the dashboard.
@@ -2376,11 +3164,22 @@ db.exec(\`
2376
3164
  * the dispatch header where there is one, and on a hash of the body where there
2377
3165
  * is not, so a retry updates the existing row instead of adding to a pile.
2378
3166
  */
2379
- function rowId(dispatchId: string | null, rawBody: string): string {
3167
+ export function dispatchKey(dispatchId: string | null, rawBody: string): string {
2380
3168
  if (dispatchId && dispatchId.trim()) return \`d:\${dispatchId.trim()}\`;
2381
3169
  return \`h:\${createHash('sha256').update(rawBody).digest('hex').slice(0, 32)}\`;
2382
3170
  }
2383
3171
 
3172
+ /**
3173
+ * Attempts, so the panel can say how hard this has been tried.
3174
+ *
3175
+ * Added after the quarantine shipped, hence the migration: a partner already
3176
+ * running 2.1.3 has the table without it. Somebody pressed [r] sixty times
3177
+ * against a key that could never work, and the only figure that moved was one
3178
+ * the dashboard was computing wrongly. An attempt count on the row would have
3179
+ * said so immediately.
3180
+ */
3181
+ ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
3182
+
2384
3183
  export interface QuarantineInput {
2385
3184
  dispatchId: string | null;
2386
3185
  event: string;
@@ -2398,7 +3197,7 @@ export interface QuarantineInput {
2398
3197
  * caller must be able to answer OneAddress whatever happens here.
2399
3198
  */
2400
3199
  export function quarantine(input: QuarantineInput): void {
2401
- const id = rowId(input.dispatchId, input.rawBody);
3200
+ const id = dispatchKey(input.dispatchId, input.rawBody);
2402
3201
  try {
2403
3202
  db.prepare(
2404
3203
  \`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
@@ -2435,12 +3234,14 @@ export interface HeldDispatch {
2435
3234
  detail: string | null;
2436
3235
  received_at: string;
2437
3236
  last_error: string | null;
3237
+ attempts: number;
2438
3238
  }
2439
3239
 
2440
3240
  /** Everything still held, oldest first. */
2441
3241
  export function heldDispatches(limit = 50): HeldDispatch[] {
2442
3242
  return db.prepare(
2443
- \`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail, received_at, last_error
3243
+ \`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
3244
+ received_at, last_error, attempts
2444
3245
  FROM quarantine
2445
3246
  WHERE replayed_at IS NULL
2446
3247
  ORDER BY received_at
@@ -2465,15 +3266,36 @@ export function heldCount(): number {
2465
3266
  */
2466
3267
  export function heldSummary(): string[] {
2467
3268
  const rows = db.prepare(
2468
- \`SELECT reason, key_id, count(*) AS n
3269
+ \`SELECT reason, key_id, count(*) AS n,
3270
+ max(attempts) AS tries, min(received_at) AS oldest
2469
3271
  FROM quarantine
2470
3272
  WHERE replayed_at IS NULL
2471
3273
  GROUP BY reason, key_id
2472
3274
  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
- );
3275
+ ).all() as unknown as {
3276
+ reason: string; key_id: string | null; n: number; tries: number; oldest: string;
3277
+ }[];
3278
+ return rows.map((r) => {
3279
+ const cause = \`\${r.n} \xD7 \${r.reason}\${r.key_id ? \` (key_id \${r.key_id})\` : ''}\`;
3280
+ // TRIED AND AGE, because the count alone does not say whether anything is
3281
+ // being done about it. A partner watching "1 \xD7 decrypt_failed" with no
3282
+ // other figure moving cannot tell a replay that is working from one that
3283
+ // is not; "61 tries" answers that without reading the log.
3284
+ const tried = r.tries > 0 ? \`, \${r.tries} \${r.tries === 1 ? 'try' : 'tries'}\` : '';
3285
+ return \`\${cause}\${tried}, first seen \${describeAge(r.oldest)}\`;
3286
+ });
3287
+ }
3288
+
3289
+ /** "3m ago", "2h ago". Coarse on purpose: nobody acts on seconds. */
3290
+ export function describeAge(iso: string): string {
3291
+ const ms = Date.now() - new Date(iso).getTime();
3292
+ if (!Number.isFinite(ms) || ms < 0) return 'just now';
3293
+ const mins = Math.floor(ms / 60_000);
3294
+ if (mins < 1) return 'moments ago';
3295
+ if (mins < 60) return \`\${mins}m ago\`;
3296
+ const hours = Math.floor(mins / 60);
3297
+ if (hours < 48) return \`\${hours}h ago\`;
3298
+ return \`\${Math.floor(hours / 24)}d ago\`;
2477
3299
  }
2478
3300
 
2479
3301
  export function markReplayed(id: string): void {
@@ -2484,7 +3306,7 @@ export function markReplayed(id: string): void {
2484
3306
 
2485
3307
  /** A replay that failed the same way stays held, with the new reason recorded. */
2486
3308
  export function markReplayFailed(id: string, error: string): void {
2487
- db.prepare('UPDATE quarantine SET last_error = ? WHERE id = ?')
3309
+ db.prepare('UPDATE quarantine SET last_error = ?, attempts = attempts + 1 WHERE id = ?')
2488
3310
  .run(error.slice(0, 500), id);
2489
3311
  }
2490
3312
 
@@ -2524,6 +3346,48 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
2524
3346
  return { replayed, unreplayed };
2525
3347
  }
2526
3348
 
3349
+ /**
3350
+ * Write the held list to a file, WITHOUT the payloads.
3351
+ *
3352
+ * ## Why the ciphertext is not in here
3353
+ *
3354
+ * The obvious export carries the payload so the fault can be replayed
3355
+ * somewhere else. It also puts a copy of a consumer's encrypted address in a
3356
+ * file that the retention window cannot reach: the quarantine purges its rows,
3357
+ * and nothing purges an export somebody emailed to support and left in a
3358
+ * downloads folder. The whole point of the retention rule is that a held
3359
+ * address does not sit anywhere indefinitely, and an export that leaks past it
3360
+ * quietly undoes that.
3361
+ *
3362
+ * Nothing in here is personal. A dispatch id, what kind of event it was, why it
3363
+ * could not be opened, which key it asked for, and when. That is the whole of
3364
+ * what diagnoses a key problem, and it is safe to paste into a support ticket
3365
+ * or send to us, which is what an export is for.
3366
+ */
3367
+ export function exportHeld(directory: string): { path: string; count: number } {
3368
+ const rows = heldDispatches(500);
3369
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3370
+ const path = join(directory, \`oneaddress-faults-\${stamp}.json\`);
3371
+ writeFileSync(path, JSON.stringify({
3372
+ exported_at: new Date().toISOString(),
3373
+ note: 'Metadata only. The held payloads are deliberately not included: they are encrypted consumer addresses and stay under the receiver retention window.',
3374
+ held: rows.map((r) => ({
3375
+ id: r.id,
3376
+ dispatch_id: r.dispatch_id,
3377
+ event: r.event,
3378
+ reason: r.reason,
3379
+ key_id: r.key_id,
3380
+ received_at: r.received_at,
3381
+ age: describeAge(r.received_at),
3382
+ attempts: r.attempts,
3383
+ detail: r.detail,
3384
+ last_error: r.last_error,
3385
+ })),
3386
+ }, null, 2));
3387
+ report.info(\`[quarantine] exported \${rows.length} held dispatch(es) to \${path}\`);
3388
+ return { path, count: rows.length };
3389
+ }
3390
+
2527
3391
  /**
2528
3392
  * Re-run every held dispatch through the handler.
2529
3393
  *
@@ -2533,14 +3397,25 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
2533
3397
  * on the second call.
2534
3398
  */
2535
3399
  export async function replayHeld(
2536
- apply: (rawBody: string) => Promise<void>,
3400
+ apply: (rawBody: string, dispatchId: string | null) => Promise<void>,
2537
3401
  limit = 50,
2538
3402
  ): Promise<{ applied: number; failed: number }> {
2539
3403
  let applied = 0;
2540
3404
  let failed = 0;
2541
3405
  for (const row of heldDispatches(limit)) {
2542
3406
  try {
2543
- await apply(row.raw_body);
3407
+ // THE ROW'S OWN DISPATCH ID, not one re-derived from the body.
3408
+ //
3409
+ // \`redeliver\` used to parse \`dispatch_id\` out of the JSON and send that
3410
+ // as the header. A dispatch that arrived WITHOUT the header was keyed on
3411
+ // a hash of its body; the replay then supplied a header from the body,
3412
+ // so the re-arrival was keyed by id instead, landed in a DIFFERENT row,
3413
+ // and one held dispatch became two. Seen on a real receiver: \`1 \xD7
3414
+ // decrypt_failed\` became \`2 \xD7\` on the first press of [r].
3415
+ //
3416
+ // Identity has to come from one place. This row already knows what it
3417
+ // arrived as.
3418
+ await apply(row.raw_body, row.dispatch_id);
2544
3419
  markReplayed(row.id);
2545
3420
  applied += 1;
2546
3421
  report.info(\`[replay] applied \${row.event} \${row.dispatch_id ?? row.id}\`);
@@ -2881,10 +3756,31 @@ import { report } from './report.js';
2881
3756
  import { readFileSync } from 'node:fs';
2882
3757
  import { join } from 'node:path';
2883
3758
 
3759
+ /**
3760
+ * WRITE-THROUGH or INBOX, and both are supported on purpose.
3761
+ *
3762
+ * \`write-through\` is what this receiver has always done: decrypt the dispatch,
3763
+ * apply it through \`CustomerStore\`, confirm to OneAddress. Right for a sole
3764
+ * trader and for anyone happy for the receiver to reach their data directly.
3765
+ *
3766
+ * \`inbox\` is for a company with change control over its customer master. The
3767
+ * receiver verifies the signature, stores the dispatch AS IT ARRIVED, and holds
3768
+ * no private key at all. The partner's own connector draws it, decrypts it,
3769
+ * applies it in their database, and acknowledges; only then does the receiver
3770
+ * confirm to OneAddress. The component reachable from the internet cannot read
3771
+ * what it holds.
3772
+ *
3773
+ * Nothing on the wire differs between them. The protocol already separates
3774
+ * delivery from application: the webhook 200 acknowledges delivery and the
3775
+ * confirm reports application. Write-through simply collapses the two.
3776
+ */
3777
+ export type ReceiverMode = 'write-through' | 'inbox';
3778
+
2884
3779
  export type ReceiverConfig = {
2885
3780
  partnerId: string;
2886
3781
  oneAddressApi: string;
2887
3782
  verifiesAccountReference: boolean;
3783
+ mode: ReceiverMode;
2888
3784
  };
2889
3785
 
2890
3786
  const DEFAULTS: ReceiverConfig = {
@@ -2895,8 +3791,16 @@ const DEFAULTS: ReceiverConfig = {
2895
3791
  // is missing entirely \u2014 in which case answering account.verify is the safer,
2896
3792
  // more useful default than silently returning "not checked".
2897
3793
  verifiesAccountReference: true,
3794
+ // DEFAULTS TO WHAT THE RECEIVER HAS ALWAYS DONE. Inbox mode needs a connector
3795
+ // running and a key living somewhere else; a receiver that silently switched
3796
+ // into it would accept dispatches nothing ever collects.
3797
+ mode: 'write-through',
2898
3798
  };
2899
3799
 
3800
+ function parseMode(value: unknown): ReceiverMode | undefined {
3801
+ return value === 'inbox' || value === 'write-through' ? value : undefined;
3802
+ }
3803
+
2900
3804
  function stripTrailingSlash(s: string): string {
2901
3805
  return s.endsWith('/') ? s.slice(0, -1) : s;
2902
3806
  }
@@ -2909,6 +3813,8 @@ function loadConfigFile(): Partial<ReceiverConfig> {
2909
3813
  if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
2910
3814
  if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
2911
3815
  if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
3816
+ const mode = parseMode(parsed.mode);
3817
+ if (mode) out.mode = mode;
2912
3818
  return out;
2913
3819
  } catch {
2914
3820
  // No config file (or unreadable / malformed): fall back to env + defaults.
@@ -2925,10 +3831,16 @@ export const config: ReceiverConfig = {
2925
3831
  process.env.VERIFIES_ACCOUNT_REFERENCE != null
2926
3832
  ? process.env.VERIFIES_ACCOUNT_REFERENCE === 'true'
2927
3833
  : (fromFile.verifiesAccountReference ?? DEFAULTS.verifiesAccountReference),
3834
+ // An UNRECOGNISED value falls back to write-through rather than failing, and
3835
+ // the startup line below says which mode is live either way, so a typo shows
3836
+ // up as "not the mode I asked for" rather than as a receiver that will not
3837
+ // start. Silent is the thing to avoid, not strict.
3838
+ mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
2928
3839
  };
2929
3840
 
2930
3841
  report.info(
2931
- '[config] loaded (oneAddressApi=' + config.oneAddressApi +
3842
+ '[config] loaded (mode=' + config.mode +
3843
+ ', oneAddressApi=' + config.oneAddressApi +
2932
3844
  ', verifiesAccountReference=' + config.verifiesAccountReference + ')',
2933
3845
  );
2934
3846
  `
@@ -3184,7 +4096,7 @@ export interface CustomerStore {
3184
4096
  import { readFileSync } from 'node:fs';
3185
4097
  import { join } from 'node:path';
3186
4098
  import { report } from './report.js';
3187
- import db, { accountKey, dec, enc, encrypted, isEncrypted, once } from './db.js';
4099
+ import db, { accountKey, dec, enc, encrypted, ensureColumn, isEncrypted, once } from './db.js';
3188
4100
  import type {
3189
4101
  AccountVerdict,
3190
4102
  Address,
@@ -3234,13 +4146,6 @@ db.exec(\`
3234
4146
  // Add any missing columns here so the handler upgrades its own schema instead of
3235
4147
  // forcing you to delete the database on every change \u2014 the behaviour a
3236
4148
  // production integration needs.
3237
- function ensureColumn(table: string, column: string, definition: string): void {
3238
- const cols = db.prepare(\`PRAGMA table_info(\${table})\`).all() as Array<{ name: string }>;
3239
- if (!cols.some((c) => c.name === column)) {
3240
- db.exec(\`ALTER TABLE \${table} ADD COLUMN \${column} \${definition}\`);
3241
- report.info(\`[store] migrated: added column \${table}.\${column}\`);
3242
- }
3243
- }
3244
4149
  ensureColumn('customers', 'address', "TEXT NOT NULL DEFAULT '{}'");
3245
4150
  ensureColumn('customers', 'updated_at', 'TEXT'); // nullable on migrate; set on write
3246
4151
  ensureColumn('customers', 'account_key', 'TEXT');
@@ -3631,11 +4536,12 @@ import {
3631
4536
  // THE ONLY LINE THAT NAMES AN IMPLEMENTATION. Point this at your own module
3632
4537
  // exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
3633
4538
  // the protocol layer changes.
3634
- import { store } from './store.js';
4539
+ import { store as writeThroughStore } from './store.js';
4540
+ import { connectorStore, setCurrentRawBody } from './connector-store.js';
3635
4541
  import { notePreviousAddress } from './tui.js';
3636
4542
  import { config } from './config.js';
3637
4543
  import { safeOneAddressCallbackUrl } from './callback-url.js';
3638
- import { describeKeys, keyFailureAdvice, resolvePrivateKey } from './keys.js';
4544
+ import { configuredKeyIds, describeKeys, keyFailureAdvice, resolvePrivateKey } from './keys.js';
3639
4545
  import {
3640
4546
  drainConfirms,
3641
4547
  enqueueConfirm,
@@ -3644,12 +4550,27 @@ import {
3644
4550
  type ConfirmStatus,
3645
4551
  } from './confirm-queue.js';
3646
4552
  import {
4553
+ dispatchKey,
3647
4554
  heldCount,
3648
4555
  purgeQuarantine,
3649
4556
  quarantine,
3650
4557
  replayHeld,
3651
4558
  type QuarantineReason,
3652
4559
  } from './quarantine.js';
4560
+ import { recordOutcome, tally } from './tally.js';
4561
+ import { accept as acceptIntoInbox, oldestUndrawn, undrawnCount } from './inbox.js';
4562
+ import { setAcknowledgementHandler, startDrawApi } from './draw-api.js';
4563
+
4564
+ /**
4565
+ * WHICH STORE ANSWERS, chosen once at startup.
4566
+ *
4567
+ * Inbox mode does not branch the handler. It swaps the implementation of the
4568
+ * contract in \`customer-store.ts\`, so every call site below is identical in
4569
+ * both modes and the mode lives in exactly one place. \`connectorStore\` asks the
4570
+ * partner's connector and refuses to write; \`writeThroughStore\` is the bundled
4571
+ * SQLite one this receiver has always used.
4572
+ */
4573
+ const store = config.mode === 'inbox' ? connectorStore : writeThroughStore;
3653
4574
 
3654
4575
  const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? '';
3655
4576
  const PARTNER_PRIVATE_KEY = process.env.PARTNER_PRIVATE_KEY_PEM ?? '';
@@ -3669,11 +4590,38 @@ const PORT = Number(process.env.PORT ?? 3001);
3669
4590
  const ONEADDRESS_API = config.oneAddressApi;
3670
4591
  const CONFIRM_SECRET = process.env.CONFIRM_SECRET || WEBHOOK_SECRET;
3671
4592
 
3672
- if (!WEBHOOK_SECRET || !PARTNER_PRIVATE_KEY || !PARTNER_ID) {
4593
+ if (!WEBHOOK_SECRET || !PARTNER_ID) {
3673
4594
  report.error('[startup] Missing required env vars. Check your .env file.');
3674
4595
  process.exit(1);
3675
4596
  }
3676
4597
 
4598
+ // THE KEY REQUIREMENT INVERTS WITH THE MODE, and this is the assertion that
4599
+ // makes inbox mode mean something.
4600
+ //
4601
+ // Write-through cannot open a single dispatch without a private key, so a
4602
+ // missing one is fatal and always was. Inbox mode's entire property is that the
4603
+ // component reachable from the internet CANNOT read what it holds, and a key
4604
+ // sitting in this process's environment makes that false \u2014 silently, while
4605
+ // every test still passes and every dispatch still lands. Nothing would ever
4606
+ // surface it, because in inbox mode nothing here attempts a decrypt to fail.
4607
+ //
4608
+ // So it refuses to start and names the fix. A partner moving from write-through
4609
+ // to inbox has one edit to make, and being told about it once beats believing a
4610
+ // property they do not have.
4611
+ if (config.mode === 'inbox') {
4612
+ const strayKeys = configuredKeyIds();
4613
+ if (PARTNER_PRIVATE_KEY || strayKeys.length > 0) {
4614
+ report.error('[startup] mode is \`inbox\`, but this receiver has a private key in its environment.');
4615
+ report.error('[startup] Inbox mode exists so the internet-facing process CANNOT read what it stores.');
4616
+ report.error('[startup] Move PARTNER_PRIVATE_KEY_PEM' + (strayKeys.length > 0 ? ' (and the per-key-id variables)' : '') + ' to the connector\\'s .env and remove it here.');
4617
+ process.exit(1);
4618
+ }
4619
+ } else if (!PARTNER_PRIVATE_KEY) {
4620
+ report.error('[startup] PARTNER_PRIVATE_KEY_PEM is missing. Check your .env file.');
4621
+ report.error('[startup] (A receiver that should hold no key at all wants RECEIVER_MODE=inbox.)');
4622
+ process.exit(1);
4623
+ }
4624
+
3677
4625
  // Startup self-check. Two faults were historically INVISIBLE until a live
3678
4626
  // dispatch, and both read afterwards as "partner key mismatch" deep inside a
3679
4627
  // try/catch, which is a day of reading webhook logs. Assert them here so the
@@ -3703,7 +4651,15 @@ if (PARTNER_PRIVATE_KEY.includes('BEGIN')) {
3703
4651
  // variable is set when it is not, and the second commonest is still holding one
3704
4652
  // key after a rotation. Both are visible in this one line, and neither is
3705
4653
  // visible anywhere else until a dispatch fails.
3706
- report.info(\`[startup] keys: \${describeKeys()}\`);
4654
+ // In inbox mode "no key" is the CORRECT state, and \`describeKeys\` would report
4655
+ // it as "every dispatch will fail to decrypt", which is true of a write-through
4656
+ // receiver and alarming nonsense here. An operator who reads a red line every
4657
+ // boot stops reading the line.
4658
+ report.info(
4659
+ config.mode === 'inbox'
4660
+ ? '[startup] keys: none, by design \u2014 the connector holds them and this process cannot read a dispatch'
4661
+ : \`[startup] keys: \${describeKeys()}\`,
4662
+ );
3707
4663
  // WHICH STORE IS LIVE, said out loud at every boot. A receiver pointed at a
3708
4664
  // partner's own database and one still writing to the bundled demo file behave
3709
4665
  // identically until the first dispatch, and the difference is where a customer's
@@ -3880,6 +4836,21 @@ app.post('/webhook', async (req: Request, res: Response) => {
3880
4836
 
3881
4837
  const event = body.event as string;
3882
4838
 
4839
+ // PARKED FOR THE CONNECTOR STORE, and read in the same tick it is set.
4840
+ // In inbox mode the receiver holds no private key, so the only thing it can
4841
+ // hand the connector for a verify is the ciphertext that arrived. The
4842
+ // CustomerStore contract passes a decoded customer, which this process cannot
4843
+ // produce, so the bytes travel out of band rather than widening the contract
4844
+ // for one implementation. See connector-store.ts for why this is safe and
4845
+ // what would make it unsafe.
4846
+ //
4847
+ // ABOVE EVERY HANDLER THAT READS IT, and that position is the fix rather than
4848
+ // a tidy-up: this sat below \`account.verify\`, which returns long before it, so
4849
+ // in inbox mode the connector was handed the PREVIOUS request's ciphertext, or
4850
+ // an empty string on the first request of the process's life. It would have
4851
+ // answered honestly about the wrong consumer.
4852
+ setCurrentRawBody(rawBody);
4853
+
3883
4854
  // \u2500\u2500 account.verify: pre-payment account check \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3884
4855
  // Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
3885
4856
  // no address, no session envelope \u2014 so it is handled HERE, above the address-
@@ -3894,6 +4865,20 @@ app.post('/webhook', async (req: Request, res: Response) => {
3894
4865
  return res.status(200).json({ ok: true, skipped: true });
3895
4866
  }
3896
4867
 
4868
+ // \u2500\u2500 INBOX MODE: the receiver cannot open this, and must not try \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4869
+ //
4870
+ // The decrypt below is the reason this branch exists. In inbox mode there
4871
+ // is no private key in this process, so \`decryptAddress\` would fail and
4872
+ // answer 422 \u2014 and the consumer, who is mid-payment, would be told the
4873
+ // account check failed when nothing is wrong. The connector holds the key
4874
+ // and the customer records, so it answers. \`connectorStore\` sends the raw
4875
+ // body; the arguments below are the contract's shape, not its source.
4876
+ if (config.mode === 'inbox') {
4877
+ const status = await store.verifyAccount(null, '', []);
4878
+ report.info(\`[webhook] account.verify \u2192 \${status} (answered by the connector)\`);
4879
+ return res.status(200).json({ status });
4880
+ }
4881
+
3897
4882
  const enc = body.customer_encrypted as {
3898
4883
  ephemeralPublicKey: string; iv: string; ciphertext: string; hkdfSalt?: string;
3899
4884
  } | undefined;
@@ -3936,8 +4921,113 @@ app.post('/webhook', async (req: Request, res: Response) => {
3936
4921
  * stops the quarantine being a way for anyone who can reach this port to fill
3937
4922
  * a partner's disk.
3938
4923
  */
3939
- const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void =>
4924
+ /**
4925
+ * What this dispatch IS, for anything that has to recognise it again.
4926
+ *
4927
+ * The same value the quarantine keys on, so a re-delivery - a OneAddress
4928
+ * retry of a 422, a press of [r], the same body posted twice - lands on the
4929
+ * record that is already there instead of looking like a new arrival.
4930
+ */
4931
+ const key = dispatchKey(dispatch || null, rawBody);
4932
+
4933
+ const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void => {
3940
4934
  quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
4935
+ recordOutcome(key, 'failed');
4936
+ };
4937
+
4938
+ /**
4939
+ * Post an \`address.verify\` verdict back to OneAddress.
4940
+ *
4941
+ * ONE IMPLEMENTATION, TWO CALLERS, and the second one is why it is a function.
4942
+ * Write-through decrypts the envelope and asks its own store; inbox mode
4943
+ * cannot decrypt anything and asks the connector. Everything from the verdict
4944
+ * onward is identical, including the callback-host check, and a second copy of
4945
+ * that check is a second place for it to be dropped.
4946
+ *
4947
+ * Returns a Response when it REFUSED to post and has already answered the
4948
+ * caller, and null when the verdict went out. Deliberately does NOT remember
4949
+ * the dispatch: the two callers differ on that, and burying the difference in
4950
+ * here is how one of them would get it silently wrong.
4951
+ */
4952
+ const postVerifyVerdict = async (result: string): Promise<Response | null> => {
4953
+ const callbackUrl = body.callback_url as string;
4954
+ const callbackToken = body.callback_token as string;
4955
+ const batchId = body.batch_id as string;
4956
+
4957
+ // Callback URL is signed inside the body, so HMAC verify already proves it
4958
+ // came from OneAddress. We still validate the host as defence in depth:
4959
+ // if the webhook secret ever leaks, an attacker who can forge a webhook
4960
+ // could otherwise coerce this server into POSTing to any internal
4961
+ // URL (database admin, cloud metadata service, \u2026) \u2014 turning the partner's
4962
+ // network position into an SSRF primitive.
4963
+ const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
4964
+ if (!safeCallbackUrl) {
4965
+ report.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
4966
+ return res.status(400).json({ error: 'Invalid callback_url host' });
4967
+ }
4968
+
4969
+ await fetch(safeCallbackUrl, {
4970
+ method: 'POST',
4971
+ headers: { 'Content-Type': 'application/json' },
4972
+ body: JSON.stringify({
4973
+ // 2026.2 \u2014 no member_name echo; OneAddress keys the result on
4974
+ // (batch_id, partner_id) and validates the opaque token alone.
4975
+ batch_id: batchId,
4976
+ partner_id: PARTNER_ID,
4977
+ result,
4978
+ token: callbackToken,
4979
+ }),
4980
+ });
4981
+ return null;
4982
+ };
4983
+
4984
+ // \u2500\u2500 INBOX MODE: hold it, do not open it \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4985
+ //
4986
+ // An \`address.updated\` is stored exactly as it arrived and the connector is
4987
+ // left to collect it. The 200 below is honest and is not a claim that anything
4988
+ // was applied: the protocol already separates the two, and the CONFIRM is what
4989
+ // reports application. It fires when the connector says so, which may be
4990
+ // hours later, and \`user_services.state\` has carried \`awaiting_confirm\` for
4991
+ // that gap since long before this mode existed.
4992
+ //
4993
+ // The verify events deliberately fall through to the code below, because they
4994
+ // must be answered NOW, before a consumer pays. In inbox mode that answer
4995
+ // comes from the connector.
4996
+ // \u2500\u2500 INBOX MODE, the other half: a verify the receiver cannot open \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4997
+ //
4998
+ // \`address.verify\` has to be answered now, like \`account.verify\` above, but it
4999
+ // is answered by POSTing a callback rather than in the response body. The
5000
+ // callback URL and token travel in the CLEARTEXT body, not the envelope, so
5001
+ // this process can still post it; only the VERDICT needs a key, and that comes
5002
+ // from the connector.
5003
+ if (config.mode === 'inbox' && event === 'address.verify') {
5004
+ // The contract's arguments, not its source: \`connectorStore\` sends the raw
5005
+ // body it was parked with and ignores these.
5006
+ const result = await store.verifyAddress({ email: null, name: '' }, {});
5007
+ report.info(\`[webhook] address.verify \u2192 \${result} (answered by the connector)\`);
5008
+ const refused = await postVerifyVerdict(result);
5009
+ if (refused) return refused;
5010
+ // DELIBERATELY NOT \`rememberDispatch\`, for the same reason as the inbox
5011
+ // \`address.updated\` branch below: nothing was decrypted here, so there is
5012
+ // no state a retry would duplicate. OneAddress keys a verify result on
5013
+ // (batch_id, partner_id), so re-answering a retried check is a no-op \u2014 and
5014
+ // NOT remembering is the safer half of the trade, because a check we failed
5015
+ // to answer still gets answered on the retry instead of being dismissed.
5016
+ return res.status(200).json({ ok: true });
5017
+ }
5018
+
5019
+ if (config.mode === 'inbox' && event === 'address.updated') {
5020
+ acceptIntoInbox({ key, dispatchId: dispatch || null, event, rawBody });
5021
+ report.info(\`[inbox] held \${dispatch || key} for the connector\`);
5022
+ // DELIBERATELY NOT \`rememberDispatch\`. A guard in templates.test.ts asserts
5023
+ // that nothing is remembered before the decrypt is attempted, because a
5024
+ // dispatch marked seen too early makes OneAddress's retry look like a
5025
+ // duplicate and loses the update. Adding an exception here for a branch
5026
+ // that happens not to decrypt would weaken the guard for the branch that
5027
+ // does. It is not needed anyway: \`accept\` is INSERT OR IGNORE on the same
5028
+ // dispatch identity, so a redelivery is already harmless.
5029
+ return res.status(200).json({ ok: true, queued: true });
5030
+ }
3941
5031
 
3942
5032
  const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
3943
5033
  if (!DISPATCH_EVENTS.includes(event)) {
@@ -4016,6 +5106,9 @@ app.post('/webhook', async (req: Request, res: Response) => {
4016
5106
  return res.status(422).json({ ok: false, error: 'Decryption failed \u2014 partner key mismatch' });
4017
5107
  }
4018
5108
  } else {
5109
+ // No \`hold\` here: there is no payload to hold, so there is nothing a fix
5110
+ // could later apply. It still counts as a dispatch that did not land.
5111
+ recordOutcome(key, 'failed');
4019
5112
  return res.status(422).json({ ok: false, error: 'No encrypted payload on dispatch' });
4020
5113
  }
4021
5114
 
@@ -4030,11 +5123,23 @@ app.post('/webhook', async (req: Request, res: Response) => {
4030
5123
  const loaEncrypted = body.loa_encrypted as
4031
5124
  { session_envelope: string; session_key_share: SessionKeyShare } | null | undefined;
4032
5125
  if (loaEncrypted && typeof loaEncrypted === 'object') {
4033
- try {
4034
- const loa: OneAddressD5LOA = await decryptLoaEncrypted(loaEncrypted, PARTNER_PRIVATE_KEY, PARTNER_ID);
4035
- loaRef = d5LoaRef(loa);
4036
- } catch (err) {
4037
- report.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
5126
+ // RESOLVED BY THE LOA'S OWN key_id, not by the single PARTNER_PRIVATE_KEY_PEM.
5127
+ // It is wrapped to the same key id as the address envelope, but it carries
5128
+ // its own share, so that is what is read. Using the single key meant that
5129
+ // after a rotation with PARTNER_KEYS_STRICT=1 the address opened and the LOA
5130
+ // silently did not, losing the proof-of-consent on exactly the changes a
5131
+ // partner most wants it for, with nothing anyone would see.
5132
+ const loaKeyId = loaEncrypted.session_key_share?.key_id ?? null;
5133
+ const loaKey = resolvePrivateKey(loaKeyId);
5134
+ if (!loaKey) {
5135
+ report.warn(\`[webhook] no key for the LOA's key_id \${loaKeyId ?? '(none)'} \u2014 applying without a consent reference\`);
5136
+ } else {
5137
+ try {
5138
+ const loa: OneAddressD5LOA = await decryptLoaEncrypted(loaEncrypted, loaKey.pem, PARTNER_ID);
5139
+ loaRef = d5LoaRef(loa);
5140
+ } catch (err) {
5141
+ report.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
5142
+ }
4038
5143
  }
4039
5144
  }
4040
5145
 
@@ -4084,6 +5189,9 @@ app.post('/webhook', async (req: Request, res: Response) => {
4084
5189
  if (verdict !== 'match') {
4085
5190
  report.warn(\`[webhook] address.updated REFUSED (\${verdict}) for account \${ctx.accountNumber ?? '(none)'} \u2014 nothing applied\`);
4086
5191
  queueConfirm(dispatch, 'failed');
5192
+ // A refusal is not a fault and is never held, but it IS a dispatch that
5193
+ // did not land, so it counts. See quarantine.ts for why the two differ.
5194
+ recordOutcome(key, 'failed');
4087
5195
  return res.status(200).json({ ok: false, error: 'account_not_matched', verdict });
4088
5196
  }
4089
5197
  }
@@ -4100,43 +5208,17 @@ app.post('/webhook', async (req: Request, res: Response) => {
4100
5208
  // acks the delivery, and it survives a restart. The drain loop does the
4101
5209
  // network part and retries it until OneAddress answers.
4102
5210
  queueConfirm(dispatch, 'confirmed');
5211
+ recordOutcome(key, 'applied');
4103
5212
  return res.status(200).json({ ok: true });
4104
5213
  }
4105
5214
 
4106
5215
  // \u2500\u2500 address.verify: consumer is running an address check \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4107
5216
  if (event === 'address.verify') {
4108
- const callbackUrl = body.callback_url as string;
4109
- const callbackToken = body.callback_token as string;
4110
- const batchId = body.batch_id as string;
4111
-
4112
- // Callback URL is signed inside the body, so HMAC verify already proves it
4113
- // came from OneAddress. We still validate the host as defence in depth:
4114
- // if the webhook secret ever leaks, an attacker who can forge a webhook
4115
- // could otherwise coerce this server into POSTing to any internal
4116
- // URL (database admin, cloud metadata service, \u2026) \u2014 turning the partner's
4117
- // network position into an SSRF primitive.
4118
- const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
4119
- if (!safeCallbackUrl) {
4120
- report.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
4121
- return res.status(400).json({ error: 'Invalid callback_url host' });
4122
- }
4123
-
4124
5217
  report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
4125
5218
  const result = await store.verifyAddress(ctx, address);
4126
5219
  report.info(\`[webhook] address.verify \u2192 \${result}\`);
4127
-
4128
- await fetch(safeCallbackUrl, {
4129
- method: 'POST',
4130
- headers: { 'Content-Type': 'application/json' },
4131
- body: JSON.stringify({
4132
- // 2026.2 \u2014 no member_name echo; OneAddress keys the result on
4133
- // (batch_id, partner_id) and validates the opaque token alone.
4134
- batch_id: batchId,
4135
- partner_id: PARTNER_ID,
4136
- result,
4137
- token: callbackToken,
4138
- }),
4139
- });
5220
+ const refused = await postVerifyVerdict(result);
5221
+ if (refused) return refused;
4140
5222
  if (dispatch) rememberDispatch(dispatch); // remember only after the callback posted
4141
5223
  return res.status(200).json({ ok: true });
4142
5224
  }
@@ -4236,13 +5318,12 @@ setInterval(() => {
4236
5318
  * anyone has fixed anything. We hold the secret, so re-signing is not a bypass:
4237
5319
  * it is the same proof, re-stated now.
4238
5320
  */
4239
- async function redeliver(rawBody: string): Promise<void> {
5321
+ async function redeliver(rawBody: string, heldDispatchId: string | null): Promise<void> {
4240
5322
  const ts = String(Math.floor(Date.now() / 1000));
4241
5323
  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 */ }
5324
+ // THE ID THE DISPATCH ARRIVED WITH, handed over by the row being replayed.
5325
+ // Re-deriving it from the body is what made one held dispatch become two.
5326
+ const dispatchId = (heldDispatchId ?? '').trim();
4246
5327
 
4247
5328
  const res = await fetch(\`http://127.0.0.1:\${PORT}/webhook\`, {
4248
5329
  method: 'POST',
@@ -4277,6 +5358,29 @@ export async function replayQuarantined(): Promise<{ applied: number; failed: nu
4277
5358
  * moment they are least inclined to. The delay lets \`listen\` settle, since this
4278
5359
  * goes back in through the port.
4279
5360
  */
5361
+ // \u2500\u2500 The connector channel, in inbox mode only \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5362
+ //
5363
+ // Wired here rather than in draw-api.ts because this file owns the confirm
5364
+ // queue: an acknowledgement from the connector is the moment OneAddress gets
5365
+ // told, and that has to go through the same durable queue every other confirm
5366
+ // uses so a failure to reach OneAddress is retried rather than lost.
5367
+ if (config.mode === 'inbox') {
5368
+ setAcknowledgementHandler((dispatchId, outcome) => {
5369
+ queueConfirm(dispatchId, outcome === 'applied' ? 'confirmed' : 'failed');
5370
+ recordOutcome(\`d:\${dispatchId}\`, outcome === 'applied' ? 'applied' : 'failed');
5371
+ });
5372
+ const started = startDrawApi();
5373
+ if (!started) {
5374
+ // AN INBOX WITH NO WAY TO DRAIN IT IS WORSE THAN NOT STARTING. In
5375
+ // write-through mode a missing connector token is irrelevant and the
5376
+ // receiver runs; here it means every dispatch would be accepted and held
5377
+ // with nothing able to collect it, and the consumer would be told nothing
5378
+ // for as long as that lasted.
5379
+ report.error('[startup] mode is \`inbox\` but the connector channel could not open. Refusing to start.');
5380
+ process.exit(1);
5381
+ }
5382
+ }
5383
+
4280
5384
  setTimeout(() => {
4281
5385
  if (heldCount() === 0) return;
4282
5386
  void replayQuarantined().catch((err: unknown) =>
@@ -4291,8 +5395,34 @@ setTimeout(() => {
4291
5395
  const QUARANTINE_KEEP_DAYS = Number(process.env.QUARANTINE_KEEP_DAYS ?? 30);
4292
5396
  setInterval(() => { purgeQuarantine(QUARANTINE_KEEP_DAYS); }, 3_600_000).unref();
4293
5397
 
4294
- /** How many confirms are still owed. Read by the dashboard. */
4295
- export { pendingConfirmCount };
5398
+ /** How many confirms are still owed, and how the dispatches went. Read by the dashboard. */
5399
+ export { pendingConfirmCount, tally };
5400
+
5401
+ /**
5402
+ * Everything the dashboard footer needs, in one call.
5403
+ *
5404
+ * One hook rather than three, because three would be three chances for the
5405
+ * footer to show figures from different moments.
5406
+ */
5407
+ export function dashboardStats(): {
5408
+ received: number;
5409
+ applied: number;
5410
+ failed: number;
5411
+ mode: string;
5412
+ awaitingConnector: number;
5413
+ oldestUndrawn: string | null;
5414
+ } {
5415
+ const t = tally();
5416
+ const inbox = config.mode === 'inbox'
5417
+ ? { count: undrawnCount(), oldest: oldestUndrawn() }
5418
+ : { count: 0, oldest: null };
5419
+ return {
5420
+ ...t,
5421
+ mode: config.mode,
5422
+ awaitingConnector: inbox.count,
5423
+ oldestUndrawn: inbox.oldest,
5424
+ };
5425
+ }
4296
5426
 
4297
5427
  // Read by src/index.ts to label the dashboard. Exported rather than re-derived
4298
5428
  // there, so the port the UI claims is the port the server actually bound.
@@ -9523,7 +10653,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
9523
10653
 
9524
10654
  // src/register.ts
9525
10655
  var import_node_crypto = require("crypto");
9526
- var PKG_VERSION = true ? "2.1.3" : "dev";
10656
+ var PKG_VERSION = true ? "2.2.0" : "dev";
9527
10657
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
9528
10658
  function hmacSha256(secret, message) {
9529
10659
  return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");