merge: W2 worktree-agent-a346544dc365ebb55

This commit is contained in:
Claude 2026-10-03 18:47:29 +00:00
commit 227703a78a
No known key found for this signature in database
6 changed files with 2246 additions and 67 deletions

265
apps/api/src/media-http.ts Normal file
View File

@ -0,0 +1,265 @@
import { createReadStream } from "node:fs";
import { mkdir, stat } from "node:fs/promises";
import path from "node:path";
import { Readable } from "node:stream";
import type { MediaServices } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { IsolationError, MediaStateConflictError } from "@rakazo/db";
import { assertInside, PathOutsideRootError, resolveInside } from "@rakazo/media/node";
import type { Context, Hono } from "hono";
import { cancelBody } from "./http-body.js";
import { MEDIA_CHUNK_TIMEOUT_MS, UploadChunkError, writeUploadChunk } from "./media-uploads.js";
/*
* Byte routes Media Studio cannot express as JSON RPC: chunk uploads and
* streaming of proxies, thumbnails, renders, and bundles. Every id is checked
* against the actor's space before a path is derived, and every path is
* resolved inside the media root.
*/
const ID = /^[A-Za-z0-9_-]{1,64}$/;
const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
const CHUNK_INDEX = /^[0-9]{1,7}$/;
const CONTENT_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".srt": "application/x-subrip",
".vtt": "text/vtt; charset=utf-8",
".json": "application/json",
".md": "text/markdown; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".zip": "application/zip",
".m4a": "audio/mp4",
".wav": "audio/wav",
};
export function mediaContentType(fileName: string): string {
return CONTENT_TYPES[path.extname(fileName).toLowerCase()] ?? "application/octet-stream";
}
export type ByteRange = { start: number; end: number };
/**
* Parse a single `bytes=` range. Returns null for no (or an ignorable
* multi-part) range and "unsatisfiable" when it cannot be served.
*/
export function parseByteRange(
header: string | undefined,
size: number,
): ByteRange | null | "unsatisfiable" {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return header.includes(",") ? null : "unsatisfiable";
const [, first = "", last = ""] = match;
if (first === "" && last === "") return "unsatisfiable";
if (first === "") {
const suffix = Number(last);
if (suffix === 0 || size === 0) return "unsatisfiable";
return { start: Math.max(0, size - suffix), end: size - 1 };
}
const start = Number(first);
const end = last === "" ? size - 1 : Math.min(Number(last), size - 1);
if (start >= size || end < start) return "unsatisfiable";
return { start, end };
}
async function sendFile(
c: Context,
absolutePath: string,
options: { fileName: string; download?: boolean },
): Promise<Response> {
const info = await stat(absolutePath).catch(() => null);
if (!info?.isFile()) return c.json({ error: "Not found" }, 404);
const size = info.size;
const headers: Record<string, string> = {
"content-type": mediaContentType(options.fileName),
"accept-ranges": "bytes",
"cache-control": "private, max-age=300",
};
if (options.download) {
headers["content-disposition"] = `attachment; filename="${options.fileName}"`;
}
const range = parseByteRange(c.req.header("range"), size);
if (range === "unsatisfiable") {
return new Response(null, {
status: 416,
headers: { ...headers, "content-range": `bytes */${size}` },
});
}
if (c.req.method === "HEAD") {
return new Response(null, {
status: 200,
headers: { ...headers, "content-length": String(size) },
});
}
const start = range?.start ?? 0;
const end = range?.end ?? size - 1;
const body =
size === 0
? null
: (Readable.toWeb(
createReadStream(absolutePath, { start, end }),
) as ReadableStream<Uint8Array>);
return new Response(body, {
status: range ? 206 : 200,
headers: {
...headers,
"content-length": String(size === 0 ? 0 : end - start + 1),
...(range ? { "content-range": `bytes ${start}-${end}/${size}` } : {}),
},
});
}
function mediaHttpError(c: Context, error: unknown): Response {
if (error instanceof IsolationError || error instanceof PathOutsideRootError) {
return c.json({ error: "Not found" }, 404);
}
if (error instanceof UploadChunkError) return c.json({ error: error.message }, error.status);
if (error instanceof MediaStateConflictError) return c.json({ error: error.message }, 409);
if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") {
return c.json({ error: "Not found" }, 404);
}
throw error;
}
export function mountMediaHttpRoutes(
app: Hono,
deps: {
/** Null when Media Studio is not configured: every route answers 404. */
services: MediaServices | null;
chunkTimeoutMs?: number;
},
resolveActor: (c: Context) => Promise<Actor | null>,
) {
async function authorize(
c: Context,
): Promise<
{ actor: Actor; services: MediaServices; response?: undefined } | { response: Response }
> {
const actor = await resolveActor(c);
if (!actor) return { response: c.json({ error: "Unauthorized" }, 401) };
if (!deps.services)
return { response: c.json({ error: "Media Studio is not configured" }, 404) };
return { actor, services: deps.services };
}
async function mediaFile(services: MediaServices, relative: string | null) {
if (!relative) return null;
await mkdir(services.deps.mediaRoot, { recursive: true });
return resolveInside(services.deps.mediaRoot, relative);
}
app.put("/api/media/uploads/:uploadId/chunks/:index", async (c) => {
const auth = await authorize(c);
if (auth.response) {
cancelBody(c.req.raw.body);
return auth.response;
}
const uploadId = c.req.param("uploadId");
const index = c.req.param("index");
if (!ID.test(uploadId) || !CHUNK_INDEX.test(index)) {
cancelBody(c.req.raw.body);
return c.json({ error: "Not found" }, 404);
}
const lengthHeader = c.req.header("content-length");
const declared = lengthHeader && /^[0-9]+$/.test(lengthHeader) ? Number(lengthHeader) : null;
const timeout = AbortSignal.timeout(deps.chunkTimeoutMs ?? MEDIA_CHUNK_TIMEOUT_MS);
const body = c.req.raw.body;
try {
const upload = await writeUploadChunk({
services: auth.services,
spaceId: auth.actor.spaceId,
uploadId,
index: Number(index),
body,
declaredLength: declared,
signal: AbortSignal.any([timeout, c.req.raw.signal]),
});
return c.json(upload);
} catch (error) {
cancelBody(body);
if (timeout.aborted) return c.json({ error: "Chunk took too long" }, 408);
return mediaHttpError(c, error);
}
});
app.get("/api/media/renders/:renderId/files/:fileName", async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const renderId = c.req.param("renderId");
const fileName = c.req.param("fileName");
if (!ID.test(renderId) || !FILE_NAME.test(fileName) || fileName.includes("..")) {
return c.json({ error: "Not found" }, 404);
}
try {
const render = await auth.services.deps.repos.getRenderRecord(auth.actor.spaceId, renderId);
const download = c.req.query("download") === "1";
// Only a render that passed its gate leaves the app as a download.
if (download && render.status !== "done") return c.json({ error: "Not found" }, 404);
const absolute = await auth.services.render.outputPath(auth.actor, renderId, fileName);
if (!absolute) return c.json({ error: "Not found" }, 404);
await mkdir(auth.services.deps.mediaRoot, { recursive: true });
const inside = await assertInside(
await resolveInside(
auth.services.deps.mediaRoot,
`${auth.actor.spaceId}/renders/${renderId}`,
),
absolute,
);
return await sendFile(c, inside, { fileName, download });
} catch (error) {
return mediaHttpError(c, error);
}
});
for (const kind of ["thumb", "proxy"] as const) {
app.get(`/api/media/assets/:assetId/${kind}`, async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const assetId = c.req.param("assetId");
if (!ID.test(assetId)) return c.json({ error: "Not found" }, 404);
try {
const asset = await auth.services.deps.repos.getAssetRecord(auth.actor.spaceId, assetId);
const relative = kind === "thumb" ? asset.thumbPath : asset.proxyPath;
const absolute = await mediaFile(auth.services, relative);
if (!absolute) return c.json({ error: "Not found" }, 404);
return await sendFile(c, absolute, { fileName: path.basename(absolute) });
} catch (error) {
return mediaHttpError(c, error);
}
});
}
app.get("/api/media/bundles/:publicationId", async (c) => {
const auth = await authorize(c);
if (auth.response) return auth.response;
const publicationId = c.req.param("publicationId");
if (!ID.test(publicationId)) return c.json({ error: "Not found" }, 404);
try {
const publication = await auth.services.deps.repos.getPublicationRecord(
auth.actor.spaceId,
publicationId,
);
if (publication.bundleArtifactId) return c.json({ error: "Not found" }, 404);
const absolute = await mediaFile(
auth.services,
`${auth.actor.spaceId}/bundles/${publication.id}/bundle.zip`,
);
if (!absolute) return c.json({ error: "Not found" }, 404);
return await sendFile(c, absolute, {
fileName: `bundle-${publication.id}.zip`,
download: true,
});
} catch (error) {
return mediaHttpError(c, error);
}
});
}

View File

