fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix
- Add await ctx.cache.subscribe() to addSymbol mutation so symbols added via the sidebar get registered in symbol_demand and yfinance jobs are queued immediately - Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue - Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false to quoteSummary() to handle Yahoo schema drift - Add error detail logging for analyst ratings schema failures - Update .gitignore with common ignores
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
// AES-256-GCM encryption / decryption for credentials at rest in x_credentials table.
|
||||
// Key is derived from IFLOW_CRYPTO_KEY env var (hex-encoded 32-byte key, 64 hex chars)
|
||||
// or falls back to a dev-only constant when unset — never used outside local dev.
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
const KEY_HEX = process.env.IFLOW_CRYPTO_KEY ??
|
||||
'00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
|
||||
|
||||
const KEY = Buffer.from(KEY_HEX.slice(0, 64), 'hex');
|
||||
if (KEY.length !== 32) {
|
||||
throw new Error('IFLOW_CRYPTO_KEY must be a 64-character hex string (32 bytes).');
|
||||
}
|
||||
|
||||
function encrypt(text: string): string {
|
||||
const iv = randomBytes(12); // GCM recommends 96-bit IVs
|
||||
const cipher = createCipheriv('aes-256-gcm', KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Format: iv (12 bytes hex) + tag (16 bytes hex) + ciphertext
|
||||
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
function decrypt(cipherText: string): string {
|
||||
const parts = cipherText.split(':');
|
||||
if (parts.length !== 3) throw new Error('Invalid encrypted payload format.');
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const tag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = Buffer.from(parts[2], 'base64');
|
||||
const decipher = createDecipheriv('aes-256-gcm', KEY, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
const cryptoModule = ((text: string): string => encrypt(text)) as typeof encrypt & { decrypt: typeof decrypt };
|
||||
cryptoModule.decrypt = decrypt;
|
||||
|
||||
export default cryptoModule;
|
||||
@@ -0,0 +1,78 @@
|
||||
// Server self-restart helper (admin console "Restart servers" action).
|
||||
// The backend is a single long-lived node process, so we restart it by either
|
||||
// exiting (when run under `node --watch`, the watcher respawns us) or by
|
||||
// re-executing a detached copy of ourselves. The frontend (Next dev) is a
|
||||
// separate process tree; we restart it best-effort via child_process so the
|
||||
// admin action can bring both halves of the app back up.
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url)); // app/server/src/lib
|
||||
const APP_DIR = path.resolve(HERE, '..', '..', '..'); // app/
|
||||
const NEXT_BIN = path.join(APP_DIR, 'node_modules', '.bin', 'next');
|
||||
|
||||
export type RestartTarget = 'backend' | 'frontend' | 'all';
|
||||
|
||||
function restartBackend(): void {
|
||||
// Under `node --watch` the watcher respawn the child when it exits.
|
||||
if (process.argv.includes('--watch')) {
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
// Otherwise re-exec a detached copy of the current process, then exit.
|
||||
const child = spawn(process.execPath, process.argv.slice(1), {
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
stdio: 'inherit',
|
||||
detached: true,
|
||||
});
|
||||
child.unref();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function restartFrontend(): void {
|
||||
try {
|
||||
spawn('pkill', ['-f', 'next dev'], { stdio: 'ignore' });
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[NEXT_BIN, 'dev', '--port', '3000'],
|
||||
{
|
||||
env: { ...process.env, HOSTNAME: '0.0.0.0', NODE_ENV: process.env.NODE_ENV ?? 'development' },
|
||||
cwd: APP_DIR,
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
},
|
||||
);
|
||||
child.on('error', (e) => console.error('[restart] frontend spawn error:', e));
|
||||
child.on('exit', (code, sig) => {
|
||||
if (code !== 0) console.error(`[restart] frontend exited code=${code} signal=${sig}`);
|
||||
});
|
||||
child.unref();
|
||||
} catch (e) {
|
||||
console.error('[restart] failed to restart frontend:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Respond first, then restart after a short beat so the HTTP 200 is flushed
|
||||
// before the process that owns the socket goes away.
|
||||
export function restartServers(target: RestartTarget): { backend: string; frontend: string } {
|
||||
const status = { backend: 'skipped', frontend: 'skipped' };
|
||||
const restartable = target === 'backend' || target === 'all' || target === 'frontend';
|
||||
if (!restartable) return status;
|
||||
|
||||
setTimeout(() => {
|
||||
// Spawn the frontend child first (it is detached and survives our exit),
|
||||
// then restart the backend last so this process is the one that dies.
|
||||
if (target === 'frontend' || target === 'all') {
|
||||
status.frontend = 'restarting';
|
||||
restartFrontend();
|
||||
}
|
||||
if (target === 'backend' || target === 'all') {
|
||||
status.backend = 'restarting';
|
||||
restartBackend();
|
||||
}
|
||||
}, 600);
|
||||
|
||||
return status;
|
||||
}
|
||||
Reference in New Issue
Block a user