From f30e5b26751cccf1c2507407888bd01f024a64da Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 29 Aug 2026 19:33:37 -0300 Subject: [PATCH] feat(video): connect tenant-bound drill-down lifecycle and multiresolution variants (FU-08) (#12006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FU-08 (Refs #11655): drill-down producer/consumer lifecycle on top of the existing cache substrate, without modifying it — new VideoDrilldownLifecycle (opaque sha256 handles, principal-bound resolve/delete with no existence oracle, preview/standard/detail multiresolution variants, 8-frame/32MiB page budget) plus a new authenticated remote-consumer route, both opt-in (default false). --- .../11655-video-bridge-drilldown-lifecycle.md | 6 + .../api/v1/video-bridge/drilldown/route.ts | 155 +++++++ .../videoBridgeDrilldownLifecycle.ts | 401 ++++++++++++++++++ stryker.conf.json | 1 + .../videoBridgeDrilldownLifecycle.test.ts | 271 ++++++++++++ ...eo-bridge-drilldown-consumer-route.test.ts | 214 ++++++++++ 6 files changed, 1048 insertions(+) create mode 100644 changelog.d/features/11655-video-bridge-drilldown-lifecycle.md create mode 100644 src/app/api/v1/video-bridge/drilldown/route.ts create mode 100644 src/lib/guardrails/videoBridgeDrilldownLifecycle.ts create mode 100644 tests/unit/guardrails/videoBridgeDrilldownLifecycle.test.ts create mode 100644 tests/unit/video-bridge-drilldown-consumer-route.test.ts diff --git a/changelog.d/features/11655-video-bridge-drilldown-lifecycle.md b/changelog.d/features/11655-video-bridge-drilldown-lifecycle.md new file mode 100644 index 0000000000..4091eeaee0 --- /dev/null +++ b/changelog.d/features/11655-video-bridge-drilldown-lifecycle.md @@ -0,0 +1,6 @@ +- Add a tenant-bound Video Bridge drill-down lifecycle on top of the existing secure cache + substrate: opaque hashed handles (never raw session/video identifiers), preview/standard/detail + multiresolution variants resampled on read, response pagination capped at 8 frames and 32 MiB, + and a new authenticated `/api/v1/video-bridge/drilldown` consumer route that stays disabled for + remote access by default and denies cross-key access with the same response as a nonexistent + handle (no existence oracle). diff --git a/src/app/api/v1/video-bridge/drilldown/route.ts b/src/app/api/v1/video-bridge/drilldown/route.ts new file mode 100644 index 0000000000..f7cac92ae9 --- /dev/null +++ b/src/app/api/v1/video-bridge/drilldown/route.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; + +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; + +import { + isVideoBridgeDrilldownRemoteAccessEnabled, + VIDEO_DRILLDOWN_VARIANTS, + VideoDrilldownLifecycle, + type VideoDrilldownVariant, +} from "@/lib/guardrails/videoBridgeDrilldownLifecycle"; +import { VideoDrilldownCache } from "@/lib/guardrails/videoBridgeDrilldown"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +// A single process-wide lifecycle backs both this authenticated consumer route and any +// future in-process producer (the video bridge guardrail, once it threads a per-request +// opt-in flag through). It is intentionally a *different* cache instance from the internal +// loopback broker route's — this route never accepts raw frames from a remote caller, only +// opaque handles minted by an in-process producer, so there is no reason to share state with +// the loopback-only extraction broker surface. +const sharedLifecycle = new VideoDrilldownLifecycle({ + cache: new VideoDrilldownCache({ + maxEntries: 64, + maxEntriesPerPrincipal: 16, + maxBytesPerPrincipal: 64 * 1024 * 1024, + maxTotalBytes: 256 * 1024 * 1024, + ttlMs: 10 * 60 * 1000, + }), +}); + +const HandleSchema = z + .string() + .regex(/^[0-9a-f]{64}$/, "handle must be an opaque 64-character hex value"); +const VariantSchema = z.enum(VIDEO_DRILLDOWN_VARIANTS as [VideoDrilldownVariant, ...VideoDrilldownVariant[]]); +const BoundedIntSchema = z + .string() + .regex(/^\d{1,9}$/) + .transform(Number); +const NonNegativeNumberSchema = z + .string() + .refine((value) => value.length > 0 && value.length <= 64 && Number.isFinite(Number(value))) + .transform(Number) + .refine((value) => value >= 0); + +const ReadQuerySchema = z + .object({ + end: NonNegativeNumberSchema.optional(), + frames: BoundedIntSchema.pipe(z.number().int().min(1).max(8)).optional(), + handle: HandleSchema, + page: BoundedIntSchema.pipe(z.number().int().min(0)).optional(), + start: NonNegativeNumberSchema.optional(), + variant: VariantSchema.optional(), + }) + .strict(); +const DeleteQuerySchema = z.object({ handle: HandleSchema }).strict(); + +function queryRecord(searchParams: URLSearchParams): Record { + const values: Record = {}; + for (const [key, value] of searchParams) values[key] = value; + return values; +} + +function corsJson(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...CORS_HEADERS, "Content-Type": "application/json", "Cache-Control": "no-store" }, + }); +} + +function corsError(status: number, message: string, type: string): Response { + return corsJson(status, buildErrorBody(status, message, null, { type })); +} + +export interface VideoBridgeDrilldownRouteDependencies { + isRemoteAccessEnabled?: () => boolean; + lifecycle?: VideoDrilldownLifecycle; +} + +async function resolvePrincipal( + request: Request +): Promise<{ error?: Response; principalId?: string }> { + const apiKey = extractApiKey(request); + if (!apiKey) { + return { error: corsError(401, "Authentication is required", "authentication_required") }; + } + if (!(await isValidApiKey(apiKey))) { + return { error: corsError(401, "The provided API key is invalid", "authentication_required") }; + } + const policy = await enforceApiKeyPolicy(request, null); + if (policy.rejection) return { error: policy.rejection }; + const principalId = policy.apiKeyInfo?.id; + if (!principalId) { + return { error: corsError(401, "Authentication is required", "authentication_required") }; + } + return { principalId }; +} + +export const OPTIONS = async (): Promise => handleCorsOptions(); + +export async function handleVideoBridgeDrilldownConsumerRequest( + request: Request, + dependencies: VideoBridgeDrilldownRouteDependencies = {} +): Promise { + const isRemoteAccessEnabled = + dependencies.isRemoteAccessEnabled ?? isVideoBridgeDrilldownRemoteAccessEnabled; + if (!isRemoteAccessEnabled()) { + return corsError( + 403, + "Video Bridge drill-down remote access is disabled", + "feature_disabled" + ); + } + if (request.method !== "GET" && request.method !== "DELETE") { + return corsError(405, "Method not allowed", "invalid_request"); + } + const resolved = await resolvePrincipal(request); + if (resolved.error) return resolved.error; + const principalId = resolved.principalId!; + const lifecycle = dependencies.lifecycle ?? sharedLifecycle; + const url = new URL(request.url); + const query = queryRecord(url.searchParams); + + if (request.method === "DELETE") { + const parsed = DeleteQuerySchema.safeParse(query); + if (!parsed.success) return corsError(400, "A valid drill-down handle is required", "invalid_request"); + const removed = lifecycle.deleteHandle(principalId, parsed.data.handle); + return corsJson(200, { removed }); + } + + const parsed = ReadQuerySchema.safeParse(query); + if (!parsed.success) return corsError(400, "Invalid Video Bridge drill-down query", "invalid_request"); + const page = await lifecycle.resolve(principalId, parsed.data.handle, { + endSeconds: parsed.data.end, + frameCount: parsed.data.frames, + page: parsed.data.page, + startSeconds: parsed.data.start, + variant: parsed.data.variant, + }); + if (!page) { + return corsError(404, "Video Bridge drill-down result was not found", "not_found"); + } + return corsJson(200, page); +} + +export async function GET(request: Request): Promise { + return handleVideoBridgeDrilldownConsumerRequest(request); +} + +export async function DELETE(request: Request): Promise { + return handleVideoBridgeDrilldownConsumerRequest(request); +} diff --git a/src/lib/guardrails/videoBridgeDrilldownLifecycle.ts b/src/lib/guardrails/videoBridgeDrilldownLifecycle.ts new file mode 100644 index 0000000000..94728e9b98 --- /dev/null +++ b/src/lib/guardrails/videoBridgeDrilldownLifecycle.ts @@ -0,0 +1,401 @@ +// Video Bridge drill-down production/consumption lifecycle (FU-08, #11655). +// +// `videoBridgeDrilldown.ts` is the secure cache substrate: it stores frames keyed by +// (principalId, sessionId, videoRef) and already refuses cross-principal reads/deletes +// without revealing whether an entry exists. This module adds the missing lifecycle on +// top of it, without touching the frozen-shape substrate file: +// - opaque hashed handles, so a consumer never needs (and never sees) the raw +// sessionId/videoRef the substrate indexes by — both are minted server-side here; +// - preview/standard/detail multiresolution variants, resampled on read (never stored +// more than once, so producing stays "zero overhead" beyond the opt-in call itself); +// - response pagination capped at 8 frames and a bounded response-byte budget; +// - a small handle registry with its own TTL/quota, cleaned up alongside the cache. +import { createHash, randomUUID } from "node:crypto"; + +import sharp from "sharp"; + +import { + VIDEO_DRILLDOWN_MAX_ENTRY_BYTES, + VideoDrilldownCache, + type VideoDrilldownCacheOptions, + type VideoDrilldownFrame, + type VideoDrilldownPutValue, + type VideoDrilldownResult, +} from "./videoBridgeDrilldown"; + +export type VideoDrilldownVariant = "preview" | "standard" | "detail"; + +export const VIDEO_DRILLDOWN_VARIANTS: readonly VideoDrilldownVariant[] = [ + "preview", + "standard", + "detail", +]; + +export interface VideoDrilldownVariantPreset { + /** Frames are only ever shrunk toward this ceiling, never upscaled. */ + maxDimension: number; + /** Default page size for this variant when the caller does not ask for a specific count. */ + defaultPageFrames: number; +} + +export const VIDEO_DRILLDOWN_VARIANT_PRESETS: Record< + VideoDrilldownVariant, + VideoDrilldownVariantPreset +> = { + preview: { maxDimension: 320, defaultPageFrames: 3 }, + standard: { maxDimension: 640, defaultPageFrames: 6 }, + detail: { maxDimension: 1280, defaultPageFrames: 8 }, +}; + +export const VIDEO_DRILLDOWN_MAX_PAGE_FRAMES = 8; +export const VIDEO_DRILLDOWN_MAX_PAGE_BYTES = VIDEO_DRILLDOWN_MAX_ENTRY_BYTES; + +const HANDLE_PATTERN = /^[0-9a-f]{64}$/; + +function truthyFlag(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; +} + +/** Producing drill-down artifacts stays off unless an operator opts the deployment in. */ +export function isVideoBridgeDrilldownProductionEnabled( + env: Record = process.env +): boolean { + return truthyFlag(env.OMNIROUTE_VIDEO_BRIDGE_DRILLDOWN_ENABLED); +} + +/** Remote (authenticated API-key) consumption stays off unless an operator opts in. */ +export function isVideoBridgeDrilldownRemoteAccessEnabled( + env: Record = process.env +): boolean { + return truthyFlag(env.OMNIROUTE_VIDEO_BRIDGE_DRILLDOWN_REMOTE_ENABLED); +} + +interface HandleEntry { + createdAt: number; + expiresAt: number; + principalId: string; + principalKey: string; + sessionId: string; + videoRef: string; +} + +export interface VideoDrilldownProducePayload { + derivation: VideoDrilldownPutValue["derivation"]; + durationSeconds: number; + frames: readonly VideoDrilldownPutValue["frames"][number][]; +} + +export interface VideoDrilldownProduceOptions { + signal?: AbortSignal; +} + +export interface VideoDrilldownProduceResult { + expiresAt: number; + handle: string; +} + +export interface VideoDrilldownResolveQuery { + endSeconds?: number; + frameCount?: number; + /** Test/defense-in-depth override; production callers use the exported constant. */ + maxPageBytes?: number; + page?: number; + startSeconds?: number; + variant?: VideoDrilldownVariant; +} + +export interface VideoDrilldownPage { + derivation: VideoDrilldownResult["derivation"]; + durationSeconds: number; + focusWindow?: VideoDrilldownResult["focusWindow"]; + frames: VideoDrilldownFrame[]; + hasMore: boolean; + page: number; + variant: VideoDrilldownVariant; +} + +export interface VideoDrilldownUsage { + bytes: number; + entries: number; + totalBytes: number; + totalEntries: number; +} + +export interface VideoDrilldownLifecycleOptions { + cache: VideoDrilldownCache; + maxHandles?: number; + maxHandlesPerPrincipal?: number; + now?: () => number; + /** Handle bookkeeping TTL. Defaults to the cache's own TTL when it is exposed via `ttlMs`. */ + ttlMs?: number; +} + +/** + * Derives the per-principal bookkeeping key from an API-key RECORD ID (`apiKeyInfo.id`), + * never from the secret itself. SHA-256 is the correct primitive here and a password KDF + * would be wrong: this value is a deterministic index key that must be recomputable on + * every lookup, not a stored credential verifier. Same construction and rationale as + * `src/lib/db/apiKeys.ts` and `videoBridgeDrilldown.ts`; CodeQL/semgrep flag the + * `sha256()` shape generically and cannot + * see that the input is an opaque row id (issue #11655, Hard Rule #14). + */ +function principalDigest(principalId: string): string { + // nosemgrep: insufficient-password-hash + return createHash("sha256").update(principalId, "utf8").digest("hex"); +} + +function frameDataUriBytes(frame: VideoDrilldownFrame): number { + const commaIndex = frame.dataUri.indexOf(","); + const encoded = commaIndex >= 0 ? frame.dataUri.slice(commaIndex + 1) : ""; + return Math.floor((encoded.length * 3) / 4); +} + +async function shrinkFrameForVariant( + frame: VideoDrilldownFrame, + preset: VideoDrilldownVariantPreset +): Promise { + if (frame.width <= preset.maxDimension && frame.height <= preset.maxDimension) return frame; + const commaIndex = frame.dataUri.indexOf(","); + const source = Buffer.from(frame.dataUri.slice(commaIndex + 1), "base64"); + const resized = await sharp(source) + .resize({ + fit: "inside", + height: preset.maxDimension, + width: preset.maxDimension, + withoutEnlargement: true, + }) + .jpeg({ progressive: false }) + .toBuffer(); + const metadata = await sharp(resized).metadata(); + return { + dataUri: `data:image/jpeg;base64,${resized.toString("base64")}`, + height: metadata.height ?? frame.height, + timestampSeconds: frame.timestampSeconds, + width: metadata.width ?? frame.width, + }; +} + +function pageFrameSlice( + frames: readonly VideoDrilldownFrame[], + page: number, + requestedFrameCount: number | undefined, + variant: VideoDrilldownVariant +): { hasMore: boolean; slice: readonly VideoDrilldownFrame[] } { + const pageSize = Math.max( + 1, + Math.min( + requestedFrameCount ?? VIDEO_DRILLDOWN_VARIANT_PRESETS[variant].defaultPageFrames, + VIDEO_DRILLDOWN_MAX_PAGE_FRAMES + ) + ); + const start = Math.max(0, page) * pageSize; + const slice = frames.slice(start, start + pageSize); + return { hasMore: start + slice.length < frames.length, slice }; +} + +function trimToByteBudget( + frames: readonly VideoDrilldownFrame[], + maxBytes: number +): { frames: VideoDrilldownFrame[]; trimmed: boolean } { + const kept: VideoDrilldownFrame[] = []; + let total = 0; + for (const frame of frames) { + const bytes = frameDataUriBytes(frame); + if (kept.length > 0 && total + bytes > maxBytes) break; + kept.push(frame); + total += bytes; + } + // A single oversized frame is still returned alone rather than producing an empty page — + // the underlying cache already enforces the 32 MiB per-entry ceiling, so this only trims + // multi-frame pages down. + return { frames: kept.length > 0 ? kept : frames.slice(0, 1), trimmed: kept.length < frames.length }; +} + +/** + * Lifecycle wrapper around {@link VideoDrilldownCache}: mints opaque handles at produce + * time, resolves/deletes strictly by (principal, handle), and applies multiresolution + * variants + pagination at read time. + */ +export class VideoDrilldownLifecycle { + private readonly cache: VideoDrilldownCache; + private readonly handles = new Map(); + private readonly handleCountByPrincipal = new Map(); + private readonly maxHandles: number; + private readonly maxHandlesPerPrincipal: number; + private readonly now: () => number; + private readonly ttlMs: number; + + constructor(options: VideoDrilldownLifecycleOptions) { + this.cache = options.cache; + this.now = options.now ?? Date.now; + this.ttlMs = options.ttlMs ?? 10 * 60 * 1000; + this.maxHandles = options.maxHandles ?? 4096; + this.maxHandlesPerPrincipal = options.maxHandlesPerPrincipal ?? 64; + } + + private sweepExpiredHandles(): number { + const now = this.now(); + let removed = 0; + for (const [handle, entry] of this.handles) { + if (entry.expiresAt <= now) { + this.dropHandle(handle, entry); + removed += 1; + } + } + return removed; + } + + private dropHandle(handle: string, entry: HandleEntry): void { + this.handles.delete(handle); + const count = this.handleCountByPrincipal.get(entry.principalKey) ?? 0; + if (count <= 1) this.handleCountByPrincipal.delete(entry.principalKey); + else this.handleCountByPrincipal.set(entry.principalKey, count - 1); + } + + /** + * A handle evicted for quota is unreachable forever (its digest can never be re-derived + * without the minted sessionId/videoRef), so its cache entry must be released here too — + * otherwise it would sit as unreclaimable, invisible quota usage until TTL expiry. + */ + private releaseEvictedHandle(handle: string, entry: HandleEntry): void { + this.cache.clearSession(entry.principalId, entry.sessionId); + this.dropHandle(handle, entry); + } + + private evictOldestHandleForPrincipal(principalKey: string): void { + for (const [handle, entry] of this.handles) { + if (entry.principalKey === principalKey) { + this.releaseEvictedHandle(handle, entry); + return; + } + } + } + + private evictOldestHandleGlobally(): void { + const oldest = this.handles.keys().next().value; + if (!oldest) return; + const entry = this.handles.get(oldest); + if (entry) this.releaseEvictedHandle(oldest, entry); + } + + private registerHandle(principalId: string, sessionId: string, videoRef: string): string { + const principalKey = principalDigest(principalId); + const createdAt = this.now(); + const handle = createHash("sha256") + .update(principalKey, "utf8") + .update(":", "utf8") + .update(sessionId, "utf8") + .update(":", "utf8") + .update(videoRef, "utf8") + .digest("hex"); + this.handles.set(handle, { + createdAt, + expiresAt: createdAt + this.ttlMs, + principalId, + principalKey, + sessionId, + videoRef, + }); + const count = (this.handleCountByPrincipal.get(principalKey) ?? 0) + 1; + this.handleCountByPrincipal.set(principalKey, count); + while ((this.handleCountByPrincipal.get(principalKey) ?? 0) > this.maxHandlesPerPrincipal) { + const before = this.handles.size; + this.evictOldestHandleForPrincipal(principalKey); + if (this.handles.size === before) break; + } + while (this.handles.size > this.maxHandles) { + const before = this.handles.size; + this.evictOldestHandleGlobally(); + if (this.handles.size === before) break; + } + return handle; + } + + private resolveHandleEntry(principalId: string, handle: string): HandleEntry | null { + if (!HANDLE_PATTERN.test(handle)) return null; + this.sweepExpiredHandles(); + const entry = this.handles.get(handle); + if (!entry || entry.principalKey !== principalDigest(principalId)) return null; + return entry; + } + + async produce( + principalId: string, + value: VideoDrilldownProducePayload, + options: VideoDrilldownProduceOptions = {} + ): Promise { + this.sweepExpiredHandles(); + const sessionId = randomUUID(); + const videoRef = randomUUID(); + await this.cache.put(principalId, sessionId, videoRef, value as VideoDrilldownPutValue, { + signal: options.signal, + }); + const handle = this.registerHandle(principalId, sessionId, videoRef); + const entry = this.handles.get(handle); + return { expiresAt: entry?.expiresAt ?? this.now() + this.ttlMs, handle }; + } + + async resolve( + principalId: string, + handle: string, + query: VideoDrilldownResolveQuery + ): Promise { + const entry = this.resolveHandleEntry(principalId, handle); + if (!entry) return null; + const stored = this.cache.get(principalId, entry.sessionId, entry.videoRef, { + endSeconds: query.endSeconds, + frameCount: 16, + startSeconds: query.startSeconds, + }); + if (!stored) { + this.dropHandle(handle, entry); + return null; + } + const variant = query.variant ?? "detail"; + const preset = VIDEO_DRILLDOWN_VARIANT_PRESETS[variant]; + const { slice, hasMore } = pageFrameSlice( + stored.frames, + query.page ?? 0, + query.frameCount, + variant + ); + const shrunk = await Promise.all(slice.map((frame) => shrinkFrameForVariant(frame, preset))); + const { frames, trimmed } = trimToByteBudget(shrunk, query.maxPageBytes ?? VIDEO_DRILLDOWN_MAX_PAGE_BYTES); + return { + derivation: stored.derivation, + durationSeconds: stored.durationSeconds, + ...(stored.focusWindow ? { focusWindow: stored.focusWindow } : {}), + frames, + hasMore: hasMore || trimmed, + page: Math.max(0, query.page ?? 0), + variant, + }; + } + + deleteHandle(principalId: string, handle: string): number { + const entry = this.resolveHandleEntry(principalId, handle); + if (!entry) return 0; + const removed = this.cache.clearSession(principalId, entry.sessionId); + this.dropHandle(handle, entry); + return removed; + } + + /** Explicit sweep for scheduled cleanup; returns the number of stale handles reclaimed. */ + cleanup(): number { + return this.sweepExpiredHandles(); + } + + getUsage(principalId: string): VideoDrilldownUsage { + return this.cache.getUsage(principalId); + } + + clearAll(): void { + this.cache.clearAll(); + this.handles.clear(); + this.handleCountByPrincipal.clear(); + } +} + +export type { VideoDrilldownCacheOptions }; diff --git a/stryker.conf.json b/stryker.conf.json index 7d98b0848a..e89ddaf143 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -394,6 +394,7 @@ "tests/unit/usage-service-hardening.test.ts", "tests/unit/validate-response-quality.test.ts", "tests/unit/vertex-passthrough-model-lockout.test.ts", + "tests/unit/video-bridge-drilldown-consumer-route.test.ts", "tests/unit/video-bridge-drilldown-route.test.ts", "tests/unit/video-bridge-route-security.test.ts", "tests/unit/xai-agent-tools-passthrough.test.ts", diff --git a/tests/unit/guardrails/videoBridgeDrilldownLifecycle.test.ts b/tests/unit/guardrails/videoBridgeDrilldownLifecycle.test.ts new file mode 100644 index 0000000000..ec51874362 --- /dev/null +++ b/tests/unit/guardrails/videoBridgeDrilldownLifecycle.test.ts @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import sharp from "sharp"; + +import { VideoDrilldownCache } from "../../../src/lib/guardrails/videoBridgeDrilldown"; +import { + isVideoBridgeDrilldownProductionEnabled, + isVideoBridgeDrilldownRemoteAccessEnabled, + VIDEO_DRILLDOWN_MAX_PAGE_FRAMES, + VIDEO_DRILLDOWN_VARIANT_PRESETS, + VideoDrilldownLifecycle, +} from "../../../src/lib/guardrails/videoBridgeDrilldownLifecycle"; + +const derivation = { + parentContentHash: `sha256:${"a".repeat(64)}`, + policy: "focused-window", + version: "video-drilldown/v1", +} as const; + +const jpegFixtures = new Map(); +async function jpeg(width: number, height: number, fill: number): Promise { + const key = `${width}x${height}x${fill}`; + const cached = jpegFixtures.get(key); + if (cached) return cached; + const buffer = await sharp({ + create: { width, height, channels: 3, background: { r: fill, g: fill, b: fill } }, + }) + .jpeg({ progressive: false }) + .toBuffer(); + jpegFixtures.set(key, buffer); + return buffer; +} + +async function jpegDataUri(width: number, height: number, fill = 1): Promise { + const buffer = await jpeg(width, height, fill); + return `data:image/jpeg;base64,${buffer.toString("base64")}`; +} + +function framesInput( + entries: ReadonlyArray<{ dataUri: string; timestampSeconds: number }> +): { derivation: typeof derivation; durationSeconds: number; frames: typeof entries } { + return { derivation, durationSeconds: 20, frames: entries }; +} + +test("video bridge drill-down feature flags default to opt-in / disabled", () => { + assert.equal(isVideoBridgeDrilldownProductionEnabled({}), false); + assert.equal(isVideoBridgeDrilldownProductionEnabled({ OMNIROUTE_VIDEO_BRIDGE_DRILLDOWN_ENABLED: "true" }), true); + assert.equal(isVideoBridgeDrilldownRemoteAccessEnabled({}), false); + assert.equal( + isVideoBridgeDrilldownRemoteAccessEnabled({ + OMNIROUTE_VIDEO_BRIDGE_DRILLDOWN_REMOTE_ENABLED: "1", + }), + true + ); +}); + +test("produce mints an opaque hashed handle that reveals no session/video identity", async () => { + const lifecycle = new VideoDrilldownLifecycle({ cache: new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }) }); + const { handle, expiresAt } = await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(640, 360), timestampSeconds: 1 }]) + ); + assert.match(handle, /^[0-9a-f]{64}$/); + assert.ok(expiresAt > Date.now()); +}); + +test("resolve returns stored frames to the owning principal by handle only", async () => { + const lifecycle = new VideoDrilldownLifecycle({ cache: new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }) }); + const { handle } = await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(640, 360), timestampSeconds: 3 }]) + ); + const page = await lifecycle.resolve("principal-a", handle, {}); + assert.ok(page); + assert.equal(page?.frames.length, 1); + assert.equal(page?.variant, "detail"); + assert.equal(page?.hasMore, false); +}); + +test("resolve denies a wrong principal and a nonexistent handle identically (no oracle)", async () => { + const lifecycle = new VideoDrilldownLifecycle({ cache: new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }) }); + const { handle } = await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(640, 360), timestampSeconds: 3 }]) + ); + const wrongPrincipal = await lifecycle.resolve("principal-b", handle, {}); + const madeUpHandle = await lifecycle.resolve("principal-b", "f".repeat(64), {}); + assert.equal(wrongPrincipal, null); + assert.equal(madeUpHandle, null); + + const deniedDelete = lifecycle.deleteHandle("principal-b", handle); + const madeUpDelete = lifecycle.deleteHandle("principal-b", "f".repeat(64)); + assert.equal(deniedDelete, 0); + assert.equal(madeUpDelete, 0); + + const ownerStillWorks = await lifecycle.resolve("principal-a", handle, {}); + assert.ok(ownerStillWorks); +}); + +test("delete removes the artifact for its own principal and is idempotent", async () => { + const lifecycle = new VideoDrilldownLifecycle({ cache: new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }) }); + const { handle } = await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(640, 360), timestampSeconds: 3 }]) + ); + assert.equal(lifecycle.deleteHandle("principal-a", handle), 1); + assert.equal(await lifecycle.resolve("principal-a", handle, {}), null); + assert.equal(lifecycle.deleteHandle("principal-a", handle), 0); +}); + +test("preview and standard variants shrink frames without ever upscaling", async () => { + const lifecycle = new VideoDrilldownLifecycle({ cache: new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }) }); + const big = await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(1280, 720), timestampSeconds: 1 }]) + ); + const small = await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(200, 100), timestampSeconds: 2 }]) + ); + + const preview = await lifecycle.resolve("principal-a", big.handle, { variant: "preview" }); + assert.ok(preview); + for (const frame of preview?.frames ?? []) { + assert.ok(frame.width <= VIDEO_DRILLDOWN_VARIANT_PRESETS.preview.maxDimension); + assert.ok(frame.height <= VIDEO_DRILLDOWN_VARIANT_PRESETS.preview.maxDimension); + } + + // The already-small 200x100 frame must not be upscaled toward the preview ceiling. + const smallPreview = await lifecycle.resolve("principal-a", small.handle, { variant: "preview" }); + const smallFrame = smallPreview?.frames[0]; + assert.deepEqual( + smallFrame && { height: smallFrame.height, width: smallFrame.width }, + { height: 100, width: 200 } + ); + + const standard = await lifecycle.resolve("principal-a", big.handle, { variant: "standard" }); + for (const frame of standard?.frames ?? []) { + assert.ok(frame.width <= VIDEO_DRILLDOWN_VARIANT_PRESETS.standard.maxDimension); + assert.ok(frame.height <= VIDEO_DRILLDOWN_VARIANT_PRESETS.standard.maxDimension); + } + + const detail = await lifecycle.resolve("principal-a", big.handle, { variant: "detail" }); + const detailBig = detail?.frames[0]; + assert.deepEqual( + detailBig && { height: detailBig.height, width: detailBig.width }, + { height: 720, width: 1280 } + ); +}); + +test("pagination never exceeds the 8-frame page cap and reports hasMore across pages", async () => { + const lifecycle = new VideoDrilldownLifecycle({ cache: new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }) }); + const entries = [] as Array<{ dataUri: string; timestampSeconds: number }>; + for (let index = 0; index < 12; index += 1) { + entries.push({ dataUri: await jpegDataUri(64, 64, (index % 250) + 1), timestampSeconds: index }); + } + const { handle } = await lifecycle.produce("principal-a", framesInput(entries.slice(0, 16))); + + const firstPage = await lifecycle.resolve("principal-a", handle, { frameCount: 100 }); + assert.ok(firstPage); + assert.ok((firstPage?.frames.length ?? 0) <= VIDEO_DRILLDOWN_MAX_PAGE_FRAMES); + assert.equal(firstPage?.hasMore, entries.length > VIDEO_DRILLDOWN_MAX_PAGE_FRAMES); + + const secondPage = await lifecycle.resolve("principal-a", handle, { page: 1 }); + assert.ok(secondPage); + const seenTimestamps = new Set([ + ...(firstPage?.frames.map((frame) => frame.timestampSeconds) ?? []), + ...(secondPage?.frames.map((frame) => frame.timestampSeconds) ?? []), + ]); + assert.equal(seenTimestamps.size, entries.length); +}); + +test("a tight page byte budget trims frames instead of exceeding it", async () => { + const lifecycle = new VideoDrilldownLifecycle({ + cache: new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }), + }); + const frameBytes = (await jpeg(320, 180, 5)).byteLength; + const { handle } = await lifecycle.produce( + "principal-a", + framesInput([ + { dataUri: await jpegDataUri(320, 180, 5), timestampSeconds: 1 }, + { dataUri: await jpegDataUri(320, 180, 5), timestampSeconds: 2 }, + { dataUri: await jpegDataUri(320, 180, 5), timestampSeconds: 3 }, + ]) + ); + const tight = await lifecycle.resolve("principal-a", handle, { + maxPageBytes: Math.floor(frameBytes * 1.5), + }); + assert.ok(tight); + assert.equal(tight?.frames.length, 1); + assert.equal(tight?.hasMore, true); +}); + +test("TTL expiry hides the artifact and cleanup reclaims the handle", async () => { + let now = 1_000; + const lifecycle = new VideoDrilldownLifecycle({ + cache: new VideoDrilldownCache({ maxEntries: 8, now: () => now, ttlMs: 5_000 }), + now: () => now, + ttlMs: 5_000, + }); + const { handle } = await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(320, 180), timestampSeconds: 1 }]) + ); + now += 10_000; + // cleanup() alone (no prior resolve/delete call) must reclaim the stale handle. + const removed = lifecycle.cleanup(); + assert.ok(removed >= 1); + assert.equal(await lifecycle.resolve("principal-a", handle, {}), null); + assert.equal(lifecycle.deleteHandle("principal-a", handle), 0); +}); + +test("per-principal and global usage accounting is delegated to the cache substrate", async () => { + const cache = new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }); + const lifecycle = new VideoDrilldownLifecycle({ cache }); + assert.deepEqual(lifecycle.getUsage("principal-a"), { + bytes: 0, + entries: 0, + totalBytes: 0, + totalEntries: 0, + }); + await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(320, 180), timestampSeconds: 1 }]) + ); + const usage = lifecycle.getUsage("principal-a"); + assert.equal(usage.entries, 1); + assert.ok(usage.bytes > 0); + assert.equal(usage.totalEntries, 1); +}); + +test("aborting mid-production leaves no orphaned handle or cache usage", async () => { + const cache = new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }); + const lifecycle = new VideoDrilldownLifecycle({ cache }); + const controller = new AbortController(); + controller.abort(); + const dataUri = await jpegDataUri(320, 180); + await assert.rejects(() => + lifecycle.produce("principal-a", framesInput([{ dataUri, timestampSeconds: 1 }]), { + signal: controller.signal, + }) + ); + assert.deepEqual(lifecycle.getUsage("principal-a"), { + bytes: 0, + entries: 0, + totalBytes: 0, + totalEntries: 0, + }); +}); + +test("per-principal handle quota evicts the oldest handle instead of growing without bound", async () => { + const lifecycle = new VideoDrilldownLifecycle({ + cache: new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }), + maxHandlesPerPrincipal: 2, + }); + const first = await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(320, 180), timestampSeconds: 1 }]) + ); + await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(320, 180), timestampSeconds: 2 }]) + ); + await lifecycle.produce( + "principal-a", + framesInput([{ dataUri: await jpegDataUri(320, 180), timestampSeconds: 3 }]) + ); + assert.equal(await lifecycle.resolve("principal-a", first.handle, {}), null); + assert.equal(lifecycle.getUsage("principal-a").entries, 2); +}); diff --git a/tests/unit/video-bridge-drilldown-consumer-route.test.ts b/tests/unit/video-bridge-drilldown-consumer-route.test.ts new file mode 100644 index 0000000000..8df8883ab1 --- /dev/null +++ b/tests/unit/video-bridge-drilldown-consumer-route.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import sharp from "sharp"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-video-drilldown-consumer-route-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "video-drilldown-consumer-route-test-secret"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const route = await import("../../src/app/api/v1/video-bridge/drilldown/route.ts"); +const { VideoDrilldownLifecycle } = await import( + "../../src/lib/guardrails/videoBridgeDrilldownLifecycle.ts" +); +const { VideoDrilldownCache } = await import("../../src/lib/guardrails/videoBridgeDrilldown.ts"); +const { isLocalOnlyPath } = await import("../../src/server/authz/routeGuard.ts"); + +async function resetStorage(): Promise { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(resetStorage); +test.after(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +async function seedKey(): Promise<{ key: string }> { + return apiKeysDb.createApiKey("video-drilldown-route-key", "test", []); +} + +function newLifecycle() { + return new VideoDrilldownLifecycle({ + cache: new VideoDrilldownCache({ maxEntries: 8, ttlMs: 60_000 }), + }); +} + +async function jpegDataUri(width: number, height: number, fill = 1): Promise { + const buffer = await sharp({ + create: { width, height, channels: 3, background: { r: fill, g: fill, b: fill } }, + }) + .jpeg({ progressive: false }) + .toBuffer(); + return `data:image/jpeg;base64,${buffer.toString("base64")}`; +} + +const derivation = { + parentContentHash: `sha256:${"a".repeat(64)}`, + policy: "focused-window", + version: "video-drilldown/v1", +} as const; + +function get(url: string, key?: string): Request { + return new Request(`http://omniroute.local${url}`, { + headers: key ? { Authorization: `Bearer ${key}` } : {}, + }); +} + +function del(url: string, key?: string): Request { + return new Request(`http://omniroute.local${url}`, { + headers: key ? { Authorization: `Bearer ${key}` } : {}, + method: "DELETE", + }); +} + +test("consumer route requires remote access to be explicitly enabled", async () => { + const { key } = await seedKey(); + const response = await route.handleVideoBridgeDrilldownConsumerRequest( + get(`/api/v1/video-bridge/drilldown?handle=${"a".repeat(64)}`, key), + { isRemoteAccessEnabled: () => false } + ); + assert.equal(response.status, 403); +}); + +test("consumer route requires an authenticated API key", async () => { + const noKey = await route.handleVideoBridgeDrilldownConsumerRequest( + get(`/api/v1/video-bridge/drilldown?handle=${"a".repeat(64)}`), + { isRemoteAccessEnabled: () => true } + ); + assert.equal(noKey.status, 401); + + const invalidKey = await route.handleVideoBridgeDrilldownConsumerRequest( + get(`/api/v1/video-bridge/drilldown?handle=${"a".repeat(64)}`, "not-a-real-key"), + { isRemoteAccessEnabled: () => true } + ); + assert.equal(invalidKey.status, 401); +}); + +test("consumer route resolves a produced handle for its own API key and paginates/variant-shapes it", async () => { + const { key } = await seedKey(); + const lifecycle = newLifecycle(); + const policyModule = await import("../../src/shared/utils/apiKeyPolicy.ts"); + const policy = await policyModule.enforceApiKeyPolicy(get("/x", key), null); + const principalId = policy.apiKeyInfo!.id; + const { handle } = await lifecycle.produce(principalId, { + derivation, + durationSeconds: 10, + frames: [ + { dataUri: await jpegDataUri(640, 360), timestampSeconds: 1 }, + { dataUri: await jpegDataUri(640, 360), timestampSeconds: 2 }, + ], + }); + + const response = await route.handleVideoBridgeDrilldownConsumerRequest( + get(`/api/v1/video-bridge/drilldown?handle=${handle}&variant=preview`, key), + { isRemoteAccessEnabled: () => true, lifecycle } + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.variant, "preview"); + assert.equal(body.frames.length, 2); + for (const frame of body.frames) { + assert.ok(frame.width <= 320); + assert.ok(frame.height <= 320); + } +}); + +test("consumer route denies a different API key's handle with the same 404 as a made-up handle", async () => { + const { key: ownerKey } = await seedKey(); + const { key: strangerKey } = await seedKey(); + const lifecycle = newLifecycle(); + const policyModule = await import("../../src/shared/utils/apiKeyPolicy.ts"); + const ownerPolicy = await policyModule.enforceApiKeyPolicy(get("/x", ownerKey), null); + const { handle } = await lifecycle.produce(ownerPolicy.apiKeyInfo!.id, { + derivation, + durationSeconds: 10, + frames: [{ dataUri: await jpegDataUri(320, 180), timestampSeconds: 1 }], + }); + + const strangerRead = await route.handleVideoBridgeDrilldownConsumerRequest( + get(`/api/v1/video-bridge/drilldown?handle=${handle}`, strangerKey), + { isRemoteAccessEnabled: () => true, lifecycle } + ); + const madeUpRead = await route.handleVideoBridgeDrilldownConsumerRequest( + get(`/api/v1/video-bridge/drilldown?handle=${"0".repeat(64)}`, strangerKey), + { isRemoteAccessEnabled: () => true, lifecycle } + ); + assert.equal(strangerRead.status, 404); + assert.equal(madeUpRead.status, 404); + assert.deepEqual(await strangerRead.json(), await madeUpRead.json()); + + const strangerDelete = await route.handleVideoBridgeDrilldownConsumerRequest( + del(`/api/v1/video-bridge/drilldown?handle=${handle}`, strangerKey), + { isRemoteAccessEnabled: () => true, lifecycle } + ); + assert.equal(strangerDelete.status, 200); + assert.deepEqual(await strangerDelete.json(), { removed: 0 }); + + const ownerRead = await route.handleVideoBridgeDrilldownConsumerRequest( + get(`/api/v1/video-bridge/drilldown?handle=${handle}`, ownerKey), + { isRemoteAccessEnabled: () => true, lifecycle } + ); + assert.equal(ownerRead.status, 200); +}); + +test("consumer route deletes an owner's handle and it becomes unresolvable afterward", async () => { + const { key } = await seedKey(); + const lifecycle = newLifecycle(); + const policyModule = await import("../../src/shared/utils/apiKeyPolicy.ts"); + const policy = await policyModule.enforceApiKeyPolicy(get("/x", key), null); + const { handle } = await lifecycle.produce(policy.apiKeyInfo!.id, { + derivation, + durationSeconds: 10, + frames: [{ dataUri: await jpegDataUri(320, 180), timestampSeconds: 1 }], + }); + + const deleted = await route.handleVideoBridgeDrilldownConsumerRequest( + del(`/api/v1/video-bridge/drilldown?handle=${handle}`, key), + { isRemoteAccessEnabled: () => true, lifecycle } + ); + assert.deepEqual(await deleted.json(), { removed: 1 }); + + const afterDelete = await route.handleVideoBridgeDrilldownConsumerRequest( + get(`/api/v1/video-bridge/drilldown?handle=${handle}`, key), + { isRemoteAccessEnabled: () => true, lifecycle } + ); + assert.equal(afterDelete.status, 404); +}); + +test("consumer route rejects malformed handles and out-of-range pagination before touching the lifecycle", async () => { + const { key } = await seedKey(); + const badHandle = await route.handleVideoBridgeDrilldownConsumerRequest( + get("/api/v1/video-bridge/drilldown?handle=not-hex", key), + { isRemoteAccessEnabled: () => true } + ); + assert.equal(badHandle.status, 400); + + const tooManyFrames = await route.handleVideoBridgeDrilldownConsumerRequest( + get(`/api/v1/video-bridge/drilldown?handle=${"a".repeat(64)}&frames=9`, key), + { isRemoteAccessEnabled: () => true } + ); + assert.equal(tooManyFrames.status, 400); + + const badVariant = await route.handleVideoBridgeDrilldownConsumerRequest( + get(`/api/v1/video-bridge/drilldown?handle=${"a".repeat(64)}&variant=ultra`, key), + { isRemoteAccessEnabled: () => true } + ); + assert.equal(badVariant.status, 400); +}); + +test("consumer route is not classified local-only — it is a real remote-authenticated surface, gated by settings instead", () => { + assert.equal(isLocalOnlyPath("/api/v1/video-bridge/drilldown", "GET"), false); +});