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