@ -0,0 +1,387 @@
import type { MediaInferenceProvider } from "@rakazo/adapter-kit";
import type {
MediaActor,
MediaIngestService,
MediaMarketingService,
MediaPrivacyService,
MediaRenderService,
MediaServiceDeps,
MediaServices,
} from "@rakazo/adapters";
import type { PlatformProfile } from "@rakazo/contracts";
import { mediaRpcShapes } from "@rakazo/contracts";
import type { MediaRepos, PrismaClient } from "@rakazo/db";
/*
* In-memory stand-ins for the media service slices, for API tests only. They
* record what the router hands them and do the minimum through the real
* repositories; nothing here renders, detects, or publishes.
*/
export interface MediaServiceCall {
service: "ingest" | "privacy" | "render" | "marketing";
method: string;
actor: MediaActor;
input: unknown;
}
export interface FakeMediaServicesOptions {
repos: MediaRepos;
mediaRoot: string;
prisma?: PrismaClient;
inference?: MediaInferenceProvider | null;
profiles?: PlatformProfile[];
now?: () => Date;
/** Render id → absolute output path the fake render service reports. */
renderOutputs?: Map<string, string>;
onUploadCompleted?: (
actor: MediaActor,
input: { assetId: string; tempPath: string; sha256: string; fileName: string },
) => Promise<void>;
}
export function fakeMediaServices(options: FakeMediaServicesOptions): {
services: MediaServices;
calls: MediaServiceCall[];
} {
const calls: MediaServiceCall[] = [];
const { repos } = options;
const record = (
service: MediaServiceCall["service"],
method: string,
actor: MediaActor,
input: unknown,
) => {
calls.push({ service, method, actor: { spaceId: actor.spaceId, userId: actor.userId }, input });
};
const ingest: MediaIngestService = {
async pushItems(actor, input) {
record("ingest", "pushItems", actor, input);
return { created: 0, updated: 0, unchanged: 0, deleted: 0 };
},
async requestSync(actor, sourceId) {
record("ingest", "requestSync", actor, sourceId);
},
async uploadCompleted(actor, input) {
record("ingest", "uploadCompleted", actor, input);
await options.onUploadCompleted?.(actor, input);
},
async recluster(actor) {
record("ingest", "recluster", actor, null);
},
async removeSource(actor, sourceId) {
record("ingest", "removeSource", actor, sourceId);
await repos.removeSource(actor.spaceId, sourceId);
},
};
const privacy: MediaPrivacyService = {
async prepareSources() {
throw new Error("prepareSources is not part of the API fakes");
},
async gate() {
throw new Error("gate is not part of the API fakes");
},
async createMark(actor, input) {
record("privacy", "createMark", actor, input);
return repos.createMark(actor.spaceId, input);
},
async updateMark(actor, input) {
record("privacy", "updateMark", actor, input);
const { markId, ...patch } = input;
return repos.updateMark(actor.spaceId, markId, patch);
},
async removePerson(actor, personId) {
record("privacy", "removePerson", actor, personId);
await repos.removePersonCompletely(actor.spaceId, personId);
},
};
const render: MediaRenderService = {
async start(actor, input) {
record("render", "start", actor, input);
return repos.createRender(actor.spaceId, { ...input, createdBy: actor.userId });
},
async cancel(actor, renderId) {
record("render", "cancel", actor, renderId);
return repos.cancelRender(actor.spaceId, renderId);
},
async outputPath(actor, renderId, fileName) {
record("render", "outputPath", actor, { renderId, fileName });
const file = options.renderOutputs?.get(renderId);
return file?.endsWith(`/${fileName}`) ? file : null;
},
};
const marketing: MediaMarketingService = {
async autoPlan(actor, input) {
record("marketing", "autoPlan", actor, input);
return [];
},
async submitPlan(actor, input) {
record("marketing", "submitPlan", actor, input);
return { ok: false, errors: [] };
},
async introVariants(actor, input) {
record("marketing", "introVariants", actor, input);
return [];
},
async thumbnails(actor, input) {
record("marketing", "thumbnails", actor, input);
return [];
},
async copy(actor, input) {
record("marketing", "copy", actor, input);
const render = await repos.getRender(actor.spaceId, input.renderId);
return {
profileId: render.profileId,
language: input.language,
title: "Title",
hook: "Hook",
caption: "Caption",
hashtags: [],
issues: [],
};
},
async createPublication(actor, input) {
record("marketing", "createPublication", actor, input);
return repos.createPublication(actor.spaceId, actor.userId, {
...input,
trackingUrl: null,
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
});
},
async bundle(actor, publicationId) {
record("marketing", "bundle", actor, publicationId);
return repos.markPublicationBundled(actor.spaceId, publicationId, null);
},
async cancelPublication(actor, publicationId) {
record("marketing", "cancelPublication", actor, publicationId);
return repos.cancelPublication(actor.spaceId, publicationId);
},
async importMetricsCsv(actor, input) {
record("marketing", "importMetricsCsv", actor, input);
return { imported: 0, skipped: [] };
},
};
const deps = {
prisma: options.prisma,
repos,
jobs: { publish: async () => undefined },
inference: options.inference ?? null,
renderer: {
id: "fake",
hardwareEncoders: async () => ["h264_fake"],
render: async () => {
throw new Error("render is not part of the API fakes");
},
},
motion: { id: "fake", templates: () => [], render: async () => ({ path: "", hasAlpha: true }) },
publisher: { id: "fake", publish: async () => ({ status: "bundled", externalUrl: null }) },
shortener: null,
sources: {},
mediaRoot: options.mediaRoot,
profiles: async () => ({
profiles: new Map(
(options.profiles ?? []).map((profile) => [
profile.id,
{ profile, origin: "builtin" as const },
]),
),
errors: [],
}),
artifacts: {},
model: { generate: async () => null },
publish: async () => undefined,
now: options.now ?? (() => new Date()),
} as unknown as MediaServiceDeps;
return { services: { deps, ingest, privacy, render, marketing }, calls };
}
/** Every RPC path of the media contract, e.g. "sources/list". */
export function mediaProcedurePaths(): string[] {
const paths: string[] = [];
const walk = (node: Record<string, unknown>, prefix: string[]) => {
for (const [key, value] of Object.entries(node)) {
const child = value as Record<string, unknown>;
if ("input" in child && "output" in child) paths.push([...prefix, key].join("/"));
else walk(child, [...prefix, key]);
}
};
walk(mediaRpcShapes, []);
return paths;
}
export interface MediaSampleIds {
sourceId: string;
assetId: string;
secondAssetId: string;
segmentId: string;
trackId: string;
personId: string;
markId: string;
collectionId: string;
brandKitId: string;
projectId: string;
planId: string;
renderId: string;
publicationId: string;
mappingId: string;
uploadId: string;
}
/**
* How a procedure must treat another space's ids: refuse them as not found,
* answer only from the caller's own space, or be covered by a dedicated test.
*/
export type ForeignExpectation = "not_found" | "scoped" | "dedicated";
/** A valid input for every media procedure, built from one space's ids. */
export function sampleMediaInputs(
ids: MediaSampleIds,
kit: unknown,
): Record<string, { input: unknown; foreign: ForeignExpectation }> {
const at = "2026-09-01T10:00:00.000Z";
return {
capabilities: { input: {}, foreign: "scoped" },
"sources/list": { input: {}, foreign: "scoped" },
"sources/createFolder": { input: { label: "Footage", path: "/" }, foreign: "dedicated" },
"sources/registerClient": {
input: { kind: "ios_library", label: "Phone", deviceId: "device-sample" },
foreign: "scoped",
},
"sources/pushItems": {
input: { sourceId: ids.sourceId, items: [], syncToken: null, complete: true },
foreign: "not_found",
},
"sources/sync": { input: { sourceId: ids.sourceId }, foreign: "not_found" },
"sources/remove": { input: { sourceId: ids.sourceId }, foreign: "not_found" },
"assets/search": { input: {}, foreign: "scoped" },
"assets/get": { input: { assetId: ids.assetId }, foreign: "not_found" },
"assets/setPaidOnly": {
input: { segmentIds: [ids.segmentId], paidOnly: true },
foreign: "not_found",
},
"assets/requestUpload": { input: { assetIds: [ids.assetId] }, foreign: "not_found" },
"uploads/begin": {
input: {
assetId: ids.secondAssetId,
sizeBytes: 10,
sha256: "a".repeat(64),
fileName: "clip.mp4",
},
foreign: "not_found",
},
"uploads/status": { input: { uploadId: ids.uploadId }, foreign: "not_found" },
"uploads/complete": { input: { uploadId: ids.uploadId }, foreign: "not_found" },
"collections/list": { input: {}, foreign: "scoped" },
"collections/get": { input: { collectionId: ids.collectionId }, foreign: "not_found" },
"collections/rename": {
input: { collectionId: ids.collectionId, title: "Renamed" },
foreign: "not_found",
},
"collections/merge": {
input: { collectionIds: [ids.collectionId, `${ids.collectionId}x`] },
foreign: "not_found",
},
"collections/split": {
input: { collectionId: ids.collectionId, atAssetId: ids.assetId },
foreign: "not_found",
},
"collections/createManual": {
input: { title: "Picks", assetIds: [ids.assetId] },
foreign: "not_found",
},
"collections/recluster": { input: {}, foreign: "scoped" },
"people/list": { input: {}, foreign: "scoped" },
"people/update": { input: { personId: ids.personId, displayName: "X" }, foreign: "not_found" },
"people/merge": {
input: { personIds: [ids.personId, `${ids.personId}x`] },
foreign: "not_found",
},
"people/assignTrack": { input: { trackId: ids.trackId, personId: null }, foreign: "not_found" },
"people/recordConsent": {
input: {
personId: ids.personId,
consentArtifactId: null,
consentRecordedAt: at,
ageVerificationMethod: "platform_verified",
ageVerifiedAt: at,
},
foreign: "not_found",
},
"people/remove": { input: { personId: ids.personId }, foreign: "not_found" },
"marks/list": { input: { assetId: ids.assetId }, foreign: "scoped" },
"marks/create": {
input: { kind: "tattoo", assetId: ids.assetId, prompt: { tMs: 0 }, treatment: "blur" },
foreign: "not_found",
},
"marks/update": { input: { markId: ids.markId, label: "x" }, foreign: "not_found" },
"marks/remove": { input: { markId: ids.markId }, foreign: "not_found" },
"brandKits/list": { input: {}, foreign: "scoped" },
"brandKits/upsert": { input: { id: ids.brandKitId, kit }, foreign: "not_found" },
"brandKits/remove": { input: { id: ids.brandKitId }, foreign: "not_found" },
"brandKits/templates": { input: {}, foreign: "scoped" },
"brandKits/introVariants": { input: { id: ids.brandKitId }, foreign: "not_found" },
"profiles/list": { input: {}, foreign: "scoped" },
"profiles/get": { input: { profileId: "example-short-vertical" }, foreign: "scoped" },
"projects/list": { input: {}, foreign: "scoped" },
"projects/get": { input: { projectId: ids.projectId }, foreign: "not_found" },
"projects/create": {
input: {
title: "Teaser",
goal: "teaser",
collectionIds: [ids.collectionId],
profileIds: ["example-short-vertical"],
},
foreign: "not_found",
},
"projects/update": { input: { projectId: ids.projectId, title: "X" }, foreign: "not_found" },
"projects/remove": { input: { projectId: ids.projectId }, foreign: "not_found" },
"projects/autoPlan": { input: { projectId: ids.projectId }, foreign: "not_found" },
"plans/submit": { input: { projectId: ids.projectId, edl: {} }, foreign: "not_found" },
"renders/start": { input: { planId: ids.planId, quality: "draft" }, foreign: "not_found" },
"renders/get": { input: { renderId: ids.renderId }, foreign: "not_found" },
"renders/cancel": { input: { renderId: ids.renderId }, foreign: "not_found" },
"renders/thumbnails": { input: { renderId: ids.renderId }, foreign: "not_found" },
"marketing/copy": { input: { renderId: ids.renderId }, foreign: "not_found" },
"publications/list": { input: { projectId: ids.projectId }, foreign: "scoped" },
"publications/create": { input: { renderId: ids.renderId }, foreign: "not_found" },
"publications/bundle": { input: { publicationId: ids.publicationId }, foreign: "not_found" },
"publications/markPublished": {
input: { publicationId: ids.publicationId, externalUrl: null },
foreign: "not_found",
},
"publications/cancel": { input: { publicationId: ids.publicationId }, foreign: "not_found" },
"metrics/mappings": { input: {}, foreign: "scoped" },
"metrics/saveMapping": {
input: {
id: ids.mappingId,
name: "Imported",
columns: { publication: "id", observedAt: "date" },
publicationKey: "id",
},
foreign: "not_found",
},
"metrics/importCsv": {
input: { mappingId: ids.mappingId, csv: "id,date" },
foreign: "not_found",
},
"metrics/record": {
input: {
publicationId: ids.publicationId,
observedAt: at,
views: 1,
clicks: 0,
conversions: 0,
revenueCents: 0,
currency: "EUR",
},
foreign: "not_found",
},
"metrics/funnel": { input: { projectId: ids.projectId }, foreign: "not_found" },
};
}

