diff --git a/apps/api/src/ai-consent.test.ts b/apps/api/src/ai-consent.test.ts index caf5be5..e76c3e7 100644 --- a/apps/api/src/ai-consent.test.ts +++ b/apps/api/src/ai-consent.test.ts @@ -29,6 +29,7 @@ function setup() { }, spaceMember: { findUnique: vi.fn(async () => ({ role: "owner" })) }, deploymentSettings: { findUnique: vi.fn(async () => null) }, + integrationProviderConfig: { findUnique: vi.fn(async () => null) }, aiDataConsent: { findMany: vi.fn(async () => []), upsert }, $transaction: vi.fn(async (queries) => Promise.all(queries)), }, @@ -134,6 +135,49 @@ describe("consent grants", () => { expect(JSON.stringify(status.recipients)).not.toContain("supermemory"); expect(deps.prisma.spaceMemoryConfig.findUnique).not.toHaveBeenCalled(); }); + it("discloses the deployment's media servers before model use, keyed by server", async () => { + const { deps, actor } = setup(); + const settings = { + comfyui: { baseUrl: "http://127.0.0.1:8188", apiKey: "comfy-key", imageModel: "sdxl" }, + transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" }, + }; + vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({ + id: "media", + ciphertext: "fake-media-ciphertext", + } as never); + deps.secrets = { + load: vi.fn(() => JSON.stringify(settings)), + } as unknown as RouterDeps["secrets"]; + const status = await aiConsentStatus(deps, actor, { botId: "bot", uses: ["model"] }); + expect(status.recipients.map((recipient) => recipient.name)).toEqual([ + "ComfyUI (http://127.0.0.1:8188)", + "Transcription (http://127.0.0.1:8080)", + ]); + expect(status.recipients[0]?.detail).toBe("Model: sdxl."); + expect(JSON.stringify(status)).not.toContain("comfy-key"); + expect((await aiConsentStatus(deps, actor, { uses: ["voice"] })).recipients).toHaveLength(1); + }); + it("discloses the transcription server for dictation when the voice provider cannot transcribe", async () => { + const { deps, actor } = setup(); + vi.mocked(deps.prisma.integrationProviderConfig.findUnique).mockResolvedValue({ + id: "media", + ciphertext: "fake-media-ciphertext-voice", + } as never); + deps.secrets = { + load: vi.fn(() => + JSON.stringify({ comfyui: null, transcription: { baseUrl: "http://127.0.0.1:8080/v1" } }), + ), + } as unknown as RouterDeps["secrets"]; + vi.mocked(deps.prisma.spaceVoicePreference.findFirst).mockResolvedValue({ + credential: { provider: "kokoro" }, + voiceId: "af_heart", + } as never); + const status = await aiConsentStatus(deps, actor, { uses: ["voice"] }); + expect(status.recipients.map((recipient) => [recipient.name, recipient.use])).toEqual([ + ["Local voice", "voice"], + ["Transcription (http://127.0.0.1:8080)", "voice"], + ]); + }); it("returns an operator policy without requiring a hosted provider", async () => { const { deps, actor } = setup(); deps.env.privacyPolicyUrl = "https://example.com/privacy"; diff --git a/apps/api/src/ai-consent.ts b/apps/api/src/ai-consent.ts index 8c595b5..e10c905 100644 --- a/apps/api/src/ai-consent.ts +++ b/apps/api/src/ai-consent.ts @@ -3,9 +3,13 @@ import { ORPCError } from "@orpc/server"; import { aiRecipient, cloudAgentsEnabled, + loadMediaSettings, + MEDIA_COMFYUI_PROVIDER_ID, + MEDIA_TRANSCRIPTION_PROVIDER_ID, parseModelSecret, selectConfiguredModel, toStringRecord, + voiceCatalogEntry, } from "@rakazo/adapters"; import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts"; import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts"; @@ -183,6 +187,21 @@ export async function aiConsentStatus( } for (const voice of voices) add(aiRecipient({ provider: voice.credential.provider, use: "voice" })); + if (uses.includes("voice")) { + // Dictation goes to the owner's transcription server when the voice provider cannot transcribe. + const transcription = (await loadMediaSettings(deps)).transcription; + const credential = transcription ? await findDefaultVoiceCredential(deps.prisma, actor) : null; + if (transcription && !(credential && voiceCatalogEntry(credential.provider)?.transcribe)) { + add( + aiRecipient({ + provider: MEDIA_TRANSCRIPTION_PROVIDER_ID, + use: "voice", + baseUrl: transcription.baseUrl, + modelId: transcription.model, + }), + ); + } + } if (includesMemory) { if (memoryOwner) { const memory = await deps.prisma.spaceMemoryConfig.findUnique({ @@ -210,6 +229,30 @@ export async function aiConsentStatus( ) { add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" })); } + if (uses.includes("model")) { + // Media tools and card actions send prompts, images, video and audio to the owner's servers. + const media = await loadMediaSettings(deps); + if (media.comfyui) { + add( + aiRecipient({ + provider: MEDIA_COMFYUI_PROVIDER_ID, + use: "model", + baseUrl: media.comfyui.baseUrl, + modelId: media.comfyui.imageModel, + }), + ); + } + if (media.transcription) { + add( + aiRecipient({ + provider: MEDIA_TRANSCRIPTION_PROVIDER_ID, + use: "model", + baseUrl: media.transcription.baseUrl, + modelId: media.transcription.model, + }), + ); + } + } return { scope: createHash("sha256") .update(JSON.stringify([actor.userId, actor.spaceId])) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d5da6b7..fd2f524 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -14,6 +14,7 @@ import type { ComposioProvider, ConnectorRegistry, DestinationEmulator, + MediaOperationRegistration, RemoteConnectorDependencies, } from "@rakazo/adapters"; import { @@ -24,6 +25,8 @@ import { createCloudAgentConnection, createConnectorStack, createJobReconciler, + createMediaOperations, + createMediaRuntime, createMessagingContextLoader, createMessagingTeamChatSender, createProfileConsolidationScheduler, @@ -47,6 +50,7 @@ import { isPipedreamEnabled, LocalAgentHomeStore, LocalArtifactStore, + loadMediaSettings, McpConnector, McpOAuthBroker, messagingPlatformsFromEnv, @@ -92,6 +96,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"; @@ -141,6 +146,8 @@ export async function createApp( email?: TransactionalEmailProvider; remoteConnectors?: RemoteConnectorDependencies; logger?: Logger; + /** Test seam: media operations instead of the configured ones. */ + mediaOperations?: MediaOperationRegistration[]; } = {}, ): Promise { const { @@ -153,6 +160,7 @@ export async function createApp( email: emailOverride, remoteConnectors, logger: loggerOverride, + mediaOperations, ...envOverrides } = overrides; const env = { ...loadEnv(process.env), ...envOverrides }; @@ -383,6 +391,15 @@ export async function createApp( CLOUD_AGENT_SPACE_ID: env.cloudAgentSpaceId, }); const shutdown = new AbortController(); + const media = createMediaRuntime({ + prisma, + events, + jobs, + artifacts, + registrations: + mediaOperations ?? + createMediaOperations({ loadSettings: () => loadMediaSettings({ prisma, secrets }) }), + }); const executor = createRunExecutor({ prisma, runtime, @@ -419,6 +436,7 @@ export async function createApp( messaging: messaging ? createMessagingContextLoader(prisma) : undefined, web: createWebProvider(), cloudAgent, + media, shutdownSignal: shutdown.signal, }); @@ -436,6 +454,7 @@ export async function createApp( deploymentModelKey: env.deploymentModelKey, messaging, cloudAgent, + media, }); if (inMemoryJobs) { await inMemoryJobs.start(jobHandlers); @@ -454,12 +473,14 @@ export async function createApp( reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }), reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }), reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }), + reconcileMediaJobs: () => media.reconcile(), }) : undefined; reconciler?.start(); const router = createRouter({ cloudAgent, + media, prisma, events, auth, @@ -522,7 +543,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"); @@ -585,6 +606,13 @@ 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, + trustedOrigin: (origin) => isTrustedOrigin(origin, env), + }); mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs }); // Shared with stop so a shutdown during retry delays does not restart polling. let messagingStopped = false; diff --git a/apps/api/src/artifact-http.test.ts b/apps/api/src/artifact-http.test.ts new file mode 100644 index 0000000..d95711f --- /dev/null +++ b/apps/api/src/artifact-http.test.ts @@ -0,0 +1,275 @@ +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { ArtifactStore } from "@rakazo/adapter-kit"; +import { LocalArtifactStore } from "@rakazo/adapters"; +import type { PrismaClient } from "@rakazo/db"; +import { Hono } from "hono"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + API_SERVER_OPTIONS, + contentDisposition, + mountArtifactHttpRoutes, + parseByteRange, + stallGuardedChunks, + UploadStalledError, +} 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) { + 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, + trustedOrigin: (origin) => origin === "http://127.0.0.1:5173", + }); + 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("refuses uploads from untrusted origins before reading the session", async () => { + const sessionUserId = vi.fn(async () => "user-1"); + const { hono, artifacts } = app(sessionUserId); + const response = await hono.request("/api/artifacts?botId=bot-1&name=a.txt", { + method: "POST", + headers: { "content-type": "text/plain", origin: "https://evil.example" }, + body: "data", + }); + expect(response.status).toBe(403); + expect(sessionUserId).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(() => 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: "", + }); + 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(); + }); +}); + +describe("upload timeouts", () => { + const dirs: string[] = []; + afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it("gives the server time for large uploads but keeps the header timeout", () => { + const server = createServer(API_SERVER_OPTIONS); + expect(server.requestTimeout).toBe(30 * 60 * 1000); + expect(server.headersTimeout).toBe(createServer().headersTimeout); + }); + + /** A body that sends `chunks` at `gapMs` intervals, then stays open forever if `hang`. */ + function drip(chunks: string[], gapMs: number, hang: boolean) { + const cancel = vi.fn(() => new Promise(() => undefined)); + let sent = 0; + const body = new ReadableStream({ + async pull(controller) { + if (sent === chunks.length) { + if (!hang) controller.close(); + else await new Promise(() => undefined); + return; + } + if (sent > 0) await new Promise((resolve) => setTimeout(resolve, gapMs)); + controller.enqueue(new TextEncoder().encode(chunks[sent++])); + }, + cancel, + }); + return { body, cancel }; + } + + it("throws once the body stops sending and cancels it without waiting", async () => { + const { body, cancel } = drip(["ab", "cd"], 5, true); + const seen: string[] = []; + await expect( + (async () => { + for await (const chunk of stallGuardedChunks(body, 50)) { + seen.push(new TextDecoder().decode(chunk)); + } + })(), + ).rejects.toBeInstanceOf(UploadStalledError); + expect(seen).toEqual(["ab", "cd"]); + expect(cancel).toHaveBeenCalled(); + }); + + async function postDrip(stallMs: number, body: ReadableStream) { + const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifact-upload-")); + dirs.push(root); + const create = vi.fn(async ({ data }: { data: Record }) => ({ + id: "art-1", + createdAt: new Date(), + ...data, + })); + const prisma = { + artifact: { create }, + spaceMember: { + findFirst: vi.fn(async () => ({ + userId: "user-1", + spaceId: "space-1", + member: { user: { email: "user@example.test" } }, + })), + }, + deploymentSettings: { findUnique: vi.fn(async () => null) }, + bot: { findFirst: vi.fn(async () => ({ id: "bot-1" })) }, + }; + const hono = new Hono(); + mountArtifactHttpRoutes(hono, { + prisma: prisma as unknown as PrismaClient, + artifacts: new LocalArtifactStore(root), + sessionUserId: async () => "user-1", + trustedOrigin: () => true, + uploadStallTimeoutMs: stallMs, + }); + const response = await hono.request( + new Request("http://localhost/api/artifacts?botId=bot-1&name=clip.mp4", { + method: "POST", + headers: { "content-type": "video/mp4" }, + body, + duplex: "half", + } as RequestInit & { duplex: "half" }), + ); + // The store creates the space directory before it reads the body, so it must exist here. + const stored = await readdir(path.join(root, "artifacts", "space-1")); + return { response, create, stored }; + } + + it("aborts a stalled upload with 408 and deletes the partial file", async () => { + const { body, cancel } = drip(["partial video bytes"], 0, true); + const { response, create, stored } = await postDrip(50, body); + expect(response.status).toBe(408); + expect(await response.json()).toEqual({ error: "Upload stalled." }); + expect(cancel).toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(stored).toEqual([]); + }); + + it("keeps a slow but steady upload going past the stall timeout", async () => { + const { body } = drip(["a", "b", "c", "d", "e", "f"], 30, false); + const { response, create, stored } = await postDrip(100, body); + expect(response.status).toBe(201); + expect(create).toHaveBeenCalledWith({ + data: expect.objectContaining({ size: 6, mimeType: "video/mp4", botId: "bot-1" }), + }); + expect(stored).toHaveLength(1); + }); +}); diff --git a/apps/api/src/artifact-http.ts b/apps/api/src/artifact-http.ts new file mode 100644 index 0000000..d7485fd --- /dev/null +++ b/apps/api/src/artifact-http.ts @@ -0,0 +1,336 @@ +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 type { PrismaClient } from "@rakazo/db"; +import { createGroupRepos, createRepos, IsolationError, requireMembership } from "@rakazo/db"; +import type { Context, Hono } from "hono"; +import { cancelBody } from "./http-body.js"; + +export const ARTIFACT_HTTP_PATH = "/api/artifacts"; + +/** + * Node's default 5-minute requestTimeout cuts off a 500 MiB video upload on any uplink slower than + * about 14 Mbit/s; 30 minutes fits it at about 2.5 Mbit/s. headersTimeout keeps its default, and + * the per-upload stall timeout stops a slow-drip client from holding a connection that long. + */ +export const API_SERVER_OPTIONS = { requestTimeout: 30 * 60 * 1000 }; +/** An upload that sends no bytes for this long is aborted and its partial file deleted. */ +export const ARTIFACT_UPLOAD_STALL_TIMEOUT_MS = 60_000; + +export class UploadStalledError extends Error { + constructor() { + super("Upload stalled"); + this.name = "UploadStalledError"; + } +} + +/** + * Yields the body's chunks and throws UploadStalledError when the next chunk takes longer than + * stallMs. Only time spent waiting for the client counts, not time spent writing to disk. + */ +export async function* stallGuardedChunks( + body: ReadableStream, + stallMs: number, +): AsyncGenerator { + const reader = body.getReader(); + try { + for (;;) { + let timer: ReturnType | undefined; + const stalled = new Promise((_, reject) => { + timer = setTimeout(() => reject(new UploadStalledError()), stallMs); + }); + try { + const { done, value } = await Promise.race([reader.read(), stalled]); + if (done) return; + yield value; + } finally { + clearTimeout(timer); + } + } + } finally { + // Never wait on the client here: a stalled or hostile body may never confirm the cancel. + void reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} + +export type ArtifactHttpDeps = { + prisma: PrismaClient; + artifacts: ArtifactStore; + /** The signed-in user (cookie or bearer session), or null. */ + sessionUserId: (request: Request) => Promise; + /** Same rule as CORS. Raw uploads can be CORS "simple" requests, so the origin is checked. */ + trustedOrigin: (origin: string) => boolean; + /** Overrides ARTIFACT_UPLOAD_STALL_TIMEOUT_MS (tests). */ + uploadStallTimeoutMs?: number; +}; + +/** 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; + //