@oneaddress/setup 2.4.0 → 2.5.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 +861 -150
- 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.5.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,8 @@ 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%%"
|
|
1015
1017
|
}
|
|
1016
1018
|
`
|
|
1017
1019
|
},
|
|
@@ -1711,7 +1713,8 @@ import { formatLine, report, type ReportLine } from './report.js';
|
|
|
1711
1713
|
// reads, with nothing here to change.
|
|
1712
1714
|
import { store } from './store.js';
|
|
1713
1715
|
import { pendingConfirmCount } from './confirm-queue.js';
|
|
1714
|
-
import { exportHeld, heldCount, heldSummary } from './quarantine.js';
|
|
1716
|
+
import { dismissHeld, exportHeld, heldCount, heldSummary } from './quarantine.js';
|
|
1717
|
+
import { reachability } from './reachable.js';
|
|
1715
1718
|
|
|
1716
1719
|
/** blessed takes colours as strings; these mirror the site's palette. */
|
|
1717
1720
|
const AMBER = HEX.amber.toLowerCase();
|
|
@@ -1870,6 +1873,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1870
1873
|
// nor UNENCRYPTED is true and both would mislead. The receiver holds
|
|
1871
1874
|
// ciphertext it cannot open; the customers live in the partner's own
|
|
1872
1875
|
// database, behind their own controls.
|
|
1876
|
+
const reach = reachability();
|
|
1873
1877
|
const vault = inbox
|
|
1874
1878
|
? \`{\${AMBER}-fg}{bold}NONE (inbox){/}\`
|
|
1875
1879
|
: store.encrypted
|
|
@@ -1883,7 +1887,16 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1883
1887
|
// claim ("you have no customers") and would be a lie on a receiver
|
|
1884
1888
|
// pointed at a real customer table, where counting every row forty times
|
|
1885
1889
|
// a minute is the thing the store is right to refuse.
|
|
1886
|
-
\`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}
|
|
1890
|
+
\`{\${DIM}-fg}on file{/} {\${CREAM}-fg}\${onFile === null ? '\u2014' : onFile.toLocaleString()}{/}\`
|
|
1891
|
+
// REACHABLE IS DRAWN ONLY WHEN IT IS KNOWN, and that is the point of the
|
|
1892
|
+
// third state. A receiver with no publicUrl configured cannot answer the
|
|
1893
|
+
// question, and a green tick or a red cross would both be inventing an
|
|
1894
|
+
// answer. Silence is the honest rendering of "not checked".
|
|
1895
|
+
+ (reach.state === 'unknown'
|
|
1896
|
+
? ''
|
|
1897
|
+
: reach.state === 'reachable'
|
|
1898
|
+
? \` {\${DIM}-fg}reachable{/} {green-fg}{bold}YES{/}\`
|
|
1899
|
+
: \` {\${DIM}-fg}reachable{/} {red-fg}{bold}NO{/}\`),
|
|
1887
1900
|
);
|
|
1888
1901
|
}
|
|
1889
1902
|
|
|
@@ -1947,7 +1960,14 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1947
1960
|
|
|
1948
1961
|
function renderFaults(): void {
|
|
1949
1962
|
const held = heldCount();
|
|
1950
|
-
const
|
|
1963
|
+
const reach = reachability();
|
|
1964
|
+
// UNREACHABLE IS A FAULT, and it belongs in this panel rather than only in
|
|
1965
|
+
// the status bar, because it is the one failure where NOTHING ELSE MOVES.
|
|
1966
|
+
// A wrong key at least produces held dispatches to look at; a stopped
|
|
1967
|
+
// tunnel produces a receiver that looks perfectly healthy and simply never
|
|
1968
|
+
// hears from anyone again.
|
|
1969
|
+
const unreachable = reach.state === 'unreachable';
|
|
1970
|
+
const show = held > 0 || unreachable;
|
|
1951
1971
|
if (show === Boolean(faultBox.hidden)) {
|
|
1952
1972
|
// Visibility is changing, so the panels above have to give back or take
|
|
1953
1973
|
// back the rows. Assigning \`bottom\` is how blessed re-lays-out; it reads
|
|
@@ -1962,6 +1982,15 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1962
1982
|
// Grouped, never one line per dispatch. The realistic shape of this table
|
|
1963
1983
|
// is forty rows with ONE cause between them, and forty identical lines hide
|
|
1964
1984
|
// the single fact that matters.
|
|
1985
|
+
if (unreachable && held === 0) {
|
|
1986
|
+
faultBox.setContent(
|
|
1987
|
+
\`\\n {bold}Nothing on the internet is reaching this receiver.{/bold}\\n\` +
|
|
1988
|
+
\` {red-fg}\${esc(reach.state === 'unreachable' ? reach.detail : '')}{/}\\n\` +
|
|
1989
|
+
\` {\${DIM}-fg}Dispatches cannot arrive while this is true. Check your tunnel is still running.{/}\`,
|
|
1990
|
+
);
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1965
1994
|
const lines = heldSummary().slice(0, 2).map((l) => \` {red-fg}\${esc(l)}{/}\`);
|
|
1966
1995
|
faultBox.setContent(
|
|
1967
1996
|
\`\\n {bold}\${held}{/bold} dispatch(es) arrived that this receiver could not open. \` +
|
|
@@ -1969,7 +1998,8 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1969
1998
|
lines.join('\\n') + '\\n' +
|
|
1970
1999
|
(replayNote
|
|
1971
2000
|
? \` {\${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
|
|
2001
|
+
: \` {\${DIM}-fg}Fix the cause, then{/} {\${AMBER}-fg}[r]{/} {\${DIM}-fg}to apply,{/} {\${AMBER}-fg}[e]{/} {\${DIM}-fg}to export,{/} \` +
|
|
2002
|
+
\`{\${AMBER}-fg}[d]{/} {\${DIM}-fg}to dismiss.{/}\`),
|
|
1973
2003
|
);
|
|
1974
2004
|
}
|
|
1975
2005
|
|
|
@@ -2003,7 +2033,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2003
2033
|
\`{red-fg}failed{/} {bold}\${failed}{/bold} \` +
|
|
2004
2034
|
awaitingYou +
|
|
2005
2035
|
\`{\${awaitingColour}-fg}awaiting confirm{/} {bold}\${awaiting}{/bold}\` +
|
|
2006
|
-
\`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [q] quit{/} \`,
|
|
2036
|
+
\`{|}{\${DIM}-fg}\${onReplay ? '[r] replay ' : ''}[e] export [d] dismiss [q] quit{/} \`,
|
|
2007
2037
|
);
|
|
2008
2038
|
}
|
|
2009
2039
|
|
|
@@ -2079,6 +2109,58 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2079
2109
|
redraw();
|
|
2080
2110
|
});
|
|
2081
2111
|
|
|
2112
|
+
// [d] DISMISSES. TWO PRESSES, and the first one only asks.
|
|
2113
|
+
//
|
|
2114
|
+
// WHY THIS KEY EXISTS AT ALL. The conformance suite deliberately sends a
|
|
2115
|
+
// dispatch this receiver cannot open (check-11: a session key wrapped to a
|
|
2116
|
+
// throwaway key pair) and passes the receiver for HOLDING it. So passing
|
|
2117
|
+
// conformance leaves a red fault panel up permanently, on a receiver that is
|
|
2118
|
+
// working correctly, with nothing on screen that clears it. Reported from a
|
|
2119
|
+
// real run: "I do wish the conformance wouldn't come up as a fault."
|
|
2120
|
+
//
|
|
2121
|
+
// WHY THE RECEIVER DOES NOT JUST RECOGNISE THE PROBE. Everything that
|
|
2122
|
+
// identifies one is chosen by the sender. See the \`dismissed_at\` docstring in
|
|
2123
|
+
// \`quarantine.ts\`: suppressing a fault panel on a sender-supplied value hides
|
|
2124
|
+
// real held dispatches from the one person whose job is to notice them.
|
|
2125
|
+
//
|
|
2126
|
+
// IRREVERSIBLE, SO IT IS CONFIRMED. Dismissal discards the held payload, which
|
|
2127
|
+
// is the point (see \`dismissHeld\`) and also means a mistaken press cannot be
|
|
2128
|
+
// undone by pressing [r]. The count is named in the question rather than after
|
|
2129
|
+
// it, because "dismiss 47" and "dismiss 1" are different decisions.
|
|
2130
|
+
//
|
|
2131
|
+
// THE ARM REMEMBERS A COUNT, NOT A BOOLEAN, and that is the load-bearing part
|
|
2132
|
+
// rather than a nicety. A boolean armed at 14:02 is still armed at 16:30, so a
|
|
2133
|
+
// partner who armed it against one conformance probe and wandered off would
|
|
2134
|
+
// come back and dismiss whatever real backlog had arrived in between - with a
|
|
2135
|
+
// single keystroke, discarding the payloads. Confirming only the exact set
|
|
2136
|
+
// that was described makes the question and the action the same question.
|
|
2137
|
+
let dismissArmedFor: number | null = null;
|
|
2138
|
+
screen.key(['d'], () => {
|
|
2139
|
+
const held = heldCount();
|
|
2140
|
+
if (held === 0) {
|
|
2141
|
+
dismissArmedFor = null;
|
|
2142
|
+
replayNote = 'Nothing held, so nothing to dismiss.';
|
|
2143
|
+
redraw();
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
if (dismissArmedFor !== held) {
|
|
2147
|
+
const changed = dismissArmedFor !== null;
|
|
2148
|
+
dismissArmedFor = held;
|
|
2149
|
+
replayNote = (changed ? \`That changed: \${held} held now. \` : '') +
|
|
2150
|
+
\`Press [d] again to dismiss \${held}: not applied, payloads discarded, cannot be undone.\`;
|
|
2151
|
+
redraw();
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
dismissArmedFor = null;
|
|
2155
|
+
try {
|
|
2156
|
+
const n = dismissHeld();
|
|
2157
|
+
replayNote = \`Dismissed \${n}. Nothing left held.\`;
|
|
2158
|
+
} catch (err) {
|
|
2159
|
+
replayNote = \`Dismiss failed: \${err instanceof Error ? err.message : String(err)}\`;
|
|
2160
|
+
}
|
|
2161
|
+
redraw();
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2082
2164
|
screen.key(['q', 'C-c'], () => {
|
|
2083
2165
|
detach();
|
|
2084
2166
|
screen.destroy();
|
|
@@ -2141,6 +2223,10 @@ import { printBanner } from './brand.js';
|
|
|
2141
2223
|
// after the passphrase is resolved. \`config.js\` reads a JSON file and the
|
|
2142
2224
|
// environment and touches neither.
|
|
2143
2225
|
import { config } from './config.js';
|
|
2226
|
+
// Same rule as \`config.js\`: \`unlock.js\` deliberately does not import the
|
|
2227
|
+
// database, so it can be loaded statically here and still run before the keys
|
|
2228
|
+
// are derived. See its own header.
|
|
2229
|
+
import { ask, databaseIsLocked } from './unlock.js';
|
|
2144
2230
|
import { PassphraseRequiredError, WrongPassphraseError } from './vault.js';
|
|
2145
2231
|
|
|
2146
2232
|
/**
|
|
@@ -2163,122 +2249,6 @@ const headless =
|
|
|
2163
2249
|
process.env.ONEADDRESS_HEADLESS === '1' ||
|
|
2164
2250
|
!process.stdout.isTTY;
|
|
2165
2251
|
|
|
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
2252
|
|
|
2283
2253
|
/**
|
|
2284
2254
|
* Where the at-rest passphrase comes from.
|
|
@@ -2332,9 +2302,14 @@ async function resolvePassphrase(): Promise<string | null> {
|
|
|
2332
2302
|
// credentials, so assuming exactly that is the natural reading. Reported as
|
|
2333
2303
|
// confusing, and the confusion was ours.
|
|
2334
2304
|
//
|
|
2335
|
-
// It therefore leads with the decision
|
|
2336
|
-
// INVENTED HERE
|
|
2337
|
-
//
|
|
2305
|
+
// It therefore leads with the decision and says plainly that the answer is
|
|
2306
|
+
// INVENTED HERE. It used to go on to name the three secrets it is NOT, which
|
|
2307
|
+
// was accurate and was four more lines of denial in a prompt that is already
|
|
2308
|
+
// the longest question in setup; reported as over-explained, and cut back to
|
|
2309
|
+
// the three facts an answer depends on (new, what it protects, unrecoverable).
|
|
2310
|
+
// The UNLOCK branch keeps its version of that list, because there the partner
|
|
2311
|
+
// is being asked for a password they may not remember choosing and naming the
|
|
2312
|
+
// wrong candidates is the whole help.
|
|
2338
2313
|
// NOT ASKED IN INBOX MODE, because the honest answer to "protect your customer
|
|
2339
2314
|
// records" is that there are none here: the connector holds them. The
|
|
2340
2315
|
// database in this mode carries dispatch ciphertext - already encrypted to a
|
|
@@ -2354,14 +2329,10 @@ async function resolvePassphrase(): Promise<string | null> {
|
|
|
2354
2329
|
}
|
|
2355
2330
|
|
|
2356
2331
|
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');
|
|
2332
|
+
process.stdout.write(' MAKE UP A NEW PASSWORD. Invented here, not one you already\\n');
|
|
2333
|
+
process.stdout.write(' hold. It encrypts the customer records this receiver keeps in\\n');
|
|
2334
|
+
process.stdout.write(' data.db. OneAddress never sees it and cannot reset it.\\n\\n');
|
|
2335
|
+
process.stdout.write(' Optional. Press Enter to skip and leave the database readable.\\n');
|
|
2365
2336
|
// ACCURATE ABOUT "LATER", because the obvious reading is wrong. Setting a
|
|
2366
2337
|
// password afterwards works and the receiver reports itself encrypted, but
|
|
2367
2338
|
// rows written before it stay in the clear until something rewrites them -
|
|
@@ -2758,7 +2729,7 @@ import express, { type Request, type Response } from 'express';
|
|
|
2758
2729
|
import rateLimit from 'express-rate-limit';
|
|
2759
2730
|
import { timingSafeEqual } from 'node:crypto';
|
|
2760
2731
|
import { report } from './report.js';
|
|
2761
|
-
import { acknowledge, drawable, markDrawn, type InboxOutcome } from './inbox.js';
|
|
2732
|
+
import { acknowledge, drawable, hold, markDrawn, type InboxOutcome } from './inbox.js';
|
|
2762
2733
|
|
|
2763
2734
|
const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
|
|
2764
2735
|
/**
|
|
@@ -2851,18 +2822,41 @@ export function startDrawApi(): { port: number } | null {
|
|
|
2851
2822
|
* confirm, which is the honest answer when the partner's own system refused
|
|
2852
2823
|
* the change: the consumer is told it did not land rather than being told it
|
|
2853
2824
|
* did.
|
|
2825
|
+
*
|
|
2826
|
+
* \`held\` QUEUES NOTHING, and that is the whole point of it. It means the
|
|
2827
|
+
* connector could not OPEN the dispatch, which is a fact about a key rather
|
|
2828
|
+
* than about the consumer's record - and a \`failed\` confirm is read as the
|
|
2829
|
+
* latter all the way down: \`partner_failed\`, terminal, refund denied. The
|
|
2830
|
+
* item stays unacknowledged so the next draw retries it once the key is
|
|
2831
|
+
* fixed. See \`hold\` in inbox.ts for why nothing else recovers this.
|
|
2854
2832
|
*/
|
|
2855
2833
|
app.post('/ack', (req: Request, res: Response) => {
|
|
2856
2834
|
if (!authorised(req, res)) return;
|
|
2857
2835
|
const body = req.body as { id?: unknown; outcome?: unknown; detail?: unknown };
|
|
2858
2836
|
const id = typeof body.id === 'string' ? body.id : '';
|
|
2859
|
-
const outcome: InboxOutcome | null =
|
|
2860
|
-
body.outcome === 'applied' || body.outcome === 'failed'
|
|
2837
|
+
const outcome: InboxOutcome | 'held' | null =
|
|
2838
|
+
body.outcome === 'applied' || body.outcome === 'failed' || body.outcome === 'held'
|
|
2839
|
+
? body.outcome
|
|
2840
|
+
: null;
|
|
2861
2841
|
if (!id || !outcome) {
|
|
2862
|
-
return res.status(400).json({ error: 'id and outcome (applied|failed) are required' });
|
|
2842
|
+
return res.status(400).json({ error: 'id and outcome (applied|failed|held) are required' });
|
|
2863
2843
|
}
|
|
2864
2844
|
const detail = typeof body.detail === 'string' ? body.detail : null;
|
|
2865
2845
|
|
|
2846
|
+
// HELD RETURNS BEFORE \`acknowledge\`, so there is no path from here to a
|
|
2847
|
+
// confirm. Ordering rather than a flag: a held item that fell through to
|
|
2848
|
+
// the code below would be stamped terminal and reported to OneAddress as
|
|
2849
|
+
// the consumer's problem, which is the defect this exists to remove.
|
|
2850
|
+
if (outcome === 'held') {
|
|
2851
|
+
const heldResult = hold(id, detail, LEASE_SECONDS);
|
|
2852
|
+
if (!heldResult) return res.status(404).json({ error: 'unknown or already acknowledged id' });
|
|
2853
|
+
return res.json({
|
|
2854
|
+
ok: true, held: true,
|
|
2855
|
+
attempts: heldResult.attempts,
|
|
2856
|
+
retryInSeconds: heldResult.retryInSeconds,
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2866
2860
|
const result = acknowledge(id, outcome, detail);
|
|
2867
2861
|
if (!result) return res.status(404).json({ error: 'unknown id' });
|
|
2868
2862
|
if (result.alreadyAcknowledged) {
|
|
@@ -2962,9 +2956,16 @@ export function setAcknowledgementHandler(
|
|
|
2962
2956
|
* the claim has to become atomic, the way \`confirm-queue.ts\` already does it.
|
|
2963
2957
|
* Written down because a lease LOOKS like it handles concurrency and does not.
|
|
2964
2958
|
*/
|
|
2965
|
-
import db from './db.js';
|
|
2959
|
+
import db, { ensureColumn } from './db.js';
|
|
2966
2960
|
import { report } from './report.js';
|
|
2967
2961
|
|
|
2962
|
+
/**
|
|
2963
|
+
* The connector's TERMINAL verdicts. Both end the item and both tell OneAddress.
|
|
2964
|
+
*
|
|
2965
|
+
* \`held\` is deliberately NOT one of these, and lives in its own function. See
|
|
2966
|
+
* \`hold\` below for why a dispatch the connector could not OPEN must never
|
|
2967
|
+
* become one of these two.
|
|
2968
|
+
*/
|
|
2968
2969
|
export type InboxOutcome = 'applied' | 'failed';
|
|
2969
2970
|
|
|
2970
2971
|
db.exec(\`
|
|
@@ -2983,6 +2984,30 @@ db.exec(\`
|
|
|
2983
2984
|
ON inbox(applied_at, drawn_at, received_at);
|
|
2984
2985
|
\`);
|
|
2985
2986
|
|
|
2987
|
+
/**
|
|
2988
|
+
* Why a held item needs its own two columns, matching \`quarantine\`'s.
|
|
2989
|
+
*
|
|
2990
|
+
* A dispatch the connector cannot OPEN is not finished with, so it keeps
|
|
2991
|
+
* \`applied_at IS NULL\` and stays in the undrawn count. But an operator then
|
|
2992
|
+
* needs to know it is BLOCKED rather than merely queued, and by what: without
|
|
2993
|
+
* these the dashboard can say a number and nothing else, and the connector's
|
|
2994
|
+
* message - which names the exact environment variable to set - is only in a
|
|
2995
|
+
* log somebody has to still have.
|
|
2996
|
+
*
|
|
2997
|
+
* Same names as the quarantine's, because it is the same idea one process over
|
|
2998
|
+
* and a second vocabulary for it would be a second thing to learn.
|
|
2999
|
+
*/
|
|
3000
|
+
ensureColumn('inbox', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
|
|
3001
|
+
ensureColumn('inbox', 'last_error', 'TEXT');
|
|
3002
|
+
|
|
3003
|
+
/**
|
|
3004
|
+
* First retry for a held item, multiplied by the attempt count and capped at
|
|
3005
|
+
* the lease. Thirty seconds because the overwhelming case is a partner who has
|
|
3006
|
+
* just corrected a key and restarted the connector, and making them wait out a
|
|
3007
|
+
* lease built for crash recovery would be answering a question nobody asked.
|
|
3008
|
+
*/
|
|
3009
|
+
const HELD_RETRY_BASE_SECONDS = 30;
|
|
3010
|
+
|
|
2986
3011
|
export interface AcceptInput {
|
|
2987
3012
|
key: string;
|
|
2988
3013
|
dispatchId: string | null;
|
|
@@ -3064,6 +3089,110 @@ export function acknowledge(
|
|
|
3064
3089
|
return { dispatchId: row.dispatch_id, alreadyAcknowledged: false };
|
|
3065
3090
|
}
|
|
3066
3091
|
|
|
3092
|
+
/**
|
|
3093
|
+
* The connector could not OPEN this one, so it is not finished with.
|
|
3094
|
+
*
|
|
3095
|
+
* ## Why this is not \`failed\`
|
|
3096
|
+
*
|
|
3097
|
+
* \`failed\` is a statement about the CONSUMER'S RECORD - the partner's system
|
|
3098
|
+
* received the change and refused it - and OneAddress reads it exactly that
|
|
3099
|
+
* way. \`/api/confirm\` resolves every non-success to \`partner_failed\`, that row
|
|
3100
|
+
* is terminal (a later SUCCESSFUL confirm cannot overwrite it), and the refund
|
|
3101
|
+
* classifier maps it to \`no_match\`: "the partner received it and said no", so
|
|
3102
|
+
* the refund is DENIED and the consumer is emailed that it did not land.
|
|
3103
|
+
*
|
|
3104
|
+
* "I could not read it" is not that statement. It is a fact about the
|
|
3105
|
+
* partner's key configuration and has nothing to do with the consumer. Sending
|
|
3106
|
+
* it as \`failed\` charged a consumer five dollars, told them a provider had
|
|
3107
|
+
* rejected them, and refused them a refund, because a key was wrong in a
|
|
3108
|
+
* process they have never heard of.
|
|
3109
|
+
*
|
|
3110
|
+
* ## Why there is no retry to fall back on
|
|
3111
|
+
*
|
|
3112
|
+
* \`draw-loop.ts\` used to reason that "OneAddress's own retry of the dispatch is
|
|
3113
|
+
* the recovery path". That is true in write-through, where an unopenable
|
|
3114
|
+
* dispatch answers 422 and the dispatch row stays \`failed\` for the retry cron
|
|
3115
|
+
* to pick up. It is FALSE here, for the reason this mode exists: the receiver
|
|
3116
|
+
* already answered 200 at arrival, so the dispatch row is \`delivered\`, and
|
|
3117
|
+
* \`retry-webhooks\` only ever re-sends rows that are \`pending\` or \`failed\`.
|
|
3118
|
+
* Nothing retries. The confirm was the only remaining signal and it was being
|
|
3119
|
+
* spent on a verdict that was not true.
|
|
3120
|
+
*
|
|
3121
|
+
* ## What happens instead
|
|
3122
|
+
*
|
|
3123
|
+
* The item stays unacknowledged, so it stays in the undrawn count, which the
|
|
3124
|
+
* dashboard already reports as a fault - and that is the behaviour recorded in
|
|
3125
|
+
* the new-matter register for this mode: an undrawn update is held INDEFINITELY
|
|
3126
|
+
* and reported as a fault, deliberately unlike the quarantine's 30-day purge,
|
|
3127
|
+
* because an undrawn update is a consumer whose address did not land and ageing
|
|
3128
|
+
* it out loses it silently.
|
|
3129
|
+
*
|
|
3130
|
+
* Re-stamping \`drawn_at\` is the backoff and costs no new mechanism: \`drawable\`
|
|
3131
|
+
* re-offers an item once its stamp is older than the lease, so the next attempt
|
|
3132
|
+
* is one lease away and the poll loop cannot spin on a key that is still wrong.
|
|
3133
|
+
* When the key is fixed the next draw applies it, with no operator command -
|
|
3134
|
+
* which is the half that \`[r]\` has to be pressed for in write-through.
|
|
3135
|
+
*
|
|
3136
|
+
* Returns null for an unknown id or one already terminally acknowledged, the
|
|
3137
|
+
* same tolerance \`acknowledge\` has and for the same reason.
|
|
3138
|
+
*/
|
|
3139
|
+
export function hold(
|
|
3140
|
+
id: string,
|
|
3141
|
+
detail: string | null,
|
|
3142
|
+
leaseSeconds: number,
|
|
3143
|
+
): { attempts: number; retryInSeconds: number } | null {
|
|
3144
|
+
const row = db.prepare(
|
|
3145
|
+
'SELECT applied_at, attempts FROM inbox WHERE id = ?',
|
|
3146
|
+
).get(id) as { applied_at: string | null; attempts: number } | undefined;
|
|
3147
|
+
if (!row) return null;
|
|
3148
|
+
if (row.applied_at !== null) return null;
|
|
3149
|
+
|
|
3150
|
+
const attempts = (row.attempts ?? 0) + 1;
|
|
3151
|
+
// \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
|
|
3152
|
+
//
|
|
3153
|
+
// The lease is 300s and is generous ON PURPOSE: re-offering a DRAWN item too
|
|
3154
|
+
// early risks applying the same update twice. A held item carries none of
|
|
3155
|
+
// that risk, because nothing was applied - it could not even be opened. So
|
|
3156
|
+
// reusing the lease would make a partner who has just fixed their key wait
|
|
3157
|
+
// five minutes for a safety property that is not in play.
|
|
3158
|
+
//
|
|
3159
|
+
// \`drawable\` re-offers a row once \`drawn_at < now - lease\`, so a stamp of
|
|
3160
|
+
// \`now - lease + backoff\` becomes drawable in exactly \`backoff\` seconds.
|
|
3161
|
+
// That is the whole mechanism: no column, no second query, no timer. Pinned
|
|
3162
|
+
// by a test that checks it is NOT drawable before the backoff and IS after,
|
|
3163
|
+
// because arithmetic written backwards still produces a plausible timestamp.
|
|
3164
|
+
//
|
|
3165
|
+
// Capped at the lease so a repeatedly-failing item can never retry FASTER
|
|
3166
|
+
// than a crashed connector's item, which would be the wrong way round.
|
|
3167
|
+
const retryInSeconds = Math.min(HELD_RETRY_BASE_SECONDS * attempts, leaseSeconds);
|
|
3168
|
+
const stamp = new Date(Date.now() - leaseSeconds * 1000 + retryInSeconds * 1000).toISOString();
|
|
3169
|
+
db.prepare(
|
|
3170
|
+
'UPDATE inbox SET attempts = ?, last_error = ?, drawn_at = ? WHERE id = ?',
|
|
3171
|
+
).run(attempts, detail?.slice(0, 500) ?? null, stamp, id);
|
|
3172
|
+
report.warn(
|
|
3173
|
+
\`[inbox] \${id} HELD after \${attempts} attempt(s), retrying in \${retryInSeconds}s, not failed: \` +
|
|
3174
|
+
\`\${detail ?? 'the connector could not open it'}\`,
|
|
3175
|
+
);
|
|
3176
|
+
return { attempts, retryInSeconds };
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
/**
|
|
3180
|
+
* The held items, for the dashboard's faults band.
|
|
3181
|
+
*
|
|
3182
|
+
* Separate from \`undrawnCount\` because the two mean different things to an
|
|
3183
|
+
* operator: undrawn is "your connector has not got to it yet", held is "your
|
|
3184
|
+
* connector tried and cannot". Only the second one needs somebody to do
|
|
3185
|
+
* something, and it is the one carrying the message that says what.
|
|
3186
|
+
*/
|
|
3187
|
+
export function heldItems(): { id: string; attempts: number; lastError: string | null }[] {
|
|
3188
|
+
return db.prepare(
|
|
3189
|
+
\`SELECT id, attempts, last_error AS lastError
|
|
3190
|
+
FROM inbox
|
|
3191
|
+
WHERE applied_at IS NULL AND attempts > 0
|
|
3192
|
+
ORDER BY received_at\`,
|
|
3193
|
+
).all() as unknown as { id: string; attempts: number; lastError: string | null }[];
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3067
3196
|
/** How many updates are sitting here unapplied. Shown as a fault when non-zero. */
|
|
3068
3197
|
export function undrawnCount(): number {
|
|
3069
3198
|
const row = db.prepare(
|
|
@@ -3297,6 +3426,38 @@ export function dispatchKey(dispatchId: string | null, rawBody: string): string
|
|
|
3297
3426
|
*/
|
|
3298
3427
|
ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
|
|
3299
3428
|
|
|
3429
|
+
/**
|
|
3430
|
+
* When the operator decided this one is not going to be applied.
|
|
3431
|
+
*
|
|
3432
|
+
* ## Why a dashboard needs this at all
|
|
3433
|
+
*
|
|
3434
|
+
* The conformance suite sends a dispatch that CANNOT be opened, deliberately:
|
|
3435
|
+
* check-11 wraps a session key to a throwaway key pair and passes only if the
|
|
3436
|
+
* receiver reports the failure instead of answering \`200 { ok: true }\`. Holding
|
|
3437
|
+
* it is the correct behaviour and is how the check is passed. The receiver then
|
|
3438
|
+
* draws a red FAULTS panel about it for the rest of the database's life, and a
|
|
3439
|
+
* partner who has just been told they passed is looking at a permanent fault
|
|
3440
|
+
* they cannot clear. Reported from a real run.
|
|
3441
|
+
*
|
|
3442
|
+
* ## Why the receiver does NOT recognise the probe itself
|
|
3443
|
+
*
|
|
3444
|
+
* The tempting fix is to spot the probe and not count it. Everything that
|
|
3445
|
+
* identifies one comes from the SENDER: the dispatch id is a header, and the
|
|
3446
|
+
* ciphertext is opaque by construction. Suppressing an operator's fault panel on
|
|
3447
|
+
* a value the sender chose is a way to hide a real held dispatch from the person
|
|
3448
|
+
* whose job is to notice it, and it would be reachable by anyone holding the
|
|
3449
|
+
* webhook secret. The signal stays; the OPERATOR gets a way to answer it.
|
|
3450
|
+
*
|
|
3451
|
+
* ## Why the payload goes with it
|
|
3452
|
+
*
|
|
3453
|
+
* Dismissing says "I am not applying this". A held payload is a consumer's
|
|
3454
|
+
* encrypted address sitting on a third party's disk, kept only because applying
|
|
3455
|
+
* it later is still on the table. Once it is not, the reason to keep it is gone,
|
|
3456
|
+
* so \`raw_body\` is emptied in the same statement. That also makes dismissal
|
|
3457
|
+
* mean what it says: \`[r]\` cannot quietly bring it back.
|
|
3458
|
+
*/
|
|
3459
|
+
ensureColumn('quarantine', 'dismissed_at', 'TEXT');
|
|
3460
|
+
|
|
3300
3461
|
export interface QuarantineInput {
|
|
3301
3462
|
dispatchId: string | null;
|
|
3302
3463
|
event: string;
|
|
@@ -3360,7 +3521,7 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
|
|
|
3360
3521
|
\`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
|
|
3361
3522
|
received_at, last_error, attempts
|
|
3362
3523
|
FROM quarantine
|
|
3363
|
-
WHERE replayed_at IS NULL
|
|
3524
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL
|
|
3364
3525
|
ORDER BY received_at
|
|
3365
3526
|
LIMIT ?\`,
|
|
3366
3527
|
).all(limit) as unknown as HeldDispatch[];
|
|
@@ -3369,7 +3530,7 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
|
|
|
3369
3530
|
/** How many dispatches are held. Shown on the dashboard. */
|
|
3370
3531
|
export function heldCount(): number {
|
|
3371
3532
|
const row = db.prepare(
|
|
3372
|
-
'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL',
|
|
3533
|
+
'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL AND dismissed_at IS NULL',
|
|
3373
3534
|
).get() as { n: number };
|
|
3374
3535
|
return row.n;
|
|
3375
3536
|
}
|
|
@@ -3386,7 +3547,7 @@ export function heldSummary(): string[] {
|
|
|
3386
3547
|
\`SELECT reason, key_id, count(*) AS n,
|
|
3387
3548
|
max(attempts) AS tries, min(received_at) AS oldest
|
|
3388
3549
|
FROM quarantine
|
|
3389
|
-
WHERE replayed_at IS NULL
|
|
3550
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL
|
|
3390
3551
|
GROUP BY reason, key_id
|
|
3391
3552
|
ORDER BY n DESC\`,
|
|
3392
3553
|
).all() as unknown as {
|
|
@@ -3430,6 +3591,38 @@ export function markReplayFailed(id: string, error: string): void {
|
|
|
3430
3591
|
.run(error.slice(0, 500), id);
|
|
3431
3592
|
}
|
|
3432
3593
|
|
|
3594
|
+
/**
|
|
3595
|
+
* The operator's answer to the fault panel: not applying these, stop counting them.
|
|
3596
|
+
*
|
|
3597
|
+
* Takes everything currently held rather than one row, because the panel groups
|
|
3598
|
+
* by cause and offers no way to point at a single dispatch. The realistic use is
|
|
3599
|
+
* one decision about one cause, which is the shape the panel already shows.
|
|
3600
|
+
*
|
|
3601
|
+
* \`raw_body\` is emptied in the same statement. See the \`dismissed_at\` docstring
|
|
3602
|
+
* for why: once the operator has said it will not be applied, a consumer's
|
|
3603
|
+
* encrypted address is being kept for no reason, and a dismissal that leaves the
|
|
3604
|
+
* payload behind is one \`[r]\` away from not being a dismissal.
|
|
3605
|
+
*
|
|
3606
|
+
* The row itself STAYS. It is the record that a dispatch arrived and was
|
|
3607
|
+
* consciously dropped, which is the fact an audit wants, and \`purgeQuarantine\`
|
|
3608
|
+
* ages it out on the same window as everything else.
|
|
3609
|
+
*/
|
|
3610
|
+
export function dismissHeld(): number {
|
|
3611
|
+
const info = db.prepare(
|
|
3612
|
+
\`UPDATE quarantine
|
|
3613
|
+
SET dismissed_at = ?, raw_body = ''
|
|
3614
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL\`,
|
|
3615
|
+
).run(new Date().toISOString());
|
|
3616
|
+
const n = Number(info.changes ?? 0);
|
|
3617
|
+
if (n > 0) {
|
|
3618
|
+
report.warn(
|
|
3619
|
+
\`[quarantine] dismissed \${n} held dispatch(es). They will NOT be applied and their \` +
|
|
3620
|
+
'payloads have been discarded. OneAddress was already told each one failed.',
|
|
3621
|
+
);
|
|
3622
|
+
}
|
|
3623
|
+
return n;
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3433
3626
|
/**
|
|
3434
3627
|
* Drop held payloads past the retention window, replayed or not.
|
|
3435
3628
|
*
|
|
@@ -3915,6 +4108,16 @@ export type ReceiverConfig = {
|
|
|
3915
4108
|
oneAddressApi: string;
|
|
3916
4109
|
verifiesAccountReference: boolean;
|
|
3917
4110
|
mode: ReceiverMode;
|
|
4111
|
+
/**
|
|
4112
|
+
* The webhook URL registered with OneAddress: where dispatches actually
|
|
4113
|
+
* arrive from the internet.
|
|
4114
|
+
*
|
|
4115
|
+
* Written by the wizard, which is the only thing that knows it - the receiver
|
|
4116
|
+
* binds a local port and has no idea what hostname reaches it. Empty disables
|
|
4117
|
+
* the reachability check rather than guessing, because a wrong URL here would
|
|
4118
|
+
* report a healthy receiver as unreachable, which is worse than silence.
|
|
4119
|
+
*/
|
|
4120
|
+
publicUrl: string;
|
|
3918
4121
|
};
|
|
3919
4122
|
|
|
3920
4123
|
const DEFAULTS: ReceiverConfig = {
|
|
@@ -3929,6 +4132,8 @@ const DEFAULTS: ReceiverConfig = {
|
|
|
3929
4132
|
// running and a key living somewhere else; a receiver that silently switched
|
|
3930
4133
|
// into it would accept dispatches nothing ever collects.
|
|
3931
4134
|
mode: 'write-through',
|
|
4135
|
+
// No guess. See the field's docstring.
|
|
4136
|
+
publicUrl: '',
|
|
3932
4137
|
};
|
|
3933
4138
|
|
|
3934
4139
|
function parseMode(value: unknown): ReceiverMode | undefined {
|
|
@@ -3947,6 +4152,7 @@ function loadConfigFile(): Partial<ReceiverConfig> {
|
|
|
3947
4152
|
if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
|
|
3948
4153
|
if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
|
|
3949
4154
|
if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
|
|
4155
|
+
if (typeof parsed.publicUrl === 'string') out.publicUrl = parsed.publicUrl;
|
|
3950
4156
|
const mode = parseMode(parsed.mode);
|
|
3951
4157
|
if (mode) out.mode = mode;
|
|
3952
4158
|
return out;
|
|
@@ -3970,6 +4176,7 @@ export const config: ReceiverConfig = {
|
|
|
3970
4176
|
// up as "not the mode I asked for" rather than as a receiver that will not
|
|
3971
4177
|
// start. Silent is the thing to avoid, not strict.
|
|
3972
4178
|
mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
|
|
4179
|
+
publicUrl: stripTrailingSlash(process.env.PUBLIC_URL || fromFile.publicUrl || DEFAULTS.publicUrl),
|
|
3973
4180
|
};
|
|
3974
4181
|
|
|
3975
4182
|
report.info(
|
|
@@ -3977,6 +4184,188 @@ report.info(
|
|
|
3977
4184
|
', oneAddressApi=' + config.oneAddressApi +
|
|
3978
4185
|
', verifiesAccountReference=' + config.verifiesAccountReference + ')',
|
|
3979
4186
|
);
|
|
4187
|
+
`
|
|
4188
|
+
},
|
|
4189
|
+
{
|
|
4190
|
+
name: "src/reachable.ts",
|
|
4191
|
+
content: `/**
|
|
4192
|
+
* IS ANYTHING ON THE INTERNET STILL REACHING THIS RECEIVER?
|
|
4193
|
+
*
|
|
4194
|
+
* ## The problem, and why the receiver cannot simply notice
|
|
4195
|
+
*
|
|
4196
|
+
* A partner running behind a tunnel stops the tunnel - closes the laptop, ends
|
|
4197
|
+
* the terminal, lets the trial lapse - and the receiver notices nothing. That is
|
|
4198
|
+
* not an oversight: the tunnel DIALS IN to this process. Nothing here holds a
|
|
4199
|
+
* connection outward, so from inside, "the tunnel is down" and "nobody sent me
|
|
4200
|
+
* anything" are the same observation, which is silence. And silence is not a
|
|
4201
|
+
* fault: a small partner legitimately goes days between dispatches, so a
|
|
4202
|
+
* watchdog that fired on quiet would cry wolf at exactly the partners least able
|
|
4203
|
+
* to tell the difference.
|
|
4204
|
+
*
|
|
4205
|
+
* Meanwhile OneAddress is failing to deliver, marking the endpoint unhealthy,
|
|
4206
|
+
* and the only place that says so is a portal nobody has open.
|
|
4207
|
+
*
|
|
4208
|
+
* ## How this answers it
|
|
4209
|
+
*
|
|
4210
|
+
* The receiver fetches ITS OWN PUBLIC URL and checks that a nonce minted at
|
|
4211
|
+
* startup comes back. Only this running process knows that value, which is what
|
|
4212
|
+
* makes the answer unambiguous.
|
|
4213
|
+
*
|
|
4214
|
+
* "DID ANYTHING ANSWER" IS NOT GOOD ENOUGH, and that is the whole reason for
|
|
4215
|
+
* the nonce. A stopped Cloudflare tunnel does not refuse the connection: the
|
|
4216
|
+
* edge is still there and serves an error page (1033, or a 502). That is a
|
|
4217
|
+
* perfectly valid HTTP response, so a check written \`if (res.ok)\` - or even
|
|
4218
|
+
* \`if (res.status < 500)\` - reports a dead tunnel as healthy. A check that
|
|
4219
|
+
* demands the nonce cannot be satisfied by anything except this process.
|
|
4220
|
+
*
|
|
4221
|
+
* ## What it deliberately does not do
|
|
4222
|
+
*
|
|
4223
|
+
* It does not fail the receiver, retry aggressively, or call OneAddress. It is
|
|
4224
|
+
* a statement on the dashboard and a log line. A receiver that shut itself down
|
|
4225
|
+
* because it could not see itself would turn a DNS blip into an outage, and the
|
|
4226
|
+
* failure it is reporting is one only a human can fix.
|
|
4227
|
+
*
|
|
4228
|
+
* It is also not a security control. The nonce proves identity of the process,
|
|
4229
|
+
* not authorisation: the endpoint returns it to anyone who asks. It is a random
|
|
4230
|
+
* value with no meaning outside this check, and knowing it grants nothing - the
|
|
4231
|
+
* webhook still requires a valid HMAC.
|
|
4232
|
+
*/
|
|
4233
|
+
import { randomBytes } from 'node:crypto';
|
|
4234
|
+
import { config } from './config.js';
|
|
4235
|
+
import { report } from './report.js';
|
|
4236
|
+
|
|
4237
|
+
/** Minted once per process. See the header: this is what makes the answer
|
|
4238
|
+
* unambiguous, and it is deliberately not a secret. */
|
|
4239
|
+
export const ALIVE_NONCE = randomBytes(16).toString('hex');
|
|
4240
|
+
|
|
4241
|
+
/** The path the check hits. Deliberately NOT \`/webhook\`: that is POST-only and
|
|
4242
|
+
* would 404 or 405 through a perfectly healthy tunnel. */
|
|
4243
|
+
export const ALIVE_PATH = '/oa-receiver-alive';
|
|
4244
|
+
|
|
4245
|
+
export type Reachability =
|
|
4246
|
+
| { state: 'unknown'; detail: string }
|
|
4247
|
+
| { state: 'reachable'; checkedAt: string }
|
|
4248
|
+
| { state: 'unreachable'; detail: string; since: string };
|
|
4249
|
+
|
|
4250
|
+
let current: Reachability = { state: 'unknown', detail: 'not checked yet' };
|
|
4251
|
+
|
|
4252
|
+
/** What the dashboard renders. Never throws. */
|
|
4253
|
+
export function reachability(): Reachability {
|
|
4254
|
+
return current;
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4257
|
+
/**
|
|
4258
|
+
* The ORIGIN to probe, or null when there is nothing usable to probe.
|
|
4259
|
+
*
|
|
4260
|
+
* TWO THINGS THIS FIXES, both found by reading the value the wizard actually
|
|
4261
|
+
* writes rather than the one this function wanted.
|
|
4262
|
+
*
|
|
4263
|
+
* 1. \`publicUrl\` is the REGISTERED WEBHOOK URL, so it ends in \`/webhook\`.
|
|
4264
|
+
* Appending the probe path to it gives \`\u2026/webhook/oa-receiver-alive\`, which
|
|
4265
|
+
* 404s through a perfectly healthy tunnel and would report every working
|
|
4266
|
+
* receiver as unreachable. The origin is what is wanted.
|
|
4267
|
+
* 2. The scaffold substitutes a PLACEHOLDER (\`<your-webhook-url>\`) when setup
|
|
4268
|
+
* ran without a registered URL. Probing that fails forever, so a partner who
|
|
4269
|
+
* skipped registration would get a permanent red panel about a fault they do
|
|
4270
|
+
* not have.
|
|
4271
|
+
*
|
|
4272
|
+
* Anything that is not an absolute http(s) URL therefore reads as NOT
|
|
4273
|
+
* CONFIGURED rather than as unreachable: the failure mode to avoid here is
|
|
4274
|
+
* crying wolf, since the whole value of the check is that it is believed.
|
|
4275
|
+
*/
|
|
4276
|
+
export function probeOrigin(raw: string): string | null {
|
|
4277
|
+
if (!raw) return null;
|
|
4278
|
+
try {
|
|
4279
|
+
const u = new URL(raw);
|
|
4280
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
4281
|
+
return u.origin;
|
|
4282
|
+
} catch {
|
|
4283
|
+
return null;
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
/**
|
|
4288
|
+
* One check. Returns the new state rather than only setting it, so a test can
|
|
4289
|
+
* drive it directly without reaching into module state.
|
|
4290
|
+
*/
|
|
4291
|
+
export async function checkReachable(fetchImpl: typeof fetch = fetch): Promise<Reachability> {
|
|
4292
|
+
const origin = probeOrigin(config.publicUrl);
|
|
4293
|
+
if (!origin) {
|
|
4294
|
+
current = {
|
|
4295
|
+
state: 'unknown',
|
|
4296
|
+
detail: config.publicUrl
|
|
4297
|
+
? 'publicUrl in oneaddress.config.json is not a usable http(s) URL'
|
|
4298
|
+
: 'no publicUrl in oneaddress.config.json',
|
|
4299
|
+
};
|
|
4300
|
+
return current;
|
|
4301
|
+
}
|
|
4302
|
+
|
|
4303
|
+
const url = \`\${origin}\${ALIVE_PATH}\`;
|
|
4304
|
+
const since = current.state === 'unreachable' ? current.since : new Date().toISOString();
|
|
4305
|
+
|
|
4306
|
+
try {
|
|
4307
|
+
const res = await fetchImpl(url, {
|
|
4308
|
+
method: 'GET',
|
|
4309
|
+
// Short: this is a liveness probe, not a download. A tunnel that takes
|
|
4310
|
+
// longer than this to answer is already failing dispatches, which time
|
|
4311
|
+
// out on OneAddress's side too.
|
|
4312
|
+
signal: AbortSignal.timeout(10_000),
|
|
4313
|
+
headers: { 'cache-control': 'no-cache' },
|
|
4314
|
+
});
|
|
4315
|
+
const body = await res.text().catch(() => '');
|
|
4316
|
+
// THE NONCE, not the status. See the header: a stopped tunnel answers with
|
|
4317
|
+
// a real HTTP response carrying an error page.
|
|
4318
|
+
if (body.trim() === ALIVE_NONCE) {
|
|
4319
|
+
current = { state: 'reachable', checkedAt: new Date().toISOString() };
|
|
4320
|
+
return current;
|
|
4321
|
+
}
|
|
4322
|
+
current = {
|
|
4323
|
+
state: 'unreachable',
|
|
4324
|
+
detail: \`\${url} answered HTTP \${res.status} but not from this receiver (tunnel down, or the URL points elsewhere)\`,
|
|
4325
|
+
since,
|
|
4326
|
+
};
|
|
4327
|
+
} catch (err) {
|
|
4328
|
+
current = {
|
|
4329
|
+
state: 'unreachable',
|
|
4330
|
+
detail: \`\${url} could not be reached: \${err instanceof Error ? err.message : String(err)}\`,
|
|
4331
|
+
since,
|
|
4332
|
+
};
|
|
4333
|
+
}
|
|
4334
|
+
return current;
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
/**
|
|
4338
|
+
* Check on a timer, and say so ONCE per transition rather than every tick.
|
|
4339
|
+
*
|
|
4340
|
+
* A line every five minutes for a tunnel that has been down all night is how a
|
|
4341
|
+
* log stops being read. The transition is the news.
|
|
4342
|
+
*/
|
|
4343
|
+
export function startReachabilityWatch(everyMs = 5 * 60_000): NodeJS.Timeout | null {
|
|
4344
|
+
if (!probeOrigin(config.publicUrl)) {
|
|
4345
|
+
report.info('[reachable] no usable publicUrl configured, so this receiver cannot check whether the internet can reach it.');
|
|
4346
|
+
return null;
|
|
4347
|
+
}
|
|
4348
|
+
let last: Reachability['state'] = 'unknown';
|
|
4349
|
+
const tick = (): void => {
|
|
4350
|
+
void checkReachable().then((r) => {
|
|
4351
|
+
if (r.state === last) return;
|
|
4352
|
+
last = r.state;
|
|
4353
|
+
if (r.state === 'unreachable') {
|
|
4354
|
+
report.error(\`[reachable] NOTHING IS REACHING THIS RECEIVER. \${r.detail}. Dispatches cannot arrive while this is true.\`);
|
|
4355
|
+
} else if (r.state === 'reachable') {
|
|
4356
|
+
report.info('[reachable] your public URL reaches this receiver.');
|
|
4357
|
+
}
|
|
4358
|
+
});
|
|
4359
|
+
};
|
|
4360
|
+
// A first check soon after startup, not immediately: a tunnel started
|
|
4361
|
+
// alongside the receiver needs a moment before it answers, and reporting it
|
|
4362
|
+
// down on second one would be wrong every single time.
|
|
4363
|
+
const first = setTimeout(tick, 15_000);
|
|
4364
|
+
first.unref?.();
|
|
4365
|
+
const timer = setInterval(tick, everyMs);
|
|
4366
|
+
timer.unref?.();
|
|
4367
|
+
return timer;
|
|
4368
|
+
}
|
|
3980
4369
|
`
|
|
3981
4370
|
},
|
|
3982
4371
|
{
|
|
@@ -4531,6 +4920,66 @@ export function customerCount(): number {
|
|
|
4531
4920
|
return (db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }).n;
|
|
4532
4921
|
}
|
|
4533
4922
|
|
|
4923
|
+
/** One applied change: what this receiver held before, and what it holds now. */
|
|
4924
|
+
export interface AddressChange {
|
|
4925
|
+
account_number: string;
|
|
4926
|
+
name: string;
|
|
4927
|
+
prev_address: Address | null;
|
|
4928
|
+
address: Address;
|
|
4929
|
+
recorded_at: string;
|
|
4930
|
+
}
|
|
4931
|
+
|
|
4932
|
+
/**
|
|
4933
|
+
* The applied changes, newest first, decrypted and joined back to the customer.
|
|
4934
|
+
*
|
|
4935
|
+
* ## Why this is exported rather than left to \`sqlite3\`
|
|
4936
|
+
*
|
|
4937
|
+
* Because \`sqlite3 data.db\` cannot answer it. Every text column here is
|
|
4938
|
+
* AES-GCM ciphertext whenever the database has a password, which is the state
|
|
4939
|
+
* this receiver asks for on its first run and reports in green on the
|
|
4940
|
+
* dashboard. So the one question a partner has after their first real dispatch
|
|
4941
|
+
* - "show me it actually changed" - had no answer short of writing code against
|
|
4942
|
+
* the store. The dashboard's LAST CHANGE panel shows the most recent one and
|
|
4943
|
+
* nothing shows the rest.
|
|
4944
|
+
*
|
|
4945
|
+
* \`prev_address\` is NULL for a row written before the column existed
|
|
4946
|
+
* (\`ensureColumn\` defaults it to \`'{}'\`, which decodes to an empty object, not
|
|
4947
|
+
* to an address) and for the first change on an account that had no address on
|
|
4948
|
+
* file. Those are different facts and both are honestly "nothing to compare
|
|
4949
|
+
* against", so both come back as null rather than as an empty address.
|
|
4950
|
+
*/
|
|
4951
|
+
export function addressHistory(limit = 20): AddressChange[] {
|
|
4952
|
+
const rows = db.prepare(
|
|
4953
|
+
\`SELECT h.prev_address AS prev_address,
|
|
4954
|
+
h.address AS address,
|
|
4955
|
+
h.recorded_at AS recorded_at,
|
|
4956
|
+
c.account_number AS account_number,
|
|
4957
|
+
c.name AS name
|
|
4958
|
+
FROM address_history h
|
|
4959
|
+
LEFT JOIN customers c ON c.account_key = h.account_key
|
|
4960
|
+
ORDER BY h.id DESC
|
|
4961
|
+
LIMIT ?\`,
|
|
4962
|
+
).all(limit) as unknown[];
|
|
4963
|
+
|
|
4964
|
+
return rows.map((raw) => {
|
|
4965
|
+
const r = raw as Record<string, string | null>;
|
|
4966
|
+
const prevJson = dec('address', r.prev_address);
|
|
4967
|
+
let prev: Address | null = null;
|
|
4968
|
+
try {
|
|
4969
|
+
const parsed = prevJson ? JSON.parse(prevJson) as Address : null;
|
|
4970
|
+
// \`{}\` is the column default, not an address anyone held.
|
|
4971
|
+
prev = parsed && Object.keys(parsed).length > 0 ? parsed : null;
|
|
4972
|
+
} catch { prev = null; }
|
|
4973
|
+
return {
|
|
4974
|
+
account_number: dec('account_number', r.account_number) ?? '(unknown)',
|
|
4975
|
+
name: dec('name', r.name) ?? '(unknown)',
|
|
4976
|
+
prev_address: prev,
|
|
4977
|
+
address: JSON.parse(dec('address', r.address) ?? '{}') as Address,
|
|
4978
|
+
recorded_at: r.recorded_at ?? '',
|
|
4979
|
+
};
|
|
4980
|
+
});
|
|
4981
|
+
}
|
|
4982
|
+
|
|
4534
4983
|
/** Is the file on disk protected? Surfaced in the dashboard, in both states. */
|
|
4535
4984
|
export const storeEncrypted = encrypted;
|
|
4536
4985
|
|
|
@@ -4723,6 +5172,7 @@ import {
|
|
|
4723
5172
|
// exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
|
|
4724
5173
|
// the protocol layer changes.
|
|
4725
5174
|
import { store as writeThroughStore } from './store.js';
|
|
5175
|
+
import { ALIVE_NONCE, ALIVE_PATH, startReachabilityWatch } from './reachable.js';
|
|
4726
5176
|
import { ConnectorUnreachableError, connectorStore, setCurrentRawBody } from './connector-store.js';
|
|
4727
5177
|
import { noteChange } from './tui.js';
|
|
4728
5178
|
import { config } from './config.js';
|
|
@@ -5518,9 +5968,26 @@ app.get('/health', (_req, res) => res.json({
|
|
|
5518
5968
|
encryptedAtRest: store.encrypted,
|
|
5519
5969
|
}));
|
|
5520
5970
|
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5971
|
+
/**
|
|
5972
|
+
* The reachability probe's target. Returns this process's startup nonce as
|
|
5973
|
+
* plain text, and nothing else.
|
|
5974
|
+
*
|
|
5975
|
+
* WHY A NONCE AND NOT \`/health\`. A stopped Cloudflare tunnel still ANSWERS -
|
|
5976
|
+
* the edge serves a 1033 or a 502 error page - so a check that accepts any HTTP
|
|
5977
|
+
* response reports a dead tunnel as healthy. Only this running process knows
|
|
5978
|
+
* this value, so receiving it back is proof the whole path is open. See
|
|
5979
|
+
* \`src/reachable.ts\`.
|
|
5980
|
+
*
|
|
5981
|
+
* NOT A SECRET, and nothing is gated on it: it proves which process answered,
|
|
5982
|
+
* not who asked. The webhook still requires a valid HMAC.
|
|
5983
|
+
*/
|
|
5984
|
+
app.get(ALIVE_PATH, (_req, res) => res.type('text/plain').send(ALIVE_NONCE));
|
|
5985
|
+
|
|
5986
|
+
app.listen(PORT, () => {
|
|
5987
|
+
report.info(\`[server] OneAddress webhook server \u2192 http://localhost:\${PORT}/webhook\`);
|
|
5988
|
+
// Started AFTER listen, or the first probe races the server it is probing.
|
|
5989
|
+
startReachabilityWatch();
|
|
5990
|
+
});
|
|
5524
5991
|
|
|
5525
5992
|
/**
|
|
5526
5993
|
* Drain the confirm queue, forever.
|
|
@@ -5678,6 +6145,228 @@ export function dashboardStats(): {
|
|
|
5678
6145
|
// there, so the port the UI claims is the port the server actually bound.
|
|
5679
6146
|
export { PORT };
|
|
5680
6147
|
export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
|
|
6148
|
+
`
|
|
6149
|
+
},
|
|
6150
|
+
{
|
|
6151
|
+
name: "src/unlock.ts",
|
|
6152
|
+
content: `/**
|
|
6153
|
+
* Opening a locked database: is it locked, and asking for the password.
|
|
6154
|
+
*
|
|
6155
|
+
* ## Why these two live in their own file
|
|
6156
|
+
*
|
|
6157
|
+
* Both are needed by \`src/index.ts\` (which prompts before starting the
|
|
6158
|
+
* receiver) and by \`scripts/show.ts\` (which prompts before reading the roster),
|
|
6159
|
+
* and a second copy of a masked-input routine is the last thing this codebase
|
|
6160
|
+
* needs: the comment inside \`ask\` records TWO earlier versions that echoed the
|
|
6161
|
+
* passphrase in clear, neither of which was caught by reading the code. One
|
|
6162
|
+
* implementation, driven by one test.
|
|
6163
|
+
*
|
|
6164
|
+
* NOTHING HERE IMPORTS \`db.ts\`, and that is a constraint rather than tidiness.
|
|
6165
|
+
* Importing the database derives its keys on import, so anything asking WHICH
|
|
6166
|
+
* QUESTION TO PUT has to run first. \`databaseIsLocked\` therefore opens its own
|
|
6167
|
+
* read-only connection.
|
|
6168
|
+
*/
|
|
6169
|
+
/**
|
|
6170
|
+
* Has this database already been locked?
|
|
6171
|
+
*
|
|
6172
|
+
* Asked BEFORE the passphrase, and without one, so the prompt can say which of
|
|
6173
|
+
* two completely different things it is doing. \`db_meta.verifier\` is written the
|
|
6174
|
+
* first time a passphrase is set, so its presence is the whole answer.
|
|
6175
|
+
*
|
|
6176
|
+
* Read through its own connection rather than importing \`db.ts\`, which derives
|
|
6177
|
+
* its keys the moment it is imported and would therefore have to run BEFORE we
|
|
6178
|
+
* know what to ask for.
|
|
6179
|
+
*/
|
|
6180
|
+
export async function databaseIsLocked(): Promise<boolean> {
|
|
6181
|
+
try {
|
|
6182
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
6183
|
+
const { join } = await import('node:path');
|
|
6184
|
+
const path = process.env.DB_PATH ?? join(process.cwd(), 'data.db');
|
|
6185
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
6186
|
+
try {
|
|
6187
|
+
const row = db
|
|
6188
|
+
.prepare("SELECT value FROM db_meta WHERE key = 'verifier'")
|
|
6189
|
+
.get() as { value?: string } | undefined;
|
|
6190
|
+
return Boolean(row?.value);
|
|
6191
|
+
} finally {
|
|
6192
|
+
db.close();
|
|
6193
|
+
}
|
|
6194
|
+
} catch {
|
|
6195
|
+
// No file yet, or no db_meta table yet. Either way: not locked.
|
|
6196
|
+
return false;
|
|
6197
|
+
}
|
|
6198
|
+
}
|
|
6199
|
+
|
|
6200
|
+
/**
|
|
6201
|
+
* Ask for a passphrase without echoing it to the screen.
|
|
6202
|
+
*
|
|
6203
|
+
* A passphrase typed in clear on a shared screen, in a screen-share, or into a
|
|
6204
|
+
* terminal that keeps scrollback is not much of a secret. readline echoes by
|
|
6205
|
+
* default, so its output hook is replaced for the duration of the question.
|
|
6206
|
+
*
|
|
6207
|
+
* Degrades to a visible prompt rather than failing: on a terminal where the
|
|
6208
|
+
* hook is not available, being asked in the clear beats not being asked.
|
|
6209
|
+
*/
|
|
6210
|
+
export async function ask(prompt: string): Promise<string> {
|
|
6211
|
+
process.stdout.write(prompt);
|
|
6212
|
+
|
|
6213
|
+
// NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
|
|
6214
|
+
// mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
|
|
6215
|
+
// by driving a real terminal and reading what came back. The first filtered
|
|
6216
|
+
// on whether the chunk contained the prompt, not knowing readline repaints
|
|
6217
|
+
// prompt and input together on every keystroke, so the condition was always
|
|
6218
|
+
// true. The second repainted the line and still leaked, because the echo was
|
|
6219
|
+
// never coming from that hook at all.
|
|
6220
|
+
//
|
|
6221
|
+
// Reading the keys directly removes the guessing. Raw mode turns the
|
|
6222
|
+
// terminal's own echo OFF, so the ONLY thing that can reach the screen is
|
|
6223
|
+
// what is written below: one asterisk per character, which is what a partner
|
|
6224
|
+
// asked for and what every other passphrase prompt does.
|
|
6225
|
+
const stdin = process.stdin;
|
|
6226
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
6227
|
+
// No terminal to control. Being asked in the clear beats not being asked,
|
|
6228
|
+
// and this path is only reached where nothing is watching anyway.
|
|
6229
|
+
const { createInterface } = await import('node:readline/promises');
|
|
6230
|
+
const rl = createInterface({ input: stdin, output: process.stdout });
|
|
6231
|
+
try {
|
|
6232
|
+
const answer = await rl.question('');
|
|
6233
|
+
return answer.trim();
|
|
6234
|
+
} finally { rl.close(); }
|
|
6235
|
+
}
|
|
6236
|
+
|
|
6237
|
+
const wasRaw = stdin.isRaw === true;
|
|
6238
|
+
stdin.setRawMode(true);
|
|
6239
|
+
stdin.resume();
|
|
6240
|
+
stdin.setEncoding('utf8');
|
|
6241
|
+
|
|
6242
|
+
return new Promise<string>((resolve) => {
|
|
6243
|
+
let typed = '';
|
|
6244
|
+
const restore = (): void => {
|
|
6245
|
+
stdin.removeListener('data', onData);
|
|
6246
|
+
stdin.setRawMode(wasRaw);
|
|
6247
|
+
stdin.pause();
|
|
6248
|
+
};
|
|
6249
|
+
const onData = (chunk: string): void => {
|
|
6250
|
+
for (const ch of chunk) {
|
|
6251
|
+
if (ch === '\\r' || ch === '\\n') {
|
|
6252
|
+
restore();
|
|
6253
|
+
process.stdout.write('\\n');
|
|
6254
|
+
resolve(typed.trim());
|
|
6255
|
+
return;
|
|
6256
|
+
}
|
|
6257
|
+
if (ch === '\\u0003') { // Ctrl+C
|
|
6258
|
+
restore();
|
|
6259
|
+
process.stdout.write('\\n');
|
|
6260
|
+
process.exit(130);
|
|
6261
|
+
}
|
|
6262
|
+
if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
|
|
6263
|
+
restore();
|
|
6264
|
+
process.stdout.write('\\n');
|
|
6265
|
+
resolve(typed.trim());
|
|
6266
|
+
return;
|
|
6267
|
+
}
|
|
6268
|
+
if (ch === '\\u007f' || ch === '\\b') {
|
|
6269
|
+
// Backspace has to move the asterisks too, or the mask stops matching
|
|
6270
|
+
// what is actually in the buffer and the count misleads.
|
|
6271
|
+
if (typed.length > 0) {
|
|
6272
|
+
typed = typed.slice(0, -1);
|
|
6273
|
+
process.stdout.write('\\b \\b');
|
|
6274
|
+
}
|
|
6275
|
+
continue;
|
|
6276
|
+
}
|
|
6277
|
+
if (ch < ' ') continue; // ignore the rest of the control range
|
|
6278
|
+
typed += ch;
|
|
6279
|
+
process.stdout.write('*');
|
|
6280
|
+
}
|
|
6281
|
+
};
|
|
6282
|
+
stdin.on('data', onData);
|
|
6283
|
+
});
|
|
6284
|
+
}
|
|
6285
|
+
`
|
|
6286
|
+
},
|
|
6287
|
+
{
|
|
6288
|
+
name: "scripts/show.ts",
|
|
6289
|
+
content: `/**
|
|
6290
|
+
* Show what this receiver holds: the roster, and every change it has applied.
|
|
6291
|
+
*
|
|
6292
|
+
* Usage:
|
|
6293
|
+
* npm run show \u2014 the roster and the last 20 changes
|
|
6294
|
+
* npm run show 100 \u2014 the last 100 changes
|
|
6295
|
+
*
|
|
6296
|
+
* ## Why this script exists
|
|
6297
|
+
*
|
|
6298
|
+
* The obvious way to check that a dispatch really landed is \`sqlite3 data.db\`,
|
|
6299
|
+
* and it does not work: the roster and the history are AES-GCM ciphertext
|
|
6300
|
+
* whenever the database has a password, which is what this receiver asks for on
|
|
6301
|
+
* its first run. Reported from a real run, immediately after a first successful
|
|
6302
|
+
* dispatch: "where do I look to prove it end to end?" The dashboard's LAST
|
|
6303
|
+
* CHANGE panel shows one change and nothing showed the rest.
|
|
6304
|
+
*
|
|
6305
|
+
* It prompts for the password for the same reason \`src/index.ts\` does, and for
|
|
6306
|
+
* the same reason it must do so BEFORE importing the store: the database
|
|
6307
|
+
* derives its keys on import.
|
|
6308
|
+
*
|
|
6309
|
+
* IN INBOX MODE THERE IS NOTHING HERE TO SHOW, and saying so is the answer
|
|
6310
|
+
* rather than printing an empty table. This receiver holds no key and no
|
|
6311
|
+
* customer records in that mode; the connector holds both, and the change is
|
|
6312
|
+
* in the connector's own store.
|
|
6313
|
+
*/
|
|
6314
|
+
import 'dotenv/config';
|
|
6315
|
+
import { config } from '../src/config.js';
|
|
6316
|
+
import { ask, databaseIsLocked } from '../src/unlock.js';
|
|
6317
|
+
|
|
6318
|
+
function fmt(a: Record<string, string> | null): string {
|
|
6319
|
+
if (!a) return '(nothing on file)';
|
|
6320
|
+
const parts = [a.street, a.suburb, a.state, a.postcode, a.country].filter(Boolean);
|
|
6321
|
+
return parts.join(', ') || '(empty)';
|
|
6322
|
+
}
|
|
6323
|
+
|
|
6324
|
+
async function askPassword(): Promise<void> {
|
|
6325
|
+
if (process.env.ONEADDRESS_DB_PASSPHRASE?.trim()) return;
|
|
6326
|
+
if (!process.stdin.isTTY) return;
|
|
6327
|
+
if (!(await databaseIsLocked())) return;
|
|
6328
|
+
const answer = await ask(' Password to unlock: ');
|
|
6329
|
+
if (answer) process.env.ONEADDRESS_DB_PASSPHRASE = answer;
|
|
6330
|
+
}
|
|
6331
|
+
|
|
6332
|
+
async function main(): Promise<void> {
|
|
6333
|
+
if (config.mode === 'inbox') {
|
|
6334
|
+
console.log('\\n This receiver is in inbox mode: it holds no customer records and no key.');
|
|
6335
|
+
console.log(' Your connector applied the change and holds the result. Look there.\\n');
|
|
6336
|
+
return;
|
|
6337
|
+
}
|
|
6338
|
+
|
|
6339
|
+
await askPassword();
|
|
6340
|
+
|
|
6341
|
+
const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
|
|
6342
|
+
const store = await import('../src/store.js');
|
|
6343
|
+
|
|
6344
|
+
const roster = store.allCustomers();
|
|
6345
|
+
console.log(\`\\n ON FILE (\${roster.length})\\n\`);
|
|
6346
|
+
for (const c of roster) {
|
|
6347
|
+
let addr: Record<string, string> | null = null;
|
|
6348
|
+
try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
|
|
6349
|
+
console.log(\` \${c.account_number.padEnd(14)} \${c.name.padEnd(24)} \${fmt(addr)}\`);
|
|
6350
|
+
}
|
|
6351
|
+
|
|
6352
|
+
const changes = store.addressHistory(limit);
|
|
6353
|
+
console.log(\`\\n APPLIED CHANGES (\${changes.length}, newest first)\\n\`);
|
|
6354
|
+
if (changes.length === 0) {
|
|
6355
|
+
console.log(' None yet. Send a dispatch and run this again.\\n');
|
|
6356
|
+
return;
|
|
6357
|
+
}
|
|
6358
|
+
for (const h of changes) {
|
|
6359
|
+
console.log(\` \${h.recorded_at} \${h.account_number} \${h.name}\`);
|
|
6360
|
+
console.log(\` was: \${fmt(h.prev_address as unknown as Record<string, string> | null)}\`);
|
|
6361
|
+
console.log(\` now: \${fmt(h.address as unknown as Record<string, string>)}\`);
|
|
6362
|
+
}
|
|
6363
|
+
console.log('');
|
|
6364
|
+
}
|
|
6365
|
+
|
|
6366
|
+
void main().catch((err: unknown) => {
|
|
6367
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
6368
|
+
process.exit(1);
|
|
6369
|
+
});
|
|
5681
6370
|
`
|
|
5682
6371
|
},
|
|
5683
6372
|
{
|
|
@@ -5858,14 +6547,36 @@ computes the LOA reference with \`d5LoaRef\`, and hands it to your store as
|
|
|
5858
6547
|
|
|
5859
6548
|
\`\`\`sql
|
|
5860
6549
|
-- Your customer roster: who you know + the address you hold on file today.
|
|
5861
|
-
--
|
|
5862
|
-
--
|
|
5863
|
-
|
|
6550
|
+
-- Seeded on startup from customers.json (edit that file, or point loadRoster in
|
|
6551
|
+
-- src/store.ts at your real customer table).
|
|
6552
|
+
--
|
|
6553
|
+
-- account_key is how a row is FOUND: a blind index of the account number when
|
|
6554
|
+
-- the database has a password, the lower-cased number when it does not. It is
|
|
6555
|
+
-- the key rather than the number itself because AES-GCM uses a fresh IV per
|
|
6556
|
+
-- write, so two encryptions of one account number differ and a primary key over
|
|
6557
|
+
-- the ciphertext would enforce nothing while looking like it did.
|
|
6558
|
+
customers(account_key, account_number, name, address JSON, updated_at)
|
|
5864
6559
|
|
|
5865
6560
|
-- Full history of every address change you apply (append-only audit trail).
|
|
5866
|
-
|
|
6561
|
+
-- BOTH SIDES of each change, so you can show what an address REPLACED and not
|
|
6562
|
+
-- only what it became.
|
|
6563
|
+
address_history(id, account_key, prev_address JSON, address JSON, recorded_at)
|
|
5867
6564
|
\`\`\`
|
|
5868
6565
|
|
|
6566
|
+
### Seeing what changed
|
|
6567
|
+
|
|
6568
|
+
\`\`\`bash
|
|
6569
|
+
npm run show # the roster, and the last 20 changes (was: / now:)
|
|
6570
|
+
npm run show 100 # the last 100
|
|
6571
|
+
\`\`\`
|
|
6572
|
+
|
|
6573
|
+
\`sqlite3 data.db\` will NOT answer this once the database has a password: every
|
|
6574
|
+
text column is ciphertext, which is the point. \`npm run show\` prompts for the
|
|
6575
|
+
same password \`npm start\` does and prints the decrypted rows.
|
|
6576
|
+
|
|
6577
|
+
In **inbox mode** there is nothing here to show. This receiver holds no key and
|
|
6578
|
+
no customer records; your connector applied the change and holds the result.
|
|
6579
|
+
|
|
5869
6580
|
## Swapping to a production database
|
|
5870
6581
|
|
|
5871
6582
|
Open \`src/store.ts\` and replace the \`db\` calls with your ORM/driver of choice:
|
|
@@ -11006,7 +11717,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
11006
11717
|
|
|
11007
11718
|
// src/register.ts
|
|
11008
11719
|
var import_node_crypto2 = require("crypto");
|
|
11009
|
-
var PKG_VERSION = true ? "2.
|
|
11720
|
+
var PKG_VERSION = true ? "2.5.0" : "dev";
|
|
11010
11721
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
11011
11722
|
function hmacSha256(secret, message) {
|
|
11012
11723
|
return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
|