Files
av-club/sidecar/sharp/validate.ts
T
pastilhasandClaude Opus 5 79ff366802 become an Officer plugin
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
2026-08-28 22:36:53 +01:00

138 lines
5.1 KiB
TypeScript

import { invalid } from "../errors";
import type { Param } from "./vocabulary";
/**
* Validate caller-supplied values against the vocabulary table.
*
* Default-deny throughout: an unknown operation, an unknown parameter, or a value of the wrong shape
* is refused. Nothing is coerced and nothing is silently dropped — a typo'd parameter that was
* quietly ignored would look exactly like an option that does not work.
*
* Error messages name the exact path (`pipeline[1].resize.width`) and say what was expected, because
* the thing on the other end is usually relaying them to a person.
*/
const ID_PATTERN = /^as_[0-9a-f]{32}$/;
/** sharp accepts a CSS colour string or {r,g,b,alpha}. Both are data — neither can reference a file. */
function validateColor(value: unknown, path: string): unknown {
if (typeof value === "string") {
if (value.length > 64) throw invalid("Colour string is too long", path);
return value;
}
if (value && typeof value === "object" && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (!["r", "g", "b", "alpha"].includes(key)) {
throw invalid(`Unknown colour component ${JSON.stringify(key)}. Expected r, g, b or alpha.`, path);
}
const component = obj[key];
if (typeof component !== "number" || !Number.isFinite(component)) {
throw invalid(`Colour component ${key} must be a number`, `${path}.${key}`);
}
}
return obj;
}
throw invalid("Expected a colour: a CSS string like '#ffffff', or {r, g, b, alpha}", path);
}
export function validateValue(spec: Param, value: unknown, path: string): unknown {
switch (spec.kind) {
case "int": {
if (typeof value !== "number" || !Number.isInteger(value)) {
throw invalid(`${path} must be an integer`, path);
}
if (value < spec.min || value > spec.max) {
throw invalid(`${path} must be between ${spec.min} and ${spec.max}, got ${value}`, path);
}
return value;
}
case "number": {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw invalid(`${path} must be a number`, path);
}
if (value < spec.min || value > spec.max) {
throw invalid(`${path} must be between ${spec.min} and ${spec.max}, got ${value}`, path);
}
return value;
}
case "bool": {
if (typeof value !== "boolean") throw invalid(`${path} must be true or false`, path);
return value;
}
case "enum": {
if (typeof value !== "string" || !spec.values.includes(value)) {
throw invalid(`${path} must be one of: ${spec.values.join(", ")}`, path);
}
return value;
}
case "color":
return validateColor(value, path);
case "numberArray": {
if (!Array.isArray(value)) throw invalid(`${path} must be an array of numbers`, path);
if (value.length < spec.minLength || value.length > spec.maxLength) {
throw invalid(
`${path} must have between ${spec.minLength} and ${spec.maxLength} entries, got ${value.length}`,
path,
);
}
value.forEach((entry, i) => {
if (typeof entry !== "number" || !Number.isFinite(entry)) {
throw invalid(`${path}[${i}] must be a number`, `${path}[${i}]`);
}
if (entry < spec.min || entry > spec.max) {
throw invalid(`${path}[${i}] must be between ${spec.min} and ${spec.max}`, `${path}[${i}]`);
}
});
return value;
}
case "assetId": {
// The ONLY way a caller may reference another file. Checked here against the same pattern the
// store issues, then resolved to a path by the store and nowhere else. See docs/SECURITY.md.
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
throw invalid(`${path} must be an asset id (as_ followed by 32 hex characters)`, path);
}
return value;
}
case "string": {
if (typeof value !== "string") throw invalid(`${path} must be a string`, path);
if (value.length > spec.maxLength) {
throw invalid(`${path} must be at most ${spec.maxLength} characters`, path);
}
return value;
}
}
}
/** Validate an options object against a table of parameter specs. Unknown keys are refused. */
export function validateObject(
params: Record<string, Param>,
value: unknown,
path: string,
): Record<string, unknown> {
if (value === undefined || value === null) return {};
if (typeof value !== "object" || Array.isArray(value)) {
throw invalid(`${path} must be an object`, path);
}
const input = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(input)) {
const spec = params[key];
if (!spec) {
const known = Object.keys(params);
throw invalid(
known.length
? `Unknown option ${JSON.stringify(key)}. Accepted here: ${known.join(", ")}`
: `${path} takes no options, got ${JSON.stringify(key)}`,
`${path}.${key}`,
);
}
if (raw === undefined) continue;
out[key] = validateValue(spec, raw, `${path}.${key}`);
}
return out;
}