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