From 509895a3c6130cf1e95c1c52df8b54ed57f537b1 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:36:44 -0400 Subject: [PATCH 01/35] Respect recording Trash and comment settings --- templates/clips/actions/add-comment.test.ts | 8 +- templates/clips/actions/add-comment.ts | 13 +- .../actions/comment-writing-access.test.ts | 223 ++++++++++++++++++ .../clips/actions/delete-comment.test.ts | 51 ++-- .../actions/get-recording-player-data.test.ts | 20 ++ .../actions/get-recording-player-data.ts | 10 + templates/clips/actions/react-to-comment.ts | 4 +- templates/clips/actions/reply-to-comment.ts | 69 +----- templates/clips/actions/resolve-comment.ts | 16 +- .../clips/actions/update-comment.test.ts | 14 +- templates/clips/actions/update-comment.ts | 18 +- ...-discussion-settings-apply-consistently.md | 6 + .../server/lib/comment-writing-access.ts | 25 ++ .../clips/server/lib/recording-page-access.ts | 7 + .../routes/api/public-recording.get.test.ts | 53 +++++ .../server/routes/api/public-recording.get.ts | 9 + .../api/thumbnail/[recordingId].get.test.ts | 41 ++++ .../routes/api/thumbnail/[recordingId].get.ts | 15 +- .../api/video/[recordingId].get.test.ts | 48 ++++ .../routes/api/video/[recordingId].get.ts | 14 +- 20 files changed, 518 insertions(+), 146 deletions(-) create mode 100644 templates/clips/actions/comment-writing-access.test.ts create mode 100644 templates/clips/changelog/2026-10-22-recording-sharing-and-discussion-settings-apply-consistently.md create mode 100644 templates/clips/server/lib/comment-writing-access.ts diff --git a/templates/clips/actions/add-comment.test.ts b/templates/clips/actions/add-comment.test.ts index 55bb5fda333..ee161b2aa64 100644 --- a/templates/clips/actions/add-comment.test.ts +++ b/templates/clips/actions/add-comment.test.ts @@ -22,7 +22,8 @@ vi.mock("@agent-native/core/server/request-context", () => ({ getRequestUserName: () => undefined, })); -vi.mock("@agent-native/core/sharing", () => ({ +vi.mock("@agent-native/core/sharing", async (importOriginal) => ({ + ...(await importOriginal()), assertAccess: (...args: unknown[]) => mockAssertAccess(...args), })); @@ -50,7 +51,10 @@ describe("add-comment access", () => { beforeEach(() => { vi.clearAllMocks(); mockGetRequestUserEmail.mockReturnValue("viewer@example.com"); - mockAssertAccess.mockResolvedValue({ role: "commenter", resource: {} }); + mockAssertAccess.mockResolvedValue({ + role: "commenter", + resource: { enableComments: true }, + }); mockDb.select.mockReturnValue({ from: vi.fn(() => ({ where: vi.fn(() => ({ diff --git a/templates/clips/actions/add-comment.ts b/templates/clips/actions/add-comment.ts index 95ef6141ed2..cedd2775b2a 100644 --- a/templates/clips/actions/add-comment.ts +++ b/templates/clips/actions/add-comment.ts @@ -4,14 +4,13 @@ import { getRequestUserEmail, getRequestUserName, } from "@agent-native/core/server/request-context"; -import { assertAccess, ForbiddenError } from "@agent-native/core/sharing"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { notifyRecordingComment } from "../server/lib/activity-notifications.js"; import { resolveCommentMentions } from "../server/lib/comment-mentions.js"; -import { isRecordingExpiredForViewer } from "../server/lib/recording-page-access.js"; +import { assertCommentWritingAccess } from "../server/lib/comment-writing-access.js"; import { nanoid } from "../server/lib/recordings.js"; const mentionSchema = z.object({ @@ -58,15 +57,7 @@ export default defineAction({ .describe("Organization members mentioned in the comment"), }), run: async (args) => { - const access = await assertAccess("recording", args.recordingId, "viewer"); - if ( - isRecordingExpiredForViewer({ - expiresAt: (access.resource as { expiresAt?: string }).expiresAt, - viewerIsOwner: access.role === "owner", - }) - ) { - throw new ForbiddenError("Recording has expired"); - } + await assertCommentWritingAccess(args.recordingId); const authorEmail = getRequestUserEmail(); if (!authorEmail) { diff --git a/templates/clips/actions/comment-writing-access.test.ts b/templates/clips/actions/comment-writing-access.test.ts new file mode 100644 index 00000000000..b0139f60daf --- /dev/null +++ b/templates/clips/actions/comment-writing-access.test.ts @@ -0,0 +1,223 @@ +import { ForbiddenError } from "@agent-native/core/sharing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + comment: { + id: "comment-1", + recordingId: "recording-1", + organizationId: "org-1", + threadId: "thread-1", + parentId: null, + authorEmail: "author@example.com", + content: "Original text", + videoTimestampMs: 12542, + mentionsJson: null, + emojiReactionsJson: "{}", + resolved: false, + }, + inserted: [] as Record[], +})); +const mockAssertAccess = vi.hoisted(() => vi.fn()); +const mockWriteAppState = vi.hoisted(() => vi.fn()); +const mockNotify = vi.hoisted(() => + vi.fn<(...args: unknown[]) => Promise>(async () => null), +); +const mockUpdate = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core/sharing", async (importOriginal) => ({ + ...(await importOriginal()), + assertAccess: (...args: unknown[]) => mockAssertAccess(...args), +})); +vi.mock("@agent-native/core/application-state", () => ({ + writeAppState: (...args: unknown[]) => mockWriteAppState(...args), +})); +vi.mock("@agent-native/core/server/request-context", () => ({ + getRequestUserEmail: () => "author@example.com", + getRequestUserName: () => "Author", +})); +vi.mock("../server/lib/activity-notifications.js", () => ({ + notifyRecordingComment: (...args: unknown[]) => mockNotify(...args), + notifyRecordingReaction: (...args: unknown[]) => mockNotify(...args), +})); +vi.mock("../server/lib/comment-mentions.js", () => ({ + resolveCommentMentions: async () => [], +})); +vi.mock("../server/lib/recordings.js", () => ({ + nanoid: () => "new-comment", + sameOwnerEmail: (left: string, right: string) => left === right, +})); +vi.mock("../server/db/index.js", () => { + const recordings = { + id: "recording.id", + organizationId: "recording.organizationId", + }; + const recordingComments = { + id: "comment.id", + recordingId: "comment.recordingId", + organizationId: "comment.organizationId", + threadId: "comment.threadId", + content: "comment.content", + emojiReactionsJson: "comment.emojiReactionsJson", + }; + return { + schema: { recordings, recordingComments }, + getDb: () => ({ + select: () => ({ + from: (table: unknown) => ({ + where: () => ({ + limit: async () => + table === recordings + ? [{ organizationId: "org-1" }] + : [{ ...state.comment }], + }), + }), + }), + insert: () => ({ + values: async (row: Record) => { + state.inserted.push(row); + }, + }), + update: () => ({ + set: (patch: Record) => ({ + where: () => { + mockUpdate(patch); + Object.assign(state.comment, patch); + return { returning: async () => [{ id: state.comment.id }] }; + }, + }), + }), + }), + }; +}); + +import addComment from "./add-comment"; +import reactToComment from "./react-to-comment"; +import replyToComment from "./reply-to-comment"; +import resolveComment from "./resolve-comment"; +import updateComment from "./update-comment"; + +const writers = [ + { + name: "new comment", + run: () => + addComment.run({ + recordingId: "recording-1", + content: "New text", + videoTimestampMs: 0, + }), + }, + { + name: "reply through add-comment", + run: () => + addComment.run({ + recordingId: "recording-1", + content: "Reply text", + videoTimestampMs: 12542, + parentId: "comment-1", + threadId: "thread-1", + }), + }, + { + name: "reply-to-comment", + run: () => + replyToComment.run({ commentId: "comment-1", content: "Reply text" }), + }, + { + name: "edit", + run: () => updateComment.run({ id: "comment-1", content: "Updated text" }), + }, + { + name: "reaction", + run: () => reactToComment.run({ commentId: "comment-1", emoji: "👍" }), + }, + { + name: "resolve", + run: () => resolveComment.run({ id: "comment-1", resolved: true }), + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + state.inserted = []; + Object.assign(state.comment, { + content: "Original text", + emojiReactionsJson: "{}", + resolved: false, + }); + mockAssertAccess.mockResolvedValue({ + role: "viewer", + resource: { enableComments: true }, + }); +}); + +describe.each(writers)("$name comment writing access", ({ run }) => { + it.each(["viewer", "owner"])( + "refuses disabled comments for a %s before side effects", + async (role) => { + mockAssertAccess.mockResolvedValue({ + role, + resource: { enableComments: false }, + }); + await expect(run()).rejects.toBeInstanceOf(ForbiddenError); + expect(state.inserted).toEqual([]); + expect(mockUpdate).not.toHaveBeenCalled(); + expect(mockNotify).not.toHaveBeenCalled(); + expect(mockWriteAppState).not.toHaveBeenCalled(); + }, + ); + + it("still allows writing when comments are enabled", async () => { + await expect(run()).resolves.toBeDefined(); + expect(mockWriteAppState).toHaveBeenCalled(); + expect(state.inserted.length + mockUpdate.mock.calls.length).toBe(1); + }); + + it("refuses an expired recording for a viewer", async () => { + mockAssertAccess.mockResolvedValue({ + role: "viewer", + resource: { enableComments: true, expiresAt: "2000-01-01T00:00:00.000Z" }, + }); + await expect(run()).rejects.toBeInstanceOf(ForbiddenError); + expect(state.inserted).toEqual([]); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("preserves the owner's expiry exemption when comments are enabled", async () => { + mockAssertAccess.mockResolvedValue({ + role: "owner", + resource: { enableComments: true, expiresAt: "2000-01-01T00:00:00.000Z" }, + }); + await expect(run()).resolves.toBeDefined(); + }); + + it("preserves recording access denial", async () => { + mockAssertAccess.mockRejectedValue(new ForbiddenError()); + await expect(run()).rejects.toBeInstanceOf(ForbiddenError); + expect(state.inserted).toEqual([]); + expect(mockUpdate).not.toHaveBeenCalled(); + }); +}); + +it("replies retain the parent thread, organization, frame, and return contract", async () => { + const result = await replyToComment.run({ + commentId: "comment-1", + content: "Reply text", + }); + expect(result).toMatchObject({ + id: "new-comment", + threadId: "thread-1", + parentId: "comment-1", + notified: null, + }); + expect(state.inserted).toEqual([ + expect.objectContaining({ + recordingId: "recording-1", + organizationId: "org-1", + threadId: "thread-1", + parentId: "comment-1", + content: "Reply text", + videoTimestampMs: 12542, + authorEmail: "author@example.com", + }), + ]); +}); diff --git a/templates/clips/actions/delete-comment.test.ts b/templates/clips/actions/delete-comment.test.ts index 20ada3b57a1..bc5c0289c0f 100644 --- a/templates/clips/actions/delete-comment.test.ts +++ b/templates/clips/actions/delete-comment.test.ts @@ -15,14 +15,9 @@ const mockGetUserEmail = vi.hoisted(() => vi.fn<() => string | null>(() => "author@example.com"), ); const mockWriteAppState = vi.hoisted(() => vi.fn()); -const { MockForbiddenError } = vi.hoisted(() => { - class MockForbiddenError extends Error {} - return { MockForbiddenError }; -}); - -vi.mock("@agent-native/core/sharing", () => ({ +vi.mock("@agent-native/core/sharing", async (importOriginal) => ({ + ...(await importOriginal()), assertAccess: (...args: unknown[]) => mockAssertAccess(...args), - ForbiddenError: MockForbiddenError, })); vi.mock("@agent-native/core/server/request-context", () => ({ @@ -37,7 +32,8 @@ vi.mock("../server/lib/recording-page-access.js", () => ({ isRecordingExpiredForViewer: () => false, })); -vi.mock("drizzle-orm", () => ({ +vi.mock("drizzle-orm", async (importOriginal) => ({ + ...(await importOriginal()), and: (...conditions: unknown[]) => ({ type: "and", conditions }), eq: (column: unknown, value: unknown) => ({ type: "eq", @@ -145,19 +141,30 @@ beforeEach(() => { }); describe("delete-comment", () => { - it("deletes a comment and every nested descendant", async () => { - const result = await action.run(action.schema.parse({ id: "comment-1" })); + it.each([true, false])( + "deletes a comment and descendants with enableComments=%s", + async (enableComments) => { + mockAssertAccess.mockResolvedValue({ + role: "viewer", + resource: { enableComments }, + }); + const result = await action.run(action.schema.parse({ id: "comment-1" })); - expect(result).toEqual({ - id: "comment-1", - deletedCommentIds: ["comment-1", "comment-1-reply", "comment-1-reply-2"], - }); - expect(state.rows.map((row) => row.id)).toEqual([ - "comment-2", - "comment-1-cross-record-reply", - ]); - expect(mockWriteAppState).toHaveBeenCalledWith("refresh-signal", { - ts: expect.any(Number), - }); - }); + expect(result).toEqual({ + id: "comment-1", + deletedCommentIds: [ + "comment-1", + "comment-1-reply", + "comment-1-reply-2", + ], + }); + expect(state.rows.map((row) => row.id)).toEqual([ + "comment-2", + "comment-1-cross-record-reply", + ]); + expect(mockWriteAppState).toHaveBeenCalledWith("refresh-signal", { + ts: expect.any(Number), + }); + }, + ); }); diff --git a/templates/clips/actions/get-recording-player-data.test.ts b/templates/clips/actions/get-recording-player-data.test.ts index 2fe22dd0aa0..d46ae971196 100644 --- a/templates/clips/actions/get-recording-player-data.test.ts +++ b/templates/clips/actions/get-recording-player-data.test.ts @@ -232,6 +232,26 @@ describe("get-recording-player-data direct public access", () => { mockShareLimit.mockResolvedValue([]); }); + it.each(["admin", "editor", "viewer"] as const)( + "withholds a trashed recording's direct player payload from %s callers", + async (role) => { + mockResolveAccess.mockResolvedValue({ + role, + resource: { + id: "rec-1", + visibility: "org", + password: null, + expiresAt: null, + trashedAt: "2026-10-22T12:00:00.000Z", + }, + }); + await expect(action.run({ recordingId: "rec-1" })).rejects.toThrow( + "No access to recording rec-1", + ); + expect(mockDb.select).not.toHaveBeenCalled(); + }, + ); + it.each(["admin", "editor", "viewer"] as const)( "requires an explicit recording share for %s callers", async (role) => { diff --git a/templates/clips/actions/get-recording-player-data.ts b/templates/clips/actions/get-recording-player-data.ts index 11fdebf7102..127681edb16 100644 --- a/templates/clips/actions/get-recording-player-data.ts +++ b/templates/clips/actions/get-recording-player-data.ts @@ -64,6 +64,7 @@ import { resolvePlayerVideoUrl } from "../server/lib/player-video-url.js"; import { canOpenDirectRecordingPage, isRecordingExpiredForViewer, + isRecordingTrashedForViewer, } from "../server/lib/recording-page-access.js"; import { hasExplicitRecordingShare } from "../server/lib/recording-share-grant.js"; import { @@ -161,6 +162,15 @@ export default defineAction({ const db = getDb(); const rec: any = access.resource; + if ( + isRecordingTrashedForViewer({ + trashedAt: rec.trashedAt, + viewerIsOwner: access.role === "owner", + }) + ) { + throw new ForbiddenError(`No access to recording ${args.recordingId}`); + } + if ( isRecordingExpiredForViewer({ expiresAt: rec.expiresAt, diff --git a/templates/clips/actions/react-to-comment.ts b/templates/clips/actions/react-to-comment.ts index 4647465904a..c03dec1d32c 100644 --- a/templates/clips/actions/react-to-comment.ts +++ b/templates/clips/actions/react-to-comment.ts @@ -1,12 +1,12 @@ import { defineAction } from "@agent-native/core/action"; import { writeAppState } from "@agent-native/core/application-state"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; -import { assertAccess } from "@agent-native/core/sharing"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { notifyRecordingReaction } from "../server/lib/activity-notifications.js"; +import { assertCommentWritingAccess } from "../server/lib/comment-writing-access.js"; const MAX_CAS_ATTEMPTS = 3; @@ -33,7 +33,7 @@ export default defineAction({ if (!existing) throw new Error(`Comment not found: ${args.commentId}`); if (attempt === 0) { - await assertAccess("recording", existing.recordingId, "viewer"); + await assertCommentWritingAccess(existing.recordingId); } const previousJson = existing.emojiReactionsJson; diff --git a/templates/clips/actions/reply-to-comment.ts b/templates/clips/actions/reply-to-comment.ts index 375b611fadb..6d685d6b142 100644 --- a/templates/clips/actions/reply-to-comment.ts +++ b/templates/clips/actions/reply-to-comment.ts @@ -1,18 +1,9 @@ import { defineAction } from "@agent-native/core/action"; -import { writeAppState } from "@agent-native/core/application-state"; -import { - getRequestUserEmail, - getRequestUserName, -} from "@agent-native/core/server/request-context"; -import { assertAccess, ForbiddenError } from "@agent-native/core/sharing"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -import { notifyRecordingComment } from "../server/lib/activity-notifications.js"; -import { resolveCommentMentions } from "../server/lib/comment-mentions.js"; -import { isRecordingExpiredForViewer } from "../server/lib/recording-page-access.js"; -import { nanoid } from "../server/lib/recordings.js"; +import addComment from "./add-comment.js"; const mentionSchema = z.object({ email: z.string().email(), @@ -43,66 +34,16 @@ export default defineAction({ .limit(1); if (!parent) throw new Error(`Comment not found: ${args.commentId}`); - const access = await assertAccess( - "recording", - parent.recordingId, - "viewer", - ); - if ( - isRecordingExpiredForViewer({ - expiresAt: (access.resource as { expiresAt?: string }).expiresAt, - viewerIsOwner: access.role === "owner", - }) - ) { - throw new ForbiddenError("Recording has expired"); - } - - const authorEmail = getRequestUserEmail(); - if (!authorEmail) { - throw new Error("Sign in required to reply to comments."); - } - const authorName = - getRequestUserName()?.trim() || args.authorName?.trim() || null; - const mentions = await resolveCommentMentions( - args.mentions, - parent.organizationId, - ); - - const id = nanoid(); - const now = new Date().toISOString(); - - await db.insert(schema.recordingComments).values({ - id, + const result = await addComment.run({ recordingId: parent.recordingId, - organizationId: parent.organizationId, threadId: parent.threadId, parentId: parent.id, - authorEmail, - authorName, - content: args.content, - mentionsJson: mentions.length > 0 ? JSON.stringify(mentions) : null, videoTimestampMs: parent.videoTimestampMs, - createdAt: now, - updatedAt: now, - }); - - const notified = await notifyRecordingComment({ - recordingId: parent.recordingId, - threadId: parent.threadId, - authorEmail, - authorName: authorName ?? undefined, content: args.content, - mentions, - videoTimestampMs: parent.videoTimestampMs, - isReply: true, + authorName: args.authorName, + mentions: args.mentions, }); - await writeAppState("refresh-signal", { ts: Date.now() }); - - console.log( - `Replied to comment ${args.commentId} (thread: ${parent.threadId})`, - ); - - return { id, threadId: parent.threadId, parentId: parent.id, notified }; + return { ...result, parentId: parent.id }; }, }); diff --git a/templates/clips/actions/resolve-comment.ts b/templates/clips/actions/resolve-comment.ts index bea8fb34ffc..2593c2ea0a6 100644 --- a/templates/clips/actions/resolve-comment.ts +++ b/templates/clips/actions/resolve-comment.ts @@ -6,7 +6,7 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -import { isRecordingExpiredForViewer } from "../server/lib/recording-page-access.js"; +import { assertCommentWritingAccess } from "../server/lib/comment-writing-access.js"; import { sameOwnerEmail } from "../server/lib/recordings.js"; const cliBoolean = z.preprocess((value) => { @@ -39,19 +39,7 @@ export default defineAction({ throw new Error("Sign in required to resolve comments."); } - const access = await assertAccess( - "recording", - existing.recordingId, - "viewer", - ); - if ( - isRecordingExpiredForViewer({ - expiresAt: (access.resource as { expiresAt?: string }).expiresAt, - viewerIsOwner: access.role === "owner", - }) - ) { - throw new ForbiddenError("Recording has expired"); - } + await assertCommentWritingAccess(existing.recordingId); if (!sameOwnerEmail(existing.authorEmail, userEmail)) { try { diff --git a/templates/clips/actions/update-comment.test.ts b/templates/clips/actions/update-comment.test.ts index d20a485a79a..30830a90a75 100644 --- a/templates/clips/actions/update-comment.test.ts +++ b/templates/clips/actions/update-comment.test.ts @@ -20,14 +20,9 @@ const mockGetUserEmail = vi.hoisted(() => ); const mockWriteAppState = vi.hoisted(() => vi.fn()); const mockIsOrgMember = vi.hoisted(() => vi.fn()); -const { MockForbiddenError } = vi.hoisted(() => { - class MockForbiddenError extends Error {} - return { MockForbiddenError }; -}); - -vi.mock("@agent-native/core/sharing", () => ({ +vi.mock("@agent-native/core/sharing", async (importOriginal) => ({ + ...(await importOriginal()), assertAccess: (...args: unknown[]) => mockAssertAccess(...args), - ForbiddenError: MockForbiddenError, })); vi.mock("@agent-native/core/server/request-context", () => ({ @@ -126,7 +121,10 @@ async function run(args: { id: string; content: string }) { beforeEach(() => { vi.resetAllMocks(); - mockAssertAccess.mockResolvedValue({ role: "viewer", resource: {} }); + mockAssertAccess.mockResolvedValue({ + role: "viewer", + resource: { enableComments: true }, + }); state.conflict = false; state.rows = [ { diff --git a/templates/clips/actions/update-comment.ts b/templates/clips/actions/update-comment.ts index 5f07bd95824..d4dc8ff4337 100644 --- a/templates/clips/actions/update-comment.ts +++ b/templates/clips/actions/update-comment.ts @@ -1,13 +1,13 @@ import { defineAction } from "@agent-native/core/action"; import { writeAppState } from "@agent-native/core/application-state"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; -import { assertAccess, ForbiddenError } from "@agent-native/core/sharing"; +import { ForbiddenError } from "@agent-native/core/sharing"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { resolveCommentMentions } from "../server/lib/comment-mentions.js"; -import { isRecordingExpiredForViewer } from "../server/lib/recording-page-access.js"; +import { assertCommentWritingAccess } from "../server/lib/comment-writing-access.js"; import { sameOwnerEmail } from "../server/lib/recordings.js"; import { displayCommentMentions, @@ -51,19 +51,7 @@ export default defineAction({ .limit(1); if (!existing) throw new Error(`Comment not found: ${args.id}`); - const access = await assertAccess( - "recording", - existing.recordingId, - "viewer", - ); - if ( - isRecordingExpiredForViewer({ - expiresAt: (access.resource as { expiresAt?: string }).expiresAt, - viewerIsOwner: access.role === "owner", - }) - ) { - throw new ForbiddenError("Recording has expired"); - } + await assertCommentWritingAccess(existing.recordingId); if (!sameOwnerEmail(existing.authorEmail, userEmail)) { throw new ForbiddenError( diff --git a/templates/clips/changelog/2026-10-22-recording-sharing-and-discussion-settings-apply-consistently.md b/templates/clips/changelog/2026-10-22-recording-sharing-and-discussion-settings-apply-consistently.md new file mode 100644 index 00000000000..1161229fefa --- /dev/null +++ b/templates/clips/changelog/2026-10-22-recording-sharing-and-discussion-settings-apply-consistently.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-10-22 +--- + +Recording sharing and discussion settings apply consistently. diff --git a/templates/clips/server/lib/comment-writing-access.ts b/templates/clips/server/lib/comment-writing-access.ts new file mode 100644 index 00000000000..4da99d299f7 --- /dev/null +++ b/templates/clips/server/lib/comment-writing-access.ts @@ -0,0 +1,25 @@ +import { assertAccess, ForbiddenError } from "@agent-native/core/sharing"; + +import { isRecordingExpiredForViewer } from "./recording-page-access.js"; + +export async function assertCommentWritingAccess( + recordingId: string, +): Promise { + const access = await assertAccess("recording", recordingId, "viewer"); + const recording = access.resource as { + enableComments: boolean; + expiresAt?: string | null; + }; + + if ( + isRecordingExpiredForViewer({ + expiresAt: recording.expiresAt, + viewerIsOwner: access.role === "owner", + }) + ) { + throw new ForbiddenError("Recording has expired"); + } + if (!recording.enableComments) { + throw new ForbiddenError(); + } +} diff --git a/templates/clips/server/lib/recording-page-access.ts b/templates/clips/server/lib/recording-page-access.ts index 6ad984c313b..485b3f5cbe4 100644 --- a/templates/clips/server/lib/recording-page-access.ts +++ b/templates/clips/server/lib/recording-page-access.ts @@ -23,6 +23,13 @@ export function isRecordingExpiredForViewer(input: { return !input.viewerIsOwner && isRecordingExpired(input.expiresAt, input.now); } +export function isRecordingTrashedForViewer(input: { + trashedAt: string | null | undefined; + viewerIsOwner: boolean; +}): boolean { + return Boolean(input.trashedAt) && !input.viewerIsOwner; +} + /** * Decide whether an authenticated request may use the editor/player route. * Public visibility is intentionally a share-link concern; a direct `/r/*` diff --git a/templates/clips/server/routes/api/public-recording.get.test.ts b/templates/clips/server/routes/api/public-recording.get.test.ts index 372224cd0a2..acde9ec0177 100644 --- a/templates/clips/server/routes/api/public-recording.get.test.ts +++ b/templates/clips/server/routes/api/public-recording.get.test.ts @@ -226,6 +226,59 @@ describe("/api/public-recording route", () => { mockHasExplicitRecordingShare.mockResolvedValue(false); }); + it.each([null, "viewer", "editor", "admin"])( + "withholds trashed share data from %s callers, including existing agent tokens", + async (role) => { + const event = { setCookies: [] as unknown[] }; + const recording = makeRecording({ + trashedAt: "2026-10-22T12:00:00.000Z", + }); + mockGetDb.mockReturnValue( + createDbWithSelectResults([[recording], [], [], [], []]), + ); + mockGetQuery.mockReturnValue({ + id: "rec-1", + agent_access: "existing-token", + }); + mockVerifyScopedAgentAccessToken.mockReturnValue({ ok: true }); + if (role) { + mockGetSession.mockResolvedValue({ + email: "viewer@example.com", + orgId: "org-1", + }); + mockResolveAccess.mockResolvedValue({ role, resource: recording }); + } + + expect(await handler(event as any)).toEqual({ error: "Not found" }); + expect(mockSetResponseStatus).toHaveBeenCalledWith(event, 404); + expect(mockSignShortLivedToken).not.toHaveBeenCalled(); + expect(mockSignScopedAgentAccessToken).not.toHaveBeenCalled(); + expect(mockResolvePlayerVideoUrl).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { role: "owner", trashedAt: "2026-10-22T12:00:00.000Z" }, + { role: "viewer", trashedAt: null }, + ])( + "keeps owner Trash previews and restored shares available: $role", + async ({ role, trashedAt }) => { + const recording = makeRecording({ trashedAt }); + mockGetDb.mockReturnValue( + createDbWithSelectResults([[recording], [], [], [], []]), + ); + mockGetSession.mockResolvedValue({ + email: `${role}@example.com`, + orgId: "org-1", + }); + mockResolveAccess.mockResolvedValue({ role, resource: recording }); + + expect(await handler({ setCookies: [] } as any)).toMatchObject({ + recording: { id: "rec-1" }, + }); + }, + ); + it("sets a protected media cookie and long fallback token after password unlock", async () => { const event = { setCookies: [] as unknown[] }; mockGetDb.mockReturnValue( diff --git a/templates/clips/server/routes/api/public-recording.get.ts b/templates/clips/server/routes/api/public-recording.get.ts index feb7858d189..270c3681c8f 100644 --- a/templates/clips/server/routes/api/public-recording.get.ts +++ b/templates/clips/server/routes/api/public-recording.get.ts @@ -46,6 +46,7 @@ import { resolvePlayerVideoUrl } from "../../lib/player-video-url.js"; import { canOpenDirectRecordingPage, isRecordingExpiredForViewer, + isRecordingTrashedForViewer, type RecordingPageAccessRole, } from "../../lib/recording-page-access.js"; import { hasExplicitRecordingShare } from "../../lib/recording-share-grant.js"; @@ -252,6 +253,14 @@ export default defineEventHandler(async (event) => { : null; const viewerIsOwner = viewerAccess?.role === "owner"; + if ( + isRecordingTrashedForViewer({ trashedAt: rec.trashedAt, viewerIsOwner }) + ) { + setResponseHeader(event, "Cache-Control", "private, max-age=0, no-store"); + setResponseStatus(event, 404); + return { error: "Not found" }; + } + let viewerIsOrgMember = false; if (session?.email && rec.visibility === "org" && rec.organizationId) { try { diff --git a/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts b/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts index 594d515b265..883c19ec301 100644 --- a/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts +++ b/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts @@ -202,6 +202,47 @@ describe("/api/thumbnail/:recordingId route", () => { mockSignShortLivedToken.mockReturnValue("renewed-token"); }); + it.each([null, "viewer", "editor", "admin"])( + "does not serve trashed thumbnails to %s callers", + async (role) => { + const recording = makeRow({ trashedAt: "2026-10-22T12:00:00.000Z" }); + mockGetDb.mockReturnValue(createDbWithRow(recording)); + if (role) + mockResolveAccess.mockResolvedValue({ role, resource: recording }); + vi.mocked(fetch).mockResolvedValue( + new Response("thumbnail", { + headers: { "content-type": "image/jpeg" }, + }), + ); + const event = makeEvent(); + + expect(await handler(event as any)).toEqual({ error: "Not found" }); + expect(event.status).toBe(404); + expect(fetch).not.toHaveBeenCalled(); + expect(mockFetchS3ObjectByUrl).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { role: "owner", trashedAt: "2026-10-22T12:00:00.000Z" }, + { role: "viewer", trashedAt: null }, + ])( + "serves owner Trash previews and restored thumbnails: $role", + async ({ role, trashedAt }) => { + const recording = makeRow({ trashedAt }); + mockGetDb.mockReturnValue(createDbWithSelectSequence([])); + mockResolveAccess.mockResolvedValue({ role, resource: recording }); + vi.mocked(fetch).mockResolvedValue( + new Response("thumbnail", { + headers: { "content-type": "image/jpeg" }, + }), + ); + const result = await handler(makeEvent() as any); + expect(result).toBeInstanceOf(Response); + expect(await (result as Response).text()).toBe("thumbnail"); + }, + ); + it("proxies a public provider thumbnail", async () => { mockGetDb.mockReturnValue(createDbWithRow(makeRow())); vi.mocked(fetch).mockResolvedValue( diff --git a/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts b/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts index 7e88a709e05..961c6c45da3 100644 --- a/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +++ b/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts @@ -34,7 +34,10 @@ import { isHeldForRedaction, REDACTION_HOLD_MESSAGE, } from "../../../lib/pending-redactions.js"; -import { isRecordingExpiredForViewer } from "../../../lib/recording-page-access.js"; +import { + isRecordingExpiredForViewer, + isRecordingTrashedForViewer, +} from "../../../lib/recording-page-access.js"; import { getOrganizationRoleForEmail } from "../../../lib/recordings.js"; import { fetchS3ObjectByUrl, @@ -403,11 +406,15 @@ async function loadRecording(recordingId: string, event: H3Event) { gate = row; } - if (!clipId) return { recording: gate, role, gateId } as const; - - if (gate.trashedAt && role !== "owner") { + if ( + isRecordingTrashedForViewer({ + trashedAt: gate.trashedAt, + viewerIsOwner: role === "owner", + }) + ) { return { error: "Not found", status: 404 } as const; } + if (!clipId) return { recording: gate, role, gateId } as const; const [footage] = await getDb() .select({ thumbnailUrl: schema.recordings.thumbnailUrl, diff --git a/templates/clips/server/routes/api/video/[recordingId].get.test.ts b/templates/clips/server/routes/api/video/[recordingId].get.test.ts index 68ee01acb11..72367206e17 100644 --- a/templates/clips/server/routes/api/video/[recordingId].get.test.ts +++ b/templates/clips/server/routes/api/video/[recordingId].get.test.ts @@ -205,6 +205,54 @@ describe("/api/video/:recordingId route", () => { mockGetDb.mockReturnValue(createDbWithSelectResult([])); }); + it.each(["viewer", "editor", "admin"])( + "does not serve a trashed recording to a %s with an existing media token", + async (role) => { + mockResolveAccess.mockResolvedValue({ + role, + resource: { + visibility: "public", + password: null, + trashedAt: "2026-10-22T12:00:00.000Z", + videoUrl: "https://cdn.example.com/clip.mp4", + }, + }); + mockVerifyShortLivedToken.mockReturnValue({ ok: true }); + vi.mocked(fetch).mockResolvedValue(new Response("video")); + const event = makeEvent(); + event.query = { t: "existing-token" }; + + expect(await handler(event as any)).toEqual({ error: "Not found" }); + expect(event.status).toBe(404); + expect(fetch).not.toHaveBeenCalled(); + expect(mockFetchS3ObjectByUrl).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { role: "owner", trashedAt: "2026-10-22T12:00:00.000Z" }, + { role: "viewer", trashedAt: null }, + ])( + "serves owner Trash previews and restored videos: $role", + async ({ role, trashedAt }) => { + mockResolveAccess.mockResolvedValue({ + role, + resource: { + visibility: "public", + password: null, + trashedAt, + videoUrl: "https://cdn.example.com/clip.mp4", + }, + }); + vi.mocked(fetch).mockResolvedValue( + new Response("video", { headers: { "content-type": "video/mp4" } }), + ); + const result = await handler(makeEvent() as any); + expect(result).toBeInstanceOf(Response); + expect(await (result as Response).text()).toBe("video"); + }, + ); + it("returns a controlled media fetch error when upstream fetch throws", async () => { vi.mocked(fetch).mockRejectedValue(new Error("fetch failed")); diff --git a/templates/clips/server/routes/api/video/[recordingId].get.ts b/templates/clips/server/routes/api/video/[recordingId].get.ts index 05dcf11f3a2..94718d344ea 100644 --- a/templates/clips/server/routes/api/video/[recordingId].get.ts +++ b/templates/clips/server/routes/api/video/[recordingId].get.ts @@ -78,7 +78,10 @@ import { isHeldForRedaction, REDACTION_HOLD_MESSAGE, } from "../../../lib/pending-redactions.js"; -import { isRecordingExpiredForViewer } from "../../../lib/recording-page-access.js"; +import { + isRecordingExpiredForViewer, + isRecordingTrashedForViewer, +} from "../../../lib/recording-page-access.js"; import { getOrganizationRoleForEmail } from "../../../lib/recordings.js"; import { fetchS3ObjectByUrl } from "../../../lib/s3-upload-provider.js"; import { @@ -377,9 +380,12 @@ export default defineEventHandler(async (event: H3Event) => { } const gate = recRow; - // The owner's context panel lists items for a trashed Clip, so the owner - // can still play them. - if (clipId && gate.trashedAt && role !== "owner") { + if ( + isRecordingTrashedForViewer({ + trashedAt: gate.trashedAt, + viewerIsOwner: role === "owner", + }) + ) { setResponseStatus(event, 404); return { error: "Not found" }; } From b78d1fd6e322cf8d6db1e3a8bd7fafcad14922a7 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:29:10 -0400 Subject: [PATCH 02/35] Apply recording policies to comment reads --- templates/clips/actions/add-comment.ts | 4 +- .../actions/comment-writing-access.test.ts | 10 ++ .../actions/get-recording-player-data.test.ts | 46 ++++++ .../actions/get-recording-player-data.ts | 18 ++- templates/clips/actions/list-comments.test.ts | 90 +++++++++++ templates/clips/actions/list-comments.ts | 4 +- .../clips/actions/list-notifications.test.ts | 144 ++++++++++++++++++ templates/clips/actions/list-notifications.ts | 25 ++- templates/clips/actions/react-to-comment.ts | 4 +- templates/clips/actions/resolve-comment.ts | 4 +- templates/clips/actions/update-comment.ts | 4 +- templates/clips/actions/view-screen.test.ts | 109 ++++++++++++- templates/clips/actions/view-screen.ts | 17 ++- ...nt-writing-access.ts => comment-access.ts} | 19 ++- 14 files changed, 464 insertions(+), 34 deletions(-) create mode 100644 templates/clips/actions/list-comments.test.ts create mode 100644 templates/clips/actions/list-notifications.test.ts rename templates/clips/server/lib/{comment-writing-access.ts => comment-access.ts} (57%) diff --git a/templates/clips/actions/add-comment.ts b/templates/clips/actions/add-comment.ts index cedd2775b2a..400ae3b3bdf 100644 --- a/templates/clips/actions/add-comment.ts +++ b/templates/clips/actions/add-comment.ts @@ -9,8 +9,8 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { notifyRecordingComment } from "../server/lib/activity-notifications.js"; +import { assertCommentAccess } from "../server/lib/comment-access.js"; import { resolveCommentMentions } from "../server/lib/comment-mentions.js"; -import { assertCommentWritingAccess } from "../server/lib/comment-writing-access.js"; import { nanoid } from "../server/lib/recordings.js"; const mentionSchema = z.object({ @@ -57,7 +57,7 @@ export default defineAction({ .describe("Organization members mentioned in the comment"), }), run: async (args) => { - await assertCommentWritingAccess(args.recordingId); + await assertCommentAccess(args.recordingId); const authorEmail = getRequestUserEmail(); if (!authorEmail) { diff --git a/templates/clips/actions/comment-writing-access.test.ts b/templates/clips/actions/comment-writing-access.test.ts index b0139f60daf..6c4bf51ca37 100644 --- a/templates/clips/actions/comment-writing-access.test.ts +++ b/templates/clips/actions/comment-writing-access.test.ts @@ -182,6 +182,16 @@ describe.each(writers)("$name comment writing access", ({ run }) => { expect(mockUpdate).not.toHaveBeenCalled(); }); + it("refuses a trashed recording for a viewer", async () => { + mockAssertAccess.mockResolvedValue({ + role: "viewer", + resource: { enableComments: true, trashedAt: "2026-10-22T12:00:00.000Z" }, + }); + await expect(run()).rejects.toBeInstanceOf(ForbiddenError); + expect(state.inserted).toEqual([]); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + it("preserves the owner's expiry exemption when comments are enabled", async () => { mockAssertAccess.mockResolvedValue({ role: "owner", diff --git a/templates/clips/actions/get-recording-player-data.test.ts b/templates/clips/actions/get-recording-player-data.test.ts index d46ae971196..d7d7556fe86 100644 --- a/templates/clips/actions/get-recording-player-data.test.ts +++ b/templates/clips/actions/get-recording-player-data.test.ts @@ -552,6 +552,51 @@ describe("get-recording-player-data view count", () => { expect(result.recording.folderId).toBe("folder-1"); }); + it.each(["owner", "viewer"])( + "omits disabled comments from the %s player payload", + async (role) => { + mockResolveAccess.mockResolvedValue({ + role, + resource: { + id: "rec-1", + ownerEmail: "owner@example.com", + visibility: "org", + enableComments: false, + password: null, + expiresAt: null, + status: "ready", + chaptersJson: "[]", + }, + }); + mockPlayerQuery.build = () => { + let isCommentsQuery = false; + const query: Record = {}; + query.from = (table: unknown) => { + isCommentsQuery = + (table as { videoTimestampMs?: string })?.videoTimestampMs === + "recordingComments.videoTimestampMs"; + return query; + }; + query.where = () => query; + query.limit = async () => []; + query.orderBy = async () => + isCommentsQuery + ? [ + { + id: "comment-1", + authorEmail: "author@example.com", + authorName: "Author", + content: "Saved comment", + }, + ] + : []; + return query; + }; + const result = await action.run({ recordingId: "rec-1" }); + expect(result.comments).toEqual([]); + }, + ); + it("includes a trashed recording's timestamp in the player payload", async () => { const trashedAt = "2026-09-22T12:00:00.000Z"; mockResolveAccess.mockResolvedValue({ @@ -703,6 +748,7 @@ describe("get-recording-player-data for agents reading a YouTube clip", () => { role: "owner", resource: { id: "rec-yt", + enableComments: true, ownerEmail: "owner@example.com", visibility: "private", password: null, diff --git a/templates/clips/actions/get-recording-player-data.ts b/templates/clips/actions/get-recording-player-data.ts index 127681edb16..69a9cf76030 100644 --- a/templates/clips/actions/get-recording-player-data.ts +++ b/templates/clips/actions/get-recording-player-data.ts @@ -251,14 +251,16 @@ export default defineAction({ ? normalizeBuilderCreditsStatus(builderCreditsRaw) : null; - const comments = await db - .select() - .from(schema.recordingComments) - .where(eq(schema.recordingComments.recordingId, args.recordingId)) - .orderBy( - asc(schema.recordingComments.videoTimestampMs), - asc(schema.recordingComments.createdAt), - ); + const comments = rec.enableComments + ? await db + .select() + .from(schema.recordingComments) + .where(eq(schema.recordingComments.recordingId, args.recordingId)) + .orderBy( + asc(schema.recordingComments.videoTimestampMs), + asc(schema.recordingComments.createdAt), + ) + : []; const hydratedComments = await hydrateCommentAuthorNames(comments); const reactions = await db diff --git a/templates/clips/actions/list-comments.test.ts b/templates/clips/actions/list-comments.test.ts new file mode 100644 index 00000000000..e350985a951 --- /dev/null +++ b/templates/clips/actions/list-comments.test.ts @@ -0,0 +1,90 @@ +import { ForbiddenError } from "@agent-native/core/sharing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockAssertAccess = vi.hoisted(() => vi.fn()); +const mockSelect = vi.hoisted(() => vi.fn()); +const comment = vi.hoisted(() => ({ + id: "comment-1", + recordingId: "recording-1", + authorEmail: "author@example.com", + authorName: "Author", + content: "A saved comment", + mentionsJson: null, +})); + +vi.mock("@agent-native/core/sharing", async (importOriginal) => ({ + ...(await importOriginal()), + assertAccess: (...args: unknown[]) => mockAssertAccess(...args), +})); +vi.mock("../server/db/index.js", () => ({ + getDb: () => ({ select: mockSelect }), + schema: { recordingComments: {} }, +})); +vi.mock("../server/lib/user-identities.js", () => ({ + hydrateCommentAuthorNames: async (rows: unknown[]) => rows, +})); + +import listComments from "./list-comments"; + +beforeEach(() => { + vi.clearAllMocks(); + mockAssertAccess.mockResolvedValue({ + role: "viewer", + resource: { enableComments: true, trashedAt: null, expiresAt: null }, + }); + mockSelect.mockReturnValue({ + from: () => ({ + where: () => ({ orderBy: async () => [comment] }), + }), + }); +}); + +describe("list-comments recording policy", () => { + it.each([ + { role: "viewer", resource: { enableComments: false } }, + { role: "owner", resource: { enableComments: false } }, + { + role: "viewer", + resource: { enableComments: true, trashedAt: "2026-10-22T12:00:00.000Z" }, + }, + { + role: "editor", + resource: { enableComments: true, trashedAt: "2026-10-22T12:00:00.000Z" }, + }, + { + role: "viewer", + resource: { enableComments: true, expiresAt: "2000-01-01T00:00:00.000Z" }, + }, + ])( + "refuses unavailable comments before querying: $role $resource", + async (access) => { + mockAssertAccess.mockResolvedValue(access); + await expect( + listComments.run({ recordingId: "recording-1" }), + ).rejects.toBeInstanceOf(ForbiddenError); + expect(mockSelect).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { + role: "owner", + resource: { enableComments: true, trashedAt: "2026-10-22T12:00:00.000Z" }, + }, + { + role: "owner", + resource: { enableComments: true, expiresAt: "2000-01-01T00:00:00.000Z" }, + }, + { role: "viewer", resource: { enableComments: true, trashedAt: null } }, + ])( + "keeps owner previews and restored comments available: $role $resource", + async (access) => { + mockAssertAccess.mockResolvedValue(access); + expect( + await listComments.run({ recordingId: "recording-1" }), + ).toMatchObject({ + comments: [{ id: "comment-1", content: "A saved comment" }], + }); + }, + ); +}); diff --git a/templates/clips/actions/list-comments.ts b/templates/clips/actions/list-comments.ts index 995feac95a7..7e6611ae69b 100644 --- a/templates/clips/actions/list-comments.ts +++ b/templates/clips/actions/list-comments.ts @@ -1,9 +1,9 @@ import { defineAction } from "@agent-native/core/action"; -import { assertAccess } from "@agent-native/core/sharing"; import { eq, asc } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { assertCommentAccess } from "../server/lib/comment-access.js"; import { hydrateCommentAuthorNames } from "../server/lib/user-identities.js"; import { displayCommentMentions } from "../shared/comment-mentions.js"; @@ -15,7 +15,7 @@ export default defineAction({ }), http: { method: "GET" }, run: async (args) => { - await assertAccess("recording", args.recordingId, "viewer"); + await assertCommentAccess(args.recordingId); const db = getDb(); const rows = await db diff --git a/templates/clips/actions/list-notifications.test.ts b/templates/clips/actions/list-notifications.test.ts new file mode 100644 index 00000000000..42851e6ce2b --- /dev/null +++ b/templates/clips/actions/list-notifications.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + email: "viewer@example.com", + recording: { + id: "rec-test", + title: "Fake recording", + ownerEmail: "owner@example.com", + password: null as string | null, + expiresAt: null as string | null, + trashedAt: null as string | null, + }, +})); +const tables = vi.hoisted(() => ({ + recordings: { + id: "id", + title: "title", + ownerEmail: "ownerEmail", + password: "password", + expiresAt: "expiresAt", + trashedAt: "trashedAt", + }, + recordingShares: {}, + recordingComments: { + recordingId: "recordingId", + createdAt: "createdAt", + mentionsJson: "mentionsJson", + }, + recordingReactions: { recordingId: "recordingId", createdAt: "createdAt" }, +})); + +vi.mock("@agent-native/core/action", () => ({ + defineAction: (options: unknown) => options, +})); +vi.mock("@agent-native/core/sharing", () => ({ accessFilter: () => true })); +vi.mock("@agent-native/core/user-profile/server", () => ({ + getUserProfiles: async () => new Map(), +})); +vi.mock("../server/lib/recordings.js", () => ({ + getCurrentOwnerEmail: () => state.email, + sameOwnerEmail: (a: string, b: string) => + a?.toLowerCase() === b?.toLowerCase(), +})); +vi.mock("../server/lib/user-identities.js", () => ({ + profileNameFor: () => "Fake author", +})); +vi.mock("drizzle-orm", () => ({ + and: (...conditions: unknown[]) => conditions, + or: (...conditions: unknown[]) => conditions, + desc: vi.fn(), + gte: vi.fn(), + sql: vi.fn(), + inArray: (column: string, values: string[]) => ({ column, values }), +})); +vi.mock("../server/db/index.js", () => ({ + schema: tables, + getDb: () => ({ + select: (projection?: Record) => { + let table: unknown; + const query = { + from: (value: unknown) => { + table = value; + return query; + }, + where: () => query, + orderBy: () => query, + limit: () => query, + then: (resolve: (rows: unknown[]) => unknown) => { + const rows = + table === tables.recordings + ? [ + Object.fromEntries( + Object.entries(projection!).map(([key, column]) => [ + key, + state.recording[column as keyof typeof state.recording], + ]), + ), + ] + : table === tables.recordingComments + ? [ + { + id: "comment-test", + recordingId: "rec-test", + content: "Fake discussion", + authorEmail: "author@example.com", + createdAt: new Date().toISOString(), + mentionsJson: JSON.stringify([ + { email: "viewer@example.com" }, + ]), + }, + ] + : []; + return Promise.resolve(rows).then(resolve); + }, + }; + return query; + }, + }), +})); + +import action from "./list-notifications"; + +describe("list-notifications recording lifecycle", () => { + beforeEach(() => { + state.email = "viewer@example.com"; + state.recording.trashedAt = null; + state.recording.expiresAt = null; + state.recording.password = null; + }); + + it("withholds a trashed recording's mention from a nonowner", async () => { + state.recording.trashedAt = "2026-01-01T00:00:00Z"; + expect(await action.run({ days: 30, limit: 200 })).toEqual({ + items: [], + count: 0, + }); + }); + + it.each([ + { email: "owner@example.com", trashedAt: "2026-01-01T00:00:00Z" }, + { email: "viewer@example.com", trashedAt: null }, + ])( + "preserves owner Trash and restored mentions: %j", + async ({ email, trashedAt }) => { + state.email = email; + state.recording.trashedAt = trashedAt; + const result = await action.run({ days: 30, limit: 200 }); + expect(result.items[0]?.preview).toBe("Fake discussion"); + expect(result.count).toBe(1); + }, + ); + + it.each(["viewer@example.com", "owner@example.com"])( + "preserves expiry filtering for %s", + async (email) => { + state.email = email; + state.recording.expiresAt = "2020-01-01T00:00:00Z"; + expect(await action.run({ days: 30, limit: 200 })).toEqual({ + items: [], + count: 0, + }); + }, + ); +}); diff --git a/templates/clips/actions/list-notifications.ts b/templates/clips/actions/list-notifications.ts index 5f79afbb69b..feb166544f6 100644 --- a/templates/clips/actions/list-notifications.ts +++ b/templates/clips/actions/list-notifications.ts @@ -5,7 +5,10 @@ import { and, desc, gte, inArray, or, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -import { canReceiveRecordingActivity } from "../server/lib/recording-page-access.js"; +import { + canReceiveRecordingActivity, + isRecordingTrashedForViewer, +} from "../server/lib/recording-page-access.js"; import { getCurrentOwnerEmail, sameOwnerEmail, @@ -32,16 +35,22 @@ export default defineAction({ ownerEmail: schema.recordings.ownerEmail, password: schema.recordings.password, expiresAt: schema.recordings.expiresAt, + trashedAt: schema.recordings.trashedAt, }) .from(schema.recordings) .where(accessFilter(schema.recordings, schema.recordingShares)); - const accessibleRecordings = visibleRecordings.filter((recording) => - canReceiveRecordingActivity({ - ownerEmail: recording.ownerEmail, - recipientEmail: me, - hasPassword: Boolean(recording.password), - expiresAt: recording.expiresAt, - }), + const accessibleRecordings = visibleRecordings.filter( + (recording) => + !isRecordingTrashedForViewer({ + trashedAt: recording.trashedAt, + viewerIsOwner: sameOwnerEmail(recording.ownerEmail, me), + }) && + canReceiveRecordingActivity({ + ownerEmail: recording.ownerEmail, + recipientEmail: me, + hasPassword: Boolean(recording.password), + expiresAt: recording.expiresAt, + }), ); if (accessibleRecordings.length === 0) { return { items: [], count: 0 }; diff --git a/templates/clips/actions/react-to-comment.ts b/templates/clips/actions/react-to-comment.ts index c03dec1d32c..f29f92c605f 100644 --- a/templates/clips/actions/react-to-comment.ts +++ b/templates/clips/actions/react-to-comment.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { notifyRecordingReaction } from "../server/lib/activity-notifications.js"; -import { assertCommentWritingAccess } from "../server/lib/comment-writing-access.js"; +import { assertCommentAccess } from "../server/lib/comment-access.js"; const MAX_CAS_ATTEMPTS = 3; @@ -33,7 +33,7 @@ export default defineAction({ if (!existing) throw new Error(`Comment not found: ${args.commentId}`); if (attempt === 0) { - await assertCommentWritingAccess(existing.recordingId); + await assertCommentAccess(existing.recordingId); } const previousJson = existing.emojiReactionsJson; diff --git a/templates/clips/actions/resolve-comment.ts b/templates/clips/actions/resolve-comment.ts index 2593c2ea0a6..1c2e4ad24d7 100644 --- a/templates/clips/actions/resolve-comment.ts +++ b/templates/clips/actions/resolve-comment.ts @@ -6,7 +6,7 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -import { assertCommentWritingAccess } from "../server/lib/comment-writing-access.js"; +import { assertCommentAccess } from "../server/lib/comment-access.js"; import { sameOwnerEmail } from "../server/lib/recordings.js"; const cliBoolean = z.preprocess((value) => { @@ -39,7 +39,7 @@ export default defineAction({ throw new Error("Sign in required to resolve comments."); } - await assertCommentWritingAccess(existing.recordingId); + await assertCommentAccess(existing.recordingId); if (!sameOwnerEmail(existing.authorEmail, userEmail)) { try { diff --git a/templates/clips/actions/update-comment.ts b/templates/clips/actions/update-comment.ts index d4dc8ff4337..4b1949ef0da 100644 --- a/templates/clips/actions/update-comment.ts +++ b/templates/clips/actions/update-comment.ts @@ -6,8 +6,8 @@ import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { assertCommentAccess } from "../server/lib/comment-access.js"; import { resolveCommentMentions } from "../server/lib/comment-mentions.js"; -import { assertCommentWritingAccess } from "../server/lib/comment-writing-access.js"; import { sameOwnerEmail } from "../server/lib/recordings.js"; import { displayCommentMentions, @@ -51,7 +51,7 @@ export default defineAction({ .limit(1); if (!existing) throw new Error(`Comment not found: ${args.id}`); - await assertCommentWritingAccess(existing.recordingId); + await assertCommentAccess(existing.recordingId); if (!sameOwnerEmail(existing.authorEmail, userEmail)) { throw new ForbiddenError( diff --git a/templates/clips/actions/view-screen.test.ts b/templates/clips/actions/view-screen.test.ts index bf242236ac4..a6deece9a98 100644 --- a/templates/clips/actions/view-screen.test.ts +++ b/templates/clips/actions/view-screen.test.ts @@ -1,5 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const context = vi.hoisted(() => ({ + navigation: { view: "library", recordingId: "rec-test" }, + recording: null as Record | null, + email: "viewer@example.com", + childReads: vi.fn(), +})); + const TABLES = vi.hoisted(() => ({ recordings: { id: "recordings.id", @@ -35,6 +42,29 @@ const mockMeetingsWhere = vi.hoisted(() => const makeRealDb = vi.hoisted(() => () => ({ select: (_projection?: unknown) => ({ from: (table: any) => { + if (context.recording) { + if (table === TABLES.recordings) { + return { where: async () => [context.recording] }; + } + context.childReads(table); + const rows = + table === "recordingComments" + ? [ + { + id: "comment-test", + content: "Fake discussion", + authorEmail: "author@example.com", + }, + ] + : []; + return { + where: () => + Object.assign(Promise.resolve(rows), { + orderBy: async () => rows, + limit: async () => rows, + }), + }; + } if (table && "recordingId" in table) return { where: mockMeetingsWhere }; if (table && "position" in table) { return { where: () => ({ orderBy: async () => [] }) }; @@ -104,11 +134,11 @@ vi.mock("@agent-native/core", () => ({ vi.mock("@agent-native/core/application-state", () => ({ readAppState: async () => null, readAppStateForCurrentTab: async (key: string) => - key === "navigation" ? { view: "library" } : null, + key === "navigation" ? context.navigation : null, })); vi.mock("@agent-native/core/server/request-context", () => ({ - getRequestUserEmail: () => "viewer@example.com", + getRequestUserEmail: () => context.email, })); vi.mock("@agent-native/core/sharing", () => ({ @@ -139,6 +169,9 @@ vi.mock("../server/db/index.js", () => ({ recordings: TABLES.recordings, recordingShares: "recordingShares", recordingViewers: "recordingViewers", + recordingComments: "recordingComments", + recordingTranscripts: { recordingId: "transcripts.recordingId" }, + recordingBrowserDiagnostics: { recordingId: "diagnostics.recordingId" }, }, })); @@ -154,6 +187,12 @@ vi.mock("../server/lib/recordings.js", () => ({ email, }), parseSpaceIds: vi.fn(), + sameOwnerEmail: (a: string, b: string) => + a?.toLowerCase() === b?.toLowerCase(), +})); + +vi.mock("../server/lib/user-identities.js", () => ({ + hydrateCommentAuthorNames: async (rows: unknown[]) => rows, })); vi.mock("../shared/browser-diagnostics.js", () => ({ @@ -169,6 +208,8 @@ import action from "./view-screen"; describe("view-screen library view on a cold start", () => { beforeEach(() => { vi.clearAllMocks(); + context.recording = null; + context.navigation.view = "library"; }); it("builds the meeting-exclusion subquery off a resolved db, not the lazy proxy", async () => { @@ -208,3 +249,67 @@ describe("view-screen library view on a cold start", () => { ); }); }); + +describe("view-screen recording access", () => { + beforeEach(() => { + vi.clearAllMocks(); + context.navigation.view = "recording"; + context.email = "viewer@example.com"; + context.recording = { + id: "rec-test", + title: "Fake recording", + ownerEmail: "owner@example.com", + status: "ready", + enableComments: true, + trashedAt: null, + expiresAt: null, + }; + }); + + it.each([ + { trashedAt: "2026-01-01T00:00:00Z" }, + { expiresAt: "2020-01-01T00:00:00Z" }, + ])("withholds recording context after access ends: %j", async (settings) => { + Object.assign(context.recording!, settings); + const screen = JSON.parse(await action.run({})); + expect(screen.recording).toBeUndefined(); + expect(screen.comments).toBeUndefined(); + expect(screen.transcript).toBeUndefined(); + expect(context.childReads).not.toHaveBeenCalled(); + }); + + it.each(["viewer@example.com", "owner@example.com"])( + "omits disabled comments for %s", + async (email) => { + context.email = email; + context.recording!.enableComments = false; + const screen = JSON.parse(await action.run({})); + expect(screen.recording.id).toBe("rec-test"); + expect(screen.comments).toEqual([]); + expect(context.childReads).not.toHaveBeenCalledWith("recordingComments"); + }, + ); + + it.each([ + { + email: "owner@example.com", + trashedAt: "2026-01-01T00:00:00Z", + expiresAt: null, + }, + { + email: "owner@example.com", + trashedAt: null, + expiresAt: "2020-01-01T00:00:00Z", + }, + { email: "viewer@example.com", trashedAt: null, expiresAt: null }, + ])( + "keeps owner previews and restored viewer context: %j", + async ({ email, ...settings }) => { + context.email = email; + Object.assign(context.recording!, settings); + const screen = JSON.parse(await action.run({})); + expect(screen.recording.id).toBe("rec-test"); + expect(screen.comments[0].content).toBe("Fake discussion"); + }, + ); +}); diff --git a/templates/clips/actions/view-screen.ts b/templates/clips/actions/view-screen.ts index 5323ae10718..ee64ef84101 100644 --- a/templates/clips/actions/view-screen.ts +++ b/templates/clips/actions/view-screen.ts @@ -22,10 +22,15 @@ import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; import { agentRecordingAccessFilter } from "../server/lib/agent-recording-access.js"; import { listingThumbnailUrl } from "../server/lib/player-thumbnail-url.js"; +import { + isRecordingExpiredForViewer, + isRecordingTrashedForViewer, +} from "../server/lib/recording-page-access.js"; import { getActiveOrganizationId, ownerEmailMatches, parseSpaceIds, + sameOwnerEmail, } from "../server/lib/recordings.js"; import { hydrateCommentAuthorNames } from "../server/lib/user-identities.js"; import { getAgentClipReadiness } from "../shared/agent-context.js"; @@ -97,7 +102,15 @@ async function fetchRecording(id: string) { ), ), ); - return row ? mapRecording(row) : null; + if (!row) return null; + const viewerIsOwner = sameOwnerEmail(row.ownerEmail, getRequestUserEmail()); + if ( + isRecordingTrashedForViewer({ trashedAt: row.trashedAt, viewerIsOwner }) || + isRecordingExpiredForViewer({ expiresAt: row.expiresAt, viewerIsOwner }) + ) { + return null; + } + return mapRecording(row); } async function fetchTranscript(recordingId: string) { @@ -501,7 +514,7 @@ async function addRecordingContext( const [transcript, comments, browserDiagnosticsSummary, bugReportSummary] = await Promise.all([ fetchTranscript(recordingId), - fetchComments(recordingId), + recording.enableComments ? fetchComments(recordingId) : [], fetchBrowserDiagnosticsSummary(recordingId), fetchBugReportSummary(recordingId), ]); diff --git a/templates/clips/server/lib/comment-writing-access.ts b/templates/clips/server/lib/comment-access.ts similarity index 57% rename from templates/clips/server/lib/comment-writing-access.ts rename to templates/clips/server/lib/comment-access.ts index 4da99d299f7..cbfc16f7283 100644 --- a/templates/clips/server/lib/comment-writing-access.ts +++ b/templates/clips/server/lib/comment-access.ts @@ -1,16 +1,27 @@ import { assertAccess, ForbiddenError } from "@agent-native/core/sharing"; -import { isRecordingExpiredForViewer } from "./recording-page-access.js"; +import { + isRecordingExpiredForViewer, + isRecordingTrashedForViewer, +} from "./recording-page-access.js"; -export async function assertCommentWritingAccess( - recordingId: string, -): Promise { +export async function assertCommentAccess(recordingId: string): Promise { const access = await assertAccess("recording", recordingId, "viewer"); const recording = access.resource as { enableComments: boolean; expiresAt?: string | null; + trashedAt?: string | null; }; + if ( + isRecordingTrashedForViewer({ + trashedAt: recording.trashedAt, + viewerIsOwner: access.role === "owner", + }) + ) { + throw new ForbiddenError(); + } + if ( isRecordingExpiredForViewer({ expiresAt: recording.expiresAt, From e845de2e1439191d8a667fe5fe495e0d49680e4c Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:49:39 -0400 Subject: [PATCH 03/35] Apply Trash access to comment cleanup --- .../clips/actions/delete-comment.test.ts | 44 ++++++++++++++++--- templates/clips/actions/delete-comment.ts | 13 +++++- .../clips/actions/list-notifications.test.ts | 13 ++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/templates/clips/actions/delete-comment.test.ts b/templates/clips/actions/delete-comment.test.ts index bc5c0289c0f..f4d2175c05c 100644 --- a/templates/clips/actions/delete-comment.test.ts +++ b/templates/clips/actions/delete-comment.test.ts @@ -1,3 +1,4 @@ +import { ForbiddenError } from "@agent-native/core/sharing"; import { beforeEach, describe, expect, it, vi } from "vitest"; type CommentRow = { @@ -28,10 +29,6 @@ vi.mock("@agent-native/core/application-state", () => ({ writeAppState: (...args: unknown[]) => mockWriteAppState(...args), })); -vi.mock("../server/lib/recording-page-access.js", () => ({ - isRecordingExpiredForViewer: () => false, -})); - vi.mock("drizzle-orm", async (importOriginal) => ({ ...(await importOriginal()), and: (...conditions: unknown[]) => ({ type: "and", conditions }), @@ -141,12 +138,49 @@ beforeEach(() => { }); describe("delete-comment", () => { + it.each([ + { role: "viewer", email: "author@example.com" }, + { role: "editor", email: "editor@example.com" }, + ])( + "refuses deletion from Trash for a nonowner: %j", + async ({ role, email }) => { + mockGetUserEmail.mockReturnValue(email); + mockAssertAccess.mockResolvedValue({ + role, + resource: { enableComments: true, trashedAt: "2026-01-01T00:00:00Z" }, + }); + const originalRows = [...state.rows]; + await expect( + action.run(action.schema.parse({ id: "comment-1" })), + ).rejects.toBeInstanceOf(ForbiddenError); + expect(state.rows).toEqual(originalRows); + expect(mockWriteAppState).not.toHaveBeenCalled(); + }, + ); + + it.each([true, false])( + "preserves owner cleanup in Trash with enableComments=%s", + async (enableComments) => { + mockGetUserEmail.mockReturnValue("owner@example.com"); + mockAssertAccess.mockResolvedValue({ + role: "owner", + resource: { enableComments, trashedAt: "2026-01-01T00:00:00Z" }, + }); + const result = await action.run(action.schema.parse({ id: "comment-1" })); + expect(result.deletedCommentIds).toEqual([ + "comment-1", + "comment-1-reply", + "comment-1-reply-2", + ]); + }, + ); + it.each([true, false])( "deletes a comment and descendants with enableComments=%s", async (enableComments) => { mockAssertAccess.mockResolvedValue({ role: "viewer", - resource: { enableComments }, + resource: { enableComments, trashedAt: null }, }); const result = await action.run(action.schema.parse({ id: "comment-1" })); diff --git a/templates/clips/actions/delete-comment.ts b/templates/clips/actions/delete-comment.ts index ed9e76db563..a28ff6b931b 100644 --- a/templates/clips/actions/delete-comment.ts +++ b/templates/clips/actions/delete-comment.ts @@ -6,7 +6,10 @@ import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -import { isRecordingExpiredForViewer } from "../server/lib/recording-page-access.js"; +import { + isRecordingExpiredForViewer, + isRecordingTrashedForViewer, +} from "../server/lib/recording-page-access.js"; export default defineAction({ description: @@ -31,6 +34,14 @@ export default defineAction({ existing.recordingId, "viewer", ); + if ( + isRecordingTrashedForViewer({ + trashedAt: (access.resource as { trashedAt?: string | null }).trashedAt, + viewerIsOwner: access.role === "owner", + }) + ) { + throw new ForbiddenError(); + } if ( isRecordingExpiredForViewer({ expiresAt: (access.resource as { expiresAt?: string }).expiresAt, diff --git a/templates/clips/actions/list-notifications.test.ts b/templates/clips/actions/list-notifications.test.ts index 42851e6ce2b..3fe55f3d29c 100644 --- a/templates/clips/actions/list-notifications.test.ts +++ b/templates/clips/actions/list-notifications.test.ts @@ -9,6 +9,7 @@ const state = vi.hoisted(() => ({ password: null as string | null, expiresAt: null as string | null, trashedAt: null as string | null, + enableComments: true, }, })); const tables = vi.hoisted(() => ({ @@ -106,6 +107,7 @@ describe("list-notifications recording lifecycle", () => { state.recording.trashedAt = null; state.recording.expiresAt = null; state.recording.password = null; + state.recording.enableComments = true; }); it("withholds a trashed recording's mention from a nonowner", async () => { @@ -116,6 +118,17 @@ describe("list-notifications recording lifecycle", () => { }); }); + it.each(["viewer@example.com", "owner@example.com"])( + "preserves historical activity with comments disabled for %s", + async (email) => { + state.email = email; + state.recording.enableComments = false; + const result = await action.run({ days: 30, limit: 200 }); + expect(result.items[0]?.preview).toBe("Fake discussion"); + expect(result.count).toBe(1); + }, + ); + it.each([ { email: "owner@example.com", trashedAt: "2026-01-01T00:00:00Z" }, { email: "viewer@example.com", trashedAt: null }, From 5d8350c0b8a757946f08517f857251ca298af7be Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:21:40 -0400 Subject: [PATCH 04/35] Apply comment visibility settings to recording history --- .../actions/get-recording-player-data.test.ts | 6 +- .../actions/get-recording-player-data.ts | 21 +-- templates/clips/actions/list-comments.test.ts | 2 +- templates/clips/actions/list-comments.ts | 2 +- .../clips/actions/list-notifications.test.ts | 16 ++- templates/clips/actions/list-notifications.ts | 40 ++++-- .../clips/actions/search-recordings.test.ts | 121 ++++++++++++++++-- templates/clips/actions/search-recordings.ts | 6 + templates/clips/actions/view-screen.test.ts | 12 +- templates/clips/actions/view-screen.ts | 5 +- .../components/player/comments-panel.test.tsx | 61 ++++++++- .../app/components/player/comments-panel.tsx | 4 +- .../app/routes/_app.r.$recordingId.test.ts | 6 +- .../clips/app/routes/_app.r.$recordingId.tsx | 35 +++-- ...hides-saved-comments-from-viewers-while.md | 6 + templates/clips/server/lib/comment-access.ts | 15 ++- .../routes/api/agent-context.json.get.test.ts | 45 +++++++ .../routes/api/agent-context.json.get.ts | 9 +- .../routes/api/public-recording.get.test.ts | 37 ++++++ .../server/routes/api/public-recording.get.ts | 21 +-- templates/clips/shared/comment-access.ts | 6 + 21 files changed, 397 insertions(+), 79 deletions(-) create mode 100644 templates/clips/changelog/2026-10-22-turning-comments-off-hides-saved-comments-from-viewers-while.md create mode 100644 templates/clips/shared/comment-access.ts diff --git a/templates/clips/actions/get-recording-player-data.test.ts b/templates/clips/actions/get-recording-player-data.test.ts index d7d7556fe86..327b3700d0d 100644 --- a/templates/clips/actions/get-recording-player-data.test.ts +++ b/templates/clips/actions/get-recording-player-data.test.ts @@ -553,7 +553,7 @@ describe("get-recording-player-data view count", () => { }); it.each(["owner", "viewer"])( - "omits disabled comments from the %s player payload", + "applies owner visibility to disabled comments in the %s player payload", async (role) => { mockResolveAccess.mockResolvedValue({ role, @@ -593,7 +593,9 @@ describe("get-recording-player-data view count", () => { return query; }; const result = await action.run({ recordingId: "rec-1" }); - expect(result.comments).toEqual([]); + expect(result.comments.map((comment) => comment.content)).toEqual( + role === "owner" ? ["Saved comment"] : [], + ); }, ); diff --git a/templates/clips/actions/get-recording-player-data.ts b/templates/clips/actions/get-recording-player-data.ts index 69a9cf76030..e948dc4f5ce 100644 --- a/templates/clips/actions/get-recording-player-data.ts +++ b/templates/clips/actions/get-recording-player-data.ts @@ -251,16 +251,17 @@ export default defineAction({ ? normalizeBuilderCreditsStatus(builderCreditsRaw) : null; - const comments = rec.enableComments - ? await db - .select() - .from(schema.recordingComments) - .where(eq(schema.recordingComments.recordingId, args.recordingId)) - .orderBy( - asc(schema.recordingComments.videoTimestampMs), - asc(schema.recordingComments.createdAt), - ) - : []; + const comments = + rec.enableComments || access.role === "owner" + ? await db + .select() + .from(schema.recordingComments) + .where(eq(schema.recordingComments.recordingId, args.recordingId)) + .orderBy( + asc(schema.recordingComments.videoTimestampMs), + asc(schema.recordingComments.createdAt), + ) + : []; const hydratedComments = await hydrateCommentAuthorNames(comments); const reactions = await db diff --git a/templates/clips/actions/list-comments.test.ts b/templates/clips/actions/list-comments.test.ts index e350985a951..db3590d4dab 100644 --- a/templates/clips/actions/list-comments.test.ts +++ b/templates/clips/actions/list-comments.test.ts @@ -42,7 +42,6 @@ beforeEach(() => { describe("list-comments recording policy", () => { it.each([ { role: "viewer", resource: { enableComments: false } }, - { role: "owner", resource: { enableComments: false } }, { role: "viewer", resource: { enableComments: true, trashedAt: "2026-10-22T12:00:00.000Z" }, @@ -67,6 +66,7 @@ describe("list-comments recording policy", () => { ); it.each([ + { role: "owner", resource: { enableComments: false } }, { role: "owner", resource: { enableComments: true, trashedAt: "2026-10-22T12:00:00.000Z" }, diff --git a/templates/clips/actions/list-comments.ts b/templates/clips/actions/list-comments.ts index 7e6611ae69b..c822ec5c762 100644 --- a/templates/clips/actions/list-comments.ts +++ b/templates/clips/actions/list-comments.ts @@ -15,7 +15,7 @@ export default defineAction({ }), http: { method: "GET" }, run: async (args) => { - await assertCommentAccess(args.recordingId); + await assertCommentAccess(args.recordingId, "read"); const db = getDb(); const rows = await db diff --git a/templates/clips/actions/list-notifications.test.ts b/templates/clips/actions/list-notifications.test.ts index 3fe55f3d29c..38cbd8cc3c1 100644 --- a/templates/clips/actions/list-notifications.test.ts +++ b/templates/clips/actions/list-notifications.test.ts @@ -20,6 +20,7 @@ const tables = vi.hoisted(() => ({ password: "password", expiresAt: "expiresAt", trashedAt: "trashedAt", + enableComments: "enableComments", }, recordingShares: {}, recordingComments: { @@ -118,14 +119,19 @@ describe("list-notifications recording lifecycle", () => { }); }); - it.each(["viewer@example.com", "owner@example.com"])( - "preserves historical activity with comments disabled for %s", - async (email) => { + it.each([ + { email: "viewer@example.com", expectedCount: 0 }, + { email: "owner@example.com", expectedCount: 1 }, + ])( + "applies comments-off visibility to notification history: %j", + async ({ email, expectedCount }) => { state.email = email; state.recording.enableComments = false; const result = await action.run({ days: 30, limit: 200 }); - expect(result.items[0]?.preview).toBe("Fake discussion"); - expect(result.count).toBe(1); + expect(result.count).toBe(expectedCount); + expect(result.items.map((item) => item.preview)).toEqual( + expectedCount ? ["Fake discussion"] : [], + ); }, ); diff --git a/templates/clips/actions/list-notifications.ts b/templates/clips/actions/list-notifications.ts index feb166544f6..8a8fa967937 100644 --- a/templates/clips/actions/list-notifications.ts +++ b/templates/clips/actions/list-notifications.ts @@ -14,6 +14,7 @@ import { sameOwnerEmail, } from "../server/lib/recordings.js"; import { profileNameFor } from "../server/lib/user-identities.js"; +import { canReadRecordingComments } from "../shared/comment-access.js"; import { parseCommentMentions } from "../shared/comment-mentions.js"; export default defineAction({ @@ -36,6 +37,7 @@ export default defineAction({ password: schema.recordings.password, expiresAt: schema.recordings.expiresAt, trashedAt: schema.recordings.trashedAt, + enableComments: schema.recordings.enableComments, }) .from(schema.recordings) .where(accessFilter(schema.recordings, schema.recordingShares)); @@ -60,7 +62,14 @@ export default defineAction({ .filter((recording) => sameOwnerEmail(recording.ownerEmail, me)) .map((recording) => recording.id); const myRecordingIdSet = new Set(myRecordingIds); - const ids = accessibleRecordings.map((recording) => recording.id); + const commentRecordingIds = accessibleRecordings + .filter((recording) => + canReadRecordingComments({ + enableComments: recording.enableComments, + viewerIsOwner: sameOwnerEmail(recording.ownerEmail, me), + }), + ) + .map((recording) => recording.id); const titleById = new Map( accessibleRecordings.map( (recording) => [recording.id, recording.title] as const, @@ -80,18 +89,23 @@ export default defineAction({ : sql`lower(${schema.recordingComments.mentionsJson}) LIKE ${mentionPattern}`; const [comments, reactions] = await Promise.all([ - db - .select() - .from(schema.recordingComments) - .where( - and( - inArray(schema.recordingComments.recordingId, ids), - gte(schema.recordingComments.createdAt, cutoff), - commentScope, - ), - ) - .orderBy(desc(schema.recordingComments.createdAt)) - .limit(args.limit), + commentRecordingIds.length + ? db + .select() + .from(schema.recordingComments) + .where( + and( + inArray( + schema.recordingComments.recordingId, + commentRecordingIds, + ), + gte(schema.recordingComments.createdAt, cutoff), + commentScope, + ), + ) + .orderBy(desc(schema.recordingComments.createdAt)) + .limit(args.limit) + : Promise.resolve([]), myRecordingIds.length ? db .select() diff --git a/templates/clips/actions/search-recordings.test.ts b/templates/clips/actions/search-recordings.test.ts index c2a1c30584c..39e736c3bd2 100644 --- a/templates/clips/actions/search-recordings.test.ts +++ b/templates/clips/actions/search-recordings.test.ts @@ -2,10 +2,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const whereConditions = vi.hoisted(() => [] as unknown[]); const selectedFields = vi.hoisted(() => [] as Record[]); +const searchRows = vi.hoisted(() => ({ + comments: [] as Record[], +})); const tables = vi.hoisted(() => ({ recordings: { + id: "recordings.id", + title: "recordings.title", + description: "recordings.description", trashedAt: "recordings.trashedAt", ownerEmail: "recordings.ownerEmail", + enableComments: "recordings.enableComments", }, recordingShares: {}, recordingViewers: {}, @@ -22,17 +29,70 @@ const tables = vi.hoisted(() => ({ })); const makeQuery = vi.hoisted(() => { - const create = (): any => ({ - from: () => create(), - innerJoin: () => create(), - where: (condition: unknown) => { - whereConditions.push(condition); - return create(); - }, - limit: () => create(), - then: (resolve: (value: unknown[]) => unknown) => - Promise.resolve([]).then(resolve), - }); + const matches = (row: Record, condition: any): boolean => { + if (condition.kind === "and") + return condition.conditions.every((part: unknown) => matches(row, part)); + if (condition.kind === "or") + return condition.conditions.some((part: unknown) => matches(row, part)); + if (condition.kind === "eq") + return row[condition.column] === condition.value; + if (condition.kind === "is-null") return row[condition.column] == null; + if (condition.kind === "owner-email") + return ( + String(row[condition.column]).toLowerCase() === + condition.email.toLowerCase() + ); + if (condition.kind === "access") return true; + if (condition.kind === "sql") { + const column = condition.values[0]; + const pattern = condition.values[1]; + return ( + typeof row[column] === "string" && + String(row[column]) + .toLowerCase() + .includes(String(pattern).replace(/^%|%$/g, "")) + ); + } + throw new Error(`Unsupported query condition: ${condition.kind}`); + }; + const create = (fields: Record): any => { + let table: unknown; + let filter: any; + let limit = Infinity; + const query = { + from: (value: unknown) => { + table = value; + return query; + }, + innerJoin: () => query, + where: (condition: unknown) => { + whereConditions.push(condition); + filter = condition; + return query; + }, + limit: (value: number) => { + limit = value; + return query; + }, + then: (resolve: (value: unknown[]) => unknown) => + Promise.resolve( + table === tables.recordingComments + ? searchRows.comments + .filter((row) => matches(row, filter)) + .slice(0, limit) + .map((row) => + Object.fromEntries( + Object.entries(fields).map(([key, column]) => [ + key, + row[String(column)], + ]), + ), + ) + : [], + ).then(resolve), + }; + return query; + }; return create; }); @@ -50,9 +110,10 @@ vi.mock("drizzle-orm", () => ({ eq: (column: unknown, value: unknown) => ({ kind: "eq", column, value }), isNull: (column: unknown) => ({ kind: "is-null", column }), or: (...conditions: unknown[]) => ({ kind: "or", conditions }), - sql: (strings: TemplateStringsArray) => ({ + sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ kind: "sql", text: strings.join("?"), + values, }), })); @@ -60,7 +121,7 @@ vi.mock("../server/db/index.js", () => ({ getDb: () => ({ select: (fields: Record) => { selectedFields.push(fields); - return makeQuery(); + return makeQuery(fields); }, }), schema: tables, @@ -89,6 +150,7 @@ describe("search-recordings", () => { beforeEach(() => { whereConditions.length = 0; selectedFields.length = 0; + searchRows.comments = []; }); it("returns Trash status and scopes trashed matches to their owner", async () => { @@ -132,4 +194,37 @@ describe("search-recordings", () => { ), ).toBe(true); }); + + it.each([ + { email: "viewer@example.com", enableComments: false, visible: false }, + { email: "owner@example.com", enableComments: false, visible: true }, + { email: "viewer@example.com", enableComments: true, visible: true }, + ])( + "applies comment visibility before returning search snippets: %j", + async ({ email, enableComments, visible }) => { + searchRows.comments = [ + { + "recordings.id": "rec-test", + "recordings.title": "Fake recording", + "recordings.description": "Fake description", + "recordings.ownerEmail": "owner@example.com", + "recordings.trashedAt": null, + "recordings.enableComments": enableComments, + "recordingComments.recordingId": "rec-test", + "recordingComments.content": "A fake needle discussion", + "recordingComments.videoTimestampMs": 12000, + }, + ]; + const result = await action.run({ query: "needle", limit: 30 }, { + userEmail: email, + } as never); + expect( + result.results.map((row) => ({ id: row.id, snippet: row.snippet })), + ).toEqual( + visible + ? [{ id: "rec-test", snippet: "A fake needle discussion" }] + : [], + ); + }, + ); }); diff --git a/templates/clips/actions/search-recordings.ts b/templates/clips/actions/search-recordings.ts index d8d1cf89a71..fe1a344ac73 100644 --- a/templates/clips/actions/search-recordings.ts +++ b/templates/clips/actions/search-recordings.ts @@ -228,6 +228,12 @@ export default defineAction({ recordingAccess, ), lifecycleFilter, + userEmail + ? or( + eq(schema.recordings.enableComments, true), + ownerEmailMatches(schema.recordings.ownerEmail, userEmail), + ) + : eq(schema.recordings.enableComments, true), sql`lower(${schema.recordingComments.content}) LIKE ${pattern} ESCAPE '\\'`, ), ) diff --git a/templates/clips/actions/view-screen.test.ts b/templates/clips/actions/view-screen.test.ts index a6deece9a98..80dc99a51ca 100644 --- a/templates/clips/actions/view-screen.test.ts +++ b/templates/clips/actions/view-screen.test.ts @@ -279,14 +279,20 @@ describe("view-screen recording access", () => { }); it.each(["viewer@example.com", "owner@example.com"])( - "omits disabled comments for %s", + "applies owner visibility to disabled comments for %s", async (email) => { context.email = email; context.recording!.enableComments = false; const screen = JSON.parse(await action.run({})); expect(screen.recording.id).toBe("rec-test"); - expect(screen.comments).toEqual([]); - expect(context.childReads).not.toHaveBeenCalledWith("recordingComments"); + expect( + screen.comments.map((comment: { content: string }) => comment.content), + ).toEqual(email === "owner@example.com" ? ["Fake discussion"] : []); + if (email !== "owner@example.com") { + expect(context.childReads).not.toHaveBeenCalledWith( + "recordingComments", + ); + } }, ); diff --git a/templates/clips/actions/view-screen.ts b/templates/clips/actions/view-screen.ts index ee64ef84101..4a5c21fa2d6 100644 --- a/templates/clips/actions/view-screen.ts +++ b/templates/clips/actions/view-screen.ts @@ -514,7 +514,10 @@ async function addRecordingContext( const [transcript, comments, browserDiagnosticsSummary, bugReportSummary] = await Promise.all([ fetchTranscript(recordingId), - recording.enableComments ? fetchComments(recordingId) : [], + recording.enableComments || + sameOwnerEmail(recording.ownerEmail, getRequestUserEmail()) + ? fetchComments(recordingId) + : [], fetchBrowserDiagnosticsSummary(recordingId), fetchBugReportSummary(recordingId), ]); diff --git a/templates/clips/app/components/player/comments-panel.test.tsx b/templates/clips/app/components/player/comments-panel.test.tsx index 09f88c0cf56..7e15a60d8a5 100644 --- a/templates/clips/app/components/player/comments-panel.test.tsx +++ b/templates/clips/app/components/player/comments-panel.test.tsx @@ -195,6 +195,7 @@ describe("CommentsPanel reply composer", () => { currentUserEmail = "viewer@example.com", comments: Comment[] = [rootComment], presentation: "default" | "share" | "inline" = "share", + enableComments = true, ) { act(() => { root.render( @@ -204,8 +205,8 @@ describe("CommentsPanel reply composer", () => { comments={comments} currentMs={34_000} currentUserEmail={currentUserEmail} - enableComments - canComment + enableComments={enableComments} + canComment={enableComments} onSeek={vi.fn()} queryKey={["recording", "recording-1"]} presentation={presentation} @@ -1305,6 +1306,62 @@ describe("CommentsPanel reply composer", () => { expect(container.textContent).toContain("commentsPanel.beFirst"); }); + it.each(["reply", "edit"])( + "closes an open %s composer when comments are disabled", + async (operation) => { + renderPanel("author@example.com"); + if (operation === "reply") { + const reply = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "commentsPanel.reply", + ); + expect(reply).toBeDefined(); + act(() => reply!.click()); + } else { + await openMenu( + container.querySelector('[aria-haspopup="menu"]'), + ); + const edit = Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find( + (item) => item.textContent?.trim() === "commentsPanel.editComment", + ); + expect(edit).toBeDefined(); + await act(async () => { + edit!.click(); + await Promise.resolve(); + }); + } + expect(container.querySelector("textarea")).not.toBeNull(); + renderPanel("author@example.com", [rootComment], "share", false); + expect(container.querySelector("textarea")).toBeNull(); + expect(container.textContent).toContain( + rootComment.content.split(" https:")[0], + ); + }, + ); + + it("shows saved owner comments read-only when comments are disabled", () => { + act(() => { + root.render( + + + , + ); + }); + expect(container.textContent).toContain("Fake saved comment"); + expect(container.querySelector('[contenteditable="true"]')).toBeNull(); + expect(container.textContent).not.toContain("commentsPanel.reply"); + }); + it("renders the shared empty state icon when comments are disabled", () => { act(() => { root.render( diff --git a/templates/clips/app/components/player/comments-panel.tsx b/templates/clips/app/components/player/comments-panel.tsx index 92a752a00f5..77a77362479 100644 --- a/templates/clips/app/components/player/comments-panel.tsx +++ b/templates/clips/app/components/player/comments-panel.tsx @@ -768,7 +768,7 @@ export function CommentsPanel(props: CommentsPanelProps) { onSeek={onSeek} onReply={() => openReply(node.comment)} onDelete={(id) => remove.mutate({ id })} - isEditing={editingId === node.comment.id} + isEditing={canComment && editingId === node.comment.id} editDraft={editDraft} onEditDraftChange={setEditDraft} onEditMentionAdd={(mention) => @@ -786,7 +786,7 @@ export function CommentsPanel(props: CommentsPanelProps) { canReply={replyAllowed} isReply={depth > 0} /> - {replyAllowed && replyTo?.id === node.comment.id ? ( + {canComment && replyAllowed && replyTo?.id === node.comment.id ? (
{ 'setPanel(recording?.enableComments ? "comments" : "transcript")', ); expect(normalizedEffect).toContain( - 'setPanel( recording && !recording.enableComments ? defaultPanel : "comments", )', + 'setPanel(recording && !canReadComments ? defaultPanel : "comments")', ); // `defaultPanel` is where the panel lands when the requested tab does not @@ -425,7 +425,7 @@ describe("direct recording route shell cue", () => { // made the guard bounce them between two tabs it rejects. const route2 = readRoute("_app.r.$recordingId.tsx").replace(/\s+/g, " "); expect(route2).toContain( - 'const defaultPanel: SidePanel = !isImage ? "transcript" : recording && !recording.enableComments && canEdit ? "settings" : "comments";', + 'const defaultPanel: SidePanel = !isImage ? "transcript" : recording && !canReadComments && canEdit ? "settings" : "comments";', ); expect(route2).not.toContain('!recording?.enableComments ? "settings"'); }); @@ -454,7 +454,7 @@ describe("direct recording route shell cue", () => { expect(route).toContain( "recordingId === VIEWER_REDESIGN_PREVIEW_ID\n ? VIEWER_PREVIEW_COMMENTS", ); - expect(route).toContain( + expect(route.replace(/\s+/g, " ")).toContain( "role != null && recordingId !== VIEWER_REDESIGN_PREVIEW_ID", ); expect(route).not.toContain("persistedPreviewReplies"); diff --git a/templates/clips/app/routes/_app.r.$recordingId.tsx b/templates/clips/app/routes/_app.r.$recordingId.tsx index cbe1e7c1503..58f68c3f341 100644 --- a/templates/clips/app/routes/_app.r.$recordingId.tsx +++ b/templates/clips/app/routes/_app.r.$recordingId.tsx @@ -35,6 +35,7 @@ import { BUILDER_CREDITS_UPGRADE_URL, type BuilderCreditsStatus, } from "@shared/builder-credits"; +import { canReadRecordingComments } from "@shared/comment-access"; import { recordingEmbedProvider } from "@shared/embed-provider"; import { isStoredButUnservableFinalizeError } from "@shared/finalize-recovery"; import { CLIPS_MEETINGS, CLIPS_VIDEO_EDITING } from "@shared/labs"; @@ -1198,7 +1199,16 @@ export default function RecordingPage() { browserDiagnostics?.summary ?? null, panel === "debug", ); - const canComment = role != null && recordingId !== VIEWER_REDESIGN_PREVIEW_ID; + const canReadComments = + Boolean(recording) && + canReadRecordingComments({ + enableComments: recording?.enableComments ?? false, + viewerIsOwner: role === "owner", + }); + const canComment = + Boolean(recording?.enableComments) && + role != null && + recordingId !== VIEWER_REDESIGN_PREVIEW_ID; // Where the panel lands when the requested tab does not apply. Transcript is // the video default; a screenshot has no transcript tab at all, so it falls // back to the conversation, or to settings when comments are off. A viewer @@ -1206,19 +1216,27 @@ export default function RecordingPage() { // comments rather than bounce between two tabs the guard below rejects. const defaultPanel: SidePanel = !isImage ? "transcript" - : recording && !recording.enableComments && canEdit + : recording && !canReadComments && canEdit ? "settings" : "comments"; useEffect(() => { if ( (!canEdit && panel === "settings") || (!browserDiagnostics && panel === "debug") || - (recording && !recording.enableComments && panel === "comments") || + (recording && !canReadComments && panel === "comments") || (isImage && panel === "transcript") ) { setPanel(defaultPanel); } - }, [browserDiagnostics, canEdit, defaultPanel, isImage, panel, recording]); + }, [ + browserDiagnostics, + canEdit, + canReadComments, + defaultPanel, + isImage, + panel, + recording, + ]); useEffect(() => { const legacyAgentSidebar = searchParams.get(AGENT_SIDEBAR_QUERY_PARAM); @@ -1239,9 +1257,7 @@ export default function RecordingPage() { return; } if (panelParam === "comments") { - setPanel( - recording && !recording.enableComments ? defaultPanel : "comments", - ); + setPanel(recording && !canReadComments ? defaultPanel : "comments"); if (isCompactLayout) { requestAnimationFrame(() => { commentsSectionRef.current?.scrollIntoView({ block: "start" }); @@ -1265,6 +1281,7 @@ export default function RecordingPage() { isCompactLayout, panelParam, recording?.enableComments, + canReadComments, searchParams, setSearchParams, setSidePanelCollapsed, @@ -2495,7 +2512,7 @@ export default function RecordingPage() { sidePanelCollapsed && "lg:hidden", )} > - {recording.enableComments ? ( + {canReadComments ? ( { return ( <> - {recording.enableComments ? ( + {canReadComments ? ( { +export async function assertCommentAccess( + recordingId: string, + operation: "read" | "write" = "write", +): Promise { const access = await assertAccess("recording", recordingId, "viewer"); const recording = access.resource as { enableComments: boolean; @@ -30,7 +34,14 @@ export async function assertCommentAccess(recordingId: string): Promise { ) { throw new ForbiddenError("Recording has expired"); } - if (!recording.enableComments) { + if ( + operation === "read" + ? !canReadRecordingComments({ + enableComments: recording.enableComments, + viewerIsOwner: access.role === "owner", + }) + : !recording.enableComments + ) { throw new ForbiddenError(); } } diff --git a/templates/clips/server/routes/api/agent-context.json.get.test.ts b/templates/clips/server/routes/api/agent-context.json.get.test.ts index 95fdd974cd6..9303f416814 100644 --- a/templates/clips/server/routes/api/agent-context.json.get.test.ts +++ b/templates/clips/server/routes/api/agent-context.json.get.test.ts @@ -120,6 +120,51 @@ describe("/api/agent-context route", () => { }); }); + it.each([true, false])( + "applies comments-off visibility to agent history with viewerIsOwner=%s", + async (viewerIsOwner) => { + mockLoadPublicAgentAccess.mockResolvedValue({ + ok: true, + access: { + recording: { + id: "rec-1", + durationMs: 1234, + status: "ready", + enableComments: false, + enableReactions: false, + }, + viewerIsOwner, + apiToken: null, + }, + }); + const savedComment = { + id: "comment-test", + content: "Fake saved comment", + }; + mockGetDb.mockReturnValue({ + select: (fields: { count?: unknown }) => { + const rows = "count" in fields ? [{ count: 1 }] : [savedComment]; + const query: any = { + from: () => query, + where: () => query, + orderBy: () => query, + limit: async () => rows, + then: (resolve: (rows: unknown[]) => unknown) => + Promise.resolve(rows).then(resolve), + }; + return query; + }, + }); + await handler({} as any); + expect(mockBuildPublicAgentContext).toHaveBeenCalledWith( + expect.objectContaining({ + comments: viewerIsOwner ? [savedComment] : [], + commentCount: viewerIsOwner ? 1 : 0, + }), + ); + }, + ); + it("passes comments and reactions into the shared agent payload", async () => { const comments = Array.from({ length: 101 }, (_, index) => ({ id: `c${index}`, diff --git a/templates/clips/server/routes/api/agent-context.json.get.ts b/templates/clips/server/routes/api/agent-context.json.get.ts index 40aaef678e7..0d2d7a58272 100644 --- a/templates/clips/server/routes/api/agent-context.json.get.ts +++ b/templates/clips/server/routes/api/agent-context.json.get.ts @@ -6,6 +6,7 @@ import { type H3Event, } from "h3"; +import { canReadRecordingComments } from "../../../shared/comment-access.js"; import { getDb, schema } from "../../db/index.js"; import { applyAgentJsonHeaders, @@ -41,6 +42,10 @@ export default defineEventHandler(async (event: H3Event) => { } const recording = accessResult.access.recording; + const commentsReadable = canReadRecordingComments({ + enableComments: recording.enableComments, + viewerIsOwner: accessResult.access.viewerIsOwner, + }); const db = getDb(); const historyQueryLimit = MAX_PUBLIC_AGENT_HISTORY_ITEMS + 1; const [ @@ -54,7 +59,7 @@ export default defineEventHandler(async (event: H3Event) => { bugReport, ] = await Promise.all([ loadAgentTranscript(recording.id, recording.durationMs), - recording.enableComments + commentsReadable ? db .select({ id: schema.recordingComments.id, @@ -91,7 +96,7 @@ export default defineEventHandler(async (event: H3Event) => { .orderBy(asc(schema.recordingReactions.createdAt)) .limit(historyQueryLimit) : Promise.resolve([]), - recording.enableComments + commentsReadable ? db .select({ count: count() }) .from(schema.recordingComments) diff --git a/templates/clips/server/routes/api/public-recording.get.test.ts b/templates/clips/server/routes/api/public-recording.get.test.ts index acde9ec0177..de64be9d593 100644 --- a/templates/clips/server/routes/api/public-recording.get.test.ts +++ b/templates/clips/server/routes/api/public-recording.get.test.ts @@ -279,6 +279,43 @@ describe("/api/public-recording route", () => { }, ); + it.each(["owner", "viewer"])( + "applies owner visibility to disabled comments for %s", + async (role) => { + const recording = makeRecording({ + password: null, + enableComments: false, + }); + mockGetQuery.mockReturnValue({ id: "rec-1" }); + mockGetSession.mockResolvedValue({ + email: `${role}@example.com`, + orgId: "org-1", + }); + mockResolveAccess.mockResolvedValue({ role, resource: recording }); + mockGetDb.mockReturnValue( + createDbWithSelectResults([ + [recording], + [], + [ + { + id: "comment-test", + content: "Fake saved comment", + authorEmail: "author@example.com", + authorName: "Fake author", + }, + ], + [], + [], + [], + ]), + ); + const result = await handler({ setCookies: [] } as any); + expect(result).toMatchObject({ + comments: role === "owner" ? [{ content: "Fake saved comment" }] : [], + }); + }, + ); + it("sets a protected media cookie and long fallback token after password unlock", async () => { const event = { setCookies: [] as unknown[] }; mockGetDb.mockReturnValue( diff --git a/templates/clips/server/routes/api/public-recording.get.ts b/templates/clips/server/routes/api/public-recording.get.ts index 270c3681c8f..556710af9ec 100644 --- a/templates/clips/server/routes/api/public-recording.get.ts +++ b/templates/clips/server/routes/api/public-recording.get.ts @@ -357,16 +357,17 @@ export default defineEventHandler(async (event) => { .where(eq(schema.recordingTranscripts.recordingId, recordingId)) .limit(1); - const comments = rec.enableComments - ? await db - .select() - .from(schema.recordingComments) - .where(eq(schema.recordingComments.recordingId, recordingId)) - .orderBy( - asc(schema.recordingComments.videoTimestampMs), - asc(schema.recordingComments.createdAt), - ) - : []; + const comments = + rec.enableComments || viewerIsOwner + ? await db + .select() + .from(schema.recordingComments) + .where(eq(schema.recordingComments.recordingId, recordingId)) + .orderBy( + asc(schema.recordingComments.videoTimestampMs), + asc(schema.recordingComments.createdAt), + ) + : []; const hydratedComments = await hydrateCommentAuthorNames(comments); const commentMentions = new Map( hydratedComments.map((comment) => [ diff --git a/templates/clips/shared/comment-access.ts b/templates/clips/shared/comment-access.ts new file mode 100644 index 00000000000..d7696a740d1 --- /dev/null +++ b/templates/clips/shared/comment-access.ts @@ -0,0 +1,6 @@ +export function canReadRecordingComments(input: { + enableComments: boolean; + viewerIsOwner: boolean; +}): boolean { + return input.enableComments || input.viewerIsOwner; +} From d54cdfe9731f0b7975c9f98da514395ac882b581 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:27:24 -0400 Subject: [PATCH 05/35] Let Content document readers play and comment on Clips uploaded to the document --- .changeset/host-access-signed-out.md | 5 + packages/core/src/sharing/access.spec.ts | 22 + packages/core/src/sharing/access.ts | 9 +- scripts/guard-db-tool-scoping.mjs | 2 + .../.agents/skills/video-sharing/SKILL.md | 11 + templates/clips/server/db/index.ts | 15 +- templates/clips/server/db/schema.ts | 35 + .../clips/server/lib/content-clip-bindings.ts | 50 + .../clips/server/lib/content-clip-peer.ts | 560 +++++++++ .../clips/server/lib/content-clips.test.ts | 1019 +++++++++++++++++ .../clips/server/lib/content-doc-grant.ts | 50 + .../server/lib/content-playback-grant.ts | 134 +++ templates/clips/server/plugins/auth.ts | 4 + templates/clips/server/plugins/db.ts | 23 + .../[recordingId]/comments.get.ts | 6 + .../[recordingId]/comments.post.ts | 6 + .../[recordingId]/playback.get.ts | 6 + .../routes/api/thumbnail/[recordingId].get.ts | 45 +- .../routes/api/video/[recordingId].get.ts | 42 +- .../clips/shared/content-clip-contract.ts | 33 + 20 files changed, 2053 insertions(+), 24 deletions(-) create mode 100644 .changeset/host-access-signed-out.md create mode 100644 templates/clips/server/lib/content-clip-bindings.ts create mode 100644 templates/clips/server/lib/content-clip-peer.ts create mode 100644 templates/clips/server/lib/content-clips.test.ts create mode 100644 templates/clips/server/lib/content-doc-grant.ts create mode 100644 templates/clips/server/lib/content-playback-grant.ts create mode 100644 templates/clips/server/routes/api/content-clips/[recordingId]/comments.get.ts create mode 100644 templates/clips/server/routes/api/content-clips/[recordingId]/comments.post.ts create mode 100644 templates/clips/server/routes/api/content-clips/[recordingId]/playback.get.ts create mode 100644 templates/clips/shared/content-clip-contract.ts diff --git a/.changeset/host-access-signed-out.md b/.changeset/host-access-signed-out.md new file mode 100644 index 00000000000..7ee8fb3910e --- /dev/null +++ b/.changeset/host-access-signed-out.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Host access resolvers registered with `registerHostAccessResolver` are now asked about signed-out requests too, with `ctx.userEmail` unset, so a host can vouch for a signed-out reader of one resource. Resolvers that only vouch for signed-in people should return `undefined` when there is no email. diff --git a/packages/core/src/sharing/access.spec.ts b/packages/core/src/sharing/access.spec.ts index 79dafed7be3..834a34472b0 100644 --- a/packages/core/src/sharing/access.spec.ts +++ b/packages/core/src/sharing/access.spec.ts @@ -658,6 +658,28 @@ describe("shareable resource access helpers", () => { } }); + it("asks the host about signed-out requests too", async () => { + await insertDoc({ id: "doc-hosted-anonymous" }); + const asked: Array = []; + registerHostAccessResolver(resourceType, async (resource, ctx) => { + asked.push(ctx.userEmail); + return resource.id === "doc-hosted-anonymous" ? "viewer" : undefined; + }); + try { + await runWithRequestContext({}, async () => { + await expect( + resolveAccess(resourceType, "doc-hosted-anonymous"), + ).resolves.toMatchObject({ role: "viewer" }); + await expect( + assertAccess(resourceType, "doc-hosted-anonymous", "editor"), + ).rejects.toThrow(); + }); + expect(asked).toContain(undefined); + } finally { + registerHostAccessResolver(resourceType, null); + } + }); + it("resolves read and write roles without letting visibility imply edit access", async () => { await insertDoc({ id: "doc-private" }); await insertDoc({ id: "doc-owned-other-org", orgId: otherOrgId }); diff --git a/packages/core/src/sharing/access.ts b/packages/core/src/sharing/access.ts index 197b6a71175..c2653073dc6 100644 --- a/packages/core/src/sharing/access.ts +++ b/packages/core/src/sharing/access.ts @@ -668,7 +668,9 @@ async function loadAndResolveAccess( * to a Builder project), the host is the authority on who may open them. The * resolver returns a role, `null` to deny, or `undefined` to fall through to * AN's own owner/org/share rules. AN keeps no copy of the host's members, - * orgs or roles: it asks, per resource, per request. + * orgs or roles: it asks, per resource, per request. It is asked for + * signed-out requests too (`ctx.userEmail` unset), so a resolver that only + * vouches for people must return `undefined` when there is no email. */ export type HostAccessResolver = ( resource: any, @@ -727,10 +729,7 @@ async function accessToResource( // A host can deny outright. When it grants access, the resource's owner // and admins keep their own roles; the host's role covers everyone else. const hostResolver = hostAccessResolvers.get(reg.type); - const hosted = - hostResolver && normalizedUserEmail - ? await hostResolver(resource, ctx) - : undefined; + const hosted = hostResolver ? await hostResolver(resource, ctx) : undefined; if (hosted === null) return null; const managingRole = await ownerOrAdminRole( diff --git a/scripts/guard-db-tool-scoping.mjs b/scripts/guard-db-tool-scoping.mjs index a127867453d..d3313446c21 100644 --- a/scripts/guard-db-tool-scoping.mjs +++ b/scripts/guard-db-tool-scoping.mjs @@ -78,6 +78,8 @@ const INTENTIONAL_RAW_DB_DENYLIST = { "clips:recording_comments": "child rows scoped through recordings", "clips:recording_context_items": "child rows scoped through recordings; every action checks access on the parent recording", + "clips:recording_content_bindings": + "access-granting child rows scoped through recordings; only Content's upload and lifecycle flows write them", "clips:recording_ctas": "child rows scoped through recordings", "clips:recording_events": "audit/event rows scoped through recordings", "clips:recording_playback_positions": diff --git a/templates/clips/.agents/skills/video-sharing/SKILL.md b/templates/clips/.agents/skills/video-sharing/SKILL.md index 451e04b8e0d..48bb039ae8a 100644 --- a/templates/clips/.agents/skills/video-sharing/SKILL.md +++ b/templates/clips/.agents/skills/video-sharing/SKILL.md @@ -54,6 +54,7 @@ Read this skill before: - **`recording_viewers`** + **`recording_events`** — view counting. - **`recording_views`** — append-only per-view log (who viewed, when) backing the owner-facing "Viewed by" popover. See "View counting" below. - **`recording_agent_views`** — outside agents reading a clip through its public agent APIs, counted separately from humans. See "Agent views" below. +- **`recording_content_bindings`** — the Agent-Native Content documents a Clip is placed in. See "Clips in Content documents" below. ## Dropping in the share UI @@ -136,6 +137,16 @@ async function canAccess( Framework first, Clips additions second. Don't invert this — the framework owns the "is this row visible at all" question. +## Clips in Content documents + +A Clip placed in a Content document has one `recording_content_bindings` row per (recording, Content app, document). `origin` is `upload` (made in the document) or `link` (pasted); `state` is `active` or `trashed`. A duplicated document adds its own row. + +- Only an active `upload` binding grants access: a Content reader with no access of their own becomes a viewer of that one recording (`server/lib/content-doc-grant.ts`, a host access resolver). A reader's own Clips sharing, in their active workspace, still applies and is never lowered. A `link` binding, or none, leaves the Clip's own sharing in charge. When every binding is trashed, document readers lose access; the owner never does. +- The Clip's own rules still apply on top: Trash, takedown and expiry refuse document readers, a password-protected Clip does not play through a document, and with comments off document readers see no comments and cannot write. +- Content calls `/api/content-clips/:recordingId/playback` and `/comments` (GET to list, POST to add, reply, edit, delete, resolve or react) server to server with a one-minute A2A token. `shared/content-clip-contract.ts` is the contract: audience `/content-clips`, one scope, and `document_id` + `recording_id` claims. A signed-out reader of a public document gets the `content-clip:public-read` scope: playback and the comment list, never a write. +- Playback returns Clips media URLs with a one-hour `content_grant` that names the reader, not a role. `/api/video` and `/api/thumbnail` re-run every gate on each request, so trashing the binding stops a URL already handed out. +- Create and change binding rows only through Content's upload and lifecycle flows. + ## Embed URLs Embeds live at `/embed/:shareId` (a share-scoped anonymous route). Supported query params: diff --git a/templates/clips/server/db/index.ts b/templates/clips/server/db/index.ts index e0dc0e8db6d..91a3de0dd63 100644 --- a/templates/clips/server/db/index.ts +++ b/templates/clips/server/db/index.ts @@ -1,6 +1,9 @@ import { createGetDb, getDbExec } from "@agent-native/core/db"; import { organizations, registerIdentityColumns } from "@agent-native/core/org"; -import { registerShareableResource } from "@agent-native/core/sharing"; +import { + registerHostAccessResolver, + registerShareableResource, +} from "@agent-native/core/sharing"; import { eq } from "drizzle-orm"; import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; @@ -10,6 +13,7 @@ import { } from "../../shared/meeting-agent-access.js"; import { usesOrganizationLogoRoute } from "../../shared/organization-logo.js"; import { recordingSharePath } from "../../shared/recording-link.js"; +import { contentDocReaderRole } from "../lib/content-doc-grant.js"; import { organizationLogoAbsoluteUrl } from "../lib/organization-logo.js"; import { absoluteUrl, @@ -73,6 +77,7 @@ registerShareableResource({ getDb, ownerAccessIgnoresOrg: true, }); +registerHostAccessResolver("recording", contentDocReaderRole); registerShareableResource({ type: "meeting", @@ -198,6 +203,14 @@ registerIdentityColumns([ offboard: "retain", reason: "Reporter contact supplied by the capture client, not a principal.", }, + { + table: "recording_content_bindings", + column: "created_by", + emailChange: "rekey", + offboard: "retain", + reason: + "Who placed the Clip in a Content document; the document grants access, not this column.", + }, { table: "recording_browser_diagnostics", column: "session_id", diff --git a/templates/clips/server/db/schema.ts b/templates/clips/server/db/schema.ts index 0345bdb27f1..4aa9c56d11b 100644 --- a/templates/clips/server/db/schema.ts +++ b/templates/clips/server/db/schema.ts @@ -353,6 +353,41 @@ export const recordingBugReports = table("recording_bug_reports", { updatedAt: text("updated_at").notNull().default(now()), }); +// A Clip placed in an Agent-Native Content document. Only an `upload` binding +// in `active` state lets the document's readers open the Clip; a `link` binding +// grants nothing, so a pasted Clip keeps its own sharing. `state` carries no +// CHECK constraint, so lifecycle states can be added without a migration. +export const recordingContentBindings = table( + "recording_content_bindings", + { + id: text("id").primaryKey(), + recordingId: text("recording_id").notNull(), + // The Content app's canonical URL, from the issuer of its A2A token. + contentApp: text("content_app").notNull(), + documentId: text("document_id").notNull(), + blockId: text("block_id"), + origin: text("origin", { enum: ["upload", "link"] }).notNull(), + state: text("state", { enum: ["active", "trashed"] }) + .notNull() + .default("active"), + // Content's time for the latest state change, so a transition that + // arrives late cannot undo a newer one. + stateChangedAt: text("state_changed_at"), + createdBy: text("created_by").notNull(), + createdAt: text("created_at").notNull().default(now()), + updatedAt: text("updated_at").notNull().default(now()), + }, + (binding) => ({ + recordingDocumentUnique: uniqueIndex( + "recording_content_bindings_recording_document_unique_idx", + ).on(binding.recordingId, binding.contentApp, binding.documentId), + documentIndex: index("recording_content_bindings_document_idx").on( + binding.contentApp, + binding.documentId, + ), + }), +); + export const recordingCtas = table("recording_ctas", { id: text("id").primaryKey(), recordingId: text("recording_id").notNull(), diff --git a/templates/clips/server/lib/content-clip-bindings.ts b/templates/clips/server/lib/content-clip-bindings.ts new file mode 100644 index 00000000000..6da3f2a6130 --- /dev/null +++ b/templates/clips/server/lib/content-clip-bindings.ts @@ -0,0 +1,50 @@ +import { and, eq } from "drizzle-orm"; + +import { getDb, schema } from "../db/index.js"; +import type { ContentDocGrant } from "./content-doc-grant.js"; + +export type ContentClipBinding = + typeof schema.recordingContentBindings.$inferSelect; + +export async function findContentBinding(input: { + recordingId: string; + contentApp: string; + documentId: string; +}): Promise { + const [binding] = await getDb() + .select() + .from(schema.recordingContentBindings) + .where( + and( + eq(schema.recordingContentBindings.recordingId, input.recordingId), + eq(schema.recordingContentBindings.contentApp, input.contentApp), + eq(schema.recordingContentBindings.documentId, input.documentId), + ), + ) + .limit(1); + return binding ?? null; +} + +export async function readContentBinding( + id: string, +): Promise { + const [binding] = await getDb() + .select() + .from(schema.recordingContentBindings) + .where(eq(schema.recordingContentBindings.id, id)) + .limit(1); + return binding ?? null; +} + +export function contentDocGrantFor( + binding: ContentClipBinding, + viewerEmail: string | null, +): ContentDocGrant { + return { + bindingId: binding.id, + recordingId: binding.recordingId, + origin: binding.origin, + state: binding.state, + viewerEmail, + }; +} diff --git a/templates/clips/server/lib/content-clip-peer.ts b/templates/clips/server/lib/content-clip-peer.ts new file mode 100644 index 00000000000..2eaa8eb16b8 --- /dev/null +++ b/templates/clips/server/lib/content-clip-peer.ts @@ -0,0 +1,560 @@ +/** + * The Clips side of a Clip placed in an Agent-Native Content document. + * Content vouches, with a short A2A token, for who is reading which document; + * Clips decides what that reader may do with the one Clip the token names. + * The token contract is in `shared/content-clip-contract.ts`. + */ +import { verifyA2AToken } from "@agent-native/core/a2a"; +import { + getAppProductionUrl, + readBodyWithSizeLimit, + runWithRequestContext, + withConfiguredAppBasePath, +} from "@agent-native/core/server"; +import { ForbiddenError, resolveAccess } from "@agent-native/core/sharing"; +import { and, eq } from "drizzle-orm"; +import { getRequestHeader, getRouterParam, type H3Event } from "h3"; +import { z } from "zod"; + +import addComment from "../../actions/add-comment.js"; +import deleteComment from "../../actions/delete-comment.js"; +import listComments from "../../actions/list-comments.js"; +import reactToComment from "../../actions/react-to-comment.js"; +import replyToComment from "../../actions/reply-to-comment.js"; +import resolveComment from "../../actions/resolve-comment.js"; +import updateComment from "../../actions/update-comment.js"; +import { canReadRecordingComments } from "../../shared/comment-access.js"; +import { + CONTENT_CLIP_AUDIENCE_PATH, + CONTENT_CLIP_CLAIMS, + CONTENT_CLIP_MAX_TOKEN_SECONDS, + CONTENT_CLIP_PUBLIC_READER, + CONTENT_CLIP_SCOPES, + type ContentClipScope, +} from "../../shared/content-clip-contract.js"; +import { recordingEmbedProvider } from "../../shared/embed-provider.js"; +import { getDb, schema } from "../db/index.js"; +import { + contentDocGrantFor, + findContentBinding, + type ContentClipBinding, +} from "./content-clip-bindings.js"; +import { runWithContentDocGrant } from "./content-doc-grant.js"; +import { + contentReaderOrgId, + signContentPlaybackGrant, + withContentPlaybackGrant, +} from "./content-playback-grant.js"; +import { + isHeldForRedaction, + REDACTION_HOLD_MESSAGE, +} from "./pending-redactions.js"; +import { resolvePlayerThumbnailUrl } from "./player-thumbnail-url.js"; +import { resolvePlayerVideoUrl } from "./player-video-url.js"; +import { + isRecordingExpiredForViewer, + isRecordingTrashedForViewer, +} from "./recording-page-access.js"; + +type Recording = typeof schema.recordings.$inferSelect; + +interface ContentClipPeer { + /** The Content app's URL, from the token's issuer. */ + contentApp: string; + documentId: string; + recordingId: string; + /** Null for a signed-out reader of a public document. */ + viewerEmail: string | null; + viewerName: string | null; +} + +interface OpenedClip { + recording: Recording; + role: string; + binding: ContentClipBinding | null; +} + +const PRIVATE_HEADERS = { + "cache-control": "private, no-store", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", +}; +const BEARER = /^Bearer ([^\s]+)$/; +const MAX_DOCUMENT_ID_CHARS = 256; +const MAX_ISSUER_CHARS = 2048; +const MAX_VIEWER_NAME_CHARS = 200; +const ISSUED_AT_SKEW_SECONDS = 60; +const MAX_COMMENT_CHARS = 10_000; +const MAX_COMMENT_ID_CHARS = 128; +const MAX_EMOJI_CHARS = 32; +const MAX_VIDEO_TIMESTAMP_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_BODY_BYTES = 64 * 1024; +const viewerEmailSchema = z.string().email().max(320); + +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMITS = { read: 600, write: 30 } as const; +const RATE_LIMIT_MAX_BUCKETS = 5000; +const rateBuckets = new Map(); + +function json(body: unknown, status = 200): Response { + return Response.json(body, { status, headers: PRIVATE_HEADERS }); +} + +function errorResponse(status: number, error: string, code?: string) { + return json({ error, ...(code ? { code } : {}) }, status); +} + +const unauthorized = () => errorResponse(401, "Unauthorized"); +const notFound = () => errorResponse(404, "Not found"); + +function contentAppFromIssuer(issuer: unknown): string | null { + if ( + typeof issuer !== "string" || + issuer.length > MAX_ISSUER_CHARS || + !URL.canParse(issuer) + ) { + return null; + } + const url = new URL(issuer); + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + url.username || + url.password || + url.search || + url.hash + ) { + return null; + } + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; +} + +function isDocumentId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= MAX_DOCUMENT_ID_CHARS && + value.trim() === value && + !/[\s\p{Cc}]/u.test(value) + ); +} + +/** + * The reader Content vouched for, or the response refusing the request. Every + * refusal is the same bare 401, so a caller learns nothing about the Clip. + */ +async function verifyPeer( + event: H3Event, + recordingId: string, + scopes: readonly ContentClipScope[], +): Promise { + const match = BEARER.exec(getRequestHeader(event, "authorization") ?? ""); + if (!match) return unauthorized(); + + let verified: Awaited>; + try { + verified = await verifyA2AToken(match[1]!, event, { + routePrefix: CONTENT_CLIP_AUDIENCE_PATH, + globalSecretOnly: true, + includeClaims: true, + requireAudience: true, + }); + } catch (error) { + // Thrown only when a credential could not be read, which is not a refusal. + if ( + error instanceof Error && + error.name === "A2AIdentityVerificationUnavailableError" + ) { + return errorResponse(503, "Identity verification is unavailable"); + } + throw error; + } + + const claims = verified.claims; + const subject = verified.email; + if (!claims || !subject) return unauthorized(); + if (!scopes.includes(claims.scope as ContentClipScope)) return unauthorized(); + + const contentApp = contentAppFromIssuer(claims.iss); + const documentId = claims[CONTENT_CLIP_CLAIMS.documentId]; + if (!contentApp || !isDocumentId(documentId)) return unauthorized(); + if (claims[CONTENT_CLIP_CLAIMS.recordingId] !== recordingId) { + return unauthorized(); + } + + // Content mints a token per call; a long-lived one would outlast the + // document access Content checked when it signed it. + const now = Math.floor(Date.now() / 1000); + if ( + typeof claims.iat !== "number" || + typeof claims.exp !== "number" || + claims.iat > now + ISSUED_AT_SKEW_SECONDS || + claims.exp - claims.iat > CONTENT_CLIP_MAX_TOKEN_SECONDS + ) { + return unauthorized(); + } + + const isPublicReader = claims.scope === CONTENT_CLIP_SCOPES.publicRead; + if (isPublicReader !== (subject === CONTENT_CLIP_PUBLIC_READER)) { + return unauthorized(); + } + let viewerEmail: string | null = null; + if (!isPublicReader) { + viewerEmail = subject.toLowerCase(); + if (!viewerEmailSchema.safeParse(viewerEmail).success) { + return unauthorized(); + } + } + const name = claims[CONTENT_CLIP_CLAIMS.viewerName]; + const viewerName = + viewerEmail && typeof name === "string" + ? name.trim().slice(0, MAX_VIEWER_NAME_CHARS) || null + : null; + + return { contentApp, documentId, recordingId, viewerEmail, viewerName }; +} + +function pruneExpiredRateBuckets(now: number): void { + for (const [key, bucket] of rateBuckets) { + if (bucket.reset < now) rateBuckets.delete(key); + } +} + +function rateLimited(peer: ContentClipPeer, kind: "read" | "write") { + const key = [ + kind, + peer.contentApp, + peer.documentId, + peer.recordingId, + peer.viewerEmail ?? CONTENT_CLIP_PUBLIC_READER, + ].join("\n"); + const now = Date.now(); + const existing = rateBuckets.get(key); + if (!existing || existing.reset < now) { + if (existing) rateBuckets.delete(key); + if (rateBuckets.size >= RATE_LIMIT_MAX_BUCKETS) { + pruneExpiredRateBuckets(now); + } + if (rateBuckets.size >= RATE_LIMIT_MAX_BUCKETS) { + return errorResponse(429, "Too many requests"); + } + rateBuckets.set(key, { count: 1, reset: now + RATE_LIMIT_WINDOW_MS }); + return null; + } + if (existing.count >= RATE_LIMITS[kind]) { + return errorResponse(429, "Too many requests"); + } + existing.count += 1; + return null; +} + +export function __resetContentClipRateLimitsForTests(): void { + rateBuckets.clear(); +} + +async function authorize( + event: H3Event, + scopes: readonly ContentClipScope[], + kind: "read" | "write", +): Promise { + const recordingId = getRouterParam(event, "recordingId"); + if (!recordingId) return notFound(); + const peer = await verifyPeer(event, recordingId, scopes); + if (peer instanceof Response) return peer; + return rateLimited(peer, kind) ?? peer; +} + +/** + * Opens the Clip as the vouched reader, with the document's binding in scope + * so an active upload binding counts as a share. The Clip's own Trash, + * takedown, expiry and password rules then apply exactly as they do on a + * share link, with the owner's exceptions. + */ +async function withOpenedClip( + peer: ContentClipPeer, + fn: (opened: OpenedClip) => Promise, +): Promise { + const binding = await findContentBinding(peer); + const orgId = await contentReaderOrgId(peer.viewerEmail); + const open = () => + runWithRequestContext( + { + userEmail: peer.viewerEmail ?? undefined, + userName: peer.viewerName ?? undefined, + orgId, + }, + async () => { + const access = await resolveAccess("recording", peer.recordingId); + if (!access) return notFound(); + const recording = access.resource as Recording; + const viewerIsOwner = access.role === "owner"; + if ( + isRecordingTrashedForViewer({ + trashedAt: recording.trashedAt, + viewerIsOwner, + }) || + recording.failureCode === "taken_down" + ) { + return notFound(); + } + if ( + isRecordingExpiredForViewer({ + expiresAt: recording.expiresAt, + viewerIsOwner, + }) + ) { + return errorResponse(410, "Recording has expired", "expired"); + } + // A password only tightens access, and a document cannot supply one. + if (recording.password && !viewerIsOwner) { + return errorResponse( + 403, + "This Clip is password protected", + "password_required", + ); + } + return fn({ recording, role: access.role, binding }); + }, + ); + return binding + ? runWithContentDocGrant( + contentDocGrantFor(binding, peer.viewerEmail), + open, + ) + : open(); +} + +function forbiddenResponse(error: unknown): Response { + if (error instanceof ForbiddenError) { + return errorResponse(403, error.message || "Forbidden", "forbidden"); + } + throw error; +} + +export async function handleContentClipPlayback( + event: H3Event, +): Promise { + const peer = await authorize( + event, + [CONTENT_CLIP_SCOPES.read, CONTENT_CLIP_SCOPES.publicRead], + "read", + ); + if (peer instanceof Response) return peer; + + return withOpenedClip(peer, async ({ recording, role, binding }) => { + if (isHeldForRedaction(recording.editsJson, role)) { + return errorResponse(409, REDACTION_HOLD_MESSAGE, "redaction_pending"); + } + const grant = signContentPlaybackGrant({ + recordingId: recording.id, + bindingId: binding?.id ?? null, + viewerEmail: peer.viewerEmail, + }); + const base = withConfiguredAppBasePath(getAppProductionUrl()); + // Clips' own media routes come back as paths and carry the grant; an + // embedded provider's player URL goes out as it is. + const playable = (url: string | null) => + url?.startsWith("/") + ? withContentPlaybackGrant(`${base}${url}`, grant.params) + : url; + + return json({ + recording: { + id: recording.id, + title: recording.title, + kind: recording.kind, + status: recording.status, + durationMs: recording.durationMs, + width: recording.width, + height: recording.height, + enableComments: Boolean(recording.enableComments), + embedProvider: recordingEmbedProvider(recording), + }, + playback: { + videoUrl: playable( + resolvePlayerVideoUrl(recording, { proxyRemoteMedia: true }), + ), + posterUrl: playable(resolvePlayerThumbnailUrl(recording)), + expiresAt: grant.expiresAt, + }, + viewer: { + isOwner: role === "owner", + canComment: + peer.viewerEmail !== null && Boolean(recording.enableComments), + }, + }); + }); +} + +export async function handleContentClipComments( + event: H3Event, +): Promise { + const peer = await authorize( + event, + [CONTENT_CLIP_SCOPES.read, CONTENT_CLIP_SCOPES.publicRead], + "read", + ); + if (peer instanceof Response) return peer; + + return withOpenedClip(peer, async ({ recording, role }) => { + if ( + !canReadRecordingComments({ + enableComments: Boolean(recording.enableComments), + viewerIsOwner: role === "owner", + }) + ) { + return errorResponse( + 403, + "Comments are turned off for this Clip", + "comments_disabled", + ); + } + try { + const { comments } = await listComments.run({ + recordingId: peer.recordingId, + }); + return json({ comments }); + } catch (error) { + return forbiddenResponse(error); + } + }); +} + +const commentText = z.string().trim().min(1).max(MAX_COMMENT_CHARS); +const commentId = z.string().trim().min(1).max(MAX_COMMENT_ID_CHARS); +const commentWriteSchema = z.discriminatedUnion("operation", [ + z + .object({ + operation: z.literal("add"), + content: commentText, + videoTimestampMs: z.number().int().min(0).max(MAX_VIDEO_TIMESTAMP_MS), + }) + .strict(), + z + .object({ + operation: z.literal("reply"), + commentId, + content: commentText, + }) + .strict(), + z + .object({ operation: z.literal("edit"), commentId, content: commentText }) + .strict(), + z.object({ operation: z.literal("delete"), commentId }).strict(), + z + .object({ + operation: z.literal("resolve"), + commentId, + resolved: z.boolean().optional(), + }) + .strict(), + z + .object({ + operation: z.literal("react"), + commentId, + emoji: z.string().trim().min(1).max(MAX_EMOJI_CHARS), + }) + .strict(), +]); +type CommentWrite = z.infer; + +async function isCommentOnRecording( + id: string, + recordingId: string, +): Promise { + const [comment] = await getDb() + .select({ id: schema.recordingComments.id }) + .from(schema.recordingComments) + .where( + and( + eq(schema.recordingComments.id, id), + eq(schema.recordingComments.recordingId, recordingId), + ), + ) + .limit(1); + return Boolean(comment); +} + +async function writeComment(write: CommentWrite, recordingId: string) { + switch (write.operation) { + case "add": { + const { id, threadId } = await addComment.run({ + recordingId, + content: write.content, + videoTimestampMs: write.videoTimestampMs, + }); + return { id, threadId }; + } + case "reply": { + const { id, threadId, parentId } = await replyToComment.run({ + commentId: write.commentId, + content: write.content, + }); + return { id, threadId, parentId }; + } + case "edit": { + const { id, content, updatedAt } = await updateComment.run({ + id: write.commentId, + content: write.content, + }); + return { id, content, updatedAt }; + } + case "delete": + return deleteComment.run({ id: write.commentId }); + case "resolve": + return resolveComment.run({ + id: write.commentId, + resolved: write.resolved, + }); + case "react": { + const { id, emoji, reacted, reactions } = await reactToComment.run({ + commentId: write.commentId, + emoji: write.emoji, + }); + return { id, emoji, reacted, reactions }; + } + } +} + +export async function handleContentClipCommentWrite( + event: H3Event, +): Promise { + const peer = await authorize(event, [CONTENT_CLIP_SCOPES.comment], "write"); + if (peer instanceof Response) return peer; + + let body: unknown; + try { + body = await readBodyWithSizeLimit(event, MAX_BODY_BYTES); + } catch (error) { + const tooLarge = (error as { statusCode?: number })?.statusCode === 413; + return errorResponse( + tooLarge ? 413 : 400, + tooLarge ? "Request body too large" : "Invalid request body", + ); + } + const parsed = commentWriteSchema.safeParse(body); + if (!parsed.success) return errorResponse(400, "Invalid comment request"); + const write = parsed.data; + + return withOpenedClip(peer, async ({ recording }) => { + // Stricter than Clips' own `delete-comment`, which lets an author delete + // with comments off: through a document, comments off means no writes. + if (!recording.enableComments) { + return errorResponse( + 403, + "Comments are turned off for this Clip", + "comments_disabled", + ); + } + if ( + write.operation !== "add" && + !(await isCommentOnRecording(write.commentId, peer.recordingId)) + ) { + return notFound(); + } + try { + return json(await writeComment(write, peer.recordingId)); + } catch (error) { + return forbiddenResponse(error); + } + }); +} diff --git a/templates/clips/server/lib/content-clips.test.ts b/templates/clips/server/lib/content-clips.test.ts new file mode 100644 index 00000000000..c27630fcc41 --- /dev/null +++ b/templates/clips/server/lib/content-clips.test.ts @@ -0,0 +1,1019 @@ +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +import { getTableConfig, type PgTable } from "drizzle-orm/pg-core"; +import { drizzle } from "drizzle-orm/pglite"; +import { mockEvent } from "h3"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const CLIPS_URL = "https://clips.example.test"; +const CONTENT_APP = "https://content.example.test"; +const SECRET = "content-clips-test-secret-0123456789abcdef"; + +const state = vi.hoisted(() => { + vi.stubEnv("APP_URL", "https://clips.example.test"); + vi.stubEnv("A2A_SECRET", "content-clips-test-secret-0123456789abcdef"); + return { db: null as any, blobs: new Map() }; +}); + +vi.mock("@agent-native/core/db", async (importOriginal) => ({ + ...(await importOriginal()), + createGetDb: () => () => state.db, +})); +vi.mock("@agent-native/core/application-state", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@agent-native/core/application-state") + >()), + readAppState: async (key: string) => state.blobs.get(key) ?? null, + writeAppState: async () => {}, +})); +vi.mock("@agent-native/core/org", async (importOriginal) => ({ + ...(await importOriginal()), + resolveOrgIdForEmail: async (email: string) => { + const { rows } = await state.db.$client.query( + "SELECT org_id FROM org_members WHERE lower(email) = $1 ORDER BY joined_at LIMIT 1", + [email.toLowerCase()], + ); + return (rows[0] as { org_id?: string } | undefined)?.org_id ?? null; + }, +})); +vi.mock("./activity-notifications.js", () => ({ + notifyRecordingComment: async () => null, + notifyRecordingReaction: async () => null, +})); +vi.mock("./user-identities.js", () => ({ + hydrateCommentAuthorNames: async (rows: unknown[]) => rows, +})); + +const coreRequire = createRequire( + new URL("../../../../packages/core/package.json", import.meta.url), +); +const { PGlite } = coreRequire("@electric-sql/pglite"); +const jose = await import(pathToFileURL(coreRequire.resolve("jose")).href); + +const { organizations, orgMembers } = await import("@agent-native/core/org"); +const { signA2AToken } = await import("@agent-native/core/a2a"); +const { schema } = await import("../db/index.js"); +const { CONTENT_CLIP_PUBLIC_READER, CONTENT_CLIP_SCOPES: SCOPES } = + await import("../../shared/content-clip-contract.js"); +const { __resetContentClipRateLimitsForTests } = + await import("./content-clip-peer.js"); +const { default: playbackRoute } = + await import("../routes/api/content-clips/[recordingId]/playback.get.js"); +const { default: listCommentsRoute } = + await import("../routes/api/content-clips/[recordingId]/comments.get.js"); +const { default: writeCommentsRoute } = + await import("../routes/api/content-clips/[recordingId]/comments.post.js"); +const { default: videoRoute } = + await import("../routes/api/video/[recordingId].get.js"); +const { default: thumbnailRoute } = + await import("../routes/api/thumbnail/[recordingId].get.js"); + +const OWNER = "owner@clips.example.test"; +const READER = "reader@content.example.test"; +const OTHER_READER = "other-reader@content.example.test"; +const PNG_DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; +const VIDEO_BYTES = Buffer.from("fake mp4 bytes for a test clip"); + +let client: Awaited>; + +function createTableSql(table: PgTable): string { + const config = getTableConfig(table); + const columns = config.columns.map((column) => { + const parts = [`"${column.name}" ${column.getSQLType()}`]; + if (column.primary) parts.push("PRIMARY KEY"); + const fallback = column.default; + if (typeof fallback === "string") { + parts.push(`DEFAULT '${fallback.replace(/'/g, "''")}'`); + } else if (typeof fallback === "number" || typeof fallback === "boolean") { + parts.push(`DEFAULT ${fallback}`); + } + return parts.join(" "); + }); + return `CREATE TABLE "${config.name}" (${columns.join(", ")});`; +} + +async function insertRecording( + id: string, + values: Partial = {}, +) { + await state.db.insert(schema.recordings).values({ + id, + organizationId: "clips-org", + ownerEmail: OWNER, + visibility: "private", + title: `Clip ${id}`, + status: "ready", + durationMs: 42_000, + width: 1280, + height: 720, + enableComments: true, + videoUrl: `https://storage.example.test/${id}.mp4`, + videoFormat: "mp4", + thumbnailUrl: PNG_DATA_URL, + editsJson: "{}", + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + ...values, + }); + state.blobs.set(`recording-blob-${id}`, { + data: VIDEO_BYTES.toString("base64"), + mimeType: "video/mp4", + }); +} + +async function insertBinding( + id: string, + values: Partial = {}, +) { + await state.db.insert(schema.recordingContentBindings).values({ + id, + recordingId: "rec-upload", + contentApp: CONTENT_APP, + documentId: "doc-1", + blockId: "block-1", + origin: "upload", + state: "active", + createdBy: OWNER, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + ...values, + }); +} + +async function setBindingState(id: string, bindingState: "active" | "trashed") { + await client.query( + "UPDATE recording_content_bindings SET state = $1 WHERE id = $2", + [bindingState, id], + ); +} + +async function updateRecording(id: string, set: Record) { + const assignments = Object.keys(set).map( + (column, index) => `"${column}" = $${index + 1}`, + ); + await client.query( + `UPDATE recordings SET ${assignments.join(", ")} WHERE id = $${assignments.length + 1}`, + [...Object.values(set), id], + ); +} + +interface TokenInput { + scope?: string; + sub?: string | null; + documentId?: string | null; + recordingId?: string | null; + audience?: string | null; + issuer?: string | null; + secret?: string; + issuedAt?: number; + expiresAt?: number; + extra?: Record; +} + +async function token(input: TokenInput = {}): Promise { + const now = Math.floor(Date.now() / 1000); + const claims: Record = { + scope: input.scope ?? SCOPES.read, + ...(input.documentId === null + ? {} + : { document_id: input.documentId ?? "doc-1" }), + ...(input.recordingId === null + ? {} + : { recording_id: input.recordingId ?? "rec-upload" }), + ...(input.sub === null ? {} : { sub: input.sub ?? READER }), + ...input.extra, + }; + let jwt = new jose.SignJWT(claims) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(input.issuedAt ?? now) + .setExpirationTime(input.expiresAt ?? now + 60); + if (input.issuer !== null) jwt = jwt.setIssuer(input.issuer ?? CONTENT_APP); + if (input.audience !== null) { + jwt = jwt.setAudience(input.audience ?? `${CLIPS_URL}/content-clips`); + } + return jwt.sign(new TextEncoder().encode(input.secret ?? SECRET)); +} + +function publicReaderToken(input: TokenInput = {}) { + return token({ + scope: SCOPES.publicRead, + sub: CONTENT_CLIP_PUBLIC_READER, + ...input, + }); +} + +function peerEvent( + path: string, + options: { bearer?: string; method?: string; body?: unknown } = {}, +) { + const event = mockEvent(`${CLIPS_URL}${path}`, { + method: options.method ?? "GET", + headers: { + ...(options.bearer ? { authorization: `Bearer ${options.bearer}` } : {}), + ...(options.body ? { "content-type": "application/json" } : {}), + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + }); + const recordingId = /\/api\/content-clips\/([^/]+)\//.exec(path)?.[1]; + event.context.params = { recordingId: decodeURIComponent(recordingId!) }; + return event; +} + +async function respond( + handler: (event: any) => unknown, + event: any, +): Promise<{ status: number; body: any; response?: Response }> { + const result = await handler(event); + if (result instanceof Response) { + const type = result.headers.get("content-type") ?? ""; + return { + status: result.status, + body: type.includes("json") ? await result.clone().json() : null, + response: result, + }; + } + return { status: event.res.status ?? 200, body: result }; +} + +function playback(recordingId: string, bearer?: string) { + return respond( + playbackRoute, + peerEvent(`/api/content-clips/${recordingId}/playback`, { bearer }), + ); +} + +function listComments(recordingId: string, bearer?: string) { + return respond( + listCommentsRoute, + peerEvent(`/api/content-clips/${recordingId}/comments`, { bearer }), + ); +} + +function writeComment(recordingId: string, bearer: string, body: unknown) { + return respond( + writeCommentsRoute, + peerEvent(`/api/content-clips/${recordingId}/comments`, { + bearer, + method: "POST", + body, + }), + ); +} + +async function fetchMedia( + handler: (event: any) => unknown, + absoluteUrl: string, +) { + const url = new URL(absoluteUrl); + expect(url.origin).toBe(CLIPS_URL); + const event = mockEvent(url.href); + event.context.params = { + recordingId: decodeURIComponent(url.pathname.split("/").pop()!), + }; + const result = await handler(event); + if (result instanceof Response) { + return { + status: result.status, + bytes: new Uint8Array(await result.arrayBuffer()), + }; + } + return { + status: event.res.status ?? 200, + bytes: result instanceof Uint8Array ? result : null, + }; +} + +beforeEach(async () => { + client = await PGlite.create("memory://"); + state.db = drizzle(client); + state.blobs.clear(); + __resetContentClipRateLimitsForTests(); + for (const table of [ + schema.recordings, + schema.recordingShares, + schema.recordingComments, + schema.recordingContentBindings, + schema.recordingContextItems, + organizations, + orgMembers, + ]) { + await client.exec(createTableSql(table as PgTable)); + } + await insertRecording("rec-upload"); + await insertBinding("binding-doc-1"); +}); + +afterEach(async () => { + await client.close(); +}); + +afterAll(() => { + vi.unstubAllEnvs(); +}); + +describe("Content doc readers play a Clip uploaded to the doc", () => { + it("gives a doc reader a signed playback URL that plays the video and poster", async () => { + const result = await playback("rec-upload", await token()); + + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ + recording: { + id: "rec-upload", + durationMs: 42_000, + width: 1280, + height: 720, + enableComments: true, + }, + viewer: { isOwner: false, canComment: true }, + }); + expect(Date.parse(result.body.playback.expiresAt)).toBeGreaterThan( + Date.now(), + ); + + const video = await fetchMedia(videoRoute, result.body.playback.videoUrl); + expect(video.status).toBe(200); + expect(Buffer.from(video.bytes!)).toEqual(VIDEO_BYTES); + + const poster = await fetchMedia( + thumbnailRoute, + result.body.playback.posterUrl, + ); + expect(poster.status).toBe(200); + }); + + it("refuses a reader of a doc the Clip is not bound to", async () => { + const result = await playback( + "rec-upload", + await token({ documentId: "doc-2" }), + ); + expect(result.status).toBe(404); + }); + + it("refuses a reader once the doc's binding is trashed", async () => { + await setBindingState("binding-doc-1", "trashed"); + expect((await playback("rec-upload", await token())).status).toBe(404); + }); + + it("stops serving an issued playback URL once the binding is trashed", async () => { + const result = await playback("rec-upload", await token()); + await setBindingState("binding-doc-1", "trashed"); + + const video = await fetchMedia(videoRoute, result.body.playback.videoUrl); + expect(video.status).not.toBe(200); + expect(video.bytes).toBeNull(); + }); + + it("refuses a playback URL whose binding was swapped for another", async () => { + await insertRecording("rec-other"); + await insertBinding("binding-other", { + recordingId: "rec-other", + documentId: "doc-9", + }); + const result = await playback("rec-upload", await token()); + const tampered = new URL(result.body.playback.videoUrl); + tampered.searchParams.set("content_binding", "binding-other"); + + expect((await fetchMedia(videoRoute, tampered.href)).status).toBe(404); + }); + + it("keeps a duplicated doc readable after the original is trashed", async () => { + await insertBinding("binding-doc-1-copy", { documentId: "doc-1-copy" }); + await setBindingState("binding-doc-1", "trashed"); + + const copy = await playback( + "rec-upload", + await token({ documentId: "doc-1-copy" }), + ); + expect(copy.status).toBe(200); + expect( + (await playback("rec-upload", await token({ documentId: "doc-1" }))) + .status, + ).toBe(404); + }); + + it("refuses doc readers a trashed or expired Clip", async () => { + await updateRecording("rec-upload", { + trashed_at: "2026-10-20T00:00:00.000Z", + }); + expect((await playback("rec-upload", await token())).status).toBe(404); + + await updateRecording("rec-upload", { + trashed_at: null, + expires_at: "2000-01-01T00:00:00.000Z", + }); + expect((await playback("rec-upload", await token())).status).toBe(410); + }); + + it("refuses doc readers a Clip its owner protected with a password", async () => { + await updateRecording("rec-upload", { password: "hashed-secret" }); + const result = await playback("rec-upload", await token()); + expect(result.status).toBe(403); + expect(result.body).toMatchObject({ code: "password_required" }); + }); + + it("stops an issued playback URL once a password is set, even with the password", async () => { + const result = await playback("rec-upload", await token()); + await updateRecording("rec-upload", { password: "hashed-secret" }); + + for (const [route, url] of [ + [videoRoute, result.body.playback.videoUrl], + [thumbnailRoute, result.body.playback.posterUrl], + ] as const) { + const unlocked = new URL(url); + unlocked.searchParams.set("password", "hashed-secret"); + const media = await fetchMedia(route, unlocked.href); + expect(media.status).toBe(401); + expect(media.bytes).toBeNull(); + } + }); + + it("refuses a media URL whose grant is present but empty", async () => { + await updateRecording("rec-upload", { visibility: "public" }); + + for (const [route, path] of [ + [videoRoute, "video"], + [thumbnailRoute, "thumbnail"], + ] as const) { + const url = `${CLIPS_URL}/api/${path}/rec-upload`; + expect((await fetchMedia(route, url)).status).toBe(200); + expect((await fetchMedia(route, `${url}?content_grant=`)).status).toBe( + 404, + ); + } + }); + + it("keeps the owner's exceptions for a trashed, expired or unbound Clip", async () => { + await setBindingState("binding-doc-1", "trashed"); + await updateRecording("rec-upload", { + trashed_at: "2026-10-20T00:00:00.000Z", + expires_at: "2000-01-01T00:00:00.000Z", + password: "hashed-secret", + }); + + const result = await playback("rec-upload", await token({ sub: OWNER })); + expect(result.status).toBe(200); + expect(result.body.viewer).toMatchObject({ isOwner: true }); + const video = await fetchMedia(videoRoute, result.body.playback.videoUrl); + expect(video.status).toBe(200); + }); + + it("plays for a signed-out reader of a public doc through its binding", async () => { + const result = await playback("rec-upload", await publicReaderToken()); + + expect(result.status).toBe(200); + expect(result.body.viewer).toMatchObject({ + isOwner: false, + canComment: false, + }); + const video = await fetchMedia(videoRoute, result.body.playback.videoUrl); + expect(video.status).toBe(200); + }); +}); + +describe("a pasted Clip keeps its own sharing", () => { + beforeEach(async () => { + await insertRecording("rec-pasted"); + await insertBinding("binding-pasted", { + recordingId: "rec-pasted", + origin: "link", + }); + }); + + it("gives doc readers nothing through a link binding", async () => { + const result = await playback( + "rec-pasted", + await token({ recordingId: "rec-pasted" }), + ); + expect(result.status).toBe(404); + expect( + ( + await playback( + "rec-pasted", + await publicReaderToken({ recordingId: "rec-pasted" }), + ) + ).status, + ).toBe(404); + }); + + it("plays for a reader the Clip is shared with", async () => { + await state.db.insert(schema.recordingShares).values({ + id: "share-1", + resourceId: "rec-pasted", + principalType: "user", + principalId: READER, + role: "viewer", + createdBy: OWNER, + createdAt: "2026-10-01T00:00:00.000Z", + }); + + const shared = await playback( + "rec-pasted", + await token({ recordingId: "rec-pasted" }), + ); + expect(shared.status).toBe(200); + expect( + ( + await playback( + "rec-pasted", + await token({ recordingId: "rec-pasted", sub: OTHER_READER }), + ) + ).status, + ).toBe(404); + }); + + it("plays for a reader whose workspace the Clip is shared with", async () => { + await state.db.insert(organizations).values({ + id: "clips-org", + name: "Clips Org", + createdBy: OWNER, + createdAt: 1, + }); + await state.db.insert(orgMembers).values({ + id: "member-reader", + orgId: "clips-org", + email: READER, + role: "member", + joinedAt: 1, + }); + await state.db.insert(schema.recordingShares).values({ + id: "share-org", + resourceId: "rec-pasted", + principalType: "org", + principalId: "clips-org", + role: "viewer", + createdBy: OWNER, + createdAt: "2026-10-01T00:00:00.000Z", + }); + + const shared = await playback( + "rec-pasted", + await token({ recordingId: "rec-pasted" }), + ); + expect(shared.status).toBe(200); + const video = await fetchMedia(videoRoute, shared.body.playback.videoUrl); + expect(video.status).toBe(200); + expect( + ( + await playback( + "rec-pasted", + await token({ recordingId: "rec-pasted", sub: OTHER_READER }), + ) + ).status, + ).toBe(404); + }); + + it("plays a public Clip for signed-in and signed-out readers", async () => { + await updateRecording("rec-pasted", { visibility: "public" }); + + expect( + (await playback("rec-pasted", await token({ recordingId: "rec-pasted" }))) + .status, + ).toBe(200); + const anonymous = await playback( + "rec-pasted", + await publicReaderToken({ recordingId: "rec-pasted" }), + ); + expect(anonymous.status).toBe(200); + const video = await fetchMedia( + videoRoute, + anonymous.body.playback.videoUrl, + ); + expect(video.status).toBe(200); + }); +}); + +describe("video comments through a doc", () => { + async function seedComment( + id: string, + values: Partial = {}, + ) { + await state.db.insert(schema.recordingComments).values({ + id, + recordingId: "rec-upload", + organizationId: "clips-org", + threadId: id, + parentId: null, + authorEmail: OWNER, + authorName: "Owner", + content: `Comment ${id}`, + videoTimestampMs: 1_000, + emojiReactionsJson: "{}", + resolved: false, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + ...values, + }); + } + + async function storedComments() { + const { rows } = await client.query( + "SELECT id, author_email, content, parent_id, resolved, emoji_reactions_json FROM recording_comments ORDER BY created_at, id", + ); + return rows as Array>; + } + + it("lets a doc reader list, add, reply, edit, react, resolve and delete", async () => { + await seedComment("owner-comment"); + const write = await token({ scope: SCOPES.comment }); + + const listed = await listComments("rec-upload", await token()); + expect(listed.status).toBe(200); + expect(listed.body.comments.map((c: { id: string }) => c.id)).toEqual([ + "owner-comment", + ]); + + const added = await writeComment("rec-upload", write, { + operation: "add", + content: "From the doc", + videoTimestampMs: 2_500, + }); + expect(added.status).toBe(200); + const addedId = added.body.id as string; + + const replied = await writeComment("rec-upload", write, { + operation: "reply", + commentId: "owner-comment", + content: "A reply", + }); + expect(replied.status).toBe(200); + + expect( + ( + await writeComment("rec-upload", write, { + operation: "edit", + commentId: addedId, + content: "Edited from the doc", + }) + ).status, + ).toBe(200); + expect( + ( + await writeComment("rec-upload", write, { + operation: "react", + commentId: "owner-comment", + emoji: "👍", + }) + ).status, + ).toBe(200); + expect( + ( + await writeComment("rec-upload", write, { + operation: "resolve", + commentId: addedId, + resolved: true, + }) + ).status, + ).toBe(200); + + const stored = await storedComments(); + expect(stored).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: addedId, + author_email: READER, + content: "Edited from the doc", + resolved: true, + }), + expect.objectContaining({ + parent_id: "owner-comment", + author_email: READER, + content: "A reply", + }), + expect.objectContaining({ + id: "owner-comment", + emoji_reactions_json: JSON.stringify({ "👍": [READER] }), + }), + ]), + ); + + expect( + ( + await writeComment("rec-upload", write, { + operation: "delete", + commentId: addedId, + }) + ).status, + ).toBe(200); + expect( + (await storedComments()).some((comment) => comment.id === addedId), + ).toBe(false); + }); + + it("refuses to change another person's comment", async () => { + await seedComment("owner-comment"); + const write = await token({ scope: SCOPES.comment }); + + for (const body of [ + { operation: "edit", commentId: "owner-comment", content: "Hijacked" }, + { operation: "delete", commentId: "owner-comment" }, + { operation: "resolve", commentId: "owner-comment", resolved: true }, + ]) { + expect((await writeComment("rec-upload", write, body)).status).toBe(403); + } + expect(await storedComments()).toEqual([ + expect.objectContaining({ + id: "owner-comment", + content: "Comment owner-comment", + resolved: false, + }), + ]); + }); + + it("keeps a reader's own stronger role on the Clip", async () => { + await seedComment("owner-comment"); + await state.db.insert(schema.recordingShares).values({ + id: "share-editor", + resourceId: "rec-upload", + principalType: "user", + principalId: READER, + role: "editor", + createdBy: OWNER, + createdAt: "2026-10-01T00:00:00.000Z", + }); + + const result = await writeComment( + "rec-upload", + await token({ scope: SCOPES.comment }), + { operation: "resolve", commentId: "owner-comment", resolved: true }, + ); + expect(result.status).toBe(200); + expect(await storedComments()).toEqual([ + expect.objectContaining({ id: "owner-comment", resolved: true }), + ]); + }); + + it("refuses a comment that belongs to another recording", async () => { + await insertRecording("rec-other", { visibility: "public" }); + await seedComment("foreign-comment", { recordingId: "rec-other" }); + + const result = await writeComment( + "rec-upload", + await token({ scope: SCOPES.comment }), + { operation: "reply", commentId: "foreign-comment", content: "Hi" }, + ); + expect(result.status).toBe(404); + expect(await storedComments()).toHaveLength(1); + }); + + it("refuses comments to readers of a doc the Clip is not bound to", async () => { + await seedComment("owner-comment"); + expect( + (await listComments("rec-upload", await token({ documentId: "doc-2" }))) + .status, + ).toBe(404); + expect( + ( + await writeComment( + "rec-upload", + await token({ scope: SCOPES.comment, documentId: "doc-2" }), + { operation: "add", content: "Hi", videoTimestampMs: 0 }, + ) + ).status, + ).toBe(404); + }); + + describe("with comments turned off", () => { + beforeEach(async () => { + await seedComment("owner-comment"); + await updateRecording("rec-upload", { enable_comments: false }); + }); + + it("shows a signed-in or signed-out doc reader no comments", async () => { + for (const bearer of [await token(), await publicReaderToken()]) { + const result = await listComments("rec-upload", bearer); + expect(result.status).toBe(403); + expect(result.body).toMatchObject({ code: "comments_disabled" }); + expect(JSON.stringify(result.body)).not.toContain("owner-comment"); + } + const played = await playback("rec-upload", await token()); + expect(played.body.recording.enableComments).toBe(false); + expect(played.body.viewer.canComment).toBe(false); + }); + + it("lets no doc reader write", async () => { + await seedComment("reader-comment", { authorEmail: READER }); + const write = await token({ scope: SCOPES.comment }); + for (const body of [ + { operation: "add", content: "Hi", videoTimestampMs: 0 }, + { operation: "reply", commentId: "owner-comment", content: "Hi" }, + { operation: "edit", commentId: "reader-comment", content: "Hi" }, + { operation: "react", commentId: "owner-comment", emoji: "👍" }, + { operation: "resolve", commentId: "reader-comment", resolved: true }, + { operation: "delete", commentId: "reader-comment" }, + ]) { + const result = await writeComment("rec-upload", write, body); + expect(result.status).toBe(403); + expect(result.body).toMatchObject({ code: "comments_disabled" }); + } + expect(await storedComments()).toEqual([ + expect.objectContaining({ + id: "owner-comment", + emoji_reactions_json: "{}", + resolved: false, + }), + expect.objectContaining({ + id: "reader-comment", + content: "Comment reader-comment", + resolved: false, + }), + ]); + }); + + it("still shows the owner the saved comments", async () => { + const result = await listComments( + "rec-upload", + await token({ sub: OWNER }), + ); + expect(result.status).toBe(200); + expect(result.body.comments).toHaveLength(1); + }); + }); +}); + +describe("a signed-out reader's token", () => { + it("reads one recording's comments and nothing else", async () => { + await insertRecording("rec-other", { visibility: "private" }); + await insertBinding("binding-other", { + recordingId: "rec-other", + documentId: "doc-2", + }); + + expect( + (await listComments("rec-upload", await publicReaderToken())).status, + ).toBe(200); + // Its claims name rec-upload in doc-1, so another recording is refused + // even though that recording is bound to a doc of its own. + expect( + (await listComments("rec-other", await publicReaderToken())).status, + ).toBe(401); + expect( + (await playback("rec-other", await publicReaderToken())).status, + ).toBe(401); + expect( + ( + await playback( + "rec-upload", + await publicReaderToken({ documentId: "doc-2" }), + ) + ).status, + ).toBe(404); + }); + + it("can never write a comment", async () => { + const result = await writeComment("rec-upload", await publicReaderToken(), { + operation: "add", + content: "Anonymous", + videoTimestampMs: 0, + }); + expect(result.status).toBe(401); + + const asCommenter = await writeComment( + "rec-upload", + await token({ scope: SCOPES.comment, sub: CONTENT_CLIP_PUBLIC_READER }), + { operation: "add", content: "Anonymous", videoTimestampMs: 0 }, + ); + expect(asCommenter.status).toBe(401); + const { rows } = await client.query( + "SELECT count(*)::int AS n FROM recording_comments", + ); + expect(rows[0]).toEqual({ n: 0 }); + }); + + it("is refused when it carries a person instead of the public reader", async () => { + expect( + (await playback("rec-upload", await publicReaderToken({ sub: READER }))) + .status, + ).toBe(401); + expect( + ( + await playback( + "rec-upload", + await token({ sub: CONTENT_CLIP_PUBLIC_READER }), + ) + ).status, + ).toBe(401); + }); +}); + +describe("token checks", () => { + const now = () => Math.floor(Date.now() / 1000); + + it.each<[string, () => Promise]>([ + ["no bearer", async () => undefined], + [ + "a wrong audience", + () => token({ audience: `${CLIPS_URL}/private-icon` }), + ], + ["another app's audience", () => token({ audience: CONTENT_APP })], + ["no audience", () => token({ audience: null })], + ["a wrong scope", () => token({ scope: "private-icon:read" })], + ["no document claim", () => token({ documentId: null })], + ["no recording claim", () => token({ recordingId: null })], + ["another recording's claim", () => token({ recordingId: "rec-other" })], + ["no subject", () => token({ sub: null })], + ["no issuer", () => token({ issuer: null })], + ["a non-http issuer", () => token({ issuer: "urn:content" })], + ["another secret", () => token({ secret: "not-the-deploy-secret-012345" })], + [ + "an expired token", + () => token({ issuedAt: now() - 120, expiresAt: now() - 60 }), + ], + [ + "a lifetime over five minutes", + () => token({ issuedAt: now(), expiresAt: now() + 60 * 60 }), + ], + [ + "a typed credential", + () => token({ extra: { typ: "agent-native-mcp-oauth" } }), + ], + ])("refuses %s", async (_name, mint) => { + const result = await playback("rec-upload", await mint()); + expect(result.status).toBe(401); + expect(JSON.stringify(result.body)).not.toContain("rec-upload"); + }); + + it("refuses a read token for a write", async () => { + const result = await writeComment("rec-upload", await token(), { + operation: "add", + content: "Hi", + videoTimestampMs: 0, + }); + expect(result.status).toBe(401); + }); + + it("refuses a comment token for a read", async () => { + const result = await playback( + "rec-upload", + await token({ scope: SCOPES.comment }), + ); + expect(result.status).toBe(401); + }); + + it("accepts a token minted by the shared A2A signer", async () => { + const bearer = await signA2AToken(READER, undefined, undefined, { + preferGlobalSecret: true, + expiresIn: "1m", + audience: `${CLIPS_URL}/content-clips`, + extraClaims: { + scope: SCOPES.read, + document_id: "doc-1", + recording_id: "rec-upload", + }, + }); + // The shared signer names this app as its issuer, so bind the Clip to it. + await insertBinding("binding-self", { + contentApp: CLIPS_URL, + documentId: "doc-1", + }); + expect((await playback("rec-upload", bearer)).status).toBe(200); + }); + + it("refuses a token from another Content app for this doc", async () => { + const result = await playback( + "rec-upload", + await token({ issuer: "https://other-content.example.test" }), + ); + expect(result.status).toBe(404); + }); + + it("rejects malformed comment requests", async () => { + const write = await token({ scope: SCOPES.comment }); + for (const body of [ + { operation: "add", content: "", videoTimestampMs: 0 }, + { operation: "add", content: "x".repeat(10_001), videoTimestampMs: 0 }, + { operation: "add", content: "Hi", videoTimestampMs: -1 }, + { operation: "rename", commentId: "c" }, + { operation: "react", commentId: "c", emoji: "x".repeat(65) }, + ]) { + expect((await writeComment("rec-upload", write, body)).status).toBe(400); + } + }); + + it("rate-limits one reader's comment writes", async () => { + const write = await token({ scope: SCOPES.comment }); + const statuses: number[] = []; + for (let attempt = 0; attempt < 31; attempt++) { + statuses.push( + ( + await writeComment("rec-upload", write, { + operation: "add", + content: `Comment ${attempt}`, + videoTimestampMs: attempt, + }) + ).status, + ); + } + expect(statuses.slice(0, 30).every((status) => status === 200)).toBe(true); + expect(statuses[30]).toBe(429); + }); +}); diff --git a/templates/clips/server/lib/content-doc-grant.ts b/templates/clips/server/lib/content-doc-grant.ts new file mode 100644 index 00000000000..85e3ece00fa --- /dev/null +++ b/templates/clips/server/lib/content-doc-grant.ts @@ -0,0 +1,50 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { resolveAccess, type AccessContext } from "@agent-native/core/sharing"; + +/** + * The Content document binding a request was vouched in through, and the + * reader Content vouched for (null for a signed-out reader of a public doc). + */ +export interface ContentDocGrant { + bindingId: string; + recordingId: string; + origin: "upload" | "link"; + state: "active" | "trashed"; + viewerEmail: string | null; +} + +const grants = new AsyncLocalStorage(); + +export function runWithContentDocGrant( + grant: ContentDocGrant, + fn: () => T, +): T { + return grants.run(grant, fn); +} + +/** + * Host access resolver for recordings. Inside a vouched Content request, an + * active upload binding makes a reader with no access of their own a viewer + * of that one recording. It never denies and never lowers a role; the + * identity check keeps a nested request for someone else (a notification + * recipient, say) from inheriting the reader's grant. + */ +export async function contentDocReaderRole( + resource: { id?: unknown }, + ctx: AccessContext, +): Promise<"viewer" | undefined> { + const grant = grants.getStore(); + if (!grant || grant.origin !== "upload" || grant.state !== "active") { + return undefined; + } + if (resource.id !== grant.recordingId) return undefined; + const userEmail = ctx.userEmail?.trim().toLowerCase() || null; + if (userEmail !== grant.viewerEmail) return undefined; + // A host role replaces the reader's share roles, so a reader who can + // already open the Clip (an editor, say) must not be handed `viewer`. + const own = await grants.exit(() => + resolveAccess("recording", grant.recordingId, ctx), + ); + return own ? undefined : "viewer"; +} diff --git a/templates/clips/server/lib/content-playback-grant.ts b/templates/clips/server/lib/content-playback-grant.ts new file mode 100644 index 00000000000..3ec68bb072d --- /dev/null +++ b/templates/clips/server/lib/content-playback-grant.ts @@ -0,0 +1,134 @@ +import { resolveOrgIdForEmail } from "@agent-native/core/org"; +import { + signShortLivedToken, + verifyShortLivedToken, +} from "@agent-native/core/server"; +import { getQuery, type H3Event } from "h3"; + +import { + contentDocGrantFor, + readContentBinding, +} from "./content-clip-bindings.js"; +import { + runWithContentDocGrant, + type ContentDocGrant, +} from "./content-doc-grant.js"; + +// A Content page plays the video and poster straight from Clips, so their +// URLs carry who Content vouched for. The grant names the reader, never a +// role: each media request re-runs every gate, so trashing the binding or the +// Clip stops a URL that was already handed out. +const PLAYBACK_GRANT_TTL_SECONDS = 60 * 60; +const GRANT_PARAM = "content_grant"; +const BINDING_PARAM = "content_binding"; + +export type ContentPlaybackAccess = + | { status: "absent" } + | { status: "invalid" } + | { + status: "granted"; + viewerEmail: string | null; + orgId: string | undefined; + docGrant: ContentDocGrant | null; + }; + +/** + * The workspace a vouched reader acts in, the one their own Clips session + * would pick, so a Clip shared with that workspace still opens for them. + */ +export async function contentReaderOrgId( + viewerEmail: string | null, +): Promise { + if (!viewerEmail) return undefined; + return (await resolveOrgIdForEmail(viewerEmail)) ?? undefined; +} + +function grantResourceId(recordingId: string, bindingId: string | null) { + return JSON.stringify(["content-clip-playback", recordingId, bindingId]); +} + +function firstQueryValue(value: unknown): string { + if (Array.isArray(value)) return firstQueryValue(value[0]); + return typeof value === "string" ? value : ""; +} + +export function signContentPlaybackGrant(input: { + recordingId: string; + bindingId: string | null; + viewerEmail: string | null; +}): { params: Record; expiresAt: string } { + const issuedAtSeconds = Math.floor(Date.now() / 1000); + const token = signShortLivedToken({ + resourceId: grantResourceId(input.recordingId, input.bindingId), + viewerEmail: input.viewerEmail ?? undefined, + ttlSeconds: PLAYBACK_GRANT_TTL_SECONDS, + }); + return { + params: { + [GRANT_PARAM]: token, + ...(input.bindingId ? { [BINDING_PARAM]: input.bindingId } : {}), + }, + expiresAt: new Date( + (issuedAtSeconds + PLAYBACK_GRANT_TTL_SECONDS) * 1000, + ).toISOString(), + }; +} + +export function withContentPlaybackGrant( + url: string, + params: Record, +): string { + const parsed = new URL(url); + for (const [key, value] of Object.entries(params)) { + parsed.searchParams.set(key, value); + } + return parsed.href; +} + +/** + * Reads a media request's Content grant. A grant that is present but does not + * verify for this recording is `invalid`, never `absent`, so a tampered URL + * cannot fall back to the browser's own Clips session. + */ +export async function readContentPlaybackGrant( + event: H3Event, + recordingId: string, +): Promise { + const query = getQuery(event); + if (query[GRANT_PARAM] === undefined && query[BINDING_PARAM] === undefined) { + return { status: "absent" }; + } + const token = firstQueryValue(query[GRANT_PARAM]); + const bindingId = firstQueryValue(query[BINDING_PARAM]) || null; + if (!token) return { status: "invalid" }; + const verified = verifyShortLivedToken( + token, + grantResourceId(recordingId, bindingId), + ); + if (!verified.ok) return { status: "invalid" }; + const viewerEmail = verified.viewerEmail?.trim().toLowerCase() || null; + const orgId = await contentReaderOrgId(viewerEmail); + if (!bindingId) { + return { status: "granted", viewerEmail, orgId, docGrant: null }; + } + + const binding = await readContentBinding(bindingId); + if (binding && binding.recordingId !== recordingId) { + return { status: "invalid" }; + } + return { + status: "granted", + viewerEmail, + orgId, + docGrant: binding ? contentDocGrantFor(binding, viewerEmail) : null, + }; +} + +export function runWithContentPlayback( + access: ContentPlaybackAccess, + fn: () => T, +): T { + return access.status === "granted" && access.docGrant + ? runWithContentDocGrant(access.docGrant, fn) + : fn(); +} diff --git a/templates/clips/server/plugins/auth.ts b/templates/clips/server/plugins/auth.ts index 9838fb83832..f9bdffef836 100644 --- a/templates/clips/server/plugins/auth.ts +++ b/templates/clips/server/plugins/auth.ts @@ -31,6 +31,10 @@ export default createToolkitAuthPlugin({ // Chunk POSTs from an outside agent carry no session cookie; the route // refuses anything without a ticket for its own recording. "/api/uploads/:recordingId/agent-chunk", + // Content calls these server to server; each refuses anything without a + // Content token naming that recording. + "/api/content-clips/:recordingId/playback", + "/api/content-clips/:recordingId/comments", "/__manifest", "/api/view-event", "/api/public-recording", diff --git a/templates/clips/server/plugins/db.ts b/templates/clips/server/plugins/db.ts index c41a7337c52..9a85053bc19 100644 --- a/templates/clips/server/plugins/db.ts +++ b/templates/clips/server/plugins/db.ts @@ -1325,6 +1325,29 @@ export const migrations = runMigrations( CREATE INDEX IF NOT EXISTS recording_transcript_versions_recording_created_idx ON recording_transcript_versions (recording_id, created_at)`, }, + { + version: 93, + name: "recording-content-bindings", + // Mirrors `recordingContentBindings` in server/db/schema.ts. `state` has + // no CHECK so later lifecycle states need no migration. + sql: `CREATE TABLE IF NOT EXISTS recording_content_bindings ( + id TEXT PRIMARY KEY, + recording_id TEXT NOT NULL, + content_app TEXT NOT NULL, + document_id TEXT NOT NULL, + block_id TEXT, + origin TEXT NOT NULL CHECK (origin IN ('upload', 'link')), + state TEXT NOT NULL DEFAULT 'active', + state_changed_at TEXT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ); + CREATE UNIQUE INDEX IF NOT EXISTS recording_content_bindings_recording_document_unique_idx + ON recording_content_bindings (recording_id, content_app, document_id); + CREATE INDEX IF NOT EXISTS recording_content_bindings_document_idx + ON recording_content_bindings (content_app, document_id)`, + }, ]), { table: "clips_migrations" }, ); diff --git a/templates/clips/server/routes/api/content-clips/[recordingId]/comments.get.ts b/templates/clips/server/routes/api/content-clips/[recordingId]/comments.get.ts new file mode 100644 index 00000000000..68bb307f427 --- /dev/null +++ b/templates/clips/server/routes/api/content-clips/[recordingId]/comments.get.ts @@ -0,0 +1,6 @@ +// guard:allow-api-route — Content calls this server to server with a document-scoped A2A token, which no action route accepts. +import { defineEventHandler } from "h3"; + +import { handleContentClipComments } from "../../../../lib/content-clip-peer.js"; + +export default defineEventHandler(handleContentClipComments); diff --git a/templates/clips/server/routes/api/content-clips/[recordingId]/comments.post.ts b/templates/clips/server/routes/api/content-clips/[recordingId]/comments.post.ts new file mode 100644 index 00000000000..37a6c446594 --- /dev/null +++ b/templates/clips/server/routes/api/content-clips/[recordingId]/comments.post.ts @@ -0,0 +1,6 @@ +// guard:allow-api-route — Content calls this server to server with a document-scoped A2A token, which no action route accepts. +import { defineEventHandler } from "h3"; + +import { handleContentClipCommentWrite } from "../../../../lib/content-clip-peer.js"; + +export default defineEventHandler(handleContentClipCommentWrite); diff --git a/templates/clips/server/routes/api/content-clips/[recordingId]/playback.get.ts b/templates/clips/server/routes/api/content-clips/[recordingId]/playback.get.ts new file mode 100644 index 00000000000..edd6d1e0cac --- /dev/null +++ b/templates/clips/server/routes/api/content-clips/[recordingId]/playback.get.ts @@ -0,0 +1,6 @@ +// guard:allow-api-route — Content calls this server to server with a document-scoped A2A token, which no action route accepts. +import { defineEventHandler } from "h3"; + +import { handleContentClipPlayback } from "../../../../lib/content-clip-peer.js"; + +export default defineEventHandler(handleContentClipPlayback); diff --git a/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts b/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts index 961c6c45da3..685d0055054 100644 --- a/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +++ b/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts @@ -29,6 +29,10 @@ import { } from "h3"; import { getDb, schema } from "../../../db/index.js"; +import { + readContentPlaybackGrant, + runWithContentPlayback, +} from "../../../lib/content-playback-grant.js"; import { contextClipIdForFootage } from "../../../lib/context-footage.js"; import { isHeldForRedaction, @@ -356,7 +360,10 @@ async function bucketImageResponse(upstream: Response): Promise { return imageResponse(upstream.body, contentType, upstream.status); } -async function loadRecording(recordingId: string, event: H3Event) { +async function loadRecording( + recordingId: string, + viewerEmail: string | undefined, +) { // Earlier-screen-time footage is served under its Clip's access, expiry, // redaction, Trash, and password rules, never its own row's. const clipId = await contextClipIdForFootage(recordingId); @@ -364,7 +371,6 @@ async function loadRecording(recordingId: string, event: H3Event) { const access = await resolveAccess("recording", gateId); let gate = (access?.resource as ThumbnailRecording | undefined) ?? null; const role = access?.role ?? null; - const session = await getSession(event).catch(() => null); if (!gate) { const [row] = await getDb() @@ -392,10 +398,10 @@ async function loadRecording(recordingId: string, event: H3Event) { if (!row) return { error: "Not found", status: 404 } as const; let viewerIsOrgMember = false; - if (session?.email && row.visibility === "org" && row.organizationId) { + if (viewerEmail && row.visibility === "org" && row.organizationId) { const orgRole = await getOrganizationRoleForEmail( row.organizationId, - session.email, + viewerEmail, ).catch(() => null); viewerIsOrgMember = Boolean(orgRole); } @@ -452,13 +458,26 @@ export default defineEventHandler(async (event: H3Event) => { return { error: "Missing recordingId" }; } + const contentGrant = await readContentPlaybackGrant(event, recordingId); + if (contentGrant.status === "invalid") { + setResponseStatus(event, 404); + return { error: "Not found" }; + } + const session = await getSession(event).catch(() => null); const orgCtx = await getOrgContext(event).catch(() => null); - - return runWithRequestContext( - { userEmail: session?.email, orgId: orgCtx?.orgId ?? session?.orgId }, - async () => { - const loaded = await loadRecording(recordingId, event); + // A Content grant names the reader in place of the browser's own session. + const granted = contentGrant.status === "granted"; + const orgId = granted + ? contentGrant.orgId + : (orgCtx?.orgId ?? session?.orgId); + const viewerEmail = granted + ? (contentGrant.viewerEmail ?? undefined) + : session?.email; + + return runWithRequestContext({ userEmail: viewerEmail, orgId }, () => + runWithContentPlayback(contentGrant, async () => { + const loaded = await loadRecording(recordingId, viewerEmail); if ("error" in loaded) { setResponseStatus(event, loaded.status); return { error: loaded.error }; @@ -505,6 +524,12 @@ export default defineEventHandler(async (event: H3Event) => { } } if (recording.password && loaded.role !== "owner") { + // A document cannot supply a password, so nothing unlocks one for a + // Content reader; the Content playback route refuses it the same way. + if (granted) { + setResponseStatus(event, 401); + return { error: "Password required", passwordRequired: true }; + } const queryToken = typeof query.t === "string" ? query.t : ""; const cookieToken = getCookie(event, cookieName(recordingId)) ?? ""; const scopedRecordingId = getRecordingAccessTokenResourceId( @@ -596,6 +621,6 @@ export default defineEventHandler(async (event: H3Event) => { }); return defaultSocialImageResponse(); } - }, + }), ); }); diff --git a/templates/clips/server/routes/api/video/[recordingId].get.ts b/templates/clips/server/routes/api/video/[recordingId].get.ts index 94718d344ea..3341925233a 100644 --- a/templates/clips/server/routes/api/video/[recordingId].get.ts +++ b/templates/clips/server/routes/api/video/[recordingId].get.ts @@ -18,6 +18,10 @@ * - non-public: caller must have a share grant (owner / viewer / editor / * admin) via `resolveAccess`. Password is still enforced on top. * - expired recordings 410. + * - a Content playback grant (`content_grant`, from the Content playback + * route) names the reader in place of the browser session and never + * unlocks a password; a grant that does not verify for this recording, + * including an empty one, is a 404. * - earlier-screen-time footage is a private recording of its own that is * shared with the clip it is attached to: every rule above is evaluated * against that clip, and the footage's bytes are served on its behalf. @@ -72,6 +76,10 @@ import { youtubeWatchUrl, } from "../../../../shared/youtube.js"; import { getDb, schema } from "../../../db/index.js"; +import { + readContentPlaybackGrant, + runWithContentPlayback, +} from "../../../lib/content-playback-grant.js"; import { contextClipIdForFootage } from "../../../lib/context-footage.js"; import { allowsLegacyS3ObjectForPersistedMedia } from "../../../lib/media-storage-provenance.js"; import { @@ -333,13 +341,25 @@ export default defineEventHandler(async (event: H3Event) => { return { error: "Missing recordingId" }; } + const contentGrant = await readContentPlaybackGrant(event, recordingId); + if (contentGrant.status === "invalid") { + setResponseStatus(event, 404); + return { error: "Not found" }; + } + const session = await getSession(event).catch(() => null); const orgCtx = await getOrgContext(event).catch(() => null); - const orgId = orgCtx?.orgId ?? session?.orgId ?? undefined; - - return runWithRequestContext( - { userEmail: session?.email, orgId }, - async () => { + // A Content grant names the reader in place of the browser's own session. + const granted = contentGrant.status === "granted"; + const orgId = granted + ? contentGrant.orgId + : (orgCtx?.orgId ?? session?.orgId ?? undefined); + const viewerEmail = granted + ? (contentGrant.viewerEmail ?? undefined) + : session?.email; + + return runWithRequestContext({ userEmail: viewerEmail, orgId }, () => + runWithContentPlayback(contentGrant, async () => { const clipId = await contextClipIdForFootage(recordingId); // Access, expiry, redaction, and password are the clip's, not the footage's. const gateId = clipId ?? recordingId; @@ -360,11 +380,11 @@ export default defineEventHandler(async (event: H3Event) => { return { error: "Not found" }; } let viewerIsOrgMember = false; - if (session?.email && row.visibility === "org" && row.organizationId) { + if (viewerEmail && row.visibility === "org" && row.organizationId) { try { const orgRole = await getOrganizationRoleForEmail( row.organizationId, - session.email, + viewerEmail, ); viewerIsOrgMember = Boolean(orgRole); } catch { @@ -444,6 +464,12 @@ export default defineEventHandler(async (event: H3Event) => { t?: string; }; if (gate.password && role !== "owner") { + // A document cannot supply a password, so nothing unlocks one for a + // Content reader; the Content playback route refuses it the same way. + if (granted) { + setResponseStatus(event, 401); + return { error: "Password required", passwordRequired: true }; + } const token = typeof q.t === "string" ? q.t : ""; const cookieToken = getCookie(event, protectedMediaCookieName(recordingId)) ?? ""; @@ -696,6 +722,6 @@ export default defineEventHandler(async (event: H3Event) => { setResponseHeader(event, "Content-Length", String(total)); return bytes; - }, + }), ); }); diff --git a/templates/clips/shared/content-clip-contract.ts b/templates/clips/shared/content-clip-contract.ts new file mode 100644 index 00000000000..1d309aa0e54 --- /dev/null +++ b/templates/clips/shared/content-clip-contract.ts @@ -0,0 +1,33 @@ +// The contract Agent-Native Content uses to call Clips server to server about +// a Clip in one of its documents. Content mints an A2A token with +// `signA2AToken` from the deployment `A2A_SECRET`, bound to this audience path +// on the Clips app URL, and carrying one scope plus the document and recording +// it vouches for. Content keeps its own copy of these strings. + +/** Appended to the Clips app URL to form the token's `aud`. */ +export const CONTENT_CLIP_AUDIENCE_PATH = "content-clips"; + +export const CONTENT_CLIP_SCOPES = { + /** A signed-in document reader: playback and the comment list. */ + read: "content-clip:read", + /** A signed-in document reader: adding and changing comments. */ + comment: "content-clip:comment", + /** A signed-out reader of a public document: playback and the comment list. */ + publicRead: "content-clip:public-read", +} as const; + +export type ContentClipScope = + (typeof CONTENT_CLIP_SCOPES)[keyof typeof CONTENT_CLIP_SCOPES]; + +/** The token subject for a signed-out reader; it is never treated as a user. */ +export const CONTENT_CLIP_PUBLIC_READER = "content-public-doc-reader"; + +export const CONTENT_CLIP_CLAIMS = { + documentId: "document_id", + recordingId: "recording_id", + /** Optional display name of a signed-in reader, used on their comments. */ + viewerName: "name", +} as const; + +/** Longest token lifetime the routes accept; Content mints one-minute tokens. */ +export const CONTENT_CLIP_MAX_TOKEN_SECONDS = 5 * 60; From 174a4c5ccb0bba706f1e3c7e8eac2b0dbec86772 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:59:59 -0400 Subject: [PATCH 06/35] Expire Content playback grants in five minutes and close review gaps --- .../.agents/skills/video-sharing/SKILL.md | 2 +- .../clips/server/lib/content-clip-peer.ts | 4 + .../clips/server/lib/content-clips.test.ts | 140 ++++++++++++++++-- .../server/lib/content-playback-grant.ts | 38 ++++- .../routes/api/thumbnail/[recordingId].get.ts | 8 +- .../routes/api/video/[recordingId].get.ts | 17 ++- .../clips/shared/content-clip-contract.ts | 18 +++ 7 files changed, 201 insertions(+), 26 deletions(-) diff --git a/templates/clips/.agents/skills/video-sharing/SKILL.md b/templates/clips/.agents/skills/video-sharing/SKILL.md index 48bb039ae8a..108fb893752 100644 --- a/templates/clips/.agents/skills/video-sharing/SKILL.md +++ b/templates/clips/.agents/skills/video-sharing/SKILL.md @@ -144,7 +144,7 @@ A Clip placed in a Content document has one `recording_content_bindings` row per - Only an active `upload` binding grants access: a Content reader with no access of their own becomes a viewer of that one recording (`server/lib/content-doc-grant.ts`, a host access resolver). A reader's own Clips sharing, in their active workspace, still applies and is never lowered. A `link` binding, or none, leaves the Clip's own sharing in charge. When every binding is trashed, document readers lose access; the owner never does. - The Clip's own rules still apply on top: Trash, takedown and expiry refuse document readers, a password-protected Clip does not play through a document, and with comments off document readers see no comments and cannot write. - Content calls `/api/content-clips/:recordingId/playback` and `/comments` (GET to list, POST to add, reply, edit, delete, resolve or react) server to server with a one-minute A2A token. `shared/content-clip-contract.ts` is the contract: audience `/content-clips`, one scope, and `document_id` + `recording_id` claims. A signed-out reader of a public document gets the `content-clip:public-read` scope: playback and the comment list, never a write. -- Playback returns Clips media URLs with a one-hour `content_grant` that names the reader, not a role. `/api/video` and `/api/thumbnail` re-run every gate on each request, so trashing the binding stops a URL already handed out. +- Playback returns Clips media URLs with a five-minute `content_grant` that names the reader, not a role. `/api/video` and `/api/thumbnail` re-run every gate on each request, so trashing the binding stops a URL already handed out. An expired grant is a 403 with code `content_grant_expired`; Content's player then asks Content for playback again, which re-checks document access. - Create and change binding rows only through Content's upload and lifecycle flows. ## Embed URLs diff --git a/templates/clips/server/lib/content-clip-peer.ts b/templates/clips/server/lib/content-clip-peer.ts index 2eaa8eb16b8..cd89817b25c 100644 --- a/templates/clips/server/lib/content-clip-peer.ts +++ b/templates/clips/server/lib/content-clip-peer.ts @@ -45,6 +45,7 @@ import { signContentPlaybackGrant, withContentPlaybackGrant, } from "./content-playback-grant.js"; +import { contextClipIdForFootage } from "./context-footage.js"; import { isHeldForRedaction, REDACTION_HOLD_MESSAGE, @@ -273,6 +274,9 @@ async function withOpenedClip( peer: ContentClipPeer, fn: (opened: OpenedClip) => Promise, ): Promise { + // Earlier-screen-time footage plays only through the Clip it is attached + // to, under that Clip's rules; a document never holds it on its own. + if (await contextClipIdForFootage(peer.recordingId)) return notFound(); const binding = await findContentBinding(peer); const orgId = await contentReaderOrgId(peer.viewerEmail); const open = () => diff --git a/templates/clips/server/lib/content-clips.test.ts b/templates/clips/server/lib/content-clips.test.ts index c27630fcc41..ed260c0899e 100644 --- a/templates/clips/server/lib/content-clips.test.ts +++ b/templates/clips/server/lib/content-clips.test.ts @@ -62,8 +62,12 @@ const jose = await import(pathToFileURL(coreRequire.resolve("jose")).href); const { organizations, orgMembers } = await import("@agent-native/core/org"); const { signA2AToken } = await import("@agent-native/core/a2a"); const { schema } = await import("../db/index.js"); -const { CONTENT_CLIP_PUBLIC_READER, CONTENT_CLIP_SCOPES: SCOPES } = - await import("../../shared/content-clip-contract.js"); +const { + CONTENT_CLIP_GRANT_EXPIRED, + CONTENT_CLIP_PLAYBACK_GRANT_SECONDS, + CONTENT_CLIP_PUBLIC_READER, + CONTENT_CLIP_SCOPES: SCOPES, +} = await import("../../shared/content-clip-contract.js"); const { __resetContentClipRateLimitsForTests } = await import("./content-clip-peer.js"); const { default: playbackRoute } = @@ -290,9 +294,17 @@ async function fetchMedia( return { status: event.res.status ?? 200, bytes: result instanceof Uint8Array ? result : null, + body: result instanceof Uint8Array ? null : (result as any), }; } +function afterGrantExpires(issuedAt: number) { + vi.useFakeTimers({ + toFake: ["Date"], + now: issuedAt + (CONTENT_CLIP_PLAYBACK_GRANT_SECONDS + 1) * 1000, + }); +} + beforeEach(async () => { client = await PGlite.create("memory://"); state.db = drizzle(client); @@ -368,9 +380,60 @@ describe("Content doc readers play a Clip uploaded to the doc", () => { const result = await playback("rec-upload", await token()); await setBindingState("binding-doc-1", "trashed"); - const video = await fetchMedia(videoRoute, result.body.playback.videoUrl); - expect(video.status).not.toBe(200); - expect(video.bytes).toBeNull(); + for (const [route, url] of [ + [videoRoute, result.body.playback.videoUrl], + [thumbnailRoute, result.body.playback.posterUrl], + ] as const) { + const media = await fetchMedia(route, url); + expect(media.status).toBe(404); + expect(media.bytes).toBeNull(); + } + }); + + it("refuses an expired playback URL with a code the player refreshes on", async () => { + const issuedAt = Date.now(); + const result = await playback("rec-upload", await token()); + expect(Date.parse(result.body.playback.expiresAt)).toBeLessThanOrEqual( + issuedAt + (CONTENT_CLIP_PLAYBACK_GRANT_SECONDS + 1) * 1000, + ); + + afterGrantExpires(issuedAt); + try { + for (const [route, url] of [ + [videoRoute, result.body.playback.videoUrl], + [thumbnailRoute, result.body.playback.posterUrl], + ] as const) { + const media = await fetchMedia(route, url); + expect(media.status).toBe(403); + expect(media.body).toMatchObject({ code: CONTENT_CLIP_GRANT_EXPIRED }); + expect(media.bytes).toBeNull(); + } + } finally { + vi.useRealTimers(); + } + }); + + it("gives a reader Content no longer vouches for no fresh grant", async () => { + const issuedAt = Date.now(); + const vouched = await token(); + expect((await playback("rec-upload", vouched)).status).toBe(200); + + afterGrantExpires(issuedAt); + try { + const stale = await playback("rec-upload", vouched); + expect(stale.status).toBe(401); + expect(stale.body.playback).toBeUndefined(); + + const renewed = await playback("rec-upload", await token()); + expect(renewed.status).toBe(200); + const video = await fetchMedia( + videoRoute, + renewed.body.playback.videoUrl, + ); + expect(video.status).toBe(200); + } finally { + vi.useRealTimers(); + } }); it("refuses a playback URL whose binding was swapped for another", async () => { @@ -467,6 +530,46 @@ describe("Content doc readers play a Clip uploaded to the doc", () => { expect(video.status).toBe(200); }); + it("refuses earlier-screen-time footage, which only plays through its Clip", async () => { + await insertRecording("rec-footage"); + await state.db.insert(schema.recordingContextItems).values({ + id: "context-1", + recordingId: "rec-upload", + requestedSeconds: 60, + originalStartedAt: "2026-10-01T00:00:00.000Z", + originalEndedAt: "2026-10-01T00:01:00.000Z", + startedAt: "2026-10-01T00:00:00.000Z", + endedAt: "2026-10-01T00:01:00.000Z", + status: "ready", + mediaRecordingId: "rec-footage", + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + }); + await insertBinding("binding-footage", { recordingId: "rec-footage" }); + const bearer = await token({ recordingId: "rec-footage" }); + + expect((await playback("rec-footage", bearer)).status).toBe(404); + expect((await listComments("rec-footage", bearer)).status).toBe(404); + }); + + it("hands out a YouTube Clip's own embed URL, checked only when issued", async () => { + await insertRecording("rec-youtube", { + videoUrl: "https://www.youtube.com/embed/abcdefghijk", + }); + await insertBinding("binding-youtube", { recordingId: "rec-youtube" }); + const bearer = await token({ recordingId: "rec-youtube" }); + + const result = await playback("rec-youtube", bearer); + expect(result.status).toBe(200); + expect(result.body.recording.embedProvider).toBe("youtube"); + const videoUrl = new URL(result.body.playback.videoUrl); + expect(videoUrl.hostname).toMatch(/youtube/); + expect(videoUrl.searchParams.has("content_grant")).toBe(false); + + await setBindingState("binding-youtube", "trashed"); + expect((await playback("rec-youtube", bearer)).status).toBe(404); + }); + it("plays for a signed-out reader of a public doc through its binding", async () => { const result = await playback("rec-upload", await publicReaderToken()); @@ -756,13 +859,24 @@ describe("video comments through a doc", () => { await insertRecording("rec-other", { visibility: "public" }); await seedComment("foreign-comment", { recordingId: "rec-other" }); - const result = await writeComment( - "rec-upload", - await token({ scope: SCOPES.comment }), + const write = await token({ scope: SCOPES.comment }); + for (const body of [ { operation: "reply", commentId: "foreign-comment", content: "Hi" }, - ); - expect(result.status).toBe(404); - expect(await storedComments()).toHaveLength(1); + { operation: "edit", commentId: "foreign-comment", content: "Hi" }, + { operation: "delete", commentId: "foreign-comment" }, + { operation: "resolve", commentId: "foreign-comment", resolved: true }, + { operation: "react", commentId: "foreign-comment", emoji: "👍" }, + ]) { + expect((await writeComment("rec-upload", write, body)).status).toBe(404); + } + expect(await storedComments()).toEqual([ + expect.objectContaining({ + id: "foreign-comment", + content: "Comment foreign-comment", + resolved: false, + emoji_reactions_json: "{}", + }), + ]); }); it("refuses comments to readers of a doc the Clip is not bound to", async () => { @@ -928,6 +1042,10 @@ describe("token checks", () => { "an expired token", () => token({ issuedAt: now() - 120, expiresAt: now() - 60 }), ], + [ + "an issue time in the future", + () => token({ issuedAt: now() + 300, expiresAt: now() + 360 }), + ], [ "a lifetime over five minutes", () => token({ issuedAt: now(), expiresAt: now() + 60 * 60 }), diff --git a/templates/clips/server/lib/content-playback-grant.ts b/templates/clips/server/lib/content-playback-grant.ts index 3ec68bb072d..ee9078d826c 100644 --- a/templates/clips/server/lib/content-playback-grant.ts +++ b/templates/clips/server/lib/content-playback-grant.ts @@ -5,6 +5,10 @@ import { } from "@agent-native/core/server"; import { getQuery, type H3Event } from "h3"; +import { + CONTENT_CLIP_GRANT_EXPIRED, + CONTENT_CLIP_PLAYBACK_GRANT_SECONDS, +} from "../../shared/content-clip-contract.js"; import { contentDocGrantFor, readContentBinding, @@ -18,13 +22,13 @@ import { // URLs carry who Content vouched for. The grant names the reader, never a // role: each media request re-runs every gate, so trashing the binding or the // Clip stops a URL that was already handed out. -const PLAYBACK_GRANT_TTL_SECONDS = 60 * 60; const GRANT_PARAM = "content_grant"; const BINDING_PARAM = "content_binding"; export type ContentPlaybackAccess = | { status: "absent" } | { status: "invalid" } + | { status: "expired" } | { status: "granted"; viewerEmail: string | null; @@ -61,7 +65,7 @@ export function signContentPlaybackGrant(input: { const token = signShortLivedToken({ resourceId: grantResourceId(input.recordingId, input.bindingId), viewerEmail: input.viewerEmail ?? undefined, - ttlSeconds: PLAYBACK_GRANT_TTL_SECONDS, + ttlSeconds: CONTENT_CLIP_PLAYBACK_GRANT_SECONDS, }); return { params: { @@ -69,7 +73,7 @@ export function signContentPlaybackGrant(input: { ...(input.bindingId ? { [BINDING_PARAM]: input.bindingId } : {}), }, expiresAt: new Date( - (issuedAtSeconds + PLAYBACK_GRANT_TTL_SECONDS) * 1000, + (issuedAtSeconds + CONTENT_CLIP_PLAYBACK_GRANT_SECONDS) * 1000, ).toISOString(), }; } @@ -88,7 +92,8 @@ export function withContentPlaybackGrant( /** * Reads a media request's Content grant. A grant that is present but does not * verify for this recording is `invalid`, never `absent`, so a tampered URL - * cannot fall back to the browser's own Clips session. + * cannot fall back to the browser's own Clips session. A genuine grant that + * ran out is `expired`, which Content's player answers by asking again. */ export async function readContentPlaybackGrant( event: H3Event, @@ -105,7 +110,11 @@ export async function readContentPlaybackGrant( token, grantResourceId(recordingId, bindingId), ); - if (!verified.ok) return { status: "invalid" }; + if (!verified.ok) { + return verified.reason === "expired" + ? { status: "expired" } + : { status: "invalid" }; + } const viewerEmail = verified.viewerEmail?.trim().toLowerCase() || null; const orgId = await contentReaderOrgId(viewerEmail); if (!bindingId) { @@ -124,6 +133,25 @@ export async function readContentPlaybackGrant( }; } +/** The response a media route sends when the grant does not let it serve. */ +export function refusedContentPlayback( + access: ContentPlaybackAccess, +): { status: number; body: { error: string; code?: string } } | null { + if (access.status === "invalid") { + return { status: 404, body: { error: "Not found" } }; + } + if (access.status === "expired") { + return { + status: 403, + body: { + error: "Playback grant expired", + code: CONTENT_CLIP_GRANT_EXPIRED, + }, + }; + } + return null; +} + export function runWithContentPlayback( access: ContentPlaybackAccess, fn: () => T, diff --git a/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts b/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts index 685d0055054..f1710a4d7b7 100644 --- a/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +++ b/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts @@ -31,6 +31,7 @@ import { import { getDb, schema } from "../../../db/index.js"; import { readContentPlaybackGrant, + refusedContentPlayback, runWithContentPlayback, } from "../../../lib/content-playback-grant.js"; import { contextClipIdForFootage } from "../../../lib/context-footage.js"; @@ -459,9 +460,10 @@ export default defineEventHandler(async (event: H3Event) => { } const contentGrant = await readContentPlaybackGrant(event, recordingId); - if (contentGrant.status === "invalid") { - setResponseStatus(event, 404); - return { error: "Not found" }; + const refused = refusedContentPlayback(contentGrant); + if (refused) { + setResponseStatus(event, refused.status); + return refused.body; } const session = await getSession(event).catch(() => null); diff --git a/templates/clips/server/routes/api/video/[recordingId].get.ts b/templates/clips/server/routes/api/video/[recordingId].get.ts index 3341925233a..e0ad6a51949 100644 --- a/templates/clips/server/routes/api/video/[recordingId].get.ts +++ b/templates/clips/server/routes/api/video/[recordingId].get.ts @@ -21,7 +21,8 @@ * - a Content playback grant (`content_grant`, from the Content playback * route) names the reader in place of the browser session and never * unlocks a password; a grant that does not verify for this recording, - * including an empty one, is a 404. + * including an empty one, is a 404, and an expired one is a 403 with + * code `content_grant_expired`. * - earlier-screen-time footage is a private recording of its own that is * shared with the clip it is attached to: every rule above is evaluated * against that clip, and the footage's bytes are served on its behalf. @@ -78,6 +79,7 @@ import { import { getDb, schema } from "../../../db/index.js"; import { readContentPlaybackGrant, + refusedContentPlayback, runWithContentPlayback, } from "../../../lib/content-playback-grant.js"; import { contextClipIdForFootage } from "../../../lib/context-footage.js"; @@ -342,9 +344,10 @@ export default defineEventHandler(async (event: H3Event) => { } const contentGrant = await readContentPlaybackGrant(event, recordingId); - if (contentGrant.status === "invalid") { - setResponseStatus(event, 404); - return { error: "Not found" }; + const refused = refusedContentPlayback(contentGrant); + if (refused) { + setResponseStatus(event, refused.status); + return refused.body; } const session = await getSession(event).catch(() => null); @@ -392,8 +395,10 @@ export default defineEventHandler(async (event: H3Event) => { } } if (row.visibility !== "public" && !viewerIsOrgMember) { - setResponseStatus(event, 403); - return { error: "Forbidden" }; + // A Content reader gets the 404 a missing Clip gets; under a grant + // a 403 means only that the grant expired. + setResponseStatus(event, granted ? 404 : 403); + return { error: granted ? "Not found" : "Forbidden" }; } recRow = row as RecordingRow; role = "viewer"; diff --git a/templates/clips/shared/content-clip-contract.ts b/templates/clips/shared/content-clip-contract.ts index 1d309aa0e54..9492f70061a 100644 --- a/templates/clips/shared/content-clip-contract.ts +++ b/templates/clips/shared/content-clip-contract.ts @@ -31,3 +31,21 @@ export const CONTENT_CLIP_CLAIMS = { /** Longest token lifetime the routes accept; Content mints one-minute tokens. */ export const CONTENT_CLIP_MAX_TOKEN_SECONDS = 5 * 60; + +/** + * How long the media URLs from playback keep working, so a reader who loses + * access to the document stops playing within minutes. When they run out + * (`playback.expiresAt`, or a media request refused with + * `CONTENT_CLIP_GRANT_EXPIRED`), Content's player asks Content for playback + * again, and Content re-checks the reader's document access before it mints + * the token that gets fresh URLs. + * + * A Clip that plays in YouTube's player (`recording.embedProvider` is + * `"youtube"`) is the exception: its `videoUrl` is YouTube's own embed URL + * with no grant. Clips checks access only when it answers playback; YouTube + * then serves the video under that video's YouTube settings. + */ +export const CONTENT_CLIP_PLAYBACK_GRANT_SECONDS = 5 * 60; + +/** The `code` of a 403 media response refused only because its grant expired. */ +export const CONTENT_CLIP_GRANT_EXPIRED = "content_grant_expired"; From 4890d87ccad736add5d265fd858c7784be1c7b53 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:34:08 -0400 Subject: [PATCH 07/35] Test Content Clips workspace resolution, token time fields and lifetime edges --- .../clips/server/lib/content-clips.test.ts | 360 +++++++++++++++++- 1 file changed, 342 insertions(+), 18 deletions(-) diff --git a/templates/clips/server/lib/content-clips.test.ts b/templates/clips/server/lib/content-clips.test.ts index ed260c0899e..dcc478594bb 100644 --- a/templates/clips/server/lib/content-clips.test.ts +++ b/templates/clips/server/lib/content-clips.test.ts @@ -1,3 +1,4 @@ +import { createHmac } from "node:crypto"; import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; @@ -17,10 +18,15 @@ import { const CLIPS_URL = "https://clips.example.test"; const CONTENT_APP = "https://content.example.test"; const SECRET = "content-clips-test-secret-0123456789abcdef"; +const GRANT_SECRET = "content-clips-grant-secret-0123456789abcdef"; const state = vi.hoisted(() => { vi.stubEnv("APP_URL", "https://clips.example.test"); vi.stubEnv("A2A_SECRET", "content-clips-test-secret-0123456789abcdef"); + vi.stubEnv( + "OAUTH_STATE_SECRET", + "content-clips-grant-secret-0123456789abcdef", + ); return { db: null as any, blobs: new Map() }; }); @@ -35,16 +41,6 @@ vi.mock("@agent-native/core/application-state", async (importOriginal) => ({ readAppState: async (key: string) => state.blobs.get(key) ?? null, writeAppState: async () => {}, })); -vi.mock("@agent-native/core/org", async (importOriginal) => ({ - ...(await importOriginal()), - resolveOrgIdForEmail: async (email: string) => { - const { rows } = await state.db.$client.query( - "SELECT org_id FROM org_members WHERE lower(email) = $1 ORDER BY joined_at LIMIT 1", - [email.toLowerCase()], - ); - return (rows[0] as { org_id?: string } | undefined)?.org_id ?? null; - }, -})); vi.mock("./activity-notifications.js", () => ({ notifyRecordingComment: async () => null, notifyRecordingReaction: async () => null, @@ -61,6 +57,7 @@ const jose = await import(pathToFileURL(coreRequire.resolve("jose")).href); const { organizations, orgMembers } = await import("@agent-native/core/org"); const { signA2AToken } = await import("@agent-native/core/a2a"); +const { withDbExec } = await import("@agent-native/core/db"); const { schema } = await import("../db/index.js"); const { CONTENT_CLIP_GRANT_EXPIRED, @@ -106,6 +103,27 @@ function createTableSql(table: PgTable): string { return `CREATE TABLE "${config.name}" (${columns.join(", ")});`; } +/** + * Core reads workspaces, settings and workspace A2A records through its own + * database handle rather than the app's; point it at this test's database. + * Core writes `?` placeholders, and none of the statements these routes + * reach contain a literal `?`. + */ +function coreDb() { + return { + async execute(statement: string | { sql: string; args?: unknown[] }) { + const query = + typeof statement === "string" ? { sql: statement } : statement; + let param = 0; + const result = await client.query( + query.sql.replace(/\?/g, () => `$${++param}`), + query.args ?? [], + ); + return { rows: result.rows, rowsAffected: result.affectedRows ?? 0 }; + }, + }; +} + async function insertRecording( id: string, values: Partial = {}, @@ -154,6 +172,49 @@ async function insertBinding( }); } +async function insertWorkspace( + id: string, + values: Partial = {}, +) { + await state.db.insert(organizations).values({ + id, + name: id, + createdBy: OWNER, + createdAt: 1, + ...values, + }); +} + +async function addMember(orgId: string, email: string, joinedAt: number) { + await state.db.insert(orgMembers).values({ + id: `member-${orgId}-${email}`, + orgId, + email, + role: "member", + joinedAt, + }); +} + +async function setActiveWorkspace(email: string, orgId: string) { + await client.query( + `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, 1) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [`u:${email}:active-org-id`, JSON.stringify({ orgId })], + ); +} + +async function shareWithWorkspace(recordingId: string, orgId: string) { + await state.db.insert(schema.recordingShares).values({ + id: `share-${recordingId}-${orgId}`, + resourceId: recordingId, + principalType: "org", + principalId: orgId, + role: "viewer", + createdBy: OWNER, + createdAt: "2026-10-01T00:00:00.000Z", + }); +} + async function setBindingState(id: string, bindingState: "active" | "trashed") { await client.query( "UPDATE recording_content_bindings SET state = $1 WHERE id = $2", @@ -179,8 +240,8 @@ interface TokenInput { audience?: string | null; issuer?: string | null; secret?: string; - issuedAt?: number; - expiresAt?: number; + issuedAt?: number | null; + expiresAt?: number | null; extra?: Record; } @@ -197,10 +258,11 @@ async function token(input: TokenInput = {}): Promise { ...(input.sub === null ? {} : { sub: input.sub ?? READER }), ...input.extra, }; - let jwt = new jose.SignJWT(claims) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt(input.issuedAt ?? now) - .setExpirationTime(input.expiresAt ?? now + 60); + let jwt = new jose.SignJWT(claims).setProtectedHeader({ alg: "HS256" }); + if (input.issuedAt !== null) jwt = jwt.setIssuedAt(input.issuedAt ?? now); + if (input.expiresAt !== null) { + jwt = jwt.setExpirationTime(input.expiresAt ?? now + 60); + } if (input.issuer !== null) jwt = jwt.setIssuer(input.issuer ?? CONTENT_APP); if (input.audience !== null) { jwt = jwt.setAudience(input.audience ?? `${CLIPS_URL}/content-clips`); @@ -237,7 +299,7 @@ async function respond( handler: (event: any) => unknown, event: any, ): Promise<{ status: number; body: any; response?: Response }> { - const result = await handler(event); + const result = await withDbExec(coreDb(), () => handler(event)); if (result instanceof Response) { const type = result.headers.get("content-type") ?? ""; return { @@ -284,7 +346,7 @@ async function fetchMedia( event.context.params = { recordingId: decodeURIComponent(url.pathname.split("/").pop()!), }; - const result = await handler(event); + const result = await withDbExec(coreDb(), () => handler(event)); if (result instanceof Response) { return { status: result.status, @@ -298,6 +360,23 @@ async function fetchMedia( }; } +/** The media URL with its grant's claims rewritten and signed again. */ +function withRewrittenGrant( + mediaUrl: string, + rewrite: (claims: Record) => void, +): string { + const url = new URL(mediaUrl); + const [payload] = url.searchParams.get("content_grant")!.split("."); + const claims = JSON.parse(Buffer.from(payload!, "base64url").toString()); + rewrite(claims); + const encoded = Buffer.from(JSON.stringify(claims)).toString("base64url"); + const signature = createHmac("sha256", GRANT_SECRET) + .update(encoded) + .digest("base64url"); + url.searchParams.set("content_grant", `${encoded}.${signature}`); + return url.href; +} + function afterGrantExpires(issuedAt: number) { vi.useFakeTimers({ toFake: ["Date"], @@ -321,6 +400,13 @@ beforeEach(async () => { ]) { await client.exec(createTableSql(table as PgTable)); } + await client.exec(` + ALTER TABLE organizations + ADD COLUMN a2a_member_delegation_enabled BOOLEAN NOT NULL DEFAULT false; + CREATE TABLE settings ( + key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at BIGINT NOT NULL + ); + `); await insertRecording("rec-upload"); await insertBinding("binding-doc-1"); }); @@ -500,6 +586,32 @@ describe("Content doc readers play a Clip uploaded to the doc", () => { } }); + it("refuses a media grant whose expiry is missing or not a number", async () => { + const result = await playback("rec-upload", await token()); + + for (const [route, url] of [ + [videoRoute, result.body.playback.videoUrl], + [thumbnailRoute, result.body.playback.posterUrl], + ] as const) { + expect( + ( + await fetchMedia( + route, + withRewrittenGrant(url, () => {}), + ) + ).status, + ).toBe(200); + for (const rewrite of [ + (claims: Record) => delete claims.exp, + (claims: Record) => (claims.exp = String(claims.exp)), + ]) { + const media = await fetchMedia(route, withRewrittenGrant(url, rewrite)); + expect(media.status).toBe(404); + expect(media.bytes).toBeNull(); + } + } + }); + it("refuses a media URL whose grant is present but empty", async () => { await updateRecording("rec-upload", { visibility: "public" }); @@ -695,6 +807,65 @@ describe("a pasted Clip keeps its own sharing", () => { }); }); +describe("a reader in several workspaces acts in the active one", () => { + beforeEach(async () => { + await insertWorkspace("org-first"); + await insertWorkspace("org-active"); + await insertWorkspace("org-outside"); + await addMember("org-first", READER, 1); + await addMember("org-active", READER, 2); + await setActiveWorkspace(READER, "org-active"); + for (const [recordingId, orgId] of [ + ["rec-active-org", "org-active"], + ["rec-first-org", "org-first"], + ["rec-outside-org", "org-outside"], + ] as const) { + await insertRecording(recordingId); + await insertBinding(`binding-${recordingId}`, { + recordingId, + origin: "link", + }); + await shareWithWorkspace(recordingId, orgId); + } + }); + + it("opens a Clip shared with that workspace, through Content and its media URLs", async () => { + const bearer = await token({ recordingId: "rec-active-org" }); + const opened = await playback("rec-active-org", bearer); + expect(opened.status).toBe(200); + expect((await listComments("rec-active-org", bearer)).status).toBe(200); + for (const [route, url] of [ + [videoRoute, opened.body.playback.videoUrl], + [thumbnailRoute, opened.body.playback.posterUrl], + ] as const) { + expect((await fetchMedia(route, url)).status).toBe(200); + } + }); + + it("refuses a Clip shared with another workspace, even one the reader belongs to", async () => { + for (const recordingId of ["rec-first-org", "rec-outside-org"]) { + const bearer = await token({ recordingId }); + expect((await playback(recordingId, bearer)).status).toBe(404); + expect((await listComments(recordingId, bearer)).status).toBe(404); + } + }); + + it("stops an issued media URL when the reader switches workspace", async () => { + const opened = await playback( + "rec-active-org", + await token({ recordingId: "rec-active-org" }), + ); + await setActiveWorkspace(READER, "org-first"); + + for (const [route, url] of [ + [videoRoute, opened.body.playback.videoUrl], + [thumbnailRoute, opened.body.playback.posterUrl], + ] as const) { + expect((await fetchMedia(route, url)).status).toBe(404); + } + }); +}); + describe("video comments through a doc", () => { async function seedComment( id: string, @@ -1054,6 +1225,21 @@ describe("token checks", () => { "a typed credential", () => token({ extra: { typ: "agent-native-mcp-oauth" } }), ], + ["no expiry", () => token({ expiresAt: null })], + ["no issue time", () => token({ issuedAt: null })], + [ + "an expiry that is a string", + () => token({ expiresAt: null, extra: { exp: String(now() + 60) } }), + ], + [ + "an issue time that is a string", + () => token({ issuedAt: null, extra: { iat: String(now()) } }), + ], + ["a not-before time to come", () => token({ extra: { nbf: now() + 30 } })], + [ + "a not-before time that is a string", + () => token({ extra: { nbf: String(now()) } }), + ], ])("refuses %s", async (_name, mint) => { const result = await playback("rec-upload", await mint()); expect(result.status).toBe(401); @@ -1104,6 +1290,144 @@ describe("token checks", () => { expect(result.status).toBe(404); }); + describe("at the edge of a lifetime", () => { + const issuedAt = Date.UTC(2026, 9, 23, 12, 0, 0); + const at = (seconds: number) => vi.setSystemTime(issuedAt + seconds * 1000); + + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"], now: issuedAt }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("accepts Content's one-minute token until its last second", async () => { + const bearer = await token(); + at(59); + expect((await playback("rec-upload", bearer)).status).toBe(200); + at(61); + expect((await playback("rec-upload", bearer)).status).toBe(401); + }); + + it("accepts a five-minute lifetime and not one second more", async () => { + const iat = issuedAt / 1000; + for (const [lifetime, status] of [ + [300, 200], + [301, 401], + ] as const) { + const bearer = await token({ expiresAt: iat + lifetime }); + expect((await playback("rec-upload", bearer)).status).toBe(status); + } + }); + + it("allows a minute of clock skew on the issue time and not one second more", async () => { + const iat = issuedAt / 1000; + for (const [skew, status] of [ + [60, 200], + [61, 401], + ] as const) { + const bearer = await token({ + issuedAt: iat + skew, + expiresAt: iat + skew + 60, + }); + expect((await playback("rec-upload", bearer)).status).toBe(status); + } + }); + + it("plays a media URL until the grant's last second", async () => { + const result = await playback("rec-upload", await token()); + const urls = [ + [videoRoute, result.body.playback.videoUrl], + [thumbnailRoute, result.body.playback.posterUrl], + ] as const; + + at(CONTENT_CLIP_PLAYBACK_GRANT_SECONDS - 1); + for (const [route, url] of urls) { + expect((await fetchMedia(route, url)).status).toBe(200); + } + at(CONTENT_CLIP_PLAYBACK_GRANT_SECONDS + 1); + for (const [route, url] of urls) { + const media = await fetchMedia(route, url); + expect(media.status).toBe(403); + expect(media.body).toMatchObject({ code: CONTENT_CLIP_GRANT_EXPIRED }); + } + }); + }); + + describe("a token that names a workspace", () => { + const WORKSPACE_DOMAIN = "content.example.test"; + const WORKSPACE_SECRET = "content-workspace-a2a-secret-0123456789"; + + beforeEach(async () => { + await insertWorkspace("org-content", { + allowedDomain: WORKSPACE_DOMAIN, + a2aSecret: WORKSPACE_SECRET, + }); + await client.query( + "UPDATE organizations SET a2a_member_delegation_enabled = true WHERE id = 'org-content'", + ); + await addMember("org-content", READER, 1); + await setActiveWorkspace(READER, "org-content"); + await insertWorkspace("org-named", { + allowedDomain: "named.example.test", + }); + }); + + it("is refused when signed with the workspace's own A2A secret", async () => { + for (const extra of [ + { org_domain: WORKSPACE_DOMAIN }, + { + org_domain: WORKSPACE_DOMAIN, + typ: "agent-native-org-member-delegation", + jti: "delegation-1", + }, + ]) { + const bearer = await token({ secret: WORKSPACE_SECRET, extra }); + expect((await playback("rec-upload", bearer)).status).toBe(401); + } + }); + + it("reaches one recording and document, from the placement's Content app", async () => { + const named = (input: TokenInput = {}) => + token({ ...input, extra: { org_domain: WORKSPACE_DOMAIN } }); + await insertRecording("rec-other"); + + expect((await playback("rec-upload", await named())).status).toBe(200); + expect( + ( + await playback( + "rec-upload", + await named({ issuer: "https://other-content.example.test" }), + ) + ).status, + ).toBe(404); + expect( + (await playback("rec-upload", await named({ documentId: "doc-2" }))) + .status, + ).toBe(404); + expect((await playback("rec-other", await named())).status).toBe(401); + expect( + (await playback("rec-other", await named({ recordingId: "rec-other" }))) + .status, + ).toBe(404); + }); + + it("gets nothing from the workspace it names", async () => { + await insertRecording("rec-named"); + await insertBinding("binding-named", { + recordingId: "rec-named", + origin: "link", + }); + await shareWithWorkspace("rec-named", "org-named"); + + const bearer = await token({ + recordingId: "rec-named", + extra: { org_domain: "named.example.test" }, + }); + expect((await playback("rec-named", bearer)).status).toBe(404); + }); + }); + it("rejects malformed comment requests", async () => { const write = await token({ scope: SCOPES.comment }); for (const body of [ From 954b784416b0ea6833c19043a02312961c8f1eb3 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:00:34 -0400 Subject: [PATCH 08/35] Reset core's workspace caches between Content Clips tests --- .../clips/server/lib/content-clips.test.ts | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/templates/clips/server/lib/content-clips.test.ts b/templates/clips/server/lib/content-clips.test.ts index dcc478594bb..e8f1616a1d0 100644 --- a/templates/clips/server/lib/content-clips.test.ts +++ b/templates/clips/server/lib/content-clips.test.ts @@ -55,7 +55,9 @@ const coreRequire = createRequire( const { PGlite } = coreRequire("@electric-sql/pglite"); const jose = await import(pathToFileURL(coreRequire.resolve("jose")).href); -const { organizations, orgMembers } = await import("@agent-native/core/org"); +const { invalidateMemberOrgCaches, organizations, orgMembers } = + await import("@agent-native/core/org"); +const { putUserSetting } = await import("@agent-native/core/settings"); const { signA2AToken } = await import("@agent-native/core/a2a"); const { withDbExec } = await import("@agent-native/core/db"); const { schema } = await import("../db/index.js"); @@ -193,13 +195,13 @@ async function addMember(orgId: string, email: string, joinedAt: number) { role: "member", joinedAt, }); + invalidateMemberOrgCaches(); } +/** Written through core, which drops its cached choice the way a switch does. */ async function setActiveWorkspace(email: string, orgId: string) { - await client.query( - `INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, 1) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, - [`u:${email}:active-org-id`, JSON.stringify({ orgId })], + await withDbExec(coreDb(), () => + putUserSetting(email, "active-org-id", { orgId }), ); } @@ -389,6 +391,9 @@ beforeEach(async () => { state.db = drizzle(client); state.blobs.clear(); __resetContentClipRateLimitsForTests(); + // Core caches workspace memberships per process; a new database makes + // whatever an earlier test left there stale. + invalidateMemberOrgCaches(); for (const table of [ schema.recordings, schema.recordingShares, @@ -747,19 +752,8 @@ describe("a pasted Clip keeps its own sharing", () => { }); it("plays for a reader whose workspace the Clip is shared with", async () => { - await state.db.insert(organizations).values({ - id: "clips-org", - name: "Clips Org", - createdBy: OWNER, - createdAt: 1, - }); - await state.db.insert(orgMembers).values({ - id: "member-reader", - orgId: "clips-org", - email: READER, - role: "member", - joinedAt: 1, - }); + await insertWorkspace("clips-org"); + await addMember("clips-org", READER, 1); await state.db.insert(schema.recordingShares).values({ id: "share-org", resourceId: "rec-pasted", From c59ca975f8b3d4727f7286aa771ee74974641e42 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:19:00 -0400 Subject: [PATCH 09/35] Give each Content Clips workspace test its own reader --- .../clips/server/lib/content-clips.test.ts | 71 ++++++++++++------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/templates/clips/server/lib/content-clips.test.ts b/templates/clips/server/lib/content-clips.test.ts index e8f1616a1d0..2036869e0db 100644 --- a/templates/clips/server/lib/content-clips.test.ts +++ b/templates/clips/server/lib/content-clips.test.ts @@ -55,8 +55,7 @@ const coreRequire = createRequire( const { PGlite } = coreRequire("@electric-sql/pglite"); const jose = await import(pathToFileURL(coreRequire.resolve("jose")).href); -const { invalidateMemberOrgCaches, organizations, orgMembers } = - await import("@agent-native/core/org"); +const { organizations, orgMembers } = await import("@agent-native/core/org"); const { putUserSetting } = await import("@agent-native/core/settings"); const { signA2AToken } = await import("@agent-native/core/a2a"); const { withDbExec } = await import("@agent-native/core/db"); @@ -187,15 +186,25 @@ async function insertWorkspace( }); } -async function addMember(orgId: string, email: string, joinedAt: number) { - await state.db.insert(orgMembers).values({ - id: `member-${orgId}-${email}`, - orgId, - email, - role: "member", - joinedAt, - }); - invalidateMemberOrgCaches(); +let workspaceReaders = 0; + +/** + * A new reader who belongs to these workspaces, joined in this order. Core + * caches memberships and the active-workspace choice per process by email, + * so a reader with workspaces must never carry over into another test. + */ +async function readerInWorkspaces(...orgIds: string[]): Promise { + const email = `workspace-reader-${++workspaceReaders}@content.example.test`; + for (const [index, orgId] of orgIds.entries()) { + await state.db.insert(orgMembers).values({ + id: `member-${orgId}-${email}`, + orgId, + email, + role: "member", + joinedAt: index + 1, + }); + } + return email; } /** Written through core, which drops its cached choice the way a switch does. */ @@ -391,9 +400,6 @@ beforeEach(async () => { state.db = drizzle(client); state.blobs.clear(); __resetContentClipRateLimitsForTests(); - // Core caches workspace memberships per process; a new database makes - // whatever an earlier test left there stale. - invalidateMemberOrgCaches(); for (const table of [ schema.recordings, schema.recordingShares, @@ -753,7 +759,7 @@ describe("a pasted Clip keeps its own sharing", () => { it("plays for a reader whose workspace the Clip is shared with", async () => { await insertWorkspace("clips-org"); - await addMember("clips-org", READER, 1); + const reader = await readerInWorkspaces("clips-org"); await state.db.insert(schema.recordingShares).values({ id: "share-org", resourceId: "rec-pasted", @@ -766,7 +772,7 @@ describe("a pasted Clip keeps its own sharing", () => { const shared = await playback( "rec-pasted", - await token({ recordingId: "rec-pasted" }), + await token({ recordingId: "rec-pasted", sub: reader }), ); expect(shared.status).toBe(200); const video = await fetchMedia(videoRoute, shared.body.playback.videoUrl); @@ -802,13 +808,14 @@ describe("a pasted Clip keeps its own sharing", () => { }); describe("a reader in several workspaces acts in the active one", () => { + let reader: string; + beforeEach(async () => { await insertWorkspace("org-first"); await insertWorkspace("org-active"); await insertWorkspace("org-outside"); - await addMember("org-first", READER, 1); - await addMember("org-active", READER, 2); - await setActiveWorkspace(READER, "org-active"); + reader = await readerInWorkspaces("org-first", "org-active"); + await setActiveWorkspace(reader, "org-active"); for (const [recordingId, orgId] of [ ["rec-active-org", "org-active"], ["rec-first-org", "org-first"], @@ -824,7 +831,7 @@ describe("a reader in several workspaces acts in the active one", () => { }); it("opens a Clip shared with that workspace, through Content and its media URLs", async () => { - const bearer = await token({ recordingId: "rec-active-org" }); + const bearer = await token({ recordingId: "rec-active-org", sub: reader }); const opened = await playback("rec-active-org", bearer); expect(opened.status).toBe(200); expect((await listComments("rec-active-org", bearer)).status).toBe(200); @@ -838,7 +845,7 @@ describe("a reader in several workspaces acts in the active one", () => { it("refuses a Clip shared with another workspace, even one the reader belongs to", async () => { for (const recordingId of ["rec-first-org", "rec-outside-org"]) { - const bearer = await token({ recordingId }); + const bearer = await token({ recordingId, sub: reader }); expect((await playback(recordingId, bearer)).status).toBe(404); expect((await listComments(recordingId, bearer)).status).toBe(404); } @@ -847,9 +854,9 @@ describe("a reader in several workspaces acts in the active one", () => { it("stops an issued media URL when the reader switches workspace", async () => { const opened = await playback( "rec-active-org", - await token({ recordingId: "rec-active-org" }), + await token({ recordingId: "rec-active-org", sub: reader }), ); - await setActiveWorkspace(READER, "org-first"); + await setActiveWorkspace(reader, "org-first"); for (const [route, url] of [ [videoRoute, opened.body.playback.videoUrl], @@ -1351,6 +1358,7 @@ describe("token checks", () => { describe("a token that names a workspace", () => { const WORKSPACE_DOMAIN = "content.example.test"; const WORKSPACE_SECRET = "content-workspace-a2a-secret-0123456789"; + let reader: string; beforeEach(async () => { await insertWorkspace("org-content", { @@ -1360,8 +1368,8 @@ describe("token checks", () => { await client.query( "UPDATE organizations SET a2a_member_delegation_enabled = true WHERE id = 'org-content'", ); - await addMember("org-content", READER, 1); - await setActiveWorkspace(READER, "org-content"); + reader = await readerInWorkspaces("org-content"); + await setActiveWorkspace(reader, "org-content"); await insertWorkspace("org-named", { allowedDomain: "named.example.test", }); @@ -1376,14 +1384,22 @@ describe("token checks", () => { jti: "delegation-1", }, ]) { - const bearer = await token({ secret: WORKSPACE_SECRET, extra }); + const bearer = await token({ + sub: reader, + secret: WORKSPACE_SECRET, + extra, + }); expect((await playback("rec-upload", bearer)).status).toBe(401); } }); it("reaches one recording and document, from the placement's Content app", async () => { const named = (input: TokenInput = {}) => - token({ ...input, extra: { org_domain: WORKSPACE_DOMAIN } }); + token({ + sub: reader, + ...input, + extra: { org_domain: WORKSPACE_DOMAIN }, + }); await insertRecording("rec-other"); expect((await playback("rec-upload", await named())).status).toBe(200); @@ -1416,6 +1432,7 @@ describe("token checks", () => { const bearer = await token({ recordingId: "rec-named", + sub: reader, extra: { org_domain: "named.example.test" }, }); expect((await playback("rec-named", bearer)).status).toBe(404); From 22eaf54d4f5a456a6eedad71c9b8a18761912338 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:39:47 -0400 Subject: [PATCH 10/35] Accept only the Content-Clips peer secret and make signed-out host access opt-in Clips' Content routes now verify tokens with CONTENT_CLIPS_PEER_SECRET alone, a core config field read through resolveContentClipsPeerSecret. Tokens signed with the deployment A2A_SECRET or a workspace secret are refused, and an unset or unusable secret answers a typed 503 (content_clips_not_configured) without reading or writing anything. Media grants are signed with a key derived from the peer secret, through a new signingKey option on core's short-lived tokens. Host access resolvers are asked about signed-out requests only when they register with { includeSignedOut: true }; Clips is the only one that does. --- .changeset/content-clips-peer-secret.md | 5 + .changeset/host-access-signed-out.md | 2 +- docs/environment-variables.md | 1 + packages/core/src/app-config/clips.spec.ts | 72 +++++++++++++- packages/core/src/app-config/clips.ts | 34 +++++++ packages/core/src/app-config/index.ts | 2 + packages/core/src/server/index.ts | 3 + .../core/src/server/short-lived-token.spec.ts | 31 ++++++ packages/core/src/server/short-lived-token.ts | 27 +++++- packages/core/src/sharing/access.spec.ts | 38 +++++++- packages/core/src/sharing/access.ts | 37 +++++-- packages/core/src/sharing/index.ts | 1 + .../.agents/skills/video-sharing/SKILL.md | 5 +- templates/clips/server/db/index.ts | 4 +- .../clips/server/lib/content-clip-peer.ts | 7 +- .../clips/server/lib/content-clips.test.ts | 97 ++++++++++++++++++- .../clips/server/lib/content-peer-secret.ts | 56 +++++++++++ .../server/lib/content-playback-grant.ts | 35 +++++-- .../api/thumbnail/[recordingId].get.test.ts | 4 + .../api/video/[recordingId].get.test.ts | 4 + .../clips/shared/content-clip-contract.ts | 30 +++++- 21 files changed, 458 insertions(+), 37 deletions(-) create mode 100644 .changeset/content-clips-peer-secret.md create mode 100644 templates/clips/server/lib/content-peer-secret.ts diff --git a/.changeset/content-clips-peer-secret.md b/.changeset/content-clips-peer-secret.md new file mode 100644 index 00000000000..6bbabdab957 --- /dev/null +++ b/.changeset/content-clips-peer-secret.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add the `clips.contentPeerSecret` server config field (`CONTENT_CLIPS_PEER_SECRET`) and `resolveContentClipsPeerSecret()`: a secret only the Content and Clips deployments hold, for Content's server-to-server requests about Clips in its documents. The resolver returns null while it is unset and throws `AppConfigurationError` when it is shorter than 32 characters or equal to `A2A_SECRET`. `signShortLivedToken` and `verifyShortLivedToken` take an optional `{ signingKey }` that replaces the app's server secret, with no fallback, so a token can be bound to one key. diff --git a/.changeset/host-access-signed-out.md b/.changeset/host-access-signed-out.md index 7ee8fb3910e..946698e7a69 100644 --- a/.changeset/host-access-signed-out.md +++ b/.changeset/host-access-signed-out.md @@ -2,4 +2,4 @@ "@agent-native/core": patch --- -Host access resolvers registered with `registerHostAccessResolver` are now asked about signed-out requests too, with `ctx.userEmail` unset, so a host can vouch for a signed-out reader of one resource. Resolvers that only vouch for signed-in people should return `undefined` when there is no email. +`registerHostAccessResolver` takes an `{ includeSignedOut: true }` option for a host that vouches for signed-out readers of one resource. Only a resolver registered with it is asked about signed-out requests, with `ctx.userEmail` unset; every other resolver is still asked only about signed-in requests, as before. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index ea19cc6af7d..73be591df16 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -579,6 +579,7 @@ only in code. | `clips.hostedStorage.ipPrefixDailyGrantCap` | `CLIPS_HOSTED_STORAGE_IP_PREFIX_DAILY_GRANT_CAP` | number | — | Optional maximum number of hosted Clips recording grants per IP prefix per day. | | `clips.hostedStorage.globalRetainedByteCap` | `CLIPS_HOSTED_STORAGE_GLOBAL_RETAINED_BYTE_CAP` | number | — | Optional maximum total bytes retained in hosted Clips object storage. | | `clips.hostedStorage.retainedByteAlertRatio` | `CLIPS_HOSTED_STORAGE_RETAINED_BYTE_ALERT_RATIO` | number | `0.8` | Fraction of the global retained-byte cap at which hosted Clips storage should alert. | +| `clips.contentPeerSecret` | `CONTENT_CLIPS_PEER_SECRET` | string | — | Secret only the Content and Clips deployments hold. Content signs its server-to-server requests about Clips in its documents with it, and Clips accepts no other credential on those routes. Must differ from A2A_SECRET. | | `dispatch.adminEmails` | `DISPATCH_ADMIN_EMAILS` | array | `[]` | Comma-separated deployment administrators for Dispatch. | | `dispatch.workspaceOwnerEmails` | `WORKSPACE_OWNER_EMAIL` | array | `[]` | Comma-separated workspace owner emails trusted by Dispatch. | | `dispatch.defaultOwnerEmail` | `DISPATCH_DEFAULT_OWNER_EMAIL` | string | — | Default Dispatch owner email. | diff --git a/packages/core/src/app-config/clips.spec.ts b/packages/core/src/app-config/clips.spec.ts index 208ca4a2691..f43fe253e41 100644 --- a/packages/core/src/app-config/clips.spec.ts +++ b/packages/core/src/app-config/clips.spec.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { resolveClipsHostedStorageConfig } from "./clips.js"; +import { + resolveClipsHostedStorageConfig, + resolveContentClipsPeerSecret, +} from "./clips.js"; import { AppConfigurationError } from "./configuration-error.js"; import { defineAppConfig, @@ -148,3 +151,70 @@ describe("Clips hosted storage config", () => { expect(() => getAppConfig()).toThrow(); }); }); + +describe("Content-to-Clips peer secret", () => { + const PEER_SECRET = "content-clips-peer-secret-0123456789abcdef"; + const DEPLOYMENT_SECRET = "deployment-a2a-secret-0123456789abcdef"; + + beforeEach(() => { + resetAppConfigForTests(); + process.env = { ...originalEnv }; + delete process.env.CONTENT_CLIPS_PEER_SECRET; + }); + + afterEach(() => { + resetAppConfigForTests(); + process.env = { ...originalEnv }; + }); + + it("reads the secret from its deployment alias", () => { + process.env.CONTENT_CLIPS_PEER_SECRET = PEER_SECRET; + + expect( + resolveContentClipsPeerSecret(getAppConfig().clips, DEPLOYMENT_SECRET), + ).toBe(PEER_SECRET); + }); + + it.each([ + ["unset", undefined], + ["empty", ""], + ["whitespace", " "], + ])("is absent, not an error, while %s", (_name, value) => { + if (value !== undefined) process.env.CONTENT_CLIPS_PEER_SECRET = value; + + expect( + resolveContentClipsPeerSecret(getAppConfig().clips, DEPLOYMENT_SECRET), + ).toBe(null); + }); + + it("is absent, not an error, when app config sets it empty", () => { + defineAppConfig({ clips: { contentPeerSecret: "" } }); + + expect( + resolveContentClipsPeerSecret(getAppConfig().clips, DEPLOYMENT_SECRET), + ).toBe(null); + }); + + it("refuses the deployment A2A secret, which every app holds", () => { + process.env.CONTENT_CLIPS_PEER_SECRET = DEPLOYMENT_SECRET; + + expect(() => + resolveContentClipsPeerSecret( + getAppConfig().clips, + ` ${DEPLOYMENT_SECRET} `, + ), + ).toThrow( + new AppConfigurationError( + "CONTENT_CLIPS_PEER_SECRET must differ from A2A_SECRET.", + ), + ); + }); + + it("refuses a secret too short to sign with", () => { + process.env.CONTENT_CLIPS_PEER_SECRET = "short-peer-secret"; + + expect(() => + resolveContentClipsPeerSecret(getAppConfig().clips, undefined), + ).toThrow(AppConfigurationError); + }); +}); diff --git a/packages/core/src/app-config/clips.ts b/packages/core/src/app-config/clips.ts index d2c55fa643b..4385ec6ed21 100644 --- a/packages/core/src/app-config/clips.ts +++ b/packages/core/src/app-config/clips.ts @@ -69,12 +69,46 @@ export const clipsHostedStorageConfig = z.object({ export const clipsConfig = z.object({ hostedStorage: clipsHostedStorageConfig.prefault({}), + contentPeerSecret: z.string().optional().meta({ + env: "CONTENT_CLIPS_PEER_SECRET", + doc: "Secret only the Content and Clips deployments hold. Content signs its server-to-server requests about Clips in its documents with it, and Clips accepts no other credential on those routes. Must differ from A2A_SECRET.", + }), }); +export type ClipsConfig = z.output; + export type ClipsHostedStorageConfig = z.output< typeof clipsHostedStorageConfig >; +const MIN_CONTENT_CLIPS_PEER_SECRET_CHARS = 32; + +/** + * The secret Content and Clips sign and verify Content's Clip requests with, + * or null when it is not set. Read it through this on both sides: a value + * equal to the deployment `A2A_SECRET` is one every app holds, so it would + * not show that a request came from Content, and it is refused rather than + * used. + */ +export function resolveContentClipsPeerSecret( + config: ClipsConfig, + deploymentA2ASecret: string | undefined, +): string | null { + const secret = config.contentPeerSecret?.trim(); + if (!secret) return null; + if (secret.length < MIN_CONTENT_CLIPS_PEER_SECRET_CHARS) { + throw new AppConfigurationError( + `CONTENT_CLIPS_PEER_SECRET must be at least ${MIN_CONTENT_CLIPS_PEER_SECRET_CHARS} characters.`, + ); + } + if (secret === deploymentA2ASecret?.trim()) { + throw new AppConfigurationError( + "CONTENT_CLIPS_PEER_SECRET must differ from A2A_SECRET.", + ); + } + return secret; +} + export type ResolvedClipsHostedStorageConfig = ClipsHostedStorageConfig & { enabled: true; endpoint: string; diff --git a/packages/core/src/app-config/index.ts b/packages/core/src/app-config/index.ts index 9dff25d5c62..9f6355e4e3d 100644 --- a/packages/core/src/app-config/index.ts +++ b/packages/core/src/app-config/index.ts @@ -8,6 +8,8 @@ export { resolveAppHomePath } from "./app-identity.js"; export { AppConfigurationError } from "./configuration-error.js"; export { resolveClipsHostedStorageConfig, + resolveContentClipsPeerSecret, + type ClipsConfig, type ClipsHostedStorageConfig, type ResolvedClipsHostedStorageConfig, } from "./clips.js"; diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index a1e65335c14..45db090e1a6 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -16,9 +16,11 @@ export { resolveAppHomePath, resetAppConfigForTests, resolveClipsHostedStorageConfig, + resolveContentClipsPeerSecret, appConfigSchema, type AppConfig, type AppConfigInput, + type ClipsConfig, type ClipsHostedStorageConfig, type ResolvedClipsHostedStorageConfig, } from "../app-config/index.js"; @@ -931,6 +933,7 @@ export { signShortLivedToken, verifyShortLivedToken, type ShortLivedTokenClaims, + type ShortLivedTokenKeyOptions, type VerifyResult as ShortLivedTokenVerifyResult, } from "./short-lived-token.js"; export { diff --git a/packages/core/src/server/short-lived-token.spec.ts b/packages/core/src/server/short-lived-token.spec.ts index dbb8cc792d0..7a62aae0b9d 100644 --- a/packages/core/src/server/short-lived-token.spec.ts +++ b/packages/core/src/server/short-lived-token.spec.ts @@ -38,6 +38,37 @@ describe("short-lived-token", () => { expect(result.ok).toBe(true); }); + it("keeps a token signed with an explicit key apart from the server secret", () => { + const signingKey = "explicit-short-lived-key-0123456789abcdef"; + const explicit = signShortLivedToken( + { resourceId: "rec_abc" }, + { signingKey }, + ); + const server = signShortLivedToken({ resourceId: "rec_abc" }); + + expect(verifyShortLivedToken(explicit, "rec_abc", { signingKey }).ok).toBe( + true, + ); + expect(verifyShortLivedToken(explicit, "rec_abc")).toEqual({ + ok: false, + reason: "bad_signature", + }); + expect(verifyShortLivedToken(server, "rec_abc", { signingKey })).toEqual({ + ok: false, + reason: "bad_signature", + }); + }); + + it("refuses an empty explicit key instead of using the server secret", () => { + const token = signShortLivedToken({ resourceId: "rec_abc" }); + expect(() => + signShortLivedToken({ resourceId: "rec_abc" }, { signingKey: "" }), + ).toThrow("An explicit short-lived token signing key is empty."); + expect(() => + verifyShortLivedToken(token, "rec_abc", { signingKey: "" }), + ).toThrow("An explicit short-lived token signing key is empty."); + }); + it("includes viewerEmail in claims when supplied", () => { const token = signShortLivedToken({ resourceId: "rec_abc", diff --git a/packages/core/src/server/short-lived-token.ts b/packages/core/src/server/short-lived-token.ts index d05edce343c..2b09f1dfa9b 100644 --- a/packages/core/src/server/short-lived-token.ts +++ b/packages/core/src/server/short-lived-token.ts @@ -24,6 +24,15 @@ interface DecodedClaims { exp: number; } +export interface ShortLivedTokenKeyOptions { + /** + * Sign or verify with this key alone, never the app's server secret, so a + * token minted under another key (one derived from `A2A_SECRET`, say) + * cannot pass. An empty key throws rather than fall back. + */ + signingKey?: string; +} + export type VerifyResult = | { ok: true; viewerEmail?: string; agentLabel?: string } | { ok: false; reason: string }; @@ -50,6 +59,14 @@ function getSigningKey(): string { return _devSigningKey; } +function keyFor(options: ShortLivedTokenKeyOptions | undefined): string { + if (options?.signingKey === undefined) return getSigningKey(); + if (!options.signingKey) { + throw new Error("An explicit short-lived token signing key is empty."); + } + return options.signingKey; +} + function base64UrlEncode(buf: Buffer | string): string { const b = typeof buf === "string" ? Buffer.from(buf, "utf8") : buf; return b @@ -64,7 +81,10 @@ function base64UrlDecode(s: string): Buffer { return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); } -export function signShortLivedToken(claims: ShortLivedTokenClaims): string { +export function signShortLivedToken( + claims: ShortLivedTokenClaims, + options?: ShortLivedTokenKeyOptions, +): string { const ttl = claims.ttlSeconds ?? DEFAULT_TTL_SECONDS; const payload: DecodedClaims = { resourceId: claims.resourceId, @@ -75,7 +95,7 @@ export function signShortLivedToken(claims: ShortLivedTokenClaims): string { const payloadStr = base64UrlEncode(JSON.stringify(payload)); const sig = base64UrlEncode( - crypto.createHmac("sha256", getSigningKey()).update(payloadStr).digest(), + crypto.createHmac("sha256", keyFor(options)).update(payloadStr).digest(), ); return `${payloadStr}.${sig}`; } @@ -95,6 +115,7 @@ export function signShortLivedToken(claims: ShortLivedTokenClaims): string { export function verifyShortLivedToken( token: string, expectedResourceId: string, + options?: ShortLivedTokenKeyOptions, ): VerifyResult { if (typeof token !== "string" || !token.includes(".")) { return { ok: false, reason: "malformed" }; @@ -103,7 +124,7 @@ export function verifyShortLivedToken( if (!payloadStr || !sig) return { ok: false, reason: "malformed" }; const expected = base64UrlEncode( - crypto.createHmac("sha256", getSigningKey()).update(payloadStr).digest(), + crypto.createHmac("sha256", keyFor(options)).update(payloadStr).digest(), ); const sigBuf = Buffer.from(sig, "utf8"); diff --git a/packages/core/src/sharing/access.spec.ts b/packages/core/src/sharing/access.spec.ts index 834a34472b0..47d81893fda 100644 --- a/packages/core/src/sharing/access.spec.ts +++ b/packages/core/src/sharing/access.spec.ts @@ -658,13 +658,43 @@ describe("shareable resource access helpers", () => { } }); - it("asks the host about signed-out requests too", async () => { - await insertDoc({ id: "doc-hosted-anonymous" }); + it("never asks a host about signed-out requests unless it opted in", async () => { + await insertDoc({ id: "doc-hosted-signed-in-only" }); const asked: Array = []; - registerHostAccessResolver(resourceType, async (resource, ctx) => { + registerHostAccessResolver(resourceType, async (_resource, ctx) => { asked.push(ctx.userEmail); - return resource.id === "doc-hosted-anonymous" ? "viewer" : undefined; + return "editor"; }); + try { + await runWithRequestContext({}, async () => { + await expect( + resolveAccess(resourceType, "doc-hosted-signed-in-only"), + ).resolves.toBeNull(); + }); + expect(asked).toEqual([]); + + await runWithRequestContext({ userEmail: viewerEmail }, async () => { + await expect( + resolveAccess(resourceType, "doc-hosted-signed-in-only"), + ).resolves.toMatchObject({ role: "editor" }); + }); + expect(asked).toEqual([viewerEmail]); + } finally { + registerHostAccessResolver(resourceType, null); + } + }); + + it("asks a host that opted in about signed-out requests", async () => { + await insertDoc({ id: "doc-hosted-anonymous" }); + const asked: Array = []; + registerHostAccessResolver( + resourceType, + async (resource, ctx) => { + asked.push(ctx.userEmail); + return resource.id === "doc-hosted-anonymous" ? "viewer" : undefined; + }, + { includeSignedOut: true }, + ); try { await runWithRequestContext({}, async () => { await expect( diff --git a/packages/core/src/sharing/access.ts b/packages/core/src/sharing/access.ts index c2653073dc6..3b94cd5b460 100644 --- a/packages/core/src/sharing/access.ts +++ b/packages/core/src/sharing/access.ts @@ -668,23 +668,41 @@ async function loadAndResolveAccess( * to a Builder project), the host is the authority on who may open them. The * resolver returns a role, `null` to deny, or `undefined` to fall through to * AN's own owner/org/share rules. AN keeps no copy of the host's members, - * orgs or roles: it asks, per resource, per request. It is asked for - * signed-out requests too (`ctx.userEmail` unset), so a resolver that only - * vouches for people must return `undefined` when there is no email. + * orgs or roles: it asks, per resource, per request. It is asked only about + * signed-in requests unless registered with `includeSignedOut`. */ export type HostAccessResolver = ( resource: any, ctx: AccessContext, ) => Promise; -const hostAccessResolvers = new Map(); +export interface HostAccessResolverOptions { + /** + * Also ask about signed-out requests, with `ctx.userEmail` unset. Only a + * resolver written to vouch for someone with no identity may set this: any + * role it returns then goes to an anonymous request. + */ + includeSignedOut?: boolean; +} + +const hostAccessResolvers = new Map< + string, + { resolve: HostAccessResolver; includeSignedOut: boolean } +>(); export function registerHostAccessResolver( resourceType: string, resolver: HostAccessResolver | null, + options?: HostAccessResolverOptions, ): void { - if (resolver) hostAccessResolvers.set(resourceType, resolver); - else hostAccessResolvers.delete(resourceType); + if (resolver) { + hostAccessResolvers.set(resourceType, { + resolve: resolver, + includeSignedOut: options?.includeSignedOut === true, + }); + } else { + hostAccessResolvers.delete(resourceType); + } } async function ownerOrAdminRole( @@ -728,8 +746,11 @@ async function accessToResource( // A host can deny outright. When it grants access, the resource's owner // and admins keep their own roles; the host's role covers everyone else. - const hostResolver = hostAccessResolvers.get(reg.type); - const hosted = hostResolver ? await hostResolver(resource, ctx) : undefined; + const host = hostAccessResolvers.get(reg.type); + const hosted = + host && (normalizedUserEmail || host.includeSignedOut) + ? await host.resolve(resource, ctx) + : undefined; if (hosted === null) return null; const managingRole = await ownerOrAdminRole( diff --git a/packages/core/src/sharing/index.ts b/packages/core/src/sharing/index.ts index 8327686073c..c8c589c9828 100644 --- a/packages/core/src/sharing/index.ts +++ b/packages/core/src/sharing/index.ts @@ -27,6 +27,7 @@ export { resolveAccessStatus, registerHostAccessResolver, type HostAccessResolver, + type HostAccessResolverOptions, type AccessContext, type ResolvedAccess, type ResourceAccessState, diff --git a/templates/clips/.agents/skills/video-sharing/SKILL.md b/templates/clips/.agents/skills/video-sharing/SKILL.md index 108fb893752..77a57caaae2 100644 --- a/templates/clips/.agents/skills/video-sharing/SKILL.md +++ b/templates/clips/.agents/skills/video-sharing/SKILL.md @@ -141,10 +141,11 @@ Framework first, Clips additions second. Don't invert this — the framework own A Clip placed in a Content document has one `recording_content_bindings` row per (recording, Content app, document). `origin` is `upload` (made in the document) or `link` (pasted); `state` is `active` or `trashed`. A duplicated document adds its own row. -- Only an active `upload` binding grants access: a Content reader with no access of their own becomes a viewer of that one recording (`server/lib/content-doc-grant.ts`, a host access resolver). A reader's own Clips sharing, in their active workspace, still applies and is never lowered. A `link` binding, or none, leaves the Clip's own sharing in charge. When every binding is trashed, document readers lose access; the owner never does. +- Only an active `upload` binding grants access: a Content reader with no access of their own becomes a viewer of that one recording (`server/lib/content-doc-grant.ts`, a host access resolver registered with `includeSignedOut: true` so it is asked about signed-out readers of a public document too). A reader's own Clips sharing, in their active workspace, still applies and is never lowered. A `link` binding, or none, leaves the Clip's own sharing in charge. When every binding is trashed, document readers lose access; the owner never does. - The Clip's own rules still apply on top: Trash, takedown and expiry refuse document readers, a password-protected Clip does not play through a document, and with comments off document readers see no comments and cannot write. - Content calls `/api/content-clips/:recordingId/playback` and `/comments` (GET to list, POST to add, reply, edit, delete, resolve or react) server to server with a one-minute A2A token. `shared/content-clip-contract.ts` is the contract: audience `/content-clips`, one scope, and `document_id` + `recording_id` claims. A signed-out reader of a public document gets the `content-clip:public-read` scope: playback and the comment list, never a write. -- Playback returns Clips media URLs with a five-minute `content_grant` that names the reader, not a role. `/api/video` and `/api/thumbnail` re-run every gate on each request, so trashing the binding stops a URL already handed out. An expired grant is a 403 with code `content_grant_expired`; Content's player then asks Content for playback again, which re-checks document access. +- The token is signed with `CONTENT_CLIPS_PEER_SECRET` (`clips.contentPeerSecret`, read with `resolveContentClipsPeerSecret`), a secret only the Content and Clips deployments hold, and Clips verifies it with that secret alone. That is what makes the token's issuer trustworthy as the Content app: a token signed with the deployment `A2A_SECRET` or a workspace secret is refused, because other apps hold those. While the secret is unset, shorter than 32 characters, or equal to `A2A_SECRET`, every route answers 503 with code `content_clips_not_configured`; set it on both deployments. +- Playback returns Clips media URLs with a five-minute `content_grant` that names the reader, not a role. The grant is signed with a key derived from `CONTENT_CLIPS_PEER_SECRET`, never the app's own server secret (which a workspace derives from `A2A_SECRET`), and a media request carrying a grant while the secret is unusable gets the same 503. `/api/video` and `/api/thumbnail` re-run every gate on each request, so trashing the binding stops a URL already handed out. An expired grant is a 403 with code `content_grant_expired`; Content's player then asks Content for playback again, which re-checks document access. - Create and change binding rows only through Content's upload and lifecycle flows. ## Embed URLs diff --git a/templates/clips/server/db/index.ts b/templates/clips/server/db/index.ts index 91a3de0dd63..25c06a11a49 100644 --- a/templates/clips/server/db/index.ts +++ b/templates/clips/server/db/index.ts @@ -77,7 +77,9 @@ registerShareableResource({ getDb, ownerAccessIgnoresOrg: true, }); -registerHostAccessResolver("recording", contentDocReaderRole); +registerHostAccessResolver("recording", contentDocReaderRole, { + includeSignedOut: true, +}); registerShareableResource({ type: "meeting", diff --git a/templates/clips/server/lib/content-clip-peer.ts b/templates/clips/server/lib/content-clip-peer.ts index cd89817b25c..7d9a4bc73fc 100644 --- a/templates/clips/server/lib/content-clip-peer.ts +++ b/templates/clips/server/lib/content-clip-peer.ts @@ -40,6 +40,7 @@ import { type ContentClipBinding, } from "./content-clip-bindings.js"; import { runWithContentDocGrant } from "./content-doc-grant.js"; +import { readContentPeerSecret } from "./content-peer-secret.js"; import { contentReaderOrgId, signContentPlaybackGrant, @@ -148,6 +149,10 @@ async function verifyPeer( recordingId: string, scopes: readonly ContentClipScope[], ): Promise { + const peerSecret = readContentPeerSecret(); + if (peerSecret.refused) { + return json(peerSecret.refused.body, peerSecret.refused.status); + } const match = BEARER.exec(getRequestHeader(event, "authorization") ?? ""); if (!match) return unauthorized(); @@ -155,7 +160,7 @@ async function verifyPeer( try { verified = await verifyA2AToken(match[1]!, event, { routePrefix: CONTENT_CLIP_AUDIENCE_PATH, - globalSecretOnly: true, + verificationSecret: peerSecret.secret, includeClaims: true, requireAudience: true, }); diff --git a/templates/clips/server/lib/content-clips.test.ts b/templates/clips/server/lib/content-clips.test.ts index 2036869e0db..1fbea63fb42 100644 --- a/templates/clips/server/lib/content-clips.test.ts +++ b/templates/clips/server/lib/content-clips.test.ts @@ -18,11 +18,16 @@ import { const CLIPS_URL = "https://clips.example.test"; const CONTENT_APP = "https://content.example.test"; const SECRET = "content-clips-test-secret-0123456789abcdef"; +const PEER_SECRET = "content-clips-peer-secret-0123456789abcdef"; const GRANT_SECRET = "content-clips-grant-secret-0123456789abcdef"; const state = vi.hoisted(() => { vi.stubEnv("APP_URL", "https://clips.example.test"); vi.stubEnv("A2A_SECRET", "content-clips-test-secret-0123456789abcdef"); + vi.stubEnv( + "CONTENT_CLIPS_PEER_SECRET", + "content-clips-peer-secret-0123456789abcdef", + ); vi.stubEnv( "OAUTH_STATE_SECRET", "content-clips-grant-secret-0123456789abcdef", @@ -62,12 +67,14 @@ const { withDbExec } = await import("@agent-native/core/db"); const { schema } = await import("../db/index.js"); const { CONTENT_CLIP_GRANT_EXPIRED, + CONTENT_CLIP_NOT_CONFIGURED, CONTENT_CLIP_PLAYBACK_GRANT_SECONDS, CONTENT_CLIP_PUBLIC_READER, CONTENT_CLIP_SCOPES: SCOPES, } = await import("../../shared/content-clip-contract.js"); const { __resetContentClipRateLimitsForTests } = await import("./content-clip-peer.js"); +const { contentPlaybackGrantKey } = await import("./content-peer-secret.js"); const { default: playbackRoute } = await import("../routes/api/content-clips/[recordingId]/playback.get.js"); const { default: listCommentsRoute } = @@ -278,7 +285,7 @@ async function token(input: TokenInput = {}): Promise { if (input.audience !== null) { jwt = jwt.setAudience(input.audience ?? `${CLIPS_URL}/content-clips`); } - return jwt.sign(new TextEncoder().encode(input.secret ?? SECRET)); + return jwt.sign(new TextEncoder().encode(input.secret ?? PEER_SECRET)); } function publicReaderToken(input: TokenInput = {}) { @@ -375,13 +382,14 @@ async function fetchMedia( function withRewrittenGrant( mediaUrl: string, rewrite: (claims: Record) => void, + key = contentPlaybackGrantKey(PEER_SECRET), ): string { const url = new URL(mediaUrl); const [payload] = url.searchParams.get("content_grant")!.split("."); const claims = JSON.parse(Buffer.from(payload!, "base64url").toString()); rewrite(claims); const encoded = Buffer.from(JSON.stringify(claims)).toString("base64url"); - const signature = createHmac("sha256", GRANT_SECRET) + const signature = createHmac("sha256", key) .update(encoded) .digest("base64url"); url.searchParams.set("content_grant", `${encoded}.${signature}`); @@ -638,6 +646,49 @@ describe("Content doc readers play a Clip uploaded to the doc", () => { } }); + it("refuses a media grant signed with any key but the peer secret's", async () => { + const result = await playback("rec-upload", await token()); + + for (const [route, url] of [ + [videoRoute, result.body.playback.videoUrl], + [thumbnailRoute, result.body.playback.posterUrl], + ] as const) { + // The app's own server secret, which a workspace derives from the + // shared A2A secret, the A2A secret itself, and the underived peer + // secret: none of them signs a grant, even one naming the owner. + for (const key of [GRANT_SECRET, SECRET, PEER_SECRET]) { + const forged = withRewrittenGrant( + url, + (claims) => (claims.viewerEmail = OWNER), + key, + ); + const media = await fetchMedia(route, forged); + expect(media.status).toBe(404); + expect(media.bytes).toBeNull(); + } + } + }); + + it("answers a media grant with 503 while the peer secret is unset", async () => { + const result = await playback("rec-upload", await token()); + vi.stubEnv("CONTENT_CLIPS_PEER_SECRET", ""); + try { + for (const [route, url] of [ + [videoRoute, result.body.playback.videoUrl], + [thumbnailRoute, result.body.playback.posterUrl], + ] as const) { + const media = await fetchMedia(route, url); + expect(media.status).toBe(503); + expect(media.body).toEqual({ + error: expect.any(String), + code: CONTENT_CLIP_NOT_CONFIGURED, + }); + } + } finally { + vi.stubEnv("CONTENT_CLIPS_PEER_SECRET", PEER_SECRET); + } + }); + it("keeps the owner's exceptions for a trashed, expired or unbound Clip", async () => { await setBindingState("binding-doc-1", "trashed"); await updateRecording("rec-upload", { @@ -1210,6 +1261,7 @@ describe("token checks", () => { ["no issuer", () => token({ issuer: null })], ["a non-http issuer", () => token({ issuer: "urn:content" })], ["another secret", () => token({ secret: "not-the-deploy-secret-012345" })], + ["the deployment A2A secret", () => token({ secret: SECRET })], [ "an expired token", () => token({ issuedAt: now() - 120, expiresAt: now() - 60 }), @@ -1264,9 +1316,8 @@ describe("token checks", () => { expect(result.status).toBe(401); }); - it("accepts a token minted by the shared A2A signer", async () => { - const bearer = await signA2AToken(READER, undefined, undefined, { - preferGlobalSecret: true, + it("accepts a token core's shared signer mints with the peer secret", async () => { + const bearer = await signA2AToken(READER, undefined, PEER_SECRET, { expiresIn: "1m", audience: `${CLIPS_URL}/content-clips`, extraClaims: { @@ -1283,6 +1334,42 @@ describe("token checks", () => { expect((await playback("rec-upload", bearer)).status).toBe(200); }); + describe("without a secret only Content and Clips hold", () => { + afterEach(() => { + vi.stubEnv("CONTENT_CLIPS_PEER_SECRET", PEER_SECRET); + }); + + it.each([ + ["unset", "", PEER_SECRET], + ["the deployment A2A secret", SECRET, SECRET], + ["too short to sign with", "short-peer-secret", "short-peer-secret"], + ])("refuses every route while it is %s", async (_name, value, signer) => { + vi.stubEnv("CONTENT_CLIPS_PEER_SECRET", value); + const read = await token({ secret: signer }); + const write = await token({ scope: SCOPES.comment, secret: signer }); + + for (const result of [ + await playback("rec-upload", read), + await listComments("rec-upload", read), + await writeComment("rec-upload", write, { + operation: "add", + content: "Hi", + videoTimestampMs: 0, + }), + ]) { + expect(result.status).toBe(503); + expect(result.body).toEqual({ + error: expect.any(String), + code: CONTENT_CLIP_NOT_CONFIGURED, + }); + } + const { rows } = await client.query( + "SELECT count(*)::int AS count FROM recording_comments", + ); + expect(rows[0]).toEqual({ count: 0 }); + }); + }); + it("refuses a token from another Content app for this doc", async () => { const result = await playback( "rec-upload", diff --git a/templates/clips/server/lib/content-peer-secret.ts b/templates/clips/server/lib/content-peer-secret.ts new file mode 100644 index 00000000000..28efd25038c --- /dev/null +++ b/templates/clips/server/lib/content-peer-secret.ts @@ -0,0 +1,56 @@ +import { createHmac } from "node:crypto"; + +import { getGlobalA2ASecret } from "@agent-native/core/a2a"; +import { AppConfigurationError } from "@agent-native/core/app-config"; +import { + getAppConfig, + resolveContentClipsPeerSecret, +} from "@agent-native/core/server"; + +import { CONTENT_CLIP_NOT_CONFIGURED } from "../../shared/content-clip-contract.js"; + +/** What a Content route or Content media URL answers without a usable secret. */ +export const CONTENT_CLIPS_NOT_CONFIGURED = { + status: 503, + body: { + error: "Clips is not set up to accept requests from Content", + code: CONTENT_CLIP_NOT_CONFIGURED, + }, +}; + +export type ContentPeerSecret = + | { secret: string; refused?: never } + | { secret?: never; refused: typeof CONTENT_CLIPS_NOT_CONFIGURED }; + +/** + * The secret only Content and Clips hold. Content's requests, and the media + * grants Clips hands back for them, are checked against it alone: a + * deployment or workspace A2A secret is held by other apps too, so anything + * signed with one could claim to come from Content. + */ +export function readContentPeerSecret(): ContentPeerSecret { + try { + const secret = resolveContentClipsPeerSecret( + getAppConfig().clips, + getGlobalA2ASecret(), + ); + return secret ? { secret } : { refused: CONTENT_CLIPS_NOT_CONFIGURED }; + } catch (error) { + if (!(error instanceof AppConfigurationError)) throw error; + console.error( + "[content-clips] peer secret configuration is invalid", + error, + ); + return { refused: CONTENT_CLIPS_NOT_CONFIGURED }; + } +} + +/** + * The key media grants are signed with. It is derived from the peer secret + * rather than being it, so the two kinds of token never share a key. + */ +export function contentPlaybackGrantKey(secret: string): string { + return createHmac("sha256", secret) + .update("agent-native:content-clips:playback-grant:v1") + .digest("hex"); +} diff --git a/templates/clips/server/lib/content-playback-grant.ts b/templates/clips/server/lib/content-playback-grant.ts index ee9078d826c..c43f95b58e3 100644 --- a/templates/clips/server/lib/content-playback-grant.ts +++ b/templates/clips/server/lib/content-playback-grant.ts @@ -17,16 +17,24 @@ import { runWithContentDocGrant, type ContentDocGrant, } from "./content-doc-grant.js"; +import { + CONTENT_CLIPS_NOT_CONFIGURED, + contentPlaybackGrantKey, + readContentPeerSecret, +} from "./content-peer-secret.js"; // A Content page plays the video and poster straight from Clips, so their // URLs carry who Content vouched for. The grant names the reader, never a // role: each media request re-runs every gate, so trashing the binding or the -// Clip stops a URL that was already handed out. +// Clip stops a URL that was already handed out. Grants are signed with a key +// derived from the Content-Clips peer secret, never the app's own server +// secret, which a workspace derives from the shared `A2A_SECRET`. const GRANT_PARAM = "content_grant"; const BINDING_PARAM = "content_binding"; export type ContentPlaybackAccess = | { status: "absent" } + | { status: "unconfigured" } | { status: "invalid" } | { status: "expired" } | { @@ -61,12 +69,19 @@ export function signContentPlaybackGrant(input: { bindingId: string | null; viewerEmail: string | null; }): { params: Record; expiresAt: string } { + const peerSecret = readContentPeerSecret(); + if (peerSecret.refused) { + throw new Error("Content playback grants need CONTENT_CLIPS_PEER_SECRET."); + } const issuedAtSeconds = Math.floor(Date.now() / 1000); - const token = signShortLivedToken({ - resourceId: grantResourceId(input.recordingId, input.bindingId), - viewerEmail: input.viewerEmail ?? undefined, - ttlSeconds: CONTENT_CLIP_PLAYBACK_GRANT_SECONDS, - }); + const token = signShortLivedToken( + { + resourceId: grantResourceId(input.recordingId, input.bindingId), + viewerEmail: input.viewerEmail ?? undefined, + ttlSeconds: CONTENT_CLIP_PLAYBACK_GRANT_SECONDS, + }, + { signingKey: contentPlaybackGrantKey(peerSecret.secret) }, + ); return { params: { [GRANT_PARAM]: token, @@ -93,7 +108,9 @@ export function withContentPlaybackGrant( * Reads a media request's Content grant. A grant that is present but does not * verify for this recording is `invalid`, never `absent`, so a tampered URL * cannot fall back to the browser's own Clips session. A genuine grant that - * ran out is `expired`, which Content's player answers by asking again. + * ran out is `expired`, which Content's player answers by asking again. With + * no usable peer secret no grant can be checked, so any grant is + * `unconfigured`. */ export async function readContentPlaybackGrant( event: H3Event, @@ -103,12 +120,15 @@ export async function readContentPlaybackGrant( if (query[GRANT_PARAM] === undefined && query[BINDING_PARAM] === undefined) { return { status: "absent" }; } + const peerSecret = readContentPeerSecret(); + if (peerSecret.refused) return { status: "unconfigured" }; const token = firstQueryValue(query[GRANT_PARAM]); const bindingId = firstQueryValue(query[BINDING_PARAM]) || null; if (!token) return { status: "invalid" }; const verified = verifyShortLivedToken( token, grantResourceId(recordingId, bindingId), + { signingKey: contentPlaybackGrantKey(peerSecret.secret) }, ); if (!verified.ok) { return verified.reason === "expired" @@ -137,6 +157,7 @@ export async function readContentPlaybackGrant( export function refusedContentPlayback( access: ContentPlaybackAccess, ): { status: number; body: { error: string; code?: string } } | null { + if (access.status === "unconfigured") return CONTENT_CLIPS_NOT_CONFIGURED; if (access.status === "invalid") { return { status: 404, body: { error: "Not found" } }; } diff --git a/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts b/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts index 883c19ec301..5d6b48533e5 100644 --- a/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts +++ b/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts @@ -35,6 +35,10 @@ vi.mock("drizzle-orm", () => ({ eq: vi.fn(), })); +vi.mock("@agent-native/core/a2a", () => ({ + getGlobalA2ASecret: vi.fn(() => undefined), +})); + vi.mock("@agent-native/core/extensions/url-safety", () => ({ createSsrfSafeDispatcher: (...args: unknown[]) => mockCreateSsrfSafeDispatcher(...args), diff --git a/templates/clips/server/routes/api/video/[recordingId].get.test.ts b/templates/clips/server/routes/api/video/[recordingId].get.test.ts index 72367206e17..147971c2571 100644 --- a/templates/clips/server/routes/api/video/[recordingId].get.test.ts +++ b/templates/clips/server/routes/api/video/[recordingId].get.test.ts @@ -33,6 +33,10 @@ vi.mock("h3", () => ({ setCookie: (...args: unknown[]) => mockSetCookie(...args), })); +vi.mock("@agent-native/core/a2a", () => ({ + getGlobalA2ASecret: vi.fn(() => undefined), +})); + vi.mock("@agent-native/core/application-state", () => ({ readAppState: (...args: unknown[]) => mockReadAppState(...args), })); diff --git a/templates/clips/shared/content-clip-contract.ts b/templates/clips/shared/content-clip-contract.ts index 9492f70061a..9e2e94bc0c6 100644 --- a/templates/clips/shared/content-clip-contract.ts +++ b/templates/clips/shared/content-clip-contract.ts @@ -1,8 +1,24 @@ // The contract Agent-Native Content uses to call Clips server to server about -// a Clip in one of its documents. Content mints an A2A token with -// `signA2AToken` from the deployment `A2A_SECRET`, bound to this audience path -// on the Clips app URL, and carrying one scope plus the document and recording -// it vouches for. Content keeps its own copy of these strings. +// a Clip in one of its documents. Content keeps its own copy of these strings. +// +// Content signs each request with the secret only Content and Clips hold, +// `CONTENT_CLIPS_PEER_SECRET`, read on both sides with +// `resolveContentClipsPeerSecret(getAppConfig().clips, getGlobalA2ASecret())` +// (null: not set up, so Content makes no call; a throw: misconfigured). Clips +// verifies with that secret alone, so neither the deployment `A2A_SECRET` nor a +// workspace secret can mint a token these routes accept: +// +// signA2AToken(subject, undefined, peerSecret, { +// expiresIn: "1m", +// audience: `${canonicalA2AAudience(clipsAppUrl)}/${CONTENT_CLIP_AUDIENCE_PATH}`, +// extraClaims: { scope, document_id, recording_id, name? }, +// }) +// +// No org domain and no `preferGlobalSecret`: either would let core sign with +// another secret. That gives an HS256 token whose `iss` is Content's app URL, +// which must equal the `content_app` Clips stored for the document's Clip. The +// subject is the reader's email for `read` and `comment`, and +// `CONTENT_CLIP_PUBLIC_READER` for `publicRead`. /** Appended to the Clips app URL to form the token's `aud`. */ export const CONTENT_CLIP_AUDIENCE_PATH = "content-clips"; @@ -49,3 +65,9 @@ export const CONTENT_CLIP_PLAYBACK_GRANT_SECONDS = 5 * 60; /** The `code` of a 403 media response refused only because its grant expired. */ export const CONTENT_CLIP_GRANT_EXPIRED = "content_grant_expired"; + +/** + * The `code` of the 503 every route answers with while Clips has no usable + * `CONTENT_CLIPS_PEER_SECRET`: unset, too short, or the same as `A2A_SECRET`. + */ +export const CONTENT_CLIP_NOT_CONFIGURED = "content_clips_not_configured"; From 71fb7a9b24c6e6f85dbbfa411457c7e01fb5fd73 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:58:31 -0400 Subject: [PATCH 11/35] Show unavailable state for missing shared recordings --- .../clips/app/lib/missing-share-retry.test.ts | 48 ----- .../clips/app/lib/missing-share-retry.ts | 20 -- .../app/routes/share-auth-loading.test.ts | 6 - .../clips/app/routes/share.$shareId.test.tsx | 187 ++++++++++++++++++ templates/clips/app/routes/share.$shareId.tsx | 24 +-- 5 files changed, 188 insertions(+), 97 deletions(-) delete mode 100644 templates/clips/app/lib/missing-share-retry.test.ts delete mode 100644 templates/clips/app/lib/missing-share-retry.ts create mode 100644 templates/clips/app/routes/share.$shareId.test.tsx diff --git a/templates/clips/app/lib/missing-share-retry.test.ts b/templates/clips/app/lib/missing-share-retry.test.ts deleted file mode 100644 index 44ee8cf14d5..00000000000 --- a/templates/clips/app/lib/missing-share-retry.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { missingShareRetryDelayMs } from "./missing-share-retry"; - -describe("missingShareRetryDelayMs", () => { - it("backs off while a share the loader could not find is still missing", () => { - const delays = [1, 2, 3, 4, 5, 6, 7].map((updateCount) => - missingShareRetryDelayMs({ - status: 404, - updateCount, - loaderFoundClip: false, - }), - ); - expect(delays).toEqual([250, 500, 1000, 2000, 2000, 2000, 2000]); - }); - - it("gives up after the retry limit", () => { - expect( - missingShareRetryDelayMs({ - status: 404, - updateCount: 8, - loaderFoundClip: false, - }), - ).toBeNull(); - }); - - it("does not retry a 404 once the loader found the clip", () => { - expect( - missingShareRetryDelayMs({ - status: 404, - updateCount: 1, - loaderFoundClip: true, - }), - ).toBeNull(); - }); - - it("does not retry other statuses", () => { - for (const status of [200, 401, 403, 410, undefined]) { - expect( - missingShareRetryDelayMs({ - status, - updateCount: 1, - loaderFoundClip: false, - }), - ).toBeNull(); - } - }); -}); diff --git a/templates/clips/app/lib/missing-share-retry.ts b/templates/clips/app/lib/missing-share-retry.ts deleted file mode 100644 index 76a46eb58c5..00000000000 --- a/templates/clips/app/lib/missing-share-retry.ts +++ /dev/null @@ -1,20 +0,0 @@ -const MISSING_SHARE_RETRY_LIMIT = 8; -const MISSING_SHARE_RETRY_INTERVAL_MS = [250, 500, 1000, 2000] as const; - -// How long to wait before asking the share API again, or null to stop. A 404 -// means "the share row isn't written yet" only while the loader has not found -// the clip. Once it has (loaderFoundClip), the API returns 404 for a viewer -// without access, so retrying only delays the request-access screen. -export function missingShareRetryDelayMs({ - status, - updateCount, - loaderFoundClip, -}: { - status: number | undefined; - updateCount: number; - loaderFoundClip: boolean; -}): number | null { - if (status !== 404 || loaderFoundClip) return null; - if (updateCount >= MISSING_SHARE_RETRY_LIMIT) return null; - return MISSING_SHARE_RETRY_INTERVAL_MS[updateCount - 1] ?? 2000; -} diff --git a/templates/clips/app/routes/share-auth-loading.test.ts b/templates/clips/app/routes/share-auth-loading.test.ts index 3088693d9a8..033ac54b0c0 100644 --- a/templates/clips/app/routes/share-auth-loading.test.ts +++ b/templates/clips/app/routes/share-auth-loading.test.ts @@ -102,12 +102,6 @@ describe("authenticated recording route loading", () => { expect(processingView).not.toContain("ShareCopyRow"); }); - it("keeps transient missing share records loading while retrying locally", () => { - const route = readRoute("share.$shareId.tsx"); - expect(route).toContain("retryingMissingShare ||"); - expect(route).toContain("getQueryState(shareQueryKey)?.dataUpdateCount"); - }); - it("keeps expired share loader data impersonal for CDN caching", () => { const route = readRoute("share.$shareId.tsx"); diff --git a/templates/clips/app/routes/share.$shareId.test.tsx b/templates/clips/app/routes/share.$shareId.test.tsx new file mode 100644 index 00000000000..c68a6674c97 --- /dev/null +++ b/templates/clips/app/routes/share.$shareId.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment happy-dom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + loaderData: {} as Record, + sessionStatus: "unauthenticated" as "unauthenticated" | "loading", +})); + +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useLoaderData: () => mocks.loaderData, +})); + +vi.mock("@agent-native/core/client/hooks", async (importOriginal) => ({ + ...(await importOriginal()), + useSession: () => ({ + session: null, + isLoading: mocks.sessionStatus === "loading", + status: mocks.sessionStatus, + error: null, + retry: () => {}, + }), + useActionMutation: () => ({ isPending: false, mutate: vi.fn() }), +})); + +vi.mock("@agent-native/core/client/i18n", async (importOriginal) => ({ + ...(await importOriginal()), + useT: () => (key: string) => key, +})); + +vi.mock("@agent-native/core/client/analytics", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@agent-native/core/client/analytics") + >()), + trackEvent: vi.fn(), +})); + +vi.mock("../../server/db", () => ({ getDb: vi.fn(), schema: {} })); + +import ShareRoute from "./share.$shareId"; + +const SHARE_ID = "clip-1"; + +const trashedPublicClip = { + id: SHARE_ID, + title: "Retired demo", + description: "", + ownerInitial: "A", + brandLogoUrl: null, + thumbnailUrl: null, + animatedThumbnailUrl: null, + updatedAt: "2026-10-24T12:00:00.000Z", + visibility: "public", + status: "ready", + hasPassword: false, + archivedAt: null, + trashedAt: "2026-10-24T12:00:00.000Z", + isLoomEmbedBacked: false, +}; + +function loaderData(overrides: Record) { + return { + recording: null, + agentContextUrl: null, + origin: "http://localhost:3000", + shareUrl: null, + ...overrides, + }; +} + +describe("share page when the public recording read answers Not found", () => { + let container: HTMLDivElement; + let root: Root; + let queryClient: QueryClient; + let fetchMock: ReturnType; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.sessionStatus = "unauthenticated"; + fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ error: "Not found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient(); + }); + + afterEach(() => { + act(() => root.unmount()); + queryClient.clear(); + container.remove(); + vi.unstubAllGlobals(); + }); + + function render() { + act(() => { + root.render( + + + + } /> + + + , + ); + }); + } + + function publicRecordingReads() { + return fetchMock.mock.calls.filter(([url]) => + String(url).includes("/api/public-recording"), + ).length; + } + + function pageState() { + return { + heading: container.querySelector("h1")?.textContent ?? null, + loadingPlaceholder: container.querySelector(".skeleton-shimmer") !== null, + publicRecordingReads: publicRecordingReads(), + }; + } + + async function settle(ms: number) { + await act(() => new Promise((resolve) => setTimeout(resolve, ms))); + } + + it.each([ + ["a trashed clip", { recording: trashedPublicClip }], + ["a missing clip", {}], + ])("shows Clip unavailable at once for %s", async (_label, overrides) => { + mocks.loaderData = loaderData(overrides); + render(); + + expect(pageState()).toMatchObject({ + heading: null, + loadingPlaceholder: true, + }); + + await vi.waitFor(() => expect(publicRecordingReads()).toBe(1)); + await settle(400); + + expect(pageState()).toEqual({ + heading: "sharePage.clipUnavailable", + loadingPlaceholder: false, + publicRecordingReads: 1, + }); + }); + + it("keeps the request-access screen for a private clip", async () => { + mocks.loaderData = loaderData({ accessDeniedStatus: 401 }); + render(); + + await vi.waitFor(() => expect(publicRecordingReads()).toBe(1)); + await settle(400); + + expect(pageState()).toEqual({ + heading: "sharePage.privateClip", + loadingPlaceholder: false, + publicRecordingReads: 1, + }); + }); + + it("waits for the session before treating Not found as final", async () => { + mocks.sessionStatus = "loading"; + mocks.loaderData = loaderData({ recording: trashedPublicClip }); + render(); + + await vi.waitFor(() => expect(publicRecordingReads()).toBe(1)); + await settle(400); + + expect(pageState()).toMatchObject({ + heading: null, + loadingPlaceholder: true, + }); + }); +}); diff --git a/templates/clips/app/routes/share.$shareId.tsx b/templates/clips/app/routes/share.$shareId.tsx index 12275ae1d75..fee48f90946 100644 --- a/templates/clips/app/routes/share.$shareId.tsx +++ b/templates/clips/app/routes/share.$shareId.tsx @@ -122,7 +122,6 @@ import { usePlayerShortcuts } from "@/hooks/use-player-shortcuts"; import { useSonnerLifecycleToast } from "@/hooks/use-sonner-lifecycle-toast"; import { useViewTracking } from "@/hooks/use-view-tracking"; import { withMediaVersion } from "@/lib/media-url"; -import { missingShareRetryDelayMs } from "@/lib/missing-share-retry"; import { parsePlaybackSpeed } from "@/lib/playback-speed"; import { readRecoverableRecordingBackupForServerId, @@ -775,15 +774,6 @@ export default function ShareRoute() { enabled: !!shareId, refetchInterval: (q) => { const payload = (q.state.data as { data?: any } | undefined)?.data; - const status = (q.state.data as { status?: number } | undefined)?.status; - const updateCount = - queryClient.getQueryState(shareQueryKey)?.dataUpdateCount ?? 0; - const retryDelay = missingShareRetryDelayMs({ - status, - updateCount, - loaderFoundClip: Boolean(loaderData.accessDeniedStatus), - }); - if (retryDelay !== null) return retryDelay; const rec = payload?.recording; if (!rec) return false; // Poll while the recording is still being assembled / transcoded so the @@ -1321,19 +1311,7 @@ export default function ShareRoute() { retrySession(); }, [retrySession, sessionStatus, shareNeedsSession]); - const retryingMissingShare = - missingShareRetryDelayMs({ - status: dataQ.data?.status, - updateCount: - queryClient.getQueryState(shareQueryKey)?.dataUpdateCount ?? 0, - loaderFoundClip: Boolean(loaderData.accessDeniedStatus), - }) !== null; - - if ( - dataQ.isLoading || - retryingMissingShare || - (sessionNeedsRetry && shareNeedsSession) - ) { + if (dataQ.isLoading || (sessionNeedsRetry && shareNeedsSession)) { return ( <> {agentDiscovery} From 4669b0d15a01a60380eaf733d49c7972687593d8 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:51:33 -0400 Subject: [PATCH 12/35] Keep unavailable recordings out of share metadata --- .../actions/get-recording-player-data.test.ts | 4 +- .../clips/actions/list-notifications.test.ts | 22 ++-- templates/clips/actions/view-screen.test.ts | 17 +-- templates/clips/actions/view-screen.ts | 5 +- .../clips/app/routes/share.$shareId.test.tsx | 101 +++++++++++++++++- templates/clips/app/routes/share.$shareId.tsx | 18 ++-- .../routes/api/public-recording.get.test.ts | 10 +- .../api/thumbnail/[recordingId].get.test.ts | 8 +- .../api/video/[recordingId].get.test.ts | 8 +- 9 files changed, 161 insertions(+), 32 deletions(-) diff --git a/templates/clips/actions/get-recording-player-data.test.ts b/templates/clips/actions/get-recording-player-data.test.ts index d7d7556fe86..7b9a4186fef 100644 --- a/templates/clips/actions/get-recording-player-data.test.ts +++ b/templates/clips/actions/get-recording-player-data.test.ts @@ -573,8 +573,8 @@ describe("get-recording-player-data view count", () => { const query: Record = {}; query.from = (table: unknown) => { isCommentsQuery = - (table as { videoTimestampMs?: string })?.videoTimestampMs === - "recordingComments.videoTimestampMs"; + (table as { videoTimestampMs?: string } | undefined) + ?.videoTimestampMs === "recordingComments.videoTimestampMs"; return query; }; query.where = () => query; diff --git a/templates/clips/actions/list-notifications.test.ts b/templates/clips/actions/list-notifications.test.ts index 3fe55f3d29c..add38ea6604 100644 --- a/templates/clips/actions/list-notifications.test.ts +++ b/templates/clips/actions/list-notifications.test.ts @@ -39,8 +39,10 @@ vi.mock("@agent-native/core/user-profile/server", () => ({ })); vi.mock("../server/lib/recordings.js", () => ({ getCurrentOwnerEmail: () => state.email, - sameOwnerEmail: (a: string, b: string) => - a?.toLowerCase() === b?.toLowerCase(), + sameOwnerEmail: ( + a: string | null | undefined, + b: string | null | undefined, + ) => a?.toLowerCase() === b?.toLowerCase(), })); vi.mock("../server/lib/user-identities.js", () => ({ profileNameFor: () => "Fake author", @@ -70,12 +72,16 @@ vi.mock("../server/db/index.js", () => ({ const rows = table === tables.recordings ? [ - Object.fromEntries( - Object.entries(projection!).map(([key, column]) => [ - key, - state.recording[column as keyof typeof state.recording], - ]), - ), + projection + ? Object.fromEntries( + Object.entries(projection).map(([key, column]) => [ + key, + state.recording[ + column as keyof typeof state.recording + ], + ]), + ) + : state.recording, ] : table === tables.recordingComments ? [ diff --git a/templates/clips/actions/view-screen.test.ts b/templates/clips/actions/view-screen.test.ts index a6deece9a98..b00ccdef869 100644 --- a/templates/clips/actions/view-screen.test.ts +++ b/templates/clips/actions/view-screen.test.ts @@ -187,8 +187,10 @@ vi.mock("../server/lib/recordings.js", () => ({ email, }), parseSpaceIds: vi.fn(), - sameOwnerEmail: (a: string, b: string) => - a?.toLowerCase() === b?.toLowerCase(), + sameOwnerEmail: ( + a: string | null | undefined, + b: string | null | undefined, + ) => a?.toLowerCase() === b?.toLowerCase(), })); vi.mock("../server/lib/user-identities.js", () => ({ @@ -251,11 +253,13 @@ describe("view-screen library view on a cold start", () => { }); describe("view-screen recording access", () => { + let recording: Record; + beforeEach(() => { vi.clearAllMocks(); context.navigation.view = "recording"; context.email = "viewer@example.com"; - context.recording = { + recording = { id: "rec-test", title: "Fake recording", ownerEmail: "owner@example.com", @@ -264,13 +268,14 @@ describe("view-screen recording access", () => { trashedAt: null, expiresAt: null, }; + context.recording = recording; }); it.each([ { trashedAt: "2026-01-01T00:00:00Z" }, { expiresAt: "2020-01-01T00:00:00Z" }, ])("withholds recording context after access ends: %j", async (settings) => { - Object.assign(context.recording!, settings); + Object.assign(recording, settings); const screen = JSON.parse(await action.run({})); expect(screen.recording).toBeUndefined(); expect(screen.comments).toBeUndefined(); @@ -282,7 +287,7 @@ describe("view-screen recording access", () => { "omits disabled comments for %s", async (email) => { context.email = email; - context.recording!.enableComments = false; + recording.enableComments = false; const screen = JSON.parse(await action.run({})); expect(screen.recording.id).toBe("rec-test"); expect(screen.comments).toEqual([]); @@ -306,7 +311,7 @@ describe("view-screen recording access", () => { "keeps owner previews and restored viewer context: %j", async ({ email, ...settings }) => { context.email = email; - Object.assign(context.recording!, settings); + Object.assign(recording, settings); const screen = JSON.parse(await action.run({})); expect(screen.recording.id).toBe("rec-test"); expect(screen.comments[0].content).toBe("Fake discussion"); diff --git a/templates/clips/actions/view-screen.ts b/templates/clips/actions/view-screen.ts index ee64ef84101..f415fc0bfed 100644 --- a/templates/clips/actions/view-screen.ts +++ b/templates/clips/actions/view-screen.ts @@ -88,7 +88,7 @@ function mapRecording(r: any) { async function fetchRecording(id: string) { const db = getDb(); - const [row] = await db + const rows = await db .select() .from(schema.recordings) .where( @@ -102,7 +102,8 @@ async function fetchRecording(id: string) { ), ), ); - if (!row) return null; + if (rows.length === 0) return null; + const [row] = rows; const viewerIsOwner = sameOwnerEmail(row.ownerEmail, getRequestUserEmail()); if ( isRecordingTrashedForViewer({ trashedAt: row.trashedAt, viewerIsOwner }) || diff --git a/templates/clips/app/routes/share.$shareId.test.tsx b/templates/clips/app/routes/share.$shareId.test.tsx index c68a6674c97..1d855b9c270 100644 --- a/templates/clips/app/routes/share.$shareId.test.tsx +++ b/templates/clips/app/routes/share.$shareId.test.tsx @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ loaderData: {} as Record, sessionStatus: "unauthenticated" as "unauthenticated" | "loading", + recordingRows: [] as Record[], })); vi.mock("react-router", async (importOriginal) => ({ @@ -40,9 +41,30 @@ vi.mock("@agent-native/core/client/analytics", async (importOriginal) => ({ trackEvent: vi.fn(), })); -vi.mock("../../server/db", () => ({ getDb: vi.fn(), schema: {} })); +vi.mock("../../server/db", () => ({ + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve(mocks.recordingRows), + }), + }), + }), + }), + schema: { recordings: {}, organizationSettings: {} }, +})); + +vi.mock("@agent-native/core/server", () => ({ + getRequestUserEmail: () => null, + signScopedAgentAccessToken: () => "access-request-token", + verifyScopedAgentAccessToken: () => ({ ok: false }), +})); + +vi.mock("@agent-native/core/sharing", () => ({ + resolveAccess: () => Promise.resolve(null), +})); -import ShareRoute from "./share.$shareId"; +import ShareRoute, { loader, meta } from "./share.$shareId"; const SHARE_ID = "clip-1"; @@ -185,3 +207,78 @@ describe("share page when the public recording read answers Not found", () => { }); }); }); + +describe("share page server data for a clip in Trash", () => { + function recordingRow(overrides: Record = {}) { + return { + id: SHARE_ID, + title: "Quarterly roadmap", + description: "Pricing changes for Q4", + thumbnailUrl: "https://media.example.test/clip-1/thumb.jpg", + animatedThumbnailUrl: null, + updatedAt: "2026-10-24T12:00:00.000Z", + sharePasswordVersion: null, + visibility: "public", + status: "ready", + ownerEmail: "owner@example.test", + organizationId: null, + password: null, + expiresAt: null, + archivedAt: null, + trashedAt: null, + sourceAppName: null, + videoUrl: "https://media.example.test/clip-1/video.mp4", + ...overrides, + }; + } + + async function loadShare(row?: Record) { + mocks.recordingRows = row ? [row] : []; + const pageData = await loader({ + params: { shareId: SHARE_ID }, + url: new URL(`https://clips.example.test/share/${SHARE_ID}`), + } as unknown as Parameters[0]); + const tags = meta({ loaderData: pageData } as unknown as Parameters< + typeof meta + >[0]); + return { pageData, tags }; + } + + it.each(["public", "private"])( + "serves a trashed %s clip the same page data and meta tags as a missing one", + async (visibility) => { + const missing = await loadShare(); + const trashed = await loadShare( + recordingRow({ visibility, trashedAt: "2026-10-24T12:00:00.000Z" }), + ); + + expect(trashed).toEqual(missing); + }, + ); + + it("keeps the title and image of a public clip that is not in Trash", async () => { + const { pageData, tags } = await loadShare(recordingRow()); + + expect(pageData).toMatchObject({ + recording: { title: "Quarterly roadmap" }, + }); + expect(tags).toContainEqual({ + property: "og:title", + content: "Quarterly roadmap · Clips", + }); + expect(tags).toContainEqual( + expect.objectContaining({ property: "og:image" }), + ); + }); + + it("keeps the request-access page data for a private clip", async () => { + const { pageData } = await loadShare( + recordingRow({ visibility: "private" }), + ); + + expect(pageData).toMatchObject({ + data: { recording: null, accessDeniedStatus: 401 }, + init: { status: 401 }, + }); + }); +}); diff --git a/templates/clips/app/routes/share.$shareId.tsx b/templates/clips/app/routes/share.$shareId.tsx index fee48f90946..611a1c46d99 100644 --- a/templates/clips/app/routes/share.$shareId.tsx +++ b/templates/clips/app/routes/share.$shareId.tsx @@ -140,7 +140,10 @@ import { parseRedactions } from "@/lib/video-redactions"; import { getDb, schema } from "../../server/db"; import { resolvePlayerThumbnailUrl } from "../../server/lib/player-thumbnail-url"; -import { isRecordingExpired } from "../../server/lib/recording-page-access"; +import { + isRecordingExpired, + isRecordingTrashedForViewer, +} from "../../server/lib/recording-page-access"; import { buildAgentApiUrls, buildAgentDiscoveryPayload, @@ -313,7 +316,13 @@ export async function loader({ params, url }: LoaderFunctionArgs) { }).ok : false; - if (isRecordingExpired(rec.expiresAt)) { + if ( + isRecordingExpired(rec.expiresAt) || + isRecordingTrashedForViewer({ + trashedAt: rec.trashedAt, + viewerIsOwner: false, + }) + ) { return shareLoaderData(emptyLoaderData(url), hasAgentAccessToken); } @@ -367,10 +376,7 @@ export async function loader({ params, url }: LoaderFunctionArgs) { isLoomEmbedBacked: isLoomEmbedBackedRecording(rec), }; const canExposeAnonymousAgentContext = - rec.visibility === "public" && - !rec.password && - !rec.archivedAt && - !rec.trashedAt; + rec.visibility === "public" && !rec.password && !rec.archivedAt; return shareLoaderData( { recording, diff --git a/templates/clips/server/routes/api/public-recording.get.test.ts b/templates/clips/server/routes/api/public-recording.get.test.ts index acde9ec0177..f3f1163eba2 100644 --- a/templates/clips/server/routes/api/public-recording.get.test.ts +++ b/templates/clips/server/routes/api/public-recording.get.test.ts @@ -249,7 +249,9 @@ describe("/api/public-recording route", () => { mockResolveAccess.mockResolvedValue({ role, resource: recording }); } - expect(await handler(event as any)).toEqual({ error: "Not found" }); + expect( + await handler(event as unknown as Parameters[0]), + ).toEqual({ error: "Not found" }); expect(mockSetResponseStatus).toHaveBeenCalledWith(event, 404); expect(mockSignShortLivedToken).not.toHaveBeenCalled(); expect(mockSignScopedAgentAccessToken).not.toHaveBeenCalled(); @@ -273,7 +275,11 @@ describe("/api/public-recording route", () => { }); mockResolveAccess.mockResolvedValue({ role, resource: recording }); - expect(await handler({ setCookies: [] } as any)).toMatchObject({ + expect( + await handler({ setCookies: [] } as unknown as Parameters< + typeof handler + >[0]), + ).toMatchObject({ recording: { id: "rec-1" }, }); }, diff --git a/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts b/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts index 883c19ec301..a8277342bcd 100644 --- a/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts +++ b/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts @@ -216,7 +216,9 @@ describe("/api/thumbnail/:recordingId route", () => { ); const event = makeEvent(); - expect(await handler(event as any)).toEqual({ error: "Not found" }); + expect( + await handler(event as unknown as Parameters[0]), + ).toEqual({ error: "Not found" }); expect(event.status).toBe(404); expect(fetch).not.toHaveBeenCalled(); expect(mockFetchS3ObjectByUrl).not.toHaveBeenCalled(); @@ -237,7 +239,9 @@ describe("/api/thumbnail/:recordingId route", () => { headers: { "content-type": "image/jpeg" }, }), ); - const result = await handler(makeEvent() as any); + const result = await handler( + makeEvent() as unknown as Parameters[0], + ); expect(result).toBeInstanceOf(Response); expect(await (result as Response).text()).toBe("thumbnail"); }, diff --git a/templates/clips/server/routes/api/video/[recordingId].get.test.ts b/templates/clips/server/routes/api/video/[recordingId].get.test.ts index 72367206e17..b24f0153735 100644 --- a/templates/clips/server/routes/api/video/[recordingId].get.test.ts +++ b/templates/clips/server/routes/api/video/[recordingId].get.test.ts @@ -222,7 +222,9 @@ describe("/api/video/:recordingId route", () => { const event = makeEvent(); event.query = { t: "existing-token" }; - expect(await handler(event as any)).toEqual({ error: "Not found" }); + expect( + await handler(event as unknown as Parameters[0]), + ).toEqual({ error: "Not found" }); expect(event.status).toBe(404); expect(fetch).not.toHaveBeenCalled(); expect(mockFetchS3ObjectByUrl).not.toHaveBeenCalled(); @@ -247,7 +249,9 @@ describe("/api/video/:recordingId route", () => { vi.mocked(fetch).mockResolvedValue( new Response("video", { headers: { "content-type": "video/mp4" } }), ); - const result = await handler(makeEvent() as any); + const result = await handler( + makeEvent() as unknown as Parameters[0], + ); expect(result).toBeInstanceOf(Response); expect(await (result as Response).text()).toBe("video"); }, From cf8b0225c2909b93dd010e4e903a1973070f31fa Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:03:51 -0400 Subject: [PATCH 13/35] Explain replacement of share retry assertions --- templates/clips/app/routes/share-auth-loading.test.ts | 1 + templates/clips/app/routes/share.$shareId.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/templates/clips/app/routes/share-auth-loading.test.ts b/templates/clips/app/routes/share-auth-loading.test.ts index 033ac54b0c0..3e71e90a33d 100644 --- a/templates/clips/app/routes/share-auth-loading.test.ts +++ b/templates/clips/app/routes/share-auth-loading.test.ts @@ -102,6 +102,7 @@ describe("authenticated recording route loading", () => { expect(processingView).not.toContain("ShareCopyRow"); }); + // oracle-change: Missing share links no longer retry; share.$shareId.test.tsx verifies the rendered unavailable state instead of the removed source assertions. it("keeps expired share loader data impersonal for CDN caching", () => { const route = readRoute("share.$shareId.tsx"); diff --git a/templates/clips/app/routes/share.$shareId.test.tsx b/templates/clips/app/routes/share.$shareId.test.tsx index 1d855b9c270..b3aa0fd0c71 100644 --- a/templates/clips/app/routes/share.$shareId.test.tsx +++ b/templates/clips/app/routes/share.$shareId.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment happy-dom +// oracle-change(templates/clips/app/lib/missing-share-retry.test.ts): Missing share links now become unavailable after the first 404; these rendering tests replace the deleted retry-helper tests. import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act } from "react"; From fb8ecb640cbc264ad42e45b35eba911d645670c0 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:27:15 -0400 Subject: [PATCH 14/35] Respect recording lifecycle in bug report context --- templates/clips/actions/view-screen.test.ts | 80 +++++++++++++++++++-- templates/clips/actions/view-screen.ts | 9 ++- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/templates/clips/actions/view-screen.test.ts b/templates/clips/actions/view-screen.test.ts index b00ccdef869..294d6009026 100644 --- a/templates/clips/actions/view-screen.test.ts +++ b/templates/clips/actions/view-screen.test.ts @@ -4,6 +4,7 @@ const context = vi.hoisted(() => ({ navigation: { view: "library", recordingId: "rec-test" }, recording: null as Record | null, email: "viewer@example.com", + role: null as "owner" | "editor" | null, childReads: vi.fn(), })); @@ -18,6 +19,7 @@ const TABLES = vi.hoisted(() => ({ updatedAt: "recordings.updatedAt", }, meetings: { recordingId: "meetings.recordingId" }, + recordingBugReports: { recordingId: "bugReports.recordingId" }, folders: { ownerEmail: "folders.ownerEmail", spaceId: "folders.spaceId", @@ -48,15 +50,23 @@ const makeRealDb = vi.hoisted(() => () => ({ } context.childReads(table); const rows = - table === "recordingComments" + table === TABLES.recordingBugReports ? [ { - id: "comment-test", - content: "Fake discussion", - authorEmail: "author@example.com", + projectId: "fake-project", + title: "Fake report", + severity: "low", }, ] - : []; + : table === "recordingComments" + ? [ + { + id: "comment-test", + content: "Fake discussion", + authorEmail: "author@example.com", + }, + ] + : []; return { where: () => Object.assign(Promise.resolve(rows), { @@ -143,7 +153,7 @@ vi.mock("@agent-native/core/server/request-context", () => ({ vi.mock("@agent-native/core/sharing", () => ({ accessFilter: () => ({ kind: "access-filter" }), - resolveAccess: async () => null, + resolveAccess: async () => (context.role ? { role: context.role } : null), })); vi.mock("drizzle-orm", () => ({ @@ -172,6 +182,7 @@ vi.mock("../server/db/index.js", () => ({ recordingComments: "recordingComments", recordingTranscripts: { recordingId: "transcripts.recordingId" }, recordingBrowserDiagnostics: { recordingId: "diagnostics.recordingId" }, + recordingBugReports: TABLES.recordingBugReports, }, })); @@ -211,6 +222,7 @@ describe("view-screen library view on a cold start", () => { beforeEach(() => { vi.clearAllMocks(); context.recording = null; + context.role = null; context.navigation.view = "library"; }); @@ -259,6 +271,7 @@ describe("view-screen recording access", () => { vi.clearAllMocks(); context.navigation.view = "recording"; context.email = "viewer@example.com"; + context.role = null; recording = { id: "rec-test", title: "Fake recording", @@ -295,6 +308,61 @@ describe("view-screen recording access", () => { }, ); + it.each([ + { trashedAt: "2026-01-01T00:00:00Z" }, + { expiresAt: "2020-01-01T00:00:00Z" }, + ])( + "withholds completed bug-report context after access ends: %j", + async (settings) => { + context.navigation.view = "bug-report-done"; + context.role = "editor"; + Object.assign(recording, settings); + + const screen = JSON.parse(await action.run({})); + + expect(screen.recording).toBeUndefined(); + expect(screen.bugReport).toBeUndefined(); + expect(context.childReads).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { + email: "owner@example.com", + role: "owner" as const, + trashedAt: "2026-01-01T00:00:00Z", + expiresAt: null, + }, + { + email: "owner@example.com", + role: "owner" as const, + trashedAt: null, + expiresAt: "2020-01-01T00:00:00Z", + }, + { + email: "viewer@example.com", + role: "editor" as const, + trashedAt: null, + expiresAt: null, + }, + ])( + "keeps authorized completed bug-report context: %j", + async ({ email, role, ...settings }) => { + context.navigation.view = "bug-report-done"; + context.email = email; + context.role = role; + Object.assign(recording, settings); + + const screen = JSON.parse(await action.run({})); + + expect(screen.recording.id).toBe("rec-test"); + expect(screen.bugReport).toMatchObject({ + title: "Fake report", + projectId: "fake-project", + }); + }, + ); + it.each([ { email: "owner@example.com", diff --git a/templates/clips/actions/view-screen.ts b/templates/clips/actions/view-screen.ts index f415fc0bfed..46c2706bad0 100644 --- a/templates/clips/actions/view-screen.ts +++ b/templates/clips/actions/view-screen.ts @@ -673,11 +673,10 @@ export default defineAction({ } case "bug-report-done": { if (nav.recordingId) { - const [recording, bugReportSummary] = await Promise.all([ - fetchRecording(nav.recordingId), - fetchBugReportSummary(nav.recordingId), - ]); - if (recording) screen.recording = recording; + const recording = await fetchRecording(nav.recordingId); + if (!recording) break; + const bugReportSummary = await fetchBugReportSummary(nav.recordingId); + screen.recording = recording; if (bugReportSummary) screen.bugReport = bugReportSummary; } break; From a9371776f81a428ccdff983180d898a9b46bc9cb Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:18:18 -0400 Subject: [PATCH 15/35] Keep recording reactions independent of comments --- .../clips/actions/search-recordings.test.ts | 51 ++-- .../components/player/comments-panel.test.tsx | 13 +- .../_app.r.$recordingId.reactions.test.tsx | 268 ++++++++++++++++++ .../clips/app/routes/_app.r.$recordingId.tsx | 9 +- .../app/routes/share-auth-loading.test.ts | 2 +- .../clips/app/routes/share.$shareId.test.tsx | 2 +- .../routes/api/agent-context.json.get.test.ts | 10 +- .../routes/api/public-recording.get.test.ts | 4 +- 8 files changed, 323 insertions(+), 36 deletions(-) create mode 100644 templates/clips/app/routes/_app.r.$recordingId.reactions.test.tsx diff --git a/templates/clips/actions/search-recordings.test.ts b/templates/clips/actions/search-recordings.test.ts index 39e736c3bd2..2d3bee63f2a 100644 --- a/templates/clips/actions/search-recordings.test.ts +++ b/templates/clips/actions/search-recordings.test.ts @@ -1,5 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +type QueryCondition = + | { kind: "and"; conditions: QueryCondition[] } + | { kind: "or"; conditions: QueryCondition[] } + | { kind: "eq"; column: string; value: unknown } + | { kind: "is-null"; column: string } + | { kind: "owner-email"; column: string; email: string } + | { kind: "access" } + | { kind: "sql"; values: unknown[] }; + +interface MockQuery { + from(value: unknown): MockQuery; + innerJoin(): MockQuery; + where(condition: QueryCondition): MockQuery; + limit(value: number): MockQuery; + then(resolve: (value: unknown[]) => unknown): Promise; +} + const whereConditions = vi.hoisted(() => [] as unknown[]); const selectedFields = vi.hoisted(() => [] as Record[]); const searchRows = vi.hoisted(() => ({ @@ -29,11 +46,14 @@ const tables = vi.hoisted(() => ({ })); const makeQuery = vi.hoisted(() => { - const matches = (row: Record, condition: any): boolean => { + const matches = ( + row: Record, + condition: QueryCondition, + ): boolean => { if (condition.kind === "and") - return condition.conditions.every((part: unknown) => matches(row, part)); + return condition.conditions.every((part) => matches(row, part)); if (condition.kind === "or") - return condition.conditions.some((part: unknown) => matches(row, part)); + return condition.conditions.some((part) => matches(row, part)); if (condition.kind === "eq") return row[condition.column] === condition.value; if (condition.kind === "is-null") return row[condition.column] == null; @@ -43,21 +63,18 @@ const makeQuery = vi.hoisted(() => { condition.email.toLowerCase() ); if (condition.kind === "access") return true; - if (condition.kind === "sql") { - const column = condition.values[0]; - const pattern = condition.values[1]; - return ( - typeof row[column] === "string" && - String(row[column]) - .toLowerCase() - .includes(String(pattern).replace(/^%|%$/g, "")) - ); - } - throw new Error(`Unsupported query condition: ${condition.kind}`); + const column = String(condition.values[0]); + const pattern = condition.values[1]; + return ( + typeof row[column] === "string" && + String(row[column]) + .toLowerCase() + .includes(String(pattern).replace(/^%|%$/g, "")) + ); }; - const create = (fields: Record): any => { + const create = (fields: Record): MockQuery => { let table: unknown; - let filter: any; + let filter: QueryCondition; let limit = Infinity; const query = { from: (value: unknown) => { @@ -65,7 +82,7 @@ const makeQuery = vi.hoisted(() => { return query; }, innerJoin: () => query, - where: (condition: unknown) => { + where: (condition: QueryCondition) => { whereConditions.push(condition); filter = condition; return query; diff --git a/templates/clips/app/components/player/comments-panel.test.tsx b/templates/clips/app/components/player/comments-panel.test.tsx index 7e15a60d8a5..b1d011ccc8a 100644 --- a/templates/clips/app/components/player/comments-panel.test.tsx +++ b/templates/clips/app/components/player/comments-panel.test.tsx @@ -5,6 +5,7 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, + assert, beforeEach, describe, expect, @@ -1312,10 +1313,10 @@ describe("CommentsPanel reply composer", () => { renderPanel("author@example.com"); if (operation === "reply") { const reply = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.trim() === "commentsPanel.reply", + (button) => button.textContent.trim() === "commentsPanel.reply", ); - expect(reply).toBeDefined(); - act(() => reply!.click()); + assert.isDefined(reply); + act(() => reply.click()); } else { await openMenu( container.querySelector('[aria-haspopup="menu"]'), @@ -1323,11 +1324,11 @@ describe("CommentsPanel reply composer", () => { const edit = Array.from( document.body.querySelectorAll('[role="menuitem"]'), ).find( - (item) => item.textContent?.trim() === "commentsPanel.editComment", + (item) => item.textContent.trim() === "commentsPanel.editComment", ); - expect(edit).toBeDefined(); + assert.isDefined(edit); await act(async () => { - edit!.click(); + edit.click(); await Promise.resolve(); }); } diff --git a/templates/clips/app/routes/_app.r.$recordingId.reactions.test.tsx b/templates/clips/app/routes/_app.r.$recordingId.reactions.test.tsx new file mode 100644 index 00000000000..66e1884c9f7 --- /dev/null +++ b/templates/clips/app/routes/_app.r.$recordingId.reactions.test.tsx @@ -0,0 +1,268 @@ +// @vitest-environment happy-dom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { + afterEach, + assert, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const OWNER = "owner@example.test"; + +const mocks = vi.hoisted(() => ({ + playerData: null as Record | null, + queries: new Map>(), + mutations: new Map>(), + mutationCalls: [] as { name: string; args: unknown }[], +})); + +vi.mock("@agent-native/core/client/hooks", async (importOriginal) => { + const actual = + await importOriginal(); + const session = { + session: { email: "owner@example.test", name: "Owner" }, + isLoading: false, + status: "authenticated", + error: null, + retry: () => {}, + }; + return { + ...actual, + useSession: () => session, + useActionQuery: (name: string) => { + let query = mocks.queries.get(name); + if (!query) { + query = { + data: + name === "get-recording-player-data" ? mocks.playerData : undefined, + error: null, + isError: false, + isFetching: false, + isLoading: false, + isPending: false, + isSuccess: true, + status: "success", + refetch: async () => ({}), + }; + mocks.queries.set(name, query); + } + return query; + }, + useActionMutation: (name: string) => { + let mutation = mocks.mutations.get(name); + if (!mutation) { + const run = async (args: unknown) => { + mocks.mutationCalls.push({ name, args }); + return { id: `${name}-result` }; + }; + mutation = { + isPending: false, + mutate: (args: unknown) => void run(args), + mutateAsync: run, + reset: () => {}, + }; + mocks.mutations.set(name, mutation); + } + return mutation; + }, + }; +}); + +vi.mock("@agent-native/core/client/i18n", async (importOriginal) => { + const { default: messages } = await import("@/i18n/en-US"); + const t = (key: string) => { + const value = key + .split(".") + .reduce( + (node, part) => (node as Record | undefined)?.[part], + messages, + ); + return typeof value === "string" ? value : key; + }; + return { + ...(await importOriginal< + typeof import("@agent-native/core/client/i18n") + >()), + useT: () => t, + }; +}); + +import { TooltipProvider } from "@/components/ui/tooltip"; + +import RecordingPage from "./_app.r.$recordingId"; + +function playerData( + recordingId: string, + settings: { enableComments: boolean; enableReactions: boolean }, +) { + return { + role: "owner", + canComment: true, + viewCount: 0, + agentViewCount: 0, + recording: { + id: recordingId, + organizationId: "org-1", + title: "Product walkthrough", + description: null, + kind: "video", + imageUrl: null, + baseImageUrl: null, + annotations: [], + thumbnailUrl: null, + animatedThumbnailUrl: null, + filmstripUrl: null, + filmstripFrameCount: 0, + durationMs: 6_000, + editsJson: "{}", + videoUrl: "/clip.webm", + videoFormat: "webm", + videoSizeBytes: 1_000, + mediaUpdatedAt: null, + width: 640, + height: 360, + hasAudio: false, + hasCamera: false, + status: "ready", + verificationPending: false, + seekableRepairPending: false, + uploadProgress: 100, + failureReason: null, + failureCode: null, + hasPassword: false, + expiresAt: null, + enableDownloads: true, + defaultSpeed: "1", + animatedThumbnailEnabled: false, + visibility: "private", + ownerEmail: OWNER, + folderId: null, + spaceIds: [], + createdAt: "2026-10-25T12:00:00.000Z", + updatedAt: "2026-10-25T12:00:00.000Z", + trashedAt: null, + ...settings, + }, + transcript: null, + comments: [], + reactions: [], + chapters: [], + tags: [], + ctas: [], + }; +} + +describe("recording page reactions", () => { + let container: HTMLDivElement; + let root: Root; + let queryClient: QueryClient; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}", { status: 200 })), + ); + mocks.queries.clear(); + mocks.mutations.clear(); + mocks.mutationCalls.length = 0; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient(); + }); + + afterEach(() => { + act(() => root.unmount()); + queryClient.clear(); + container.remove(); + vi.unstubAllGlobals(); + }); + + async function renderRecording( + recordingId: string, + settings: { enableComments: boolean; enableReactions: boolean }, + ) { + mocks.playerData = playerData(recordingId, settings); + await act(async () => { + root.render( + + + + + } /> + + + + , + ); + }); + } + + function headerReactButton() { + return Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent.trim() === "React", + ); + } + + it("lets the owner react when comments are off and reactions are on", async () => { + await renderRecording("rec-comments-off", { + enableComments: false, + enableReactions: true, + }); + + const react = headerReactButton(); + assert.isDefined(react); + expect(react.disabled).toBe(false); + expect( + document.querySelector('[placeholder="Leave a comment..."]'), + ).toBeNull(); + + await act(async () => { + react.click(); + }); + const fire = document.querySelector( + 'button[aria-label="React fire"]', + ); + assert.isNotNull(fire); + await act(async () => { + fire.click(); + }); + + expect(mocks.mutationCalls).toEqual([ + { + name: "react-to-recording", + args: { + recordingId: "rec-comments-off", + emoji: "🔥", + videoTimestampMs: 0, + }, + }, + ]); + }); + + it("hides the reaction button when reactions are off", async () => { + await renderRecording("rec-reactions-off", { + enableComments: false, + enableReactions: false, + }); + + expect(headerReactButton()).toBeUndefined(); + }); + + it("keeps reactions disabled on the viewer redesign preview", async () => { + await renderRecording("viewer-redesign-preview", { + enableComments: false, + enableReactions: true, + }); + + expect(headerReactButton()?.disabled).toBe(true); + }); +}); diff --git a/templates/clips/app/routes/_app.r.$recordingId.tsx b/templates/clips/app/routes/_app.r.$recordingId.tsx index 58f68c3f341..2b5db4a2868 100644 --- a/templates/clips/app/routes/_app.r.$recordingId.tsx +++ b/templates/clips/app/routes/_app.r.$recordingId.tsx @@ -1205,10 +1205,9 @@ export default function RecordingPage() { enableComments: recording?.enableComments ?? false, viewerIsOwner: role === "owner", }); - const canComment = - Boolean(recording?.enableComments) && - role != null && - recordingId !== VIEWER_REDESIGN_PREVIEW_ID; + // Reactions have their own setting; turning comments off must not gate them. + const canReact = role != null && recordingId !== VIEWER_REDESIGN_PREVIEW_ID; + const canComment = Boolean(recording?.enableComments) && canReact; // Where the panel lands when the requested tab does not apply. Transcript is // the video default; a screenshot has no transcript tab at all, so it falls // back to the conversation, or to settings when comments are off. A viewer @@ -2775,7 +2774,7 @@ export default function RecordingPage() { type="button" variant="ghost" size="sm" - disabled={!canComment} + disabled={!canReact} className="gap-1.5 px-2 text-xs" > diff --git a/templates/clips/app/routes/share-auth-loading.test.ts b/templates/clips/app/routes/share-auth-loading.test.ts index 3e71e90a33d..30fcbd48a4c 100644 --- a/templates/clips/app/routes/share-auth-loading.test.ts +++ b/templates/clips/app/routes/share-auth-loading.test.ts @@ -102,7 +102,7 @@ describe("authenticated recording route loading", () => { expect(processingView).not.toContain("ShareCopyRow"); }); - // oracle-change: Missing share links no longer retry; share.$shareId.test.tsx verifies the rendered unavailable state instead of the removed source assertions. + // oracle-change: The share page no longer polls the share API after a missing-record 404; share.$shareId.test.tsx verifies the rendered unavailable state instead of the removed polling assertions. it("keeps expired share loader data impersonal for CDN caching", () => { const route = readRoute("share.$shareId.tsx"); diff --git a/templates/clips/app/routes/share.$shareId.test.tsx b/templates/clips/app/routes/share.$shareId.test.tsx index b3aa0fd0c71..5d79f7cc730 100644 --- a/templates/clips/app/routes/share.$shareId.test.tsx +++ b/templates/clips/app/routes/share.$shareId.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -// oracle-change(templates/clips/app/lib/missing-share-retry.test.ts): Missing share links now become unavailable after the first 404; these rendering tests replace the deleted retry-helper tests. +// oracle-change(templates/clips/app/lib/missing-share-retry.test.ts): The share page no longer polls the share API after a missing-record 404; these rendering tests of the unavailable state replace the deleted polling-helper tests. import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act } from "react"; diff --git a/templates/clips/server/routes/api/agent-context.json.get.test.ts b/templates/clips/server/routes/api/agent-context.json.get.test.ts index 9303f416814..54fa2d37395 100644 --- a/templates/clips/server/routes/api/agent-context.json.get.test.ts +++ b/templates/clips/server/routes/api/agent-context.json.get.test.ts @@ -144,10 +144,10 @@ describe("/api/agent-context route", () => { mockGetDb.mockReturnValue({ select: (fields: { count?: unknown }) => { const rows = "count" in fields ? [{ count: 1 }] : [savedComment]; - const query: any = { - from: () => query, - where: () => query, - orderBy: () => query, + const query = { + from: (): unknown => query, + where: (): unknown => query, + orderBy: (): unknown => query, limit: async () => rows, then: (resolve: (rows: unknown[]) => unknown) => Promise.resolve(rows).then(resolve), @@ -155,7 +155,7 @@ describe("/api/agent-context route", () => { return query; }, }); - await handler({} as any); + await handler({} as unknown as Parameters[0]); expect(mockBuildPublicAgentContext).toHaveBeenCalledWith( expect.objectContaining({ comments: viewerIsOwner ? [savedComment] : [], diff --git a/templates/clips/server/routes/api/public-recording.get.test.ts b/templates/clips/server/routes/api/public-recording.get.test.ts index c4776f08f38..434875c3be5 100644 --- a/templates/clips/server/routes/api/public-recording.get.test.ts +++ b/templates/clips/server/routes/api/public-recording.get.test.ts @@ -315,7 +315,9 @@ describe("/api/public-recording route", () => { [], ]), ); - const result = await handler({ setCookies: [] } as any); + const result = await handler({ setCookies: [] } as unknown as Parameters< + typeof handler + >[0]); expect(result).toMatchObject({ comments: role === "owner" ? [{ content: "Fake saved comment" }] : [], }); From e781dc15b97fb4912c9e099740f672f295e05281 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:58:28 -0400 Subject: [PATCH 16/35] Apply recording Trash policy to activity recipients --- templates/clips/actions/list-notifications.ts | 25 +++--- ...y-notifications-follow-trash-visibility.md | 6 ++ .../server/lib/activity-notifications.spec.ts | 81 ++++++++++++++++++- .../server/lib/activity-notifications.ts | 3 + .../clips/server/lib/recording-page-access.ts | 15 +++- 5 files changed, 107 insertions(+), 23 deletions(-) create mode 100644 templates/clips/changelog/2026-10-26-recording-activity-notifications-follow-trash-visibility.md diff --git a/templates/clips/actions/list-notifications.ts b/templates/clips/actions/list-notifications.ts index feb166544f6..b4ba998bc7b 100644 --- a/templates/clips/actions/list-notifications.ts +++ b/templates/clips/actions/list-notifications.ts @@ -5,10 +5,7 @@ import { and, desc, gte, inArray, or, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -import { - canReceiveRecordingActivity, - isRecordingTrashedForViewer, -} from "../server/lib/recording-page-access.js"; +import { canReceiveRecordingActivity } from "../server/lib/recording-page-access.js"; import { getCurrentOwnerEmail, sameOwnerEmail, @@ -39,18 +36,14 @@ export default defineAction({ }) .from(schema.recordings) .where(accessFilter(schema.recordings, schema.recordingShares)); - const accessibleRecordings = visibleRecordings.filter( - (recording) => - !isRecordingTrashedForViewer({ - trashedAt: recording.trashedAt, - viewerIsOwner: sameOwnerEmail(recording.ownerEmail, me), - }) && - canReceiveRecordingActivity({ - ownerEmail: recording.ownerEmail, - recipientEmail: me, - hasPassword: Boolean(recording.password), - expiresAt: recording.expiresAt, - }), + const accessibleRecordings = visibleRecordings.filter((recording) => + canReceiveRecordingActivity({ + ownerEmail: recording.ownerEmail, + recipientEmail: me, + hasPassword: Boolean(recording.password), + expiresAt: recording.expiresAt, + trashedAt: recording.trashedAt, + }), ); if (accessibleRecordings.length === 0) { return { items: [], count: 0 }; diff --git a/templates/clips/changelog/2026-10-26-recording-activity-notifications-follow-trash-visibility.md b/templates/clips/changelog/2026-10-26-recording-activity-notifications-follow-trash-visibility.md new file mode 100644 index 00000000000..59767cee6e1 --- /dev/null +++ b/templates/clips/changelog/2026-10-26-recording-activity-notifications-follow-trash-visibility.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-10-26 +--- + +Recording activity notifications follow Trash visibility. diff --git a/templates/clips/server/lib/activity-notifications.spec.ts b/templates/clips/server/lib/activity-notifications.spec.ts index b89eb2cb82e..47e8caa0e4c 100644 --- a/templates/clips/server/lib/activity-notifications.spec.ts +++ b/templates/clips/server/lib/activity-notifications.spec.ts @@ -52,6 +52,7 @@ vi.mock("../db/index.js", () => ({ orgId: "org_id", password: "password", expiresAt: "expires_at", + trashedAt: "trashed_at", }, recordingComments: { recordingId: "recording_id", @@ -79,6 +80,7 @@ const RECORDING = { orgId: null, password: null as string | null, expiresAt: null as string | null, + trashedAt: null as string | null, }; function stubDb(options: { @@ -88,16 +90,25 @@ function stubDb(options: { const participants = (options.participants ?? []).map((authorEmail) => ({ authorEmail, })); - mocks.select.mockReturnValue({ + mocks.select.mockImplementation((columns: Record) => ({ from: () => ({ where: () => { - const rows = options.recording ? [options.recording] : []; + const rows = options.recording + ? [ + Object.fromEntries( + Object.keys(columns).map((key) => [ + key, + options.recording?.[key as keyof typeof RECORDING], + ]), + ), + ] + : []; return Object.assign(Promise.resolve(participants), { limit: async () => rows, }); }, }), - }); + })); } function notifyArgs() { @@ -316,3 +327,67 @@ describe("clips activity notifications", () => { expect(result.status).toBe("notification-error"); }); }); + +describe("activity email recording lifecycle", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getUserSetting.mockResolvedValue(null); + mocks.filterRecipients.mockImplementation( + async ({ emails }: { emails: string[] }) => emails, + ); + mocks.notifyActivity.mockResolvedValue({ + status: "delivered", + sent: [], + failed: [], + }); + }); + + it.each([ + { kind: "comment", trashedAt: "2026-10-22T12:00:00Z" }, + { kind: "reply", trashedAt: "2026-10-22T12:00:00Z" }, + { kind: "reaction", trashedAt: "2026-10-22T12:00:00Z" }, + { kind: "comment", trashedAt: null }, + { kind: "reply", trashedAt: null }, + { kind: "reaction", trashedAt: null }, + ])( + "limits $kind recipients while trashedAt=$trashedAt", + async ({ kind, trashedAt }) => { + stubDb({ + recording: { ...RECORDING, trashedAt }, + participants: ["viewer@example.test"], + }); + if (kind === "reaction") { + await notifyRecordingReaction({ + recordingId: RECORDING.id, + emoji: "fire", + viewerEmail: RECORDING.ownerEmail, + extraRecipients: ["viewer@example.test"], + }); + } else { + await notifyRecordingComment({ + recordingId: RECORDING.id, + threadId: "fake-thread", + authorEmail: RECORDING.ownerEmail, + content: "Fake saved comment", + isReply: kind === "reply", + mentions: + kind === "comment" + ? [{ email: "viewer@example.test", name: "Fake viewer" }] + : [], + }); + } + expect(notifyArgs().candidates).toEqual( + trashedAt + ? [RECORDING.ownerEmail] + : [RECORDING.ownerEmail, "viewer@example.test"], + ); + expect(mocks.filterRecipients).toHaveBeenCalledWith( + expect.objectContaining({ + emails: trashedAt + ? [RECORDING.ownerEmail] + : [RECORDING.ownerEmail, "viewer@example.test"], + }), + ); + }, + ); +}); diff --git a/templates/clips/server/lib/activity-notifications.ts b/templates/clips/server/lib/activity-notifications.ts index ac98d1ba5e0..04c99a5f687 100644 --- a/templates/clips/server/lib/activity-notifications.ts +++ b/templates/clips/server/lib/activity-notifications.ts @@ -33,6 +33,7 @@ async function getRecording(recordingId: string) { orgId: schema.recordings.orgId, password: schema.recordings.password, expiresAt: schema.recordings.expiresAt, + trashedAt: schema.recordings.trashedAt, }) .from(schema.recordings) .where(eq(schema.recordings.id, recordingId)) @@ -101,6 +102,7 @@ async function deliverRecordingCommentEmails( recipientEmail: email, hasPassword: Boolean(recording.password), expiresAt: recording.expiresAt, + trashedAt: recording.trashedAt, }), ); const allowed = await filterRecipientsByResourceAccess({ @@ -169,6 +171,7 @@ async function deliverRecordingReactionEmails( recipientEmail: email, hasPassword: Boolean(recording.password), expiresAt: recording.expiresAt, + trashedAt: recording.trashedAt, }), ); const allowed = await filterRecipientsByResourceAccess({ diff --git a/templates/clips/server/lib/recording-page-access.ts b/templates/clips/server/lib/recording-page-access.ts index 485b3f5cbe4..b8dee2b1565 100644 --- a/templates/clips/server/lib/recording-page-access.ts +++ b/templates/clips/server/lib/recording-page-access.ts @@ -54,12 +54,19 @@ export function canReceiveRecordingActivity(input: { recipientEmail: string; hasPassword: boolean; expiresAt?: string | null; + trashedAt?: string | null; now?: number; }): boolean { - if (isRecordingExpired(input.expiresAt, input.now)) return false; - if (!input.hasPassword) return true; - return ( + const viewerIsOwner = + Boolean(input.ownerEmail && input.recipientEmail) && input.ownerEmail.trim().toLowerCase() === - input.recipientEmail.trim().toLowerCase() + input.recipientEmail.trim().toLowerCase(); + return ( + !isRecordingExpired(input.expiresAt, input.now) && + !isRecordingTrashedForViewer({ + trashedAt: input.trashedAt, + viewerIsOwner, + }) && + (!input.hasPassword || viewerIsOwner) ); } From 60c024162c084d4c9b93e47ee81e6a2bde4ea4e4 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:29:39 -0400 Subject: [PATCH 17/35] Narrow recording context rows before lifecycle checks --- templates/clips/actions/view-screen.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/clips/actions/view-screen.ts b/templates/clips/actions/view-screen.ts index 46c2706bad0..83e9579fad2 100644 --- a/templates/clips/actions/view-screen.ts +++ b/templates/clips/actions/view-screen.ts @@ -102,8 +102,8 @@ async function fetchRecording(id: string) { ), ), ); - if (rows.length === 0) return null; - const [row] = rows; + const row = rows[0]; + if (!row) return null; const viewerIsOwner = sameOwnerEmail(row.ownerEmail, getRequestUserEmail()); if ( isRecordingTrashedForViewer({ trashedAt: row.trashedAt, viewerIsOwner }) || From 603d862d6e289ab91a6c3b21c45a76ee6c6e181b Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:33:19 -0400 Subject: [PATCH 18/35] Preserve comment cleanup and recording search availability --- .../clips/actions/search-recordings.test.ts | 431 +++++++++--------- templates/clips/actions/search-recordings.ts | 36 +- .../clips/actions/update-recording.test.ts | 22 + templates/clips/actions/update-recording.ts | 5 +- .../components/player/comments-panel.test.tsx | 105 ++++- .../app/components/player/comments-panel.tsx | 33 +- .../_app.r.$recordingId.reactions.test.tsx | 60 ++- .../clips/app/routes/_app.r.$recordingId.tsx | 3 + .../2026-10-26-recording-search-expiry.md | 5 + 9 files changed, 464 insertions(+), 236 deletions(-) create mode 100644 templates/clips/changelog/2026-10-26-recording-search-expiry.md diff --git a/templates/clips/actions/search-recordings.test.ts b/templates/clips/actions/search-recordings.test.ts index 2d3bee63f2a..bafd8d4c826 100644 --- a/templates/clips/actions/search-recordings.test.ts +++ b/templates/clips/actions/search-recordings.test.ts @@ -1,237 +1,223 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -type QueryCondition = - | { kind: "and"; conditions: QueryCondition[] } - | { kind: "or"; conditions: QueryCondition[] } - | { kind: "eq"; column: string; value: unknown } - | { kind: "is-null"; column: string } - | { kind: "owner-email"; column: string; email: string } - | { kind: "access" } - | { kind: "sql"; values: unknown[] }; - -interface MockQuery { - from(value: unknown): MockQuery; - innerJoin(): MockQuery; - where(condition: QueryCondition): MockQuery; - limit(value: number): MockQuery; - then(resolve: (value: unknown[]) => unknown): Promise; -} - -const whereConditions = vi.hoisted(() => [] as unknown[]); -const selectedFields = vi.hoisted(() => [] as Record[]); -const searchRows = vi.hoisted(() => ({ - comments: [] as Record[], -})); -const tables = vi.hoisted(() => ({ - recordings: { - id: "recordings.id", - title: "recordings.title", - description: "recordings.description", - trashedAt: "recordings.trashedAt", - ownerEmail: "recordings.ownerEmail", - enableComments: "recordings.enableComments", - }, - recordingShares: {}, - recordingViewers: {}, - recordingTranscripts: { - recordingId: "recordingTranscripts.recordingId", - fullText: "recordingTranscripts.fullText", - segmentsJson: "recordingTranscripts.segmentsJson", - }, - recordingComments: { - recordingId: "recordingComments.recordingId", - content: "recordingComments.content", - videoTimestampMs: "recordingComments.videoTimestampMs", - }, -})); - -const makeQuery = vi.hoisted(() => { - const matches = ( - row: Record, - condition: QueryCondition, - ): boolean => { - if (condition.kind === "and") - return condition.conditions.every((part) => matches(row, part)); - if (condition.kind === "or") - return condition.conditions.some((part) => matches(row, part)); - if (condition.kind === "eq") - return row[condition.column] === condition.value; - if (condition.kind === "is-null") return row[condition.column] == null; - if (condition.kind === "owner-email") - return ( - String(row[condition.column]).toLowerCase() === - condition.email.toLowerCase() - ); - if (condition.kind === "access") return true; - const column = String(condition.values[0]); - const pattern = condition.values[1]; - return ( - typeof row[column] === "string" && - String(row[column]) - .toLowerCase() - .includes(String(pattern).replace(/^%|%$/g, "")) - ); - }; - const create = (fields: Record): MockQuery => { - let table: unknown; - let filter: QueryCondition; - let limit = Infinity; - const query = { - from: (value: unknown) => { - table = value; - return query; - }, - innerJoin: () => query, - where: (condition: QueryCondition) => { - whereConditions.push(condition); - filter = condition; - return query; - }, - limit: (value: number) => { - limit = value; - return query; - }, - then: (resolve: (value: unknown[]) => unknown) => - Promise.resolve( - table === tables.recordingComments - ? searchRows.comments - .filter((row) => matches(row, filter)) - .slice(0, limit) - .map((row) => - Object.fromEntries( - Object.entries(fields).map(([key, column]) => [ - key, - row[String(column)], - ]), - ), - ) - : [], - ).then(resolve), - }; - return query; - }; - return create; -}); +import { PGlite } from "@electric-sql/pglite"; +import { sql, type SQLWrapper } from "drizzle-orm"; +import { getTableConfig, type PgTable } from "drizzle-orm/pg-core"; +import { drizzle, type PgliteDatabase } from "drizzle-orm/pglite"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import * as schema from "../server/db/schema.js"; vi.mock("@agent-native/core", () => ({ defineAction: (options: unknown) => options, embedApp: () => ({}), })); - -vi.mock("@agent-native/core/server", () => ({ - buildDeepLink: vi.fn(), -})); - -vi.mock("drizzle-orm", () => ({ - and: (...conditions: unknown[]) => ({ kind: "and", conditions }), - eq: (column: unknown, value: unknown) => ({ kind: "eq", column, value }), - isNull: (column: unknown) => ({ kind: "is-null", column }), - or: (...conditions: unknown[]) => ({ kind: "or", conditions }), - sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ - kind: "sql", - text: strings.join("?"), - values, - }), -})); - -vi.mock("../server/db/index.js", () => ({ - getDb: () => ({ - select: (fields: Record) => { - selectedFields.push(fields); - return makeQuery(fields); - }, - }), - schema: tables, -})); - +vi.mock("@agent-native/core/server", () => ({ buildDeepLink: vi.fn() })); vi.mock("@agent-native/core/server/request-context", () => ({ getRequestUserEmail: () => null, })); - -vi.mock("../server/lib/recordings.js", () => ({ - ownerEmailMatches: (column: unknown, email: string) => ({ - kind: "owner-email", - column, - email, - }), +vi.mock("../server/db/index.js", async () => ({ + getDb: () => db, + schema: await import("../server/db/schema.js"), })); - vi.mock("../server/lib/agent-recording-access.js", () => ({ - agentRecordingAccessFilter: () => ({ kind: "access" }), + agentRecordingAccessFilter: () => sql`true`, isAgentRecordingCaller: () => false, })); +vi.mock("../server/lib/recordings.js", () => ({ + ownerEmailMatches: (column: SQLWrapper, email: string) => + sql`lower(${column}) = ${email.trim().toLowerCase()}`, +})); -import action from "./search-recordings"; +import action from "./search-recordings.js"; -describe("search-recordings", () => { - beforeEach(() => { - whereConditions.length = 0; - selectedFields.length = 0; - searchRows.comments = []; - }); +const client = new PGlite(); +let db: PgliteDatabase; +const now = "2026-07-15T12:00:00.000Z"; - it("returns Trash status and scopes trashed matches to their owner", async () => { - await action.run({ query: "roadmap", limit: 30 }, { - userEmail: "owner@example.com", - } as never); +async function createQueryTable(table: PgTable) { + const config = getTableConfig(table); + const quote = (value: string) => `"${value.replaceAll('"', '""')}"`; + await client.exec( + `create table ${quote(config.name)} (${config.columns + .map((column) => `${quote(column.name)} ${column.getSQLType()}`) + .join(", ")})`, + ); +} - expect(selectedFields).toHaveLength(3); - expect( - selectedFields.every( - (fields) => fields.trashedAt === tables.recordings.trashedAt, - ), - ).toBe(true); - expect(whereConditions).toHaveLength(3); - expect( - whereConditions.every((condition: any) => - condition.conditions.some( - (nested: any) => - nested.kind === "or" && - nested.conditions.some( - (part: any) => - part.kind === "owner-email" && - part.column === tables.recordings.ownerEmail && - part.email === "owner@example.com", - ), - ), - ), - ).toBe(true); +// oracle-change: Replace predicate-shape mocks with executed PostgreSQL coverage of all three search queries, owner/Trash and comments-off controls, expiry offsets, and filtering before limits. +type MatchSource = "title" | "transcript" | "comment"; +async function seedMatch( + source: MatchSource, + id: string, + expiresAt: string | null, + options: { enableComments?: boolean; trashedAt?: string | null } = {}, +) { + await db.insert(schema.recordings).values({ + id, + organizationId: "fake-workspace", + ownerEmail: "owner@example.test", + title: source === "title" ? "Fake needle recording" : "Fake recording", + description: "", + kind: "video", + enableComments: options.enableComments ?? true, + expiresAt, + trashedAt: options.trashedAt ?? null, + createdAt: now, + updatedAt: now, }); - - it("keeps Trash out of agent search without a caller identity", async () => { - await action.run({ query: "roadmap", limit: 30 }, {} as never); - - expect( - whereConditions.every((condition: any) => - condition.conditions.some( - (nested: any) => - nested.kind === "is-null" && - nested.column === tables.recordings.trashedAt, - ), - ), - ).toBe(true); + if (source === "comment") + await db.insert(schema.recordingComments).values({ + id: "comment-" + id, + organizationId: "fake-workspace", + recordingId: id, + threadId: "thread-" + id, + authorEmail: "owner@example.test", + content: "Fake needle discussion " + id, + videoTimestampMs: 1000, + createdAt: now, + updatedAt: now, + }); + if (source === "transcript") + await db.insert(schema.recordingTranscripts).values({ + recordingId: id, + ownerEmail: "owner@example.test", + fullText: "Fake needle transcript " + id, + segmentsJson: "[]", + status: "ready", + createdAt: now, + updatedAt: now, + }); +} +describe("recording search with PostgreSQL queries", () => { + beforeAll(async () => { + db = drizzle(client, { schema }); + for (const table of [ + schema.recordings, + schema.recordingTranscripts, + schema.recordingComments, + ]) + await createQueryTable(table); + vi.spyOn(Date, "now").mockReturnValue(Date.parse(now)); }); - + beforeEach(async () => { + await db.delete(schema.recordingComments); + await db.delete(schema.recordingTranscripts); + await db.delete(schema.recordings); + }); + afterAll(async () => { + vi.restoreAllMocks(); + await client.close(); + }); + for (const source of ["title", "transcript", "comment"] as const) + describe(source + " matches", () => { + it.each([ + { + email: "viewer@example.test", + expiresAt: "2000-01-01T00:00:00Z", + visible: false, + }, + { email: undefined, expiresAt: "2000-01-01T00:00:00Z", visible: false }, + { + email: "viewer@example.test", + expiresAt: "2026-07-15T13:00:00+02:00", + visible: false, + }, + { + email: "viewer@example.test", + expiresAt: "2026-07-15T11:00:00-02:00", + visible: true, + }, + { email: "viewer@example.test", expiresAt: now, visible: true }, + { email: "viewer@example.test", expiresAt: null, visible: true }, + { email: "viewer@example.test", expiresAt: "", visible: true }, + { + email: "OWNER@example.test", + expiresAt: "2000-01-01T00:00:00Z", + visible: true, + }, + { email: "owner@example.test", expiresAt: "not-a-date", visible: true }, + ])( + "filters expiry for $email / $expiresAt", + async ({ email, expiresAt, visible }) => { + await seedMatch(source, "fake-recording", expiresAt); + const result = await action.run({ query: "needle", limit: 30 }, { + userEmail: email, + } as never); + expect(result.results.map((row) => row.id)).toEqual( + visible ? ["fake-recording"] : [], + ); + }, + ); + it.each([ + { email: "owner@example.test", visible: true }, + { email: "viewer@example.test", visible: false }, + { email: undefined, visible: false }, + ])("returns Trash only to $email", async ({ email, visible }) => { + await seedMatch(source, "trashed-recording", null, { trashedAt: now }); + const result = await action.run({ query: "needle", limit: 30 }, { + userEmail: email, + } as never); + expect( + result.results.map((row) => ({ + id: row.id, + trashedAt: row.trashedAt, + })), + ).toEqual(visible ? [{ id: "trashed-recording", trashedAt: now }] : []); + }); + it("filters expiry before the result limit", async () => { + await seedMatch(source, "expired-first", "2000-01-01T00:00:00Z"); + await seedMatch(source, "available-second", null); + const result = await action.run({ query: "needle", limit: 1 }, { + userEmail: "viewer@example.test", + } as never); + expect(result.results.map((row) => row.id)).toEqual([ + "available-second", + ]); + }); + it.each(["not-a-date", "2026-02-30T12:00:00Z", "12:30:00"])( + "keeps valid matches available beside malformed expiry %s", + async (expiresAt) => { + await seedMatch(source, "invalid-first", expiresAt); + await seedMatch(source, "available-second", null); + const result = await action.run({ query: "needle", limit: 1 }, { + userEmail: "viewer@example.test", + } as never); + expect(result.results.map((row) => row.id)).toEqual([ + "available-second", + ]); + }, + ); + it("reports malformed expiry to its owner for repair", async () => { + await seedMatch(source, "invalid-expiry", "not-a-date"); + const result = await action.run({ query: "needle", limit: 1 }, { + userEmail: "OWNER@example.test", + } as never); + expect(result.results.map((row) => row.id)).toEqual(["invalid-expiry"]); + expect(result).toMatchObject({ + warnings: [ + { + code: "invalid_recording_expiry", + recordingIds: ["invalid-expiry"], + }, + ], + }); + }); + }); it.each([ - { email: "viewer@example.com", enableComments: false, visible: false }, - { email: "owner@example.com", enableComments: false, visible: true }, - { email: "viewer@example.com", enableComments: true, visible: true }, + { email: "viewer@example.test", enableComments: false, visible: false }, + { email: "owner@example.test", enableComments: false, visible: true }, + { email: "viewer@example.test", enableComments: true, visible: true }, ])( - "applies comment visibility before returning search snippets: %j", + "applies comment visibility before returning snippets: %j", async ({ email, enableComments, visible }) => { - searchRows.comments = [ - { - "recordings.id": "rec-test", - "recordings.title": "Fake recording", - "recordings.description": "Fake description", - "recordings.ownerEmail": "owner@example.com", - "recordings.trashedAt": null, - "recordings.enableComments": enableComments, - "recordingComments.recordingId": "rec-test", - "recordingComments.content": "A fake needle discussion", - "recordingComments.videoTimestampMs": 12000, - }, - ]; + await seedMatch("comment", "fake-recording", null, { enableComments }); const result = await action.run({ query: "needle", limit: 30 }, { userEmail: email, } as never); @@ -239,9 +225,26 @@ describe("search-recordings", () => { result.results.map((row) => ({ id: row.id, snippet: row.snippet })), ).toEqual( visible - ? [{ id: "rec-test", snippet: "A fake needle discussion" }] + ? [ + { + id: "fake-recording", + snippet: "Fake needle discussion fake-recording", + }, + ] : [], ); }, ); + it.each(["title", "transcript"] as const)( + "keeps %s matches available with comments disabled", + async (source) => { + await seedMatch(source, "fake-recording", null, { + enableComments: false, + }); + const result = await action.run({ query: "needle", limit: 30 }, { + userEmail: "viewer@example.test", + } as never); + expect(result.results.map((row) => row.id)).toEqual(["fake-recording"]); + }, + ); }); diff --git a/templates/clips/actions/search-recordings.ts b/templates/clips/actions/search-recordings.ts index fe1a344ac73..752b66a5bdc 100644 --- a/templates/clips/actions/search-recordings.ts +++ b/templates/clips/actions/search-recordings.ts @@ -1,7 +1,7 @@ import { defineAction, embedApp } from "@agent-native/core"; import { buildDeepLink } from "@agent-native/core/server"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; -import { and, eq, isNull, or, sql } from "drizzle-orm"; +import { and, eq, or, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; @@ -122,12 +122,21 @@ export default defineAction({ userEmail: ctx?.userEmail, }; const userEmail = ctx?.userEmail ?? getRequestUserEmail(); - const lifecycleFilter = userEmail - ? or( - isNull(schema.recordings.trashedAt), - ownerEmailMatches(schema.recordings.ownerEmail, userEmail), - ) - : isNull(schema.recordings.trashedAt); + const ownerFilter = userEmail + ? ownerEmailMatches(schema.recordings.ownerEmail, userEmail) + : sql`false`; + // PostgreSQL 15's SQL/JSON parser needs a numeric offset instead of UTC Z. + const expiryIsCurrent = sql`jsonb_path_match_tz( + to_jsonb(regexp_replace(${schema.recordings.expiresAt}, '[zZ]$', '+00:00')), + '$.datetime() >= $now.datetime()', + jsonb_build_object('now', ${new Date(Date.now()).toISOString().replace(/Z$/, "+00:00")}::text), true + )`; + const expiryAbsent = sql`nullif(${schema.recordings.expiresAt}, '') is null`; + const expiryNeedsRepair = sql`not (${expiryAbsent}) and ${expiryIsCurrent} is null`; + const lifecycleFilter = sql`case when ${ownerFilter} then true else + ${schema.recordings.trashedAt} is null and + (case when ${expiryAbsent} then true else ${expiryIsCurrent} end) + end`; const recMatches = await db .select({ @@ -138,6 +147,7 @@ export default defineAction({ kind: schema.recordings.kind, mediaUpdatedAt: schema.recordings.mediaUpdatedAt, trashedAt: schema.recordings.trashedAt, + expiryNeedsRepair, durationMs: schema.recordings.durationMs, ownerEmail: schema.recordings.ownerEmail, visibility: schema.recordings.visibility, @@ -171,6 +181,7 @@ export default defineAction({ kind: schema.recordings.kind, mediaUpdatedAt: schema.recordings.mediaUpdatedAt, trashedAt: schema.recordings.trashedAt, + expiryNeedsRepair, durationMs: schema.recordings.durationMs, ownerEmail: schema.recordings.ownerEmail, visibility: schema.recordings.visibility, @@ -208,6 +219,7 @@ export default defineAction({ kind: schema.recordings.kind, mediaUpdatedAt: schema.recordings.mediaUpdatedAt, trashedAt: schema.recordings.trashedAt, + expiryNeedsRepair, durationMs: schema.recordings.durationMs, ownerEmail: schema.recordings.ownerEmail, visibility: schema.recordings.visibility, @@ -348,9 +360,19 @@ export default defineAction({ return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""); }); + const invalidExpiryIds = Array.from( + new Set( + [...recMatches, ...transcriptRows, ...commentRows] + .filter((row) => row.expiryNeedsRepair) + .map((row) => row.id), + ), + ); return { query: args.query, results: results.slice(0, args.limit), + warnings: invalidExpiryIds.length + ? [{ code: "invalid_recording_expiry", recordingIds: invalidExpiryIds }] + : [], }; }, link: ({ result }) => { diff --git a/templates/clips/actions/update-recording.test.ts b/templates/clips/actions/update-recording.test.ts index 60f7342d910..7639998ed2a 100644 --- a/templates/clips/actions/update-recording.test.ts +++ b/templates/clips/actions/update-recording.test.ts @@ -126,6 +126,28 @@ describe("update-recording share password version", () => { }); }); +describe("update-recording expiration", () => { + it.each(["not-a-date", "2026-02-30T12:00:00Z", "12:30:00"])( + "rejects invalid expiry %s before writing", + async (expiresAt) => { + await expect(run({ id: "recording-1", expiresAt })).rejects.toThrow(); + expect(state.patches).toEqual([]); + }, + ); + + it("stores offset expiry as a canonical UTC instant", async () => { + await run({ id: "recording-1", expiresAt: "2026-07-15T13:00:00+02:00" }); + expect(state.patches[0]).toMatchObject({ + expiresAt: "2026-07-15T11:00:00.000Z", + }); + }); + + it.each([null, ""])("clears expiry with %j", async (expiresAt) => { + await run({ id: "recording-1", expiresAt }); + expect(state.patches[0]).toMatchObject({ expiresAt: null }); + }); +}); + describe("update-recording chapters", () => { const intro = { startMs: 0, title: "Intro" }; const demo = { startMs: 48_000, title: "Demo" }; diff --git a/templates/clips/actions/update-recording.ts b/templates/clips/actions/update-recording.ts index 9d09c734fa5..f53ce00c90a 100644 --- a/templates/clips/actions/update-recording.ts +++ b/templates/clips/actions/update-recording.ts @@ -41,7 +41,10 @@ export default defineAction({ defaultSpeed: z.string().optional(), animatedThumbnailEnabled: z.union([z.boolean(), cliBoolean]).optional(), password: z.string().nullish(), - expiresAt: z.string().nullish(), + expiresAt: z + .union([z.literal(""), z.iso.datetime({ offset: true })]) + .transform((value) => (value ? new Date(value).toISOString() : null)) + .nullish(), chaptersJson: z .string() .optional() diff --git a/templates/clips/app/components/player/comments-panel.test.tsx b/templates/clips/app/components/player/comments-panel.test.tsx index b1d011ccc8a..eee17114219 100644 --- a/templates/clips/app/components/player/comments-panel.test.tsx +++ b/templates/clips/app/components/player/comments-panel.test.tsx @@ -25,6 +25,7 @@ import { TimestampedCommentBar } from "./timestamped-comment-button"; const actionMocks = vi.hoisted(() => ({ addComment: vi.fn(), updateComment: vi.fn(), + deleteComment: vi.fn(), otherMutation: vi.fn(), mutationOptions: new Map(), })); @@ -42,7 +43,9 @@ vi.mock("@agent-native/core/client/hooks", () => ({ ? actionMocks.addComment : name === "update-comment" ? actionMocks.updateComment - : actionMocks.otherMutation + : name === "delete-comment" + ? actionMocks.deleteComment + : actionMocks.otherMutation )(vars); }, }; @@ -197,6 +200,7 @@ describe("CommentsPanel reply composer", () => { comments: Comment[] = [rootComment], presentation: "default" | "share" | "inline" = "share", enableComments = true, + canDeleteOwnComments?: boolean, ) { act(() => { root.render( @@ -208,6 +212,7 @@ describe("CommentsPanel reply composer", () => { currentUserEmail={currentUserEmail} enableComments={enableComments} canComment={enableComments} + canDeleteOwnComments={canDeleteOwnComments} onSeek={vi.fn()} queryKey={["recording", "recording-1"]} presentation={presentation} @@ -1363,6 +1368,104 @@ describe("CommentsPanel reply composer", () => { expect(container.textContent).not.toContain("commentsPanel.reply"); }); + it.each([ + { + viewer: "the author with comments on", + email: "author@example.com", + enableComments: true, + canDeleteOwnComments: undefined, + items: ["commentsPanel.editComment", "commentsPanel.delete"], + }, + { + viewer: "the author reviewing history with comments off", + email: "author@example.com", + enableComments: false, + canDeleteOwnComments: true, + items: ["commentsPanel.delete"], + }, + { + viewer: "the recording owner on another author's history", + email: "owner@example.com", + enableComments: false, + canDeleteOwnComments: true, + items: null, + }, + { + viewer: "the author when the caller grants no deletion", + email: "author@example.com", + enableComments: false, + canDeleteOwnComments: undefined, + items: null, + }, + ])( + "gives $viewer only the comment menu actions they may take", + async ({ email, enableComments, canDeleteOwnComments, items }) => { + renderPanel( + email, + [rootComment], + "inline", + enableComments, + canDeleteOwnComments, + ); + const menuTrigger = container.querySelector( + '[aria-haspopup="menu"]', + ); + if (items === null) { + expect(menuTrigger).toBeNull(); + return; + } + await openMenu(menuTrigger); + expect( + Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + (item) => item.textContent.trim(), + ), + ).toEqual(items); + }, + ); + + it("lets the author delete saved history while comments are off", async () => { + renderPanel( + "author@example.com", + [ + { + ...rootComment, + emojiReactionsJson: JSON.stringify({ "👍": ["member@example.com"] }), + }, + ], + "inline", + false, + true, + ); + const buttons = () => + Array.from(container.querySelectorAll("button"), (button) => + (button.getAttribute("aria-label") ?? button.textContent).trim(), + ); + expect(buttons()).not.toContain("commentsPanel.reply"); + expect(buttons()).not.toContain("commentsPanel.react"); + expect(buttons()).not.toContain("👍 1"); + expect(container.textContent).toContain("👍 1"); + expect(container.textContent).not.toContain("commentsPanel.resolve"); + expect(container.querySelector("textarea")).toBeNull(); + + await openMenu( + container.querySelector('[aria-haspopup="menu"]'), + ); + const deleteItem = Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent.trim() === "commentsPanel.delete"); + assert.isDefined(deleteItem); + await act(async () => { + deleteItem.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(actionMocks.deleteComment).toHaveBeenCalledWith({ + id: rootComment.id, + }); + expect(container.textContent).not.toContain("Please take a look"); + }); + it("renders the shared empty state icon when comments are disabled", () => { act(() => { root.render( diff --git a/templates/clips/app/components/player/comments-panel.tsx b/templates/clips/app/components/player/comments-panel.tsx index 77a77362479..e1c3555b9ff 100644 --- a/templates/clips/app/components/player/comments-panel.tsx +++ b/templates/clips/app/components/player/comments-panel.tsx @@ -190,6 +190,11 @@ export interface CommentsPanelProps { currentUserName?: string; enableComments: boolean; canComment: boolean; + /** + * Lets the viewer delete comments they wrote. Defaults to `canComment`; an + * owner reviewing history with comments off may delete without commenting. + */ + canDeleteOwnComments?: boolean; onSeek: (ms: number) => void; queryKey: readonly unknown[]; selectComments?: CommentsLens["selectComments"]; @@ -208,6 +213,7 @@ export function CommentsPanel(props: CommentsPanelProps) { currentUserName, enableComments, canComment, + canDeleteOwnComments = canComment, onSeek, onUnauthenticated, queryKey, @@ -765,6 +771,7 @@ export function CommentsPanel(props: CommentsPanelProps) { formatRelativeTime={formatters.formatRelativeTime} currentUserEmail={currentUserEmail} canComment={canComment} + canDelete={canDeleteOwnComments} onSeek={onSeek} onReply={() => openReply(node.comment)} onDelete={(id) => remove.mutate({ id })} @@ -1193,6 +1200,7 @@ function CommentCard({ formatRelativeTime, currentUserEmail, canComment, + canDelete, editDraft, isEditing, onSeek, @@ -1214,6 +1222,7 @@ function CommentCard({ formatRelativeTime: ReturnType["formatRelativeTime"]; currentUserEmail?: string; canComment: boolean; + canDelete: boolean; editDraft: string; isEditing: boolean; onSeek: (ms: number) => void; @@ -1414,7 +1423,7 @@ function CommentCard({ ); })} - {isOwner && canComment ? ( + {isOwner && (canComment || canDelete) ? (