diff --git a/.env.example b/.env.example index 45e6c9bf..3ede82c8 100644 --- a/.env.example +++ b/.env.example @@ -139,7 +139,15 @@ DB_IDLE_TX_TIMEOUT_MS=10000 INTAKE_RATE_PER_KEY=60 INTAKE_RATE_PER_IP=20 INTAKE_RATE_PER_KEY_ANON=10 -INTAKE_MAX_BYTES=5242880 +# Whole-request ceiling — keep above the per-file/total attachment caps below +# (10 MB file + 25 MB total) plus the gallery media caps (100 MB video) or +# attachments the widget accepts will 413. (Was 41943040 / 40 MB before +# gallery media support raised the ceiling to fit one max-size video.) +# The intake handlers reject an oversized declared Content-Length before +# buffering the body, but Content-Length can be absent or lie on chunked +# bodies — put a reverse-proxy body-size cap (e.g. nginx client_max_body_size) +# in front of the app to bound streamed/chunked requests too. +INTAKE_MAX_BYTES=157286400 INTAKE_REQUIRE_DWELL=true INTAKE_MIN_DWELL_MS=1500 @@ -148,6 +156,12 @@ INTAKE_USER_FILE_MAX_BYTES=10485760 INTAKE_USER_FILES_TOTAL_MAX_BYTES=26214400 INTAKE_USER_FILES_MAX_COUNT=5 +# Gallery media caps (media[N] + mediaMeta parts: screenshots + trimmed +# recordings from the SDK's capture flow) +INTAKE_MEDIA_MAX_COUNT=3 +INTAKE_MEDIA_IMAGE_MAX_BYTES=10485760 +INTAKE_MEDIA_VIDEO_MAX_BYTES=104857600 + # Virus scan via the bundled clamav sidecar. Off by default — flip to true # AFTER `docker compose up -d` and the clamav service has finished pulling # its signature DB (~500MB, watch with `docker compose logs -f clamav` diff --git a/apps/dashboard/app/components/report-drawer/attachments-tab.vue b/apps/dashboard/app/components/report-drawer/attachments-tab.vue index 6bcd7c16..4a90b15c 100644 --- a/apps/dashboard/app/components/report-drawer/attachments-tab.vue +++ b/apps/dashboard/app/components/report-drawer/attachments-tab.vue @@ -1,15 +1,23 @@ + + diff --git a/apps/dashboard/app/middleware/auth.global.ts b/apps/dashboard/app/middleware/auth.global.ts index a453708c..7177844c 100644 --- a/apps/dashboard/app/middleware/auth.global.ts +++ b/apps/dashboard/app/middleware/auth.global.ts @@ -1,5 +1,5 @@ export default defineNuxtRouteMiddleware(async (to) => { - const publicPaths = ["/auth/sign-in"] + const publicPaths = ["/auth/sign-in", "/s/"] if (publicPaths.some((p) => to.path.startsWith(p))) return // useRequestFetch() forwards the incoming request's cookie during SSR. diff --git a/apps/dashboard/app/pages/projects/[id]/reports/[reportId].vue b/apps/dashboard/app/pages/projects/[id]/reports/[reportId].vue index 2f353daf..8d3428e8 100644 --- a/apps/dashboard/app/pages/projects/[id]/reports/[reportId].vue +++ b/apps/dashboard/app/pages/projects/[id]/reports/[reportId].vue @@ -87,7 +87,9 @@ const consoleHasData = computed( const networkHasData = computed(() => logs.value !== null && logs.value.network.length > 0) const cookiesHasData = computed(() => (report.value?.context?.cookies?.length ?? 0) > 0) const userFileCount = computed( - () => (report.value?.attachments ?? []).filter((a) => a.kind === "user-file").length, + () => + (report.value?.attachments ?? []).filter((a) => a.kind === "user-file" || a.kind === "media") + .length, ) const tabs = computed(() => { diff --git a/apps/dashboard/app/pages/projects/[id]/settings.vue b/apps/dashboard/app/pages/projects/[id]/settings.vue index e765ec68..94c8bdca 100644 --- a/apps/dashboard/app/pages/projects/[id]/settings.vue +++ b/apps/dashboard/app/pages/projects/[id]/settings.vue @@ -1,6 +1,6 @@ + + diff --git a/apps/dashboard/app/utils/clamp-playback.test.ts b/apps/dashboard/app/utils/clamp-playback.test.ts new file mode 100644 index 00000000..9387e928 --- /dev/null +++ b/apps/dashboard/app/utils/clamp-playback.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test" +import { clampPlayback } from "./clamp-playback" + +describe("clampPlayback", () => { + test("normal pre-start seek: currentTime before trim start seeks forward", () => { + const action = clampPlayback({ + currentTime: 0, + duration: 20, + trimStartMs: 5000, + trimEndMs: null, + }) + expect(action).toEqual({ type: "seek", to: 5 }) + }) + + test("THE BUG: trimStartMs beyond real duration must not seek — stays none forever", () => { + const input = { + currentTime: 10, + duration: 10, + trimStartMs: 60_000, + trimEndMs: null, + } + const first = clampPlayback(input) + expect(first).toEqual({ type: "none" }) + + // Re-invoking with the identical state (as timeupdate would fire again + // after a browser-clamped seek) must keep returning "none" — this is + // the loop-proof guarantee. + const second = clampPlayback(input) + expect(second).toEqual({ type: "none" }) + const third = clampPlayback(input) + expect(third).toEqual({ type: "none" }) + }) + + test("trim-end pause-and-reset: currentTime past a real trim end pauses and resets to start", () => { + const action = clampPlayback({ + currentTime: 10, + duration: 20, + trimStartMs: 5000, + trimEndMs: 10_000, + }) + expect(action).toEqual({ type: "pause-and-reset", to: 5 }) + }) + + test("natural EOF with trimEnd null → none (not a real trim end)", () => { + const action = clampPlayback({ + currentTime: 20, + duration: 20, + trimStartMs: 0, + trimEndMs: null, + }) + expect(action).toEqual({ type: "none" }) + }) + + test("epsilon: gap of 0.1s below start is not worth seeking", () => { + const action = clampPlayback({ + currentTime: 4.9, + duration: 20, + trimStartMs: 5000, + trimEndMs: null, + }) + expect(action).toEqual({ type: "none" }) + }) + + test("epsilon: gap of 3s below start does seek", () => { + const action = clampPlayback({ + currentTime: 2, + duration: 20, + trimStartMs: 5000, + trimEndMs: null, + }) + expect(action).toEqual({ type: "seek", to: 5 }) + }) + + test("NaN duration during metadata load does not affect normal pre-start seek", () => { + const action = clampPlayback({ + currentTime: 0, + duration: Number.NaN, + trimStartMs: 5000, + trimEndMs: null, + }) + expect(action).toEqual({ type: "seek", to: 5 }) + }) + + test("degenerate row: trimStart >= trimEnd after capping plays untrimmed", () => { + const action = clampPlayback({ + currentTime: 3, + duration: 20, + trimStartMs: 15_000, + trimEndMs: 10_000, + }) + expect(action).toEqual({ type: "none" }) + }) +}) diff --git a/apps/dashboard/app/utils/clamp-playback.ts b/apps/dashboard/app/utils/clamp-playback.ts new file mode 100644 index 00000000..123d1bbc --- /dev/null +++ b/apps/dashboard/app/utils/clamp-playback.ts @@ -0,0 +1,55 @@ +export interface PlaybackClampInput { + currentTime: number + duration: number + trimStartMs: number | null + trimEndMs: number | null +} + +export type PlaybackClampAction = + | { type: "none" } + | { type: "seek"; to: number } + | { type: "pause-and-reset"; to: number } + +// Below this gap (seconds), a seek is not worth issuing. This is what +// breaks the reassignment loop: when trimStartMs exceeds the video's real +// duration, the browser clamps a seek to `duration` and fires `timeupdate` +// again with currentTime already sitting (almost) exactly at the clamped +// startS — without this epsilon the handler would keep reassigning +// currentTime forever. +const SEEK_EPSILON_S = 0.25 + +export function clampPlayback(input: PlaybackClampInput): PlaybackClampAction { + const { currentTime, duration, trimStartMs, trimEndMs } = input + + let startS = (trimStartMs ?? 0) / 1000 + let endS = trimEndMs != null ? trimEndMs / 1000 : Number.POSITIVE_INFINITY + + // duration is NaN/Infinity while metadata is still loading — don't let a + // not-yet-known duration cap the trim window. + const hasKnownDuration = Number.isFinite(duration) + if (hasKnownDuration) { + startS = Math.min(startS, duration) + endS = Math.min(endS, duration) + } + + // Degenerate row (e.g. trimStartMs far beyond the real duration, both + // capped down to the same value): play untrimmed rather than fighting + // the metadata. + if (startS >= endS) { + return { type: "none" } + } + + if (currentTime < startS && startS - currentTime > SEEK_EPSILON_S) { + return { type: "seek", to: startS } + } + + // Only pause-and-reset for a *real* trim end (strictly before the actual + // duration). Natural end-of-video with no/degenerate trim end must fall + // through to "none" — otherwise every video would pause-and-reset on + // reaching EOF. + if (currentTime >= endS && hasKnownDuration && endS < duration) { + return { type: "pause-and-reset", to: startS } + } + + return { type: "none" } +} diff --git a/apps/dashboard/nuxt.config.ts b/apps/dashboard/nuxt.config.ts index cc79422c..bb96a6d0 100644 --- a/apps/dashboard/nuxt.config.ts +++ b/apps/dashboard/nuxt.config.ts @@ -105,6 +105,7 @@ export default defineNuxtConfig({ headers: { contentSecurityPolicy: { "img-src": ["'self'", "data:", "blob:", "https:"], + "media-src": ["'self'"], }, }, }, @@ -147,12 +148,21 @@ export default defineNuxtConfig({ // here would defeat that oracle protection, so disable nuxt-security's // corsHandler entirely and let the handler own CORS. xssValidator is // also off because intake bodies are structured JSON + base64 blobs. + // requestSizeLimiter is off too: its ~8 MB multipart ceiling sat below + // the SDK's advertised attachment limits (10 MB/file, 25 MB total) and + // killed video-attachment submissions mid-upload. The intake handler + // enforces its own authoritative byte caps (INTAKE_MAX_BYTES and the + // per-file/total INTAKE_USER_FILE_* gates) with proper 413 responses. "/api/intake/**": { security: { corsHandler: false, xssValidator: false, + requestSizeLimiter: false, }, }, + "/api/intake/media": { + security: { corsHandler: false, xssValidator: false, requestSizeLimiter: false }, + }, // GitHub webhook — nuxt-security's per-IP rate limiter would block // GitHub's exponential-backoff delivery retries during restarts / // deployments. The webhook handler enforces its own defence-in-depth @@ -167,6 +177,13 @@ export default defineNuxtConfig({ xssValidator: false, }, }, + "/api/shared/**": { + // 5-minute ceiling, not longer: shared caches (CDN/proxies) hold these + // public recordings, and a revoked link must stop resolving within + // minutes — an hour of post-revoke availability was ruled unacceptable + // for session recordings that can contain on-screen PII. + headers: { "cache-control": "public, max-age=300" }, + }, }, nitro: { experimental: { @@ -191,6 +208,8 @@ export default defineNuxtConfig({ [process.env.NODE_ENV === "production" ? "*/1 * * * *" : "*/10 * * * * *"]: ["github:sync"], // Daily at 03:00 UTC — cleans up any unconsumed expired write-lock rows. "0 3 * * *": ["github:cleanup-write-locks"], + // Daily at 04:00 UTC — purges expired/revoked shared_media rows + blobs. + "0 4 * * *": ["media:purge"], }, routeRules: { // Baseline security headers for every dashboard response. diff --git a/apps/dashboard/server/api/intake/media.post.ts b/apps/dashboard/server/api/intake/media.post.ts new file mode 100644 index 00000000..4550deab --- /dev/null +++ b/apps/dashboard/server/api/intake/media.post.ts @@ -0,0 +1,264 @@ +import { randomBytes, randomUUID } from "node:crypto" +import { + createError, + defineEventHandler, + getHeader, + getRequestIP, + getRequestURL, + readMultipartFormData, +} from "h3" +import { eq } from "drizzle-orm" +import { z } from "zod" +import { db } from "../../db" +import { projects, sharedMedia } from "../../db/schema" +import { + applyIntakePostCors, + applyIntakePreflightCors, + isOriginAllowed, +} from "../../lib/intake-cors" +import { env } from "../../lib/env" +import { getIpLimiter, getShareMintLimiter } from "../../lib/rate-limit" +import { getStorage } from "../../lib/storage" +import { rollbackPuts } from "../../lib/storage/rollback" + +// Strip any `;codecs=...` parameters so a browser-emitted content-type like +// `video/webm;codecs=vp9` (Chrome's MediaRecorder default) is validated and +// stored as the bare `video/webm` the allowlist expects. +function bareMime(m: string): string { + return m.split(";")[0]?.trim() ?? m +} + +// v1 supports minting share links for gallery recordings only — no images. +const MintMeta = z.object({ + projectKey: z.string().regex(/^rp_pk_[A-Za-z0-9]{24}$/), + kind: z.literal("video"), + // Normalize BEFORE the enum so a parameterized codec string still validates + // (and is persisted bare). A still-unsupported mime keeps the `mime` issue + // path, preserving the 415 mapping below. + mime: z + .string() + .transform(bareMime) + .pipe(z.enum(["video/webm", "video/mp4"])), + durationMs: z.number().int().nonnegative().optional(), + trim: z + .object({ startMs: z.number().int().nonnegative(), endMs: z.number().int().positive() }) + .refine((t) => t.endMs > t.startMs, "endMs must be after startMs") + .optional(), +}) + +const MEDIA_EXT: Record = { + "video/webm": "webm", + "video/mp4": "mp4", +} + +const DAY_MS = 86_400_000 +// Token collisions at 256 bits of entropy are effectively impossible, but the +// unique constraint is real defense-in-depth (e.g. a broken RNG). One retry +// with a freshly-generated token covers that case cheaply; a second failure +// is treated as a genuine server error rather than looping forever. +const MAX_MINT_ATTEMPTS = 2 + +export default defineEventHandler(async (event) => { + // Preflight reflects Origin so browsers can proceed with the real POST. + // No response body reads happen on preflight, so this is safe. + if (event.method === "OPTIONS") { + applyIntakePreflightCors(event) + event.node.res.statusCode = 204 + return "" + } + + if (event.method !== "POST") { + throw createError({ statusCode: 405, statusMessage: "Method not allowed" }) + } + + const rawOrigin = getHeader(event, "origin") ?? "" + // Same chrome-extension:// → X-Repro-Origin fallback as reports.ts — see + // that file's comment for the full threat-model rationale. + const origin = + rawOrigin.length > 0 && rawOrigin.startsWith("chrome-extension://") + ? (getHeader(event, "x-repro-origin") ?? "") + : rawOrigin + // TRUST_XFF is OFF by default — a public deployment must not be trivially + // rate-limit-bypassed via a spoofed X-Forwarded-For header. Same rationale + // as reports.ts. + const ip = getRequestIP(event, { xForwardedFor: env.TRUST_XFF }) ?? "unknown" + + // Pre-buffer size gate: readMultipartFormData below buffers the ENTIRE body + // into RAM before any other check runs, so an oversized (or maliciously huge) + // body would be fully read pre-auth. Reject on the declared Content-Length + // first. Content-Length can be absent or lie (chunked bodies), so the + // post-parse video-size cap stays as the authoritative check; this only caps + // the honest-but-oversized common case cheaply. Deployments that must also + // bound chunked/streamed bodies should set a reverse-proxy body cap. + const declaredLength = getHeader(event, "content-length") + if (declaredLength !== undefined && Number(declaredLength) > env.INTAKE_MAX_BYTES) { + throw createError({ statusCode: 413, statusMessage: "Payload too large" }) + } + + let parts: Awaited> + try { + parts = await readMultipartFormData(event) + } catch { + throw createError({ statusCode: 400, statusMessage: "Invalid multipart body" }) + } + if (!parts) { + throw createError({ statusCode: 400, statusMessage: "Expected multipart/form-data" }) + } + + const metaPart = parts.find((p) => p.name === "meta") + if (!metaPart?.data) { + throw createError({ statusCode: 400, statusMessage: "Missing 'meta' part" }) + } + const filePart = parts.find((p) => p.name === "file") + if (!filePart?.data || filePart.data.length === 0) { + throw createError({ statusCode: 400, statusMessage: "Missing 'file' part" }) + } + + let meta: z.infer + try { + meta = MintMeta.parse(JSON.parse(metaPart.data.toString("utf8"))) + } catch (err) { + // Distinguish "mime not in the video-only allowlist" (415, a content-type + // rejection) from every other malformed-payload case (400). This is the + // only field with product-visible semantics beyond "the JSON is wrong". + if (err instanceof z.ZodError && err.issues.every((i) => i.path.join(".") === "mime")) { + throw createError({ statusCode: 415, statusMessage: "Unsupported media mime type" }) + } + const issues = + err && typeof err === "object" && "issues" in err + ? (err as { issues: unknown }).issues + : String(err) + console.warn("[share-mint] invalid meta payload", JSON.stringify(issues, null, 2)) + throw createError({ statusCode: 400, statusMessage: "Invalid meta payload", data: { issues } }) + } + + const [project] = await db + .select() + .from(projects) + .where(eq(projects.publicKey, meta.projectKey)) + .limit(1) + if (!project || project.deletedAt) { + throw createError({ statusCode: 401, statusMessage: "Invalid project key" }) + } + + // Origin allowlist MUST be checked before the rate limiter takes, otherwise + // an attacker with just a leaked project key can burn the legitimate SDK's + // quota from any origin (including origins not on the allowlist). Same + // ordering rationale as reports.ts. + if (!isOriginAllowed(origin, project.allowedOrigins)) { + // Deliberately do NOT emit ACAO here — cross-origin scripts cannot read + // this 403 body, which removes the cross-origin enumeration oracle. + throw createError({ statusCode: 403, statusMessage: "Origin not allowed" }) + } + + // Origin validated: emit ACAO so the legitimate SDK (on this allowed + // origin) can read both success AND error bodies of the rest of this + // request. Use the RAW origin, matching reports.ts. + if (rawOrigin) { + applyIntakePostCors(event, rawOrigin) + } + + if (!project.shareLinksEnabled) { + throw createError({ + statusCode: 403, + statusMessage: "Share links are disabled for this project", + }) + } + + // Defense-in-depth: the actual uploaded part's content-type must agree + // with the mime the caller declared in meta (which is already restricted + // to the video-only allowlist by the zod schema above). + const fileMime = bareMime(filePart.type ?? "") + if (fileMime !== meta.mime) { + throw createError({ + statusCode: 415, + statusMessage: "File content-type does not match meta.mime", + }) + } + + // Fire the per-project mint limiter and the per-IP limiter in parallel + // (mirrors reports.ts). The per-IP take stops one project key from being + // used to hammer mints from a single host even while the project quota has + // room, and vice-versa. + const shareLimiter = await getShareMintLimiter() + const ipLimiter = await getIpLimiter() + const [shareTake, ipTake] = await Promise.all([ + shareLimiter.take(`share:${project.id}`), + ipLimiter.take(`ip:${ip}`), + ]) + if (!shareTake.allowed || !ipTake.allowed) { + const retryAfterMs = Math.max( + shareTake.allowed ? 0 : shareTake.retryAfterMs, + ipTake.allowed ? 0 : ipTake.retryAfterMs, + ) + event.node.res.setHeader("Retry-After", Math.ceil(retryAfterMs / 1000).toString()) + const message = !shareTake.allowed + ? "Too many share links minted for this project" + : "Too many share links minted from this IP" + throw createError({ statusCode: 429, statusMessage: message }) + } + + if (filePart.data.length > env.INTAKE_MEDIA_VIDEO_MAX_BYTES) { + throw createError({ statusCode: 413, statusMessage: "File exceeds the video size cap" }) + } + + if (meta.trim && meta.durationMs !== undefined && meta.trim.endMs > meta.durationMs) { + throw createError({ statusCode: 400, statusMessage: "trim.endMs exceeds durationMs" }) + } + + const storage = await getStorage() + const id = randomUUID() + const ext = MEDIA_EXT[meta.mime] + const key = `shared-media/${id}.${ext}` + await storage.put(key, new Uint8Array(filePart.data), meta.mime) + + const expiresAt = new Date(Date.now() + project.shareRetentionDays * DAY_MS) + + let inserted: typeof sharedMedia.$inferSelect | undefined + let lastErr: unknown = null + for (let attempt = 0; attempt < MAX_MINT_ATTEMPTS; attempt++) { + const token = randomBytes(32).toString("base64url") + try { + const rows = await db + .insert(sharedMedia) + .values({ + id, + projectId: project.id, + token, + kind: meta.kind, + mime: meta.mime, + storageKey: key, + sizeBytes: filePart.data.length, + durationMs: meta.durationMs ?? null, + trimStartMs: meta.trim?.startMs ?? null, + trimEndMs: meta.trim?.endMs ?? null, + expiresAt, + }) + .returning() + inserted = rows[0] + lastErr = null + break + } catch (err) { + lastErr = err + // Only a unique-violation is worth retrying (token collision, or in + // principle the client-generated id). Anything else is a real error — + // bubble out immediately instead of masking it with a retry loop. + const code = (err as { code?: string }).code + if (code !== "23505") break + } + } + + if (!inserted) { + await rollbackPuts(storage, [key]) + console.error("[share-mint] insert failed after retries", lastErr) + throw createError({ statusCode: 500, statusMessage: "Failed to mint share link" }) + } + + event.node.res.statusCode = 201 + return { + id: inserted.id, + token: inserted.token, + shareUrl: `${getRequestURL(event).origin}/s/${inserted.token}`, + expiresAt: expiresAt.toISOString(), + } +}) diff --git a/apps/dashboard/server/api/intake/reports.ts b/apps/dashboard/server/api/intake/reports.ts index 3f8a9359..64eba446 100644 --- a/apps/dashboard/server/api/intake/reports.ts +++ b/apps/dashboard/server/api/intake/reports.ts @@ -1,7 +1,7 @@ import { createError, defineEventHandler, getHeader, getRequestIP, readMultipartFormData } from "h3" import { and, count, eq, gte, sql } from "drizzle-orm" import { randomUUID } from "node:crypto" -import { LogsAttachment, ReportIntakeInput } from "@reprojs/shared" +import { LogsAttachment, MediaMetaEntry, ReportIntakeInput } from "@reprojs/shared" import { db } from "../../db" import { githubIntegrations, projects, reports, reportAttachments } from "../../db/schema" import { @@ -25,6 +25,36 @@ const DENIED_USER_FILE_MIMES = new Set([ ]) const DENIED_USER_FILE_EXTS = [".exe", ".bat", ".cmd", ".com", ".scr", ".sh", ".ps1", ".vbs"] +// Gallery media (`media[N]` + `mediaMeta`) — screenshots + trimmed +// recordings from the SDK's own capture/annotation flow, distinct from +// arbitrary user-picked `attachment[N]` files. +const MEDIA_PART_RE = /^media\[(\d+)\]$/ +// Strip any `;codecs=...`/`;charset=...` parameters so a browser-emitted +// content-type like `video/webm;codecs=vp9` (Chrome's MediaRecorder default) +// is validated and stored as the bare `video/webm` the allowlist expects. +function bareMime(m: string): string { + return m.split(";")[0]?.trim() ?? m +} +const ALLOWED_MEDIA_MIMES = new Set([ + "image/png", + "image/jpeg", + "image/webp", + "video/webm", + "video/mp4", +]) +const MEDIA_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "video/webm": "webm", + "video/mp4": "mp4", +} +// clamd's default StreamMaxLength (25 MB). Media parts above this are stored +// unscanned (scanStatus: "skipped-size") rather than fail-closed rejected — +// the mime allowlist is the primary control for large video parts. See +// task-10 brief for rationale. +const CLAMAV_STREAM_CEILING = 26_214_400 + export default defineEventHandler(async (event) => { // Preflight reflects Origin so browsers can proceed with the real POST. // No response body reads happen on preflight, so this is safe. @@ -58,6 +88,18 @@ export default defineEventHandler(async (event) => { // rate-limit-bypassed via a spoofed X-Forwarded-For header. const ip = getRequestIP(event, { xForwardedFor: env.TRUST_XFF }) ?? "unknown" + // Pre-buffer size gate: readMultipartFormData below buffers the ENTIRE body + // into RAM before any other check runs, so an oversized (or maliciously huge) + // body would be fully read pre-auth. Reject on the declared Content-Length + // first. Content-Length can be absent or lie (chunked bodies), so the + // post-parse totalBytes gate stays as the authoritative check; this only caps + // the honest-but-oversized common case cheaply. Deployments that must also + // bound chunked/streamed bodies should set a reverse-proxy body cap. + const declaredLength = getHeader(event, "content-length") + if (declaredLength !== undefined && Number(declaredLength) > env.INTAKE_MAX_BYTES) { + throw createError({ statusCode: 413, statusMessage: "Payload too large" }) + } + let parts: Awaited> try { parts = await readMultipartFormData(event) @@ -282,6 +324,164 @@ export default defineEventHandler(async (event) => { } } + // ── Gallery media validation + virus scan (BEFORE the report row is + // inserted, same rationale as the user-file block above) ───────────────── + const mediaParts = parts.flatMap((p) => { + const m = p.name?.match(MEDIA_PART_RE) + if (!m || !p.data || p.data.length === 0) return [] + return [ + { idx: Number(m[1]), data: p.data, mime: bareMime(p.type ?? "application/octet-stream") }, + ] + }) + + interface MediaScanMeta { + scannedAt: Date | null + scanStatus: string + scanEngine: string | null + scanDurationMs: number | null + } + const mediaScanMetaByIdx = new Map() + const mediaMetaByIdx = new Map() + + if (mediaParts.length > 0) { + // Indices must form the exact unique contiguous set 0..mediaParts.length-1. + // Without this, duplicate media[N] parts (e.g. two media[0]s) both pass + // the mediaMeta length check below, both resolve the same mediaMeta + // entry, and both persist to the SAME storage key + // `${report.id}/media/N.` — the second `storage.put` silently + // overwrites the first blob while two attachment rows end up pointing at + // one key with divergent sizes. Gaps/out-of-range indices (e.g. a lone + // media[7]) are equally malformed and are now caught here too, instead + // of surfacing as a less specific "mediaMeta entry missing" error later. + const seenMediaIdx = new Set() + for (const { idx } of mediaParts) { + if (idx >= mediaParts.length || seenMediaIdx.has(idx)) { + throw createError({ + statusCode: 400, + statusMessage: "media part indices must be unique and contiguous", + }) + } + seenMediaIdx.add(idx) + } + + const mediaMetaPart = parts.find((p) => p.name === "mediaMeta") + if (!mediaMetaPart?.data) { + throw createError({ statusCode: 400, statusMessage: "Missing 'mediaMeta' part" }) + } + + let rawMeta: unknown + try { + rawMeta = JSON.parse(mediaMetaPart.data.toString("utf8")) + } catch { + throw createError({ statusCode: 400, statusMessage: "Invalid mediaMeta" }) + } + if (!Array.isArray(rawMeta)) { + throw createError({ statusCode: 400, statusMessage: "Invalid mediaMeta" }) + } + // Length check BEFORE the count cap: a mismatched meta array is a + // malformed request regardless of how many parts were sent, and we want + // a distinct 400 for it rather than letting a coincidentally-oversized + // array masquerade as a 413. + if (rawMeta.length !== mediaParts.length) { + throw createError({ + statusCode: 400, + statusMessage: "mediaMeta length must match the number of media parts", + }) + } + if (mediaParts.length > env.INTAKE_MEDIA_MAX_COUNT) { + throw createError({ + statusCode: 413, + statusMessage: `Too many media parts (max ${env.INTAKE_MEDIA_MAX_COUNT})`, + }) + } + + let meta: MediaMetaEntry[] + try { + meta = rawMeta.map((entry) => MediaMetaEntry.parse(entry)) + } catch (err) { + const issues = + err && typeof err === "object" && "issues" in err + ? (err as { issues: unknown }).issues + : String(err) + console.warn("[intake] invalid mediaMeta entry", JSON.stringify(issues, null, 2)) + throw createError({ statusCode: 400, statusMessage: "Invalid mediaMeta", data: { issues } }) + } + meta.forEach((entry, i) => mediaMetaByIdx.set(i, entry)) + + for (const { idx, data, mime } of mediaParts) { + const m = mediaMetaByIdx.get(idx) + if (!m) { + throw createError({ + statusCode: 400, + statusMessage: `mediaMeta entry missing for media[${idx}]`, + }) + } + // Normalize the declared mediaMeta mime the same way as the part mime so + // a parameterized codec string on either side (part content-type or the + // sidecar meta) still matches and stays on the bare allowlist. + if (!ALLOWED_MEDIA_MIMES.has(mime) || mime !== bareMime(m.mime)) { + throw createError({ + statusCode: 415, + statusMessage: `Media type not allowed: media[${idx}]`, + }) + } + const cap = + m.kind === "image" ? env.INTAKE_MEDIA_IMAGE_MAX_BYTES : env.INTAKE_MEDIA_VIDEO_MAX_BYTES + if (data.length > cap) { + throw createError({ + statusCode: 413, + statusMessage: `media[${idx}] exceeds the ${m.kind} size cap`, + }) + } + if (m.trim && m.durationMs !== undefined && m.trim.endMs > m.durationMs) { + throw createError({ + statusCode: 400, + statusMessage: `media[${idx}] trim.endMs exceeds durationMs`, + }) + } + } + + // Virus-scan each media part, same sequential-clamd rationale as the + // user-file loop above. Parts over the clamd StreamMaxLength ceiling are + // stored unscanned rather than fail-closed rejected — see + // CLAMAV_STREAM_CEILING comment. + if (env.INTAKE_USER_FILE_SCAN_ENABLED) { + for (const { idx, data } of mediaParts) { + if (data.length > CLAMAV_STREAM_CEILING) { + mediaScanMetaByIdx.set(idx, { + scannedAt: null, + scanStatus: "skipped-size", + scanEngine: null, + scanDurationMs: null, + }) + continue + } + let scan: Awaited> + try { + scan = await scanBytes(new Uint8Array(data)) + } catch (err) { + console.error("[intake] virus scanner unavailable", err) + throw createError({ + statusCode: 503, + statusMessage: "Attachment scanner unavailable, please retry", + }) + } + if (!scan.clean) { + throw createError({ + statusCode: 422, + statusMessage: `Media rejected by virus scanner: media[${idx}] (${scan.reason ?? "infected"})`, + }) + } + mediaScanMetaByIdx.set(idx, { + scannedAt: new Date(), + scanStatus: "clean", + scanEngine: scan.engine, + scanDurationMs: scan.durationMs, + }) + } + } + } + // SEC2: Daily ceiling — hard cap on reports per project per rolling 24h // window. Previously the COUNT and INSERT ran in separate statements which // allowed concurrent requests to all observe count = cap-1 and each commit @@ -439,6 +639,45 @@ export default defineEventHandler(async (event) => { } } + // ── Persist gallery media attachments (kind = "media") ──────────────────── + // All validation + virus-scanning ran above, BEFORE the report row was + // inserted, so every part here is known-clean and within its size cap. + if (mediaParts.length > 0) { + const storage = await getStorage() + const writtenKeys: string[] = [] + try { + // Sequential (not Promise.all) so writtenKeys only ever contains keys + // that have actually landed in storage before any failure, in a + // well-defined order — mirrors the user-file block's rollback contract. + for (const { idx, data, mime } of mediaParts) { + const m = mediaMetaByIdx.get(idx)! + const ext = MEDIA_EXT[mime] ?? "bin" + const key = `${report.id}/media/${idx}.${ext}` + await storage.put(key, new Uint8Array(data), mime) + writtenKeys.push(key) + const scanMeta = mediaScanMetaByIdx.get(idx) + await db.insert(reportAttachments).values({ + reportId: report.id, + kind: "media", + storageKey: key, + contentType: mime, + sizeBytes: data.length, + filename: `media-${idx}.${ext}`, + durationMs: m.durationMs ?? null, + trimStartMs: m.trim?.startMs ?? null, + trimEndMs: m.trim?.endMs ?? null, + scannedAt: scanMeta?.scannedAt ?? null, + scanStatus: scanMeta?.scanStatus ?? null, + scanEngine: scanMeta?.scanEngine ?? null, + scanDurationMs: scanMeta?.scanDurationMs ?? null, + }) + } + } catch (err) { + await rollbackPuts(storage, writtenKeys) + throw err + } + } + // Auto-create GitHub issue on intake when the toggle is on. // Runs after all attachments are persisted so the sync worker sees them. // Fire-and-forget: enqueueSync is a single SQL UPSERT (microseconds), so a diff --git a/apps/dashboard/server/api/projects/[id]/index.get.ts b/apps/dashboard/server/api/projects/[id]/index.get.ts index d288683f..f5e193a3 100644 --- a/apps/dashboard/server/api/projects/[id]/index.get.ts +++ b/apps/dashboard/server/api/projects/[id]/index.get.ts @@ -25,5 +25,7 @@ export default defineEventHandler(async (event) => { allowedOrigins: p.allowedOrigins, dailyReportCap: p.dailyReportCap, replayEnabled: p.replayEnabled, + shareLinksEnabled: p.shareLinksEnabled, + shareRetentionDays: p.shareRetentionDays, } }) diff --git a/apps/dashboard/server/api/projects/[id]/index.patch.ts b/apps/dashboard/server/api/projects/[id]/index.patch.ts index feb0db81..877109e8 100644 --- a/apps/dashboard/server/api/projects/[id]/index.patch.ts +++ b/apps/dashboard/server/api/projects/[id]/index.patch.ts @@ -18,6 +18,12 @@ export default defineEventHandler(async (event) => { ...(body.allowedOrigins !== undefined ? { allowedOrigins: body.allowedOrigins } : {}), ...(body.dailyReportCap !== undefined ? { dailyReportCap: body.dailyReportCap } : {}), ...(body.replayEnabled !== undefined ? { replayEnabled: body.replayEnabled } : {}), + ...(body.shareLinksEnabled !== undefined + ? { shareLinksEnabled: body.shareLinksEnabled } + : {}), + ...(body.shareRetentionDays !== undefined + ? { shareRetentionDays: body.shareRetentionDays } + : {}), updatedAt: new Date(), }) .where(eq(projects.id, id)) @@ -36,5 +42,7 @@ export default defineEventHandler(async (event) => { allowedOrigins: updated.allowedOrigins, dailyReportCap: updated.dailyReportCap, replayEnabled: updated.replayEnabled, + shareLinksEnabled: updated.shareLinksEnabled, + shareRetentionDays: updated.shareRetentionDays, } }) diff --git a/apps/dashboard/server/api/projects/[id]/reports/[reportId]/index.get.ts b/apps/dashboard/server/api/projects/[id]/reports/[reportId]/index.get.ts index 023ae03f..fe718e7d 100644 --- a/apps/dashboard/server/api/projects/[id]/reports/[reportId]/index.get.ts +++ b/apps/dashboard/server/api/projects/[id]/reports/[reportId]/index.get.ts @@ -77,6 +77,9 @@ export default defineEventHandler(async (event): Promise => { scanStatus: reportAttachments.scanStatus, scanEngine: reportAttachments.scanEngine, scanDurationMs: reportAttachments.scanDurationMs, + durationMs: reportAttachments.durationMs, + trimStartMs: reportAttachments.trimStartMs, + trimEndMs: reportAttachments.trimEndMs, }) .from(reportAttachments) .where(eq(reportAttachments.reportId, reportId)), @@ -113,7 +116,10 @@ export default defineEventHandler(async (event): Promise => { attachments: attachmentRows.map((a) => ({ id: a.id, kind: a.kind, - url: `/api/projects/${projectId}/reports/${reportId}/attachment?id=${a.id}`, + url: + a.kind === "media" + ? `/api/projects/${projectId}/reports/${reportId}/media/${a.id}` + : `/api/projects/${projectId}/reports/${reportId}/attachment?id=${a.id}`, contentType: a.contentType, sizeBytes: a.sizeBytes, filename: a.filename ?? null, @@ -121,6 +127,9 @@ export default defineEventHandler(async (event): Promise => { scanStatus: a.scanStatus ?? null, scanEngine: a.scanEngine ?? null, scanDurationMs: a.scanDurationMs ?? null, + durationMs: a.durationMs ?? null, + trimStartMs: a.trimStartMs ?? null, + trimEndMs: a.trimEndMs ?? null, })), } }) diff --git a/apps/dashboard/server/api/projects/[id]/reports/[reportId]/media/[attachmentId].get.ts b/apps/dashboard/server/api/projects/[id]/reports/[reportId]/media/[attachmentId].get.ts new file mode 100644 index 00000000..aba15d2b --- /dev/null +++ b/apps/dashboard/server/api/projects/[id]/reports/[reportId]/media/[attachmentId].get.ts @@ -0,0 +1,91 @@ +// apps/dashboard/server/api/projects/[id]/reports/[reportId]/media/[attachmentId].get.ts +// +// Session-authed Range-streaming blob for a report's kind="media" gallery +// attachments (images + trimmed video from the widget gallery/recording +// flow). Mirrors the shared-media blob route's Range semantics (Task 13) +// but is scoped to a project member instead of a public share token. +import { + createError, + defineEventHandler, + getHeader, + getRouterParam, + sendStream, + setHeader, + setResponseStatus, +} from "h3" +import { and, eq } from "drizzle-orm" +import { db } from "../../../../../../db" +import { reportAttachments, reports } from "../../../../../../db/schema" +import { parseRangeHeader } from "../../../../../../lib/range" +import { requireProjectRole } from "../../../../../../lib/permissions" +import { getStorage } from "../../../../../../lib/storage" + +// Same allowlist the intake route accepts for `media[N]` parts. Never trust +// the stored content_type directly — allowlisting blocks stored-XSS via a +// spoofed content type regardless of what landed in storage. +const MEDIA_CONTENT_TYPES: Record = { + "image/png": "image/png", + "image/jpeg": "image/jpeg", + "image/webp": "image/webp", + "video/webm": "video/webm", + "video/mp4": "video/mp4", +} + +export default defineEventHandler(async (event) => { + const projectId = getRouterParam(event, "id") + const reportId = getRouterParam(event, "reportId") + const attachmentId = getRouterParam(event, "attachmentId") + if (!projectId || !reportId || !attachmentId) { + throw createError({ statusCode: 400, statusMessage: "missing params" }) + } + + await requireProjectRole(event, projectId, "viewer") + + // The row must belong to this report, be kind="media", AND the report + // must belong to this project — same ownership check attachment.get.ts + // does via the join, so a viewer on project A can't fetch a media + // attachment id that happens to belong to a report on project B. + const [row] = await db + .select({ + storageKey: reportAttachments.storageKey, + contentType: reportAttachments.contentType, + sizeBytes: reportAttachments.sizeBytes, + }) + .from(reportAttachments) + .innerJoin(reports, eq(reports.id, reportAttachments.reportId)) + .where( + and( + eq(reportAttachments.id, attachmentId), + eq(reportAttachments.reportId, reportId), + eq(reportAttachments.kind, "media"), + eq(reports.projectId, projectId), + ), + ) + .limit(1) + + if (!row) throw createError({ statusCode: 404, statusMessage: "Not found" }) + + const storage = await getStorage() + const range = parseRangeHeader(getHeader(event, "range"), row.sizeBytes) + + const safeType = MEDIA_CONTENT_TYPES[row.contentType] ?? "application/octet-stream" + + setHeader(event, "Accept-Ranges", "bytes") + setHeader(event, "X-Content-Type-Options", "nosniff") + setHeader(event, "Content-Type", safeType) + setHeader(event, "Content-Disposition", "inline") + setHeader(event, "Cache-Control", "private, max-age=3600") + + if (range === "unsatisfiable") { + setHeader(event, "Content-Range", `bytes */${row.sizeBytes}`) + throw createError({ statusCode: 416, statusMessage: "Range Not Satisfiable" }) + } + + const s = await storage.getStream(row.storageKey, range ?? undefined) + if (range) { + setResponseStatus(event, 206) + setHeader(event, "Content-Range", `bytes ${s.start}-${s.end}/${s.totalBytes}`) + } + setHeader(event, "Content-Length", s.end - s.start + 1) + return sendStream(event, s.stream) +}) diff --git a/apps/dashboard/server/api/projects/[id]/shared-media/[mediaId].delete.ts b/apps/dashboard/server/api/projects/[id]/shared-media/[mediaId].delete.ts new file mode 100644 index 00000000..311d2aa6 --- /dev/null +++ b/apps/dashboard/server/api/projects/[id]/shared-media/[mediaId].delete.ts @@ -0,0 +1,34 @@ +import { createError, defineEventHandler, getRouterParam } from "h3" +import { and, eq } from "drizzle-orm" +import { db } from "../../../../db" +import { sharedMedia } from "../../../../db/schema" +import { requireProjectRole } from "../../../../lib/permissions" + +// Revokes a shared-media link. Idempotent: revoking an already-revoked row +// still returns 200 and leaves the original revokedAt untouched — callers +// shouldn't be able to "refresh" the revocation timestamp by calling twice. +// Scoped by projectId AND id together so a mediaId from another project +// 404s instead of leaking cross-project existence. +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, "id") + if (!id) throw createError({ statusCode: 400, statusMessage: "missing project id" }) + const mediaId = getRouterParam(event, "mediaId") + if (!mediaId) throw createError({ statusCode: 400, statusMessage: "missing mediaId" }) + await requireProjectRole(event, id, "manager") + + const [row] = await db + .select() + .from(sharedMedia) + .where(and(eq(sharedMedia.id, mediaId), eq(sharedMedia.projectId, id))) + .limit(1) + if (!row) throw createError({ statusCode: 404, statusMessage: "Shared media not found" }) + + if (!row.revokedAt) { + await db + .update(sharedMedia) + .set({ revokedAt: new Date() }) + .where(and(eq(sharedMedia.id, mediaId), eq(sharedMedia.projectId, id))) + } + + return { ok: true } +}) diff --git a/apps/dashboard/server/api/projects/[id]/shared-media/index.get.ts b/apps/dashboard/server/api/projects/[id]/shared-media/index.get.ts new file mode 100644 index 00000000..1316aa1e --- /dev/null +++ b/apps/dashboard/server/api/projects/[id]/shared-media/index.get.ts @@ -0,0 +1,31 @@ +import { createError, defineEventHandler, getRequestURL, getRouterParam } from "h3" +import { desc, eq } from "drizzle-orm" +import type { SharedMediaDTO } from "@reprojs/shared" +import { db } from "../../../../db" +import { sharedMedia } from "../../../../db/schema" +import { requireProjectRole } from "../../../../lib/permissions" + +export default defineEventHandler(async (event): Promise => { + const id = getRouterParam(event, "id") + if (!id) throw createError({ statusCode: 400, statusMessage: "missing project id" }) + await requireProjectRole(event, id, "manager") + + const rows = await db + .select() + .from(sharedMedia) + .where(eq(sharedMedia.projectId, id)) + .orderBy(desc(sharedMedia.createdAt)) + + const origin = getRequestURL(event).origin + return rows.map((row) => ({ + id: row.id, + kind: row.kind, + mime: row.mime, + sizeBytes: row.sizeBytes, + durationMs: row.durationMs, + createdAt: row.createdAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null, + shareUrl: `${origin}/s/${row.token}`, + })) +}) diff --git a/apps/dashboard/server/api/projects/index.get.ts b/apps/dashboard/server/api/projects/index.get.ts index 54ae461f..355ffd04 100644 --- a/apps/dashboard/server/api/projects/index.get.ts +++ b/apps/dashboard/server/api/projects/index.get.ts @@ -26,6 +26,8 @@ export default defineEventHandler(async (event): Promise => { allowedOrigins: r.allowedOrigins, dailyReportCap: r.dailyReportCap, replayEnabled: r.replayEnabled, + shareLinksEnabled: r.shareLinksEnabled, + shareRetentionDays: r.shareRetentionDays, })) } @@ -41,6 +43,8 @@ export default defineEventHandler(async (event): Promise => { allowedOrigins: projects.allowedOrigins, dailyReportCap: projects.dailyReportCap, replayEnabled: projects.replayEnabled, + shareLinksEnabled: projects.shareLinksEnabled, + shareRetentionDays: projects.shareRetentionDays, }) .from(projects) .innerJoin(projectMembers, eq(projectMembers.projectId, projects.id)) @@ -58,5 +62,7 @@ export default defineEventHandler(async (event): Promise => { allowedOrigins: r.allowedOrigins, dailyReportCap: r.dailyReportCap, replayEnabled: r.replayEnabled, + shareLinksEnabled: r.shareLinksEnabled, + shareRetentionDays: r.shareRetentionDays, })) }) diff --git a/apps/dashboard/server/api/shared/[token]/blob.get.ts b/apps/dashboard/server/api/shared/[token]/blob.get.ts new file mode 100644 index 00000000..6bde49ff --- /dev/null +++ b/apps/dashboard/server/api/shared/[token]/blob.get.ts @@ -0,0 +1,41 @@ +import { + createError, + defineEventHandler, + getHeader, + getRouterParam, + sendStream, + setHeader, + setResponseStatus, +} from "h3" +import { parseRangeHeader } from "../../../lib/range" +import { findLiveSharedMedia } from "../../../lib/shared-media" +import { getStorage } from "../../../lib/storage" + +// Public Range-streaming blob for a shared-media token. Same uniform 404 +// rule as index.get.ts — unknown, expired, and revoked tokens are +// indistinguishable. +export default defineEventHandler(async (event) => { + const token = getRouterParam(event, "token") ?? "" + const row = await findLiveSharedMedia(token) + if (!row) throw createError({ statusCode: 404, statusMessage: "Not found" }) + + const storage = await getStorage() + const range = parseRangeHeader(getHeader(event, "range"), row.sizeBytes) + + setHeader(event, "Accept-Ranges", "bytes") + setHeader(event, "X-Content-Type-Options", "nosniff") + setHeader(event, "Content-Type", row.mime) + + if (range === "unsatisfiable") { + setHeader(event, "Content-Range", `bytes */${row.sizeBytes}`) + throw createError({ statusCode: 416, statusMessage: "Range Not Satisfiable" }) + } + + const s = await storage.getStream(row.storageKey, range ?? undefined) + if (range) { + setResponseStatus(event, 206) + setHeader(event, "Content-Range", `bytes ${s.start}-${s.end}/${s.totalBytes}`) + } + setHeader(event, "Content-Length", s.end - s.start + 1) + return sendStream(event, s.stream) +}) diff --git a/apps/dashboard/server/api/shared/[token]/index.get.ts b/apps/dashboard/server/api/shared/[token]/index.get.ts new file mode 100644 index 00000000..96a3352c --- /dev/null +++ b/apps/dashboard/server/api/shared/[token]/index.get.ts @@ -0,0 +1,23 @@ +import { createError, defineEventHandler, getRouterParam } from "h3" +import { findLiveSharedMedia } from "../../../lib/shared-media" + +// Public metadata for a shared-media token. Deliberately excludes every +// project-scoped field (id, projectId, token, storageKey) — this response +// is reachable by anyone with the link, so it must not leak which project +// or attachment row the clip belongs to. +export default defineEventHandler(async (event) => { + const token = getRouterParam(event, "token") ?? "" + const row = await findLiveSharedMedia(token) + if (!row) throw createError({ statusCode: 404, statusMessage: "Not found" }) + + return { + kind: row.kind, + mime: row.mime, + sizeBytes: row.sizeBytes, + durationMs: row.durationMs, + trimStartMs: row.trimStartMs, + trimEndMs: row.trimEndMs, + createdAt: row.createdAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + } +}) diff --git a/apps/dashboard/server/db/migrations/0018_plain_stardust.sql b/apps/dashboard/server/db/migrations/0018_plain_stardust.sql new file mode 100644 index 00000000..047107d0 --- /dev/null +++ b/apps/dashboard/server/db/migrations/0018_plain_stardust.sql @@ -0,0 +1,27 @@ +CREATE TABLE "shared_media" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "token" text NOT NULL, + "kind" text NOT NULL, + "mime" text NOT NULL, + "storage_key" text NOT NULL, + "size_bytes" integer NOT NULL, + "duration_ms" integer, + "trim_start_ms" integer, + "trim_end_ms" integer, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp NOT NULL, + "revoked_at" timestamp, + CONSTRAINT "shared_media_token_unique" UNIQUE("token") +); +--> statement-breakpoint +ALTER TABLE "report_attachments" DROP CONSTRAINT "report_attachments_kind_check";--> statement-breakpoint +ALTER TABLE "projects" ADD COLUMN "share_links_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "projects" ADD COLUMN "share_retention_days" integer DEFAULT 30 NOT NULL;--> statement-breakpoint +ALTER TABLE "report_attachments" ADD COLUMN "duration_ms" integer;--> statement-breakpoint +ALTER TABLE "report_attachments" ADD COLUMN "trim_start_ms" integer;--> statement-breakpoint +ALTER TABLE "report_attachments" ADD COLUMN "trim_end_ms" integer;--> statement-breakpoint +ALTER TABLE "shared_media" ADD CONSTRAINT "shared_media_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "shared_media_project_idx" ON "shared_media" USING btree ("project_id");--> statement-breakpoint +CREATE INDEX "shared_media_expires_idx" ON "shared_media" USING btree ("expires_at");--> statement-breakpoint +ALTER TABLE "report_attachments" ADD CONSTRAINT "report_attachments_kind_check" CHECK ("report_attachments"."kind" IN ('screenshot', 'annotated-screenshot', 'replay', 'logs', 'user-file', 'media')); \ No newline at end of file diff --git a/apps/dashboard/server/db/migrations/meta/0018_snapshot.json b/apps/dashboard/server/db/migrations/meta/0018_snapshot.json new file mode 100644 index 00000000..2db78513 --- /dev/null +++ b/apps/dashboard/server/db/migrations/meta/0018_snapshot.json @@ -0,0 +1,3058 @@ +{ + "id": "b22b4a78-413a-4cdd-a091-78a6e5e22b3f", + "prevId": "28ce5484-1614-4e6e-bd6c-972966b988b4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "signup_gated": { + "name": "signup_gated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allowed_email_domains": { + "name": "allowed_email_domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_settings_singleton": { + "name": "app_settings_singleton", + "value": "\"app_settings\".\"id\" = 1" + } + }, + "isRLSEnabled": false + }, + "public.github_app": { + "name": "github_app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "github_app_singleton": { + "name": "github_app_singleton", + "value": "\"github_app\".\"id\" = 1" + } + }, + "isRLSEnabled": false + }, + "public.shared_media": { + "name": "shared_media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trim_start_ms": { + "name": "trim_start_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trim_end_ms": { + "name": "trim_end_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "shared_media_project_idx": { + "name": "shared_media_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_media_expires_idx": { + "name": "shared_media_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_media_project_id_projects_id_fk": { + "name": "shared_media_project_id_projects_id_fk", + "tableFrom": "shared_media", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shared_media_token_unique": { + "name": "shared_media_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_origins": { + "name": "allowed_origins", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "daily_report_cap": { + "name": "daily_report_cap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1000 + }, + "replay_enabled": { + "name": "replay_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "share_links_enabled": { + "name": "share_links_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "share_retention_days": { + "name": "share_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "public_key_regenerated_at": { + "name": "public_key_regenerated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_public_key_idx": { + "name": "projects_public_key_idx", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.report_attachments": { + "name": "report_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "report_id": { + "name": "report_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scanned_at": { + "name": "scanned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scan_status": { + "name": "scan_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scan_engine": { + "name": "scan_engine", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scan_duration_ms": { + "name": "scan_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trim_start_ms": { + "name": "trim_start_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trim_end_ms": { + "name": "trim_end_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "report_attachments_report_idx": { + "name": "report_attachments_report_idx", + "columns": [ + { + "expression": "report_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "report_attachments_report_id_reports_id_fk": { + "name": "report_attachments_report_id_reports_id_fk", + "tableFrom": "report_attachments", + "tableTo": "reports", + "columnsFrom": [ + "report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "report_attachments_kind_check": { + "name": "report_attachments_kind_check", + "value": "\"report_attachments\".\"kind\" IN ('screenshot', 'annotated-screenshot', 'replay', 'logs', 'user-file', 'media')" + } + }, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'web'" + }, + "device_platform": { + "name": "device_platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "github_issue_number": { + "name": "github_issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "github_issue_node_id": { + "name": "github_issue_node_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_issue_url": { + "name": "github_issue_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "milestone_number": { + "name": "milestone_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "milestone_title": { + "name": "milestone_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_synced_at": { + "name": "github_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "github_comments_synced_at": { + "name": "github_comments_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "reports_project_created_idx": { + "name": "reports_project_created_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_project_status_created_idx": { + "name": "reports_project_status_created_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_project_priority_idx": { + "name": "reports_project_priority_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_tags_gin_idx": { + "name": "reports_tags_gin_idx", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "reports_github_issue_number_idx": { + "name": "reports_github_issue_number_idx", + "columns": [ + { + "expression": "github_issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"reports\".\"github_issue_number\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_project_updated_at_idx": { + "name": "reports_project_updated_at_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_project_source_created_idx": { + "name": "reports_project_source_created_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_project_idempotency_key_idx": { + "name": "reports_project_idempotency_key_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"reports\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_project_id_projects_id_fk": { + "name": "reports_project_id_projects_id_fk", + "tableFrom": "reports", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'active'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.report_comments": { + "name": "report_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "report_id": { + "name": "report_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_client_id": { + "name": "actor_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_comment_id": { + "name": "github_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "report_comment_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "report_comments_report_created_idx": { + "name": "report_comments_report_created_idx", + "columns": [ + { + "expression": "report_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "report_comments_report_id_reports_id_fk": { + "name": "report_comments_report_id_reports_id_fk", + "tableFrom": "report_comments", + "tableTo": "reports", + "columnsFrom": [ + "report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "report_comments_user_id_user_id_fk": { + "name": "report_comments_user_id_user_id_fk", + "tableFrom": "report_comments", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "report_comments_github_comment_id_unique": { + "name": "report_comments_github_comment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "github_comment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.report_assignees": { + "name": "report_assignees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "report_id": { + "name": "report_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_avatar_url": { + "name": "github_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "report_assignees_report_idx": { + "name": "report_assignees_report_idx", + "columns": [ + { + "expression": "report_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "report_assignees_report_github_unique": { + "name": "report_assignees_report_github_unique", + "columns": [ + { + "expression": "report_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "report_assignees_report_id_reports_id_fk": { + "name": "report_assignees_report_id_reports_id_fk", + "tableFrom": "report_assignees", + "tableTo": "reports", + "columnsFrom": [ + "report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "report_assignees_assigned_by_user_id_fk": { + "name": "report_assignees_assigned_by_user_id_fk", + "tableFrom": "report_assignees", + "tableTo": "user", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_invitations": { + "name": "project_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_invitations_token_idx": { + "name": "project_invitations_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_invitations_project_email_idx": { + "name": "project_invitations_project_email_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_invitations_project_id_projects_id_fk": { + "name": "project_invitations_project_id_projects_id_fk", + "tableFrom": "project_invitations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integrations": { + "name": "github_integrations", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "default_labels": { + "name": "default_labels", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "default_assignees": { + "name": "default_assignees", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auto_create_on_intake": { + "name": "auto_create_on_intake", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "push_on_edit": { + "name": "push_on_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "labels_last_synced_at": { + "name": "labels_last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "milestones_last_synced_at": { + "name": "milestones_last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "members_last_synced_at": { + "name": "members_last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "github_integrations_installation_id_idx": { + "name": "github_integrations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_integrations_project_id_projects_id_fk": { + "name": "github_integrations_project_id_projects_id_fk", + "tableFrom": "github_integrations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_webhook_deliveries": { + "name": "github_webhook_deliveries", + "schema": "", + "columns": { + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_webhook_deliveries_received_at_idx": { + "name": "github_webhook_deliveries_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_write_locks": { + "name": "github_write_locks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "report_id": { + "name": "report_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "github_write_lock_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "github_write_locks_lookup_idx": { + "name": "github_write_locks_lookup_idx", + "columns": [ + { + "expression": "report_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_write_locks_report_id_reports_id_fk": { + "name": "github_write_locks_report_id_reports_id_fk", + "tableFrom": "github_write_locks", + "tableTo": "reports", + "columnsFrom": [ + "report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_members_user_idx": { + "name": "project_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_members_project_id_user_id_pk": { + "name": "project_members_project_id_user_id_pk", + "columns": [ + "project_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_buckets": { + "name": "rate_limit_buckets", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "last_refill_ms": { + "name": "last_refill_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.report_events": { + "name": "report_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "report_id": { + "name": "report_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_client_id": { + "name": "actor_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "report_events_report_created_idx": { + "name": "report_events_report_created_idx", + "columns": [ + { + "expression": "report_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "report_events_project_created_at_idx": { + "name": "report_events_project_created_at_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "report_events_report_id_reports_id_fk": { + "name": "report_events_report_id_reports_id_fk", + "tableFrom": "report_events", + "tableTo": "reports", + "columnsFrom": [ + "report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "report_events_project_id_projects_id_fk": { + "name": "report_events_project_id_projects_id_fk", + "tableFrom": "report_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.report_sync_jobs": { + "name": "report_sync_jobs", + "schema": "", + "columns": { + "report_id": { + "name": "report_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reconcile'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "report_sync_jobs_pending_idx": { + "name": "report_sync_jobs_pending_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"report_sync_jobs\".\"state\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "report_sync_jobs_failed_idx": { + "name": "report_sync_jobs_failed_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"report_sync_jobs\".\"state\" = 'failed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "report_sync_jobs_report_id_reports_id_fk": { + "name": "report_sync_jobs_report_id_reports_id_fk", + "tableFrom": "report_sync_jobs", + "tableTo": "reports", + "columnsFrom": [ + "report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "report_sync_jobs_report_id_signature_pk": { + "name": "report_sync_jobs_report_id_signature_pk", + "columns": [ + "report_id", + "signature" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "identity_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_handle": { + "name": "external_handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_name": { + "name": "external_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_email": { + "name": "external_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_avatar_url": { + "name": "external_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_identities_provider_external_id_unique": { + "name": "user_identities_provider_external_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_identities_user_provider_unique": { + "name": "user_identities_user_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_identities_user_id_user_id_fk": { + "name": "user_identities_user_id_user_id_fk", + "tableFrom": "user_identities", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.report_comment_source": { + "name": "report_comment_source", + "schema": "public", + "values": [ + "dashboard", + "github" + ] + }, + "public.github_write_lock_kind": { + "name": "github_write_lock_kind", + "schema": "public", + "values": [ + "labels", + "assignees", + "milestone", + "state", + "title", + "comment_upsert", + "comment_delete" + ] + }, + "public.identity_provider": { + "name": "identity_provider", + "schema": "public", + "values": [ + "github" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/dashboard/server/db/migrations/meta/_journal.json b/apps/dashboard/server/db/migrations/meta/_journal.json index f6b98377..586c2f13 100644 --- a/apps/dashboard/server/db/migrations/meta/_journal.json +++ b/apps/dashboard/server/db/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1778157226091, "tag": "0017_cute_toxin", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1784275161396, + "tag": "0018_plain_stardust", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/dashboard/server/db/schema/index.ts b/apps/dashboard/server/db/schema/index.ts index 6e04c90d..76b38529 100644 --- a/apps/dashboard/server/db/schema/index.ts +++ b/apps/dashboard/server/db/schema/index.ts @@ -13,3 +13,4 @@ export * from "./user-identities" export * from "./github-write-locks" export * from "./report-comments" export * from "./report-assignees" +export * from "./shared-media" diff --git a/apps/dashboard/server/db/schema/projects.ts b/apps/dashboard/server/db/schema/projects.ts index 1e765556..6f6ea009 100644 --- a/apps/dashboard/server/db/schema/projects.ts +++ b/apps/dashboard/server/db/schema/projects.ts @@ -25,6 +25,8 @@ export const projects = pgTable( .default(sql`'{}'::text[]`), dailyReportCap: integer("daily_report_cap").notNull().default(1000), replayEnabled: boolean("replay_enabled").notNull().default(true), + shareLinksEnabled: boolean("share_links_enabled").notNull().default(true), + shareRetentionDays: integer("share_retention_days").notNull().default(30), publicKeyRegeneratedAt: timestamp("public_key_regenerated_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), diff --git a/apps/dashboard/server/db/schema/reports.ts b/apps/dashboard/server/db/schema/reports.ts index f645fefe..28ccd843 100644 --- a/apps/dashboard/server/db/schema/reports.ts +++ b/apps/dashboard/server/db/schema/reports.ts @@ -86,7 +86,7 @@ export const reportAttachments = pgTable( .notNull() .references(() => reports.id, { onDelete: "cascade" }), kind: text("kind", { - enum: ["screenshot", "annotated-screenshot", "replay", "logs", "user-file"], + enum: ["screenshot", "annotated-screenshot", "replay", "logs", "user-file", "media"], }).notNull(), storageKey: text("storage_key").notNull(), contentType: text("content_type").notNull(), @@ -100,12 +100,15 @@ export const reportAttachments = pgTable( scanStatus: text("scan_status"), scanEngine: text("scan_engine"), scanDurationMs: integer("scan_duration_ms"), + durationMs: integer("duration_ms"), + trimStartMs: integer("trim_start_ms"), + trimEndMs: integer("trim_end_ms"), createdAt: timestamp("created_at").defaultNow().notNull(), }, (table) => ({ kindCheck: check( "report_attachments_kind_check", - sql`${table.kind} IN ('screenshot', 'annotated-screenshot', 'replay', 'logs', 'user-file')`, + sql`${table.kind} IN ('screenshot', 'annotated-screenshot', 'replay', 'logs', 'user-file', 'media')`, ), reportIdx: index("report_attachments_report_idx").on(table.reportId), }), diff --git a/apps/dashboard/server/db/schema/shared-media.ts b/apps/dashboard/server/db/schema/shared-media.ts new file mode 100644 index 00000000..34888072 --- /dev/null +++ b/apps/dashboard/server/db/schema/shared-media.ts @@ -0,0 +1,30 @@ +import { index, integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core" +import { projects } from "./projects" + +export const sharedMedia = pgTable( + "shared_media", + { + id: uuid("id").defaultRandom().primaryKey(), + projectId: uuid("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + token: text("token").notNull().unique(), + kind: text("kind", { enum: ["video"] }).notNull(), + mime: text("mime").notNull(), + storageKey: text("storage_key").notNull(), + sizeBytes: integer("size_bytes").notNull(), + durationMs: integer("duration_ms"), + trimStartMs: integer("trim_start_ms"), + trimEndMs: integer("trim_end_ms"), + createdAt: timestamp("created_at").notNull().defaultNow(), + expiresAt: timestamp("expires_at").notNull(), + revokedAt: timestamp("revoked_at"), + }, + (table) => ({ + projectIdx: index("shared_media_project_idx").on(table.projectId), + expiresIdx: index("shared_media_expires_idx").on(table.expiresAt), + }), +) + +export type SharedMedia = typeof sharedMedia.$inferSelect +export type NewSharedMedia = typeof sharedMedia.$inferInsert diff --git a/apps/dashboard/server/lib/env.ts b/apps/dashboard/server/lib/env.ts index fe56c45b..a012abb9 100644 --- a/apps/dashboard/server/lib/env.ts +++ b/apps/dashboard/server/lib/env.ts @@ -77,11 +77,26 @@ const Schema = z.object({ INTAKE_RATE_PER_KEY: intString(60), INTAKE_RATE_PER_IP: intString(20), INTAKE_RATE_PER_KEY_ANON: intString(10), - INTAKE_MAX_BYTES: intString(5_242_880), + // Whole-multipart ceiling. Must sit ABOVE the SDK's advertised attachment + // limits (10 MB/file, 25 MB total user files) plus screenshot/replay/logs + // headroom — a 5 MB default silently 413'd every video attachment the + // widget itself accepted. + // Raised to 150 MB so a single max-size gallery video (100 MB, see + // INTAKE_MEDIA_VIDEO_MAX_BYTES below) plus the report JSON + other parts + // fits under the whole-multipart ceiling. + INTAKE_MAX_BYTES: intString(157_286_400), INTAKE_USER_FILE_MAX_BYTES: intString(10 * 1024 * 1024), INTAKE_USER_FILES_TOTAL_MAX_BYTES: intString(25 * 1024 * 1024), INTAKE_USER_FILES_MAX_COUNT: intString(5), + // Gallery media (`media[N]` + `mediaMeta`) caps — separate from the + // generic user-file attachment caps above since media parts come from the + // SDK's own capture/annotation flow (screenshots + trimmed recordings), + // not arbitrary user-picked files. + INTAKE_MEDIA_MAX_COUNT: intString(3), + INTAKE_MEDIA_IMAGE_MAX_BYTES: intString(10_485_760), + INTAKE_MEDIA_VIDEO_MAX_BYTES: intString(104_857_600), + // Virus scanning for user-supplied attachments. When ENABLED is false // (the default) the scan path is skipped entirely so self-hosters who // don't run a ClamAV sidecar aren't impacted. When true, intake fails @@ -117,6 +132,11 @@ const Schema = z.object({ // typed by hand; a runaway loop hits the cap before burning SMTP quota. INVITE_RATE_PER_ADMIN: intString(5), + // Share-link mint limiter — caps how many share links a single project + // can mint per minute (POST /api/intake/media). Low default: minting is a + // deliberate reporter action (export a clip), not a high-frequency call. + SHARE_MINTS_PER_MINUTE: intString(2), + RATE_LIMIT_STORE: z.enum(["memory", "postgres"]).default("memory"), TRUST_XFF: boolString.default(false), SDK_PATH: z.string().optional().default(""), diff --git a/apps/dashboard/server/lib/range.test.ts b/apps/dashboard/server/lib/range.test.ts new file mode 100644 index 00000000..7f3f20e7 --- /dev/null +++ b/apps/dashboard/server/lib/range.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { parseRangeHeader } from "./range" + +test("parses bytes=0-499", () => { + expect(parseRangeHeader("bytes=0-499", 1000)).toEqual({ start: 0, end: 499 }) +}) +test("open-ended and suffix ranges", () => { + expect(parseRangeHeader("bytes=500-", 1000)).toEqual({ start: 500, end: 999 }) + expect(parseRangeHeader("bytes=-200", 1000)).toEqual({ start: 800, end: 999 }) +}) +test("clamps end to size", () => { + expect(parseRangeHeader("bytes=0-99999", 1000)).toEqual({ start: 0, end: 999 }) +}) +test("unsatisfiable start", () => { + expect(parseRangeHeader("bytes=1000-", 1000)).toBe("unsatisfiable") +}) +test("absent, malformed, multi-range → null", () => { + expect(parseRangeHeader(undefined, 1000)).toBeNull() + expect(parseRangeHeader("bytes=abc", 1000)).toBeNull() + expect(parseRangeHeader("bytes=0-1,5-9", 1000)).toBeNull() +}) diff --git a/apps/dashboard/server/lib/range.ts b/apps/dashboard/server/lib/range.ts new file mode 100644 index 00000000..13475677 --- /dev/null +++ b/apps/dashboard/server/lib/range.ts @@ -0,0 +1,23 @@ +const RANGE_RE = /^bytes=(\d*)-(\d*)$/ + +export function parseRangeHeader( + header: string | undefined, + totalBytes: number, +): { start: number; end: number } | "unsatisfiable" | null { + if (!header) return null + const m = RANGE_RE.exec(header.trim()) + if (!m) return null + const [, rawStart, rawEnd] = m + if (rawStart === "" && rawEnd === "") return null + if (rawStart === "") { + const suffix = Number(rawEnd) + if (suffix === 0) return "unsatisfiable" + const start = Math.max(0, totalBytes - suffix) + return totalBytes === 0 ? "unsatisfiable" : { start, end: totalBytes - 1 } + } + const start = Number(rawStart) + if (start >= totalBytes) return "unsatisfiable" + const end = rawEnd === "" ? totalBytes - 1 : Math.min(Number(rawEnd), totalBytes - 1) + if (end < start) return "unsatisfiable" + return { start, end } +} diff --git a/apps/dashboard/server/lib/rate-limit.ts b/apps/dashboard/server/lib/rate-limit.ts index 85c70056..57a98051 100644 --- a/apps/dashboard/server/lib/rate-limit.ts +++ b/apps/dashboard/server/lib/rate-limit.ts @@ -72,6 +72,7 @@ let _keyLimiter: RateLimiter | null = null let _ipLimiter: RateLimiter | null = null let _anonKeyLimiter: RateLimiter | null = null let _inviteLimiter: RateLimiter | null = null +let _shareMintLimiter: RateLimiter | null = null async function buildLimiter(perMinute: number): Promise { if (env.RATE_LIMIT_STORE === "postgres") { @@ -114,3 +115,16 @@ export async function getInviteLimiter(): Promise { } return _inviteLimiter } + +/** + * Limiter for the public share-link mint endpoint (POST /api/intake/media). + * Keyed per-project (`share:${project.id}`) so one project minting share + * links can't burn another project's quota. Low default (2/min) since + * minting is a deliberate reporter action, not a high-frequency call. + */ +export async function getShareMintLimiter(): Promise { + if (!_shareMintLimiter) { + _shareMintLimiter = await buildLimiter(env.SHARE_MINTS_PER_MINUTE) + } + return _shareMintLimiter +} diff --git a/apps/dashboard/server/lib/shared-media-purge.test.ts b/apps/dashboard/server/lib/shared-media-purge.test.ts new file mode 100644 index 00000000..d4f356e9 --- /dev/null +++ b/apps/dashboard/server/lib/shared-media-purge.test.ts @@ -0,0 +1,135 @@ +// apps/dashboard/server/lib/shared-media-purge.test.ts +// Integration test — hits the real Postgres instance and the real (local- +// disk) storage adapter, mirroring server/lib/github-write-locks.test.ts. +// Run with: bun test apps/dashboard/server/lib/shared-media-purge.test.ts +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { sql } from "drizzle-orm" +import { db } from "../db" +import { projects, sharedMedia } from "../db/schema" +import { getStorage } from "./storage" +import { purgeSharedMedia } from "./shared-media-purge" + +let testProjectId: string + +async function truncate() { + await db.execute(sql`TRUNCATE shared_media RESTART IDENTITY CASCADE`) +} + +beforeEach(async () => { + await db.execute( + sql`TRUNCATE project_invitations, project_members, projects, "account", "session", "verification", "user" RESTART IDENTITY CASCADE`, + ) + await truncate() + + const [p] = await db + .insert(projects) + .values({ + name: "test", + createdBy: "user-test", + publicKey: "rp_pk_sharedmediapurgetest", + allowedOrigins: [], + }) + .returning() + testProjectId = p.id +}) + +afterEach(async () => { + await truncate() + await db.execute( + sql`TRUNCATE project_invitations, project_members, projects, "account", "session", "verification", "user" RESTART IDENTITY CASCADE`, + ) +}) + +async function seedRow(opts: { + label: string + expiresAt: Date + revokedAt?: Date | null +}): Promise<{ id: string; storageKey: string }> { + const storage = await getStorage() + const storageKey = `shared-media/purge-test-${opts.label}-${Math.random().toString(36).slice(2)}.webm` + await storage.put(storageKey, new Uint8Array([1, 2, 3, 4]), "video/webm") + + const [row] = await db + .insert(sharedMedia) + .values({ + projectId: testProjectId, + token: `tok_${opts.label}_${Math.random().toString(36).slice(2)}`, + kind: "video", + mime: "video/webm", + storageKey, + sizeBytes: 4, + expiresAt: opts.expiresAt, + revokedAt: opts.revokedAt ?? null, + }) + .returning({ id: sharedMedia.id }) + if (!row) throw new Error("seedRow: insert returned no row") + return { id: row.id, storageKey } +} + +describe("purgeSharedMedia", () => { + test("deletes expired and long-revoked rows + blobs; leaves live and recently-revoked ones", async () => { + const now = new Date("2026-07-17T12:00:00.000Z") + + const live = await seedRow({ + label: "live", + expiresAt: new Date(now.getTime() + 60 * 60 * 1000), // +1h, not expired + }) + const expired = await seedRow({ + label: "expired", + expiresAt: new Date(now.getTime() - 60 * 1000), // -1min, expired + }) + const revoked25hAgo = await seedRow({ + label: "revoked-25h", + expiresAt: new Date(now.getTime() + 60 * 60 * 1000), // not expired on its own + revokedAt: new Date(now.getTime() - 25 * 60 * 60 * 1000), // revoked 25h ago + }) + const revoked1hAgo = await seedRow({ + label: "revoked-1h", + expiresAt: new Date(now.getTime() + 60 * 60 * 1000), + revokedAt: new Date(now.getTime() - 60 * 60 * 1000), // revoked 1h ago — inside grace + }) + + const result = await purgeSharedMedia(now) + expect(result).toEqual({ purged: 2 }) + + const remaining = await db.select().from(sharedMedia) + const remainingIds = remaining.map((r) => r.id).toSorted() + expect(remainingIds).toEqual([live.id, revoked1hAgo.id].toSorted()) + + const storage = await getStorage() + + // Live + recently-revoked rows remain, and their blobs are untouched. + await expect(storage.get(live.storageKey)).resolves.toBeDefined() + await expect(storage.get(revoked1hAgo.storageKey)).resolves.toBeDefined() + + // Purged rows' blobs are gone. + await expect(storage.get(expired.storageKey)).rejects.toBeDefined() + await expect(storage.get(revoked25hAgo.storageKey)).rejects.toBeDefined() + }) + + test("storage.delete failure (missing blob) does not block row deletion", async () => { + const now = new Date("2026-07-17T12:00:00.000Z") + const expired = await seedRow({ + label: "missing-blob", + expiresAt: new Date(now.getTime() - 1000), + }) + + // Simulate a blob that's already gone from storage. + const storage = await getStorage() + await storage.delete(expired.storageKey) + + const result = await purgeSharedMedia(now) + expect(result).toEqual({ purged: 1 }) + + const remaining = await db.select().from(sharedMedia) + expect(remaining.length).toBe(0) + }) + + test("returns { purged: 0 } when nothing qualifies", async () => { + const now = new Date("2026-07-17T12:00:00.000Z") + await seedRow({ label: "live-only", expiresAt: new Date(now.getTime() + 60 * 60 * 1000) }) + + const result = await purgeSharedMedia(now) + expect(result).toEqual({ purged: 0 }) + }) +}) diff --git a/apps/dashboard/server/lib/shared-media-purge.ts b/apps/dashboard/server/lib/shared-media-purge.ts new file mode 100644 index 00000000..d631e135 --- /dev/null +++ b/apps/dashboard/server/lib/shared-media-purge.ts @@ -0,0 +1,57 @@ +// apps/dashboard/server/lib/shared-media-purge.ts +// Nightly retention sweep for shared_media rows (Task 9's public video-share +// links). Two independent triggers make a row eligible for purge: +// - expires_at has passed (natural TTL), or +// - the link was explicitly revoked (see shared-media admin DELETE) and +// the revoke happened more than 24h ago. +// The 24h grace window on revoke exists so a just-revoked row is still +// visible/debuggable in the admin UI for a day before its blob is reclaimed. +import { and, eq, isNotNull, lt, or } from "drizzle-orm" +import { db } from "../db" +import { sharedMedia } from "../db/schema" +import { getStorage } from "./storage" + +const REVOKED_GRACE_MS = 24 * 60 * 60 * 1000 + +/** + * Delete shared_media rows (and their storage blobs) that are expired or + * were revoked more than 24h ago. + * + * Boundary semantics: a row revoked *exactly* 24h before `now` is NOT + * purged — the condition is strict `<` on `revoked_at < now - 24h`, so the + * row must be older than the grace window, not merely at its edge. + * + * Per row: storage.delete() runs in its own try/catch — a blob that's + * already missing (or a storage backend error) must never block the row + * from being deleted, otherwise a corrupt/missing blob would wedge the + * purge forever on the same row. + */ +export async function purgeSharedMedia(now = new Date()): Promise<{ purged: number }> { + const revokedGraceCutoff = new Date(now.getTime() - REVOKED_GRACE_MS) + + const candidates = await db + .select({ id: sharedMedia.id, storageKey: sharedMedia.storageKey }) + .from(sharedMedia) + .where( + or( + lt(sharedMedia.expiresAt, now), + and(isNotNull(sharedMedia.revokedAt), lt(sharedMedia.revokedAt, revokedGraceCutoff)), + ), + ) + + if (candidates.length === 0) return { purged: 0 } + + const storage = await getStorage() + let purged = 0 + for (const row of candidates) { + try { + await storage.delete(row.storageKey) + } catch { + // Blob already gone (or backend hiccup) — must not block row deletion. + } + await db.delete(sharedMedia).where(eq(sharedMedia.id, row.id)) + purged++ + } + + return { purged } +} diff --git a/apps/dashboard/server/lib/shared-media.ts b/apps/dashboard/server/lib/shared-media.ts new file mode 100644 index 00000000..52d4f96e --- /dev/null +++ b/apps/dashboard/server/lib/shared-media.ts @@ -0,0 +1,20 @@ +import { eq } from "drizzle-orm" +import { db } from "../db" +import { sharedMedia } from "../db/schema" + +/** + * Look up a shared_media row by token, treating unknown, expired, and + * revoked tokens identically (all return null). Callers MUST surface a + * uniform 404 for every null case — the public share routes must not leak + * which of the three states a given token is in. + */ +export async function findLiveSharedMedia( + token: string, +): Promise { + if (!token || token.length > 128) return null + const [row] = await db.select().from(sharedMedia).where(eq(sharedMedia.token, token)).limit(1) + if (!row) return null + if (row.revokedAt) return null + if (row.expiresAt.getTime() < Date.now()) return null + return row +} diff --git a/apps/dashboard/server/lib/storage/index.ts b/apps/dashboard/server/lib/storage/index.ts index 5e0b62fc..ea16d893 100644 --- a/apps/dashboard/server/lib/storage/index.ts +++ b/apps/dashboard/server/lib/storage/index.ts @@ -1,8 +1,17 @@ import { env } from "../env" +export interface StorageStream { + stream: ReadableStream | NodeJS.ReadableStream + contentType: string + totalBytes: number // full object size, regardless of range + start: number // inclusive byte offset actually served + end: number // inclusive +} + export interface StorageAdapter { put(key: string, bytes: Uint8Array, contentType: string): Promise<{ key: string }> get(key: string): Promise<{ bytes: Uint8Array; contentType: string }> + getStream(key: string, range?: { start: number; end?: number }): Promise delete(key: string): Promise } diff --git a/apps/dashboard/server/lib/storage/local-disk.test.ts b/apps/dashboard/server/lib/storage/local-disk.test.ts index 800165e5..cebbea00 100644 --- a/apps/dashboard/server/lib/storage/local-disk.test.ts +++ b/apps/dashboard/server/lib/storage/local-disk.test.ts @@ -4,6 +4,14 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { LocalDiskAdapter } from "./local-disk" +async function collect(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return new Uint8Array(Buffer.concat(chunks)) +} + let root: string const roots: string[] = [] @@ -49,4 +57,40 @@ describe("LocalDiskAdapter", () => { await expect(adapter.get("x.bin")).rejects.toThrow() await adapter.delete("x.bin") // second delete no-op }) + + test("getStream with a range returns exactly the requested bytes", async () => { + const adapter = new LocalDiskAdapter(root) + const key = "attachments/range/ten.bin" + const bytes = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + await adapter.put(key, bytes, "application/octet-stream") + + const result = await adapter.getStream(key, { start: 2, end: 5 }) + const got = await collect(result.stream as NodeJS.ReadableStream) + + expect(Array.from(got)).toEqual([2, 3, 4, 5]) + expect(result.totalBytes).toBe(10) + expect(result.start).toBe(2) + expect(result.end).toBe(5) + expect(result.contentType).toBe("application/octet-stream") + }) + + test("getStream with no range returns the full object", async () => { + const adapter = new LocalDiskAdapter(root) + const key = "attachments/range/full.bin" + const bytes = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + await adapter.put(key, bytes, "application/octet-stream") + + const result = await adapter.getStream(key) + const got = await collect(result.stream as NodeJS.ReadableStream) + + expect(Array.from(got)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + expect(result.totalBytes).toBe(10) + expect(result.start).toBe(0) + expect(result.end).toBe(9) + }) + + test("getStream on a missing key rejects", async () => { + const adapter = new LocalDiskAdapter(root) + await expect(adapter.getStream("nope.bin")).rejects.toThrow() + }) }) diff --git a/apps/dashboard/server/lib/storage/local-disk.ts b/apps/dashboard/server/lib/storage/local-disk.ts index b7492b40..7ca375f8 100644 --- a/apps/dashboard/server/lib/storage/local-disk.ts +++ b/apps/dashboard/server/lib/storage/local-disk.ts @@ -1,6 +1,7 @@ -import { mkdir, readFile, unlink, writeFile } from "node:fs/promises" +import { createReadStream } from "node:fs" +import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises" import { dirname, join, resolve } from "node:path" -import type { StorageAdapter } from "./index" +import type { StorageAdapter, StorageStream } from "./index" const CONTENT_TYPE_SUFFIX = ".contenttype" @@ -31,6 +32,21 @@ export class LocalDiskAdapter implements StorageAdapter { return { bytes: new Uint8Array(bytes), contentType } } + async getStream(key: string, range?: { start: number; end?: number }): Promise { + const full = this.resolveKey(key) + const st = await stat(full) + let contentType = "application/octet-stream" + try { + contentType = (await readFile(`${full}${CONTENT_TYPE_SUFFIX}`, "utf8")).trim() + } catch { + // sidecar missing — fall through + } + const start = range?.start ?? 0 + const end = range?.end ?? st.size - 1 + const stream = createReadStream(full, { start, end }) + return { stream, contentType, totalBytes: st.size, start, end } + } + async delete(key: string) { const full = this.resolveKey(key) await Promise.all( diff --git a/apps/dashboard/server/lib/storage/s3.test.ts b/apps/dashboard/server/lib/storage/s3.test.ts index e21d6f0a..dea750b8 100644 --- a/apps/dashboard/server/lib/storage/s3.test.ts +++ b/apps/dashboard/server/lib/storage/s3.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { GetObjectCommand, HeadObjectCommand, type S3Client } from "@aws-sdk/client-s3" +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import { _reloadEnvForTesting } from "../env" import { S3Adapter } from "./s3" @@ -147,3 +148,88 @@ describe("validateS3Endpoint SSRF blocklist", () => { }) }) }) + +function webStreamOfBytes(bytes: number[]): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(bytes)) + controller.close() + }, + }) +} + +/** Minimal S3Client stand-in; `send` is swapped per-test via the injection seam on S3Adapter. */ +function fakeClient( + sendImpl: (command: GetObjectCommand | HeadObjectCommand) => Promise, +): S3Client { + return { send: mock(sendImpl) } as unknown as S3Client +} + +describe("S3Adapter#getStream", () => { + test("range requested, backend 206 omits ContentRange: totalBytes comes from HeadObjectCommand, not the partial ContentLength", async () => { + let headCalls = 0 + const client = fakeClient(async (command) => { + if (command instanceof HeadObjectCommand) { + headCalls++ + return { ContentLength: 100 } + } + // GetObjectCommand: 206 response WITHOUT ContentRange, ContentLength is the + // partial (4-byte) size only — must not leak into totalBytes. + return { + Body: { transformToWebStream: () => webStreamOfBytes([1, 2, 3, 4]) }, + ContentLength: 4, + ContentType: "video/webm", + } + }) + const adapter = new S3Adapter(client) + + const result = await adapter.getStream("videos/clip.webm", { start: 0, end: 3 }) + + expect(result.totalBytes).toBe(100) + expect(result.start).toBe(0) + expect(result.end).toBe(3) + expect(headCalls).toBe(1) + }) + + test("open-ended range with missing ContentRange: end is derived from the HEAD-corrected totalBytes", async () => { + const client = fakeClient(async (command) => { + if (command instanceof HeadObjectCommand) return { ContentLength: 100 } + return { + Body: { transformToWebStream: () => webStreamOfBytes([1, 2, 3, 4]) }, + ContentLength: 4, + ContentType: "video/webm", + } + }) + const adapter = new S3Adapter(client) + + const result = await adapter.getStream("videos/clip.webm", { start: 96 }) + + expect(result.totalBytes).toBe(100) + expect(result.start).toBe(96) + expect(result.end).toBe(99) + }) + + test("range requested, backend includes ContentRange: totalBytes parsed from it, no HEAD call", async () => { + let headCalls = 0 + const client = fakeClient(async (command) => { + if (command instanceof HeadObjectCommand) { + headCalls++ + return { ContentLength: 4 } + } + return { + Body: { transformToWebStream: () => webStreamOfBytes([1, 2, 3, 4]) }, + ContentLength: 4, + ContentType: "video/webm", + ContentRange: "bytes 0-3/50", + } + }) + const adapter = new S3Adapter(client) + + const result = await adapter.getStream("videos/clip.webm", { start: 0, end: 3 }) + + expect(result.totalBytes).toBe(50) + expect(result.start).toBe(0) + expect(result.end).toBe(3) + expect(headCalls).toBe(0) + }) +}) diff --git a/apps/dashboard/server/lib/storage/s3.ts b/apps/dashboard/server/lib/storage/s3.ts index 6b133103..e29a109c 100644 --- a/apps/dashboard/server/lib/storage/s3.ts +++ b/apps/dashboard/server/lib/storage/s3.ts @@ -1,11 +1,12 @@ import { DeleteObjectCommand, GetObjectCommand, + HeadObjectCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3" import { env } from "../env" -import type { StorageAdapter } from "./index" +import type { StorageAdapter, StorageStream } from "./index" function resolveCredentials(): { accessKeyId: string; secretAccessKey: string } { const envId = env.S3_ACCESS_KEY_ID @@ -74,8 +75,14 @@ export class S3Adapter implements StorageAdapter { private readonly client: S3Client private readonly bucket: string - constructor() { + // `client` param is a test-only injection seam so getStream's Head-fallback logic + // can be exercised against a mocked S3Client instead of a live endpoint. + constructor(client?: S3Client) { this.bucket = env.S3_BUCKET + if (client) { + this.client = client + return + } const endpoint = env.S3_ENDPOINT ? validateS3Endpoint(env.S3_ENDPOINT) : undefined this.client = new S3Client({ ...(endpoint ? { endpoint } : {}), @@ -105,6 +112,46 @@ export class S3Adapter implements StorageAdapter { return { bytes, contentType } } + async getStream(key: string, range?: { start: number; end?: number }): Promise { + const res = await this.client.send( + new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + Range: range ? `bytes=${range.start}-${range.end ?? ""}` : undefined, + }), + ) + if (!res.Body) throw new Error(`S3 get: empty body for ${key}`) + const contentType = res.ContentType ?? "application/octet-stream" + const stream = res.Body.transformToWebStream() + + if (range) { + const match = res.ContentRange ? /\/(\d+)$/.exec(res.ContentRange) : null + let totalBytes: number + if (match) { + totalBytes = Number(match[1]) + } else { + // Some S3-compatibles (e.g. certain MinIO/Garage configs) omit ContentRange on + // 206 responses. res.ContentLength there is only the partial byte count, which + // would violate StorageStream.totalBytes' "full object size" contract and corrupt + // downstream Content-Range headers. Fetch the true size via HEAD instead. + const head = await this.client.send( + new HeadObjectCommand({ Bucket: this.bucket, Key: key }), + ) + totalBytes = head.ContentLength ?? 0 + } + return { + stream, + contentType, + totalBytes, + start: range.start, + end: range.end ?? totalBytes - 1, + } + } + + const totalBytes = res.ContentLength ?? 0 + return { stream, contentType, totalBytes, start: 0, end: totalBytes - 1 } + } + async delete(key: string): Promise { await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })) } diff --git a/apps/dashboard/server/tasks/media/purge.ts b/apps/dashboard/server/tasks/media/purge.ts new file mode 100644 index 00000000..5228bc57 --- /dev/null +++ b/apps/dashboard/server/tasks/media/purge.ts @@ -0,0 +1,17 @@ +// apps/dashboard/server/tasks/media/purge.ts +// Nitro scheduled task — nightly sweep that deletes expired and long-revoked +// shared_media rows plus their storage blobs. See ../../lib/shared-media-purge +// for the selection rule and boundary semantics. +import { defineTask } from "nitropack/runtime" +import { purgeSharedMedia } from "../../lib/shared-media-purge" + +export default defineTask({ + meta: { + name: "media:purge", + description: "Delete expired and revoked shared media", + }, + async run() { + const { purged } = await purgeSharedMedia() + return { result: "ok", purged } + }, +}) diff --git a/apps/dashboard/tests/api/intake-media.test.ts b/apps/dashboard/tests/api/intake-media.test.ts new file mode 100644 index 00000000..3d98e673 --- /dev/null +++ b/apps/dashboard/tests/api/intake-media.test.ts @@ -0,0 +1,244 @@ +import { setup } from "../nuxt-setup" +import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test" +import { eq } from "drizzle-orm" +import { db } from "../../server/db" +import { reportAttachments, reports } from "../../server/db/schema" +import { createUser, seedProject, truncateDomain, truncateReports } from "../helpers" + +await setup({ server: true, port: 3000, host: "localhost" }) +setDefaultTimeout(15000) + +const BASE_URL = process.env.TEST_BASE_URL ?? "http://localhost:3000" +const PK = "rp_pk_MEDIATEST1234567890abcd0" +const ORIGIN = "https://example.com" + +function reportBlob(): Blob { + return new Blob( + [ + JSON.stringify({ + projectKey: PK, + title: "with media", + description: "x", + context: { + source: "web", + url: "https://example.com/page", + pageUrl: "https://example.com/page", + userAgent: "Mozilla/5.0 Test", + viewport: { w: 1440, h: 900 }, + timestamp: new Date().toISOString(), + }, + _dwellMs: 5000, + _hp: "", + }), + ], + { type: "application/json" }, + ) +} + +async function postReportWithMedia(opts: { + media?: { name: string; type: string; bytes: Uint8Array }[] + mediaMeta?: unknown + includeMediaMeta?: boolean + screenshot?: { name: string; type: string; bytes: Uint8Array } +}): Promise<{ res: Response; reportId: string | null }> { + const form = new FormData() + form.append("report", reportBlob()) + ;(opts.media ?? []).forEach((m, i) => { + form.append(`media[${i}]`, new File([m.bytes], m.name, { type: m.type })) + }) + const includeMediaMeta = opts.includeMediaMeta ?? opts.mediaMeta !== undefined + if (includeMediaMeta) { + form.append( + "mediaMeta", + new Blob([JSON.stringify(opts.mediaMeta ?? [])], { type: "application/json" }), + ) + } + if (opts.screenshot) { + form.append( + "screenshot", + new File([opts.screenshot.bytes], opts.screenshot.name, { type: opts.screenshot.type }), + ) + } + const res = await fetch(`${BASE_URL}/api/intake/reports`, { + method: "POST", + headers: { Origin: ORIGIN }, + body: form, + }) + let reportId: string | null = null + if (res.status === 201) { + const body = (await res.clone().json()) as { id: string } + reportId = body.id + } + return { res, reportId } +} + +describe("POST /api/intake/reports — gallery media", () => { + beforeAll(async () => { + await truncateDomain() + const admin = await createUser("media-admin@example.com", "admin") + await seedProject({ + name: "Media Test Project", + publicKey: PK, + allowedOrigins: [ORIGIN], + createdBy: admin, + }) + }) + + afterEach(async () => { + await truncateReports() + }) + + test("1: happy path — image + trimmed video persist as kind='media' with trim metadata", async () => { + const { res, reportId } = await postReportWithMedia({ + media: [ + { name: "media-0.png", type: "image/png", bytes: new Uint8Array([1, 2, 3, 4]) }, + { name: "media-1.webm", type: "video/webm", bytes: new Uint8Array([5, 6, 7, 8]) }, + ], + mediaMeta: [ + { kind: "image", mime: "image/png" }, + { + kind: "video", + mime: "video/webm", + durationMs: 5000, + trim: { startMs: 1000, endMs: 4000 }, + }, + ], + }) + expect(res.status).toBe(201) + expect(reportId).toBeString() + const rows = await db + .select() + .from(reportAttachments) + .where(eq(reportAttachments.reportId, reportId as string)) + const mediaRows = rows.filter((r) => r.kind === "media") + expect(mediaRows).toHaveLength(2) + const row0 = mediaRows.find((r) => r.storageKey.endsWith("/media/0.png")) + const row1 = mediaRows.find((r) => r.storageKey.endsWith("/media/1.webm")) + expect(row0).toBeDefined() + expect(row1).toBeDefined() + expect(row1?.trimStartMs).toBe(1000) + expect(row1?.trimEndMs).toBe(4000) + expect(row1?.durationMs).toBe(5000) + }) + + test("2: missing mediaMeta while media[0] present → 400", async () => { + const { res } = await postReportWithMedia({ + media: [{ name: "media-0.png", type: "image/png", bytes: new Uint8Array([1, 2, 3]) }], + includeMediaMeta: false, + }) + expect(res.status).toBe(400) + }) + + test("3: mediaMeta length mismatch (2 entries, 1 part) → 400", async () => { + const { res } = await postReportWithMedia({ + media: [{ name: "media-0.png", type: "image/png", bytes: new Uint8Array([1, 2, 3]) }], + mediaMeta: [ + { kind: "image", mime: "image/png" }, + { kind: "image", mime: "image/png" }, + ], + }) + expect(res.status).toBe(400) + }) + + test("4: denied mime (media[0] as text/plain) → 415", async () => { + const { res } = await postReportWithMedia({ + media: [{ name: "media-0.txt", type: "text/plain", bytes: new Uint8Array([1, 2, 3]) }], + mediaMeta: [{ kind: "image", mime: "text/plain" }], + }) + expect(res.status).toBe(415) + }) + + test("5: count over INTAKE_MEDIA_MAX_COUNT (4 parts) → 413", async () => { + const media = Array.from({ length: 4 }, (_, i) => ({ + name: `media-${i}.png`, + type: "image/png", + bytes: new Uint8Array([i + 1]), + })) + const mediaMeta = Array.from({ length: 4 }, () => ({ kind: "image", mime: "image/png" })) + const { res } = await postReportWithMedia({ media, mediaMeta }) + expect(res.status).toBe(413) + }) + + test("6: old-SDK compat — legacy screenshot part, no media parts → 201, screenshot row as before", async () => { + const { res, reportId } = await postReportWithMedia({ + screenshot: { name: "screenshot.png", type: "image/png", bytes: new Uint8Array([9, 9, 9]) }, + }) + expect(res.status).toBe(201) + expect(reportId).toBeString() + const rows = await db + .select() + .from(reportAttachments) + .where(eq(reportAttachments.reportId, reportId as string)) + expect(rows.filter((r) => r.kind === "media")).toHaveLength(0) + const screenshotRow = rows.find((r) => r.kind === "screenshot") + expect(screenshotRow).toBeDefined() + expect(screenshotRow?.storageKey.endsWith("/screenshot.png")).toBe(true) + }) + + test("8: parameterized codec mime (video/webm;codecs=vp9) → 201, stored contentType is bare", async () => { + const { res, reportId } = await postReportWithMedia({ + media: [ + { name: "clip.webm", type: "video/webm;codecs=vp9", bytes: new Uint8Array([1, 2, 3, 4]) }, + ], + mediaMeta: [{ kind: "video", mime: "video/webm;codecs=vp9", durationMs: 3000 }], + }) + expect(res.status).toBe(201) + expect(reportId).toBeString() + const rows = await db + .select() + .from(reportAttachments) + .where(eq(reportAttachments.reportId, reportId as string)) + const mediaRow = rows.find((r) => r.kind === "media") + expect(mediaRow).toBeDefined() + expect(mediaRow?.contentType).toBe("video/webm") + expect(mediaRow?.storageKey.endsWith("/media/0.webm")).toBe(true) + }) + + test("9: image mime declared as kind='video' → 400 (kind/mime family mismatch)", async () => { + const { res } = await postReportWithMedia({ + media: [{ name: "media-0.png", type: "image/png", bytes: new Uint8Array([1, 2, 3]) }], + mediaMeta: [{ kind: "video", mime: "image/png" }], + }) + expect(res.status).toBe(400) + }) + + test("7: duplicate media[0] indices (two parts, same slot) → 400, no rows persisted", async () => { + const form = new FormData() + form.append("report", reportBlob()) + // Two parts both claim index 0, with divergent byte lengths — the + // second `storage.put` would silently overwrite the first blob at + // `${report.id}/media/0.` if this weren't rejected up front. + form.append( + "media[0]", + new File([new Uint8Array([1, 2, 3])], "media-0-a.png", { type: "image/png" }), + ) + form.append( + "media[0]", + new File([new Uint8Array([4, 5, 6, 7, 8, 9])], "media-0-b.png", { type: "image/png" }), + ) + form.append( + "mediaMeta", + new Blob( + [ + JSON.stringify([ + { kind: "image", mime: "image/png" }, + { kind: "image", mime: "image/png" }, + ]), + ], + { type: "application/json" }, + ), + ) + const res = await fetch(`${BASE_URL}/api/intake/reports`, { + method: "POST", + headers: { Origin: ORIGIN }, + body: form, + }) + expect(res.status).toBe(400) + // Validation must precede the report insert entirely — not just the + // media attachment writes. + const allReports = await db.select().from(reports) + const allAttachments = await db.select().from(reportAttachments) + expect(allReports).toHaveLength(0) + expect(allAttachments).toHaveLength(0) + }) +}) diff --git a/apps/dashboard/tests/api/intake-size-limits.test.ts b/apps/dashboard/tests/api/intake-size-limits.test.ts new file mode 100644 index 00000000..2956b001 --- /dev/null +++ b/apps/dashboard/tests/api/intake-size-limits.test.ts @@ -0,0 +1,141 @@ +import { beforeAll, expect, setDefaultTimeout, test } from "bun:test" +import net from "node:net" +import { seedProject, truncateDomain, truncateReports } from "../helpers" + +setDefaultTimeout(30_000) + +// Regression test for field bug: a report with a video attachment inside the +// client-advertised limits (DEFAULT_ATTACHMENT_LIMITS: 10 MB/file, 25 MB total) +// was rejected with 413. Two ceilings sat below the client limits: +// - env INTAKE_MAX_BYTES defaulted to 5 MB for the whole multipart request +// - nuxt-security's requestSizeLimiter (~8 MB multipart) ran before the handler +// The server must accept anything the SDK's own validation allows. +const PK = "rp_pk_sizelimits000000000000001".slice(0, 30) +const ORIGIN = "https://example.com" +const BASE = process.env.TEST_BASE_URL ?? "http://localhost:3000" + +beforeAll(async () => { + await truncateDomain() + await truncateReports() + await seedProject({ + name: "size-limits", + publicKey: PK, + allowedOrigins: [ORIGIN], + createdBy: "test", + }) +}) + +function reportForm(attachments: { name: string; bytes: number; type: string }[]): FormData { + const f = new FormData() + f.append( + "report", + new Blob( + [ + JSON.stringify({ + projectKey: PK, + title: "video within client limits", + description: "size regression", + context: { + source: "web", + url: "https://example.com/page", + pageUrl: "https://example.com/page", + userAgent: "Mozilla/5.0 Test", + viewport: { w: 1440, h: 900 }, + timestamp: new Date().toISOString(), + }, + _dwellMs: 5000, + _hp: "", + }), + ], + { type: "application/json" }, + ), + ) + attachments.forEach((a, i) => { + f.append(`attachment[${i}]`, new File([new Uint8Array(a.bytes)], a.name, { type: a.type })) + }) + return f +} + +// Send a raw HTTP request with a forged Content-Length so we can advertise a +// huge body while only writing a few bytes. `fetch` recomputes Content-Length +// from the actual body and won't let us forge it, so we go down to a TCP +// socket. Before the pre-buffer gate, the handler would call +// readMultipartFormData and block waiting for the (never-arriving) full body — +// the test would time out with status 0. With the gate, the handler rejects on +// the header alone and responds 413 immediately. +function rawForgedRequest( + path: string, + forgedContentLength: number, + timeoutMs = 5000, +): Promise<{ status: number }> { + const url = new URL(BASE) + const port = Number(url.port || 80) + return new Promise((resolve, reject) => { + const body = "--X\r\n(not a real body)\r\n" + const socket = net.connect(port, url.hostname, () => { + const head = + [ + `POST ${path} HTTP/1.1`, + `Host: ${url.host}`, + `Origin: ${ORIGIN}`, + "Content-Type: multipart/form-data; boundary=X", + `Content-Length: ${forgedContentLength}`, + "Connection: close", + "", + "", + ].join("\r\n") + body + socket.write(head) + }) + let data = "" + socket.setTimeout(timeoutMs) + socket.on("data", (c) => { + data += c.toString() + }) + socket.on("timeout", () => { + socket.destroy() + resolve({ status: 0 }) // no response arrived — pre-fix behaviour + }) + socket.on("close", () => { + const m = data.match(/^HTTP\/1\.1 (\d+)/) + resolve({ status: m ? Number(m[1]) : 0 }) + }) + socket.on("error", reject) + }) +} + +test("forged Content-Length above the ceiling → 413 before buffering the body", async () => { + const { status } = await rawForgedRequest("/api/intake/reports", 200_000_000) + expect(status).toBe(413) +}) + +test("accepts a 9MB video attachment (single file within client limits)", async () => { + const res = await fetch(`${BASE}/api/intake/reports`, { + method: "POST", + body: reportForm([{ name: "clip.mp4", bytes: 9 * 1024 * 1024, type: "video/mp4" }]), + headers: { Origin: ORIGIN }, + }) + expect(res.status).toBe(201) +}) + +test("accepts attachments near the 25MB advertised total", async () => { + const res = await fetch(`${BASE}/api/intake/reports`, { + method: "POST", + body: reportForm([ + { name: "clip-a.mp4", bytes: 10 * 1024 * 1024, type: "video/mp4" }, + { name: "clip-b.webm", bytes: 10 * 1024 * 1024, type: "video/webm" }, + { name: "notes.json", bytes: 4 * 1024 * 1024, type: "application/json" }, + ]), + headers: { Origin: ORIGIN }, + }) + expect(res.status).toBe(201) +}) + +test("still rejects a request over the raised server ceiling", async () => { + // Server stays authoritative: a single part over INTAKE_MAX_BYTES (40 MB) is 413. + const res = await fetch(`${BASE}/api/intake/reports`, { + method: "POST", + body: reportForm([{ name: "huge.mp4", bytes: 41 * 1024 * 1024, type: "video/mp4" }]), + headers: { Origin: ORIGIN }, + }) + expect(res.status).toBe(413) +}) diff --git a/apps/dashboard/tests/api/report-media-stream.test.ts b/apps/dashboard/tests/api/report-media-stream.test.ts new file mode 100644 index 00000000..86bffc0a --- /dev/null +++ b/apps/dashboard/tests/api/report-media-stream.test.ts @@ -0,0 +1,216 @@ +// apps/dashboard/tests/api/report-media-stream.test.ts +import { setup } from "../nuxt-setup" +import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test" +import { eq } from "drizzle-orm" +import { db } from "../../server/db" +import { projectMembers, reportAttachments } from "../../server/db/schema" +import { createUser, seedProject, signIn, truncateDomain, truncateReports } from "../helpers" + +await setup({ server: true, port: 3000, host: "localhost" }) +setDefaultTimeout(15000) + +const BASE_URL = process.env.TEST_BASE_URL ?? "http://localhost:3000" +const PK = "rp_pk_MEDIASTREAM123456789012a" +const ORIGIN = "https://example.com" + +function reportBlob(): Blob { + return new Blob( + [ + JSON.stringify({ + projectKey: PK, + title: "with media", + description: "x", + context: { + source: "web", + url: "https://example.com/page", + pageUrl: "https://example.com/page", + userAgent: "Mozilla/5.0 Test", + viewport: { w: 1440, h: 900 }, + timestamp: new Date().toISOString(), + }, + _dwellMs: 5000, + _hp: "", + }), + ], + { type: "application/json" }, + ) +} + +// Same happy-path FormData shape as tests/api/intake-media.test.ts — the +// real intake route is the simplest way to get a genuine kind="media" row +// (with trim metadata) + real storage bytes. +async function postReportWithMedia(): Promise { + const form = new FormData() + form.append("report", reportBlob()) + form.append( + "media[0]", + new File([new Uint8Array([10, 11, 12, 13, 14, 15, 16, 17])], "media-0.webm", { + type: "video/webm", + }), + ) + form.append( + "mediaMeta", + new Blob( + [ + JSON.stringify([ + { + kind: "video", + mime: "video/webm", + durationMs: 5000, + trim: { startMs: 1000, endMs: 4000 }, + }, + ]), + ], + { type: "application/json" }, + ), + ) + const res = await fetch(`${BASE_URL}/api/intake/reports`, { + method: "POST", + headers: { Origin: ORIGIN }, + body: form, + }) + if (res.status !== 201) { + throw new Error(`seed intake failed: ${res.status} ${await res.text()}`) + } + const body = (await res.json()) as { id: string } + return body.id +} + +async function seedMember( + email: string, + projectId: string, + role: "viewer" | "manager" | "developer" | "owner", +): Promise<{ userId: string; cookie: string }> { + const userId = await createUser(email, "member") + await db.insert(projectMembers).values({ projectId, userId, role }) + const cookie = await signIn(email) + return { userId, cookie } +} + +describe("GET /api/projects/:id/reports/:reportId/media/:attachmentId", () => { + let projectId: string + + beforeAll(async () => { + await truncateDomain() + const admin = await createUser("media-stream-admin@example.com", "admin") + projectId = await seedProject({ + name: "Media Stream Test Project", + publicKey: PK, + allowedOrigins: [ORIGIN], + createdBy: admin, + }) + }) + + afterEach(async () => { + await truncateReports() + }) + + test("no Range header -> 200 with full bytes", async () => { + const reportId = await postReportWithMedia() + const [row] = await db + .select() + .from(reportAttachments) + .where(eq(reportAttachments.reportId, reportId)) + if (!row) throw new Error("expected media attachment row") + + const { cookie } = await seedMember("viewer-full@example.com", projectId, "viewer") + const res = await fetch( + `${BASE_URL}/api/projects/${projectId}/reports/${reportId}/media/${row.id}`, + { headers: { cookie } }, + ) + expect(res.status).toBe(200) + const buf = new Uint8Array(await res.arrayBuffer()) + expect(Array.from(buf)).toEqual([10, 11, 12, 13, 14, 15, 16, 17]) + expect(res.headers.get("content-type")).toBe("video/webm") + expect(res.headers.get("content-disposition")).toStartWith("inline") + expect(res.headers.get("cache-control")).toBe("private, max-age=3600") + }) + + test("Range: bytes=0-3 -> 206 with content-range", async () => { + const reportId = await postReportWithMedia() + const [row] = await db + .select() + .from(reportAttachments) + .where(eq(reportAttachments.reportId, reportId)) + if (!row) throw new Error("expected media attachment row") + + const { cookie } = await seedMember("viewer-range@example.com", projectId, "viewer") + const res = await fetch( + `${BASE_URL}/api/projects/${projectId}/reports/${reportId}/media/${row.id}`, + { headers: { cookie, Range: "bytes=0-3" } }, + ) + expect(res.status).toBe(206) + expect(res.headers.get("content-range")).toBe("bytes 0-3/8") + const buf = new Uint8Array(await res.arrayBuffer()) + expect(Array.from(buf)).toEqual([10, 11, 12, 13]) + }) + + test("a user-file attachment id on the media route -> 404", async () => { + const reportId = await postReportWithMedia() + const [userFileRow] = await db + .insert(reportAttachments) + .values({ + reportId, + kind: "user-file", + storageKey: "reports/whatever/user-file.txt", + contentType: "text/plain", + sizeBytes: 3, + filename: "notes.txt", + }) + .returning() + if (!userFileRow) throw new Error("failed to seed user-file row") + + const { cookie } = await seedMember("viewer-wrongkind@example.com", projectId, "viewer") + const res = await fetch( + `${BASE_URL}/api/projects/${projectId}/reports/${reportId}/media/${userFileRow.id}`, + { headers: { cookie } }, + ) + expect(res.status).toBe(404) + }) + + test("signed-out request -> non-200", async () => { + const reportId = await postReportWithMedia() + const [row] = await db + .select() + .from(reportAttachments) + .where(eq(reportAttachments.reportId, reportId)) + if (!row) throw new Error("expected media attachment row") + + const res = await fetch( + `${BASE_URL}/api/projects/${projectId}/reports/${reportId}/media/${row.id}`, + ) + expect(res.status).not.toBe(200) + }) + + test("detail endpoint returns durationMs/trimStartMs/trimEndMs and media url for the media attachment", async () => { + const reportId = await postReportWithMedia() + const [row] = await db + .select() + .from(reportAttachments) + .where(eq(reportAttachments.reportId, reportId)) + if (!row) throw new Error("expected media attachment row") + + const { cookie } = await seedMember("viewer-detail@example.com", projectId, "viewer") + const res = await fetch(`${BASE_URL}/api/projects/${projectId}/reports/${reportId}`, { + headers: { cookie }, + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + attachments: { + id: string + kind: string + url: string + durationMs: number | null + trimStartMs: number | null + trimEndMs: number | null + }[] + } + const mediaDto = body.attachments.find((a) => a.id === row.id) + expect(mediaDto).toBeDefined() + expect(mediaDto?.kind).toBe("media") + expect(mediaDto?.url).toBe(`/api/projects/${projectId}/reports/${reportId}/media/${row.id}`) + expect(mediaDto?.durationMs).toBe(5000) + expect(mediaDto?.trimStartMs).toBe(1000) + expect(mediaDto?.trimEndMs).toBe(4000) + }) +}) diff --git a/apps/dashboard/tests/api/share-mint.test.ts b/apps/dashboard/tests/api/share-mint.test.ts new file mode 100644 index 00000000..5786a54b --- /dev/null +++ b/apps/dashboard/tests/api/share-mint.test.ts @@ -0,0 +1,188 @@ +import { setup } from "../nuxt-setup" +import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test" +import net from "node:net" +import { eq } from "drizzle-orm" +import { ShareMintResponse } from "@reprojs/shared" +import { db } from "../../server/db" +import { projects, sharedMedia } from "../../server/db/schema" +import { createUser, seedProject, truncateDomain, truncateSharedMedia } from "../helpers" + +await setup({ server: true, port: 3000, host: "localhost" }) +setDefaultTimeout(15000) + +const BASE_URL = process.env.TEST_BASE_URL ?? "http://localhost:3000" +const PK = "rp_pk_SHAREMINT123456789012345" +const ORIGIN = "https://example.com" + +async function postMint(opts: { + origin?: string | null + projectKey?: string + mime?: string + fileType?: string + includeFile?: boolean + extraMeta?: Record +}): Promise { + const form = new FormData() + if (opts.includeFile !== false) { + form.append( + "file", + new File([new Uint8Array(1024).fill(7)], "clip.webm", { + type: opts.fileType ?? opts.mime ?? "video/webm", + }), + ) + } + form.append( + "meta", + new Blob( + [ + JSON.stringify({ + projectKey: opts.projectKey ?? PK, + kind: "video", + mime: opts.mime ?? "video/webm", + durationMs: 3000, + trim: { startMs: 0, endMs: 2000 }, + ...opts.extraMeta, + }), + ], + { type: "application/json" }, + ), + ) + const headers: Record = {} + if (opts.origin !== null) { + headers.Origin = opts.origin ?? ORIGIN + } + return fetch(`${BASE_URL}/api/intake/media`, { + method: "POST", + headers, + body: form, + }) +} + +describe("POST /api/intake/media", () => { + let projectId: string + + beforeAll(async () => { + await truncateDomain() + const admin = await createUser("share-mint-admin@example.com", "admin") + projectId = await seedProject({ + name: "Share Mint Test Project", + publicKey: PK, + allowedOrigins: [ORIGIN], + createdBy: admin, + }) + }) + + afterEach(async () => { + await truncateSharedMedia() + // Some tests flip share_links_enabled off — restore for the next test. + await db.update(projects).set({ shareLinksEnabled: true }).where(eq(projects.id, projectId)) + }) + + test("forged Content-Length above the ceiling → 413 before buffering the body", async () => { + // See intake-size-limits.test.ts for the rationale — fetch recomputes + // Content-Length, so we forge it over a raw TCP socket. Pre-fix, the + // handler blocked in readMultipartFormData waiting for the never-arriving + // body and the socket timed out (status 0); the pre-buffer gate now + // rejects on the header alone. + const url = new URL(BASE_URL) + const port = Number(url.port || 80) + const status = await new Promise((resolve, reject) => { + const body = "--X\r\n(not a real body)\r\n" + const socket = net.connect(port, url.hostname, () => { + socket.write( + [ + "POST /api/intake/media HTTP/1.1", + `Host: ${url.host}`, + `Origin: ${ORIGIN}`, + "Content-Type: multipart/form-data; boundary=X", + "Content-Length: 200000000", + "Connection: close", + "", + "", + ].join("\r\n") + body, + ) + }) + let data = "" + socket.setTimeout(5000) + socket.on("data", (c) => { + data += c.toString() + }) + socket.on("timeout", () => { + socket.destroy() + resolve(0) + }) + socket.on("close", () => { + const m = data.match(/^HTTP\/1\.1 (\d+)/) + resolve(m ? Number(m[1]) : 0) + }) + socket.on("error", reject) + }) + expect(status).toBe(413) + }) + + test("happy path: mints a share link and persists a shared_media row", async () => { + const res = await postMint({}) + expect(res.status).toBe(201) + const body = await res.json() + const parsed = ShareMintResponse.parse(body) + expect(parsed.token.length).toBeGreaterThanOrEqual(43) + expect(parsed.shareUrl.endsWith(`/s/${parsed.token}`)).toBe(true) + + const [row] = await db.select().from(sharedMedia).where(eq(sharedMedia.id, parsed.id)) + expect(row).toBeDefined() + expect(row?.token).toBe(parsed.token) + expect(row?.trimStartMs).toBe(0) + expect(row?.trimEndMs).toBe(2000) + expect(row?.kind).toBe("video") + expect(row?.mime).toBe("video/webm") + + const now = Date.now() + const expiresAt = row?.expiresAt ? new Date(row.expiresAt).getTime() : 0 + const expectedExpiry = now + 30 * 24 * 60 * 60 * 1000 + expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThan(5 * 60 * 1000) + }) + + test("share links disabled on the project → 403", async () => { + await db.update(projects).set({ shareLinksEnabled: false }).where(eq(projects.id, projectId)) + const res = await postMint({}) + expect(res.status).toBe(403) + }) + + test("parameterized codec mime (video/webm;codecs=vp9) → 201, stored mime is bare", async () => { + const res = await postMint({ mime: "video/webm;codecs=vp9", fileType: "video/webm;codecs=vp9" }) + expect(res.status).toBe(201) + const body = await res.json() + const parsed = ShareMintResponse.parse(body) + const [row] = await db.select().from(sharedMedia).where(eq(sharedMedia.id, parsed.id)) + expect(row?.mime).toBe("video/webm") + }) + + test("wrong mime (image/png) → 415", async () => { + const res = await postMint({ mime: "image/png", fileType: "image/png" }) + expect(res.status).toBe(415) + }) + + test("bad project key → 401", async () => { + const res = await postMint({ projectKey: "rp_pk_doesnotexist000000000000" }) + expect(res.status).toBe(401) + }) + + test("disallowed Origin → 403", async () => { + const res = await postMint({ origin: "https://evil.example" }) + expect(res.status).toBe(403) + }) + + test("missing file part → 400", async () => { + const res = await postMint({ includeFile: false }) + expect(res.status).toBe(400) + }) + + test("invalid trim (endMs < startMs) → 400, no row created", async () => { + const res = await postMint({ extraMeta: { trim: { startMs: 2000, endMs: 1000 } } }) + expect(res.status).toBe(400) + + // Assert no shared_media row was created for this mint attempt + const rows = await db.select().from(sharedMedia) + expect(rows.length).toBe(0) + }) +}) diff --git a/apps/dashboard/tests/api/share-page.test.ts b/apps/dashboard/tests/api/share-page.test.ts new file mode 100644 index 00000000..a931ad66 --- /dev/null +++ b/apps/dashboard/tests/api/share-page.test.ts @@ -0,0 +1,85 @@ +import { randomBytes } from "node:crypto" +import { setup } from "../nuxt-setup" +import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test" +import { db } from "../../server/db" +import { sharedMedia } from "../../server/db/schema" +import { getStorage } from "../../server/lib/storage" +import { createUser, seedProject, truncateDomain, truncateSharedMedia } from "../helpers" + +await setup({ server: true, port: 3000, host: "localhost" }) +setDefaultTimeout(15000) + +const BASE_URL = process.env.TEST_BASE_URL ?? "http://localhost:3000" +const PK = "rp_pk_SHAREPAGE1234567890123456" + +const BUFFER = new Uint8Array(32).map((_, i) => i) + +async function seedSharedMedia( + projectId: string, + overrides: Partial = {}, +): Promise<{ token: string }> { + const storage = await getStorage() + const token = randomBytes(32).toString("base64url") + const id = crypto.randomUUID() + const storageKey = `shared-media/${id}.webm` + await storage.put(storageKey, BUFFER, "video/webm") + await db.insert(sharedMedia).values({ + id, + projectId, + token, + kind: "video", + mime: "video/webm", + storageKey, + sizeBytes: BUFFER.length, + durationMs: 3000, + trimStartMs: 0, + trimEndMs: 2000, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + ...overrides, + }) + return { token } +} + +describe("GET /s/:token", () => { + let projectId: string + + beforeAll(async () => { + await truncateDomain() + const admin = await createUser("share-page-admin@example.com", "admin") + projectId = await seedProject({ + name: "Share Page Test Project", + publicKey: PK, + allowedOrigins: [], + createdBy: admin, + }) + }) + + afterEach(async () => { + await truncateSharedMedia() + }) + + test("live token: 200, renders OG video tags, no sign-in redirect", async () => { + const { token } = await seedSharedMedia(projectId) + const res = await fetch(`${BASE_URL}/s/${token}`, { + headers: { Accept: "text/html" }, + redirect: "manual", + }) + expect(res.status).toBe(200) + const body = await res.text() + expect(body).toContain("og:video") + expect(body).toContain(`/api/shared/${token}/blob`) + expect(body).not.toContain("/auth/sign-in") + }) + + test("garbage token: 200 SSR page rendering the not-available state", async () => { + const res = await fetch(`${BASE_URL}/s/not-a-real-token`, { + headers: { Accept: "text/html" }, + redirect: "manual", + }) + expect(res.status).toBe(200) + const body = await res.text() + expect(body).toContain("isn") + expect(body).toContain("available") + expect(body).not.toContain("/auth/sign-in") + }) +}) diff --git a/apps/dashboard/tests/api/share-public.test.ts b/apps/dashboard/tests/api/share-public.test.ts new file mode 100644 index 00000000..ccd0a1b2 --- /dev/null +++ b/apps/dashboard/tests/api/share-public.test.ts @@ -0,0 +1,140 @@ +import { randomBytes } from "node:crypto" +import { setup } from "../nuxt-setup" +import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test" +import { db } from "../../server/db" +import { sharedMedia } from "../../server/db/schema" +import { getStorage } from "../../server/lib/storage" +import { createUser, seedProject, truncateDomain, truncateSharedMedia } from "../helpers" + +await setup({ server: true, port: 3000, host: "localhost" }) +setDefaultTimeout(15000) + +const BASE_URL = process.env.TEST_BASE_URL ?? "http://localhost:3000" +const PK = "rp_pk_SHAREPUBLIC1234567890123" + +// Known 32-byte buffer: byte i has value i, so slicing is trivially verifiable. +const BUFFER = new Uint8Array(32).map((_, i) => i) + +async function seedSharedMedia( + projectId: string, + overrides: Partial = {}, +): Promise<{ token: string; storageKey: string }> { + const storage = await getStorage() + const token = randomBytes(32).toString("base64url") + const id = crypto.randomUUID() + const storageKey = `shared-media/${id}.webm` + await storage.put(storageKey, BUFFER, "video/webm") + await db.insert(sharedMedia).values({ + id, + projectId, + token, + kind: "video", + mime: "video/webm", + storageKey, + sizeBytes: BUFFER.length, + durationMs: 3000, + trimStartMs: 0, + trimEndMs: 2000, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + ...overrides, + }) + return { token, storageKey } +} + +describe("GET /api/shared/:token and /api/shared/:token/blob", () => { + let projectId: string + + beforeAll(async () => { + await truncateDomain() + const admin = await createUser("share-public-admin@example.com", "admin") + projectId = await seedProject({ + name: "Share Public Test Project", + publicKey: PK, + allowedOrigins: [], + createdBy: admin, + }) + }) + + afterEach(async () => { + await truncateSharedMedia() + }) + + test("meta: 200 with the expected shape and no project info", async () => { + const { token } = await seedSharedMedia(projectId) + const res = await fetch(`${BASE_URL}/api/shared/${token}`) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + kind: "video", + mime: "video/webm", + sizeBytes: 32, + durationMs: 3000, + trimStartMs: 0, + trimEndMs: 2000, + createdAt: expect.any(String), + expiresAt: expect.any(String), + }) + expect(body.projectId).toBeUndefined() + expect(body.storageKey).toBeUndefined() + expect(body.id).toBeUndefined() + expect(body.token).toBeUndefined() + }) + + test("blob: no Range header → 200 full body + accept-ranges", async () => { + const { token } = await seedSharedMedia(projectId) + const res = await fetch(`${BASE_URL}/api/shared/${token}/blob`) + expect(res.status).toBe(200) + expect(res.headers.get("accept-ranges")).toBe("bytes") + expect(res.headers.get("content-type")).toBe("video/webm") + expect(res.headers.get("x-content-type-options")).toBe("nosniff") + const buf = new Uint8Array(await res.arrayBuffer()) + expect(buf.length).toBe(32) + expect([...buf]).toEqual([...BUFFER]) + }) + + test("blob: Range: bytes=8-15 → 206 with exact slice", async () => { + const { token } = await seedSharedMedia(projectId) + const res = await fetch(`${BASE_URL}/api/shared/${token}/blob`, { + headers: { Range: "bytes=8-15" }, + }) + expect(res.status).toBe(206) + expect(res.headers.get("content-range")).toBe("bytes 8-15/32") + expect(res.headers.get("content-length")).toBe("8") + const buf = new Uint8Array(await res.arrayBuffer()) + expect([...buf]).toEqual(Array.from(BUFFER.slice(8, 16))) + }) + + test("blob: unsatisfiable Range → 416 with bytes */total", async () => { + const { token } = await seedSharedMedia(projectId) + const res = await fetch(`${BASE_URL}/api/shared/${token}/blob`, { + headers: { Range: "bytes=99-" }, + }) + expect(res.status).toBe(416) + expect(res.headers.get("content-range")).toBe("bytes */32") + }) + + test("expired row → 404 on both meta and blob", async () => { + const { token } = await seedSharedMedia(projectId, { + expiresAt: new Date(Date.now() - 60_000), + }) + const metaRes = await fetch(`${BASE_URL}/api/shared/${token}`) + expect(metaRes.status).toBe(404) + const blobRes = await fetch(`${BASE_URL}/api/shared/${token}/blob`) + expect(blobRes.status).toBe(404) + }) + + test("revoked row → 404 on both meta and blob", async () => { + const { token } = await seedSharedMedia(projectId, { revokedAt: new Date() }) + const metaRes = await fetch(`${BASE_URL}/api/shared/${token}`) + expect(metaRes.status).toBe(404) + const blobRes = await fetch(`${BASE_URL}/api/shared/${token}/blob`) + expect(blobRes.status).toBe(404) + }) + + test("garbage token → 404 on both meta and blob", async () => { + const metaRes = await fetch(`${BASE_URL}/api/shared/not-a-real-token`) + expect(metaRes.status).toBe(404) + const blobRes = await fetch(`${BASE_URL}/api/shared/not-a-real-token/blob`) + expect(blobRes.status).toBe(404) + }) +}) diff --git a/apps/dashboard/tests/api/shared-media-admin.test.ts b/apps/dashboard/tests/api/shared-media-admin.test.ts new file mode 100644 index 00000000..fb00c036 --- /dev/null +++ b/apps/dashboard/tests/api/shared-media-admin.test.ts @@ -0,0 +1,210 @@ +import { setup } from "../nuxt-setup" +import { afterEach, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test" +import { eq } from "drizzle-orm" +import type { ProjectDTO, SharedMediaDTO } from "@reprojs/shared" +import { db } from "../../server/db" +import { projectMembers, projects, sharedMedia } from "../../server/db/schema" +import { + apiFetch, + createUser, + seedProject, + signIn, + truncateDomain, + truncateSharedMedia, +} from "../helpers" + +await setup({ server: true, port: 3000, host: "localhost" }) +setDefaultTimeout(15000) + +const PK = "rp_pk_SHAREDMEDIAADMIN000000000" + +/** + * Seed a member-role user and add them to the given project at the specified + * role, mirroring the helper in manager-role.test.ts. Bypasses the + * invitation flow — the permission boundary under test is + * authenticated-session-only. + */ +async function seedMemberAtRole( + email: string, + projectId: string, + role: "viewer" | "manager" | "developer" | "owner", +): Promise<{ userId: string; cookie: string }> { + const userId = await createUser(email, "member") + await db.insert(projectMembers).values({ projectId, userId, role }) + const cookie = await signIn(email) + return { userId, cookie } +} + +async function seedSharedMediaRow(opts: { + projectId: string + createdAt: Date + expiresAt: Date + revokedAt?: Date | null +}): Promise { + const [row] = await db + .insert(sharedMedia) + .values({ + projectId: opts.projectId, + token: `tok_${Math.random().toString(36).slice(2)}${Date.now()}`, + kind: "video", + mime: "video/webm", + storageKey: `shared-media/${Math.random().toString(36).slice(2)}.webm`, + sizeBytes: 12345, + createdAt: opts.createdAt, + expiresAt: opts.expiresAt, + revokedAt: opts.revokedAt ?? null, + }) + .returning({ id: sharedMedia.id }) + if (!row) throw new Error("seedSharedMediaRow: insert returned no row") + return row.id +} + +describe("shared-media admin API", () => { + let ownerCookie: string + let projectId: string + let adminUserId: string + let liveMediaId: string + let expiredMediaId: string + + beforeAll(async () => { + await truncateDomain() + adminUserId = await createUser("share-admin-owner@example.com", "admin") + ownerCookie = await signIn("share-admin-owner@example.com") + projectId = await seedProject({ + name: "Shared Media Admin Test Project", + publicKey: PK, + createdBy: adminUserId, + }) + }) + + afterEach(async () => { + await truncateSharedMedia() + await db + .update(projects) + .set({ shareLinksEnabled: true, shareRetentionDays: 30 }) + .where(eq(projects.id, projectId)) + }) + + async function seedTwoRows() { + const now = Date.now() + // Older row (expired) created first, newer row (live) created second — + // list should return newest-first, i.e. the live row first. + expiredMediaId = await seedSharedMediaRow({ + projectId, + createdAt: new Date(now - 60_000), + expiresAt: new Date(now - 1_000), // already expired + }) + liveMediaId = await seedSharedMediaRow({ + projectId, + createdAt: new Date(now), + expiresAt: new Date(now + 60 * 24 * 60 * 60 * 1000), // far future + }) + } + + test("owner: GET list returns two DTOs, newest (live) first", async () => { + await seedTwoRows() + const { status, body } = await apiFetch( + `/api/projects/${projectId}/shared-media`, + { headers: { cookie: ownerCookie } }, + ) + expect(status).toBe(200) + const list = body as SharedMediaDTO[] + expect(list.length).toBe(2) + expect(list[0]?.id).toBe(liveMediaId) + expect(list[0]?.shareUrl).toContain(`/s/`) + expect(list[1]?.id).toBe(expiredMediaId) + }) + + test("owner: DELETE live row revokes it; DELETE again is idempotent", async () => { + await seedTwoRows() + + const { status: delStatus } = await apiFetch( + `/api/projects/${projectId}/shared-media/${liveMediaId}`, + { method: "DELETE", headers: { cookie: ownerCookie } }, + ) + expect(delStatus).toBe(200) + + const [afterFirst] = await db.select().from(sharedMedia).where(eq(sharedMedia.id, liveMediaId)) + expect(afterFirst?.revokedAt).not.toBeNull() + const firstRevokedAt = afterFirst?.revokedAt?.getTime() + + // Idempotent second revoke — still 200, revokedAt timestamp unchanged. + const { status: delStatus2 } = await apiFetch( + `/api/projects/${projectId}/shared-media/${liveMediaId}`, + { method: "DELETE", headers: { cookie: ownerCookie } }, + ) + expect(delStatus2).toBe(200) + + const [afterSecond] = await db.select().from(sharedMedia).where(eq(sharedMedia.id, liveMediaId)) + expect(afterSecond?.revokedAt?.getTime()).toBe(firstRevokedAt) + }) + + test("DELETE with a mediaId from another project → 404", async () => { + await seedTwoRows() + const otherProjectId = await seedProject({ + name: "Other Project", + publicKey: "rp_pk_SHAREDMEDIAOTHER0000000", + createdBy: adminUserId, + }) + + const { status } = await apiFetch( + `/api/projects/${otherProjectId}/shared-media/${liveMediaId}`, + { + method: "DELETE", + headers: { cookie: ownerCookie }, + }, + ) + expect(status).toBe(404) + + await db.delete(projects).where(eq(projects.id, otherProjectId)) + }) + + test("viewer-role member: GET and DELETE both → 403", async () => { + await seedTwoRows() + const { cookie: viewerCookie } = await seedMemberAtRole( + "share-admin-viewer@example.com", + projectId, + "viewer", + ) + + const { status: getStatus } = await apiFetch(`/api/projects/${projectId}/shared-media`, { + headers: { cookie: viewerCookie }, + }) + expect(getStatus).toBe(403) + + const { status: delStatus } = await apiFetch( + `/api/projects/${projectId}/shared-media/${liveMediaId}`, + { method: "DELETE", headers: { cookie: viewerCookie } }, + ) + expect(delStatus).toBe(403) + }) + + test("owner: PATCH shareLinksEnabled + shareRetentionDays → 200 and columns updated", async () => { + const { status, body } = await apiFetch(`/api/projects/${projectId}`, { + method: "PATCH", + headers: { cookie: ownerCookie }, + body: JSON.stringify({ shareLinksEnabled: false, shareRetentionDays: 7 }), + }) + expect(status).toBe(200) + expect((body as ProjectDTO).shareLinksEnabled).toBe(false) + expect((body as ProjectDTO).shareRetentionDays).toBe(7) + + const [row] = await db.select().from(projects).where(eq(projects.id, projectId)) + expect(row?.shareLinksEnabled).toBe(false) + expect(row?.shareRetentionDays).toBe(7) + }) + + test("manager PATCH project → 403 (owner gate holds)", async () => { + const { cookie: managerCookie } = await seedMemberAtRole( + "share-admin-manager@example.com", + projectId, + "manager", + ) + const { status } = await apiFetch(`/api/projects/${projectId}`, { + method: "PATCH", + headers: { cookie: managerCookie }, + body: JSON.stringify({ shareLinksEnabled: false, shareRetentionDays: 7 }), + }) + expect(status).toBe(403) + }) +}) diff --git a/apps/dashboard/tests/helpers.ts b/apps/dashboard/tests/helpers.ts index 28a2589a..d643d04c 100644 --- a/apps/dashboard/tests/helpers.ts +++ b/apps/dashboard/tests/helpers.ts @@ -154,6 +154,10 @@ export async function truncateGithub() { await db.execute(sql`TRUNCATE report_sync_jobs, github_integrations RESTART IDENTITY CASCADE`) } +export async function truncateSharedMedia() { + await db.execute(sql`TRUNCATE shared_media RESTART IDENTITY CASCADE`) +} + export async function truncateGithubApp() { await db.execute(sql`TRUNCATE github_app RESTART IDENTITY CASCADE`) } diff --git a/apps/extension/tests/e2e/widget-modes.e2e.ts b/apps/extension/tests/e2e/widget-modes.e2e.ts new file mode 100644 index 00000000..522d2571 --- /dev/null +++ b/apps/extension/tests/e2e/widget-modes.e2e.ts @@ -0,0 +1,376 @@ +import { chromium, expect, test } from "@playwright/test" +import type { Page, BrowserContext, CDPSession } from "@playwright/test" +import { createServer } from "node:http" +import { readFileSync, mkdtempSync } from "node:fs" +import { dirname, resolve as resolvePath } from "node:path" +import { tmpdir } from "node:os" +import { fileURLToPath } from "node:url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const EXT_PATH = resolvePath(__dirname, "../../dist-e2e") +const FIXTURE = readFileSync(resolvePath(__dirname, "fixtures/test-site.html"), "utf8") + +// --------------------------------------------------------------------------- +// The widget mounts inside a ShadowRoot with mode: "closed" (packages/ui/src +// /shadow.ts), so Playwright's own locator engine cannot see or interact +// with anything past #repro-host — confirmed empirically before writing this +// spec: page.getByRole(), page.locator(".ft-launcher"), and +// locator.ariaSnapshot() all fail to find widget-internal elements, even +// though host.shadowRoot === null proves the closure is real (existing +// inject.e2e.ts only ever asserts on #repro-host for exactly this reason). +// +// Chrome DevTools Protocol, however, operates at the render-engine level +// (it's what powers DevTools' own Elements panel showing closed shadow +// content) and is unaffected by the JS-facing `shadowRoot` restriction. +// `DOM.getFlattenedDocument({ pierce: true })` returns every node including +// closed-shadow descendants, and `DOM.getBoxModel` gives real viewport +// coordinates for them. Combining that with `page.mouse.click(x, y)` — a +// genuinely trusted mouse event dispatched at real screen coordinates, +// required for getDisplayMedia's user-activation gate — lets this spec +// drive the actual widget UI and assert on real structure/text instead of +// falling back to screenshot diffing. Every locator below is a CDP node +// query, not a Playwright locator. +// --------------------------------------------------------------------------- + +interface CdpNode { + nodeId: number + parentId?: number + backendNodeId: number + nodeType: number + nodeName: string + localName?: string + nodeValue: string + attributes?: string[] +} + +function attrOf(node: CdpNode, name: string): string | undefined { + const a = node.attributes + if (!a) return undefined + const i = a.indexOf(name) + return i >= 0 && i % 2 === 0 ? a[i + 1] : undefined +} + +function hasClass(node: CdpNode, cls: string): boolean { + const c = attrOf(node, "class") + return Boolean(c && c.split(/\s+/).includes(cls)) +} + +/** Concatenate the text of every #text descendant of `root` in document + * order, walking the flat node list via parentId (DOM.getFlattenedDocument + * doesn't nest `children` the way DOM.getDocument does). Recursive + * (pre-order) rather than an explicit LIFO stack — a stack that pushes all + * children then pops visits them in reverse, scrambling sibling order. */ +function textOf(nodes: CdpNode[], root: CdpNode): string { + const byParent = new Map() + for (const n of nodes) { + if (n.parentId == null) continue + const list = byParent.get(n.parentId) ?? [] + list.push(n) + byParent.set(n.parentId, list) + } + function walk(node: CdpNode): string { + if (node.nodeType === 3) return node.nodeValue + const kids = byParent.get(node.nodeId) ?? [] + return kids.map(walk).join("") + } + return walk(root) +} + +async function flat(cdp: CDPSession): Promise { + const doc = await cdp.send("DOM.getFlattenedDocument", { depth: -1, pierce: true }) + return doc.nodes as CdpNode[] +} + +async function centerOf(cdp: CDPSession, node: CdpNode): Promise<{ x: number; y: number }> { + const box = await cdp.send("DOM.getBoxModel", { nodeId: node.nodeId }) + const [x1, y1, x2, y2, x3, y3, x4, y4] = box.model.content + return { x: (x1 + x2 + x3 + x4) / 4, y: (y1 + y2 + y3 + y4) / 4 } +} + +async function click(cdp: CDPSession, page: Page, node: CdpNode): Promise { + const { x, y } = await centerOf(cdp, node) + await page.mouse.click(x, y) +} + +/** Poll `DOM.getFlattenedDocument` until `predicate` finds a node, or throw + * after `timeoutMs`. Standing in for Playwright's own auto-waiting locators, + * which don't work here (see header comment). */ +async function waitForNode( + cdp: CDPSession, + predicate: (n: CdpNode, all: CdpNode[]) => boolean, + opts: { timeoutMs?: number; label?: string } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 10_000 + const deadline = Date.now() + timeoutMs + for (;;) { + const nodes = await flat(cdp) + const found = nodes.find((n) => predicate(n, nodes)) + if (found) return found + if (Date.now() > deadline) { + throw new Error( + `waitForNode timed out after ${timeoutMs}ms${opts.label ? `: ${opts.label}` : ""}`, + ) + } + await new Promise((r) => setTimeout(r, 200)) + } +} + +async function setupExtensionContext(testOrigin: string): Promise<{ + context: BrowserContext + page: Page + cdp: CDPSession +}> { + const userDataDir = mkdtempSync(resolvePath(tmpdir(), "repro-ext-widget-modes-")) + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + `--disable-extensions-except=${EXT_PATH}`, + `--load-extension=${EXT_PATH}`, + "--no-sandbox", + // Auto-accept the getDisplayMedia() screen-picker with "Entire screen" + // and auto-accept any getUserMedia() prompt, so screen recording runs + // headlessly without a human clicking a native OS dialog. NOTE: on + // macOS this only bypasses Chrome's *in-app* picker UI — the OS-level + // Screen Recording (TCC) permission gate for the Chromium binary is a + // separate check this flag does not satisfy. See the second test + // below for what that means for this spec in practice. + "--auto-select-desktop-capture-source=Entire screen", + "--use-fake-ui-for-media-stream", + ], + }) + + let [sw] = context.serviceWorkers() + if (!sw) sw = await context.waitForEvent("serviceworker") + const extId = new URL(sw.url()).host + + const popup = await context.newPage() + await popup.goto(`chrome-extension://${extId}/index.html`) + await popup.evaluate( + async ({ origin }) => { + await chrome.storage.local.set({ + configs: [ + { + id: "widget-modes-1", + label: "widget-modes test", + origin, + projectKey: "rp_pk_" + "a".repeat(24), + intakeEndpoint: "https://repro.example.com", + createdAt: Date.now(), + }, + ], + }) + }, + { origin: testOrigin }, + ) + await popup.close() + + const page = await context.newPage() + const cdp = await context.newCDPSession(page) + await cdp.send("DOM.enable") + + return { context, page, cdp } +} + +function startFixtureServer(): Promise<{ + server: ReturnType + origin: string +}> { + const server = createServer((_, res) => { + res.setHeader("Content-Type", "text/html") + res.end(FIXTURE) + }) + return new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (typeof address !== "object" || address === null) { + reject(new Error("no address")) + return + } + resolve({ server, origin: `http://127.0.0.1:${address.port}` }) + }) + }) +} + +test("widget modes: launcher opens the menu; Record screen renders the control bar", async () => { + test.setTimeout(30_000) + const { server, origin: testOrigin } = await startFixtureServer() + const { context, page, cdp } = await setupExtensionContext(testOrigin) + + try { + await page.goto(testOrigin) + + // --- Host mounts (DOM-level, matches the existing inject.e2e.ts pattern) + const host = page.locator("#repro-host") + await expect(host).toBeAttached({ timeout: 10_000 }) + + // --- Launcher click opens the menu with its options + const launcher = await waitForNode(cdp, (n) => hasClass(n, "ft-launcher"), { + label: "launcher button", + }) + await click(cdp, page, launcher) + + const menu = await waitForNode( + cdp, + (n) => n.nodeName === "DIV" && attrOf(n, "class")?.startsWith("ft-menu pos-") === true, + { label: "menu popover" }, + ) + expect(menu).toBeDefined() + + const menuNodes = await flat(cdp) + const rows = menuNodes.filter((n) => hasClass(n, "ft-menu-row")) + const rowLabels = rows.map((r) => textOf(menuNodes, r).trim()) + // The widget-modes menu exposes four rows: Capture, Record screen, + // Report bug, and Gallery (packages/ui/src/menu.tsx). The task brief + // says "three options" — the actual shipped menu has four (Gallery is a + // fourth row alongside the three original modes). Asserting the real + // menu contents rather than a stale "three" count. Icon glyphs + // (📷🎥🐛🖼) precede each row's label text, so match with `includes` + // rather than `startsWith`. + expect(rows.length).toBe(4) + expect(rowLabels.some((l) => l.includes("Capture"))).toBe(true) + expect(rowLabels.some((l) => l.includes("Record screen"))).toBe(true) + expect(rowLabels.some((l) => l.includes("Report bug") && !l.includes("with this"))).toBe(true) + expect(rowLabels.some((l) => l.includes("Gallery"))).toBe(true) + + // --- Click "Record screen" -> control bar appears (this renders + // optimistically as soon as recording mode starts, before the + // getDisplayMedia() promise settles — see mount.ts's startRecord()). + const recordRow = rows.find((r) => textOf(menuNodes, r).trim().includes("Record screen")) + if (!recordRow) throw new Error("Record screen row not found") + await click(cdp, page, recordRow) + + const controlBar = await waitForNode(cdp, (n) => hasClass(n, "ft-rec-bar"), { + timeoutMs: 15_000, + label: "recording control bar (ft-rec-bar)", + }) + expect(controlBar).toBeDefined() + const barNodes = await flat(cdp) + expect(barNodes.find((n) => hasClass(n, "ft-rec-stop"))).toBeDefined() + expect(barNodes.find((n) => hasClass(n, "ft-rec-cancel"))).toBeDefined() + expect(barNodes.find((n) => hasClass(n, "ft-rec-time"))).toBeDefined() + } finally { + await context.close() + server.close() + } +}) + +test("widget modes: full record -> trim -> save-to-gallery flow (requires real screen capture)", async () => { + test.setTimeout(60_000) + const { server, origin: testOrigin } = await startFixtureServer() + const { context, page, cdp } = await setupExtensionContext(testOrigin) + + try { + await page.goto(testOrigin) + await expect(page.locator("#repro-host")).toBeAttached({ timeout: 10_000 }) + + const launcher = await waitForNode(cdp, (n) => hasClass(n, "ft-launcher"), { + label: "launcher button", + }) + await click(cdp, page, launcher) + const menuNodes = await flat(cdp) + const recordRow = menuNodes.find( + (n) => hasClass(n, "ft-menu-row") && textOf(menuNodes, n).trim().includes("Record screen"), + ) + if (!recordRow) throw new Error("Record screen row not found") + await click(cdp, page, recordRow) + + await waitForNode(cdp, (n) => hasClass(n, "ft-rec-bar"), { + timeoutMs: 15_000, + label: "recording control bar (ft-rec-bar)", + }) + + // Give getDisplayMedia's promise time to settle one way or the other: + // either the recording stays live (ft-rec-bar persists, timer ticks) or + // the widget fails open back to the menu with an + // "Screen recording unavailable" toast (packages/ui/src/mount.ts:294, + // hit when startScreenRecording()'s getDisplayMedia() call rejects). + await page.waitForTimeout(3_000) + + const settled = await flat(cdp) + const stillRecording = settled.find((n) => hasClass(n, "ft-rec-stop")) + const failToast = settled.find((n) => hasClass(n, "ft-mode-toast")) + + if (!stillRecording && failToast) { + const toastText = textOf(settled, failToast) + test.skip( + true, + `getDisplayMedia() was rejected in this sandbox (widget's own fail-open toast: ` + + `"${toastText}"). This is a macOS Screen Recording (TCC) permission gate on the ` + + `Chromium binary Playwright launches — --auto-select-desktop-capture-source only ` + + `bypasses Chrome's in-app source picker, not the OS-level permission check, and ` + + `granting that permission requires a one-time interactive System Settings grant per ` + + `binary (not scriptable, and out of scope to modify on a shared dev machine). The ` + + `widget's fail-open behavior itself is confirmed working — see the sibling test for ` + + `the reliably-reproducible portion of this flow (menu + control bar). The actual ` + + `record -> trim -> save flow is covered by Task 8's manual pass on a real browser ` + + `with the permission granted, per the task brief's carve-out for interactive ` + + `screen-share testing.`, + ) + } + + if (!stillRecording) + throw new Error("recording stopped for an unexpected reason (no toast either)") + + // --- Click Stop -> trim screen appears + const stopNode = await waitForNode(cdp, (n) => hasClass(n, "ft-rec-stop"), { + label: "stop button", + }) + await click(cdp, page, stopNode) + + const trimScreen = await waitForNode(cdp, (n) => hasClass(n, "ft-trim"), { + timeoutMs: 15_000, + label: "trim screen (ft-trim)", + }) + expect(trimScreen).toBeDefined() + const trimNodes = await flat(cdp) + const confirmBtn = trimNodes.find( + (n) => hasClass(n, "ft-btn-primary") && textOf(trimNodes, n).trim() === "Confirm", + ) + expect(confirmBtn).toBeDefined() + + // --- Click Confirm -> outcome bar appears + if (!confirmBtn) throw new Error("Confirm button not found") + await click(cdp, page, confirmBtn) + + const outcomeBar = await waitForNode(cdp, (n) => hasClass(n, "ft-outcome-bar"), { + label: "outcome bar (ft-outcome-bar)", + }) + expect(attrOf(outcomeBar, "data-kind")).toBe("video") + const outcomeNodes = await flat(cdp) + const saveBtn = outcomeNodes.find( + (n) => + hasClass(n, "ft-btn-secondary") && textOf(outcomeNodes, n).trim() === "Save to gallery", + ) + expect(saveBtn).toBeDefined() + + // --- Click "Save to gallery" + if (!saveBtn) throw new Error("Save to gallery button not found") + await click(cdp, page, saveBtn) + await waitForNode(cdp, (n) => !hasClass(n, "ft-outcome-bar"), { + timeoutMs: 10_000, + label: "outcome bar dismissed after save", + }).catch(() => {}) + + // --- Reopen the menu -> Gallery -> exactly one video tile + const launcher2 = await waitForNode(cdp, (n) => hasClass(n, "ft-launcher"), { + label: "launcher button (reopen)", + }) + await click(cdp, page, launcher2) + + const menuNodes2 = await flat(cdp) + const galleryRow = menuNodes2.find( + (n) => hasClass(n, "ft-menu-row") && textOf(menuNodes2, n).trim().includes("Gallery"), + ) + if (!galleryRow) throw new Error("Gallery row not found") + await click(cdp, page, galleryRow) + + await waitForNode(cdp, (n) => hasClass(n, "ft-gallery"), { label: "gallery view" }) + const galleryNodes = await flat(cdp) + const tiles = galleryNodes.filter((n) => hasClass(n, "ft-gallery-tile")) + expect(tiles.length).toBe(1) + expect(attrOf(tiles[0] as CdpNode, "data-kind")).toBe("video") + } finally { + await context.close() + server.close() + } +}) diff --git a/bun.lock b/bun.lock index 919865bf..467051f1 100644 --- a/bun.lock +++ b/bun.lock @@ -69,7 +69,7 @@ }, "apps/extension": { "name": "@reprojs/extension", - "version": "0.1.2", + "version": "0.1.4", "dependencies": { "preact": "^10.23.0", }, @@ -87,7 +87,7 @@ }, "packages/core": { "name": "@reprojs/core", - "version": "0.4.0", + "version": "0.4.2", "devDependencies": { "@preact/signals": "^1.3.0", "@reprojs/recorder": "workspace:*", @@ -104,7 +104,7 @@ }, "packages/expo": { "name": "@reprojs/expo", - "version": "0.3.0", + "version": "0.3.1", "dependencies": { "@expo/config-plugins": "^8.0.0", }, @@ -187,6 +187,7 @@ }, "devDependencies": { "@napi-rs/canvas": "^0.1.98", + "fake-indexeddb": "^6.2.5", "happy-dom": "^20.9.0", }, }, diff --git a/docs/superpowers/plans/2026-07-17-widget-modes-gallery-recording.md b/docs/superpowers/plans/2026-07-17-widget-modes-gallery-recording.md new file mode 100644 index 00000000..67fc5c74 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-widget-modes-gallery-recording.md @@ -0,0 +1,2197 @@ +# Widget 3-Mode Launcher, Gallery, Recording & Share Links — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the widget into a 3-mode tool (Capture / Record screen ≤5:00 / Report bug) with a local IndexedDB gallery, metadata-based trim, a 2-step report wizard fed by the gallery, intake support for media attachments, and public share links with retention on the dashboard. + +**Architecture:** SDK side: a new `screen-record.ts` MediaRecorder module in `@reprojs/core`, a gallery store + new flow components in `@reprojs/ui`, and a rewritten mode-aware `mount.ts`/`reporter.tsx`. Dashboard side: additive `media` attachment kind on intake, a `shared_media` table + `POST /api/intake/media` mint endpoint, Range-streaming blob routes, a public `/s/:token` player page, and a nightly purge task. + +**Tech Stack:** TypeScript strict, Preact + Shadow DOM, IndexedDB (fake-indexeddb for tests), MediaRecorder/getDisplayMedia, Nuxt 4 + Nitro, Drizzle + Postgres, bun test. + +**Spec:** `docs/superpowers/specs/2026-07-17-widget-modes-gallery-recording-design.md` + +## Global Constraints + +- Package scope is `@reprojs/*` (core is published; ui/sdk-utils/shared/recorder are workspace packages bundled into core). +- Bun for everything: `bun test`, `bun run`, `bun add`. Never npm/npx/vitest/jest. +- TDD: every task writes the failing test first. Unit tests live next to source (`foo.ts` + `foo.test.ts`). +- Conventional Commits, one concern per commit, each commit message ends with `Co-Authored-By: Claude Fable 5 `. +- No `any`. `as unknown as X` only when strictly necessary with an inline justification. +- Drizzle migrations: NEVER hand-write `.sql` or snapshot files. Edit schema TS, then run `bun run db:gen` from the REPO ROOT (it runs auth:gen then drizzle-kit generate), then `bun run db:migrate`. +- Widget CSS: edit `packages/ui/src/styles.css` ONLY, then regenerate with `bun run packages/ui/build-css.ts`. Never hand-edit `packages/ui/src/styles-inline.ts`. New CSS must not contain backticks or `${`. +- Do not clobber unrelated edits in `apps/dashboard/nuxt.config.ts` — make minimal, targeted insertions (the user edits this file directly). +- oxlint is pinned at 1.59.0; do not bump. `bun run check` (root) must pass before each commit (husky runs it). +- Dashboard integration tests (`apps/dashboard/tests/`) require the dev server running (`bun run dev` in another terminal, `TEST_BASE_URL` default `http://localhost:3000`) and real Postgres (`bun run dev:docker`). +- Wire-contract constants (from spec): recording max 5:00 (`300_000` ms), video bitrate 2_500_000 bps, gallery quota 50 items / 500 MB, media per report max 3, image ≤ 10 MB, video ≤ 100 MB, share token 32 random bytes base64url, retention default 30 days, share links default ON. +- Intake multipart part names today: `report`, `screenshot`, `logs`, `replay`, `attachment[N]`. This plan adds `media[N]` + `mediaMeta`. The project key travels INSIDE the `report` (or `meta`) JSON body, not a header. + +## File Map (created ▲ / modified ●) + +``` +packages/shared/src/reports.ts ● AttachmentKind + "media", MediaMetaEntry/MediaMetaInput, AttachmentDTO trim fields +packages/shared/src/projects.ts ● UpdateProjectInput/ProjectDTO share settings +packages/shared/src/shared-media.ts ▲ ShareMintResponse, SharedMediaDTO +packages/sdk-utils/src/format.ts ▲ formatBytes (promoted from attachment-list) +packages/sdk-utils/src/media/{types,validate}.ts ▲ TrimRange, MEDIA_LIMITS, validateMediaSelection +packages/core/src/screen-record.ts ▲ MediaRecorder session state machine +packages/core/src/share-client.ts ▲ mintShareLink() +packages/core/src/hotkey.ts ▲ optional open-menu hotkey +packages/core/src/intake-client.ts ● media[N] + mediaMeta parts +packages/core/src/config.ts ● InitOptions.hotkey +packages/core/src/index.ts ● openMenu/capture/record exports + new mount wiring +packages/ui/src/gallery/{store,thumbnail}.ts ▲ IndexedDB store + poster helper +packages/ui/src/gallery/gallery-view.tsx ▲ grid / preview / delete / copy-link / report-with +packages/ui/src/record/{control-bar,trim-screen,outcome-bar}.tsx ▲ +packages/ui/src/menu.tsx ▲ 3-option launcher menu +packages/ui/src/capture-flow.tsx ▲ annotate → outcome bar flow +packages/ui/src/reporter.tsx ● 2-step wizard (Details → Review) +packages/ui/src/wizard/step-details.tsx ● media picker section +packages/ui/src/wizard/media-picker.tsx ▲ gallery chips + capture/record-now buttons +packages/ui/src/mount.ts ● mode state machine +packages/ui/src/styles.css ● new classes (then regen styles-inline) +apps/dashboard/server/db/schema/reports.ts ● kind check + duration/trim columns +apps/dashboard/server/db/schema/projects.ts ● share_links_enabled, share_retention_days +apps/dashboard/server/db/schema/shared-media.ts ▲ shared_media table +apps/dashboard/server/api/intake/reports.ts ● media part handling +apps/dashboard/server/api/intake/media.post.ts ▲ share-link mint +apps/dashboard/server/api/shared/[token]/index.get.ts ▲ public meta +apps/dashboard/server/api/shared/[token]/blob.get.ts ▲ public Range streaming +apps/dashboard/server/api/projects/[id]/reports/[reportId]/media/[attachmentId].get.ts ▲ authed Range streaming +apps/dashboard/server/api/projects/[id]/shared-media/{index.get.ts,[mediaId].delete.ts} ▲ list + revoke +apps/dashboard/server/lib/storage/{index,local-disk,s3}.ts ● getStream() +apps/dashboard/server/lib/range.ts ▲ Range header parsing +apps/dashboard/server/tasks/media/purge.ts ▲ retention purge +apps/dashboard/app/pages/s/[token].vue ▲ public player page +apps/dashboard/app/components/report-drawer/trim-video.vue ▲ trim-aware player +apps/dashboard/app/components/report-drawer/attachments-tab.vue ● media section +apps/dashboard/app/pages/projects/[id]/settings.vue ● Sharing tab +apps/dashboard/app/middleware/auth.global.ts ● publicPaths += "/s" +apps/dashboard/nuxt.config.ts ● routeRules + media-src CSP + purge schedule +``` + +--- + +### Task 1: Shared contracts (`@reprojs/shared`) + +**Files:** +- Modify: `packages/shared/src/reports.ts` (AttachmentKind at lines ~218-225, AttachmentDTO at ~227-241) +- Modify: `packages/shared/src/projects.ts` (UpdateProjectInput at ~26-32, ProjectDTO at ~7-19) +- Create: `packages/shared/src/shared-media.ts` +- Modify: `packages/shared/src/index.ts` (add `export * from "./shared-media"`) +- Test: `packages/shared/src/shared-media.test.ts`, extend `packages/shared/src/reports.test.ts` if present (create if not) + +**Interfaces:** +- Consumes: nothing new. +- Produces (used by every later task): + - `AttachmentKind` gains `"media"`. + - `MediaMetaEntry` / `MediaMetaInput` zod schemas + inferred types. + - `AttachmentDTO` gains `durationMs: number | null`, `trimStartMs: number | null`, `trimEndMs: number | null`. + - `UpdateProjectInput` gains `shareLinksEnabled?: boolean`, `shareRetentionDays?: number (1..365)`; `ProjectDTO` gains both (required). + - `ShareMintResponse`, `SharedMediaDTO`. + +- [ ] **Step 1: Write the failing test** + +`packages/shared/src/shared-media.test.ts`: + +```ts +import { test, expect } from "bun:test" +import { AttachmentKind, MediaMetaInput, ShareMintResponse, SharedMediaDTO } from "./index" + +test("AttachmentKind accepts media", () => { + expect(AttachmentKind.parse("media")).toBe("media") +}) + +test("MediaMetaInput enforces max 3 entries and trim shape", () => { + const good = MediaMetaInput.parse([ + { kind: "video", mime: "video/webm", durationMs: 12_000, trim: { startMs: 1000, endMs: 9000 } }, + { kind: "image", mime: "image/png" }, + ]) + expect(good).toHaveLength(2) + expect(() => + MediaMetaInput.parse([{ kind: "video", mime: "video/webm" }, { kind: "video", mime: "video/webm" }, { kind: "video", mime: "video/webm" }, { kind: "video", mime: "video/webm" }]), + ).toThrow() + expect(() => MediaMetaInput.parse([{ kind: "gif", mime: "image/gif" }])).toThrow() +}) + +test("ShareMintResponse roundtrip", () => { + const r = ShareMintResponse.parse({ + id: "e48d797c-1af8-4b16-95df-a99b78277c1c", + token: "a".repeat(43), + shareUrl: "https://feedback.example.com/s/" + "a".repeat(43), + expiresAt: "2026-08-16T00:00:00.000Z", + }) + expect(r.token.length).toBeGreaterThanOrEqual(43) +}) + +test("SharedMediaDTO parses a revoked row", () => { + const dto = SharedMediaDTO.parse({ + id: "e48d797c-1af8-4b16-95df-a99b78277c1c", + kind: "video", + mime: "video/webm", + sizeBytes: 1024, + durationMs: 5000, + createdAt: "2026-07-17T00:00:00.000Z", + expiresAt: "2026-08-16T00:00:00.000Z", + revokedAt: "2026-07-18T00:00:00.000Z", + shareUrl: "https://feedback.example.com/s/tok", + }) + expect(dto.revokedAt).not.toBeNull() +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/shared && bun test src/shared-media.test.ts` +Expected: FAIL — `MediaMetaInput`/`ShareMintResponse`/`SharedMediaDTO` not exported; `AttachmentKind.parse("media")` throws. + +- [ ] **Step 3: Implement** + +In `packages/shared/src/reports.ts`, extend the enum and DTO, and add media-meta schemas right below `AttachmentKind`: + +```ts +export const AttachmentKind = z.enum([ + "screenshot", + "annotated-screenshot", + "replay", + "logs", + "user-file", + "media", +]) + +/** Sidecar metadata for gallery media parts (`media[N]` + `mediaMeta`). */ +export const MediaMetaEntry = z.object({ + kind: z.enum(["image", "video"]), + mime: z.string().min(1).max(255), + durationMs: z.number().int().nonnegative().optional(), + trim: z + .object({ startMs: z.number().int().nonnegative(), endMs: z.number().int().positive() }) + .refine((t) => t.endMs > t.startMs, "endMs must be after startMs") + .optional(), +}) +export type MediaMetaEntry = z.infer +export const MediaMetaInput = z.array(MediaMetaEntry).max(3) +export type MediaMetaInput = z.infer +``` + +In `AttachmentDTO`, add: + +```ts + durationMs: z.number().int().nullable(), + trimStartMs: z.number().int().nullable(), + trimEndMs: z.number().int().nullable(), +``` + +In `packages/shared/src/projects.ts`: add to `UpdateProjectInput`: + +```ts + shareLinksEnabled: z.boolean().optional(), + shareRetentionDays: z.number().int().min(1).max(365).optional(), +``` + +and to `ProjectDTO`: + +```ts + shareLinksEnabled: z.boolean(), + shareRetentionDays: z.number().int(), +``` + +Create `packages/shared/src/shared-media.ts`: + +```ts +import { z } from "zod" + +export const ShareMintResponse = z.object({ + id: z.string().uuid(), + token: z.string().min(43), + shareUrl: z.string().url(), + expiresAt: z.string(), +}) +export type ShareMintResponse = z.infer + +export const SharedMediaDTO = z.object({ + id: z.string().uuid(), + kind: z.literal("video"), + mime: z.string(), + sizeBytes: z.number().int(), + durationMs: z.number().int().nullable(), + createdAt: z.string(), + expiresAt: z.string(), + revokedAt: z.string().nullable(), + shareUrl: z.string().url(), +}) +export type SharedMediaDTO = z.infer +``` + +Add `export * from "./shared-media"` to `packages/shared/src/index.ts`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/shared && bun test` +Expected: PASS (all shared tests, not just the new file — the enum change must not break existing tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/shared +git commit -m "feat(shared): media attachment kind, media-meta contract, share-link DTOs + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: `@reprojs/sdk-utils` — shared byte formatter + media limits/validation + +**Files:** +- Create: `packages/sdk-utils/src/format.ts`, `packages/sdk-utils/src/format.test.ts` +- Create: `packages/sdk-utils/src/media/types.ts`, `packages/sdk-utils/src/media/validate.ts`, `packages/sdk-utils/src/media/validate.test.ts`, `packages/sdk-utils/src/media/index.ts` +- Modify: `packages/sdk-utils/src/index.ts` (barrel: `export * from "./format"`, `export * from "./media"`) +- Modify: `packages/ui/src/wizard/attachment-list.tsx` (delete the local `formatBytes` at lines 14-18, import from `@reprojs/sdk-utils`) + +**Interfaces:** +- Produces: + - `formatBytes(n: number): string` — exact behavior of the current local helper in attachment-list.tsx (`<1024 → "N B"`, `<1 MiB → "K KB"` 0dp, else `"M MB"` 1dp). + - `interface TrimRange { startMs: number; endMs: number }` + - `type MediaKind = "image" | "video"` + - `interface MediaLimits { maxCount: number; imageMaxBytes: number; videoMaxBytes: number }` + - `const MEDIA_LIMITS: MediaLimits = { maxCount: 3, imageMaxBytes: 10 * 1024 * 1024, videoMaxBytes: 100 * 1024 * 1024 }` + - `validateMediaSelection(items: { kind: MediaKind; sizeBytes: number }[], limits?: MediaLimits): { ok: boolean; errors: string[] }` + +- [ ] **Step 1: Write the failing tests** + +`packages/sdk-utils/src/format.test.ts`: + +```ts +import { test, expect } from "bun:test" +import { formatBytes } from "./format" + +test("formatBytes matches the widget's existing rendering", () => { + expect(formatBytes(512)).toBe("512 B") + expect(formatBytes(2048)).toBe("2 KB") + expect(formatBytes(1_572_864)).toBe("1.5 MB") +}) +``` + +`packages/sdk-utils/src/media/validate.test.ts`: + +```ts +import { test, expect } from "bun:test" +import { MEDIA_LIMITS, validateMediaSelection } from "./validate" + +test("accepts up to 3 items within per-kind caps", () => { + const r = validateMediaSelection([ + { kind: "image", sizeBytes: 5 * 1024 * 1024 }, + { kind: "video", sizeBytes: 90 * 1024 * 1024 }, + ]) + expect(r.ok).toBe(true) + expect(r.errors).toHaveLength(0) +}) + +test("rejects a 4th item", () => { + const items = Array.from({ length: 4 }, () => ({ kind: "image" as const, sizeBytes: 1024 })) + const r = validateMediaSelection(items) + expect(r.ok).toBe(false) + expect(r.errors[0]).toContain(String(MEDIA_LIMITS.maxCount)) +}) + +test("rejects oversize video and oversize image with distinct messages", () => { + expect(validateMediaSelection([{ kind: "video", sizeBytes: 101 * 1024 * 1024 }]).ok).toBe(false) + expect(validateMediaSelection([{ kind: "image", sizeBytes: 11 * 1024 * 1024 }]).ok).toBe(false) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/sdk-utils && bun test src/format.test.ts src/media/validate.test.ts` +Expected: FAIL — modules don't exist. + +- [ ] **Step 3: Implement** + +`packages/sdk-utils/src/format.ts` (move the exact logic from `packages/ui/src/wizard/attachment-list.tsx:14-18`): + +```ts +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB` + return `${(n / (1024 * 1024)).toFixed(1)} MB` +} +``` + +(Before writing, read attachment-list.tsx lines 14-18 and copy its exact rounding — the test values above assume `Math.round` for KB and `toFixed(1)` for MB; if the source differs, match the source and fix the test.) + +`packages/sdk-utils/src/media/types.ts`: + +```ts +export type MediaKind = "image" | "video" + +export interface TrimRange { + startMs: number + endMs: number +} + +export interface MediaLimits { + maxCount: number + imageMaxBytes: number + videoMaxBytes: number +} + +export const MEDIA_LIMITS: MediaLimits = { + maxCount: 3, + imageMaxBytes: 10 * 1024 * 1024, + videoMaxBytes: 100 * 1024 * 1024, +} +``` + +`packages/sdk-utils/src/media/validate.ts`: + +```ts +import { formatBytes } from "../format" +import { MEDIA_LIMITS, type MediaKind, type MediaLimits } from "./types" + +export { MEDIA_LIMITS } from "./types" + +export function validateMediaSelection( + items: { kind: MediaKind; sizeBytes: number }[], + limits: MediaLimits = MEDIA_LIMITS, +): { ok: boolean; errors: string[] } { + const errors: string[] = [] + if (items.length > limits.maxCount) { + errors.push(`At most ${limits.maxCount} media items per report`) + } + for (const item of items) { + const cap = item.kind === "video" ? limits.videoMaxBytes : limits.imageMaxBytes + if (item.sizeBytes > cap) { + errors.push(`${item.kind} exceeds ${formatBytes(cap)} limit`) + } + } + return { ok: errors.length === 0, errors } +} +``` + +`packages/sdk-utils/src/media/index.ts`: + +```ts +export * from "./types" +export * from "./validate" +``` + +Barrel: append `export * from "./format"` and `export * from "./media"` to `packages/sdk-utils/src/index.ts`. + +In `packages/ui/src/wizard/attachment-list.tsx`: delete the local `formatBytes` (lines 14-18) and add `formatBytes` to the existing `@reprojs/sdk-utils` import. + +- [ ] **Step 4: Run tests** + +Run: `cd packages/sdk-utils && bun test && cd ../ui && bun test` +Expected: PASS — including the existing attachment-list tests (unchanged rendering). + +- [ ] **Step 5: Commit** + +```bash +git add packages/sdk-utils packages/ui/src/wizard/attachment-list.tsx +git commit -m "feat(sdk-utils): shared formatBytes and media selection limits + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: `@reprojs/core` — screen recording session (`screen-record.ts`) + +**Files:** +- Create: `packages/core/src/screen-record.ts` +- Test: `packages/core/src/screen-record.test.ts` (model on `packages/core/src/display-media.test.ts`, which already shows how to bootstrap happy-dom and stub `navigator.mediaDevices.getDisplayMedia`) + +**Interfaces:** +- Produces (consumed by Task 8's mount wiring and Task 5's UI): + +```ts +export const MAX_RECORDING_MS = 300_000 +export const RECORDING_VIDEO_BPS = 2_500_000 +export type RecordingEndReason = "stopped" | "auto" | "track-ended" | "cancelled" | "error" +export interface RecordingResult { blob: Blob; mime: string; durationMs: number } +export interface RecordingSession { + stop(): void // triggers onEnd("stopped") with assembled result + cancel(): void // triggers onEnd("cancelled") with null result + snapshot(): RecordingResult | null // best-effort assembly of chunks so far (pagehide flush) + elapsedMs(): number +} +export interface ScreenRecordDeps { + getDisplayMedia?: (c: DisplayMediaStreamOptions) => Promise + createMediaRecorder?: (stream: MediaStream, opts: MediaRecorderOptions) => MediaRecorder + now?: () => number +} +export interface ScreenRecordOptions extends ScreenRecordDeps { + maxMs?: number // default MAX_RECORDING_MS + onTick?: (elapsedMs: number) => void // fired every TICK_MS (500) + onEnd: (result: RecordingResult | null, reason: RecordingEndReason) => void +} +/** Resolves null (after calling nothing) when getDisplayMedia is unavailable/denied. */ +export function startScreenRecording(opts: ScreenRecordOptions): Promise +``` + +Behavior contract: +- Requests `{ video: true, audio: false }` (no audio in v1). +- Picks mime: first supported of `video/webm;codecs=vp9`, `video/webm`, `video/mp4` via `MediaRecorder.isTypeSupported` (fall back to `video/webm` if `isTypeSupported` is absent). +- `new MediaRecorder(stream, { mimeType, videoBitsPerSecond: RECORDING_VIDEO_BPS })`, `start(1000)` (1s timeslice), chunks accumulated from `dataavailable`. +- Auto-stop when `elapsedMs() >= maxMs` (checked on the tick interval) → reason `"auto"`. +- Video track `ended` (user hits the browser's native "Stop sharing") → reason `"track-ended"` with assembled result. +- `cancel()` discards chunks, stops tracks, `onEnd(null, "cancelled")`. +- All paths stop every track and clear the interval exactly once (idempotent teardown). +- `getDisplayMedia` rejection or absence → resolve `null` (caller shows a toast); never throw. + +- [ ] **Step 1: Write the failing test** + +`packages/core/src/screen-record.test.ts`: + +```ts +import { afterAll, beforeAll, expect, test } from "bun:test" +import { Window } from "happy-dom" +import { startScreenRecording, MAX_RECORDING_MS, RECORDING_VIDEO_BPS } from "./screen-record" + +let win: Window +beforeAll(() => { + win = new Window() + Object.assign(globalThis, { window: win, document: win.document, navigator: win.navigator }) +}) +afterAll(() => { + // @ts-expect-error test cleanup of injected globals + delete globalThis.window; // @ts-expect-error + delete globalThis.document; // @ts-expect-error + delete globalThis.navigator +}) + +type Listener = (ev: { data?: Blob }) => void + +class FakeTrack { + stopped = false + private listeners: (() => void)[] = [] + stop() { this.stopped = true } + addEventListener(_: "ended", cb: () => void) { this.listeners.push(cb) } + fireEnded() { for (const cb of this.listeners) cb() } +} + +class FakeStream { + tracks = [new FakeTrack()] + getTracks() { return this.tracks } + getVideoTracks() { return this.tracks } +} + +class FakeMediaRecorder { + static created: FakeMediaRecorder[] = [] + ondataavailable: Listener | null = null + onstop: (() => void) | null = null + state = "inactive" + constructor(public stream: FakeStream, public opts: { mimeType?: string; videoBitsPerSecond?: number }) { + FakeMediaRecorder.created.push(this) + } + start(_timeslice: number) { this.state = "recording" } + stop() { + this.state = "inactive" + this.ondataavailable?.({ data: new Blob(["chunk"], { type: "video/webm" }) }) + this.onstop?.() + } +} + +function deps(stream: FakeStream) { + return { + getDisplayMedia: async () => stream as unknown as MediaStream, // FakeStream implements the used surface + createMediaRecorder: (s: MediaStream, o: MediaRecorderOptions) => + new FakeMediaRecorder(s as unknown as FakeStream, o) as unknown as MediaRecorder, + } +} + +test("constants match the spec", () => { + expect(MAX_RECORDING_MS).toBe(300_000) + expect(RECORDING_VIDEO_BPS).toBe(2_500_000) +}) + +test("stop() assembles chunks, reports duration, stops tracks", async () => { + const stream = new FakeStream() + let ended: { result: unknown; reason: string } | null = null + const session = await startScreenRecording({ + ...deps(stream), + onEnd: (result, reason) => { ended = { result, reason } }, + }) + expect(session).not.toBeNull() + session!.stop() + await Bun.sleep(10) + expect(ended!.reason).toBe("stopped") + const result = ended!.result as { blob: Blob; mime: string; durationMs: number } + expect(result.blob.size).toBeGreaterThan(0) + expect(result.mime).toContain("video/") + expect(stream.tracks[0]!.stopped).toBe(true) +}) + +test("cancel() discards and reports null", async () => { + const stream = new FakeStream() + let ended: { result: unknown; reason: string } | null = null + const session = await startScreenRecording({ ...deps(stream), onEnd: (r, reason) => { ended = { result: r, reason } } }) + session!.cancel() + await Bun.sleep(10) + expect(ended).toEqual({ result: null, reason: "cancelled" }) + expect(stream.tracks[0]!.stopped).toBe(true) +}) + +test("native stop-sharing (track ended) finishes the recording", async () => { + const stream = new FakeStream() + let reason = "" + const session = await startScreenRecording({ ...deps(stream), onEnd: (_r, why) => { reason = why } }) + expect(session).not.toBeNull() + stream.tracks[0]!.fireEnded() + await Bun.sleep(10) + expect(reason).toBe("track-ended") +}) + +test("auto-stops at maxMs", async () => { + const stream = new FakeStream() + let reason = "" + await startScreenRecording({ + ...deps(stream), + maxMs: 40, + onEnd: (_r, why) => { reason = why }, + }) + await Bun.sleep(700) // TICK_MS is 500; one tick past maxMs must fire auto-stop + expect(reason).toBe("auto") +}) + +test("returns null when getDisplayMedia rejects", async () => { + const session = await startScreenRecording({ + getDisplayMedia: async () => { throw new Error("denied") }, + onEnd: () => {}, + }) + expect(session).toBeNull() +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/core && bun test src/screen-record.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `packages/core/src/screen-record.ts`** + +```ts +export const MAX_RECORDING_MS = 300_000 +export const RECORDING_VIDEO_BPS = 2_500_000 +const TICK_MS = 500 +const MIME_CANDIDATES = ["video/webm;codecs=vp9", "video/webm", "video/mp4"] + +export type RecordingEndReason = "stopped" | "auto" | "track-ended" | "cancelled" | "error" + +export interface RecordingResult { + blob: Blob + mime: string + durationMs: number +} + +export interface RecordingSession { + stop(): void + cancel(): void + snapshot(): RecordingResult | null + elapsedMs(): number +} + +export interface ScreenRecordDeps { + getDisplayMedia?: (c: DisplayMediaStreamOptions) => Promise + createMediaRecorder?: (stream: MediaStream, opts: MediaRecorderOptions) => MediaRecorder + now?: () => number +} + +export interface ScreenRecordOptions extends ScreenRecordDeps { + maxMs?: number + onTick?: (elapsedMs: number) => void + onEnd: (result: RecordingResult | null, reason: RecordingEndReason) => void +} + +function pickMime(): string { + const MR = globalThis.MediaRecorder as typeof MediaRecorder | undefined + if (!MR || typeof MR.isTypeSupported !== "function") return "video/webm" + return MIME_CANDIDATES.find((m) => MR.isTypeSupported(m)) ?? "video/webm" +} + +export async function startScreenRecording(opts: ScreenRecordOptions): Promise { + const now = opts.now ?? (() => performance.now()) + const maxMs = opts.maxMs ?? MAX_RECORDING_MS + const getDM = + opts.getDisplayMedia ?? + (navigator.mediaDevices?.getDisplayMedia + ? navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices) + : null) + if (!getDM) return null + + let stream: MediaStream + try { + stream = await getDM({ video: true, audio: false }) + } catch { + return null + } + + const mime = pickMime() + const createRecorder = + opts.createMediaRecorder ?? ((s: MediaStream, o: MediaRecorderOptions) => new MediaRecorder(s, o)) + let recorder: MediaRecorder + try { + recorder = createRecorder(stream, { mimeType: mime, videoBitsPerSecond: RECORDING_VIDEO_BPS }) + } catch { + for (const t of stream.getTracks()) t.stop() + return null + } + + const chunks: Blob[] = [] + const startedAt = now() + let finished = false + let pendingReason: RecordingEndReason = "stopped" + + const elapsed = () => Math.max(0, Math.round(now() - startedAt)) + const assemble = (): RecordingResult | null => + chunks.length === 0 ? null : { blob: new Blob(chunks, { type: mime }), mime, durationMs: elapsed() } + + const teardown = () => { + clearInterval(timer) + for (const t of stream.getTracks()) t.stop() + } + + const finish = (reason: RecordingEndReason, result: RecordingResult | null) => { + if (finished) return + finished = true + teardown() + opts.onEnd(result, reason) + } + + recorder.ondataavailable = (ev: BlobEvent) => { + if (ev.data && ev.data.size > 0) chunks.push(ev.data) + } + recorder.onstop = () => { + if (pendingReason === "cancelled") finish("cancelled", null) + else finish(pendingReason, assemble()) + } + recorder.onerror = () => { + pendingReason = "error" + stopRecorder() + } + + const stopRecorder = () => { + try { + if (recorder.state !== "inactive") recorder.stop() + else recorder.onstop?.call(recorder, new Event("stop")) + } catch { + finish(pendingReason === "cancelled" ? "cancelled" : "error", pendingReason === "cancelled" ? null : assemble()) + } + } + + const videoTrack = stream.getVideoTracks()[0] + videoTrack?.addEventListener("ended", () => { + pendingReason = "track-ended" + stopRecorder() + }) + + const timer = setInterval(() => { + const ms = elapsed() + opts.onTick?.(ms) + if (ms >= maxMs) { + pendingReason = "auto" + stopRecorder() + } + }, TICK_MS) + + try { + recorder.start(1000) + } catch { + finish("error", null) + return null + } + + return { + stop() { + pendingReason = "stopped" + stopRecorder() + }, + cancel() { + pendingReason = "cancelled" + chunks.length = 0 + stopRecorder() + }, + snapshot: assemble, + elapsedMs: elapsed, + } +} +``` + +Note: the test's `FakeMediaRecorder` lacks `onerror`/`state` transitions beyond the used surface — the two `as unknown as` casts in the test are justified as test doubles implementing only the consumed surface. + +- [ ] **Step 4: Run tests** + +Run: `cd packages/core && bun test` +Expected: PASS (new file + all existing core tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/screen-record.ts packages/core/src/screen-record.test.ts +git commit -m "feat(core): screen recording session with auto-stop, cancel and snapshot + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: `@reprojs/ui` — gallery store (IndexedDB) + thumbnail helper + +**Files:** +- Create: `packages/ui/src/gallery/store.ts`, `packages/ui/src/gallery/store.test.ts` +- Create: `packages/ui/src/gallery/thumbnail.ts`, `packages/ui/src/gallery/thumbnail.test.ts` +- Modify: `packages/ui/package.json` (add devDependency `fake-indexeddb`) + +**Interfaces:** +- Consumes: `TrimRange` from `@reprojs/sdk-utils` (Task 2). +- Produces (consumed by Tasks 5-8): + +```ts +export interface GalleryItem { + id: string + kind: "image" | "video" + blob: Blob + thumb: Blob | null + mime: string + sizeBytes: number + durationMs?: number + trim?: TrimRange + createdAt: number + shareUrl?: string + shareToken?: string + shareExpiresAt?: string +} +export const GALLERY_MAX_ITEMS = 50 +export const GALLERY_MAX_TOTAL_BYTES = 500 * 1024 * 1024 +export interface GalleryStore { + add(item: GalleryItem): Promise<{ evicted: GalleryItem[] }> + list(): Promise // newest first + get(id: string): Promise + update(id: string, patch: Partial>): Promise + remove(id: string): Promise +} +/** Resolves null when IndexedDB is unavailable — callers must fail open. */ +export function openGallery(deps?: { indexedDB?: IDBFactory }): Promise +``` + +- `thumbnail.ts` produces `makeThumbnail(blob: Blob, kind: "image" | "video"): Promise` — image path decodes via `decodeImage` (reuse `packages/ui/src/decode-image.ts`, CSP-safe) and downscales to ≤160px on a canvas; video path returns `null` in v1 environments without seekable video (always wrapped in try/catch; any failure → `null`; UI renders a generic tile for `thumb: null`). + +- [ ] **Step 1: Add fake-indexeddb** + +Run: `cd packages/ui && bun add -d fake-indexeddb` +Expected: `fake-indexeddb` appears under devDependencies in `packages/ui/package.json`. + +- [ ] **Step 2: Write the failing store test** + +`packages/ui/src/gallery/store.test.ts`: + +```ts +import { beforeEach, expect, test } from "bun:test" +import { IDBFactory } from "fake-indexeddb" +import { GALLERY_MAX_ITEMS, openGallery, type GalleryItem } from "./store" + +let idb: IDBFactory +beforeEach(() => { + idb = new IDBFactory() // fresh DB per test +}) + +function item(overrides: Partial = {}): GalleryItem { + const blob = new Blob(["x".repeat(overrides.sizeBytes ?? 100)], { type: "image/png" }) + return { + id: crypto.randomUUID(), + kind: "image", + blob, + thumb: null, + mime: "image/png", + sizeBytes: blob.size, + createdAt: Date.now(), + ...overrides, + } +} + +test("add + list returns newest first", async () => { + const store = (await openGallery({ indexedDB: idb }))! + const a = item({ createdAt: 1000 }) + const b = item({ createdAt: 2000 }) + await store.add(a) + await store.add(b) + const all = await store.list() + expect(all.map((i) => i.id)).toEqual([b.id, a.id]) +}) + +test("get/update/remove roundtrip", async () => { + const store = (await openGallery({ indexedDB: idb }))! + const v = item({ kind: "video", mime: "video/webm", durationMs: 9000 }) + await store.add(v) + await store.update(v.id, { trim: { startMs: 500, endMs: 8000 }, shareUrl: "https://x/s/tok" }) + const got = await store.get(v.id) + expect(got?.trim).toEqual({ startMs: 500, endMs: 8000 }) + expect(got?.shareUrl).toBe("https://x/s/tok") + await store.remove(v.id) + expect(await store.get(v.id)).toBeNull() +}) + +test("evicts oldest past GALLERY_MAX_ITEMS", async () => { + const store = (await openGallery({ indexedDB: idb }))! + const items = Array.from({ length: GALLERY_MAX_ITEMS + 1 }, (_, i) => item({ createdAt: i + 1 })) + let evicted: GalleryItem[] = [] + for (const it of items) { + const r = await store.add(it) // eslint-disable-line no-await-in-loop -- sequential inserts are the behavior under test + evicted = evicted.concat(r.evicted) + } + expect(evicted.map((e) => e.id)).toEqual([items[0]!.id]) + expect((await store.list())).toHaveLength(GALLERY_MAX_ITEMS) +}) + +test("evicts oldest past the byte budget", async () => { + const store = (await openGallery({ indexedDB: idb }))! + // Use a tiny injected budget via the exported test seam + const big = item({ sizeBytes: 400 }) + const bigger = item({ sizeBytes: 400, createdAt: Date.now() + 1 }) + await store.add(big) + const r = await store.add(bigger, /* maxTotalBytes test seam */ 600) + expect(r.evicted.map((e) => e.id)).toEqual([big.id]) +}) + +test("openGallery returns null without IndexedDB", async () => { + expect(await openGallery({ indexedDB: undefined })).toBeNull() +}) +``` + +Note: `add` takes an optional second parameter `maxTotalBytes` (defaults `GALLERY_MAX_TOTAL_BYTES`) purely so the byte-eviction path is testable without allocating 500 MB. Reflect that in the `GalleryStore` interface: `add(item: GalleryItem, maxTotalBytes?: number)`. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd packages/ui && bun test src/gallery/store.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `packages/ui/src/gallery/store.ts`** + +```ts +import type { TrimRange } from "@reprojs/sdk-utils" + +export interface GalleryItem { + id: string + kind: "image" | "video" + blob: Blob + thumb: Blob | null + mime: string + sizeBytes: number + durationMs?: number + trim?: TrimRange + createdAt: number + shareUrl?: string + shareToken?: string + shareExpiresAt?: string +} + +export const GALLERY_MAX_ITEMS = 50 +export const GALLERY_MAX_TOTAL_BYTES = 500 * 1024 * 1024 +const DB_NAME = "repro-gallery" +const STORE = "media" + +export interface GalleryStore { + add(item: GalleryItem, maxTotalBytes?: number): Promise<{ evicted: GalleryItem[] }> + list(): Promise + get(id: string): Promise + update( + id: string, + patch: Partial>, + ): Promise + remove(id: string): Promise +} + +function req(r: IDBRequest): Promise { + return new Promise((resolve, reject) => { + r.onsuccess = () => resolve(r.result) + r.onerror = () => reject(r.error) + }) +} + +function txDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onabort = tx.onerror = () => reject(tx.error) + }) +} + +export async function openGallery(deps?: { indexedDB?: IDBFactory }): Promise { + const factory = deps ? deps.indexedDB : globalThis.indexedDB + if (!factory) return null + let db: IDBDatabase + try { + const open = factory.open(DB_NAME, 1) + open.onupgradeneeded = () => { + const store = open.result.createObjectStore(STORE, { keyPath: "id" }) + store.createIndex("createdAt", "createdAt") + } + db = await req(open as IDBRequest) + } catch { + return null + } + + const listAll = async (): Promise => { + const tx = db.transaction(STORE, "readonly") + const rows = await req(tx.objectStore(STORE).getAll() as IDBRequest) + return rows.sort((a, b) => b.createdAt - a.createdAt) + } + + return { + async add(item, maxTotalBytes = GALLERY_MAX_TOTAL_BYTES) { + const existing = await listAll() + const evicted: GalleryItem[] = [] + // Evict oldest-first until both budgets fit the incoming item. + let count = existing.length + 1 + let bytes = existing.reduce((n, i) => n + i.sizeBytes, 0) + item.sizeBytes + for (let i = existing.length - 1; i >= 0 && (count > GALLERY_MAX_ITEMS || bytes > maxTotalBytes); i--) { + const victim = existing[i]! + evicted.push(victim) + count-- + bytes -= victim.sizeBytes + } + const tx = db.transaction(STORE, "readwrite") + const store = tx.objectStore(STORE) + for (const v of evicted) store.delete(v.id) + store.put(item) + await txDone(tx) + return { evicted } + }, + list: listAll, + async get(id) { + const tx = db.transaction(STORE, "readonly") + const row = await req(tx.objectStore(STORE).get(id) as IDBRequest) + return row ?? null + }, + async update(id, patch) { + const tx = db.transaction(STORE, "readwrite") + const store = tx.objectStore(STORE) + const row = await req(store.get(id) as IDBRequest) + if (row) store.put({ ...row, ...patch }) + await txDone(tx) + }, + async remove(id) { + const tx = db.transaction(STORE, "readwrite") + tx.objectStore(STORE).delete(id) + await txDone(tx) + }, + } +} +``` + +- [ ] **Step 5: Write + implement the thumbnail helper** + +`packages/ui/src/gallery/thumbnail.test.ts`: + +```ts +import { afterAll, beforeAll, expect, test } from "bun:test" +import { Window } from "happy-dom" +import { makeThumbnail } from "./thumbnail" + +let win: Window +beforeAll(() => { + win = new Window() + Object.assign(globalThis, { window: win, document: win.document }) +}) +afterAll(() => { + // @ts-expect-error test cleanup + delete globalThis.window; // @ts-expect-error + delete globalThis.document +}) + +test("returns null instead of throwing when decoding is impossible", async () => { + const junk = new Blob(["not an image"], { type: "image/png" }) + expect(await makeThumbnail(junk, "image")).toBeNull() + expect(await makeThumbnail(junk, "video")).toBeNull() +}) +``` + +`packages/ui/src/gallery/thumbnail.ts`: + +```ts +import { closeSource, decodeImage, sourceHeight, sourceWidth } from "../decode-image" + +const MAX_EDGE = 160 + +/** Best-effort poster/preview. Any failure returns null — UI renders a generic tile. */ +export async function makeThumbnail(blob: Blob, kind: "image" | "video"): Promise { + if (kind !== "image") return null // video posters need blob: URLs, which strict host CSPs block; v1 uses a generic tile + try { + const source = await decodeImage(blob) + if (!source) return null + try { + const w = sourceWidth(source) + const h = sourceHeight(source) + const scale = Math.min(1, MAX_EDGE / Math.max(w, h)) + const canvas = document.createElement("canvas") + canvas.width = Math.max(1, Math.round(w * scale)) + canvas.height = Math.max(1, Math.round(h * scale)) + const ctx = canvas.getContext("2d") + if (!ctx) return null + ctx.drawImage(source as CanvasImageSource, 0, 0, canvas.width, canvas.height) + return await new Promise((resolve) => canvas.toBlob(resolve, "image/png")) + } finally { + closeSource(source) + } + } catch { + return null + } +} +``` + +(Check the actual export names in `packages/ui/src/decode-image.ts` before writing — the exploration confirmed `decodeImage`, `ImageSource`, `sourceWidth`, `sourceHeight`, `closeSource` exist; if `drawImage` needs a narrower type than `CanvasImageSource`, follow how `annotation/flatten` draws the same `ImageSource`.) + +- [ ] **Step 6: Run tests** + +Run: `cd packages/ui && bun test src/gallery/` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add packages/ui/src/gallery packages/ui/package.json bun.lock +git commit -m "feat(ui): IndexedDB gallery store with quota eviction and thumbnail helper + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: `@reprojs/ui` — recording UI (control bar, trim screen, outcome bar) + +**Files:** +- Create: `packages/ui/src/record/control-bar.tsx`, `packages/ui/src/record/trim-screen.tsx`, `packages/ui/src/record/outcome-bar.tsx` +- Test: `packages/ui/src/record/control-bar.test.ts`, `packages/ui/src/record/trim-screen.test.ts`, `packages/ui/src/record/outcome-bar.test.ts` +- Modify: `packages/ui/src/styles.css` (new `.ft-rec-*`, `.ft-trim-*`, `.ft-outcome-*` classes), then regen `styles-inline.ts` + +**Interfaces:** +- Consumes: `formatBytes`, `TrimRange` (Task 2); `GalleryItem` (Task 4); existing `PrimaryButton`/`SecondaryButton` from `wizard/controls.tsx`. +- Produces (consumed by Task 6/8 mount): + +```ts +// control-bar.tsx +export function RecordControlBar(props: { + elapsedMs: number + maxMs: number + onStop: () => void + onCancel: () => void +}): JSX.Element // renders mm:ss / 5:00, a Stop and a Cancel button + +// trim-screen.tsx +export function TrimScreen(props: { + blob: Blob + durationMs: number + initial?: TrimRange + onConfirm: (trim: TrimRange | undefined) => void // undefined = full length + onCancel: () => void +}): JSX.Element +export function clampTrim(t: { startMs: number; endMs: number }, durationMs: number): TrimRange | undefined +// clampTrim: clamps into [0, durationMs], snaps to 100ms, returns undefined when the range covers the full clip + +// outcome-bar.tsx +export function OutcomeBar(props: { + kind: "image" | "video" + onSave: () => void + onDiscard: () => void + onReport: () => void +}): JSX.Element // "Save to gallery" / "Discard" / "Report bug with this" +``` + +Trim-screen behavior: renders a `