@oneaddress/setup 2.1.1 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1643 -110
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
|
|
|
856
856
|
var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
|
|
857
857
|
var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
|
|
858
858
|
var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
|
|
859
|
-
var WIZARD_VERSION = true ? "2.1.
|
|
859
|
+
var WIZARD_VERSION = true ? "2.1.3" : "?";
|
|
860
860
|
function printCompactHeader() {
|
|
861
861
|
const INNER = 42;
|
|
862
862
|
const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
|
|
@@ -941,6 +941,20 @@ WEBHOOK_SECRET=your-webhook-secret-here
|
|
|
941
941
|
# -----BEGIN PRIVATE KEY-----\\nMIGHAgEA...\\n-----END PRIVATE KEY-----
|
|
942
942
|
PARTNER_PRIVATE_KEY_PEM=
|
|
943
943
|
|
|
944
|
+
# ROTATING A KEY? Every dispatch names the key it was encrypted to, and both
|
|
945
|
+
# keys are valid during the overlap window. Set one variable per key, named for
|
|
946
|
+
# its key_id with dashes as underscores, upper-cased, and keep the OLD one set
|
|
947
|
+
# until the window closes:
|
|
948
|
+
#
|
|
949
|
+
# PARTNER_PRIVATE_KEY_PEM_04032299_4B04_4842_AA29_5095500C8ECE=...
|
|
950
|
+
#
|
|
951
|
+
# PARTNER_PRIVATE_KEY_PEM above is then a fallback, used for any key_id with no
|
|
952
|
+
# variable of its own. Convenient with one key and risky with two, because it
|
|
953
|
+
# answers for an id it does not hold and the failure looks like corruption
|
|
954
|
+
# rather than like a rotation. Set PARTNER_KEYS_STRICT=1 to turn it off once you
|
|
955
|
+
# have more than one key.
|
|
956
|
+
PARTNER_KEYS_STRICT=
|
|
957
|
+
|
|
944
958
|
# HTTP port for the local webhook server
|
|
945
959
|
PORT=3001
|
|
946
960
|
`
|
|
@@ -964,8 +978,8 @@ data.db-shm
|
|
|
964
978
|
"private": true,
|
|
965
979
|
"scripts": {
|
|
966
980
|
"dev": "tsx watch src/index.ts",
|
|
967
|
-
"start": "tsx src/index.ts",
|
|
968
|
-
"headless": "tsx src/index.ts -- --headless",
|
|
981
|
+
"start": "node --disable-warning=ExperimentalWarning node_modules/tsx/dist/cli.mjs src/index.ts",
|
|
982
|
+
"headless": "node --disable-warning=ExperimentalWarning node_modules/tsx/dist/cli.mjs src/index.ts -- --headless",
|
|
969
983
|
"build": "tsup src/index.ts --format esm --no-dts --outDir dist",
|
|
970
984
|
"type-check": "tsc --noEmit",
|
|
971
985
|
"test": "tsx scripts/test.ts"
|
|
@@ -1081,6 +1095,7 @@ import {
|
|
|
1081
1095
|
encryptField,
|
|
1082
1096
|
isEncrypted,
|
|
1083
1097
|
newSalt,
|
|
1098
|
+
PassphraseRequiredError,
|
|
1084
1099
|
verifierMatches,
|
|
1085
1100
|
WrongPassphraseError,
|
|
1086
1101
|
type VaultKeys,
|
|
@@ -1128,14 +1143,36 @@ function saltForThisDatabase(): Buffer {
|
|
|
1128
1143
|
}
|
|
1129
1144
|
|
|
1130
1145
|
function openKeys(): VaultKeys | null {
|
|
1146
|
+
// THE VERIFIER IS READ FIRST, and the order is the whole fix.
|
|
1147
|
+
//
|
|
1148
|
+
// This used to return null the moment no passphrase was set, without ever
|
|
1149
|
+
// asking whether the database was encrypted. An encrypted store opened with
|
|
1150
|
+
// no passphrase therefore ran IN THE CLEAR over the top of itself: it could
|
|
1151
|
+
// not decrypt the existing rows, so their blind indexes never matched, so it
|
|
1152
|
+
// seeded a SECOND copy of the roster in plaintext beside the first. The next
|
|
1153
|
+
// unlock then tried to converge both copies onto one key and died on a
|
|
1154
|
+
// UNIQUE constraint, permanently, with the correct passphrase in hand.
|
|
1155
|
+
//
|
|
1156
|
+
// Reported by a partner on 2.1.1 as "it is locking me out", and the lockout
|
|
1157
|
+
// was the last step of three. Two things were wrong and the quiet one was
|
|
1158
|
+
// worse: plaintext customer records written into a database its owner had
|
|
1159
|
+
// encrypted.
|
|
1160
|
+
const stored = readMeta('verifier');
|
|
1131
1161
|
const passphrase = process.env.ONEADDRESS_DB_PASSPHRASE;
|
|
1132
|
-
|
|
1162
|
+
|
|
1163
|
+
if (!passphrase || !passphrase.trim()) {
|
|
1164
|
+
// Never silently. A database with no verifier has never been locked and
|
|
1165
|
+
// running it in the clear is the documented, chosen behaviour; one WITH a
|
|
1166
|
+
// verifier is somebody's encrypted store and this is not the process that
|
|
1167
|
+
// gets to open it.
|
|
1168
|
+
if (stored !== null) throw new PassphraseRequiredError();
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1133
1171
|
|
|
1134
1172
|
const keys = deriveKeys(passphrase, saltForThisDatabase());
|
|
1135
1173
|
|
|
1136
1174
|
// Checked BEFORE anything is written. A mistyped passphrase must not be able
|
|
1137
1175
|
// to write a single row of ciphertext that nothing can ever read back.
|
|
1138
|
-
const stored = readMeta('verifier');
|
|
1139
1176
|
if (stored === null) writeMeta('verifier', buildVerifier(keys));
|
|
1140
1177
|
else if (!verifierMatches(keys, stored)) throw new WrongPassphraseError();
|
|
1141
1178
|
|
|
@@ -1182,7 +1219,16 @@ export function once(column: string, value: string | null): string | null {
|
|
|
1182
1219
|
return isEncrypted(value) ? value : encryptField(keys, column, value);
|
|
1183
1220
|
}
|
|
1184
1221
|
|
|
1185
|
-
export { WrongPassphraseError };
|
|
1222
|
+
export { PassphraseRequiredError, WrongPassphraseError };
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Is this stored value ciphertext? Exported for the repair pass in \`store.ts\`.
|
|
1226
|
+
*
|
|
1227
|
+
* The repair has to tell an encrypted row from a plaintext one written beside
|
|
1228
|
+
* it by a run that had no passphrase, and that is the only question that
|
|
1229
|
+
* separates them.
|
|
1230
|
+
*/
|
|
1231
|
+
export { isEncrypted };
|
|
1186
1232
|
export default db;
|
|
1187
1233
|
`
|
|
1188
1234
|
},
|
|
@@ -1363,6 +1409,25 @@ export class WrongPassphraseError extends Error {
|
|
|
1363
1409
|
}
|
|
1364
1410
|
}
|
|
1365
1411
|
|
|
1412
|
+
/**
|
|
1413
|
+
* The database is encrypted and nobody supplied a passphrase.
|
|
1414
|
+
*
|
|
1415
|
+
* NAMED SEPARATELY FROM \\\`WrongPassphraseError\\\` because the remedy is different:
|
|
1416
|
+
* one means try again, the other means you have not been asked yet. Before this
|
|
1417
|
+
* existed the case had no error at all - opening an encrypted database with no
|
|
1418
|
+
* passphrase returned no keys and the receiver carried on IN THE CLEAR over the
|
|
1419
|
+
* top of it, which cost a partner their database. See the refusal in db.ts.
|
|
1420
|
+
*/
|
|
1421
|
+
export class PassphraseRequiredError extends Error {
|
|
1422
|
+
constructor() {
|
|
1423
|
+
super(
|
|
1424
|
+
'This database is encrypted. Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` ' +
|
|
1425
|
+
'in a terminal to be asked for it.',
|
|
1426
|
+
);
|
|
1427
|
+
this.name = 'PassphraseRequiredError';
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1366
1431
|
export interface VaultKeys {
|
|
1367
1432
|
/** AES-256-GCM key for the personal columns. */
|
|
1368
1433
|
cipherKey: Buffer;
|
|
@@ -1595,7 +1660,12 @@ export function formatLine(line: ReportLine): string {
|
|
|
1595
1660
|
import blessed from 'blessed';
|
|
1596
1661
|
import { HEX, ONE_ROWS, ADDRESS_ROWS, terminalFitsFullMark } from './brand.js';
|
|
1597
1662
|
import { formatLine, report, type ReportLine } from './report.js';
|
|
1598
|
-
|
|
1663
|
+
// One import, same as server.ts. Swapping the store swaps what the dashboard
|
|
1664
|
+
// reads, with nothing here to change.
|
|
1665
|
+
import { store } from './store.js';
|
|
1666
|
+
import type { StoredCustomer } from './customer-store.js';
|
|
1667
|
+
import { pendingConfirmCount } from './confirm-queue.js';
|
|
1668
|
+
import { heldCount, heldSummary } from './quarantine.js';
|
|
1599
1669
|
|
|
1600
1670
|
/** blessed takes colours as strings; these mirror the site's palette. */
|
|
1601
1671
|
const AMBER = HEX.amber.toLowerCase();
|
|
@@ -1610,6 +1680,14 @@ export interface TuiOptions {
|
|
|
1610
1680
|
port: number;
|
|
1611
1681
|
/** Called when the operator quits, so the caller can close the server. */
|
|
1612
1682
|
onQuit: () => void;
|
|
1683
|
+
/**
|
|
1684
|
+
* Re-apply everything the receiver could not open, bound to [r].
|
|
1685
|
+
*
|
|
1686
|
+
* Passed IN rather than imported: \`server.ts\` already imports this file for
|
|
1687
|
+
* \`notePreviousAddress\`, so importing it back would be a cycle. Optional so
|
|
1688
|
+
* the dashboard still renders for a caller that has no replay to offer.
|
|
1689
|
+
*/
|
|
1690
|
+
onReplay?: () => Promise<{ applied: number; failed: number }>;
|
|
1613
1691
|
}
|
|
1614
1692
|
|
|
1615
1693
|
/** One address as a single line, the way the change panel shows it. */
|
|
@@ -1618,7 +1696,7 @@ function oneLine(a: Record<string, unknown>): string {
|
|
|
1618
1696
|
return parts.length > 0 ? parts.join(', ') : '(empty)';
|
|
1619
1697
|
}
|
|
1620
1698
|
|
|
1621
|
-
export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void {
|
|
1699
|
+
export function startDashboard({ partnerName, port, onQuit, onReplay }: TuiOptions): void {
|
|
1622
1700
|
const screen = blessed.screen({
|
|
1623
1701
|
smartCSR: true,
|
|
1624
1702
|
title: \`\${partnerName} \u2014 OneAddress receiver\`,
|
|
@@ -1671,6 +1749,23 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
|
|
|
1671
1749
|
style: { border: { fg: DIM }, label: { fg: DIM } },
|
|
1672
1750
|
});
|
|
1673
1751
|
|
|
1752
|
+
// \u2500\u2500 The faults band: dispatches that arrived and could not be opened \u2500\u2500\u2500\u2500\u2500\u2500
|
|
1753
|
+
//
|
|
1754
|
+
// HIDDEN WHEN THERE IS NOTHING WRONG, and that is a deliberate departure from
|
|
1755
|
+
// how the rest of this screen works. The other panels are always drawn,
|
|
1756
|
+
// including the ENCRYPTED/UNENCRYPTED line, because a property you only
|
|
1757
|
+
// mention when it holds is one nobody notices the absence of. A faults panel
|
|
1758
|
+
// is the other case: a permanent empty box teaches the eye to skip that
|
|
1759
|
+
// region, which is precisely the region that has to be noticed the one day it
|
|
1760
|
+
// fills. So it appears, and the two panels above give up the rows.
|
|
1761
|
+
const FAULT_HEIGHT = 7;
|
|
1762
|
+
const faultBox = blessed.box({
|
|
1763
|
+
parent: screen, bottom: 3, left: 0, width: '100%', height: FAULT_HEIGHT,
|
|
1764
|
+
label: ' FAULTS ', tags: true, padding: { left: 1, right: 1 }, hidden: true,
|
|
1765
|
+
border: { type: 'line' } as never,
|
|
1766
|
+
style: { border: { fg: 'red' }, label: { fg: 'red' } },
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1674
1769
|
const footer = blessed.box({
|
|
1675
1770
|
parent: screen, bottom: 0, left: 0, width: '100%', height: 3,
|
|
1676
1771
|
tags: true, padding: { left: 2 },
|
|
@@ -1694,14 +1789,18 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
|
|
|
1694
1789
|
// Both states are shown, and the unprotected one is the loud colour. A
|
|
1695
1790
|
// security property mentioned only when it holds is one nobody notices the
|
|
1696
1791
|
// absence of.
|
|
1697
|
-
const vault =
|
|
1792
|
+
const vault = store.encrypted
|
|
1698
1793
|
? \`{green-fg}{bold}ENCRYPTED{/}\`
|
|
1699
1794
|
: \`{red-fg}{bold}UNENCRYPTED{/}\`;
|
|
1700
1795
|
status.setContent(
|
|
1701
1796
|
\`{\${AMBER}-fg}{bold}\${esc(partnerName)}{/} \` +
|
|
1702
1797
|
\`{\${DIM}-fg}listening{/} {\${CREAM}-fg}:\${port}/webhook{/} \` +
|
|
1703
1798
|
\`{\${DIM}-fg}customer file{/} \${vault} \` +
|
|
1704
|
-
|
|
1799
|
+
// A DASH RATHER THAN A ZERO when the store declines to count. Zero is a
|
|
1800
|
+
// claim ("you have no customers") and would be a lie on a receiver
|
|
1801
|
+
// pointed at a real customer table, where counting every row forty times
|
|
1802
|
+
// a minute is the thing the store is right to refuse.
|
|
1803
|
+
\`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}\`,
|
|
1705
1804
|
);
|
|
1706
1805
|
}
|
|
1707
1806
|
|
|
@@ -1725,18 +1824,76 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
|
|
|
1725
1824
|
);
|
|
1726
1825
|
}
|
|
1727
1826
|
|
|
1827
|
+
/** Shown while a replay is running, so [r] does not look like it did nothing. */
|
|
1828
|
+
let replayNote = '';
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* The customer count, refreshed out of band.
|
|
1832
|
+
*
|
|
1833
|
+
* CACHED RATHER THAN READ DURING RENDER, because \`count()\` may be async and a
|
|
1834
|
+
* render cannot await. It starts null, which is also what a store that
|
|
1835
|
+
* declines to count returns forever, so the dash below is the honest initial
|
|
1836
|
+
* state rather than a placeholder that happens to look the same.
|
|
1837
|
+
*/
|
|
1838
|
+
let onFile: number | null = null;
|
|
1839
|
+
function refreshCount(): void {
|
|
1840
|
+
void Promise.resolve(store.count())
|
|
1841
|
+
.then((n) => { onFile = n; })
|
|
1842
|
+
.catch(() => { onFile = null; });
|
|
1843
|
+
}
|
|
1844
|
+
refreshCount();
|
|
1845
|
+
|
|
1846
|
+
function renderFaults(): void {
|
|
1847
|
+
const held = heldCount();
|
|
1848
|
+
const show = held > 0;
|
|
1849
|
+
if (show === Boolean(faultBox.hidden)) {
|
|
1850
|
+
// Visibility is changing, so the panels above have to give back or take
|
|
1851
|
+
// back the rows. Assigning \`bottom\` is how blessed re-lays-out; it reads
|
|
1852
|
+
// the value on the next render rather than caching a computed box.
|
|
1853
|
+
if (show) faultBox.show(); else faultBox.hide();
|
|
1854
|
+
const edge = show ? 3 + FAULT_HEIGHT : 3;
|
|
1855
|
+
(changeBox as unknown as { bottom: number }).bottom = edge;
|
|
1856
|
+
(logBox as unknown as { bottom: number }).bottom = edge;
|
|
1857
|
+
}
|
|
1858
|
+
if (!show) return;
|
|
1859
|
+
|
|
1860
|
+
// Grouped, never one line per dispatch. The realistic shape of this table
|
|
1861
|
+
// is forty rows with ONE cause between them, and forty identical lines hide
|
|
1862
|
+
// the single fact that matters.
|
|
1863
|
+
const lines = heldSummary().slice(0, 3).map((l) => \` {red-fg}\${esc(l)}{/}\`);
|
|
1864
|
+
faultBox.setContent(
|
|
1865
|
+
\`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open.\\n\` +
|
|
1866
|
+
\` {\${DIM}-fg}They are held, encrypted, exactly as they arrived. Nothing is lost yet.{/}\\n\\n\` +
|
|
1867
|
+
lines.join('\\n') + '\\n\\n' +
|
|
1868
|
+
(replayNote
|
|
1869
|
+
? \` {\${AMBER}-fg}\${esc(replayNote)}{/}\`
|
|
1870
|
+
: \` {\${DIM}-fg}Fix the cause (usually a key in .env), then press{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply them.{/}\`),
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1728
1874
|
function renderFooter(): void {
|
|
1875
|
+
// AWAITING IS THE ONE AN OPERATOR CANNOT AFFORD TO MISS, so it is drawn
|
|
1876
|
+
// whether or not it is zero. A dispatch that was applied here but never
|
|
1877
|
+
// acknowledged back to OneAddress shows the consumer a FAILED delivery for
|
|
1878
|
+
// an update that in fact succeeded, and the auto-refund cron treats a
|
|
1879
|
+
// terminal failure as refundable: the work is done and the money goes back.
|
|
1880
|
+
// Read from the queue rather than counted here, so a receiver restarted
|
|
1881
|
+
// with a backlog shows the backlog instead of zero.
|
|
1882
|
+
const awaiting = pendingConfirmCount();
|
|
1883
|
+
const awaitingColour = awaiting > 0 ? AMBER : DIM;
|
|
1729
1884
|
footer.setContent(
|
|
1730
1885
|
\`{\${DIM}-fg}received{/} {bold}\${received}{/bold} \` +
|
|
1731
1886
|
\`{green-fg}applied{/} {bold}\${applied}{/bold} \` +
|
|
1732
|
-
\`{red-fg}failed{/} {bold}\${failed}{/bold}\` +
|
|
1733
|
-
\`{
|
|
1887
|
+
\`{red-fg}failed{/} {bold}\${failed}{/bold} \` +
|
|
1888
|
+
\`{\${awaitingColour}-fg}awaiting confirm{/} {bold}\${awaiting}{/bold}\` +
|
|
1889
|
+
\`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[q] quit{/} \`,
|
|
1734
1890
|
);
|
|
1735
1891
|
}
|
|
1736
1892
|
|
|
1737
1893
|
function redraw(): void {
|
|
1738
1894
|
renderStatus();
|
|
1739
1895
|
renderChange();
|
|
1896
|
+
renderFaults();
|
|
1740
1897
|
renderFooter();
|
|
1741
1898
|
screen.render();
|
|
1742
1899
|
}
|
|
@@ -1750,21 +1907,64 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
|
|
|
1750
1907
|
const colour = line.level === 'error' ? 'red' : line.level === 'warn' ? 'yellow' : CREAM;
|
|
1751
1908
|
const time = new Date(line.at).toTimeString().slice(0, 8);
|
|
1752
1909
|
|
|
1753
|
-
|
|
1754
|
-
|
|
1910
|
+
// COUNTED ON THE OUTCOME LINE, WHICHEVER IT IS.
|
|
1911
|
+
//
|
|
1912
|
+
// \`received\` used to increment only on \`address.updated for <account>\`,
|
|
1913
|
+
// which is logged AFTER decryption because the account number comes out of
|
|
1914
|
+
// the decrypted envelope. A dispatch that failed to decrypt returned 422
|
|
1915
|
+
// before that line ran, so it was never counted as having arrived: a
|
|
1916
|
+
// partner with a wrong key saw \`received 0 \xB7 failed 12\` and went looking at
|
|
1917
|
+
// their tunnel and their webhook URL. Everything was arriving. The error
|
|
1918
|
+
// line said "check your key" and the counter, louder, said "nothing here".
|
|
1919
|
+
//
|
|
1920
|
+
// Every dispatch produces exactly one of these, so counting either as an
|
|
1921
|
+
// arrival makes received = applied + failed hold.
|
|
1922
|
+
const failure = /REFUSED|decryption failed|Decryption failed|no private key for/.test(text);
|
|
1923
|
+
if (/address\\.updated for /.test(text) || failure) received++;
|
|
1924
|
+
if (failure) failed++;
|
|
1755
1925
|
if (/\\[store\\] saved address for /.test(text)) {
|
|
1756
1926
|
applied++;
|
|
1757
1927
|
// The store logs the account key and never the address, so the panel is
|
|
1758
1928
|
// refreshed from the DATABASE rather than parsed out of the log line.
|
|
1759
1929
|
const acct = /saved address for (\\S+)/.exec(text)?.[1];
|
|
1760
|
-
|
|
1761
|
-
|
|
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
|
+
}
|
|
1762
1941
|
}
|
|
1763
1942
|
|
|
1764
1943
|
logBox.log(\`{\${DIM}-fg}\${time}{/} {\${colour}-fg}\${esc(text)}{/}\`);
|
|
1765
1944
|
redraw();
|
|
1766
1945
|
});
|
|
1767
1946
|
|
|
1947
|
+
// [r] REPLAYS. Bound unconditionally rather than only while faults exist, so
|
|
1948
|
+
// pressing it on a clean receiver says "nothing held" instead of doing
|
|
1949
|
+
// nothing, which is indistinguishable from a wedged UI.
|
|
1950
|
+
let replaying = false;
|
|
1951
|
+
screen.key(['r'], () => {
|
|
1952
|
+
if (!onReplay || replaying) return;
|
|
1953
|
+
replaying = true;
|
|
1954
|
+
replayNote = 'Replaying\u2026';
|
|
1955
|
+
redraw();
|
|
1956
|
+
void onReplay()
|
|
1957
|
+
.then(({ applied, failed }) => {
|
|
1958
|
+
replayNote = failed > 0
|
|
1959
|
+
? \`Applied \${applied}, \${failed} still failing \u2014 the cause is not fixed yet.\`
|
|
1960
|
+
: \`Applied \${applied}. Nothing left held.\`;
|
|
1961
|
+
})
|
|
1962
|
+
.catch((err: unknown) => {
|
|
1963
|
+
replayNote = \`Replay failed: \${err instanceof Error ? err.message : String(err)}\`;
|
|
1964
|
+
})
|
|
1965
|
+
.finally(() => { replaying = false; redraw(); });
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1768
1968
|
screen.key(['q', 'C-c'], () => {
|
|
1769
1969
|
detach();
|
|
1770
1970
|
screen.destroy();
|
|
@@ -1772,6 +1972,14 @@ export function startDashboard({ partnerName, port, onQuit }: TuiOptions): void
|
|
|
1772
1972
|
process.exit(0);
|
|
1773
1973
|
});
|
|
1774
1974
|
|
|
1975
|
+
// EVERY OTHER REDRAW IS TRIGGERED BY A REPORTED LINE, and the awaiting count
|
|
1976
|
+
// is the one number that can change without one. The confirm backoff caps at
|
|
1977
|
+
// an hour, so a confirm stuck against a wrong secret would sit on screen at
|
|
1978
|
+
// its hour-old value and read as settled. Cheap: one indexed COUNT, and only
|
|
1979
|
+
// while a terminal is attached.
|
|
1980
|
+
const tick = setInterval(() => { refreshCount(); redraw(); }, 2_000);
|
|
1981
|
+
tick.unref();
|
|
1982
|
+
|
|
1775
1983
|
redraw();
|
|
1776
1984
|
}
|
|
1777
1985
|
|
|
@@ -1814,7 +2022,7 @@ export function notePreviousAddress(prev: Record<string, unknown>): void {
|
|
|
1814
2022
|
* \`--headless\` skips both: no prompt (a service has nobody to ask), no screen.
|
|
1815
2023
|
*/
|
|
1816
2024
|
import { printBanner } from './brand.js';
|
|
1817
|
-
import { WrongPassphraseError } from './vault.js';
|
|
2025
|
+
import { PassphraseRequiredError, WrongPassphraseError } from './vault.js';
|
|
1818
2026
|
|
|
1819
2027
|
/**
|
|
1820
2028
|
* No dashboard without a terminal to draw it in.
|
|
@@ -1878,25 +2086,79 @@ async function databaseIsLocked(): Promise<boolean> {
|
|
|
1878
2086
|
* hook is not available, being asked in the clear beats not being asked.
|
|
1879
2087
|
*/
|
|
1880
2088
|
async function ask(prompt: string): Promise<string> {
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
2089
|
+
process.stdout.write(prompt);
|
|
2090
|
+
|
|
2091
|
+
// NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
|
|
2092
|
+
// mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
|
|
2093
|
+
// by driving a real terminal and reading what came back. The first filtered
|
|
2094
|
+
// on whether the chunk contained the prompt, not knowing readline repaints
|
|
2095
|
+
// prompt and input together on every keystroke, so the condition was always
|
|
2096
|
+
// true. The second repainted the line and still leaked, because the echo was
|
|
2097
|
+
// never coming from that hook at all.
|
|
2098
|
+
//
|
|
2099
|
+
// Reading the keys directly removes the guessing. Raw mode turns the
|
|
2100
|
+
// terminal's own echo OFF, so the ONLY thing that can reach the screen is
|
|
2101
|
+
// what is written below: one asterisk per character, which is what a partner
|
|
2102
|
+
// asked for and what every other passphrase prompt does.
|
|
2103
|
+
const stdin = process.stdin;
|
|
2104
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
2105
|
+
// No terminal to control. Being asked in the clear beats not being asked,
|
|
2106
|
+
// and this path is only reached where nothing is watching anyway.
|
|
2107
|
+
const { createInterface } = await import('node:readline/promises');
|
|
2108
|
+
const rl = createInterface({ input: stdin, output: process.stdout });
|
|
2109
|
+
try {
|
|
2110
|
+
const answer = await rl.question('');
|
|
2111
|
+
return answer.trim();
|
|
2112
|
+
} finally { rl.close(); }
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
const wasRaw = stdin.isRaw === true;
|
|
2116
|
+
stdin.setRawMode(true);
|
|
2117
|
+
stdin.resume();
|
|
2118
|
+
stdin.setEncoding('utf8');
|
|
2119
|
+
|
|
2120
|
+
return new Promise<string>((resolve) => {
|
|
2121
|
+
let typed = '';
|
|
2122
|
+
const restore = (): void => {
|
|
2123
|
+
stdin.removeListener('data', onData);
|
|
2124
|
+
stdin.setRawMode(wasRaw);
|
|
2125
|
+
stdin.pause();
|
|
1890
2126
|
};
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2127
|
+
const onData = (chunk: string): void => {
|
|
2128
|
+
for (const ch of chunk) {
|
|
2129
|
+
if (ch === '\\r' || ch === '\\n') {
|
|
2130
|
+
restore();
|
|
2131
|
+
process.stdout.write('\\n');
|
|
2132
|
+
resolve(typed.trim());
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
if (ch === '\\u0003') { // Ctrl+C
|
|
2136
|
+
restore();
|
|
2137
|
+
process.stdout.write('\\n');
|
|
2138
|
+
process.exit(130);
|
|
2139
|
+
}
|
|
2140
|
+
if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
|
|
2141
|
+
restore();
|
|
2142
|
+
process.stdout.write('\\n');
|
|
2143
|
+
resolve(typed.trim());
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
if (ch === '\\u007f' || ch === '\\b') {
|
|
2147
|
+
// Backspace has to move the asterisks too, or the mask stops matching
|
|
2148
|
+
// what is actually in the buffer and the count misleads.
|
|
2149
|
+
if (typed.length > 0) {
|
|
2150
|
+
typed = typed.slice(0, -1);
|
|
2151
|
+
process.stdout.write('\\b \\b');
|
|
2152
|
+
}
|
|
2153
|
+
continue;
|
|
2154
|
+
}
|
|
2155
|
+
if (ch < ' ') continue; // ignore the rest of the control range
|
|
2156
|
+
typed += ch;
|
|
2157
|
+
process.stdout.write('*');
|
|
2158
|
+
}
|
|
2159
|
+
};
|
|
2160
|
+
stdin.on('data', onData);
|
|
2161
|
+
});
|
|
1900
2162
|
}
|
|
1901
2163
|
|
|
1902
2164
|
/**
|
|
@@ -1927,7 +2189,17 @@ async function resolvePassphrase(): Promise<string | null> {
|
|
|
1927
2189
|
if (headless || !process.stdin.isTTY) return null;
|
|
1928
2190
|
|
|
1929
2191
|
if (await databaseIsLocked()) {
|
|
1930
|
-
|
|
2192
|
+
// NAMES THE WAY OUT, because a partner can arrive here having never
|
|
2193
|
+
// knowingly set a passphrase. Versions before 2.0.1 asked for one with a
|
|
2194
|
+
// prompt that did not say whether it was creating or checking, so a
|
|
2195
|
+
// database can carry a verifier its owner does not remember agreeing to.
|
|
2196
|
+
// Reported by exactly that partner: "there was no opportunity to set a
|
|
2197
|
+
// passphrase". Without this line the only options are guess or search the
|
|
2198
|
+
// internet, and the answer is one command.
|
|
2199
|
+
process.stdout.write('\\n This customer database is encrypted.\\n');
|
|
2200
|
+
process.stdout.write(' If you do not know the passphrase, delete data.db and start\\n');
|
|
2201
|
+
process.stdout.write(' again: it holds your demo roster and test dispatches, nothing\\n');
|
|
2202
|
+
process.stdout.write(' OneAddress needs.\\n\\n');
|
|
1931
2203
|
const answer = await ask(' Passphrase to unlock: ');
|
|
1932
2204
|
return answer || null;
|
|
1933
2205
|
}
|
|
@@ -1962,11 +2234,32 @@ async function main(): Promise<void> {
|
|
|
1962
2234
|
// Dynamic, and this is the whole point of the file: importing the server
|
|
1963
2235
|
// pulls in the store, which pulls in the database, which derives its keys on
|
|
1964
2236
|
// import. The passphrase has to be set before that chain starts.
|
|
1965
|
-
let config: {
|
|
2237
|
+
let config: {
|
|
2238
|
+
partnerName: string;
|
|
2239
|
+
port: number;
|
|
2240
|
+
replay: () => Promise<{ applied: number; failed: number }>;
|
|
2241
|
+
};
|
|
1966
2242
|
try {
|
|
1967
2243
|
const server = await import('./server.js');
|
|
1968
|
-
config = {
|
|
2244
|
+
config = {
|
|
2245
|
+
partnerName: server.PARTNER_NAME,
|
|
2246
|
+
port: server.PORT,
|
|
2247
|
+
// Handed through rather than imported by the dashboard, because the
|
|
2248
|
+
// server already imports the dashboard (for notePreviousAddress) and a
|
|
2249
|
+
// second edge the other way is a cycle. This file is the one place that
|
|
2250
|
+
// holds both.
|
|
2251
|
+
replay: server.replayQuarantined,
|
|
2252
|
+
};
|
|
1969
2253
|
} catch (err) {
|
|
2254
|
+
if (err instanceof PassphraseRequiredError) {
|
|
2255
|
+
// Reached when nobody could be asked: a service unit, a piped stdin, or
|
|
2256
|
+
// the setup wizard's own health-check autostart. Before the refusal in
|
|
2257
|
+
// db.ts this case ran in the clear and corrupted the database instead.
|
|
2258
|
+
console.error('\\n This database is encrypted and no passphrase was supplied.');
|
|
2259
|
+
console.error(' Run \`npm start\` in a terminal to be asked for it, or set');
|
|
2260
|
+
console.error(' ONEADDRESS_DB_PASSPHRASE for an unattended start.\\n');
|
|
2261
|
+
process.exit(1);
|
|
2262
|
+
}
|
|
1970
2263
|
if (err instanceof WrongPassphraseError) {
|
|
1971
2264
|
// Named for what it is. Without this the first symptom is a decryption
|
|
1972
2265
|
// error on a live dispatch, which reads as a corrupt database and sends
|
|
@@ -1984,6 +2277,7 @@ async function main(): Promise<void> {
|
|
|
1984
2277
|
startDashboard({
|
|
1985
2278
|
partnerName: config.partnerName,
|
|
1986
2279
|
port: config.port,
|
|
2280
|
+
onReplay: config.replay,
|
|
1987
2281
|
onQuit: () => { /* the process exits; the OS closes the socket */ },
|
|
1988
2282
|
});
|
|
1989
2283
|
}
|
|
@@ -1992,6 +2286,581 @@ main().catch((err) => {
|
|
|
1992
2286
|
console.error('[startup]', err instanceof Error ? err.message : err);
|
|
1993
2287
|
process.exit(1);
|
|
1994
2288
|
});
|
|
2289
|
+
`
|
|
2290
|
+
},
|
|
2291
|
+
{
|
|
2292
|
+
name: "src/quarantine.ts",
|
|
2293
|
+
content: `/**
|
|
2294
|
+
* Dispatches that arrived intact and could not be opened.
|
|
2295
|
+
*
|
|
2296
|
+
* ## What this is for
|
|
2297
|
+
*
|
|
2298
|
+
* A dispatch whose signature verifies but whose payload will not decrypt used
|
|
2299
|
+
* to get an HTTP 422 and a log line, and nothing was kept. OneAddress retries a
|
|
2300
|
+
* 422, so the update survived exactly as long as its retry window. A partner
|
|
2301
|
+
* who worked out on Thursday that the wrong key was installed on Monday had
|
|
2302
|
+
* lost it, with nothing local to point at.
|
|
2303
|
+
*
|
|
2304
|
+
* So the bytes are kept. Fix the key, replay, and the same handler runs against
|
|
2305
|
+
* the same request it already received.
|
|
2306
|
+
*
|
|
2307
|
+
* ## THREE PROPERTIES THAT ARE THE DESIGN, NOT DECORATION
|
|
2308
|
+
*
|
|
2309
|
+
* **Nothing is decrypted here.** The row holds exactly the ciphertext that
|
|
2310
|
+
* arrived. It cannot contain a plaintext address, because at the moment it is
|
|
2311
|
+
* written the receiver does not have one - that is the entire reason the row
|
|
2312
|
+
* exists. Replay decrypts in memory in the handler, the same as a live dispatch.
|
|
2313
|
+
*
|
|
2314
|
+
* **Only a SIGNATURE-VERIFIED request is ever quarantined.** The call sites are
|
|
2315
|
+
* all below the HMAC check. Quarantining before it would let anyone who can
|
|
2316
|
+
* reach the webhook fill the disk, which turns a diagnostic into a
|
|
2317
|
+
* denial-of-service surface.
|
|
2318
|
+
*
|
|
2319
|
+
* **The retention rule is the OPPOSITE of the confirm queue's, deliberately.**
|
|
2320
|
+
* A confirm record is content-free, so keeping an outstanding one forever costs
|
|
2321
|
+
* nothing and dropping it loses an update; nothing ages those out. A
|
|
2322
|
+
* quarantined payload is a consumer's encrypted address on a partner's disk,
|
|
2323
|
+
* and keeping it indefinitely is a retention nobody agreed to. These are purged
|
|
2324
|
+
* on a window (30 days by default), replayed or not, and purging one that was
|
|
2325
|
+
* never replayed says so out loud rather than quietly.
|
|
2326
|
+
*
|
|
2327
|
+
* ## What is NOT quarantined, and why
|
|
2328
|
+
*
|
|
2329
|
+
* An \`address.updated\` refused because the account reference matches no
|
|
2330
|
+
* customer is a DECISION, not a fault. It is already reported to OneAddress as
|
|
2331
|
+
* a failed confirm, the partner's data is exactly as they intended, and a
|
|
2332
|
+
* replay would apply an address for an account they do not recognise. It stays
|
|
2333
|
+
* a refusal.
|
|
2334
|
+
*/
|
|
2335
|
+
import db from './db.js';
|
|
2336
|
+
import { report } from './report.js';
|
|
2337
|
+
import { createHash } from 'node:crypto';
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* Why a dispatch could not be applied. Shown verbatim on the dashboard.
|
|
2341
|
+
*
|
|
2342
|
+
* TWO VALUES, AND THE DISTINCTION IS THE DIAGNOSIS. \`no_key\` is a line missing
|
|
2343
|
+
* from \`.env\`; \`decrypt_failed\` is a key that is present and wrong. AES-GCM
|
|
2344
|
+
* cannot tell a wrong key from a tampered ciphertext, so this record of which
|
|
2345
|
+
* branch refused is the only thing that separates them afterwards.
|
|
2346
|
+
*
|
|
2347
|
+
* There is deliberately no \`handler_error\`. A fault inside the handler is a
|
|
2348
|
+
* bug in code the partner can edit, not a dispatch waiting on a configuration
|
|
2349
|
+
* change, and holding a payload for it would suggest replaying is the remedy.
|
|
2350
|
+
*/
|
|
2351
|
+
export type QuarantineReason = 'no_key' | 'decrypt_failed';
|
|
2352
|
+
|
|
2353
|
+
db.exec(\`
|
|
2354
|
+
CREATE TABLE IF NOT EXISTS quarantine (
|
|
2355
|
+
id TEXT PRIMARY KEY,
|
|
2356
|
+
dispatch_id TEXT,
|
|
2357
|
+
event TEXT NOT NULL,
|
|
2358
|
+
reason TEXT NOT NULL,
|
|
2359
|
+
key_id TEXT,
|
|
2360
|
+
raw_body TEXT NOT NULL,
|
|
2361
|
+
detail TEXT,
|
|
2362
|
+
received_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2363
|
+
replayed_at TEXT,
|
|
2364
|
+
last_error TEXT
|
|
2365
|
+
);
|
|
2366
|
+
CREATE INDEX IF NOT EXISTS idx_quarantine_open
|
|
2367
|
+
ON quarantine(replayed_at, received_at);
|
|
2368
|
+
\`);
|
|
2369
|
+
|
|
2370
|
+
/**
|
|
2371
|
+
* One row per dispatch, not one per delivery attempt.
|
|
2372
|
+
*
|
|
2373
|
+
* OneAddress retries a 422, so the SAME broken dispatch arrives again every few
|
|
2374
|
+
* minutes while the key is still wrong. Without a stable id a single
|
|
2375
|
+
* misconfigured key would write a row a minute until someone noticed. Keyed on
|
|
2376
|
+
* the dispatch header where there is one, and on a hash of the body where there
|
|
2377
|
+
* is not, so a retry updates the existing row instead of adding to a pile.
|
|
2378
|
+
*/
|
|
2379
|
+
function rowId(dispatchId: string | null, rawBody: string): string {
|
|
2380
|
+
if (dispatchId && dispatchId.trim()) return \`d:\${dispatchId.trim()}\`;
|
|
2381
|
+
return \`h:\${createHash('sha256').update(rawBody).digest('hex').slice(0, 32)}\`;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
export interface QuarantineInput {
|
|
2385
|
+
dispatchId: string | null;
|
|
2386
|
+
event: string;
|
|
2387
|
+
reason: QuarantineReason;
|
|
2388
|
+
keyId: string | null;
|
|
2389
|
+
rawBody: string;
|
|
2390
|
+
detail: string;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
/**
|
|
2394
|
+
* Keep a dispatch that could not be applied.
|
|
2395
|
+
*
|
|
2396
|
+
* Best-effort by design: this runs on a path that is ALREADY failing, and a
|
|
2397
|
+
* quarantine write that throws would turn a recoverable 422 into a 500. The
|
|
2398
|
+
* caller must be able to answer OneAddress whatever happens here.
|
|
2399
|
+
*/
|
|
2400
|
+
export function quarantine(input: QuarantineInput): void {
|
|
2401
|
+
const id = rowId(input.dispatchId, input.rawBody);
|
|
2402
|
+
try {
|
|
2403
|
+
db.prepare(
|
|
2404
|
+
\`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
|
|
2405
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2406
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
2407
|
+
reason = excluded.reason,
|
|
2408
|
+
key_id = excluded.key_id,
|
|
2409
|
+
detail = excluded.detail\`,
|
|
2410
|
+
).run(
|
|
2411
|
+
id,
|
|
2412
|
+
input.dispatchId,
|
|
2413
|
+
input.event,
|
|
2414
|
+
input.reason,
|
|
2415
|
+
input.keyId,
|
|
2416
|
+
input.rawBody,
|
|
2417
|
+
input.detail.slice(0, 500),
|
|
2418
|
+
);
|
|
2419
|
+
report.warn(
|
|
2420
|
+
\`[quarantine] \${input.event} held (\${input.reason}\${input.keyId ? \`, key_id \${input.keyId}\` : ''}). \` +
|
|
2421
|
+
'Fix the cause and press [r] on the dashboard, or run \`npm run replay\`, to apply it.',
|
|
2422
|
+
);
|
|
2423
|
+
} catch (err) {
|
|
2424
|
+
report.error('[quarantine] could not hold this dispatch:', err instanceof Error ? err.message : err);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
export interface HeldDispatch {
|
|
2429
|
+
id: string;
|
|
2430
|
+
dispatch_id: string | null;
|
|
2431
|
+
event: string;
|
|
2432
|
+
reason: QuarantineReason;
|
|
2433
|
+
key_id: string | null;
|
|
2434
|
+
raw_body: string;
|
|
2435
|
+
detail: string | null;
|
|
2436
|
+
received_at: string;
|
|
2437
|
+
last_error: string | null;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/** Everything still held, oldest first. */
|
|
2441
|
+
export function heldDispatches(limit = 50): HeldDispatch[] {
|
|
2442
|
+
return db.prepare(
|
|
2443
|
+
\`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail, received_at, last_error
|
|
2444
|
+
FROM quarantine
|
|
2445
|
+
WHERE replayed_at IS NULL
|
|
2446
|
+
ORDER BY received_at
|
|
2447
|
+
LIMIT ?\`,
|
|
2448
|
+
).all(limit) as unknown as HeldDispatch[];
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/** How many dispatches are held. Shown on the dashboard. */
|
|
2452
|
+
export function heldCount(): number {
|
|
2453
|
+
const row = db.prepare(
|
|
2454
|
+
'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL',
|
|
2455
|
+
).get() as { n: number };
|
|
2456
|
+
return row.n;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
/**
|
|
2460
|
+
* A one-line summary per held dispatch, grouped by cause.
|
|
2461
|
+
*
|
|
2462
|
+
* Grouped because the realistic shape of this table is fifty rows with ONE
|
|
2463
|
+
* cause between them: a key was wrong for an afternoon. Fifty identical lines
|
|
2464
|
+
* hide that; "47 held: no key for key_id 0403\u2026" is the whole diagnosis.
|
|
2465
|
+
*/
|
|
2466
|
+
export function heldSummary(): string[] {
|
|
2467
|
+
const rows = db.prepare(
|
|
2468
|
+
\`SELECT reason, key_id, count(*) AS n
|
|
2469
|
+
FROM quarantine
|
|
2470
|
+
WHERE replayed_at IS NULL
|
|
2471
|
+
GROUP BY reason, key_id
|
|
2472
|
+
ORDER BY n DESC\`,
|
|
2473
|
+
).all() as unknown as { reason: string; key_id: string | null; n: number }[];
|
|
2474
|
+
return rows.map((r) =>
|
|
2475
|
+
\`\${r.n} \xD7 \${r.reason}\${r.key_id ? \` (key_id \${r.key_id})\` : ''}\`,
|
|
2476
|
+
);
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
export function markReplayed(id: string): void {
|
|
2480
|
+
db.prepare(
|
|
2481
|
+
'UPDATE quarantine SET replayed_at = ?, last_error = NULL WHERE id = ?',
|
|
2482
|
+
).run(new Date().toISOString(), id);
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
/** A replay that failed the same way stays held, with the new reason recorded. */
|
|
2486
|
+
export function markReplayFailed(id: string, error: string): void {
|
|
2487
|
+
db.prepare('UPDATE quarantine SET last_error = ? WHERE id = ?')
|
|
2488
|
+
.run(error.slice(0, 500), id);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
/**
|
|
2492
|
+
* Drop held payloads past the retention window, replayed or not.
|
|
2493
|
+
*
|
|
2494
|
+
* DELIBERATELY UNLIKE \`purgeDelivered\` IN THE CONFIRM QUEUE, which never ages
|
|
2495
|
+
* out an outstanding row. The difference is what the row contains. A confirm
|
|
2496
|
+
* record names a dispatch and an outcome and nothing else, so holding it costs
|
|
2497
|
+
* a consumer nothing. A quarantined payload is that consumer's address,
|
|
2498
|
+
* encrypted, on a third party's disk: an unbounded hold is a retention decision
|
|
2499
|
+
* made on their behalf by nobody.
|
|
2500
|
+
*
|
|
2501
|
+
* An unreplayed row going out is reported separately and loudly, because that
|
|
2502
|
+
* is an update the partner never applied and is now no longer able to.
|
|
2503
|
+
*/
|
|
2504
|
+
export function purgeQuarantine(days: number): { replayed: number; unreplayed: number } {
|
|
2505
|
+
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
2506
|
+
const doomed = db.prepare(
|
|
2507
|
+
'SELECT id, replayed_at FROM quarantine WHERE received_at < ?',
|
|
2508
|
+
).all(cutoff) as unknown as { id: string; replayed_at: string | null }[];
|
|
2509
|
+
if (doomed.length === 0) return { replayed: 0, unreplayed: 0 };
|
|
2510
|
+
|
|
2511
|
+
db.prepare('DELETE FROM quarantine WHERE received_at < ?').run(cutoff);
|
|
2512
|
+
|
|
2513
|
+
const unreplayed = doomed.filter((d) => d.replayed_at === null).length;
|
|
2514
|
+
const replayed = doomed.length - unreplayed;
|
|
2515
|
+
if (replayed > 0) {
|
|
2516
|
+
report.info(\`[quarantine] purged \${replayed} replayed payload(s) older than \${days}d\`);
|
|
2517
|
+
}
|
|
2518
|
+
if (unreplayed > 0) {
|
|
2519
|
+
report.warn(
|
|
2520
|
+
\`[quarantine] purged \${unreplayed} payload(s) older than \${days}d that were NEVER APPLIED. \` +
|
|
2521
|
+
'Those address updates are gone from this receiver. Raise them with OneAddress if you need them re-sent.',
|
|
2522
|
+
);
|
|
2523
|
+
}
|
|
2524
|
+
return { replayed, unreplayed };
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
/**
|
|
2528
|
+
* Re-run every held dispatch through the handler.
|
|
2529
|
+
*
|
|
2530
|
+
* Takes the handler rather than importing it, for the same reason \`drainConfirms\`
|
|
2531
|
+
* takes its sender: this module then needs no knowledge of Express, decryption
|
|
2532
|
+
* or keys, and a test can drive it with a function that fails once and succeeds
|
|
2533
|
+
* on the second call.
|
|
2534
|
+
*/
|
|
2535
|
+
export async function replayHeld(
|
|
2536
|
+
apply: (rawBody: string) => Promise<void>,
|
|
2537
|
+
limit = 50,
|
|
2538
|
+
): Promise<{ applied: number; failed: number }> {
|
|
2539
|
+
let applied = 0;
|
|
2540
|
+
let failed = 0;
|
|
2541
|
+
for (const row of heldDispatches(limit)) {
|
|
2542
|
+
try {
|
|
2543
|
+
await apply(row.raw_body);
|
|
2544
|
+
markReplayed(row.id);
|
|
2545
|
+
applied += 1;
|
|
2546
|
+
report.info(\`[replay] applied \${row.event} \${row.dispatch_id ?? row.id}\`);
|
|
2547
|
+
} catch (err) {
|
|
2548
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2549
|
+
markReplayFailed(row.id, message);
|
|
2550
|
+
failed += 1;
|
|
2551
|
+
report.warn(\`[replay] \${row.dispatch_id ?? row.id} still failing: \${message}\`);
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
if (applied === 0 && failed === 0) report.info('[replay] nothing held');
|
|
2555
|
+
return { applied, failed };
|
|
2556
|
+
}
|
|
2557
|
+
`
|
|
2558
|
+
},
|
|
2559
|
+
{
|
|
2560
|
+
name: "src/confirm-queue.ts",
|
|
2561
|
+
content: `/**
|
|
2562
|
+
* Telling OneAddress you applied an update, durably.
|
|
2563
|
+
*
|
|
2564
|
+
* ## The bug this replaces, which cost real money
|
|
2565
|
+
*
|
|
2566
|
+
* The confirm callback used to be one \`fetch\` with a \`catch\` that logged. If
|
|
2567
|
+
* \`/api/confirm\` was unreachable for thirty seconds, the address was applied
|
|
2568
|
+
* here and OneAddress never learned. The consumer was shown a failed delivery,
|
|
2569
|
+
* and the auto-refund cron could refund a dispatch that had in fact succeeded.
|
|
2570
|
+
* Nothing retried, nothing surfaced, and the receiver's own log said "applied".
|
|
2571
|
+
*
|
|
2572
|
+
* So the confirm is now a queued, durable fact rather than a best-effort call:
|
|
2573
|
+
* it survives a restart, retries with backoff, and is visible while it is
|
|
2574
|
+
* outstanding.
|
|
2575
|
+
*
|
|
2576
|
+
* ## Why the queue is keyed on dispatch_id
|
|
2577
|
+
*
|
|
2578
|
+
* One row per dispatch, \`INSERT OR IGNORE\`. OneAddress retries a dispatch it
|
|
2579
|
+
* has not heard about, so the same id can arrive more than once; enqueuing
|
|
2580
|
+
* twice would confirm twice. The primary key makes that impossible rather than
|
|
2581
|
+
* making it something every caller has to remember.
|
|
2582
|
+
*
|
|
2583
|
+
* ## Why a failing confirm is retried forever rather than given up on
|
|
2584
|
+
*
|
|
2585
|
+
* The commonest cause of a 401 here is a wrong or stale CONFIRM_SECRET, which
|
|
2586
|
+
* is fixed by editing \`.env\` and restarting. Abandoning the row would mean the
|
|
2587
|
+
* partner fixes the secret and the update stays lost. The backoff caps at an
|
|
2588
|
+
* hour, so a permanently-broken secret costs one request an hour and stays
|
|
2589
|
+
* visible in the queue depth, which is the signal that something needs a human.
|
|
2590
|
+
*/
|
|
2591
|
+
import db from './db.js';
|
|
2592
|
+
import { report } from './report.js';
|
|
2593
|
+
|
|
2594
|
+
export type ConfirmStatus = 'confirmed' | 'failed';
|
|
2595
|
+
|
|
2596
|
+
db.exec(\`
|
|
2597
|
+
CREATE TABLE IF NOT EXISTS confirm_queue (
|
|
2598
|
+
dispatch_id INTEGER PRIMARY KEY,
|
|
2599
|
+
status TEXT NOT NULL,
|
|
2600
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
2601
|
+
next_attempt_at TEXT NOT NULL,
|
|
2602
|
+
last_error TEXT,
|
|
2603
|
+
delivered_at TEXT,
|
|
2604
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
2605
|
+
);
|
|
2606
|
+
CREATE INDEX IF NOT EXISTS idx_confirm_due
|
|
2607
|
+
ON confirm_queue(delivered_at, next_attempt_at);
|
|
2608
|
+
\`);
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* Backoff, in seconds, by attempt number.
|
|
2612
|
+
*
|
|
2613
|
+
* Fast at first because the overwhelming majority of failures are a blip and
|
|
2614
|
+
* clear on the second try; then long, because the ones that do not clear are
|
|
2615
|
+
* configuration and need a person, not a tighter loop.
|
|
2616
|
+
*/
|
|
2617
|
+
const BACKOFF_SECONDS = [5, 15, 60, 300, 900, 3600];
|
|
2618
|
+
|
|
2619
|
+
function delayFor(attempts: number): number {
|
|
2620
|
+
return BACKOFF_SECONDS[Math.min(attempts, BACKOFF_SECONDS.length - 1)];
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
function isoIn(seconds: number): string {
|
|
2624
|
+
return new Date(Date.now() + seconds * 1000).toISOString();
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
/**
|
|
2628
|
+
* Record that this dispatch needs confirming. Safe to call twice.
|
|
2629
|
+
*
|
|
2630
|
+
* Deliberately synchronous and local: the webhook handler must not wait on a
|
|
2631
|
+
* network round trip to OneAddress before answering the dispatch, or a slow
|
|
2632
|
+
* confirm turns into a timed-out webhook and a pointless redelivery.
|
|
2633
|
+
*/
|
|
2634
|
+
export function enqueueConfirm(dispatchId: number, status: ConfirmStatus): void {
|
|
2635
|
+
db.prepare(
|
|
2636
|
+
\`INSERT OR IGNORE INTO confirm_queue (dispatch_id, status, next_attempt_at)
|
|
2637
|
+
VALUES (?, ?, ?)\`,
|
|
2638
|
+
).run(dispatchId, status, new Date().toISOString());
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
export interface PendingConfirm {
|
|
2642
|
+
dispatch_id: number;
|
|
2643
|
+
status: ConfirmStatus;
|
|
2644
|
+
attempts: number;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
/** Everything due to be sent now, oldest first. */
|
|
2648
|
+
export function dueConfirms(limit = 20): PendingConfirm[] {
|
|
2649
|
+
return db.prepare(
|
|
2650
|
+
\`SELECT dispatch_id, status, attempts
|
|
2651
|
+
FROM confirm_queue
|
|
2652
|
+
WHERE delivered_at IS NULL
|
|
2653
|
+
AND next_attempt_at <= ?
|
|
2654
|
+
ORDER BY created_at
|
|
2655
|
+
LIMIT ?\`,
|
|
2656
|
+
).all(new Date().toISOString(), limit) as unknown as PendingConfirm[];
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
/** How many confirms are still outstanding. Shown on the dashboard. */
|
|
2660
|
+
export function pendingConfirmCount(): number {
|
|
2661
|
+
const row = db.prepare(
|
|
2662
|
+
'SELECT count(*) AS n FROM confirm_queue WHERE delivered_at IS NULL',
|
|
2663
|
+
).get() as { n: number };
|
|
2664
|
+
return row.n;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
export function markDelivered(dispatchId: number): void {
|
|
2668
|
+
db.prepare(
|
|
2669
|
+
\`UPDATE confirm_queue SET delivered_at = ?, last_error = NULL WHERE dispatch_id = ?\`,
|
|
2670
|
+
).run(new Date().toISOString(), dispatchId);
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
/** Back off and record why, so the queue explains itself without a log dig. */
|
|
2674
|
+
export function markFailed(dispatchId: number, attempts: number, error: string): void {
|
|
2675
|
+
const next = delayFor(attempts);
|
|
2676
|
+
db.prepare(
|
|
2677
|
+
\`UPDATE confirm_queue
|
|
2678
|
+
SET attempts = attempts + 1, next_attempt_at = ?, last_error = ?
|
|
2679
|
+
WHERE dispatch_id = ?\`,
|
|
2680
|
+
).run(isoIn(next), error.slice(0, 500), dispatchId);
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
/**
|
|
2684
|
+
* Drop delivered rows older than the retention window.
|
|
2685
|
+
*
|
|
2686
|
+
* Delivered ONLY. An outstanding confirm is never purged on age: a receiver
|
|
2687
|
+
* that was off for a month must still tell OneAddress what it applied, and
|
|
2688
|
+
* silently dropping those is the original bug with extra steps.
|
|
2689
|
+
*/
|
|
2690
|
+
export function purgeDelivered(days: number): number {
|
|
2691
|
+
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
2692
|
+
const res = db.prepare(
|
|
2693
|
+
\`DELETE FROM confirm_queue WHERE delivered_at IS NOT NULL AND delivered_at < ?\`,
|
|
2694
|
+
).run(cutoff);
|
|
2695
|
+
return Number(res.changes ?? 0);
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
/**
|
|
2699
|
+
* Drain the queue once.
|
|
2700
|
+
*
|
|
2701
|
+
* Takes the sender so this module needs no knowledge of HTTP, secrets or
|
|
2702
|
+
* signing, which is what lets it be tested without a server: the test passes a
|
|
2703
|
+
* function that fails twice and then succeeds, and asserts the row survives.
|
|
2704
|
+
*/
|
|
2705
|
+
export async function drainConfirms(
|
|
2706
|
+
send: (dispatchId: number, status: ConfirmStatus) => Promise<void>,
|
|
2707
|
+
limit = 20,
|
|
2708
|
+
): Promise<{ delivered: number; failed: number }> {
|
|
2709
|
+
let delivered = 0;
|
|
2710
|
+
let failed = 0;
|
|
2711
|
+
for (const row of dueConfirms(limit)) {
|
|
2712
|
+
try {
|
|
2713
|
+
await send(row.dispatch_id, row.status);
|
|
2714
|
+
markDelivered(row.dispatch_id);
|
|
2715
|
+
delivered += 1;
|
|
2716
|
+
} catch (err) {
|
|
2717
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2718
|
+
markFailed(row.dispatch_id, row.attempts, message);
|
|
2719
|
+
failed += 1;
|
|
2720
|
+
// Said once per attempt rather than once per drain, because the attempt
|
|
2721
|
+
// count and the reason are what tell a partner whether to wait or act.
|
|
2722
|
+
report.warn(
|
|
2723
|
+
\`[confirm] dispatch \${row.dispatch_id} not acknowledged (attempt \${row.attempts + 1}): \${message}\`,
|
|
2724
|
+
);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
return { delivered, failed };
|
|
2728
|
+
}
|
|
2729
|
+
`
|
|
2730
|
+
},
|
|
2731
|
+
{
|
|
2732
|
+
name: "src/keys.ts",
|
|
2733
|
+
content: `/**
|
|
2734
|
+
* Which private key opens this dispatch.
|
|
2735
|
+
*
|
|
2736
|
+
* ## Why this file exists
|
|
2737
|
+
*
|
|
2738
|
+
* OneAddress supports key rotation: a new key is registered, both are valid for
|
|
2739
|
+
* an overlap window, and every dispatch names the key it was encrypted to in
|
|
2740
|
+
* \`session_key_share.key_id\`. A receiver holding ONE key therefore has a window
|
|
2741
|
+
* where perfectly good dispatches arrive that it cannot open.
|
|
2742
|
+
*
|
|
2743
|
+
* Until this existed the scaffold read a single \`PARTNER_PRIVATE_KEY_PEM\` and
|
|
2744
|
+
* used it for every dispatch whatever key id was named. That fails in the worst
|
|
2745
|
+
* possible way: AES-GCM cannot tell "wrong key" from "tampered ciphertext", so
|
|
2746
|
+
* the error is an authentication-tag failure that reads like corruption. A real
|
|
2747
|
+
* partner hit exactly this and had no way to tell which of the two it was.
|
|
2748
|
+
*
|
|
2749
|
+
* ## The convention, and why it matches the production receiver
|
|
2750
|
+
*
|
|
2751
|
+
* PARTNER_PRIVATE_KEY_PEM_<KEY_ID> dashes to underscores, upper-cased
|
|
2752
|
+
* PARTNER_PRIVATE_KEY_PEM the single-key fallback
|
|
2753
|
+
*
|
|
2754
|
+
* This is the same shape \`@oneaddress/receiver\` uses for \`OA_PRIVATE_KEY_<ID>\`,
|
|
2755
|
+
* deliberately: a partner who outgrows this scaffold and moves to the
|
|
2756
|
+
* production receiver should not have to learn a second way of naming keys.
|
|
2757
|
+
*
|
|
2758
|
+
* ## The fallback is convenient and slightly dangerous
|
|
2759
|
+
*
|
|
2760
|
+
* It answers for ANY key id, which is what made a rotation look like corruption.
|
|
2761
|
+
* It is kept because removing it would break every existing single-key partner
|
|
2762
|
+
* on upgrade, but two things now make it safe to hold:
|
|
2763
|
+
*
|
|
2764
|
+
* - \`PARTNER_KEYS_STRICT=1\` disables it, and any partner holding more than one
|
|
2765
|
+
* key should set that
|
|
2766
|
+
* - when it answers and the decrypt then fails, the error SAYS it was the
|
|
2767
|
+
* fallback and names the key id it was asked for, so the next step is
|
|
2768
|
+
* obvious rather than a guess
|
|
2769
|
+
*/
|
|
2770
|
+
|
|
2771
|
+
/** Where a key came from. Carried into the error message when a decrypt fails. */
|
|
2772
|
+
export type KeySource = 'exact' | 'fallback';
|
|
2773
|
+
|
|
2774
|
+
export interface ResolvedKey {
|
|
2775
|
+
pem: string;
|
|
2776
|
+
source: KeySource;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
const PREFIX = 'PARTNER_PRIVATE_KEY_PEM';
|
|
2780
|
+
|
|
2781
|
+
/** \`04032299-4b04-...\` becomes \`04032299_4B04_...\`, matching the env convention. */
|
|
2782
|
+
function envSuffix(keyId: string): string {
|
|
2783
|
+
return keyId.replace(/-/g, '_').toUpperCase();
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
/**
|
|
2787
|
+
* A PEM written into \`.env\` on one line arrives with literal backslash-n.
|
|
2788
|
+
* Both forms are accepted so a key pasted either way works.
|
|
2789
|
+
*/
|
|
2790
|
+
function unescapeNewlines(pem: string): string {
|
|
2791
|
+
return pem.includes('\\\\n') ? pem.replace(/\\\\n/g, '\\n') : pem;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
/** Every key id this receiver has a key for, lower-cased. */
|
|
2795
|
+
export function configuredKeyIds(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
2796
|
+
return Object.keys(env)
|
|
2797
|
+
.filter((k) => k.startsWith(\`\${PREFIX}_\`) && (env[k] ?? '').trim() !== '')
|
|
2798
|
+
.map((k) => k.slice(PREFIX.length + 1).toLowerCase().replaceAll('_', '-'));
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
/** Is the single-key fallback turned off? */
|
|
2802
|
+
export function strictKeys(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
2803
|
+
return (env.PARTNER_KEYS_STRICT ?? '').trim() === '1';
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
/**
|
|
2807
|
+
* The key for this dispatch, or null when nothing can open it.
|
|
2808
|
+
*
|
|
2809
|
+
* Null is a real answer, not an error: it means "I do not hold this key", and
|
|
2810
|
+
* the caller turns that into a message naming the key id, which is the one
|
|
2811
|
+
* thing the partner needs in order to fix it.
|
|
2812
|
+
*/
|
|
2813
|
+
export function resolvePrivateKey(
|
|
2814
|
+
keyId: string | null | undefined,
|
|
2815
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
2816
|
+
): ResolvedKey | null {
|
|
2817
|
+
if (keyId) {
|
|
2818
|
+
const exact = env[\`\${PREFIX}_\${envSuffix(keyId)}\`];
|
|
2819
|
+
if (exact && exact.trim()) return { pem: unescapeNewlines(exact), source: 'exact' };
|
|
2820
|
+
}
|
|
2821
|
+
if (strictKeys(env)) return null;
|
|
2822
|
+
const fallback = env[PREFIX];
|
|
2823
|
+
if (fallback && fallback.trim()) return { pem: unescapeNewlines(fallback), source: 'fallback' };
|
|
2824
|
+
return null;
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
/**
|
|
2828
|
+
* What to tell the partner when a decrypt fails, given which key answered.
|
|
2829
|
+
*
|
|
2830
|
+
* SEPARATE FROM THE CATCH so the wording is one thing rather than three copies
|
|
2831
|
+
* drifting apart, and so it can be tested without staging a failed decrypt.
|
|
2832
|
+
*/
|
|
2833
|
+
export function keyFailureAdvice(keyId: string | null | undefined, resolved: ResolvedKey | null): string {
|
|
2834
|
+
const named = keyId ?? '(none named)';
|
|
2835
|
+
if (!resolved) {
|
|
2836
|
+
return strictKeys()
|
|
2837
|
+
? \`no key configured for key_id \${named}. Set PARTNER_PRIVATE_KEY_PEM_\${keyId ? envSuffix(keyId) : '<KEY_ID>'} in .env. (PARTNER_KEYS_STRICT=1 is set, so the single-key fallback is off.)\`
|
|
2838
|
+
: \`no key configured for key_id \${named}, and PARTNER_PRIVATE_KEY_PEM is empty. Set one in .env.\`;
|
|
2839
|
+
}
|
|
2840
|
+
if (resolved.source === 'fallback') {
|
|
2841
|
+
return \`the single-key PARTNER_PRIVATE_KEY_PEM was used for key_id \${named} and could not open it. If you have rotated keys, set PARTNER_PRIVATE_KEY_PEM_\${keyId ? envSuffix(keyId) : '<KEY_ID>'} to the matching private key. Both old and new stay valid during the overlap window, so keep the previous one set too.\`;
|
|
2842
|
+
}
|
|
2843
|
+
return \`PARTNER_PRIVATE_KEY_PEM_\${keyId ? envSuffix(keyId) : '<KEY_ID>'} is set but does not open this dispatch. Check it is the private half of the public key registered for that key_id in the portal.\`;
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
/**
|
|
2847
|
+
* A line for the startup log: what this receiver can open.
|
|
2848
|
+
*
|
|
2849
|
+
* Printed every boot because the commonest key problem is believing a variable
|
|
2850
|
+
* is set when it is not, and the second commonest is holding one key after a
|
|
2851
|
+
* rotation. Both are visible in one line.
|
|
2852
|
+
*/
|
|
2853
|
+
export function describeKeys(env: NodeJS.ProcessEnv = process.env): string {
|
|
2854
|
+
const ids = configuredKeyIds(env);
|
|
2855
|
+
const hasFallback = !strictKeys(env) && (env[PREFIX] ?? '').trim() !== '';
|
|
2856
|
+
if (ids.length === 0 && !hasFallback) {
|
|
2857
|
+
return 'NO private key configured \u2014 every dispatch will fail to decrypt';
|
|
2858
|
+
}
|
|
2859
|
+
const parts: string[] = [];
|
|
2860
|
+
if (ids.length > 0) parts.push(\`\${ids.length} key id(s): \${ids.join(', ')}\`);
|
|
2861
|
+
if (hasFallback) parts.push(ids.length > 0 ? 'plus the single-key fallback' : 'single-key fallback only');
|
|
2862
|
+
return parts.join(' ');
|
|
2863
|
+
}
|
|
1995
2864
|
`
|
|
1996
2865
|
},
|
|
1997
2866
|
{
|
|
@@ -2093,6 +2962,194 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
|
|
|
2093
2962
|
return null;
|
|
2094
2963
|
}
|
|
2095
2964
|
}
|
|
2965
|
+
`
|
|
2966
|
+
},
|
|
2967
|
+
{
|
|
2968
|
+
name: "src/customer-store.ts",
|
|
2969
|
+
content: `/**
|
|
2970
|
+
* \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
|
|
2971
|
+
* \u2551 src/customer-store.ts \u2014 THE CONTRACT \u2551
|
|
2972
|
+
* \u2551 \u2551
|
|
2973
|
+
* \u2551 Everything OneAddress needs from your systems, in one file you \u2551
|
|
2974
|
+
* \u2551 can hand to a DBA. \`src/store.ts\` implements it against the \u2551
|
|
2975
|
+
* \u2551 bundled SQLite file; you replace that with your own. \u2551
|
|
2976
|
+
* \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
|
|
2977
|
+
*
|
|
2978
|
+
* ## Why this is a separate file from the implementation
|
|
2979
|
+
*
|
|
2980
|
+
* Until this existed, replacing the store meant reading \`store.ts\` and
|
|
2981
|
+
* reverse-engineering the three functions \`server.ts\` happened to import, past
|
|
2982
|
+
* a schema, a roster seeder, an encryption layer and a repair pass that exist
|
|
2983
|
+
* for the DEMO and are no use to a company that already has a customer
|
|
2984
|
+
* database. The contract was real but it was implied, and an implied contract
|
|
2985
|
+
* is one you find out you got wrong in production.
|
|
2986
|
+
*
|
|
2987
|
+
* It is also the answer to the question a DBA asks first, which is not "how do
|
|
2988
|
+
* I write an adapter" but "what is this thing going to do to my table". That is
|
|
2989
|
+
* five methods and a paragraph each, and it is reviewable in ten minutes
|
|
2990
|
+
* without reading any of our code.
|
|
2991
|
+
*
|
|
2992
|
+
* ## What you are NOT obliged to keep
|
|
2993
|
+
*
|
|
2994
|
+
* The SQLite file, the at-rest encryption, the blind index, \`customers.json\`,
|
|
2995
|
+
* the repair pass and the address history are the DEFAULT implementation, not
|
|
2996
|
+
* the contract. A company whose customer data already lives in its own database
|
|
2997
|
+
* should delete every one of them: encrypting a copy of a record you already
|
|
2998
|
+
* hold protects nothing and gives you a second thing to key-manage. Implement
|
|
2999
|
+
* the five methods below against your own tables and your own controls.
|
|
3000
|
+
*
|
|
3001
|
+
* ## What the receiver still owns, and must keep owning
|
|
3002
|
+
*
|
|
3003
|
+
* The protocol half: signature verification, the replay window, decryption,
|
|
3004
|
+
* the confirm callback, the quarantine. None of it is in here, and a store
|
|
3005
|
+
* implementation is never given a chance to weaken any of it. By the time your
|
|
3006
|
+
* code is called, the dispatch has been proven to come from OneAddress and has
|
|
3007
|
+
* been decrypted in memory.
|
|
3008
|
+
*
|
|
3009
|
+
* Note that the receiver keeps a small local SQLite file even when your
|
|
3010
|
+
* customers live elsewhere, for the confirm queue and the quarantine. Neither
|
|
3011
|
+
* holds customer data: the confirm queue holds dispatch ids and outcomes, and
|
|
3012
|
+
* the quarantine holds ciphertext the receiver could not open. Your DBA is
|
|
3013
|
+
* entitled to ask, and that is the answer.
|
|
3014
|
+
*/
|
|
3015
|
+
|
|
3016
|
+
/** An address as OneAddress sends it. Free-form so new fields are not lost. */
|
|
3017
|
+
export type Address = Record<string, unknown>;
|
|
3018
|
+
|
|
3019
|
+
/**
|
|
3020
|
+
* Who the dispatch is about, as \`server.ts\` recovered it.
|
|
3021
|
+
*
|
|
3022
|
+
* ALL OF THIS CAME OUT OF THE CIPHERTEXT, not off the wire. Under D5 there is
|
|
3023
|
+
* no cleartext customer block on a dispatch, so nothing here can be set by
|
|
3024
|
+
* anyone who has not proven they hold the key this partner registered.
|
|
3025
|
+
*/
|
|
3026
|
+
export interface Customer {
|
|
3027
|
+
email: string | null;
|
|
3028
|
+
name: string;
|
|
3029
|
+
accountNumber?: string;
|
|
3030
|
+
knownNames?: string[];
|
|
3031
|
+
/**
|
|
3032
|
+
* D5 LOA reference \u2014 the base64url SHA-256 of the signed consent, recomputed
|
|
3033
|
+
* after decrypting \`loa_encrypted\`. Echo it in your own audit trail if you
|
|
3034
|
+
* want proof-of-consent alongside the change. Null on legacy dispatches.
|
|
3035
|
+
*/
|
|
3036
|
+
loaRef?: string | null;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
/** One customer as the dashboard shows them. */
|
|
3040
|
+
export interface StoredCustomer {
|
|
3041
|
+
account_number: string;
|
|
3042
|
+
name: string;
|
|
3043
|
+
/** The address as a JSON string, the way it was stored. */
|
|
3044
|
+
address: string;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
export type AccountVerdict = 'match' | 'no_match' | 'no_account';
|
|
3048
|
+
export type VerifyResult = 'match' | 'mismatch' | 'not_found';
|
|
3049
|
+
|
|
3050
|
+
export interface CustomerStore {
|
|
3051
|
+
/** Shown on \`/health\` and in the startup line, so a swap is visible. */
|
|
3052
|
+
readonly name: string;
|
|
3053
|
+
|
|
3054
|
+
/**
|
|
3055
|
+
* Are the personal columns YOU hold protected at rest?
|
|
3056
|
+
*
|
|
3057
|
+
* Reported on the dashboard in both states, and the unprotected one is drawn
|
|
3058
|
+
* in red. A store backed by a database with its own encryption should return
|
|
3059
|
+
* \`true\`; one writing plaintext to a file should return \`false\` and mean it.
|
|
3060
|
+
* This is a claim the operator will read as true, so do not return \`true\`
|
|
3061
|
+
* because it feels tidier.
|
|
3062
|
+
*/
|
|
3063
|
+
readonly encrypted: boolean;
|
|
3064
|
+
|
|
3065
|
+
/**
|
|
3066
|
+
* Pre-payment account check (\`account.verify\`). BEFORE the consumer pays.
|
|
3067
|
+
*
|
|
3068
|
+
* The boundary that stops somebody pushing an update to an account that is
|
|
3069
|
+
* not theirs, and the only one of these three that runs before money moves.
|
|
3070
|
+
*
|
|
3071
|
+
* 'match' \u2014 the account exists and the name agrees
|
|
3072
|
+
* 'no_match' \u2014 the account exists and the name does not
|
|
3073
|
+
* 'no_account' \u2014 no such account
|
|
3074
|
+
*
|
|
3075
|
+
* \`knownNames\` carries the other names the consumer has verified under, so a
|
|
3076
|
+
* married name on your record and a maiden name on their ID still match. Test
|
|
3077
|
+
* each of them, not just \`name\`.
|
|
3078
|
+
*
|
|
3079
|
+
* RETURN 'no_match' RATHER THAN 'match' WHEN YOU ARE UNSURE. A false 'match'
|
|
3080
|
+
* authorises a stranger's address onto a customer's account; a false
|
|
3081
|
+
* 'no_match' costs a support call.
|
|
3082
|
+
*/
|
|
3083
|
+
verifyAccount(
|
|
3084
|
+
accountNumber: string | null,
|
|
3085
|
+
name: string,
|
|
3086
|
+
knownNames?: string[],
|
|
3087
|
+
): Promise<AccountVerdict> | AccountVerdict;
|
|
3088
|
+
|
|
3089
|
+
/**
|
|
3090
|
+
* Is the address you hold the current one? (\`address.verify\`)
|
|
3091
|
+
*
|
|
3092
|
+
* 'match' \u2014 you already hold exactly this address
|
|
3093
|
+
* 'mismatch' \u2014 you know the customer and hold something different
|
|
3094
|
+
* 'not_found' \u2014 the account is not one of yours
|
|
3095
|
+
*
|
|
3096
|
+
* A customer you have never had an update for is 'mismatch', not 'match':
|
|
3097
|
+
* you know them, you simply do not have THIS address yet.
|
|
3098
|
+
*
|
|
3099
|
+
* READ-ONLY. Nothing here may write.
|
|
3100
|
+
*/
|
|
3101
|
+
verifyAddress(customer: Customer, incoming: Address): Promise<VerifyResult>;
|
|
3102
|
+
|
|
3103
|
+
/**
|
|
3104
|
+
* Apply a new address (\`address.updated\`), and return the one it REPLACED.
|
|
3105
|
+
*
|
|
3106
|
+
* The return value is what the dashboard shows as "was", and it is the
|
|
3107
|
+
* question an operator asks first when a change looks wrong. Read it before
|
|
3108
|
+
* you write, because after the write nothing can reconstruct it. Return \`{}\`
|
|
3109
|
+
* if there was nothing.
|
|
3110
|
+
*
|
|
3111
|
+
* ## Called only once the caller is satisfied
|
|
3112
|
+
*
|
|
3113
|
+
* When you declare that you verify account references, \`server.ts\` refuses
|
|
3114
|
+
* anything that is not a 'match' before reaching here, so by this point the
|
|
3115
|
+
* account is a real one of yours. **If you do NOT verify account references,
|
|
3116
|
+
* that guard is not running and this is called with whatever identity the
|
|
3117
|
+
* envelope carried.** Key on something you trust in that mode; the default
|
|
3118
|
+
* implementation keys on the name, and two customers who share a name share a
|
|
3119
|
+
* row.
|
|
3120
|
+
*
|
|
3121
|
+
* ## Throwing is meaningful
|
|
3122
|
+
*
|
|
3123
|
+
* Throw and the dispatch is answered as failed, and OneAddress retries it.
|
|
3124
|
+
* That is the right thing to do if your database is unreachable. Do NOT
|
|
3125
|
+
* swallow a write failure and return normally: the consumer is then told the
|
|
3126
|
+
* address landed when it did not.
|
|
3127
|
+
*/
|
|
3128
|
+
saveAddress(customer: Customer, incoming: Address): Promise<Address>;
|
|
3129
|
+
|
|
3130
|
+
/**
|
|
3131
|
+
* One customer, for the dashboard's change panel. \`null\` if unknown.
|
|
3132
|
+
*
|
|
3133
|
+
* SEPARATE FROM THE THREE HOOKS because it is cosmetic: returning \`null\`
|
|
3134
|
+
* always is a perfectly good implementation and costs you a panel, nothing
|
|
3135
|
+
* more. It exists as a method rather than being derived from a list-everyone
|
|
3136
|
+
* call BECAUSE of what that would mean at your scale - see \`count\` below.
|
|
3137
|
+
*/
|
|
3138
|
+
find(accountNumber: string): Promise<StoredCustomer | null> | StoredCustomer | null;
|
|
3139
|
+
|
|
3140
|
+
/**
|
|
3141
|
+
* How many customers are on file, or \`null\` for "not worth asking".
|
|
3142
|
+
*
|
|
3143
|
+
* **RETURN \`null\` IF THIS IS A COUNT OVER A REAL CUSTOMER TABLE.** The
|
|
3144
|
+
* dashboard redraws every two seconds; a \`SELECT count(*)\` over four million
|
|
3145
|
+
* rows, forty times a minute, forever, is a load-bearing decision somebody
|
|
3146
|
+
* should make deliberately rather than inherit from a demo. The dashboard
|
|
3147
|
+
* draws a dash and is otherwise identical.
|
|
3148
|
+
*
|
|
3149
|
+
* The bundled SQLite store answers it because its roster is three rows.
|
|
3150
|
+
*/
|
|
3151
|
+
count(): Promise<number | null> | number | null;
|
|
3152
|
+
}
|
|
2096
3153
|
`
|
|
2097
3154
|
},
|
|
2098
3155
|
{
|
|
@@ -2101,25 +3158,45 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
|
|
|
2101
3158
|
* \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
|
|
2102
3159
|
* \u2551 src/store.ts \u2014 DATABASE INTEGRATION \u2551
|
|
2103
3160
|
* \u2551 \u2551
|
|
2104
|
-
* \u2551
|
|
2105
|
-
* \u2551
|
|
2106
|
-
* \u2551 three hooks
|
|
3161
|
+
* \u2551 THE DEFAULT implementation of \`CustomerStore\`, backed by the \u2551
|
|
3162
|
+
* \u2551 bundled SQLite file: a CUSTOMER ROSTER (who your customers are \u2551
|
|
3163
|
+
* \u2551 + the address you hold on file for each) and the three hooks \u2551
|
|
3164
|
+
* \u2551 OneAddress calls: \u2551
|
|
2107
3165
|
* \u2551 verifyAccount \u2014 pre-payment account check (account.verify) \u2551
|
|
2108
3166
|
* \u2551 verifyAddress \u2014 is your on-file address current? (address.verify)
|
|
2109
3167
|
* \u2551 saveAddress \u2014 apply a new address (address.updated) \u2551
|
|
2110
3168
|
* \u2551 \u2551
|
|
2111
|
-
* \u2551
|
|
2112
|
-
* \u2551
|
|
2113
|
-
* \u2551
|
|
2114
|
-
* \u2551
|
|
3169
|
+
* \u2551 THE CONTRACT IS \`src/customer-store.ts\`, not this file. Read \u2551
|
|
3170
|
+
* \u2551 that one first: it is five methods and a paragraph each, with \u2551
|
|
3171
|
+
* \u2551 no schema, no seeding and no encryption in the way, and it is \u2551
|
|
3172
|
+
* \u2551 what you implement against your own database. \u2551
|
|
3173
|
+
* \u2551 \u2551
|
|
3174
|
+
* \u2551 Everything below the contract \u2014 the SQLite file, the at-rest \u2551
|
|
3175
|
+
* \u2551 encryption, the blind index, customers.json, the repair pass, \u2551
|
|
3176
|
+
* \u2551 the history table \u2014 is THIS implementation, not the contract. \u2551
|
|
3177
|
+
* \u2551 A company whose customers already live in its own database \u2551
|
|
3178
|
+
* \u2551 should delete all of it rather than port it. \u2551
|
|
3179
|
+
* \u2551 \u2551
|
|
3180
|
+
* \u2551 server.ts calls the hooks after verifying and decrypting each \u2551
|
|
3181
|
+
* \u2551 event \u2014 the protocol layer is handled for you, never touch it. \u2551
|
|
2115
3182
|
* \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
|
|
2116
3183
|
*/
|
|
2117
3184
|
import { readFileSync } from 'node:fs';
|
|
2118
3185
|
import { join } from 'node:path';
|
|
2119
3186
|
import { report } from './report.js';
|
|
2120
|
-
import db, { accountKey, dec, enc, encrypted, once } from './db.js';
|
|
2121
|
-
|
|
2122
|
-
|
|
3187
|
+
import db, { accountKey, dec, enc, encrypted, isEncrypted, once } from './db.js';
|
|
3188
|
+
import type {
|
|
3189
|
+
AccountVerdict,
|
|
3190
|
+
Address,
|
|
3191
|
+
Customer,
|
|
3192
|
+
CustomerStore,
|
|
3193
|
+
StoredCustomer,
|
|
3194
|
+
VerifyResult,
|
|
3195
|
+
} from './customer-store.js';
|
|
3196
|
+
|
|
3197
|
+
// Re-exported so existing imports of these types from \`./store.js\` keep
|
|
3198
|
+
// working. They are DEFINED in the contract, which is the file to change.
|
|
3199
|
+
export type { AccountVerdict, Address, Customer, CustomerStore, StoredCustomer, VerifyResult };
|
|
2123
3200
|
|
|
2124
3201
|
// \u2500\u2500 Schema \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2125
3202
|
// \`customers\` is your roster: the account number, the customer's name, and the
|
|
@@ -2178,6 +3255,48 @@ ensureColumn('address_history', 'prev_address', "TEXT NOT NULL DEFAULT '{}'");
|
|
|
2178
3255
|
// pass produces the same key rather than hashing a ciphertext and orphaning the
|
|
2179
3256
|
// row. A row orphaned that way still reads fine in a listing and is invisible
|
|
2180
3257
|
// to every dispatch, which is the worst kind of broken.
|
|
3258
|
+
// REPAIR FIRST, because a database can already be in the state the refusal in
|
|
3259
|
+
// db.ts now prevents. Before that refusal existed, a run with no passphrase
|
|
3260
|
+
// against an encrypted store could not decrypt the roster, so its blind indexes
|
|
3261
|
+
// never matched and it seeded a SECOND copy of every customer in plaintext. The
|
|
3262
|
+
// relabel below then maps both copies onto one key and dies on UNIQUE, with the
|
|
3263
|
+
// correct passphrase in hand and no way forward but deleting the file.
|
|
3264
|
+
//
|
|
3265
|
+
// So the plaintext twin is dropped. WHICH ONE GOES IS NOT A GUESS: the
|
|
3266
|
+
// encrypted row is the one the owner's passphrase wrote, the plaintext row was
|
|
3267
|
+
// written by a process that could not read it. Only the second is discarded,
|
|
3268
|
+
// and only when an encrypted row for the same account exists.
|
|
3269
|
+
//
|
|
3270
|
+
// Scoped to an encrypted database. With no keys there is nothing to compare,
|
|
3271
|
+
// every row is plaintext, and there is no duplicate to find.
|
|
3272
|
+
if (encrypted) {
|
|
3273
|
+
const all = db.prepare('SELECT rowid AS rid, account_number FROM customers').all() as Array<
|
|
3274
|
+
{ rid: number; account_number: string }
|
|
3275
|
+
>;
|
|
3276
|
+
const seen = new Map<string, boolean>(); // plain account -> has an encrypted row
|
|
3277
|
+
for (const r of all) {
|
|
3278
|
+
if (isEncrypted(r.account_number)) {
|
|
3279
|
+
const plain = dec('account_number', r.account_number);
|
|
3280
|
+
if (plain) seen.set(plain, true);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
const drop = db.prepare('DELETE FROM customers WHERE rowid = ?');
|
|
3284
|
+
let dropped = 0;
|
|
3285
|
+
for (const r of all) {
|
|
3286
|
+
if (isEncrypted(r.account_number)) continue;
|
|
3287
|
+
if (!seen.get(r.account_number)) continue;
|
|
3288
|
+
drop.run(r.rid);
|
|
3289
|
+
dropped += 1;
|
|
3290
|
+
}
|
|
3291
|
+
if (dropped > 0) {
|
|
3292
|
+
// Said out loud. A silent repair of somebody's customer table is the kind
|
|
3293
|
+
// of thing they should hear about from us rather than notice later.
|
|
3294
|
+
report.warn(
|
|
3295
|
+
\`[store] repaired \${dropped} plaintext duplicate customer row(s) left by a run with no passphrase\`,
|
|
3296
|
+
);
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
|
|
2181
3300
|
{
|
|
2182
3301
|
const rows = db.prepare('SELECT rowid AS rid, account_number, name, address FROM customers').all() as Array<
|
|
2183
3302
|
{ rid: number; account_number: string; name: string; address: string }
|
|
@@ -2269,22 +3388,6 @@ const ROSTER = loadRoster();
|
|
|
2269
3388
|
report.info(\`[store] roster ready (\${ROSTER.length} customers)\`);
|
|
2270
3389
|
}
|
|
2271
3390
|
|
|
2272
|
-
// \u2500\u2500 Identity handed in by server.ts after it verifies + decrypts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2273
|
-
export type Customer = {
|
|
2274
|
-
email: string | null;
|
|
2275
|
-
name: string;
|
|
2276
|
-
accountNumber?: string;
|
|
2277
|
-
knownNames?: string[];
|
|
2278
|
-
/**
|
|
2279
|
-
* D5 LOA reference \u2014 the base64url SHA-256 of the signed consent, recomputed
|
|
2280
|
-
* by server.ts after decrypting \`loa_encrypted\`. A production integration
|
|
2281
|
-
* echoes this in the \`/api/confirm\` callback so OneAddress can verify
|
|
2282
|
-
* proof-of-receipt. Null on legacy dispatches that carry no encrypted LOA.
|
|
2283
|
-
* The store ignores it; it rides along on the identity object for convenience.
|
|
2284
|
-
*/
|
|
2285
|
-
loaRef?: string | null;
|
|
2286
|
-
};
|
|
2287
|
-
|
|
2288
3391
|
/**
|
|
2289
3392
|
* Canonical, order- and case-insensitive form of an address, so two addresses
|
|
2290
3393
|
* compare equal iff they mean the same thing regardless of key order or casing.
|
|
@@ -2319,13 +3422,6 @@ function findCustomer(accountNumber: string | undefined, name: string): { accoun
|
|
|
2319
3422
|
}
|
|
2320
3423
|
|
|
2321
3424
|
// \u2500\u2500 account.verify: pre-payment account check \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2322
|
-
/** One stored row, with the personal columns decrypted. */
|
|
2323
|
-
export interface StoredCustomer {
|
|
2324
|
-
account_number: string;
|
|
2325
|
-
name: string;
|
|
2326
|
-
address: string;
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
3425
|
/** Decrypt a row read straight out of SQLite. */
|
|
2330
3426
|
function decodeRow(raw: unknown): StoredCustomer | undefined {
|
|
2331
3427
|
if (!raw) return undefined;
|
|
@@ -2352,8 +3448,6 @@ export function customerCount(): number {
|
|
|
2352
3448
|
/** Is the file on disk protected? Surfaced in the dashboard, in both states. */
|
|
2353
3449
|
export const storeEncrypted = encrypted;
|
|
2354
3450
|
|
|
2355
|
-
export type AccountVerdict = 'match' | 'no_match' | 'no_account';
|
|
2356
|
-
|
|
2357
3451
|
/**
|
|
2358
3452
|
* Confirms the typed account number is really one of yours and the name agrees,
|
|
2359
3453
|
* BEFORE the consumer pays. The boundary that stops someone pushing an update to
|
|
@@ -2376,8 +3470,6 @@ export function verifyAccount(accountNumber: string | null, name: string, knownN
|
|
|
2376
3470
|
}
|
|
2377
3471
|
|
|
2378
3472
|
// \u2500\u2500 address.verify: is your on-file address current? \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2379
|
-
export type VerifyResult = 'match' | 'mismatch' | 'not_found';
|
|
2380
|
-
|
|
2381
3473
|
/**
|
|
2382
3474
|
* Compares the consumer's current OneAddress address against what YOU hold.
|
|
2383
3475
|
* 'match' \u2014 you already hold this exact address (no update needed)
|
|
@@ -2456,6 +3548,47 @@ export async function saveAddress(customer: Customer, incoming: Address): Promis
|
|
|
2456
3548
|
|
|
2457
3549
|
return previous;
|
|
2458
3550
|
}
|
|
3551
|
+
|
|
3552
|
+
/**
|
|
3553
|
+
* One customer by account number, for the dashboard.
|
|
3554
|
+
*
|
|
3555
|
+
* AN INDEXED LOOKUP, not a scan. The dashboard used to call \`allCustomers()\`
|
|
3556
|
+
* and \`.find()\` the one it wanted, which is invisible at three rows and absurd
|
|
3557
|
+
* at four million: decrypting an entire customer table to draw one panel. Doing
|
|
3558
|
+
* it here also means a real store implements a lookup, which is what its own
|
|
3559
|
+
* database is already good at, instead of being handed a list-everything
|
|
3560
|
+
* method it has no safe way to answer.
|
|
3561
|
+
*/
|
|
3562
|
+
export function findByAccount(accountNumber: string): StoredCustomer | null {
|
|
3563
|
+
const acct = (accountNumber ?? '').trim();
|
|
3564
|
+
if (!acct) return null;
|
|
3565
|
+
return decodeRow(
|
|
3566
|
+
db.prepare('SELECT account_number, name, address FROM customers WHERE account_key = ?')
|
|
3567
|
+
.get(accountKey(acct)),
|
|
3568
|
+
) ?? null;
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
/**
|
|
3572
|
+
* THE OBJECT THE RECEIVER ACTUALLY TALKS TO.
|
|
3573
|
+
*
|
|
3574
|
+
* \`server.ts\` and \`tui.ts\` import this and nothing else from here, so pointing
|
|
3575
|
+
* the receiver at your own systems is one import: write a module exporting a
|
|
3576
|
+
* \`CustomerStore\` and change the two lines that name this one. The \`satisfies\`
|
|
3577
|
+
* is the part that makes that safe \u2014 miss a method, or change a signature the
|
|
3578
|
+
* receiver depends on, and this file stops compiling rather than failing on a
|
|
3579
|
+
* live dispatch.
|
|
3580
|
+
*/
|
|
3581
|
+
export const store = {
|
|
3582
|
+
name: 'sqlite',
|
|
3583
|
+
encrypted: storeEncrypted,
|
|
3584
|
+
verifyAccount,
|
|
3585
|
+
verifyAddress,
|
|
3586
|
+
saveAddress,
|
|
3587
|
+
find: findByAccount,
|
|
3588
|
+
// Three rows, so a count is free. A store over a real customer table should
|
|
3589
|
+
// return null here; the contract file says why.
|
|
3590
|
+
count: customerCount,
|
|
3591
|
+
} satisfies CustomerStore;
|
|
2459
3592
|
`
|
|
2460
3593
|
},
|
|
2461
3594
|
{
|
|
@@ -2495,10 +3628,28 @@ import {
|
|
|
2495
3628
|
type SessionKeyShare,
|
|
2496
3629
|
type OneAddressD5LOA,
|
|
2497
3630
|
} from '@oneaddress/partner-sdk';
|
|
2498
|
-
|
|
3631
|
+
// THE ONLY LINE THAT NAMES AN IMPLEMENTATION. Point this at your own module
|
|
3632
|
+
// exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
|
|
3633
|
+
// the protocol layer changes.
|
|
3634
|
+
import { store } from './store.js';
|
|
2499
3635
|
import { notePreviousAddress } from './tui.js';
|
|
2500
3636
|
import { config } from './config.js';
|
|
2501
3637
|
import { safeOneAddressCallbackUrl } from './callback-url.js';
|
|
3638
|
+
import { describeKeys, keyFailureAdvice, resolvePrivateKey } from './keys.js';
|
|
3639
|
+
import {
|
|
3640
|
+
drainConfirms,
|
|
3641
|
+
enqueueConfirm,
|
|
3642
|
+
pendingConfirmCount,
|
|
3643
|
+
purgeDelivered,
|
|
3644
|
+
type ConfirmStatus,
|
|
3645
|
+
} from './confirm-queue.js';
|
|
3646
|
+
import {
|
|
3647
|
+
heldCount,
|
|
3648
|
+
purgeQuarantine,
|
|
3649
|
+
quarantine,
|
|
3650
|
+
replayHeld,
|
|
3651
|
+
type QuarantineReason,
|
|
3652
|
+
} from './quarantine.js';
|
|
2502
3653
|
|
|
2503
3654
|
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? '';
|
|
2504
3655
|
const PARTNER_PRIVATE_KEY = process.env.PARTNER_PRIVATE_KEY_PEM ?? '';
|
|
@@ -2548,6 +3699,33 @@ if (PARTNER_PRIVATE_KEY.includes('BEGIN')) {
|
|
|
2548
3699
|
}
|
|
2549
3700
|
}
|
|
2550
3701
|
|
|
3702
|
+
// SAID OUT LOUD ON EVERY BOOT, because the commonest key problem is believing a
|
|
3703
|
+
// variable is set when it is not, and the second commonest is still holding one
|
|
3704
|
+
// key after a rotation. Both are visible in this one line, and neither is
|
|
3705
|
+
// visible anywhere else until a dispatch fails.
|
|
3706
|
+
report.info(\`[startup] keys: \${describeKeys()}\`);
|
|
3707
|
+
// WHICH STORE IS LIVE, said out loud at every boot. A receiver pointed at a
|
|
3708
|
+
// partner's own database and one still writing to the bundled demo file behave
|
|
3709
|
+
// identically until the first dispatch, and the difference is where a customer's
|
|
3710
|
+
// address ends up. Naming it costs a line and removes the question.
|
|
3711
|
+
//
|
|
3712
|
+
// AND WHETHER IT IS PROTECTED, IN BOTH DIRECTIONS. The dashboard has always
|
|
3713
|
+
// drawn UNENCRYPTED in red, and a receiver running as a service has no
|
|
3714
|
+
// dashboard: stdin is not a terminal, so nobody is asked for a passphrase,
|
|
3715
|
+
// nobody answers, and it runs in the clear having said nothing. That is the
|
|
3716
|
+
// deployment most likely to hold real customers and the one least likely to
|
|
3717
|
+
// have a person looking at it. Encryption stays OPT-IN - requiring it would
|
|
3718
|
+
// lock out every install that has never set a passphrase - but choosing it by
|
|
3719
|
+
// not being asked is not a choice, so the absence is stated as loudly as the
|
|
3720
|
+
// presence.
|
|
3721
|
+
report.info(
|
|
3722
|
+
store.encrypted
|
|
3723
|
+
? \`[startup] store: \${store.name} (encrypted at rest)\`
|
|
3724
|
+
: \`[startup] store: \${store.name} \u2014 NOT ENCRYPTED AT REST. \` +
|
|
3725
|
+
'Customer records are readable by anyone who can read the file. ' +
|
|
3726
|
+
'Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` in a terminal to be asked.',
|
|
3727
|
+
);
|
|
3728
|
+
|
|
2551
3729
|
// In-memory dedup cache. Records a dispatch id only once it has been fully
|
|
2552
3730
|
// HANDLED, so a dispatch that failed to decrypt is NOT remembered and a later
|
|
2553
3731
|
// retry re-runs it rather than being dismissed as a duplicate. Bounded so a
|
|
@@ -2569,9 +3747,10 @@ function rememberDispatch(id: string): void {
|
|
|
2569
3747
|
|
|
2570
3748
|
/**
|
|
2571
3749
|
* Close the loop: tell OneAddress you have applied an update, so the consumer's
|
|
2572
|
-
* dashboard flips the service to "Confirmed".
|
|
2573
|
-
* the webhook
|
|
2574
|
-
* and mark it failed)
|
|
3750
|
+
* dashboard flips the service to "Confirmed". Called ONLY by the confirm
|
|
3751
|
+
* queue's drain, never from the webhook handler: a slow confirm must not delay
|
|
3752
|
+
* the webhook 200 (OneAddress would time the DISPATCH out and mark it failed),
|
|
3753
|
+
* and a failed one has to be retried rather than logged and lost.
|
|
2575
3754
|
*
|
|
2576
3755
|
* Auth for /api/confirm (all three required):
|
|
2577
3756
|
* Authorization: Bearer <secret>
|
|
@@ -2579,13 +3758,11 @@ function rememberDispatch(id: string): void {
|
|
|
2579
3758
|
* X-OneAddress-Signature: HMAC-SHA256(secret, \`\${timestamp}.\${rawBody}\`)
|
|
2580
3759
|
* The same secret signs the Bearer and the body.
|
|
2581
3760
|
*/
|
|
2582
|
-
async function confirmToOneAddress(
|
|
2583
|
-
//
|
|
2584
|
-
//
|
|
2585
|
-
//
|
|
2586
|
-
|
|
2587
|
-
if (!Number.isInteger(dispatchId) || dispatchId <= 0) return;
|
|
2588
|
-
|
|
3761
|
+
async function confirmToOneAddress(dispatchId: number, status: ConfirmStatus): Promise<void> {
|
|
3762
|
+
// No probe check here any more. queueConfirm is the only door into the queue
|
|
3763
|
+
// and it drops non-numeric dispatch ids, so by the time a row is drained it
|
|
3764
|
+
// is a real dispatch. Keeping a second copy of that rule would mean a probe
|
|
3765
|
+
// could be queued forever and silently skipped on every drain.
|
|
2589
3766
|
const bodyStr = JSON.stringify({
|
|
2590
3767
|
dispatch_id: dispatchId,
|
|
2591
3768
|
partner_id: PARTNER_ID,
|
|
@@ -2595,7 +3772,7 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
|
|
|
2595
3772
|
const ts = String(Math.floor(Date.now() / 1000));
|
|
2596
3773
|
const sig = createHmac('sha256', CONFIRM_SECRET).update(\`\${ts}.\${bodyStr}\`).digest('hex');
|
|
2597
3774
|
|
|
2598
|
-
|
|
3775
|
+
{
|
|
2599
3776
|
const confirmRes = await fetch(\`\${ONEADDRESS_API}/api/confirm\`, {
|
|
2600
3777
|
method: 'POST',
|
|
2601
3778
|
headers: {
|
|
@@ -2608,18 +3785,31 @@ async function confirmToOneAddress(dispatch: string, status: 'confirmed' | 'fail
|
|
|
2608
3785
|
});
|
|
2609
3786
|
if (confirmRes.ok) {
|
|
2610
3787
|
report.info(\`[confirm] dispatch \${dispatchId} \u2192 \${status}: acknowledged by OneAddress\`);
|
|
2611
|
-
|
|
2612
|
-
const detail = await confirmRes.text().catch(() => '');
|
|
2613
|
-
report.error(\`[confirm] dispatch \${dispatchId} confirm FAILED \u2014 HTTP \${confirmRes.status} \${detail}\`);
|
|
2614
|
-
if (confirmRes.status === 401) {
|
|
2615
|
-
report.error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET in .env to it (from the portal Webhook screen); otherwise your webhook signing secret should work.');
|
|
2616
|
-
}
|
|
3788
|
+
return;
|
|
2617
3789
|
}
|
|
2618
|
-
|
|
2619
|
-
|
|
3790
|
+
const detail = (await confirmRes.text().catch(() => '')).slice(0, 200);
|
|
3791
|
+
if (confirmRes.status === 401) {
|
|
3792
|
+
report.error('[confirm] 401 means the wrong secret. If your partner has a separate confirm secret, set CONFIRM_SECRET in .env to it (from the portal Webhook screen); otherwise your webhook signing secret should work. The confirm stays queued, so fixing .env and restarting will deliver it.');
|
|
3793
|
+
}
|
|
3794
|
+
// THROWN, NOT LOGGED AND SWALLOWED. The queue is what decides to retry, and
|
|
3795
|
+
// it can only do that if failure reaches it. Returning quietly here is the
|
|
3796
|
+
// original bug: applied locally, never acknowledged, refunded upstream.
|
|
3797
|
+
throw new Error(\`HTTP \${confirmRes.status} \${detail}\`);
|
|
2620
3798
|
}
|
|
2621
3799
|
}
|
|
2622
3800
|
|
|
3801
|
+
/**
|
|
3802
|
+
* Hand a confirm to the queue.
|
|
3803
|
+
*
|
|
3804
|
+
* Probes (the go-live \`address.test\`) carry a non-numeric dispatch id and have
|
|
3805
|
+
* nothing to confirm, so they are dropped here rather than queued forever.
|
|
3806
|
+
*/
|
|
3807
|
+
function queueConfirm(dispatch: string, status: ConfirmStatus): void {
|
|
3808
|
+
const dispatchId = Number(dispatch);
|
|
3809
|
+
if (!Number.isInteger(dispatchId) || dispatchId <= 0) return;
|
|
3810
|
+
enqueueConfirm(dispatchId, status);
|
|
3811
|
+
}
|
|
3812
|
+
|
|
2623
3813
|
const app = express();
|
|
2624
3814
|
app.disable('x-powered-by'); // don't fingerprint the framework
|
|
2625
3815
|
|
|
@@ -2721,7 +3911,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
2721
3911
|
const name = typeof cust.name === 'string' ? cust.name : '';
|
|
2722
3912
|
const knownNames = Array.isArray(cust.known_names) ? cust.known_names.map(String) : [];
|
|
2723
3913
|
|
|
2724
|
-
const status = verifyAccount(accountNumber, name, knownNames);
|
|
3914
|
+
const status = await store.verifyAccount(accountNumber, name, knownNames);
|
|
2725
3915
|
report.info(\`[webhook] account.verify \u2192 \${status} for account \${accountNumber ?? '(none)'} (\${name})\`);
|
|
2726
3916
|
return res.status(200).json({ status });
|
|
2727
3917
|
}
|
|
@@ -2732,6 +3922,23 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
2732
3922
|
// Acknowledge it here; only the real dispatch events below require decryption.
|
|
2733
3923
|
// A real address.updated that arrives WITHOUT a payload still falls through to
|
|
2734
3924
|
// the 422 below, because it IS a dispatch event.
|
|
3925
|
+
/**
|
|
3926
|
+
* Keep this dispatch so it can be applied once the cause is fixed.
|
|
3927
|
+
*
|
|
3928
|
+
* A closure rather than four calls passing the same three values, because the
|
|
3929
|
+
* value that must not be got wrong is \`rawBody\`: quarantine holds the bytes
|
|
3930
|
+
* AS THEY ARRIVED and never a re-serialised \`body\`. \`JSON.stringify(body)\` is
|
|
3931
|
+
* a different string with the same meaning, and replaying it would fail the
|
|
3932
|
+
* signature check it is about to be re-signed under, for a reason nobody
|
|
3933
|
+
* would find.
|
|
3934
|
+
*
|
|
3935
|
+
* Everything that calls this sits BELOW the HMAC check above. That is what
|
|
3936
|
+
* stops the quarantine being a way for anyone who can reach this port to fill
|
|
3937
|
+
* a partner's disk.
|
|
3938
|
+
*/
|
|
3939
|
+
const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void =>
|
|
3940
|
+
quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
|
|
3941
|
+
|
|
2735
3942
|
const DISPATCH_EVENTS = ['address.updated', 'address.verify', 'address.test', 'address.test-dispatch'];
|
|
2736
3943
|
if (!DISPATCH_EVENTS.includes(event)) {
|
|
2737
3944
|
report.info(\`[webhook] "\${event}" acknowledged (no address payload to decrypt)\`);
|
|
@@ -2765,25 +3972,47 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
2765
3972
|
|
|
2766
3973
|
if (sessionEnvelope && sessionKeyShare) {
|
|
2767
3974
|
// D5 \u2014 call decryptSession. Wrong key_id / wrong partner_id / tampered
|
|
2768
|
-
// ciphertext all surface as an AES-GCM authentication-tag error
|
|
3975
|
+
// ciphertext all surface as an AES-GCM authentication-tag error, which is
|
|
3976
|
+
// why the key is chosen BY key_id here rather than assumed: the failure
|
|
3977
|
+
// cannot tell you which of those three it was, so the message has to.
|
|
3978
|
+
const keyId = sessionKeyShare.key_id ?? null;
|
|
3979
|
+
const resolved = resolvePrivateKey(keyId);
|
|
3980
|
+
if (!resolved) {
|
|
3981
|
+
report.error(\`[webhook] no private key for key_id \${keyId ?? '(none)'} \u2014 \${keyFailureAdvice(keyId, null)}\`);
|
|
3982
|
+
hold('no_key', keyId, keyFailureAdvice(keyId, null));
|
|
3983
|
+
return res.status(422).json({ ok: false, error: 'no private key for this key_id' });
|
|
3984
|
+
}
|
|
2769
3985
|
try {
|
|
2770
|
-
const data = await decryptSession(sessionKeyShare, sessionEnvelope,
|
|
3986
|
+
const data = await decryptSession(sessionKeyShare, sessionEnvelope, resolved.pem, PARTNER_ID);
|
|
2771
3987
|
address = data.new_address as unknown as Record<string, unknown>;
|
|
2772
3988
|
decName = typeof data.verified_name === 'string' ? data.verified_name : '';
|
|
2773
3989
|
decAccount = typeof data.account_number === 'string' ? data.account_number : '';
|
|
2774
3990
|
decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
|
|
2775
3991
|
} catch (err) {
|
|
2776
|
-
|
|
3992
|
+
// Names WHICH key answered. A rotation used to surface here as an
|
|
3993
|
+
// authentication-tag error indistinguishable from corruption, with the
|
|
3994
|
+
// single-key fallback having silently answered for a key id it never held.
|
|
3995
|
+
report.error(\`[webhook] D5 decryption failed for key_id \${keyId ?? '(none)'}: \${keyFailureAdvice(keyId, resolved)}\`);
|
|
3996
|
+
report.error('[webhook] underlying error:', err);
|
|
3997
|
+
hold('decrypt_failed', keyId, keyFailureAdvice(keyId, resolved));
|
|
2777
3998
|
return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
|
|
2778
3999
|
}
|
|
2779
4000
|
} else if (legacyPayload) {
|
|
4001
|
+
// Pre-D5 dispatches name no key id, so only the fallback can apply.
|
|
4002
|
+
const resolved = resolvePrivateKey(null);
|
|
4003
|
+
if (!resolved) {
|
|
4004
|
+
report.error(\`[webhook] no private key for a legacy dispatch \u2014 \${keyFailureAdvice(null, null)}\`);
|
|
4005
|
+
hold('no_key', null, keyFailureAdvice(null, null));
|
|
4006
|
+
return res.status(422).json({ ok: false, error: 'no private key configured' });
|
|
4007
|
+
}
|
|
2780
4008
|
try {
|
|
2781
|
-
address = await decryptAddress(legacyPayload,
|
|
4009
|
+
address = await decryptAddress(legacyPayload, resolved.pem, PARTNER_ID);
|
|
2782
4010
|
decName = typeof address.fullName === 'string' ? address.fullName : '';
|
|
2783
4011
|
decAccount = typeof address.accountReference === 'string' ? address.accountReference : '';
|
|
2784
4012
|
decKnownNames = Array.isArray(address.knownNames) ? address.knownNames as string[] : [];
|
|
2785
4013
|
} catch (err) {
|
|
2786
4014
|
report.error('[webhook] Decryption failed \u2014 check PARTNER_PRIVATE_KEY_PEM in .env:', err);
|
|
4015
|
+
hold('decrypt_failed', null, keyFailureAdvice(null, resolved));
|
|
2787
4016
|
return res.status(422).json({ ok: false, error: 'Decryption failed \u2014 partner key mismatch' });
|
|
2788
4017
|
}
|
|
2789
4018
|
} else {
|
|
@@ -2851,10 +4080,10 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
2851
4080
|
// OneAddress conformance check "Refuses an account reference that matches
|
|
2852
4081
|
// no record" tests exactly this.
|
|
2853
4082
|
if (config.verifiesAccountReference) {
|
|
2854
|
-
const verdict = verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
|
|
4083
|
+
const verdict = await store.verifyAccount(ctx.accountNumber, ctx.name ?? '', ctx.knownNames ?? []);
|
|
2855
4084
|
if (verdict !== 'match') {
|
|
2856
4085
|
report.warn(\`[webhook] address.updated REFUSED (\${verdict}) for account \${ctx.accountNumber ?? '(none)'} \u2014 nothing applied\`);
|
|
2857
|
-
|
|
4086
|
+
queueConfirm(dispatch, 'failed');
|
|
2858
4087
|
return res.status(200).json({ ok: false, error: 'account_not_matched', verdict });
|
|
2859
4088
|
}
|
|
2860
4089
|
}
|
|
@@ -2863,12 +4092,14 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
2863
4092
|
// it can show both halves; a no-op under --headless. Passed directly rather
|
|
2864
4093
|
// than reported, because the previous address is a customer's address and
|
|
2865
4094
|
// must never reach a log line.
|
|
2866
|
-
const replaced = await saveAddress(ctx, address);
|
|
4095
|
+
const replaced = await store.saveAddress(ctx, address);
|
|
2867
4096
|
notePreviousAddress(replaced);
|
|
2868
4097
|
if (dispatch) rememberDispatch(dispatch); // remember only after it is stored
|
|
2869
4098
|
// Close the loop back to OneAddress so the service flips to "Confirmed".
|
|
2870
|
-
//
|
|
2871
|
-
|
|
4099
|
+
// QUEUED, not sent: this is a local INSERT, so it cannot delay the 200 that
|
|
4100
|
+
// acks the delivery, and it survives a restart. The drain loop does the
|
|
4101
|
+
// network part and retries it until OneAddress answers.
|
|
4102
|
+
queueConfirm(dispatch, 'confirmed');
|
|
2872
4103
|
return res.status(200).json({ ok: true });
|
|
2873
4104
|
}
|
|
2874
4105
|
|
|
@@ -2891,7 +4122,7 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
2891
4122
|
}
|
|
2892
4123
|
|
|
2893
4124
|
report.info(\`[webhook] address.verify for \${ctx.accountNumber || ctx.name}\`);
|
|
2894
|
-
const result = await verifyAddress(ctx, address);
|
|
4125
|
+
const result = await store.verifyAddress(ctx, address);
|
|
2895
4126
|
report.info(\`[webhook] address.verify \u2192 \${result}\`);
|
|
2896
4127
|
|
|
2897
4128
|
await fetch(safeCallbackUrl, {
|
|
@@ -2938,12 +4169,131 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
2938
4169
|
return res.status(200).json({ ok: true, skipped: true });
|
|
2939
4170
|
});
|
|
2940
4171
|
|
|
2941
|
-
|
|
4172
|
+
/**
|
|
4173
|
+
* Enough to answer "is this receiver healthy AND is it protected?" from a
|
|
4174
|
+
* monitoring system, without a terminal and without reading the logs.
|
|
4175
|
+
*
|
|
4176
|
+
* \`encryptedAtRest\` is here rather than only on the dashboard because the
|
|
4177
|
+
* deployment that most needs the answer is the one with no dashboard. Carrying
|
|
4178
|
+
* it makes the unprotected state alertable instead of merely visible.
|
|
4179
|
+
*
|
|
4180
|
+
* Deliberately NO counts, no customer data and no key material: this endpoint
|
|
4181
|
+
* is reachable by whatever can reach the webhook.
|
|
4182
|
+
*/
|
|
4183
|
+
app.get('/health', (_req, res) => res.json({
|
|
4184
|
+
status: 'ok',
|
|
4185
|
+
store: store.name,
|
|
4186
|
+
encryptedAtRest: store.encrypted,
|
|
4187
|
+
}));
|
|
2942
4188
|
|
|
2943
4189
|
app.listen(PORT, () =>
|
|
2944
4190
|
report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`),
|
|
2945
4191
|
);
|
|
2946
4192
|
|
|
4193
|
+
/**
|
|
4194
|
+
* Drain the confirm queue, forever.
|
|
4195
|
+
*
|
|
4196
|
+
* STARTED AT BOOT, not only after a dispatch, because the queue survives a
|
|
4197
|
+
* restart: a receiver that was down while OneAddress was unreachable comes back
|
|
4198
|
+
* with confirms owed, and nobody is going to send a fresh dispatch to trigger
|
|
4199
|
+
* them. On a healthy receiver this loop finds nothing and costs one indexed
|
|
4200
|
+
* SELECT every few seconds.
|
|
4201
|
+
*/
|
|
4202
|
+
const CONFIRM_DRAIN_MS = Number(process.env.CONFIRM_DRAIN_MS ?? 5000);
|
|
4203
|
+
const CONFIRM_KEEP_DAYS = Number(process.env.CONFIRM_KEEP_DAYS ?? 30);
|
|
4204
|
+
|
|
4205
|
+
let draining = false;
|
|
4206
|
+
setInterval(() => {
|
|
4207
|
+
// Guarded rather than queued: a slow OneAddress must not start a second
|
|
4208
|
+
// drain over the same rows, which would confirm each one twice.
|
|
4209
|
+
if (draining) return;
|
|
4210
|
+
draining = true;
|
|
4211
|
+
void drainConfirms(confirmToOneAddress)
|
|
4212
|
+
.catch((err: unknown) => report.error('[confirm] drain error:', err))
|
|
4213
|
+
.finally(() => { draining = false; });
|
|
4214
|
+
}, CONFIRM_DRAIN_MS).unref();
|
|
4215
|
+
|
|
4216
|
+
// Delivered rows only, so an outstanding confirm is never aged out. Hourly is
|
|
4217
|
+
// far more often than needed and costs one DELETE over an index.
|
|
4218
|
+
setInterval(() => {
|
|
4219
|
+
const purged = purgeDelivered(CONFIRM_KEEP_DAYS);
|
|
4220
|
+
if (purged > 0) report.info(\`[confirm] purged \${purged} delivered confirm record(s) older than \${CONFIRM_KEEP_DAYS}d\`);
|
|
4221
|
+
}, 3_600_000).unref();
|
|
4222
|
+
|
|
4223
|
+
/**
|
|
4224
|
+
* Re-deliver a held dispatch TO THIS SERVER, re-signed.
|
|
4225
|
+
*
|
|
4226
|
+
* A self-POST rather than a second code path into the handler. The handler is
|
|
4227
|
+
* one 300-line route and the alternative is extracting it so replay can call it
|
|
4228
|
+
* directly, which would give two ways in and, in time, two behaviours: the one
|
|
4229
|
+
* partners hit and the one replay hits, differing in whichever branch was added
|
|
4230
|
+
* to only one of them. Going back in through the front door means a replayed
|
|
4231
|
+
* dispatch is verified, parsed, decrypted, applied and confirmed by exactly the
|
|
4232
|
+
* code a live one is.
|
|
4233
|
+
*
|
|
4234
|
+
* The body is byte-for-byte what arrived; only the timestamp and signature are
|
|
4235
|
+
* new, because the original pair is outside the \xB15-minute window by the time
|
|
4236
|
+
* anyone has fixed anything. We hold the secret, so re-signing is not a bypass:
|
|
4237
|
+
* it is the same proof, re-stated now.
|
|
4238
|
+
*/
|
|
4239
|
+
async function redeliver(rawBody: string): Promise<void> {
|
|
4240
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
4241
|
+
const sig = createHmac('sha256', WEBHOOK_SECRET).update(\`\${ts}.\${rawBody}\`).digest('hex');
|
|
4242
|
+
let dispatchId = '';
|
|
4243
|
+
try {
|
|
4244
|
+
dispatchId = String((JSON.parse(rawBody) as { dispatch_id?: unknown }).dispatch_id ?? '');
|
|
4245
|
+
} catch { /* the handler will reject it as bad JSON, which is the right answer */ }
|
|
4246
|
+
|
|
4247
|
+
const res = await fetch(\`http://127.0.0.1:\${PORT}/webhook\`, {
|
|
4248
|
+
method: 'POST',
|
|
4249
|
+
headers: {
|
|
4250
|
+
'Content-Type': 'application/json',
|
|
4251
|
+
'X-OneAddress-Timestamp': ts,
|
|
4252
|
+
'X-OneAddress-Signature': sig,
|
|
4253
|
+
...(dispatchId ? { 'X-OneAddress-Dispatch': dispatchId } : {}),
|
|
4254
|
+
},
|
|
4255
|
+
body: rawBody,
|
|
4256
|
+
});
|
|
4257
|
+
const text = (await res.text().catch(() => '')).slice(0, 200);
|
|
4258
|
+
if (!res.ok) throw new Error(\`HTTP \${res.status} \${text}\`);
|
|
4259
|
+
// A 200 is not on its own success here: the handler answers \`ok: false\` with
|
|
4260
|
+
// 200 when it REFUSES a dispatch (an account reference matching nobody), and
|
|
4261
|
+
// treating that as applied would clear the row for an update that was never
|
|
4262
|
+
// stored.
|
|
4263
|
+
if (!text.includes('"ok":true')) throw new Error(\`refused: \${text}\`);
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
/** Apply everything held. Bound to [r] on the dashboard and run once at boot. */
|
|
4267
|
+
export async function replayQuarantined(): Promise<{ applied: number; failed: number }> {
|
|
4268
|
+
return replayHeld(redeliver);
|
|
4269
|
+
}
|
|
4270
|
+
|
|
4271
|
+
/**
|
|
4272
|
+
* Try the backlog once, a moment after boot.
|
|
4273
|
+
*
|
|
4274
|
+
* Because the realistic sequence is: a key is wrong, dispatches pile up, the
|
|
4275
|
+
* partner edits \`.env\`, the partner restarts. Making them find a command after
|
|
4276
|
+
* that is making the recovery depend on reading documentation at the exact
|
|
4277
|
+
* moment they are least inclined to. The delay lets \`listen\` settle, since this
|
|
4278
|
+
* goes back in through the port.
|
|
4279
|
+
*/
|
|
4280
|
+
setTimeout(() => {
|
|
4281
|
+
if (heldCount() === 0) return;
|
|
4282
|
+
void replayQuarantined().catch((err: unknown) =>
|
|
4283
|
+
report.error('[replay] could not run at startup:', err),
|
|
4284
|
+
);
|
|
4285
|
+
}, 1_500).unref();
|
|
4286
|
+
|
|
4287
|
+
// Held payloads age out whether or not they were ever applied, which is the
|
|
4288
|
+
// opposite of the confirm queue one block up. The reason is what the row holds:
|
|
4289
|
+
// a confirm record names a dispatch, a quarantined payload is a consumer's
|
|
4290
|
+
// encrypted address on someone else's disk.
|
|
4291
|
+
const QUARANTINE_KEEP_DAYS = Number(process.env.QUARANTINE_KEEP_DAYS ?? 30);
|
|
4292
|
+
setInterval(() => { purgeQuarantine(QUARANTINE_KEEP_DAYS); }, 3_600_000).unref();
|
|
4293
|
+
|
|
4294
|
+
/** How many confirms are still owed. Read by the dashboard. */
|
|
4295
|
+
export { pendingConfirmCount };
|
|
4296
|
+
|
|
2947
4297
|
// Read by src/index.ts to label the dashboard. Exported rather than re-derived
|
|
2948
4298
|
// there, so the port the UI claims is the port the server actually bound.
|
|
2949
4299
|
export { PORT };
|
|
@@ -3106,8 +4456,174 @@ Valid address-verify results: \`"match"\` \xB7 \`"mismatch"\` \xB7 \`"not_found"
|
|
|
3106
4456
|
|
|
3107
4457
|
After an \`address.updated\` is stored, the receiver POSTs to \`\${ONEADDRESS_API}/api/confirm\`
|
|
3108
4458
|
(HMAC-SHA256 over \`\${timestamp}.\${rawBody}\`, Bearer + \`X-OneAddress-Signature\` headers)
|
|
3109
|
-
so the consumer's dashboard flips the service to **Confirmed**.
|
|
3110
|
-
|
|
4459
|
+
so the consumer's dashboard flips the service to **Confirmed**.
|
|
4460
|
+
|
|
4461
|
+
It is QUEUED, not fired and forgotten. The acknowledgement is a durable local
|
|
4462
|
+
row, retried with backoff and surviving a restart, so a local write is all the
|
|
4463
|
+
webhook waits on. That matters more than it sounds: an update OneAddress never
|
|
4464
|
+
hears about is shown to the consumer as a FAILED delivery, and a failed delivery
|
|
4465
|
+
can be refunded \u2014 so a thirty-second outage used to mean you did the work and the
|
|
4466
|
+
payment went back. The dashboard's \`awaiting confirm\` count is how many are still
|
|
4467
|
+
owed; a non-zero figure that will not come down is usually a wrong
|
|
4468
|
+
\`CONFIRM_SECRET\`, and the log says so.
|
|
4469
|
+
|
|
4470
|
+
### When a dispatch will not decrypt
|
|
4471
|
+
|
|
4472
|
+
If a dispatch arrives with a valid signature and cannot be opened \u2014 the wrong key,
|
|
4473
|
+
or a \`key_id\` you have no variable for \u2014 it is answered \`422\` (so OneAddress keeps
|
|
4474
|
+
treating it as undelivered) and the encrypted payload is HELD exactly as it
|
|
4475
|
+
arrived. Nothing is decrypted on the way in; the receiver could not, which is the
|
|
4476
|
+
whole reason the record exists.
|
|
4477
|
+
|
|
4478
|
+
A \`FAULTS\` panel appears on the dashboard while anything is held and names the
|
|
4479
|
+
cause and the \`key_id\`. Fix the key and restart and the backlog applies itself,
|
|
4480
|
+
or press \`[r]\` without restarting.
|
|
4481
|
+
|
|
4482
|
+
### Rotating a key
|
|
4483
|
+
|
|
4484
|
+
Every dispatch names the key it was encrypted to, and both keys stay valid for an
|
|
4485
|
+
overlap window. Give each its own variable, named for its \`key_id\` with dashes as
|
|
4486
|
+
underscores, upper-cased:
|
|
4487
|
+
|
|
4488
|
+
\`\`\`
|
|
4489
|
+
PARTNER_PRIVATE_KEY_PEM_04032299_4B04_4842_AA29_5095500C8ECE=...
|
|
4490
|
+
\`\`\`
|
|
4491
|
+
|
|
4492
|
+
\`PARTNER_PRIVATE_KEY_PEM\` is then a fallback for any \`key_id\` without a variable
|
|
4493
|
+
of its own. Convenient with one key and a trap with two: it answers for an id it
|
|
4494
|
+
does not hold, and AES-GCM cannot tell a wrong key from a tampered ciphertext, so
|
|
4495
|
+
the failure reads like corruption rather than like a rotation. **Set
|
|
4496
|
+
\`PARTNER_KEYS_STRICT=1\` once you hold more than one key.** The startup line tells
|
|
4497
|
+
you which keys this receiver can open.
|
|
4498
|
+
|
|
4499
|
+
## Pointing this at your own database
|
|
4500
|
+
|
|
4501
|
+
Everything OneAddress needs from your systems is **five methods in
|
|
4502
|
+
\`src/customer-store.ts\`**. That file is the contract: no schema, no seeding, no
|
|
4503
|
+
encryption in the way, a paragraph per method. It is the file to hand a DBA, and
|
|
4504
|
+
it is readable in ten minutes without reading any of ours.
|
|
4505
|
+
|
|
4506
|
+
\`src/store.ts\` is the DEFAULT implementation of it, backed by the bundled SQLite
|
|
4507
|
+
file. The SQLite schema, the at-rest encryption, the blind index,
|
|
4508
|
+
\`customers.json\`, the repair pass and the history table all belong to that
|
|
4509
|
+
implementation and **not** to the contract. If your customers already live in
|
|
4510
|
+
your own database, delete them rather than port them: encrypting a second copy
|
|
4511
|
+
of a record you already hold protects nothing and gives you another key to
|
|
4512
|
+
manage.
|
|
4513
|
+
|
|
4514
|
+
Write a module exporting a \`CustomerStore\` and change one line:
|
|
4515
|
+
|
|
4516
|
+
\`\`\`typescript
|
|
4517
|
+
// src/server.ts and src/tui.ts
|
|
4518
|
+
import { store } from './my-customer-store.js';
|
|
4519
|
+
\`\`\`
|
|
4520
|
+
|
|
4521
|
+
The \`satisfies CustomerStore\` on your export is what keeps that safe \u2014 miss a
|
|
4522
|
+
method, or drift from a signature the receiver depends on, and it stops
|
|
4523
|
+
compiling rather than failing on a live dispatch. The receiver names the live
|
|
4524
|
+
store in its startup line, so you can see which one is running without reading
|
|
4525
|
+
code.
|
|
4526
|
+
|
|
4527
|
+
### What it needs on your database
|
|
4528
|
+
|
|
4529
|
+
Less than people expect. The receiver never reads a customer's address for its
|
|
4530
|
+
own purposes and never lists your table:
|
|
4531
|
+
|
|
4532
|
+
| Method | Needs |
|
|
4533
|
+
|--------|-------|
|
|
4534
|
+
| \`verifyAccount\` | read the account number and the name |
|
|
4535
|
+
| \`verifyAddress\` | read the address you hold |
|
|
4536
|
+
| \`saveAddress\` | read the address, then write it; append your own audit row |
|
|
4537
|
+
| \`find\` | read one row by account number (optional, dashboard only) |
|
|
4538
|
+
| \`count\` | nothing \u2014 return \`null\` and it is never asked |
|
|
4539
|
+
|
|
4540
|
+
A least-privilege PostgreSQL grant for that is short enough to review:
|
|
4541
|
+
|
|
4542
|
+
\`\`\`sql
|
|
4543
|
+
CREATE ROLE oneaddress_receiver LOGIN PASSWORD '...';
|
|
4544
|
+
GRANT USAGE ON SCHEMA app TO oneaddress_receiver;
|
|
4545
|
+
GRANT SELECT (account_number, name, address) ON app.customers TO oneaddress_receiver;
|
|
4546
|
+
GRANT UPDATE (address, updated_at) ON app.customers TO oneaddress_receiver;
|
|
4547
|
+
GRANT INSERT ON app.address_history TO oneaddress_receiver;
|
|
4548
|
+
-- No DELETE, no DDL, no access to any other table.
|
|
4549
|
+
\`\`\`
|
|
4550
|
+
|
|
4551
|
+
**No INSERT on \`customers\`.** The receiver has no business creating customers:
|
|
4552
|
+
a dispatch for an account you do not have is one you should refuse, and a role
|
|
4553
|
+
that cannot create a row cannot be talked into it by a bug in ours.
|
|
4554
|
+
|
|
4555
|
+
### What stays ours
|
|
4556
|
+
|
|
4557
|
+
The receiver keeps a small local SQLite file even when your customers live
|
|
4558
|
+
elsewhere, for the confirm queue and the quarantine. Neither holds customer
|
|
4559
|
+
data: the confirm queue holds dispatch ids and outcomes, the quarantine holds
|
|
4560
|
+
ciphertext the receiver could not open. Your DBA is entitled to ask, and that is
|
|
4561
|
+
the answer.
|
|
4562
|
+
|
|
4563
|
+
The protocol half stays ours too, and a store implementation is never given a
|
|
4564
|
+
chance to weaken it: signature verification, the replay window, decryption and
|
|
4565
|
+
the confirm callback all run before your code is called. By then the dispatch
|
|
4566
|
+
has been proven to come from OneAddress and decrypted in memory.
|
|
4567
|
+
|
|
4568
|
+
### One thing to get right at your scale
|
|
4569
|
+
|
|
4570
|
+
\`count()\` is asked every two seconds while the dashboard is open. **Return
|
|
4571
|
+
\`null\` from it** if answering means a \`SELECT count(*)\` over a real customer
|
|
4572
|
+
table; the dashboard draws a dash and is otherwise identical. The bundled store
|
|
4573
|
+
answers it because its roster is three rows.
|
|
4574
|
+
|
|
4575
|
+
The same reasoning is why there is no "list every customer" method. The
|
|
4576
|
+
dashboard asks for the ONE customer a dispatch just changed, by account number,
|
|
4577
|
+
so your database does the thing it is already good at.
|
|
4578
|
+
|
|
4579
|
+
## What this is, and where it stops
|
|
4580
|
+
|
|
4581
|
+
Honest limits, so you find them here rather than in production. Most are further
|
|
4582
|
+
away than people expect, and the last one is a hard edge rather than a slope.
|
|
4583
|
+
|
|
4584
|
+
**It is a real receiver, not a toy.** It verifies signatures, enforces the
|
|
4585
|
+
replay window, decrypts per-partner envelopes, holds what it cannot open,
|
|
4586
|
+
retries its acknowledgements durably and refuses accounts you do not recognise.
|
|
4587
|
+
Nothing in the protocol layer is stubbed.
|
|
4588
|
+
|
|
4589
|
+
**Throughput is not the constraint.** An address change is a rare event per
|
|
4590
|
+
customer. A single process on modest hardware handles far more than a consumer
|
|
4591
|
+
base generates, and the work per dispatch is one decrypt and one write.
|
|
4592
|
+
|
|
4593
|
+
**Its own bookkeeping is a local SQLite file**, whatever your customers live in.
|
|
4594
|
+
The confirm queue holds dispatch ids and outcomes; the quarantine holds
|
|
4595
|
+
ciphertext it could not open. Neither holds customer data, and both stay local
|
|
4596
|
+
even after you implement \`CustomerStore\` against your own database.
|
|
4597
|
+
|
|
4598
|
+
**The hard edge: run ONE of these.** Because that bookkeeping is a local file,
|
|
4599
|
+
two instances are not two workers sharing a queue - they are two separate
|
|
4600
|
+
receivers. Each acknowledges only what it received, and pressing \`[r]\` on one
|
|
4601
|
+
does nothing for the other's backlog. That is fine, and it is not a scaling
|
|
4602
|
+
problem at this event rate, but it is a thing to know before somebody sets
|
|
4603
|
+
\`replicas: 2\` and wonders why half the confirms never leave.
|
|
4604
|
+
|
|
4605
|
+
If you genuinely need more than one instance, or a metrics endpoint, or a
|
|
4606
|
+
different database behind the receiver's own bookkeeping, talk to us before
|
|
4607
|
+
building around it: that is a different piece of software and we would rather
|
|
4608
|
+
give it to you than watch you rebuild it.
|
|
4609
|
+
|
|
4610
|
+
### The order to do things in
|
|
4611
|
+
|
|
4612
|
+
1. **Run it as generated.** Point the wizard at a tunnel, pass conformance, see
|
|
4613
|
+
a real dispatch land in the demo roster.
|
|
4614
|
+
2. **Implement \`CustomerStore\` against your own database.** The contract is five
|
|
4615
|
+
methods; the grant above is what your DBA needs to approve. Delete the SQLite
|
|
4616
|
+
roster, \`customers.json\`, the at-rest encryption and the history table when
|
|
4617
|
+
you do - they are ours, not the contract's.
|
|
4618
|
+
3. **Decide the two retention windows.** \`CONFIRM_KEEP_DAYS\` and
|
|
4619
|
+
\`QUARANTINE_KEEP_DAYS\` both default to 30. The second one is holding your
|
|
4620
|
+
customers' encrypted addresses, so it is a decision your privacy people
|
|
4621
|
+
should make rather than inherit.
|
|
4622
|
+
4. **Run it where it will live**, with \`ONEADDRESS_DB_PASSPHRASE\` set if you kept
|
|
4623
|
+
our store, and watch the startup line: it names which store is live and
|
|
4624
|
+
whether it is encrypted at rest, in both directions. \`/health\` carries the
|
|
4625
|
+
same two facts so you can alert on them.
|
|
4626
|
+
5. **Set \`PARTNER_KEYS_STRICT=1\`** the day you hold a second key.
|
|
3111
4627
|
|
|
3112
4628
|
## Conformance check
|
|
3113
4629
|
|
|
@@ -3135,6 +4651,23 @@ webhook URL at [partners.oneaddress.io](https://partners.oneaddress.io).
|
|
|
3135
4651
|
| \`oneAddressApi\` | \`https://oneaddress.io\` | Base URL the confirm callback posts to. |
|
|
3136
4652
|
| \`verifiesAccountReference\` | \`(your portal declaration)\` | Whether you answer \`account.verify\` with a real match, or reply "not checked". |
|
|
3137
4653
|
|
|
4654
|
+
**Three optional environment variables** tune what is kept, and most partners
|
|
4655
|
+
set none of them:
|
|
4656
|
+
|
|
4657
|
+
| Var | Default | Purpose |
|
|
4658
|
+
|-----|---------|---------|
|
|
4659
|
+
| \`PARTNER_KEYS_STRICT\` | unset | \`1\` turns off the single-key fallback. Set it once you hold more than one key. |
|
|
4660
|
+
| \`CONFIRM_KEEP_DAYS\` | \`30\` | How long DELIVERED confirm records are kept. An OUTSTANDING one is never aged out. |
|
|
4661
|
+
| \`QUARANTINE_KEEP_DAYS\` | \`30\` | How long a held payload is kept, applied or not. |
|
|
4662
|
+
|
|
4663
|
+
The last two are deliberately opposite rules, and the difference is what the row
|
|
4664
|
+
holds. A confirm record names a dispatch and an outcome, so keeping an unanswered
|
|
4665
|
+
one forever costs nobody anything and dropping it loses an update. A held payload
|
|
4666
|
+
is one of your customers' addresses, encrypted, sitting on your disk: keeping that
|
|
4667
|
+
indefinitely is a retention decision nobody made, so it has a window, and a
|
|
4668
|
+
payload that ages out without ever being applied is logged loudly rather than
|
|
4669
|
+
quietly.
|
|
4670
|
+
|
|
3138
4671
|
Each field can be overridden for a one-off by an environment variable of the
|
|
3139
4672
|
matching name (\`PARTNER_ID\` / \`ONEADDRESS_API\` / \`VERIFIES_ACCOUNT_REFERENCE\`).
|
|
3140
4673
|
One secret has no config-file home because it must stay out of a non-secret
|
|
@@ -7990,7 +9523,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
7990
9523
|
|
|
7991
9524
|
// src/register.ts
|
|
7992
9525
|
var import_node_crypto = require("crypto");
|
|
7993
|
-
var PKG_VERSION = true ? "2.1.
|
|
9526
|
+
var PKG_VERSION = true ? "2.1.3" : "dev";
|
|
7994
9527
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
7995
9528
|
function hmacSha256(secret, message) {
|
|
7996
9529
|
return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
|