import { NextResponse } from "next/server"; import { authorizeWorkerRequest } from "yt-dlp-transcript-common/lib/workerToken"; import { isValidChannelSlug } from "yt-dlp-transcript-common/controller/channels"; import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; import { isValidSiteId, listSiteIds } from "yt-dlp-transcript-common/lib/site"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import type { QueueOutcome } from "../../channels/lib/queueForSlugs"; // THE OPS API IS ADAPTERS, AND NOTHING ELSE. // // Every route under /api/ops is ~5 lines that validate a JSON body and call ONE // existing server action. No route may contain a rule the UI does not already // enforce: the point of the layer is that an agent driving the editor over HTTP // and an operator clicking the same button get the same refusal, with the same // sentence, from the same code. A check written here would be a second opinion // nobody maintains. // // THE TOKEN IS THE WORKER TOKEN, ON PURPOSE. `WORKER_TOKEN` already gates the // LAN worker protocol and already means "this instance accepts instructions // from something that is not the browser in front of it". A second secret would // be a second thing to distribute, rotate and leave unset; the failure modes are // identical, so the gate is. Unset => 503 (the surface is off, you opt in), // wrong => 401. // // A JOB-STARTING ROUTE RETURNS A jobId AND NEVER STREAMS. runManagedFunction // hands back a ReadableStream the browser consumes; an HTTP caller wants to // disconnect and poll. So every adapter cancels the stream (which stops pushing // into the controller and leaves the on-disk log running — see streamCommand's // `cancel()` note) and returns the id. Follow it with /api/jobs//log. export type OpsBody = Record; // Thrown by the field readers below; caught by `ops()` and rendered as a 400. export class OpsInputError extends Error {} export function opsFail( error: string, status = 400, extra?: Record, ): NextResponse { return NextResponse.json({ ok: false, error, ...extra }, { status }); } // Auth + body parse + unknown-key rejection, wrapped around one handler. // // UNKNOWN KEYS ARE A 400, not a silent ignore. A caller that misspells // `downloadFilterExclude` would otherwise get a cheerful `{ ok: true }` and a // channel that still downloads everything. The allow-list IS the route's // documented body shape. export async function ops( request: Request, allowedKeys: readonly string[], run: (body: OpsBody) => Promise, ): Promise { const auth = authorizeWorkerRequest(request.headers.get("authorization")); if (!auth.ok) return opsFail(auth.error, auth.status); let body: unknown; try { body = await request.json(); } catch { return opsFail("malformed JSON body"); } if (typeof body !== "object" || body === null || Array.isArray(body)) { return opsFail("body must be a JSON object"); } const unknown = Object.keys(body as OpsBody).filter( (k) => !allowedKeys.includes(k), ); if (unknown.length) { return opsFail( `unknown key(s): ${unknown.join(", ")} — this route accepts ${ allowedKeys.length ? allowedKeys.join(", ") : "no keys" }`, ); } try { return await run(body as OpsBody); } catch (e) { if (e instanceof OpsInputError) return opsFail(e.message); return opsFail((e as Error).message, 500); } } // A GET route's gate. Same token, no body. export function opsAuth(request: Request): NextResponse | null { const auth = authorizeWorkerRequest(request.headers.get("authorization")); return auth.ok ? null : opsFail(auth.error, auth.status); } // --- field readers ---------------------------------------------------------- export function reqString(body: OpsBody, key: string): string { const v = body[key]; if (typeof v !== "string" || !v.trim()) { throw new OpsInputError(`"${key}" is required and must be a non-empty string`); } return v.trim(); } // A CHANNEL SLUG, NOT MERELY A STRING. Every slug below reaches a `path.join` // under `channelsDir`, and the readers swallow their own errors — so a // traversing segment would fail SILENTLY (an empty config, an "empty channel") // rather than loudly. `isValidChannelSlug` is CHANNEL_SLUG_RE, which forbids // "/" and "..", and is what every other slug-taking surface in the app uses. // // One reader for every route rather than a check per route: a route added later // gets this for free by calling reqSlug instead of reqString, and there is one // place to be wrong. export function reqSlug(body: OpsBody, key: string): string { const v = reqString(body, key); if (!isValidChannelSlug(v)) { throw new OpsInputError( `"${v}" is not a valid channel slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`, ); } return v; } export function reqSlugs(body: OpsBody, key: string): string[] { const values = reqStringArray(body, key); for (const v of values) { if (!isValidChannelSlug(v)) { throw new OpsInputError( `"${v}" is not a valid channel slug (letters, digits, ".", "_", "-"; must start with a letter or digit)`, ); } } return values; } export function optString(body: OpsBody, key: string): string | undefined { const v = body[key]; if (v === undefined) return undefined; if (typeof v !== "string") { throw new OpsInputError(`"${key}" must be a string`); } return v; } export function optBool(body: OpsBody, key: string): boolean | undefined { const v = body[key]; if (v === undefined) return undefined; if (typeof v !== "boolean") { throw new OpsInputError(`"${key}" must be a boolean`); } return v; } // A channel's site memberships, as the Configure form's Sites section posts // them: THE WHOLE SET — a site left out is a site the channel leaves, and `[]` // is "on no site". Undefined when absent (memberships untouched). // // STRICTER THAN THE FORM'S PARSER, ON PURPOSE. planSiteMembershipWrites skips a // siteId it does not know, because for the form that means the site was deleted // since the page loaded. Over HTTP it means a typo, and skipping it would answer // `{ ok: true }` about a membership that was never written — the silent success // this file's unknown-key rule exists to refuse. Group ids and new group names // are still the planner's to judge. export type OpsSiteMembership = { siteId: string; groupId?: string; newGroupName?: string; }; export function readSiteMemberships( body: OpsBody, key = "sites", ): OpsSiteMembership[] | undefined { const v = body[key]; if (v === undefined) return undefined; if (!Array.isArray(v)) { throw new OpsInputError( `"${key}" must be an array of { siteId, groupId? | newGroupName? } ([] = on no site)`, ); } const known = new Set(listSiteIds(getPaths())); const out: OpsSiteMembership[] = []; for (const entry of v) { if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { throw new OpsInputError(`each "${key}" entry must be an object with a "siteId"`); } const e = entry as Record; const stray = Object.keys(e).filter( (k) => !["siteId", "groupId", "newGroupName"].includes(k), ); if (stray.length) { throw new OpsInputError( `unknown key(s) in a "${key}" entry: ${stray.join(", ")} — accepted: siteId, groupId, newGroupName`, ); } if (typeof e.siteId !== "string" || !isValidSiteId(e.siteId)) { throw new OpsInputError(`"${String(e.siteId)}" is not a valid site id`); } if (!known.has(e.siteId)) { throw new OpsInputError( `no site "${e.siteId}" — known: ${[...known].join(", ") || "none"}`, ); } for (const k of ["groupId", "newGroupName"] as const) { if (e[k] !== undefined && typeof e[k] !== "string") { throw new OpsInputError(`"${k}" in a "${key}" entry must be a string`); } } if (e.groupId !== undefined && e.newGroupName !== undefined) { throw new OpsInputError( `a "${key}" entry takes "groupId" or "newGroupName", not both`, ); } out.push({ siteId: e.siteId, ...(e.groupId !== undefined ? { groupId: e.groupId as string } : {}), ...(e.newGroupName !== undefined ? { newGroupName: e.newGroupName as string } : {}), }); } return out; } // A whole number above zero, or undefined when absent. A type check, not a // rule: what the number means is the action's business. export function optPositiveInt(body: OpsBody, key: string): number | undefined { const v = body[key]; if (v === undefined) return undefined; if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) { throw new OpsInputError(`"${key}" must be a whole number above zero`); } return v; } // A whole number, zero or above, or undefined when absent — for a duration or // a floor where 0 means "off". export function optNonNegativeInt(body: OpsBody, key: string): number | undefined { const v = body[key]; if (v === undefined) return undefined; if (typeof v !== "number" || !Number.isInteger(v) || v < 0) { throw new OpsInputError(`"${key}" must be a whole number, zero or above`); } return v; } // A list of videos across channels: `[{ slug, id }, …]`. Every slug is a // CHANNEL SLUG (see reqSlug) and every id one path segment, checked here so a // traversing entry is refused at the door rather than read as an unknown video. export function reqVideoItems( body: OpsBody, key: string, ): { slug: string; id: string }[] { const v = body[key]; if (!Array.isArray(v) || v.length === 0) { throw new OpsInputError( `"${key}" is required and must be a non-empty array of { "slug", "id" }`, ); } return v.map((entry, i) => { if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { throw new OpsInputError(`"${key}[${i}]" must be an object { "slug", "id" }`); } const extra = Object.keys(entry).filter((k) => k !== "slug" && k !== "id"); if (extra.length) { throw new OpsInputError( `"${key}[${i}]" has unknown key(s): ${extra.join(", ")} — an item is { "slug", "id" }`, ); } const slug = reqSlug(entry as OpsBody, "slug"); const id = reqVideoId(entry as OpsBody, "id"); return { slug, id }; }); } // ONE VIDEO ID, one path segment — checked here for the reason reqSlug is: the // id reaches a path.join under the channel's `data/`, and a traversing one // must be refused at the door rather than read as an unknown video. export function reqVideoId(body: OpsBody, key: string): string { const id = reqString(body, key); if (id === "." || id === ".." || /[/\\\0]/.test(id)) { throw new OpsInputError(`"${id}" is not a video id (one path segment)`); } return id; } export function reqStringArray(body: OpsBody, key: string): string[] { const v = body[key]; if ( !Array.isArray(v) || v.length === 0 || v.some((s) => typeof s !== "string" || !s.trim()) ) { throw new OpsInputError( `"${key}" is required and must be a non-empty array of strings`, ); } return (v as string[]).map((s) => s.trim()); } // A SUBSET OF A LIST THE CALLER CAN ALREADY ACT ON, or undefined when absent. // Every id must be in `of`: a route taking it narrows a gesture the UI offers // (a checkbox selection inside a bucket), it never widens it. A stray id is a // 400 naming every stray, not a silent drop — a caller that pasted a list from // an older report would otherwise run a smaller job than it asked for and be // told nothing. Duplicates collapse; order is the caller's. export function optSubset( body: OpsBody, key: string, of: readonly string[], ofName: string, ): string[] | undefined { if (body[key] === undefined) return undefined; const wanted = [...new Set(reqStringArray(body, key))]; const known = new Set(of); const stray = wanted.filter((v) => !known.has(v)); if (stray.length) { throw new OpsInputError( `${stray.length} of "${key}" not in ${ofName}: ${stray.join(", ")}`, ); } return wanted; } // ONE SITE OR SEVERAL, SPELLED EITHER WAY. The two build routes disagreed — // build-site took `siteIds` (a list), build-deploy took `siteId` (one) — so the // same body worked on one and 400'd on the other, and the fix people reached // for was to guess, which costs a round trip every time. Both routes now accept // both keys, and this reader is the single place that says what that means. // // Both keys at once is still a 400 rather than a merge: a caller that sent both // has two ideas about what it wants, and picking one on its behalf is how a // deploy of the wrong site becomes somebody's afternoon. Neither key is a 400 // naming both, because "which did you mean" is the actual question. // // A SITE ID, NOT MERELY A STRING, and checked BEFORE any job starts — the same // reason reqSlug exists, with a worse failure behind it. `getSite` throws a // plain Error on an id failing SITE_ID_RE and `ops()` maps that to a 500, so // ["good", "BAD"] used to queue the first build, throw on the second and answer // 500 with no jobs and no ids: a real build running that nothing was watching, // and a `--wait` exiting 1 about it. export function reqSiteIds(body: OpsBody): string[] { const one = body.siteId; const many = body.siteIds; if (one !== undefined && many !== undefined) { throw new OpsInputError('send either "siteId" or "siteIds", not both'); } let ids: string[]; if (one !== undefined) ids = [reqString(body, "siteId")]; else if (many !== undefined) ids = reqStringArray(body, "siteIds"); else { throw new OpsInputError( '"siteId" (a string) or "siteIds" (a non-empty array of strings) is required', ); } for (const id of ids) { if (!isValidSiteId(id)) { throw new OpsInputError( `"${id}" is not a valid site id (lowercase letters, digits and "-"; must start with a letter or digit)`, ); } } return ids; } // A PREVIEW BRANCH, JUDGED BEFORE ANY JOB STARTS. `previewBranchProblem` is the // same function the server action and the browser control use — this is not a // second opinion, it is the one opinion asked earlier. Earlier matters: on // build-deploy the action's own check happens before its build, but a `siteIds` // fan-out would otherwise walk every site to refuse each in turn and answer // with a joined list of the same sentence. export function optPreviewBranch(body: OpsBody): string | undefined { const raw = body.preview; if (raw === undefined) return undefined; const problem = previewBranchProblem(raw); if (problem) throw new OpsInputError(problem); return (raw as string).trim(); } export function oneOf( body: OpsBody, key: string, values: readonly T[], ): T { const v = reqString(body, key); if (!(values as readonly string[]).includes(v)) { throw new OpsInputError(`"${key}" must be one of ${values.join(", ")}`); } return v as T; } // --- result mapping --------------------------------------------------------- // StreamActionResult -> { ok: true, jobId } | { ok: false, error, info? }. // The stream is cancelled, never returned: see the header. export function jobResponse(result: StreamActionResult): NextResponse { if (!result.ok) { return opsFail(result.error, 400, result.info ? { info: true } : undefined); } void result.stream.cancel(); return NextResponse.json({ ok: true, jobId: result.jobId }); } // The `{ error } | undefined` shape every /channels action returns. export function actionResponse( result: { error: string } | undefined, ): NextResponse { if (result?.error) return opsFail(result.error); return NextResponse.json({ ok: true }); } // The `{ ok } | { ok: false, error }` shape the lane/pause actions return. export function okResponse( result: { ok: boolean; error?: string }, ): NextResponse { if (!result.ok) return opsFail(result.error ?? "action failed"); return NextResponse.json({ ok: true }); } // A bulk fan-out's { queued, skipped }. A skip is not a failure — the caller // gets both lists and decides, exactly as the bulk bar in the UI does. // // IT ALSO RETURNS THE JOB IDS, and that was not a nicety: without them a // job-starting fan-out was indistinguishable from an action that started // nothing, so `pnpm ops relocate --wait` returned 0 the moment the response // arrived and reported success about a copy that had not begun. // // `jobIds` is parallel to `queued`; `jobId` is present only when exactly one // job was started, so the single-slug case reads like every other job-starting // route (`jobResponse`). Both are ADDITIVE — `queued` and `skipped` keep their // meaning and their spelling. export function queueResponse(outcome: QueueOutcome): NextResponse { return NextResponse.json({ ok: true, queued: outcome.queued, skipped: outcome.skipped, jobIds: outcome.jobIds, ...(outcome.jobIds.length === 1 ? { jobId: outcome.jobIds[0] } : {}), }); }