Files
pig/packages/core/src/learn.ts
T
karti 19dd30acbe
CI / verify (push) Successful in 3m23s
CI / publish (push) Has been skipped
Give the Learn cards a real frame instead of a gradient
The preview cards led with a generated gradient. It was a deliberate fallback —
nothing renders a frame of a Cap embed without loading the embed, and loading
nine embeds to decorate a grid is how a page becomes unusable on a phone — but
for videos PIG serves itself the frame is right there in the file.

The poster is named after the VIDEO's content hash, not its own:
`overview.4d4581ae.mp4` -> `overview.4d4581ae.jpg`. Re-rendering a clip changes
both names together, so a thumbnail cannot outlive what it claims to show. It
needs no schema column and no manifest entry, because the name is derivable.

`learnPoster.sh` cuts the frame with `thumbnail=90` starting four seconds in
rather than taking frame 0: the first frame of a Playwright capture is often
mid-paint, and a poster of a half-rendered page is worse than no poster.

The resolver ASSERTS the poster rather than verifying it — @pig/core is pure and
has no filesystem. That is safe in both directions: a missing poster 404s, which
`<video poster>` renders exactly as it renders no poster, and which the card
falls back from via onError. Claiming a poster that is absent is free; omitting
one that exists would cost every card its thumbnail.

Cap-hosted rows are unchanged and still get the gradient, verified by there
being exactly five <img> elements on a page with nine resources.

Also widens the media allowlist to jpg/webp. The filename pattern, the
traversal rules and the symlink check are untouched and still cover them,
because extension is the only axis that changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 17:53:30 -07:00

543 lines
23 KiB
TypeScript

