feat(media): stream artifacts over HTTP with Range support

Videos and audio can be hundreds of megabytes. Artifacts only travelled as
base64 inside RPC calls, capped at 10 MiB and held whole in memory, so media
could neither be uploaded, played with seeking nor produced by media jobs.

- ArtifactStore gains stat, createReadStream (inclusive byte ranges),
  putStream (size cap enforced while writing, real SHA-256) and an optional
  localPath so ffmpeg can read stored files in place. LocalArtifactStore
  implements them with the same safety as before (O_EXCL, O_NOFOLLOW, 0600)
  and now validates space ids and keys before building paths.
- storeArtifactStream / storeArtifactFromFile store a stream or a job's
  output file as an artifact row, with width, height, duration and parent,
  and remove the blob again if the row cannot be written.
- GET /api/artifacts/:id serves an artifact with Range/206/416, ETag,
  inline disposition for media and attachment otherwise (safe RFC 6266
  filenames), nosniff and same-origin CORP. Authorization matches
  artifacts.get: the owner's artifact of an active bot thread or group,
  with membership of the artifact's space (media elements cannot send the
  space header; an explicit header must name the same space).
- POST /api/artifacts takes a raw streamed body with the target and name in
  the query, authorized like artifacts.create, with the MIME allowlist and
  the per-kind caps checked from Content-Length and again while writing.
- The API CSP allows media from the app origin and blob URLs.
- Web: artifactUrl(artifactId) and uploadArtifactFile for the UI.

Tests cover the streaming store, range parsing, filename sanitizing, the
file helper, and a Postgres suite for both routes: ranges, caching, other
users, shared-space members, other spaces, archived threads, signed-out
requests and upload limits including chunked bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:31:39 +00:00
parent 7ff93f08bb
commit 62e9bc67f1
No known key found for this signature in database
14 changed files with 1460 additions and 23 deletions

View File

@ -89,6 +89,7 @@ import { requestLogging } from "@rakazo/logging/hono";
import { MarkdownMemoryStore } from "@rakazo/memory";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { mountArtifactHttpRoutes } from "./artifact-http.js";
import type { AppEnv } from "./env.js";
import { loadEnv } from "./env.js";
import { mountLocalSettings } from "./local-settings.js";
@ -513,7 +514,7 @@ export async function createApp(
await next();
c.res.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
);
c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
c.res.headers.set("X-Frame-Options", "DENY");
@ -583,6 +584,12 @@ export async function createApp(
if (actor) enrichLogContext({ "user.id": actor.userId, "space.id": actor.spaceId });
return actor;
});
mountArtifactHttpRoutes(app, {
prisma,
artifacts,
sessionUserId: async (request) =>
(await auth.api.getSession({ headers: sessionHeaders(request) }))?.user?.id ?? null,
});
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });
// Shared with stop so a shutdown during retry delays does not restart polling.
let messagingStopped = false;

View File

