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
85 lines
3.8 KiB
TypeScript
85 lines
3.8 KiB
TypeScript
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;
|
||
}
|