Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions apps/cli/src/developers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -153,17 +153,17 @@ 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),
}

#[derive(Args)]
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)]
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions apps/web/__tests__/unit/developer-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
6 changes: 4 additions & 2 deletions apps/web/__tests__/unit/infra-console-round-trips.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
137 changes: 137 additions & 0 deletions apps/web/__tests__/unit/infra-embed-ancestors.test.ts
Original file line number Diff line number Diff line change
@@ -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);
},
);
});
Loading
Loading