@oneaddress/setup 2.1.3 → 2.3.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 +1533 -195
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -828,7 +828,7 @@ var Y2 = ({ indicator: t = "dots" } = {}) => {
|
|
|
828
828
|
};
|
|
829
829
|
|
|
830
830
|
// src/prompts.ts
|
|
831
|
-
var
|
|
831
|
+
var import_node_crypto6 = require("crypto");
|
|
832
832
|
var import_node_net = require("net");
|
|
833
833
|
var import_node_fs3 = require("fs");
|
|
834
834
|
var import_node_os2 = require("os");
|
|
@@ -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.3.0" : "?";
|
|
860
860
|
function printCompactHeader() {
|
|
861
861
|
const INNER = 42;
|
|
862
862
|
const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
|
|
@@ -909,6 +909,7 @@ function stripAnsi(s) {
|
|
|
909
909
|
|
|
910
910
|
// src/scaffold.ts
|
|
911
911
|
var import_promises = require("fs/promises");
|
|
912
|
+
var import_node_crypto = require("crypto");
|
|
912
913
|
var import_node_fs = require("fs");
|
|
913
914
|
var import_node_path = require("path");
|
|
914
915
|
|
|
@@ -1006,10 +1007,11 @@ data.db-shm
|
|
|
1006
1007
|
{
|
|
1007
1008
|
name: "oneaddress.config.json",
|
|
1008
1009
|
content: `{
|
|
1009
|
-
"//": "Written by npx @oneaddress/setup, read by src/config.ts. Non-secret config only \u2014 secrets live in .env. Any field can be overridden by an env var of the matching name (PARTNER_ID / ONEADDRESS_API / VERIFIES_ACCOUNT_REFERENCE).",
|
|
1010
|
+
"//": "Written by npx @oneaddress/setup, read by src/config.ts. Non-secret config only \u2014 secrets live in .env. Any field can be overridden by an env var of the matching name (PARTNER_ID / ONEADDRESS_API / VERIFIES_ACCOUNT_REFERENCE / RECEIVER_MODE).",
|
|
1010
1011
|
"partnerId": "%%PARTNER_ID%%",
|
|
1011
1012
|
"oneAddressApi": "%%ONEADDRESS_API%%",
|
|
1012
|
-
"verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE
|
|
1013
|
+
"verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE%%,
|
|
1014
|
+
"mode": "%%RECEIVER_MODE%%"
|
|
1013
1015
|
}
|
|
1014
1016
|
`
|
|
1015
1017
|
},
|
|
@@ -1087,6 +1089,7 @@ data.db-shm
|
|
|
1087
1089
|
*/
|
|
1088
1090
|
import { DatabaseSync } from 'node:sqlite';
|
|
1089
1091
|
import { join } from 'node:path';
|
|
1092
|
+
import { report } from './report.js';
|
|
1090
1093
|
import {
|
|
1091
1094
|
accountIndex,
|
|
1092
1095
|
buildVerifier,
|
|
@@ -1229,6 +1232,50 @@ export { PassphraseRequiredError, WrongPassphraseError };
|
|
|
1229
1232
|
* separates them.
|
|
1230
1233
|
*/
|
|
1231
1234
|
export { isEncrypted };
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Read a timestamp out of this database, whichever of the two formats it is in.
|
|
1238
|
+
*
|
|
1239
|
+
* THIS DATABASE HOLDS TWO, AND NOTHING DECLARED WHICH WAS WHICH.
|
|
1240
|
+
*
|
|
1241
|
+
* a SQL default, \`datetime('now')\` -> "2026-09-14 22:39:52" UTC, no zone
|
|
1242
|
+
* a JS write, \`toISOString()\` -> "2026-09-14T22:39:52.695Z"
|
|
1243
|
+
*
|
|
1244
|
+
* Both appear in the SAME table: \`quarantine.received_at\` is the first and
|
|
1245
|
+
* \`quarantine.replayed_at\` is the second.
|
|
1246
|
+
*
|
|
1247
|
+
* The first is not valid ISO-8601, so \`new Date(...)\` falls to implementation
|
|
1248
|
+
* -defined parsing and V8 reads it as LOCAL time. A row written a minute ago
|
|
1249
|
+
* therefore reads as an hour old for every hour the machine is from UTC. It
|
|
1250
|
+
* reported \`8h ago\` for a two-minute-old row on a partner's machine in Perth,
|
|
1251
|
+
* which is exactly UTC+8 and is how it was found.
|
|
1252
|
+
*
|
|
1253
|
+
* TOLERANT RATHER THAN MIGRATING, deliberately: a receiver that has been
|
|
1254
|
+
* running has rows in the old shape and rewriting somebody's database to fix a
|
|
1255
|
+
* display bug is the wrong trade. This is the same shape as the transition
|
|
1256
|
+
* -tolerant blind-index reads elsewhere in this project.
|
|
1257
|
+
*/
|
|
1258
|
+
export function parseStoredTime(value: string): Date {
|
|
1259
|
+
// Zone-less \`YYYY-MM-DD HH:MM:SS\` is SQLite's, and SQLite's \`now\` is UTC.
|
|
1260
|
+
const sqlite = /^(\\d{4}-\\d{2}-\\d{2}) (\\d{2}:\\d{2}:\\d{2})$/.exec(value.trim());
|
|
1261
|
+
return new Date(sqlite ? \`\${sqlite[1]}T\${sqlite[2]}Z\` : value);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Add a column to an existing table if it is not already there.
|
|
1266
|
+
*
|
|
1267
|
+
* SQLite has no \`ADD COLUMN IF NOT EXISTS\`, and a receiver that has been
|
|
1268
|
+
* running since before a column existed still has to start. Lives here rather
|
|
1269
|
+
* than in one of the two files that needs it, because the second copy is how
|
|
1270
|
+
* the two drift.
|
|
1271
|
+
*/
|
|
1272
|
+
export function ensureColumn(table: string, column: string, definition: string): void {
|
|
1273
|
+
const cols = db.prepare(\`PRAGMA table_info(\${table})\`).all() as Array<{ name: string }>;
|
|
1274
|
+
if (cols.some((c) => c.name === column)) return;
|
|
1275
|
+
db.exec(\`ALTER TABLE \${table} ADD COLUMN \${column} \${definition}\`);
|
|
1276
|
+
report.info(\`[db] migrated: added column \${table}.\${column}\`);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1232
1279
|
export default db;
|
|
1233
1280
|
`
|
|
1234
1281
|
},
|
|
@@ -1663,9 +1710,8 @@ import { formatLine, report, type ReportLine } from './report.js';
|
|
|
1663
1710
|
// One import, same as server.ts. Swapping the store swaps what the dashboard
|
|
1664
1711
|
// reads, with nothing here to change.
|
|
1665
1712
|
import { store } from './store.js';
|
|
1666
|
-
import type { StoredCustomer } from './customer-store.js';
|
|
1667
1713
|
import { pendingConfirmCount } from './confirm-queue.js';
|
|
1668
|
-
import { heldCount, heldSummary } from './quarantine.js';
|
|
1714
|
+
import { exportHeld, heldCount, heldSummary } from './quarantine.js';
|
|
1669
1715
|
|
|
1670
1716
|
/** blessed takes colours as strings; these mirror the site's palette. */
|
|
1671
1717
|
const AMBER = HEX.amber.toLowerCase();
|
|
@@ -1684,19 +1730,50 @@ export interface TuiOptions {
|
|
|
1684
1730
|
* Re-apply everything the receiver could not open, bound to [r].
|
|
1685
1731
|
*
|
|
1686
1732
|
* Passed IN rather than imported: \`server.ts\` already imports this file for
|
|
1687
|
-
* \`
|
|
1733
|
+
* \`noteChange\`, so importing it back would be a cycle. Optional so
|
|
1688
1734
|
* the dashboard still renders for a caller that has no replay to offer.
|
|
1689
1735
|
*/
|
|
1690
1736
|
onReplay?: () => Promise<{ applied: number; failed: number }>;
|
|
1737
|
+
/**
|
|
1738
|
+
* How the dispatches went, asked of the receiver rather than counted here.
|
|
1739
|
+
*
|
|
1740
|
+
* Same reason as \`onReplay\`: \`server.ts\` already imports this file, so this
|
|
1741
|
+
* file cannot import it back. Optional, and a caller that omits it gets
|
|
1742
|
+
* zeroes, which is honest for a dashboard driving nothing.
|
|
1743
|
+
*/
|
|
1744
|
+
stats?: () => {
|
|
1745
|
+
received: number;
|
|
1746
|
+
applied: number;
|
|
1747
|
+
failed: number;
|
|
1748
|
+
mode: string;
|
|
1749
|
+
awaitingConnector: number;
|
|
1750
|
+
oldestUndrawn: string | null;
|
|
1751
|
+
};
|
|
1691
1752
|
}
|
|
1692
1753
|
|
|
1693
|
-
/**
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1754
|
+
/**
|
|
1755
|
+
* What the server reports about a dispatch it applied.
|
|
1756
|
+
*
|
|
1757
|
+
* DELIBERATELY CARRIES NO ADDRESS AND NO NAME. This is the whole interface
|
|
1758
|
+
* between the protocol layer and the screen, so anything absent from this type
|
|
1759
|
+
* is something the dashboard cannot render however it is rewritten later. The
|
|
1760
|
+
* address-formatting helper that used to live here went with it: a function
|
|
1761
|
+
* that turns an address into a display string, sitting in the UI module, is an
|
|
1762
|
+
* invitation.
|
|
1763
|
+
*/
|
|
1764
|
+
export interface ChangeFacts {
|
|
1765
|
+
/** The partner's own identifier for their own record. Never the consumer's name. */
|
|
1766
|
+
accountNumber: string;
|
|
1767
|
+
/** Did the account-reference guard run and pass? False means you do not verify them. */
|
|
1768
|
+
accountChecked: boolean;
|
|
1769
|
+
/** Did the record actually move, or was it already current? */
|
|
1770
|
+
changed: boolean;
|
|
1771
|
+
/** Proof of consent, for your audit trail. */
|
|
1772
|
+
loaRef: string | null;
|
|
1773
|
+
dispatchId: string | null;
|
|
1697
1774
|
}
|
|
1698
1775
|
|
|
1699
|
-
export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptions): void {
|
|
1776
|
+
export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: TuiOptions): void {
|
|
1700
1777
|
const screen = blessed.screen({
|
|
1701
1778
|
smartCSR: true,
|
|
1702
1779
|
title: \`\${partnerName} \u2014 OneAddress receiver\`,
|
|
@@ -1758,7 +1835,9 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
|
|
|
1758
1835
|
// is the other case: a permanent empty box teaches the eye to skip that
|
|
1759
1836
|
// region, which is precisely the region that has to be noticed the one day it
|
|
1760
1837
|
// fills. So it appears, and the two panels above give up the rows.
|
|
1761
|
-
|
|
1838
|
+
// Six, not seven. Every row this takes comes off the two panels above it, and
|
|
1839
|
+
// the one above left cannot afford to lose any: see renderChange.
|
|
1840
|
+
const FAULT_HEIGHT = 6;
|
|
1762
1841
|
const faultBox = blessed.box({
|
|
1763
1842
|
parent: screen, bottom: 3, left: 0, width: '100%', height: FAULT_HEIGHT,
|
|
1764
1843
|
label: ' FAULTS ', tags: true, padding: { left: 1, right: 1 }, hidden: true,
|
|
@@ -1774,24 +1853,28 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
|
|
|
1774
1853
|
});
|
|
1775
1854
|
|
|
1776
1855
|
// \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
|
|
1778
|
-
let applied = 0;
|
|
1779
|
-
let failed = 0;
|
|
1780
|
-
let lastChange: { customer: StoredCustomer; previous: Record<string, unknown> } | null = null;
|
|
1781
|
-
let pendingPrevious: Record<string, unknown> = {};
|
|
1856
|
+
let lastChange: ChangeFacts | null = null;
|
|
1782
1857
|
|
|
1783
|
-
// The server hands over
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1786
|
-
|
|
1858
|
+
// The server hands over FACTS about a dispatch, never the address. See
|
|
1859
|
+
// \`ChangeFacts\` and the note at the call site in server.ts: this module is
|
|
1860
|
+
// the screen, and a screen is the one place a consumer's home address is
|
|
1861
|
+
// most likely to be photographed.
|
|
1862
|
+
setChange = (facts) => { lastChange = facts; redraw(); };
|
|
1787
1863
|
|
|
1788
1864
|
function renderStatus(): void {
|
|
1789
1865
|
// Both states are shown, and the unprotected one is the loud colour. A
|
|
1790
1866
|
// security property mentioned only when it holds is one nobody notices the
|
|
1791
1867
|
// absence of.
|
|
1792
|
-
const
|
|
1793
|
-
|
|
1794
|
-
|
|
1868
|
+
const inbox = statsMode() === 'inbox';
|
|
1869
|
+
// IN INBOX MODE THERE IS NO CUSTOMER FILE HERE AT ALL, so neither ENCRYPTED
|
|
1870
|
+
// nor UNENCRYPTED is true and both would mislead. The receiver holds
|
|
1871
|
+
// ciphertext it cannot open; the customers live in the partner's own
|
|
1872
|
+
// database, behind their own controls.
|
|
1873
|
+
const vault = inbox
|
|
1874
|
+
? \`{\${AMBER}-fg}{bold}NONE (inbox){/}\`
|
|
1875
|
+
: store.encrypted
|
|
1876
|
+
? \`{green-fg}{bold}ENCRYPTED{/}\`
|
|
1877
|
+
: \`{red-fg}{bold}UNENCRYPTED{/}\`;
|
|
1795
1878
|
status.setContent(
|
|
1796
1879
|
\`{\${AMBER}-fg}{bold}\${esc(partnerName)}{/} \` +
|
|
1797
1880
|
\`{\${DIM}-fg}listening{/} {\${CREAM}-fg}:\${port}/webhook{/} \` +
|
|
@@ -1808,19 +1891,36 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
|
|
|
1808
1891
|
if (!lastChange) {
|
|
1809
1892
|
changeBox.setContent(
|
|
1810
1893
|
\`\\n {\${DIM}-fg}Waiting for a dispatch.{/}\\n\\n\` +
|
|
1811
|
-
\` {\${DIM}-fg}When one arrives,
|
|
1812
|
-
\` {\${DIM}-fg}
|
|
1894
|
+
\` {\${DIM}-fg}When one arrives, what happened to it appears here.{/}\\n\` +
|
|
1895
|
+
\` {\${DIM}-fg}The address itself never does \u2014 see below.{/}\`,
|
|
1813
1896
|
);
|
|
1814
1897
|
return;
|
|
1815
1898
|
}
|
|
1816
|
-
const
|
|
1817
|
-
|
|
1818
|
-
|
|
1899
|
+
const f = lastChange;
|
|
1900
|
+
// WHAT HAPPENED, NOT WHAT IT SAYS.
|
|
1901
|
+
//
|
|
1902
|
+
// Every line here is a fact about the dispatch and none of them is content.
|
|
1903
|
+
// An operator watching this can tell that the envelope opened, that the
|
|
1904
|
+
// account was one of theirs, and whether the record actually moved - which
|
|
1905
|
+
// is everything the panel was ever for. To read the address itself, look in
|
|
1906
|
+
// your own database, which is where it lives and which is a deliberate act
|
|
1907
|
+
// rather than a screen left open.
|
|
1908
|
+
//
|
|
1909
|
+
// COMPACT ON PURPOSE. The faults band takes rows off this panel when it
|
|
1910
|
+
// appears and blessed clips silently, so a tall panel loses its last line
|
|
1911
|
+
// and reads as a dispatch that half worked. That cost a real end-to-end run
|
|
1912
|
+
// an hour of doubt.
|
|
1913
|
+
const check = f.accountChecked
|
|
1914
|
+
? \`{green-fg}account matched{/}\`
|
|
1915
|
+
: \`{\${DIM}-fg}account not checked{/}\`;
|
|
1916
|
+
const moved = f.changed
|
|
1917
|
+
? \`{green-fg}address changed{/}\`
|
|
1918
|
+
: \`{\${AMBER}-fg}already current{/}\`;
|
|
1819
1919
|
changeBox.setContent(
|
|
1820
|
-
\`\\n {bold}\${esc(
|
|
1821
|
-
\` {
|
|
1822
|
-
\` {
|
|
1823
|
-
\` {
|
|
1920
|
+
\`\\n {\${DIM}-fg}account{/} {\${AMBER}-fg}{bold}\${esc(f.accountNumber)}{/}{/}\\n\\n\` +
|
|
1921
|
+
\` {green-fg}decrypted{/} \${check} \${moved}\\n\\n\` +
|
|
1922
|
+
\` {\${DIM}-fg}loa_ref{/} {\${CREAM}-fg}\${esc(f.loaRef ?? '(none)')}{/}\\n\` +
|
|
1923
|
+
\` {\${DIM}-fg}dispatch{/} {\${CREAM}-fg}\${esc(f.dispatchId ?? '(none)')}{/}\`,
|
|
1824
1924
|
);
|
|
1825
1925
|
}
|
|
1826
1926
|
|
|
@@ -1835,6 +1935,8 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
|
|
|
1835
1935
|
* declines to count returns forever, so the dash below is the honest initial
|
|
1836
1936
|
* state rather than a placeholder that happens to look the same.
|
|
1837
1937
|
*/
|
|
1938
|
+
const statsMode = (): string => stats?.().mode ?? 'write-through';
|
|
1939
|
+
|
|
1838
1940
|
let onFile: number | null = null;
|
|
1839
1941
|
function refreshCount(): void {
|
|
1840
1942
|
void Promise.resolve(store.count())
|
|
@@ -1860,14 +1962,14 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
|
|
|
1860
1962
|
// Grouped, never one line per dispatch. The realistic shape of this table
|
|
1861
1963
|
// is forty rows with ONE cause between them, and forty identical lines hide
|
|
1862
1964
|
// the single fact that matters.
|
|
1863
|
-
const lines = heldSummary().slice(0,
|
|
1965
|
+
const lines = heldSummary().slice(0, 2).map((l) => \` {red-fg}\${esc(l)}{/}\`);
|
|
1864
1966
|
faultBox.setContent(
|
|
1865
|
-
\`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open
|
|
1866
|
-
\`
|
|
1867
|
-
lines.join('\\n') + '\\n
|
|
1967
|
+
\`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open. \` +
|
|
1968
|
+
\`{\${DIM}-fg}Held, encrypted, exactly as they arrived.{/}\\n\` +
|
|
1969
|
+
lines.join('\\n') + '\\n' +
|
|
1868
1970
|
(replayNote
|
|
1869
1971
|
? \` {\${AMBER}-fg}\${esc(replayNote)}{/}\`
|
|
1870
|
-
: \` {\${DIM}-fg}Fix the cause
|
|
1972
|
+
: \` {\${DIM}-fg}Fix the cause, then{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply,{/} {\${AMBER}-fg}[e]{/} {\${DIM}-fg}to export the list.{/}\`),
|
|
1871
1973
|
);
|
|
1872
1974
|
}
|
|
1873
1975
|
|
|
@@ -1881,12 +1983,27 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
|
|
|
1881
1983
|
// with a backlog shows the backlog instead of zero.
|
|
1882
1984
|
const awaiting = pendingConfirmCount();
|
|
1883
1985
|
const awaitingColour = awaiting > 0 ? AMBER : DIM;
|
|
1986
|
+
// ASKED, not accumulated. The server counts per dispatch, so a redelivery
|
|
1987
|
+
// updates a record rather than adding one.
|
|
1988
|
+
const s = stats?.() ?? {
|
|
1989
|
+
received: 0, applied: 0, failed: 0,
|
|
1990
|
+
mode: 'write-through', awaitingConnector: 0, oldestUndrawn: null,
|
|
1991
|
+
};
|
|
1992
|
+
const { received, applied, failed } = s;
|
|
1993
|
+
// AWAITING YOUR SYSTEMS, in inbox mode only. Drawn whether or not it is
|
|
1994
|
+
// zero there, and absent entirely in write-through, because a counter for a
|
|
1995
|
+
// thing that cannot happen is noise that teaches the eye to skip the row.
|
|
1996
|
+
const awaitingYou = s.mode === 'inbox'
|
|
1997
|
+
? \`{\${s.awaitingConnector > 0 ? AMBER : DIM}-fg}awaiting your systems{/} \` +
|
|
1998
|
+
\`{bold}\${s.awaitingConnector}{/bold} \`
|
|
1999
|
+
: '';
|
|
1884
2000
|
footer.setContent(
|
|
1885
2001
|
\`{\${DIM}-fg}received{/} {bold}\${received}{/bold} \` +
|
|
1886
2002
|
\`{green-fg}applied{/} {bold}\${applied}{/bold} \` +
|
|
1887
2003
|
\`{red-fg}failed{/} {bold}\${failed}{/bold} \` +
|
|
2004
|
+
awaitingYou +
|
|
1888
2005
|
\`{\${awaitingColour}-fg}awaiting confirm{/} {bold}\${awaiting}{/bold}\` +
|
|
1889
|
-
\`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[q] quit{/} \`,
|
|
2006
|
+
\`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [q] quit{/} \`,
|
|
1890
2007
|
);
|
|
1891
2008
|
}
|
|
1892
2009
|
|
|
@@ -1899,46 +2016,25 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
|
|
|
1899
2016
|
}
|
|
1900
2017
|
|
|
1901
2018
|
// \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
|
-
//
|
|
2019
|
+
// NARRATION ONLY. It used to derive the footer's counters from these lines
|
|
2020
|
+
// too, on the reasoning that one source cannot disagree with itself. That
|
|
2021
|
+
// held until replay shipped: a replay produces exactly the lines an arrival
|
|
2022
|
+
// does, so pressing [r] against a key that could never work drove \`received\`
|
|
2023
|
+
// from 5 to 67 while nothing arrived. A log line says what happened and not
|
|
2024
|
+
// what it happened TO, so two lines about one dispatch are indistinguishable
|
|
2025
|
+
// from two dispatches. The counters key on the dispatch now; see
|
|
2026
|
+
// \`src/tally.ts\`.
|
|
1905
2027
|
const detach = report.attach((line: ReportLine) => {
|
|
1906
2028
|
const text = formatLine(line);
|
|
1907
2029
|
const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
|
|
1908
2030
|
const time = new Date(line.at).toTimeString().slice(0, 8);
|
|
1909
2031
|
|
|
1910
|
-
//
|
|
1911
|
-
//
|
|
1912
|
-
//
|
|
1913
|
-
// which is
|
|
1914
|
-
//
|
|
1915
|
-
//
|
|
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
|
-
if (/\\[store\\] saved address for /.test(text)) {
|
|
1926
|
-
applied++;
|
|
1927
|
-
// The store logs the account key and never the address, so the panel is
|
|
1928
|
-
// refreshed from the DATABASE rather than parsed out of the log line.
|
|
1929
|
-
const acct = /saved address for (\\S+)/.exec(text)?.[1];
|
|
1930
|
-
// An indexed lookup of the one customer, not a decrypt of the whole
|
|
1931
|
-
// roster to find them. Invisible at three rows and absurd at four
|
|
1932
|
-
// million, which is the scale a real store is pointed at.
|
|
1933
|
-
const prev = pendingPrevious;
|
|
1934
|
-
if (acct) {
|
|
1935
|
-
void Promise.resolve(store.find(acct)).then((customer) => {
|
|
1936
|
-
if (!customer) return;
|
|
1937
|
-
lastChange = { customer, previous: prev };
|
|
1938
|
-
redraw();
|
|
1939
|
-
});
|
|
1940
|
-
}
|
|
1941
|
-
}
|
|
2032
|
+
// NOTHING IS PARSED OUT OF A LOG LINE HERE ANY MORE, and nothing is looked
|
|
2033
|
+
// up from the store. The panel used to scrape the account out of \`[store]
|
|
2034
|
+
// saved address for ...\` and then fetch that customer's record to render
|
|
2035
|
+
// it, which is how a customer's name and address reached the screen. The
|
|
2036
|
+
// server calls \`noteChange\` with facts instead, so this module has no path
|
|
2037
|
+
// to a customer record at all.
|
|
1942
2038
|
|
|
1943
2039
|
logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
|
|
1944
2040
|
redraw();
|
|
@@ -1965,6 +2061,24 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
|
|
|
1965
2061
|
.finally(() => { replaying = false; redraw(); });
|
|
1966
2062
|
});
|
|
1967
2063
|
|
|
2064
|
+
// [e] EXPORTS THE FAULT LIST. Metadata only, no payloads: see exportHeld.
|
|
2065
|
+
// Bound unconditionally for the same reason [r] is, so pressing it on a clean
|
|
2066
|
+
// receiver says there is nothing to export rather than appearing to hang.
|
|
2067
|
+
screen.key(['e'], () => {
|
|
2068
|
+
if (heldCount() === 0) {
|
|
2069
|
+
replayNote = 'Nothing held, so nothing to export.';
|
|
2070
|
+
redraw();
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
try {
|
|
2074
|
+
const { path, count } = exportHeld(process.cwd());
|
|
2075
|
+
replayNote = \`Exported \${count} to \${path}\`;
|
|
2076
|
+
} catch (err) {
|
|
2077
|
+
replayNote = \`Export failed: \${err instanceof Error ? err.message : String(err)}\`;
|
|
2078
|
+
}
|
|
2079
|
+
redraw();
|
|
2080
|
+
});
|
|
2081
|
+
|
|
1968
2082
|
screen.key(['q', 'C-c'], () => {
|
|
1969
2083
|
detach();
|
|
1970
2084
|
screen.destroy();
|
|
@@ -1991,10 +2105,10 @@ export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptio
|
|
|
1991
2105
|
* reason \`report\` has a default sink: \`--headless\` must not need a second code
|
|
1992
2106
|
* path through the handler.
|
|
1993
2107
|
*/
|
|
1994
|
-
let
|
|
2108
|
+
let setChange: (facts: ChangeFacts) => void = () => {};
|
|
1995
2109
|
|
|
1996
|
-
export function
|
|
1997
|
-
|
|
2110
|
+
export function noteChange(facts: ChangeFacts): void {
|
|
2111
|
+
setChange(facts);
|
|
1998
2112
|
}
|
|
1999
2113
|
`
|
|
2000
2114
|
},
|
|
@@ -2238,6 +2352,14 @@ async function main(): Promise<void> {
|
|
|
2238
2352
|
partnerName: string;
|
|
2239
2353
|
port: number;
|
|
2240
2354
|
replay: () => Promise<{ applied: number; failed: number }>;
|
|
2355
|
+
stats: () => {
|
|
2356
|
+
received: number;
|
|
2357
|
+
applied: number;
|
|
2358
|
+
failed: number;
|
|
2359
|
+
mode: string;
|
|
2360
|
+
awaitingConnector: number;
|
|
2361
|
+
oldestUndrawn: string | null;
|
|
2362
|
+
};
|
|
2241
2363
|
};
|
|
2242
2364
|
try {
|
|
2243
2365
|
const server = await import('./server.js');
|
|
@@ -2249,6 +2371,7 @@ async function main(): Promise<void> {
|
|
|
2249
2371
|
// second edge the other way is a cycle. This file is the one place that
|
|
2250
2372
|
// holds both.
|
|
2251
2373
|
replay: server.replayQuarantined,
|
|
2374
|
+
stats: server.dashboardStats,
|
|
2252
2375
|
};
|
|
2253
2376
|
} catch (err) {
|
|
2254
2377
|
if (err instanceof PassphraseRequiredError) {
|
|
@@ -2278,6 +2401,7 @@ async function main(): Promise<void> {
|
|
|
2278
2401
|
partnerName: config.partnerName,
|
|
2279
2402
|
port: config.port,
|
|
2280
2403
|
onReplay: config.replay,
|
|
2404
|
+
stats: config.stats,
|
|
2281
2405
|
onQuit: () => { /* the process exits; the OS closes the socket */ },
|
|
2282
2406
|
});
|
|
2283
2407
|
}
|
|
@@ -2286,6 +2410,708 @@ main().catch((err) => {
|
|
|
2286
2410
|
console.error('[startup]', err instanceof Error ? err.message : err);
|
|
2287
2411
|
process.exit(1);
|
|
2288
2412
|
});
|
|
2413
|
+
`
|
|
2414
|
+
},
|
|
2415
|
+
{
|
|
2416
|
+
name: "src/connector-store.ts",
|
|
2417
|
+
content: `/**
|
|
2418
|
+
* A \`CustomerStore\` whose answers come from the connector.
|
|
2419
|
+
*
|
|
2420
|
+
* ## Why the mode swaps the STORE rather than branching the handler
|
|
2421
|
+
*
|
|
2422
|
+
* The obvious way to build inbox mode is an \`if (mode === 'inbox')\` beside each
|
|
2423
|
+
* verify event in \`server.ts\`. That works and it puts the mode into the middle
|
|
2424
|
+
* of the protocol layer, where it has to be got right twice and stay right
|
|
2425
|
+
* every time either handler changes.
|
|
2426
|
+
*
|
|
2427
|
+
* The contract in \`customer-store.ts\` already says exactly what the receiver
|
|
2428
|
+
* needs from a partner: verify an account, verify an address, apply a change.
|
|
2429
|
+
* In inbox mode the answers come from a different place. That is an
|
|
2430
|
+
* IMPLEMENTATION of the contract, not a special case in the caller, so the
|
|
2431
|
+
* handler is untouched and there is one place the mode lives.
|
|
2432
|
+
*
|
|
2433
|
+
* ## Three methods answer, two deliberately refuse
|
|
2434
|
+
*
|
|
2435
|
+
* \`verifyAccount\` and \`verifyAddress\` ask the connector and wait, because a
|
|
2436
|
+
* consumer is mid-payment on the other end of the first one.
|
|
2437
|
+
*
|
|
2438
|
+
* \`saveAddress\` THROWS. In inbox mode an \`address.updated\` never reaches the
|
|
2439
|
+
* store at all: it is held for the connector to draw, and the connector applies
|
|
2440
|
+
* it against the partner's own database. If this is ever called, the mode
|
|
2441
|
+
* switch has been bypassed and the right answer is a loud failure rather than a
|
|
2442
|
+
* quiet write into a store that should not exist.
|
|
2443
|
+
*
|
|
2444
|
+
* \`find\` and \`count\` return nothing, honestly, because this receiver holds no
|
|
2445
|
+
* customer records in this mode. The dashboard draws a dash rather than a zero,
|
|
2446
|
+
* which is the difference between "not asked" and "none".
|
|
2447
|
+
*/
|
|
2448
|
+
import { report } from './report.js';
|
|
2449
|
+
import { askConnector } from './connector-client.js';
|
|
2450
|
+
import type {
|
|
2451
|
+
AccountVerdict,
|
|
2452
|
+
Address,
|
|
2453
|
+
Customer,
|
|
2454
|
+
CustomerStore,
|
|
2455
|
+
StoredCustomer,
|
|
2456
|
+
VerifyResult,
|
|
2457
|
+
} from './customer-store.js';
|
|
2458
|
+
|
|
2459
|
+
/**
|
|
2460
|
+
* The raw body of the request currently being handled.
|
|
2461
|
+
*
|
|
2462
|
+
* SET BY THE HANDLER, read here. The contract passes a decoded \`Customer\`,
|
|
2463
|
+
* which is exactly what this store does not have and cannot produce: the
|
|
2464
|
+
* receiver holds no private key in this mode, so the only thing it can send the
|
|
2465
|
+
* connector is the ciphertext that arrived. Rather than widen the contract for
|
|
2466
|
+
* one implementation, the handler parks the bytes here for the length of the
|
|
2467
|
+
* request.
|
|
2468
|
+
*
|
|
2469
|
+
* Safe because Node runs one request's synchronous path at a time and this is
|
|
2470
|
+
* read immediately, in the same tick the handler sets it. It would NOT be safe
|
|
2471
|
+
* if anything awaited between the set and the read, which is why they are
|
|
2472
|
+
* adjacent and why this comment exists.
|
|
2473
|
+
*/
|
|
2474
|
+
let currentRawBody = '';
|
|
2475
|
+
|
|
2476
|
+
export function setCurrentRawBody(raw: string): void {
|
|
2477
|
+
currentRawBody = raw;
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
function verdictOf(body: Record<string, unknown> | undefined, key: string): string | null {
|
|
2481
|
+
const value = body?.[key];
|
|
2482
|
+
return typeof value === 'string' ? value : null;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
export const connectorStore = {
|
|
2486
|
+
name: 'connector',
|
|
2487
|
+
// The receiver holds no customer records in this mode, so "is the customer
|
|
2488
|
+
// file encrypted" has no true answer. False is the honest one: there is no
|
|
2489
|
+
// protected customer store here, because there is no customer store here.
|
|
2490
|
+
encrypted: false,
|
|
2491
|
+
|
|
2492
|
+
async verifyAccount(
|
|
2493
|
+
accountNumber: string | null,
|
|
2494
|
+
_name: string,
|
|
2495
|
+
_knownNames: string[] = [],
|
|
2496
|
+
): Promise<AccountVerdict> {
|
|
2497
|
+
const answer = await askConnector('account.verify', currentRawBody);
|
|
2498
|
+
if (!answer.reached) {
|
|
2499
|
+
// NEVER GUESSED. A fabricated match authorises a stranger's address onto
|
|
2500
|
+
// a customer's account. \`no_account\` is the safe answer and stops the
|
|
2501
|
+
// consumer before they pay, which is the trade this design accepted.
|
|
2502
|
+
report.warn(
|
|
2503
|
+
\`[connector] account check for \${accountNumber ?? '(none)'} could not be answered; \` +
|
|
2504
|
+
'refusing rather than guessing',
|
|
2505
|
+
);
|
|
2506
|
+
return 'no_account';
|
|
2507
|
+
}
|
|
2508
|
+
const status = verdictOf(answer.body, 'status');
|
|
2509
|
+
if (status === 'match' || status === 'no_match' || status === 'no_account') return status;
|
|
2510
|
+
report.warn(\`[connector] account.verify answered "\${status ?? '(nothing)'}", which is not a verdict\`);
|
|
2511
|
+
return 'no_account';
|
|
2512
|
+
},
|
|
2513
|
+
|
|
2514
|
+
async verifyAddress(_customer: Customer, _incoming: Address): Promise<VerifyResult> {
|
|
2515
|
+
const answer = await askConnector('address.verify', currentRawBody);
|
|
2516
|
+
if (!answer.reached) return 'not_found';
|
|
2517
|
+
const result = verdictOf(answer.body, 'result');
|
|
2518
|
+
if (result === 'match' || result === 'mismatch' || result === 'not_found') return result;
|
|
2519
|
+
report.warn(\`[connector] address.verify answered "\${result ?? '(nothing)'}", which is not a result\`);
|
|
2520
|
+
return 'not_found';
|
|
2521
|
+
},
|
|
2522
|
+
|
|
2523
|
+
saveAddress(_customer: Customer, _incoming: Address): Promise<Address> {
|
|
2524
|
+
// Unreachable by design: see the header. Loud rather than quiet.
|
|
2525
|
+
return Promise.reject(new Error(
|
|
2526
|
+
'saveAddress was called in inbox mode. Updates are held for the connector to draw and ' +
|
|
2527
|
+
'apply against your own database; nothing should write through this receiver.',
|
|
2528
|
+
));
|
|
2529
|
+
},
|
|
2530
|
+
|
|
2531
|
+
find(_accountNumber: string): StoredCustomer | null { return null; },
|
|
2532
|
+
count(): null { return null; },
|
|
2533
|
+
} satisfies CustomerStore;
|
|
2534
|
+
`
|
|
2535
|
+
},
|
|
2536
|
+
{
|
|
2537
|
+
name: "src/connector-client.ts",
|
|
2538
|
+
content: `/**
|
|
2539
|
+
* Asking the connector a question that cannot wait.
|
|
2540
|
+
*
|
|
2541
|
+
* ## Why this direction exists at all
|
|
2542
|
+
*
|
|
2543
|
+
* Everything else between the receiver and the connector is a PULL: the
|
|
2544
|
+
* connector draws work when it is ready. That is the right shape, because the
|
|
2545
|
+
* partner's system should control its own pace.
|
|
2546
|
+
*
|
|
2547
|
+
* The two verify events cannot work that way. \`account.verify\` runs BEFORE the
|
|
2548
|
+
* consumer pays and has to answer in the same request with match, no match, or
|
|
2549
|
+
* no account; \`address.verify\` is the same shape. Both need a decrypt and a
|
|
2550
|
+
* customer lookup, and in inbox mode both of those live in the connector. So
|
|
2551
|
+
* for these, and only these, the receiver calls out and waits.
|
|
2552
|
+
*
|
|
2553
|
+
* ## A CORRECTION TO THE DESIGN DOCUMENT, recorded where it matters
|
|
2554
|
+
*
|
|
2555
|
+
* \`docs/architecture/receiver-connector-design.md\` says the connector "accepts
|
|
2556
|
+
* no inbound connections". That is not achievable alongside a synchronous
|
|
2557
|
+
* verify, and the verify decision is the one that was taken deliberately. So
|
|
2558
|
+
* the connector DOES listen, on loopback only, and the honest form of the
|
|
2559
|
+
* property is: no inbound port reachable from any NETWORK. The difference
|
|
2560
|
+
* matters the day a partner puts the two on separate hosts, which is what the
|
|
2561
|
+
* transport configuration below exists for.
|
|
2562
|
+
*
|
|
2563
|
+
* ## What happens when the connector is down
|
|
2564
|
+
*
|
|
2565
|
+
* The receiver tells OneAddress honestly that it could not check, and the
|
|
2566
|
+
* consumer is stopped BEFORE they pay rather than after. That is the cost of
|
|
2567
|
+
* choosing this over a verify-only key in the receiver: such a key would keep
|
|
2568
|
+
* answers flowing during an outage and would make "the receiver cannot read
|
|
2569
|
+
* addresses" untrue, which is the whole point of the split.
|
|
2570
|
+
*
|
|
2571
|
+
* It is NEVER answered with a guess. A fabricated match authorises a stranger's
|
|
2572
|
+
* address onto a customer's account; a fabricated no-match costs a support
|
|
2573
|
+
* call. Neither is ours to invent.
|
|
2574
|
+
*/
|
|
2575
|
+
import { report } from './report.js';
|
|
2576
|
+
|
|
2577
|
+
/**
|
|
2578
|
+
* Where the connector listens.
|
|
2579
|
+
*
|
|
2580
|
+
* 3003, NOT 3002. Two loopback listeners exist in this design and swapping them
|
|
2581
|
+
* is silent: the receiver's own draw channel is on 3002, so a receiver pointed
|
|
2582
|
+
* at 3002 for verify asks ITSELF the question and gets a 404 that reads exactly
|
|
2583
|
+
* like a connector that is down.
|
|
2584
|
+
*/
|
|
2585
|
+
const CONNECTOR_URL = process.env.CONNECTOR_URL ?? 'http://127.0.0.1:3003';
|
|
2586
|
+
const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
|
|
2587
|
+
/**
|
|
2588
|
+
* Short, because a consumer is watching a spinner on the other end of this.
|
|
2589
|
+
* A verify that takes eight seconds has already failed as far as they are
|
|
2590
|
+
* concerned, and an honest "could not check" beats a long hang.
|
|
2591
|
+
*/
|
|
2592
|
+
const TIMEOUT_MS = Number(process.env.CONNECTOR_TIMEOUT_MS ?? 5000);
|
|
2593
|
+
|
|
2594
|
+
export type VerifyKind = 'account.verify' | 'address.verify';
|
|
2595
|
+
|
|
2596
|
+
export interface ConnectorVerdict {
|
|
2597
|
+
reached: boolean;
|
|
2598
|
+
/** Whatever the connector answered. Passed through untouched. */
|
|
2599
|
+
body?: Record<string, unknown>;
|
|
2600
|
+
error?: string;
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
/**
|
|
2604
|
+
* Hand the connector an encrypted verify event and wait for its answer.
|
|
2605
|
+
*
|
|
2606
|
+
* The RAW BODY goes over, not anything decoded: the receiver holds no private
|
|
2607
|
+
* key in this mode and has nothing to decode it with. The connector decrypts,
|
|
2608
|
+
* looks the customer up in the partner's database, and answers.
|
|
2609
|
+
*/
|
|
2610
|
+
export async function askConnector(kind: VerifyKind, rawBody: string): Promise<ConnectorVerdict> {
|
|
2611
|
+
const controller = new AbortController();
|
|
2612
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
2613
|
+
try {
|
|
2614
|
+
const res = await fetch(\`\${CONNECTOR_URL}/verify\`, {
|
|
2615
|
+
method: 'POST',
|
|
2616
|
+
headers: {
|
|
2617
|
+
'Content-Type': 'application/json',
|
|
2618
|
+
'Authorization': \`Bearer \${TOKEN}\`,
|
|
2619
|
+
'X-OneAddress-Verify-Kind': kind,
|
|
2620
|
+
},
|
|
2621
|
+
body: rawBody,
|
|
2622
|
+
signal: controller.signal,
|
|
2623
|
+
});
|
|
2624
|
+
const text = await res.text().catch(() => '');
|
|
2625
|
+
if (!res.ok) {
|
|
2626
|
+
report.error(\`[connector] \${kind} refused: HTTP \${res.status} \${text.slice(0, 200)}\`);
|
|
2627
|
+
return { reached: false, error: \`HTTP \${res.status}\` };
|
|
2628
|
+
}
|
|
2629
|
+
try {
|
|
2630
|
+
return { reached: true, body: JSON.parse(text) as Record<string, unknown> };
|
|
2631
|
+
} catch {
|
|
2632
|
+
report.error(\`[connector] \${kind} answered with something that is not JSON\`);
|
|
2633
|
+
return { reached: false, error: 'bad response' };
|
|
2634
|
+
}
|
|
2635
|
+
} catch (err) {
|
|
2636
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2637
|
+
// NAMES THE REMEDY. This is the failure a partner will actually see, and
|
|
2638
|
+
// "fetch failed" on its own sends them looking at OneAddress.
|
|
2639
|
+
report.error(
|
|
2640
|
+
\`[connector] could not reach the connector for \${kind} (\${message}). \` +
|
|
2641
|
+
\`Account checks will fail until it is back. Is it running, and is CONNECTOR_URL (\${CONNECTOR_URL}) right?\`,
|
|
2642
|
+
);
|
|
2643
|
+
return { reached: false, error: message };
|
|
2644
|
+
} finally {
|
|
2645
|
+
clearTimeout(timer);
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
`
|
|
2649
|
+
},
|
|
2650
|
+
{
|
|
2651
|
+
name: "src/draw-api.ts",
|
|
2652
|
+
content: `/**
|
|
2653
|
+
* The channel your connector draws from.
|
|
2654
|
+
*
|
|
2655
|
+
* ## The most dangerous surface in this receiver
|
|
2656
|
+
*
|
|
2657
|
+
* Everything else here handles ciphertext. This hands out dispatches a
|
|
2658
|
+
* connector will decrypt, and takes its word for what was applied. An exposed
|
|
2659
|
+
* or unauthenticated version of it is precisely the oracle the whole
|
|
2660
|
+
* architecture exists to prevent, so three things are not configurable:
|
|
2661
|
+
*
|
|
2662
|
+
* 1. **It binds to 127.0.0.1 and nothing else.** There is deliberately no
|
|
2663
|
+
* option to bind it to an interface. A partner who needs the connector on
|
|
2664
|
+
* another host needs a transport with mutual authentication, not this one
|
|
2665
|
+
* with a wider bind, and giving them a knob that looks like it does the
|
|
2666
|
+
* job is how that ends up on a LAN.
|
|
2667
|
+
* 2. **It runs on a SEPARATE PORT from the webhook.** Same process, different
|
|
2668
|
+
* listener, so nothing routed from the internet can reach these paths even
|
|
2669
|
+
* by mistake in a reverse proxy.
|
|
2670
|
+
* 3. **It needs its own credential.** \`CONNECTOR_TOKEN\`, not the webhook
|
|
2671
|
+
* secret and not the confirm secret. Those are shared with OneAddress; a
|
|
2672
|
+
* leak of either must not also hand somebody your customers' addresses.
|
|
2673
|
+
* Compared in constant time, and the API refuses to start without it.
|
|
2674
|
+
*
|
|
2675
|
+
* ## What it does NOT decrypt
|
|
2676
|
+
*
|
|
2677
|
+
* Nothing. It hands over \`raw_body\`, the bytes as they arrived. The private key
|
|
2678
|
+
* lives in the connector, which is the only component that can read any of it.
|
|
2679
|
+
*/
|
|
2680
|
+
import express, { type Request, type Response } from 'express';
|
|
2681
|
+
import rateLimit from 'express-rate-limit';
|
|
2682
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2683
|
+
import { report } from './report.js';
|
|
2684
|
+
import { acknowledge, drawable, markDrawn, type InboxOutcome } from './inbox.js';
|
|
2685
|
+
|
|
2686
|
+
const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
|
|
2687
|
+
/**
|
|
2688
|
+
* The RECEIVER's loopback listener, which is not the connector's.
|
|
2689
|
+
*
|
|
2690
|
+
* Named \`DRAW_PORT\` rather than \`CONNECTOR_PORT\` because the connector has a
|
|
2691
|
+
* listener of its own, on \`CONNECTOR_PORT\` (3003), for the verify questions
|
|
2692
|
+
* that cannot wait for a draw. One name for two ports on one host is a
|
|
2693
|
+
* misconfiguration nobody would find: each end would bind or dial the other's.
|
|
2694
|
+
*/
|
|
2695
|
+
const DRAW_PORT = Number(process.env.DRAW_PORT ?? 3002);
|
|
2696
|
+
/**
|
|
2697
|
+
* How long a drawn item stays claimed before it is offered again.
|
|
2698
|
+
*
|
|
2699
|
+
* Crash recovery for ONE connector, not concurrency for two. See \`inbox.ts\`.
|
|
2700
|
+
* Generous, because the cost of re-offering too early is a duplicate apply and
|
|
2701
|
+
* the cost of re-offering too late is a delay nobody dies of.
|
|
2702
|
+
*/
|
|
2703
|
+
const LEASE_SECONDS = Number(process.env.CONNECTOR_LEASE_SECONDS ?? 300);
|
|
2704
|
+
|
|
2705
|
+
/** Constant time, and length-safe: \`timingSafeEqual\` throws on a length mismatch. */
|
|
2706
|
+
function tokenMatches(presented: string): boolean {
|
|
2707
|
+
const a = Buffer.from(presented);
|
|
2708
|
+
const b = Buffer.from(TOKEN);
|
|
2709
|
+
if (a.length !== b.length) return false;
|
|
2710
|
+
return timingSafeEqual(a, b);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
function authorised(req: Request, res: Response): boolean {
|
|
2714
|
+
const header = req.headers.authorization ?? '';
|
|
2715
|
+
const presented = header.startsWith('Bearer ') ? header.slice(7) : '';
|
|
2716
|
+
if (presented && tokenMatches(presented)) return true;
|
|
2717
|
+
// No detail, deliberately. This endpoint should tell an unauthorised caller
|
|
2718
|
+
// nothing at all about whether it exists or what it holds.
|
|
2719
|
+
res.status(401).json({ error: 'Unauthorised' });
|
|
2720
|
+
return false;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
export function startDrawApi(): { port: number } | null {
|
|
2724
|
+
if (connectorTokenMissing()) return null;
|
|
2725
|
+
|
|
2726
|
+
const app = express();
|
|
2727
|
+
app.disable('x-powered-by');
|
|
2728
|
+
|
|
2729
|
+
// A BACKSTOP, AND NOT WHAT KEEPS THIS SHUT.
|
|
2730
|
+
//
|
|
2731
|
+
// What keeps it shut is the loopback bind and the credential, compared in
|
|
2732
|
+
// constant time. This bounds what an attacker who is already on the box can
|
|
2733
|
+
// spend, and it is deliberately far above any real connector: a draw every
|
|
2734
|
+
// two seconds plus an acknowledgement per item is a few hundred a minute at
|
|
2735
|
+
// worst, so a legitimate connector never meets this and a partner who tunes
|
|
2736
|
+
// \`CONNECTOR_POLL_MS\` down still has room.
|
|
2737
|
+
//
|
|
2738
|
+
// No \`trust proxy\` here, unlike the webhook app. There is no proxy in front
|
|
2739
|
+
// of a loopback listener, so \`X-Forwarded-For\` is somebody trying it on, and
|
|
2740
|
+
// trusting it would let them key the limiter on an address of their choosing.
|
|
2741
|
+
//
|
|
2742
|
+
// Attached ONCE, on the app rather than per route. Two references to the same
|
|
2743
|
+
// limiter instance count a request twice and silently halve the ceiling.
|
|
2744
|
+
app.use(rateLimit({
|
|
2745
|
+
windowMs: 60_000,
|
|
2746
|
+
max: 2000,
|
|
2747
|
+
standardHeaders: true,
|
|
2748
|
+
legacyHeaders: false,
|
|
2749
|
+
message: { error: 'Too many requests' },
|
|
2750
|
+
}));
|
|
2751
|
+
|
|
2752
|
+
app.use(express.json({ limit: '2mb' }));
|
|
2753
|
+
|
|
2754
|
+
/**
|
|
2755
|
+
* Take the next batch of work.
|
|
2756
|
+
*
|
|
2757
|
+
* A GET with no side effects would be tidier and would be wrong: drawing has
|
|
2758
|
+
* to stamp the lease, or a connector that asks twice gets the same items and
|
|
2759
|
+
* applies them twice.
|
|
2760
|
+
*/
|
|
2761
|
+
app.post('/draw', (req: Request, res: Response) => {
|
|
2762
|
+
if (!authorised(req, res)) return;
|
|
2763
|
+
const limit = Math.min(Number((req.body as { limit?: unknown }).limit ?? 20) || 20, 100);
|
|
2764
|
+
const items = drawable(limit, LEASE_SECONDS);
|
|
2765
|
+
markDrawn(items.map((i) => i.id));
|
|
2766
|
+
if (items.length > 0) report.info(\`[draw] connector took \${items.length} dispatch(es)\`);
|
|
2767
|
+
return res.json({ items });
|
|
2768
|
+
});
|
|
2769
|
+
|
|
2770
|
+
/**
|
|
2771
|
+
* What the connector did with one item.
|
|
2772
|
+
*
|
|
2773
|
+
* \`applied\` queues the confirm to OneAddress. \`failed\` queues a failed
|
|
2774
|
+
* confirm, which is the honest answer when the partner's own system refused
|
|
2775
|
+
* the change: the consumer is told it did not land rather than being told it
|
|
2776
|
+
* did.
|
|
2777
|
+
*/
|
|
2778
|
+
app.post('/ack', (req: Request, res: Response) => {
|
|
2779
|
+
if (!authorised(req, res)) return;
|
|
2780
|
+
const body = req.body as { id?: unknown; outcome?: unknown; detail?: unknown };
|
|
2781
|
+
const id = typeof body.id === 'string' ? body.id : '';
|
|
2782
|
+
const outcome: InboxOutcome | null =
|
|
2783
|
+
body.outcome === 'applied' || body.outcome === 'failed' ? body.outcome : null;
|
|
2784
|
+
if (!id || !outcome) {
|
|
2785
|
+
return res.status(400).json({ error: 'id and outcome (applied|failed) are required' });
|
|
2786
|
+
}
|
|
2787
|
+
const detail = typeof body.detail === 'string' ? body.detail : null;
|
|
2788
|
+
|
|
2789
|
+
const result = acknowledge(id, outcome, detail);
|
|
2790
|
+
if (!result) return res.status(404).json({ error: 'unknown id' });
|
|
2791
|
+
if (result.alreadyAcknowledged) {
|
|
2792
|
+
// NOT AN ERROR. A connector that is unsure its acknowledgement landed
|
|
2793
|
+
// should retry, and punishing that is how an update ends up applied and
|
|
2794
|
+
// never confirmed.
|
|
2795
|
+
return res.json({ ok: true, duplicate: true });
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
if (result.dispatchId) onAcknowledged(result.dispatchId, outcome);
|
|
2799
|
+
return res.json({ ok: true });
|
|
2800
|
+
});
|
|
2801
|
+
|
|
2802
|
+
/** Enough for the connector to know it is talking to the right receiver. */
|
|
2803
|
+
app.get('/connector/health', (req: Request, res: Response) => {
|
|
2804
|
+
if (!authorised(req, res)) return;
|
|
2805
|
+
return res.json({ status: 'ok', mode: 'inbox' });
|
|
2806
|
+
});
|
|
2807
|
+
|
|
2808
|
+
app.listen(DRAW_PORT, '127.0.0.1', () =>
|
|
2809
|
+
report.info(\`[draw] connector channel \u2192 http://127.0.0.1:\${DRAW_PORT} (loopback only)\`),
|
|
2810
|
+
);
|
|
2811
|
+
return { port: DRAW_PORT };
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
/**
|
|
2815
|
+
* Refuse to open the channel without a credential, and say why.
|
|
2816
|
+
*
|
|
2817
|
+
* Returning null rather than throwing, because a receiver in write-through mode
|
|
2818
|
+
* has no connector and must start perfectly well without one. In inbox mode the
|
|
2819
|
+
* caller turns this into a hard failure, since an inbox with no way to drain it
|
|
2820
|
+
* is worse than a receiver that will not start.
|
|
2821
|
+
*/
|
|
2822
|
+
function connectorTokenMissing(): boolean {
|
|
2823
|
+
if (TOKEN.trim().length >= 16) return false;
|
|
2824
|
+
report.error(
|
|
2825
|
+
'[draw] CONNECTOR_TOKEN is missing or shorter than 16 characters, so the connector ' +
|
|
2826
|
+
'channel was NOT opened. This is its own credential on purpose: it must not be your ' +
|
|
2827
|
+
'webhook secret or your confirm secret, because those are shared with OneAddress and a ' +
|
|
2828
|
+
'leak of either must not also hand somebody your customers\\' addresses.',
|
|
2829
|
+
);
|
|
2830
|
+
return true;
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
/**
|
|
2834
|
+
* Told when an item is acknowledged, so the receiver can confirm to OneAddress.
|
|
2835
|
+
*
|
|
2836
|
+
* A setter rather than an import, because \`server.ts\` owns the confirm queue
|
|
2837
|
+
* and already imports this module: importing it back would be a cycle.
|
|
2838
|
+
*/
|
|
2839
|
+
let onAcknowledged: (dispatchId: string, outcome: InboxOutcome) => void = () => {};
|
|
2840
|
+
|
|
2841
|
+
export function setAcknowledgementHandler(
|
|
2842
|
+
handler: (dispatchId: string, outcome: InboxOutcome) => void,
|
|
2843
|
+
): void {
|
|
2844
|
+
onAcknowledged = handler;
|
|
2845
|
+
}
|
|
2846
|
+
`
|
|
2847
|
+
},
|
|
2848
|
+
{
|
|
2849
|
+
name: "src/inbox.ts",
|
|
2850
|
+
content: `/**
|
|
2851
|
+
* Dispatches waiting for YOUR system to collect them.
|
|
2852
|
+
*
|
|
2853
|
+
* ## What this is, and why it is not the quarantine
|
|
2854
|
+
*
|
|
2855
|
+
* Both tables hold a signature-verified dispatch as the ciphertext that
|
|
2856
|
+
* arrived. They mean opposite things.
|
|
2857
|
+
*
|
|
2858
|
+
* A QUARANTINED dispatch is one the receiver could not open: something is
|
|
2859
|
+
* wrong, usually a key, and it is purged on a window because holding a
|
|
2860
|
+
* consumer's encrypted address forever is a retention decision nobody made.
|
|
2861
|
+
*
|
|
2862
|
+
* An INBOX dispatch is one nothing is wrong with. It arrived intact and is
|
|
2863
|
+
* waiting for the partner's own systems to draw it, apply it in their database
|
|
2864
|
+
* and say so. **It is NEVER aged out**, and that asymmetry is deliberate: an
|
|
2865
|
+
* undrawn update is a consumer whose address has not landed, and deleting it on
|
|
2866
|
+
* a timer loses it silently. The dashboard reports a growing undrawn count as a
|
|
2867
|
+
* fault instead, which is the honest way to make an operator deal with it.
|
|
2868
|
+
*
|
|
2869
|
+
* ## Nothing here is readable
|
|
2870
|
+
*
|
|
2871
|
+
* The rows hold the bytes as they arrived. In inbox mode the receiver holds no
|
|
2872
|
+
* private key at all, so it could not decrypt these if it wanted to. That is
|
|
2873
|
+
* the property the whole split exists for: the component reachable from the
|
|
2874
|
+
* internet cannot read what it stores.
|
|
2875
|
+
*
|
|
2876
|
+
* ## The lease, and what it is NOT for
|
|
2877
|
+
*
|
|
2878
|
+
* A connector that draws a batch and then crashes must not strand it. So a draw
|
|
2879
|
+
* stamps \`drawn_at\`, and an item becomes drawable again once that stamp is
|
|
2880
|
+
* older than the lease. That is CRASH RECOVERY for one connector.
|
|
2881
|
+
*
|
|
2882
|
+
* It is not a design for two connectors drawing at once. Two would each get
|
|
2883
|
+
* their own view of what is drawable between leases, and nothing here stops
|
|
2884
|
+
* them applying the same update twice. If a second connector is ever wanted,
|
|
2885
|
+
* the claim has to become atomic, the way \`confirm-queue.ts\` already does it.
|
|
2886
|
+
* Written down because a lease LOOKS like it handles concurrency and does not.
|
|
2887
|
+
*/
|
|
2888
|
+
import db from './db.js';
|
|
2889
|
+
import { report } from './report.js';
|
|
2890
|
+
|
|
2891
|
+
export type InboxOutcome = 'applied' | 'failed';
|
|
2892
|
+
|
|
2893
|
+
db.exec(\`
|
|
2894
|
+
CREATE TABLE IF NOT EXISTS inbox (
|
|
2895
|
+
id TEXT PRIMARY KEY,
|
|
2896
|
+
dispatch_id TEXT,
|
|
2897
|
+
event TEXT NOT NULL,
|
|
2898
|
+
raw_body TEXT NOT NULL,
|
|
2899
|
+
received_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2900
|
+
drawn_at TEXT,
|
|
2901
|
+
applied_at TEXT,
|
|
2902
|
+
outcome TEXT,
|
|
2903
|
+
detail TEXT
|
|
2904
|
+
);
|
|
2905
|
+
CREATE INDEX IF NOT EXISTS idx_inbox_open
|
|
2906
|
+
ON inbox(applied_at, drawn_at, received_at);
|
|
2907
|
+
\`);
|
|
2908
|
+
|
|
2909
|
+
export interface AcceptInput {
|
|
2910
|
+
key: string;
|
|
2911
|
+
dispatchId: string | null;
|
|
2912
|
+
event: string;
|
|
2913
|
+
rawBody: string;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
/**
|
|
2917
|
+
* Take a dispatch for the connector to collect.
|
|
2918
|
+
*
|
|
2919
|
+
* \`INSERT OR IGNORE\`, keyed on the same dispatch identity everything else here
|
|
2920
|
+
* uses, so a OneAddress retry of a delivery we already hold does not queue the
|
|
2921
|
+
* same update twice.
|
|
2922
|
+
*/
|
|
2923
|
+
export function accept(input: AcceptInput): void {
|
|
2924
|
+
db.prepare(
|
|
2925
|
+
\`INSERT OR IGNORE INTO inbox (id, dispatch_id, event, raw_body)
|
|
2926
|
+
VALUES (?, ?, ?, ?)\`,
|
|
2927
|
+
).run(input.key, input.dispatchId, input.event, input.rawBody);
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
export interface InboxItem {
|
|
2931
|
+
id: string;
|
|
2932
|
+
dispatch_id: string | null;
|
|
2933
|
+
event: string;
|
|
2934
|
+
raw_body: string;
|
|
2935
|
+
received_at: string;
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
/**
|
|
2939
|
+
* What the connector may take now.
|
|
2940
|
+
*
|
|
2941
|
+
* Anything never drawn, plus anything drawn longer ago than the lease and still
|
|
2942
|
+
* unacknowledged, which is the crashed-connector case.
|
|
2943
|
+
*/
|
|
2944
|
+
export function drawable(limit: number, leaseSeconds: number): InboxItem[] {
|
|
2945
|
+
const cutoff = new Date(Date.now() - leaseSeconds * 1000).toISOString();
|
|
2946
|
+
return db.prepare(
|
|
2947
|
+
\`SELECT id, dispatch_id, event, raw_body, received_at
|
|
2948
|
+
FROM inbox
|
|
2949
|
+
WHERE applied_at IS NULL
|
|
2950
|
+
AND (drawn_at IS NULL OR drawn_at < ?)
|
|
2951
|
+
ORDER BY received_at
|
|
2952
|
+
LIMIT ?\`,
|
|
2953
|
+
).all(cutoff, limit) as unknown as InboxItem[];
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
export function markDrawn(ids: string[]): void {
|
|
2957
|
+
if (ids.length === 0) return;
|
|
2958
|
+
const stamp = new Date().toISOString();
|
|
2959
|
+
const stmt = db.prepare('UPDATE inbox SET drawn_at = ? WHERE id = ?');
|
|
2960
|
+
for (const id of ids) stmt.run(stamp, id);
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
/**
|
|
2964
|
+
* The connector's verdict on one item.
|
|
2965
|
+
*
|
|
2966
|
+
* Returns the dispatch id to confirm, or null when there is nothing to tell
|
|
2967
|
+
* OneAddress: an unknown id, or one already acknowledged. Deliberately not an
|
|
2968
|
+
* error, because a connector retrying an acknowledgement it is unsure landed is
|
|
2969
|
+
* doing the right thing and must not be punished for it.
|
|
2970
|
+
*/
|
|
2971
|
+
export function acknowledge(
|
|
2972
|
+
id: string,
|
|
2973
|
+
outcome: InboxOutcome,
|
|
2974
|
+
detail: string | null,
|
|
2975
|
+
): { dispatchId: string | null; alreadyAcknowledged: boolean } | null {
|
|
2976
|
+
const row = db.prepare(
|
|
2977
|
+
'SELECT dispatch_id, applied_at FROM inbox WHERE id = ?',
|
|
2978
|
+
).get(id) as { dispatch_id: string | null; applied_at: string | null } | undefined;
|
|
2979
|
+
if (!row) return null;
|
|
2980
|
+
if (row.applied_at !== null) {
|
|
2981
|
+
return { dispatchId: row.dispatch_id, alreadyAcknowledged: true };
|
|
2982
|
+
}
|
|
2983
|
+
db.prepare(
|
|
2984
|
+
'UPDATE inbox SET applied_at = ?, outcome = ?, detail = ? WHERE id = ?',
|
|
2985
|
+
).run(new Date().toISOString(), outcome, detail?.slice(0, 500) ?? null, id);
|
|
2986
|
+
report.info(\`[inbox] \${id} acknowledged by the connector: \${outcome}\`);
|
|
2987
|
+
return { dispatchId: row.dispatch_id, alreadyAcknowledged: false };
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
/** How many updates are sitting here unapplied. Shown as a fault when non-zero. */
|
|
2991
|
+
export function undrawnCount(): number {
|
|
2992
|
+
const row = db.prepare(
|
|
2993
|
+
'SELECT count(*) AS n FROM inbox WHERE applied_at IS NULL',
|
|
2994
|
+
).get() as { n: number };
|
|
2995
|
+
return row.n;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
/** When the oldest unapplied item arrived, for the age on the dashboard. */
|
|
2999
|
+
export function oldestUndrawn(): string | null {
|
|
3000
|
+
const row = db.prepare(
|
|
3001
|
+
'SELECT min(received_at) AS oldest FROM inbox WHERE applied_at IS NULL',
|
|
3002
|
+
).get() as { oldest: string | null };
|
|
3003
|
+
return row.oldest;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
/**
|
|
3007
|
+
* NOTHING PURGES THIS TABLE, and the absence is the design.
|
|
3008
|
+
*
|
|
3009
|
+
* \`purgeQuarantine\` and \`purgeDelivered\` both exist one file over. There is no
|
|
3010
|
+
* \`purgeInbox\`, and there should not be: every row here is an address change a
|
|
3011
|
+
* consumer paid for that has not reached the partner's system yet. Ageing one
|
|
3012
|
+
* out would delete the only copy anybody still has.
|
|
3013
|
+
*
|
|
3014
|
+
* Rows that HAVE been applied are kept too, because they are the partner's own
|
|
3015
|
+
* record of what they were sent and when they acted on it. If that ever needs
|
|
3016
|
+
* bounding, bound the APPLIED rows and never the open ones.
|
|
3017
|
+
*/
|
|
3018
|
+
export function appliedCount(): number {
|
|
3019
|
+
const row = db.prepare(
|
|
3020
|
+
'SELECT count(*) AS n FROM inbox WHERE applied_at IS NOT NULL',
|
|
3021
|
+
).get() as { n: number };
|
|
3022
|
+
return row.n;
|
|
3023
|
+
}
|
|
3024
|
+
`
|
|
3025
|
+
},
|
|
3026
|
+
{
|
|
3027
|
+
name: "src/tally.ts",
|
|
3028
|
+
content: `/**
|
|
3029
|
+
* How many dispatches arrived, landed, and did not.
|
|
3030
|
+
*
|
|
3031
|
+
* ## THE BUG THIS REPLACES, WHICH A PARTNER FOUND IN ABOUT A MINUTE
|
|
3032
|
+
*
|
|
3033
|
+
* The dashboard used to derive these by reading its own log feed: a line
|
|
3034
|
+
* matching a failure pattern counted as one arrival and one failure. The
|
|
3035
|
+
* comment beside it asserted the invariant that made that sound: "every
|
|
3036
|
+
* dispatch produces exactly one of these".
|
|
3037
|
+
*
|
|
3038
|
+
* Then replay shipped. A replay produces exactly the same lines an arrival
|
|
3039
|
+
* does, because it IS a delivery, back in through the front door. Somebody
|
|
3040
|
+
* pressed [r] sixty times against a key that was never going to work and
|
|
3041
|
+
* watched \`received\` climb from 5 to 67 and \`failed\` from 1 to 63. Nothing was
|
|
3042
|
+
* arriving. The receiver was counting its own attempts to fix itself.
|
|
3043
|
+
*
|
|
3044
|
+
* The same fault was always there for a cause nobody had to trigger by hand:
|
|
3045
|
+
* OneAddress RETRIES a 422, so a wrong key inflated these counters on its own,
|
|
3046
|
+
* quietly, every few minutes.
|
|
3047
|
+
*
|
|
3048
|
+
* ## Why counting here fixes the class rather than the instance
|
|
3049
|
+
*
|
|
3050
|
+
* Narration is the wrong source. A log line says what happened, not what it
|
|
3051
|
+
* happened TO, so two lines about one dispatch are indistinguishable from two
|
|
3052
|
+
* dispatches. This keys on the DISPATCH, so every re-delivery of it - a
|
|
3053
|
+
* replay, a OneAddress retry, a partner curling the same body twice - lands on
|
|
3054
|
+
* the entry that is already there.
|
|
3055
|
+
*
|
|
3056
|
+
* That also makes a replay that finally WORKS do the right thing on its own:
|
|
3057
|
+
* the entry flips from failed to applied, and the totals move without anything
|
|
3058
|
+
* having to know a replay was involved.
|
|
3059
|
+
*
|
|
3060
|
+
* ## What is counted, stated because the footer does not have room to
|
|
3061
|
+
*
|
|
3062
|
+
* An \`address.updated\` that was stored is \`applied\`. Anything that could not be
|
|
3063
|
+
* opened, or was refused, is \`failed\`. \`received\` is how many distinct
|
|
3064
|
+
* dispatches reached one of those two, so \`received = applied + failed\` holds
|
|
3065
|
+
* by construction rather than by hoping.
|
|
3066
|
+
*
|
|
3067
|
+
* An \`address.verify\` is deliberately none of them: it answers a question and
|
|
3068
|
+
* changes nothing, so counting it as an application would overstate what this
|
|
3069
|
+
* receiver has done. It still shows in the activity log.
|
|
3070
|
+
*
|
|
3071
|
+
* Since boot, like every other figure on that footer. The held count beside
|
|
3072
|
+
* them is not: the quarantine is on disk and survives a restart.
|
|
3073
|
+
*/
|
|
3074
|
+
|
|
3075
|
+
export type Outcome = 'applied' | 'failed';
|
|
3076
|
+
|
|
3077
|
+
/**
|
|
3078
|
+
* Bounded, because this is memory and a busy receiver runs for months.
|
|
3079
|
+
*
|
|
3080
|
+
* At the cap the oldest entry goes, exactly as \`seenDispatches\` does, and the
|
|
3081
|
+
* totals then describe the most recent 5000 dispatches rather than all of them.
|
|
3082
|
+
* That is the honest trade for a footer: an operator reads it to see whether
|
|
3083
|
+
* things are working now, and nobody audits from it.
|
|
3084
|
+
*/
|
|
3085
|
+
const MAX_TRACKED = 5000;
|
|
3086
|
+
const outcomes = new Map<string, Outcome>();
|
|
3087
|
+
|
|
3088
|
+
export function recordOutcome(dispatchKey: string, outcome: Outcome): void {
|
|
3089
|
+
if (!outcomes.has(dispatchKey) && outcomes.size >= MAX_TRACKED) {
|
|
3090
|
+
const oldest = outcomes.keys().next().value;
|
|
3091
|
+
if (oldest !== undefined) outcomes.delete(oldest);
|
|
3092
|
+
}
|
|
3093
|
+
// \`set\` on an existing key overwrites in place and keeps its insertion order,
|
|
3094
|
+
// which is what makes a replay that succeeds flip failed to applied rather
|
|
3095
|
+
// than adding a second entry.
|
|
3096
|
+
outcomes.set(dispatchKey, outcome);
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
export interface Tally {
|
|
3100
|
+
received: number;
|
|
3101
|
+
applied: number;
|
|
3102
|
+
failed: number;
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
export function tally(): Tally {
|
|
3106
|
+
let applied = 0;
|
|
3107
|
+
for (const outcome of outcomes.values()) if (outcome === 'applied') applied += 1;
|
|
3108
|
+
return { received: outcomes.size, applied, failed: outcomes.size - applied };
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
/** Test seam. Never called by the receiver. */
|
|
3112
|
+
export function resetTally(): void {
|
|
3113
|
+
outcomes.clear();
|
|
3114
|
+
}
|
|
2289
3115
|
`
|
|
2290
3116
|
},
|
|
2291
3117
|
{
|
|
@@ -2332,9 +3158,11 @@ main().catch((err) => {
|
|
|
2332
3158
|
* replay would apply an address for an account they do not recognise. It stays
|
|
2333
3159
|
* a refusal.
|
|
2334
3160
|
*/
|
|
2335
|
-
import db from './db.js';
|
|
3161
|
+
import db, { ensureColumn, parseStoredTime } from './db.js';
|
|
2336
3162
|
import { report } from './report.js';
|
|
2337
3163
|
import { createHash } from 'node:crypto';
|
|
3164
|
+
import { writeFileSync } from 'node:fs';
|
|
3165
|
+
import { join } from 'node:path';
|
|
2338
3166
|
|
|
2339
3167
|
/**
|
|
2340
3168
|
* Why a dispatch could not be applied. Shown verbatim on the dashboard.
|
|
@@ -2376,11 +3204,22 @@ db.exec(\`
|
|
|
2376
3204
|
* the dispatch header where there is one, and on a hash of the body where there
|
|
2377
3205
|
* is not, so a retry updates the existing row instead of adding to a pile.
|
|
2378
3206
|
*/
|
|
2379
|
-
function
|
|
3207
|
+
export function dispatchKey(dispatchId: string | null, rawBody: string): string {
|
|
2380
3208
|
if (dispatchId && dispatchId.trim()) return \`d:\${dispatchId.trim()}\`;
|
|
2381
3209
|
return \`h:\${createHash('sha256').update(rawBody).digest('hex').slice(0, 32)}\`;
|
|
2382
3210
|
}
|
|
2383
3211
|
|
|
3212
|
+
/**
|
|
3213
|
+
* Attempts, so the panel can say how hard this has been tried.
|
|
3214
|
+
*
|
|
3215
|
+
* Added after the quarantine shipped, hence the migration: a partner already
|
|
3216
|
+
* running 2.1.3 has the table without it. Somebody pressed [r] sixty times
|
|
3217
|
+
* against a key that could never work, and the only figure that moved was one
|
|
3218
|
+
* the dashboard was computing wrongly. An attempt count on the row would have
|
|
3219
|
+
* said so immediately.
|
|
3220
|
+
*/
|
|
3221
|
+
ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
|
|
3222
|
+
|
|
2384
3223
|
export interface QuarantineInput {
|
|
2385
3224
|
dispatchId: string | null;
|
|
2386
3225
|
event: string;
|
|
@@ -2398,7 +3237,7 @@ export interface QuarantineInput {
|
|
|
2398
3237
|
* caller must be able to answer OneAddress whatever happens here.
|
|
2399
3238
|
*/
|
|
2400
3239
|
export function quarantine(input: QuarantineInput): void {
|
|
2401
|
-
const id =
|
|
3240
|
+
const id = dispatchKey(input.dispatchId, input.rawBody);
|
|
2402
3241
|
try {
|
|
2403
3242
|
db.prepare(
|
|
2404
3243
|
\`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
|
|
@@ -2435,12 +3274,14 @@ export interface HeldDispatch {
|
|
|
2435
3274
|
detail: string | null;
|
|
2436
3275
|
received_at: string;
|
|
2437
3276
|
last_error: string | null;
|
|
3277
|
+
attempts: number;
|
|
2438
3278
|
}
|
|
2439
3279
|
|
|
2440
3280
|
/** Everything still held, oldest first. */
|
|
2441
3281
|
export function heldDispatches(limit = 50): HeldDispatch[] {
|
|
2442
3282
|
return db.prepare(
|
|
2443
|
-
\`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
|
|
3283
|
+
\`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
|
|
3284
|
+
received_at, last_error, attempts
|
|
2444
3285
|
FROM quarantine
|
|
2445
3286
|
WHERE replayed_at IS NULL
|
|
2446
3287
|
ORDER BY received_at
|
|
@@ -2465,15 +3306,39 @@ export function heldCount(): number {
|
|
|
2465
3306
|
*/
|
|
2466
3307
|
export function heldSummary(): string[] {
|
|
2467
3308
|
const rows = db.prepare(
|
|
2468
|
-
\`SELECT reason, key_id, count(*) AS n
|
|
3309
|
+
\`SELECT reason, key_id, count(*) AS n,
|
|
3310
|
+
max(attempts) AS tries, min(received_at) AS oldest
|
|
2469
3311
|
FROM quarantine
|
|
2470
3312
|
WHERE replayed_at IS NULL
|
|
2471
3313
|
GROUP BY reason, key_id
|
|
2472
3314
|
ORDER BY n DESC\`,
|
|
2473
|
-
).all() as unknown as {
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
)
|
|
3315
|
+
).all() as unknown as {
|
|
3316
|
+
reason: string; key_id: string | null; n: number; tries: number; oldest: string;
|
|
3317
|
+
}[];
|
|
3318
|
+
return rows.map((r) => {
|
|
3319
|
+
const cause = \`\${r.n} \xD7 \${r.reason}\${r.key_id ? \` (key_id \${r.key_id})\` : ''}\`;
|
|
3320
|
+
// TRIED AND AGE, because the count alone does not say whether anything is
|
|
3321
|
+
// being done about it. A partner watching "1 \xD7 decrypt_failed" with no
|
|
3322
|
+
// other figure moving cannot tell a replay that is working from one that
|
|
3323
|
+
// is not; "61 tries" answers that without reading the log.
|
|
3324
|
+
const tried = r.tries > 0 ? \`, \${r.tries} \${r.tries === 1 ? 'try' : 'tries'}\` : '';
|
|
3325
|
+
return \`\${cause}\${tried}, first seen \${describeAge(r.oldest)}\`;
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
/** "3m ago", "2h ago". Coarse on purpose: nobody acts on seconds. */
|
|
3330
|
+
export function describeAge(iso: string): string {
|
|
3331
|
+
// \`parseStoredTime\`, not \`new Date\`. See its docstring: this column is written
|
|
3332
|
+
// by a SQL default in SQLite's zone-less UTC format, which V8 parses as local
|
|
3333
|
+
// time, so a fresh row read as hours old by the machine's UTC offset.
|
|
3334
|
+
const ms = Date.now() - parseStoredTime(iso).getTime();
|
|
3335
|
+
if (!Number.isFinite(ms) || ms < 0) return 'just now';
|
|
3336
|
+
const mins = Math.floor(ms / 60_000);
|
|
3337
|
+
if (mins < 1) return 'moments ago';
|
|
3338
|
+
if (mins < 60) return \`\${mins}m ago\`;
|
|
3339
|
+
const hours = Math.floor(mins / 60);
|
|
3340
|
+
if (hours < 48) return \`\${hours}h ago\`;
|
|
3341
|
+
return \`\${Math.floor(hours / 24)}d ago\`;
|
|
2477
3342
|
}
|
|
2478
3343
|
|
|
2479
3344
|
export function markReplayed(id: string): void {
|
|
@@ -2484,7 +3349,7 @@ export function markReplayed(id: string): void {
|
|
|
2484
3349
|
|
|
2485
3350
|
/** A replay that failed the same way stays held, with the new reason recorded. */
|
|
2486
3351
|
export function markReplayFailed(id: string, error: string): void {
|
|
2487
|
-
db.prepare('UPDATE quarantine SET last_error =
|
|
3352
|
+
db.prepare('UPDATE quarantine SET last_error = ?, attempts = attempts + 1 WHERE id = ?')
|
|
2488
3353
|
.run(error.slice(0, 500), id);
|
|
2489
3354
|
}
|
|
2490
3355
|
|
|
@@ -2502,13 +3367,27 @@ export function markReplayFailed(id: string, error: string): void {
|
|
|
2502
3367
|
* is an update the partner never applied and is now no longer able to.
|
|
2503
3368
|
*/
|
|
2504
3369
|
export function purgeQuarantine(days: number): { replayed: number; unreplayed: number } {
|
|
2505
|
-
|
|
3370
|
+
// THE COMPARISON HAPPENS INSIDE SQLITE, IN SQLITE'S OWN FORMAT, and that is
|
|
3371
|
+
// the fix rather than a tidy-up.
|
|
3372
|
+
//
|
|
3373
|
+
// It used to build an ISO cutoff in JS and compare it against a column
|
|
3374
|
+
// written by a SQL default. SQLite compares those as STRINGS, and the two
|
|
3375
|
+
// formats differ at the separator: ' ' is 0x20 and 'T' is 0x54. So on the
|
|
3376
|
+
// boundary DAY a row still inside the window sorted BEFORE the cutoff and was
|
|
3377
|
+
// deleted, up to about a day early. The table holds a consumer's encrypted
|
|
3378
|
+
// address under a window we have written down, so "saved by the date prefix
|
|
3379
|
+
// usually differing" is not good enough.
|
|
3380
|
+
//
|
|
3381
|
+
// \`datetime('now', ?)\` keeps both sides in one format and one clock. The
|
|
3382
|
+
// modifier is a bound parameter rather than interpolated, so \`days\` cannot
|
|
3383
|
+
// reach the SQL text.
|
|
3384
|
+
const cutoffExpr = \`-\${Math.max(0, Math.floor(days))} days\`;
|
|
2506
3385
|
const doomed = db.prepare(
|
|
2507
|
-
|
|
2508
|
-
).all(
|
|
3386
|
+
"SELECT id, replayed_at FROM quarantine WHERE received_at < datetime('now', ?)",
|
|
3387
|
+
).all(cutoffExpr) as unknown as { id: string; replayed_at: string | null }[];
|
|
2509
3388
|
if (doomed.length === 0) return { replayed: 0, unreplayed: 0 };
|
|
2510
3389
|
|
|
2511
|
-
db.prepare(
|
|
3390
|
+
db.prepare("DELETE FROM quarantine WHERE received_at < datetime('now', ?)").run(cutoffExpr);
|
|
2512
3391
|
|
|
2513
3392
|
const unreplayed = doomed.filter((d) => d.replayed_at === null).length;
|
|
2514
3393
|
const replayed = doomed.length - unreplayed;
|
|
@@ -2524,6 +3403,48 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
|
|
|
2524
3403
|
return { replayed, unreplayed };
|
|
2525
3404
|
}
|
|
2526
3405
|
|
|
3406
|
+
/**
|
|
3407
|
+
* Write the held list to a file, WITHOUT the payloads.
|
|
3408
|
+
*
|
|
3409
|
+
* ## Why the ciphertext is not in here
|
|
3410
|
+
*
|
|
3411
|
+
* The obvious export carries the payload so the fault can be replayed
|
|
3412
|
+
* somewhere else. It also puts a copy of a consumer's encrypted address in a
|
|
3413
|
+
* file that the retention window cannot reach: the quarantine purges its rows,
|
|
3414
|
+
* and nothing purges an export somebody emailed to support and left in a
|
|
3415
|
+
* downloads folder. The whole point of the retention rule is that a held
|
|
3416
|
+
* address does not sit anywhere indefinitely, and an export that leaks past it
|
|
3417
|
+
* quietly undoes that.
|
|
3418
|
+
*
|
|
3419
|
+
* Nothing in here is personal. A dispatch id, what kind of event it was, why it
|
|
3420
|
+
* could not be opened, which key it asked for, and when. That is the whole of
|
|
3421
|
+
* what diagnoses a key problem, and it is safe to paste into a support ticket
|
|
3422
|
+
* or send to us, which is what an export is for.
|
|
3423
|
+
*/
|
|
3424
|
+
export function exportHeld(directory: string): { path: string; count: number } {
|
|
3425
|
+
const rows = heldDispatches(500);
|
|
3426
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3427
|
+
const path = join(directory, \`oneaddress-faults-\${stamp}.json\`);
|
|
3428
|
+
writeFileSync(path, JSON.stringify({
|
|
3429
|
+
exported_at: new Date().toISOString(),
|
|
3430
|
+
note: 'Metadata only. The held payloads are deliberately not included: they are encrypted consumer addresses and stay under the receiver retention window.',
|
|
3431
|
+
held: rows.map((r) => ({
|
|
3432
|
+
id: r.id,
|
|
3433
|
+
dispatch_id: r.dispatch_id,
|
|
3434
|
+
event: r.event,
|
|
3435
|
+
reason: r.reason,
|
|
3436
|
+
key_id: r.key_id,
|
|
3437
|
+
received_at: r.received_at,
|
|
3438
|
+
age: describeAge(r.received_at),
|
|
3439
|
+
attempts: r.attempts,
|
|
3440
|
+
detail: r.detail,
|
|
3441
|
+
last_error: r.last_error,
|
|
3442
|
+
})),
|
|
3443
|
+
}, null, 2));
|
|
3444
|
+
report.info(\`[quarantine] exported \${rows.length} held dispatch(es) to \${path}\`);
|
|
3445
|
+
return { path, count: rows.length };
|
|
3446
|
+
}
|
|
3447
|
+
|
|
2527
3448
|
/**
|
|
2528
3449
|
* Re-run every held dispatch through the handler.
|
|
2529
3450
|
*
|
|
@@ -2533,14 +3454,25 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
|
|
|
2533
3454
|
* on the second call.
|
|
2534
3455
|
*/
|
|
2535
3456
|
export async function replayHeld(
|
|
2536
|
-
apply: (rawBody: string) => Promise<void>,
|
|
3457
|
+
apply: (rawBody: string, dispatchId: string | null) => Promise<void>,
|
|
2537
3458
|
limit = 50,
|
|
2538
3459
|
): Promise<{ applied: number; failed: number }> {
|
|
2539
3460
|
let applied = 0;
|
|
2540
3461
|
let failed = 0;
|
|
2541
3462
|
for (const row of heldDispatches(limit)) {
|
|
2542
3463
|
try {
|
|
2543
|
-
|
|
3464
|
+
// THE ROW'S OWN DISPATCH ID, not one re-derived from the body.
|
|
3465
|
+
//
|
|
3466
|
+
// \`redeliver\` used to parse \`dispatch_id\` out of the JSON and send that
|
|
3467
|
+
// as the header. A dispatch that arrived WITHOUT the header was keyed on
|
|
3468
|
+
// a hash of its body; the replay then supplied a header from the body,
|
|
3469
|
+
// so the re-arrival was keyed by id instead, landed in a DIFFERENT row,
|
|
3470
|
+
// and one held dispatch became two. Seen on a real receiver: \`1 \xD7
|
|
3471
|
+
// decrypt_failed\` became \`2 \xD7\` on the first press of [r].
|
|
3472
|
+
//
|
|
3473
|
+
// Identity has to come from one place. This row already knows what it
|
|
3474
|
+
// arrived as.
|
|
3475
|
+
await apply(row.raw_body, row.dispatch_id);
|
|
2544
3476
|
markReplayed(row.id);
|
|
2545
3477
|
applied += 1;
|
|
2546
3478
|
report.info(\`[replay] applied \${row.event} \${row.dispatch_id ?? row.id}\`);
|
|
@@ -2881,10 +3813,31 @@ import { report } from './report.js';
|
|
|
2881
3813
|
import { readFileSync } from 'node:fs';
|
|
2882
3814
|
import { join } from 'node:path';
|
|
2883
3815
|
|
|
3816
|
+
/**
|
|
3817
|
+
* WRITE-THROUGH or INBOX, and both are supported on purpose.
|
|
3818
|
+
*
|
|
3819
|
+
* \`write-through\` is what this receiver has always done: decrypt the dispatch,
|
|
3820
|
+
* apply it through \`CustomerStore\`, confirm to OneAddress. Right for a sole
|
|
3821
|
+
* trader and for anyone happy for the receiver to reach their data directly.
|
|
3822
|
+
*
|
|
3823
|
+
* \`inbox\` is for a company with change control over its customer master. The
|
|
3824
|
+
* receiver verifies the signature, stores the dispatch AS IT ARRIVED, and holds
|
|
3825
|
+
* no private key at all. The partner's own connector draws it, decrypts it,
|
|
3826
|
+
* applies it in their database, and acknowledges; only then does the receiver
|
|
3827
|
+
* confirm to OneAddress. The component reachable from the internet cannot read
|
|
3828
|
+
* what it holds.
|
|
3829
|
+
*
|
|
3830
|
+
* Nothing on the wire differs between them. The protocol already separates
|
|
3831
|
+
* delivery from application: the webhook 200 acknowledges delivery and the
|
|
3832
|
+
* confirm reports application. Write-through simply collapses the two.
|
|
3833
|
+
*/
|
|
3834
|
+
export type ReceiverMode = 'write-through' | 'inbox';
|
|
3835
|
+
|
|
2884
3836
|
export type ReceiverConfig = {
|
|
2885
3837
|
partnerId: string;
|
|
2886
3838
|
oneAddressApi: string;
|
|
2887
3839
|
verifiesAccountReference: boolean;
|
|
3840
|
+
mode: ReceiverMode;
|
|
2888
3841
|
};
|
|
2889
3842
|
|
|
2890
3843
|
const DEFAULTS: ReceiverConfig = {
|
|
@@ -2895,8 +3848,16 @@ const DEFAULTS: ReceiverConfig = {
|
|
|
2895
3848
|
// is missing entirely \u2014 in which case answering account.verify is the safer,
|
|
2896
3849
|
// more useful default than silently returning "not checked".
|
|
2897
3850
|
verifiesAccountReference: true,
|
|
3851
|
+
// DEFAULTS TO WHAT THE RECEIVER HAS ALWAYS DONE. Inbox mode needs a connector
|
|
3852
|
+
// running and a key living somewhere else; a receiver that silently switched
|
|
3853
|
+
// into it would accept dispatches nothing ever collects.
|
|
3854
|
+
mode: 'write-through',
|
|
2898
3855
|
};
|
|
2899
3856
|
|
|
3857
|
+
function parseMode(value: unknown): ReceiverMode | undefined {
|
|
3858
|
+
return value === 'inbox' || value === 'write-through' ? value : undefined;
|
|
3859
|
+
}
|
|
3860
|
+
|
|
2900
3861
|
function stripTrailingSlash(s: string): string {
|
|
2901
3862
|
return s.endsWith('/') ? s.slice(0, -1) : s;
|
|
2902
3863
|
}
|
|
@@ -2909,6 +3870,8 @@ function loadConfigFile(): Partial<ReceiverConfig> {
|
|
|
2909
3870
|
if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
|
|
2910
3871
|
if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
|
|
2911
3872
|
if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
|
|
3873
|
+
const mode = parseMode(parsed.mode);
|
|
3874
|
+
if (mode) out.mode = mode;
|
|
2912
3875
|
return out;
|
|
2913
3876
|
} catch {
|
|
2914
3877
|
// No config file (or unreadable / malformed): fall back to env + defaults.
|
|
@@ -2925,10 +3888,16 @@ export const config: ReceiverConfig = {
|
|
|
2925
3888
|
process.env.VERIFIES_ACCOUNT_REFERENCE != null
|
|
2926
3889
|
? process.env.VERIFIES_ACCOUNT_REFERENCE === 'true'
|
|
2927
3890
|
: (fromFile.verifiesAccountReference ?? DEFAULTS.verifiesAccountReference),
|
|
3891
|
+
// An UNRECOGNISED value falls back to write-through rather than failing, and
|
|
3892
|
+
// the startup line below says which mode is live either way, so a typo shows
|
|
3893
|
+
// up as "not the mode I asked for" rather than as a receiver that will not
|
|
3894
|
+
// start. Silent is the thing to avoid, not strict.
|
|
3895
|
+
mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
|
|
2928
3896
|
};
|
|
2929
3897
|
|
|
2930
3898
|
report.info(
|
|
2931
|
-
'[config] loaded (
|
|
3899
|
+
'[config] loaded (mode=' + config.mode +
|
|
3900
|
+
', oneAddressApi=' + config.oneAddressApi +
|
|
2932
3901
|
', verifiesAccountReference=' + config.verifiesAccountReference + ')',
|
|
2933
3902
|
);
|
|
2934
3903
|
`
|
|
@@ -3044,6 +4013,30 @@ export interface StoredCustomer {
|
|
|
3044
4013
|
address: string;
|
|
3045
4014
|
}
|
|
3046
4015
|
|
|
4016
|
+
/**
|
|
4017
|
+
* Did this dispatch actually change anything?
|
|
4018
|
+
*
|
|
4019
|
+
* THE ONLY QUESTION THE RECEIVER MAY ASK ABOUT AN ADDRESS, and the reason it
|
|
4020
|
+
* exists here rather than inside a store implementation is that the answer is
|
|
4021
|
+
* a single boolean the dashboard can show, where the two addresses are a
|
|
4022
|
+
* customer's home and must not travel any further than the apply.
|
|
4023
|
+
*
|
|
4024
|
+
* Order- and case-insensitive over the WHOLE object, so it keeps working
|
|
4025
|
+
* whatever fields OneAddress adds. \`saveAddress\` returns the address it
|
|
4026
|
+
* replaced; feed that and the incoming one in here, show the boolean, and let
|
|
4027
|
+
* both go.
|
|
4028
|
+
*/
|
|
4029
|
+
export function sameAddress(a: Address, b: Address): boolean {
|
|
4030
|
+
const canonical = (x: Address): string =>
|
|
4031
|
+
JSON.stringify(
|
|
4032
|
+
Object.entries(x)
|
|
4033
|
+
.filter(([, v]) => typeof v === 'string' && (v as string).trim() !== '')
|
|
4034
|
+
.map(([k, v]) => [k.toLowerCase(), (v as string).trim().toLowerCase().replace(/\\s+/g, ' ')] as [string, string])
|
|
4035
|
+
.sort((p, q) => p[0].localeCompare(q[0])),
|
|
4036
|
+
);
|
|
4037
|
+
return canonical(a) === canonical(b);
|
|
4038
|
+
}
|
|
4039
|
+
|
|
3047
4040
|
export type AccountVerdict = 'match' | 'no_match' | 'no_account';
|
|
3048
4041
|
export type VerifyResult = 'match' | 'mismatch' | 'not_found';
|
|
3049
4042
|
|
|
@@ -3184,7 +4177,7 @@ export interface CustomerStore {
|
|
|
3184
4177
|
import { readFileSync } from 'node:fs';
|
|
3185
4178
|
import { join } from 'node:path';
|
|
3186
4179
|
import { report } from './report.js';
|
|
3187
|
-
import db, { accountKey, dec, enc, encrypted, isEncrypted, once } from './db.js';
|
|
4180
|
+
import db, { accountKey, dec, enc, encrypted, ensureColumn, isEncrypted, once } from './db.js';
|
|
3188
4181
|
import type {
|
|
3189
4182
|
AccountVerdict,
|
|
3190
4183
|
Address,
|
|
@@ -3234,13 +4227,6 @@ db.exec(\`
|
|
|
3234
4227
|
// Add any missing columns here so the handler upgrades its own schema instead of
|
|
3235
4228
|
// forcing you to delete the database on every change \u2014 the behaviour a
|
|
3236
4229
|
// 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
4230
|
ensureColumn('customers', 'address', "TEXT NOT NULL DEFAULT '{}'");
|
|
3245
4231
|
ensureColumn('customers', 'updated_at', 'TEXT'); // nullable on migrate; set on write
|
|
3246
4232
|
ensureColumn('customers', 'account_key', 'TEXT');
|
|
@@ -3544,7 +4530,12 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
|
|
|
3544
4530
|
// Metadata only \u2014 the address itself is personal information, so the key is
|
|
3545
4531
|
// logged and the address never is. Centralised log aggregation turns every
|
|
3546
4532
|
// log line into a place customer addresses can be read.
|
|
3547
|
-
|
|
4533
|
+
// The ACCOUNT REFERENCE, or a marker - never the name \`acct\` may have fallen
|
|
4534
|
+
// back to. The row is legitimately keyed on the name when you do not verify
|
|
4535
|
+
// account references (see the docstring above); logging it is a different
|
|
4536
|
+
// decision, and the wrong one.
|
|
4537
|
+
const loggable = (customer.accountNumber ?? '').trim() || '(name-keyed record)';
|
|
4538
|
+
report.info(\`[store] saved address for \${loggable}\${customer.loaRef ? \` (loa_ref \${customer.loaRef})\` : ''}\`);
|
|
3548
4539
|
|
|
3549
4540
|
return previous;
|
|
3550
4541
|
}
|
|
@@ -3631,11 +4622,13 @@ import {
|
|
|
3631
4622
|
// THE ONLY LINE THAT NAMES AN IMPLEMENTATION. Point this at your own module
|
|
3632
4623
|
// exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
|
|
3633
4624
|
// the protocol layer changes.
|
|
3634
|
-
import { store } from './store.js';
|
|
3635
|
-
import {
|
|
4625
|
+
import { store as writeThroughStore } from './store.js';
|
|
4626
|
+
import { connectorStore, setCurrentRawBody } from './connector-store.js';
|
|
4627
|
+
import { noteChange } from './tui.js';
|
|
3636
4628
|
import { config } from './config.js';
|
|
3637
4629
|
import { safeOneAddressCallbackUrl } from './callback-url.js';
|
|
3638
|
-
import {
|
|
4630
|
+
import { sameAddress } from './customer-store.js';
|
|
4631
|
+
import { configuredKeyIds, describeKeys, keyFailureAdvice, resolvePrivateKey } from './keys.js';
|
|
3639
4632
|
import {
|
|
3640
4633
|
drainConfirms,
|
|
3641
4634
|
enqueueConfirm,
|
|
@@ -3644,12 +4637,27 @@ import {
|
|
|
3644
4637
|
type ConfirmStatus,
|
|
3645
4638
|
} from './confirm-queue.js';
|
|
3646
4639
|
import {
|
|
4640
|
+
dispatchKey,
|
|
3647
4641
|
heldCount,
|
|
3648
4642
|
purgeQuarantine,
|
|
3649
4643
|
quarantine,
|
|
3650
4644
|
replayHeld,
|
|
3651
4645
|
type QuarantineReason,
|
|
3652
4646
|
} from './quarantine.js';
|
|
4647
|
+
import { recordOutcome, tally } from './tally.js';
|
|
4648
|
+
import { accept as acceptIntoInbox, oldestUndrawn, undrawnCount } from './inbox.js';
|
|
4649
|
+
import { setAcknowledgementHandler, startDrawApi } from './draw-api.js';
|
|
4650
|
+
|
|
4651
|
+
/**
|
|
4652
|
+
* WHICH STORE ANSWERS, chosen once at startup.
|
|
4653
|
+
*
|
|
4654
|
+
* Inbox mode does not branch the handler. It swaps the implementation of the
|
|
4655
|
+
* contract in \`customer-store.ts\`, so every call site below is identical in
|
|
4656
|
+
* both modes and the mode lives in exactly one place. \`connectorStore\` asks the
|
|
4657
|
+
* partner's connector and refuses to write; \`writeThroughStore\` is the bundled
|
|
4658
|
+
* SQLite one this receiver has always used.
|
|
4659
|
+
*/
|
|
4660
|
+
const store = config.mode === 'inbox' ? connectorStore : writeThroughStore;
|
|
3653
4661
|
|
|
3654
4662
|
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? '';
|
|
3655
4663
|
const PARTNER_PRIVATE_KEY = process.env.PARTNER_PRIVATE_KEY_PEM ?? '';
|
|
@@ -3669,11 +4677,38 @@ const PORT = Number(process.env.PORT ?? 3001);
|
|
|
3669
4677
|
const ONEADDRESS_API = config.oneAddressApi;
|
|
3670
4678
|
const CONFIRM_SECRET = process.env.CONFIRM_SECRET || WEBHOOK_SECRET;
|
|
3671
4679
|
|
|
3672
|
-
if (!WEBHOOK_SECRET || !
|
|
4680
|
+
if (!WEBHOOK_SECRET || !PARTNER_ID) {
|
|
3673
4681
|
report.error('[startup] Missing required env vars. Check your .env file.');
|
|
3674
4682
|
process.exit(1);
|
|
3675
4683
|
}
|
|
3676
4684
|
|
|
4685
|
+
// THE KEY REQUIREMENT INVERTS WITH THE MODE, and this is the assertion that
|
|
4686
|
+
// makes inbox mode mean something.
|
|
4687
|
+
//
|
|
4688
|
+
// Write-through cannot open a single dispatch without a private key, so a
|
|
4689
|
+
// missing one is fatal and always was. Inbox mode's entire property is that the
|
|
4690
|
+
// component reachable from the internet CANNOT read what it holds, and a key
|
|
4691
|
+
// sitting in this process's environment makes that false \u2014 silently, while
|
|
4692
|
+
// every test still passes and every dispatch still lands. Nothing would ever
|
|
4693
|
+
// surface it, because in inbox mode nothing here attempts a decrypt to fail.
|
|
4694
|
+
//
|
|
4695
|
+
// So it refuses to start and names the fix. A partner moving from write-through
|
|
4696
|
+
// to inbox has one edit to make, and being told about it once beats believing a
|
|
4697
|
+
// property they do not have.
|
|
4698
|
+
if (config.mode === 'inbox') {
|
|
4699
|
+
const strayKeys = configuredKeyIds();
|
|
4700
|
+
if (PARTNER_PRIVATE_KEY || strayKeys.length > 0) {
|
|
4701
|
+
report.error('[startup] mode is \`inbox\`, but this receiver has a private key in its environment.');
|
|
4702
|
+
report.error('[startup] Inbox mode exists so the internet-facing process CANNOT read what it stores.');
|
|
4703
|
+
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.');
|
|
4704
|
+
process.exit(1);
|
|
4705
|
+
}
|
|
4706
|
+
} else if (!PARTNER_PRIVATE_KEY) {
|
|
4707
|
+
report.error('[startup] PARTNER_PRIVATE_KEY_PEM is missing. Check your .env file.');
|
|
4708
|
+
report.error('[startup] (A receiver that should hold no key at all wants RECEIVER_MODE=inbox.)');
|
|
4709
|
+
process.exit(1);
|
|
4710
|
+
}
|
|
4711
|
+
|
|
3677
4712
|
// Startup self-check. Two faults were historically INVISIBLE until a live
|
|
3678
4713
|
// dispatch, and both read afterwards as "partner key mismatch" deep inside a
|
|
3679
4714
|
// try/catch, which is a day of reading webhook logs. Assert them here so the
|
|
@@ -3703,7 +4738,15 @@ if (PARTNER_PRIVATE_KEY.includes('BEGIN')) {
|
|
|
3703
4738
|
// variable is set when it is not, and the second commonest is still holding one
|
|
3704
4739
|
// key after a rotation. Both are visible in this one line, and neither is
|
|
3705
4740
|
// visible anywhere else until a dispatch fails.
|
|
3706
|
-
|
|
4741
|
+
// In inbox mode "no key" is the CORRECT state, and \`describeKeys\` would report
|
|
4742
|
+
// it as "every dispatch will fail to decrypt", which is true of a write-through
|
|
4743
|
+
// receiver and alarming nonsense here. An operator who reads a red line every
|
|
4744
|
+
// boot stops reading the line.
|
|
4745
|
+
report.info(
|
|
4746
|
+
config.mode === 'inbox'
|
|
4747
|
+
? '[startup] keys: none, by design \u2014 the connector holds them and this process cannot read a dispatch'
|
|
4748
|
+
: \`[startup] keys: \${describeKeys()}\`,
|
|
4749
|
+
);
|
|
3707
4750
|
// WHICH STORE IS LIVE, said out loud at every boot. A receiver pointed at a
|
|
3708
4751
|
// partner's own database and one still writing to the bundled demo file behave
|
|
3709
4752
|
// identically until the first dispatch, and the difference is where a customer's
|
|
@@ -3880,6 +4923,21 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
3880
4923
|
|
|
3881
4924
|
const event = body.event as string;
|
|
3882
4925
|
|
|
4926
|
+
// PARKED FOR THE CONNECTOR STORE, and read in the same tick it is set.
|
|
4927
|
+
// In inbox mode the receiver holds no private key, so the only thing it can
|
|
4928
|
+
// hand the connector for a verify is the ciphertext that arrived. The
|
|
4929
|
+
// CustomerStore contract passes a decoded customer, which this process cannot
|
|
4930
|
+
// produce, so the bytes travel out of band rather than widening the contract
|
|
4931
|
+
// for one implementation. See connector-store.ts for why this is safe and
|
|
4932
|
+
// what would make it unsafe.
|
|
4933
|
+
//
|
|
4934
|
+
// ABOVE EVERY HANDLER THAT READS IT, and that position is the fix rather than
|
|
4935
|
+
// a tidy-up: this sat below \`account.verify\`, which returns long before it, so
|
|
4936
|
+
// in inbox mode the connector was handed the PREVIOUS request's ciphertext, or
|
|
4937
|
+
// an empty string on the first request of the process's life. It would have
|
|
4938
|
+
// answered honestly about the wrong consumer.
|
|
4939
|
+
setCurrentRawBody(rawBody);
|
|
4940
|
+
|
|
3883
4941
|
// \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
4942
|
// Carries an encrypted CUSTOMER block { name, known_names, account_number } \u2014
|
|
3885
4943
|
// no address, no session envelope \u2014 so it is handled HERE, above the address-
|
|
@@ -3894,6 +4952,20 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
3894
4952
|
return res.status(200).json({ ok: true, skipped: true });
|
|
3895
4953
|
}
|
|
3896
4954
|
|
|
4955
|
+
// \u2500\u2500 INBOX MODE: the receiver cannot open this, and must not try \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4956
|
+
//
|
|
4957
|
+
// The decrypt below is the reason this branch exists. In inbox mode there
|
|
4958
|
+
// is no private key in this process, so \`decryptAddress\` would fail and
|
|
4959
|
+
// answer 422 \u2014 and the consumer, who is mid-payment, would be told the
|
|
4960
|
+
// account check failed when nothing is wrong. The connector holds the key
|
|
4961
|
+
// and the customer records, so it answers. \`connectorStore\` sends the raw
|
|
4962
|
+
// body; the arguments below are the contract's shape, not its source.
|
|
4963
|
+
if (config.mode === 'inbox') {
|
|
4964
|
+
const status = await store.verifyAccount(null, '', []);
|
|
4965
|
+
report.info(\`[webhook] account.verify \u2192 \${status} (answered by the connector)\`);
|
|
4966
|
+
return res.status(200).json({ status });
|
|
4967
|
+
}
|
|
4968
|
+
|
|
3897
4969
|
const enc = body.customer_encrypted as {
|
|
3898
4970
|
ephemeralPublicKey: string; iv: string; ciphertext: string; hkdfSalt?: string;
|
|
3899
4971
|
} | undefined;
|
|
@@ -3936,8 +5008,113 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
3936
5008
|
* stops the quarantine being a way for anyone who can reach this port to fill
|
|
3937
5009
|
* a partner's disk.
|
|
3938
5010
|
*/
|
|
3939
|
-
|
|
5011
|
+
/**
|
|
5012
|
+
* What this dispatch IS, for anything that has to recognise it again.
|
|
5013
|
+
*
|
|
5014
|
+
* The same value the quarantine keys on, so a re-delivery - a OneAddress
|
|
5015
|
+
* retry of a 422, a press of [r], the same body posted twice - lands on the
|
|
5016
|
+
* record that is already there instead of looking like a new arrival.
|
|
5017
|
+
*/
|
|
5018
|
+
const key = dispatchKey(dispatch || null, rawBody);
|
|
5019
|
+
|
|
5020
|
+
const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void => {
|
|
3940
5021
|
quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
|
|
5022
|
+
recordOutcome(key, 'failed');
|
|
5023
|
+
};
|
|
5024
|
+
|
|
5025
|
+
/**
|
|
5026
|
+
* Post an \`address.verify\` verdict back to OneAddress.
|
|
5027
|
+
*
|
|
5028
|
+
* ONE IMPLEMENTATION, TWO CALLERS, and the second one is why it is a function.
|
|
5029
|
+
* Write-through decrypts the envelope and asks its own store; inbox mode
|
|
5030
|
+
* cannot decrypt anything and asks the connector. Everything from the verdict
|
|
5031
|
+
* onward is identical, including the callback-host check, and a second copy of
|
|
5032
|
+
* that check is a second place for it to be dropped.
|
|
5033
|
+
*
|
|
5034
|
+
* Returns a Response when it REFUSED to post and has already answered the
|
|
5035
|
+
* caller, and null when the verdict went out. Deliberately does NOT remember
|
|
5036
|
+
* the dispatch: the two callers differ on that, and burying the difference in
|
|
5037
|
+
* here is how one of them would get it silently wrong.
|
|
5038
|
+
*/
|
|
5039
|
+
const postVerifyVerdict = async (result: string): Promise<Response | null> => {
|
|
5040
|
+
const callbackUrl = body.callback_url as string;
|
|
5041
|
+
const callbackToken = body.callback_token as string;
|
|
5042
|
+
const batchId = body.batch_id as string;
|
|
5043
|
+
|
|
5044
|
+
// Callback URL is signed inside the body, so HMAC verify already proves it
|
|
5045
|
+
// came from OneAddress. We still validate the host as defence in depth:
|
|
5046
|
+
// if the webhook secret ever leaks, an attacker who can forge a webhook
|
|
5047
|
+
// could otherwise coerce this server into POSTing to any internal
|
|
5048
|
+
// URL (database admin, cloud metadata service, \u2026) \u2014 turning the partner's
|
|
5049
|
+
// network position into an SSRF primitive.
|
|
5050
|
+
const safeCallbackUrl = safeOneAddressCallbackUrl(callbackUrl);
|
|
5051
|
+
if (!safeCallbackUrl) {
|
|
5052
|
+
report.warn(\`[webhook] refusing callback to non-OneAddress host: \${callbackUrl}\`);
|
|
5053
|
+
return res.status(400).json({ error: 'Invalid callback_url host' });
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
await fetch(safeCallbackUrl, {
|
|
5057
|
+
method: 'POST',
|
|
5058
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5059
|
+
body: JSON.stringify({
|
|
5060
|
+
// 2026.2 \u2014 no member_name echo; OneAddress keys the result on
|
|
5061
|
+
// (batch_id, partner_id) and validates the opaque token alone.
|
|
5062
|
+
batch_id: batchId,
|
|
5063
|
+
partner_id: PARTNER_ID,
|
|
5064
|
+
result,
|
|
5065
|
+
token: callbackToken,
|
|
5066
|
+
}),
|
|
5067
|
+
});
|
|
5068
|
+
return null;
|
|
5069
|
+
};
|
|
5070
|
+
|
|
5071
|
+
// \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
|
|
5072
|
+
//
|
|
5073
|
+
// An \`address.updated\` is stored exactly as it arrived and the connector is
|
|
5074
|
+
// left to collect it. The 200 below is honest and is not a claim that anything
|
|
5075
|
+
// was applied: the protocol already separates the two, and the CONFIRM is what
|
|
5076
|
+
// reports application. It fires when the connector says so, which may be
|
|
5077
|
+
// hours later, and \`user_services.state\` has carried \`awaiting_confirm\` for
|
|
5078
|
+
// that gap since long before this mode existed.
|
|
5079
|
+
//
|
|
5080
|
+
// The verify events deliberately fall through to the code below, because they
|
|
5081
|
+
// must be answered NOW, before a consumer pays. In inbox mode that answer
|
|
5082
|
+
// comes from the connector.
|
|
5083
|
+
// \u2500\u2500 INBOX MODE, the other half: a verify the receiver cannot open \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
5084
|
+
//
|
|
5085
|
+
// \`address.verify\` has to be answered now, like \`account.verify\` above, but it
|
|
5086
|
+
// is answered by POSTing a callback rather than in the response body. The
|
|
5087
|
+
// callback URL and token travel in the CLEARTEXT body, not the envelope, so
|
|
5088
|
+
// this process can still post it; only the VERDICT needs a key, and that comes
|
|
5089
|
+
// from the connector.
|
|
5090
|
+
if (config.mode === 'inbox' && event === 'address.verify') {
|
|
5091
|
+
// The contract's arguments, not its source: \`connectorStore\` sends the raw
|
|
5092
|
+
// body it was parked with and ignores these.
|
|
5093
|
+
const result = await store.verifyAddress({ email: null, name: '' }, {});
|
|
5094
|
+
report.info(\`[webhook] address.verify \u2192 \${result} (answered by the connector)\`);
|
|
5095
|
+
const refused = await postVerifyVerdict(result);
|
|
5096
|
+
if (refused) return refused;
|
|
5097
|
+
// DELIBERATELY NOT \`rememberDispatch\`, for the same reason as the inbox
|
|
5098
|
+
// \`address.updated\` branch below: nothing was decrypted here, so there is
|
|
5099
|
+
// no state a retry would duplicate. OneAddress keys a verify result on
|
|
5100
|
+
// (batch_id, partner_id), so re-answering a retried check is a no-op \u2014 and
|
|
5101
|
+
// NOT remembering is the safer half of the trade, because a check we failed
|
|
5102
|
+
// to answer still gets answered on the retry instead of being dismissed.
|
|
5103
|
+
return res.status(200).json({ ok: true });
|
|
5104
|
+
}
|
|
5105
|
+
|
|
5106
|
+
if (config.mode === 'inbox' && event === 'address.updated') {
|
|
5107
|
+
acceptIntoInbox({ key, dispatchId: dispatch || null, event, rawBody });
|
|
5108
|
+
report.info(\`[inbox] held \${dispatch || key} for the connector\`);
|
|
5109
|
+
// DELIBERATELY NOT \`rememberDispatch\`. A guard in templates.test.ts asserts
|
|
5110
|
+
// that nothing is remembered before the decrypt is attempted, because a
|
|
5111
|
+
// dispatch marked seen too early makes OneAddress's retry look like a
|
|
5112
|
+
// duplicate and loses the update. Adding an exception here for a branch
|
|
5113
|
+
// that happens not to decrypt would weaken the guard for the branch that
|
|
5114
|
+
// does. It is not needed anyway: \`accept\` is INSERT OR IGNORE on the same
|
|
5115
|
+
// dispatch identity, so a redelivery is already harmless.
|
|
5116
|
+
return res.status(200).json({ ok: true, queued: true });
|
|
5117
|
+
}
|
|
3941
5118
|
|
|
3942
5119
|
const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
|
|
3943
5120
|
if (!DISPATCH_EVENTS.includes(event)) {
|
|
@@ -4016,6 +5193,9 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
4016
5193
|
return res.status(422).json({ ok: false, error: 'Decryption failed \u2014 partner key mismatch' });
|
|
4017
5194
|
}
|
|
4018
5195
|
} else {
|
|
5196
|
+
// No \`hold\` here: there is no payload to hold, so there is nothing a fix
|
|
5197
|
+
// could later apply. It still counts as a dispatch that did not land.
|
|
5198
|
+
recordOutcome(key, 'failed');
|
|
4019
5199
|
return res.status(422).json({ ok: false, error: 'No encrypted payload on dispatch' });
|
|
4020
5200
|
}
|
|
4021
5201
|
|
|
@@ -4030,11 +5210,23 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
4030
5210
|
const loaEncrypted = body.loa_encrypted as
|
|
4031
5211
|
{ session_envelope: string; session_key_share: SessionKeyShare } | null | undefined;
|
|
4032
5212
|
if (loaEncrypted && typeof loaEncrypted === 'object') {
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
5213
|
+
// RESOLVED BY THE LOA'S OWN key_id, not by the single PARTNER_PRIVATE_KEY_PEM.
|
|
5214
|
+
// It is wrapped to the same key id as the address envelope, but it carries
|
|
5215
|
+
// its own share, so that is what is read. Using the single key meant that
|
|
5216
|
+
// after a rotation with PARTNER_KEYS_STRICT=1 the address opened and the LOA
|
|
5217
|
+
// silently did not, losing the proof-of-consent on exactly the changes a
|
|
5218
|
+
// partner most wants it for, with nothing anyone would see.
|
|
5219
|
+
const loaKeyId = loaEncrypted.session_key_share?.key_id ?? null;
|
|
5220
|
+
const loaKey = resolvePrivateKey(loaKeyId);
|
|
5221
|
+
if (!loaKey) {
|
|
5222
|
+
report.warn(\`[webhook] no key for the LOA's key_id \${loaKeyId ?? '(none)'} \u2014 applying without a consent reference\`);
|
|
5223
|
+
} else {
|
|
5224
|
+
try {
|
|
5225
|
+
const loa: OneAddressD5LOA = await decryptLoaEncrypted(loaEncrypted, loaKey.pem, PARTNER_ID);
|
|
5226
|
+
loaRef = d5LoaRef(loa);
|
|
5227
|
+
} catch (err) {
|
|
5228
|
+
report.error('[webhook] LOA decryption failed:', err instanceof Error ? err.message : err);
|
|
5229
|
+
}
|
|
4038
5230
|
}
|
|
4039
5231
|
}
|
|
4040
5232
|
|
|
@@ -4052,7 +5244,12 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
4052
5244
|
|
|
4053
5245
|
// \u2500\u2500 address.updated: consumer changed their address \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
4054
5246
|
if (event === 'address.updated') {
|
|
4055
|
-
|
|
5247
|
+
// THE ACCOUNT, NEVER THE NAME. This used to fall back to \`ctx.name\`, so a
|
|
5248
|
+
// partner who does not verify account references wrote a real consumer's
|
|
5249
|
+
// name into every log line, and from there into whatever aggregates their
|
|
5250
|
+
// logs. The account reference is the partner's own identifier for their own
|
|
5251
|
+
// record; the name is the consumer's.
|
|
5252
|
+
report.info(\`[webhook] address.updated for \${ctx.accountNumber || '(no account reference)'}\`);
|
|
4056
5253
|
|
|
4057
5254
|
// AN ACCOUNT REFERENCE THAT MATCHES NOTHING MUST BE A HARD NO-MATCH.
|
|
4058
5255
|
//
|
|
@@ -4084,59 +5281,56 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
4084
5281
|
if (verdict !== 'match') {
|
|
4085
5282
|
report.warn(\`[webhook] address.updated REFUSED (\${verdict}) for account \${ctx.accountNumber ?? '(none)'} \u2014 nothing applied\`);
|
|
4086
5283
|
queueConfirm(dispatch, 'failed');
|
|
5284
|
+
// A refusal is not a fault and is never held, but it IS a dispatch that
|
|
5285
|
+
// did not land, so it counts. See quarantine.ts for why the two differ.
|
|
5286
|
+
recordOutcome(key, 'failed');
|
|
4087
5287
|
return res.status(200).json({ ok: false, error: 'account_not_matched', verdict });
|
|
4088
5288
|
}
|
|
4089
5289
|
}
|
|
4090
5290
|
|
|
4091
|
-
//
|
|
4092
|
-
//
|
|
4093
|
-
//
|
|
4094
|
-
//
|
|
5291
|
+
// THE DASHBOARD IS HANDED FACTS, NOT THE ADDRESS, and that is structural
|
|
5292
|
+
// rather than a formatting choice.
|
|
5293
|
+
//
|
|
5294
|
+
// It used to receive the address this dispatch REPLACED and look the new
|
|
5295
|
+
// one up from the store, and it painted both on a panel. Everything around
|
|
5296
|
+
// here is careful that an address never reaches a log line - two comments
|
|
5297
|
+
// one screen apart say so - and then it was drawn in large text on a screen
|
|
5298
|
+
// that gets screenshotted, screen-shared and left open in an office.
|
|
5299
|
+
//
|
|
5300
|
+
// The receiver does not need to show an address to prove it works. It needs
|
|
5301
|
+
// to show that the envelope opened, that the account was one of yours, and
|
|
5302
|
+
// whether anything actually changed. Those are the facts below, and nothing
|
|
5303
|
+
// that reaches \`tui.ts\` can be turned back into a consumer's home.
|
|
5304
|
+
//
|
|
5305
|
+
// There is nothing here to sneak past, either, which is why this is better
|
|
5306
|
+
// than marking test dispatches as safe to display: a marker is something an
|
|
5307
|
+
// attacker can try to forge onto a real dispatch, and a panel that never
|
|
5308
|
+
// renders an address has nothing to forge it into.
|
|
4095
5309
|
const replaced = await store.saveAddress(ctx, address);
|
|
4096
|
-
|
|
5310
|
+
noteChange({
|
|
5311
|
+
accountNumber: ctx.accountNumber || '(no account reference)',
|
|
5312
|
+
accountChecked: config.verifiesAccountReference,
|
|
5313
|
+
changed: !sameAddress(replaced, address),
|
|
5314
|
+
loaRef,
|
|
5315
|
+
dispatchId: dispatch || null,
|
|
5316
|
+
});
|
|
4097
5317
|
if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
|
|
4098
5318
|
// Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
4099
5319
|
// QUEUED, not sent: this is a local INSERT, so it cannot delay the 200 that
|
|
4100
5320
|
// acks the delivery, and it survives a restart. The drain loop does the
|
|
4101
5321
|
// network part and retries it until OneAddress answers.
|
|
4102
5322
|
queueConfirm(dispatch, 'confirmed');
|
|
5323
|
+
recordOutcome(key, 'applied');
|
|
4103
5324
|
return res.status(200).json({ ok: true });
|
|
4104
5325
|
}
|
|
4105
5326
|
|
|
4106
5327
|
// \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
5328
|
if (event === 'address.verify') {
|
|
4108
|
-
|
|
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
|
-
report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
|
|
5329
|
+
report.info(\`[webhook] address.verify for \${ctx.accountNumber || '(no account reference)'}\`);
|
|
4125
5330
|
const result = await store.verifyAddress(ctx, address);
|
|
4126
5331
|
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
|
-
});
|
|
5332
|
+
const refused = await postVerifyVerdict(result);
|
|
5333
|
+
if (refused) return refused;
|
|
4140
5334
|
if (dispatch) rememberDispatch(dispatch); // remember only after the callback posted
|
|
4141
5335
|
return res.status(200).json({ ok: true });
|
|
4142
5336
|
}
|
|
@@ -4236,13 +5430,12 @@ setInterval(() => {
|
|
|
4236
5430
|
* anyone has fixed anything. We hold the secret, so re-signing is not a bypass:
|
|
4237
5431
|
* it is the same proof, re-stated now.
|
|
4238
5432
|
*/
|
|
4239
|
-
async function redeliver(rawBody: string): Promise<void> {
|
|
5433
|
+
async function redeliver(rawBody: string, heldDispatchId: string | null): Promise<void> {
|
|
4240
5434
|
const ts = String(Math.floor(Date.now() / 1000));
|
|
4241
5435
|
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 */ }
|
|
5436
|
+
// THE ID THE DISPATCH ARRIVED WITH, handed over by the row being replayed.
|
|
5437
|
+
// Re-deriving it from the body is what made one held dispatch become two.
|
|
5438
|
+
const dispatchId = (heldDispatchId ?? '').trim();
|
|
4246
5439
|
|
|
4247
5440
|
const res = await fetch(\`http://127.0.0.1:\${PORT}/webhook\`, {
|
|
4248
5441
|
method: 'POST',
|
|
@@ -4277,6 +5470,29 @@ export async function replayQuarantined(): Promise<{ applied: number; failed: nu
|
|
|
4277
5470
|
* moment they are least inclined to. The delay lets \`listen\` settle, since this
|
|
4278
5471
|
* goes back in through the port.
|
|
4279
5472
|
*/
|
|
5473
|
+
// \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
|
|
5474
|
+
//
|
|
5475
|
+
// Wired here rather than in draw-api.ts because this file owns the confirm
|
|
5476
|
+
// queue: an acknowledgement from the connector is the moment OneAddress gets
|
|
5477
|
+
// told, and that has to go through the same durable queue every other confirm
|
|
5478
|
+
// uses so a failure to reach OneAddress is retried rather than lost.
|
|
5479
|
+
if (config.mode === 'inbox') {
|
|
5480
|
+
setAcknowledgementHandler((dispatchId, outcome) => {
|
|
5481
|
+
queueConfirm(dispatchId, outcome === 'applied' ? 'confirmed' : 'failed');
|
|
5482
|
+
recordOutcome(\`d:\${dispatchId}\`, outcome === 'applied' ? 'applied' : 'failed');
|
|
5483
|
+
});
|
|
5484
|
+
const started = startDrawApi();
|
|
5485
|
+
if (!started) {
|
|
5486
|
+
// AN INBOX WITH NO WAY TO DRAIN IT IS WORSE THAN NOT STARTING. In
|
|
5487
|
+
// write-through mode a missing connector token is irrelevant and the
|
|
5488
|
+
// receiver runs; here it means every dispatch would be accepted and held
|
|
5489
|
+
// with nothing able to collect it, and the consumer would be told nothing
|
|
5490
|
+
// for as long as that lasted.
|
|
5491
|
+
report.error('[startup] mode is \`inbox\` but the connector channel could not open. Refusing to start.');
|
|
5492
|
+
process.exit(1);
|
|
5493
|
+
}
|
|
5494
|
+
}
|
|
5495
|
+
|
|
4280
5496
|
setTimeout(() => {
|
|
4281
5497
|
if (heldCount() === 0) return;
|
|
4282
5498
|
void replayQuarantined().catch((err: unknown) =>
|
|
@@ -4291,8 +5507,34 @@ setTimeout(() => {
|
|
|
4291
5507
|
const QUARANTINE_KEEP_DAYS = Number(process.env.QUARANTINE_KEEP_DAYS ?? 30);
|
|
4292
5508
|
setInterval(() => { purgeQuarantine(QUARANTINE_KEEP_DAYS); }, 3_600_000).unref();
|
|
4293
5509
|
|
|
4294
|
-
/** How many confirms are still owed. Read by the dashboard. */
|
|
4295
|
-
export { pendingConfirmCount };
|
|
5510
|
+
/** How many confirms are still owed, and how the dispatches went. Read by the dashboard. */
|
|
5511
|
+
export { pendingConfirmCount, tally };
|
|
5512
|
+
|
|
5513
|
+
/**
|
|
5514
|
+
* Everything the dashboard footer needs, in one call.
|
|
5515
|
+
*
|
|
5516
|
+
* One hook rather than three, because three would be three chances for the
|
|
5517
|
+
* footer to show figures from different moments.
|
|
5518
|
+
*/
|
|
5519
|
+
export function dashboardStats(): {
|
|
5520
|
+
received: number;
|
|
5521
|
+
applied: number;
|
|
5522
|
+
failed: number;
|
|
5523
|
+
mode: string;
|
|
5524
|
+
awaitingConnector: number;
|
|
5525
|
+
oldestUndrawn: string | null;
|
|
5526
|
+
} {
|
|
5527
|
+
const t = tally();
|
|
5528
|
+
const inbox = config.mode === 'inbox'
|
|
5529
|
+
? { count: undrawnCount(), oldest: oldestUndrawn() }
|
|
5530
|
+
: { count: 0, oldest: null };
|
|
5531
|
+
return {
|
|
5532
|
+
...t,
|
|
5533
|
+
mode: config.mode,
|
|
5534
|
+
awaitingConnector: inbox.count,
|
|
5535
|
+
oldestUndrawn: inbox.oldest,
|
|
5536
|
+
};
|
|
5537
|
+
}
|
|
4296
5538
|
|
|
4297
5539
|
// Read by src/index.ts to label the dashboard. Exported rather than re-derived
|
|
4298
5540
|
// there, so the port the UI claims is the port the server actually bound.
|
|
@@ -9493,11 +10735,49 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
|
|
|
9493
10735
|
};
|
|
9494
10736
|
|
|
9495
10737
|
// src/scaffold.ts
|
|
9496
|
-
function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi) {
|
|
9497
|
-
|
|
10738
|
+
function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken) {
|
|
10739
|
+
const filled = content.replaceAll("%%PARTNER_ID%%", partnerId).replaceAll("%%WEBHOOK_SECRET%%", webhookSecret).replaceAll("%%WEBHOOK_URL%%", webhookUrl || "<your-webhook-url>").replaceAll("%%PRIVATE_KEY%%", privateKey || "<paste your PKCS8 PEM private key here>").replaceAll("%%ONEADDRESS_API%%", oneAddressApi || "https://oneaddress.io").replaceAll("%%VERIFIES_ACCOUNT_REFERENCE%%", verifiesAccountReference ? "true" : "false").replaceAll("%%RECEIVER_MODE%%", mode === "inbox" ? "inbox" : "write-through");
|
|
10740
|
+
if (mode !== "inbox") return filled;
|
|
10741
|
+
const withReadme = filled.replace(
|
|
10742
|
+
"## Architecture",
|
|
10743
|
+
`## Inbox mode
|
|
10744
|
+
|
|
10745
|
+
This receiver holds dispatches as ciphertext and keeps **no private key** - it refuses to
|
|
10746
|
+
start with one - so it cannot read what it stores. A **connector** you run separately draws
|
|
10747
|
+
from it, decrypts, applies the change to your systems and acknowledges; only then does
|
|
10748
|
+
OneAddress confirm the update to the consumer.
|
|
10749
|
+
|
|
10750
|
+
Until that connector runs, updates are held and the dashboard counts them as awaiting your
|
|
10751
|
+
systems. Nothing is lost and nothing is applied.
|
|
10752
|
+
|
|
10753
|
+
| What | Where |
|
|
10754
|
+
|------|-------|
|
|
10755
|
+
| The decryption key | Your connector's environment, never this one |
|
|
10756
|
+
| \`CONNECTOR_TOKEN\` | \`.env\` here, and the same value in your connector |
|
|
10757
|
+
| The draw channel | \`http://127.0.0.1:3002\`, loopback only |
|
|
10758
|
+
|
|
10759
|
+
\`CONNECTOR_TOKEN\` is its own credential: **not** your webhook secret and **not** your
|
|
10760
|
+
confirm secret. Those two are shared with OneAddress, and a leak of either must not also
|
|
10761
|
+
hand somebody your customers' addresses.
|
|
10762
|
+
|
|
10763
|
+
## Architecture`
|
|
10764
|
+
);
|
|
10765
|
+
return withReadme.replace(
|
|
10766
|
+
/^PARTNER_PRIVATE_KEY_PEM=.*$/m,
|
|
10767
|
+
`# No PARTNER_PRIVATE_KEY_PEM here, on purpose: this receiver runs in inbox
|
|
10768
|
+
# mode and refuses to start with one. The key belongs in your connector.
|
|
10769
|
+
|
|
10770
|
+
# The credential your connector presents to draw from this inbox. It must
|
|
10771
|
+
# match CONNECTOR_TOKEN in the connector's own environment, and it must not
|
|
10772
|
+
# be your webhook secret or your confirm secret.
|
|
10773
|
+
CONNECTOR_TOKEN=${connectorToken || generateConnectorToken()}`
|
|
10774
|
+
);
|
|
10775
|
+
}
|
|
10776
|
+
function generateConnectorToken() {
|
|
10777
|
+
return (0, import_node_crypto.randomBytes)(24).toString("hex");
|
|
9498
10778
|
}
|
|
9499
10779
|
var SENSITIVE_FILES = /* @__PURE__ */ new Set([".env"]);
|
|
9500
|
-
async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io") {
|
|
10780
|
+
async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io", mode = "write-through", connectorToken = "") {
|
|
9501
10781
|
const templates = TEMPLATES[platform];
|
|
9502
10782
|
if (!templates) throw new Error(`Unknown platform: ${platform}`);
|
|
9503
10783
|
const written = [];
|
|
@@ -9507,7 +10787,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
9507
10787
|
if (!(0, import_node_fs.existsSync)(dir)) {
|
|
9508
10788
|
await (0, import_promises.mkdir)(dir, { recursive: true });
|
|
9509
10789
|
}
|
|
9510
|
-
const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi);
|
|
10790
|
+
const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken);
|
|
9511
10791
|
const isSensitive = SENSITIVE_FILES.has((0, import_node_path.basename)(name));
|
|
9512
10792
|
await (0, import_promises.writeFile)(dest, filled, { encoding: "utf8", mode: isSensitive ? 384 : 420 });
|
|
9513
10793
|
if (isSensitive && process.platform !== "win32") {
|
|
@@ -9522,11 +10802,11 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
9522
10802
|
}
|
|
9523
10803
|
|
|
9524
10804
|
// src/register.ts
|
|
9525
|
-
var
|
|
9526
|
-
var PKG_VERSION = true ? "2.
|
|
10805
|
+
var import_node_crypto2 = require("crypto");
|
|
10806
|
+
var PKG_VERSION = true ? "2.3.0" : "dev";
|
|
9527
10807
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
9528
10808
|
function hmacSha256(secret, message) {
|
|
9529
|
-
return (0,
|
|
10809
|
+
return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
|
|
9530
10810
|
}
|
|
9531
10811
|
async function registerInstall(installId, partnerId, webhookSecret, platform, webhookUrl) {
|
|
9532
10812
|
try {
|
|
@@ -9559,10 +10839,10 @@ async function registerInstall(installId, partnerId, webhookSecret, platform, we
|
|
|
9559
10839
|
}
|
|
9560
10840
|
|
|
9561
10841
|
// src/conformance.ts
|
|
9562
|
-
var
|
|
10842
|
+
var import_node_crypto3 = require("crypto");
|
|
9563
10843
|
async function runConformance(webhookUrl, _partnerId, webhookSecret) {
|
|
9564
10844
|
function sign(ts, body) {
|
|
9565
|
-
return (0,
|
|
10845
|
+
return (0, import_node_crypto3.createHmac)("sha256", webhookSecret).update(`${ts}.${body}`).digest("hex");
|
|
9566
10846
|
}
|
|
9567
10847
|
const validBody = JSON.stringify({ event: "conformance.ping", test: true });
|
|
9568
10848
|
const now = String(Math.floor(Date.now() / 1e3));
|
|
@@ -9603,7 +10883,7 @@ async function runConformance(webhookUrl, _partnerId, webhookSecret) {
|
|
|
9603
10883
|
// Unique per test run so a partner re-running `npm test` doesn't
|
|
9604
10884
|
// hit the scaffolded server's dedup cache and short-circuit the
|
|
9605
10885
|
// "accepted" assertion with { ok: true, duplicate: true }.
|
|
9606
|
-
"X-OneAddress-Dispatch": `conformance-${(0,
|
|
10886
|
+
"X-OneAddress-Dispatch": `conformance-${(0, import_node_crypto3.randomUUID)()}`
|
|
9607
10887
|
},
|
|
9608
10888
|
body: validBody,
|
|
9609
10889
|
signal: AbortSignal.timeout(8e3)
|
|
@@ -9655,7 +10935,7 @@ async function runDecryptCheck(webhookSecret, partnerId) {
|
|
|
9655
10935
|
async function callOnce() {
|
|
9656
10936
|
try {
|
|
9657
10937
|
const ts = String(Math.floor(Date.now() / 1e3));
|
|
9658
|
-
const sig = (0,
|
|
10938
|
+
const sig = (0, import_node_crypto3.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
|
|
9659
10939
|
const res2 = await fetch(VERIFY_DECRYPT_API, {
|
|
9660
10940
|
method: "POST",
|
|
9661
10941
|
headers: {
|
|
@@ -9730,24 +11010,46 @@ var COMMANDS = {
|
|
|
9730
11010
|
function installDependencies(platform, outputDir) {
|
|
9731
11011
|
const spec = COMMANDS[platform];
|
|
9732
11012
|
if (!spec) {
|
|
9733
|
-
return { ok: true, output: "", manualCommand: "" };
|
|
11013
|
+
return Promise.resolve({ ok: true, output: "", manualCommand: "" });
|
|
9734
11014
|
}
|
|
9735
11015
|
const cwd = spec.cwd ? (0, import_node_path2.join)(outputDir, spec.cwd) : outputDir;
|
|
9736
11016
|
const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
|
|
9737
|
-
|
|
9738
|
-
|
|
9739
|
-
|
|
9740
|
-
|
|
9741
|
-
|
|
9742
|
-
|
|
9743
|
-
|
|
11017
|
+
return new Promise((resolve2) => {
|
|
11018
|
+
const child = (0, import_node_child_process.spawn)(spec.cmd, spec.args, {
|
|
11019
|
+
cwd,
|
|
11020
|
+
stdio: "pipe",
|
|
11021
|
+
shell: process.platform === "win32"
|
|
11022
|
+
});
|
|
11023
|
+
let output = "";
|
|
11024
|
+
const collect = (chunk) => {
|
|
11025
|
+
if (output.length < 64e3) output += chunk.toString("utf8");
|
|
11026
|
+
};
|
|
11027
|
+
child.stdout?.on("data", collect);
|
|
11028
|
+
child.stderr?.on("data", collect);
|
|
11029
|
+
let settled = false;
|
|
11030
|
+
const finish = (result) => {
|
|
11031
|
+
if (settled) return;
|
|
11032
|
+
settled = true;
|
|
11033
|
+
clearTimeout(timer);
|
|
11034
|
+
resolve2(result);
|
|
11035
|
+
};
|
|
11036
|
+
const timer = setTimeout(() => {
|
|
11037
|
+
child.kill("SIGKILL");
|
|
11038
|
+
finish({
|
|
11039
|
+
ok: false,
|
|
11040
|
+
output: `${output.trim()}
|
|
11041
|
+
|
|
11042
|
+
Timed out after 5 minutes.`.trim(),
|
|
11043
|
+
manualCommand
|
|
11044
|
+
});
|
|
11045
|
+
}, 5 * 60 * 1e3);
|
|
11046
|
+
child.on("error", (err) => {
|
|
11047
|
+
finish({ ok: false, output: `${output}${err.message}`.trim(), manualCommand });
|
|
11048
|
+
});
|
|
11049
|
+
child.on("close", (code) => {
|
|
11050
|
+
finish({ ok: code === 0, output: output.trim(), manualCommand });
|
|
11051
|
+
});
|
|
9744
11052
|
});
|
|
9745
|
-
const output = [result.stdout ?? "", result.stderr ?? ""].join("").trim();
|
|
9746
|
-
return {
|
|
9747
|
-
ok: result.status === 0 && !result.error,
|
|
9748
|
-
output,
|
|
9749
|
-
manualCommand
|
|
9750
|
-
};
|
|
9751
11053
|
}
|
|
9752
11054
|
|
|
9753
11055
|
// src/autostart.ts
|
|
@@ -9932,7 +11234,7 @@ async function handOverTerminal(platform, outputDir, port, secrets = {}) {
|
|
|
9932
11234
|
var import_node_child_process3 = require("child_process");
|
|
9933
11235
|
var import_promises2 = require("fs/promises");
|
|
9934
11236
|
var import_node_fs2 = require("fs");
|
|
9935
|
-
var
|
|
11237
|
+
var import_node_crypto4 = require("crypto");
|
|
9936
11238
|
var import_node_path3 = require("path");
|
|
9937
11239
|
var import_node_os = __toESM(require("os"));
|
|
9938
11240
|
var tunnelProcess = null;
|
|
@@ -9971,7 +11273,7 @@ function platformKey() {
|
|
|
9971
11273
|
return "linux-x64";
|
|
9972
11274
|
}
|
|
9973
11275
|
async function sha256File(path) {
|
|
9974
|
-
const hash = (0,
|
|
11276
|
+
const hash = (0, import_node_crypto4.createHash)("sha256");
|
|
9975
11277
|
hash.update(await (0, import_promises2.readFile)(path));
|
|
9976
11278
|
return hash.digest("hex");
|
|
9977
11279
|
}
|
|
@@ -10105,7 +11407,7 @@ async function startTunnel(port) {
|
|
|
10105
11407
|
}
|
|
10106
11408
|
|
|
10107
11409
|
// src/register-url.ts
|
|
10108
|
-
var
|
|
11410
|
+
var import_node_crypto5 = require("crypto");
|
|
10109
11411
|
var PORTAL_API = "https://partners.oneaddress.io/api/partner/webhook-url";
|
|
10110
11412
|
function describeReadFailure(status) {
|
|
10111
11413
|
if (status === 401) return "the portal rejected the signature (HTTP 401), which means your Webhook signing secret does not match the one on your profile. Copy it again from partners.oneaddress.io \u2192 Webhook \u2192 Webhook signing secret.";
|
|
@@ -10116,7 +11418,7 @@ function describeReadFailure(status) {
|
|
|
10116
11418
|
async function getExistingWebhookUrl(partnerId, webhookSecret, onError) {
|
|
10117
11419
|
try {
|
|
10118
11420
|
const ts = String(Math.floor(Date.now() / 1e3));
|
|
10119
|
-
const sig = (0,
|
|
11421
|
+
const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
|
|
10120
11422
|
const res = await fetch(PORTAL_API, {
|
|
10121
11423
|
method: "GET",
|
|
10122
11424
|
headers: {
|
|
@@ -10140,7 +11442,7 @@ async function getExistingWebhookUrl(partnerId, webhookSecret, onError) {
|
|
|
10140
11442
|
async function getVerifiesAccountReference(partnerId, webhookSecret, onError) {
|
|
10141
11443
|
try {
|
|
10142
11444
|
const ts = String(Math.floor(Date.now() / 1e3));
|
|
10143
|
-
const sig = (0,
|
|
11445
|
+
const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
|
|
10144
11446
|
const res = await fetch(PORTAL_API, {
|
|
10145
11447
|
method: "GET",
|
|
10146
11448
|
headers: {
|
|
@@ -10167,7 +11469,7 @@ async function getVerifiesAccountReference(partnerId, webhookSecret, onError) {
|
|
|
10167
11469
|
async function verifyWebhookSecret(partnerId, webhookSecret) {
|
|
10168
11470
|
try {
|
|
10169
11471
|
const ts = String(Math.floor(Date.now() / 1e3));
|
|
10170
|
-
const sig = (0,
|
|
11472
|
+
const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.`).digest("hex");
|
|
10171
11473
|
const res = await fetch(PORTAL_API, {
|
|
10172
11474
|
method: "GET",
|
|
10173
11475
|
headers: {
|
|
@@ -10188,7 +11490,7 @@ async function registerWebhookUrl(partnerId, webhookSecret, webhookUrl) {
|
|
|
10188
11490
|
try {
|
|
10189
11491
|
const body = JSON.stringify({ webhook_url: webhookUrl });
|
|
10190
11492
|
const ts = String(Math.floor(Date.now() / 1e3));
|
|
10191
|
-
const sig = (0,
|
|
11493
|
+
const sig = (0, import_node_crypto5.createHmac)("sha256", webhookSecret).update(`${ts}.${body}`).digest("hex");
|
|
10192
11494
|
const res = await fetch(PORTAL_API, {
|
|
10193
11495
|
method: "PATCH",
|
|
10194
11496
|
headers: {
|
|
@@ -10385,7 +11687,7 @@ ${wrapped}
|
|
|
10385
11687
|
-----END EC PRIVATE KEY-----
|
|
10386
11688
|
`;
|
|
10387
11689
|
try {
|
|
10388
|
-
const pkcs8 = (0,
|
|
11690
|
+
const pkcs8 = (0, import_node_crypto6.createPrivateKey)({ key: sec1Pem, format: "pem", type: "sec1" }).export({ type: "pkcs8", format: "pem" });
|
|
10389
11691
|
return { pem: pkcs8 };
|
|
10390
11692
|
} catch (err) {
|
|
10391
11693
|
return { pem: "", error: translateKeyError(err) };
|
|
@@ -10429,7 +11731,7 @@ function translateKeyError(err) {
|
|
|
10429
11731
|
function validatePrivateKey(pem) {
|
|
10430
11732
|
let key;
|
|
10431
11733
|
try {
|
|
10432
|
-
key = (0,
|
|
11734
|
+
key = (0, import_node_crypto6.createPrivateKey)({ key: pem, format: "pem", type: "pkcs8" });
|
|
10433
11735
|
} catch (err) {
|
|
10434
11736
|
return translateKeyError(err);
|
|
10435
11737
|
}
|
|
@@ -10550,6 +11852,39 @@ async function main() {
|
|
|
10550
11852
|
accountRefReason = r2;
|
|
10551
11853
|
});
|
|
10552
11854
|
const verifiesAccountReference = accountRefDeclaration === true;
|
|
11855
|
+
const modeChoice = platform !== "ts-node" ? "write-through" : await ve({
|
|
11856
|
+
message: "How should this receiver handle updates?",
|
|
11857
|
+
options: [
|
|
11858
|
+
{
|
|
11859
|
+
value: "write-through",
|
|
11860
|
+
label: "Apply them itself (simplest)",
|
|
11861
|
+
hint: "Decrypts and writes to a customer store on this machine. Right for a sole trader, or to try it out."
|
|
11862
|
+
},
|
|
11863
|
+
{
|
|
11864
|
+
value: "inbox",
|
|
11865
|
+
label: "Hold them for your own systems to collect",
|
|
11866
|
+
hint: "Holds ciphertext and keeps NO private key, so this process cannot read what it stores. Needs a connector you run separately."
|
|
11867
|
+
}
|
|
11868
|
+
],
|
|
11869
|
+
initialValue: "write-through"
|
|
11870
|
+
});
|
|
11871
|
+
assertNotCancelled(modeChoice);
|
|
11872
|
+
const receiverMode = modeChoice;
|
|
11873
|
+
const connectorToken = receiverMode === "inbox" ? generateConnectorToken() : "";
|
|
11874
|
+
if (receiverMode === "inbox") {
|
|
11875
|
+
M2.warn(
|
|
11876
|
+
"Inbox mode needs a second process. This receiver will hold dispatches as ciphertext and\nkeep NO private key \u2014 it refuses to start with one \u2014 so a connector of yours must draw,\ndecrypt and apply them, then acknowledge. Until that connector runs, updates are held and\nthe dashboard counts them as awaiting your systems."
|
|
11877
|
+
);
|
|
11878
|
+
M2.info(
|
|
11879
|
+
`Your connector credential (also written to .env as CONNECTOR_TOKEN):
|
|
11880
|
+
|
|
11881
|
+
${connectorToken}
|
|
11882
|
+
|
|
11883
|
+
Set the same value as CONNECTOR_TOKEN in your connector. It is its own credential:
|
|
11884
|
+
not your webhook secret and not your confirm secret, because those are shared with
|
|
11885
|
+
OneAddress and a leak of either must not also hand somebody your customers' addresses.`
|
|
11886
|
+
);
|
|
11887
|
+
}
|
|
10553
11888
|
if (accountRefDeclaration === void 0) {
|
|
10554
11889
|
M2.warn(
|
|
10555
11890
|
"Could not read your account-reference declaration from the portal.\n" + (accountRefReason ? "Reason: " + accountRefReason + "\n" : "") + "Conformance check-14 has been left out of the generated `npm test`, so a\npassing run does NOT mean your receiver matches account references correctly.\nSet it at partners.oneaddress.io \u2192 My Profile, then re-run this wizard."
|
|
@@ -10567,7 +11902,10 @@ async function main() {
|
|
|
10567
11902
|
"",
|
|
10568
11903
|
// webhookUrl — filled in after server starts
|
|
10569
11904
|
privateKey,
|
|
10570
|
-
verifiesAccountReference
|
|
11905
|
+
verifiesAccountReference,
|
|
11906
|
+
void 0,
|
|
11907
|
+
receiverMode,
|
|
11908
|
+
connectorToken
|
|
10571
11909
|
);
|
|
10572
11910
|
s1.stop(`Scaffolded ${written.length} files in ${outDir}`);
|
|
10573
11911
|
for (const f of written) M2.success(` ${f}`);
|
|
@@ -10579,7 +11917,7 @@ async function main() {
|
|
|
10579
11917
|
for (; ; ) {
|
|
10580
11918
|
const s2 = Y2();
|
|
10581
11919
|
s2.start("Installing dependencies (this can take 30\u201360 s)");
|
|
10582
|
-
const install = installDependencies(platform, outDir);
|
|
11920
|
+
const install = await installDependencies(platform, outDir);
|
|
10583
11921
|
if (install.ok) {
|
|
10584
11922
|
s2.stop("Dependencies installed");
|
|
10585
11923
|
break;
|
|
@@ -10749,7 +12087,7 @@ one of two things, and neither is your server (it is running on :3001):
|
|
|
10749
12087
|
);
|
|
10750
12088
|
}
|
|
10751
12089
|
registerInstall(
|
|
10752
|
-
`OA-${(0,
|
|
12090
|
+
`OA-${(0, import_node_crypto6.randomUUID)()}`,
|
|
10753
12091
|
pid,
|
|
10754
12092
|
secret,
|
|
10755
12093
|
platform,
|