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>
This commit is contained in:
+208
-9
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Hand-written, because drizzle-kit rewrites a CHECK by dropping and adding it
|
||||
-- and will happily emit that with no IF EXISTS — which fails on any database
|
||||
-- where the constraint was created under an earlier name.
|
||||
--
|
||||
-- Widening only: 'pig' is the self-hosted provider, whose external_id is a
|
||||
-- media filename rather than a remote video id. Every value already stored
|
||||
-- still satisfies the new list, so this needs no backfill and cannot fail on
|
||||
-- existing rows.
|
||||
--
|
||||
-- The unique key on (track, provider, external_id) is untouched and is what
|
||||
-- makes the self-hosted seed idempotent.
|
||||
ALTER TABLE "learn_resources" DROP CONSTRAINT IF EXISTS "learn_resources_provider_check";--> statement-breakpoint
|
||||
ALTER TABLE "learn_resources" ADD CONSTRAINT "learn_resources_provider_check" CHECK ("learn_resources"."provider" IN ('cap', 'loom', 'youtube_nocookie', 'pig'));
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1786655800000,
|
||||
"tag": "0012_viewer_team_role",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1786700000000,
|
||||
"tag": "0013_learn_self_hosted_provider",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+261
-33
@@ -35,8 +35,16 @@
|
||||
* is more useful than one that opens on a loss, which reads as a broken
|
||||
* product rather than an under-utilised book.
|
||||
*/
|
||||
import { ALLOCATION_STATUSES, quarterBoundsFor, type AllocationStatus } from '@pig/core';
|
||||
import {
|
||||
ALLOCATION_STATUSES,
|
||||
LEARN_MEDIA_PATH_PREFIX,
|
||||
isLearnMediaFilename,
|
||||
quarterBoundsFor,
|
||||
type AllocationStatus,
|
||||
} from '@pig/core';
|
||||
import { and, eq, like, or } from 'drizzle-orm';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { createDatabase } from '../client';
|
||||
import {
|
||||
accounts,
|
||||
@@ -932,37 +940,15 @@ async function seedDemo() {
|
||||
// is the opposite of what a demo seed is for — so the titles are illustrative
|
||||
// and prefixed, and the videos behind them are whatever is actually there.
|
||||
//
|
||||
// The platform rows are `code`-visible: they are what a code-holder with no
|
||||
// account sees. The concept rows are `members`, and the CHECK constraint on
|
||||
// the table would refuse them any other way round.
|
||||
// PLATFORM rows are no longer seeded here. The five real recordings in
|
||||
// HOSTED_LEARN_MANIFEST cover that track now, and leaving three illustrative
|
||||
// Cap rows beneath five genuine ones made the page read as half-placeholder
|
||||
// to the exact audience it is meant to convince.
|
||||
//
|
||||
// The concept rows stay: `supply` and `demand` have no real recordings yet,
|
||||
// and an empty track hides the shape of the page. They are `members`-visible,
|
||||
// which the CHECK constraint enforces anyway — only `platform` may be `code`.
|
||||
const LEARN_RESOURCES = [
|
||||
{
|
||||
track: 'platform' as const,
|
||||
title: `${PREFIX}Your first hour in PIG`,
|
||||
summary: 'Signing in, finding your pipeline, and what the Overview numbers mean.',
|
||||
externalId: '0n6n9p83efnxbs2',
|
||||
visibility: 'code' as const,
|
||||
durationSeconds: 8 * 60 + 40,
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
track: 'platform' as const,
|
||||
title: `${PREFIX}Allocations: joining what we bought to what we sold`,
|
||||
summary: 'The one table the product is built around, walked through on the demo book.',
|
||||
externalId: '1rqq9rk4dpp71fd',
|
||||
visibility: 'code' as const,
|
||||
durationSeconds: 12 * 60 + 15,
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
track: 'platform' as const,
|
||||
title: `${PREFIX}Reading the margin report without fooling yourself`,
|
||||
summary: 'Why cost is charged against the whole commitment, and what idle capacity costs.',
|
||||
externalId: 'sjqqvthbfma27bm',
|
||||
visibility: 'code' as const,
|
||||
durationSeconds: 9 * 60 + 5,
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
track: 'supply' as const,
|
||||
title: `${PREFIX}How neocloud capacity is actually priced`,
|
||||
@@ -1027,6 +1013,12 @@ async function seedDemo() {
|
||||
if (inserted.length) learnAdded += 1;
|
||||
}
|
||||
|
||||
// The self-hosted set, which is real rather than invented — see the long
|
||||
// note on `seedHostedLearn`. Folded into the demo seed so one command gives
|
||||
// a complete Learn page, but kept in its own function with its own flags
|
||||
// because it is not demo data and must not be removed with `--clear`.
|
||||
const hosted = await seedHostedLearn();
|
||||
|
||||
// -------------------------------------------------- agent-derived facts
|
||||
//
|
||||
// Without these the fact-review queue and every provenance tooltip are
|
||||
@@ -1199,11 +1191,247 @@ async function seedDemo() {
|
||||
console.log(
|
||||
` ${LEARN_RESOURCES.length} learn resources (${learnAdded} new) — 3 platform walkthroughs behind the share code`,
|
||||
);
|
||||
console.log(
|
||||
` ${hosted.present} PIG-hosted learn videos (${hosted.added} new)` +
|
||||
`${hosted.missing > 0 ? `, ${hosted.missing} manifest entries with no file yet` : ''}`,
|
||||
);
|
||||
console.log('\nEverything is prefixed "DEMO — ". Remove it with: pnpm db:demo -- --clear');
|
||||
console.log('The PIG-hosted rows are NOT prefixed and survive that. Remove them with: pnpm db:demo -- --clear-hosted');
|
||||
}
|
||||
|
||||
const shouldClear = process.argv.includes('--clear');
|
||||
(shouldClear ? clear() : seedDemo())
|
||||
// ------------------------------------------------------- PIG-hosted learn
|
||||
//
|
||||
// Real videos, served by PIG itself from PIG_MEDIA_DIR — not demo data. They
|
||||
// carry no `DEMO — ` prefix precisely because they are genuine product
|
||||
// walkthroughs, which also means `--clear` leaves them alone; `--clear-hosted`
|
||||
// is their own switch.
|
||||
//
|
||||
// **A row is written only when its file is actually on disk.** A learn row
|
||||
// whose media 404s is worse than a missing row: the card renders, the play
|
||||
// button does nothing, and the feature reads as broken. So the manifest below
|
||||
// declares the curriculum, and the seed inserts the entries it can find.
|
||||
// Running it before the videos are generated is a no-op with a printed list,
|
||||
// and running it again afterwards fills them in.
|
||||
//
|
||||
// **The filename is discovered, not written down.** Files are
|
||||
// content-addressed — `<slug>.<hash>.mp4` — so the hash changes every time a
|
||||
// video is re-rendered, and a manifest carrying the hash would be a file that
|
||||
// has to be edited in lockstep with a render. Instead the slug is the stable
|
||||
// identity and the directory supplies the rest. Idempotency then rests on the
|
||||
// unique key (track, provider, external_id) exactly as the DEMO rows do.
|
||||
//
|
||||
// A re-render produces a NEW hash and therefore a new row; the old row keeps
|
||||
// pointing at a file that is no longer there. That is reported rather than
|
||||
// resolved automatically, because deleting rows on the strength of a missing
|
||||
// file would empty the curriculum the first time someone ran this with the
|
||||
// media directory unmounted.
|
||||
|
||||
interface HostedLearnEntry {
|
||||
/** Stable identity. Also the filename stem: `<slug>.<hash>.<ext>`. */
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
track: 'supply' | 'demand' | 'platform';
|
||||
visibility: 'members' | 'code';
|
||||
durationSeconds: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export const HOSTED_LEARN_MANIFEST: readonly HostedLearnEntry[] = [
|
||||
{
|
||||
slug: 'overview-and-margin',
|
||||
title: 'Overview and the margin question',
|
||||
summary:
|
||||
'Which contracted capacity is sold, at what margin, and what is idle right now — and why cost is charged against the full commitment.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 29,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
slug: 'quarterly-calendar',
|
||||
title: 'The quarterly calendar',
|
||||
summary:
|
||||
'What closes, what renews, what expires and when capacity lands — with export authorisations and compliance artefacts at the top.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 30,
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
slug: 'capacity-to-allocations',
|
||||
title: 'Capacity to allocations',
|
||||
summary:
|
||||
'Joining a commitment you bought to a deal you sold — the availability book, the matcher, and the allocation the ledger is built on.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 26,
|
||||
sortOrder: 3,
|
||||
},
|
||||
{
|
||||
slug: 'importing-your-book',
|
||||
title: 'Importing your book',
|
||||
summary:
|
||||
'Getting off the spreadsheet — CSV, Excel, Notion or a bounded Google Sheets range, with a dry run you review before anything is written.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 28,
|
||||
sortOrder: 4,
|
||||
},
|
||||
{
|
||||
slug: 'piggy-and-its-boundary',
|
||||
title: 'Piggy, and what it will not do',
|
||||
summary:
|
||||
'The docked agent reads through scoped, page-specific PIG tools — and has no shell, no filesystem, and no ability to write CRM records.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 28,
|
||||
sortOrder: 5,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Where the served files live. The same variable the API route reads.
|
||||
*
|
||||
* The default is resolved from this file's location, not from `process.cwd()`,
|
||||
* because pnpm runs the seed with the working directory at `packages/db` while
|
||||
* the server runs at the repository root — so a relative default would mean
|
||||
* two different directories, and the seed would write rows for files the API
|
||||
* cannot find.
|
||||
*/
|
||||
function mediaDirectory(): string {
|
||||
const configured = process.env.PIG_MEDIA_DIR?.trim();
|
||||
if (configured && configured.length > 0) return resolve(configured);
|
||||
return resolve(import.meta.dirname, '../../../../media');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the one file belonging to a slug.
|
||||
*
|
||||
* Two files for one slug is an error rather than a choice: picking the newest
|
||||
* would silently publish whichever render happened to finish last, and the
|
||||
* operator has an old file to delete.
|
||||
*/
|
||||
function mediaFileFor(
|
||||
slug: string,
|
||||
filenames: readonly string[],
|
||||
): { filename: string | null; ambiguous: readonly string[] } {
|
||||
const matches = filenames.filter(
|
||||
(name) => isLearnMediaFilename(name) && name.startsWith(`${slug}.`),
|
||||
);
|
||||
/*
|
||||
* Reported and skipped, not thrown.
|
||||
*
|
||||
* This runs from the middle of seedDemo(), so throwing on a duplicate took
|
||||
* out every later section — facts, activities, the lot — and left the
|
||||
* operator working out why `pnpm db:demo` died on two MP4s sharing a slug.
|
||||
* A stale render is a condition of one directory entry; its blast radius
|
||||
* should be that entry. Missing files are already handled this way.
|
||||
*/
|
||||
if (matches.length > 1) return { filename: null, ambiguous: matches };
|
||||
return { filename: matches[0] ?? null, ambiguous: [] };
|
||||
}
|
||||
|
||||
export async function seedHostedLearn(): Promise<{
|
||||
present: number;
|
||||
added: number;
|
||||
missing: number;
|
||||
}> {
|
||||
const directory = mediaDirectory();
|
||||
let filenames: string[] = [];
|
||||
try {
|
||||
filenames = await readdir(directory);
|
||||
} catch {
|
||||
// No directory is the normal state of a fresh checkout, not a failure.
|
||||
console.log(` (no media directory at ${directory} — skipping PIG-hosted learn videos)`);
|
||||
return { present: 0, added: 0, missing: HOSTED_LEARN_MANIFEST.length };
|
||||
}
|
||||
|
||||
const [owner] = await db.select({ id: users.id }).from(users).limit(1);
|
||||
let present = 0;
|
||||
let added = 0;
|
||||
const absent: string[] = [];
|
||||
|
||||
for (const entry of HOSTED_LEARN_MANIFEST) {
|
||||
const { filename, ambiguous } = mediaFileFor(entry.slug, filenames);
|
||||
if (ambiguous.length > 0) {
|
||||
console.warn(
|
||||
` ! ${entry.slug}: ${ambiguous.length} files match (${ambiguous.join(', ')}) — ` +
|
||||
'content-addressed names mean one is a stale render. Delete it and re-run. Skipped.',
|
||||
);
|
||||
absent.push(entry.slug);
|
||||
continue;
|
||||
}
|
||||
if (!filename) {
|
||||
absent.push(entry.slug);
|
||||
continue;
|
||||
}
|
||||
present += 1;
|
||||
|
||||
const inserted = await db
|
||||
.insert(learnResources)
|
||||
.values({
|
||||
track: entry.track,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
// The same path the resolver builds, from the same constant — so a
|
||||
// seeded row and a row created through the API are indistinguishable.
|
||||
url: `${LEARN_MEDIA_PATH_PREFIX}${filename}`,
|
||||
provider: 'pig',
|
||||
externalId: filename,
|
||||
visibility: entry.visibility,
|
||||
durationSeconds: entry.durationSeconds,
|
||||
sortOrder: entry.sortOrder,
|
||||
addedByUserId: owner?.id ?? null,
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [learnResources.track, learnResources.provider, learnResources.externalId],
|
||||
})
|
||||
.returning({ id: learnResources.id });
|
||||
if (inserted.length) added += 1;
|
||||
}
|
||||
|
||||
if (absent.length) {
|
||||
console.log(
|
||||
` (no file yet in ${directory} for: ${absent.join(', ')} — ` +
|
||||
'drop <slug>.<hash>.mp4 there and run this again)',
|
||||
);
|
||||
}
|
||||
|
||||
// Rows whose file has gone: reported, never deleted. See the note above.
|
||||
const orphans = (
|
||||
await db
|
||||
.select({ externalId: learnResources.externalId, title: learnResources.title })
|
||||
.from(learnResources)
|
||||
.where(eq(learnResources.provider, 'pig'))
|
||||
).filter((row) => !filenames.includes(row.externalId));
|
||||
for (const orphan of orphans) {
|
||||
console.log(` ! "${orphan.title}" points at ${orphan.externalId}, which is not on disk`);
|
||||
}
|
||||
|
||||
return { present, added, missing: absent.length };
|
||||
}
|
||||
|
||||
async function clearHostedLearn() {
|
||||
const removed = await db
|
||||
.delete(learnResources)
|
||||
.where(eq(learnResources.provider, 'pig'))
|
||||
.returning({ id: learnResources.id });
|
||||
console.log(`Removed ${removed.length} PIG-hosted learn resource(s). Files on disk are untouched.`);
|
||||
}
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const run = argv.includes('--clear')
|
||||
? clear
|
||||
: argv.includes('--clear-hosted')
|
||||
? clearHostedLearn
|
||||
// Insert only the self-hosted rows: the command to run after generating a
|
||||
// video, when reseeding the whole demo book would be beside the point.
|
||||
: argv.includes('--hosted')
|
||||
? seedHostedLearn
|
||||
: seedDemo;
|
||||
|
||||
run()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
console.error('Demo seed failed:', error);
|
||||
|
||||
Reference in New Issue
Block a user