/**
* Learn — the two tracks, and the host allowlist that turns a pasted link into
* an iframe source.
*
* **Two tracks, and they are not the same kind of thing.** `supply` and
* `demand` are CONCEPT material: how this market actually works, taught to the
* GTM team that runs that side. `platform` is PIG itself — onboarding, feature
* walkthroughs, demos. The distinction is load-bearing rather than cosmetic,
* because the access code unlocks exactly one of them.
*
* **Only the platform track may be visible to a code-holder.** Someone holding
* the share code has no account and no principal; they may see how the product
* works, because that is a sales asset. They may not see how we source and
* price capacity. This predicate is enforced three times on purpose — here, in
* the API write path, and in a database CHECK constraint — because a concept
* video becoming anon-visible through a mistake in a form is the failure that
* matters, and a UI-only rule does not survive an API caller.
*
* **The allowlist is the whole XSS surface of the feature.** A learn resource
* is a URL somebody pasted, and it ends up as an `iframe src`. So a pasted URL
* is never stored as a source and never rendered as one: it is resolved
* through the table below into a *provider* and an *external id*, and every
* embed URL is rebuilt from a hardcoded template and a pattern-checked id.
* Anything the table does not match is rejected at the write path, so a row
* that cannot be rendered safely cannot exist.
*
* Adding a provider is one row here plus one host in the proxy's `frame-src`
* (see `LEARN_FRAME_SRC_HOSTS`). Do not add a row whose URL shape has not been
* checked against the running service — the id extraction is what decides
* whether a hostile path becomes a trusted embed.
*
* **One provider is not an iframe at all.** `pig` is video PIG serves itself,
* from `/media/learn/…` on this origin, rendered in a native `<video>`. It
* goes through the identical funnel — a pasted string is resolved to a
* provider and an id, and every source is rebuilt from a template — but its
* resolved embed carries `kind: 'video'`, so a caller must branch rather than
* assume an iframe. That is why `LearnEmbed` is a discriminated union: the
* alternative, an `isVideo` boolean beside a bare `src`, lets a caller render
* an iframe from a video row by forgetting one `if`.
*/
// ---------------------------------------------------------------------------
// Tracks and visibility
// ---------------------------------------------------------------------------
export const LEARN_TRACKS = ['supply', 'demand', 'platform'] as const;
export type LearnTrack = (typeof LEARN_TRACKS)[number];
/** The tracks that teach the market rather than the product. Members only. */
export const LEARN_CONCEPT_TRACKS = ['supply', 'demand'] as const satisfies readonly LearnTrack[];
export type LearnConceptTrack = (typeof LEARN_CONCEPT_TRACKS)[number];
/** The one track a code-holder may reach. Named once; referenced everywhere. */
export const LEARN_CODE_TRACK = 'platform' as const satisfies LearnTrack;
export const LEARN_TRACK_LABELS: Record<LearnTrack, string> = {
supply: 'Supply',
demand: 'Demand',
platform: 'Platform',
};
export const LEARN_TRACK_DESCRIPTIONS: Record<LearnTrack, string> = {
supply: 'How capacity is sourced, qualified, priced and contracted.',
demand: 'How compute is sold, renewed and expanded.',
platform: 'Onboarding, feature walkthroughs and product demos of PIG itself.',
};
export const LEARN_VISIBILITIES = ['members', 'code'] as const;
export type LearnVisibility = (typeof LEARN_VISIBILITIES)[number];
export function isLearnTrack(value: string): value is LearnTrack {
return (LEARN_TRACKS as readonly string[]).includes(value);
}
export function isLearnVisibility(value: string): value is LearnVisibility {
return (LEARN_VISIBILITIES as readonly string[]).includes(value);
}
/**
* May this track carry this visibility?
*
* Phrased as a predicate over the pair rather than "is this track public", so
* that the check reads the same in the write path and in the CHECK constraint
* and neither can drift into asking a subtly different question.
*/
export function learnVisibilityPermitted(track: LearnTrack, visibility: LearnVisibility): boolean {
return visibility !== 'code' || track === LEARN_CODE_TRACK;
}
// ---------------------------------------------------------------------------
// The provider allowlist
// ---------------------------------------------------------------------------
export const LEARN_PROVIDERS = ['cap', 'loom', 'youtube_nocookie', 'pig'] as const;
export type LearnProvider = (typeof LEARN_PROVIDERS)[number];
/**
* What a caller must render, and the discriminator that decides which.
*
* `kind` is the contract between this package and the web app. A union rather
* than an optional flag because the two branches need different elements and
* different sandboxing: an iframe is a third-party document on someone else's
* origin, a video is bytes we serve. Conflating them is how a same-origin
* `<iframe src>` ends up rendering a file the browser will happily execute
* scripts from.
*/
export type LearnEmbed =
| { kind: 'iframe'; src: string }
/**
* `poster` is part of the shape because a `<video>` with no poster is a
* black rectangle until it buffers. Nothing sets it yet — a poster is a
* second content-addressed file, and inventing its path here would produce
* a 404 on every card.
*/
| { kind: 'video'; src: string; poster?: string };
export type LearnEmbedKind = LearnEmbed['kind'];
// ---------------------------------------------------------------------------
// Self-hosted media
// ---------------------------------------------------------------------------
/**
* The one prefix a self-hosted source may live under. Fixed, not configurable:
* a configurable prefix is a prefix that can be set to `/` by an operator who
* has not thought about it, and the whole safety argument here is that the
* path is a constant with one pattern-checked segment after it.
*/
export const LEARN_MEDIA_PATH_PREFIX = '/media/learn/';
/** Extension → Content-Type. One table, so the allowlist and the served type cannot drift. */
export const LEARN_MEDIA_CONTENT_TYPES: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
m4v: 'video/x-m4v',
// Poster frames. Served from the same directory and the same route as the
// video they were cut from — see `learnPosterFilename`.
jpg: 'image/jpeg',
webp: 'image/webp',
};
/**
* The filename charset, anchored, with the dot rules written into the pattern
* rather than checked afterwards.
*
* Every dot must be followed by at least one ordinary character, so `..`
* cannot appear at all — traversal is impossible by construction rather than
* by a `includes('..')` guard someone can later delete. No slash, no scheme,
* no percent-encoding, no space. The lookahead caps the length, because an
* unbounded filename is an unbounded path to `stat`.
*/
export const LEARN_MEDIA_FILENAME_PATTERN =
/^(?=.{1,120}$)[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*\.(?:mp4|webm|m4v|jpg|webp)$/;
export function isLearnMediaFilename(value: string): boolean {
return LEARN_MEDIA_FILENAME_PATTERN.test(value);
}
/** The public path for a filename that has already passed the pattern. */
export function learnMediaPath(filename: string): string {
return `${LEARN_MEDIA_PATH_PREFIX}${filename}`;
}
/**
* The poster that belongs to a video file.
*
* Derived by swapping the extension, which keeps the VIDEO's content hash in
* the poster's name: `overview.4d4581ae.mp4` -> `overview.4d4581ae.jpg`.
* Re-rendering a video changes both names together, so a poster can never go
* stale against the clip it claims to show — which a separately hashed or
* hand-named thumbnail would eventually do.
*
* Returns null for a filename that is not a video, so a poster cannot acquire
* a poster of its own.
*/
export function learnPosterFilename(videoFilename: string): string | null {
const dot = videoFilename.lastIndexOf('.');
if (dot <= 0) return null;
const extension = videoFilename.slice(dot + 1).toLowerCase();
if (!LEARN_MEDIA_CONTENT_TYPES[extension]?.startsWith('video/')) return null;
return `${videoFilename.slice(0, dot)}.jpg`;
}
/** The Content-Type for a validated filename, or null if it has no known one. */
export function learnMediaContentType(filename: string): string | null {
const extension = filename.slice(filename.lastIndexOf('.') + 1).toLowerCase();
return LEARN_MEDIA_CONTENT_TYPES[extension] ?? null;
}
export interface LearnProviderDefinition {
provider: LearnProvider;
label: string;
/** Which element the resolved embed becomes. See `LearnEmbed`. */
kind: LearnEmbedKind;
/**
* Disabled providers are inert: a pasted link matching one is rejected, so a
* row can never be created and nothing can ever be framed from it. They are
* listed so that turning one on is a flag and a CSP host rather than a
* design exercise under time pressure.
*/
enabled: boolean;
/**
* Exact hostnames. Never a suffix match — `evil-loom.com` ends in loom.com.
* Empty for a self-hosted provider, which is matched on the path prefix and
* must therefore never be reachable from the host lookup.
*/
hosts: readonly string[];
/** Path prefixes whose NEXT segment is the id, and nothing after it. */
idSegmentPrefixes: readonly string[];
/** The id charset, anchored. Everything downstream trusts this. */
idPattern: RegExp;
embed(externalId: string): string;
watch(externalId: string): string;
/**
* What the proxy's `frame-src` needs before this provider can render, or
* null for a provider that is never framed. A native `<video>` on this
* origin needs no `frame-src` entry at all — it is covered by
* `default-src 'self'` — which is a real part of why self-hosting was
* chosen over another embed host.
*/
frameSrc: string | null;
}
/**
* Cap (cap.so) self-hosted at video.karti.ai.
*
* Verified against the running instance rather than assumed: the Next.js app
* carries `app/s/[videoId]` and `app/embed/[videoId]`, both of which answer 404
* for an unknown id — which is how we know the routes exist at all, since
* every unrouted path there answers 307 instead. Ids observed in that
* instance's database are lowercase alphanumeric, 15 characters; the pattern
* is deliberately a little wider than that and no wider.
*
* HyperFrames (app.heygen.com) is the next one wanted. It is absent rather
* than disabled because its share/embed path shape has not been checked
* against the live service, and guessing that is precisely how an id
* extraction ends up accepting a path it should not.
*/
export const LEARN_PROVIDER_TABLE: readonly LearnProviderDefinition[] = [
{
provider: 'cap',
label: 'Cap',
kind: 'iframe',
enabled: true,
hosts: ['video.karti.ai'],
idSegmentPrefixes: ['s', 'embed'],
idPattern: /^[a-z0-9]{8,32}$/,
embed: (id) => `https://video.karti.ai/embed/${id}`,
watch: (id) => `https://video.karti.ai/s/${id}`,
frameSrc: 'https://video.karti.ai',
},
{
provider: 'loom',
label: 'Loom',
kind: 'iframe',
enabled: false,
hosts: ['www.loom.com', 'loom.com'],
idSegmentPrefixes: ['share', 'embed'],
idPattern: /^[a-f0-9]{16,64}$/,
embed: (id) => `https://www.loom.com/embed/${id}`,
watch: (id) => `https://www.loom.com/share/${id}`,
frameSrc: 'https://www.loom.com',
},
{
provider: 'youtube_nocookie',
label: 'YouTube',
kind: 'iframe',
enabled: false,
// The nocookie host only, never youtube.com: the point of listing YouTube
// at all is the privacy-preserving embed, and accepting the ordinary host
// would quietly reintroduce the tracking this avoids.
hosts: ['www.youtube-nocookie.com', 'youtube-nocookie.com'],
idSegmentPrefixes: ['embed'],
idPattern: /^[A-Za-z0-9_-]{11}$/,
embed: (id) => `https://www.youtube-nocookie.com/embed/${id}`,
watch: (id) => `https://www.youtube-nocookie.com/embed/${id}`,
frameSrc: 'https://www.youtube-nocookie.com',
},
/**
* PIG itself. The file lives on disk beside the application and is served
* from `/media/learn/…` by this same process.
*
* `hosts` is empty on purpose. This provider is matched on the path prefix,
* never on a hostname, so an absolute URL naming any host — including our
* own public URL — resolves as `unknown_host` and cannot become a video
* source. That keeps exactly one accept shape to reason about.
*
* `idSegmentPrefixes` is empty for the same reason: the generic two-segment
* extractor is not used here, because the id is a filename with dots and an
* extension rather than an opaque token.
*/
{
provider: 'pig',
label: 'PIG (self-hosted)',
kind: 'video',
enabled: true,
hosts: [],
idSegmentPrefixes: [],
idPattern: LEARN_MEDIA_FILENAME_PATTERN,
embed: (id) => learnMediaPath(id),
// The file is its own share link. There is no player page to send someone
// to, and inventing one would be a route that does not exist.
watch: (id) => learnMediaPath(id),
frameSrc: null,
},
];
/**
* Hosts the proxy must allow in `frame-src` for the enabled providers.
*
* A provider with no `frameSrc` contributes nothing: a self-hosted `<video>`
* is not framed, so widening `frame-src` for it would grant framing rights
* nothing asked for.
*/
export const LEARN_FRAME_SRC_HOSTS: readonly string[] = LEARN_PROVIDER_TABLE.filter(
(definition): definition is LearnProviderDefinition & { frameSrc: string } =>
definition.enabled && definition.frameSrc !== null,
).map((definition) => definition.frameSrc);
export function learnProviderDefinition(
provider: LearnProvider,
): LearnProviderDefinition | undefined {
return LEARN_PROVIDER_TABLE.find((definition) => definition.provider === provider);
}
export const LEARN_EMBED_REJECTIONS = [
'malformed_url',
'insecure_scheme',
'unknown_host',
'provider_disabled',
'unrecognised_path',
'malformed_id',
] as const;
export type LearnEmbedRejection = (typeof LEARN_EMBED_REJECTIONS)[number];
export type LearnEmbedResolution =
| {
ok: true;
provider: LearnProvider;
externalId: string;
/** Canonical share link. Safe to show a human; never an iframe source. */
watchUrl: string;
/**
* The source string, kept for every caller written before self-hosting
* existed. New code should read `embed` instead: this field cannot say
* whether the source belongs in an iframe or a `<video>`, and rendering
* it in the wrong one is the mistake the union exists to prevent.
*/
embedUrl: string;
/** What to render, and which element to render it in. */
embed: LearnEmbed;
}
| { ok: false; reason: LearnEmbedRejection };
export const LEARN_EMBED_REJECTION_MESSAGES: Record<LearnEmbedRejection, string> = {
malformed_url: 'That is not a URL.',
insecure_scheme: 'Only https links can be embedded.',
// Hostless providers are omitted: naming `/media/learn/…` as an "allowed
// host" would invite someone to paste an absolute URL to it, which this
// resolver refuses.
unknown_host: `Links from that host are not allowed. Allowed: ${LEARN_PROVIDER_TABLE.filter((d) => d.enabled && d.hosts.length > 0).map((d) => d.hosts[0]).join(', ')}.`,
provider_disabled: 'That provider is recognised but not enabled yet.',
unrecognised_path: 'That looks like the right host but not a share link.',
malformed_id: 'The video id in that link is not a shape we recognise.',
};
/** Build the render instruction for a provider and an already-validated id. */
function embedFor(definition: LearnProviderDefinition, externalId: string): LearnEmbed {
const src = definition.embed(externalId);
if (definition.kind !== 'video') return { kind: 'iframe', src };
/*
* The poster is asserted, not verified — this module is pure and has no
* filesystem. A poster that was never generated 404s, which a <video poster>
* renders exactly as it renders no poster at all, and which the card falls
* back from. Claiming a missing image is therefore free; omitting one that
* exists would cost every card its thumbnail.
*/
const posterFile = learnPosterFilename(externalId);
return posterFile
? { kind: 'video', src, poster: learnMediaPath(posterFile) }
: { kind: 'video', src };
}
/**
* Extract the id from a path, or null.
*
* The segment must be the LAST one. `/s/<id>/../../anything` and
* `/s/<id>/edit` are both rejected rather than silently truncated to `<id>`,
* because "close enough to a share link" is not a category this function is
* allowed to have.
*/
function externalIdFromPath(definition: LearnProviderDefinition, pathname: string): string | null {
const segments = pathname.split('/').filter(Boolean);
if (segments.length !== 2) return null;
const [prefix, candidate] = segments;
if (!prefix || !candidate) return null;
if (!definition.idSegmentPrefixes.includes(prefix)) return null;
return candidate;
}
/**
* Turn a pasted URL into a provider and an id, or say why not.
*
* Everything a caller may render is rebuilt from the template in the table.
* The input string itself is never returned as a URL, so a resolution result
* cannot carry an attacker's bytes into an attribute.
*/
export function resolveLearnEmbed(raw: string): LearnEmbedResolution {
const trimmed = raw.trim();
/*
* The self-hosted branch, taken before anything is parsed as a URL.
*
* A leading slash is the ONLY way to reach it, and `//evil.example/x` — a
* protocol-relative URL, which also begins with a slash — falls into it and
* is rejected by the prefix check rather than treated as a host. There is no
* second accept shape: no bare filename, no absolute URL naming our own
* origin, because each extra shape is another place the pattern has to hold.
*/
if (trimmed.startsWith('/')) return resolveSelfHostedMedia(trimmed);
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return { ok: false, reason: 'malformed_url' };
}
// `javascript:` and `data:` are the obvious ones; `http:` matters too,
// because framing it from an https page is blocked anyway and storing it
// produces a resource that silently never plays.
if (parsed.protocol !== 'https:') return { ok: false, reason: 'insecure_scheme' };
// `https://video.karti.ai@evil.example/` parses with hostname `evil.example`
// and reads to a human as the trusted host. Never legitimate here.
if (parsed.username || parsed.password) return { ok: false, reason: 'malformed_url' };
// A trusted hostname on an unexpected port is a different service.
if (parsed.port) return { ok: false, reason: 'malformed_url' };
const host = parsed.hostname.toLowerCase();
const definition = LEARN_PROVIDER_TABLE.find((candidate) => candidate.hosts.includes(host));
if (!definition) return { ok: false, reason: 'unknown_host' };
if (!definition.enabled) return { ok: false, reason: 'provider_disabled' };
const externalId = externalIdFromPath(definition, parsed.pathname);
if (externalId === null) return { ok: false, reason: 'unrecognised_path' };
if (!definition.idPattern.test(externalId)) return { ok: false, reason: 'malformed_id' };
return {
ok: true,
provider: definition.provider,
externalId,
watchUrl: definition.watch(externalId),
embedUrl: definition.embed(externalId),
embed: embedFor(definition, externalId),
};
}
/**
* Resolve `/media/learn/<filename>` and nothing else.
*
* The rejection reasons are reused rather than extended so that the write path
* keeps one vocabulary: a path that is not under the prefix, or that has a
* further segment, is `unrecognised_path`; a filename that fails the pattern
* is `malformed_id`. A query string or a fragment is a rejection too — the
* stored id must be the exact bytes of a file on disk, and `?v=2` silently
* dropped is a stored id that no longer names anything.
*/
function resolveSelfHostedMedia(path: string): LearnEmbedResolution {
const definition = learnProviderDefinition('pig');
if (!definition) return { ok: false, reason: 'unknown_host' };
if (!definition.enabled) return { ok: false, reason: 'provider_disabled' };
if (!path.startsWith(LEARN_MEDIA_PATH_PREFIX)) return { ok: false, reason: 'unrecognised_path' };
const filename = path.slice(LEARN_MEDIA_PATH_PREFIX.length);
if (filename.includes('/')) return { ok: false, reason: 'unrecognised_path' };
if (!isLearnMediaFilename(filename)) return { ok: false, reason: 'malformed_id' };
return {
ok: true,
provider: 'pig',
externalId: filename,
watchUrl: definition.watch(filename),
embedUrl: definition.embed(filename),
embed: embedFor(definition, filename),
};
}
/**
* Rebuild an embed source from stored columns.
*
* Re-validates the id rather than trusting the database. A row written before
* a pattern was tightened, or by a future code path that skipped the resolver,
* must not be framed on the strength of having been persisted once.
*/
export function learnEmbedUrl(provider: LearnProvider, externalId: string): string | null {
return learnEmbed(provider, externalId)?.src ?? null;
}
/**
* The same rebuild, carrying the discriminator.
*
* This is the one a renderer should call. `learnEmbedUrl` remains because
* callers predate self-hosting, and both go through this function so a
* tightened pattern cannot apply to one and not the other.
*/
export function learnEmbed(provider: LearnProvider, externalId: string): LearnEmbed | null {
const definition = learnProviderDefinition(provider);
if (!definition || !definition.enabled) return null;
if (!definition.idPattern.test(externalId)) return null;
return embedFor(definition, externalId);
}
/** The human-facing share link, on the same terms. */
export function learnWatchUrl(provider: LearnProvider, externalId: string): string | null {
const definition = learnProviderDefinition(provider);
if (!definition || !definition.enabled) return null;
if (!definition.idPattern.test(externalId)) return null;
return definition.watch(externalId);
}
// ---------------------------------------------------------------------------
// Presentation helpers
// ---------------------------------------------------------------------------
/**
* `4:32`, or `1:04:12` past the hour.
*
* Here rather than in the web app because the duration is also rendered by the
* public code-holder view, and two formatters would eventually disagree about
* whether a 61-minute video is `61:00` or `1:01:00`.
*/
export function formatLearnDuration(seconds: number | null | undefined): string | null {
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return null;
const whole = Math.round(seconds);
const hours = Math.floor(whole / 3600);
const minutes = Math.floor((whole % 3600) / 60);
const secs = whole % 60;
const pad = (value: number) => String(value).padStart(2, '0');
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
}