Files
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

123 lines
4.7 KiB
TypeScript
Raw Permalink 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.
#!/usr/bin/env bun
// Generate docs/SHARP.md from sidecar/sharp/vocabulary.ts.
//
// The vocabulary table is what the validator enforces. Writing the reference by hand would mean two
// descriptions of one thing, and the doc would quietly stop being true the first time a bound
// changed. This makes the drift structurally impossible instead of a discipline problem.
//
// Run: bun run docs
import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS, type Param } from "../sidecar/sharp/vocabulary";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function describeParam(spec: Param): string {
const base = (() => {
switch (spec.kind) {
case "int": return `integer, ${spec.min}–${spec.max}`;
case "number": return `number, ${spec.min}–${spec.max}`;
case "bool": return "boolean";
case "enum": return spec.values.map((v) => `\`${v}\``).join(" · ");
case "color": return "colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}`";
case "numberArray": return `array of ${spec.minLength === spec.maxLength ? spec.minLength : `${spec.minLength}–${spec.maxLength}`} numbers, each ${spec.min}–${spec.max}`;
case "assetId": return "**asset id** (never a path)";
case "string": return `string, up to ${spec.maxLength} chars`;
}
})();
return spec.note ? `${base}. ${spec.note}` : base;
}
function paramTable(params: Record<string, Param>): string {
const keys = Object.keys(params);
if (!keys.length) return "_Takes no options._\n";
return [
"| Parameter | Accepts |",
"|---|---|",
...keys.map((key) => `| \`${key}\` | ${describeParam(params[key]!)} |`),
"",
].join("\n");
}
const lines: string[] = [
"# sharp — the operation vocabulary",
"",
"**Generated from `sidecar/sharp/vocabulary.ts` by `scripts/gen-docs.ts`. Do not edit by hand —",
"run `bun run docs`.**",
"",
"Every operation is named what sharp names it and takes sharp's own arguments, so",
"[sharp's documentation](https://sharp.pixelplumbing.com/api-operation) describes what each one",
"*does*. This page describes what av-club *accepts*: the bounds, which exist because an unbounded",
"number here is a denial of service rather than a picture.",
"",
"Requests go to `POST /v1/sharp` — see [API.md](API.md) for the envelope.",
"",
"```json",
"{",
' "input": "as_9f86d081884c7d659a2feaa0c55ad015",',
' "options": { "failOn": "warning" },',
' "pipeline": [',
' { "rotate": {} },',
' { "resize": { "width": 1200, "fit": "inside", "withoutEnlargement": true } }',
" ],",
' "output": { "format": "avif", "options": { "quality": 55 } }',
"}",
"```",
"",
"## Three things that catch people",
"",
"**`rotate` with no angle applies the EXIF orientation.** Output is re-encoded and metadata is",
"dropped, so a photo that looked upright in a phone's gallery arrives sideways on the web unless you",
"include `{ \"rotate\": {} }`. This is the most commonly forgotten operation.",
"",
"**`resize` enlarges by default.** A 300px source asked for 800px comes back 800px and soft. Pass",
"`withoutEnlargement: true` unless you actually want that.",
"",
"**Metadata is always stripped and there is no way to keep it.** `withMetadata` is deliberately not",
"in this vocabulary — it is how a client's GPS coordinates end up published. Dimensions come back in",
"the response instead.",
"",
"## Input options",
"",
"sharp's constructor options, passed as `options`.",
"",
paramTable(INPUT_OPTIONS),
"`limitInputPixels` is **not** accepted: it is the decompression-bomb guard and is set by the",
"server. A 1 KB PNG can expand to tens of gigabytes.",
"",
"## Operations",
"",
`${Object.keys(OPERATIONS).length} operations, run in the order given.`,
"",
];
for (const [name, spec] of Object.entries(OPERATIONS)) {
lines.push(`### \`${name}\``, "", spec.describe, "");
if (spec.positional) {
lines.push(
`Takes \`${spec.positional}\` directly — \`{ "${name}": { "${spec.positional}": … } }\`.`,
"",
);
}
lines.push(paramTable(spec.params));
}
lines.push(
"## Output formats",
"",
"Given as `output.format`, with `output.options` carrying the encoder settings. Defaults to `webp`.",
"",
);
for (const [name, spec] of Object.entries(OUTPUT_FORMATS)) {
lines.push(`### \`${name}\``, "", spec.describe, "", paramTable(spec.params));
}
const path = resolve(ROOT, "docs/SHARP.md");
await Bun.write(path, lines.join("\n"));
console.log(
`[gen-docs] docs/SHARP.md — ${Object.keys(OPERATIONS).length} operations, ${Object.keys(OUTPUT_FORMATS).length} output formats`,
);