View File

@ -0,0 +1,244 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { mkdir, open, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import type { MediaActor, MediaServices } from "@rakazo/adapters";
import type { MediaUpload } from "@rakazo/contracts";
import type { MediaRepos } from "@rakazo/db";
import { MediaStateConflictError } from "@rakazo/db";
import { resolveInside } from "@rakazo/media/node";
/*
* Resumable chunked uploads. Each chunk lands in its own file
* (<spaceId>/uploads/<uploadId>/chunks/<index>.part, written to a temp name and
* renamed, so a repeated or concurrent PUT of the same index is harmless).
* Completion concatenates the chunks in order into <spaceId>/uploads/<uploadId>/file
* while hashing, verifies size and SHA-256, and hands the file to ingest.
*
* Limits: the contract caps the total size, every chunk must have its exact
* length, one chunk must arrive within MEDIA_CHUNK_TIMEOUT_MS, and an upload
* that saw no progress for MEDIA_UPLOAD_IDLE_MS expires and must start over.
*/
/** A single chunk must arrive within this window; slow links resume per chunk. */
export const MEDIA_CHUNK_TIMEOUT_MS = 10 * 60_000;
/** An open upload without a new chunk for this long is failed and its bytes removed. */
export const MEDIA_UPLOAD_IDLE_MS = 24 * 60 * 60_000;
export class UploadChunkError extends Error {
constructor(
readonly status: 400 | 409 | 413,
message: string,
) {
super(message);
this.name = "UploadChunkError";
}
}
type UploadRow = Awaited<ReturnType<MediaRepos["getUploadRecord"]>>;
type UploadSize = Pick<UploadRow, "sizeBytes" | "chunkBytes">;
export function uploadChunkCount(row: UploadSize): number {
return Number((row.sizeBytes + BigInt(row.chunkBytes) - 1n) / BigInt(row.chunkBytes));
}
/** Exact byte length chunk `index` must have: every chunk is full except the last. */
export function expectedChunkBytes(row: UploadSize, index: number): number {
const count = uploadChunkCount(row);
if (index < count - 1) return row.chunkBytes;
return Number(row.sizeBytes - BigInt(row.chunkBytes) * BigInt(count - 1));
}
function uploadDir(row: Pick<UploadRow, "spaceId" | "id">): string {
return `${row.spaceId}/uploads/${row.id}`;
}
async function mediaPath(mediaRoot: string, relative: string): Promise<string> {
await mkdir(mediaRoot, { recursive: true });
return resolveInside(mediaRoot, relative);
}
async function chunksDir(mediaRoot: string, row: UploadRow): Promise<string> {
const dir = await mediaPath(mediaRoot, `${uploadDir(row)}/chunks`);
await mkdir(dir, { recursive: true });
return dir;
}
async function removeUploadFiles(mediaRoot: string, row: UploadRow): Promise<void> {
await rm(await mediaPath(mediaRoot, uploadDir(row)), { recursive: true, force: true });
}
function isIdle(services: MediaServices, row: UploadRow): boolean {
return services.deps.now().getTime() - row.updatedAt.getTime() > MEDIA_UPLOAD_IDLE_MS;
}
/** Fail an idle open upload and drop its bytes. Returns true when it expired. */
async function expireIfIdle(services: MediaServices, row: UploadRow): Promise<boolean> {
if (row.status !== "open" || !isIdle(services, row)) return false;
await services.deps.repos.failUpload(row.spaceId, row.id).catch((error: unknown) => {
// A concurrent request already closed it; the files still go.
if (!(error instanceof MediaStateConflictError)) throw error;
});
await removeUploadFiles(services.deps.mediaRoot, row);
return true;
}
/**
* Open an upload, or resume the open one for the same asset and content. An
* idle one expires first so the client starts over with a fresh upload.
*/
export async function beginMediaUpload(
services: MediaServices,
actor: MediaActor,
input: { assetId: string; sizeBytes: number; sha256: string; fileName: string },
): Promise<MediaUpload> {
const { repos } = services.deps;
const asset = await repos.getAssetRecord(actor.spaceId, input.assetId);
// Device items wait for their original; files read from a source never upload.
if (asset.storage !== "remote" && asset.ingestState !== "awaiting_upload") {
throw new MediaStateConflictError("This item does not need an upload");
}
const begin = () =>
repos.beginUpload(actor.spaceId, { ...input, assetId: asset.id, userId: actor.userId });
const upload = await begin();
const row = await repos.getUploadRecord(actor.spaceId, upload.id);
return (await expireIfIdle(services, row)) ? begin() : upload;
}
/**
* Stream one chunk body to disk without buffering more than one network read.
* Rejects bodies longer or shorter than the chunk's exact size.
*/
export async function writeUploadChunk(input: {
services: MediaServices;
spaceId: string;
uploadId: string;
index: number;
body: ReadableStream<Uint8Array> | null;
declaredLength: number | null;
/** Aborting stops reading the body; the chunk is then discarded as incomplete. */
signal?: AbortSignal;
}): Promise<MediaUpload> {
const { services } = input;
const { repos, mediaRoot } = services.deps;
const row = await repos.getUploadRecord(input.spaceId, input.uploadId);
if (await expireIfIdle(services, row)) throw new UploadChunkError(409, "Upload expired");
if (row.status !== "open") throw new UploadChunkError(409, "Upload is not open");
const count = uploadChunkCount(row);
if (!Number.isInteger(input.index) || input.index < 0 || input.index >= count) {
throw new UploadChunkError(400, "Chunk index out of range");
}
const expected = expectedChunkBytes(row, input.index);
if (input.declaredLength !== null && input.declaredLength > expected) {
throw new UploadChunkError(413, "Chunk is too large");
}
if (!input.body) throw new UploadChunkError(400, "Chunk is empty");
const dir = await chunksDir(mediaRoot, row);
const finalPath = path.join(dir, `${input.index}.part`);
const tempPath = path.join(dir, `${input.index}.${randomUUID()}.tmp`);
const handle = await open(tempPath, "wx");
let written = 0;
let ok = false;
const reader = input.body.getReader();
const stop = () => void reader.cancel().catch(() => undefined);
input.signal?.addEventListener("abort", stop, { once: true });
try {
if (input.signal?.aborted) stop();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
written += value.byteLength;
if (written > expected) {
void reader.cancel().catch(() => undefined);
throw new UploadChunkError(413, "Chunk is too large");
}
await handle.write(value);
}
if (written !== expected) throw new UploadChunkError(400, "Chunk is incomplete");
ok = true;
} finally {
input.signal?.removeEventListener("abort", stop);
reader.releaseLock();
await handle.close();
if (!ok) await rm(tempPath, { force: true });
}
await rename(tempPath, finalPath);
return repos.markChunkReceived(input.spaceId, input.uploadId, input.index);
}
const completing = new Set<string>();
/**
* Assemble a fully received upload, verify it, hand it to ingest, and close it.
* A checksum mismatch fails the upload so the client starts a fresh one.
*/
export async function completeMediaUpload(
services: MediaServices,
actor: MediaActor,
uploadId: string,
): Promise<MediaUpload> {
const { repos, mediaRoot } = services.deps;
const row = await repos.getUploadRecord(actor.spaceId, uploadId);
if (row.status === "complete") return repos.uploadStatus(actor.spaceId, uploadId);
if (await expireIfIdle(services, row)) throw new UploadChunkError(409, "Upload expired");
if (row.status !== "open") throw new MediaStateConflictError("Upload is not open");
const received = new Set(row.receivedChunks);
const count = uploadChunkCount(row);
for (let index = 0; index < count; index++) {
if (!received.has(index)) throw new MediaStateConflictError("Upload is missing chunks");
}
if (completing.has(row.id)) throw new MediaStateConflictError("Upload is already completing");
completing.add(row.id);
try {
const dir = await chunksDir(mediaRoot, row);
const relativeFile = `${uploadDir(row)}/file`;
const filePath = await mediaPath(mediaRoot, relativeFile);
const tempFile = await mediaPath(mediaRoot, `${uploadDir(row)}/file.${randomUUID()}.tmp`);
const hash = createHash("sha256");
let total = 0;
const out = await open(tempFile, "wx");
try {
for (let index = 0; index < count; index++) {
const chunkPath = path.join(dir, `${index}.part`);
const info = await stat(chunkPath).catch(() => null);
if (!info || info.size !== expectedChunkBytes(row, index)) {
throw new MediaStateConflictError("Upload is missing chunks");
}
for await (const buffer of createReadStream(chunkPath) as AsyncIterable<Buffer>) {
hash.update(buffer);
total += buffer.byteLength;
await out.write(buffer);
}
}
} catch (error) {
await out.close();
await rm(tempFile, { force: true });
throw error;
}
await out.close();
const sha256 = hash.digest("hex");
if (sha256 !== row.sha256 || BigInt(total) !== row.sizeBytes) {
await removeUploadFiles(mediaRoot, row);
await repos.failUpload(actor.spaceId, uploadId);
throw new UploadChunkError(400, "Upload does not match its checksum");
}
await rename(tempFile, filePath);
await rm(dir, { recursive: true, force: true });
await services.ingest.uploadCompleted(actor, {
assetId: row.assetId,
tempPath: filePath,
sha256,
fileName: row.fileName,
});
// Ingest normally moves the file into the asset folder and closes the
// upload itself; otherwise the assembled file stays as the original.
const after = await repos.getUploadRecord(actor.spaceId, uploadId);
if (after.status === "complete") {
await removeUploadFiles(mediaRoot, row);
return repos.uploadStatus(actor.spaceId, uploadId);
}
return repos.completeUpload(actor.spaceId, uploadId, { storagePath: relativeFile });
} finally {
completing.delete(row.id);
}
}

View File

