Backups to master, replacement-VPS restore, broker proxy, multi-instance

Four features:

- Backup: lib/backup-sync.ts pushes the full settings table and firm
  credentials (the deliberate exception to "credentials never leave the
  machine") to the master's bearer-gated /api/instances/backup — every
  5 minutes when the content hash changed, within seconds of a settings
  or credential edit, and on the new backup-now command. Enabled by the
  master_backup_token setting.

- Restore: a dead VPS's replacement pulls the backup and adopts its
  uuid/name, so roster, payouts and card links carry on. Pre-boot via
  seed-settings.js --restore-from (setup-linux.sh prompts for it) or
  the Settings page's restore picker (lib/backup-restore.ts). Stop the
  old machine first; the master 409s a live-name rename.

- Proxy: the proxy_url setting routes BROKER traffic only — TopstepX
  REST + SignalR hub, Tradovate REST + websocket, Yahoo quotes — via
  lib/outbound-proxy.ts (axios agents, undici dispatcher, ws agent).
  The master link, reporter, catalog and backup push stay direct, so a
  dead proxy cannot take down the control channel. Restart to apply.

- Multi-instance: AUTOFIRMER_DB moves the SQLite file so one checkout
  runs N instances; scripts/instances.json (per-device, gitignored)
  drives start-all/update-check, and add-instance.sh / remove-instance.sh
  manage the extra PM2 processes. Absent registry = single-instance
  behavior, unchanged. Instance 1 keeps port 3000 for the clicker and
  extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-09-22 13:55:35 -05:00
co-authored by Claude Fable 5
parent 97947bffde
commit 8154f25bb9
25 changed files with 1396 additions and 76 deletions
+153 -10
View File
@@ -1,31 +1,174 @@
/**
* Writes the reporter settings into autotrader.sqlite before first launch.
* Writes the reporter settings into the instance database before first launch,
* and optionally restores a full backup from the master.
*
* lib/db.ts seeds these keys with INSERT OR IGNORE, so values written here
* survive the app's own startup seeding. Must be run from the project root —
* lib/db.ts opens the database at process.cwd().
* lib/db.ts seeds its keys with INSERT OR IGNORE, so values written here
* survive the app's own startup seeding. Must be run from the project root
* unless AUTOFIRMER_DB points elsewhere — lib/db.ts opens the database at
* process.cwd() by default, and this script must hit the same file.
*
* node scripts/seed-settings.js <master_dashboard_url> <instance_name>
* node scripts/seed-settings.js <master_dashboard_url> <instance_name> \
* [--token <backup_token>] [--restore-from <instance_uuid>]
*
* --restore-from pulls that instance's encrypted backup from the master
* (authenticated by --token) and applies it — settings, firm credentials,
* instruments, account assignments — before the app has ever booted, so the
* very first hello to the master already presents the adopted uuid. The
* backup's instance_name wins over the CLI argument.
*/
const path = require('path');
const { mkdirSync } = require('fs');
const Database = require('better-sqlite3');
const [, , url, name] = process.argv;
const args = process.argv.slice(2);
const positional = [];
let token = '';
let restoreFrom = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--token') token = args[++i] ?? '';
else if (args[i] === '--restore-from') restoreFrom = args[++i] ?? '';
else positional.push(args[i]);
}
const [url, name] = positional;
const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
const DB_PATH = process.env.AUTOFIRMER_DB
? path.resolve(process.env.AUTOFIRMER_DB)
: path.join(process.cwd(), 'autotrader.sqlite');
mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
// Mirrors the schema in lib/db.ts for every table a restore touches. The app
// has usually never run at this point, so the tables may not exist yet; the
// shapes must be kept in sync with lib/db.ts by hand.
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS firm_credentials (
firm_id TEXT PRIMARY KEY,
firm_name TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS instruments (
symbol TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS account_assignments (
account_id INTEGER PRIMARY KEY,
firm_id TEXT NOT NULL,
account_type_id TEXT,
manual INTEGER NOT NULL DEFAULT 0,
resolved_at TEXT NOT NULL
);
`);
try {
db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''");
} catch {
// Column already exists
}
const upsert = db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
);
if (url) { upsert.run('master_dashboard_url', url); console.log(' master_dashboard_url = ' + url); }
if (name) { upsert.run('instance_name', name); console.log(' instance_name = ' + name); }
if (url) { upsert.run('master_dashboard_url', url); console.log(' master_dashboard_url = ' + url); }
if (name) { upsert.run('instance_name', name); console.log(' instance_name = ' + name); }
if (token) { upsert.run('master_backup_token', token); console.log(' master_backup_token = (set)'); }
db.close();
// Runtime bookkeeping keys that describe the machine the backup was taken on,
// not the one being provisioned. Kept in sync with lib/backup-restore.ts.
const RESTORE_SKIP_KEYS = new Set([
'runner_heartbeat',
'catalog_last_sync_at',
'catalog_last_error',
'backup_last_push_at',
'backup_last_error',
]);
async function restore() {
if (!restoreFrom) return;
if (!url) { console.error('--restore-from needs the master url argument'); process.exit(1); }
if (!token) { console.error('--restore-from needs --token'); process.exit(1); }
const endpoint = new URL(`/api/instances/backup/${restoreFrom}`, url);
console.log(` restoring from ${endpoint}`);
const res = await fetch(endpoint, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
console.error(` restore failed: master answered ${res.status} ${await res.text().catch(() => '')}`);
process.exit(1);
}
const payload = await res.json();
if (payload?.v !== 1 || typeof payload.settings !== 'object') {
console.error(' restore failed: unrecognized backup payload');
process.exit(1);
}
const setCred = db.prepare(`
INSERT INTO firm_credentials (firm_id, firm_name, username, password, api_key, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(firm_id) DO UPDATE SET
firm_name = excluded.firm_name,
username = excluded.username,
password = excluded.password,
api_key = excluded.api_key,
updated_at = excluded.updated_at
`);
const setInstr = db.prepare(
'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' +
'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled'
);
const setAssign = db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at
`);
db.transaction(() => {
for (const [key, value] of Object.entries(payload.settings)) {
if (RESTORE_SKIP_KEYS.has(key)) continue;
if (typeof value !== 'string') continue;
upsert.run(key, value);
}
// The freshly seeded master url wins over whatever the backup recorded:
// the operator just typed it, and an old backup may point at a dead host.
if (url) upsert.run('master_dashboard_url', url);
if (token) upsert.run('master_backup_token', token);
for (const c of payload.credentials ?? []) {
setCred.run(c.firm_id, c.firm_name, c.username, c.password, c.api_key ?? '', c.updated_at ?? new Date().toISOString());
}
for (const i of payload.instruments ?? []) {
setInstr.run(i.symbol, i.enabled ? 1 : 0);
}
for (const a of payload.assignments ?? []) {
setAssign.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, a.resolved_at ?? new Date().toISOString());
}
})();
console.log(` restored: ${Object.keys(payload.settings).length} settings, ` +
`${(payload.credentials ?? []).length} credentials, ` +
`${(payload.instruments ?? []).length} instruments, ` +
`${(payload.assignments ?? []).length} assignments`);
console.log(` adopted identity: "${payload.settings.instance_name ?? ''}" (${payload.settings.instance_id ?? 'no uuid'})`);
}
restore()
.catch((err) => { console.error(` restore failed: ${err.message}`); process.exit(1); })
.finally(() => db.close());