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 new file mode 100644 index 00000000000..946698e7a69 --- /dev/null +++ b/.changeset/host-access-signed-out.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +`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 79dafed7be3..47d81893fda 100644 --- a/packages/core/src/sharing/access.spec.ts +++ b/packages/core/src/sharing/access.spec.ts @@ -658,6 +658,58 @@ describe("shareable resource access helpers", () => { } }); + 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) => { + asked.push(ctx.userEmail); + 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( + 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..3b94cd5b460 100644 --- a/packages/core/src/sharing/access.ts +++ b/packages/core/src/sharing/access.ts @@ -668,21 +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. + * 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( @@ -726,10 +746,10 @@ 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 host = hostAccessResolvers.get(reg.type); const hosted = - hostResolver && normalizedUserEmail - ? await hostResolver(resource, ctx) + host && (normalizedUserEmail || host.includeSignedOut) + ? await host.resolve(resource, ctx) : undefined; if (hosted === null) return null; 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/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..77a57caaae2 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,17 @@ 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 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. +- 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 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..25c06a11a49 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,9 @@ registerShareableResource({ getDb, ownerAccessIgnoresOrg: true, }); +registerHostAccessResolver("recording", contentDocReaderRole, { + includeSignedOut: true, +}); registerShareableResource({ type: "meeting", @@ -198,6 +205,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..7d9a4bc73fc --- /dev/null +++ b/templates/clips/server/lib/content-clip-peer.ts @@ -0,0 +1,569 @@ +/** + * 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 { readContentPeerSecret } from "./content-peer-secret.js"; +import { + contentReaderOrgId, + signContentPlaybackGrant, + withContentPlaybackGrant, +} from "./content-playback-grant.js"; +import { contextClipIdForFootage } from "./context-footage.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 peerSecret = readContentPeerSecret(); + if (peerSecret.refused) { + return json(peerSecret.refused.body, peerSecret.refused.status); + } + 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, + verificationSecret: peerSecret.secret, + 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 { + // 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 = () => + 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..1fbea63fb42 --- /dev/null +++ b/templates/clips/server/lib/content-clips.test.ts @@ -0,0 +1,1559 @@ +import { createHmac } from "node:crypto"; +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 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", + ); + 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("./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 { 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"); +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 } = + 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(", ")});`; +} + +/** + * 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 = {}, +) { + 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 insertWorkspace( + id: string, + values: Partial = {}, +) { + await state.db.insert(organizations).values({ + id, + name: id, + createdBy: OWNER, + createdAt: 1, + ...values, + }); +} + +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. */ +async function setActiveWorkspace(email: string, orgId: string) { + await withDbExec(coreDb(), () => + putUserSetting(email, "active-org-id", { 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", + [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 | null; + expiresAt?: number | null; + 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" }); + 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`); + } + return jwt.sign(new TextEncoder().encode(input.secret ?? PEER_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 withDbExec(coreDb(), () => 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 withDbExec(coreDb(), () => 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, + body: result instanceof Uint8Array ? null : (result as any), + }; +} + +/** The media URL with its grant's claims rewritten and signed again. */ +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", key) + .update(encoded) + .digest("base64url"); + url.searchParams.set("content_grant", `${encoded}.${signature}`); + return url.href; +} + +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); + 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 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"); +}); + +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"); + + 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 () => { + 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 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" }); + + 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("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", { + 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("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()); + + 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 insertWorkspace("clips-org"); + const reader = await readerInWorkspaces("clips-org"); + 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", sub: reader }), + ); + 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("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"); + 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"], + ["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", 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); + 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, sub: reader }); + 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", sub: reader }), + ); + 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, + 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 write = await token({ scope: SCOPES.comment }); + for (const body of [ + { operation: "reply", commentId: "foreign-comment", content: "Hi" }, + { 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 () => { + 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" })], + ["the deployment A2A secret", () => token({ secret: SECRET })], + [ + "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 }), + ], + [ + "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); + 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 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: { + 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); + }); + + 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", + await token({ issuer: "https://other-content.example.test" }), + ); + 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"; + let reader: string; + + 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'", + ); + reader = await readerInWorkspaces("org-content"); + 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({ + 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({ + sub: reader, + ...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", + sub: reader, + 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 [ + { 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-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 new file mode 100644 index 00000000000..c43f95b58e3 --- /dev/null +++ b/templates/clips/server/lib/content-playback-grant.ts @@ -0,0 +1,183 @@ +import { resolveOrgIdForEmail } from "@agent-native/core/org"; +import { + signShortLivedToken, + verifyShortLivedToken, +} 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, +} from "./content-clip-bindings.js"; +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. 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" } + | { + 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 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, + }, + { signingKey: contentPlaybackGrantKey(peerSecret.secret) }, + ); + return { + params: { + [GRANT_PARAM]: token, + ...(input.bindingId ? { [BINDING_PARAM]: input.bindingId } : {}), + }, + expiresAt: new Date( + (issuedAtSeconds + CONTENT_CLIP_PLAYBACK_GRANT_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. A genuine grant that + * 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, + recordingId: string, +): Promise { + const query = getQuery(event); + 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" + ? { status: "expired" } + : { 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, + }; +} + +/** 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 === "unconfigured") return CONTENT_CLIPS_NOT_CONFIGURED; + 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, +): 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.test.ts b/templates/clips/server/routes/api/thumbnail/[recordingId].get.test.ts index a8277342bcd..0f8e614a777 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/thumbnail/[recordingId].get.ts b/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts index 961c6c45da3..f1710a4d7b7 100644 --- a/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +++ b/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts @@ -29,6 +29,11 @@ import { } from "h3"; import { getDb, schema } from "../../../db/index.js"; +import { + readContentPlaybackGrant, + refusedContentPlayback, + runWithContentPlayback, +} from "../../../lib/content-playback-grant.js"; import { contextClipIdForFootage } from "../../../lib/context-footage.js"; import { isHeldForRedaction, @@ -356,7 +361,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 +372,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 +399,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 +459,27 @@ export default defineEventHandler(async (event: H3Event) => { return { error: "Missing recordingId" }; } + const contentGrant = await readContentPlaybackGrant(event, recordingId); + const refused = refusedContentPlayback(contentGrant); + if (refused) { + setResponseStatus(event, refused.status); + return refused.body; + } + 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 +526,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 +623,6 @@ export default defineEventHandler(async (event: H3Event) => { }); return defaultSocialImageResponse(); } - }, + }), ); }); 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 b24f0153735..6d0d57c708e 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/server/routes/api/video/[recordingId].get.ts b/templates/clips/server/routes/api/video/[recordingId].get.ts index 94718d344ea..e0ad6a51949 100644 --- a/templates/clips/server/routes/api/video/[recordingId].get.ts +++ b/templates/clips/server/routes/api/video/[recordingId].get.ts @@ -18,6 +18,11 @@ * - 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, 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. @@ -72,6 +77,11 @@ import { youtubeWatchUrl, } from "../../../../shared/youtube.js"; import { getDb, schema } from "../../../db/index.js"; +import { + readContentPlaybackGrant, + refusedContentPlayback, + 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 +343,26 @@ export default defineEventHandler(async (event: H3Event) => { return { error: "Missing recordingId" }; } + const contentGrant = await readContentPlaybackGrant(event, recordingId); + const refused = refusedContentPlayback(contentGrant); + if (refused) { + setResponseStatus(event, refused.status); + return refused.body; + } + 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 +383,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 { @@ -372,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"; @@ -444,6 +469,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 +727,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..9e2e94bc0c6 --- /dev/null +++ b/templates/clips/shared/content-clip-contract.ts @@ -0,0 +1,73 @@ +// The contract Agent-Native Content uses to call Clips server to server about +// 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"; + +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; + +/** + * 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"; + +/** + * 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";