/** * Env-driven configuration for flybridge, validated once at startup. * * Three ways to supply the Twitch client id/secret: * - Production (systemd), the shape infra actually installs: `LoadCredentialEncrypted=twitch-app:...` * drops ONE decrypted file named `twitch-app` under `$CREDENTIALS_DIRECTORY` holding two lines, * `\n\n` — see `infra/06-secrets.sh`, which writes exactly that, and * `docs/design/infra.md` section 3's secrets table, which names the single * `/etc/fly/creds/twitch-app.cred`. Never logged. * - Production (systemd), the split shape: `LoadCredential=twitch-client-id:...` / * `twitch-client-secret:...` drops two files named `twitch-client-id` / `twitch-client-secret` * (`docs/design/stage-bridge.md` section B1). Preferred when both are present. * - Dev: `TWITCH_CLIENT_ID` / `TWITCH_CLIENT_SECRET` env vars directly. * * 06-secrets.sh's own header flagged this as an unreconciled deviation between the two design docs * ("flybridge's own implementation ... will need to parse that shape, or the two docs need * reconciling before P3"). Both shapes are read here, so neither doc has to lose. * * Every other setting is plain env, with defaults matching the design doc and * `docs/control-api.md`. `loadConfig()` collects every problem before throwing, rather than * failing on the first one, so a misconfigured deploy gets one clear error list instead of a * fix-one-rerun-find-the-next loop. */ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { DEFAULT_NOTICE_MIN_INTERVAL_MS } from './notice'; import { DEFAULT_EVENTSUB_GRACE_MS } from './subscription-health'; /** Game-specific words (channel, bot account, title used in templates) never belong in code. */ export interface BridgeConfig { /** Twitch application client id. Never logged. */ twitchClientId: string; /** Twitch application client secret. Never logged. */ twitchClientSecret: string; /** Path to the persisted refresh-token store written by `tools/authorize.mts`. */ tokensFile: string; /** Broadcaster channel login (no leading `#`). */ channel: string; /** Bot account login used to send chat messages. */ botUser: string; /** Base URL of flysim's localhost control API (`docs/control-api.md`). */ simControlUrl: string; /** Per-call timeout for the control API client (`src/sim.ts`). */ simTimeoutMs: number; /** Game title substituted into templates (`docs/stream-mvp-plan.md`: keep game words out of code). */ gameTitle: string; /** Whether to create/subscribe to the Sugar Channel Points reward (`src/redemptions.ts`). */ featureRedemptions: boolean; /** Whether Predictions are enabled (`src/predictions.ts`, disabled in B1: Affiliate-only). */ featurePredictions: boolean; /** * Whether AutoMod-passed chat is forwarded to flysim's `POST /chat` for the on-screen CHAT * panel (`src/onscreen-chat.ts`). Default **true**. flysim has its own independent kill switch * (`[chat] enabled`), so turning the panel off can be done from either end. */ featureOnscreenChat: boolean; /** * Quiet mode (`FEATURE_QUIET`, default **false**): the bridge speaks only when spoken to. * * With it on, nothing the bridge says is self-initiated — no startup or recovery notice * (`src/notice.ts`), no explainer rotation (`src/explainer.ts`), no follow or raid thanks * (`src/eventsub.ts`). The `channel.follow` and `channel.raid` subscriptions stay, so * `flybridge_follows_total` / `flybridge_raids_total` keep counting and `/health` keeps * reporting the same subscription list; only the chat line is dropped. * * What it does NOT touch: the five commands (`!fly !brain !how !stuck !sugar`), the Sugar * redemption replies, and forwarding viewer chat to the on-screen CHAT panel. A viewer who * asks still gets an answer. */ featureQuiet: boolean; /** Path to the redemption intent log replayed at startup (`src/redemptions.ts`). */ redemptionStateFile: string; /** * How long the `channel.chat.message` EventSub subscription may stay unconfirmed before the * bridge logs one line and exits non-zero so systemd restarts it with a fresh websocket * transport (`src/subscription-health.ts`). Default 60_000 ms, `EVENTSUB_GRACE_MS`. * * Lower it and a slow reconnect becomes an unnecessary restart; raise it and the channel stays * dead longer. 60 s is comfortably above a healthy reconnect (measured at well under a second * on 2026-09-16, both before the outage and on the manual restart that ended it) and well * inside what a viewer will tolerate. */ eventsubGraceMs: number; /** Path to the notice log that rate-limits startup/recovery chat notices across restarts. */ noticeStateFile: string; /** * Minimum gap between two startup/recovery notices, across process restarts * (`src/notice.ts`). Default 600_000 ms, `NOTICE_MIN_INTERVAL_MS`. */ noticeMinIntervalMs: number; /** `node:http` health/metrics server bind (`src/health.ts`). */ healthHost: string; healthPort: number; /** * How often the explainer rotation posts, absent chat activity resetting the silence clock. * `0` (`EXPLAINER_INTERVAL_MS=0`) turns the rotation off on its own, independently of * `featureQuiet` — see `explainerEnabled` in `src/explainer.ts`. */ explainerIntervalMs: number; /** Chat must have been silent this long before the explainer poster skips a post. */ explainerSilenceMs: number; rateLimits: RateLimitSettings; } export interface RateLimitSettings { /** Per-user, per-command token bucket interval. Default 60_000 ms (1 per 60 s per command). */ perUserPerCommandMs: number; /** Global reply bucket: at most one reply per this many ms across all commands. Default 5_000. */ globalReplyMs: number; /** Per-command global cooldown, ms, before that command can fire again for anyone. */ perCommandCooldownMs: Record; /** Self-imposed chat send ceiling, messages per 30 s, below Twitch's moderator limit of 100/30s. */ chatMessagesPer30s: number; } const DEFAULT_SIM_CONTROL_URL = 'http://127.0.0.1:7401'; const DEFAULT_TOKENS_FILE = '/var/lib/flybridge/tokens.json'; const DEFAULT_REDEMPTION_STATE_FILE = '/var/lib/flybridge/redemption-state.json'; const DEFAULT_NOTICE_STATE_FILE = '/var/lib/flybridge/notice-state.json'; const DEFAULT_HEALTH_HOST = '127.0.0.1'; const DEFAULT_HEALTH_PORT = 7410; const DEFAULT_SIM_TIMEOUT_MS = 2_000; const DEFAULT_EXPLAINER_INTERVAL_MS = 20 * 60 * 1000; const DEFAULT_EXPLAINER_SILENCE_MS = 20 * 60 * 1000; const DEFAULT_PER_USER_PER_COMMAND_MS = 60_000; const DEFAULT_GLOBAL_REPLY_MS = 5_000; const DEFAULT_CHAT_MESSAGES_PER_30S = 15; const DEFAULT_SUGAR_COOLDOWN_MS = 10_000; /** Thrown by `loadConfig` with every validation problem collected, not just the first. */ export class ConfigError extends Error { readonly problems: readonly string[]; constructor(problems: readonly string[]) { super(`invalid flybridge configuration:\n - ${problems.join('\n - ')}`); this.name = 'ConfigError'; this.problems = problems; } } /** Minimal environment shape `loadConfig` reads from. Matches `process.env`. */ export type Env = Record; export interface LoadConfigOptions { env?: Env; /** Override for reading credential files, for tests. Defaults to `node:fs`'s `readFileSync`. */ readCredentialFile?: (path: string) => string; } /** * Load and validate configuration from environment variables (and, when `CREDENTIALS_DIRECTORY` * is set, systemd credential files). Throws `ConfigError` listing every problem found. */ export function loadConfig(options: LoadConfigOptions = {}): BridgeConfig { const env = options.env ?? process.env; const readCredentialFile = options.readCredentialFile ?? ((path: string) => readFileSync(path, 'utf8')); const problems: string[] = []; const { twitchClientId, twitchClientSecret } = readAppCredentials(env, readCredentialFile, problems); const channel = requireNonEmpty(env.CHANNEL, 'CHANNEL', problems); const botUser = requireNonEmpty(env.BOT_USER, 'BOT_USER', problems); const gameTitle = requireNonEmpty(env.GAME_TITLE, 'GAME_TITLE', problems); const simControlUrl = env.SIM_CONTROL_URL?.trim() || DEFAULT_SIM_CONTROL_URL; validateUrl(simControlUrl, 'SIM_CONTROL_URL', problems); const tokensFile = env.TOKENS_FILE?.trim() || DEFAULT_TOKENS_FILE; const redemptionStateFile = env.REDEMPTION_STATE_FILE?.trim() || DEFAULT_REDEMPTION_STATE_FILE; const noticeStateFile = env.NOTICE_STATE_FILE?.trim() || DEFAULT_NOTICE_STATE_FILE; const healthHost = env.HEALTH_HOST?.trim() || DEFAULT_HEALTH_HOST; const healthPort = parsePositiveInt(env.HEALTH_PORT, DEFAULT_HEALTH_PORT, 'HEALTH_PORT', problems); const simTimeoutMs = parsePositiveInt(env.SIM_TIMEOUT_MS, DEFAULT_SIM_TIMEOUT_MS, 'SIM_TIMEOUT_MS', problems); // Non-negative, not positive: 0 is the documented "no explainer rotation" value, and it has to // be reachable without also setting FEATURE_QUIET (which silences everything else too). const explainerIntervalMs = parseNonNegativeInt( env.EXPLAINER_INTERVAL_MS, DEFAULT_EXPLAINER_INTERVAL_MS, 'EXPLAINER_INTERVAL_MS', problems, ); const explainerSilenceMs = parsePositiveInt( env.EXPLAINER_SILENCE_MS, DEFAULT_EXPLAINER_SILENCE_MS, 'EXPLAINER_SILENCE_MS', problems, ); const eventsubGraceMs = parsePositiveInt( env.EVENTSUB_GRACE_MS, DEFAULT_EVENTSUB_GRACE_MS, 'EVENTSUB_GRACE_MS', problems, ); const noticeMinIntervalMs = parsePositiveInt( env.NOTICE_MIN_INTERVAL_MS, DEFAULT_NOTICE_MIN_INTERVAL_MS, 'NOTICE_MIN_INTERVAL_MS', problems, ); const featureRedemptions = parseBoolean(env.FEATURE_REDEMPTIONS, false, 'FEATURE_REDEMPTIONS', problems); const featurePredictions = parseBoolean(env.FEATURE_PREDICTIONS, false, 'FEATURE_PREDICTIONS', problems); // On by default: the CHAT panel is part of the locked rail layout, and flysim's own // `[chat] enabled` is the switch an operator reaches for in a hurry. const featureOnscreenChat = parseBoolean(env.FEATURE_ONSCREEN_CHAT, true, 'FEATURE_ONSCREEN_CHAT', problems); // Off by default: a bridge that says nothing unprompted is a per-channel choice, not the shape // docs/design/stage-bridge.md B2 describes. const featureQuiet = parseBoolean(env.FEATURE_QUIET, false, 'FEATURE_QUIET', problems); const perUserPerCommandMs = parsePositiveInt( env.RATE_LIMIT_PER_USER_PER_COMMAND_MS, DEFAULT_PER_USER_PER_COMMAND_MS, 'RATE_LIMIT_PER_USER_PER_COMMAND_MS', problems, ); const globalReplyMs = parsePositiveInt( env.RATE_LIMIT_GLOBAL_REPLY_MS, DEFAULT_GLOBAL_REPLY_MS, 'RATE_LIMIT_GLOBAL_REPLY_MS', problems, ); const chatMessagesPer30s = parsePositiveInt( env.RATE_LIMIT_CHAT_MESSAGES_PER_30S, DEFAULT_CHAT_MESSAGES_PER_30S, 'RATE_LIMIT_CHAT_MESSAGES_PER_30S', problems, ); const sugarCooldownMs = parsePositiveInt( env.RATE_LIMIT_SUGAR_COOLDOWN_MS, DEFAULT_SUGAR_COOLDOWN_MS, 'RATE_LIMIT_SUGAR_COOLDOWN_MS', problems, ); if (problems.length > 0) throw new ConfigError(problems); return { twitchClientId, twitchClientSecret, tokensFile, channel, botUser, simControlUrl, simTimeoutMs, gameTitle, featureRedemptions, featurePredictions, featureOnscreenChat, featureQuiet, redemptionStateFile, eventsubGraceMs, noticeStateFile, noticeMinIntervalMs, healthHost, healthPort, explainerIntervalMs, explainerSilenceMs, rateLimits: { perUserPerCommandMs, globalReplyMs, chatMessagesPer30s, perCommandCooldownMs: { fly: 0, brain: 0, how: 0, stuck: 0, sugar: sugarCooldownMs, }, }, }; } interface AppCredentials { twitchClientId: string; twitchClientSecret: string; } /** * Resolve the app id/secret. Under `CREDENTIALS_DIRECTORY` the split `twitch-client-id` / * `twitch-client-secret` pair wins when both are readable; otherwise the combined two-line * `twitch-app` file (`infra/06-secrets.sh`) is parsed. A problem is only recorded when NEITHER * shape is present, and it names both, so a misconfigured deploy is told what to install rather * than which of two files it happened to look for first. */ function readAppCredentials( env: Env, readCredentialFile: (path: string) => string, problems: string[], ): AppCredentials { const credentialsDirectory = env.CREDENTIALS_DIRECTORY?.trim(); if (!credentialsDirectory) { return { twitchClientId: requireNonEmpty(env.TWITCH_CLIENT_ID, 'TWITCH_CLIENT_ID', problems), twitchClientSecret: requireNonEmpty(env.TWITCH_CLIENT_SECRET, 'TWITCH_CLIENT_SECRET', problems), }; } const split = readSplitAppCredentials(credentialsDirectory, readCredentialFile); if (split) return split; const combinedName = 'twitch-app'; let combinedRaw: string; try { combinedRaw = readCredentialFile(join(credentialsDirectory, combinedName)); } catch (cause) { problems.push( `CREDENTIALS_DIRECTORY has neither the split credentials "twitch-client-id"/"twitch-client-secret" ` + `nor the combined "${combinedName}" (two lines, then , as ` + `infra/06-secrets.sh installs it): ${(cause as Error).message}`, ); return { twitchClientId: '', twitchClientSecret: '' }; } const lines = combinedRaw .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0); if (lines.length !== 2) { problems.push( `credential "${combinedName}" in CREDENTIALS_DIRECTORY must hold exactly two non-empty lines ` + `( then ), found ${lines.length}`, ); return { twitchClientId: '', twitchClientSecret: '' }; } return { twitchClientId: lines[0]!, twitchClientSecret: lines[1]! }; } function readSplitAppCredentials( credentialsDirectory: string, readCredentialFile: (path: string) => string, ): AppCredentials | null { const read = (name: string): string | null => { try { const value = readCredentialFile(join(credentialsDirectory, name)).trim(); return value.length > 0 ? value : null; } catch { return null; } }; const id = read('twitch-client-id'); const secret = read('twitch-client-secret'); return id !== null && secret !== null ? { twitchClientId: id, twitchClientSecret: secret } : null; } function requireNonEmpty(value: string | undefined, name: string, problems: string[]): string { const trimmed = value?.trim(); if (!trimmed) { problems.push(`${name} is required (set it directly, or CREDENTIALS_DIRECTORY for credential files)`); return ''; } return trimmed; } function validateUrl(value: string, name: string, problems: string[]): void { try { // eslint-disable-next-line no-new new URL(value); } catch { problems.push(`${name} must be a valid URL, got ${JSON.stringify(value)}`); } } function parsePositiveInt( value: string | undefined, fallback: number, name: string, problems: string[], ): number { if (value === undefined || value.trim() === '') return fallback; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0 || String(parsed) !== value.trim()) { problems.push(`${name} must be a positive integer, got ${JSON.stringify(value)}`); return fallback; } return parsed; } /** Like `parsePositiveInt`, but `0` is a legal value: an interval of zero means "never". */ function parseNonNegativeInt( value: string | undefined, fallback: number, name: string, problems: string[], ): number { if (value === undefined || value.trim() === '') return fallback; const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 0 || String(parsed) !== value.trim()) { problems.push(`${name} must be a non-negative integer, got ${JSON.stringify(value)}`); return fallback; } return parsed; } function parseBoolean(value: string | undefined, fallback: boolean, name: string, problems: string[]): boolean { if (value === undefined || value.trim() === '') return fallback; const normalized = value.trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; if (['0', 'false', 'no', 'off'].includes(normalized)) return false; problems.push(`${name} must be one of true/false/1/0/yes/no/on/off, got ${JSON.stringify(value)}`); return fallback; }