@oneaddress/setup 2.3.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 +1336 -375
- 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();
|
|
@@ -2136,6 +2218,15 @@ export function noteChange(facts: ChangeFacts): void {
|
|
|
2136
2218
|
* \`--headless\` skips both: no prompt (a service has nobody to ask), no screen.
|
|
2137
2219
|
*/
|
|
2138
2220
|
import { printBanner } from './brand.js';
|
|
2221
|
+
// Config only - NOT the server, the store or the database. Those derive keys on
|
|
2222
|
+
// import, which is the whole reason this file loads them dynamically below,
|
|
2223
|
+
// after the passphrase is resolved. \`config.js\` reads a JSON file and the
|
|
2224
|
+
// environment and touches neither.
|
|
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';
|
|
2139
2230
|
import { PassphraseRequiredError, WrongPassphraseError } from './vault.js';
|
|
2140
2231
|
|
|
2141
2232
|
/**
|
|
@@ -2158,122 +2249,6 @@ const headless =
|
|
|
2158
2249
|
process.env.ONEADDRESS_HEADLESS === '1' ||
|
|
2159
2250
|
!process.stdout.isTTY;
|
|
2160
2251
|
|
|
2161
|
-
/**
|
|
2162
|
-
* Has this database already been locked?
|
|
2163
|
-
*
|
|
2164
|
-
* Asked BEFORE the passphrase, and without one, so the prompt can say which of
|
|
2165
|
-
* two completely different things it is doing. \`db_meta.verifier\` is written the
|
|
2166
|
-
* first time a passphrase is set, so its presence is the whole answer.
|
|
2167
|
-
*
|
|
2168
|
-
* Read through its own connection rather than importing \`db.ts\`, which derives
|
|
2169
|
-
* its keys the moment it is imported and would therefore have to run BEFORE we
|
|
2170
|
-
* know what to ask for.
|
|
2171
|
-
*/
|
|
2172
|
-
async function databaseIsLocked(): Promise<boolean> {
|
|
2173
|
-
try {
|
|
2174
|
-
const { DatabaseSync } = await import('node:sqlite');
|
|
2175
|
-
const { join } = await import('node:path');
|
|
2176
|
-
const path = process.env.DB_PATH ?? join(process.cwd(), 'data.db');
|
|
2177
|
-
const db = new DatabaseSync(path, { readOnly: true });
|
|
2178
|
-
try {
|
|
2179
|
-
const row = db
|
|
2180
|
-
.prepare("SELECT value FROM db_meta WHERE key = 'verifier'")
|
|
2181
|
-
.get() as { value?: string } | undefined;
|
|
2182
|
-
return Boolean(row?.value);
|
|
2183
|
-
} finally {
|
|
2184
|
-
db.close();
|
|
2185
|
-
}
|
|
2186
|
-
} catch {
|
|
2187
|
-
// No file yet, or no db_meta table yet. Either way: not locked.
|
|
2188
|
-
return false;
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
|
|
2192
|
-
/**
|
|
2193
|
-
* Ask for a passphrase without echoing it to the screen.
|
|
2194
|
-
*
|
|
2195
|
-
* A passphrase typed in clear on a shared screen, in a screen-share, or into a
|
|
2196
|
-
* terminal that keeps scrollback is not much of a secret. readline echoes by
|
|
2197
|
-
* default, so its output hook is replaced for the duration of the question.
|
|
2198
|
-
*
|
|
2199
|
-
* Degrades to a visible prompt rather than failing: on a terminal where the
|
|
2200
|
-
* hook is not available, being asked in the clear beats not being asked.
|
|
2201
|
-
*/
|
|
2202
|
-
async function ask(prompt: string): Promise<string> {
|
|
2203
|
-
process.stdout.write(prompt);
|
|
2204
|
-
|
|
2205
|
-
// NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
|
|
2206
|
-
// mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
|
|
2207
|
-
// by driving a real terminal and reading what came back. The first filtered
|
|
2208
|
-
// on whether the chunk contained the prompt, not knowing readline repaints
|
|
2209
|
-
// prompt and input together on every keystroke, so the condition was always
|
|
2210
|
-
// true. The second repainted the line and still leaked, because the echo was
|
|
2211
|
-
// never coming from that hook at all.
|
|
2212
|
-
//
|
|
2213
|
-
// Reading the keys directly removes the guessing. Raw mode turns the
|
|
2214
|
-
// terminal's own echo OFF, so the ONLY thing that can reach the screen is
|
|
2215
|
-
// what is written below: one asterisk per character, which is what a partner
|
|
2216
|
-
// asked for and what every other passphrase prompt does.
|
|
2217
|
-
const stdin = process.stdin;
|
|
2218
|
-
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
2219
|
-
// No terminal to control. Being asked in the clear beats not being asked,
|
|
2220
|
-
// and this path is only reached where nothing is watching anyway.
|
|
2221
|
-
const { createInterface } = await import('node:readline/promises');
|
|
2222
|
-
const rl = createInterface({ input: stdin, output: process.stdout });
|
|
2223
|
-
try {
|
|
2224
|
-
const answer = await rl.question('');
|
|
2225
|
-
return answer.trim();
|
|
2226
|
-
} finally { rl.close(); }
|
|
2227
|
-
}
|
|
2228
|
-
|
|
2229
|
-
const wasRaw = stdin.isRaw === true;
|
|
2230
|
-
stdin.setRawMode(true);
|
|
2231
|
-
stdin.resume();
|
|
2232
|
-
stdin.setEncoding('utf8');
|
|
2233
|
-
|
|
2234
|
-
return new Promise<string>((resolve) => {
|
|
2235
|
-
let typed = '';
|
|
2236
|
-
const restore = (): void => {
|
|
2237
|
-
stdin.removeListener('data', onData);
|
|
2238
|
-
stdin.setRawMode(wasRaw);
|
|
2239
|
-
stdin.pause();
|
|
2240
|
-
};
|
|
2241
|
-
const onData = (chunk: string): void => {
|
|
2242
|
-
for (const ch of chunk) {
|
|
2243
|
-
if (ch === '\\r' || ch === '\\n') {
|
|
2244
|
-
restore();
|
|
2245
|
-
process.stdout.write('\\n');
|
|
2246
|
-
resolve(typed.trim());
|
|
2247
|
-
return;
|
|
2248
|
-
}
|
|
2249
|
-
if (ch === '\\u0003') { // Ctrl+C
|
|
2250
|
-
restore();
|
|
2251
|
-
process.stdout.write('\\n');
|
|
2252
|
-
process.exit(130);
|
|
2253
|
-
}
|
|
2254
|
-
if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
|
|
2255
|
-
restore();
|
|
2256
|
-
process.stdout.write('\\n');
|
|
2257
|
-
resolve(typed.trim());
|
|
2258
|
-
return;
|
|
2259
|
-
}
|
|
2260
|
-
if (ch === '\\u007f' || ch === '\\b') {
|
|
2261
|
-
// Backspace has to move the asterisks too, or the mask stops matching
|
|
2262
|
-
// what is actually in the buffer and the count misleads.
|
|
2263
|
-
if (typed.length > 0) {
|
|
2264
|
-
typed = typed.slice(0, -1);
|
|
2265
|
-
process.stdout.write('\\b \\b');
|
|
2266
|
-
}
|
|
2267
|
-
continue;
|
|
2268
|
-
}
|
|
2269
|
-
if (ch < ' ') continue; // ignore the rest of the control range
|
|
2270
|
-
typed += ch;
|
|
2271
|
-
process.stdout.write('*');
|
|
2272
|
-
}
|
|
2273
|
-
};
|
|
2274
|
-
stdin.on('data', onData);
|
|
2275
|
-
});
|
|
2276
|
-
}
|
|
2277
2252
|
|
|
2278
2253
|
/**
|
|
2279
2254
|
* Where the at-rest passphrase comes from.
|
|
@@ -2310,29 +2285,72 @@ async function resolvePassphrase(): Promise<string | null> {
|
|
|
2310
2285
|
// Reported by exactly that partner: "there was no opportunity to set a
|
|
2311
2286
|
// passphrase". Without this line the only options are guess or search the
|
|
2312
2287
|
// internet, and the answer is one command.
|
|
2313
|
-
process.stdout.write('\\n This customer database is encrypted.\\n');
|
|
2314
|
-
process.stdout.write('
|
|
2315
|
-
process.stdout.write('
|
|
2316
|
-
process.stdout.write(' OneAddress
|
|
2317
|
-
|
|
2288
|
+
process.stdout.write('\\n This customer database is encrypted and needs its password.\\n\\n');
|
|
2289
|
+
process.stdout.write(' It is the one you chose on this receiver. It is NOT your Webhook\\n');
|
|
2290
|
+
process.stdout.write(' signing secret, NOT your ECDH private key, and NOT your\\n');
|
|
2291
|
+
process.stdout.write(' OneAddress sign-in.\\n\\n');
|
|
2292
|
+
process.stdout.write(' If you do not have it, delete data.db and start again: it holds\\n');
|
|
2293
|
+
process.stdout.write(' your demo roster and test dispatches, nothing OneAddress needs.\\n\\n');
|
|
2294
|
+
const answer = await ask(' Password to unlock: ');
|
|
2318
2295
|
return answer || null;
|
|
2319
2296
|
}
|
|
2320
2297
|
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2298
|
+
// SAYS WHAT IS BEING ASKED BEFORE IT WARNS ABOUT IT. The first version opened
|
|
2299
|
+
// with SET A PASSPHRASE and four lines of consequence, which reads as an
|
|
2300
|
+
// instruction to produce a passphrase you are assumed to already have - and
|
|
2301
|
+
// this prompt arrives moments after the partner has pasted two real
|
|
2302
|
+
// credentials, so assuming exactly that is the natural reading. Reported as
|
|
2303
|
+
// confusing, and the confusion was ours.
|
|
2304
|
+
//
|
|
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.
|
|
2313
|
+
// NOT ASKED IN INBOX MODE, because the honest answer to "protect your customer
|
|
2314
|
+
// records" is that there are none here: the connector holds them. The
|
|
2315
|
+
// database in this mode carries dispatch ciphertext - already encrypted to a
|
|
2316
|
+
// key this process does not have - and a queue of dispatch ids. Asking anyway
|
|
2317
|
+
// is the confusing-prompt problem twice over: a question about data that is
|
|
2318
|
+
// absent, put to somebody who has just been told this receiver cannot read
|
|
2319
|
+
// anything.
|
|
2320
|
+
//
|
|
2321
|
+
// The UNLOCK branch above is deliberately NOT skipped. A database encrypted
|
|
2322
|
+
// while the receiver ran write-through is still encrypted after a switch to
|
|
2323
|
+
// inbox, and skipping the question there would refuse to open a file whose
|
|
2324
|
+
// owner has the password.
|
|
2325
|
+
if (config.mode === 'inbox') {
|
|
2326
|
+
process.stdout.write('\\n No password needed: in inbox mode this receiver holds no customer records.\\n');
|
|
2327
|
+
process.stdout.write(' Your connector holds them, and the key.\\n\\n');
|
|
2328
|
+
return null;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
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');
|
|
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');
|
|
2336
|
+
// ACCURATE ABOUT "LATER", because the obvious reading is wrong. Setting a
|
|
2337
|
+
// password afterwards works and the receiver reports itself encrypted, but
|
|
2338
|
+
// rows written before it stay in the clear until something rewrites them -
|
|
2339
|
+
// confirmed by finding a seeded customer name in the database file after
|
|
2340
|
+
// exactly that sequence.
|
|
2341
|
+
process.stdout.write(' You can set one later with ONEADDRESS_DB_PASSPHRASE, but records\\n');
|
|
2342
|
+
process.stdout.write(' written before then stay readable until they change.\\n\\n');
|
|
2326
2343
|
|
|
2327
2344
|
for (;;) {
|
|
2328
|
-
const first = await ask(' New
|
|
2345
|
+
const first = await ask(' New password (or Enter to skip): ');
|
|
2329
2346
|
if (!first) {
|
|
2330
|
-
process.stdout.write(' Continuing WITHOUT encryption
|
|
2347
|
+
process.stdout.write(' Continuing WITHOUT encryption: anyone who can read data.db can\\n');
|
|
2348
|
+
process.stdout.write(' read your customer records.\\n\\n');
|
|
2331
2349
|
return null;
|
|
2332
2350
|
}
|
|
2333
|
-
const again = await ask('
|
|
2351
|
+
const again = await ask(' Type it again: ');
|
|
2334
2352
|
if (first === again) {
|
|
2335
|
-
process.stdout.write('
|
|
2353
|
+
process.stdout.write(' Password set. Keep it somewhere you will still have it.\\n\\n');
|
|
2336
2354
|
return first;
|
|
2337
2355
|
}
|
|
2338
2356
|
process.stdout.write(' Those do not match. Try again.\\n\\n');
|
|
@@ -2471,6 +2489,22 @@ import type {
|
|
|
2471
2489
|
* if anything awaited between the set and the read, which is why they are
|
|
2472
2490
|
* adjacent and why this comment exists.
|
|
2473
2491
|
*/
|
|
2492
|
+
/**
|
|
2493
|
+
* The connector could not be reached, so the question was never answered.
|
|
2494
|
+
*
|
|
2495
|
+
* A THROW RATHER THAN A VERDICT, because every value this function can return
|
|
2496
|
+
* is a statement about the customer's record and none of them means "we could
|
|
2497
|
+
* not ask". The handler turns this into a non-2xx, which the relay already
|
|
2498
|
+
* classifies as \`unreachable\` and shows the consumer as a transient network
|
|
2499
|
+
* problem rather than as a missing account.
|
|
2500
|
+
*/
|
|
2501
|
+
export class ConnectorUnreachableError extends Error {
|
|
2502
|
+
constructor(public readonly kind: string) {
|
|
2503
|
+
super(\`the connector could not be reached to answer \${kind}\`);
|
|
2504
|
+
this.name = 'ConnectorUnreachableError';
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2474
2508
|
let currentRawBody = '';
|
|
2475
2509
|
|
|
2476
2510
|
export function setCurrentRawBody(raw: string): void {
|
|
@@ -2496,14 +2530,26 @@ export const connectorStore = {
|
|
|
2496
2530
|
): Promise<AccountVerdict> {
|
|
2497
2531
|
const answer = await askConnector('account.verify', currentRawBody);
|
|
2498
2532
|
if (!answer.reached) {
|
|
2499
|
-
// NEVER GUESSED
|
|
2500
|
-
// a customer's account.
|
|
2501
|
-
//
|
|
2533
|
+
// NEVER GUESSED: a fabricated match authorises a stranger's address onto
|
|
2534
|
+
// a customer's account.
|
|
2535
|
+
//
|
|
2536
|
+
// BUT NOT \`no_account\` EITHER, which is what this returned. That is a
|
|
2537
|
+
// POSITIVE CLAIM - the consumer is shown "\u2717 No account found / This
|
|
2538
|
+
// service couldn't find an account with these details" and goes off to
|
|
2539
|
+
// re-check an account number that was correct all along, or concludes
|
|
2540
|
+
// they have no account with a provider they do.
|
|
2541
|
+
//
|
|
2542
|
+
// The trade was accepted for its safety, and the safety is real. What was
|
|
2543
|
+
// missed is that it is available WITHOUT the false claim: the relay
|
|
2544
|
+
// already classifies a failed check as \`unreachable\` and tells the
|
|
2545
|
+
// consumer "we couldn't reach this service just now". That stops them
|
|
2546
|
+
// before they pay AND is true. Throwing gets us there, because a
|
|
2547
|
+
// non-2xx is exactly what the relay reads as unreachable.
|
|
2502
2548
|
report.warn(
|
|
2503
2549
|
\`[connector] account check for \${accountNumber ?? '(none)'} could not be answered; \` +
|
|
2504
|
-
'
|
|
2550
|
+
'reporting the service as unreachable rather than claiming no account exists',
|
|
2505
2551
|
);
|
|
2506
|
-
|
|
2552
|
+
throw new ConnectorUnreachableError('account.verify');
|
|
2507
2553
|
}
|
|
2508
2554
|
const status = verdictOf(answer.body, 'status');
|
|
2509
2555
|
if (status === 'match' || status === 'no_match' || status === 'no_account') return status;
|
|
@@ -2513,7 +2559,9 @@ export const connectorStore = {
|
|
|
2513
2559
|
|
|
2514
2560
|
async verifyAddress(_customer: Customer, _incoming: Address): Promise<VerifyResult> {
|
|
2515
2561
|
const answer = await askConnector('address.verify', currentRawBody);
|
|
2516
|
-
|
|
2562
|
+
// Same reasoning as \`verifyAccount\`: \`not_found\` is a claim about the
|
|
2563
|
+
// customer's record, and "we could not ask" is not that claim.
|
|
2564
|
+
if (!answer.reached) throw new ConnectorUnreachableError('address.verify');
|
|
2517
2565
|
const result = verdictOf(answer.body, 'result');
|
|
2518
2566
|
if (result === 'match' || result === 'mismatch' || result === 'not_found') return result;
|
|
2519
2567
|
report.warn(\`[connector] address.verify answered "\${result ?? '(nothing)'}", which is not a result\`);
|
|
@@ -2681,7 +2729,7 @@ import express, { type Request, type Response } from 'express';
|
|
|
2681
2729
|
import rateLimit from 'express-rate-limit';
|
|
2682
2730
|
import { timingSafeEqual } from 'node:crypto';
|
|
2683
2731
|
import { report } from './report.js';
|
|
2684
|
-
import { acknowledge, drawable, markDrawn, type InboxOutcome } from './inbox.js';
|
|
2732
|
+
import { acknowledge, drawable, hold, markDrawn, type InboxOutcome } from './inbox.js';
|
|
2685
2733
|
|
|
2686
2734
|
const TOKEN = process.env.CONNECTOR_TOKEN ?? '';
|
|
2687
2735
|
/**
|
|
@@ -2774,18 +2822,41 @@ export function startDrawApi(): { port: number } | null {
|
|
|
2774
2822
|
* confirm, which is the honest answer when the partner's own system refused
|
|
2775
2823
|
* the change: the consumer is told it did not land rather than being told it
|
|
2776
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.
|
|
2777
2832
|
*/
|
|
2778
2833
|
app.post('/ack', (req: Request, res: Response) => {
|
|
2779
2834
|
if (!authorised(req, res)) return;
|
|
2780
2835
|
const body = req.body as { id?: unknown; outcome?: unknown; detail?: unknown };
|
|
2781
2836
|
const id = typeof body.id === 'string' ? body.id : '';
|
|
2782
|
-
const outcome: InboxOutcome | null =
|
|
2783
|
-
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;
|
|
2784
2841
|
if (!id || !outcome) {
|
|
2785
|
-
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' });
|
|
2786
2843
|
}
|
|
2787
2844
|
const detail = typeof body.detail === 'string' ? body.detail : null;
|
|
2788
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
|
+
|
|
2789
2860
|
const result = acknowledge(id, outcome, detail);
|
|
2790
2861
|
if (!result) return res.status(404).json({ error: 'unknown id' });
|
|
2791
2862
|
if (result.alreadyAcknowledged) {
|
|
@@ -2885,9 +2956,16 @@ export function setAcknowledgementHandler(
|
|
|
2885
2956
|
* the claim has to become atomic, the way \`confirm-queue.ts\` already does it.
|
|
2886
2957
|
* Written down because a lease LOOKS like it handles concurrency and does not.
|
|
2887
2958
|
*/
|
|
2888
|
-
import db from './db.js';
|
|
2959
|
+
import db, { ensureColumn } from './db.js';
|
|
2889
2960
|
import { report } from './report.js';
|
|
2890
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
|
+
*/
|
|
2891
2969
|
export type InboxOutcome = 'applied' | 'failed';
|
|
2892
2970
|
|
|
2893
2971
|
db.exec(\`
|
|
@@ -2906,6 +2984,30 @@ db.exec(\`
|
|
|
2906
2984
|
ON inbox(applied_at, drawn_at, received_at);
|
|
2907
2985
|
\`);
|
|
2908
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
|
+
|
|
2909
3011
|
export interface AcceptInput {
|
|
2910
3012
|
key: string;
|
|
2911
3013
|
dispatchId: string | null;
|
|
@@ -2987,6 +3089,110 @@ export function acknowledge(
|
|
|
2987
3089
|
return { dispatchId: row.dispatch_id, alreadyAcknowledged: false };
|
|
2988
3090
|
}
|
|
2989
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
|
+
|
|
2990
3196
|
/** How many updates are sitting here unapplied. Shown as a fault when non-zero. */
|
|
2991
3197
|
export function undrawnCount(): number {
|
|
2992
3198
|
const row = db.prepare(
|
|
@@ -3220,6 +3426,38 @@ export function dispatchKey(dispatchId: string | null, rawBody: string): string
|
|
|
3220
3426
|
*/
|
|
3221
3427
|
ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
|
|
3222
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
|
+
|
|
3223
3461
|
export interface QuarantineInput {
|
|
3224
3462
|
dispatchId: string | null;
|
|
3225
3463
|
event: string;
|
|
@@ -3283,7 +3521,7 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
|
|
|
3283
3521
|
\`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
|
|
3284
3522
|
received_at, last_error, attempts
|
|
3285
3523
|
FROM quarantine
|
|
3286
|
-
WHERE replayed_at IS NULL
|
|
3524
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL
|
|
3287
3525
|
ORDER BY received_at
|
|
3288
3526
|
LIMIT ?\`,
|
|
3289
3527
|
).all(limit) as unknown as HeldDispatch[];
|
|
@@ -3292,7 +3530,7 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
|
|
|
3292
3530
|
/** How many dispatches are held. Shown on the dashboard. */
|
|
3293
3531
|
export function heldCount(): number {
|
|
3294
3532
|
const row = db.prepare(
|
|
3295
|
-
'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',
|
|
3296
3534
|
).get() as { n: number };
|
|
3297
3535
|
return row.n;
|
|
3298
3536
|
}
|
|
@@ -3309,7 +3547,7 @@ export function heldSummary(): string[] {
|
|
|
3309
3547
|
\`SELECT reason, key_id, count(*) AS n,
|
|
3310
3548
|
max(attempts) AS tries, min(received_at) AS oldest
|
|
3311
3549
|
FROM quarantine
|
|
3312
|
-
WHERE replayed_at IS NULL
|
|
3550
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL
|
|
3313
3551
|
GROUP BY reason, key_id
|
|
3314
3552
|
ORDER BY n DESC\`,
|
|
3315
3553
|
).all() as unknown as {
|
|
@@ -3353,6 +3591,38 @@ export function markReplayFailed(id: string, error: string): void {
|
|
|
3353
3591
|
.run(error.slice(0, 500), id);
|
|
3354
3592
|
}
|
|
3355
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
|
+
|
|
3356
3626
|
/**
|
|
3357
3627
|
* Drop held payloads past the retention window, replayed or not.
|
|
3358
3628
|
*
|
|
@@ -3838,6 +4108,16 @@ export type ReceiverConfig = {
|
|
|
3838
4108
|
oneAddressApi: string;
|
|
3839
4109
|
verifiesAccountReference: boolean;
|
|
3840
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;
|
|
3841
4121
|
};
|
|
3842
4122
|
|
|
3843
4123
|
const DEFAULTS: ReceiverConfig = {
|
|
@@ -3852,6 +4132,8 @@ const DEFAULTS: ReceiverConfig = {
|
|
|
3852
4132
|
// running and a key living somewhere else; a receiver that silently switched
|
|
3853
4133
|
// into it would accept dispatches nothing ever collects.
|
|
3854
4134
|
mode: 'write-through',
|
|
4135
|
+
// No guess. See the field's docstring.
|
|
4136
|
+
publicUrl: '',
|
|
3855
4137
|
};
|
|
3856
4138
|
|
|
3857
4139
|
function parseMode(value: unknown): ReceiverMode | undefined {
|
|
@@ -3870,6 +4152,7 @@ function loadConfigFile(): Partial<ReceiverConfig> {
|
|
|
3870
4152
|
if (typeof parsed.partnerId === 'string') out.partnerId = parsed.partnerId;
|
|
3871
4153
|
if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
|
|
3872
4154
|
if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
|
|
4155
|
+
if (typeof parsed.publicUrl === 'string') out.publicUrl = parsed.publicUrl;
|
|
3873
4156
|
const mode = parseMode(parsed.mode);
|
|
3874
4157
|
if (mode) out.mode = mode;
|
|
3875
4158
|
return out;
|
|
@@ -3893,6 +4176,7 @@ export const config: ReceiverConfig = {
|
|
|
3893
4176
|
// up as "not the mode I asked for" rather than as a receiver that will not
|
|
3894
4177
|
// start. Silent is the thing to avoid, not strict.
|
|
3895
4178
|
mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
|
|
4179
|
+
publicUrl: stripTrailingSlash(process.env.PUBLIC_URL || fromFile.publicUrl || DEFAULTS.publicUrl),
|
|
3896
4180
|
};
|
|
3897
4181
|
|
|
3898
4182
|
report.info(
|
|
@@ -3900,6 +4184,188 @@ report.info(
|
|
|
3900
4184
|
', oneAddressApi=' + config.oneAddressApi +
|
|
3901
4185
|
', verifiesAccountReference=' + config.verifiesAccountReference + ')',
|
|
3902
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
|
+
}
|
|
3903
4369
|
`
|
|
3904
4370
|
},
|
|
3905
4371
|
{
|
|
@@ -4177,6 +4643,7 @@ export interface CustomerStore {
|
|
|
4177
4643
|
import { readFileSync } from 'node:fs';
|
|
4178
4644
|
import { join } from 'node:path';
|
|
4179
4645
|
import { report } from './report.js';
|
|
4646
|
+
import { config } from './config.js';
|
|
4180
4647
|
import db, { accountKey, dec, enc, encrypted, ensureColumn, isEncrypted, once } from './db.js';
|
|
4181
4648
|
import type {
|
|
4182
4649
|
AccountVerdict,
|
|
@@ -4356,8 +4823,30 @@ function loadRoster(): RosterEntry[] {
|
|
|
4356
4823
|
}
|
|
4357
4824
|
}
|
|
4358
4825
|
|
|
4359
|
-
|
|
4360
|
-
|
|
4826
|
+
/**
|
|
4827
|
+
* IN INBOX MODE NOTHING IS SEEDED, and that is a privacy fix rather than a
|
|
4828
|
+
* saving.
|
|
4829
|
+
*
|
|
4830
|
+
* This block runs on IMPORT, and \`server.ts\` imports this module in both modes
|
|
4831
|
+
* because it picks the store at runtime. So an inbox receiver - the process that
|
|
4832
|
+
* exists specifically so it cannot read what it holds - was writing a full
|
|
4833
|
+
* customer roster to its own database on startup: names, account numbers and
|
|
4834
|
+
* the address on file for each, in the clear.
|
|
4835
|
+
*
|
|
4836
|
+
* The dispatches were never the leak. Those are stored as the ciphertext they
|
|
4837
|
+
* arrived as, and an end-to-end run confirms the new address appears nowhere in
|
|
4838
|
+
* the receiver's database file. The ROSTER was, and it was there before a
|
|
4839
|
+
* single dispatch arrived. Found by searching the receiver's database bytes for
|
|
4840
|
+
* a customer name after that run, which is the only check that would have found
|
|
4841
|
+
* it: every other one asks the receiver what it thinks it is holding.
|
|
4842
|
+
*
|
|
4843
|
+
* In inbox mode the connector owns the roster and answers every account
|
|
4844
|
+
* question over the loopback channel, so the receiver needs none of it.
|
|
4845
|
+
*/
|
|
4846
|
+
const ROSTER = config.mode === 'inbox' ? [] : loadRoster();
|
|
4847
|
+
if (config.mode === 'inbox') {
|
|
4848
|
+
report.info('[store] no roster held: in inbox mode your connector owns customer records');
|
|
4849
|
+
} else {
|
|
4361
4850
|
const upsert = db.prepare(\`
|
|
4362
4851
|
INSERT INTO customers (account_key, account_number, name, address)
|
|
4363
4852
|
VALUES ($account_key, $account_number, $name, $address)
|
|
@@ -4431,6 +4920,66 @@ export function customerCount(): number {
|
|
|
4431
4920
|
return (db.prepare('SELECT COUNT(*) AS n FROM customers').get() as { n: number }).n;
|
|
4432
4921
|
}
|
|
4433
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
|
+
|
|
4434
4983
|
/** Is the file on disk protected? Surfaced in the dashboard, in both states. */
|
|
4435
4984
|
export const storeEncrypted = encrypted;
|
|
4436
4985
|
|
|
@@ -4623,7 +5172,8 @@ import {
|
|
|
4623
5172
|
// exporting a \`CustomerStore\` (see src/customer-store.ts) and nothing else in
|
|
4624
5173
|
// the protocol layer changes.
|
|
4625
5174
|
import { store as writeThroughStore } from './store.js';
|
|
4626
|
-
import {
|
|
5175
|
+
import { ALIVE_NONCE, ALIVE_PATH, startReachabilityWatch } from './reachable.js';
|
|
5176
|
+
import { ConnectorUnreachableError, connectorStore, setCurrentRawBody } from './connector-store.js';
|
|
4627
5177
|
import { noteChange } from './tui.js';
|
|
4628
5178
|
import { config } from './config.js';
|
|
4629
5179
|
import { safeOneAddressCallbackUrl } from './callback-url.js';
|
|
@@ -4761,12 +5311,22 @@ report.info(
|
|
|
4761
5311
|
// lock out every install that has never set a passphrase - but choosing it by
|
|
4762
5312
|
// not being asked is not a choice, so the absence is stated as loudly as the
|
|
4763
5313
|
// presence.
|
|
5314
|
+
//
|
|
5315
|
+
// AND IN INBOX MODE IT SAYS SOMETHING ELSE, for the same reason the keys line
|
|
5316
|
+
// above does. There are no customer records in this mode - the connector holds
|
|
5317
|
+
// them - so "Customer records are readable by anyone who can read the file" was
|
|
5318
|
+
// alarming about data that is not here, and sent the partner to set a passphrase
|
|
5319
|
+
// protecting nothing. \`connectorStore.encrypted\` was already hardcoded false
|
|
5320
|
+
// with a comment saying exactly that; this line had simply never read it.
|
|
4764
5321
|
report.info(
|
|
4765
|
-
|
|
4766
|
-
? \`[startup] store: \${store.name}
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
5322
|
+
config.mode === 'inbox'
|
|
5323
|
+
? \`[startup] store: \${store.name} \u2014 no customer records here by design. This database holds \` +
|
|
5324
|
+
'dispatch ciphertext your connector decrypts, and the confirm queue.'
|
|
5325
|
+
: store.encrypted
|
|
5326
|
+
? \`[startup] store: \${store.name} (encrypted at rest)\`
|
|
5327
|
+
: \`[startup] store: \${store.name} \u2014 NOT ENCRYPTED AT REST. \` +
|
|
5328
|
+
'Customer records are readable by anyone who can read the file. ' +
|
|
5329
|
+
'Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` in a terminal to be asked.',
|
|
4770
5330
|
);
|
|
4771
5331
|
|
|
4772
5332
|
// In-memory dedup cache. Records a dispatch id only once it has been fully
|
|
@@ -4806,11 +5366,17 @@ async function confirmToOneAddress(dispatchId: number, status: ConfirmStatus): P
|
|
|
4806
5366
|
// and it drops non-numeric dispatch ids, so by the time a row is drained it
|
|
4807
5367
|
// is a real dispatch. Keeping a second copy of that rule would mean a probe
|
|
4808
5368
|
// could be queued forever and silently skipped on every drain.
|
|
5369
|
+
// REPORTED, not "Applied". The note is fixed text sent with EVERY confirm,
|
|
5370
|
+
// including \`failed\` ones - so a dispatch the connector could not open, or
|
|
5371
|
+
// one refused because the account reference matched nobody, arrived at
|
|
5372
|
+
// OneAddress reading "Applied by the OneAddress webhook receiver". That is
|
|
5373
|
+
// read by whoever is working out what happened, and it said the opposite of
|
|
5374
|
+
// \`status\` sitting beside it.
|
|
4809
5375
|
const bodyStr = JSON.stringify({
|
|
4810
5376
|
dispatch_id: dispatchId,
|
|
4811
5377
|
partner_id: PARTNER_ID,
|
|
4812
5378
|
status,
|
|
4813
|
-
note: '
|
|
5379
|
+
note: 'Reported by the OneAddress webhook receiver',
|
|
4814
5380
|
});
|
|
4815
5381
|
const ts = String(Math.floor(Date.now() / 1000));
|
|
4816
5382
|
const sig = createHmac('sha256', CONFIRM_SECRET).update(\`\${ts}.\${bodyStr}\`).digest('hex');
|
|
@@ -4961,7 +5527,19 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
4961
5527
|
// and the customer records, so it answers. \`connectorStore\` sends the raw
|
|
4962
5528
|
// body; the arguments below are the contract's shape, not its source.
|
|
4963
5529
|
if (config.mode === 'inbox') {
|
|
4964
|
-
|
|
5530
|
+
let status: Awaited<ReturnType<typeof store.verifyAccount>>;
|
|
5531
|
+
try {
|
|
5532
|
+
status = await store.verifyAccount(null, '', []);
|
|
5533
|
+
} catch (err) {
|
|
5534
|
+
// 503 RATHER THAN A VERDICT. Every verdict is a claim about the
|
|
5535
|
+
// customer's record; "the connector is down" is not one of them. A
|
|
5536
|
+
// non-2xx is what OneAddress reads as \`unreachable\`, which the consumer
|
|
5537
|
+
// sees as a transient network problem instead of "no account found".
|
|
5538
|
+
if (err instanceof ConnectorUnreachableError) {
|
|
5539
|
+
return res.status(503).json({ ok: false, error: 'connector_unreachable' });
|
|
5540
|
+
}
|
|
5541
|
+
throw err;
|
|
5542
|
+
}
|
|
4965
5543
|
report.info(\`[webhook] account.verify \u2192 \${status} (answered by the connector)\`);
|
|
4966
5544
|
return res.status(200).json({ status });
|
|
4967
5545
|
}
|
|
@@ -5090,7 +5668,17 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
5090
5668
|
if (config.mode === 'inbox' && event === 'address.verify') {
|
|
5091
5669
|
// The contract's arguments, not its source: \`connectorStore\` sends the raw
|
|
5092
5670
|
// body it was parked with and ignores these.
|
|
5093
|
-
|
|
5671
|
+
let result: Awaited<ReturnType<typeof store.verifyAddress>>;
|
|
5672
|
+
try {
|
|
5673
|
+
result = await store.verifyAddress({ email: null, name: '' }, {});
|
|
5674
|
+
} catch (err) {
|
|
5675
|
+
// As above, and the callback is deliberately NOT posted: a verdict posted
|
|
5676
|
+
// here would be a claim about a record nobody read.
|
|
5677
|
+
if (err instanceof ConnectorUnreachableError) {
|
|
5678
|
+
return res.status(503).json({ ok: false, error: 'connector_unreachable' });
|
|
5679
|
+
}
|
|
5680
|
+
throw err;
|
|
5681
|
+
}
|
|
5094
5682
|
report.info(\`[webhook] address.verify \u2192 \${result} (answered by the connector)\`);
|
|
5095
5683
|
const refused = await postVerifyVerdict(result);
|
|
5096
5684
|
if (refused) return refused;
|
|
@@ -5380,9 +5968,26 @@ app.get('/health', (_req, res) => res.json({
|
|
|
5380
5968
|
encryptedAtRest: store.encrypted,
|
|
5381
5969
|
}));
|
|
5382
5970
|
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
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
|
+
});
|
|
5386
5991
|
|
|
5387
5992
|
/**
|
|
5388
5993
|
* Drain the confirm queue, forever.
|
|
@@ -5425,121 +6030,343 @@ setInterval(() => {
|
|
|
5425
6030
|
* dispatch is verified, parsed, decrypted, applied and confirmed by exactly the
|
|
5426
6031
|
* code a live one is.
|
|
5427
6032
|
*
|
|
5428
|
-
* The body is byte-for-byte what arrived; only the timestamp and signature are
|
|
5429
|
-
* new, because the original pair is outside the \xB15-minute window by the time
|
|
5430
|
-
* anyone has fixed anything. We hold the secret, so re-signing is not a bypass:
|
|
5431
|
-
* it is the same proof, re-stated now.
|
|
6033
|
+
* The body is byte-for-byte what arrived; only the timestamp and signature are
|
|
6034
|
+
* new, because the original pair is outside the \xB15-minute window by the time
|
|
6035
|
+
* anyone has fixed anything. We hold the secret, so re-signing is not a bypass:
|
|
6036
|
+
* it is the same proof, re-stated now.
|
|
6037
|
+
*/
|
|
6038
|
+
async function redeliver(rawBody: string, heldDispatchId: string | null): Promise<void> {
|
|
6039
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
6040
|
+
const sig = createHmac('sha256', WEBHOOK_SECRET).update(\`\${ts}.\${rawBody}\`).digest('hex');
|
|
6041
|
+
// THE ID THE DISPATCH ARRIVED WITH, handed over by the row being replayed.
|
|
6042
|
+
// Re-deriving it from the body is what made one held dispatch become two.
|
|
6043
|
+
const dispatchId = (heldDispatchId ?? '').trim();
|
|
6044
|
+
|
|
6045
|
+
const res = await fetch(\`http://127.0.0.1:\${PORT}/webhook\`, {
|
|
6046
|
+
method: 'POST',
|
|
6047
|
+
headers: {
|
|
6048
|
+
'Content-Type': 'application/json',
|
|
6049
|
+
'X-OneAddress-Timestamp': ts,
|
|
6050
|
+
'X-OneAddress-Signature': sig,
|
|
6051
|
+
...(dispatchId ? { 'X-OneAddress-Dispatch': dispatchId } : {}),
|
|
6052
|
+
},
|
|
6053
|
+
body: rawBody,
|
|
6054
|
+
});
|
|
6055
|
+
const text = (await res.text().catch(() => '')).slice(0, 200);
|
|
6056
|
+
if (!res.ok) throw new Error(\`HTTP \${res.status} \${text}\`);
|
|
6057
|
+
// A 200 is not on its own success here: the handler answers \`ok: false\` with
|
|
6058
|
+
// 200 when it REFUSES a dispatch (an account reference matching nobody), and
|
|
6059
|
+
// treating that as applied would clear the row for an update that was never
|
|
6060
|
+
// stored.
|
|
6061
|
+
if (!text.includes('"ok":true')) throw new Error(\`refused: \${text}\`);
|
|
6062
|
+
}
|
|
6063
|
+
|
|
6064
|
+
/** Apply everything held. Bound to [r] on the dashboard and run once at boot. */
|
|
6065
|
+
export async function replayQuarantined(): Promise<{ applied: number; failed: number }> {
|
|
6066
|
+
return replayHeld(redeliver);
|
|
6067
|
+
}
|
|
6068
|
+
|
|
6069
|
+
/**
|
|
6070
|
+
* Try the backlog once, a moment after boot.
|
|
6071
|
+
*
|
|
6072
|
+
* Because the realistic sequence is: a key is wrong, dispatches pile up, the
|
|
6073
|
+
* partner edits \`.env\`, the partner restarts. Making them find a command after
|
|
6074
|
+
* that is making the recovery depend on reading documentation at the exact
|
|
6075
|
+
* moment they are least inclined to. The delay lets \`listen\` settle, since this
|
|
6076
|
+
* goes back in through the port.
|
|
6077
|
+
*/
|
|
6078
|
+
// \u2500\u2500 The connector channel, in inbox mode only \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
6079
|
+
//
|
|
6080
|
+
// Wired here rather than in draw-api.ts because this file owns the confirm
|
|
6081
|
+
// queue: an acknowledgement from the connector is the moment OneAddress gets
|
|
6082
|
+
// told, and that has to go through the same durable queue every other confirm
|
|
6083
|
+
// uses so a failure to reach OneAddress is retried rather than lost.
|
|
6084
|
+
if (config.mode === 'inbox') {
|
|
6085
|
+
setAcknowledgementHandler((dispatchId, outcome) => {
|
|
6086
|
+
queueConfirm(dispatchId, outcome === 'applied' ? 'confirmed' : 'failed');
|
|
6087
|
+
recordOutcome(\`d:\${dispatchId}\`, outcome === 'applied' ? 'applied' : 'failed');
|
|
6088
|
+
});
|
|
6089
|
+
const started = startDrawApi();
|
|
6090
|
+
if (!started) {
|
|
6091
|
+
// AN INBOX WITH NO WAY TO DRAIN IT IS WORSE THAN NOT STARTING. In
|
|
6092
|
+
// write-through mode a missing connector token is irrelevant and the
|
|
6093
|
+
// receiver runs; here it means every dispatch would be accepted and held
|
|
6094
|
+
// with nothing able to collect it, and the consumer would be told nothing
|
|
6095
|
+
// for as long as that lasted.
|
|
6096
|
+
report.error('[startup] mode is \`inbox\` but the connector channel could not open. Refusing to start.');
|
|
6097
|
+
process.exit(1);
|
|
6098
|
+
}
|
|
6099
|
+
}
|
|
6100
|
+
|
|
6101
|
+
setTimeout(() => {
|
|
6102
|
+
if (heldCount() === 0) return;
|
|
6103
|
+
void replayQuarantined().catch((err: unknown) =>
|
|
6104
|
+
report.error('[replay] could not run at startup:', err),
|
|
6105
|
+
);
|
|
6106
|
+
}, 1_500).unref();
|
|
6107
|
+
|
|
6108
|
+
// Held payloads age out whether or not they were ever applied, which is the
|
|
6109
|
+
// opposite of the confirm queue one block up. The reason is what the row holds:
|
|
6110
|
+
// a confirm record names a dispatch, a quarantined payload is a consumer's
|
|
6111
|
+
// encrypted address on someone else's disk.
|
|
6112
|
+
const QUARANTINE_KEEP_DAYS = Number(process.env.QUARANTINE_KEEP_DAYS ?? 30);
|
|
6113
|
+
setInterval(() => { purgeQuarantine(QUARANTINE_KEEP_DAYS); }, 3_600_000).unref();
|
|
6114
|
+
|
|
6115
|
+
/** How many confirms are still owed, and how the dispatches went. Read by the dashboard. */
|
|
6116
|
+
export { pendingConfirmCount, tally };
|
|
6117
|
+
|
|
6118
|
+
/**
|
|
6119
|
+
* Everything the dashboard footer needs, in one call.
|
|
6120
|
+
*
|
|
6121
|
+
* One hook rather than three, because three would be three chances for the
|
|
6122
|
+
* footer to show figures from different moments.
|
|
6123
|
+
*/
|
|
6124
|
+
export function dashboardStats(): {
|
|
6125
|
+
received: number;
|
|
6126
|
+
applied: number;
|
|
6127
|
+
failed: number;
|
|
6128
|
+
mode: string;
|
|
6129
|
+
awaitingConnector: number;
|
|
6130
|
+
oldestUndrawn: string | null;
|
|
6131
|
+
} {
|
|
6132
|
+
const t = tally();
|
|
6133
|
+
const inbox = config.mode === 'inbox'
|
|
6134
|
+
? { count: undrawnCount(), oldest: oldestUndrawn() }
|
|
6135
|
+
: { count: 0, oldest: null };
|
|
6136
|
+
return {
|
|
6137
|
+
...t,
|
|
6138
|
+
mode: config.mode,
|
|
6139
|
+
awaitingConnector: inbox.count,
|
|
6140
|
+
oldestUndrawn: inbox.oldest,
|
|
6141
|
+
};
|
|
6142
|
+
}
|
|
6143
|
+
|
|
6144
|
+
// Read by src/index.ts to label the dashboard. Exported rather than re-derived
|
|
6145
|
+
// there, so the port the UI claims is the port the server actually bound.
|
|
6146
|
+
export { PORT };
|
|
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.
|
|
5432
6313
|
*/
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
// THE ID THE DISPATCH ARRIVED WITH, handed over by the row being replayed.
|
|
5437
|
-
// Re-deriving it from the body is what made one held dispatch become two.
|
|
5438
|
-
const dispatchId = (heldDispatchId ?? '').trim();
|
|
6314
|
+
import 'dotenv/config';
|
|
6315
|
+
import { config } from '../src/config.js';
|
|
6316
|
+
import { ask, databaseIsLocked } from '../src/unlock.js';
|
|
5439
6317
|
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
'X-OneAddress-Timestamp': ts,
|
|
5445
|
-
'X-OneAddress-Signature': sig,
|
|
5446
|
-
...(dispatchId ? { 'X-OneAddress-Dispatch': dispatchId } : {}),
|
|
5447
|
-
},
|
|
5448
|
-
body: rawBody,
|
|
5449
|
-
});
|
|
5450
|
-
const text = (await res.text().catch(() => '')).slice(0, 200);
|
|
5451
|
-
if (!res.ok) throw new Error(\`HTTP \${res.status} \${text}\`);
|
|
5452
|
-
// A 200 is not on its own success here: the handler answers \`ok: false\` with
|
|
5453
|
-
// 200 when it REFUSES a dispatch (an account reference matching nobody), and
|
|
5454
|
-
// treating that as applied would clear the row for an update that was never
|
|
5455
|
-
// stored.
|
|
5456
|
-
if (!text.includes('"ok":true')) throw new Error(\`refused: \${text}\`);
|
|
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)';
|
|
5457
6322
|
}
|
|
5458
6323
|
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
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;
|
|
5462
6330
|
}
|
|
5463
6331
|
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
* that is making the recovery depend on reading documentation at the exact
|
|
5470
|
-
* moment they are least inclined to. The delay lets \`listen\` settle, since this
|
|
5471
|
-
* goes back in through the port.
|
|
5472
|
-
*/
|
|
5473
|
-
// \u2500\u2500 The connector channel, in inbox mode only \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
5474
|
-
//
|
|
5475
|
-
// Wired here rather than in draw-api.ts because this file owns the confirm
|
|
5476
|
-
// queue: an acknowledgement from the connector is the moment OneAddress gets
|
|
5477
|
-
// told, and that has to go through the same durable queue every other confirm
|
|
5478
|
-
// uses so a failure to reach OneAddress is retried rather than lost.
|
|
5479
|
-
if (config.mode === 'inbox') {
|
|
5480
|
-
setAcknowledgementHandler((dispatchId, outcome) => {
|
|
5481
|
-
queueConfirm(dispatchId, outcome === 'applied' ? 'confirmed' : 'failed');
|
|
5482
|
-
recordOutcome(\`d:\${dispatchId}\`, outcome === 'applied' ? 'applied' : 'failed');
|
|
5483
|
-
});
|
|
5484
|
-
const started = startDrawApi();
|
|
5485
|
-
if (!started) {
|
|
5486
|
-
// AN INBOX WITH NO WAY TO DRAIN IT IS WORSE THAN NOT STARTING. In
|
|
5487
|
-
// write-through mode a missing connector token is irrelevant and the
|
|
5488
|
-
// receiver runs; here it means every dispatch would be accepted and held
|
|
5489
|
-
// with nothing able to collect it, and the consumer would be told nothing
|
|
5490
|
-
// for as long as that lasted.
|
|
5491
|
-
report.error('[startup] mode is \`inbox\` but the connector channel could not open. Refusing to start.');
|
|
5492
|
-
process.exit(1);
|
|
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;
|
|
5493
6337
|
}
|
|
5494
|
-
}
|
|
5495
6338
|
|
|
5496
|
-
|
|
5497
|
-
if (heldCount() === 0) return;
|
|
5498
|
-
void replayQuarantined().catch((err: unknown) =>
|
|
5499
|
-
report.error('[replay] could not run at startup:', err),
|
|
5500
|
-
);
|
|
5501
|
-
}, 1_500).unref();
|
|
6339
|
+
await askPassword();
|
|
5502
6340
|
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
// a confirm record names a dispatch, a quarantined payload is a consumer's
|
|
5506
|
-
// encrypted address on someone else's disk.
|
|
5507
|
-
const QUARANTINE_KEEP_DAYS = Number(process.env.QUARANTINE_KEEP_DAYS ?? 30);
|
|
5508
|
-
setInterval(() => { purgeQuarantine(QUARANTINE_KEEP_DAYS); }, 3_600_000).unref();
|
|
6341
|
+
const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
|
|
6342
|
+
const store = await import('../src/store.js');
|
|
5509
6343
|
|
|
5510
|
-
|
|
5511
|
-
|
|
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
|
+
}
|
|
5512
6351
|
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
oldestUndrawn: string | null;
|
|
5526
|
-
} {
|
|
5527
|
-
const t = tally();
|
|
5528
|
-
const inbox = config.mode === 'inbox'
|
|
5529
|
-
? { count: undrawnCount(), oldest: oldestUndrawn() }
|
|
5530
|
-
: { count: 0, oldest: null };
|
|
5531
|
-
return {
|
|
5532
|
-
...t,
|
|
5533
|
-
mode: config.mode,
|
|
5534
|
-
awaitingConnector: inbox.count,
|
|
5535
|
-
oldestUndrawn: inbox.oldest,
|
|
5536
|
-
};
|
|
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('');
|
|
5537
6364
|
}
|
|
5538
6365
|
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
6366
|
+
void main().catch((err: unknown) => {
|
|
6367
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
6368
|
+
process.exit(1);
|
|
6369
|
+
});
|
|
5543
6370
|
`
|
|
5544
6371
|
},
|
|
5545
6372
|
{
|
|
@@ -5554,6 +6381,7 @@ export const PARTNER_NAME = process.env.PARTNER_NAME?.trim() || 'Your receiver';
|
|
|
5554
6381
|
import 'dotenv/config';
|
|
5555
6382
|
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
|
5556
6383
|
import { spawnSync } from 'node:child_process';
|
|
6384
|
+
import { readFileSync } from 'node:fs';
|
|
5557
6385
|
|
|
5558
6386
|
const secret = process.env.WEBHOOK_SECRET ?? '';
|
|
5559
6387
|
const partnerId = process.env.PARTNER_ID ?? '%%PARTNER_ID%%';
|
|
@@ -5561,21 +6389,73 @@ const keyPem = (process.env.PARTNER_PRIVATE_KEY_PEM ?? '').replace(/\\\\n/g,
|
|
|
5561
6389
|
const port = process.env.PORT ?? '3001';
|
|
5562
6390
|
const targetUrl = process.argv[2] ?? \`http://localhost:\${port}/webhook\`;
|
|
5563
6391
|
|
|
5564
|
-
|
|
5565
|
-
|
|
6392
|
+
/**
|
|
6393
|
+
* Which shape of receiver is this?
|
|
6394
|
+
*
|
|
6395
|
+
* Read from the same config file the server reads, so the two cannot disagree.
|
|
6396
|
+
* An inbox receiver holds NO private key - that is the property it exists for -
|
|
6397
|
+
* so this runner used to die on its own env gate with "Missing required env
|
|
6398
|
+
* vars. Check your .env file", telling the partner to fix a file that was
|
|
6399
|
+
* exactly as the wizard wrote it. It was never run in that mode.
|
|
6400
|
+
*/
|
|
6401
|
+
let receiverMode = 'write-through';
|
|
6402
|
+
try {
|
|
6403
|
+
const cfg = JSON.parse(readFileSync(new URL('../oneaddress.config.json', import.meta.url), 'utf8'));
|
|
6404
|
+
if (cfg && typeof cfg.mode === 'string') receiverMode = cfg.mode;
|
|
6405
|
+
} catch {
|
|
6406
|
+
// No config, or unreadable: treat it as the default, which is what every
|
|
6407
|
+
// scaffold before the mode existed was.
|
|
6408
|
+
}
|
|
6409
|
+
const isInbox = receiverMode === 'inbox';
|
|
6410
|
+
|
|
6411
|
+
if (!secret || !partnerId) {
|
|
6412
|
+
console.error('[test] Missing WEBHOOK_SECRET or PARTNER_ID. Check your .env file.');
|
|
5566
6413
|
process.exit(1);
|
|
5567
6414
|
}
|
|
5568
6415
|
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
6416
|
+
/**
|
|
6417
|
+
* The public key the conformance suite encrypts to.
|
|
6418
|
+
*
|
|
6419
|
+
* Write-through derives it from the private key it already holds. Inbox mode
|
|
6420
|
+
* has no private key to derive from, so it takes the PUBLIC half directly:
|
|
6421
|
+
* a public key is not a secret, and keeping one here does not give this process
|
|
6422
|
+
* the ability to read anything.
|
|
6423
|
+
*
|
|
6424
|
+
* Copy it from partners.oneaddress.io -> My Profile.
|
|
6425
|
+
*/
|
|
6426
|
+
const publicKeyFromEnv = (process.env.PARTNER_PUBLIC_KEY_B64 ?? '').trim();
|
|
6427
|
+
|
|
6428
|
+
if (isInbox && !publicKeyFromEnv) {
|
|
6429
|
+
console.log('[test] Inbox mode: this receiver holds no private key, so the encrypted checks');
|
|
6430
|
+
console.log('[test] cannot be built here. Two ways to run them:');
|
|
6431
|
+
console.log('[test]');
|
|
6432
|
+
console.log('[test] 1. Set PARTNER_PUBLIC_KEY_B64 in .env (copy it from My Profile in the');
|
|
6433
|
+
console.log('[test] portal). It is a public value and safe to keep on this machine.');
|
|
6434
|
+
console.log('[test] 2. Or run conformance from your connector host, which has the key.');
|
|
6435
|
+
console.log('[test]');
|
|
6436
|
+
console.log('[test] Signing and replay are covered by the wizard\\'s own checks either way.');
|
|
6437
|
+
process.exit(0);
|
|
6438
|
+
}
|
|
6439
|
+
|
|
6440
|
+
if (!isInbox && !keyPem) {
|
|
6441
|
+
console.error('[test] Missing PARTNER_PRIVATE_KEY_PEM. Check your .env file.');
|
|
5576
6442
|
process.exit(1);
|
|
5577
6443
|
}
|
|
5578
6444
|
|
|
6445
|
+
let publicKeyB64: string;
|
|
6446
|
+
if (publicKeyFromEnv) {
|
|
6447
|
+
publicKeyB64 = publicKeyFromEnv;
|
|
6448
|
+
} else {
|
|
6449
|
+
try {
|
|
6450
|
+
const priv = createPrivateKey({ key: keyPem, format: 'pem' });
|
|
6451
|
+
const pub = createPublicKey(priv);
|
|
6452
|
+
publicKeyB64 = pub.export({ type: 'spki', format: 'der' }).toString('base64');
|
|
6453
|
+
} catch (err) {
|
|
6454
|
+
console.error('[test] Failed to derive public key from PARTNER_PRIVATE_KEY_PEM:', err);
|
|
6455
|
+
process.exit(1);
|
|
6456
|
+
}
|
|
6457
|
+
}
|
|
6458
|
+
|
|
5579
6459
|
console.log(\`[test] Running conformance against \${targetUrl}\\n\`);
|
|
5580
6460
|
|
|
5581
6461
|
/**
|
|
@@ -5596,9 +6476,14 @@ console.log(\`[test] Running conformance against \${targetUrl}\\n\`);
|
|
|
5596
6476
|
* the check would be asking a question your code is not the one answering.
|
|
5597
6477
|
*/
|
|
5598
6478
|
const verifiesAccountReference =
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
6479
|
+
// NEVER in inbox mode. The receiver cannot read an account reference there,
|
|
6480
|
+
// so check-14 asks it a question only the connector can answer, and a
|
|
6481
|
+
// correctly built system fails a check it was never the subject of.
|
|
6482
|
+
!isInbox && (
|
|
6483
|
+
process.env.OA_VERIFIES_ACCOUNT_REFERENCE === '1' ||
|
|
6484
|
+
process.env.OA_VERIFIES_ACCOUNT_REFERENCE === 'true' ||
|
|
6485
|
+
%%VERIFIES_ACCOUNT_REFERENCE%%
|
|
6486
|
+
);
|
|
5602
6487
|
|
|
5603
6488
|
const result = spawnSync(
|
|
5604
6489
|
'npx',
|
|
@@ -5662,14 +6547,36 @@ computes the LOA reference with \`d5LoaRef\`, and hands it to your store as
|
|
|
5662
6547
|
|
|
5663
6548
|
\`\`\`sql
|
|
5664
6549
|
-- Your customer roster: who you know + the address you hold on file today.
|
|
5665
|
-
--
|
|
5666
|
-
--
|
|
5667
|
-
|
|
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)
|
|
5668
6559
|
|
|
5669
6560
|
-- Full history of every address change you apply (append-only audit trail).
|
|
5670
|
-
|
|
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)
|
|
6564
|
+
\`\`\`
|
|
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
|
|
5671
6571
|
\`\`\`
|
|
5672
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
|
+
|
|
5673
6580
|
## Swapping to a production database
|
|
5674
6581
|
|
|
5675
6582
|
Open \`src/store.ts\` and replace the \`db\` calls with your ORM/driver of choice:
|
|
@@ -6310,7 +7217,7 @@ async def _confirm_to_oneaddress(dispatch: str, status: str) -> None:
|
|
|
6310
7217
|
"dispatch_id": dispatch_id,
|
|
6311
7218
|
"partner_id": PARTNER_ID,
|
|
6312
7219
|
"status": status,
|
|
6313
|
-
"note": "
|
|
7220
|
+
"note": "Reported by the OneAddress webhook receiver",
|
|
6314
7221
|
})
|
|
6315
7222
|
ts = str(int(time.time()))
|
|
6316
7223
|
sig = hmac_lib.new(CONFIRM_SECRET.encode(), f"{ts}.{body_str}".encode(), hashlib.sha256).hexdigest()
|
|
@@ -7325,7 +8232,7 @@ public class OneAddressWebhookController {
|
|
|
7325
8232
|
confirmBody.put("dispatch_id", dispatchId);
|
|
7326
8233
|
confirmBody.put("partner_id", partnerId);
|
|
7327
8234
|
confirmBody.put("status", status);
|
|
7328
|
-
confirmBody.put("note", "
|
|
8235
|
+
confirmBody.put("note", "Reported by the OneAddress webhook receiver");
|
|
7329
8236
|
String bodyStr = MAPPER.writeValueAsString(confirmBody);
|
|
7330
8237
|
String ts = String.valueOf(System.currentTimeMillis() / 1000L);
|
|
7331
8238
|
String sig = OneAddressVerifier.sign(ts + "." + bodyStr, confirmSecret);
|
|
@@ -8371,7 +9278,7 @@ async Task ConfirmToOneAddress(string oneAddressApi, string confirmSecret, strin
|
|
|
8371
9278
|
dispatch_id = dispatchId,
|
|
8372
9279
|
partner_id = pid,
|
|
8373
9280
|
status,
|
|
8374
|
-
note = "
|
|
9281
|
+
note = "Reported by the OneAddress webhook receiver",
|
|
8375
9282
|
});
|
|
8376
9283
|
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
|
8377
9284
|
var sig = Convert.ToHexString(
|
|
@@ -9473,7 +10380,7 @@ func confirmToOneAddress(oneAddressAPI, confirmSecret, partnerID, dispatch, stat
|
|
|
9473
10380
|
"dispatch_id": dispatchID,
|
|
9474
10381
|
"partner_id": partnerID,
|
|
9475
10382
|
"status": status,
|
|
9476
|
-
"note": "
|
|
10383
|
+
"note": "Reported by the OneAddress webhook receiver",
|
|
9477
10384
|
})
|
|
9478
10385
|
bodyStr := string(bodyBytes)
|
|
9479
10386
|
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
|
@@ -10257,7 +11164,7 @@ function oaConfirmToOneAddress(string $oneAddressApi, string $confirmSecret, str
|
|
|
10257
11164
|
'dispatch_id' => (int) $dispatch,
|
|
10258
11165
|
'partner_id' => $partnerId,
|
|
10259
11166
|
'status' => $status,
|
|
10260
|
-
'note' => '
|
|
11167
|
+
'note' => 'Reported by the OneAddress webhook receiver',
|
|
10261
11168
|
]);
|
|
10262
11169
|
$ts = (string) time();
|
|
10263
11170
|
$sig = hash_hmac('sha256', $ts . '.' . $bodyStr, $confirmSecret);
|
|
@@ -10735,10 +11642,11 @@ npx @oneaddress/conformance test %%WEBHOOK_URL%%
|
|
|
10735
11642
|
};
|
|
10736
11643
|
|
|
10737
11644
|
// src/scaffold.ts
|
|
10738
|
-
function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken) {
|
|
11645
|
+
function applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken, port) {
|
|
10739
11646
|
const filled = content.replaceAll("%%PARTNER_ID%%", partnerId).replaceAll("%%WEBHOOK_SECRET%%", webhookSecret).replaceAll("%%WEBHOOK_URL%%", webhookUrl || "<your-webhook-url>").replaceAll("%%PRIVATE_KEY%%", privateKey || "<paste your PKCS8 PEM private key here>").replaceAll("%%ONEADDRESS_API%%", oneAddressApi || "https://oneaddress.io").replaceAll("%%VERIFIES_ACCOUNT_REFERENCE%%", verifiesAccountReference ? "true" : "false").replaceAll("%%RECEIVER_MODE%%", mode === "inbox" ? "inbox" : "write-through");
|
|
10740
|
-
|
|
10741
|
-
|
|
11647
|
+
const withPort = applyPort(filled, port);
|
|
11648
|
+
if (mode !== "inbox") return withPort;
|
|
11649
|
+
const withReadme = withPort.replace(
|
|
10742
11650
|
"## Architecture",
|
|
10743
11651
|
`## Inbox mode
|
|
10744
11652
|
|
|
@@ -10773,11 +11681,17 @@ hand somebody your customers' addresses.
|
|
|
10773
11681
|
CONNECTOR_TOKEN=${connectorToken || generateConnectorToken()}`
|
|
10774
11682
|
);
|
|
10775
11683
|
}
|
|
11684
|
+
function applyPort(content, port) {
|
|
11685
|
+
if (!Number.isInteger(port) || port === DEFAULT_PORT) return content;
|
|
11686
|
+
return content.replace(/^PORT=\d+$/m, `PORT=${port}`);
|
|
11687
|
+
}
|
|
11688
|
+
var DEFAULT_PORT = 3001;
|
|
11689
|
+
var RESERVED_PORTS = [3002, 3003];
|
|
10776
11690
|
function generateConnectorToken() {
|
|
10777
11691
|
return (0, import_node_crypto.randomBytes)(24).toString("hex");
|
|
10778
11692
|
}
|
|
10779
11693
|
var SENSITIVE_FILES = /* @__PURE__ */ new Set([".env"]);
|
|
10780
|
-
async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io", mode = "write-through", connectorToken = "") {
|
|
11694
|
+
async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUrl, privateKey = "", verifiesAccountReference = false, oneAddressApi = "https://oneaddress.io", mode = "write-through", connectorToken = "", port = DEFAULT_PORT) {
|
|
10781
11695
|
const templates = TEMPLATES[platform];
|
|
10782
11696
|
if (!templates) throw new Error(`Unknown platform: ${platform}`);
|
|
10783
11697
|
const written = [];
|
|
@@ -10787,7 +11701,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
10787
11701
|
if (!(0, import_node_fs.existsSync)(dir)) {
|
|
10788
11702
|
await (0, import_promises.mkdir)(dir, { recursive: true });
|
|
10789
11703
|
}
|
|
10790
|
-
const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken);
|
|
11704
|
+
const filled = applyTokens(content, partnerId, webhookSecret, webhookUrl, privateKey, verifiesAccountReference, oneAddressApi, mode, connectorToken, port);
|
|
10791
11705
|
const isSensitive = SENSITIVE_FILES.has((0, import_node_path.basename)(name));
|
|
10792
11706
|
await (0, import_promises.writeFile)(dest, filled, { encoding: "utf8", mode: isSensitive ? 384 : 420 });
|
|
10793
11707
|
if (isSensitive && process.platform !== "win32") {
|
|
@@ -10803,7 +11717,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
10803
11717
|
|
|
10804
11718
|
// src/register.ts
|
|
10805
11719
|
var import_node_crypto2 = require("crypto");
|
|
10806
|
-
var PKG_VERSION = true ? "2.
|
|
11720
|
+
var PKG_VERSION = true ? "2.5.0" : "dev";
|
|
10807
11721
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
10808
11722
|
function hmacSha256(secret, message) {
|
|
10809
11723
|
return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
|
|
@@ -11054,15 +11968,17 @@ Timed out after 5 minutes.`.trim(),
|
|
|
11054
11968
|
|
|
11055
11969
|
// src/autostart.ts
|
|
11056
11970
|
var import_node_child_process2 = require("child_process");
|
|
11057
|
-
|
|
11058
|
-
|
|
11059
|
-
|
|
11060
|
-
|
|
11061
|
-
|
|
11062
|
-
|
|
11063
|
-
|
|
11064
|
-
|
|
11065
|
-
|
|
11971
|
+
function commandsFor(port) {
|
|
11972
|
+
return {
|
|
11973
|
+
"ts-node": { cmd: "npx", args: ["tsx", "src/server.ts"] },
|
|
11974
|
+
"python": { cmd: "uvicorn", args: ["app:app", "--port", String(port)] },
|
|
11975
|
+
"go-http": { cmd: "go", args: ["run", "."] },
|
|
11976
|
+
"php-laravel": { cmd: "php", args: ["artisan", "serve", `--port=${port}`] },
|
|
11977
|
+
"csharp-aspnet": { cmd: "dotnet", args: ["run"] },
|
|
11978
|
+
"java-spring": { cmd: "mvn", args: ["spring-boot:run"] }
|
|
11979
|
+
// no mvnw wrapper is scaffolded
|
|
11980
|
+
};
|
|
11981
|
+
}
|
|
11066
11982
|
var DASHBOARD_COMMANDS = {
|
|
11067
11983
|
"ts-node": { cmd: "npx", args: ["tsx", "src/index.ts"] }
|
|
11068
11984
|
};
|
|
@@ -11164,13 +12080,13 @@ async function pollHealth(port, timeoutMs) {
|
|
|
11164
12080
|
return false;
|
|
11165
12081
|
}
|
|
11166
12082
|
async function startServer(platform, outputDir, port = 3001, secrets = {}) {
|
|
11167
|
-
const spec =
|
|
12083
|
+
const spec = commandsFor(port)[platform];
|
|
11168
12084
|
if (!spec) {
|
|
11169
12085
|
return { ok: false, output: `No start command defined for platform: ${platform}`, manualCommand: "" };
|
|
11170
12086
|
}
|
|
11171
12087
|
serverOutput = "";
|
|
11172
12088
|
const manualCommand = `cd ${outputDir} && ${spec.cmd} ${spec.args.join(" ")}`;
|
|
11173
|
-
serverProcess = spawnReceiver(spec, outputDir, ["ignore", "pipe", "pipe"], secrets);
|
|
12089
|
+
serverProcess = spawnReceiver(spec, outputDir, ["ignore", "pipe", "pipe"], { ...secrets, PORT: String(port) });
|
|
11174
12090
|
serverProcess.stdout?.on("data", (d3) => {
|
|
11175
12091
|
serverOutput += d3.toString();
|
|
11176
12092
|
});
|
|
@@ -11217,7 +12133,7 @@ async function handOverTerminal(platform, outputDir, port, secrets = {}) {
|
|
|
11217
12133
|
}
|
|
11218
12134
|
}
|
|
11219
12135
|
process.stdin.pause();
|
|
11220
|
-
const child = spawnReceiver(spec, outputDir, "inherit", secrets);
|
|
12136
|
+
const child = spawnReceiver(spec, outputDir, "inherit", { ...secrets, PORT: String(port) });
|
|
11221
12137
|
serverProcess = child;
|
|
11222
12138
|
return new Promise((resolve2) => {
|
|
11223
12139
|
child.once("error", (err) => {
|
|
@@ -11747,6 +12663,25 @@ function validatePrivateKey(pem) {
|
|
|
11747
12663
|
function stripControlChars(line) {
|
|
11748
12664
|
return line.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
|
|
11749
12665
|
}
|
|
12666
|
+
function validatePortChoice(v2) {
|
|
12667
|
+
const raw = (v2 ?? "").trim();
|
|
12668
|
+
if (!raw) return void 0;
|
|
12669
|
+
if (!/^\d+$/.test(raw)) return `Enter a port number, or leave blank for ${DEFAULT_PORT}`;
|
|
12670
|
+
const n = Number(raw);
|
|
12671
|
+
if (n < 1024 || n > 65535) return "Choose a port between 1024 and 65535";
|
|
12672
|
+
if (RESERVED_PORTS.includes(n)) {
|
|
12673
|
+
return `${n} is used by the connector channel. Choose another port.`;
|
|
12674
|
+
}
|
|
12675
|
+
return void 0;
|
|
12676
|
+
}
|
|
12677
|
+
function childEnvForMode(mode, v2) {
|
|
12678
|
+
return {
|
|
12679
|
+
WEBHOOK_SECRET: v2.secret,
|
|
12680
|
+
PARTNER_ID: v2.partnerId,
|
|
12681
|
+
VERIFIES_ACCOUNT_REFERENCE: String(v2.verifiesAccountReference),
|
|
12682
|
+
...mode === "inbox" ? { CONNECTOR_TOKEN: v2.connectorToken } : { PARTNER_PRIVATE_KEY_PEM: v2.privateKey }
|
|
12683
|
+
};
|
|
12684
|
+
}
|
|
11750
12685
|
async function main() {
|
|
11751
12686
|
printHeader();
|
|
11752
12687
|
Ie("OneAddress Partner Setup");
|
|
@@ -11796,23 +12731,6 @@ async function main() {
|
|
|
11796
12731
|
"The portal rejected that secret (HTTP 401): it does not match the one on your profile.\nRegenerate it at partners.oneaddress.io -> Webhook -> Webhook signing secret\n(use Revoke and regenerate, not Rotate), copy the value shown once, and paste it here."
|
|
11797
12732
|
);
|
|
11798
12733
|
}
|
|
11799
|
-
const privateKeyRaw = await he({
|
|
11800
|
-
message: "Your ECDH Private Key",
|
|
11801
|
-
placeholder: "Paste the base64 key body from My Profile, or enter a path to the .pem file",
|
|
11802
|
-
validate: (v2) => {
|
|
11803
|
-
if (!v2.trim()) return "Private key is required";
|
|
11804
|
-
const { pem, error } = normalisePrivateKey(v2);
|
|
11805
|
-
if (error) return error;
|
|
11806
|
-
return validatePrivateKey(pem);
|
|
11807
|
-
}
|
|
11808
|
-
});
|
|
11809
|
-
assertNotCancelled(privateKeyRaw);
|
|
11810
|
-
const normalised = normalisePrivateKey(privateKeyRaw.trim());
|
|
11811
|
-
if (normalised.error) {
|
|
11812
|
-
xe(`Private key parse failed unexpectedly: ${normalised.error}`);
|
|
11813
|
-
process.exit(1);
|
|
11814
|
-
}
|
|
11815
|
-
const privateKey = normalised.pem;
|
|
11816
12734
|
const platform = await ve({
|
|
11817
12735
|
message: "Which platform?",
|
|
11818
12736
|
options: [
|
|
@@ -11829,29 +12747,6 @@ async function main() {
|
|
|
11829
12747
|
]
|
|
11830
12748
|
});
|
|
11831
12749
|
assertNotCancelled(platform);
|
|
11832
|
-
let outDir;
|
|
11833
|
-
for (; ; ) {
|
|
11834
|
-
const outputDir = await he({
|
|
11835
|
-
message: "Where to write the files?",
|
|
11836
|
-
placeholder: "./oneaddress-webhook",
|
|
11837
|
-
defaultValue: "./oneaddress-webhook"
|
|
11838
|
-
});
|
|
11839
|
-
assertNotCancelled(outputDir);
|
|
11840
|
-
outDir = outputDir.trim() || "./oneaddress-webhook";
|
|
11841
|
-
if (!(0, import_node_fs3.existsSync)(outDir) || (0, import_node_fs3.readdirSync)(outDir).length === 0) break;
|
|
11842
|
-
const overwrite = await ye({
|
|
11843
|
-
message: `${outDir} already has files \u2014 overwrite?`,
|
|
11844
|
-
initialValue: false
|
|
11845
|
-
});
|
|
11846
|
-
assertNotCancelled(overwrite);
|
|
11847
|
-
if (overwrite) break;
|
|
11848
|
-
M2.info(`Keeping the files in ${outDir}. Enter a different directory to write into.`);
|
|
11849
|
-
}
|
|
11850
|
-
let accountRefReason = "";
|
|
11851
|
-
const accountRefDeclaration = await getVerifiesAccountReference(pid, secret, (r2) => {
|
|
11852
|
-
accountRefReason = r2;
|
|
11853
|
-
});
|
|
11854
|
-
const verifiesAccountReference = accountRefDeclaration === true;
|
|
11855
12750
|
const modeChoice = platform !== "ts-node" ? "write-through" : await ve({
|
|
11856
12751
|
message: "How should this receiver handle updates?",
|
|
11857
12752
|
options: [
|
|
@@ -11885,6 +12780,69 @@ not your webhook secret and not your confirm secret, because those are shared wi
|
|
|
11885
12780
|
OneAddress and a leak of either must not also hand somebody your customers' addresses.`
|
|
11886
12781
|
);
|
|
11887
12782
|
}
|
|
12783
|
+
let privateKey = "";
|
|
12784
|
+
if (receiverMode === "inbox") {
|
|
12785
|
+
M2.info(
|
|
12786
|
+
"No private key is collected for an inbox receiver. It belongs in your connector,\nwhich is the only process that decrypts. This one is meant never to hold one, and\nit refuses to start if it finds one in its environment."
|
|
12787
|
+
);
|
|
12788
|
+
} else {
|
|
12789
|
+
const privateKeyRaw = await he({
|
|
12790
|
+
message: "Your ECDH Private Key",
|
|
12791
|
+
placeholder: "Paste the base64 key body from My Profile, or enter a path to the .pem file",
|
|
12792
|
+
validate: (v2) => {
|
|
12793
|
+
if (!v2.trim()) return "Private key is required";
|
|
12794
|
+
const { pem, error } = normalisePrivateKey(v2);
|
|
12795
|
+
if (error) return error;
|
|
12796
|
+
return validatePrivateKey(pem);
|
|
12797
|
+
}
|
|
12798
|
+
});
|
|
12799
|
+
assertNotCancelled(privateKeyRaw);
|
|
12800
|
+
const normalised = normalisePrivateKey(privateKeyRaw.trim());
|
|
12801
|
+
if (normalised.error) {
|
|
12802
|
+
xe(`Private key parse failed unexpectedly: ${normalised.error}`);
|
|
12803
|
+
process.exit(1);
|
|
12804
|
+
}
|
|
12805
|
+
privateKey = normalised.pem;
|
|
12806
|
+
}
|
|
12807
|
+
const portAnswer = await he({
|
|
12808
|
+
message: "Which port should the receiver listen on?",
|
|
12809
|
+
placeholder: String(DEFAULT_PORT),
|
|
12810
|
+
defaultValue: String(DEFAULT_PORT),
|
|
12811
|
+
initialValue: String(DEFAULT_PORT),
|
|
12812
|
+
validate: validatePortChoice
|
|
12813
|
+
});
|
|
12814
|
+
assertNotCancelled(portAnswer);
|
|
12815
|
+
const chosenPort = Number(String(portAnswer).trim() || DEFAULT_PORT);
|
|
12816
|
+
if (!await isPortFree(chosenPort)) {
|
|
12817
|
+
M2.warn(
|
|
12818
|
+
`Port ${chosenPort} looks busy. Free it (lsof -i :${chosenPort} on macOS or Linux,
|
|
12819
|
+
netstat -ano | findstr :${chosenPort} on Windows), or change PORT in the generated
|
|
12820
|
+
.env afterwards. Setup will carry on either way.`
|
|
12821
|
+
);
|
|
12822
|
+
}
|
|
12823
|
+
let outDir;
|
|
12824
|
+
for (; ; ) {
|
|
12825
|
+
const outputDir = await he({
|
|
12826
|
+
message: "Where to write the files?",
|
|
12827
|
+
placeholder: "./oneaddress-webhook",
|
|
12828
|
+
defaultValue: "./oneaddress-webhook"
|
|
12829
|
+
});
|
|
12830
|
+
assertNotCancelled(outputDir);
|
|
12831
|
+
outDir = outputDir.trim() || "./oneaddress-webhook";
|
|
12832
|
+
if (!(0, import_node_fs3.existsSync)(outDir) || (0, import_node_fs3.readdirSync)(outDir).length === 0) break;
|
|
12833
|
+
const overwrite = await ye({
|
|
12834
|
+
message: `${outDir} already has files \u2014 overwrite?`,
|
|
12835
|
+
initialValue: false
|
|
12836
|
+
});
|
|
12837
|
+
assertNotCancelled(overwrite);
|
|
12838
|
+
if (overwrite) break;
|
|
12839
|
+
M2.info(`Keeping the files in ${outDir}. Enter a different directory to write into.`);
|
|
12840
|
+
}
|
|
12841
|
+
let accountRefReason = "";
|
|
12842
|
+
const accountRefDeclaration = await getVerifiesAccountReference(pid, secret, (r2) => {
|
|
12843
|
+
accountRefReason = r2;
|
|
12844
|
+
});
|
|
12845
|
+
const verifiesAccountReference = accountRefDeclaration === true;
|
|
11888
12846
|
if (accountRefDeclaration === void 0) {
|
|
11889
12847
|
M2.warn(
|
|
11890
12848
|
"Could not read your account-reference declaration from the portal.\n" + (accountRefReason ? "Reason: " + accountRefReason + "\n" : "") + "Conformance check-14 has been left out of the generated `npm test`, so a\npassing run does NOT mean your receiver matches account references correctly.\nSet it at partners.oneaddress.io \u2192 My Profile, then re-run this wizard."
|
|
@@ -11905,7 +12863,8 @@ OneAddress and a leak of either must not also hand somebody your customers' addr
|
|
|
11905
12863
|
verifiesAccountReference,
|
|
11906
12864
|
void 0,
|
|
11907
12865
|
receiverMode,
|
|
11908
|
-
connectorToken
|
|
12866
|
+
connectorToken,
|
|
12867
|
+
chosenPort
|
|
11909
12868
|
);
|
|
11910
12869
|
s1.stop(`Scaffolded ${written.length} files in ${outDir}`);
|
|
11911
12870
|
for (const f of written) M2.success(` ${f}`);
|
|
@@ -11942,22 +12901,18 @@ OneAddress and a leak of either must not also hand somebody your customers' addr
|
|
|
11942
12901
|
}
|
|
11943
12902
|
if (choice === "skip") break;
|
|
11944
12903
|
}
|
|
11945
|
-
const
|
|
11946
|
-
|
|
11947
|
-
|
|
11948
|
-
|
|
11949
|
-
|
|
11950
|
-
|
|
11951
|
-
}
|
|
12904
|
+
const childSecrets = childEnvForMode(receiverMode, {
|
|
12905
|
+
secret,
|
|
12906
|
+
partnerId: pid,
|
|
12907
|
+
verifiesAccountReference,
|
|
12908
|
+
privateKey,
|
|
12909
|
+
connectorToken
|
|
12910
|
+
});
|
|
12911
|
+
const SERVER_PORT = chosenPort;
|
|
11952
12912
|
const s3 = Y2();
|
|
11953
12913
|
s3.start(`Starting server (waiting up to 15 s for /health on :${SERVER_PORT})`);
|
|
11954
12914
|
onCleanup(stopServer);
|
|
11955
|
-
const start = await startServer(platform, outDir, SERVER_PORT,
|
|
11956
|
-
WEBHOOK_SECRET: secret,
|
|
11957
|
-
PARTNER_ID: pid,
|
|
11958
|
-
PARTNER_PRIVATE_KEY_PEM: privateKey,
|
|
11959
|
-
VERIFIES_ACCOUNT_REFERENCE: String(verifiesAccountReference)
|
|
11960
|
-
});
|
|
12915
|
+
const start = await startServer(platform, outDir, SERVER_PORT, childSecrets);
|
|
11961
12916
|
let serverRunning = false;
|
|
11962
12917
|
if (start.ok) {
|
|
11963
12918
|
s3.stop(`Server is healthy on port ${SERVER_PORT}`);
|
|
@@ -12031,7 +12986,7 @@ Continuing will overwrite it.`
|
|
|
12031
12986
|
s4.start("Starting Cloudflare Tunnel");
|
|
12032
12987
|
onCleanup(stopTunnel);
|
|
12033
12988
|
try {
|
|
12034
|
-
let tunnel = await startTunnel(
|
|
12989
|
+
let tunnel = await startTunnel(SERVER_PORT);
|
|
12035
12990
|
webhookUrl = `${tunnel.url}/webhook`;
|
|
12036
12991
|
tunnelBase = tunnel.url;
|
|
12037
12992
|
s4.stop(`Tunnel active: ${tunnel.url}`);
|
|
@@ -12044,7 +12999,7 @@ Continuing will overwrite it.`
|
|
|
12044
12999
|
if (!webhookReady) {
|
|
12045
13000
|
s4b.stop("First tunnel is slow to route, trying a fresh one");
|
|
12046
13001
|
stopTunnel();
|
|
12047
|
-
tunnel = await startTunnel(
|
|
13002
|
+
tunnel = await startTunnel(SERVER_PORT);
|
|
12048
13003
|
webhookUrl = `${tunnel.url}/webhook`;
|
|
12049
13004
|
tunnelBase = tunnel.url;
|
|
12050
13005
|
const s4c = Y2();
|
|
@@ -12057,7 +13012,7 @@ Continuing will overwrite it.`
|
|
|
12057
13012
|
if (!webhookReady) {
|
|
12058
13013
|
M2.warn(
|
|
12059
13014
|
`The tunnel edge is still not routing after a retry. This is almost always
|
|
12060
|
-
one of two things, and neither is your server (it is running on
|
|
13015
|
+
one of two things, and neither is your server (it is running on :${SERVER_PORT}):
|
|
12061
13016
|
1. Open ${tunnel.url}/health in a browser. If it never loads, your network
|
|
12062
13017
|
is blocking cloudflared and no wait will fix it, so try a different network.
|
|
12063
13018
|
2. The tunnel only lives while this wizard runs, so keep this window open.`
|
|
@@ -12067,7 +13022,8 @@ one of two things, and neither is your server (it is running on :3001):
|
|
|
12067
13022
|
s4.stop("Tunnel setup failed");
|
|
12068
13023
|
M2.warn(`Could not start tunnel: ${err instanceof Error ? err.message : String(err)}`);
|
|
12069
13024
|
M2.warn(
|
|
12070
|
-
|
|
13025
|
+
`To get a public URL manually, try: npx cloudflared tunnel --url http://localhost:${SERVER_PORT}
|
|
13026
|
+
Or deploy to any server and set the URL in partners.oneaddress.io \u2192 My Profile.`
|
|
12071
13027
|
);
|
|
12072
13028
|
}
|
|
12073
13029
|
}
|
|
@@ -12105,7 +13061,7 @@ one of two things, and neither is your server (it is running on :3001):
|
|
|
12105
13061
|
if (!webhookReady) {
|
|
12106
13062
|
M2.warn(
|
|
12107
13063
|
`Skipping the conformance checks: the tunnel edge is not routing.
|
|
12108
|
-
Your server is fine, it is running on
|
|
13064
|
+
Your server is fine, it is running on :${SERVER_PORT}. This is either your network
|
|
12109
13065
|
blocking cloudflared (open ${tunnelBase}/health in a browser to check) or the
|
|
12110
13066
|
tunnel needing this wizard to stay open. Re-run once that URL loads in a browser.`
|
|
12111
13067
|
);
|
|
@@ -12116,18 +13072,28 @@ tunnel needing this wizard to stay open. Re-run once that URL loads in a browser
|
|
|
12116
13072
|
await new Promise((r2) => setTimeout(r2, 200));
|
|
12117
13073
|
s6.stop("Conformance checks:");
|
|
12118
13074
|
await runConformance(webhookUrl, pid, secret);
|
|
12119
|
-
|
|
12120
|
-
|
|
13075
|
+
if (receiverMode === "inbox") {
|
|
13076
|
+
M2.info(
|
|
13077
|
+
"Decrypt check skipped: this receiver holds no private key, which is what inbox\nmode is for. Your connector is the process that decrypts, so prove the key there:\nstart it against this receiver and confirm it draws and applies a dispatch."
|
|
13078
|
+
);
|
|
13079
|
+
} else {
|
|
13080
|
+
await runDecryptCheck(secret, pid);
|
|
13081
|
+
}
|
|
13082
|
+
if (verifiesAccountReference && receiverMode !== "inbox") {
|
|
12121
13083
|
M2.info(
|
|
12122
13084
|
"You have declared that your receiver matches the account reference itself.\nRun `npm test` to include check-14, which sends an account reference that matches\nnothing and fails a receiver that reports it as applied. The three checks above\ncover signing and replay only."
|
|
12123
13085
|
);
|
|
13086
|
+
} else if (verifiesAccountReference) {
|
|
13087
|
+
M2.info(
|
|
13088
|
+
"You have declared that your receiver matches the account reference itself.\nIn inbox mode your CONNECTOR does that, before it applies anything, and it\nrefuses a dispatch whose reference matches none of your records. The three checks\nabove cover signing and replay only."
|
|
13089
|
+
);
|
|
12124
13090
|
}
|
|
12125
13091
|
}
|
|
12126
13092
|
}
|
|
12127
13093
|
const summary = [
|
|
12128
13094
|
`Partner ID: ${pid}`,
|
|
12129
13095
|
`Webhook URL: ${webhookUrl || "(not set \u2014 update in portal)"}`,
|
|
12130
|
-
`Server port:
|
|
13096
|
+
`Server port: ${SERVER_PORT}`,
|
|
12131
13097
|
`Files in: ${outDir}`
|
|
12132
13098
|
].join("\n");
|
|
12133
13099
|
const nextSteps = [
|
|
@@ -12142,12 +13108,7 @@ tunnel needing this wizard to stay open. Re-run once that URL loads in a browser
|
|
|
12142
13108
|
if (serverRunning) {
|
|
12143
13109
|
const openDashboard = platformHasDashboard(platform) ? await ye({ message: "Open the receiver dashboard now?", initialValue: true }) : false;
|
|
12144
13110
|
if (openDashboard === true) {
|
|
12145
|
-
const handover = await handOverTerminal(platform, outDir, SERVER_PORT,
|
|
12146
|
-
WEBHOOK_SECRET: secret,
|
|
12147
|
-
PARTNER_ID: pid,
|
|
12148
|
-
PARTNER_PRIVATE_KEY_PEM: privateKey,
|
|
12149
|
-
VERIFIES_ACCOUNT_REFERENCE: String(verifiesAccountReference)
|
|
12150
|
-
});
|
|
13111
|
+
const handover = await handOverTerminal(platform, outDir, SERVER_PORT, childSecrets);
|
|
12151
13112
|
if (!handover.ok) {
|
|
12152
13113
|
M2.warn(
|
|
12153
13114
|
`${handover.reason ?? "The dashboard could not start."}
|
|
@@ -12161,7 +13122,7 @@ Your receiver is set up and working. Start it yourself with:
|
|
|
12161
13122
|
console.log("");
|
|
12162
13123
|
console.log(` ${DIM2}\u250C${"\u2500".repeat(BOX_WIDTH)}\u2510${R3}`);
|
|
12163
13124
|
for (const inner of [
|
|
12164
|
-
` ${GRN}\u25C8${R3} ${CRM2}Server live${R3} ${MID2}on http://localhost
|
|
13125
|
+
` ${GRN}\u25C8${R3} ${CRM2}Server live${R3} ${MID2}on http://localhost:${SERVER_PORT}/webhook${R3}`,
|
|
12165
13126
|
` ${AMB2}\u25C8${R3} ${MID2}Edit ${CRM2}src/store.ts${R3} ${MID2}to wire your database${R3}`,
|
|
12166
13127
|
` ${MID2}Press Ctrl+C to stop the server and exit.${R3}`,
|
|
12167
13128
|
...platformHasDashboard(platform) ? [` ${MID2}Run ${CRM2}npm start${R3} ${MID2}here any time for the dashboard.${R3}`] : []
|