@ -0,0 +1,516 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRouterClient, ORPCError } from "@orpc/server";
import type { Actor } from "@rakazo/contracts";
import type { MediaRepos } from "@rakazo/db";
import { createMediaRepos } from "@rakazo/db";
import { Hono } from "hono";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { MediaTestDb, SeededMediaSpace } from "../../../packages/db/src/media-fixtures.js";
import {
openMediaTestDb,
seedMediaSpace,
sourceItem,
testBrandKit,
} from "../../../packages/db/src/media-fixtures.js";
import { testProfile } from "../../../packages/media/src/test-fixtures.js";
import { createMediaRouter } from "./media.js";
import { mountMediaHttpRoutes } from "./media-http.js";
import type { MediaServiceCall } from "./media-test-fakes.js";
import { fakeMediaServices, sampleMediaInputs } from "./media-test-fakes.js";
import { MEDIA_UPLOAD_IDLE_MS } from "./media-uploads.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
type Space = { userId: string; spaceId: string };
function actorOf(space: Space, isDeploymentOwner = false): Actor {
return {
userId: space.userId,
spaceId: space.spaceId,
email: `${space.userId}@rakazo.test`,
isDeploymentOwner,
};
}
function procedure(client: unknown, rpcPath: string) {
let node = client;
for (const key of rpcPath.split("/")) node = (node as Record<string, unknown>)[key];
return node as (input: unknown) => Promise<unknown>;
}
async function rpcCode(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof ORPCError) return error.code;
throw error;
}
return "OK";
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
describePostgres("media API authorization and uploads (PostgreSQL)", () => {
let db: MediaTestDb;
let repos: MediaRepos;
let mediaRoot: string;
let a: Space;
let b: Space;
let seededA: SeededMediaSpace;
let seededB: SeededMediaSpace;
let calls: MediaServiceCall[];
let renderOutputs: Map<string, string>;
let rpc: (actor: Actor) => unknown;
let app: Hono;
let currentActor: Actor | null;
function setup(options: { now?: () => Date; chunkTimeoutMs?: number } = {}) {
const fake = fakeMediaServices({
repos,
mediaRoot,
prisma: db.prisma,
profiles: [testProfile()],
renderOutputs,
...(options.now ? { now: options.now } : {}),
});
calls = fake.calls;
const router = createMediaRouter({
prisma: db.prisma,
services: fake.services,
dataDir: path.join(mediaRoot, ".."),
});
rpc = (actor) => createRouterClient(router, { context: { actor } });
app = new Hono();
mountMediaHttpRoutes(
app,
{
services: fake.services,
...(options.chunkTimeoutMs ? { chunkTimeoutMs: options.chunkTimeoutMs } : {}),
},
async () => currentActor,
);
}
async function snapshot(spaceId: string) {
const p = db.prisma;
const where = { spaceId };
const orderBy = { id: "asc" as const };
return JSON.stringify(
[
await p.mediaSource.findMany({ where, orderBy }),
await p.mediaAsset.findMany({ where, orderBy }),
await p.mediaUpload.findMany({ where, orderBy }),
await p.mediaSegment.findMany({ where, orderBy }),
await p.mediaAnalysis.findMany({ where, orderBy }),
await p.mediaCollection.findMany({ where, orderBy }),
await p.mediaCollectionItem.findMany({
where: { collection: where },
orderBy: [{ collectionId: "asc" }, { assetId: "asc" }],
}),
await p.mediaPerson.findMany({ where, orderBy }),
await p.mediaFaceTrack.findMany({ where, orderBy }),
await p.mediaMark.findMany({ where, orderBy }),
await p.mediaBrandKit.findMany({ where, orderBy }),
await p.mediaProject.findMany({ where, orderBy }),
await p.mediaEditPlan.findMany({ where, orderBy }),
await p.mediaRender.findMany({ where, orderBy }),
await p.mediaPublication.findMany({ where, orderBy }),
await p.mediaMetric.findMany({ where, orderBy }),
await p.mediaMetricMapping.findMany({ where, orderBy }),
],
(_key, value) => (typeof value === "bigint" ? value.toString() : value),
);
}
async function artifactIn(space: Space): Promise<string> {
const artifact = await db.prisma.artifact.create({
data: {
spaceId: space.spaceId,
userId: space.userId,
name: "consent.pdf",
mimeType: "application/pdf",
size: 1,
hash: "0".repeat(64),
storageKey: `test/${space.spaceId}/consent.pdf`,
},
});
return artifact.id;
}
/** A device item in `space` that waits for its original. */
async function remoteAsset(space: Space, index: number): Promise<string> {
const source = await procedure(
rpc(actorOf(space)),
"sources/registerClient",
)({ kind: "ios_library", label: "Phone", deviceId: `device-${index}` });
const synced = await repos.upsertSourceItems(space.spaceId, (source as { id: string }).id, [
sourceItem(index),
]);
const assetId = synced.changedAssetIds[0] as string;
await repos.requestUpload(space.spaceId, [assetId]);
return assetId;
}
function putChunk(uploadId: string, index: number, body: BodyInit, actor: Actor | null) {
currentActor = actor;
return app.request(`/api/media/uploads/${uploadId}/chunks/${index}`, { method: "PUT", body });
}
beforeAll(async () => {
db = openMediaTestDb(databaseUrl!);
repos = createMediaRepos(db.prisma);
const root = await mkdtemp(path.join(tmpdir(), "media-api-pg-"));
mediaRoot = path.join(root, "media");
await mkdir(mediaRoot);
renderOutputs = new Map();
a = await db.newUser();
b = await db.newUser();
seededA = await seedMediaSpace(repos, a.spaceId, a.userId);
seededB = await seedMediaSpace(repos, b.spaceId, b.userId);
setup();
});
afterAll(async () => {
await db?.cleanup();
if (mediaRoot) await rm(path.dirname(mediaRoot), { recursive: true, force: true });
});
it("refuses another space's ids in every media procedure and leaves that space untouched", async () => {
const before = await snapshot(a.spaceId);
const foreignIds = Object.values(seededA);
const intruder = rpc(actorOf(b));
const inputs = sampleMediaInputs(seededA, testBrandKit);
for (const [rpcPath, sample] of Object.entries(inputs)) {
if (sample.foreign === "dedicated") continue;
const call = procedure(intruder, rpcPath)(sample.input);
if (sample.foreign === "not_found") {
expect(await rpcCode(call), rpcPath).toBe("NOT_FOUND");
continue;
}
const result = JSON.stringify(await call);
for (const id of foreignIds) expect(result, rpcPath).not.toContain(id);
}
expect(await snapshot(a.spaceId)).toBe(before);
// No service ever received the other space's ids.
const leaked = calls.filter((call) =>
foreignIds.some((id) => JSON.stringify(call.input ?? null).includes(id)),
);
expect(leaked).toEqual([]);
expect(calls.every((call) => call.actor.spaceId === b.spaceId)).toBe(true);
});
it("serves the same inputs inside the caller's own space", async () => {
// Positive control: NOT_FOUND above comes from isolation, not from bad samples.
const fabricated = new Set(["collections/merge", "people/merge"]);
for (const [rpcPath, sample] of Object.entries(sampleMediaInputs(seededA, testBrandKit))) {
if (sample.foreign !== "not_found" || fabricated.has(rpcPath)) continue;
const space = await db.newUser();
const seeded = await seedMediaSpace(repos, space.spaceId, space.userId);
const input = sampleMediaInputs(seeded, testBrandKit)[rpcPath]?.input;
const code = await rpcCode(procedure(rpc(actorOf(space)), rpcPath)(input));
expect(code, rpcPath).not.toBe("NOT_FOUND");
}
}, 120_000);
it("refuses another space's records referenced from the caller's own ones", async () => {
const own = rpc(actorOf(b));
const foreignArtifact = await artifactIn(a);
const attempts: Array<[string, unknown]> = [
["projects/update", { projectId: seededB.projectId, collectionIds: [seededA.collectionId] }],
["projects/update", { projectId: seededB.projectId, brandKitId: seededA.brandKitId }],
[
"projects/create",
{
title: "Mixed",
goal: "teaser",
assetIds: [seededA.assetId],
profileIds: ["example-short-vertical"],
},
],
["people/assignTrack", { trackId: seededB.trackId, personId: seededA.personId }],
[
"marks/create",
{
kind: "tattoo",
assetId: seededB.assetId,
personId: seededA.personId,
prompt: { tMs: 0 },
treatment: "blur",
},
],
["marks/update", { markId: seededB.markId, personId: seededA.personId }],
[
"assets/setPaidOnly",
{ segmentIds: [seededB.segmentId, seededA.segmentId], paidOnly: true },
],
["collections/merge", { collectionIds: [seededB.collectionId, seededA.collectionId] }],
["people/merge", { personIds: [seededB.personId, seededA.personId] }],
["brandKits/upsert", { kit: { ...testBrandKit, logoArtifactIds: [foreignArtifact] } }],
[
"people/recordConsent",
{
personId: seededB.personId,
consentArtifactId: foreignArtifact,
consentRecordedAt: "2026-09-01T10:00:00.000Z",
ageVerificationMethod: "document_on_file",
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
},
],
];
const before = await snapshot(a.spaceId);
for (const [rpcPath, input] of attempts) {
expect(await rpcCode(procedure(own, rpcPath)(input)), rpcPath).toBe("NOT_FOUND");
}
expect(await snapshot(a.spaceId)).toBe(before);
const person = await db.prisma.mediaPerson.findUniqueOrThrow({
where: { id: seededB.personId },
});
expect(person.consentRecordedAt).toBeNull();
});
it("records consent only as the signed-in member and ignores it on updates", async () => {
const space = await db.newUser();
const seeded = await seedMediaSpace(repos, space.spaceId, space.userId);
const client = rpc(actorOf(space));
await procedure(
client,
"people/update",
)({
personId: seeded.personId,
displayName: "Person 1",
consentRecordedAt: "2026-09-01T10:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
});
let person = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: seeded.personId } });
expect(person.consentRecordedAt).toBeNull();
expect(person.ageVerifiedAt).toBeNull();
const artifactId = await artifactIn(space);
await procedure(
client,
"people/recordConsent",
)({
personId: seeded.personId,
consentArtifactId: artifactId,
consentRecordedAt: "2026-09-01T10:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T10:00:00.000Z",
});
person = await db.prisma.mediaPerson.findUniqueOrThrow({ where: { id: seeded.personId } });
expect(person.consentRecordedBy).toBe(space.userId);
expect(person.consentArtifactId).toBe(artifactId);
expect(person.ageVerificationMethod).toBe("platform_verified");
});
it("assembles a resumable chunked upload and hands it to ingest", async () => {
const assetId = await remoteAsset(b, 101);
const bytes = new TextEncoder().encode("0123456789");
const upload = await repos.beginUpload(b.spaceId, {
assetId,
userId: b.userId,
sizeBytes: bytes.length,
sha256: sha256(bytes),
fileName: "clip.mp4",
chunkBytes: 4,
});
const owner = actorOf(b);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), actorOf(a))).status).toBe(404);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), null)).status).toBe(401);
expect((await putChunk(upload.id, 3, bytes.slice(0, 2), owner)).status).toBe(400);
expect((await putChunk(upload.id, 0, bytes.slice(0, 5), owner)).status).toBe(413);
expect((await putChunk(upload.id, 0, bytes.slice(0, 3), owner)).status).toBe(400);
// Out of order and repeated chunks are fine; the client resumes from status.
expect((await putChunk(upload.id, 2, bytes.slice(8), owner)).status).toBe(200);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(200);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(200);
const client = rpc(owner);
const partial = (await procedure(client, "uploads/status")({ uploadId: upload.id })) as {
receivedChunks: number[];
};
expect([...partial.receivedChunks].sort()).toEqual([0, 2]);
expect(await rpcCode(procedure(client, "uploads/complete")({ uploadId: upload.id }))).toBe(
"CONFLICT",
);
expect((await putChunk(upload.id, 1, bytes.slice(4, 8), owner)).status).toBe(200);
calls.length = 0;
const done = (await procedure(client, "uploads/complete")({ uploadId: upload.id })) as {
status: string;
};
expect(done.status).toBe("complete");
const handed = calls.filter((call) => call.method === "uploadCompleted");
expect(handed).toHaveLength(1);
const input = handed[0]?.input as { tempPath: string; sha256: string; assetId: string };
expect(input.assetId).toBe(assetId);
expect(input.sha256).toBe(sha256(bytes));
expect(input.tempPath.startsWith(path.join(mediaRoot, b.spaceId, "uploads"))).toBe(true);
expect(await readFile(input.tempPath, "utf8")).toBe("0123456789");
const asset = await repos.getAssetRecord(b.spaceId, assetId);
expect(asset.storage).toBe("uploaded");
expect(asset.storagePath).toBe(`${b.spaceId}/uploads/${upload.id}/file`);
// Completing again is idempotent and does not hand the file over twice.
await procedure(client, "uploads/complete")({ uploadId: upload.id });
expect(calls.filter((call) => call.method === "uploadCompleted")).toHaveLength(1);
expect((await putChunk(upload.id, 0, bytes.slice(0, 4), owner)).status).toBe(409);
});
it("opens uploads through the RPC only for items that wait for their original", async () => {
const client = rpc(actorOf(b));
const assetId = await remoteAsset(b, 102);
const input = { assetId, sizeBytes: 10, sha256: "a".repeat(64), fileName: "clip.mp4" };
const first = (await procedure(client, "uploads/begin")(input)) as { id: string };
const resumed = (await procedure(client, "uploads/begin")(input)) as { id: string };
expect(resumed.id).toBe(first.id);
const referenced = { ...input, assetId: seededB.assetId };
expect(await rpcCode(procedure(client, "uploads/begin")(referenced))).toBe("CONFLICT");
const tooLarge = { ...input, sizeBytes: 21 * 1024 * 1024 * 1024 };
expect(await rpcCode(procedure(client, "uploads/begin")(tooLarge))).toBe("BAD_REQUEST");
});
it("fails an upload whose bytes do not match the announced checksum", async () => {
const assetId = await remoteAsset(b, 103);
const bytes = new TextEncoder().encode("abcdef");
const upload = await repos.beginUpload(b.spaceId, {
assetId,
userId: b.userId,
sizeBytes: bytes.length,
sha256: "f".repeat(64),
fileName: "clip.mp4",
chunkBytes: 4,
});
const owner = actorOf(b);
await putChunk(upload.id, 0, bytes.slice(0, 4), owner);
await putChunk(upload.id, 1, bytes.slice(4), owner);
const client = rpc(owner);
expect(await rpcCode(procedure(client, "uploads/complete")({ uploadId: upload.id }))).toBe(
"BAD_REQUEST",
);
const status = (await procedure(client, "uploads/status")({ uploadId: upload.id })) as {
status: string;
};
expect(status.status).toBe("failed");
const dir = path.join(mediaRoot, b.spaceId, "uploads", upload.id);
await expect(stat(dir)).rejects.toMatchObject({ code: "ENOENT" });
});
it("expires an idle upload and starts a fresh one", async () => {
const assetId = await remoteAsset(b, 104);
const input = { assetId, sizeBytes: 10, sha256: "b".repeat(64), fileName: "clip.mp4" };
const first = (await procedure(rpc(actorOf(b)), "uploads/begin")(input)) as { id: string };
setup({ now: () => new Date(Date.now() + MEDIA_UPLOAD_IDLE_MS + 60_000) });
try {
const response = await putChunk(first.id, 0, new Uint8Array(10), actorOf(b));
expect(response.status).toBe(409);
const status = (await procedure(
rpc(actorOf(b)),
"uploads/status",
)({
uploadId: first.id,
})) as { status: string };
expect(status.status).toBe("failed");
const fresh = (await procedure(rpc(actorOf(b)), "uploads/begin")(input)) as { id: string };
expect(fresh.id).not.toBe(first.id);
} finally {
setup();
}
});
it("drops a chunk that does not arrive in time", async () => {
const assetId = await remoteAsset(b, 105);
const upload = await repos.beginUpload(b.spaceId, {
assetId,
userId: b.userId,
sizeBytes: 10,
sha256: "c".repeat(64),
fileName: "clip.mp4",
});
setup({ chunkTimeoutMs: 50 });
try {
const stalled = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(2));
},
});
currentActor = actorOf(b);
const response = await app.request(`/api/media/uploads/${upload.id}/chunks/0`, {
method: "PUT",
body: stalled,
duplex: "half",
} as RequestInit);
expect(response.status).toBe(408);
const status = await repos.uploadStatus(b.spaceId, upload.id);
expect(status.receivedChunks).toEqual([]);
} finally {
setup();
}
});
it("streams render files only inside the caller's space and render folder", async () => {
const renderDir = path.join(mediaRoot, a.spaceId, "renders", seededA.renderId);
await mkdir(renderDir, { recursive: true });
const video = path.join(renderDir, "video.mp4");
await writeFile(video, "0123456789");
renderOutputs.set(seededA.renderId, video);
const url = `/api/media/renders/${seededA.renderId}/files/video.mp4`;
currentActor = actorOf(a);
const full = await app.request(url);
expect(full.status).toBe(200);
expect(await full.text()).toBe("0123456789");
const ranged = await app.request(url, { headers: { range: "bytes=2-4" } });
expect(ranged.status).toBe(206);
expect(ranged.headers.get("content-range")).toBe("bytes 2-4/10");
expect(await ranged.text()).toBe("234");
const download = await app.request(`${url}?download=1`);
expect(download.headers.get("content-disposition")).toContain("attachment");
// A render that has not passed its gate never leaves as a download.
const queuedVideo = path.join(mediaRoot, a.spaceId, "renders", seededA.queuedRenderId, "v.mp4");
await mkdir(path.dirname(queuedVideo), { recursive: true });
await writeFile(queuedVideo, "x");
renderOutputs.set(seededA.queuedRenderId, queuedVideo);
const queuedUrl = `/api/media/renders/${seededA.queuedRenderId}/files/v.mp4`;
expect((await app.request(queuedUrl)).status).toBe(200);
expect((await app.request(`${queuedUrl}?download=1`)).status).toBe(404);
// A path the service reports outside the render folder is refused.
const outside = path.join(mediaRoot, a.spaceId, "video.mp4");
await writeFile(outside, "secret");
renderOutputs.set(seededA.renderId, outside);
expect((await app.request(url)).status).toBe(404);
renderOutputs.set(seededA.renderId, video);
currentActor = actorOf(b);
expect((await app.request(url)).status).toBe(404);
expect((await app.request(`/api/media/assets/${seededA.assetId}/thumb`)).status).toBe(404);
expect((await app.request(`/api/media/bundles/${seededA.publicationId}`)).status).toBe(404);
});
it("refuses a folder source to members who do not own the deployment", async () => {
const folder = await mkdtemp(path.join(tmpdir(), "media-footage-"));
try {
const input = { label: "Footage", path: folder };
const member = procedure(rpc(actorOf(b)), "sources/createFolder")(input);
expect(await rpcCode(member)).toBe("FORBIDDEN");
const created = (await procedure(rpc(actorOf(b, true)), "sources/createFolder")(input)) as {
id: string;
kind: string;
};
expect(created.kind).toBe("folder");
const row = await db.prisma.mediaSource.findUniqueOrThrow({ where: { id: created.id } });
expect(row.ownerUserId).toBe(b.userId);
} finally {
await rm(folder, { recursive: true, force: true });
}
});
});

