@oneaddress/setup 2.5.0 → 2.7.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 +443 -50
- 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.7.0" : "?";
|
|
860
860
|
function printCompactHeader() {
|
|
861
861
|
const INNER = 42;
|
|
862
862
|
const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
|
|
@@ -1013,7 +1013,8 @@ data.db-shm
|
|
|
1013
1013
|
"oneAddressApi": "%%ONEADDRESS_API%%",
|
|
1014
1014
|
"verifiesAccountReference": %%VERIFIES_ACCOUNT_REFERENCE%%,
|
|
1015
1015
|
"mode": "%%RECEIVER_MODE%%",
|
|
1016
|
-
"publicUrl": "%%WEBHOOK_URL%%"
|
|
1016
|
+
"publicUrl": "%%WEBHOOK_URL%%",
|
|
1017
|
+
"exportDir": ""
|
|
1017
1018
|
}
|
|
1018
1019
|
`
|
|
1019
1020
|
},
|
|
@@ -1713,7 +1714,8 @@ import { formatLine, report, type ReportLine } from './report.js';
|
|
|
1713
1714
|
// reads, with nothing here to change.
|
|
1714
1715
|
import { store } from './store.js';
|
|
1715
1716
|
import { pendingConfirmCount } from './confirm-queue.js';
|
|
1716
|
-
import { dismissHeld, exportHeld, heldCount, heldSummary } from './quarantine.js';
|
|
1717
|
+
import { dismissHeld, drillCount, exportHeld, heldCount, heldSummary } from './quarantine.js';
|
|
1718
|
+
import { config } from './config.js';
|
|
1717
1719
|
import { reachability } from './reachable.js';
|
|
1718
1720
|
|
|
1719
1721
|
/** blessed takes colours as strings; these mirror the site's palette. */
|
|
@@ -1874,6 +1876,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1874
1876
|
// ciphertext it cannot open; the customers live in the partner's own
|
|
1875
1877
|
// database, behind their own controls.
|
|
1876
1878
|
const reach = reachability();
|
|
1879
|
+
const drills = drillCount();
|
|
1877
1880
|
const vault = inbox
|
|
1878
1881
|
? \`{\${AMBER}-fg}{bold}NONE (inbox){/}\`
|
|
1879
1882
|
: store.encrypted
|
|
@@ -1892,6 +1895,11 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1892
1895
|
// third state. A receiver with no publicUrl configured cannot answer the
|
|
1893
1896
|
// question, and a green tick or a red cross would both be inventing an
|
|
1894
1897
|
// answer. Silence is the honest rendering of "not checked".
|
|
1898
|
+
// DRILLS ARE NAMED, not hidden. A conformance run leaves a trace and the
|
|
1899
|
+
// partner should be able to see it; what it must not do is look like a
|
|
1900
|
+
// fault. Absent when there are none, because a permanent "drills 0" is a
|
|
1901
|
+
// counter for a thing that has not happened.
|
|
1902
|
+
+ (drills > 0 ? \` {\${DIM}-fg}conformance probes{/} {\${CREAM}-fg}\${drills}{/}\` : '')
|
|
1895
1903
|
+ (reach.state === 'unknown'
|
|
1896
1904
|
? ''
|
|
1897
1905
|
: reach.state === 'reachable'
|
|
@@ -1939,6 +1947,16 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
1939
1947
|
|
|
1940
1948
|
/** Shown while a replay is running, so [r] does not look like it did nothing. */
|
|
1941
1949
|
let replayNote = '';
|
|
1950
|
+
/**
|
|
1951
|
+
* The export confirmation, kept apart from \`replayNote\`.
|
|
1952
|
+
*
|
|
1953
|
+
* They are both one-line notes under the fault panel and they answer
|
|
1954
|
+
* different questions, so sharing a slot means a press of [r] silently wipes
|
|
1955
|
+
* the only place the export path was written down. The PATH is the entire
|
|
1956
|
+
* value of the message: a receiver started from a shortcut or a service
|
|
1957
|
+
* manager has a working directory the partner has never seen.
|
|
1958
|
+
*/
|
|
1959
|
+
let exportNote = '';
|
|
1942
1960
|
|
|
1943
1961
|
/**
|
|
1944
1962
|
* The customer count, refreshed out of band.
|
|
@@ -2075,6 +2093,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2075
2093
|
// nothing, which is indistinguishable from a wedged UI.
|
|
2076
2094
|
let replaying = false;
|
|
2077
2095
|
screen.key(['r'], () => {
|
|
2096
|
+
if (promptOpen) return;
|
|
2078
2097
|
if (!onReplay || replaying) return;
|
|
2079
2098
|
replaying = true;
|
|
2080
2099
|
replayNote = 'Replaying\u2026';
|
|
@@ -2091,23 +2110,84 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2091
2110
|
.finally(() => { replaying = false; redraw(); });
|
|
2092
2111
|
});
|
|
2093
2112
|
|
|
2113
|
+
// A ONE-LINE PATH PROMPT DRAWN OVER THE DASHBOARD.
|
|
2114
|
+
//
|
|
2115
|
+
// \`screen.key\` handlers are GLOBAL in blessed and keep firing while a textbox
|
|
2116
|
+
// has focus, so [r], [d] and [q] would all act on keystrokes meant for the
|
|
2117
|
+
// path. \`promptOpen\` gates every one of them. Without it, typing a Windows
|
|
2118
|
+
// path containing a \`d\` arms a dismissal behind the prompt, and the \`q\` in
|
|
2119
|
+
// \`C:\\\\Users\\\\qmark\` quits the receiver mid-sentence.
|
|
2120
|
+
let promptOpen = false;
|
|
2121
|
+
function askForPath(initial: string, onAnswer: (dir: string) => void): void {
|
|
2122
|
+
promptOpen = true;
|
|
2123
|
+
const box = blessed.textbox({
|
|
2124
|
+
parent: screen,
|
|
2125
|
+
top: 'center', left: 'center', width: '80%', height: 3,
|
|
2126
|
+
border: { type: 'line' },
|
|
2127
|
+
style: { border: { fg: AMBER }, fg: CREAM },
|
|
2128
|
+
label: ' Export to which folder? Enter accepts, Esc cancels ',
|
|
2129
|
+
inputOnFocus: true,
|
|
2130
|
+
keys: true,
|
|
2131
|
+
});
|
|
2132
|
+
// PRE-FILLED WITH THE RESOLVED DEFAULT, not left empty. The answer to
|
|
2133
|
+
// "where does this go" has to be visible BEFORE the write, and an empty box
|
|
2134
|
+
// asks the partner to know something the dashboard never told them.
|
|
2135
|
+
box.setValue(initial);
|
|
2136
|
+
screen.render();
|
|
2137
|
+
box.readInput((_err, value) => {
|
|
2138
|
+
promptOpen = false;
|
|
2139
|
+
box.destroy();
|
|
2140
|
+
screen.render();
|
|
2141
|
+
// Escape gives no value. Treated as cancel, NOT as "use the default":
|
|
2142
|
+
// a partner who hits Esc has changed their mind about writing a file.
|
|
2143
|
+
if (typeof value !== 'string') { exportNote = 'Export cancelled.'; redraw(); return; }
|
|
2144
|
+
onAnswer(value.trim());
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2094
2148
|
// [e] EXPORTS THE FAULT LIST. Metadata only, no payloads: see exportHeld.
|
|
2095
2149
|
// Bound unconditionally for the same reason [r] is, so pressing it on a clean
|
|
2096
2150
|
// receiver says there is nothing to export rather than appearing to hang.
|
|
2151
|
+
//
|
|
2152
|
+
// IT ASKS WHERE, EVERY TIME (Tim's call). \`exportDir\` in
|
|
2153
|
+
// \`oneaddress.config.json\` and \`ONEADDRESS_EXPORT_DIR\` still decide the
|
|
2154
|
+
// DEFAULT, but they were the only ways to choose and neither is visible from
|
|
2155
|
+
// the dashboard - reported from a real run as "it looks like export location
|
|
2156
|
+
// cannot be picked". Pressing Enter on the pre-filled prompt is byte-for-byte
|
|
2157
|
+
// the old behaviour, so nothing that worked before needs changing.
|
|
2097
2158
|
screen.key(['e'], () => {
|
|
2159
|
+
if (promptOpen) return;
|
|
2098
2160
|
if (heldCount() === 0) {
|
|
2099
2161
|
replayNote = 'Nothing held, so nothing to export.';
|
|
2100
2162
|
redraw();
|
|
2101
2163
|
return;
|
|
2102
2164
|
}
|
|
2165
|
+
askForPath(config.exportDir || process.cwd(), (dir) => { writeExport(dir); redraw(); });
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
/**
|
|
2169
|
+
* Write the fault export, and say where it landed.
|
|
2170
|
+
*
|
|
2171
|
+
* Shared by [e] and [d] rather than duplicated, because [d] exports FIRST and
|
|
2172
|
+
* the two must agree on the destination and on the message. Returns whether
|
|
2173
|
+
* it succeeded, which is what lets [d] refuse to discard anything after a
|
|
2174
|
+
* failed write.
|
|
2175
|
+
*/
|
|
2176
|
+
function writeExport(dir: string): boolean {
|
|
2103
2177
|
try {
|
|
2104
|
-
const { path, count } = exportHeld(
|
|
2105
|
-
|
|
2178
|
+
const { path, count } = exportHeld(dir);
|
|
2179
|
+
exportNote = \`Saved \${count} to \${path}\`;
|
|
2180
|
+
replayNote = '';
|
|
2181
|
+
return true;
|
|
2106
2182
|
} catch (err) {
|
|
2107
|
-
|
|
2183
|
+
// NAMES THE DIRECTORY IT TRIED. "EACCES" on its own sends a partner to
|
|
2184
|
+
// search for a file that was never written.
|
|
2185
|
+
exportNote = \`Export FAILED writing to \${dir || process.cwd()}: \` +
|
|
2186
|
+
\`\${err instanceof Error ? err.message : String(err)}\`;
|
|
2187
|
+
replayNote = '';
|
|
2188
|
+
return false;
|
|
2108
2189
|
}
|
|
2109
|
-
|
|
2110
|
-
});
|
|
2190
|
+
}
|
|
2111
2191
|
|
|
2112
2192
|
// [d] DISMISSES. TWO PRESSES, and the first one only asks.
|
|
2113
2193
|
//
|
|
@@ -2118,10 +2198,19 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2118
2198
|
// working correctly, with nothing on screen that clears it. Reported from a
|
|
2119
2199
|
// real run: "I do wish the conformance wouldn't come up as a fault."
|
|
2120
2200
|
//
|
|
2121
|
-
//
|
|
2122
|
-
//
|
|
2123
|
-
//
|
|
2124
|
-
//
|
|
2201
|
+
// THE RECEIVER DOES RECOGNISE THE PROBE NOW, AND THIS PARAGRAPH USED TO SAY
|
|
2202
|
+
// OTHERWISE. It argued the receiver could not, because "everything that
|
|
2203
|
+
// identifies one is chosen by the sender". That over-weighted the attacker:
|
|
2204
|
+
// anyone able to forge a signed dispatch already holds the webhook secret and
|
|
2205
|
+
// can make this receiver APPLY a bogus address change, so hiding a quarantine
|
|
2206
|
+
// row is not the prize. The real question was signed versus unsigned, and
|
|
2207
|
+
// \`conformance: true\` rides inside the signed body. A drill is classified, not
|
|
2208
|
+
// suppressed - still written, still counted, drawn calmly rather than as an
|
|
2209
|
+
// alarm - so [d] is no longer the only way to clear a passing run.
|
|
2210
|
+
//
|
|
2211
|
+
// IT STILL EXISTS, because a drill is not the only thing that gets held: a
|
|
2212
|
+
// genuinely undecryptable dispatch from a key that has since been rotated is
|
|
2213
|
+
// never going to replay, and without this the panel stays red forever.
|
|
2125
2214
|
//
|
|
2126
2215
|
// IRREVERSIBLE, SO IT IS CONFIRMED. Dismissal discards the held payload, which
|
|
2127
2216
|
// is the point (see \`dismissHeld\`) and also means a mistaken press cannot be
|
|
@@ -2136,6 +2225,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2136
2225
|
// that was described makes the question and the action the same question.
|
|
2137
2226
|
let dismissArmedFor: number | null = null;
|
|
2138
2227
|
screen.key(['d'], () => {
|
|
2228
|
+
if (promptOpen) return;
|
|
2139
2229
|
const held = heldCount();
|
|
2140
2230
|
if (held === 0) {
|
|
2141
2231
|
dismissArmedFor = null;
|
|
@@ -2152,9 +2242,29 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2152
2242
|
return;
|
|
2153
2243
|
}
|
|
2154
2244
|
dismissArmedFor = null;
|
|
2245
|
+
// EXPORTED BEFORE IT IS DISCARDED, ALWAYS.
|
|
2246
|
+
//
|
|
2247
|
+
// \`exportHeld\` reads \`heldDispatches()\`, which filters out dismissed rows,
|
|
2248
|
+
// so [d] then [e] exported NOTHING and the record was unrecoverable from the
|
|
2249
|
+
// UI. The payload is meant to go - it is an encrypted consumer address on a
|
|
2250
|
+
// third party's disk - but the metadata is the part you would send to
|
|
2251
|
+
// support, and dismissing destroyed exactly that. Reported from a real run:
|
|
2252
|
+
// "if they dismiss a fault, does that mean they cant export and record it?"
|
|
2253
|
+
//
|
|
2254
|
+
// WRITTEN WITHOUT ASKING, unlike [e]. The partner is one keypress from
|
|
2255
|
+
// discarding the evidence and an extra prompt here is a prompt at the worst
|
|
2256
|
+
// possible moment; the default destination is the right answer for a record
|
|
2257
|
+
// nobody asked for. A FAILED write ABORTS the dismissal rather than
|
|
2258
|
+
// proceeding, because "we could not save the record, so we destroyed it" is
|
|
2259
|
+
// the one outcome nothing recovers from.
|
|
2260
|
+
if (!writeExport(config.exportDir || process.cwd())) {
|
|
2261
|
+
replayNote = 'Dismiss ABORTED: nothing was discarded because the record could not be saved.';
|
|
2262
|
+
redraw();
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2155
2265
|
try {
|
|
2156
2266
|
const n = dismissHeld();
|
|
2157
|
-
replayNote = \`Dismissed \${n}. Nothing left held.\`;
|
|
2267
|
+
replayNote = \`Dismissed \${n}. Nothing left held - the record was exported first.\`;
|
|
2158
2268
|
} catch (err) {
|
|
2159
2269
|
replayNote = \`Dismiss failed: \${err instanceof Error ? err.message : String(err)}\`;
|
|
2160
2270
|
}
|
|
@@ -2162,6 +2272,7 @@ export function startDashboard({ partnerName, port, onQuit, onReplay, stats }: T
|
|
|
2162
2272
|
});
|
|
2163
2273
|
|
|
2164
2274
|
screen.key(['q', 'C-c'], () => {
|
|
2275
|
+
if (promptOpen) return;
|
|
2165
2276
|
detach();
|
|
2166
2277
|
screen.destroy();
|
|
2167
2278
|
onQuit();
|
|
@@ -3367,8 +3478,9 @@ export function resetTally(): void {
|
|
|
3367
3478
|
import db, { ensureColumn, parseStoredTime } from './db.js';
|
|
3368
3479
|
import { report } from './report.js';
|
|
3369
3480
|
import { createHash } from 'node:crypto';
|
|
3370
|
-
import { writeFileSync } from 'node:fs';
|
|
3371
|
-
import { join } from 'node:path';
|
|
3481
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3482
|
+
import { join, resolve } from 'node:path';
|
|
3483
|
+
import { config } from './config.js';
|
|
3372
3484
|
|
|
3373
3485
|
/**
|
|
3374
3486
|
* Why a dispatch could not be applied. Shown verbatim on the dashboard.
|
|
@@ -3458,6 +3570,43 @@ ensureColumn('quarantine', 'attempts', 'INTEGER NOT NULL DEFAULT 0');
|
|
|
3458
3570
|
*/
|
|
3459
3571
|
ensureColumn('quarantine', 'dismissed_at', 'TEXT');
|
|
3460
3572
|
|
|
3573
|
+
/**
|
|
3574
|
+
* Was this a conformance DRILL rather than a real dispatch?
|
|
3575
|
+
*
|
|
3576
|
+
* ## The problem it solves
|
|
3577
|
+
*
|
|
3578
|
+
* Conformance check 11 sends a dispatch this receiver CANNOT open, on purpose,
|
|
3579
|
+
* and passes it for refusing one. So a receiver that quarantines ends a
|
|
3580
|
+
* SUCCESSFUL run with a red fault panel, a \`failed\` count that never returns to
|
|
3581
|
+
* zero, and a \`decrypt_failed\` line naming the partner's real \`key_id\` - telling
|
|
3582
|
+
* them to fix the one thing that is not broken. Reported from a real run.
|
|
3583
|
+
*
|
|
3584
|
+
* ## Why this is trusted and the dispatch id is not
|
|
3585
|
+
*
|
|
3586
|
+
* The flag comes from \`conformance: true\` in the body, and the HMAC covers
|
|
3587
|
+
* \`\${timestamp}.\${rawBody}\`. The body is signed; the headers are not. The
|
|
3588
|
+
* conformance dispatch also carries a recognisable \`X-OneAddress-Dispatch\`
|
|
3589
|
+
* prefix, and keying off THAT would be a mistake: it sits outside the signature,
|
|
3590
|
+
* so anything on the path could set it and change how an operator's fault panel
|
|
3591
|
+
* behaves. Same reasoning as \`crossover_key_ids\`, which is already a signed-body
|
|
3592
|
+
* field that tells a receiver how to treat a payload it cannot decrypt.
|
|
3593
|
+
*
|
|
3594
|
+
* ## Classified, NOT suppressed
|
|
3595
|
+
*
|
|
3596
|
+
* A drill is still recorded, still counted, still visible on the dashboard. It
|
|
3597
|
+
* is drawn calmly instead of as an alarm. Suppression is what creates a silent
|
|
3598
|
+
* failure; classification does not, and if the marker is ever wrong the operator
|
|
3599
|
+
* still sees the event, in the wrong colour rather than not at all.
|
|
3600
|
+
*
|
|
3601
|
+
* ## A drill holds NO payload
|
|
3602
|
+
*
|
|
3603
|
+
* \`raw_body\` is empty for a drill, which is a property rather than a saving.
|
|
3604
|
+
* The probe is sealed to a throwaway key pair the conformance run generated and
|
|
3605
|
+
* discarded, so no key that will ever exist can open it: replay cannot succeed,
|
|
3606
|
+
* and keeping the bytes would put an unopenable blob in the replay set forever.
|
|
3607
|
+
*/
|
|
3608
|
+
ensureColumn('quarantine', 'drill', 'INTEGER NOT NULL DEFAULT 0');
|
|
3609
|
+
|
|
3461
3610
|
export interface QuarantineInput {
|
|
3462
3611
|
dispatchId: string | null;
|
|
3463
3612
|
event: string;
|
|
@@ -3465,6 +3614,10 @@ export interface QuarantineInput {
|
|
|
3465
3614
|
keyId: string | null;
|
|
3466
3615
|
rawBody: string;
|
|
3467
3616
|
detail: string;
|
|
3617
|
+
/** A conformance probe rather than a consumer's dispatch. See the
|
|
3618
|
+
* \`drill\` column's docstring. Defaults to false, so a caller that does
|
|
3619
|
+
* not know about drills still records a real fault. */
|
|
3620
|
+
drill?: boolean;
|
|
3468
3621
|
}
|
|
3469
3622
|
|
|
3470
3623
|
/**
|
|
@@ -3476,27 +3629,44 @@ export interface QuarantineInput {
|
|
|
3476
3629
|
*/
|
|
3477
3630
|
export function quarantine(input: QuarantineInput): void {
|
|
3478
3631
|
const id = dispatchKey(input.dispatchId, input.rawBody);
|
|
3632
|
+
const drill = input.drill === true;
|
|
3479
3633
|
try {
|
|
3480
3634
|
db.prepare(
|
|
3481
|
-
\`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail)
|
|
3482
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3635
|
+
\`INSERT INTO quarantine (id, dispatch_id, event, reason, key_id, raw_body, detail, drill)
|
|
3636
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3483
3637
|
ON CONFLICT(id) DO UPDATE SET
|
|
3484
3638
|
reason = excluded.reason,
|
|
3485
3639
|
key_id = excluded.key_id,
|
|
3486
|
-
detail = excluded.detail
|
|
3640
|
+
detail = excluded.detail,
|
|
3641
|
+
drill = excluded.drill\`,
|
|
3487
3642
|
).run(
|
|
3488
3643
|
id,
|
|
3489
3644
|
input.dispatchId,
|
|
3490
3645
|
input.event,
|
|
3491
3646
|
input.reason,
|
|
3492
3647
|
input.keyId,
|
|
3493
|
-
|
|
3648
|
+
// NO PAYLOAD FOR A DRILL. The probe is sealed to a throwaway key pair the
|
|
3649
|
+
// conformance run generated and discarded, so nothing that will ever exist
|
|
3650
|
+
// can open it. Keeping the bytes would put a permanently unopenable blob
|
|
3651
|
+
// in the replay set.
|
|
3652
|
+
drill ? '' : input.rawBody,
|
|
3494
3653
|
input.detail.slice(0, 500),
|
|
3654
|
+
drill ? 1 : 0,
|
|
3495
3655
|
);
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3656
|
+
if (drill) {
|
|
3657
|
+
// INFO, NOT WARN, and it says what happened rather than what to fix. The
|
|
3658
|
+
// old line ended a PASSING conformance run by telling the partner to go
|
|
3659
|
+
// and correct a key that is perfectly correct.
|
|
3660
|
+
report.info(
|
|
3661
|
+
\`[quarantine] \${input.event} was a conformance probe and was refused, which is how that \` +
|
|
3662
|
+
'check is passed. Nothing is wrong with your key.',
|
|
3663
|
+
);
|
|
3664
|
+
} else {
|
|
3665
|
+
report.warn(
|
|
3666
|
+
\`[quarantine] \${input.event} held (\${input.reason}\${input.keyId ? \`, key_id \${input.keyId}\` : ''}). \` +
|
|
3667
|
+
'Fix the cause and press [r] on the dashboard, or run \`npm run replay\`, to apply it.',
|
|
3668
|
+
);
|
|
3669
|
+
}
|
|
3500
3670
|
} catch (err) {
|
|
3501
3671
|
report.error('[quarantine] could not hold this dispatch:', err instanceof Error ? err.message : err);
|
|
3502
3672
|
}
|
|
@@ -3521,16 +3691,30 @@ export function heldDispatches(limit = 50): HeldDispatch[] {
|
|
|
3521
3691
|
\`SELECT id, dispatch_id, event, reason, key_id, raw_body, detail,
|
|
3522
3692
|
received_at, last_error, attempts
|
|
3523
3693
|
FROM quarantine
|
|
3524
|
-
WHERE replayed_at IS NULL AND dismissed_at IS NULL
|
|
3694
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0
|
|
3525
3695
|
ORDER BY received_at
|
|
3526
3696
|
LIMIT ?\`,
|
|
3527
3697
|
).all(limit) as unknown as HeldDispatch[];
|
|
3528
3698
|
}
|
|
3529
3699
|
|
|
3700
|
+
/**
|
|
3701
|
+
* How many conformance probes this receiver has refused.
|
|
3702
|
+
*
|
|
3703
|
+
* Its own figure rather than a slice of \`heldCount\`, because the two answer
|
|
3704
|
+
* different questions. Held means "waiting on you". A drill means "a check ran
|
|
3705
|
+
* and this receiver behaved correctly", which is not a queue and not work.
|
|
3706
|
+
*/
|
|
3707
|
+
export function drillCount(): number {
|
|
3708
|
+
const row = db.prepare(
|
|
3709
|
+
'SELECT count(*) AS n FROM quarantine WHERE drill = 1 AND dismissed_at IS NULL',
|
|
3710
|
+
).get() as { n: number };
|
|
3711
|
+
return row.n;
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3530
3714
|
/** How many dispatches are held. Shown on the dashboard. */
|
|
3531
3715
|
export function heldCount(): number {
|
|
3532
3716
|
const row = db.prepare(
|
|
3533
|
-
'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL AND dismissed_at IS NULL',
|
|
3717
|
+
'SELECT count(*) AS n FROM quarantine WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0',
|
|
3534
3718
|
).get() as { n: number };
|
|
3535
3719
|
return row.n;
|
|
3536
3720
|
}
|
|
@@ -3547,7 +3731,7 @@ export function heldSummary(): string[] {
|
|
|
3547
3731
|
\`SELECT reason, key_id, count(*) AS n,
|
|
3548
3732
|
max(attempts) AS tries, min(received_at) AS oldest
|
|
3549
3733
|
FROM quarantine
|
|
3550
|
-
WHERE replayed_at IS NULL AND dismissed_at IS NULL
|
|
3734
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0
|
|
3551
3735
|
GROUP BY reason, key_id
|
|
3552
3736
|
ORDER BY n DESC\`,
|
|
3553
3737
|
).all() as unknown as {
|
|
@@ -3611,7 +3795,7 @@ export function dismissHeld(): number {
|
|
|
3611
3795
|
const info = db.prepare(
|
|
3612
3796
|
\`UPDATE quarantine
|
|
3613
3797
|
SET dismissed_at = ?, raw_body = ''
|
|
3614
|
-
WHERE replayed_at IS NULL AND dismissed_at IS NULL\`,
|
|
3798
|
+
WHERE replayed_at IS NULL AND dismissed_at IS NULL AND drill = 0\`,
|
|
3615
3799
|
).run(new Date().toISOString());
|
|
3616
3800
|
const n = Number(info.changes ?? 0);
|
|
3617
3801
|
if (n > 0) {
|
|
@@ -3691,10 +3875,25 @@ export function purgeQuarantine(days: number): { replayed: number; unreplayed: n
|
|
|
3691
3875
|
* what diagnoses a key problem, and it is safe to paste into a support ticket
|
|
3692
3876
|
* or send to us, which is what an export is for.
|
|
3693
3877
|
*/
|
|
3694
|
-
export function exportHeld(directory
|
|
3878
|
+
export function exportHeld(directory?: string): { path: string; count: number } {
|
|
3695
3879
|
const rows = heldDispatches(500);
|
|
3696
3880
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
3697
|
-
|
|
3881
|
+
// RESOLVED TO AN ABSOLUTE PATH, ALWAYS, and that is the point of the change.
|
|
3882
|
+
// "Exported 3 to oneaddress-faults-\u2026.json" is not an answer to "where is it";
|
|
3883
|
+
// a receiver is routinely started by a shortcut or a service manager whose
|
|
3884
|
+
// working directory is not where the partner thinks it is. Reported after a
|
|
3885
|
+
// real run: the file was written and could not be found.
|
|
3886
|
+
//
|
|
3887
|
+
// The explicit argument still wins, so a caller (and every existing test) can
|
|
3888
|
+
// name a directory; \`config.exportDir\` is the persistent choice and
|
|
3889
|
+
// \`ONEADDRESS_EXPORT_DIR\` the one-run override. Empty resolves to the working
|
|
3890
|
+
// directory, which is what this always did.
|
|
3891
|
+
const target = resolve(directory ?? config.exportDir ?? '');
|
|
3892
|
+
// Created rather than failed on. A partner who set \`exportDir\` to a folder
|
|
3893
|
+
// they have not made yet wants the file, not a lecture, and a directory they
|
|
3894
|
+
// named is one they have already consented to.
|
|
3895
|
+
mkdirSync(target, { recursive: true });
|
|
3896
|
+
const path = join(target, \`oneaddress-faults-\${stamp}.json\`);
|
|
3698
3897
|
writeFileSync(path, JSON.stringify({
|
|
3699
3898
|
exported_at: new Date().toISOString(),
|
|
3700
3899
|
note: 'Metadata only. The held payloads are deliberately not included: they are encrypted consumer addresses and stay under the receiver retention window.',
|
|
@@ -3711,7 +3910,11 @@ export function exportHeld(directory: string): { path: string; count: number } {
|
|
|
3711
3910
|
last_error: r.last_error,
|
|
3712
3911
|
})),
|
|
3713
3912
|
}, null, 2));
|
|
3714
|
-
|
|
3913
|
+
// THE PATH IS THE MESSAGE. Logged as well as shown on the dashboard, because
|
|
3914
|
+
// the dashboard line is gone the next time anything is pressed while the log
|
|
3915
|
+
// survives a scrollback, a \`npm start | tee\`, and a headless run where there
|
|
3916
|
+
// is no dashboard at all.
|
|
3917
|
+
report.info(\`[quarantine] exported \${rows.length} held dispatch(es) to \${path} - safe to send to support, no payloads inside\`);
|
|
3715
3918
|
return { path, count: rows.length };
|
|
3716
3919
|
}
|
|
3717
3920
|
|
|
@@ -4108,6 +4311,17 @@ export type ReceiverConfig = {
|
|
|
4108
4311
|
oneAddressApi: string;
|
|
4109
4312
|
verifiesAccountReference: boolean;
|
|
4110
4313
|
mode: ReceiverMode;
|
|
4314
|
+
/**
|
|
4315
|
+
* Where \`[e]\` writes the fault export.
|
|
4316
|
+
*
|
|
4317
|
+
* Empty means the working directory, which is what it always did. Named
|
|
4318
|
+
* because "it saved a file" is useless if you cannot find it: the receiver is
|
|
4319
|
+
* routinely started by a double-clicked shortcut or a service manager whose
|
|
4320
|
+
* working directory is not where the partner thinks it is.
|
|
4321
|
+
*
|
|
4322
|
+
* \`ONEADDRESS_EXPORT_DIR\` overrides it for one run without editing the file.
|
|
4323
|
+
*/
|
|
4324
|
+
exportDir: string;
|
|
4111
4325
|
/**
|
|
4112
4326
|
* The webhook URL registered with OneAddress: where dispatches actually
|
|
4113
4327
|
* arrive from the internet.
|
|
@@ -4134,6 +4348,9 @@ const DEFAULTS: ReceiverConfig = {
|
|
|
4134
4348
|
mode: 'write-through',
|
|
4135
4349
|
// No guess. See the field's docstring.
|
|
4136
4350
|
publicUrl: '',
|
|
4351
|
+
// Empty resolves to the working directory at use, not here, so a config file
|
|
4352
|
+
// written on one machine does not pin an absolute path from another.
|
|
4353
|
+
exportDir: '',
|
|
4137
4354
|
};
|
|
4138
4355
|
|
|
4139
4356
|
function parseMode(value: unknown): ReceiverMode | undefined {
|
|
@@ -4153,6 +4370,7 @@ function loadConfigFile(): Partial<ReceiverConfig> {
|
|
|
4153
4370
|
if (typeof parsed.oneAddressApi === 'string') out.oneAddressApi = parsed.oneAddressApi;
|
|
4154
4371
|
if (typeof parsed.verifiesAccountReference === 'boolean') out.verifiesAccountReference = parsed.verifiesAccountReference;
|
|
4155
4372
|
if (typeof parsed.publicUrl === 'string') out.publicUrl = parsed.publicUrl;
|
|
4373
|
+
if (typeof parsed.exportDir === 'string') out.exportDir = parsed.exportDir;
|
|
4156
4374
|
const mode = parseMode(parsed.mode);
|
|
4157
4375
|
if (mode) out.mode = mode;
|
|
4158
4376
|
return out;
|
|
@@ -4177,6 +4395,7 @@ export const config: ReceiverConfig = {
|
|
|
4177
4395
|
// start. Silent is the thing to avoid, not strict.
|
|
4178
4396
|
mode: parseMode(process.env.RECEIVER_MODE) ?? fromFile.mode ?? DEFAULTS.mode,
|
|
4179
4397
|
publicUrl: stripTrailingSlash(process.env.PUBLIC_URL || fromFile.publicUrl || DEFAULTS.publicUrl),
|
|
4398
|
+
exportDir: process.env.ONEADDRESS_EXPORT_DIR || fromFile.exportDir || DEFAULTS.exportDir,
|
|
4180
4399
|
};
|
|
4181
4400
|
|
|
4182
4401
|
report.info(
|
|
@@ -5595,9 +5814,27 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
5595
5814
|
*/
|
|
5596
5815
|
const key = dispatchKey(dispatch || null, rawBody);
|
|
5597
5816
|
|
|
5817
|
+
/**
|
|
5818
|
+
* A DRILL, declared in the SIGNED body. See \`quarantine.ts\`'s \`drill\` column.
|
|
5819
|
+
*
|
|
5820
|
+
* Read from \`body\`, which the HMAC covers, and never from the dispatch-id
|
|
5821
|
+
* header, which it does not. The conformance run already puts a recognisable
|
|
5822
|
+
* prefix on that header and using it would let anything on the path decide
|
|
5823
|
+
* whether this receiver raises an alarm.
|
|
5824
|
+
*/
|
|
5825
|
+
const isDrill = (body as Record<string, unknown>).conformance === true;
|
|
5826
|
+
|
|
5598
5827
|
const hold = (reason: QuarantineReason, keyId: string | null, detail: string): void => {
|
|
5599
|
-
quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail });
|
|
5600
|
-
|
|
5828
|
+
quarantine({ dispatchId: dispatch || null, event, reason, keyId, rawBody, detail, drill: isDrill });
|
|
5829
|
+
// NOT COUNTED AS A FAILURE WHEN IT IS A DRILL. \`failed\` on the footer is a
|
|
5830
|
+
// figure about this partner's dispatches; a probe that was refused exactly
|
|
5831
|
+
// as the check requires is not one of them, and leaving it in is what made a
|
|
5832
|
+
// passing conformance run read as \`failed 1\` forever.
|
|
5833
|
+
//
|
|
5834
|
+
// Recorded as NOTHING rather than as a third outcome, deliberately:
|
|
5835
|
+
// \`tally.ts\` holds \`received === applied + failed\` as an invariant and a
|
|
5836
|
+
// drill is not a dispatch to this partner's business at all.
|
|
5837
|
+
if (!isDrill) recordOutcome(key, 'failed');
|
|
5601
5838
|
};
|
|
5602
5839
|
|
|
5603
5840
|
/**
|
|
@@ -5754,11 +5991,24 @@ app.post('/webhook', async (req: Request, res: Response) => {
|
|
|
5754
5991
|
decAccount = typeof data.account_number === 'string' ? data.account_number : '';
|
|
5755
5992
|
decKnownNames = Array.isArray(data.known_names) ? data.known_names : [];
|
|
5756
5993
|
} catch (err) {
|
|
5757
|
-
//
|
|
5758
|
-
//
|
|
5759
|
-
//
|
|
5760
|
-
|
|
5761
|
-
|
|
5994
|
+
// A DRILL SAYS SO INSTEAD OF SHOUTING. Conformance check 11 wraps its
|
|
5995
|
+
// session key to a throwaway pair so that decryption MUST fail; the two
|
|
5996
|
+
// lines below would then name the partner's real key_id, print a raw
|
|
5997
|
+
// crypto error, and advise them to go and fix a key that is correct - at
|
|
5998
|
+
// the end of a run that just passed. The refusal below is unchanged,
|
|
5999
|
+
// because the refusal is what the check is testing.
|
|
6000
|
+
if (isDrill) {
|
|
6001
|
+
report.info(
|
|
6002
|
+
\`[webhook] conformance probe for key_id \${keyId ?? '(none)'} could not be decrypted, which is \` +
|
|
6003
|
+
'what that check requires. Your key is fine.',
|
|
6004
|
+
);
|
|
6005
|
+
} else {
|
|
6006
|
+
// Names WHICH key answered. A rotation used to surface here as an
|
|
6007
|
+
// authentication-tag error indistinguishable from corruption, with the
|
|
6008
|
+
// single-key fallback having silently answered for a key id it never held.
|
|
6009
|
+
report.error(\`[webhook] D5 decryption failed for key_id \${keyId ?? '(none)'}: \${keyFailureAdvice(keyId, resolved)}\`);
|
|
6010
|
+
report.error('[webhook] underlying error:', err);
|
|
6011
|
+
}
|
|
5762
6012
|
hold('decrypt_failed', keyId, keyFailureAdvice(keyId, resolved));
|
|
5763
6013
|
return res.status(422).json({ ok: false, error: 'D5 decryption failed \u2014 partner key mismatch' });
|
|
5764
6014
|
}
|
|
@@ -6290,8 +6540,9 @@ export async function ask(prompt: string): Promise<string> {
|
|
|
6290
6540
|
* Show what this receiver holds: the roster, and every change it has applied.
|
|
6291
6541
|
*
|
|
6292
6542
|
* Usage:
|
|
6293
|
-
* npm run show \u2014
|
|
6294
|
-
* npm run show 100 \u2014
|
|
6543
|
+
* npm run show \u2014 a LIVE branded view, refreshing as changes land
|
|
6544
|
+
* npm run show 100 \u2014 keep 100 changes on screen instead of 20
|
|
6545
|
+
* npm run show > f.txt \u2014 plain one-shot text, for a file or a pipe
|
|
6295
6546
|
*
|
|
6296
6547
|
* ## Why this script exists
|
|
6297
6548
|
*
|
|
@@ -6302,6 +6553,23 @@ export async function ask(prompt: string): Promise<string> {
|
|
|
6302
6553
|
* dispatch: "where do I look to prove it end to end?" The dashboard's LAST
|
|
6303
6554
|
* CHANGE panel shows one change and nothing showed the rest.
|
|
6304
6555
|
*
|
|
6556
|
+
* ## Why it STREAMS rather than printing once
|
|
6557
|
+
*
|
|
6558
|
+
* The first version printed a snapshot and exited, which answers "did it land"
|
|
6559
|
+
* only if you already knew to run it again. Watching a dispatch arrive meant
|
|
6560
|
+
* re-running it by hand and diffing two screenfuls in your head. It holds the
|
|
6561
|
+
* screen now and polls, so a change appears as it is applied.
|
|
6562
|
+
*
|
|
6563
|
+
* POLLED, NOT SUBSCRIBED, and that is forced rather than lazy: this is a
|
|
6564
|
+
* SEPARATE PROCESS from the receiver, so there is no event to listen to. SQLite
|
|
6565
|
+
* readers do not block writers, so a poll costs the running receiver nothing.
|
|
6566
|
+
* The screen is only redrawn when the rendered content actually CHANGES, so an
|
|
6567
|
+
* idle receiver is not repainting a terminal once a second.
|
|
6568
|
+
*
|
|
6569
|
+
* NON-TTY FALLS BACK TO ONE-SHOT TEXT. \`npm run show > out.txt\`, a pipe, and a
|
|
6570
|
+
* CI log have no terminal to hold, and a blessed screen drawn into a pipe
|
|
6571
|
+
* produces escape-code soup. Same rule the dashboard itself follows.
|
|
6572
|
+
*
|
|
6305
6573
|
* It prompts for the password for the same reason \`src/index.ts\` does, and for
|
|
6306
6574
|
* the same reason it must do so BEFORE importing the store: the database
|
|
6307
6575
|
* derives its keys on import.
|
|
@@ -6312,8 +6580,17 @@ export async function ask(prompt: string): Promise<string> {
|
|
|
6312
6580
|
* in the connector's own store.
|
|
6313
6581
|
*/
|
|
6314
6582
|
import 'dotenv/config';
|
|
6583
|
+
import blessed from 'blessed';
|
|
6315
6584
|
import { config } from '../src/config.js';
|
|
6316
6585
|
import { ask, databaseIsLocked } from '../src/unlock.js';
|
|
6586
|
+
import { HEX, ONE_ROWS, ADDRESS_ROWS, terminalFitsFullMark } from '../src/brand.js';
|
|
6587
|
+
|
|
6588
|
+
const AMBER = HEX.amber.toLowerCase();
|
|
6589
|
+
const CREAM = HEX.cream.toLowerCase();
|
|
6590
|
+
const DIM = '#8a7f6a';
|
|
6591
|
+
|
|
6592
|
+
/** Escape blessed's tag syntax so a customer's name can never inject markup. */
|
|
6593
|
+
const esc = (s: unknown): string => String(s ?? '').replace(/[{}]/g, '');
|
|
6317
6594
|
|
|
6318
6595
|
function fmt(a: Record<string, string> | null): string {
|
|
6319
6596
|
if (!a) return '(nothing on file)';
|
|
@@ -6329,18 +6606,32 @@ async function askPassword(): Promise<void> {
|
|
|
6329
6606
|
if (answer) process.env.ONEADDRESS_DB_PASSPHRASE = answer;
|
|
6330
6607
|
}
|
|
6331
6608
|
|
|
6332
|
-
|
|
6333
|
-
if (config.mode === 'inbox') {
|
|
6334
|
-
console.log('\\n This receiver is in inbox mode: it holds no customer records and no key.');
|
|
6335
|
-
console.log(' Your connector applied the change and holds the result. Look there.\\n');
|
|
6336
|
-
return;
|
|
6337
|
-
}
|
|
6609
|
+
type Store = typeof import('../src/store.js');
|
|
6338
6610
|
|
|
6339
|
-
|
|
6611
|
+
/** The roster, as display rows. */
|
|
6612
|
+
function rosterRows(store: Store): string[] {
|
|
6613
|
+
return store.allCustomers().map((c) => {
|
|
6614
|
+
let addr: Record<string, string> | null = null;
|
|
6615
|
+
try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
|
|
6616
|
+
return \` \${esc(c.account_number).padEnd(20)} \${esc(c.name).padEnd(24)} \${esc(fmt(addr))}\`;
|
|
6617
|
+
});
|
|
6618
|
+
}
|
|
6340
6619
|
|
|
6341
|
-
|
|
6342
|
-
|
|
6620
|
+
/** Applied changes, newest first, as display rows with both sides. */
|
|
6621
|
+
function changeRows(store: Store, limit: number): string[] {
|
|
6622
|
+
const out: string[] = [];
|
|
6623
|
+
for (const h of store.addressHistory(limit)) {
|
|
6624
|
+
const prev = h.prev_address as unknown as Record<string, string> | null;
|
|
6625
|
+
const now = h.address as unknown as Record<string, string>;
|
|
6626
|
+
out.push(\` {\${DIM}-fg}\${esc(h.recorded_at)}{/} {\${CREAM}-fg}\${esc(h.account_number)}{/} \${esc(h.name)}\`);
|
|
6627
|
+
out.push(\` {\${DIM}-fg}was:{/} \${esc(fmt(prev))}\`);
|
|
6628
|
+
out.push(\` {\${AMBER}-fg}now:{/} \${esc(fmt(now))}\`);
|
|
6629
|
+
}
|
|
6630
|
+
return out;
|
|
6631
|
+
}
|
|
6343
6632
|
|
|
6633
|
+
/** One-shot plain text. The pipe, the file, the CI log. */
|
|
6634
|
+
function printOnce(store: Store, limit: number): void {
|
|
6344
6635
|
const roster = store.allCustomers();
|
|
6345
6636
|
console.log(\`\\n ON FILE (\${roster.length})\\n\`);
|
|
6346
6637
|
for (const c of roster) {
|
|
@@ -6348,7 +6639,6 @@ async function main(): Promise<void> {
|
|
|
6348
6639
|
try { addr = JSON.parse(c.address) as Record<string, string>; } catch { addr = null; }
|
|
6349
6640
|
console.log(\` \${c.account_number.padEnd(14)} \${c.name.padEnd(24)} \${fmt(addr)}\`);
|
|
6350
6641
|
}
|
|
6351
|
-
|
|
6352
6642
|
const changes = store.addressHistory(limit);
|
|
6353
6643
|
console.log(\`\\n APPLIED CHANGES (\${changes.length}, newest first)\\n\`);
|
|
6354
6644
|
if (changes.length === 0) {
|
|
@@ -6363,6 +6653,86 @@ async function main(): Promise<void> {
|
|
|
6363
6653
|
console.log('');
|
|
6364
6654
|
}
|
|
6365
6655
|
|
|
6656
|
+
/** The live view: the mark, the roster, the changes, refreshed as they land. */
|
|
6657
|
+
function stream(store: Store, limit: number): void {
|
|
6658
|
+
const screen = blessed.screen({ smartCSR: true, title: 'What this receiver holds \u2014 OneAddress' });
|
|
6659
|
+
|
|
6660
|
+
// Same rule as the dashboard: the pixel wordmark needs 88 columns, and below
|
|
6661
|
+
// that it wraps and reads as broken rather than as large.
|
|
6662
|
+
const wide = terminalFitsFullMark(Number(screen.width));
|
|
6663
|
+
const markHeight = wide ? 9 : 3;
|
|
6664
|
+
blessed.box({
|
|
6665
|
+
parent: screen, top: 0, left: 0, width: '100%', height: markHeight,
|
|
6666
|
+
tags: true, padding: { left: 2 },
|
|
6667
|
+
content: wide
|
|
6668
|
+
? ONE_ROWS.map((row, i) => \`{\${CREAM}-fg}\${row}{/} {\${AMBER}-fg}\${ADDRESS_ROWS[i]}{/}\`).join('\\n')
|
|
6669
|
+
: \`{\${CREAM}-fg}{bold}One{/bold}{/}{\${AMBER}-fg}{bold}Address{/bold}{/}\`,
|
|
6670
|
+
});
|
|
6671
|
+
|
|
6672
|
+
const rosterBox = blessed.box({
|
|
6673
|
+
parent: screen, top: markHeight, left: 0, width: '100%', height: 'shrink',
|
|
6674
|
+
tags: true, padding: { left: 1, right: 1 },
|
|
6675
|
+
border: { type: 'line' }, style: { border: { fg: AMBER } },
|
|
6676
|
+
label: ' ON FILE ',
|
|
6677
|
+
});
|
|
6678
|
+
|
|
6679
|
+
const changesBox = blessed.box({
|
|
6680
|
+
parent: screen, top: markHeight + 3, left: 0, width: '100%', bottom: 1,
|
|
6681
|
+
tags: true, padding: { left: 1, right: 1 }, scrollable: true, alwaysScroll: true,
|
|
6682
|
+
border: { type: 'line' }, style: { border: { fg: AMBER } },
|
|
6683
|
+
label: ' APPLIED CHANGES \u2014 newest first ',
|
|
6684
|
+
});
|
|
6685
|
+
|
|
6686
|
+
blessed.box({
|
|
6687
|
+
parent: screen, bottom: 0, left: 0, width: '100%', height: 1,
|
|
6688
|
+
tags: true, padding: { left: 2 },
|
|
6689
|
+
content: \`{\${DIM}-fg}live \u2014 updates as dispatches land{/} {\${CREAM}-fg}[q]{/} quit\`,
|
|
6690
|
+
});
|
|
6691
|
+
|
|
6692
|
+
// ONLY REDRAWN WHEN THE CONTENT CHANGES. A poll that repaints unconditionally
|
|
6693
|
+
// makes an idle receiver flicker once a second and destroys any scrollback
|
|
6694
|
+
// position the operator had.
|
|
6695
|
+
let last = '';
|
|
6696
|
+
const tick = (): void => {
|
|
6697
|
+
const roster = rosterRows(store);
|
|
6698
|
+
const changes = changeRows(store, limit);
|
|
6699
|
+
const next = roster.join('\\n') + '\\u0000' + changes.join('\\n');
|
|
6700
|
+
if (next === last) return;
|
|
6701
|
+
last = next;
|
|
6702
|
+
rosterBox.height = roster.length + 2;
|
|
6703
|
+
rosterBox.setContent(roster.join('\\n') || ' (nobody on file yet)');
|
|
6704
|
+
changesBox.top = markHeight + roster.length + 2;
|
|
6705
|
+
changesBox.setContent(changes.join('\\n') || ' None yet. Send a dispatch and this fills in.');
|
|
6706
|
+
screen.render();
|
|
6707
|
+
};
|
|
6708
|
+
|
|
6709
|
+
screen.key(['q', 'C-c', 'escape'], () => { screen.destroy(); process.exit(0); });
|
|
6710
|
+
tick();
|
|
6711
|
+
screen.render();
|
|
6712
|
+
// DELIBERATELY NOT unref'd. An unref'd timer does not hold the event loop
|
|
6713
|
+
// open, and with the screen drawn there is nothing else pending, so the
|
|
6714
|
+
// process would exit the instant it finished painting.
|
|
6715
|
+
setInterval(tick, 1000);
|
|
6716
|
+
}
|
|
6717
|
+
|
|
6718
|
+
async function main(): Promise<void> {
|
|
6719
|
+
if (config.mode === 'inbox') {
|
|
6720
|
+
console.log('\\n This receiver is in inbox mode: it holds no customer records and no key.');
|
|
6721
|
+
console.log(' Your connector applied the change and holds the result. Look there.\\n');
|
|
6722
|
+
return;
|
|
6723
|
+
}
|
|
6724
|
+
|
|
6725
|
+
await askPassword();
|
|
6726
|
+
|
|
6727
|
+
const limit = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 20;
|
|
6728
|
+
const store = await import('../src/store.js');
|
|
6729
|
+
|
|
6730
|
+
// A pipe, a file or a CI log has no terminal to hold, and a blessed screen
|
|
6731
|
+
// drawn into one produces escape-code soup rather than output.
|
|
6732
|
+
if (!process.stdout.isTTY) { printOnce(store, limit); return; }
|
|
6733
|
+
stream(store, limit);
|
|
6734
|
+
}
|
|
6735
|
+
|
|
6366
6736
|
void main().catch((err: unknown) => {
|
|
6367
6737
|
console.error(err instanceof Error ? err.message : String(err));
|
|
6368
6738
|
process.exit(1);
|
|
@@ -6563,6 +6933,26 @@ customers(account_key, account_number, name, address JSON, updated_at)
|
|
|
6563
6933
|
address_history(id, account_key, prev_address JSON, address JSON, recorded_at)
|
|
6564
6934
|
\`\`\`
|
|
6565
6935
|
|
|
6936
|
+
### Where the fault export lands
|
|
6937
|
+
|
|
6938
|
+
\`[e]\` on the dashboard writes a metadata-only file (no payloads) you can send
|
|
6939
|
+
straight to support. It goes to the working directory by default, which is not
|
|
6940
|
+
always where you think it is when the receiver was started by a shortcut or a
|
|
6941
|
+
service manager, so you can name somewhere:
|
|
6942
|
+
|
|
6943
|
+
\`\`\`jsonc
|
|
6944
|
+
// oneaddress.config.json
|
|
6945
|
+
{ "exportDir": "/home/you/oneaddress-exports" }
|
|
6946
|
+
\`\`\`
|
|
6947
|
+
|
|
6948
|
+
\`\`\`bash
|
|
6949
|
+
ONEADDRESS_EXPORT_DIR=/tmp npm start # one run, without editing the file
|
|
6950
|
+
\`\`\`
|
|
6951
|
+
|
|
6952
|
+
The directory is created if it does not exist. The confirmation on the dashboard
|
|
6953
|
+
and the line in the log both name the **full path**, and the dashboard keeps it
|
|
6954
|
+
in its own slot so pressing \`[r]\` does not wipe it.
|
|
6955
|
+
|
|
6566
6956
|
### Seeing what changed
|
|
6567
6957
|
|
|
6568
6958
|
\`\`\`bash
|
|
@@ -11717,7 +12107,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
|
|
|
11717
12107
|
|
|
11718
12108
|
// src/register.ts
|
|
11719
12109
|
var import_node_crypto2 = require("crypto");
|
|
11720
|
-
var PKG_VERSION = true ? "2.
|
|
12110
|
+
var PKG_VERSION = true ? "2.7.0" : "dev";
|
|
11721
12111
|
var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
|
|
11722
12112
|
function hmacSha256(secret, message) {
|
|
11723
12113
|
return (0, import_node_crypto2.createHmac)("sha256", secret).update(message).digest("hex");
|
|
@@ -12786,6 +13176,9 @@ OneAddress and a leak of either must not also hand somebody your customers' addr
|
|
|
12786
13176
|
"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
13177
|
);
|
|
12788
13178
|
} else {
|
|
13179
|
+
M2.info(
|
|
13180
|
+
"It looks like one of these:\n -----BEGIN PRIVATE KEY----- (a .pem file, paste the whole thing)\n MIGHAgEAMB... (the base64 body, copied from My Profile)\n C:\\keys\\oneaddress.pem (or just the path to the file)\nThis is your PRIVATE key, not the public one shown next to it in the portal."
|
|
13181
|
+
);
|
|
12789
13182
|
const privateKeyRaw = await he({
|
|
12790
13183
|
message: "Your ECDH Private Key",
|
|
12791
13184
|
placeholder: "Paste the base64 key body from My Profile, or enter a path to the .pem file",
|