@kitn.ai/chat
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
255 lines (232 loc) • 9.61 kB
text/typescript
// src/primitives/embed-providers.ts
// Pure provider resolution for <kc-embed>: map an EmbedCardData → an embeddable
// player URL + poster + iframe sandbox/allow. Covers youtube (privacy-enhanced
// youtube-nocookie), vimeo (dnt=1), and generic (https-only, ORIGIN-ALLOWLISTED).
// No network, no DOM. See docs/superpowers/specs/2026-06-13-kc-link-embed-cards-design.md.
import type { CardEnvelope } from './card-contract';
/** Media provider for an embed card. */
export type EmbedProvider = 'youtube' | 'vimeo' | 'generic';
/** Lazy media-embed payload (YouTube / Vimeo / generic player URL). */
export interface EmbedCardData {
/** Media provider. 'generic' frames `url` directly. */
provider: EmbedProvider;
/** Provider video id (youtube/vimeo) when not parsing from `url`. */
id?: string;
/** Full media/watch/embed URL. */
url?: string;
/** Accessible iframe title + poster label. */
title?: string;
/** Thumbnail before play; derived for youtube/vimeo when omitted. */
poster?: string;
/** Start offset, seconds. */
start?: number;
/** Player aspect ratio. Default '16:9'. */
aspectRatio?: '16:9' | '4:3' | '1:1' | '9:16';
}
/** The full envelope an agent/server emits for an embed card. */
export type EmbedCardEnvelope = CardEnvelope<'embed', EmbedCardData>;
/** The `type` discriminator for embed cards. */
export const EMBED_CARD_TYPE = 'embed' as const;
export interface ResolvedEmbed {
/** The iframe src loaded on play (already including autoplay/start params). */
embedUrl: string;
/** Poster/thumbnail to show before play (derived when not supplied). */
posterUrl?: string;
/** sandbox attribute for the player iframe. */
sandbox: string;
/** allow attribute (fullscreen, encrypted-media, picture-in-picture, …). */
allow: string;
}
// A provider player NEEDS allow-scripts + allow-same-origin (its OWN origin) to run.
// That is safe here because the framed origin is a KNOWN, trusted video provider on a
// DIFFERENT origin than the host — same-origin policy still isolates the host page from
// the provider. allow-popups(-to-escape-sandbox) lets "watch on YouTube" work. The
// `allow` attribute (not sandbox) governs autoplay/fullscreen/encrypted-media.
// Contrast <kc-artifact> (allow-scripts allow-forms, NO allow-same-origin) which frames
// arbitrary consumer HTML and so trusts nothing.
const PLAYER_SANDBOX =
'allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox';
const PLAYER_ALLOW =
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen';
// --- generic origin allowlist (security decision) -------------------------
//
// A `generic` embed frames an ARBITRARY https URL. An agent-supplied generic URL is a
// supply-chain risk, so it is REJECTED unless the app has explicitly allowlisted its
// origin. The allowlist defaults to EMPTY: out of the box, `generic` embeds are blocked.
const allowedGenericOrigins = new Set<string>();
/**
* App opt-in: allow `generic` embeds whose origin is in this list. Origins are
* normalized via the URL parser (scheme + host + port). Only https origins are
* accepted (a non-https origin is ignored). Call once at app startup.
*/
export function configureEmbedAllowlist(origins: string[]): void {
for (const o of origins) {
const origin = normalizeOrigin(o);
if (origin) allowedGenericOrigins.add(origin);
}
}
/** True when `url`'s origin has been allowlisted for `generic` embeds. */
export function isGenericOriginAllowed(url: string): boolean {
const origin = originOf(url);
return origin !== undefined && allowedGenericOrigins.has(origin);
}
/** Test-only: clear the generic allowlist so tests stay isolated. */
export function __resetEmbedAllowlistForTests(): void {
allowedGenericOrigins.clear();
}
/** Normalize an allowlist entry (a URL or bare origin) to a canonical https origin, or undefined. */
function normalizeOrigin(input: string): string | undefined {
const origin = originOf(input) ?? originOf(`https://${input}`);
if (!origin) return undefined;
return origin.startsWith('https://') ? origin : undefined;
}
/** The `scheme://host[:port]` origin of a URL, or undefined when unparseable. */
function originOf(url: string): string | undefined {
try {
return new URL(url).origin;
} catch {
return undefined;
}
}
// --- id parsing -----------------------------------------------------------
/** Extract a YouTube id from a watch / youtu.be / shorts / embed URL. */
export function parseYouTubeId(url: string): string | undefined {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return undefined;
}
const host = parsed.hostname.replace(/^www\./, '');
if (host === 'youtu.be') {
return cleanId(parsed.pathname.slice(1));
}
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtube-nocookie.com') {
const v = parsed.searchParams.get('v');
if (v) return cleanId(v);
// /shorts/<id>, /embed/<id>, /v/<id>
const m = parsed.pathname.match(/^\/(?:shorts|embed|v)\/([^/?#]+)/);
if (m) return cleanId(m[1]);
}
return undefined;
}
/** Extract a Vimeo id from a vimeo.com/<id> (or player.vimeo.com/video/<id>) URL. */
export function parseVimeoId(url: string): string | undefined {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return undefined;
}
const host = parsed.hostname.replace(/^www\./, '');
if (host !== 'vimeo.com' && host !== 'player.vimeo.com') return undefined;
const m = parsed.pathname.match(/(?:^|\/)(\d+)(?:\/|$)/);
return m ? m[1] : undefined;
}
/** A provider video id must be the [A-Za-z0-9_-] alphabet (matches the schema pattern). */
function cleanId(id: string): string | undefined {
return /^[A-Za-z0-9_-]+$/.test(id) ? id : undefined;
}
// --- resolution -----------------------------------------------------------
/**
* Resolve an EmbedCardData to an embeddable player URL + poster + sandbox/allow.
* Throws (with a human message) on a missing/invalid provider id, a non-https
* generic URL, or a generic origin not in the app allowlist — the card turns these
* into an inline error + an `error` event.
*/
export function resolveEmbed(data: EmbedCardData): ResolvedEmbed {
const start = data.start ? `&start=${data.start}` : '';
switch (data.provider) {
case 'youtube': {
const id = data.id ?? (data.url ? parseYouTubeId(data.url) : undefined);
if (!id) throw new Error('youtube embed: missing or unparseable id/url');
return {
embedUrl: `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0${start}`,
posterUrl: data.poster ?? `https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
sandbox: PLAYER_SANDBOX,
allow: PLAYER_ALLOW,
};
}
case 'vimeo': {
const id = data.id ?? (data.url ? parseVimeoId(data.url) : undefined);
if (!id) throw new Error('vimeo embed: missing or unparseable id/url');
return {
embedUrl: `https://player.vimeo.com/video/${id}?autoplay=1&dnt=1${
data.start ? `#t=${data.start}s` : ''
}`,
// Vimeo has no static thumbnail URL; rely on a supplied poster (or placeholder).
posterUrl: data.poster,
sandbox: PLAYER_SANDBOX,
allow: PLAYER_ALLOW,
};
}
case 'generic': {
if (!data.url) throw new Error('generic embed: missing url');
assertHttpsEmbeddable(data.url);
if (!isGenericOriginAllowed(data.url)) {
throw new Error(
`generic embed: origin not allowlisted — call configureEmbedAllowlist([...]) to permit ${
originOf(data.url) ?? data.url
}`,
);
}
return { embedUrl: data.url, posterUrl: data.poster, sandbox: PLAYER_SANDBOX, allow: PLAYER_ALLOW };
}
}
}
/** Reject non-https or javascript:/data: player URLs (defense in depth). */
function assertHttpsEmbeddable(url: string): void {
let protocol: string;
try {
protocol = new URL(url).protocol;
} catch {
throw new Error(`generic embed: invalid url "${url}"`);
}
if (protocol !== 'https:') {
throw new Error(`generic embed: only https player URLs are allowed (got ${protocol})`);
}
}
/**
* The canonical "watch on the provider" URL for the optional fallback affordance
* (emitted as `open`/target:'tab' when an embed is blocked). Returns `undefined`
* when there is nothing useful to link to.
*/
export function watchUrl(data: EmbedCardData): string | undefined {
switch (data.provider) {
case 'youtube': {
const id = data.id ?? (data.url ? parseYouTubeId(data.url) : undefined);
return id ? `https://www.youtube.com/watch?v=${id}` : data.url;
}
case 'vimeo': {
const id = data.id ?? (data.url ? parseVimeoId(data.url) : undefined);
return id ? `https://vimeo.com/${id}` : data.url;
}
case 'generic':
return data.url;
}
}
/** Human-readable provider label for the fallback affordance ("Open on YouTube"). */
export function providerLabel(provider: EmbedProvider): string {
switch (provider) {
case 'youtube':
return 'YouTube';
case 'vimeo':
return 'Vimeo';
case 'generic':
return 'site';
}
}
/** CSS aspect-ratio value for a card's aspectRatio (default 16:9). */
export function aspectRatioValue(aspectRatio: EmbedCardData['aspectRatio']): string {
switch (aspectRatio) {
case '4:3':
return '4 / 3';
case '1:1':
return '1 / 1';
case '9:16':
return '9 / 16';
case '16:9':
default:
return '16 / 9';
}
}