Redesign Learn, and give it five real videos in Karti's voice
CI / verify (push) Successful in 3m32s
CI / publish (push) Has been skipped

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>
This commit is contained in:
2026-08-13 17:30:08 -07:00
parent a21ecf9e53
commit 45b70b17f0
24 changed files with 2728 additions and 654 deletions
+208 -9
View File
@@ -28,6 +28,15 @@
* (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`.
*/
// ---------------------------------------------------------------------------
@@ -82,12 +91,83 @@ export function learnVisibilityPermitted(track: LearnTrack, visibility: LearnVis
// The provider allowlist
// ---------------------------------------------------------------------------
export const LEARN_PROVIDERS = ['cap', 'loom', 'youtube_nocookie'] as const;
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
@@ -95,7 +175,11 @@ export interface LearnProviderDefinition {
* design exercise under time pressure.
*/
enabled: boolean;
/** Exact hostnames. Never a suffix match — `evil-loom.com` ends in loom.com. */
/**
* 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[];
@@ -103,8 +187,14 @@ export interface LearnProviderDefinition {
idPattern: RegExp;
embed(externalId: string): string;
watch(externalId: string): string;
/** What the proxy's `frame-src` needs before this provider can render. */
frameSrc: 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;
}
/**
@@ -126,6 +216,7 @@ export const LEARN_PROVIDER_TABLE: readonly LearnProviderDefinition[] = [
{
provider: 'cap',
label: 'Cap',
kind: 'iframe',
enabled: true,
hosts: ['video.karti.ai'],
idSegmentPrefixes: ['s', 'embed'],
@@ -137,6 +228,7 @@ export const LEARN_PROVIDER_TABLE: readonly LearnProviderDefinition[] = [
{
provider: 'loom',
label: 'Loom',
kind: 'iframe',
enabled: false,
hosts: ['www.loom.com', 'loom.com'],
idSegmentPrefixes: ['share', 'embed'],
@@ -148,6 +240,7 @@ export const LEARN_PROVIDER_TABLE: readonly LearnProviderDefinition[] = [
{
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
@@ -159,11 +252,45 @@ export const LEARN_PROVIDER_TABLE: readonly LearnProviderDefinition[] = [
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. */
/**
* 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.enabled,
(definition): definition is LearnProviderDefinition & { frameSrc: string } =>
definition.enabled && definition.frameSrc !== null,
).map((definition) => definition.frameSrc);
export function learnProviderDefinition(
@@ -189,19 +316,36 @@ export type LearnEmbedResolution =
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.',
unknown_host: `Links from that host are not allowed. Allowed: ${LEARN_PROVIDER_TABLE.filter((d) => d.enabled).map((d) => d.hosts[0]).join(', ')}.`,
// 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.
*
@@ -227,9 +371,22 @@ function externalIdFromPath(definition: LearnProviderDefinition, pathname: strin
* 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(raw.trim());
parsed = new URL(trimmed);
} catch {
return { ok: false, reason: 'malformed_url' };
}
@@ -260,6 +417,37 @@ export function resolveLearnEmbed(raw: string): LearnEmbedResolution {
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),
};
}
@@ -271,10 +459,21 @@ export function resolveLearnEmbed(raw: string): LearnEmbedResolution {
* 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 definition.embed(externalId);
return embedFor(definition, externalId);
}
/** The human-facing share link, on the same terms. */