Files
pig/packages/core/src/learn.ts
T
karti 45b70b17f0
CI / verify (push) Successful in 3m32s
CI / publish (push) Has been skipped
Redesign Learn, and give it five real videos in Karti's voice
THE PAGE. The anonymous route rendered outside Shell, so it sat flush against
the viewport edge and read as a form rather than a product — which is the first
thing anyone at Prime Intellect sees when the link is shared. It now brings its
own chrome and leads with a hero; the platform track is a numbered course, the
concept tracks are a poster grid, and admin add/archive moved behind one Manage
toggle so they stop competing with the content. Verified in Chrome at 1440 and
393, light and dark: horizontal overflow is 0 in all three access states.

THE VIDEOS. Five ~30s walkthroughs, narrated in Karti's cloned voice through
Chatterbox and cut against real screen capture of the seeded demo book. The
audio is rendered FIRST and its measured duration drives the capture, because a
shot list that runs short leaves the narrator talking over a frozen frame and
one that runs long gets cut mid-sentence. Levels are loudness-normalised so
clips do not jump between videos.

Cap cannot take a programmatic upload — video.karti.ai needs an interactive
login — so PIG serves these itself. A native <video> on this origin needs no
iframe and therefore no CSP frame-src at all; Karti's own Cap recordings still
render through the existing iframe path, which is why the resolver is now a
discriminated union.

THREE THINGS THE VERIFIERS CAUGHT, all of which shipped green:

  - createMediaRoutes was never mounted. Every layer landed — migration, seed,
    both feeds, the bind mount, the docs — except the one that serves the bytes,
    so /media/learn/* fell through to the SPA fallback and answered HTTP 200
    text/html. The player showed a black box with working controls and no error.
    The tests certified the route factory in isolation, which proves the handler
    and says nothing about whether it is wired in. There is now an assertion
    against the ASSEMBLED app, and it fails loudly on content-type — the failure
    mode is a 200, not a 404.
  - A symlink in the media directory escaped the root. resolve() is lexical and
    stat() follows links, so the containment check this file's own header
    promised did not hold. realpath before the check closes it.
  - Vite proxied only /api, so self-hosted playback broke for anyone running the
    app the documented way — in the same invisible 200-text/html manner.

Also: a duplicate media slug used to throw from the middle of seedDemo() and
take out every later section; it now reports and skips that one entry. And the
player has an onError state, because content-addressed filenames mean a
re-render deliberately leaves the old row pointing at a file that is gone.

The three DEMO platform rows are dropped — five real recordings supersede them,
and placeholders sitting under real ones made the page read as half-finished to
the audience it is meant to convince. The supply and demand concept rows stay:
there are no real recordings for those tracks yet, and an empty track hides the
shape of the page.

Tests 275, typecheck clean.

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

507 lines
21 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',
};
/**
* 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)$/;
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 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);
return definition.kind === 'video' ? { kind: 'video', src } : { kind: 'iframe', 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)}`;
}