diff --git a/apps/cli/src/developers.rs b/apps/cli/src/developers.rs index b4247ea863..1b4215945b 100644 --- a/apps/cli/src/developers.rs +++ b/apps/cli/src/developers.rs @@ -14,11 +14,11 @@ use crate::{ const ABOUT: &str = "\ Cap Infra is Cap's usage-billed video and speech API. A developer app holds its videos, -transcripts, a stored list of origins, API keys and prepaid credits. +transcripts, its allowed sites, API keys and prepaid credits. -Origins added with `cap developers domains` are stored on the app and nothing checks them -today: uploads, embeds and playback work from any origin. To limit a browser upload to one -origin, set corsOrigin when your server creates the upload. +Only the sites added with `cap developers domains` can embed the app's player. With none +added, any site can. Share links, HLS, MP4 and poster URLs stay open to everyone. To limit +a browser upload to one origin, set corsOrigin when your server creates the upload. Amounts: 100,000 micro-credits are $1. Credit purchases are in cents, from 500 ($5) to 1000000 ($10,000). Durations are in seconds. @@ -55,7 +55,7 @@ enum DeveloperCommands { Update(DeveloperUpdateArgs), /// Delete an app and revoke its keys Delete(DeveloperDeleteArgs), - /// Add or remove origins on an app's stored list. Nothing checks the list today + /// Add or remove the sites that can embed an app's player Domains(DeveloperDomainsArgs), /// Rotate an app's API keys Keys(DeveloperKeysArgs), @@ -153,9 +153,9 @@ struct DeveloperDomainsArgs { #[derive(Subcommand)] enum DeveloperDomainCommands { - /// Store an origin on the app, for example https://app.example.com. It restricts nothing today + /// Allow a site to embed the player, for example https://app.example.com. Once one is added, only listed sites can Add(DeveloperDomainAddArgs), - /// Remove a stored origin by its id or by the origin itself + /// Remove an allowed site by its id or by the origin itself Remove(DeveloperDomainRemoveArgs), } @@ -163,7 +163,7 @@ enum DeveloperDomainCommands { struct DeveloperDomainAddArgs { /// App id from `cap developers list` app: String, - /// Origin to store, for example https://app.example.com + /// Site to allow, for example https://app.example.com domain: String, /// Confirm without a prompt #[arg(long)] diff --git a/apps/cli/src/mcp.rs b/apps/cli/src/mcp.rs index 003d2b682f..4acc5446f7 100644 --- a/apps/cli/src/mcp.rs +++ b/apps/cli/src/mcp.rs @@ -556,7 +556,7 @@ struct DeveloperAppDeleteInput { struct DeveloperDomainAddInput { /// App id from developer_apps_list app_id: String, - /// Origin to store, for example https://app.example.com + /// Site to allow, for example https://app.example.com domain: String, /// true only after the user confirmed the change confirmed: bool, @@ -3044,7 +3044,7 @@ impl CapMcpServer { #[tool( name = "developer_domain_add", - description = "Store an origin on a developer app after explicit user confirmation. Nothing checks stored origins today: uploads, embeds and playback work from any origin, so this restricts nothing. To limit a browser upload to one origin, set corsOrigin when creating the upload", + description = "Allow a site to embed a developer app's player, after explicit user confirmation. With no sites added any site can embed it; once one is added, only listed sites can. Share links, HLS, MP4 and poster URLs stay open to everyone. Only https sites, or localhost for testing, are accepted", annotations( read_only_hint = false, destructive_hint = false, @@ -3078,7 +3078,7 @@ impl CapMcpServer { #[tool( name = "developer_domain_remove", - description = "Remove a stored origin from a developer app after explicit user confirmation. Nothing checks stored origins today, so removing one blocks nothing. Call with dry_run=true first to show the user which origin would be removed", + description = "Remove a site from the ones allowed to embed a developer app's player, after explicit user confirmation. Removing the last one lets any site embed it again. Call with dry_run=true first to show the user which site would be removed", annotations( read_only_hint = false, destructive_hint = true, diff --git a/apps/web/__tests__/unit/developer-actions.test.ts b/apps/web/__tests__/unit/developer-actions.test.ts index f64bf130d3..503d93c165 100644 --- a/apps/web/__tests__/unit/developer-actions.test.ts +++ b/apps/web/__tests__/unit/developer-actions.test.ts @@ -521,6 +521,18 @@ describe("addDeveloperDomain", () => { expect(result).toEqual({ success: true, domain: "http://localhost:3000" }); }); + it("refuses a plain http site, which cannot embed the player", async () => { + mockGetCurrentUser.mockResolvedValue(mockUser); + mockDb.limit.mockResolvedValueOnce([mockApp]); + const result = await addDeveloperDomain("app-456", "http://myshop.com"); + expect(result).toEqual({ + success: false, + message: + "Only https sites can embed your player. Enter one like https://myshop.com.", + }); + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + it("normalizes to lowercase", async () => { mockGetCurrentUser.mockResolvedValue(mockUser); mockDb.limit.mockResolvedValueOnce([mockApp]); diff --git a/apps/web/__tests__/unit/infra-console-round-trips.test.ts b/apps/web/__tests__/unit/infra-console-round-trips.test.ts index ab2a605e2b..e91c11c7d4 100644 --- a/apps/web/__tests__/unit/infra-console-round-trips.test.ts +++ b/apps/web/__tests__/unit/infra-console-round-trips.test.ts @@ -245,13 +245,15 @@ describe("the console loads only what a page shows", () => { expect(page.props).toEqual({ appId: "app-1", secretKeyPrefix: null }); }); - it("counts videos only on the settings page, and only for that app", async () => { + it("counts videos and lists allowed sites only on the settings page, and only for that app", async () => { const page = (await InfraSettingsPage(params)) as Element; - expect(state.queries).toHaveLength(3); + expect(state.queries).toHaveLength(4); const [count] = reads("developer_videos"); expect(count).toContain("count(*)"); expect(count).toContain("`appId` = ?"); + const [domains] = reads("developer_app_domains"); + expect(domains).toContain("`appId` = ?"); expect(page.props).toMatchObject({ appId: "app-1", name: "First app", diff --git a/apps/web/__tests__/unit/infra-embed-ancestors.test.ts b/apps/web/__tests__/unit/infra-embed-ancestors.test.ts new file mode 100644 index 0000000000..c17fd5ed8f --- /dev/null +++ b/apps/web/__tests__/unit/infra-embed-ancestors.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { normalizeEmbedOrigin } from "@/lib/infra-domain"; +import { + embedFrameAncestors, + embedFrameSource, + embedPlaybackId, + withFrameAncestors, +} from "@/lib/infra-embed-ancestors"; + +describe("embedFrameAncestors", () => { + it("sends nothing for an empty list", () => { + expect(embedFrameAncestors([])).toBeNull(); + }); + + it("allows one site plus the hosted origin", () => { + expect(embedFrameAncestors(["https://myshop.com"])).toBe( + "frame-ancestors 'self' https://myshop.com", + ); + }); + + it("allows several sites, once each", () => { + expect( + embedFrameAncestors([ + "https://myshop.com", + "https://www.myshop.com", + "https://app.example.com:8443", + "https://myshop.com", + ]), + ).toBe( + "frame-ancestors 'self' https://myshop.com https://www.myshop.com https://app.example.com:8443", + ); + }); + + it("keeps localhost over http for testing", () => { + expect( + embedFrameAncestors(["http://localhost:3000", "http://127.0.0.1:8080"]), + ).toBe( + "frame-ancestors 'self' http://localhost:3000 http://127.0.0.1:8080", + ); + }); + + it.each([ + "*.myshop.com", + "https://*.myshop.com", + "*", + "http://myshop.com", + "https://myshop.com/path", + "https://myshop.com/", + "myshop.com", + "https://myshop.com; script-src *", + "https://myshop.com https://evil.example", + "https://user:pass@myshop.com", + "javascript:alert(1)", + "", + "'self'", + "'unsafe-inline'", + "data:", + "HTTPS://MYSHOP.COM", + ])("skips the stored entry %j", (entry) => { + expect(embedFrameSource(entry)).toBeNull(); + expect(embedFrameAncestors([entry, "https://myshop.com"])).toBe( + "frame-ancestors 'self' https://myshop.com", + ); + }); + + it("keeps a set list restrictive when no entry is usable", () => { + expect(embedFrameAncestors(["http://myshop.com", "*.myshop.com"])).toBe( + "frame-ancestors 'self'", + ); + }); +}); + +describe("withFrameAncestors", () => { + const ancestors = "frame-ancestors 'self' https://myshop.com"; + + it("sets the directive when there is no policy", () => { + expect(withFrameAncestors(null, ancestors)).toBe(ancestors); + }); + + it("keeps the other directives of an existing policy", () => { + expect( + withFrameAncestors("default-src 'self'; img-src * data:", ancestors), + ).toBe(`default-src 'self'; img-src * data:; ${ancestors}`); + }); + + it("replaces an existing frame-ancestors directive", () => { + expect( + withFrameAncestors("default-src 'self'; Frame-Ancestors *; ", ancestors), + ).toBe(`default-src 'self'; ${ancestors}`); + }); +}); + +describe("embedPlaybackId", () => { + it.each([ + ["/dev/playback-id-0123456789ab/embed", "playback-id-0123456789ab"], + ["/dev/%70layback-id-0123456789ab/embed", "playback-id-0123456789ab"], + ["/Dev/playback-id-0123456789ab/Embed", "playback-id-0123456789ab"], + ["/DEV/playback-id-0123456789ab/EMBED", "playback-id-0123456789ab"], + ["/dev/playback%2Did%2D0123456789ab/embed", "playback-id-0123456789ab"], + ])("finds the playback id in %s", (path, id) => { + expect(embedPlaybackId(path)).toBe(id); + }); + + it.each([ + "/dev/playback-id-0123456789ab", + "/dev/playback-id-0123456789ab/embed/", + "/dev/playback-id-0123456789ab/embed/x", + "//dev/playback-id-0123456789ab/embed", + "/dev/short/embed", + "/dev/playback%2Fid-0123456789ab/embed", + "/dev/%E0%A4%A/embed", + "/s/playback-id-0123456789ab/embed", + "/embed/playback-id-0123456789ab", + "/%64ev/playback-id-0123456789ab/embed", + "/dev/playback-id-0123456789ab/%65mbed", + ])("ignores %s", (path) => { + expect(embedPlaybackId(path)).toBeNull(); + }); +}); + +describe("normalizeEmbedOrigin", () => { + it.each([ + ["myshop.com", "https://myshop.com"], + ["https://myshop.com/pricing", "https://myshop.com"], + ["localhost:3000", "http://localhost:3000"], + ["http://127.0.0.1:8080", "http://127.0.0.1:8080"], + ])("accepts %s as %s", (input, origin) => { + expect(normalizeEmbedOrigin(input)).toEqual({ ok: true, origin }); + }); + + it.each(["http://myshop.com", "*.myshop.com", "ftp://myshop.com", ""])( + "refuses %j", + (input) => { + expect(normalizeEmbedOrigin(input).ok).toBe(false); + }, + ); +}); diff --git a/apps/web/__tests__/unit/proxy-embed-ancestors.test.ts b/apps/web/__tests__/unit/proxy-embed-ancestors.test.ts new file mode 100644 index 0000000000..2dfec5f3b5 --- /dev/null +++ b/apps/web/__tests__/unit/proxy-embed-ancestors.test.ts @@ -0,0 +1,188 @@ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearEmbedOriginsCache, + EMBED_ORIGINS_CACHE_MS, +} from "@/lib/developer-embed-origins"; +import { proxy } from "../../proxy"; + +const state = vi.hoisted(() => ({ + domains: [] as string[], + fail: false, + lookups: 0, +})); + +vi.mock("@cap/database", () => ({ + db: () => ({ + select: () => ({ + from: () => ({ + where: async () => [], + innerJoin: () => ({ + where: async () => { + state.lookups += 1; + if (state.fail) throw new Error("database unavailable"); + return state.domains.map((domain) => ({ domain })); + }, + }), + }), + }), + }), +})); + +vi.mock("@cap/database/schema", () => ({ + organizations: {}, + developerAppDomains: {}, + developerVideos: {}, +})); + +vi.mock("drizzle-orm", () => ({ eq: () => undefined })); + +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => ({ + WEB_URL: "https://cap.so", + VERCEL_URL_HOST: undefined, + VERCEL_BRANCH_URL_HOST: undefined, + VERCEL_PROJECT_PRODUCTION_URL_HOST: undefined, + }), +})); + +const PLAYBACK_ID = "playback-id-0123456789ab"; +const EMBED = `https://cap.so/dev/${PLAYBACK_ID}/embed`; + +const send = (url: string, headers: Record = {}) => + proxy(new NextRequest(url, { headers })); + +const headerList = (response: Response) => [...response.headers.entries()]; + +beforeEach(() => { + state.domains = []; + state.fail = false; + state.lookups = 0; + clearEmbedOriginsCache(); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("embed frame-ancestors", () => { + it("leaves the embed response untouched when the list is empty", async () => { + const embed = await send(`${EMBED}?autoplay=1`); + const hosted = await send(`https://cap.so/dev/${PLAYBACK_ID}`); + expect(state.lookups).toBe(1); + expect(embed.status).toBe(200); + expect(headerList(embed)).toEqual(headerList(hosted)); + expect(headerList(embed)).toEqual([["x-middleware-next", "1"]]); + }); + + it("allows one site", async () => { + state.domains = ["https://myshop.com"]; + const response = await send(EMBED); + expect(response.status).toBe(200); + expect(response.headers.get("content-security-policy")).toBe( + "frame-ancestors 'self' https://myshop.com", + ); + expect(response.headers.get("x-frame-options")).toBeNull(); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + + it("allows several sites and skips stored entries it cannot honour", async () => { + state.domains = [ + "https://myshop.com", + "*.myshop.com", + "http://myshop.com", + "https://docs.myshop.com", + ]; + const response = await send(EMBED); + expect(response.headers.get("content-security-policy")).toBe( + "frame-ancestors 'self' https://myshop.com https://docs.myshop.com", + ); + }); + + it.each([ + "/dev/%70layback-id-0123456789ab/embed", + "/Dev/playback-id-0123456789ab/Embed", + "/DEV/playback-id-0123456789ab/EMBED?autoplay=1", + ])("restricts the same embed at %s", async (path) => { + state.domains = ["https://myshop.com"]; + const response = await send(`https://cap.so${path}`); + expect(state.lookups).toBe(1); + expect(response.headers.get("content-security-policy")).toBe( + "frame-ancestors 'self' https://myshop.com", + ); + }); + + it("leaves a differently cased embed untouched when the list is empty", async () => { + const main = await send( + `https://cap.so/Dev/${PLAYBACK_ID}/Embed?dub_id=click_123`, + ); + expect(state.lookups).toBe(1); + expect(main.headers.get("content-security-policy")).toBeNull(); + expect(main.headers.get("set-cookie")).toContain("dub_id=click_123"); + }); + + it("sends one policy header for the RSC request too", async () => { + state.domains = ["https://myshop.com"]; + const response = await send(EMBED, { rsc: "1" }); + const policies = [...response.headers.entries()].filter( + ([name]) => name === "content-security-policy", + ); + expect(policies).toEqual([ + ["content-security-policy", "frame-ancestors 'self' https://myshop.com"], + ]); + }); + + it("serves the embed as before when the list cannot be read", async () => { + state.domains = ["https://myshop.com"]; + state.fail = true; + const response = await send(EMBED); + expect(response.status).toBe(200); + expect(headerList(response)).toEqual([["x-middleware-next", "1"]]); + }); + + it("reads an app's list once a minute per instance", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + state.domains = ["https://myshop.com"]; + await send(EMBED); + state.domains = []; + const cached = await send(EMBED); + expect(state.lookups).toBe(1); + expect(cached.headers.get("content-security-policy")).toBe( + "frame-ancestors 'self' https://myshop.com", + ); + + vi.setSystemTime(Date.now() + EMBED_ORIGINS_CACHE_MS + 1); + const fresh = await send(EMBED); + expect(state.lookups).toBe(2); + expect(fresh.headers.get("content-security-policy")).toBeNull(); + }); + + it("does not keep a failed read", async () => { + state.domains = ["https://myshop.com"]; + state.fail = true; + await send(EMBED); + state.fail = false; + const response = await send(EMBED); + expect(state.lookups).toBe(2); + expect(response.headers.get("content-security-policy")).toBe( + "frame-ancestors 'self' https://myshop.com", + ); + }); + + it.each([ + `/dev/${PLAYBACK_ID}`, + `/dev/${PLAYBACK_ID}/embed/extra`, + "/dev/short/embed", + `/s/${PLAYBACK_ID}`, + `/embed/${PLAYBACK_ID}`, + "/", + "/pricing", + ])("never restricts framing of %s", async (path) => { + state.domains = ["https://myshop.com"]; + const response = await send(`https://cap.so${path}`); + expect(state.lookups).toBe(0); + expect(response.headers.get("content-security-policy")).toBeNull(); + }); +}); diff --git a/apps/web/__tests__/unit/proxy-frame-headers.test.ts b/apps/web/__tests__/unit/proxy-frame-headers.test.ts index e2050cffe0..5954a2451e 100644 --- a/apps/web/__tests__/unit/proxy-frame-headers.test.ts +++ b/apps/web/__tests__/unit/proxy-frame-headers.test.ts @@ -4,11 +4,20 @@ import { proxy } from "../../proxy"; vi.mock("@cap/database", () => ({ db: () => ({ - select: () => ({ from: () => ({ where: async () => [] }) }), + select: () => ({ + from: () => ({ + where: async () => [], + innerJoin: () => ({ where: async () => [] }), + }), + }), }), })); -vi.mock("@cap/database/schema", () => ({ organizations: {} })); +vi.mock("@cap/database/schema", () => ({ + organizations: {}, + developerAppDomains: {}, + developerVideos: {}, +})); vi.mock("@cap/env", () => ({ buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, diff --git a/apps/web/actions/developers/add-domain.ts b/apps/web/actions/developers/add-domain.ts index 25b526c0d3..cbc61b2202 100644 --- a/apps/web/actions/developers/add-domain.ts +++ b/apps/web/actions/developers/add-domain.ts @@ -8,13 +8,13 @@ import { and, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { canUseInfra } from "@/lib/infra-access"; import { actionFailure } from "@/lib/infra-action-result"; -import { normalizeAllowedOrigin } from "@/lib/infra-domain"; +import { normalizeEmbedOrigin } from "@/lib/infra-domain"; export async function addDeveloperDomain(appId: string, domain: string) { const user = await getCurrentUser(); if (!user || !canUseInfra(user.email)) throw new Error("Unauthorized"); - const allowed = normalizeAllowedOrigin(domain); + const allowed = normalizeEmbedOrigin(domain); if (!allowed.ok) return actionFailure(allowed.message); const { origin } = allowed; diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/settings/app-settings-client.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/settings/app-settings-client.tsx index ca849ff1bd..5e3eaf81a4 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/settings/app-settings-client.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/settings/app-settings-client.tsx @@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { useId, useState } from "react"; +import { type ReactNode, useId, useState } from "react"; import { toast } from "sonner"; import { deleteDeveloperApp } from "@/actions/developers/delete-app"; import { updateDeveloperApp } from "@/actions/developers/update-app"; @@ -25,12 +25,14 @@ export function AppSettingsClient({ environment: initialEnvironment, balanceMicroCredits, videoCount, + domains, }: { appId: string; name: string; environment: Environment; balanceMicroCredits: number; videoCount: number; + domains: ReactNode; }) { const router = useRouter(); const nameId = useId(); @@ -107,6 +109,8 @@ export function AppSettingsClient({ + {domains} + (null); + + const add = useEffectMutation({ + mutationFn: (origin: string) => + runAction(() => addDeveloperDomain(appId, origin)), + onSuccess: (result) => { + setNewDomain(""); + toast.success(`${result.domain} added`); + router.refresh(); + }, + onError: (cause) => + setProblem(actionMessage(cause, "Could not add the site. Try again.")), + }); + + return ( + +
{ + e.preventDefault(); + if (!newDomain.trim() || add.isPending) return; + const allowed = normalizeEmbedOrigin(newDomain); + if (!allowed.ok) { + setProblem(allowed.message); + return; + } + setProblem(null); + add.mutate(allowed.origin); + }} + > + + { + setNewDomain(e.target.value); + setProblem(null); + }} + placeholder="myshop.com" + autoComplete="off" + autoCapitalize="none" + spellCheck={false} + inputMode="url" + aria-invalid={problem ? true : undefined} + aria-describedby={problem ? problemId : undefined} + className="min-w-[12rem] flex-1" + /> + +
+ {problem && ( + + )} + {domains.length > 0 ? ( +
    + {domains.map((d) => ( + + ))} +
+ ) : ( +

+ Any site can embed your player. +

+ )} +
+ ); +} diff --git a/apps/web/app/(infra)/dashboard/infra/[appId]/settings/page.tsx b/apps/web/app/(infra)/dashboard/infra/[appId]/settings/page.tsx index 940302a040..10208dfaf8 100644 --- a/apps/web/app/(infra)/dashboard/infra/[appId]/settings/page.tsx +++ b/apps/web/app/(infra)/dashboard/infra/[appId]/settings/page.tsx @@ -1,9 +1,11 @@ import { + getInfraAppDomains, getInfraVideoCount, infraPageMetadata, requireInfraApp, } from "../../infra-data"; import { AppSettingsClient } from "./app-settings-client"; +import { DomainsSection } from "./domains-section"; export const generateMetadata = infraPageMetadata("Settings"); @@ -14,7 +16,10 @@ export default async function InfraSettingsPage({ }) { const { appId } = await params; const { app } = await requireInfraApp(appId); - const videoCount = await getInfraVideoCount(app.id); + const [videoCount, domains] = await Promise.all([ + getInfraVideoCount(app.id), + getInfraAppDomains(app.id), + ]); return ( } /> ); } diff --git a/apps/web/app/(infra)/dashboard/infra/_components/domain-row.tsx b/apps/web/app/(infra)/dashboard/infra/_components/domain-row.tsx new file mode 100644 index 0000000000..1fd346147d --- /dev/null +++ b/apps/web/app/(infra)/dashboard/infra/_components/domain-row.tsx @@ -0,0 +1,57 @@ +"use client"; + +import clsx from "clsx"; +import { X } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { removeDeveloperDomain } from "@/actions/developers/remove-domain"; +import { useEffectMutation } from "@/lib/EffectRuntime"; +import { runAction } from "./run-action"; +import { FOCUS, MONO } from "./ui"; + +export function DomainRow({ + appId, + domainId, + domain, +}: { + appId: string; + domainId: string; + domain: string; +}) { + const router = useRouter(); + const removeMutation = useEffectMutation({ + mutationFn: () => runAction(() => removeDeveloperDomain(appId, domainId)), + onSuccess: () => { + toast.success(`${domain} removed`); + router.refresh(); + }, + onError: () => { + toast.error("Could not remove the site"); + }, + }); + + return ( +
  • + + {domain} + + +
  • + ); +} diff --git a/apps/web/app/(infra)/dashboard/infra/infra-data.ts b/apps/web/app/(infra)/dashboard/infra/infra-data.ts index 359fcd6788..0f47e35268 100644 --- a/apps/web/app/(infra)/dashboard/infra/infra-data.ts +++ b/apps/web/app/(infra)/dashboard/infra/infra-data.ts @@ -6,6 +6,7 @@ import { import { developerVideoTakenDownSql } from "@cap/database/developer-video-takedown"; import { developerApiKeys, + developerAppDomains, developerApps, developerCreditAccounts, developerCreditTransactions, @@ -99,6 +100,23 @@ const getInfraCreditAccount = cache( }, ); +export type InfraAppDomain = Pick< + typeof developerAppDomains.$inferSelect, + "id" | "domain" +>; + +export const getInfraAppDomains = cache( + (appId: string): Promise => + db() + .select({ + id: developerAppDomains.id, + domain: developerAppDomains.domain, + }) + .from(developerAppDomains) + .where(eq(developerAppDomains.appId, appId)) + .orderBy(developerAppDomains.domain), +); + export const getInfraApiKeys = cache( (appId: string): Promise => db() diff --git a/apps/web/app/api/v1/[...route]/route.ts b/apps/web/app/api/v1/[...route]/route.ts index 69938ad5fb..4ec2a3cc0b 100644 --- a/apps/web/app/api/v1/[...route]/route.ts +++ b/apps/web/app/api/v1/[...route]/route.ts @@ -168,7 +168,7 @@ import { CREDIT_CHECKOUT_METADATA_TYPE, creditCheckoutSessionParams, } from "@/lib/infra-credit-checkout"; -import { normalizeAllowedOrigin } from "@/lib/infra-domain"; +import { normalizeEmbedOrigin } from "@/lib/infra-domain"; import { settleFirstMonthAtDelete, settleFirstMonthsForDeletedApp, @@ -4204,7 +4204,7 @@ const AgentManagementHandlersLive = HttpApiBuilder.group( yield* requireAgentWrites(requestId); const principal = yield* Agent.AgentPrincipal; yield* requireScope(principal, "developer:write", requestId); - const allowed = normalizeAllowedOrigin(payload.domain); + const allowed = normalizeEmbedOrigin(payload.domain); if (!allowed.ok) { return yield* badRequest(requestId, allowed.message); } diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index e59f668b45..53975f9948 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -1492,7 +1492,7 @@ Every video has a hosted player, returned as `shareUrl` and `embedUrl` when you The player sets no cookies. Each time a page with the player loads, Cap records one anonymous view with the page's address, so plays can be counted. - `shareUrl` is a page with the player and the video's name. Send it to a viewer as a link. -- `embedUrl` is the player alone, filling whatever frame you give it. Any site can load it in an iframe: +- `embedUrl` is the player alone, filling whatever frame you give it. Any site can load it in an iframe, unless you limit it to your own sites with [Allowed Sites](#allowed-sites): ```html