av-club keeps doing exactly what it did — sharp, ffmpeg and ffprobe behind one API, and the invariant that the caller never names a path. What changes is how it is reached and who decides who may reach it. `src/` is `sidecar/`, because that is the name the platform looks for: `sidecar/index.ts` present means "this plugin ships a process", and presence is the declaration for everything structural here. `manifest.ts` at the root is the other half of that rule. api/router.ts is a proxy and nothing else. the platform authenticates, checks the `av-club` permission, and forwards — so the sidecar now accepts a request carrying `X-Officer-User` and no longer demands its own bearer token from callers who came that way. it binds LOOPBACK to make that trustworthy, which is the same boundary caldav's DAV door already relies on. AV_CLUB_TOKEN stays, and still guards anything calling the port directly: fofos/platform is the stated first consumer and is not Officer. THREE THINGS HAD TO GO, and each one broke it in a way worth writing down. package.json and bun.lock: a package.json inside a plugin is a package boundary, so bun stops walking up for the platform's tsconfig and `@@/sidecar/connect` stops resolving. the sidecar died on its first import, fifteen times. npm dependencies go in the manifest instead, where npm-deps.ts intersects them across plugins into one plugins/node_modules — which is also how this surfaced a real conflict: sharp ^0.33.5 (atelier) and ^0.35.3 here do not overlap, since a 0.x caret pins the minor. atelier moved up; it calls one method. tsconfig.json: same failure one layer earlier, shadowing the platform's `@@/*` paths so api/router.ts would not mount. no other plugin has one. no screens, deliberately. this is an API and a body of documentation; its callers are a commander watching a folder, an agent, or curl. a dock tile would advertise a page with nothing on it. verified through /api/av-club with an agent key: a 3000x2000 jpeg uploaded, probed, and resized to a 1200x800 avif — 166 KB to 1 KB — without the caller ever holding av-club's own token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
72 lines
3.0 KiB
TypeScript
72 lines
3.0 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;
|
||
}
|
||
|
||
export const PORT = num("AV_CLUB_PORT", 3020, 1);
|
||
|
||
/**
|
||
* 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;
|
||
}
|