333
apps/api/src/media.test.ts Normal file
View File

@ -0,0 +1,333 @@
import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createRouterClient, ORPCError } from "@orpc/server";
import type { Actor } from "@rakazo/contracts";
import { MediaToolInputSchemas, mediaRpcShapes } from "@rakazo/contracts";
import type { MediaRepos, PrismaClient } from "@rakazo/db";
import { InvalidMediaInputError, IsolationError, MediaStateConflictError } from "@rakazo/db";
import { PathOutsideRootError } from "@rakazo/media/node";
import { Hono } from "hono";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createMediaRouter,
MEDIA_NOT_CONFIGURED,
mediaRpcError,
unconfiguredCapabilities,
validateMediaFolder,
} from "./media.js";
import { mountMediaHttpRoutes, parseByteRange } from "./media-http.js";
import type { MediaSampleIds } from "./media-test-fakes.js";
import { fakeMediaServices, mediaProcedurePaths, sampleMediaInputs } from "./media-test-fakes.js";
import { expectedChunkBytes, uploadChunkCount } from "./media-uploads.js";
const member: Actor = {
userId: "user-member",
spaceId: "space-a",
email: "member@rakazo.test",
isDeploymentOwner: false,
};
const owner: Actor = { ...member, userId: "user-owner", isDeploymentOwner: true };
const ids = Object.fromEntries(
[
"sourceId",
"assetId",
"secondAssetId",
"segmentId",
"trackId",
"personId",
"markId",
"collectionId",
"brandKitId",
"projectId",
"planId",
"renderId",
"publicationId",
"mappingId",
"uploadId",
].map((key) => [key, `id-${key.toLowerCase()}`]),
) as unknown as MediaSampleIds;
const kit = {
name: "Kit",
colors: {
primary: "#101010",
secondary: "#202020",
accent: "#303030",
text: "#f0f0f0",
background: "#000000",
},
};
function client(router: ReturnType<typeof createMediaRouter>, actor: Actor | null) {
return createRouterClient(router, { context: { actor } });
}
function procedure(target: unknown, rpcPath: string) {
let node: unknown = target;
for (const key of rpcPath.split("/")) node = (node as Record<string, unknown>)[key];
return node as (input: unknown) => Promise<unknown>;
}
async function rpcCode(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof ORPCError) return error.code;
throw error;
}
return "OK";
}
/** Repositories that fail the test when reached. */
function untouchedRepos(): MediaRepos {
return new Proxy({} as MediaRepos, {
get: (_target, key) => () => {
throw new Error(`repository ${String(key)} must not be called`);
},
});
}
const prismaStub = {} as PrismaClient;
describe("media router without a configured sidecar", () => {
const router = createMediaRouter({ prisma: prismaStub, services: null, dataDir: "/data" });
const inputs = sampleMediaInputs(ids, kit);
it("has a sample input for every media procedure", () => {
expect(Object.keys(inputs).sort()).toEqual(mediaProcedurePaths().sort());
});
it("reports media as not configured so every surface keeps it hidden", async () => {
const capabilities = await client(router, member).capabilities({});
expect(capabilities).toEqual(unconfiguredCapabilities());
expect(capabilities.configured).toBe(false);
expect(
Object.values(capabilities.operations).every(
(op) => !(op as { available: boolean }).available,
),
).toBe(true);
});
it("refuses every other procedure", async () => {
for (const [rpcPath, sample] of Object.entries(inputs)) {
if (rpcPath === "capabilities") continue;
const promise = procedure(client(router, member), rpcPath)(sample.input);
await expect(promise, rpcPath).rejects.toMatchObject({
code: "PRECONDITION_FAILED",
message: MEDIA_NOT_CONFIGURED,
});
}
});
it("requires a signed-in member for every procedure", async () => {
for (const [rpcPath, sample] of Object.entries(inputs)) {
const code = await rpcCode(procedure(client(router, null), rpcPath)(sample.input));
expect(code, rpcPath).toBe("UNAUTHORIZED");
}
});
it("answers the byte routes with 404 and anonymous callers with 401", async () => {
const app = new Hono();
let actor: Actor | null = member;
mountMediaHttpRoutes(app, { services: null }, async () => actor);
const requests = [
new Request("http://api/api/media/uploads/u1/chunks/0", { method: "PUT", body: "abcd" }),
new Request("http://api/api/media/renders/r1/files/video.mp4"),
new Request("http://api/api/media/assets/a1/thumb"),
new Request("http://api/api/media/assets/a1/proxy"),
new Request("http://api/api/media/bundles/p1"),
];
for (const request of requests) {
expect((await app.request(request.clone())).status, request.url).toBe(404);
}
actor = null;
for (const request of requests) {
expect((await app.request(request.clone())).status, request.url).toBe(401);
}
});
});
describe("media router permissions", () => {
let root: string;
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), "media-api-"));
});
afterAll(async () => {
await rm(root, { recursive: true, force: true });
});
it("lets only the deployment owner add a folder source", async () => {
const created: unknown[] = [];
const repos = new Proxy({} as MediaRepos, {
get: (_target, key) => {
if (key === "createSource") {
return async (_spaceId: string, input: unknown) => {
created.push(input);
return {
id: "src-1",
kind: "folder",
label: "Footage",
status: "idle",
assetCount: 0,
lastSyncedAt: null,
error: null,
createdAt: "2026-10-01T00:00:00.000Z",
};
};
}
return () => {
throw new Error(`repository ${String(key)} must not be called`);
};
},
});
const { services, calls } = fakeMediaServices({ repos, mediaRoot: path.join(root, "media") });
const footage = path.join(root, "footage");
await mkdir(footage);
const router = createMediaRouter({
prisma: prismaStub,
services,
dataDir: path.join(root, "data"),
appRoot: path.join(root, "app"),
});
const denied = client(router, member).sources.createFolder({ label: "Footage", path: footage });
await expect(denied).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(created).toEqual([]);
expect(calls).toEqual([]);
await client(router, owner).sources.createFolder({ label: "Footage", path: footage });
expect(created).toEqual([
{ kind: "folder", label: "Footage", ownerUserId: owner.userId, config: { path: footage } },
]);
expect(calls.map((call) => `${call.service}.${call.method}`)).toEqual(["ingest.requestSync"]);
});
it("accepts only a real, specific folder apart from evOo's own directories", async () => {
const dataDir = path.join(root, "data");
const appRoot = path.join(root, "app");
const folder = path.join(root, "clips");
await mkdir(path.join(dataDir, "media"), { recursive: true });
await mkdir(path.join(appRoot, "packages"), { recursive: true });
await mkdir(folder, { recursive: true });
await symlink(folder, path.join(root, "clips-link"));
const forbidden = { dataDir, appRoot };
await expect(validateMediaFolder(folder, forbidden)).resolves.toBe(folder);
const rejected = [
"clips",
path.join(root, "missing"),
path.join(root, "clips-link"),
"/",
dataDir,
path.join(dataDir, "media"),
root,
path.join(appRoot, "packages"),
];
for (const candidate of rejected) {
await expect(validateMediaFolder(candidate, forbidden), candidate).rejects.toBeInstanceOf(
InvalidMediaInputError,
);
}
});
});
describe("consent stays with the signed-in user", () => {
it("never accepts consent or age fields from the agent's people tool", () => {
const schema = MediaToolInputSchemas.media_people;
expect(schema.shape.action.options).toEqual(["list", "update", "merge"]);
const parsed = schema.parse({
action: "update",
personId: "person-1",
consentArtifactId: "artifact-1",
consentRecordedAt: "2026-09-01T00:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T00:00:00.000Z",
});
expect(Object.keys(parsed).sort()).toEqual(["action", "personId"]);
});
it("drops consent fields sent to people.update", async () => {
const patches: unknown[] = [];
const repos = new Proxy({} as MediaRepos, {
get: (_target, key) => {
if (key === "updatePerson") {
return async (_spaceId: string, _personId: string, patch: unknown) => {
patches.push(patch);
return {};
};
}
return () => {
throw new Error(`repository ${String(key)} must not be called`);
};
},
});
const { services } = fakeMediaServices({ repos, mediaRoot: "/unused" });
const router = createMediaRouter({ prisma: prismaStub, services, dataDir: "/data" });
await procedure(
client(router, member),
"people/update",
)({
personId: "person-1",
displayName: "Person 1",
consentRecordedAt: "2026-09-01T00:00:00.000Z",
ageVerificationMethod: "platform_verified",
ageVerifiedAt: "2026-09-01T00:00:00.000Z",
}).catch(() => undefined);
expect(patches).toHaveLength(1);
expect(Object.entries(patches[0] as object).filter(([, value]) => value !== undefined)).toEqual(
[["displayName", "Person 1"]],
);
});
it("marks recordConsent as the only user-only write and keeps it out of the services", () => {
const people = mediaRpcShapes.people as Record<string, unknown>;
expect(Object.keys(people)).toContain("recordConsent");
const { services } = fakeMediaServices({ repos: untouchedRepos(), mediaRoot: "/unused" });
const serviceMethods = (["ingest", "privacy", "render", "marketing"] as const).flatMap((name) =>
Object.keys(services[name] as unknown as Record<string, unknown>),
);
expect(serviceMethods.filter((method) => /consent|age/i.test(method))).toEqual([]);
});
});
describe("media error mapping", () => {
it("never reveals whether a foreign id exists", () => {
expect((mediaRpcError(new IsolationError()) as ORPCError<string, unknown>).code).toBe(
"NOT_FOUND",
);
expect(
(mediaRpcError(new PathOutsideRootError("../x")) as ORPCError<string, unknown>).code,
).toBe("BAD_REQUEST");
expect(
(mediaRpcError(new MediaStateConflictError("busy")) as ORPCError<string, unknown>).code,
).toBe("CONFLICT");
const other = new Error("boom");
expect(mediaRpcError(other)).toBe(other);
});
});
describe("upload and streaming helpers", () => {
it("splits uploads into full chunks plus a shorter last one", () => {
const row = { sizeBytes: 10n, chunkBytes: 4 };
expect(uploadChunkCount(row)).toBe(3);
expect([0, 1, 2].map((index) => expectedChunkBytes(row, index))).toEqual([4, 4, 2]);
expect(uploadChunkCount({ sizeBytes: 8n, chunkBytes: 4 })).toBe(2);
expect(expectedChunkBytes({ sizeBytes: 8n, chunkBytes: 4 }, 1)).toBe(4);
});
it("parses single byte ranges and refuses unsatisfiable ones", () => {
expect(parseByteRange(undefined, 100)).toBeNull();
expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=50-500", 100)).toEqual({ start: 50, end: 99 });
expect(parseByteRange("bytes=0-1,5-6", 100)).toBeNull();
expect(parseByteRange("bytes=100-", 100)).toBe("unsatisfiable");
expect(parseByteRange("bytes=9-3", 100)).toBe("unsatisfiable");
expect(parseByteRange("items=0-1", 100)).toBe("unsatisfiable");
expect(parseByteRange("bytes=-0", 100)).toBe("unsatisfiable");
});
});

