Files
av-club/sidecar/config.ts
T
pastilhasandClaude Opus 5 e2ed76eac4 bind a free port, do not assert one
a plugin sidecar does not choose a port. it binds whatever is free and REPORTS
it, which is how every other sidecar here works and why none of them can collide.
av-club asserted 3020 because it started life as a standalone service.

that assumption cost a deploy the same day it became a plugin: fofos still had a
copy of the pre-plugin code running from ~/garbage/av-club, on `main`, holding
3020 — so the plugin's sidecar died on EADDRINUSE in a restart loop while the
platform reported "sidecar not available". two things claiming one number, and
neither able to say so.

AV_CLUB_PORT still pins it, for running outside Officer — the standalone mode
fofos/platform was written against, where the other side has to know where to
look. inside Officer nothing sets it.

the announcement now sends `server.port`, never the requested value. reporting
what you asked for rather than what you got is how a sidecar ends up advertising
a port it is not on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:41:52 +01:00

85 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { resolve } from "node:path";
/**
* Configuration, read once at boot.
*
* Every limit here exists because its absence is a denial of service — see docs/SECURITY.md,
* "Layer 5 — bounded work". They are configurable so a deployment can tighten them, not so a
* deployment can remove them: each has a floor enforced below.
*/
function num(key: string, fallback: number, min: number): number {
const raw = process.env[key];
if (raw === undefined) return fallback;
const value = Number(raw);
if (!Number.isFinite(value)) {
throw new Error(`${key} must be a number, got ${JSON.stringify(raw)}`);
}
if (value < min) {
throw new Error(`${key} must be at least ${min}, got ${value}`);
}
return value;
}
/**
* The port to listen on. **0 means "any free one", and that is the default.**
*
* A plugin sidecar does not choose a number. It binds whatever is free and REPORTS it to the platform,
* which is how every other sidecar here works and why none of them can collide. av-club asserted 3020
* because it began life as a standalone service, and on 2026-08-28 that assumption cost a deploy: a
* stale copy of the pre-plugin code was still holding 3020 on fofos, so the plugin's sidecar died on
* EADDRINUSE in a restart loop.
*
* `AV_CLUB_PORT` still works and still pins the port. That is for running this outside Officer — the
* standalone mode fofos/platform was written against — where something on the other side has to know
* where to find it. Inside Officer nothing should set it.
*/
export const PORT = num("AV_CLUB_PORT", 0, 0);
/**
* Where assets live. Outside the repo by default, and deliberately not under any directory a deploy
* would replace — losing every stored asset because the working tree was refreshed is the kind of
* failure that is only noticed later, by which point the originals are gone.
*/
export const ASSETS_DIR = resolve(process.env.AV_CLUB_ASSETS_DIR ?? `${process.env.HOME}/.local/share/av-club/assets`);
/** Multipart bodies are buffered whole. Without a cap, one request is an out-of-memory. */
export const MAX_UPLOAD_BYTES = num("AV_CLUB_MAX_UPLOAD_BYTES", 512 * 1024 * 1024, 1024);
/** A crafted file can make an encoder run effectively forever. The process is killed at this point. */
export const JOB_TIMEOUT_MS = num("AV_CLUB_JOB_TIMEOUT_MS", 120_000, 1_000);
/**
* Decompression bombs: a 1 KB PNG can expand to tens of gigabytes. This is sharp's own
* `limitInputPixels`, defaulted to its documented default (0x3FFF × 0x3FFF) rather than to `false`.
*/
export const MAX_INPUT_PIXELS = num("AV_CLUB_MAX_INPUT_PIXELS", 0x3fff * 0x3fff, 1);
/** `resize(50000, 50000)` is an allocation request, not an image. */
export const MAX_OUTPUT_DIMENSION = num("AV_CLUB_MAX_OUTPUT_DIMENSION", 16_383, 1);
/** ffmpeg will take every core it is given. Two callers should not be able to take the box down. */
export const MAX_CONCURRENT_JOBS = num("AV_CLUB_MAX_CONCURRENT_JOBS", 2, 1);
/**
* The bearer token every request must carry.
*
* A FUNCTION, not a constant evaluated at import. The limits above are plain data that anything may
* read — the doc generator imports the sharp vocabulary, which imports MAX_OUTPUT_DIMENSION from
* here — and a module-scope throw meant that generating documentation required a production secret.
*
* The check itself still happens at boot: server.ts calls this before it listens, so a missing token
* is a refusal to start rather than a service that quietly accepts everyone. There is no
* "unauthenticated by default" mode; this process runs decoders over untrusted bytes.
*/
export function requireToken(): string {
const value = process.env.AV_CLUB_TOKEN;
if (!value || value.length < 16) {
throw new Error(
"AV_CLUB_TOKEN must be set and at least 16 characters. Generate one with: openssl rand -hex 32",
);
}
return value;
}