evOo-local/packages/adapters/src/artifacts.test.ts
Claude 62e9bc67f1
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
2026-10-03 13:31:39 +00:00

198 lines
7.4 KiB
TypeScript

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", 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 })));
});
describe("LocalArtifactStore", () => {
it("creates artifact files with owner-only permissions", async () => {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifacts-"));
dirs.push(root);
const store = new LocalArtifactStore(root);
const stored = await store.put(
{ name: "private.txt", mimeType: "text/plain", bytes: new TextEncoder().encode("private") },
context,
);
const info = await stat(path.join(root, "artifacts", "space-1", stored.id));
expect(info.mode & 0o777).toBe(0o600);
});
it("does not follow a replacement symlink when reading an artifact", async () => {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifacts-"));
dirs.push(root);
const store = new LocalArtifactStore(root);
const stored = await store.put(
{ name: "private.txt", mimeType: "text/plain", bytes: new TextEncoder().encode("private") },
context,
);
const file = path.join(root, "artifacts", "space-1", stored.id);
const target = path.join(root, "outside.txt");
await writeFile(target, "outside");
await rm(file);
await symlink(target, file);
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();
});
});