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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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/76] 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 6e7b9071f79dcb87734ab5dab4f430872817b4c8 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:07:44 -0400 Subject: [PATCH 11/76] Play Clips inside Content pages behind content.clip-embed A Clips link pasted into a video block's Link tab becomes a Clip block that plays in Content's own player, with the Clip's comments as markers on the scrubber. Content asks Clips for playback and comments over the peer contract, signed with the Content-Clips peer secret, and checks document access first, so signed-out readers of public pages can watch too. Registry blocks now keep their source on the clipboard, so a copied Clip pastes with its data instead of dropping out of the save. --- .changeset/registry-block-clipboard-source.md | 5 + .../src/editor/RegistryBlockNode.spec.ts | 39 ++ .../toolkit/src/editor/RegistryBlockNode.tsx | 9 +- .../content/actions/_clip-embed-access.ts | 136 ++++++ .../actions/clip-embed-actions.test.ts | 302 ++++++++++++ .../content/actions/get-clip-playback.ts | 71 +++ templates/content/actions/get-clips-links.ts | 21 + .../content/actions/list-clip-comments.ts | 73 +++ templates/content/actions/view-screen.test.ts | 42 ++ templates/content/actions/view-screen.ts | 29 ++ .../app/blocks/contentBlockRegistry.tsx | 2 + .../content/app/blocks/publicBlockRegistry.ts | 11 + templates/content/app/clip-embed-messages.ts | 456 ++++++++++++++++++ .../editor/clip-embed/ClipEmbedBlock.test.tsx | 270 +++++++++++ .../editor/clip-embed/ClipEmbedBlock.tsx | 277 +++++++++++ .../editor/clip-embed/ClipPlayer.tsx | 143 ++++++ .../editor/clip-embed/clip-embed-content.ts | 25 + .../editor/clip-embed/clip-media.spec.ts | 118 +++++ .../editor/clip-embed/clip-media.ts | 90 ++++ .../editor/clip-embed/comment-preview.tsx | 61 +++ .../editor/clip-embed/player-controls.tsx | 288 +++++++++++ .../editor/clip-embed/scrubber-position.ts | 93 ++++ .../components/editor/clip-embed/scrubber.tsx | 342 +++++++++++++ .../clip-embed/use-clip-playback.test.tsx | 276 +++++++++++ .../editor/clip-embed/use-clip-playback.ts | 156 ++++++ .../editor/clip-embed/use-clips-links.ts | 31 ++ .../editor/content-selection.spec.ts | 75 +++ .../components/editor/content-selection.ts | 41 ++ .../editor/extensions/VideoBlock.tsx | 61 +-- .../editor/extensions/VideoLinkForm.test.tsx | 151 ++++++ .../editor/extensions/VideoLinkForm.tsx | 96 ++++ .../editor/extensions/video-link-probe.ts | 29 ++ .../content/app/components/ui/slider.tsx | 1 + templates/content/app/global.css | 3 + templates/content/app/i18n-data.ts | 4 + templates/content/app/routes/p.$id.tsx | 28 +- .../content/server/lib/clips-peer.test.ts | 290 +++++++++++ templates/content/server/lib/clips-peer.ts | 230 +++++++++ .../content/server/lib/public-documents.ts | 6 + .../content/shared/clip-embed-block.spec.ts | 129 +++++ templates/content/shared/clip-embed-block.ts | 87 ++++ .../content/shared/clips-peer-contract.ts | 101 ++++ templates/content/shared/feature-flags.ts | 10 + templates/content/shared/nfm-registry.ts | 2 + templates/content/shared/video-link.spec.ts | 159 ++++++ templates/content/shared/video-link.ts | 143 ++++++ 46 files changed, 4969 insertions(+), 43 deletions(-) create mode 100644 .changeset/registry-block-clipboard-source.md create mode 100644 templates/content/actions/_clip-embed-access.ts create mode 100644 templates/content/actions/clip-embed-actions.test.ts create mode 100644 templates/content/actions/get-clip-playback.ts create mode 100644 templates/content/actions/get-clips-links.ts create mode 100644 templates/content/actions/list-clip-comments.ts create mode 100644 templates/content/app/blocks/publicBlockRegistry.ts create mode 100644 templates/content/app/clip-embed-messages.ts create mode 100644 templates/content/app/components/editor/clip-embed/ClipEmbedBlock.test.tsx create mode 100644 templates/content/app/components/editor/clip-embed/ClipEmbedBlock.tsx create mode 100644 templates/content/app/components/editor/clip-embed/ClipPlayer.tsx create mode 100644 templates/content/app/components/editor/clip-embed/clip-embed-content.ts create mode 100644 templates/content/app/components/editor/clip-embed/clip-media.spec.ts create mode 100644 templates/content/app/components/editor/clip-embed/clip-media.ts create mode 100644 templates/content/app/components/editor/clip-embed/comment-preview.tsx create mode 100644 templates/content/app/components/editor/clip-embed/player-controls.tsx create mode 100644 templates/content/app/components/editor/clip-embed/scrubber-position.ts create mode 100644 templates/content/app/components/editor/clip-embed/scrubber.tsx create mode 100644 templates/content/app/components/editor/clip-embed/use-clip-playback.test.tsx create mode 100644 templates/content/app/components/editor/clip-embed/use-clip-playback.ts create mode 100644 templates/content/app/components/editor/clip-embed/use-clips-links.ts create mode 100644 templates/content/app/components/editor/extensions/VideoLinkForm.test.tsx create mode 100644 templates/content/app/components/editor/extensions/VideoLinkForm.tsx create mode 100644 templates/content/app/components/editor/extensions/video-link-probe.ts create mode 100644 templates/content/app/components/ui/slider.tsx create mode 100644 templates/content/server/lib/clips-peer.test.ts create mode 100644 templates/content/server/lib/clips-peer.ts create mode 100644 templates/content/shared/clip-embed-block.spec.ts create mode 100644 templates/content/shared/clip-embed-block.ts create mode 100644 templates/content/shared/clips-peer-contract.ts create mode 100644 templates/content/shared/video-link.spec.ts create mode 100644 templates/content/shared/video-link.ts diff --git a/.changeset/registry-block-clipboard-source.md b/.changeset/registry-block-clipboard-source.md new file mode 100644 index 00000000000..d0c038d4bf1 --- /dev/null +++ b/.changeset/registry-block-clipboard-source.md @@ -0,0 +1,5 @@ +--- +"@agent-native/toolkit": patch +--- + +A registry block copied out of the editor keeps its source. `createRegistryBlockNode` writes the block's MDX source to `data-block-source` in its HTML and reads it back on parse, so a block pasted from the clipboard keeps its data and still saves, instead of arriving as an empty block that drops out of the document. diff --git a/packages/toolkit/src/editor/RegistryBlockNode.spec.ts b/packages/toolkit/src/editor/RegistryBlockNode.spec.ts index 8c422f6bf5d..c352f2d7563 100644 --- a/packages/toolkit/src/editor/RegistryBlockNode.spec.ts +++ b/packages/toolkit/src/editor/RegistryBlockNode.spec.ts @@ -234,6 +234,45 @@ describe("RegistryBlockNode keyboard guard", () => { }); }); +describe("RegistryBlockNode clipboard", () => { + it("keeps a copied block's source when it is pasted, under a fresh id", () => { + const editor = createEditor(); + const raw = ''; + + try { + const pos = findPlanBlockPos(editor); + editor.view.dispatch( + editor.state.tr.setNodeMarkup(pos, undefined, { + ...editor.state.doc.nodeAt(pos)!.attrs, + __raw: raw, + }), + ); + selectPlanBlock(editor); + const { dom } = editor.view.serializeForClipboard( + editor.state.selection.content(), + ); + + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + editor.view.pasteHTML(dom.innerHTML); + + const blocks: Record[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name === "planBlock") blocks.push(node.attrs); + return true; + }); + expect(blocks).toHaveLength(2); + expect(blocks[1]).toMatchObject({ + blockType: "diagram", + blockId: "diagram-fresh", + sourceBlockId: "diagram-1", + __raw: raw, + }); + } finally { + editor.destroy(); + } + }); +}); + describe("LegacyJsonEditSurface", () => { let container: HTMLDivElement; let root: Root; diff --git a/packages/toolkit/src/editor/RegistryBlockNode.tsx b/packages/toolkit/src/editor/RegistryBlockNode.tsx index 10d272299e6..ba047014ef1 100644 --- a/packages/toolkit/src/editor/RegistryBlockNode.tsx +++ b/packages/toolkit/src/editor/RegistryBlockNode.tsx @@ -533,16 +533,23 @@ export function createRegistryBlockNode( title: node.getAttribute("data-title") || null, summary: node.getAttribute("data-summary") || null, sourceBlockId: node.getAttribute("data-source-block-id") || null, + __raw: node.getAttribute("data-block-source") || null, }; }, }, ]; }, - renderHTML({ HTMLAttributes }) { + renderHTML({ node, HTMLAttributes }) { return [ "div", mergeAttributes(HTMLAttributes, { + // The clipboard carries a block only as this HTML, so a pasted copy + // without its source would lose its data and drop out of the save. + "data-block-source": + typeof node.attrs.__raw === "string" && node.attrs.__raw + ? node.attrs.__raw + : undefined, [dataTag]: "", "data-block-type": HTMLAttributes.blockType ?? "", "data-block-id": HTMLAttributes.blockId ?? "", diff --git a/templates/content/actions/_clip-embed-access.ts b/templates/content/actions/_clip-embed-access.ts new file mode 100644 index 00000000000..d1452ca862d --- /dev/null +++ b/templates/content/actions/_clip-embed-access.ts @@ -0,0 +1,136 @@ +import { fail } from "@agent-native/core/action"; +import { isFeatureFlagEnabled } from "@agent-native/core/feature-flags"; +import { + getRequestOrgId, + getRequestUserEmail, + getRequestUserName, +} from "@agent-native/core/server/request-context"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import type { ClipReader } from "../server/lib/clips-peer.js"; +import { isPublicViewerOwner } from "../server/lib/public-documents.js"; +import { isClipRecordingId } from "../shared/clip-embed-block.js"; +import { + CLIP_PLAYBACK_ERRORS, + type ClipPlaybackErrorCode, +} from "../shared/clips-peer-contract.js"; +import { CLIP_EMBED_FLAG } from "../shared/feature-flags.js"; +import { resolveDocumentAccess } from "./_document-access.js"; + +export const clipEmbedArgsSchema = z.object({ + documentId: z.string().trim().min(1).max(256).describe("Page id"), + recordingId: z + .string() + .refine(isClipRecordingId, "Not a Clips recording id") + .describe("Clips recording id from the page's Clip block"), +}); + +const FAILURES: Record< + ClipPlaybackErrorCode, + { statusCode: number; message: string } +> = { + [CLIP_PLAYBACK_ERRORS.disabled]: { + statusCode: 404, + message: "Clips in pages are not turned on", + }, + [CLIP_PLAYBACK_ERRORS.documentNotFound]: { + statusCode: 404, + message: "Page not found", + }, + [CLIP_PLAYBACK_ERRORS.notConfigured]: { + statusCode: 503, + message: "Clips is not set up for this Content deployment", + }, + [CLIP_PLAYBACK_ERRORS.notFound]: { + statusCode: 404, + message: "Clip not found", + }, + [CLIP_PLAYBACK_ERRORS.expired]: { + statusCode: 410, + message: "This Clip has expired", + }, + [CLIP_PLAYBACK_ERRORS.passwordRequired]: { + statusCode: 403, + message: "This Clip is password protected", + }, + [CLIP_PLAYBACK_ERRORS.redactionPending]: { + statusCode: 409, + message: "This Clip is waiting for its edits to finish", + }, + [CLIP_PLAYBACK_ERRORS.commentsDisabled]: { + statusCode: 403, + message: "Comments are turned off for this Clip", + }, + [CLIP_PLAYBACK_ERRORS.forbidden]: { + statusCode: 403, + message: "Clips refused this reader", + }, + [CLIP_PLAYBACK_ERRORS.unauthorized]: { + statusCode: 502, + message: "Clips refused Content's credentials", + }, + [CLIP_PLAYBACK_ERRORS.rateLimited]: { + statusCode: 429, + message: "Too many Clip requests", + }, + [CLIP_PLAYBACK_ERRORS.unreachable]: { + statusCode: 502, + message: "Clips did not answer", + }, +}; + +export function failClip(code: ClipPlaybackErrorCode): never { + const { message, statusCode } = FAILURES[code]; + fail(message, { errorCode: code, statusCode }); +} + +async function documentState(documentId: string) { + // guard:allow-unscoped — reads only whether a page is public or in Trash; signed-in access is decided by resolveDocumentAccess, signed-out access only by visibility "public". + const [row] = await getDb() + .select({ + visibility: schema.documents.visibility, + trashedAt: schema.documents.trashedAt, + }) + .from(schema.documents) + .where(eq(schema.documents.id, documentId)) + .limit(1); + return row ?? null; +} + +/** + * The reader Content may vouch for to Clips about a Clip in this page, after + * checking they can read it. A signed-out reader, or a public page's + * anonymous viewer, counts only on a public page and is never named. + */ +export async function authorizeClipReader( + documentId: string, +): Promise { + const email = getRequestUserEmail(); + const signedIn = Boolean(email) && !isPublicViewerOwner(email); + + if (signedIn) { + const access = await resolveDocumentAccess(documentId, { + skipResourceBody: true, + }); + if (!access) failClip(CLIP_PLAYBACK_ERRORS.documentNotFound); + } + const state = await documentState(documentId); + if (!state || state.trashedAt) { + failClip(CLIP_PLAYBACK_ERRORS.documentNotFound); + } + if (!signedIn && state.visibility !== "public") { + failClip(CLIP_PLAYBACK_ERRORS.documentNotFound); + } + + const enabled = await isFeatureFlagEnabled(CLIP_EMBED_FLAG, { + userEmail: signedIn ? email : undefined, + orgId: getRequestOrgId(), + }); + if (!enabled) failClip(CLIP_PLAYBACK_ERRORS.disabled); + + return signedIn + ? { kind: "signed-in", email: email!, name: getRequestUserName() ?? null } + : { kind: "public" }; +} diff --git a/templates/content/actions/clip-embed-actions.test.ts b/templates/content/actions/clip-embed-actions.test.ts new file mode 100644 index 00000000000..35352cba94f --- /dev/null +++ b/templates/content/actions/clip-embed-actions.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + userEmail: undefined as string | undefined, + userName: undefined as string | undefined, + documentRows: [] as Array<{ visibility: string; trashedAt: string | null }>, + resolveDocumentAccess: vi.fn(), + isFeatureFlagEnabled: vi.fn(), + requestClips: vi.fn(), +})); + +vi.mock("@agent-native/core/server/request-context", () => ({ + getRequestUserEmail: () => mocks.userEmail, + getRequestUserName: () => mocks.userName, + getRequestOrgId: () => "org_1", +})); + +vi.mock("@agent-native/core/feature-flags", () => ({ + isFeatureFlagEnabled: mocks.isFeatureFlagEnabled, +})); + +vi.mock("./_document-access.js", () => ({ + resolveDocumentAccess: mocks.resolveDocumentAccess, +})); + +vi.mock("../server/db/index.js", () => ({ + schema: { documents: { id: "id", visibility: "visibility", trashedAt: "t" } }, + getDb: () => ({ + select: () => ({ + from: () => ({ + where: () => ({ limit: async () => mocks.documentRows }), + }), + }), + }), +})); + +vi.mock("../server/lib/clips-peer.js", () => ({ + requestClips: mocks.requestClips, +})); + +import getClipPlayback from "./get-clip-playback"; +import listClipComments from "./list-clip-comments"; + +const PUBLIC_VIEWER = + "public-0f8fad5b-d9cb-469f-a165-70867728950e@agent-native.local"; +const args = { documentId: "doc_1", recordingId: "rec_1" }; + +const playback = { + recording: { + id: "rec_1", + title: "Demo", + kind: "video", + status: "ready", + durationMs: 12_000, + width: 1280, + height: 720, + enableComments: true, + embedProvider: null, + }, + playback: { + videoUrl: "https://clips.example.test/api/video/rec_1?cg=1", + posterUrl: "https://clips.example.test/api/thumbnail/rec_1?cg=1", + expiresAt: "2026-10-23T12:05:00.000Z", + }, + viewer: { isOwner: false, canComment: true }, +}; + +async function failureOf(promise: Promise) { + try { + await promise; + } catch (error) { + return { + errorCode: (error as { errorCode?: string }).errorCode, + statusCode: (error as { statusCode?: number }).statusCode, + }; + } + throw new Error("expected the action to fail"); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.userEmail = "reader@example.test"; + mocks.userName = "Robin Reader"; + mocks.documentRows = [{ visibility: "private", trashedAt: null }]; + mocks.resolveDocumentAccess.mockResolvedValue({ role: "viewer" }); + mocks.isFeatureFlagEnabled.mockResolvedValue(true); + mocks.requestClips.mockResolvedValue({ ok: true, body: playback }); +}); + +describe("get-clip-playback", () => { + it("plays a Clip for a reader of the page, as that reader", async () => { + await expect(getClipPlayback.run(args)).resolves.toEqual(playback); + expect(mocks.resolveDocumentAccess).toHaveBeenCalledWith("doc_1", { + skipResourceBody: true, + }); + expect(mocks.requestClips).toHaveBeenCalledWith({ + reader: { + kind: "signed-in", + email: "reader@example.test", + name: "Robin Reader", + }, + documentId: "doc_1", + recordingId: "rec_1", + resource: "playback", + }); + }); + + it("refuses a signed-in caller who can't read the page, before asking Clips", async () => { + mocks.resolveDocumentAccess.mockResolvedValue(null); + await expect(failureOf(getClipPlayback.run(args))).resolves.toEqual({ + errorCode: "document_not_found", + statusCode: 404, + }); + expect(mocks.isFeatureFlagEnabled).not.toHaveBeenCalled(); + expect(mocks.requestClips).not.toHaveBeenCalled(); + }); + + it("refuses a page in Trash", async () => { + mocks.documentRows = [ + { visibility: "private", trashedAt: "2026-10-01T00:00:00Z" }, + ]; + await expect(failureOf(getClipPlayback.run(args))).resolves.toEqual({ + errorCode: "document_not_found", + statusCode: 404, + }); + expect(mocks.requestClips).not.toHaveBeenCalled(); + }); + + it.each([ + ["no session", undefined], + ["a public page's anonymous viewer", PUBLIC_VIEWER], + ])( + "vouches for %s as a public reader of a public page", + async (_label, email) => { + mocks.userEmail = email; + mocks.documentRows = [{ visibility: "public", trashedAt: null }]; + await getClipPlayback.run(args); + expect(mocks.resolveDocumentAccess).not.toHaveBeenCalled(); + expect(mocks.isFeatureFlagEnabled).toHaveBeenCalledWith( + expect.objectContaining({ key: "content.clip-embed" }), + { userEmail: undefined, orgId: "org_1" }, + ); + expect(mocks.requestClips).toHaveBeenCalledWith( + expect.objectContaining({ reader: { kind: "public" } }), + ); + }, + ); + + it("refuses a signed-out caller a page that isn't public, before asking Clips", async () => { + mocks.userEmail = PUBLIC_VIEWER; + for (const rows of [ + [{ visibility: "private", trashedAt: null }], + [{ visibility: "org", trashedAt: null }], + [{ visibility: "public", trashedAt: "2026-10-01T00:00:00Z" }], + [], + ]) { + mocks.documentRows = rows; + await expect(failureOf(getClipPlayback.run(args))).resolves.toEqual({ + errorCode: "document_not_found", + statusCode: 404, + }); + } + expect(mocks.requestClips).not.toHaveBeenCalled(); + }); + + it("asks Clips nothing while the flag is off for the reader", async () => { + mocks.isFeatureFlagEnabled.mockResolvedValue(false); + await expect(failureOf(getClipPlayback.run(args))).resolves.toEqual({ + errorCode: "clip_embed_disabled", + statusCode: 404, + }); + expect(mocks.requestClips).not.toHaveBeenCalled(); + }); + + it.each([ + ["clips_not_configured", 503], + ["clip_not_found", 404], + ["clip_expired", 410], + ["clip_password_required", 403], + ["clip_redaction_pending", 409], + ["clip_forbidden", 403], + ["clip_token_refused", 502], + ["clip_rate_limited", 429], + ["clips_unreachable", 502], + ])("keeps Clips' %s as its own failure", async (code, statusCode) => { + mocks.requestClips.mockResolvedValue({ ok: false, code }); + await expect(failureOf(getClipPlayback.run(args))).resolves.toEqual({ + errorCode: code, + statusCode, + }); + }); + + it("refuses an answer for another Clip or with a URL it can't play", async () => { + for (const body of [ + { ...playback, recording: { ...playback.recording, id: "rec_2" } }, + { + ...playback, + playback: { ...playback.playback, videoUrl: "javascript:alert(1)" }, + }, + { recording: playback.recording }, + ]) { + mocks.requestClips.mockResolvedValue({ ok: true, body }); + await expect(failureOf(getClipPlayback.run(args))).resolves.toEqual({ + errorCode: "clips_unreachable", + statusCode: 502, + }); + } + }); + + it("refuses a recording id that isn't one", async () => { + await expect( + getClipPlayback.run({ documentId: "doc_1", recordingId: "../video" }), + ).rejects.toThrow(); + expect(mocks.requestClips).not.toHaveBeenCalled(); + }); +}); + +describe("list-clip-comments", () => { + const comments = [ + { + id: "c1", + parentId: null, + authorEmail: "ada@example.test", + authorName: "Ada Example", + content: "Look here", + videoTimestampMs: 4_000, + }, + { + id: "c2", + parentId: "c1", + authorEmail: "bo@example.test", + authorName: null, + content: "A reply", + videoTimestampMs: 4_000, + }, + { + id: "c3", + parentId: null, + authorEmail: "bo@example.test", + authorName: null, + content: "And here", + videoTimestampMs: 9_500, + }, + ]; + + it("returns one marker per thread, after the same page check", async () => { + mocks.requestClips.mockResolvedValue({ ok: true, body: { comments } }); + await expect(listClipComments.run(args)).resolves.toEqual({ + markers: [ + { + id: "c1", + videoTimestampMs: 4_000, + content: "Look here", + authorName: "Ada Example", + }, + { + id: "c3", + videoTimestampMs: 9_500, + content: "And here", + authorName: "bo@example.test", + }, + ], + }); + expect(mocks.requestClips).toHaveBeenCalledWith( + expect.objectContaining({ resource: "comments" }), + ); + }); + + it("never hands a signed-out reader a commenter's email", async () => { + mocks.userEmail = undefined; + mocks.documentRows = [{ visibility: "public", trashedAt: null }]; + mocks.requestClips.mockResolvedValue({ ok: true, body: { comments } }); + const { markers } = await listClipComments.run(args); + expect(markers.map((marker) => marker.authorName)).toEqual([ + "Ada Example", + null, + ]); + }); + + it("tells comments turned off apart from a Clip with no comments", async () => { + mocks.requestClips.mockResolvedValue({ ok: true, body: { comments: [] } }); + await expect(listClipComments.run(args)).resolves.toEqual({ markers: [] }); + + mocks.requestClips.mockResolvedValue({ + ok: false, + code: "clip_comments_disabled", + }); + await expect(failureOf(listClipComments.run(args))).resolves.toEqual({ + errorCode: "clip_comments_disabled", + statusCode: 403, + }); + }); + + it("refuses a caller who can't read the page, before asking Clips", async () => { + mocks.resolveDocumentAccess.mockResolvedValue(null); + await expect(failureOf(listClipComments.run(args))).resolves.toEqual({ + errorCode: "document_not_found", + statusCode: 404, + }); + expect(mocks.requestClips).not.toHaveBeenCalled(); + }); +}); diff --git a/templates/content/actions/get-clip-playback.ts b/templates/content/actions/get-clip-playback.ts new file mode 100644 index 00000000000..a780becfe3c --- /dev/null +++ b/templates/content/actions/get-clip-playback.ts @@ -0,0 +1,71 @@ +import { defineAction } from "@agent-native/core/action"; +import { z } from "zod"; + +import { requestClips } from "../server/lib/clips-peer.js"; +import { + CLIP_PLAYBACK_ERRORS, + type ClipPlayback, +} from "../shared/clips-peer-contract.js"; +import { + authorizeClipReader, + clipEmbedArgsSchema, + failClip, +} from "./_clip-embed-access.js"; + +const mediaUrl = z + .string() + .refine( + (value) => + URL.canParse(value) && + ["https:", "http:"].includes(new URL(value).protocol), + ) + .nullable(); + +const playbackSchema = z.object({ + recording: z.object({ + id: z.string(), + title: z.string().nullable(), + kind: z.string().nullable(), + status: z.string().nullable(), + durationMs: z.number().nullable(), + width: z.number().nullable(), + height: z.number().nullable(), + enableComments: z.boolean(), + embedProvider: z.string().nullable(), + }), + playback: z.object({ + videoUrl: mediaUrl, + posterUrl: mediaUrl, + expiresAt: z.string().refine((value) => !Number.isNaN(Date.parse(value))), + }), + viewer: z.object({ isOwner: z.boolean(), canComment: z.boolean() }), +}); + +export default defineAction({ + description: + "Play a Clip placed in a page. Checks the caller can read the page, then asks Clips for playback URLs that stop working within minutes; the Clip block's player calls it again when they do.", + schema: clipEmbedArgsSchema, + http: { method: "GET" }, + readOnly: true, + requiresAuth: false, + agentTool: false, + run: async ({ documentId, recordingId }): Promise => { + const reader = await authorizeClipReader(documentId); + const result = await requestClips({ + reader, + documentId, + recordingId, + resource: "playback", + }); + if (!result.ok) failClip(result.code); + + const parsed = playbackSchema.safeParse(result.body); + if (!parsed.success || parsed.data.recording.id !== recordingId) { + console.warn("[content-clips] unreadable playback answer", { + recordingId, + }); + failClip(CLIP_PLAYBACK_ERRORS.unreachable); + } + return parsed.data; + }, +}); diff --git a/templates/content/actions/get-clips-links.ts b/templates/content/actions/get-clips-links.ts new file mode 100644 index 00000000000..1776d248edd --- /dev/null +++ b/templates/content/actions/get-clips-links.ts @@ -0,0 +1,21 @@ +import { defineAction } from "@agent-native/core/action"; +import { z } from "zod"; + +import { + clipsLinkOrigins, + resolveClipsAppUrl, +} from "../server/lib/clips-peer.js"; + +export default defineAction({ + description: + "Where this Content's Clips app lives: the app URL that opens a Clip, and the app URLs whose /r/, /share/ and /embed/ links the video block's Link tab turns into a Clip block. Both are empty when no Clips app is set up.", + schema: z.object({}), + http: { method: "GET" }, + readOnly: true, + requiresAuth: false, + agentTool: false, + run: async (): Promise<{ appUrl: string | null; linkOrigins: string[] }> => ({ + appUrl: resolveClipsAppUrl(), + linkOrigins: clipsLinkOrigins(), + }), +}); diff --git a/templates/content/actions/list-clip-comments.ts b/templates/content/actions/list-clip-comments.ts new file mode 100644 index 00000000000..8bf81a2a922 --- /dev/null +++ b/templates/content/actions/list-clip-comments.ts @@ -0,0 +1,73 @@ +import { defineAction } from "@agent-native/core/action"; +import { z } from "zod"; + +import { requestClips } from "../server/lib/clips-peer.js"; +import { + CLIP_PLAYBACK_ERRORS, + type ClipCommentMarker, +} from "../shared/clips-peer-contract.js"; +import { + authorizeClipReader, + clipEmbedArgsSchema, + failClip, +} from "./_clip-embed-access.js"; + +const MAX_MARKERS = 500; +const MAX_PREVIEW_CHARS = 280; + +const commentsSchema = z.object({ + comments: z.array( + z.object({ + id: z.string(), + parentId: z.string().nullable().optional(), + authorEmail: z.string().nullable().optional(), + authorName: z.string().nullable().optional(), + content: z.string(), + videoTimestampMs: z.number().min(0), + }), + ), +}); + +export default defineAction({ + description: + "List where a Clip placed in a page has comment threads, for the markers on its player's scrubber. Checks the caller can read the page first. Fails with clip_comments_disabled, not an empty list, when the Clip's comments are off.", + schema: clipEmbedArgsSchema, + http: { method: "GET" }, + readOnly: true, + requiresAuth: false, + agentTool: false, + run: async ({ + documentId, + recordingId, + }): Promise<{ markers: ClipCommentMarker[] }> => { + const reader = await authorizeClipReader(documentId); + const result = await requestClips({ + reader, + documentId, + recordingId, + resource: "comments", + }); + if (!result.ok) failClip(result.code); + + const parsed = commentsSchema.safeParse(result.body); + if (!parsed.success) { + console.warn("[content-clips] unreadable comments answer", { + recordingId, + }); + failClip(CLIP_PLAYBACK_ERRORS.unreachable); + } + const markers = parsed.data.comments + .filter((comment) => !comment.parentId) + .slice(0, MAX_MARKERS) + .map((comment) => ({ + id: comment.id, + videoTimestampMs: comment.videoTimestampMs, + content: comment.content.slice(0, MAX_PREVIEW_CHARS), + // A signed-out reader sees names, never commenters' emails. + authorName: + comment.authorName?.trim() || + (reader.kind === "signed-in" ? comment.authorEmail || null : null), + })); + return { markers }; + }, +}); diff --git a/templates/content/actions/view-screen.test.ts b/templates/content/actions/view-screen.test.ts index 30ad9ec7365..51a7a978825 100644 --- a/templates/content/actions/view-screen.test.ts +++ b/templates/content/actions/view-screen.test.ts @@ -70,6 +70,48 @@ describe("buildSelectionScreenSection", () => { expect(section?.hint).toContain("baseRevision"); expect(section?.hint).toContain("idempotencyKey"); }); + + it("names a selected Clip block and the recording it plays", () => { + const selection = { + documentId: "doc1", + collapsed: false, + selectedBlock: { + blockType: "clip-embed", + blockId: "clip-embed-1a2b3c4d", + recordingId: "rec_1", + }, + blockText: "", + heading: "Demo", + }; + const section = buildSelectionScreenSection(selection, "doc1"); + expect(section).toMatchObject({ + documentId: "doc1", + collapsed: false, + selectedBlock: { + blockType: "clip-embed", + blockId: "clip-embed-1a2b3c4d", + recordingId: "rec_1", + }, + heading: "Demo", + }); + expect(section).not.toHaveProperty("selectedText"); + expect(section?.hint).toContain("clip-embed-1a2b3c4d"); + }); + + it("ignores a selected block it can't read", () => { + const section = buildSelectionScreenSection( + { + documentId: "doc1", + collapsed: true, + selectedBlock: { blockType: 7 }, + blockText: "", + heading: null, + }, + "doc1", + ); + expect(section).not.toHaveProperty("selectedBlock"); + expect(section?.collapsed).toBe(true); + }); }); function property( diff --git a/templates/content/actions/view-screen.ts b/templates/content/actions/view-screen.ts index 5dd76d56cce..4eefe91bfa3 100644 --- a/templates/content/actions/view-screen.ts +++ b/templates/content/actions/view-screen.ts @@ -239,12 +239,26 @@ export function documentContentPreview(content: string) { interface ContentSelectionAppState { documentId?: unknown; collapsed?: unknown; + selectedBlock?: unknown; selectedText?: unknown; textTruncated?: unknown; blockText?: unknown; heading?: unknown; } +function readSelectedBlock(value: unknown) { + if (!value || typeof value !== "object") return null; + const { blockType, blockId, recordingId } = value as Record; + if (typeof blockType !== "string" || typeof blockId !== "string") { + return null; + } + return { + blockType, + blockId, + ...(typeof recordingId === "string" ? { recordingId } : {}), + }; +} + export function buildSelectionScreenSection( selection: unknown, openDocumentId: string | undefined, @@ -262,6 +276,21 @@ export function buildSelectionScreenSection( const heading = typeof state.heading === "string" ? state.heading : null; const blockText = typeof state.blockText === "string" ? state.blockText : ""; + const selectedBlock = readSelectedBlock(state.selectedBlock); + if (selectedBlock) { + return { + documentId: state.documentId, + collapsed: false, + selectedBlock, + heading, + hint: + `The user selected a whole ${selectedBlock.blockType} block. It is the tag with id="${selectedBlock.blockId}" in get-document's body` + + (selectedBlock.recordingId + ? `; it plays Clips recording ${selectedBlock.recordingId}.` + : "."), + }; + } + if (state.collapsed === true || typeof state.selectedText !== "string") { return { documentId: state.documentId, diff --git a/templates/content/app/blocks/contentBlockRegistry.tsx b/templates/content/app/blocks/contentBlockRegistry.tsx index 8388795d7af..049e32374a6 100644 --- a/templates/content/app/blocks/contentBlockRegistry.tsx +++ b/templates/content/app/blocks/contentBlockRegistry.tsx @@ -15,6 +15,7 @@ import { import { BuilderSetupCard } from "@agent-native/toolkit/app/chat/chat/run-recovery"; import { useEffect, useRef, useState } from "react"; +import { clipEmbedBlock } from "@/components/editor/clip-embed/ClipEmbedBlock"; import { uploadImageFile } from "@/components/editor/image-upload"; import { Popover, @@ -49,6 +50,7 @@ for (const block of builderDocsBlocks) { } contentBlockRegistry.register(sourceComponentBlock); contentBlockRegistry.register(inlineDatabaseBlock); +contentBlockRegistry.register(clipEmbedBlock); type ContentBlockRenderContext = BlockRenderContext & { documentId?: string | null; diff --git a/templates/content/app/blocks/publicBlockRegistry.ts b/templates/content/app/blocks/publicBlockRegistry.ts new file mode 100644 index 00000000000..690f13d85e4 --- /dev/null +++ b/templates/content/app/blocks/publicBlockRegistry.ts @@ -0,0 +1,11 @@ +import { BlockRegistry } from "@agent-native/core/blocks/server"; + +import { clipEmbedBlock } from "@/components/editor/clip-embed/ClipEmbedBlock"; + +/** + * Blocks a public page renders for a signed-out reader. Only blocks whose + * reads work without a session belong here; the rest keep their fallback. + */ +export const publicContentBlockRegistry = new BlockRegistry(); + +publicContentBlockRegistry.register(clipEmbedBlock); diff --git a/templates/content/app/clip-embed-messages.ts b/templates/content/app/clip-embed-messages.ts new file mode 100644 index 00000000000..ecbb9344f45 --- /dev/null +++ b/templates/content/app/clip-embed-messages.ts @@ -0,0 +1,456 @@ +import type { BuiltinLocaleCode } from "@agent-native/core/client/i18n"; + +const en = { + play: "Play", + pause: "Pause", + back: "Back 5 seconds", + forward: "Forward 5 seconds", + mute: "Mute", + unmute: "Unmute", + volume: "Volume", + speed: "Playback speed", + fullscreen: "Full screen", + exitFullscreen: "Exit full screen", + commentsAtOne: "1 comment at {{time}}", + commentsAt: "{{count}} comments at {{time}}", + untitled: "Untitled Clip", + openInClips: "Open in Clips", + loading: "Loading Clip", + retry: "Try again", + commentsFailed: "Comments didn't load", + notFound: "This Clip isn't available, or you don't have access.", + disabled: "Clips in pages aren't turned on.", + notConfigured: "Clips isn't connected to Content here.", + expired: "This Clip has expired.", + passwordRequired: "This Clip has a password. Open it in Clips to watch.", + redactionPending: "This Clip is still being edited. Try again soon.", + forbidden: "You don't have access to this Clip.", + refused: "Clips didn't accept this page's request.", + rateLimited: "Too many requests. Try again in a minute.", + unreachable: "Clips isn't responding. Try again later.", + failed: "This Clip didn't load.", + refreshFailed: "This Clip stopped and couldn't reconnect.", + unplayable: "This Clip can't play here.", + playsInClipsOnly: "This Clip plays only in Clips.", + linkClipsDisabled: "Clips links can't go in pages yet.", + linkClipsPage: "That Clips link isn't a Clip.", + linkVideoPage: "That's a video page. Paste a link to a video file.", + linkNotVideo: "That link isn't a video file.", + linkChecking: "Checking link…", +} as const; + +type ClipEmbedMessages = Record; + +const translations: Record< + Exclude, + ClipEmbedMessages +> = { + "es-ES": { + play: "Reproducir", + pause: "Pausar", + back: "Retroceder 5 segundos", + forward: "Avanzar 5 segundos", + mute: "Silenciar", + unmute: "Activar sonido", + volume: "Volumen", + speed: "Velocidad de reproducción", + fullscreen: "Pantalla completa", + exitFullscreen: "Salir de pantalla completa", + commentsAtOne: "1 comentario en {{time}}", + commentsAt: "{{count}} comentarios en {{time}}", + untitled: "Clip sin título", + openInClips: "Abrir en Clips", + loading: "Cargando clip", + retry: "Reintentar", + commentsFailed: "No se cargaron los comentarios", + notFound: "Este clip no está disponible o no tienes acceso.", + disabled: "Los clips en páginas no están activados.", + notConfigured: "Clips no está conectado a Content aquí.", + expired: "Este clip ha caducado.", + passwordRequired: "Este clip tiene contraseña. Ábrelo en Clips para verlo.", + redactionPending: + "Este clip todavía se está editando. Inténtalo de nuevo pronto.", + forbidden: "No tienes acceso a este clip.", + refused: "Clips no aceptó la solicitud de esta página.", + rateLimited: "Demasiadas solicitudes. Inténtalo de nuevo en un minuto.", + unreachable: "Clips no responde. Inténtalo de nuevo más tarde.", + failed: "Este clip no se cargó.", + refreshFailed: "Este clip se detuvo y no pudo reconectarse.", + unplayable: "Este clip no se puede reproducir aquí.", + playsInClipsOnly: "Este clip solo se reproduce en Clips.", + linkClipsDisabled: + "Los enlaces de Clips aún no se pueden añadir a páginas.", + linkClipsPage: "Ese enlace de Clips no es un clip.", + linkVideoPage: + "Es una página de video. Pega un enlace a un archivo de video.", + linkNotVideo: "Ese enlace no es un archivo de video.", + linkChecking: "Comprobando enlace…", + }, + "fr-FR": { + play: "Lire", + pause: "Pause", + back: "Reculer de 5 secondes", + forward: "Avancer de 5 secondes", + mute: "Couper le son", + unmute: "Réactiver le son", + volume: "Volume", + speed: "Vitesse de lecture", + fullscreen: "Plein écran", + exitFullscreen: "Quitter le plein écran", + commentsAtOne: "1 commentaire à {{time}}", + commentsAt: "{{count}} commentaires à {{time}}", + untitled: "Clip sans titre", + openInClips: "Ouvrir dans Clips", + loading: "Chargement du clip", + retry: "Réessayer", + commentsFailed: "Les commentaires n’ont pas pu être chargés", + notFound: "Ce clip n’est pas disponible, ou vous n’y avez pas accès.", + disabled: "Les clips dans les pages ne sont pas activés.", + notConfigured: "Clips n’est pas connecté à Content ici.", + expired: "Ce clip a expiré.", + passwordRequired: + "Ce clip est protégé par un mot de passe. Ouvrez-le dans Clips pour le regarder.", + redactionPending: + "Ce clip est encore en cours de modification. Réessayez bientôt.", + forbidden: "Vous n’avez pas accès à ce clip.", + refused: "Clips n’a pas accepté la demande de cette page.", + rateLimited: "Trop de demandes. Réessayez dans une minute.", + unreachable: "Clips ne répond pas. Réessayez plus tard.", + failed: "Ce clip ne s’est pas chargé.", + refreshFailed: "Ce clip s’est arrêté et n’a pas pu se reconnecter.", + unplayable: "Ce clip ne peut pas être lu ici.", + playsInClipsOnly: "Ce clip ne se lit que dans Clips.", + linkClipsDisabled: + "Les liens Clips ne peuvent pas encore être ajoutés aux pages.", + linkClipsPage: "Ce lien Clips n’est pas un clip.", + linkVideoPage: + "C’est une page vidéo. Collez un lien vers un fichier vidéo.", + linkNotVideo: "Ce lien n’est pas un fichier vidéo.", + linkChecking: "Vérification du lien…", + }, + "de-DE": { + play: "Abspielen", + pause: "Pausieren", + back: "5 Sekunden zurück", + forward: "5 Sekunden vor", + mute: "Stummschalten", + unmute: "Ton einschalten", + volume: "Lautstärke", + speed: "Wiedergabegeschwindigkeit", + fullscreen: "Vollbild", + exitFullscreen: "Vollbild beenden", + commentsAtOne: "1 Kommentar bei {{time}}", + commentsAt: "{{count}} Kommentare bei {{time}}", + untitled: "Clip ohne Titel", + openInClips: "In Clips öffnen", + loading: "Clip wird geladen", + retry: "Erneut versuchen", + commentsFailed: "Kommentare wurden nicht geladen", + notFound: "Dieser Clip ist nicht verfügbar, oder du hast keinen Zugriff.", + disabled: "Clips in Seiten sind nicht aktiviert.", + notConfigured: "Clips ist hier nicht mit Content verbunden.", + expired: "Dieser Clip ist abgelaufen.", + passwordRequired: + "Dieser Clip hat ein Passwort. Öffne ihn in Clips, um ihn anzusehen.", + redactionPending: + "Dieser Clip wird noch bearbeitet. Versuche es bald noch einmal.", + forbidden: "Du hast keinen Zugriff auf diesen Clip.", + refused: "Clips hat die Anfrage dieser Seite nicht angenommen.", + rateLimited: "Zu viele Anfragen. Versuche es in einer Minute erneut.", + unreachable: "Clips antwortet nicht. Versuche es später erneut.", + failed: "Dieser Clip wurde nicht geladen.", + refreshFailed: + "Dieser Clip wurde angehalten und konnte sich nicht neu verbinden.", + unplayable: "Dieser Clip kann hier nicht abgespielt werden.", + playsInClipsOnly: "Dieser Clip läuft nur in Clips.", + linkClipsDisabled: + "Clips-Links können noch nicht in Seiten eingefügt werden.", + linkClipsPage: "Dieser Clips-Link ist kein Clip.", + linkVideoPage: + "Das ist eine Videoseite. Füge einen Link zu einer Videodatei ein.", + linkNotVideo: "Dieser Link ist keine Videodatei.", + linkChecking: "Link wird geprüft…", + }, + "pt-BR": { + play: "Reproduzir", + pause: "Pausar", + back: "Voltar 5 segundos", + forward: "Avançar 5 segundos", + mute: "Silenciar", + unmute: "Ativar som", + volume: "Volume", + speed: "Velocidade de reprodução", + fullscreen: "Tela cheia", + exitFullscreen: "Sair da tela cheia", + commentsAtOne: "1 comentário em {{time}}", + commentsAt: "{{count}} comentários em {{time}}", + untitled: "Clip sem título", + openInClips: "Abrir no Clips", + loading: "Carregando clip", + retry: "Tentar novamente", + commentsFailed: "Os comentários não carregaram", + notFound: "Este clip não está disponível ou você não tem acesso.", + disabled: "Clips em páginas não estão ativados.", + notConfigured: "O Clips não está conectado ao Content aqui.", + expired: "Este clip expirou.", + passwordRequired: "Este clip tem senha. Abra-o no Clips para assistir.", + redactionPending: + "Este clip ainda está sendo editado. Tente novamente em breve.", + forbidden: "Você não tem acesso a este clip.", + refused: "O Clips não aceitou a solicitação desta página.", + rateLimited: "Muitas solicitações. Tente novamente em um minuto.", + unreachable: "O Clips não está respondendo. Tente novamente mais tarde.", + failed: "Este clip não carregou.", + refreshFailed: "Este clip parou e não conseguiu se reconectar.", + unplayable: "Este clip não pode ser reproduzido aqui.", + playsInClipsOnly: "Este clip só é reproduzido no Clips.", + linkClipsDisabled: + "Links do Clips ainda não podem ser adicionados a páginas.", + linkClipsPage: "Esse link do Clips não é um clip.", + linkVideoPage: + "Isso é uma página de vídeo. Cole um link para um arquivo de vídeo.", + linkNotVideo: "Esse link não é um arquivo de vídeo.", + linkChecking: "Verificando link…", + }, + "zh-CN": { + play: "播放", + pause: "暂停", + back: "后退 5 秒", + forward: "前进 5 秒", + mute: "静音", + unmute: "取消静音", + volume: "音量", + speed: "播放速度", + fullscreen: "全屏", + exitFullscreen: "退出全屏", + commentsAtOne: "{{time}} 处有 1 条评论", + commentsAt: "{{time}} 处有 {{count}} 条评论", + untitled: "未命名片段", + openInClips: "在 Clips 中打开", + loading: "正在加载片段", + retry: "重试", + commentsFailed: "评论未能加载", + notFound: "此片段不可用,或你没有访问权限。", + disabled: "页面中的片段功能尚未开启。", + notConfigured: "此处的 Content 尚未连接 Clips。", + expired: "此片段已过期。", + passwordRequired: "此片段设有密码。请在 Clips 中打开观看。", + redactionPending: "此片段仍在编辑中。请稍后再试。", + forbidden: "你没有此片段的访问权限。", + refused: "Clips 未接受此页面的请求。", + rateLimited: "请求过多。请一分钟后再试。", + unreachable: "Clips 没有响应。请稍后再试。", + failed: "此片段未能加载。", + refreshFailed: "此片段已停止,且无法重新连接。", + unplayable: "此片段无法在此处播放。", + playsInClipsOnly: "此片段只能在 Clips 中播放。", + linkClipsDisabled: "暂时还不能在页面中添加 Clips 链接。", + linkClipsPage: "该 Clips 链接不是片段。", + linkVideoPage: "这是视频页面。请粘贴视频文件的链接。", + linkNotVideo: "该链接不是视频文件。", + linkChecking: "正在检查链接…", + }, + "zh-TW": { + play: "播放", + pause: "暫停", + back: "倒退 5 秒", + forward: "快轉 5 秒", + mute: "靜音", + unmute: "取消靜音", + volume: "音量", + speed: "播放速度", + fullscreen: "全螢幕", + exitFullscreen: "結束全螢幕", + commentsAtOne: "{{time}} 處有 1 則留言", + commentsAt: "{{time}} 處有 {{count}} 則留言", + untitled: "未命名片段", + openInClips: "在 Clips 中開啟", + loading: "正在載入片段", + retry: "重試", + commentsFailed: "留言未能載入", + notFound: "此片段無法使用,或你沒有存取權限。", + disabled: "頁面中的片段功能尚未開啟。", + notConfigured: "此處的 Content 尚未連接 Clips。", + expired: "此片段已過期。", + passwordRequired: "此片段設有密碼。請在 Clips 中開啟觀看。", + redactionPending: "此片段仍在編輯中。請稍後再試。", + forbidden: "你沒有此片段的存取權限。", + refused: "Clips 未接受此頁面的要求。", + rateLimited: "要求過多。請一分鐘後再試。", + unreachable: "Clips 沒有回應。請稍後再試。", + failed: "此片段未能載入。", + refreshFailed: "此片段已停止,且無法重新連線。", + unplayable: "此片段無法在此處播放。", + playsInClipsOnly: "此片段只能在 Clips 中播放。", + linkClipsDisabled: "暫時還不能在頁面中加入 Clips 連結。", + linkClipsPage: "該 Clips 連結不是片段。", + linkVideoPage: "這是影片頁面。請貼上影片檔案的連結。", + linkNotVideo: "該連結不是影片檔案。", + linkChecking: "正在檢查連結…", + }, + "ja-JP": { + play: "再生", + pause: "一時停止", + back: "5 秒戻る", + forward: "5 秒進む", + mute: "ミュート", + unmute: "ミュート解除", + volume: "音量", + speed: "再生速度", + fullscreen: "全画面表示", + exitFullscreen: "全画面表示を終了", + commentsAtOne: "{{time}} にコメント 1 件", + commentsAt: "{{time}} にコメント {{count}} 件", + untitled: "無題のクリップ", + openInClips: "Clips で開く", + loading: "クリップを読み込み中", + retry: "再試行", + commentsFailed: "コメントを読み込めませんでした", + notFound: "このクリップは利用できないか、アクセス権がありません。", + disabled: "ページ内のクリップは有効になっていません。", + notConfigured: "ここでは Clips が Content に接続されていません。", + expired: "このクリップは期限切れです。", + passwordRequired: + "このクリップにはパスワードがあります。Clips で開いて視聴してください。", + redactionPending: + "このクリップはまだ編集中です。しばらくしてからもう一度お試しください。", + forbidden: "このクリップへのアクセス権がありません。", + refused: "Clips はこのページのリクエストを受け付けませんでした。", + rateLimited: "リクエストが多すぎます。1 分後にもう一度お試しください。", + unreachable: "Clips が応答していません。後でもう一度お試しください。", + failed: "このクリップを読み込めませんでした。", + refreshFailed: "このクリップは停止し、再接続できませんでした。", + unplayable: "このクリップはここでは再生できません。", + playsInClipsOnly: "このクリップは Clips でのみ再生できます。", + linkClipsDisabled: "Clips のリンクはまだページに追加できません。", + linkClipsPage: "その Clips のリンクはクリップではありません。", + linkVideoPage: + "動画ページです。動画ファイルへのリンクを貼り付けてください。", + linkNotVideo: "そのリンクは動画ファイルではありません。", + linkChecking: "リンクを確認しています…", + }, + "ko-KR": { + play: "재생", + pause: "일시정지", + back: "5초 뒤로", + forward: "5초 앞으로", + mute: "음소거", + unmute: "음소거 해제", + volume: "볼륨", + speed: "재생 속도", + fullscreen: "전체 화면", + exitFullscreen: "전체 화면 종료", + commentsAtOne: "{{time}}에 댓글 1개", + commentsAt: "{{time}}에 댓글 {{count}}개", + untitled: "제목 없는 클립", + openInClips: "Clips에서 열기", + loading: "클립 불러오는 중", + retry: "다시 시도", + commentsFailed: "댓글을 불러오지 못했습니다", + notFound: "이 클립을 사용할 수 없거나 접근 권한이 없습니다.", + disabled: "페이지의 클립 기능이 켜져 있지 않습니다.", + notConfigured: "여기서는 Clips가 Content에 연결되어 있지 않습니다.", + expired: "이 클립은 만료되었습니다.", + passwordRequired: + "이 클립에는 비밀번호가 있습니다. Clips에서 열어 시청하세요.", + redactionPending: "이 클립은 아직 편집 중입니다. 잠시 후 다시 시도하세요.", + forbidden: "이 클립에 대한 접근 권한이 없습니다.", + refused: "Clips가 이 페이지의 요청을 받아들이지 않았습니다.", + rateLimited: "요청이 너무 많습니다. 1분 후에 다시 시도하세요.", + unreachable: "Clips가 응답하지 않습니다. 나중에 다시 시도하세요.", + failed: "이 클립을 불러오지 못했습니다.", + refreshFailed: "이 클립이 멈췄고 다시 연결하지 못했습니다.", + unplayable: "이 클립은 여기서 재생할 수 없습니다.", + playsInClipsOnly: "이 클립은 Clips에서만 재생됩니다.", + linkClipsDisabled: "Clips 링크는 아직 페이지에 넣을 수 없습니다.", + linkClipsPage: "그 Clips 링크는 클립이 아닙니다.", + linkVideoPage: "동영상 페이지입니다. 동영상 파일 링크를 붙여 넣으세요.", + linkNotVideo: "그 링크는 동영상 파일이 아닙니다.", + linkChecking: "링크 확인 중…", + }, + "hi-IN": { + play: "चलाएं", + pause: "रोकें", + back: "5 सेकंड पीछे", + forward: "5 सेकंड आगे", + mute: "म्यूट करें", + unmute: "अनम्यूट करें", + volume: "वॉल्यूम", + speed: "प्लेबैक गति", + fullscreen: "पूर्ण स्क्रीन", + exitFullscreen: "पूर्ण स्क्रीन से बाहर निकलें", + commentsAtOne: "{{time}} पर 1 टिप्पणी", + commentsAt: "{{time}} पर {{count}} टिप्पणियां", + untitled: "बिना शीर्षक वाली क्लिप", + openInClips: "Clips में खोलें", + loading: "क्लिप लोड हो रही है", + retry: "फिर से कोशिश करें", + commentsFailed: "टिप्पणियां लोड नहीं हुईं", + notFound: "यह क्लिप उपलब्ध नहीं है, या आपके पास इसकी पहुंच नहीं है।", + disabled: "पेजों में क्लिप चालू नहीं हैं।", + notConfigured: "यहां Clips, Content से जुड़ा नहीं है।", + expired: "इस क्लिप की समय-सीमा खत्म हो गई है।", + passwordRequired: "इस क्लिप पर पासवर्ड है। देखने के लिए इसे Clips में खोलें।", + redactionPending: + "इस क्लिप में अभी बदलाव हो रहे हैं। थोड़ी देर बाद फिर से कोशिश करें।", + forbidden: "आपके पास इस क्लिप की पहुंच नहीं है।", + refused: "Clips ने इस पेज का अनुरोध स्वीकार नहीं किया।", + rateLimited: "बहुत ज़्यादा अनुरोध। एक मिनट बाद फिर से कोशिश करें।", + unreachable: "Clips जवाब नहीं दे रहा है। बाद में फिर से कोशिश करें।", + failed: "यह क्लिप लोड नहीं हुई।", + refreshFailed: "यह क्लिप रुक गई और फिर से कनेक्ट नहीं हो सकी।", + unplayable: "यह क्लिप यहां नहीं चल सकती।", + playsInClipsOnly: "यह क्लिप सिर्फ़ Clips में चलती है।", + linkClipsDisabled: "Clips लिंक अभी पेजों में नहीं जोड़े जा सकते।", + linkClipsPage: "वह Clips लिंक कोई क्लिप नहीं है।", + linkVideoPage: "यह एक वीडियो पेज है। किसी वीडियो फ़ाइल का लिंक चिपकाएं।", + linkNotVideo: "वह लिंक कोई वीडियो फ़ाइल नहीं है।", + linkChecking: "लिंक जांचा जा रहा है…", + }, + "ar-SA": { + play: "تشغيل", + pause: "إيقاف مؤقت", + back: "رجوع 5 ثوانٍ", + forward: "تقديم 5 ثوانٍ", + mute: "كتم الصوت", + unmute: "إلغاء كتم الصوت", + volume: "مستوى الصوت", + speed: "سرعة التشغيل", + fullscreen: "ملء الشاشة", + exitFullscreen: "الخروج من ملء الشاشة", + commentsAtOne: "تعليق واحد عند {{time}}", + commentsAt: "{{count}} تعليقات عند {{time}}", + untitled: "مقطع بلا عنوان", + openInClips: "فتح في Clips", + loading: "جارٍ تحميل المقطع", + retry: "إعادة المحاولة", + commentsFailed: "لم يتم تحميل التعليقات", + notFound: "هذا المقطع غير متاح، أو ليس لديك صلاحية الوصول إليه.", + disabled: "المقاطع في الصفحات غير مفعّلة.", + notConfigured: "Clips غير متصل بـ Content هنا.", + expired: "انتهت صلاحية هذا المقطع.", + passwordRequired: "هذا المقطع محمي بكلمة مرور. افتحه في Clips لمشاهدته.", + redactionPending: "لا يزال هذا المقطع قيد التعديل. حاول مرة أخرى قريبًا.", + forbidden: "ليس لديك صلاحية الوصول إلى هذا المقطع.", + refused: "لم يقبل Clips طلب هذه الصفحة.", + rateLimited: "طلبات كثيرة جدًا. حاول مرة أخرى بعد دقيقة.", + unreachable: "Clips لا يستجيب. حاول مرة أخرى لاحقًا.", + failed: "لم يتم تحميل هذا المقطع.", + refreshFailed: "توقف هذا المقطع ولم يتمكن من إعادة الاتصال.", + unplayable: "لا يمكن تشغيل هذا المقطع هنا.", + playsInClipsOnly: "يعمل هذا المقطع في Clips فقط.", + linkClipsDisabled: "لا يمكن إضافة روابط Clips إلى الصفحات بعد.", + linkClipsPage: "رابط Clips هذا ليس مقطعًا.", + linkVideoPage: "هذه صفحة فيديو. الصق رابطًا إلى ملف فيديو.", + linkNotVideo: "هذا الرابط ليس ملف فيديو.", + linkChecking: "جارٍ التحقق من الرابط…", + }, +}; + +export const clipEmbedMessagesByLocale: Record< + BuiltinLocaleCode, + ClipEmbedMessages +> = { + "en-US": en, + ...translations, +}; diff --git a/templates/content/app/components/editor/clip-embed/ClipEmbedBlock.test.tsx b/templates/content/app/components/editor/clip-embed/ClipEmbedBlock.test.tsx new file mode 100644 index 00000000000..ce4e8caec43 --- /dev/null +++ b/templates/content/app/components/editor/clip-embed/ClipEmbedBlock.test.tsx @@ -0,0 +1,270 @@ +// @vitest-environment happy-dom + +import type { ClipPlayback } from "@shared/clips-peer-contract"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TooltipProvider } from "@/components/ui/tooltip"; + +import { ClipEmbed } from "./ClipEmbedBlock"; + +type FakeQuery = { + data: unknown; + isError: boolean; + isSuccess: boolean; + error: unknown; + refetch: ReturnType; +}; + +const queries = vi.hoisted(() => ({ + byName: {} as Record, + options: {} as Record | undefined>, +})); + +vi.mock("@agent-native/core/client/hooks", () => ({ + useActionQuery: ( + name: string, + _params: unknown, + options?: Record, + ) => { + queries.options[name] = options; + return queries.byName[name]; + }, +})); + +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string, options?: Record) => + options ? `${key} ${JSON.stringify(options)}` : key, +})); + +vi.mock("@agent-native/toolkit/app/review", () => ({ + InlineMarkdown: ({ content }: { content: string }) => <>{content}, +})); + +function fakeQuery(data?: unknown, error?: unknown): FakeQuery { + return { + data, + isError: Boolean(error), + isSuccess: data !== undefined, + error: error ?? null, + refetch: vi.fn(async () => ({ isError: false, data })), + }; +} + +function refusal(errorCode?: string) { + return Object.assign(new Error("refused"), { errorCode }); +} + +function playback(overrides?: { + recording?: Partial; + playback?: Partial; +}): ClipPlayback { + return { + recording: { + id: "rec_1", + title: "Quarterly demo", + kind: "video", + status: "ready", + durationMs: 12_000, + width: 1280, + height: 720, + enableComments: true, + embedProvider: null, + ...overrides?.recording, + }, + playback: { + videoUrl: "https://clips.example.test/api/video/rec_1?cg=1", + posterUrl: null, + expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), + ...overrides?.playback, + }, + viewer: { isOwner: false, canComment: true }, + }; +} + +const markers = [ + { + id: "c1", + videoTimestampMs: 4_000, + content: "Look here", + authorName: "Ada Example", + }, + { id: "c2", videoTimestampMs: 9_500, content: "And here", authorName: null }, +]; + +let root: Root | null = null; +let container: HTMLDivElement; + +function render() { + act(() => + root!.render( + + + , + ), + ); +} + +beforeEach(() => { + queries.byName = { + "get-clip-playback": fakeQuery(playback()), + "list-clip-comments": fakeQuery({ markers }), + "get-clips-links": fakeQuery({ + appUrl: "https://clips.example.test", + linkOrigins: ["https://clips.example.test"], + }), + }; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root?.unmount()); + container.remove(); + root = null; +}); + +describe("ClipEmbed", () => { + it("holds a 16:9 place while the Clip loads", () => { + queries.byName["get-clip-playback"] = fakeQuery(); + render(); + const skeleton = container.querySelector( + '[aria-label="clipEmbed.loading"]', + ); + expect(skeleton?.style.aspectRatio).toBe("16 / 9"); + expect(container.querySelector("video")).toBeNull(); + }); + + it("plays the Clip with its title, length and comment markers", () => { + render(); + const video = container.querySelector("video"); + expect(video?.getAttribute("src")).toBe( + "https://clips.example.test/api/video/rec_1?cg=1", + ); + expect(container.textContent).toContain("Quarterly demo"); + expect(container.textContent).toContain("0:12"); + expect( + container.querySelectorAll("[data-player-comment-marker]"), + ).toHaveLength(2); + expect( + container.querySelector( + 'a[href="https://clips.example.test/share/rec_1"]', + ), + ).not.toBeNull(); + }); + + it("seeks to a comment when its marker is clicked", () => { + render(); + const video = container.querySelector("video")!; + const marker = container.querySelector( + '[data-player-comment-marker][aria-label^="clipEmbed.commentsAtOne"]', + )!; + act(() => marker.click()); + expect(video.currentTime).toBe(4); + }); + + it("shows no markers and no error while the Clip's comments are off", () => { + queries.byName["get-clip-playback"] = fakeQuery( + playback({ recording: { enableComments: false } }), + ); + render(); + expect(queries.options["list-clip-comments"]?.enabled).toBe(false); + expect(container.querySelector("[data-player-comment-marker]")).toBeNull(); + expect(container.querySelector("video")).not.toBeNull(); + expect(container.textContent).not.toContain("clipEmbed.commentsFailed"); + }); + + it("says so when the comment list failed, and still plays", () => { + queries.byName["list-clip-comments"] = fakeQuery( + undefined, + refusal("clips_unreachable"), + ); + render(); + expect(container.querySelector("video")).not.toBeNull(); + expect(container.textContent).toContain("clipEmbed.commentsFailed"); + }); + + it.each([ + ["clips_not_configured", "clipEmbed.notConfigured"], + ["clip_not_found", "clipEmbed.notFound"], + ["document_not_found", "clipEmbed.notFound"], + ["clip_expired", "clipEmbed.expired"], + ["clip_password_required", "clipEmbed.passwordRequired"], + ["clip_redaction_pending", "clipEmbed.redactionPending"], + ["clips_unreachable", "clipEmbed.unreachable"], + ["clip_embed_disabled", "clipEmbed.disabled"], + [undefined, "clipEmbed.failed"], + ])("tells %s apart with its own message", (code, message) => { + queries.byName["get-clip-playback"] = fakeQuery(undefined, refusal(code)); + render(); + expect(container.querySelector('[role="status"]')?.textContent).toContain( + message, + ); + expect(container.querySelector("video")).toBeNull(); + }); + + it("offers Clips for a password-protected Clip and a retry for an outage", () => { + queries.byName["get-clip-playback"] = fakeQuery( + undefined, + refusal("clip_password_required"), + ); + render(); + expect( + container.querySelector( + 'a[href="https://clips.example.test/share/rec_1"]', + )?.textContent, + ).toBe("clipEmbed.openInClips"); + + queries.byName["get-clip-playback"] = fakeQuery( + undefined, + refusal("clips_unreachable"), + ); + render(); + const retry = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "clipEmbed.retry", + ); + act(() => retry!.click()); + expect(queries.byName["get-clip-playback"]!.refetch).toHaveBeenCalled(); + }); + + it("asks for a fresh grant when the video fails", async () => { + render(); + const video = container.querySelector("video")!; + await act(async () => { + video.dispatchEvent(new Event("error")); + }); + expect(queries.byName["get-clip-playback"]!.refetch).toHaveBeenCalledTimes( + 1, + ); + }); + + it("plays a YouTube Clip in YouTube's player inside the frame", () => { + queries.byName["get-clip-playback"] = fakeQuery( + playback({ + recording: { embedProvider: "youtube" }, + playback: { videoUrl: "https://www.youtube.com/embed/dQw4w9WgXcQ" }, + }), + ); + render(); + expect(container.querySelector("video")).toBeNull(); + expect(container.querySelector("iframe")?.getAttribute("src")).toBe( + "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0", + ); + expect(queries.options["list-clip-comments"]?.enabled).toBe(false); + }); + + it("declines a Clip another service plays, and links to Clips", () => { + queries.byName["get-clip-playback"] = fakeQuery( + playback({ + recording: { embedProvider: "loom" }, + playback: { videoUrl: "https://www.loom.com/embed/abc" }, + }), + ); + render(); + expect(container.querySelector("video, iframe")).toBeNull(); + expect(container.textContent).toContain("clipEmbed.playsInClipsOnly"); + expect(container.textContent).toContain("clipEmbed.openInClips"); + }); +}); diff --git a/templates/content/app/components/editor/clip-embed/ClipEmbedBlock.tsx b/templates/content/app/components/editor/clip-embed/ClipEmbedBlock.tsx new file mode 100644 index 00000000000..6e8c888e015 --- /dev/null +++ b/templates/content/app/components/editor/clip-embed/ClipEmbedBlock.tsx @@ -0,0 +1,277 @@ +import type { + BlockReadProps, + BlockRenderContext, + BlockSpec, +} from "@agent-native/core/blocks/server"; +import { useT } from "@agent-native/core/client/i18n"; +import { + clipEmbedBlockConfig, + type ClipEmbedData, +} from "@shared/clip-embed-block"; +import type { ClipPlayback } from "@shared/clips-peer-contract"; +import { IconMovie } from "@tabler/icons-react"; +import type { ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +import { youtubeEmbedSrc } from "./clip-media"; +import { ClipPlayer } from "./ClipPlayer"; +import { msToClock } from "./scrubber"; +import { + useClipMarkers, + useClipPlayback, + type ClipPlayerError, +} from "./use-clip-playback"; +import { clipShareUrl, useClipsLinks } from "./use-clips-links"; + +type ContentBlockRenderContext = BlockRenderContext & { + documentId?: string | null; +}; + +const ERROR_MESSAGES: Record = { + clip_embed_disabled: "clipEmbed.disabled", + document_not_found: "clipEmbed.notFound", + clips_not_configured: "clipEmbed.notConfigured", + clip_not_found: "clipEmbed.notFound", + clip_expired: "clipEmbed.expired", + clip_password_required: "clipEmbed.passwordRequired", + clip_redaction_pending: "clipEmbed.redactionPending", + clip_comments_disabled: "clipEmbed.failed", + clip_forbidden: "clipEmbed.forbidden", + clip_token_refused: "clipEmbed.refused", + clip_rate_limited: "clipEmbed.rateLimited", + clips_unreachable: "clipEmbed.unreachable", + refresh_failed: "clipEmbed.refreshFailed", + unplayable: "clipEmbed.unplayable", + failed: "clipEmbed.failed", +}; + +/** Failures that may clear up on their own, so the reader can ask again. */ +const RETRYABLE = new Set([ + "clip_redaction_pending", + "clip_rate_limited", + "clips_unreachable", + "refresh_failed", + "unplayable", + "failed", +]); + +function ClipFrame({ + recordingId, + children, +}: { + recordingId: string; + children: ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} + +function ClipMessage({ + message, + action, +}: { + message: string; + action?: ReactNode; +}) { + return ( +
+ +
+ {message} + {action} +
+
+ ); +} + +function ClipCaption({ + grant, + openUrl, + trailing, +}: { + grant: ClipPlayback; + openUrl: string | null; + trailing?: ReactNode; +}) { + const t = useT(); + const title = grant.recording.title?.trim() || t("clipEmbed.untitled"); + const durationMs = grant.recording.durationMs; + return ( +
+ + {openUrl ? ( + + {title} + + ) : ( + {title} + )} + {durationMs ? ( + · {msToClock(durationMs)} + ) : null} +
+ {trailing} +
+ ); +} + +export function ClipEmbed({ + documentId, + recordingId, +}: { + documentId: string; + recordingId: string; +}) { + const t = useT(); + const playback = useClipPlayback(documentId, recordingId); + const { grant } = playback; + const links = useClipsLinks(); + const openUrl = links.appUrl ? clipShareUrl(links.appUrl, recordingId) : null; + const provider = grant?.recording.embedProvider ?? null; + const youtubeSrc = + provider === "youtube" + ? youtubeEmbedSrc(grant?.playback.videoUrl ?? null) + : null; + const markers = useClipMarkers( + documentId, + recordingId, + Boolean(grant?.recording.enableComments) && !provider, + ); + + const openInClips = openUrl ? ( + + ) : null; + + if (playback.error) { + const error = playback.error; + return ( + + + {t("clipEmbed.retry")} + + ) : null + } + /> + + ); + } + + if (!grant) { + return ( + + + + ); + } + + if (provider === "youtube" && youtubeSrc) { + return ( + +
+