@oneaddress/setup 2.1.1 → 2.1.2

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.
Files changed (2) hide show
  1. package/dist/index.js +194 -28
  2. 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.1.1" : "?";
859
+ var WIZARD_VERSION = true ? "2.1.2" : "?";
860
860
  function printCompactHeader() {
861
861
  const INNER = 42;
862
862
  const TOP = fn("\u250C") + dm("\u2500".repeat(INNER)) + fn("\u2510");
@@ -964,8 +964,8 @@ data.db-shm
964
964
  "private": true,
965
965
  "scripts": {
966
966
  "dev": "tsx watch src/index.ts",
967
- "start": "tsx src/index.ts",
968
- "headless": "tsx src/index.ts -- --headless",
967
+ "start": "node --disable-warning=ExperimentalWarning node_modules/tsx/dist/cli.mjs src/index.ts",
968
+ "headless": "node --disable-warning=ExperimentalWarning node_modules/tsx/dist/cli.mjs src/index.ts -- --headless",
969
969
  "build": "tsup src/index.ts --format esm --no-dts --outDir dist",
970
970
  "type-check": "tsc --noEmit",
971
971
  "test": "tsx scripts/test.ts"
@@ -1081,6 +1081,7 @@ import {
1081
1081
  encryptField,
1082
1082
  isEncrypted,
1083
1083
  newSalt,
1084
+ PassphraseRequiredError,
1084
1085
  verifierMatches,
1085
1086
  WrongPassphraseError,
1086
1087
  type VaultKeys,
@@ -1128,14 +1129,36 @@ function saltForThisDatabase(): Buffer {
1128
1129
  }
1129
1130
 
1130
1131
  function openKeys(): VaultKeys | null {
1132
+ // THE VERIFIER IS READ FIRST, and the order is the whole fix.
1133
+ //
1134
+ // This used to return null the moment no passphrase was set, without ever
1135
+ // asking whether the database was encrypted. An encrypted store opened with
1136
+ // no passphrase therefore ran IN THE CLEAR over the top of itself: it could
1137
+ // not decrypt the existing rows, so their blind indexes never matched, so it
1138
+ // seeded a SECOND copy of the roster in plaintext beside the first. The next
1139
+ // unlock then tried to converge both copies onto one key and died on a
1140
+ // UNIQUE constraint, permanently, with the correct passphrase in hand.
1141
+ //
1142
+ // Reported by a partner on 2.1.1 as "it is locking me out", and the lockout
1143
+ // was the last step of three. Two things were wrong and the quiet one was
1144
+ // worse: plaintext customer records written into a database its owner had
1145
+ // encrypted.
1146
+ const stored = readMeta('verifier');
1131
1147
  const passphrase = process.env.ONEADDRESS_DB_PASSPHRASE;
1132
- if (!passphrase || !passphrase.trim()) return null;
1148
+
1149
+ if (!passphrase || !passphrase.trim()) {
1150
+ // Never silently. A database with no verifier has never been locked and
1151
+ // running it in the clear is the documented, chosen behaviour; one WITH a
1152
+ // verifier is somebody's encrypted store and this is not the process that
1153
+ // gets to open it.
1154
+ if (stored !== null) throw new PassphraseRequiredError();
1155
+ return null;
1156
+ }
1133
1157
 
1134
1158
  const keys = deriveKeys(passphrase, saltForThisDatabase());
1135
1159
 
1136
1160
  // Checked BEFORE anything is written. A mistyped passphrase must not be able
1137
1161
  // to write a single row of ciphertext that nothing can ever read back.
1138
- const stored = readMeta('verifier');
1139
1162
  if (stored === null) writeMeta('verifier', buildVerifier(keys));
1140
1163
  else if (!verifierMatches(keys, stored)) throw new WrongPassphraseError();
1141
1164
 
@@ -1182,7 +1205,16 @@ export function once(column: string, value: string | null): string | null {
1182
1205
  return isEncrypted(value) ? value : encryptField(keys, column, value);
1183
1206
  }
1184
1207
 
1185
- export { WrongPassphraseError };
1208
+ export { PassphraseRequiredError, WrongPassphraseError };
1209
+
1210
+ /**
1211
+ * Is this stored value ciphertext? Exported for the repair pass in \`store.ts\`.
1212
+ *
1213
+ * The repair has to tell an encrypted row from a plaintext one written beside
1214
+ * it by a run that had no passphrase, and that is the only question that
1215
+ * separates them.
1216
+ */
1217
+ export { isEncrypted };
1186
1218
  export default db;
1187
1219
  `
1188
1220
  },
@@ -1363,6 +1395,25 @@ export class WrongPassphraseError extends Error {
1363
1395
  }
1364
1396
  }
1365
1397
 
1398
+ /**
1399
+ * The database is encrypted and nobody supplied a passphrase.
1400
+ *
1401
+ * NAMED SEPARATELY FROM \\\`WrongPassphraseError\\\` because the remedy is different:
1402
+ * one means try again, the other means you have not been asked yet. Before this
1403
+ * existed the case had no error at all - opening an encrypted database with no
1404
+ * passphrase returned no keys and the receiver carried on IN THE CLEAR over the
1405
+ * top of it, which cost a partner their database. See the refusal in db.ts.
1406
+ */
1407
+ export class PassphraseRequiredError extends Error {
1408
+ constructor() {
1409
+ super(
1410
+ 'This database is encrypted. Set ONEADDRESS_DB_PASSPHRASE, or run \`npm start\` ' +
1411
+ 'in a terminal to be asked for it.',
1412
+ );
1413
+ this.name = 'PassphraseRequiredError';
1414
+ }
1415
+ }
1416
+
1366
1417
  export interface VaultKeys {
1367
1418
  /** AES-256-GCM key for the personal columns. */
1368
1419
  cipherKey: Buffer;
@@ -1814,7 +1865,7 @@ export function notePreviousAddress(prev: Record<string, unknown>): void {
1814
1865
  * \`--headless\` skips both: no prompt (a service has nobody to ask), no screen.
1815
1866
  */
1816
1867
  import { printBanner } from './brand.js';
1817
- import { WrongPassphraseError } from './vault.js';
1868
+ import { PassphraseRequiredError, WrongPassphraseError } from './vault.js';
1818
1869
 
1819
1870
  /**
1820
1871
  * No dashboard without a terminal to draw it in.
@@ -1878,25 +1929,79 @@ async function databaseIsLocked(): Promise<boolean> {
1878
1929
  * hook is not available, being asked in the clear beats not being asked.
1879
1930
  */
1880
1931
  async function ask(prompt: string): Promise<string> {
1881
- const { createInterface } = await import('node:readline/promises');
1882
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
1883
- const hooked = rl as unknown as { _writeToOutput?: (s: string) => void };
1884
- const original = hooked._writeToOutput;
1885
- try {
1886
- hooked._writeToOutput = function (chunk: string): void {
1887
- // Echo the prompt itself, mask everything the user types.
1888
- if (chunk.includes(prompt)) process.stdout.write(chunk);
1889
- else process.stdout.write('*');
1932
+ process.stdout.write(prompt);
1933
+
1934
+ // NO readline. Two versions of this used readline's \`_writeToOutput\` hook to
1935
+ // mask the echo and BOTH ECHOED THE PASSPHRASE IN CLEAR, which was only found
1936
+ // by driving a real terminal and reading what came back. The first filtered
1937
+ // on whether the chunk contained the prompt, not knowing readline repaints
1938
+ // prompt and input together on every keystroke, so the condition was always
1939
+ // true. The second repainted the line and still leaked, because the echo was
1940
+ // never coming from that hook at all.
1941
+ //
1942
+ // Reading the keys directly removes the guessing. Raw mode turns the
1943
+ // terminal's own echo OFF, so the ONLY thing that can reach the screen is
1944
+ // what is written below: one asterisk per character, which is what a partner
1945
+ // asked for and what every other passphrase prompt does.
1946
+ const stdin = process.stdin;
1947
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
1948
+ // No terminal to control. Being asked in the clear beats not being asked,
1949
+ // and this path is only reached where nothing is watching anyway.
1950
+ const { createInterface } = await import('node:readline/promises');
1951
+ const rl = createInterface({ input: stdin, output: process.stdout });
1952
+ try {
1953
+ const answer = await rl.question('');
1954
+ return answer.trim();
1955
+ } finally { rl.close(); }
1956
+ }
1957
+
1958
+ const wasRaw = stdin.isRaw === true;
1959
+ stdin.setRawMode(true);
1960
+ stdin.resume();
1961
+ stdin.setEncoding('utf8');
1962
+
1963
+ return new Promise<string>((resolve) => {
1964
+ let typed = '';
1965
+ const restore = (): void => {
1966
+ stdin.removeListener('data', onData);
1967
+ stdin.setRawMode(wasRaw);
1968
+ stdin.pause();
1890
1969
  };
1891
- } catch { /* keep the visible prompt */ }
1892
- try {
1893
- const answer = await rl.question(prompt);
1894
- process.stdout.write('\\n');
1895
- return answer.trim();
1896
- } finally {
1897
- if (original) hooked._writeToOutput = original;
1898
- rl.close();
1899
- }
1970
+ const onData = (chunk: string): void => {
1971
+ for (const ch of chunk) {
1972
+ if (ch === '\\r' || ch === '\\n') {
1973
+ restore();
1974
+ process.stdout.write('\\n');
1975
+ resolve(typed.trim());
1976
+ return;
1977
+ }
1978
+ if (ch === '\\u0003') { // Ctrl+C
1979
+ restore();
1980
+ process.stdout.write('\\n');
1981
+ process.exit(130);
1982
+ }
1983
+ if (ch === '\\u0004') { // Ctrl+D on an empty line ends it
1984
+ restore();
1985
+ process.stdout.write('\\n');
1986
+ resolve(typed.trim());
1987
+ return;
1988
+ }
1989
+ if (ch === '\\u007f' || ch === '\\b') {
1990
+ // Backspace has to move the asterisks too, or the mask stops matching
1991
+ // what is actually in the buffer and the count misleads.
1992
+ if (typed.length > 0) {
1993
+ typed = typed.slice(0, -1);
1994
+ process.stdout.write('\\b \\b');
1995
+ }
1996
+ continue;
1997
+ }
1998
+ if (ch < ' ') continue; // ignore the rest of the control range
1999
+ typed += ch;
2000
+ process.stdout.write('*');
2001
+ }
2002
+ };
2003
+ stdin.on('data', onData);
2004
+ });
1900
2005
  }
1901
2006
 
1902
2007
  /**
@@ -1927,7 +2032,17 @@ async function resolvePassphrase(): Promise<string | null> {
1927
2032
  if (headless || !process.stdin.isTTY) return null;
1928
2033
 
1929
2034
  if (await databaseIsLocked()) {
1930
- process.stdout.write('\\n This customer database is encrypted.\\n\\n');
2035
+ // NAMES THE WAY OUT, because a partner can arrive here having never
2036
+ // knowingly set a passphrase. Versions before 2.0.1 asked for one with a
2037
+ // prompt that did not say whether it was creating or checking, so a
2038
+ // database can carry a verifier its owner does not remember agreeing to.
2039
+ // Reported by exactly that partner: "there was no opportunity to set a
2040
+ // passphrase". Without this line the only options are guess or search the
2041
+ // internet, and the answer is one command.
2042
+ process.stdout.write('\\n This customer database is encrypted.\\n');
2043
+ process.stdout.write(' If you do not know the passphrase, delete data.db and start\\n');
2044
+ process.stdout.write(' again: it holds your demo roster and test dispatches, nothing\\n');
2045
+ process.stdout.write(' OneAddress needs.\\n\\n');
1931
2046
  const answer = await ask(' Passphrase to unlock: ');
1932
2047
  return answer || null;
1933
2048
  }
@@ -1967,6 +2082,15 @@ async function main(): Promise<void> {
1967
2082
  const server = await import('./server.js');
1968
2083
  config = { partnerName: server.PARTNER_NAME, port: server.PORT };
1969
2084
  } catch (err) {
2085
+ if (err instanceof PassphraseRequiredError) {
2086
+ // Reached when nobody could be asked: a service unit, a piped stdin, or
2087
+ // the setup wizard's own health-check autostart. Before the refusal in
2088
+ // db.ts this case ran in the clear and corrupted the database instead.
2089
+ console.error('\\n This database is encrypted and no passphrase was supplied.');
2090
+ console.error(' Run \`npm start\` in a terminal to be asked for it, or set');
2091
+ console.error(' ONEADDRESS_DB_PASSPHRASE for an unattended start.\\n');
2092
+ process.exit(1);
2093
+ }
1970
2094
  if (err instanceof WrongPassphraseError) {
1971
2095
  // Named for what it is. Without this the first symptom is a decryption
1972
2096
  // error on a live dispatch, which reads as a corrupt database and sends
@@ -2117,7 +2241,7 @@ export function safeOneAddressCallbackUrl(raw: string): string | null {
2117
2241
  import { readFileSync } from 'node:fs';
2118
2242
  import { join } from 'node:path';
2119
2243
  import { report } from './report.js';
2120
- import db, { accountKey, dec, enc, encrypted, once } from './db.js';
2244
+ import db, { accountKey, dec, enc, encrypted, isEncrypted, once } from './db.js';
2121
2245
 
2122
2246
  export type Address = Record<string, unknown>;
2123
2247
 
@@ -2178,6 +2302,48 @@ ensureColumn('address_history', 'prev_address', "TEXT NOT NULL DEFAULT '{}'");
2178
2302
  // pass produces the same key rather than hashing a ciphertext and orphaning the
2179
2303
  // row. A row orphaned that way still reads fine in a listing and is invisible
2180
2304
  // to every dispatch, which is the worst kind of broken.
2305
+ // REPAIR FIRST, because a database can already be in the state the refusal in
2306
+ // db.ts now prevents. Before that refusal existed, a run with no passphrase
2307
+ // against an encrypted store could not decrypt the roster, so its blind indexes
2308
+ // never matched and it seeded a SECOND copy of every customer in plaintext. The
2309
+ // relabel below then maps both copies onto one key and dies on UNIQUE, with the
2310
+ // correct passphrase in hand and no way forward but deleting the file.
2311
+ //
2312
+ // So the plaintext twin is dropped. WHICH ONE GOES IS NOT A GUESS: the
2313
+ // encrypted row is the one the owner's passphrase wrote, the plaintext row was
2314
+ // written by a process that could not read it. Only the second is discarded,
2315
+ // and only when an encrypted row for the same account exists.
2316
+ //
2317
+ // Scoped to an encrypted database. With no keys there is nothing to compare,
2318
+ // every row is plaintext, and there is no duplicate to find.
2319
+ if (encrypted) {
2320
+ const all = db.prepare('SELECT rowid AS rid, account_number FROM customers').all() as Array<
2321
+ { rid: number; account_number: string }
2322
+ >;
2323
+ const seen = new Map<string, boolean>(); // plain account -> has an encrypted row
2324
+ for (const r of all) {
2325
+ if (isEncrypted(r.account_number)) {
2326
+ const plain = dec('account_number', r.account_number);
2327
+ if (plain) seen.set(plain, true);
2328
+ }
2329
+ }
2330
+ const drop = db.prepare('DELETE FROM customers WHERE rowid = ?');
2331
+ let dropped = 0;
2332
+ for (const r of all) {
2333
+ if (isEncrypted(r.account_number)) continue;
2334
+ if (!seen.get(r.account_number)) continue;
2335
+ drop.run(r.rid);
2336
+ dropped += 1;
2337
+ }
2338
+ if (dropped > 0) {
2339
+ // Said out loud. A silent repair of somebody's customer table is the kind
2340
+ // of thing they should hear about from us rather than notice later.
2341
+ report.warn(
2342
+ \`[store] repaired \${dropped} plaintext duplicate customer row(s) left by a run with no passphrase\`,
2343
+ );
2344
+ }
2345
+ }
2346
+
2181
2347
  {
2182
2348
  const rows = db.prepare('SELECT rowid AS rid, account_number, name, address FROM customers').all() as Array<
2183
2349
  { rid: number; account_number: string; name: string; address: string }
@@ -7990,7 +8156,7 @@ async function scaffold(platform, outputDir, partnerId, webhookSecret, webhookUr
7990
8156
 
7991
8157
  // src/register.ts
7992
8158
  var import_node_crypto = require("crypto");
7993
- var PKG_VERSION = true ? "2.1.1" : "dev";
8159
+ var PKG_VERSION = true ? "2.1.2" : "dev";
7994
8160
  var REGISTER_URL = "https://partners.oneaddress.io/api/partner/installs";
7995
8161
  function hmacSha256(secret, message) {
7996
8162
  return (0, import_node_crypto.createHmac)("sha256", secret).update(message).digest("hex");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneaddress/setup",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "Interactive setup wizard for OneAddress partner webhook integrations",
5
5
  "main": "dist/index.js",
6
6
  "bin": {