flybrain/services/bridge/src/config.ts
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

403 lines
16 KiB
TypeScript

/**
* 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,
* `<client_id>\n<client_secret>\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<string, number>;
/** 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<string, string | undefined>;
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, <client_id> then <client_secret>, 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 ` +
`(<client_id> then <client_secret>), 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;
}