@ -0,0 +1,142 @@
import type { ArtifactStore } from "@rakazo/adapter-kit";
import type { PrismaClient } from "@rakazo/db";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { contentDisposition, mountArtifactHttpRoutes, parseByteRange } from "./artifact-http.js";
describe("parseByteRange", () => {
it("ignores an absent header or another unit", () => {
expect(parseByteRange(undefined, 100)).toBeNull();
expect(parseByteRange("", 100)).toBeNull();
expect(parseByteRange("items=0-1", 100)).toBeNull();
});
it("parses closed, open and suffix ranges and clamps the end to the size", () => {
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=-500", 100)).toEqual({ start: 0, end: 99 });
expect(parseByteRange("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 });
expect(parseByteRange("Bytes= 0-0 ", 100)).toEqual({ start: 0, end: 0 });
});
it("rejects multiple, inverted, out-of-file and malformed ranges", () => {
for (const header of [
"bytes=0-1,5-6",
"bytes=5-4",
"bytes=100-",
"bytes=100-200",
"bytes=-0",
"bytes=-",
"bytes=a-b",
"bytes=1.5-2",
"bytes= -1-2",
"bytes=0x10-20",
`bytes=${"9".repeat(16)}-`,
]) {
expect(parseByteRange(header, 100), header).toBe("unsatisfiable");
}
expect(parseByteRange("bytes=0-0", 0)).toBe("unsatisfiable");
});
});
describe("contentDisposition", () => {
it("keeps plain names and marks media inline", () => {
expect(contentDisposition("clip.mp4", true)).toBe(
`inline; filename="clip.mp4"; filename*=UTF-8''clip.mp4`,
);
});
it("neutralizes quotes, separators, control characters and header injection", () => {
const header = contentDisposition('../evil"\r\nSet-Cookie: x=1;.txt', false);
expect(header.startsWith("attachment; ")).toBe(true);
expect(header).not.toMatch(/[\r\n]/);
expect(header).toContain(`filename=".._evil___Set-Cookie: x=1_.txt"`);
expect(header).not.toContain("/");
});
it("encodes non-ASCII names with an ASCII fallback", () => {
expect(contentDisposition("Grüße (1).png", true)).toBe(
`inline; filename="Gr__e (1).png"; filename*=UTF-8''Gr%C3%BC%C3%9Fe%20%281%29.png`,
);
expect(contentDisposition(" ", false)).toBe(
`attachment; filename="download"; filename*=UTF-8''download`,
);
});
});
describe("artifact HTTP routes", () => {
function app(sessionUserId: () => Promise<string | null>) {
const prisma = {
artifact: { findUnique: vi.fn(), create: vi.fn() },
spaceMember: { findFirst: vi.fn() },
};
const artifacts = { putStream: vi.fn(), stat: vi.fn(), createReadStream: vi.fn() };
const hono = new Hono();
mountArtifactHttpRoutes(hono, {
prisma: prisma as unknown as PrismaClient,
artifacts: artifacts as unknown as ArtifactStore,
sessionUserId,
});
return { hono, prisma, artifacts };
}
it("rejects signed-out reads and uploads before touching storage", async () => {
const { hono, prisma, artifacts } = app(async () => null);
const read = await hono.request("/api/artifacts/art-1");
const upload = await hono.request("/api/artifacts?botId=bot-1&name=a.mp4", {
method: "POST",
headers: { "content-type": "video/mp4" },
body: "data",
});
expect(read.status).toBe(401);
expect(upload.status).toBe(401);
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
expect(artifacts.putStream).not.toHaveBeenCalled();
});
it("answers ids that are not artifact ids with 404 without a lookup", async () => {
const { hono, prisma } = app(async () => "user-1");
const response = await hono.request(`/api/artifacts/${encodeURIComponent("../etc/passwd")}`);
expect(response.status).toBe(404);
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
});
it("rejects declared oversized and disallowed uploads without reading the body", async () => {
const { hono, prisma, artifacts } = app(async () => "user-1");
prisma.spaceMember.findFirst.mockResolvedValue({
userId: "user-1",
spaceId: "space-1",
member: { user: { email: "user@example.test" } },
});
(prisma as unknown as { deploymentSettings: unknown }).deploymentSettings = {
findUnique: vi.fn(async () => null),
};
const cancel = vi.fn(() => new Promise<void>(() => undefined));
const oversized = await hono.request(
new Request("http://localhost/api/artifacts?botId=bot-1&name=a.mp4", {
method: "POST",
headers: { "content-type": "video/mp4", "content-length": String(501 * 1024 * 1024) },
body: new ReadableStream({ cancel }),
duplex: "half",
} as RequestInit & { duplex: "half" }),
);
expect(oversized.status).toBe(413);
expect(cancel).toHaveBeenCalled();
const html = await hono.request("/api/artifacts?botId=bot-1&name=a.html", {
method: "POST",
headers: { "content-type": "text/html" },
body: "<script></script>",
});
expect(html.status).toBe(415);
const noTarget = await hono.request("/api/artifacts?name=a.mp4", {
method: "POST",
headers: { "content-type": "video/mp4" },
body: "data",
});
expect(noTarget.status).toBe(400);
expect(artifacts.putStream).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,285 @@
import type { AdapterContext, ArtifactByteRange, ArtifactStore } from "@rakazo/adapter-kit";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { artifactMaxBytes, storeArtifactStream } from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { attachmentKindOf, isAllowedAttachmentMimeType } from "@rakazo/contracts";
import {
createGroupRepos,
createRepos,
IsolationError,
type PrismaClient,
requireMembership,
} from "@rakazo/db";
import type { Context, Hono } from "hono";
import { cancelBody } from "./http-body.js";
export const ARTIFACT_HTTP_PATH = "/api/artifacts";
export type ArtifactHttpDeps = {
prisma: PrismaClient;
artifacts: ArtifactStore;
/** The signed-in user (cookie or bearer session), or null. */
sessionUserId: (request: Request) => Promise<string | null>;
};
/** Generated ids only; anything else is not an artifact. */
const ARTIFACT_ID = /^[A-Za-z0-9_-]{1,64}$/;
const MAX_RANGE_DIGITS = 15;
/**
* Parses a single `bytes=` range against the stored size. Returns null when the header should
* be ignored (absent or another unit) and "unsatisfiable" for multiple ranges or offsets
* outside the file, which the caller answers with 416.
*/
export function parseByteRange(
header: string | undefined,
size: number,
): ArtifactByteRange | "unsatisfiable" | null {
if (!header) return null;
const value = header.trim();
if (!value.toLowerCase().startsWith("bytes=")) return null;
const spec = value.slice("bytes=".length).trim();
const match = new RegExp(`^(\\d{0,${MAX_RANGE_DIGITS}})-(\\d{0,${MAX_RANGE_DIGITS}})$`).exec(
spec,
);
if (!match || size <= 0) return "unsatisfiable";
const [, first = "", last = ""] = match;
if (first === "" && last === "") return "unsatisfiable";
if (first === "") {
const suffix = Number(last);
if (suffix <= 0) return "unsatisfiable";
return { start: Math.max(0, size - suffix), end: size - 1 };
}
const start = Number(first);
const end = last === "" ? size - 1 : Number(last);
if (start >= size || end < start) return "unsatisfiable";
return { start, end: Math.min(end, size - 1) };
}
/** `inline` or `attachment` with an ASCII fallback and the exact UTF-8 name (RFC 6266). */
export function contentDisposition(name: string, inline: boolean): string {
const cleaned =
Array.from(name, (char) => {
const code = char.codePointAt(0) ?? 0;
return code < 0x20 || code === 0x7f || char === "/" || char === "\\" ? "_" : char;
})
.join("")
.trim() || "download";
const ascii = cleaned.replace(/[^ -~]/g, "_").replace(/["%;]/g, "_");
const encoded = encodeURIComponent(cleaned).replace(
/['()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `${inline ? "inline" : "attachment"}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
}
function adapterContext(actor: Actor, operationId: string, signal: AbortSignal): AdapterContext {
return {
operationId,
traceId: operationId,
spaceId: actor.spaceId,
userId: actor.userId,
signal,
};
}
function notFound(c: Context) {
return c.json({ error: "Not found" }, 404);
}
function artifactDto(row: {
id: string;
botId: string | null;
groupId: string | null;
runId: string | null;
name: string;
mimeType: string;
size: number;
createdAt: Date;
}) {
return {
id: row.id,
botId: row.botId,
groupId: row.groupId,
runId: row.runId,
name: row.name,
mimeType: row.mimeType,
size: row.size,
createdAt: row.createdAt.toISOString(),
};
}
/**
* Streams artifacts over HTTP so media can play with seeking and large files never travel as
* base64 RPC payloads. Authorization matches `artifacts.get` and `artifacts.create`.
*/
export function mountArtifactHttpRoutes(app: Hono, deps: ArtifactHttpDeps) {
const repos = createRepos(deps.prisma);
const groupRepos = createGroupRepos(deps.prisma);
/** Same rule as artifacts.get: the owner's artifact of an active bot thread or group. */
async function readableArtifact(
userId: string,
requestedSpaceId: string | undefined,
id: string,
) {
if (!ARTIFACT_ID.test(id)) return null;
const row = await deps.prisma.artifact.findUnique({ where: { id } });
if (!row) return null;
// <video> and <img> cannot send the space header, so the artifact's space is the default;
// membership is still required and an explicit header must name the same space.
const actor = await requireMembership(
deps.prisma,
userId,
requestedSpaceId ?? row.spaceId,
).catch(() => null);
if (!actor || actor.spaceId !== row.spaceId || actor.userId !== row.userId) return null;
try {
if (row.groupId) await groupRepos.getGroupTarget(actor, row.groupId);
else if (row.botId) await repos.getBot(actor, row.botId);
else return null;
} catch (error) {
if (error instanceof IsolationError) return null;
throw error;
}
return { row, actor };
}
app.get(`${ARTIFACT_HTTP_PATH}/:id`, async (c) => {
const userId = await deps.sessionUserId(c.req.raw);
if (!userId) return c.json({ error: "Unauthorized" }, 401);
const found = await readableArtifact(
userId,
c.req.header("x-rakazo-space-id"),
c.req.param("id"),
);
if (!found) return notFound(c);
const { row, actor } = found;
const context = adapterContext(actor, `artifact-http-get:${row.id}`, c.req.raw.signal);
let size: number;
try {
({ size } = await deps.artifacts.stat(row.storageKey, context));
} catch {
return notFound(c);
}
const mimeType = isAllowedAttachmentMimeType(row.mimeType)
? row.mimeType
: "application/octet-stream";
const inline = attachmentKindOf(mimeType) !== "file" && c.req.query("download") === undefined;
const etag = /^[A-Za-z0-9]{16,128}$/.test(row.hash) ? `"${row.hash}"` : undefined;
const headers = new Headers({
"accept-ranges": "bytes",
// Revalidate every time so a revoked thread stops serving from the browser cache.
"cache-control": "private, no-cache",
"content-disposition": contentDisposition(row.name, inline),
"content-type": mimeType,
"cross-origin-resource-policy": "same-origin",
"x-content-type-options": "nosniff",
});
if (etag) headers.set("etag", etag);
if (etag && c.req.header("if-none-match") === etag) {
return new Response(null, { status: 304, headers });
}
const ifRange = c.req.header("if-range");
const range =
ifRange === undefined || ifRange === etag
? parseByteRange(c.req.header("range"), size)
: null;
if (range === "unsatisfiable") {
headers.set("content-range", `bytes */${size}`);
return new Response(null, { status: 416, headers });
}
const status = range ? 206 : 200;
headers.set("content-length", String(range ? range.end - range.start + 1 : size));
if (range) headers.set("content-range", `bytes ${range.start}-${range.end}/${size}`);
// Hono answers HEAD from this handler and drops the body; never open a stream for it.
if (c.req.method === "HEAD") return new Response(null, { status, headers });
let body: ReadableStream<Uint8Array>;
try {
body = await deps.artifacts.createReadStream(row.storageKey, context, range ?? undefined);
} catch {
return notFound(c);
}
return new Response(body, { status, headers });
});
app.post(ARTIFACT_HTTP_PATH, async (c) => {
const request = c.req.raw;
const reject = (status: 400 | 401 | 404 | 413 | 415, error: string) => {
cancelBody(request.body);
return c.json({ error }, status);
};
const userId = await deps.sessionUserId(request);
if (!userId) return reject(401, "Unauthorized");
const actor = await requireMembership(
deps.prisma,
userId,
c.req.header("x-rakazo-space-id"),
).catch(() => null);
if (!actor) return reject(401, "Unauthorized");
const botId = c.req.query("botId") || undefined;
const groupId = c.req.query("groupId") || undefined;
const name = c.req.query("name") ?? "";
if (Boolean(botId) === Boolean(groupId)) {
return reject(400, "Provide exactly one of botId or groupId");
}
if (name.length < 1 || name.length > 255) return reject(400, "Invalid file name");
const mimeType = (c.req.header("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase();
if (!isAllowedAttachmentMimeType(mimeType)) {
return reject(415, "Unsupported attachment type");
}
const maxBytes = artifactMaxBytes(mimeType);
const declared = c.req.header("content-length");
if (declared !== undefined) {
if (!/^\d{1,16}$/.test(declared)) return reject(400, "Invalid content length");
if (Number(declared) > maxBytes) return reject(413, "File is too large.");
if (Number(declared) === 0) return reject(400, "File is empty.");
}
if (!request.body) return reject(400, "File is empty.");
// Same targets as artifacts.create: an own active bot, or an active group of own bots.
let ownerBotId: string | undefined;
try {
ownerBotId = botId
? (await repos.getBot(actor, botId)).id
: (await groupRepos.getGroupTarget(actor, groupId!)).members[0]?.bot.id;
} catch (error) {
if (!(error instanceof IsolationError)) throw error;
}
if (!ownerBotId) return reject(404, "Not found");
let row: Awaited<ReturnType<typeof storeArtifactStream>>;
try {
row = await storeArtifactStream(deps, {
spaceId: actor.spaceId,
userId: actor.userId,
botId: ownerBotId,
groupId: groupId ?? null,
runId: null,
name,
mimeType,
stream: request.body,
maxBytes,
signal: request.signal,
});
} catch (error) {
if (error instanceof ArtifactTooLargeError) return reject(413, "File is too large.");
throw error;
}
if (row.size === 0) {
await deps.prisma.artifact.delete({ where: { id: row.id } });
await deps.artifacts
.remove(
row.storageKey,
adapterContext(actor, `artifact-http-post:${row.id}`, request.signal),
)
.catch(() => undefined);
return c.json({ error: "File is empty." }, 400);
}
return c.json(artifactDto(row), 201);
});
}

View File

@ -55,7 +55,11 @@ export function requestBodyLimit(maxSize: number): MiddlewareHandler {
};
}
/** Install limits only on framework-parsed JSON surfaces with known payload contracts. */
/**
* Install limits only on framework-parsed JSON surfaces with known payload contracts. Streaming
* artifact uploads (`POST /api/artifacts`) are not buffered: the route enforces the per-kind cap
* from Content-Length and again while writing.
*/
export function mountApiRequestBodyLimits(app: Hono): void {
app.use("/api/auth/*", requestBodyLimit(MAX_AUTH_REQUEST_BYTES));
app.use(`${LOCAL_SETTINGS_RPC}/*`, requestBodyLimit(MAX_RPC_REQUEST_BYTES));

View File

@ -0,0 +1,38 @@
import { t } from "@lingui/core/macro";
import { ArtifactSchema } from "@rakazo/contracts";
import type { ArtifactTarget } from "./artifact-open";
import { withSpaceHeaders } from "./rpc";
const ARTIFACT_PATH = "/api/artifacts";
/**
* Same-origin URL that streams an artifact with Range support, for `<video>`, `<audio>`, `<img>`
* and downloads. The session cookie authorizes it; `download` forces a save instead of inline.
*/
export function artifactUrl(artifactId: string, options: { download?: boolean } = {}): string {
const path = `${ARTIFACT_PATH}/${encodeURIComponent(artifactId)}`;
return options.download ? `${path}?download=1` : path;
}
/** Uploads a file as a raw streamed body, for media and files above the inline RPC cap. */
export async function uploadArtifactFile(
target: ArtifactTarget,
file: Blob,
name: string,
mimeType: string,
): Promise<ReturnType<typeof ArtifactSchema.parse>> {
const query = new URLSearchParams(
"botId" in target ? { botId: target.botId, name } : { groupId: target.groupId, name },
);
const response = await fetch(`${ARTIFACT_PATH}?${query}`, {
method: "POST",
headers: withSpaceHeaders({ "content-type": mimeType }),
credentials: "include",
body: file,
});
const body: unknown = await response.json().catch(() => null);
const artifact = ArtifactSchema.safeParse(body);
if (response.ok && artifact.success) return artifact.data;
const error = (body as { error?: unknown } | null)?.error;
throw new Error(typeof error === "string" ? error : t`Could not upload ${name}. Try again.`);
}

View File

@ -4,7 +4,9 @@ import type {
AgentRunRequest,
AgentRuntimeCapabilities,
AgentRuntimeEvent,
ArtifactByteRange,
ArtifactPut,
ArtifactStreamPut,
BackgroundJob,
BackgroundJobHandlers,
BrowserActRequest,
@ -63,6 +65,7 @@ import type {
SemanticMemorySaveRequest,
SnapshotRef,
SpeechClip,
StoredArtifactStream,
TransactionalEmail,
VoiceCapabilities,
VoiceInfo,
@ -283,11 +286,25 @@ export interface AgentHomeStore {
): Promise<Array<{ path: string; kind: "file" | "dir"; size: number }>>;
}
/**
* Artifact bytes, keyed per space (`context.spaceId`). `stream: true` means large media can be
* stored and served without holding it in memory.
*/
export interface ArtifactStore {
describe(): AdapterDescriptor<{ stream: boolean }>;
put(artifact: ArtifactPut, context: AdapterContext): Promise<{ id: string; hash: string }>;
get(id: string, context: AdapterContext): Promise<Uint8Array>;
remove(id: string, context: AdapterContext): Promise<void>;
stat(key: string, context: AdapterContext): Promise<{ size: number }>;
/** Rejects with a RangeError when the range does not fit the stored size. */
createReadStream(
key: string,
context: AdapterContext,
range?: ArtifactByteRange,
): Promise<ReadableStream<Uint8Array>>;
putStream(artifact: ArtifactStreamPut, context: AdapterContext): Promise<StoredArtifactStream>;
/** A local file path for tools such as ffmpeg that read in place; only stores on local disk. */
localPath?(key: string, context: AdapterContext): Promise<string>;
}
export interface SecretStore {

View File

@ -552,6 +552,34 @@ export interface ArtifactPut {
bytes: Uint8Array;
}
/** Inclusive byte offsets, as in an HTTP `Range: bytes=start-end` header. */
export interface ArtifactByteRange {
start: number;
end: number;
}
export interface ArtifactStreamPut {
name: string;
mimeType: string;
stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
/** The store stops reading and rejects with ArtifactTooLargeError beyond this many bytes. */
maxBytes: number;
}
export interface StoredArtifactStream {
key: string;
size: number;
/** Hex SHA-256 of the stored bytes. */
sha256: string;
}
export class ArtifactTooLargeError extends Error {
constructor(readonly maxBytes: number) {
super(`Artifact exceeds ${maxBytes} bytes`);
this.name = "ArtifactTooLargeError";
}
}
export interface NotificationMessage {
kind: "completion" | "failure" | "help" | "takeover";
title: string;

View File

@ -0,0 +1,125 @@
import { createHash } from "node:crypto";
import { mkdtemp, readdir, rm, symlink, truncate, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { ARTIFACT_MAX_BYTES_BY_KIND } from "@rakazo/contracts";
import { AttachmentValidationError } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { afterEach, describe, expect, it, vi } from "vitest";
import { storeArtifactFromFile } from "./artifact-files.js";
import { LocalArtifactStore } from "./artifacts.js";
const dirs: string[] = [];
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function setup(
create = vi.fn(async ({ data }: { data: object }) => ({ id: "art-1", ...data })),
) {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifact-files-"));
dirs.push(root);
const store = new LocalArtifactStore(path.join(root, "data"));
const prisma = { artifact: { create } } as unknown as Pick<PrismaClient, "artifact">;
return { root, store, prisma, create };
}
const owner = {
spaceId: "space-1",
userId: "user-1",
botId: "bot-1",
groupId: null,
runId: "run-1",
};
describe("storeArtifactFromFile", () => {
it("streams the file into the store and records size, hash and media metadata", async () => {
const { root, store, prisma, create } = await setup();
const file = path.join(root, "out.mp4");
await writeFile(file, "not really a video");
const row = await storeArtifactFromFile(
{ prisma, artifacts: store },
{
...owner,
path: file,
name: "out.mp4",
mimeType: "video/mp4",
width: 1080,
height: 1920,
durationMs: 1500,
parentArtifactId: "art-0",
},
);
expect(create).toHaveBeenCalledWith({
data: expect.objectContaining({
...owner,
name: "out.mp4",
mimeType: "video/mp4",
size: 18,
hash: createHash("sha256").update("not really a video").digest("hex"),
width: 1080,
height: 1920,
durationMs: 1500,
parentArtifactId: "art-0",
}),
});
const key = (row as unknown as { storageKey: string }).storageKey;
const context = { spaceId: "space-1", signal: new AbortController().signal } as never;
expect(Buffer.from(await store.get(key, context)).toString()).toBe("not really a video");
});
it("rejects disallowed types, oversized files and symlinks before storing anything", async () => {
const { root, store, prisma, create } = await setup();
const html = path.join(root, "page.html");
await writeFile(html, "<script></script>");
await expect(
storeArtifactFromFile(
{ prisma, artifacts: store },
{ ...owner, path: html, name: "page.html", mimeType: "text/html" },
),
).rejects.toBeInstanceOf(AttachmentValidationError);
const huge = path.join(root, "huge.png");
await writeFile(huge, "");
await truncate(huge, ARTIFACT_MAX_BYTES_BY_KIND.image + 1);
await expect(
storeArtifactFromFile(
{ prisma, artifacts: store },
{ ...owner, path: huge, name: "huge.png", mimeType: "image/png" },
),
).rejects.toBeInstanceOf(ArtifactTooLargeError);
const link = path.join(root, "link.txt");
await writeFile(path.join(root, "secret.txt"), "secret");
await symlink(path.join(root, "secret.txt"), link);
await expect(
storeArtifactFromFile(
{ prisma, artifacts: store },
{ ...owner, path: link, name: "link.txt", mimeType: "text/plain" },
),
).rejects.toThrow();
expect(create).not.toHaveBeenCalled();
});
it("removes the stored blob when the artifact row cannot be written", async () => {
const { root, store, prisma } = await setup(
vi.fn(async () => {
throw new Error("db down");
}),
);
const file = path.join(root, "notes.txt");
await writeFile(file, "hello");
await expect(
storeArtifactFromFile(
{ prisma, artifacts: store },
{ ...owner, path: file, name: "notes.txt", mimeType: "text/plain" },
),
).rejects.toThrow("db down");
expect(await readdir(path.join(root, "data", "artifacts", "space-1"))).toEqual([]);
});
});

View File

@ -0,0 +1,116 @@
import { constants } from "node:fs";
import { open } from "node:fs/promises";
import type { AdapterContext, ArtifactStore, ArtifactStreamPut } from "@rakazo/adapter-kit";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { ARTIFACT_MAX_BYTES_BY_KIND, attachmentKindOf } from "@rakazo/contracts";
import { validateAttachmentMimeType } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
export type StoreArtifactOwner = {
spaceId: string;
userId: string;
botId: string | null;
groupId: string | null;
runId: string | null;
};
export type StoreArtifactMetadata = {
name: string;
mimeType: string;
width?: number;
height?: number;
durationMs?: number;
parentArtifactId?: string;
};
type StoreArtifactDeps = {
prisma: Pick<PrismaClient, "artifact">;
artifacts: ArtifactStore;
};
/** Largest artifact of this MIME type that may be streamed or generated. */
export function artifactMaxBytes(mimeType: string): number {
return ARTIFACT_MAX_BYTES_BY_KIND[attachmentKindOf(mimeType)];
}
/**
* Streams bytes into the artifact store and records the artifact row; the stored blob is removed
* again when the row cannot be written. Rejects disallowed MIME types and anything above the
* per-kind cap (or the smaller `maxBytes`) without buffering the whole file.
*/
export async function storeArtifactStream(
deps: StoreArtifactDeps,
input: StoreArtifactOwner &
StoreArtifactMetadata & {
stream: ArtifactStreamPut["stream"];
maxBytes?: number;
signal?: AbortSignal;
},
) {
validateAttachmentMimeType(input.mimeType);
const maxBytes = Math.min(
input.maxBytes ?? Number.POSITIVE_INFINITY,
artifactMaxBytes(input.mimeType),
);
const operationId = `artifact-store:${input.runId ?? input.botId ?? input.userId}`;
const context: AdapterContext = {
operationId,
traceId: operationId,
spaceId: input.spaceId,
userId: input.userId,
...(input.botId ? { botId: input.botId } : {}),
...(input.runId ? { runId: input.runId } : {}),
signal: input.signal ?? new AbortController().signal,
};
const stored = await deps.artifacts.putStream(
{ name: input.name, mimeType: input.mimeType, stream: input.stream, maxBytes },
context,
);
try {
return await deps.prisma.artifact.create({
data: {
spaceId: input.spaceId,
userId: input.userId,
botId: input.botId,
groupId: input.groupId,
runId: input.runId,
name: input.name,
mimeType: input.mimeType,
size: stored.size,
hash: stored.sha256,
storageKey: stored.key,
width: input.width,
height: input.height,
durationMs: input.durationMs,
parentArtifactId: input.parentArtifactId,
},
});
} catch (error) {
await deps.artifacts.remove(stored.key, context).catch(() => undefined);
throw error;
}
}
/** Stores a file a job produced on local disk (e.g. an ffmpeg output) as an artifact. */
export async function storeArtifactFromFile(
deps: StoreArtifactDeps,
input: StoreArtifactOwner & StoreArtifactMetadata & { path: string; signal?: AbortSignal },
) {
validateAttachmentMimeType(input.mimeType);
const maxBytes = artifactMaxBytes(input.mimeType);
const handle = await open(input.path, constants.O_RDONLY | O_NOFOLLOW);
try {
const info = await handle.stat();
if (!info.isFile()) throw new Error(`Not a file: ${input.name}`);
if (info.size > maxBytes) throw new ArtifactTooLargeError(maxBytes);
return await storeArtifactStream(deps, {
...input,
stream: handle.createReadStream({ autoClose: false }),
maxBytes,
});
} finally {
await handle.close();
}
}

View File

@ -1,11 +1,27 @@
import { mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { mkdtemp, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { type AdapterContext, ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { afterEach, describe, expect, it } from "vitest";
import { LocalArtifactStore } from "./artifacts.js";
const dirs: string[] = [];
const context = { spaceId: "space-1" } as never;
const context = { spaceId: "space-1", signal: new AbortController().signal } as AdapterContext;
async function tempStore() {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifacts-"));
dirs.push(root);
return { root, store: new LocalArtifactStore(root) };
}
async function readAll(stream: ReadableStream<Uint8Array>) {
return Buffer.from(await new Response(stream).arrayBuffer());
}
async function* chunks(...parts: string[]) {
for (const part of parts) yield new TextEncoder().encode(part);
}
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
@ -42,4 +58,140 @@ describe("LocalArtifactStore", () => {
await expect(store.get(stored.id, context)).rejects.toThrow();
});
it("streams uploads to disk with a real hash, size and owner-only mode", async () => {
const { root, store } = await tempStore();
const stored = await store.putStream(
{
name: "clip.mp4",
mimeType: "video/mp4",
stream: new Response("hello media world").body!,
maxBytes: 1024,
},
context,
);
expect(stored.size).toBe(17);
expect(stored.sha256).toBe(createHash("sha256").update("hello media world").digest("hex"));
const info = await stat(path.join(root, "artifacts", "space-1", stored.key));
expect(info.mode & 0o777).toBe(0o600);
expect((await store.stat(stored.key, context)).size).toBe(17);
expect(await store.localPath(stored.key, context)).toBe(
path.join(root, "artifacts", "space-1", stored.key),
);
expect((await store.get(stored.key, context)).byteLength).toBe(17);
});
it("accepts async iterables and rejects bodies over the cap without leaving a file", async () => {
const { root, store } = await tempStore();
const ok = await store.putStream(
{ name: "a.txt", mimeType: "text/plain", stream: chunks("ab", "cd"), maxBytes: 4 },
context,
);
expect(ok.size).toBe(4);
await expect(
store.putStream(
{ name: "b.txt", mimeType: "text/plain", stream: chunks("abc", "de"), maxBytes: 4 },
context,
),
).rejects.toBeInstanceOf(ArtifactTooLargeError);
expect(await readdir(path.join(root, "artifacts", "space-1"))).toEqual([ok.key]);
});
it("stops and cleans up when the run is cancelled mid-upload", async () => {
const { root, store } = await tempStore();
const controller = new AbortController();
async function* aborting() {
yield new TextEncoder().encode("first");
controller.abort();
yield new TextEncoder().encode("second");
}
await expect(
store.putStream(
{ name: "c.txt", mimeType: "text/plain", stream: aborting(), maxBytes: 100 },
{ ...context, signal: controller.signal },
),
).rejects.toThrow();
expect(await readdir(path.join(root, "artifacts", "space-1"))).toEqual([]);
});
it("reads whole artifacts and inclusive byte ranges", async () => {
const { store } = await tempStore();
const stored = await store.putStream(
{ name: "n.txt", mimeType: "text/plain", stream: chunks("0123456789"), maxBytes: 100 },
context,
);
expect((await readAll(await store.createReadStream(stored.key, context))).toString()).toBe(
"0123456789",
);
const middle = await store.createReadStream(stored.key, context, { start: 2, end: 5 });
expect((await readAll(middle)).toString()).toBe("2345");
const last = await store.createReadStream(stored.key, context, { start: 9, end: 9 });
expect((await readAll(last)).toString()).toBe("9");
for (const range of [
{ start: 0, end: 10 },
{ start: 5, end: 4 },
{ start: -1, end: 3 },
{ start: 1.5, end: 3 },
]) {
await expect(store.createReadStream(stored.key, context, range)).rejects.toBeInstanceOf(
RangeError,
);
}
});
it("streams large artifacts in bounded chunks", async () => {
const { store } = await tempStore();
const bytes = Buffer.alloc(700 * 1024, 7);
const stored = await store.putStream(
{
name: "big.bin",
mimeType: "text/plain",
stream: new Response(bytes).body!,
maxBytes: bytes.length,
},
context,
);
const reader = (await store.createReadStream(stored.key, context)).getReader();
let total = 0;
let largest = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
largest = Math.max(largest, value.byteLength);
}
expect(total).toBe(bytes.length);
expect(largest).toBeLessThanOrEqual(256 * 1024);
});
it("rejects keys and spaces that could address another path", async () => {
const { store } = await tempStore();
for (const key of ["../space-2/x", "a/b", ".hidden", "", "x".repeat(200)]) {
await expect(store.stat(key, context)).rejects.toThrow("Invalid artifact key");
await expect(store.createReadStream(key, context)).rejects.toThrow("Invalid artifact key");
await expect(store.remove(key, context)).rejects.toThrow("Invalid artifact key");
}
await expect(
store.putStream(
{ name: "x", mimeType: "text/plain", stream: chunks("x"), maxBytes: 10 },
{ ...context, spaceId: "../escape" },
),
).rejects.toThrow("Invalid artifact space");
});
it("does not follow a replacement symlink when streaming or resolving a local path", async () => {
const { root, store } = await tempStore();
const stored = await store.putStream(
{ name: "p.txt", mimeType: "text/plain", stream: chunks("private"), maxBytes: 100 },
context,
);
const file = path.join(root, "artifacts", "space-1", stored.key);
const target = path.join(root, "outside.txt");
await writeFile(target, "outside");
await rm(file);
await symlink(target, file);
await expect(store.createReadStream(stored.key, context)).rejects.toThrow();
await expect(store.stat(stored.key, context)).rejects.toThrow();
await expect(store.localPath(stored.key, context)).rejects.toThrow();
});
});

View File

@ -1,16 +1,23 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { mkdir, open, rm } from "node:fs/promises";
import { type FileHandle, lstat, mkdir, open, rm } from "node:fs/promises";
import path from "node:path";
import type {
AdapterContext,
ArtifactPut,
ArtifactStore,
NotificationMessage,
NotificationProvider,
import {
type AdapterContext,
type ArtifactByteRange,
type ArtifactPut,
type ArtifactStore,
type ArtifactStreamPut,
ArtifactTooLargeError,
type NotificationMessage,
type NotificationProvider,
type StoredArtifactStream,
} from "@rakazo/adapter-kit";
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
const READ_CHUNK_BYTES = 256 * 1024;
/** Space ids and artifact keys are generated ids; anything else could address another path. */
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
export class LocalArtifactStore implements ArtifactStore {
constructor(private readonly root: string) {}
@ -24,29 +31,75 @@ export class LocalArtifactStore implements ArtifactStore {
};
}
async put(artifact: ArtifactPut, context: AdapterContext) {
const id = randomUUID();
const dir = path.join(this.root, "artifacts", context.spaceId);
private spaceDir(context: AdapterContext) {
if (!SAFE_SEGMENT.test(context.spaceId)) throw new Error("Invalid artifact space");
return path.join(this.root, "artifacts", context.spaceId);
}
private file(key: string, context: AdapterContext) {
if (!SAFE_SEGMENT.test(key)) throw new Error("Invalid artifact key");
return path.join(this.spaceDir(context), key);
}
/** Creates a new owner-only file under a fresh key; never follows or replaces an existing path. */
private async create(context: AdapterContext) {
const dir = this.spaceDir(context);
await mkdir(dir, { recursive: true });
const file = path.join(dir, id);
const key = randomUUID();
const file = path.join(dir, key);
const handle = await open(
file,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | O_NOFOLLOW,
0o600,
);
return { key, file, handle };
}
private openForRead(key: string, context: AdapterContext) {
return open(this.file(key, context), constants.O_RDONLY | O_NOFOLLOW);
}
async put(artifact: ArtifactPut, context: AdapterContext) {
const { key, handle } = await this.create(context);
try {
await handle.writeFile(artifact.bytes);
} finally {
await handle.close();
}
return { id, hash: String(artifact.bytes.byteLength) };
return { id: key, hash: String(artifact.bytes.byteLength) };
}
async putStream(
artifact: ArtifactStreamPut,
context: AdapterContext,
): Promise<StoredArtifactStream> {
if (!Number.isSafeInteger(artifact.maxBytes) || artifact.maxBytes < 0) {
throw new Error("maxBytes must be a non-negative integer");
}
const { key, file, handle } = await this.create(context);
const hash = createHash("sha256");
let size = 0;
try {
try {
for await (const chunk of byteChunks(artifact.stream)) {
context.signal.throwIfAborted();
size += chunk.byteLength;
if (size > artifact.maxBytes) throw new ArtifactTooLargeError(artifact.maxBytes);
hash.update(chunk);
await writeFully(handle, chunk);
}
} finally {
await handle.close();
}
} catch (error) {
await rm(file, { force: true }).catch(() => undefined);
throw error;
}
return { key, size, sha256: hash.digest("hex") };
}
async get(id: string, context: AdapterContext) {
const handle = await open(
path.join(this.root, "artifacts", context.spaceId, id),
constants.O_RDONLY | O_NOFOLLOW,
);
const handle = await this.openForRead(id, context);
try {
return new Uint8Array(await handle.readFile());
} finally {
@ -54,9 +107,116 @@ export class LocalArtifactStore implements ArtifactStore {
}
}
async remove(id: string, context: AdapterContext) {
await rm(path.join(this.root, "artifacts", context.spaceId, id), { force: true });
async stat(key: string, context: AdapterContext) {
const handle = await this.openForRead(key, context);
try {
const info = await handle.stat();
if (!info.isFile()) throw new Error("Artifact is not a file");
return { size: info.size };
} finally {
await handle.close();
}
}
async createReadStream(key: string, context: AdapterContext, range?: ArtifactByteRange) {
const handle = await this.openForRead(key, context);
try {
const info = await handle.stat();
if (!info.isFile()) throw new Error("Artifact is not a file");
const start = range?.start ?? 0;
const end = range?.end ?? info.size - 1;
if (
range &&
(!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
end < start ||
end >= info.size)
) {
throw new RangeError("Range does not fit the artifact");
}
return fileByteStream(handle, start, end);
} catch (error) {
await handle.close();
throw error;
}
}
async localPath(key: string, context: AdapterContext) {
const file = this.file(key, context);
const info = await lstat(file);
if (!info.isFile()) throw new Error("Artifact is not a file");
return file;
}
async remove(id: string, context: AdapterContext) {
await rm(this.file(id, context), { force: true });
}
}
async function* byteChunks(
source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
): AsyncIterable<Uint8Array> {
if (!("getReader" in source)) {
yield* source;
return;
}
const reader = source.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) return;
yield value;
}
} finally {
// Stop the producer (e.g. an upload body) when the store gives up early.
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}
async function writeFully(handle: FileHandle, chunk: Uint8Array) {
let offset = 0;
while (offset < chunk.byteLength) {
const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset);
offset += bytesWritten;
}
}
/** Streams [start, end] of an open file and closes it when done, failed or cancelled. */
function fileByteStream(handle: FileHandle, start: number, end: number) {
let position = start;
let closed = false;
const close = async () => {
if (closed) return;
closed = true;
await handle.close().catch(() => undefined);
};
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const length = Math.min(end - position + 1, READ_CHUNK_BYTES);
if (length <= 0) {
await close();
controller.close();
return;
}
const buffer = new Uint8Array(length);
const { bytesRead } = await handle.read(buffer, 0, length, position);
if (bytesRead === 0) throw new Error("Artifact ended before the requested range");
position += bytesRead;
controller.enqueue(bytesRead === length ? buffer : buffer.subarray(0, bytesRead));
if (position > end) {
await close();
controller.close();
}
} catch (error) {
await close();
controller.error(error);
}
},
cancel: close,
});
}
export class CapturingNotificationProvider implements NotificationProvider {

View File

@ -1,6 +1,7 @@
export * from "./agent-connections.js";
export * from "./agent-environment.js";
export * from "./ai-consent.js";
export * from "./artifact-files.js";
export * from "./artifacts.js";
export * from "./auto-review.js";
export * from "./background-job-handlers.js";

View File

@ -0,0 +1,361 @@
import { mkdtempSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { PrismaClient } from "@rakazo/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { sessionCookieHeader } from "./index.js";
type App = { request: (input: string | Request, init?: RequestInit) => Promise<Response> };
process.env.WAKEUP_DRIVER = "memory";
process.env.SANDBOX_PROVIDER = "fake";
process.env.AGENT_RUNTIME = "scripted";
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
const describeRoutes = hasDb ? describe : describe.skip;
const ORIGIN = "http://127.0.0.1:5173";
describeRoutes("artifact HTTP routes", () => {
let app: App;
let prisma: PrismaClient;
let stop: () => Promise<void>;
const stamp = Date.now();
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-artifact-routes-"));
const video = Buffer.from("0123456789abcdefghij");
beforeAll(async () => {
const { createApp } = await import("../../../apps/api/src/app.ts");
const handles = await createApp({
databaseUrl: process.env.DATABASE_URL!,
dataDir,
sandboxProvider: "fake",
agentRuntime: "scripted",
});
app = handles.app;
prisma = handles.prisma;
stop = handles.stop;
});
afterAll(async () => {
await stop?.();
});
it("uploads by stream and serves full, ranged, cached and download responses", async () => {
const owner = await signup(app, `artifact-route-${stamp}@rakazo.test`, "Route Owner");
const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Streamer"));
const uploaded = await upload(app, owner, { botId: bot.id }, "clip.mp4", "video/mp4", video);
expect(uploaded.status).toBe(201);
const artifact = (await uploaded.json()) as { id: string; size: number; groupId: null };
expect(artifact).toMatchObject({ size: video.length, groupId: null, mimeType: "video/mp4" });
const row = await prisma.artifact.findUniqueOrThrow({ where: { id: artifact.id } });
expect(row).toMatchObject({ botId: bot.id, groupId: null, runId: null, name: "clip.mp4" });
expect(row.hash).toMatch(/^[0-9a-f]{64}$/);
const full = await get(app, owner, artifact.id);
expect(full.status).toBe(200);
expect(full.headers.get("content-type")).toBe("video/mp4");
expect(full.headers.get("accept-ranges")).toBe("bytes");
expect(full.headers.get("content-length")).toBe(String(video.length));
expect(full.headers.get("content-disposition")).toMatch(/^inline; filename="clip\.mp4"/);
expect(full.headers.get("x-content-type-options")).toBe("nosniff");
expect(full.headers.get("content-security-policy")).toContain("media-src 'self' blob:");
expect(Buffer.from(await full.arrayBuffer())).toEqual(video);
const ranged = await get(app, owner, artifact.id, { range: "bytes=2-5" });
expect(ranged.status).toBe(206);
expect(ranged.headers.get("content-range")).toBe(`bytes 2-5/${video.length}`);
expect(ranged.headers.get("content-length")).toBe("4");
expect(await ranged.text()).toBe("2345");
const suffix = await get(app, owner, artifact.id, { range: "bytes=-3" });
expect(suffix.status).toBe(206);
expect(await suffix.text()).toBe("hij");
for (const range of [`bytes=${video.length}-`, "bytes=0-1,4-5", "bytes=x-y"]) {
const rejected = await get(app, owner, artifact.id, { range });
expect(rejected.status, range).toBe(416);
expect(rejected.headers.get("content-range")).toBe(`bytes */${video.length}`);
}
const etag = full.headers.get("etag")!;
expect(etag).toBe(`"${row.hash}"`);
expect((await get(app, owner, artifact.id, { "if-none-match": etag })).status).toBe(304);
const staleIfRange = await get(app, owner, artifact.id, {
range: "bytes=0-1",
"if-range": '"other"',
});
expect(staleIfRange.status).toBe(200);
expect(Buffer.from(await staleIfRange.arrayBuffer())).toEqual(video);
const head = await app.request(`/api/artifacts/${artifact.id}`, {
method: "HEAD",
headers: { cookie: owner },
});
expect(head.status).toBe(200);
expect(head.headers.get("content-length")).toBe(String(video.length));
const download = await app.request(`/api/artifacts/${artifact.id}?download=1`, {
headers: { cookie: owner },
});
expect(download.headers.get("content-disposition")).toMatch(/^attachment; /);
await download.body?.cancel();
// Artifacts created through the inline RPC are served the same way; files download.
const notes = await rpc<{ id: string }>(app, owner, "artifacts/create", {
botId: bot.id,
name: "notes.txt",
mimeType: "text/plain",
contentBase64: Buffer.from("hello notes").toString("base64"),
});
const notesResponse = await get(app, owner, notes.id);
expect(notesResponse.headers.get("content-disposition")).toMatch(/^attachment; /);
expect(await notesResponse.text()).toBe("hello notes");
});
it("denies other users, other spaces, archived threads and signed-out requests", async () => {
const owner = await signup(app, `artifact-owner-${stamp}@rakazo.test`, "Artifact Owner");
const intruder = await signup(app, `artifact-intruder-${stamp}@rakazo.test`, "Intruder");
const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Owner Bot"));
const bot2 = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Owner Bot 2"));
const group = await rpc<{ id: string }>(app, owner, "groups/create", {
name: "Owner Group",
botIds: [bot.id, bot2.id],
});
const botArtifact = (await (
await upload(app, owner, { botId: bot.id }, "a.mp3", "audio/mpeg", video)
).json()) as { id: string };
const groupUpload = await upload(
app,
owner,
{ groupId: group.id },
"g.png",
"image/png",
video,
);
expect(groupUpload.status).toBe(201);
const groupArtifact = (await groupUpload.json()) as { id: string; groupId: string };
expect(groupArtifact.groupId).toBe(group.id);
expect((await get(app, owner, groupArtifact.id)).status).toBe(200);
// Another user: reads and uploads into the owner's threads look like missing resources.
expect((await get(app, intruder, botArtifact.id)).status).toBe(404);
expect((await get(app, intruder, groupArtifact.id)).status).toBe(404);
expect(
(await upload(app, intruder, { botId: bot.id }, "x.mp4", "video/mp4", video)).status,
).toBe(404);
expect(
(await upload(app, intruder, { groupId: group.id }, "x.mp4", "video/mp4", video)).status,
).toBe(404);
// Signed out.
expect((await app.request(`/api/artifacts/${botArtifact.id}`)).status).toBe(401);
expect(
(
await app.request(`/api/artifacts?botId=${bot.id}&name=x.mp4`, {
method: "POST",
headers: { "content-type": "video/mp4" },
body: video,
})
).status,
).toBe(401);
// An explicit space header must name the artifact's space, like the RPC.
const other = await rpc<{ id: string }>(app, owner, "spaces/create", { name: "Other space" });
expect((await get(app, owner, botArtifact.id, { "x-rakazo-space-id": other.id })).status).toBe(
404,
);
expect(
(
await upload(app, owner, { botId: bot.id }, "x.mp4", "video/mp4", video, {
"x-rakazo-space-id": other.id,
})
).status,
).toBe(404);
// Archived threads stop serving their artifacts, as artifacts.get does.
await rpc(app, owner, "groups/archive", { groupId: group.id });
expect((await get(app, owner, groupArtifact.id)).status).toBe(404);
await rpc(app, owner, "bots/archive", { botId: bot.id });
expect((await get(app, owner, botArtifact.id)).status).toBe(404);
});
it("keeps a shared-space member out of another member's artifacts", async () => {
const owner = await signup(app, `artifact-shared-owner-${stamp}@rakazo.test`, "Shared Owner");
const member = await signup(app, `artifact-shared-member-${stamp}@rakazo.test`, "Member");
const shared = await rpc<{ id: string }>(app, owner, "spaces/create", { name: "Shared" });
const sharedRow = await prisma.space.findUniqueOrThrow({
where: { id: shared.id },
select: { organizationId: true },
});
const memberActor = await rpc<{ userId: string }>(app, member, "me");
await prisma.member.deleteMany({ where: { userId: memberActor.userId } });
await prisma.member.create({
data: {
id: `artifact-shared-org-${stamp}`,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
await prisma.spaceMember.create({
data: {
id: `artifact-shared-member-${stamp}`,
spaceId: shared.id,
organizationId: sharedRow.organizationId,
userId: memberActor.userId,
role: "member",
createdAt: new Date(),
},
});
const ownerBot = await rpc<{ id: string }>(
app,
owner,
"bots/create",
botInput("Shared Owner Bot"),
shared.id,
);
const artifact = (await (
await upload(app, owner, { botId: ownerBot.id }, "s.webm", "video/webm", video, {
"x-rakazo-space-id": shared.id,
})
).json()) as { id: string };
expect((await get(app, owner, artifact.id)).status).toBe(200);
expect((await get(app, member, artifact.id)).status).toBe(404);
expect((await get(app, member, artifact.id, { "x-rakazo-space-id": shared.id })).status).toBe(
404,
);
expect(
(
await upload(app, member, { botId: ownerBot.id }, "x.mp4", "video/mp4", video, {
"x-rakazo-space-id": shared.id,
})
).status,
).toBe(404);
});
it("enforces type and size limits on uploads, including chunked bodies", async () => {
const owner = await signup(app, `artifact-limits-${stamp}@rakazo.test`, "Limits");
const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Limits Bot"));
const before = await prisma.artifact.count({ where: { botId: bot.id } });
expect((await upload(app, owner, { botId: bot.id }, "a.html", "text/html", video)).status).toBe(
415,
);
expect((await upload(app, owner, { botId: bot.id }, "", "video/mp4", video)).status).toBe(400);
expect(
(await upload(app, owner, { botId: bot.id }, "e.mp4", "video/mp4", Buffer.alloc(0))).status,
).toBe(400);
const declared = await app.request(
new Request(`${ORIGIN}/api/artifacts?botId=${bot.id}&name=big.mp4`, {
method: "POST",
headers: {
cookie: owner,
"content-type": "video/mp4",
"content-length": String(500 * 1024 * 1024 + 1),
},
body: new ReadableStream({ start: (controller) => controller.close() }),
duplex: "half",
} as RequestInit & { duplex: "half" }),
);
expect(declared.status).toBe(413);
// No Content-Length: the 25 MiB file cap is enforced while streaming to disk.
const mib = new Uint8Array(1024 * 1024);
let sent = 0;
const chunked = await app.request(
new Request(`${ORIGIN}/api/artifacts?botId=${bot.id}&name=big.txt`, {
method: "POST",
headers: { cookie: owner, "content-type": "text/plain" },
body: new ReadableStream({
pull(controller) {
if (sent++ > 26) controller.close();
else controller.enqueue(mib);
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" }),
);
expect(chunked.status).toBe(413);
expect(await prisma.artifact.count({ where: { botId: bot.id } })).toBe(before);
const me = await rpc<{ spaceId: string }>(app, owner, "me");
const spaceDir = path.join(dataDir, "artifacts", me.spaceId);
expect(readdirSafe(spaceDir)).toEqual([]);
});
});
function readdirSafe(dir: string) {
try {
return readdirSync(dir);
} catch {
return [];
}
}
function botInput(name: string) {
return {
name,
title: name,
description: "artifact routes",
instructions: "",
notifyOnFinish: true,
};
}
async function upload(
app: App,
cookie: string,
target: { botId: string } | { groupId: string },
name: string,
mimeType: string,
bytes: Buffer,
headers: Record<string, string> = {},
) {
const query = new URLSearchParams({ ...target, name });
return app.request(`/api/artifacts?${query}`, {
method: "POST",
headers: { cookie, origin: ORIGIN, "content-type": mimeType, ...headers },
body: bytes,
});
}
function get(app: App, cookie: string, id: string, headers: Record<string, string> = {}) {
return app.request(`/api/artifacts/${id}`, { headers: { cookie, ...headers } });
}
async function signup(app: App, email: string, name: string) {
const response = await app.request("/api/auth/sign-up/email", {
method: "POST",
headers: { "content-type": "application/json", origin: ORIGIN },
body: JSON.stringify({ email, password: "test-password-123", name }),
});
expect(response.status).toBeLessThan(400);
return sessionCookieHeader(response);
}
async function rpc<T>(
app: App,
cookie: string,
procedure: string,
body: unknown = {},
spaceId?: string,
): Promise<T> {
const response = await app.request(`/rpc/${procedure}`, {
method: "POST",
headers: {
"content-type": "application/json",
cookie,
origin: ORIGIN,
...(spaceId ? { "x-rakazo-space-id": spaceId } : {}),
},
body: JSON.stringify({ json: body }),
});
const text = await response.text();
const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } };
if (response.status >= 400 || parsed.error) {
throw new Error(`${procedure} ${response.status}: ${parsed.error?.message ?? text}`);
}
return parsed.json as T;
}

View File

@ -98,6 +98,7 @@ async function main() {
"packages/testkit/src/journeys.test.ts",
"packages/testkit/src/authorization.test.ts",
"packages/testkit/src/attachments.test.ts",
"packages/testkit/src/artifact-routes.test.ts",
"packages/testkit/src/voice.test.ts",
"packages/testkit/src/search.test.ts",
"packages/testkit/src/inbox.test.ts",