@oneaddress/setup 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1070 -173
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -856,7 +856,7 @@ var _R = ["\u2588\u2588\u2588\u2588\u2588\u2588 ", "\u2588\u2588 \u2588\u2588"
|
|
|
856
856
|
var _S = [" \u2588\u2588\u2588\u2588\u2588\u2588", "\u2588\u2588 ", "\u2588\u2588 ", " \u2588\u2588\u2588\u2588\u2588 ", " \u2588\u2588", " \u2588\u2588", "\u2588\u2588\u2588\u2588\u2588\u2588 "];
|
|
857
857
|
var ONE_ROWS = Array.from({ length: 7 }, (_3, i) => [_O[i], _N[i], _E[i]].join(" "));
|
|
858
858
|
var ADDR_ROWS = Array.from({ length: 7 }, (_3, i) => [_A[i], _D2[i], _D2[i], _R[i], _E[i], _S[i], _S[i]].join(" "));
|
|
859
|
-
var WIZARD_VERSION = true ? "2.
|
|
859
|
+
var WIZARD_VERSION = true ? "2.6.0" : "?";
|
|
860
860
|
function printCompactHeader() {
|
|
861
861
|
const INNER = 42;
|
|
862
862
|
const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
|
|
@@ -983,6 +983,7 @@ data.db-shm
|
|
|
983
983
|
"headless": "node --disable-warning=ExperimentalWarning node_modules/tsx/dist/cli.mjs src/index.ts -- --headless",
|
|
984
984
|
"build": "tsup src/index.ts --format esm --no-dts --outDir dist",
|
|
985
985
|
"type-check": "tsc --noEmit",
|
|
986
|
+
"show": "tsx scripts/show.ts",
|
|
986
987
|
"test": "tsx scripts/test.ts"
|
|
987
988
|
},
|
|
988
989
|
"dependencies": {
|
|
@@ -1011,7 +1012,9 @@ data.db-shm
|
|
|
1011
1012
|
"partnerId": "%%PARTNER_ID%%",
|
|
1012
1013
|
"oneAddressApi": "%%ONEADDRESS_API%%",
|
|
1013
1014
|
"verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE%%,
|
|
1014
|
-
"mode": "%%RECEIVER_MODE%%"
|
|
1015
|
+
"mode": "%%RECEIVER_MODE%%",
|
|
1016
|
+
"publicUrl": "%%WEBHOOK_URL%%",
|
|
1017
|
+
"exportDir": ""
|
|
1015
1018
|
}
|
|
1016
1019
|
`
|
|
1017
1020
|
},
|
|
@@ -1711,7 +1714,9 @@ import { formatLine, report, type ReportLine } from './report.js';
|
|
|
1711
1714
|
// reads, with nothing here to change.
|
|
1712
1715
|
import { store } from './store.js';
|
|
1713
1716
|
import { pendingConfirmCount } from './confirm-queue.js';
|
|
1714
|
-
import { exportHeld, heldCount, heldSummary } from './quarantine.js';
|
|
1717
|
+
import { dismissHeld, drillCount, exportHeld, heldCount, heldSummary } from './quarantine.js';
|
|
1718
|
+
import { config } from './config.js';
|
|
1719
|
+
import { reachability } from './reachable.js';
|
|
1715
1720
|
|
|
1716
1721
|
/** blessed takes colours as strings; these mirror the site's palette. */
|
|
1717
1722
|
const AMBER = HEX.amber.toLowerCase();
|
|
@@ -1870,6 +1875,8 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1870
1875
|
// nor UNENCRYPTED is true and both would mislead. The receiver holds
|
|
1871
1876
|
// ciphertext it cannot open; the customers live in the partner's own
|
|
1872
1877
|
// database, behind their own controls.
|
|
1878
|
+
const reach = reachability();
|
|
1879
|
+
const drills = drillCount();
|
|
1873
1880
|
const vault = inbox
|
|
1874
1881
|
? \`{\${AMBER}-fg}{bold}NONE (inbox){/}\`
|
|
1875
1882
|
: store.encrypted
|
|
@@ -1883,7 +1890,21 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1883
1890
|
// claim ("you have no customers") and would be a lie on a receiver
|
|
1884
1891
|
// pointed at a real customer table, where counting every row forty times
|
|
1885
1892
|
// a minute is the thing the store is right to refuse.
|
|
1886
|
-
\`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}
|
|
1893
|
+
\`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}\`
|
|
1894
|
+
// REACHABLE IS DRAWN ONLY WHEN IT IS KNOWN, and that is the point of the
|
|
1895
|
+
// third state. A receiver with no publicUrl configured cannot answer the
|
|
1896
|
+
// question, and a green tick or a red cross would both be inventing an
|
|
1897
|
+
// answer. Silence is the honest rendering of "not checked".
|
|
1898
|
+
// DRILLS ARE NAMED, not hidden. A conformance run leaves a trace and the
|
|
1899
|
+
// partner should be able to see it; what it must not do is look like a
|
|
1900
|
+
// fault. Absent when there are none, because a permanent "drills 0" is a
|
|
1901
|
+
// counter for a thing that has not happened.
|
|
1902
|
+
+ (drills > 0 ? \` {\${DIM}-fg}conformance probes{/} {\${CREAM}-fg}\${drills}{/}\` : '')
|
|
1903
|
+
+ (reach.state === 'unknown'
|
|
1904
|
+
? ''
|
|
1905
|
+
: reach.state === 'reachable'
|
|
1906
|
+
? \` {\${DIM}-fg}reachable{/} {green-fg}{bold}YES{/}\`
|
|
1907
|
+
: \` {\${DIM}-fg}reachable{/} {red-fg}{bold}NO{/}\`),
|
|
1887
1908
|
);
|
|
1888
1909
|
}
|
|
1889
1910
|
|
|
@@ -1926,6 +1947,16 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1926
1947
|
|
|
1927
1948
|
/** Shown while a replay is running, so [r] does not look like it did nothing. */
|
|
1928
1949
|
let replayNote = '';
|
|
1950
|
+
/**
|
|
1951
|
+
* The export confirmation, kept apart from \`replayNote\`.
|
|
1952
|
+
*
|
|
1953
|
+
* They are both one-line notes under the fault panel and they answer
|
|
1954
|
+
* different questions, so sharing a slot means a press of [r] silently wipes
|
|
1955
|
+
* the only place the export path was written down. The PATH is the entire
|
|
1956
|
+
* value of the message: a receiver started from a shortcut or a service
|
|
1957
|
+
* manager has a working directory the partner has never seen.
|
|
1958
|
+
*/
|
|
1959
|
+
let exportNote = '';
|
|
1929
1960
|
|
|
1930
1961
|
/**
|
|
1931
1962
|
* The customer count, refreshed out of band.
|
|
@@ -1947,7 +1978,14 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1947
1978
|
|
|
1948
1979
|
function renderFaults(): void {
|
|
1949
1980
|
const held = heldCount();
|
|
1950
|
-
const
|
|
1981
|
+
const reach = reachability();
|
|
1982
|
+
// UNREACHABLE IS A FAULT, and it belongs in this panel rather than only in
|
|
1983
|
+
// the status bar, because it is the one failure where NOTHING ELSE MOVES.
|
|
1984
|
+
// A wrong key at least produces held dispatches to look at; a stopped
|
|
1985
|
+
// tunnel produces a receiver that looks perfectly healthy and simply never
|
|
1986
|
+
// hears from anyone again.
|
|
1987
|
+
const unreachable = reach.state === 'unreachable';
|
|
1988
|
+
const show = held > 0 || unreachable;
|
|
1951
1989
|
if (show === Boolean(faultBox.hidden)) {
|
|
1952
1990
|
// Visibility is changing, so the panels above have to give back or take
|
|
1953
1991
|
// back the rows. Assigning \`bottom\` is how blessed re-lays-out; it reads
|
|
@@ -1962,6 +2000,15 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1962
2000
|
// Grouped, never one line per dispatch. The realistic shape of this table
|
|
1963
2001
|
// is forty rows with ONE cause between them, and forty identical lines hide
|
|
1964
2002
|
// the single fact that matters.
|
|
2003
|
+
if (unreachable && held === 0) {
|
|
2004
|
+
faultBox.setContent(
|
|
2005
|
+
\`\\n {bold}Nothing on the internet is reaching this receiver.{/bold}\\n\` +
|
|
2006
|
+
\` {red-fg}\${esc(reach.state === 'unreachable' ? reach.detail : '')}{/}\\n\` +
|
|
2007
|
+
\` {\${DIM}-fg}Dispatches cannot arrive while this is true. Check your tunnel is still running.{/}\`,
|
|
2008
|
+
);
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
1965
2012
|
const lines = heldSummary().slice(0, 2).map((l) => \` {red-fg}\${esc(l)}{/}\`);
|
|
1966
2013
|
faultBox.setContent(
|
|
1967
2014
|
\`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open. \` +
|
|
@@ -1969,7 +2016,8 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1969
2016
|
lines.join('\\n') + '\\n' +
|
|
1970
2017
|
(replayNote
|
|
1971
2018
|
? \` {\${AMBER}-fg}\${esc(replayNote)}{/}\`
|
|
1972
|
-
: \` {\${DIM}-fg}Fix the cause, then{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply,{/} {\${AMBER}-fg}[e]{/} {\${DIM}-fg}to export
|
|
2019
|
+
: \` {\${DIM}-fg}Fix the cause, then{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply,{/} {\${AMBER}-fg}[e]{/} {\${DIM}-fg}to export,{/} \` +
|
|
2020
|
+
\`{\${AMBER}-fg}[d]{/} {\${DIM}-fg}to dismiss.{/}\`),
|
|
1973
2021
|
);
|
|
1974
2022
|
}
|
|
1975
2023
|
|
|
@@ -2003,7 +2051,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2003
2051
|
\`{red-fg}failed{/} {bold}\${failed}{/bold} \` +
|
|
2004
2052
|
awaitingYou +
|
|
2005
2053
|
\`{\${awaitingColour}-fg}awaiting confirm{/} {bold}\${awaiting}{/bold}\` +
|
|
2006
|
-
\`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [q] quit{/} \`,
|
|
2054
|
+
\`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [d] dismiss [q] quit{/} \`,
|
|
2007
2055
|
);
|
|
2008
2056
|
}
|
|
2009
2057
|
|
|
@@ -2071,10 +2119,71 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2071
2119
|
return;
|
|
2072
2120
|
}
|
|
2073
2121
|
try {
|
|
2074
|
-
|
|
2075
|
-
|
|
2122
|
+
// NO ARGUMENT. \`exportHeld\` resolves \`config.exportDir\`, then
|
|
2123
|
+
// \`ONEADDRESS_EXPORT_DIR\`, then the working directory, and returns an
|
|
2124
|
+
// ABSOLUTE path. Passing \`process.cwd()\` here is what made the setting
|
|
2125
|
+
// unreachable from the one key that uses it.
|
|
2126
|
+
const { path, count } = exportHeld();
|
|
2127
|
+
exportNote = \`Saved \${count} to \${path}\`;
|
|
2128
|
+
replayNote = '';
|
|
2129
|
+
} catch (err) {
|
|
2130
|
+
// NAMES THE DIRECTORY IT TRIED. "EACCES" on its own sends a partner to
|
|
2131
|
+
// search for a file that was never written.
|
|
2132
|
+
exportNote = \`Export FAILED writing to \${config.exportDir || process.cwd()}: \` +
|
|
2133
|
+
\`\${err instanceof Error ? err.message : String(err)}\`;
|
|
2134
|
+
replayNote = '';
|
|
2135
|
+
}
|
|
2136
|
+
redraw();
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
// [d] DISMISSES. TWO PRESSES, and the first one only asks.
|
|
2140
|
+
//
|
|
2141
|
+
// WHY THIS KEY EXISTS AT ALL. The conformance suite deliberately sends a
|
|
2142
|
+
// dispatch this receiver cannot open (check-11: a session key wrapped to a
|
|
2143
|
+
// throwaway key pair) and passes the receiver for HOLDING it. So passing
|
|
2144
|
+
// conformance leaves a red fault panel up permanently, on a receiver that is
|
|
2145
|
+
// working correctly, with nothing on screen that clears it. Reported from a
|
|
2146
|
+
// real run: "I do wish the conformance wouldn't come up as a fault."
|
|
2147
|
+
//
|
|
2148
|
+
// WHY THE RECEIVER DOES NOT JUST RECOGNISE THE PROBE. Everything that
|
|
2149
|
+
// identifies one is chosen by the sender. See the \`dismissed_at\` docstring in
|
|
2150
|
+
// \`quarantine.ts\`: suppressing a fault panel on a sender-supplied value hides
|
|
2151
|
+
// real held dispatches from the one person whose job is to notice them.
|
|
2152
|
+
//
|
|
2153
|
+
// IRREVERSIBLE, SO IT IS CONFIRMED. Dismissal discards the held payload, which
|
|
2154
|
+
// is the point (see \`dismissHeld\`) and also means a mistaken press cannot be
|
|
2155
|
+
// undone by pressing [r]. The count is named in the question rather than after
|
|
2156
|
+
// it, because "dismiss 47" and "dismiss 1" are different decisions.
|
|
2157
|
+
//
|
|
2158
|
+
// THE ARM REMEMBERS A COUNT, NOT A BOOLEAN, and that is the load-bearing part
|
|
2159
|
+
// rather than a nicety. A boolean armed at 14:02 is still armed at 16:30, so a
|
|
2160
|
+
// partner who armed it against one conformance probe and wandered off would
|
|
2161
|
+
// come back and dismiss whatever real backlog had arrived in between - with a
|
|
2162
|
+
// single keystroke, discarding the payloads. Confirming only the exact set
|
|
2163
|
+
// that was described makes the question and the action the same question.
|
|
2164
|
+
let dismissArmedFor: number | null = null;
|
|
2165
|
+
screen.key(['d'], () => {
|
|
2166
|
+
const held = heldCount();
|
|
2167
|
+
if (held === 0) {
|
|
2168
|
+
dismissArmedFor = null;
|
|
2169
|
+
replayNote = 'Nothing held, so nothing to dismiss.';
|
|
2170
|
+
redraw();
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
if (dismissArmedFor !== held) {
|
|
2174
|
+
const changed = dismissArmedFor !== null;
|
|
2175
|
+
dismissArmedFor = held;
|
|
2176
|
+
replayNote = (changed ? \`That changed: \${held} held now. \` : '') +
|
|
2177
|
+
\`Press [d] again to dismiss \${held}: not applied, payloads discarded, cannot be undone.\`;
|
|
2178
|
+
redraw();
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
dismissArmedFor = null;
|
|
2182
|
+
try {
|
|
2183
|
+
const n = dismissHeld();
|
|
2184
|
+
replayNote = \`Dismissed \${n}. Nothing left held.\`;
|
|
2076
2185
|
} catch (err) {
|
|
2077
|
-
replayNote = \`
|
|
2186
|
+
replayNote = \`Dismiss failed: \${err instanceof Error ? err.message : String(err)}\`;
|
|
2078
2187
|
}
|
|
2079
2188
|
redraw();
|
|
2080
2189
|
});
|
|
@@ -2141,6 +2250,10 @@ import { printBanner } from './brand.js';
|
|
|
2141
2250
|
// after the passphrase is resolved. \`config.js\` reads a JSON file and the
|
|
2142
2251
|
// environment and touches neither.
|
|
2143
2252
|
import { config } from './config.js';
|
|
2253
|
+
// Same rule as \`config.js\`: \`unlock.js\` deliberately does not import the
|
|
2254
|
+
// database, so it can be loaded statically here and still run before the keys
|
|
2255
|
+
// are derived. See its own header.
|
|
2256
|
+
import { ask, databaseIsLocked } from './unlock.js';
|
|
2144
2257
|
import { PassphraseRequiredError, WrongPassphraseError } from './vault.js';
|
|
2145
2258
|
|
|
2146
2259
|
/**
|
|
@@ -2163,122 +2276,6 @@ const headless =
|
|
|
2163
2276
|
process.env.ONEADDRESS_HEADLESS === '1' ||
|
|
2164
2277
|
!process.stdout.isTTY;
|
|
2165
2278
|
|
|
2166
|
-
/**
|
|
2167
|
-
* Has this database already been locked?
|
|
2168
|
-
*
|
|
2169
|
-
* Asked BEFORE the passphrase, and without one, so the prompt can say which of
|
|
2170
|
-
* two completely different things it is doing. \`db_meta.verifier\` is written the
|
|
2171
|
-
* first time a passphrase is set, so its presence is the whole answer.
|
|
2172
|
-
*
|
|
2173
|
-
* Read through its own connection rather than importing \`db.ts\`, which derives
|
|
2174
|
-
* its keys the moment it is imported and would therefore have to run BEFORE we
|
|
2175
|
-
* know what to ask for.
|
|
2176
|
-
*/
|
|
2177
|
-
async function databaseIsLocked(): Promise<boolean> {
|
|
2178
|
-
try {
|
|
2179
|
-
const { DatabaseSync } = await import('node:sqlite');
|
|
2180
|
-
const { join } = await import('node:path');
|
|
2181
|
-
const path = process.env.DB_PATH ?? join(process.cwd(), 'data.db');
|
|
2182
|
-
const db = new DatabaseSync(path, { readOnly: true });
|
|
2183
|
-
try {
|
|
2184
|
-
const row = db
|
|
2185
|
-
.prepare("SELECT value FROM db_meta WHERE key = 'verifier'")
|
|
2186
|
-
.get() as { value?: string } | undefined;
|
|
2187
|
-
return Boolean(row?.value);
|
|
2188
|
-
} finally {
|
|
2189
|
-
db.close();
|
|
2190
|
-
}
|
|
2191
|
-
} catch {
|
|
2192
|
-
// No file yet, or no db_meta table yet. Either way: not locked.
|
|
2193
|
-
return false;
|
|
2194
|
-
}
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
|
-
/**
|
|
2198
|
-
* Ask for a passphrase without echoing it to the screen.
|
|
2199
|
-
*
|
|
2200
|
-
* A passphrase typed in clear on a shared screen, in a screen-share, or into a
|
|
2201
|
-
* terminal that keeps scrollback is not much of a secret. readline echoes by
|
|
2202
|
-
* default, so its output hook is replaced for the duration of the question.
|
|
2203
|
-
*
|
|
2204
|
-
* Degrades to a visible prompt rather than failing: on a terminal where the
|
|
2205
|
-
* hook is not available, being asked in the clear beats not being asked.
|
|
2206
|
-
*/
|
|
2207
|
-
async function ask(prompt: string): Promise<string> {
|
|
2208
|
-
process.stdout.write(prompt);
|
|
2209
|
-
|
|
2210
|
-
// NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
|
|
2211
|
-
// mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
|
|
2212
|
-
// by driving a real terminal and reading what came back. The first filtered
|
|
2213
|
-
// on whether the chunk contained the prompt, not knowing readline repaints
|
|
2214
|
-
// prompt and input together on every keystroke, so the condition was always
|
|
2215
|
-
// true. The second repainted the line and still leaked, because the echo was
|
|
2216
|
-
// never coming from that hook at all.
|
|
2217
|
-
//
|
|
2218
|
-
// Reading the keys directly removes the guessing. Raw mode turns the
|
|
2219
|
-
// terminal's own echo OFF, so the ONLY thing that can reach the screen is
|
|
2220
|
-
// what is written below: one asterisk per character, which is what a partner
|
|
2221
|
-
// asked for and what every other passphrase prompt does.
|
|
2222
|
-
const stdin = process.stdin;
|
|
2223
|
-
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
2224
|
-
// No terminal to control. Being asked in the clear beats not being asked,
|
|
2225
|
-
// and this path is only reached where nothing is watching anyway.
|
|
2226
|
-
const { createInterface } = await import('node:readline/promises');
|
|
2227
|
-
const rl = createInterface({ input: stdin, output: process.stdout });
|
|
2228
|
-
try {
|
|
2229
|
-
const answer = await rl.question('');
|
|
2230
|
-
return answer.trim();
|
|
2231
|
-
} finally { rl.close(); }
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
|
-
const wasRaw = stdin.isRaw === true;
|
|
2235
|
-
stdin.setRawMode(true);
|
|
2236
|
-
stdin.resume();
|
|
2237
|
-
stdin.setEncoding('utf8');
|
|
2238
|
-
|
|
2239
|
-
return new Promise<string>((resolve) => {
|
|
2240
|
-
let typed = '';
|
|
2241
|
-
const restore = (): void => {
|
|
2242
|
-
stdin.removeListener('data', onData);
|
|
2243
|
-
stdin.setRawMode(wasRaw);
|
|
2244
|
-
stdin.pause();
|
|
2245
|
-
};
|
|
2246
|
-
const onData = (chunk: string): void => {
|
|
2247
|
-
for (const ch of chunk) {
|
|
2248
|
-
if (ch === '\\r' || ch === '\\n') {
|
|
2249
|
-
restore();
|
|
2250
|
-
process.stdout.write('\\n');
|
|
2251
|
-
resolve(typed.trim());
|
|
2252
|
-
return;
|
|
2253
|
-
}
|
|
2254
|
-
if (ch === '\\u0003') { // Ctrl+C
|
|
2255
|
-
restore();
|
|
2256
|
-
process.stdout.write('\\n');
|
|
2257
|
-
process.exit(130);
|
|
2258
|
-
}
|
|
2259
|
-
if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
|
|
2260
|
-
restore();
|
|
2261
|
-
process.stdout.write('\\n');
|
|
2262
|
-
resolve(typed.trim());
|
|
2263
|
-
return;
|
|
2264
|
-
}
|
|
2265
|
-
if (ch === '\\u007f' || ch === '\\b') {
|
|
2266
|
-
// Backspace has to move the asterisks too, or the mask stops matching
|
|
2267
|
-
// what is actually in the buffer and the count misleads.
|
|
2268
|
-
if (typed.length > 0) {
|
|
2269
|
-
typed = typed.slice(0, -1);
|
|
2270
|
-
process.stdout.write('\\b \\b');
|
|
2271
|
-
}
|
|
2272
|
-
continue;
|
|
2273
|
-
}
|
|
2274
|
-
if (ch < ' ') continue; // ignore the rest of the control range
|
|
2275
|
-
typed += ch;
|
|
2276
|
-
process.stdout.write('*');
|
|
2277
|
-
}
|
|
2278
|
-
};
|
|
2279
|
-
stdin.on('data', onData);
|
|
2280
|
-
});
|
|
2281
|
-
}
|
|
2282
2279
|
|
|
2283
2280
|
/**
|
|
2284
2281
|
* Where the at-rest passphrase comes from.
|
|
@@ -2332,9 +2329,14 @@ async function resolvePassphrase(): Promise<string | null> {
|
|
|
2332
2329
|
// credentials, so assuming exactly that is the natural reading. Reported as
|
|
2333
2330
|
// confusing, and the confusion was ours.
|
|
2334
2331
|
//
|
|
2335
|
-
// It therefore leads with the decision
|
|
2336
|
-
// INVENTED HERE
|
|
2337
|
-
//
|
|
2332
|
+
// It therefore leads with the decision and says plainly that the answer is
|
|
2333
|
+
// INVENTED HERE. It used to go on to name the three secrets it is NOT, which
|
|
2334
|
+
// was accurate and was four more lines of denial in a prompt that is already
|
|
2335
|
+
// the longest question in setup; reported as over-explained, and cut back to
|
|
2336
|
+
// the three facts an answer depends on (new, what it protects, unrecoverable).
|
|
2337
|
+
// The UNLOCK branch keeps its version of that list, because there the partner
|
|
2338
|
+
// is being asked for a password they may not remember choosing and naming the
|
|
2339
|
+
// wrong candidates is the whole help.
|
|
2338
2340
|
// NOT ASKED IN INBOX MODE, because the honest answer to "protect your customer
|
|
2339
2341
|
// records" is that there are none here: the connector holds them. The
|
|
2340
2342
|
// database in this mode carries dispatch ciphertext - already encrypted to a
|
|
@@ -2354,14 +2356,10 @@ async function resolvePassphrase(): Promise<string | null> {
|
|
|
2354
2356
|
}
|
|
2355
2357
|
|
|
2356
2358
|
process.stdout.write('\\n \u2500\u2500 Protect this receiver\\'s customer database? \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\\n\\n');
|
|
2357
|
-
process.stdout.write(' MAKE UP A NEW PASSWORD
|
|
2358
|
-
process.stdout.write('
|
|
2359
|
-
process.stdout.write('
|
|
2360
|
-
process.stdout.write('
|
|
2361
|
-
process.stdout.write(' It encrypts the customer records this receiver keeps in data.db.\\n');
|
|
2362
|
-
process.stdout.write(' OneAddress never sees it and cannot reset it, so if you lose it\\n');
|
|
2363
|
-
process.stdout.write(' those records cannot be read again.\\n\\n');
|
|
2364
|
-
process.stdout.write(' OPTIONAL. Press Enter to skip and leave the database readable.\\n');
|
|
2359
|
+
process.stdout.write(' MAKE UP A NEW PASSWORD. Invented here, not one you already\\n');
|
|
2360
|
+
process.stdout.write(' hold. It encrypts the customer records this receiver keeps in\\n');
|
|
2361
|
+
process.stdout.write(' data.db. OneAddress never sees it and cannot reset it.\\n\\n');
|
|
2362
|
+
process.stdout.write(' Optional. Press Enter to skip and leave the database readable.\\n');
|
|
2365
2363
|
// ACCURATE ABOUT "LATER", because the obvious reading is wrong. Setting a
|
|
2366
2364
|
// password afterwards works and the receiver reports itself encrypted, but
|
|
2367
2365
|
// rows written before it stay in the clear until something rewrites them -
|
|
@@ -2758,7 +2756,7 @@ import express, { type Request, type Response } from 'express';
|
|
|
2758
2756
|
import rateLimit from 'express-rate-limit';
|
|
2759
2757
|
import { timingSafeEqual } from 'node:crypto';
|
|
2760
2758
|
import { report } from './report.js';
|
|
2761
|
-
import { acknowledge, drawable, markDrawn, type InboxOutcome } from './inbox.js';
|
|
2759
|
+
import { acknowledge, drawable, hold, markDrawn, type InboxOutcome } from './inbox.js';
|
|
2762
2760
|
|
|
2763
2761
|
const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
|
|
2764
2762
|
/**
|
|
@@ -2851,18 +2849,41 @@ export function startDrawApi(): { port: number } | null {
|
|
|
2851
2849
|
* confirm, which is the honest answer when the partner's own system refused
|
|
2852
2850
|
* the change: the consumer is told it did not land rather than being told it
|
|
2853
2851
|
* did.
|
|
2852
|
+
*
|
|
2853
|
+
* \`held\` QUEUES NOTHING, and that is the whole point of it. It means the
|
|
2854
|
+
* connector could not OPEN the dispatch, which is a fact about a key rather
|
|
2855
|
+
* than about the consumer's record - and a \`failed\` confirm is read as the
|
|
2856
|
+
* latter all the way down: \`partner_failed\`, terminal, refund denied. The
|
|
2857
|
+
* item stays unacknowledged so the next draw retries it once the key is
|
|
2858
|
+
* fixed. See \`hold\` in inbox.ts for why nothing else recovers this.
|
|
2854
2859
|
*/
|
|
2855
2860
|
app.post('/ack', (req: Request, res: Response) => {
|
|
2856
2861
|
if (!authorised(req, res)) return;
|
|
2857
2862
|
const body = req.body as { id?: unknown; outcome?: unknown; detail?: unknown };
|
|
2858
2863
|
const id = typeof body.id === 'string' ? body.id : '';
|
|
2859
|
-
const outcome: InboxOutcome | null =
|
|
2860
|
-
body.outcome === 'applied' || body.outcome === 'failed'
|
|
2864
|
+
const outcome: InboxOutcome | 'held' | null =
|
|
2865
|
+
body.outcome === 'applied' || body.outcome === 'failed' || body.outcome === 'held'
|
|
2866
|
+
? body.outcome
|
|
2867
|
+
: null;
|
|
2861
2868
|
if (!id || !outcome) {
|
|
2862
|
-
return res.status(400).json({ error: 'id and outcome (applied|failed) are required' });
|
|
2869
|
+
return res.status(400).json({ error: 'id and outcome (applied|failed|held) are required' });
|
|
2863
2870
|
}
|
|
2864
2871
|
const detail = typeof body.detail === 'string' ? body.detail : null;
|
|
2865
2872
|
|
|
2873
|
+
// HELD RETURNS BEFORE \`acknowledge\`, so there is no path from here to a
|
|
2874
|
+
// confirm. Ordering rather than a flag: a held item that fell through to
|
|
2875
|
+
// the code below would be stamped terminal and reported to OneAddress as
|
|
2876
|
+
// the consumer's problem, which is the defect this exists to remove.
|
|
2877
|
+
if (outcome === 'held') {
|
|
2878
|
+
const heldResult = hold(id, detail, LEASE_SECONDS);
|
|
2879
|
+
if (!heldResult) return res.status(404).json({ error: 'unknown or already acknowledged id' });
|
|
2880
|
+
return res.json({
|
|
2881
|
+
ok: true, held: true,
|
|
2882
|
+
attempts: heldResult.attempts,
|
|
2883
|
+
retryInSeconds: heldResult.retryInSeconds,
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2866
2887
|
const result = acknowledge(id, outcome, detail);
|
|
2867
2888
|
if (!result) return res.status(404).json({ error: 'unknown id' });
|
|
2868
2889
|
if (result.alreadyAcknowledged) {
|
|
@@ -2962,9 +2983,16 @@ export function setAcknowledgementHandler(
|
|
|
2962
2983
|
* the claim has to become atomic, the way \`confirm-queue.ts\` already does it.
|
|
2963
2984
|
* Written down because a lease LOOKS like it handles concurrency and does not.
|
|
2964
2985
|
*/
|
|
2965
|
-
import db from './db.js';
|
|
2986
|
+
import db, { ensureColumn } from './db.js';
|
|
2966
2987
|
import { report } from './report.js';
|
|
2967
2988
|
|
|
2989
|
+
/**
|
|
2990
|
+
* The connector's TERMINAL verdicts. Both end the item and both tell OneAddress.
|
|
2991
|
+
*
|
|
2992
|
+
* \`held\` is deliberately NOT one of these, and lives in its own function. See
|
|
2993
|
+
* \`hold\` below for why a dispatch the connector could not OPEN must never
|
|
2994
|
+
* become one of these two.
|
|
2995
|
+
*/
|
|
2968
2996
|
export type InboxOutcome = 'applied' | 'failed';
|
|
2969
2997
|
|
|
2970
2998
|
db.exec(\`
|
|
@@ -2983,6 +3011,30 @@ db.exec(\`
|
|
|
2983
3011
|
ON inbox(applied_at, drawn_at, received_at);
|
|
2984
3012
|
\`);
|
|
2985
3013
|
|
|
3014
|
+
/**
|
|
3015
|
+
* Why a held item needs its own two columns, matching \`quarantine\`'s.
|
|
3016
|
+
*
|
|
3017
|
+
* A dispatch the connector cannot OPEN is not finished with, so it keeps
|
|
3018
|
+
* \`applied_at IS NULL\` and stays in the undrawn count. But an operator then
|
|
3019
|
+
* needs to know it is BLOCKED rather than merely queued, and by what: without
|
|
3020
|
+
* these the dashboard can say a number and nothing else, and the connector's
|
|
3021
|
+
* message - which names the exact environment variable to set - is only in a
|
|
3022
|
+
* log somebody has to still have.
|
|
3023
|
+
*
|
|
3024
|
+
* Same names as the quarantine's, because it is the same idea one process over
|
|
3025
|
+
* and a second vocabulary for it would be a second thing to learn.
|
|
3026
|
+
*/
|
|
3027
|
+
ensureColumn('inbox', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
|
|
3028
|
+
ensureColumn('inbox', 'last_error', 'TEXT');
|
|
3029
|
+
|
|
3030
|
+
/**
|
|
3031
|
+
* First retry for a held item, multiplied by the attempt count and capped at
|
|
3032
|
+
* the lease. Thirty seconds because the overwhelming case is a partner who has
|
|
3033
|
+
* just corrected a key and restarted the connector, and making them wait out a
|
|
3034
|
+
* lease built for crash recovery would be answering a question nobody asked.
|
|
3035
|
+
*/
|
|
3036
|
+
const HELD_RETRY_BASE_SECONDS = 30;
|
|
3037
|
+
|
|
2986
3038
|
export interface AcceptInput {
|
|
2987
3039
|
key: string;
|
|
2988
3040
|
dispatchId: string | null;
|
|
@@ -3064,6 +3116,110 @@ export function acknowledge(
|
|
|
3064
3116
|
return { dispatchId: row.dispatch_id, alreadyAcknowledged: false };
|
|
3065
3117
|
}
|
|
3066
3118
|
|
|
3119
|
+
/**
|
|
3120
|
+
* The connector could not OPEN this one, so it is not finished with.
|
|
3121
|
+
*
|
|
3122
|
+
* ## Why this is not \`failed\`
|
|
3123
|
+
*
|
|
3124
|
+
* \`failed\` is a statement about the CONSUMER'S RECORD - the partner's system
|
|
3125
|
+
* received the change and refused it - and OneAddress reads it exactly that
|
|
3126
|
+
* way. \`/api/confirm\` resolves every non-success to \`partner_failed\`, that row
|
|
3127
|
+
* is terminal (a later SUCCESSFUL confirm cannot overwrite it), and the refund
|
|
3128
|
+
* classifier maps it to \`no_match\`: "the partner received it and said no", so
|
|
3129
|
+
* the refund is DENIED and the consumer is emailed that it did not land.
|
|
3130
|
+
*
|
|
3131
|
+
* "I could not read it" is not that statement. It is a fact about the
|
|
3132
|
+
* partner's key configuration and has nothing to do with the consumer. Sending
|
|
3133
|
+
* it as \`failed\` charged a consumer five dollars, told them a provider had
|
|
3134
|
+
* rejected them, and refused them a refund, because a key was wrong in a
|
|
3135
|
+
* process they have never heard of.
|
|
3136
|
+
*
|
|
3137
|
+
* ## Why there is no retry to fall back on
|
|
3138
|
+
*
|
|
3139
|
+
* \`draw-loop.ts\` used to reason that "OneAddress's own retry of the dispatch is
|
|
3140
|
+
* the recovery path". That is true in write-through, where an unopenable
|
|
3141
|
+
* dispatch answers 422 and the dispatch row stays \`failed\` for the retry cron
|
|
3142
|
+
* to pick up. It is FALSE here, for the reason this mode exists: the receiver
|
|
3143
|
+
* already answered 200 at arrival, so the dispatch row is \`delivered\`, and
|
|
3144
|
+
* \`retry-webhooks\` only ever re-sends rows that are \`pending\` or \`failed\`.
|
|
3145
|
+
* Nothing retries. The confirm was the only remaining signal and it was being
|
|
3146
|
+
* spent on a verdict that was not true.
|
|
3147
|
+
*
|
|
3148
|
+
* ## What happens instead
|
|
3149
|
+
*
|
|
3150
|
+
* The item stays unacknowledged, so it stays in the undrawn count, which the
|
|
3151
|
+
* dashboard already reports as a fault - and that is the behaviour recorded in
|
|
3152
|
+
* the new-matter register for this mode: an undrawn update is held INDEFINITELY
|
|
3153
|
+
* and reported as a fault, deliberately unlike the quarantine's 30-day purge,
|
|
3154
|
+
* because an undrawn update is a consumer whose address did not land and ageing
|
|
3155
|
+
* it out loses it silently.
|
|
3156
|
+
*
|
|
3157
|
+
* Re-stamping \`drawn_at\` is the backoff and costs no new mechanism: \`drawable\`
|
|
3158
|
+
* re-offers an item once its stamp is older than the lease, so the next attempt
|
|
3159
|
+
* is one lease away and the poll loop cannot spin on a key that is still wrong.
|
|
3160
|
+
* When the key is fixed the next draw applies it, with no operator command -
|
|
3161
|
+
* which is the half that \`[r]\` has to be pressed for in write-through.
|
|
3162
|
+
*
|
|
3163
|
+
* Returns null for an unknown id or one already terminally acknowledged, the
|
|
3164
|
+
* same tolerance \`acknowledge\` has and for the same reason.
|
|
3165
|
+
*/
|
|
3166
|
+
export function hold(
|
|
3167
|
+
id: string,
|
|
3168
|
+
detail: string | null,
|
|
3169
|
+
leaseSeconds: number,
|
|
3170
|
+
): { attempts: number; retryInSeconds: number } | null {
|
|
3171
|
+
const row = db.prepare(
|
|
3172
|
+
'SELECT applied_at, attempts FROM inbox WHERE id = ?',
|
|
3173
|
+
).get(id) as { applied_at: string | null; attempts: number } | undefined;
|
|
3174
|
+
if (!row) return null;
|
|
3175
|
+
if (row.applied_at !== null) return null;
|
|
3176
|
+
|
|
3177
|
+
const attempts = (row.attempts ?? 0) + 1;
|
|
3178
|
+
// \u2500\u2500 THE BACKOFF IS NOT THE LEASE, AND THAT IS THE POINT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3179
|
+
//
|
|
3180
|
+
// The lease is 300s and is generous ON PURPOSE: re-offering a DRAWN item too
|
|
3181
|
+
// early risks applying the same update twice. A held item carries none of
|
|
3182
|
+
// that risk, because nothing was applied - it could not even be opened. So
|
|
3183
|
+
// reusing the lease would make a partner who has just fixed their key wait
|
|
3184
|
+
// five minutes for a safety property that is not in play.
|
|
3185
|
+
//
|
|
3186
|
+
// \`drawable\` re-offers a row once \`drawn_at < now - lease\`, so a stamp of
|
|
3187
|
+
// \`now - lease + backoff\` becomes drawable in exactly \`backoff\` seconds.
|
|
3188
|
+
// That is the whole mechanism: no column, no second query, no timer. Pinned
|
|
3189
|
+
// by a test that checks it is NOT drawable before the backoff and IS after,
|
|
3190
|
+
// because arithmetic written backwards still produces a plausible timestamp.
|
|
3191
|
+
//
|
|
3192
|
+
// Capped at the lease so a repeatedly-failing item can never retry FASTER
|
|
3193
|
+
// than a crashed connector's item, which would be the wrong way round.
|
|
3194
|
+
const retryInSeconds = Math.min(HELD_RETRY_BASE_SECONDS * attempts, leaseSeconds);
|
|
3195
|
+
const stamp = new Date(Date.now() - leaseSeconds * 1000 + retryInSeconds * 1000).toISOString();
|
|
3196
|
+
db.prepare(
|
|
3197
|
+
'UPDATE inbox SET attempts = ?, last_error = ?, drawn_at = ? WHERE id = ?',
|
|
3198
|
+
).run(attempts, detail?.slice(0, 500) ?? null, stamp, id);
|
|
3199
|
+
report.warn(
|
|
3200
|
+
\`[inbox] \${id} HELD after \${attempts} attempt(s), retrying in \${retryInSeconds}s, not failed: \` +
|
|
3201
|
+
\`\${detail ?? 'the connector could not open it'}\`,
|
|
3202
|
+
);
|
|
3203
|
+
return { attempts, retryInSeconds };
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
/**
|
|
3207
|
+
* The held items, for the dashboard's faults band.
|
|
3208
|
+
*
|
|
3209
|
+
* Separate from \`undrawnCount\` because the two mean different things to an
|
|
3210
|
+
* operator: undrawn is "your connector has not got to it yet", held is "your
|
|
3211
|
+
* connector tried and cannot". Only the second one needs somebody to do
|
|
3212
|
+
* something, and it is the one carrying the message that says what.
|
|
3213
|
+
*/
|
|
3214
|
+
export function heldItems(): { id: string; attempts: number; lastError: string | null }[] {
|
|
3215
|
+
return db.prepare(
|
|
3216
|
+
\`SELECT id, attempts, last_error AS lastError
|
|
3217
|
+
FROM inbox
|
|
3218
|
+
WHERE applied_at IS NULL AND attempts > 0
|
|
3219
|
+
ORDER BY received_at\`,
|
|
3220
|
+
).all() as unknown as { id: string; attempts: number; lastError: string | null }[];
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3067
3223
|
/** How many updates are sitting here unapplied. Shown as a fault when non-zero. */
|
|
3068
3224
|
export function undrawnCount(): number {
|
|
3069
3225
|
const row = db.prepare(
|
|
@@ -3238,8 +3394,9 @@ export function resetTally(): void {
|
|
|
3238
3394
|
import db, { ensureColumn, parseStoredTime } from './db.js';
|
|
3239
3395
|
import { report } from './report.js';
|
|
3240
3396
|
import { createHash } from 'node:crypto';
|
|
3241
|
-
import { writeFileSync } from 'node:fs';
|
|
3242
|
-
import { join } from 'node:path';
|
|
3397
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3398
|
+
import { join, resolve } from 'node:path';
|
|
3399
|
+
import { config } from './config.js';
|
|
3243
3400
|
|
|
3244
3401
|
/**
|
|
3245
3402
|
* Why a dispatch could not be applied. Shown verbatim on the dashboard.
|
|
@@ -3297,6 +3454,75 @@ export function dispatchKey(dispatchId: string | null, rawBody: string): string
|
|
|
3297
3454
|
*/
|
|
3298
3455
|
ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
|
|
3299
3456
|
|
|
3457
|
+
/**
|
|
3458
|
+
* When the operator decided this one is not going to be applied.
|
|
3459
|
+
*
|
|
3460
|
+
* ## Why a dashboard needs this at all
|
|
3461
|
+
*
|
|
3462
|
+
* The conformance suite sends a dispatch that CANNOT be opened, deliberately:
|
|
3463
|
+
* check-11 wraps a session key to a throwaway key pair and passes only if the
|
|
3464
|
+
* receiver reports the failure instead of answering \`200 { ok: true }\`. Holding
|
|
3465
|
+
* it is the correct behaviour and is how the check is passed. The receiver then
|
|
3466
|
+
* draws a red FAULTS panel about it for the rest of the database's life, and a
|
|
3467
|
+
* partner who has just been told they passed is looking at a permanent fault
|
|
3468
|
+
* they cannot clear. Reported from a real run.
|
|
3469
|
+
*
|
|
3470
|
+
* ## Why the receiver does NOT recognise the probe itself
|
|
3471
|
+
*
|
|
3472
|
+
* The tempting fix is to spot the probe and not count it. Everything that
|
|
3473
|
+
* identifies one comes from the SENDER: the dispatch id is a header, and the
|
|
3474
|
+
* ciphertext is opaque by construction. Suppressing an operator's fault panel on
|
|
3475
|
+
* a value the sender chose is a way to hide a real held dispatch from the person
|
|
3476
|
+
* whose job is to notice it, and it would be reachable by anyone holding the
|
|
3477
|
+
* webhook secret. The signal stays; the OPERATOR gets a way to answer it.
|
|
3478
|
+
*
|
|
3479
|
+
* ## Why the payload goes with it
|
|
3480
|
+
*
|
|
3481
|
+
* Dismissing says "I am not applying this". A held payload is a consumer's
|
|
3482
|
+
* encrypted address sitting on a third party's disk, kept only because applying
|
|
3483
|
+
* it later is still on the table. Once it is not, the reason to keep it is gone,
|
|
3484
|
+
* so \`raw_body\` is emptied in the same statement. That also makes dismissal
|
|
3485
|
+
* mean what it says: \`[r]\` cannot quietly bring it back.
|
|
3486
|
+
*/
|
|
3487
|
+
ensureColumn('quarantine', 'dismissed_at', 'TEXT');
|
|
3488
|
+
|
|
3489
|
+
/**
|
|
3490
|
+
* Was this a conformance DRILL rather than a real dispatch?
|
|
3491
|
+
*
|
|
3492
|
+
* ## The problem it solves
|
|
3493
|
+
*
|
|
3494
|
+
* Conformance check 11 sends a dispatch this receiver CANNOT open, on purpose,
|
|
3495
|
+
* and passes it for refusing one. So a receiver that quarantines ends a
|
|
3496
|
+
* SUCCESSFUL run with a red fault panel, a \`failed\` count that never returns to
|
|
3497
|
+
* zero, and a \`decrypt_failed\` line naming the partner's real \`key_id\` - telling
|
|
3498
|
+
* them to fix the one thing that is not broken. Reported from a real run.
|
|
3499
|
+
*
|
|
3500
|
+
* ## Why this is trusted and the dispatch id is not
|
|
3501
|
+
*
|
|
3502
|
+
* The flag comes from \`conformance: true\` in the body, and the HMAC covers
|
|
3503
|
+
* \`\${timestamp}.\${rawBody}\`. The body is signed; the headers are not. The
|
|
3504
|
+
* conformance dispatch also carries a recognisable \`X-OneAddress-Dispatch\`
|
|
3505
|
+
* prefix, and keying off THAT would be a mistake: it sits outside the signature,
|
|
3506
|
+
* so anything on the path could set it and change how an operator's fault panel
|
|
3507
|
+
* behaves. Same reasoning as \`crossover_key_ids\`, which is already a signed-body
|
|
3508
|
+
* field that tells a receiver how to treat a payload it cannot decrypt.
|
|
3509
|
+
*
|
|
3510
|
+
* ## Classified, NOT suppressed
|
|
3511
|
+
*
|
|
3512
|
+
* A drill is still recorded, still counted, still visible on the dashboard. It
|
|
3513
|
+
* is drawn calmly instead of as an alarm. Suppression is what creates a silent
|
|
3514
|
+
* failure; classification does not, and if the marker is ever wrong the operator
|
|
3515
|
+
* still sees the event, in the wrong colour rather than not at all.
|
|
3516
|
+
*
|
|
3517
|
+
* ## A drill holds NO payload
|
|
3518
|
+
*
|
|
3519
|
+
* \`raw_body\` is empty for a drill, which is a property rather than a saving.
|
|
3520
|
+
* The probe is sealed to a throwaway key pair the conformance run generated and
|
|
3521
|
+
* discarded, so no key that will ever exist can open it: replay cannot succeed,
|
|
3522
|
+
* and keeping the bytes would put an unopenable blob in the replay set forever.
|
|
3523
|
+
*/
|
|
3524
|
+
ensureColumn('quarantine', 'drill', 'INTEGER NOT NULL DEFAULT 0');
|
|
3525
|
+
|
|
3300
3526
|
export interface QuarantineInput {
|
|
3301
3527
|
dispatchId: string | null;
|
|
3302
3528
|
event: string;
|
|
@@ -3304,6 +3530,10 @@ export interface QuarantineInput {
|
|
|
3304
3530
|
keyId: string | null;
|
|
3305
3531
|
rawBody: string;
|
|
3306
3532
|
detail: string;
|
|
3533
|
+
/** A conformance probe rather than a consumer's dispatch. See the
|
|
3534
|
+
* \`drill\` column's docstring. Defaults to false, so a caller that does
|
|
3535
|
+
* not know about drills still records a real fault. */
|
|
3536
|
+
drill?: boolean;
|
|
3307
3537
|
}
|
|
3308
3538
|
|
|
3309
3539
|
/**
|
|
@@ -3315,27 +3545,44 @@ export interface QuarantineInput {
|
|
|
3315
3545
|
*/
|
|
3316
3546
|
export function quarantine(input: QuarantineInput): void {
|
|
3317
3547
|
const id = dispatchKey(input.dispatchId, input.rawBody);
|
|
3548
|
+
const drill = input.drill === true;
|
|
3318
3549
|
try {
|
|
3319
3550
|
db.prepare(
|
|
3320
|
-
\`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
|
|
3321
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3551
|
+
\`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail, drill)
|
|
3552
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3322
3553
|
ON CONFLICT(id) DO UPDATE SET
|
|
3323
3554
|
reason = excluded.reason,
|
|
3324
3555
|
key_id = excluded.key_id,
|
|
3325
|
-
detail = excluded.detail
|
|
3556
|
+
detail = excluded.detail,
|
|
3557
|
+
drill = excluded.drill\`,
|
|
3326
3558
|
).run(
|
|
3327
3559
|
id,
|
|
3328
3560
|
input.dispatchId,
|
|
3329
3561
|
input.event,
|
|
3330
3562
|
input.reason,
|
|
3331
3563
|
input.keyId,
|
|
3332
|
-
|
|
3564
|
+
// NO PAYLOAD FOR A DRILL. The probe is sealed to a throwaway key pair the
|
|
3565
|
+
// conformance run generated and discarded, so nothing that will ever exist
|
|
3566
|
+
// can open it. Keeping the bytes would put a permanently unopenable blob
|
|
3567
|
+
// in the replay set.
|
|
3568
|
+
drill ? '' : input.rawBody,
|
|
3333
3569
|
input.detail.slice(0, 500),
|
|
3570
|
+
drill ? 1 : 0,
|
|
3334
3571
|
);
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3572
|
+
if (drill) {
|
|
3573
|
+
// INFO, NOT WARN, and it says what happened rather than what to fix. The
|
|
3574
|
+
// old line ended a PASSING conformance run by telling the partner to go
|
|
3575
|
+
// and correct a key that is perfectly correct.
|
|
3576
|
+
report.info(
|
|
3577
|
+
\`[quarantine] \${input.event} was a conformance probe and was refused, which is how that \` +
|
|
3578
|
+
'check is passed. Nothing is wrong with your key.',
|
|
3579
|
+
);
|
|
3580
|
+
} else {
|
|
3581
|
+
report.warn(
|
|
3582
|
+
\`[quarantine] \${input.event} held (\${input.reason}\${input.keyId ? \`, key_id \${input.keyId}\` : ''}). \` +
|
|
3583
|
+
'Fix the cause and press [r] on the dashboard, or run \`npm run replay\`, to apply it.',
|
|
3584
|
+
);
|
|
3585
|
+
}
|
|
3339
3586
|
} catch (err) {
|
|
3340
3587
|
report.error('[quarantine] could not hold this dispatch:', err instanceof Error ? err.message : err);
|
|
3341
3588
|
}
|
|
@@ -3360,16 +3607,30 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
|
|
|
3360
3607
|
\`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
|
|
3361
3608
|
received_at, last_error, attempts
|
|
3362
3609
|
FROM quarantine
|
|
3363
|
-
WHERE replayed_at IS NULL
|
|
3610
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0
|
|
3364
3611
|
ORDER BY received_at
|
|
3365
3612
|
LIMIT ?\`,
|
|
3366
3613
|
).all(limit) as unknown as HeldDispatch[];
|
|
3367
3614
|
}
|
|
3368
3615
|
|
|
3616
|
+
/**
|
|
3617
|
+
* How many conformance probes this receiver has refused.
|
|
3618
|
+
*
|
|
3619
|
+
* Its own figure rather than a slice of \`heldCount\`, because the two answer
|
|
3620
|
+
* different questions. Held means "waiting on you". A drill means "a check ran
|
|
3621
|
+
* and this receiver behaved correctly", which is not a queue and not work.
|
|
3622
|
+
*/
|
|
3623
|
+
export function drillCount(): number {
|
|
3624
|
+
const row = db.prepare(
|
|
3625
|
+
'SELECT count(*) AS n FROM quarantine WHERE drill = 1 AND dismissed_at IS NULL',
|
|
3626
|
+
).get() as { n: number };
|
|
3627
|
+
return row.n;
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3369
3630
|
/** How many dispatches are held. Shown on the dashboard. */
|
|
3370
3631
|
export function heldCount(): number {
|
|
3371
3632
|
const row = db.prepare(
|
|
3372
|
-
'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL',
|
|
3633
|
+
'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0',
|
|
3373
3634
|
).get() as { n: number };
|
|
3374
3635
|
return row.n;
|
|
3375
3636
|
}
|
|
@@ -3386,7 +3647,7 @@ export function heldSummary(): string[] {
|
|
|
3386
3647
|
\`SELECT reason, key_id, count(*) AS n,
|
|
3387
3648
|
max(attempts) AS tries, min(received_at) AS oldest
|
|
3388
3649
|
FROM quarantine
|
|
3389
|
-
WHERE replayed_at IS NULL
|
|
3650
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0
|
|
3390
3651
|
GROUP BY reason, key_id
|
|
3391
3652
|
ORDER BY n DESC\`,
|
|
3392
3653
|
).all() as unknown as {
|
|
@@ -3430,6 +3691,38 @@ export function markReplayFailed(id: string, error: string): void {
|
|
|
3430
3691
|
.run(error.slice(0, 500), id);
|
|
3431
3692
|
}
|
|
3432
3693
|
|
|
3694
|
+
/**
|
|
3695
|
+
* The operator's answer to the fault panel: not applying these, stop counting them.
|
|
3696
|
+
*
|
|
3697
|
+
* Takes everything currently held rather than one row, because the panel groups
|
|
3698
|
+
* by cause and offers no way to point at a single dispatch. The realistic use is
|
|
3699
|
+
* one decision about one cause, which is the shape the panel already shows.
|
|
3700
|
+
*
|
|
3701
|
+
* \`raw_body\` is emptied in the same statement. See the \`dismissed_at\` docstring
|
|
3702
|
+
* for why: once the operator has said it will not be applied, a consumer's
|
|
3703
|
+
* encrypted address is being kept for no reason, and a dismissal that leaves the
|
|
3704
|
+
* payload behind is one \`[r]\` away from not being a dismissal.
|
|
3705
|
+
*
|
|
3706
|
+
* The row itself STAYS. It is the record that a dispatch arrived and was
|
|
3707
|
+
* consciously dropped, which is the fact an audit wants, and \`purgeQuarantine\`
|
|
3708
|
+
* ages it out on the same window as everything else.
|
|
3709
|
+
*/
|
|
3710
|
+
export function dismissHeld(): number {
|
|
3711
|
+
const info = db.prepare(
|
|
3712
|
+
\`UPDATE quarantine
|
|
3713
|
+
SET dismissed_at = ?, raw_body = ''
|
|
3714
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0\`,
|
|
3715
|
+
).run(new Date().toISOString());
|
|
3716
|
+
const n = Number(info.changes ?? 0);
|
|
3717
|
+
if (n > 0) {
|
|
3718
|
+
report.warn(
|
|
3719
|
+
\`[quarantine] dismissed \${n} held dispatch(es). They will NOT be applied and their \` +
|
|
3720
|
+
'payloads have been discarded. OneAddress was already told each one failed.',
|
|
3721
|
+
);
|
|
3722
|
+
}
|
|
3723
|
+
return n;
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3433
3726
|
/**
|
|
3434
3727
|
* Drop held payloads past the retention window, replayed or not.
|
|
3435
3728
|
*
|
|
@@ -3498,10 +3791,25 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
|
|
|
3498
3791
|
* what diagnoses a key problem, and it is safe to paste into a support ticket
|
|
3499
3792
|
* or send to us, which is what an export is for.
|
|
3500
3793
|
*/
|
|
3501
|
-
export function exportHeld(directory
|
|
3794
|
+
export function exportHeld(directory?: string): { path: string; count: number } {
|
|
3502
3795
|
const rows = heldDispatches(500);
|
|
3503
3796
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3504
|
-
|
|
3797
|
+
// RESOLVED TO AN ABSOLUTE PATH, ALWAYS, and that is the point of the change.
|
|
3798
|
+
// "Exported 3 to oneaddress-faults-\u2026.json" is not an answer to "where is it";
|
|
3799
|
+
// a receiver is routinely started by a shortcut or a service manager whose
|
|
3800
|
+
// working directory is not where the partner thinks it is. Reported after a
|
|
3801
|
+
// real run: the file was written and could not be found.
|
|
3802
|
+
//
|
|
3803
|
+
// The explicit argument still wins, so a caller (and every existing test) can
|
|
3804
|
+
// name a directory; \`config.exportDir\` is the persistent choice and
|
|
3805
|
+
// \`ONEADDRESS_EXPORT_DIR\` the one-run override. Empty resolves to the working
|
|
3806
|
+
// directory, which is what this always did.
|
|
3807
|
+
const target = resolve(directory ?? config.exportDir ?? '');
|
|
3808
|
+
// Created rather than failed on. A partner who set \`exportDir\` to a folder
|
|
3809
|
+
// they have not made yet wants the file, not a lecture, and a directory they
|
|
3810
|
+
// named is one they have already consented to.
|
|
3811
|
+
mkdirSync(target, { recursive: true });
|
|
3812
|
+
const path = join(target, \`oneaddress-faults-\${stamp}.json\`);
|
|
3505
3813
|
writeFileSync(path, JSON.stringify({
|
|
3506
3814
|
exported_at: new Date().toISOString(),
|
|
3507
3815
|
note: 'Metadata only. The held payloads are deliberately not included: they are encrypted consumer addresses and stay under the receiver retention window.',
|
|
@@ -3518,7 +3826,11 @@ export function exportHeld(directory: string): { path: string; count: number } {
|
|
|
3518
3826
|
last_error: r.last_error,
|
|
3519
3827
|
})),
|
|
3520
3828
|
}, null, 2));
|
|
3521
|
-
|
|
3829
|
+
// THE PATH IS THE MESSAGE. Logged as well as shown on the dashboard, because
|
|
3830
|
+
// the dashboard line is gone the next time anything is pressed while the log
|
|
3831
|
+
// survives a scrollback, a \`npm start | tee\`, and a headless run where there
|
|
3832
|
+
// is no dashboard at all.
|
|
3833
|
+
report.info(\`[quarantine] exported \${rows.length} held dispatch(es) to \${path} - safe to send to support, no payloads inside\`);
|
|
3522
3834
|
return { path, count: rows.length };
|
|
3523
3835
|
}
|
|
3524
3836
|
|
|
@@ -3915,6 +4227,27 @@ export type ReceiverConfig = {
|
|
|
3915
4227
|
oneAddressApi: string;
|
|
3916
4228
|
verifiesAccountReference: boolean;
|
|
3917
4229
|
mode: ReceiverMode;
|
|
4230
|
+
/**
|
|
4231
|
+
* Where \`[e]\` writes the fault export.
|
|
4232
|
+
*
|
|
4233
|
+
* Empty means the working directory, which is what it always did. Named
|
|
4234
|
+
* because "it saved a file" is useless if you cannot find it: the receiver is
|
|
4235
|
+
* routinely started by a double-clicked shortcut or a service manager whose
|
|
4236
|
+
* working directory is not where the partner thinks it is.
|
|
4237
|
+
*
|
|
4238
|
+
* \`ONEADDRESS_EXPORT_DIR\` overrides it for one run without editing the file.
|
|
4239
|
+
*/
|
|
4240
|
+
exportDir: string;
|
|
4241
|
+
/**
|
|
4242
|
+
* The webhook URL registered with OneAddress: where dispatches actually
|
|
4243
|
+
* arrive from the internet.
|
|
4244
|
+
*
|
|
4245
|
+
* Written by the wizard, which is the only thing that knows it - the receiver
|
|
4246
|
+
* binds a local port and has no idea what hostname reaches it. Empty disables
|
|
4247
|
+
* the reachability check rather than guessing, because a wrong URL here would
|
|
4248
|
+
* report a healthy receiver as unreachable, which is worse than silence.
|
|
4249
|
+
*/
|
|
4250
|
+
publicUrl: string;
|
|
3918
4251
|
};
|
|
3919
4252
|
|
|
3920
4253
|
const DEFAULTS: ReceiverConfig = {
|
|
@@ -3929,6 +4262,11 @@ const DEFAULTS: ReceiverConfig = {
|
|
|
3929
4262
|
// running and a key living somewhere else; a receiver that silently switched
|
|
3930
4263
|
// into it would accept dispatches nothing ever collects.
|
|
3931
4264
|
mode: 'write-through',
|
|
4265
|
+
// No guess. See the field's docstring.
|
|
4266
|
+
publicUrl: '',
|
|
4267
|
+
// Empty resolves to the working directory at use, not here, so a config file
|
|
4268
|
+
// written on one machine does not pin an absolute path from another.
|
|
4269
|
+
exportDir: '',
|
|
3932
4270
|
};
|
|
3933
4271
|
|
|
3934
4272
|
function parseMode(value: unknown): ReceiverMode | undefined {
|
|
@@ -3947,6 +4285,8 @@ function loadConfigFile(): Partial<ReceiverConfig> {
|
|
|
3947
4285
|
if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
|
|
3948
4286
|
if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
|
|
3949
4287
|
if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
|
|
4288
|
+
if (typeof parsed.publicUrl === 'string') out.publicUrl = parsed.publicUrl;
|
|
4289
|
+
if (typeof parsed.exportDir === 'string') out.exportDir = parsed.exportDir;
|
|
3950
4290
|
const mode = parseMode(parsed.mode);
|
|
3951
4291
|
if (mode) out.mode = mode;
|
|
3952
4292
|
return out;
|
|
@@ -3970,6 +4310,8 @@ export const config: ReceiverConfig = {
|
|
|
3970
4310
|
// up as "not the mode I asked for" rather than as a receiver that will not
|
|
3971
4311
|
// start. Silent is the thing to avoid, not strict.
|
|
3972
4312
|
mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
|
|
4313
|
+
publicUrl: stripTrailingSlash(process.env.PUBLIC_URL || fromFile.publicUrl || DEFAULTS.publicUrl),
|
|
4314
|
+
exportDir: process.env.ONEADDRESS_EXPORT_DIR || fromFile.exportDir || DEFAULTS.exportDir,
|
|
3973
4315
|
};
|
|
3974
4316
|
|
|
3975
4317
|
report.info(
|
|
@@ -3977,6 +4319,188 @@ report.info(
|
|
|
3977
4319
|
', oneAddressApi=' + config.oneAddressApi +
|
|
3978
4320
|
', verifiesAccountReference=' + config.verifiesAccountReference + ')',
|
|
3979
4321
|
);
|
|
4322
|
+
`
|
|
4323
|
+
},
|
|
4324
|
+
{
|
|
4325
|
+
name: "src/reachable.ts",
|
|
4326
|
+
content: `/**
|
|
4327
|
+
* IS ANYTHING ON THE INTERNET STILL REACHING THIS RECEIVER?
|
|
4328
|
+
*
|
|
4329
|
+
* ## The problem, and why the receiver cannot simply notice
|
|
4330
|
+
*
|
|
4331
|
+
* A partner running behind a tunnel stops the tunnel - closes the laptop, ends
|
|
4332
|
+
* the terminal, lets the trial lapse - and the receiver notices nothing. That is
|
|
4333
|
+
* not an oversight: the tunnel DIALS IN to this process. Nothing here holds a
|
|
4334
|
+
* connection outward, so from inside, "the tunnel is down" and "nobody sent me
|
|
4335
|
+
* anything" are the same observation, which is silence. And silence is not a
|
|
4336
|
+
* fault: a small partner legitimately goes days between dispatches, so a
|
|
4337
|
+
* watchdog that fired on quiet would cry wolf at exactly the partners least able
|
|
4338
|
+
* to tell the difference.
|
|
4339
|
+
*
|
|
4340
|
+
* Meanwhile OneAddress is failing to deliver, marking the endpoint unhealthy,
|
|
4341
|
+
* and the only place that says so is a portal nobody has open.
|
|
4342
|
+
*
|
|
4343
|
+
* ## How this answers it
|
|
4344
|
+
*
|
|
4345
|
+
* The receiver fetches ITS OWN PUBLIC URL and checks that a nonce minted at
|
|
4346
|
+
* startup comes back. Only this running process knows that value, which is what
|
|
4347
|
+
* makes the answer unambiguous.
|
|
4348
|
+
*
|
|
4349
|
+
* "DID ANYTHING ANSWER" IS NOT GOOD ENOUGH, and that is the whole reason for
|
|
4350
|
+
* the nonce. A stopped Cloudflare tunnel does not refuse the connection: the
|
|
4351
|
+
* edge is still there and serves an error page (1033, or a 502). That is a
|
|
4352
|
+
* perfectly valid HTTP response, so a check written \`if (res.ok)\` - or even
|
|
4353
|
+
* \`if (res.status < 500)\` - reports a dead tunnel as healthy. A check that
|
|
4354
|
+
* demands the nonce cannot be satisfied by anything except this process.
|
|
4355
|
+
*
|
|
4356
|
+
* ## What it deliberately does not do
|
|
4357
|
+
*
|
|
4358
|
+
* It does not fail the receiver, retry aggressively, or call OneAddress. It is
|
|
4359
|
+
* a statement on the dashboard and a log line. A receiver that shut itself down
|
|
4360
|
+
* because it could not see itself would turn a DNS blip into an outage, and the
|
|
4361
|
+
* failure it is reporting is one only a human can fix.
|
|
4362
|
+
*
|
|
4363
|
+
* It is also not a security control. The nonce proves identity of the process,
|
|
4364
|
+
* not authorisation: the endpoint returns it to anyone who asks. It is a random
|
|
4365
|
+
* value with no meaning outside this check, and knowing it grants nothing - the
|
|
4366
|
+
* webhook still requires a valid HMAC.
|
|
4367
|
+
*/
|
|
4368
|
+
import { randomBytes } from 'node:crypto';
|
|
4369
|
+
import { config } from './config.js';
|
|
4370
|
+
import { report } from './report.js';
|
|
4371
|
+
|
|
4372
|
+
/** Minted once per process. See the header: this is what makes the answer
|
|
4373
|
+
* unambiguous, and it is deliberately not a secret. */
|
|
4374
|
+
export const ALIVE_NONCE = randomBytes(16).toString('hex');
|
|
4375
|
+
|
|
4376
|
+
/** The path the check hits. Deliberately NOT \`/webhook\`: that is POST-only and
|
|
4377
|
+
* would 404 or 405 through a perfectly healthy tunnel. */
|
|
4378
|
+
export const ALIVE_PATH = '/oa-receiver-alive';
|
|
4379
|
+
|
|
4380
|
+
export type Reachability =
|
|
4381
|
+
| { state: 'unknown'; detail: string }
|
|
4382
|
+
| { state: 'reachable'; checkedAt: string }
|
|
4383
|
+
| { state: 'unreachable'; detail: string; since: string };
|
|
4384
|
+
|
|
4385
|
+
let current: Reachability = { state: 'unknown', detail: 'not checked yet' };
|
|
4386
|
+
|
|
4387
|
+
/** What the dashboard renders. Never throws. */
|
|
4388
|
+
export function reachability(): Reachability {
|
|
4389
|
+
return current;
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4392
|
+
/**
|
|
4393
|
+
* The ORIGIN to probe, or null when there is nothing usable to probe.
|
|
4394
|
+
*
|
|
4395
|
+
* TWO THINGS THIS FIXES, both found by reading the value the wizard actually
|
|
4396
|
+
* writes rather than the one this function wanted.
|
|
4397
|
+
*
|
|
4398
|
+
* 1. \`publicUrl\` is the REGISTERED WEBHOOK URL, so it ends in \`/webhook\`.
|
|
4399
|
+
* Appending the probe path to it gives \`\u2026/webhook/oa-receiver-alive\`, which
|
|
4400
|
+
* 404s through a perfectly healthy tunnel and would report every working
|
|
4401
|
+
* receiver as unreachable. The origin is what is wanted.
|
|
4402
|
+
* 2. The scaffold substitutes a PLACEHOLDER (\`<your-webhook-url>\`) when setup
|
|
4403
|
+
* ran without a registered URL. Probing that fails forever, so a partner who
|
|
4404
|
+
* skipped registration would get a permanent red panel about a fault they do
|
|
4405
|
+
* not have.
|
|
4406
|
+
*
|
|
4407
|
+
* Anything that is not an absolute http(s) URL therefore reads as NOT
|
|
4408
|
+
* CONFIGURED rather than as unreachable: the failure mode to avoid here is
|
|
4409
|
+
* crying wolf, since the whole value of the check is that it is believed.
|
|
4410
|
+
*/
|
|
4411
|
+
export function probeOrigin(raw: string): string | null {
|
|
4412
|
+
if (!raw) return null;
|
|
4413
|
+
try {
|
|
4414
|
+
const u = new URL(raw);
|
|
4415
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
4416
|
+
return u.origin;
|
|
4417
|
+
} catch {
|
|
4418
|
+
return null;
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
|
|
4422
|
+
/**
|
|
4423
|
+
* One check. Returns the new state rather than only setting it, so a test can
|
|
4424
|
+
* drive it directly without reaching into module state.
|
|
4425
|
+
*/
|
|
4426
|
+
export async function checkReachable(fetchImpl: typeof fetch = fetch): Promise<Reachability> {
|
|
4427
|
+
const origin = probeOrigin(config.publicUrl);
|
|
4428
|
+
if (!origin) {
|
|
4429
|
+
current = {
|
|
4430
|
+
state: 'unknown',
|
|
4431
|
+
detail: config.publicUrl
|
|
4432
|
+
? 'publicUrl in oneaddress.config.json is not a usable http(s) URL'
|
|
4433
|
+
: 'no publicUrl in oneaddress.config.json',
|
|
4434
|
+
};
|
|
4435
|
+
return current;
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
const url = \`\${origin}\${ALIVE_PATH}\`;
|
|
4439
|
+
const since = current.state === 'unreachable' ? current.since : new Date().toISOString();
|
|
4440
|
+
|
|
4441
|
+
try {
|
|
4442
|
+
const res = await fetchImpl(url, {
|
|
4443
|
+
method: 'GET',
|
|
4444
|
+
// Short: this is a liveness probe, not a download. A tunnel that takes
|
|
4445
|
+
// longer than this to answer is already failing dispatches, which time
|
|
4446
|
+
// out on OneAddress's side too.
|
|
4447
|
+
signal: AbortSignal.timeout(10_000),
|
|
4448
|
+
headers: { 'cache-control': 'no-cache' },
|
|
4449
|
+
});
|
|
4450
|
+
const body = await res.text().catch(() => '');
|
|
4451
|
+
// THE NONCE, not the status. See the header: a stopped tunnel answers with
|
|
4452
|
+
// a real HTTP response carrying an error page.
|
|
4453
|
+
if (body.trim() === ALIVE_NONCE) {
|
|
4454
|
+
current = { state: 'reachable', checkedAt: new Date().toISOString() };
|
|
4455
|
+
return current;
|
|
4456
|
+
}
|
|
4457
|
+
current = {
|
|
4458
|
+
state: 'unreachable',
|
|
4459
|
+
detail: \`\${url} answered HTTP \${res.status} but not from this receiver (tunnel down, or the URL points elsewhere)\`,
|
|
4460
|
+
since,
|
|
4461
|
+
};
|
|
4462
|
+
} catch (err) {
|
|
4463
|
+
current = {
|
|
4464
|
+
state: 'unreachable',
|
|
4465
|
+
detail: \`\${url} could not be reached: \${err instanceof Error ? err.message : String(err)}\`,
|
|
4466
|
+
since,
|
|
4467
|
+
};
|
|
4468
|
+
}
|
|
4469
|
+
return current;
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
/**
|
|
4473
|
+
* Check on a timer, and say so ONCE per transition rather than every tick.
|
|
4474
|
+
*
|
|
4475
|
+
* A line every five minutes for a tunnel that has been down all night is how a
|
|
4476
|
+
* log stops being read. The transition is the news.
|
|
4477
|
+
*/
|
|
4478
|
+
export function startReachabilityWatch(everyMs = 5 * 60_000): NodeJS.Timeout | null {
|
|
4479
|
+
if (!probeOrigin(config.publicUrl)) {
|
|
4480
|
+
report.info('[reachable] no usable publicUrl configured, so this receiver cannot check whether the internet can reach it.');
|
|
4481
|
+
return null;
|
|
4482
|
+
}
|
|
4483
|
+
let last: Reachability['state'] = 'unknown';
|
|
4484
|
+
const tick = (): void => {
|
|
4485
|
+
void checkReachable().then((r) => {
|
|
4486
|
+
if (r.state === last) return;
|
|
4487
|
+
last = r.state;
|
|
4488
|
+
if (r.state === 'unreachable') {
|
|
4489
|
+
report.error(\`[reachable] NOTHING IS REACHING THIS RECEIVER. \${r.detail}. Dispatches cannot arrive while this is true.\`);
|
|
4490
|
+
} else if (r.state === 'reachable') {
|
|
4491
|
+
report.info('[reachable] your public URL reaches this receiver.');
|
|
4492
|
+
}
|
|
4493
|
+
});
|
|
4494
|
+
};
|
|
4495
|
+
// A first check soon after startup, not immediately: a tunnel started
|
|
4496
|
+
// alongside the receiver needs a moment before it answers, and reporting it
|
|
4497
|
+
// down on second one would be wrong every single time.
|
|
4498
|
+
const first = setTimeout(tick, 15_000);
|
|
4499
|
+
first.unref?.();
|
|
4500
|
+
const timer = setInterval(tick, everyMs);
|
|
4501
|
+
timer.unref?.();
|
|
4502
|
+
return timer;
|
|
4503
|
+
}
|
|
3980
4504
|
`
|
|
3981
4505
|
},
|
|
3982
4506
|
{
|
|
@@ -4531,6 +5055,66 @@ export function customerCount(): number {
|
|
|
4531
5055
|
return (db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }).n;
|
|
4532
5056
|
}
|
|
4533
5057
|
|
|
5058
|
+
/** One applied change: what this receiver held before, and what it holds now. */
|
|
5059
|
+
export interface AddressChange {
|
|
5060
|
+
account_number: string;
|
|
5061
|
+
name: string;
|
|
5062
|
+
prev_address: Address | null;
|
|
5063
|
+
address: Address;
|
|
5064
|
+
recorded_at: string;
|
|
5065
|
+
}
|
|
5066
|
+
|
|
5067
|
+
/**
|
|
5068
|
+
* The applied changes, newest first, decrypted and joined back to the customer.
|
|
5069
|
+
*
|
|
5070
|
+
* ## Why this is exported rather than left to \`sqlite3\`
|
|
5071
|
+
*
|
|
5072
|
+
* Because \`sqlite3 data.db\` cannot answer it. Every text column here is
|
|
5073
|
+
* AES-GCM ciphertext whenever the database has a password, which is the state
|
|
5074
|
+
* this receiver asks for on its first run and reports in green on the
|
|
5075
|
+
* dashboard. So the one question a partner has after their first real dispatch
|
|
5076
|
+
* - "show me it actually changed" - had no answer short of writing code against
|
|
5077
|
+
* the store. The dashboard's LAST CHANGE panel shows the most recent one and
|
|
5078
|
+
* nothing shows the rest.
|
|
5079
|
+
*
|
|
5080
|
+
* \`prev_address\` is NULL for a row written before the column existed
|
|
5081
|
+
* (\`ensureColumn\` defaults it to \`'{}'\`, which decodes to an empty object, not
|
|
5082
|
+
* to an address) and for the first change on an account that had no address on
|
|
5083
|
+
* file. Those are different facts and both are honestly "nothing to compare
|
|
5084
|
+
* against", so both come back as null rather than as an empty address.
|
|
5085
|
+
*/
|
|
5086
|
+
export function addressHistory(limit = 20): AddressChange[] {
|
|
5087
|
+
const rows = db.prepare(
|
|
5088
|
+
\`SELECT h.prev_address AS prev_address,
|
|
5089
|
+
h.address AS address,
|
|
5090
|
+
h.recorded_at AS recorded_at,
|
|
5091
|
+
c.account_number AS account_number,
|
|
5092
|
+
c.name AS name
|
|
5093
|
+
FROM address_history h
|
|
5094
|
+
LEFT JOIN customers c ON c.account_key = h.account_key
|
|
5095
|
+
ORDER BY h.id DESC
|
|
5096
|
+
LIMIT ?\`,
|
|
5097
|
+
).all(limit) as unknown[];
|
|
5098
|
+
|
|
5099
|
+
return rows.map((raw) => {
|
|
5100
|
+
const r = raw as Record<string, string | null>;
|
|
5101
|
+
const prevJson = dec('address', r.prev_address);
|
|
5102
|
+
let prev: Address | null = null;
|
|
5103
|
+
try {
|
|
5104
|
+
const parsed = prevJson ? JSON.parse(prevJson) as Address : null;
|
|
5105
|
+
// \`{}\` is the column default, not an address anyone held.
|
|
5106
|
+
prev = parsed && Object.keys(parsed).length > 0 ? parsed : null;
|
|
5107
|
+
} catch { prev = null; }
|
|
5108
|
+
return {
|
|
5109
|
+
account_number: dec('account_number', r.account_number) ?? '(unknown)',
|
|
5110
|
+
name: dec('name', r.name) ?? '(unknown)',
|
|
5111
|
+
prev_address: prev,
|
|
5112
|
+
address: JSON.parse(dec('address', r.address) ?? '{}') as Address,
|
|
5113
|
+
recorded_at: r.recorded_at ?? '',
|
|
5114
|
+
};
|
|
5115
|
+
});
|
|
5116
|
+
}
|
|
5117
|
+
|
|
4534
5118
|
/** Is the file on disk protected? Surfaced in the dashboard, in both states. */
|
|
4535
5119
|
export const storeEncrypted = encrypted;
|
|
4536
5120
|
|
|
@@ -4723,6 +5307,7 @@ import {
|
|
|
4723
5307
|
// exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
|
|
4724
5308
|
// the protocol layer changes.
|
|
4725
5309
|
import { store as writeThroughStore } from './store.js';
|
|
5310
|
+
import { ALIVE_NONCE, ALIVE_PATH, startReachabilityWatch } from './reachable.js';
|
|
4726
5311
|
import { ConnectorUnreachableError, connectorStore, setCurrentRawBody } from './connector-store.js';
|
|
4727
5312
|
import { noteChange } from './tui.js';
|
|
4728
5313
|
import { config } from './config.js';
|
|
@@ -5145,9 +5730,27 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
5145
5730
|
*/
|
|
5146
5731
|
const key = dispatchKey(dispatch || null, rawBody);
|
|
5147
5732
|
|
|
5733
|
+
/**
|
|
5734
|
+
* A DRILL, declared in the SIGNED body. See \`quarantine.ts\`'s \`drill\` column.
|
|
5735
|
+
*
|
|
5736
|
+
* Read from \`body\`, which the HMAC covers, and never from the dispatch-id
|
|
5737
|
+
* header, which it does not. The conformance run already puts a recognisable
|
|
5738
|
+
* prefix on that header and using it would let anything on the path decide
|
|
5739
|
+
* whether this receiver raises an alarm.
|
|
5740
|
+
*/
|
|
5741
|
+
const isDrill = (body as Record<string, unknown>).conformance === true;
|
|
5742
|
+
|
|
5148
5743
|
const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void => {
|
|
5149
|
-
quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
|
|
5150
|
-
|
|
5744
|
+
quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail, drill: isDrill });
|
|
5745
|
+
// NOT COUNTED AS A FAILURE WHEN IT IS A DRILL. \`failed\` on the footer is a
|
|
5746
|
+
// figure about this partner's dispatches; a probe that was refused exactly
|
|
5747
|
+
// as the check requires is not one of them, and leaving it in is what made a
|
|
5748
|
+
// passing conformance run read as \`failed 1\` forever.
|
|
5749
|
+
//
|
|
5750
|
+
// Recorded as NOTHING rather than as a third outcome, deliberately:
|
|
5751
|
+
// \`tally.ts\` holds \`received === applied + failed\` as an invariant and a
|
|
5752
|
+
// drill is not a dispatch to this partner's business at all.
|
|
5753
|
+
if (!isDrill) recordOutcome(key, 'failed');
|
|
5151
5754
|
};
|
|
5152
5755
|
|
|
5153
5756
|
/**
|
|
@@ -5304,11 +5907,24 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
5304
5907
|
decAccount = typeof data.account_number === 'string' ? data.account_number : '';
|
|
5305
5908
|
decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
|
|
5306
5909
|
} catch (err) {
|
|
5307
|
-
//
|
|
5308
|
-
//
|
|
5309
|
-
//
|
|
5310
|
-
|
|
5311
|
-
|
|
5910
|
+
// A DRILL SAYS SO INSTEAD OF SHOUTING. Conformance check 11 wraps its
|
|
5911
|
+
// session key to a throwaway pair so that decryption MUST fail; the two
|
|
5912
|
+
// lines below would then name the partner's real key_id, print a raw
|
|
5913
|
+
// crypto error, and advise them to go and fix a key that is correct - at
|
|
5914
|
+
// the end of a run that just passed. The refusal below is unchanged,
|
|
5915
|
+
// because the refusal is what the check is testing.
|
|
5916
|
+
if (isDrill) {
|
|
5917
|
+
report.info(
|
|
5918
|
+
\`[webhook] conformance probe for key_id \${keyId ?? '(none)'} could not be decrypted, which is \` +
|
|
5919
|
+
'what that check requires. Your key is fine.',
|
|
5920
|
+
);
|
|
5921
|
+
} else {
|
|
5922
|
+
// Names WHICH key answered. A rotation used to surface here as an
|
|
5923
|
+
// authentication-tag error indistinguishable from corruption, with the
|
|
5924
|
+
// single-key fallback having silently answered for a key id it never held.
|
|
5925
|
+
report.error(\`[webhook] D5 decryption failed for key_id \${keyId ?? '(none)'}: \${keyFailureAdvice(keyId, resolved)}\`);
|
|
5926
|
+
report.error('[webhook] underlying error:', err);
|
|
5927
|
+
}
|
|
5312
5928
|
hold('decrypt_failed', keyId, keyFailureAdvice(keyId, resolved));
|
|
5313
5929
|
return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
|
|
5314
5930
|
}
|
|
@@ -5518,9 +6134,26 @@ app.get('/health', (_req, res) => res.json({
|
|
|
5518
6134
|
encryptedAtRest: store.encrypted,
|
|
5519
6135
|
}));
|
|
5520
6136
|
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
6137
|
+
/**
|
|
6138
|
+
* The reachability probe's target. Returns this process's startup nonce as
|
|
6139
|
+
* plain text, and nothing else.
|
|
6140
|
+
*
|
|
6141
|
+
* WHY A NONCE AND NOT \`/health\`. A stopped Cloudflare tunnel still ANSWERS -
|
|
6142
|
+
* the edge serves a 1033 or a 502 error page - so a check that accepts any HTTP
|
|
6143
|
+
* response reports a dead tunnel as healthy. Only this running process knows
|
|
6144
|
+
* this value, so receiving it back is proof the whole path is open. See
|
|
6145
|
+
* \`src/reachable.ts\`.
|
|
6146
|
+
*
|
|
6147
|
+
* NOT A SECRET, and nothing is gated on it: it proves which process answered,
|
|
6148
|
+
* not who asked. The webhook still requires a valid HMAC.
|
|
6149
|
+
*/
|
|
6150
|
+
app.get(ALIVE_PATH, (_req, res) => res.type('text/plain').send(ALIVE_NONCE));
|
|
6151
|
+
|
|
6152
|
+
app.listen(PORT, () => {
|
|
6153
|
+
report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`);
|
|
6154
|
+
// Started AFTER listen, or the first probe races the server it is probing.
|
|
6155
|
+
startReachabilityWatch();
|
|
6156
|
+
});
|
|
5524
6157
|
|
|
5525
6158
|
/**
|
|
5526
6159
|
* Drain the confirm queue, forever.
|
|
@@ -5678,6 +6311,228 @@ export function dashboardStats(): {
|
|
|
5678
6311
|
// there, so the port the UI claims is the port the server actually bound.
|
|
5679
6312
|
export { PORT };
|
|
5680
6313
|
export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
|
|
6314
|
+
`
|
|
6315
|
+
},
|
|
6316
|
+
{
|
|
6317
|
+
name: "src/unlock.ts",
|
|
6318
|
+
content: `/**
|
|
6319
|
+
* Opening a locked database: is it locked, and asking for the password.
|
|
6320
|
+
*
|
|
6321
|
+
* ## Why these two live in their own file
|
|
6322
|
+
*
|
|
6323
|
+
* Both are needed by \`src/index.ts\` (which prompts before starting the
|
|
6324
|
+
* receiver) and by \`scripts/show.ts\` (which prompts before reading the roster),
|
|
6325
|
+
* and a second copy of a masked-input routine is the last thing this codebase
|
|
6326
|
+
* needs: the comment inside \`ask\` records TWO earlier versions that echoed the
|
|
6327
|
+
* passphrase in clear, neither of which was caught by reading the code. One
|
|
6328
|
+
* implementation, driven by one test.
|
|
6329
|
+
*
|
|
6330
|
+
* NOTHING HERE IMPORTS \`db.ts\`, and that is a constraint rather than tidiness.
|
|
6331
|
+
* Importing the database derives its keys on import, so anything asking WHICH
|
|
6332
|
+
* QUESTION TO PUT has to run first. \`databaseIsLocked\` therefore opens its own
|
|
6333
|
+
* read-only connection.
|
|
6334
|
+
*/
|
|
6335
|
+
/**
|
|
6336
|
+
* Has this database already been locked?
|
|
6337
|
+
*
|
|
6338
|
+
* Asked BEFORE the passphrase, and without one, so the prompt can say which of
|
|
6339
|
+
* two completely different things it is doing. \`db_meta.verifier\` is written the
|
|
6340
|
+
* first time a passphrase is set, so its presence is the whole answer.
|
|
6341
|
+
*
|
|
6342
|
+
* Read through its own connection rather than importing \`db.ts\`, which derives
|
|
6343
|
+
* its keys the moment it is imported and would therefore have to run BEFORE we
|
|
6344
|
+
* know what to ask for.
|
|
6345
|
+
*/
|
|
6346
|
+
export async function databaseIsLocked(): Promise<boolean> {
|
|
6347
|
+
try {
|
|
6348
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
6349
|
+
const { join } = await import('node:path');
|
|
6350
|
+
const path = process.env.DB_PATH ?? join(process.cwd(), 'data.db');
|
|
6351
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
6352
|
+
try {
|
|
6353
|
+
const row = db
|
|
6354
|
+
.prepare("SELECT value FROM db_meta WHERE key = 'verifier'")
|
|
6355
|
+
.get() as { value?: string } | undefined;
|
|
6356
|
+
return Boolean(row?.value);
|
|
6357
|
+
} finally {
|
|
6358
|
+
db.close();
|
|
6359
|
+
}
|
|
6360
|
+
} catch {
|
|
6361
|
+
// No file yet, or no db_meta table yet. Either way: not locked.
|
|
6362
|
+
return false;
|
|
6363
|
+
}
|
|
6364
|
+
}
|
|
6365
|
+
|
|
6366
|
+
/**
|
|
6367
|
+
* Ask for a passphrase without echoing it to the screen.
|
|
6368
|
+
*
|
|
6369
|
+
* A passphrase typed in clear on a shared screen, in a screen-share, or into a
|
|
6370
|
+
* terminal that keeps scrollback is not much of a secret. readline echoes by
|
|
6371
|
+
* default, so its output hook is replaced for the duration of the question.
|
|
6372
|
+
*
|
|
6373
|
+
* Degrades to a visible prompt rather than failing: on a terminal where the
|
|
6374
|
+
* hook is not available, being asked in the clear beats not being asked.
|
|
6375
|
+
*/
|
|
6376
|
+
export async function ask(prompt: string): Promise<string> {
|
|
6377
|
+
process.stdout.write(prompt);
|
|
6378
|
+
|
|
6379
|
+
// NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
|
|
6380
|
+
// mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
|
|
6381
|
+
// by driving a real terminal and reading what came back. The first filtered
|
|
6382
|
+
// on whether the chunk contained the prompt, not knowing readline repaints
|
|
6383
|
+
// prompt and input together on every keystroke, so the condition was always
|
|
6384
|
+
// true. The second repainted the line and still leaked, because the echo was
|
|
6385
|
+
// never coming from that hook at all.
|
|
6386
|
+
//
|
|
6387
|
+
// Reading the keys directly removes the guessing. Raw mode turns the
|
|
6388
|
+
// terminal's own echo OFF, so the ONLY thing that can reach the screen is
|
|
6389
|
+
// what is written below: one asterisk per character, which is what a partner
|
|
6390
|
+
// asked for and what every other passphrase prompt does.
|
|
6391
|
+
const stdin = process.stdin;
|
|
6392
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
6393
|
+
// No terminal to control. Being asked in the clear beats not being asked,
|
|
6394
|
+
// and this path is only reached where nothing is watching anyway.
|
|
6395
|
+
const { createInterface } = await import('node:readline/promises');
|
|
6396
|
+
const rl = createInterface({ input: stdin, output: process.stdout });
|
|
6397
|
+
try {
|
|
6398
|
+
const answer = await rl.question('');
|
|
6399
|
+
return answer.trim();
|
|
6400
|
+
} finally { rl.close(); }
|
|
6401
|
+
}
|
|
6402
|
+
|
|
6403
|
+
const wasRaw = stdin.isRaw === true;
|
|
6404
|
+
stdin.setRawMode(true);
|
|
6405
|
+
stdin.resume();
|
|
6406
|
+
stdin.setEncoding('utf8');
|
|
6407
|
+
|
|
6408
|
+
return new Promise<string>((resolve) => {
|
|
6409
|
+
let typed = '';
|
|
6410
|
+
const restore = (): void => {
|
|
6411
|
+
stdin.removeListener('data', onData);
|
|
6412
|
+
stdin.setRawMode(wasRaw);
|
|
6413
|
+
stdin.pause();
|
|
6414
|
+
};
|
|
6415
|
+
const onData = (chunk: string): void => {
|
|
6416
|
+
for (const ch of chunk) {
|
|
6417
|
+
if (ch === '\\r' || ch === '\\n') {
|
|
6418
|
+
restore();
|
|
6419
|
+
process.stdout.write('\\n');
|
|
6420
|
+
resolve(typed.trim());
|
|
6421
|
+
return;
|
|
6422
|
+
}
|
|
6423
|
+
if (ch === '\\u0003') { // Ctrl+C
|
|
6424
|
+
restore();
|
|
6425
|
+
process.stdout.write('\\n');
|
|
6426
|
+
process.exit(130);
|
|
6427
|
+
}
|
|
6428
|
+
if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
|
|
6429
|
+
restore();
|
|
6430
|
+
process.stdout.write('\\n');
|
|
6431
|
+
resolve(typed.trim());
|
|
6432
|
+
return;
|
|
6433
|
+
}
|
|
6434
|
+
if (ch === '\\u007f' || ch === '\\b') {
|
|
6435
|
+
// Backspace has to move the asterisks too, or the mask stops matching
|
|
6436
|
+
// what is actually in the buffer and the count misleads.
|
|
6437
|
+
if (typed.length > 0) {
|
|
6438
|
+
typed = typed.slice(0, -1);
|
|
6439
|
+
process.stdout.write('\\b \\b');
|
|
6440
|
+
}
|
|
6441
|
+
continue;
|
|
6442
|
+
}
|
|
6443
|
+
if (ch < ' ') continue; // ignore the rest of the control range
|
|
6444
|
+
typed += ch;
|
|
6445
|
+
process.stdout.write('*');
|
|
6446
|
+
}
|
|
6447
|
+
};
|
|
6448
|
+
stdin.on('data', onData);
|
|
6449
|
+
});
|
|
6450
|
+
}
|
|
6451
|
+
`
|
|
6452
|
+
},
|
|
6453
|
+
{
|
|
6454
|
+
name: "scripts/show.ts",
|
|
6455
|
+
content: `/**
|
|
6456
|
+
* Show what this receiver holds: the roster, and every change it has applied.
|
|
6457
|
+
*
|
|
6458
|
+
* Usage:
|
|
6459
|
+
* npm run show \u2014 the roster and the last 20 changes
|
|
6460
|
+
* npm run show 100 \u2014 the last 100 changes
|
|
6461
|
+
*
|
|
6462
|
+
* ## Why this script exists
|
|
6463
|
+
*
|
|
6464
|
+
* The obvious way to check that a dispatch really landed is \`sqlite3 data.db\`,
|
|
6465
|
+
* and it does not work: the roster and the history are AES-GCM ciphertext
|
|
6466
|
+
* whenever the database has a password, which is what this receiver asks for on
|
|
6467
|
+
* its first run. Reported from a real run, immediately after a first successful
|
|
6468
|
+
* dispatch: "where do I look to prove it end to end?" The dashboard's LAST
|
|
6469
|
+
* CHANGE panel shows one change and nothing showed the rest.
|
|
6470
|
+
*
|
|
6471
|
+
* It prompts for the password for the same reason \`src/index.ts\` does, and for
|
|
6472
|
+
* the same reason it must do so BEFORE importing the store: the database
|
|
6473
|
+
* derives its keys on import.
|
|
6474
|
+
*
|
|
6475
|
+
* IN INBOX MODE THERE IS NOTHING HERE TO SHOW, and saying so is the answer
|
|
6476
|
+
* rather than printing an empty table. This receiver holds no key and no
|
|
6477
|
+
* customer records in that mode; the connector holds both, and the change is
|
|
6478
|
+
* in the connector's own store.
|
|
6479
|
+
*/
|
|
6480
|
+
import 'dotenv/config';
|
|
6481
|
+
import { config } from '../src/config.js';
|
|
6482
|
+
import { ask, databaseIsLocked } from '../src/unlock.js';
|
|
6483
|
+
|
|
6484
|
+
function fmt(a: Record<string, string> | null): string {
|
|
6485
|
+
if (!a) return '(nothing on file)';
|
|
6486
|
+
const parts = [a.street, a.suburb, a.state, a.postcode, a.country].filter(Boolean);
|
|
6487
|
+
return parts.join(', ') || '(empty)';
|
|
6488
|
+
}
|
|
6489
|
+
|
|
6490
|
+
async function askPassword(): Promise<void> {
|
|
6491
|
+
if (process.env.ONEADDRESS_DB_PASSPHRASE?.trim()) return;
|
|
6492
|
+
if (!process.stdin.isTTY) return;
|
|
6493
|
+
if (!(await databaseIsLocked())) return;
|
|
6494
|
+
const answer = await ask(' Password to unlock: ');
|
|
6495
|
+
if (answer) process.env.ONEADDRESS_DB_PASSPHRASE = answer;
|
|
6496
|
+
}
|
|
6497
|
+
|
|
6498
|
+
async function main(): Promise<void> {
|
|
6499
|
+
if (config.mode === 'inbox') {
|
|
6500
|
+
console.log('\\n This receiver is in inbox mode: it holds no customer records and no key.');
|
|
6501
|
+
console.log(' Your connector applied the change and holds the result. Look there.\\n');
|
|
6502
|
+
return;
|
|
6503
|
+
}
|
|
6504
|
+
|
|
6505
|
+
await askPassword();
|
|
6506
|
+
|
|
6507
|
+
const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
|
|
6508
|
+
const store = await import('../src/store.js');
|
|
6509
|
+
|
|
6510
|
+
const roster = store.allCustomers();
|
|
6511
|
+
console.log(\`\\n ON FILE (\${roster.length})\\n\`);
|
|
6512
|
+
for (const c of roster) {
|
|
6513
|
+
let addr: Record<string, string> | null = null;
|
|
6514
|
+
try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
|
|
6515
|
+
console.log(\` \${c.account_number.padEnd(14)} \${c.name.padEnd(24)} \${fmt(addr)}\`);
|
|
6516
|
+
}
|
|
6517
|
+
|
|
6518
|
+
const changes = store.addressHistory(limit);
|
|
6519
|
+
console.log(\`\\n APPLIED CHANGES (\${changes.length}, newest first)\\n\`);
|
|
6520
|
+
if (changes.length === 0) {
|
|
6521
|
+
console.log(' None yet. Send a dispatch and run this again.\\n');
|
|
6522
|
+
return;
|
|
6523
|
+
}
|
|
6524
|
+
for (const h of changes) {
|
|
6525
|
+
console.log(\` \${h.recorded_at} \${h.account_number} \${h.name}\`);
|
|
6526
|
+
console.log(\` was: \${fmt(h.prev_address as unknown as Record<string, string> | null)}\`);
|
|
6527
|
+
console.log(\` now: \${fmt(h.address as unknown as Record<string, string>)}\`);
|
|
6528
|
+
}
|
|
6529
|
+
console.log('');
|
|
6530
|
+
}
|
|
6531
|
+
|
|
6532
|
+
void main().catch((err: unknown) => {
|
|
6533
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
6534
|
+
process.exit(1);
|
|
6535
|
+
});
|
|
5681
6536
|
`
|
|
5682
6537
|
},
|
|
5683
6538
|
{
|
|
@@ -5858,14 +6713,56 @@ computes the LOA reference with \`d5LoaRef\`, and hands it to your store as
|
|
|
5858
6713
|
|
|
5859
6714
|
\`\`\`sql
|
|
5860
6715
|
-- Your customer roster: who you know + the address you hold on file today.
|
|
5861
|
-
--
|
|
5862
|
-
--
|
|
5863
|
-
|
|
6716
|
+
-- Seeded on startup from customers.json (edit that file, or point loadRoster in
|
|
6717
|
+
-- src/store.ts at your real customer table).
|
|
6718
|
+
--
|
|
6719
|
+
-- account_key is how a row is FOUND: a blind index of the account number when
|
|
6720
|
+
-- the database has a password, the lower-cased number when it does not. It is
|
|
6721
|
+
-- the key rather than the number itself because AES-GCM uses a fresh IV per
|
|
6722
|
+
-- write, so two encryptions of one account number differ and a primary key over
|
|
6723
|
+
-- the ciphertext would enforce nothing while looking like it did.
|
|
6724
|
+
customers(account_key, account_number, name, address JSON, updated_at)
|
|
5864
6725
|
|
|
5865
6726
|
-- Full history of every address change you apply (append-only audit trail).
|
|
5866
|
-
|
|
6727
|
+
-- BOTH SIDES of each change, so you can show what an address REPLACED and not
|
|
6728
|
+
-- only what it became.
|
|
6729
|
+
address_history(id, account_key, prev_address JSON, address JSON, recorded_at)
|
|
5867
6730
|
\`\`\`
|
|
5868
6731
|
|
|
6732
|
+
### Where the fault export lands
|
|
6733
|
+
|
|
6734
|
+
\`[e]\` on the dashboard writes a metadata-only file (no payloads) you can send
|
|
6735
|
+
straight to support. It goes to the working directory by default, which is not
|
|
6736
|
+
always where you think it is when the receiver was started by a shortcut or a
|
|
6737
|
+
service manager, so you can name somewhere:
|
|
6738
|
+
|
|
6739
|
+
\`\`\`jsonc
|
|
6740
|
+
// oneaddress.config.json
|
|
6741
|
+
{ "exportDir": "/home/you/oneaddress-exports" }
|
|
6742
|
+
\`\`\`
|
|
6743
|
+
|
|
6744
|
+
\`\`\`bash
|
|
6745
|
+
ONEADDRESS_EXPORT_DIR=/tmp npm start # one run, without editing the file
|
|
6746
|
+
\`\`\`
|
|
6747
|
+
|
|
6748
|
+
The directory is created if it does not exist. The confirmation on the dashboard
|
|
6749
|
+
and the line in the log both name the **full path**, and the dashboard keeps it
|
|
6750
|
+
in its own slot so pressing \`[r]\` does not wipe it.
|
|
6751
|
+
|
|
6752
|
+
### Seeing what changed
|
|
6753
|
+
|
|
6754
|
+
\`\`\`bash
|
|
6755
|
+
npm run show # the roster, and the last 20 changes (was: / now:)
|
|
6756
|
+
npm run show 100 # the last 100
|
|
6757
|
+
\`\`\`
|
|
6758
|
+
|
|
6759
|
+
\`sqlite3 data.db\` will NOT answer this once the database has a password: every
|
|
6760
|
+
text column is ciphertext, which is the point. \`npm run show\` prompts for the
|
|
6761
|
+
same password \`npm start\` does and prints the decrypted rows.
|
|
6762
|
+
|
|
6763
|
+
In **inbox mode** there is nothing here to show. This receiver holds no key and
|
|
6764
|
+
no customer records; your connector applied the change and holds the result.
|
|
6765
|
+
|
|
5869
6766
|
## Swapping to a production database
|
|
5870
6767
|
|
|
5871
6768
|
Open \`src/store.ts\` and replace the \`db\` calls with your ORM/driver of choice:
|
|
@@ -11006,7 +11903,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
11006
11903
|
|
|
11007
11904
|
// src/register.ts
|
|
11008
11905
|
var import_node_crypto2 = require("crypto");
|
|
11009
|
-
var PKG_VERSION = true ? "2.
|
|
11906
|
+
var PKG_VERSION = true ? "2.6.0" : "dev";
|
|
11010
11907
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
11011
11908
|
function hmacSha256(secret, message) {
|
|
11012
11909
|
return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
|