View File

@ -1,113 +1,547 @@
import { lstat, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { implement, ORPCError } from "@orpc/server";
import type { Actor } from "@rakazo/contracts";
import { appContract } from "@rakazo/contracts";
import { UnsupportedMediaOperationError } from "@rakazo/adapter-kit";
import type { MediaServices } from "@rakazo/adapters";
import type { Actor, BrandKit, MediaCapabilities, MediaSourceKind } from "@rakazo/contracts";
import { appContract, MediaInferenceOperationSchema } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import {
InvalidMediaInputError,
InvalidProjectWorkspacePathError,
IsolationError,
lockMediaKey,
MediaStateConflictError,
mapMediaSource,
resolveProjectWorkspaceDirectory,
} from "@rakazo/db";
import { summarizeProfile } from "@rakazo/media";
import { PathOutsideRootError, resolveInside } from "@rakazo/media/node";
import { beginMediaUpload, completeMediaUpload, UploadChunkError } from "./media-uploads.js";
export interface MediaRouterDeps {
prisma: PrismaClient;
/** Null when Media Studio is not configured: everything but capabilities refuses. */
services?: MediaServices | null;
/** DATA_DIR; a folder source may neither sit inside it nor contain it. */
dataDir: string;
/** Application install directory a folder source may not point into. */
appRoot?: string;
}
// Phase A scaffold: every procedure is replaced by its real implementation.
function notImplemented(): never {
throw new ORPCError("NOT_IMPLEMENTED", { message: "Media Studio is not available yet" });
export const MEDIA_NOT_CONFIGURED = "Media Studio is not configured";
const DEFAULT_APP_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
/** Translate domain errors into RPC errors; everything else passes through. */
export function mediaRpcError(error: unknown): unknown {
if (error instanceof ORPCError) return error;
if (error instanceof IsolationError) return new ORPCError("NOT_FOUND");
if (
error instanceof InvalidMediaInputError ||
error instanceof InvalidProjectWorkspacePathError
) {
return new ORPCError("BAD_REQUEST", { message: error.message });
}
if (error instanceof PathOutsideRootError) {
return new ORPCError("BAD_REQUEST", { message: "That path is not allowed." });
}
if (error instanceof MediaStateConflictError) {
return new ORPCError("CONFLICT", { message: error.message });
}
if (error instanceof UploadChunkError) {
return new ORPCError(error.status === 409 ? "CONFLICT" : "BAD_REQUEST", {
message: error.message,
});
}
if (error instanceof UnsupportedMediaOperationError) {
return new ORPCError("PRECONDITION_FAILED", { message: error.message });
}
return error;
}
export function createMediaRouter(_deps: MediaRouterDeps) {
export function unconfiguredCapabilities(): MediaCapabilities {
const unavailable = {
available: false,
experimental: false,
model: null,
license: null,
commercialUse: null,
};
return {
configured: false,
operations: Object.fromEntries(
MediaInferenceOperationSchema.options.map((operation) => [operation, unavailable]),
) as MediaCapabilities["operations"],
accelerator: "none",
hardwareEncoders: [],
};
}
function overlaps(a: string, b: string): boolean {
const inside = (root: string, candidate: string) => {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
};
return inside(a, b) || inside(b, a);
}
async function realOrResolved(target: string): Promise<string> {
return realpath(target).catch(() => path.resolve(target));
}
/**
* A host folder for a folder source: absolute, an existing directory, not a
* symlink, and apart from DATA_DIR and the application itself. Returns the real path.
*/
export async function validateMediaFolder(
input: string,
forbidden: { dataDir: string; appRoot: string },
): Promise<string> {
const trimmed = input.trim();
if (!path.isAbsolute(trimmed)) {
throw new InvalidMediaInputError("Use an absolute folder path.");
}
const info = await lstat(trimmed).catch(() => null);
if (!info) throw new InvalidMediaInputError("That folder does not exist or is not readable.");
if (info.isSymbolicLink())
throw new InvalidMediaInputError("Choose the folder, not a link to it.");
const resolved = await resolveProjectWorkspaceDirectory(trimmed);
if (path.parse(resolved).root === resolved) {
throw new InvalidMediaInputError("Choose a specific folder.");
}
const dataDir = await realOrResolved(forbidden.dataDir);
const appRoot = await realOrResolved(forbidden.appRoot);
const relativeToApp = path.relative(appRoot, resolved);
const insideApp =
relativeToApp === "" || (!relativeToApp.startsWith("..") && !path.isAbsolute(relativeToApp));
if (overlaps(dataDir, resolved) || insideApp) {
throw new InvalidMediaInputError("That folder belongs to evOo itself.");
}
return resolved;
}
/** Artifacts referenced from media records must belong to the caller's space. */
export async function assertSpaceArtifacts(
prisma: PrismaClient,
spaceId: string,
artifactIds: string[],
): Promise<void> {
const ids = [...new Set(artifactIds)];
if (ids.length === 0) return;
const owned = await prisma.artifact.count({ where: { spaceId, id: { in: ids } } });
if (owned !== ids.length) throw new IsolationError();
}
function brandKitArtifactIds(kit: BrandKit): string[] {
return [
...kit.fonts.map((font) => font.artifactId),
...kit.logoArtifactIds,
...(kit.lutArtifactId ? [kit.lutArtifactId] : []),
...kit.musicBeds.map((bed) => bed.artifactId),
];
}
async function removeMediaPath(mediaRoot: string, relative: string | null): Promise<void> {
if (!relative) return;
await rm(await resolveInside(mediaRoot, relative), { recursive: true, force: true });
}
export function createMediaRouter(deps: MediaRouterDeps) {
const os = implement(appContract.media).$context<{ actor: Actor | null; signal?: AbortSignal }>();
const authed = os.use(async ({ context, next }) => {
if (!context.actor) throw new ORPCError("UNAUTHORIZED");
return next({ context: { ...context, actor: context.actor } });
try {
return await next({ context: { ...context, actor: context.actor } });
} catch (error) {
throw mediaRpcError(error);
}
});
const media = authed.use(async ({ next }) => {
if (!deps.services) {
throw new ORPCError("PRECONDITION_FAILED", { message: MEDIA_NOT_CONFIGURED });
}
return next({ context: { services: deps.services } });
});
const forbiddenRoots = { dataDir: deps.dataDir, appRoot: deps.appRoot ?? DEFAULT_APP_ROOT };
async function knownProfiles(services: MediaServices, profileIds: string[] | undefined) {
if (!profileIds) return;
const { profiles } = await services.deps.profiles();
const unknown = profileIds.filter((id) => !profiles.has(id));
if (unknown.length > 0) {
throw new InvalidMediaInputError(`Unknown profile: ${unknown.join(", ")}`);
}
}
return os.router({
capabilities: authed.capabilities.handler(notImplemented),
capabilities: authed.capabilities.handler(async ({ context }) => {
const services = deps.services;
if (!services) return unconfiguredCapabilities();
const hardwareEncoders = await services.deps.renderer.hardwareEncoders().catch(() => []);
const inference = services.deps.inference;
if (!inference) return { ...unconfiguredCapabilities(), hardwareEncoders };
const capabilities = await inference
.capabilities(context.signal)
.catch(() => ({ ...unconfiguredCapabilities(), configured: true }));
return { ...capabilities, configured: true, hardwareEncoders };
}),
sources: {
list: authed.sources.list.handler(notImplemented),
createFolder: authed.sources.createFolder.handler(notImplemented),
registerClient: authed.sources.registerClient.handler(notImplemented),
pushItems: authed.sources.pushItems.handler(notImplemented),
sync: authed.sources.sync.handler(notImplemented),
remove: authed.sources.remove.handler(notImplemented),
list: media.sources.list.handler(({ context }) =>
context.services.deps.repos.listSources(context.actor.spaceId),
),
createFolder: media.sources.createFolder.handler(async ({ context, input }) => {
// A folder lives on the host, and the host is the deployment owner's machine.
if (!context.actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN");
const folder = await validateMediaFolder(input.path, forbiddenRoots);
const { repos } = context.services.deps;
const source = await repos.createSource(context.actor.spaceId, {
kind: "folder",
label: input.label,
ownerUserId: context.actor.userId,
config: { path: folder },
});
await context.services.ingest.requestSync(context.actor, source.id);
return source;
}),
registerClient: media.sources.registerClient.handler(async ({ context, input }) => {
const { spaceId, userId } = context.actor;
const kind: MediaSourceKind = input.kind;
// One source per device: re-registering returns the existing one.
return deps.prisma.$transaction(async (tx) => {
await lockMediaKey(tx, `source:${spaceId}:${kind}:${input.deviceId}`);
const existing = await tx.mediaSource.findFirst({
where: { spaceId, kind, config: { path: ["deviceId"], equals: input.deviceId } },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
include: { _count: { select: { assets: true } } },
});
if (existing) return mapMediaSource(existing, existing._count.assets);
const created = await tx.mediaSource.create({
data: {
spaceId,
kind,
label: input.label,
ownerUserId: userId,
config: { deviceId: input.deviceId },
},
});
return mapMediaSource(created, 0);
});
}),
pushItems: media.sources.pushItems.handler(async ({ context, input }) => {
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
return context.services.ingest.pushItems(context.actor, input);
}),
sync: media.sources.sync.handler(async ({ context, input }) => {
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
await context.services.ingest.requestSync(context.actor, input.sourceId);
return { ok: true as const };
}),
remove: media.sources.remove.handler(async ({ context, input }) => {
await context.services.deps.repos.getSource(context.actor.spaceId, input.sourceId);
await context.services.ingest.removeSource(context.actor, input.sourceId);
return { ok: true as const };
}),
},
assets: {
search: authed.assets.search.handler(notImplemented),
get: authed.assets.get.handler(notImplemented),
setPaidOnly: authed.assets.setPaidOnly.handler(notImplemented),
requestUpload: authed.assets.requestUpload.handler(notImplemented),
search: media.assets.search.handler(({ context, input }) =>
context.services.deps.repos.searchAssets(context.actor.spaceId, input),
),
get: media.assets.get.handler(({ context, input }) =>
context.services.deps.repos.getAsset(context.actor.spaceId, input.assetId),
),
setPaidOnly: media.assets.setPaidOnly.handler(async ({ context, input }) => {
await context.services.deps.repos.setSegmentsPaidOnly(
context.actor.spaceId,
input.segmentIds,
input.paidOnly,
);
return { ok: true as const };
}),
requestUpload: media.assets.requestUpload.handler(async ({ context, input }) => ({
awaiting: await context.services.deps.repos.requestUpload(
context.actor.spaceId,
input.assetIds,
),
})),
},
uploads: {
begin: authed.uploads.begin.handler(notImplemented),
status: authed.uploads.status.handler(notImplemented),
complete: authed.uploads.complete.handler(notImplemented),
begin: media.uploads.begin.handler(({ context, input }) =>
beginMediaUpload(context.services, context.actor, input),
),
status: media.uploads.status.handler(({ context, input }) =>
context.services.deps.repos.uploadStatus(context.actor.spaceId, input.uploadId),
),
complete: media.uploads.complete.handler(({ context, input }) =>
completeMediaUpload(context.services, context.actor, input.uploadId),
),
},
collections: {
list: authed.collections.list.handler(notImplemented),
get: authed.collections.get.handler(notImplemented),
rename: authed.collections.rename.handler(notImplemented),
merge: authed.collections.merge.handler(notImplemented),
split: authed.collections.split.handler(notImplemented),
createManual: authed.collections.createManual.handler(notImplemented),
recluster: authed.collections.recluster.handler(notImplemented),
list: media.collections.list.handler(({ context, input }) =>
context.services.deps.repos.listCollections(context.actor.spaceId, input),
),
get: media.collections.get.handler(({ context, input }) =>
context.services.deps.repos.getCollection(context.actor.spaceId, input.collectionId),
),
rename: media.collections.rename.handler(({ context, input }) =>
context.services.deps.repos.renameCollection(
context.actor.spaceId,
input.collectionId,
input.title,
),
),
merge: media.collections.merge.handler(({ context, input }) =>
context.services.deps.repos.mergeCollections(context.actor.spaceId, input.collectionIds),
),
split: media.collections.split.handler(({ context, input }) =>
context.services.deps.repos.splitCollection(
context.actor.spaceId,
input.collectionId,
input.atAssetId,
),
),
createManual: media.collections.createManual.handler(({ context, input }) =>
context.services.deps.repos.createManualCollection(
context.actor.spaceId,
input.title,
input.assetIds,
),
),
recluster: media.collections.recluster.handler(async ({ context }) => {
await context.services.ingest.recluster(context.actor);
return { ok: true as const };
}),
},
people: {
list: authed.people.list.handler(notImplemented),
update: authed.people.update.handler(notImplemented),
merge: authed.people.merge.handler(notImplemented),
assignTrack: authed.people.assignTrack.handler(notImplemented),
recordConsent: authed.people.recordConsent.handler(notImplemented),
remove: authed.people.remove.handler(notImplemented),
list: media.people.list.handler(({ context }) =>
context.services.deps.repos.listPeople(context.actor.spaceId),
),
update: media.people.update.handler(({ context, input }) =>
context.services.deps.repos.updatePerson(context.actor.spaceId, input.personId, {
displayName: input.displayName,
isSelf: input.isSelf,
confirmed: input.confirmed,
anonymizeByDefault: input.anonymizeByDefault,
}),
),
merge: media.people.merge.handler(({ context, input }) =>
context.services.deps.repos.mergePeople(context.actor.spaceId, input.personIds),
),
assignTrack: media.people.assignTrack.handler(({ context, input }) =>
context.services.deps.repos.assignTrack(
context.actor.spaceId,
input.trackId,
input.personId,
),
),
// The only consent write path: the signed-in member is the recorder.
recordConsent: media.people.recordConsent.handler(async ({ context, input }) => {
await assertSpaceArtifacts(
deps.prisma,
context.actor.spaceId,
input.consentArtifactId ? [input.consentArtifactId] : [],
);
return context.services.deps.repos.recordConsent(
{ spaceId: context.actor.spaceId, recordedByUserId: context.actor.userId },
input,
);
}),
remove: media.people.remove.handler(async ({ context, input }) => {
await context.services.deps.repos.getPerson(context.actor.spaceId, input.personId);
await context.services.privacy.removePerson(context.actor, input.personId);
return { ok: true as const };
}),
},
marks: {
list: authed.marks.list.handler(notImplemented),
create: authed.marks.create.handler(notImplemented),
update: authed.marks.update.handler(notImplemented),
remove: authed.marks.remove.handler(notImplemented),
list: media.marks.list.handler(({ context, input }) =>
context.services.deps.repos.listMarks(context.actor.spaceId, {
...(input.personId ? { personId: input.personId } : {}),
...(input.assetId ? { assetId: input.assetId } : {}),
}),
),
create: media.marks.create.handler(async ({ context, input }) => {
const { repos } = context.services.deps;
await repos.getAssetRecord(context.actor.spaceId, input.assetId);
if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId);
return context.services.privacy.createMark(context.actor, input);
}),
update: media.marks.update.handler(async ({ context, input }) => {
const { repos } = context.services.deps;
await repos.getMark(context.actor.spaceId, input.markId);
if (input.personId) await repos.getPerson(context.actor.spaceId, input.personId);
return context.services.privacy.updateMark(context.actor, input);
}),
remove: media.marks.remove.handler(async ({ context, input }) => {
const { repos, mediaRoot } = context.services.deps;
const { maskDir } = await repos.removeMark(context.actor.spaceId, input.markId);
await removeMediaPath(mediaRoot, maskDir);
return { ok: true as const };
}),
},
brandKits: {
list: authed.brandKits.list.handler(notImplemented),
upsert: authed.brandKits.upsert.handler(notImplemented),
remove: authed.brandKits.remove.handler(notImplemented),
templates: authed.brandKits.templates.handler(notImplemented),
introVariants: authed.brandKits.introVariants.handler(notImplemented),
list: media.brandKits.list.handler(({ context }) =>
context.services.deps.repos.listBrandKits(context.actor.spaceId),
),
upsert: media.brandKits.upsert.handler(async ({ context, input }) => {
await assertSpaceArtifacts(
deps.prisma,
context.actor.spaceId,
brandKitArtifactIds(input.kit),
);
return context.services.deps.repos.upsertBrandKit(context.actor.spaceId, input);
}),
remove: media.brandKits.remove.handler(async ({ context, input }) => {
await context.services.deps.repos.removeBrandKit(context.actor.spaceId, input.id);
return { ok: true as const };
}),
templates: media.brandKits.templates.handler(({ context }) =>
context.services.deps.motion.templates(),
),
introVariants: media.brandKits.introVariants.handler(async ({ context, input }) => {
await context.services.deps.repos.getBrandKit(context.actor.spaceId, input.id);
return context.services.marketing.introVariants(context.actor, {
brandKitId: input.id,
count: input.count,
});
}),
},
profiles: {
list: authed.profiles.list.handler(notImplemented),
get: authed.profiles.get.handler(notImplemented),
list: media.profiles.list.handler(async ({ context }) => {
const { profiles } = await context.services.deps.profiles();
const now = context.services.deps.now();
return [...profiles.values()].map(({ profile, origin }) =>
summarizeProfile(profile, origin, now),
);
}),
get: media.profiles.get.handler(async ({ context, input }) => {
const { profiles } = await context.services.deps.profiles();
const entry = profiles.get(input.profileId);
if (!entry) throw new ORPCError("NOT_FOUND");
return entry.profile;
}),
},
projects: {
list: authed.projects.list.handler(notImplemented),
get: authed.projects.get.handler(notImplemented),
create: authed.projects.create.handler(notImplemented),
update: authed.projects.update.handler(notImplemented),
remove: authed.projects.remove.handler(notImplemented),
autoPlan: authed.projects.autoPlan.handler(notImplemented),
list: media.projects.list.handler(({ context, input }) =>
context.services.deps.repos.listProjects(context.actor.spaceId, input),
),
get: media.projects.get.handler(({ context, input }) =>
context.services.deps.repos.getProject(context.actor.spaceId, input.projectId),
),
create: media.projects.create.handler(async ({ context, input }) => {
await knownProfiles(context.services, input.profileIds);
return context.services.deps.repos.createProject(
context.actor.spaceId,
context.actor.userId,
input,
);
}),
update: media.projects.update.handler(async ({ context, input }) => {
await knownProfiles(context.services, input.profileIds);
const { projectId, ...patch } = input;
return context.services.deps.repos.updateProject(context.actor.spaceId, projectId, patch);
}),
remove: media.projects.remove.handler(async ({ context, input }) => {
const { repos, mediaRoot } = context.services.deps;
const { spaceId } = context.actor;
await repos.getProjectRecord(spaceId, input.projectId);
const renders = await repos.listRenders(spaceId, input.projectId);
await repos.removeProject(spaceId, input.projectId);
for (const render of renders)
await removeMediaPath(mediaRoot, `${spaceId}/renders/${render.id}`);
return { ok: true as const };
}),
autoPlan: media.projects.autoPlan.handler(async ({ context, input }) => {
await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId);
await knownProfiles(context.services, input.profileIds);
return context.services.marketing.autoPlan(context.actor, input);
}),
},
plans: {
submit: authed.plans.submit.handler(notImplemented),
submit: media.plans.submit.handler(async ({ context, input }) => {
await context.services.deps.repos.getProjectRecord(context.actor.spaceId, input.projectId);
return context.services.marketing.submitPlan(context.actor, {
projectId: input.projectId,
edl: input.edl,
rationale: input.rationale,
createdBy: "user",
});
}),
},
renders: {
start: authed.renders.start.handler(notImplemented),
get: authed.renders.get.handler(notImplemented),
cancel: authed.renders.cancel.handler(notImplemented),
thumbnails: authed.renders.thumbnails.handler(notImplemented),
start: media.renders.start.handler(async ({ context, input }) => {
await context.services.deps.repos.getPlan(context.actor.spaceId, input.planId);
return context.services.render.start(context.actor, input);
}),
get: media.renders.get.handler(({ context, input }) =>
context.services.deps.repos.getRender(context.actor.spaceId, input.renderId),
),
cancel: media.renders.cancel.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.render.cancel(context.actor, input.renderId);
}),
thumbnails: media.renders.thumbnails.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.marketing.thumbnails(context.actor, input);
}),
},
marketing: {
copy: authed.marketing.copy.handler(notImplemented),
copy: media.marketing.copy.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.marketing.copy(context.actor, input);
}),
},
publications: {
list: authed.publications.list.handler(notImplemented),
create: authed.publications.create.handler(notImplemented),
bundle: authed.publications.bundle.handler(notImplemented),
markPublished: authed.publications.markPublished.handler(notImplemented),
cancel: authed.publications.cancel.handler(notImplemented),
list: media.publications.list.handler(({ context, input }) =>
context.services.deps.repos.listPublications(context.actor.spaceId, input),
),
create: media.publications.create.handler(async ({ context, input }) => {
await context.services.deps.repos.getRender(context.actor.spaceId, input.renderId);
return context.services.marketing.createPublication(context.actor, input);
}),
bundle: media.publications.bundle.handler(async ({ context, input }) => {
await context.services.deps.repos.getPublication(
context.actor.spaceId,
input.publicationId,
);
return context.services.marketing.bundle(context.actor, input.publicationId);
}),
markPublished: media.publications.markPublished.handler(({ context, input }) =>
context.services.deps.repos.markPublicationPublished(
context.actor.spaceId,
input.publicationId,
input.externalUrl,
context.services.deps.now(),
),
),
cancel: media.publications.cancel.handler(async ({ context, input }) => {
await context.services.deps.repos.getPublication(
context.actor.spaceId,
input.publicationId,
);
return context.services.marketing.cancelPublication(context.actor, input.publicationId);
}),
},
metrics: {
mappings: authed.metrics.mappings.handler(notImplemented),
saveMapping: authed.metrics.saveMapping.handler(notImplemented),
importCsv: authed.metrics.importCsv.handler(notImplemented),
record: authed.metrics.record.handler(notImplemented),
funnel: authed.metrics.funnel.handler(notImplemented),
mappings: media.metrics.mappings.handler(({ context }) =>
context.services.deps.repos.listMetricMappings(context.actor.spaceId),
),
saveMapping: media.metrics.saveMapping.handler(({ context, input }) =>
context.services.deps.repos.saveMetricMapping(context.actor.spaceId, input),
),
importCsv: media.metrics.importCsv.handler(async ({ context, input }) => {
await context.services.deps.repos.getMetricMapping(context.actor.spaceId, input.mappingId);
return context.services.marketing.importMetricsCsv(context.actor, input);
}),
record: media.metrics.record.handler(({ context, input }) =>
context.services.deps.repos.recordMetric(context.actor.spaceId, {
...input,
source: "manual",
}),
),
funnel: media.metrics.funnel.handler(({ context, input }) =>
context.services.deps.repos.funnel(context.actor.spaceId, input.projectId),
),
},
});
}