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:
@@ -48,6 +48,27 @@ PIG_PORT=8920
|
|||||||
PIG_PUBLIC_URL=http://localhost:8920
|
PIG_PUBLIC_URL=http://localhost:8920
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# --- Learn videos, hosted by PIG ---------------------------------------------
|
||||||
|
# PIG serves its own Learn videos from disk, as a native <video> — no embed
|
||||||
|
# host, no iframe, and therefore nothing to add to the proxy's frame-src.
|
||||||
|
#
|
||||||
|
# PIG_MEDIA_DIR is where the application READS them. Running from source that
|
||||||
|
# is a path on this machine, relative to the repository root; in the container
|
||||||
|
# it is always /app/media and docker-compose sets it for you.
|
||||||
|
PIG_MEDIA_DIR=./media
|
||||||
|
#
|
||||||
|
# PIG_MEDIA_HOST_DIR is the HOST directory docker-compose bind-mounts there,
|
||||||
|
# read-only. Two names for the two sides of the mount, on purpose. It must
|
||||||
|
# exist before `compose up` — Docker creates a missing bind source as an empty
|
||||||
|
# root-owned directory, which serves 404s and cannot be written to without
|
||||||
|
# sudo. On the deployment host this is normally /opt/pig/media.
|
||||||
|
PIG_MEDIA_HOST_DIR=./media
|
||||||
|
#
|
||||||
|
# Filenames are CONTENT-ADDRESSED — `<slug>.<hash>.mp4` — because the files
|
||||||
|
# themselves are served without authentication while the listing behind
|
||||||
|
# /api/learn stays code-gated. The hash is what makes a URL unguessable. See
|
||||||
|
# deploy/README.md, "Learn videos", for the trade this makes and its cost.
|
||||||
|
|
||||||
# --- Deployment: which image to run -----------------------------------------
|
# --- Deployment: which image to run -----------------------------------------
|
||||||
# Leave EMPTY to build from the working tree, which is what a development or
|
# Leave EMPTY to build from the working tree, which is what a development or
|
||||||
# self-hosted-from-source install wants. Set it to a published tag and
|
# self-hosted-from-source install wants. Set it to a published tag and
|
||||||
|
|||||||
@@ -19,3 +19,7 @@ coverage/
|
|||||||
# Postgres volume mounts used by local compose
|
# Postgres volume mounts used by local compose
|
||||||
deploy/pgdata/
|
deploy/pgdata/
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
|
# Self-hosted Learn videos. Hundreds of megabytes of rendered MP4 that the
|
||||||
|
# deployment mounts from the host — a release artefact, not source.
|
||||||
|
media/
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
type AuthProvider,
|
type AuthProvider,
|
||||||
} from './lib/auth-provider';
|
} from './lib/auth-provider';
|
||||||
import { apiError } from './lib/mutation';
|
import { apiError } from './lib/mutation';
|
||||||
|
import { createMediaRoutes } from './lib/media';
|
||||||
import { CapacityService } from './services/capacity';
|
import { CapacityService } from './services/capacity';
|
||||||
import { createSignupRoute } from './routes/signup';
|
import { createSignupRoute } from './routes/signup';
|
||||||
import { createRegisterRoute } from './routes/register';
|
import { createRegisterRoute } from './routes/register';
|
||||||
@@ -132,6 +133,18 @@ export function createApp(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Learn videos PIG serves itself. Mounted here — before the authenticator,
|
||||||
|
* and before server.ts's SPA fallback — because a <video> re-requests byte
|
||||||
|
* ranges on every seek and carries no bearer token while doing it.
|
||||||
|
*
|
||||||
|
* The FILES are unauthenticated; the LISTING behind /api/learn is not. See
|
||||||
|
* lib/media.ts for that trade and what it costs. Position IS the access
|
||||||
|
* decision here: the /api/* allowlist below can never match /media/learn/*,
|
||||||
|
* so adding an entry there would be dead code.
|
||||||
|
*/
|
||||||
|
app.route('/', createMediaRoutes());
|
||||||
|
|
||||||
// Everything below requires a principal.
|
// Everything below requires a principal.
|
||||||
app.use('/api/*', async (c, next) => {
|
app.use('/api/*', async (c, next) => {
|
||||||
const path = new URL(c.req.url).pathname;
|
const path = new URL(c.req.url).pathname;
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Serving the Learn videos PIG hosts itself.
|
||||||
|
*
|
||||||
|
* ## The trade, stated plainly
|
||||||
|
*
|
||||||
|
* **The files are unauthenticated. The listing is not.** `/api/learn` and
|
||||||
|
* `/api/learn/public` decide who learns that a video exists, what it is called
|
||||||
|
* and which track it belongs to; this route hands the bytes to anyone who can
|
||||||
|
* name the file. That is the same shape every video platform has — a gated
|
||||||
|
* manifest in front of segments on a public CDN — and it is the shape a
|
||||||
|
* `<video>` element actually wants, because a media element re-requests ranges
|
||||||
|
* on every seek and does not carry a bearer token while doing it.
|
||||||
|
*
|
||||||
|
* What it costs: **a URL, once shared, is a permanent public link to that
|
||||||
|
* video**. Someone who has been given the share code can copy the `src` out of
|
||||||
|
* the page and post it, and revoking the Learn access code will not close it.
|
||||||
|
* The only remedies are renaming the file (a new hash) or deleting it. We are
|
||||||
|
* taking that deliberately, because these are product demos meant to be
|
||||||
|
* shareable with the code — the material that must never leak is the concept
|
||||||
|
* tracks, and those are gated by the listing, which is where the boundary
|
||||||
|
* genuinely is.
|
||||||
|
*
|
||||||
|
* The mitigation is the filename. Names are **content-addressed** — a hash in
|
||||||
|
* the middle — so a URL is unguessable and enumerating the directory over HTTP
|
||||||
|
* is not possible: there is no index, and a wrong guess is a flat 404. That
|
||||||
|
* makes the exposure "whoever was given the link" rather than "the internet",
|
||||||
|
* which is exactly what an unlisted video is.
|
||||||
|
*
|
||||||
|
* ## Ranges are not optional
|
||||||
|
*
|
||||||
|
* A `<video>` that cannot be range-requested cannot be scrubbed: the browser
|
||||||
|
* asks for `bytes=…` when the user drags the scrubber, and a server that
|
||||||
|
* answers 200 with the whole file makes seeking either impossible or a
|
||||||
|
* re-download. So `Accept-Ranges: bytes` is advertised and a single range is
|
||||||
|
* honoured with a 206. This is also why the route streams from a file
|
||||||
|
* descriptor rather than reading the file into memory: these are hundreds of
|
||||||
|
* megabytes and several viewers may be seeking at once.
|
||||||
|
*
|
||||||
|
* ## Why the path cannot escape the directory
|
||||||
|
*
|
||||||
|
* The filename is validated by the same allowlist that decides whether a row
|
||||||
|
* may exist at all (`isLearnMediaFilename` in `@pig/core`), so the pattern
|
||||||
|
* that admits a video source and the pattern that admits a file read are one
|
||||||
|
* pattern rather than two that drift. It permits no slash and no `..`. The
|
||||||
|
* resolved path is then checked to be inside the root anyway, because a
|
||||||
|
* defence that rests on a single regular expression rests on nobody ever
|
||||||
|
* editing that regular expression.
|
||||||
|
*/
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { realpath, stat } from 'node:fs/promises';
|
||||||
|
import { join, resolve, sep } from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { isLearnMediaFilename, learnMediaContentType, LEARN_MEDIA_PATH_PREFIX } from '@pig/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the files live.
|
||||||
|
*
|
||||||
|
* Read from the environment here rather than through `loadConfig` because the
|
||||||
|
* media root is an operational detail of one route, and a missing value is not
|
||||||
|
* a reason to refuse to boot — an install with no videos should serve 404s and
|
||||||
|
* work in every other respect.
|
||||||
|
*
|
||||||
|
* The default is relative to the working directory, which is the repository
|
||||||
|
* root in development. The container sets it explicitly to `/app/media`, which
|
||||||
|
* is where docker-compose bind-mounts the host directory read-only.
|
||||||
|
*/
|
||||||
|
export const LEARN_MEDIA_DIR_ENV = 'PIG_MEDIA_DIR';
|
||||||
|
const DEFAULT_MEDIA_DIR = './media';
|
||||||
|
|
||||||
|
export function learnMediaRoot(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
|
const configured = env[LEARN_MEDIA_DIR_ENV]?.trim();
|
||||||
|
return resolve(configured && configured.length > 0 ? configured : DEFAULT_MEDIA_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mount path, exported so `app.ts` and the resolver cannot disagree. */
|
||||||
|
export const LEARN_MEDIA_ROUTE = `${LEARN_MEDIA_PATH_PREFIX}:filename`;
|
||||||
|
|
||||||
|
interface ParsedRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single byte range against a known size, or say what to do instead.
|
||||||
|
*
|
||||||
|
* `null` means "serve the whole thing with 200" — the correct answer for no
|
||||||
|
* header, a syntactically odd one, or a multi-range request, all of which a
|
||||||
|
* server is permitted to ignore. `'unsatisfiable'` is the one case that must
|
||||||
|
* NOT become a 200: a range starting past the end is a client with a stale
|
||||||
|
* idea of the file, and answering it with the whole file would splice the
|
||||||
|
* beginning of the video into the middle of its buffer.
|
||||||
|
*/
|
||||||
|
export function parseByteRange(header: string | undefined, size: number): ParsedRange | null | 'unsatisfiable' {
|
||||||
|
if (!header) return null;
|
||||||
|
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const [, rawStart, rawEnd] = match;
|
||||||
|
if (rawStart === '' && rawEnd === '') return null;
|
||||||
|
|
||||||
|
// A suffix range — `bytes=-500`, the last 500 bytes. Browsers use it to read
|
||||||
|
// the MP4 moov atom when it sits at the end of the file, so a player that
|
||||||
|
// cannot start at all is often this branch missing.
|
||||||
|
if (rawStart === '') {
|
||||||
|
const suffix = Number(rawEnd);
|
||||||
|
if (!Number.isSafeInteger(suffix) || suffix <= 0) return 'unsatisfiable';
|
||||||
|
if (size === 0) return 'unsatisfiable';
|
||||||
|
return { start: Math.max(0, size - suffix), end: size - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Number(rawStart);
|
||||||
|
if (!Number.isSafeInteger(start) || start < 0) return 'unsatisfiable';
|
||||||
|
if (start >= size) return 'unsatisfiable';
|
||||||
|
const end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
||||||
|
if (!Number.isSafeInteger(end) || end < start) return 'unsatisfiable';
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMediaRoutes(options: { root?: string } = {}) {
|
||||||
|
const app = new Hono();
|
||||||
|
const root = options.root ? resolve(options.root) : learnMediaRoot();
|
||||||
|
|
||||||
|
// GET and HEAD both, because a player probes with HEAD before it commits to
|
||||||
|
// downloading, and an unrouted HEAD would 404 a file that is plainly there.
|
||||||
|
app.on(['GET', 'HEAD'], LEARN_MEDIA_ROUTE, async (c) => {
|
||||||
|
const filename = c.req.param('filename');
|
||||||
|
if (!filename || !isLearnMediaFilename(filename)) return c.notFound();
|
||||||
|
|
||||||
|
const contentType = learnMediaContentType(filename);
|
||||||
|
if (!contentType) return c.notFound();
|
||||||
|
|
||||||
|
const path = join(root, filename);
|
||||||
|
// Belt and braces over the pattern: if this ever fails, the pattern has
|
||||||
|
// been loosened and the loosening is a traversal.
|
||||||
|
if (path !== resolve(path) || !path.startsWith(root + sep)) return c.notFound();
|
||||||
|
|
||||||
|
let size: number;
|
||||||
|
let modified: Date;
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
* realpath BEFORE the containment check, not just resolve().
|
||||||
|
*
|
||||||
|
* resolve() is lexical: it collapses `..` in the string but cannot see
|
||||||
|
* through a symlink, and stat() follows one. So a link planted in the
|
||||||
|
* media directory pointing at /etc/passwd passed the check above and was
|
||||||
|
* served in full, while this file's own header claimed containment. The
|
||||||
|
* directory is operator-populated and mounted read-only, so this was
|
||||||
|
* hardening rather than a live hole — but it becomes real the moment the
|
||||||
|
* directory is filled by an rsync or a tarball unpack.
|
||||||
|
*/
|
||||||
|
const real = await realpath(path);
|
||||||
|
if (real !== path && !real.startsWith(root + sep)) return c.notFound();
|
||||||
|
const info = await stat(real);
|
||||||
|
if (!info.isFile()) return c.notFound();
|
||||||
|
size = info.size;
|
||||||
|
modified = info.mtime;
|
||||||
|
} catch {
|
||||||
|
// Missing, unreadable, a dangling symlink — all one answer. Telling the
|
||||||
|
// difference tells a prober which names exist, and unguessable names are
|
||||||
|
// the only thing standing between these files and enumeration.
|
||||||
|
return c.notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
'content-type': contentType,
|
||||||
|
'accept-ranges': 'bytes',
|
||||||
|
// Content-addressed: the bytes behind a name never change, so a year is
|
||||||
|
// safe and `immutable` stops the revalidation round trip on every seek.
|
||||||
|
'cache-control': 'public, max-age=31536000, immutable',
|
||||||
|
'last-modified': modified.toUTCString(),
|
||||||
|
etag: `"${size.toString(16)}-${modified.getTime().toString(16)}"`,
|
||||||
|
// These are downloads to a media element, never documents. Without it a
|
||||||
|
// browser that sniffs its way to text/html on a truncated file would
|
||||||
|
// treat same-origin bytes as a page.
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
});
|
||||||
|
|
||||||
|
const range = parseByteRange(c.req.header('range'), size);
|
||||||
|
if (range === 'unsatisfiable') {
|
||||||
|
headers.set('content-range', `bytes */${size}`);
|
||||||
|
// Explicit zero rather than an absent header: without it Node falls back
|
||||||
|
// to chunked encoding for a body that does not exist.
|
||||||
|
headers.set('content-length', '0');
|
||||||
|
return new Response(null, { status: 416, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = range ? range.start : 0;
|
||||||
|
const end = range ? range.end : Math.max(0, size - 1);
|
||||||
|
const length = size === 0 ? 0 : end - start + 1;
|
||||||
|
headers.set('content-length', String(length));
|
||||||
|
if (range) headers.set('content-range', `bytes ${start}-${end}/${size}`);
|
||||||
|
|
||||||
|
// A HEAD answers with the headers and no body — including the 206 status
|
||||||
|
// and Content-Range, so the player learns the file is seekable without
|
||||||
|
// fetching a byte of it.
|
||||||
|
if (c.req.method === 'HEAD') {
|
||||||
|
return new Response(null, { status: range ? 206 : 200, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = createReadStream(path, size === 0 ? undefined : { start, end });
|
||||||
|
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||||
|
status: range ? 206 : 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
LEARN_EMBED_REJECTION_MESSAGES,
|
LEARN_EMBED_REJECTION_MESSAGES,
|
||||||
LEARN_TRACKS,
|
LEARN_TRACKS,
|
||||||
LEARN_VISIBILITIES,
|
LEARN_VISIBILITIES,
|
||||||
learnEmbedUrl,
|
learnEmbed,
|
||||||
learnVisibilityPermitted,
|
learnVisibilityPermitted,
|
||||||
learnWatchUrl,
|
learnWatchUrl,
|
||||||
resolveLearnEmbed,
|
resolveLearnEmbed,
|
||||||
@@ -326,9 +326,9 @@ interface LearnRowForView {
|
|||||||
* available, not that it is broken.
|
* available, not that it is broken.
|
||||||
*/
|
*/
|
||||||
export function learnResourceView(row: LearnRowForView) {
|
export function learnResourceView(row: LearnRowForView) {
|
||||||
const embedUrl = learnEmbedUrl(row.provider, row.externalId);
|
const embed = learnEmbed(row.provider, row.externalId);
|
||||||
const watchUrl = learnWatchUrl(row.provider, row.externalId);
|
const watchUrl = learnWatchUrl(row.provider, row.externalId);
|
||||||
if (!embedUrl || !watchUrl) return null;
|
if (!embed || !watchUrl) return null;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
track: row.track,
|
track: row.track,
|
||||||
@@ -339,7 +339,14 @@ export function learnResourceView(row: LearnRowForView) {
|
|||||||
durationSeconds: row.durationSeconds,
|
durationSeconds: row.durationSeconds,
|
||||||
sortOrder: row.sortOrder,
|
sortOrder: row.sortOrder,
|
||||||
publishedAt: row.publishedAt,
|
publishedAt: row.publishedAt,
|
||||||
embedUrl,
|
/**
|
||||||
|
* The discriminated union — `kind: 'iframe' | 'video'` — is what the
|
||||||
|
* client branches on. Sent alongside the flat `embedUrl` rather than
|
||||||
|
* instead of it so a client mid-deploy keeps rendering; the flat field is
|
||||||
|
* the same string and will go once nothing reads it.
|
||||||
|
*/
|
||||||
|
embed,
|
||||||
|
embedUrl: embed.src,
|
||||||
watchUrl,
|
watchUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,17 @@ import {
|
|||||||
LEARN_CODE_TRACK,
|
LEARN_CODE_TRACK,
|
||||||
LEARN_FRAME_SRC_HOSTS,
|
LEARN_FRAME_SRC_HOSTS,
|
||||||
formatLearnDuration,
|
formatLearnDuration,
|
||||||
|
learnEmbed,
|
||||||
learnEmbedUrl,
|
learnEmbedUrl,
|
||||||
learnVisibilityPermitted,
|
learnVisibilityPermitted,
|
||||||
resolveLearnEmbed,
|
resolveLearnEmbed,
|
||||||
} from '@pig/core';
|
} from '@pig/core';
|
||||||
import type { Database } from '@pig/db';
|
import type { Database } from '@pig/db';
|
||||||
|
import { learnResources, platformSettings } from '@pig/db';
|
||||||
|
import { createMediaRoutes, LEARN_MEDIA_DIR_ENV, parseByteRange } from '../src/lib/media';
|
||||||
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { createApp } from '../src/app';
|
import { createApp } from '../src/app';
|
||||||
import { loadConfig } from '../src/lib/config';
|
import { loadConfig } from '../src/lib/config';
|
||||||
import {
|
import {
|
||||||
@@ -95,10 +101,89 @@ describe('embed allowlist', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('names every enabled host, so the CSP handoff cannot drift', () => {
|
it('names every enabled host, so the CSP handoff cannot drift', () => {
|
||||||
|
// The self-hosted provider is enabled and contributes NOTHING here. A
|
||||||
|
// native <video> on this origin is covered by `default-src 'self'`, and
|
||||||
|
// widening frame-src for it would hand out framing rights nothing needs.
|
||||||
assert.deepEqual(LEARN_FRAME_SRC_HOSTS, ['https://video.karti.ai']);
|
assert.deepEqual(LEARN_FRAME_SRC_HOSTS, ['https://video.karti.ai']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- self-hosted
|
||||||
|
|
||||||
|
describe('self-hosted media', () => {
|
||||||
|
it('resolves a /media/learn path to a native video, not an iframe', () => {
|
||||||
|
const resolved = resolveLearnEmbed('/media/learn/pig-tour.7f3a91c2.mp4');
|
||||||
|
assert.equal(resolved.ok, true);
|
||||||
|
assert.equal(resolved.ok && resolved.provider, 'pig');
|
||||||
|
assert.equal(resolved.ok && resolved.externalId, 'pig-tour.7f3a91c2.mp4');
|
||||||
|
assert.deepEqual(resolved.ok && resolved.embed, {
|
||||||
|
kind: 'video',
|
||||||
|
src: '/media/learn/pig-tour.7f3a91c2.mp4',
|
||||||
|
});
|
||||||
|
// The flat field stays in step for callers written before the union.
|
||||||
|
assert.equal(resolved.ok && resolved.embedUrl, '/media/learn/pig-tour.7f3a91c2.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still resolves a remote provider to an iframe', () => {
|
||||||
|
const resolved = resolveLearnEmbed('https://video.karti.ai/s/0n6n9p83efnxbs2');
|
||||||
|
assert.deepEqual(resolved.ok && resolved.embed, {
|
||||||
|
kind: 'iframe',
|
||||||
|
src: 'https://video.karti.ai/embed/0n6n9p83efnxbs2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses every path that would read a file we did not mean to serve', () => {
|
||||||
|
// Traversal in each encoding that has ever worked somewhere, a scheme, a
|
||||||
|
// host, a protocol-relative URL that a naive `startsWith('/')` would treat
|
||||||
|
// as a path, an extension we do not serve, and a bare dotfile.
|
||||||
|
const hostile = [
|
||||||
|
'/media/learn/../../etc/passwd',
|
||||||
|
'/media/learn/..%2f..%2fetc%2fpasswd',
|
||||||
|
'/media/learn/../secrets.mp4',
|
||||||
|
'/media/learn/..',
|
||||||
|
'/media/learn/sub/dir/video.mp4',
|
||||||
|
'/media/learn/',
|
||||||
|
'/etc/passwd',
|
||||||
|
'/media/other/video.mp4',
|
||||||
|
'//evil.example/media/learn/video.mp4',
|
||||||
|
'file:///media/learn/video.mp4',
|
||||||
|
'https://primeintellectgrowth.com/media/learn/pig-tour.7f3a91c2.mp4',
|
||||||
|
'/media/learn/.env',
|
||||||
|
'/media/learn/video.mp4.sh',
|
||||||
|
'/media/learn/video mp4.mp4',
|
||||||
|
'/media/learn/video.mp4?v=2',
|
||||||
|
'/media/learn/video.mp4#t=10',
|
||||||
|
'/media/learn/-leading-dash.mp4',
|
||||||
|
`/media/learn/${'a'.repeat(130)}.mp4`,
|
||||||
|
];
|
||||||
|
for (const candidate of hostile) {
|
||||||
|
assert.equal(resolveLearnEmbed(candidate).ok, false, `should reject: ${candidate}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-validates a stored filename rather than trusting the database', () => {
|
||||||
|
// Same argument as the Cap case: persistence is not validation. A row
|
||||||
|
// written by a future path that skipped the resolver must not become a
|
||||||
|
// file read.
|
||||||
|
assert.equal(learnEmbed('pig', '../../etc/passwd'), null);
|
||||||
|
assert.equal(learnEmbed('pig', 'pig-tour.mp4.sh'), null);
|
||||||
|
assert.deepEqual(learnEmbed('pig', 'pig-tour.7f3a91c2.mp4'), {
|
||||||
|
kind: 'video',
|
||||||
|
src: '/media/learn/pig-tour.7f3a91c2.mp4',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a self-hosted path at the create schema, on the platform track', () => {
|
||||||
|
const parsed = learnResourceCreateSchema.safeParse({
|
||||||
|
track: LEARN_CODE_TRACK,
|
||||||
|
title: 'A tour of PIG in five minutes',
|
||||||
|
url: '/media/learn/pig-tour.7f3a91c2.mp4',
|
||||||
|
visibility: 'code',
|
||||||
|
});
|
||||||
|
assert.equal(parsed.success, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ------------------------------------------------------------- the two rules
|
// ------------------------------------------------------------- the two rules
|
||||||
|
|
||||||
describe('code visibility', () => {
|
describe('code visibility', () => {
|
||||||
@@ -269,6 +354,232 @@ describe('attempt limiter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------- the public round trip
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A self-hosted row must survive the whole read path as a `video`.
|
||||||
|
*
|
||||||
|
* Asserted through `app.request` on the real public route rather than on the
|
||||||
|
* serialiser, because the failure this guards against is composition: the
|
||||||
|
* route enumerates its columns, drops rows it cannot rebuild an embed for, and
|
||||||
|
* is the one read a code-holder performs. A row that resolves fine in
|
||||||
|
* isolation and is silently dropped by `renderable()` would leave the Learn
|
||||||
|
* page empty with nothing in the log.
|
||||||
|
*/
|
||||||
|
describe('a self-hosted row through /api/learn/public', () => {
|
||||||
|
const config = loadConfig({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
DATABASE_URL: 'postgres://unused:unused@127.0.0.1:1/unused',
|
||||||
|
PIG_PUBLIC_URL: 'https://pig-learn-test.invalid',
|
||||||
|
SUPABASE_URL: 'https://identity-learn-test.invalid',
|
||||||
|
SUPABASE_ANON_KEY: 'learn-test-anon-key',
|
||||||
|
SUPABASE_SERVICE_KEY: '',
|
||||||
|
PIG_ADMIN_EMAILS: '',
|
||||||
|
PIGGY_ENABLED: 'false',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000001',
|
||||||
|
track: 'platform' as const,
|
||||||
|
title: 'A tour of PIG in five minutes',
|
||||||
|
summary: 'What the product is for.',
|
||||||
|
provider: 'pig' as const,
|
||||||
|
externalId: 'pig-tour.7f3a91c2.mp4',
|
||||||
|
visibility: 'code' as const,
|
||||||
|
durationSeconds: 300,
|
||||||
|
sortOrder: 1,
|
||||||
|
publishedAt: new Date('2026-08-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000002',
|
||||||
|
track: 'platform' as const,
|
||||||
|
title: 'The Cap-hosted one, still an iframe',
|
||||||
|
summary: null,
|
||||||
|
provider: 'cap' as const,
|
||||||
|
externalId: '0n6n9p83efnxbs2',
|
||||||
|
visibility: 'code' as const,
|
||||||
|
durationSeconds: 520,
|
||||||
|
sortOrder: 2,
|
||||||
|
publishedAt: new Date('2026-08-02T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Answers the settings lookup and the resource read, and nothing else. */
|
||||||
|
const db = {
|
||||||
|
select: () => ({
|
||||||
|
from: (table: unknown) => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: async () =>
|
||||||
|
table === platformSettings ? [{ code: ACCESS_CODE }] : [],
|
||||||
|
orderBy: async () => (table === learnResources ? rows : []),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as unknown as Database;
|
||||||
|
|
||||||
|
const authProvider = {
|
||||||
|
name: 'learn-test-stub',
|
||||||
|
async verifyAccessToken(): Promise<{ subject: string; email: string }> {
|
||||||
|
throw new Error('Not a valid identity token.');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns kind:"video" for the self-hosted row and kind:"iframe" for the remote one', async () => {
|
||||||
|
const app = createApp(config, db, authProvider);
|
||||||
|
const token = mintLearnToken(ACCESS_CODE, Date.now() + LEARN_TOKEN_TTL_MS);
|
||||||
|
const response = await app.request('https://pig-learn-test.invalid/api/learn/public', {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
resources: { title: string; embed: { kind: string; src: string }; embedUrl: string }[];
|
||||||
|
};
|
||||||
|
assert.equal(body.resources.length, 2, 'neither row may be dropped');
|
||||||
|
|
||||||
|
const [hosted, remote] = body.resources;
|
||||||
|
assert.deepEqual(hosted?.embed, {
|
||||||
|
kind: 'video',
|
||||||
|
src: '/media/learn/pig-tour.7f3a91c2.mp4',
|
||||||
|
});
|
||||||
|
// Same-origin and relative, so the page needs no CSP host for it at all.
|
||||||
|
assert.equal(hosted?.embedUrl, '/media/learn/pig-tour.7f3a91c2.mp4');
|
||||||
|
assert.equal(remote?.embed.kind, 'iframe');
|
||||||
|
assert.equal(remote?.embed.src, 'https://video.karti.ai/embed/0n6n9p83efnxbs2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves that src from the ASSEMBLED app, not just from the route factory', async () => {
|
||||||
|
/*
|
||||||
|
* The assertion that would have caught the media route shipping unmounted.
|
||||||
|
*
|
||||||
|
* `describe('the media route')` below exercises createMediaRoutes() as a
|
||||||
|
* standalone Hono app, which verifies the handler and proves nothing about
|
||||||
|
* whether createApp wires it in. It did not: every layer landed — the
|
||||||
|
* migration, the seed, both feeds, the bind mount, the docs — except the
|
||||||
|
* one that serves the bytes, and the whole suite stayed green. Requests to
|
||||||
|
* /media/learn/* fell through to server.ts's SPA fallback and returned
|
||||||
|
* HTTP 200 text/html, so the player showed a black box with working
|
||||||
|
* controls and no error.
|
||||||
|
*
|
||||||
|
* Assert against the composed application, and specifically on the
|
||||||
|
* content-type: the failure mode is a 200, not a 404.
|
||||||
|
*/
|
||||||
|
// The route reads its root from the environment at request time, so point
|
||||||
|
// it at a directory that really holds the file the feed advertises.
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'pig-media-mounted-'));
|
||||||
|
writeFileSync(join(root, 'pig-tour.7f3a91c2.mp4'), Buffer.alloc(2048, 7));
|
||||||
|
const previous = process.env[LEARN_MEDIA_DIR_ENV];
|
||||||
|
process.env[LEARN_MEDIA_DIR_ENV] = root;
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
const app = createApp(config, db, authProvider);
|
||||||
|
response = await app.request(
|
||||||
|
'https://pig-learn-test.invalid/media/learn/pig-tour.7f3a91c2.mp4',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env[LEARN_MEDIA_DIR_ENV];
|
||||||
|
else process.env[LEARN_MEDIA_DIR_ENV] = previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.notEqual(response.status, 404, 'the media route is not mounted in createApp');
|
||||||
|
assert.equal(
|
||||||
|
response.headers.get('content-type'),
|
||||||
|
'video/mp4',
|
||||||
|
'the SPA fallback answered instead of the media route',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------- serving the file
|
||||||
|
|
||||||
|
describe('the media route', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'pig-media-'));
|
||||||
|
// 1 KiB of distinguishable bytes: an assertion on a range is only meaningful
|
||||||
|
// if the wrong offset produces different content.
|
||||||
|
const body = Buffer.from(Array.from({ length: 1024 }, (_, i) => i % 251));
|
||||||
|
writeFileSync(join(root, 'pig-tour.7f3a91c2.mp4'), body);
|
||||||
|
const app = createMediaRoutes({ root });
|
||||||
|
const url = (path: string) => `https://pig.invalid${path}`;
|
||||||
|
|
||||||
|
it('serves the whole file with a seekable header set', async () => {
|
||||||
|
const response = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'));
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(response.headers.get('content-type'), 'video/mp4');
|
||||||
|
// Without this a browser will not attempt a range request at all, and the
|
||||||
|
// scrubber becomes decorative.
|
||||||
|
assert.equal(response.headers.get('accept-ranges'), 'bytes');
|
||||||
|
assert.equal(response.headers.get('content-length'), '1024');
|
||||||
|
assert.deepEqual(Buffer.from(await response.arrayBuffer()), body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a range with 206 and exactly those bytes', async () => {
|
||||||
|
const response = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'), {
|
||||||
|
headers: { range: 'bytes=100-199' },
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 206);
|
||||||
|
assert.equal(response.headers.get('content-range'), 'bytes 100-199/1024');
|
||||||
|
assert.equal(response.headers.get('content-length'), '100');
|
||||||
|
assert.deepEqual(Buffer.from(await response.arrayBuffer()), body.subarray(100, 200));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers an open-ended and a suffix range', async () => {
|
||||||
|
const open = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'), {
|
||||||
|
headers: { range: 'bytes=1000-' },
|
||||||
|
});
|
||||||
|
assert.equal(open.headers.get('content-range'), 'bytes 1000-1023/1024');
|
||||||
|
|
||||||
|
// How a player finds an MP4 moov atom at the end of the file. Getting this
|
||||||
|
// branch wrong is why some videos never start rather than never seek.
|
||||||
|
const suffix = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'), {
|
||||||
|
headers: { range: 'bytes=-24' },
|
||||||
|
});
|
||||||
|
assert.equal(suffix.headers.get('content-range'), 'bytes 1000-1023/1024');
|
||||||
|
assert.deepEqual(Buffer.from(await suffix.arrayBuffer()), body.subarray(1000));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a range past the end rather than restarting the file', () => {
|
||||||
|
// 200-with-the-whole-file here splices byte 0 into the middle of the
|
||||||
|
// player's buffer, which corrupts playback instead of failing it.
|
||||||
|
assert.equal(parseByteRange('bytes=2000-', 1024), 'unsatisfiable');
|
||||||
|
assert.equal(parseByteRange('bytes=500-400', 1024), 'unsatisfiable');
|
||||||
|
// A multi-range request is ignored, which the spec permits.
|
||||||
|
assert.equal(parseByteRange('bytes=0-10,20-30', 1024), null);
|
||||||
|
assert.equal(parseByteRange(undefined, 1024), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 416 with the real size so a client can recover', async () => {
|
||||||
|
const response = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'), {
|
||||||
|
headers: { range: 'bytes=5000-' },
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 416);
|
||||||
|
assert.equal(response.headers.get('content-range'), 'bytes */1024');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers HEAD without a body, so a player can probe cheaply', async () => {
|
||||||
|
const response = await app.request(url('/media/learn/pig-tour.7f3a91c2.mp4'), {
|
||||||
|
method: 'HEAD',
|
||||||
|
});
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(response.headers.get('content-length'), '1024');
|
||||||
|
assert.equal((await response.text()).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads nothing outside the media directory', async () => {
|
||||||
|
// The route sees these as filenames; each must 404 rather than resolve.
|
||||||
|
for (const path of [
|
||||||
|
'/media/learn/..%2f..%2fpackage.json',
|
||||||
|
'/media/learn/%2e%2e%2fpackage.json',
|
||||||
|
'/media/learn/pig-tour.7f3a91c2.mp4.sh',
|
||||||
|
'/media/learn/absent.7f3a91c2.mp4',
|
||||||
|
]) {
|
||||||
|
const response = await app.request(url(path));
|
||||||
|
assert.equal(response.status, 404, `should not serve: ${path}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('duration formatting', () => {
|
describe('duration formatting', () => {
|
||||||
it('crosses the hour without renaming the minutes', () => {
|
it('crosses the hour without renaming the minutes', () => {
|
||||||
assert.equal(formatLearnDuration(272), '4:32');
|
assert.equal(formatLearnDuration(272), '4:32');
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* The admin add form.
|
||||||
|
*
|
||||||
|
* Track and visibility are plain selects rather than a clever control because
|
||||||
|
* the pairing rule between them is enforced by the API and the database, not
|
||||||
|
* here — so the UI's job is to be legible, and disabling the option would only
|
||||||
|
* hide a refusal the server is going to make anyway with a better message.
|
||||||
|
*/
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
LEARN_TRACKS,
|
||||||
|
LEARN_TRACK_LABELS,
|
||||||
|
LEARN_VISIBILITIES,
|
||||||
|
type LearnTrack,
|
||||||
|
type LearnVisibility,
|
||||||
|
} from '@pig/core';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Button, Input } from '@/components/ui';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import type { LearnResourceView } from './model';
|
||||||
|
|
||||||
|
export function AddResourceDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [track, setTrack] = useState<LearnTrack>('platform');
|
||||||
|
const [visibility, setVisibility] = useState<LearnVisibility>('code');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [summary, setSummary] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [minutes, setMinutes] = useState('');
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const parsedMinutes = Number(minutes);
|
||||||
|
return api<LearnResourceView>('/api/learn/resources', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
track,
|
||||||
|
visibility,
|
||||||
|
title: title.trim(),
|
||||||
|
summary: summary.trim() || undefined,
|
||||||
|
url: url.trim(),
|
||||||
|
durationSeconds:
|
||||||
|
minutes.trim() && Number.isFinite(parsedMinutes) && parsedMinutes > 0
|
||||||
|
? Math.round(parsedMinutes * 60)
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async (created) => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['learn'] });
|
||||||
|
onOpenChange(false);
|
||||||
|
setTitle('');
|
||||||
|
setSummary('');
|
||||||
|
setUrl('');
|
||||||
|
setMinutes('');
|
||||||
|
toast.success(`Added “${created.title}”.`);
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Could not add that video.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90dvh] w-[calc(100vw-1.5rem)] max-w-lg overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add a video</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Paste a share link from video.karti.ai. Other hosts are rejected until they are added
|
||||||
|
to the allowlist.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="flex min-w-0 flex-col gap-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
create.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Share link" htmlFor="learn-url">
|
||||||
|
<Input
|
||||||
|
id="learn-url"
|
||||||
|
value={url}
|
||||||
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
|
placeholder="https://video.karti.ai/s/…"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Title" htmlFor="learn-title">
|
||||||
|
<Input
|
||||||
|
id="learn-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Summary" htmlFor="learn-summary">
|
||||||
|
<Input
|
||||||
|
id="learn-summary"
|
||||||
|
value={summary}
|
||||||
|
onChange={(event) => setSummary(event.target.value)}
|
||||||
|
placeholder="What someone learns from it"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Track" htmlFor="learn-track">
|
||||||
|
<NativeSelect
|
||||||
|
id="learn-track"
|
||||||
|
value={track}
|
||||||
|
onChange={(value) => setTrack(value as LearnTrack)}
|
||||||
|
options={LEARN_TRACKS.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: LEARN_TRACK_LABELS[value],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Visibility" htmlFor="learn-visibility">
|
||||||
|
<NativeSelect
|
||||||
|
id="learn-visibility"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(value) => setVisibility(value as LearnVisibility)}
|
||||||
|
options={LEARN_VISIBILITIES.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: value === 'code' ? 'Anyone with the code' : 'Members only',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Length in minutes" htmlFor="learn-minutes">
|
||||||
|
<Input
|
||||||
|
id="learn-minutes"
|
||||||
|
value={minutes}
|
||||||
|
onChange={(event) => setMinutes(event.target.value)}
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex min-w-0 flex-col gap-2 pt-1 sm:flex-row sm:justify-end">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={create.isPending || !url.trim() || !title.trim()}
|
||||||
|
>
|
||||||
|
{create.isPending ? 'Adding…' : 'Add video'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<label htmlFor={htmlFor} className="text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelect({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="h-11 w-full min-w-0 rounded-lg border border-border bg-surface px-3 text-base text-fg focus-visible:border-accent"
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Archive, as an overlay rather than a footer.
|
||||||
|
*
|
||||||
|
* It used to sit in the card's footer, which is where a reader's eye lands
|
||||||
|
* after the title — so the most destructive control on the page was competing
|
||||||
|
* with the content for attention, for the majority of viewers who cannot even
|
||||||
|
* use it. It is now a sibling of the play button (never a descendant: a button
|
||||||
|
* inside a button is invalid and Firefox drops the inner one) and only appears
|
||||||
|
* once an admin has asked to manage.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { cn } from '@/components/ui';
|
||||||
|
|
||||||
|
export function ArchiveControl({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
|
const archive = useMutation({
|
||||||
|
mutationFn: () => api<unknown>(`/api/learn/resources/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['learn'] });
|
||||||
|
toast.success(`Archived “${title}”.`);
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
setConfirming(false);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Could not archive that video.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// Two taps, no dialog. A modal for an archive that a colleague can
|
||||||
|
// restore is ceremony; one silent tap is a video gone from a shared
|
||||||
|
// library because a thumb brushed the corner of a card.
|
||||||
|
if (!confirming) {
|
||||||
|
setConfirming(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
archive.mutate();
|
||||||
|
}}
|
||||||
|
onBlur={() => setConfirming(false)}
|
||||||
|
disabled={archive.isPending}
|
||||||
|
aria-label={confirming ? `Confirm archiving ${title}` : `Archive ${title}`}
|
||||||
|
className={cn(
|
||||||
|
'tap inline-flex items-center gap-1.5 rounded-lg border border-border px-2.5 text-xs font-medium',
|
||||||
|
'bg-surface/90 backdrop-blur-sm transition-colors disabled:opacity-50',
|
||||||
|
confirming ? 'text-danger hover:bg-danger/10' : 'text-muted hover:bg-surface-2 hover:text-fg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 shrink-0" aria-hidden />
|
||||||
|
{confirming ? 'Confirm' : 'Archive'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* The first thing a stranger sees.
|
||||||
|
*
|
||||||
|
* This route is what gets pasted into a message to someone at Prime Intellect,
|
||||||
|
* and until they type the code it is the entire product as far as they are
|
||||||
|
* concerned. It was a bare label, an input and a button — a form, with no
|
||||||
|
* indication of what it opened. So the gate is now the hero: it says whose
|
||||||
|
* page this is, what is behind the code, and how long the access lasts, and
|
||||||
|
* the input is the largest thing on the screen.
|
||||||
|
*
|
||||||
|
* The right-hand panel is ornament and is marked as such. It is a locked
|
||||||
|
* poster, not a fake video: inventing a plausible-looking thumbnail with a
|
||||||
|
* made-up title would be a promise about content that may not exist.
|
||||||
|
*/
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { ArrowRight, Lock, PlayCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ApiError } from '@/lib/api';
|
||||||
|
import { Button, Input } from '@/components/ui';
|
||||||
|
|
||||||
|
/** Uneven on purpose — three identical bars read as a loading state. */
|
||||||
|
const BAR_WIDTHS = [{ width: '78%' }, { width: '58%' }, { width: '68%' }];
|
||||||
|
|
||||||
|
export function LearnAccessHero({ onUnlocked }: { onUnlocked: (token: string) => void }) {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
|
||||||
|
const unlock = useMutation({
|
||||||
|
mutationFn: async (value: string) => {
|
||||||
|
const response = await fetch('/api/learn/access', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code: value }),
|
||||||
|
});
|
||||||
|
const body = (await response.json().catch(() => ({}))) as {
|
||||||
|
token?: string;
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
};
|
||||||
|
if (!response.ok || !body.token) {
|
||||||
|
throw new ApiError(body.error ?? 'That code is not valid.', response.status, body.code);
|
||||||
|
}
|
||||||
|
return body.token;
|
||||||
|
},
|
||||||
|
onSuccess: (minted) => {
|
||||||
|
onUnlocked(minted);
|
||||||
|
toast.success('Unlocked. Here are the product walkthroughs.');
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'That code is not valid.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const trimmed = code.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
unlock.mutate(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative min-w-0 overflow-hidden rounded-3xl border border-border bg-surface">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-[radial-gradient(circle_at_82%_-10%,hsl(var(--accent-subtle)),transparent_58%),radial-gradient(circle_at_-5%_110%,hsl(var(--surface-2)),transparent_55%)]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative grid min-w-0 gap-10 p-6 sm:p-10 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:items-center lg:gap-12 lg:p-14">
|
||||||
|
<div className="flex min-w-0 flex-col gap-5">
|
||||||
|
<span className="inline-flex w-fit min-w-0 items-center gap-2 rounded-full border border-border bg-surface px-3 py-1 text-xs font-medium text-muted">
|
||||||
|
<PlayCircle className="size-3.5 shrink-0 text-accent-fg" aria-hidden />
|
||||||
|
<span className="min-w-0">Shared preview · Prime Intellect Growth</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="min-w-0 text-3xl font-semibold leading-[1.1] tracking-tight sm:text-4xl lg:text-[2.75rem]">
|
||||||
|
See how PIG runs both sides of the book.
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="min-w-0 max-w-xl text-base leading-7 text-muted">
|
||||||
|
Short product walkthroughs: what the platform does with contracted capacity, how it
|
||||||
|
joins what we bought to what we sold, and what the numbers on the margin report
|
||||||
|
actually mean. Enter the code you were given to watch them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="flex min-w-0 flex-col gap-2 pt-1 sm:flex-row">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<label htmlFor="learn-code" className="sr-only">
|
||||||
|
Access code
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="learn-code"
|
||||||
|
value={code}
|
||||||
|
onChange={(event) => setCode(event.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="Enter your access code"
|
||||||
|
className="h-12 w-full min-w-0 bg-surface text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="shrink-0"
|
||||||
|
disabled={unlock.isPending || !code.trim()}
|
||||||
|
>
|
||||||
|
{unlock.isPending ? 'Checking…' : 'Unlock'}
|
||||||
|
{unlock.isPending ? null : <ArrowRight className="size-4" aria-hidden />}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="min-w-0 text-sm text-muted">
|
||||||
|
Whoever shared this page has the code. Access lasts twelve hours and covers the
|
||||||
|
platform walkthroughs only.{' '}
|
||||||
|
<a href="/" className="font-medium text-accent-fg underline underline-offset-4">
|
||||||
|
Have a PIG account? Sign in
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Decorative, and hidden from assistive technology: it carries no
|
||||||
|
information the copy has not already given. Redacted bars rather than
|
||||||
|
invented titles — a plausible-looking fake thumbnail is a promise
|
||||||
|
about content that may not exist.
|
||||||
|
*/}
|
||||||
|
<div className="hidden min-w-0 lg:block" aria-hidden>
|
||||||
|
<div className="relative flex min-w-0 flex-col gap-3 rounded-2xl border border-border bg-surface/70 p-4 shadow-sm backdrop-blur-sm">
|
||||||
|
{[0, 1, 2].map((row) => (
|
||||||
|
<div key={row} className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="relative aspect-video w-24 shrink-0 overflow-hidden rounded-lg border border-border bg-gradient-to-br from-accent-subtle via-surface-2 to-surface">
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-muted">
|
||||||
|
<Lock className="size-4" strokeWidth={1.75} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
|
<span className="block h-2.5 rounded-full bg-fg/[0.09]" style={BAR_WIDTHS[row]} />
|
||||||
|
<span className="block h-2 w-full rounded-full bg-fg/[0.05]" />
|
||||||
|
<span className="block h-2 w-2/3 rounded-full bg-fg/[0.05]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="border-t border-border pt-3 text-center text-sm font-medium text-muted">
|
||||||
|
Product walkthroughs, waiting on a code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* The player.
|
||||||
|
*
|
||||||
|
* Two kinds of source, one frame. A Cap resource is a third-party document and
|
||||||
|
* has to be an iframe with a sandbox; a PIG-hosted resource is bytes from this
|
||||||
|
* origin and has to be a native `<video>`, because framing our own origin
|
||||||
|
* would hand a media file a document context it has no business having. The
|
||||||
|
* branch is on the resolved `kind`, never on the url — see `model.ts`.
|
||||||
|
*
|
||||||
|
* Nothing here builds a source. Every src arrives from the API already
|
||||||
|
* resolved through the host allowlist in `@pig/core`; a resource the server
|
||||||
|
* could not resolve is not in the response at all. Concatenating a URL in this
|
||||||
|
* file would reintroduce exactly the hole the allowlist closes.
|
||||||
|
*
|
||||||
|
* The media box is a fixed `aspect-video` with an absolutely positioned child,
|
||||||
|
* so the dialog is the same height before and after the embed loads. Sizing it
|
||||||
|
* from the loaded content instead is what makes a player jump under the
|
||||||
|
* pointer a beat after it opens.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import { formatLearnDuration, LEARN_TRACK_LABELS } from '@pig/core';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Badge } from '@/components/ui';
|
||||||
|
import { learnPlayback, watchHost, type LearnResourceView } from './model';
|
||||||
|
|
||||||
|
export function LearnPlayerDialog({
|
||||||
|
resource,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
resource: LearnResourceView | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
// Reset per resource, or a failure on one video would persist as the error
|
||||||
|
// state of the next one opened.
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
useEffect(() => setFailed(false), [resource?.id]);
|
||||||
|
|
||||||
|
const playback = resource ? learnPlayback(resource) : null;
|
||||||
|
const duration = formatLearnDuration(resource?.durationSeconds);
|
||||||
|
const host = watchHost(resource?.watchUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={resource !== null} onOpenChange={(next) => !next && onClose()}>
|
||||||
|
{/* Esc and the overlay both close it — Radix's behaviour, kept. */}
|
||||||
|
<DialogContent className="max-h-[92dvh] w-[calc(100vw-1.5rem)] max-w-4xl gap-0 overflow-y-auto p-0">
|
||||||
|
{resource ? (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="min-w-0 gap-1 p-4 pr-14 text-left sm:p-5 sm:pr-16">
|
||||||
|
<DialogTitle className="min-w-0 break-words text-base leading-snug sm:text-lg">
|
||||||
|
{resource.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="min-w-0 break-words">
|
||||||
|
{resource.summary ?? `${LEARN_TRACK_LABELS[resource.track]} track.`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="relative aspect-video w-full min-w-0 border-y border-border bg-surface-2">
|
||||||
|
{playback?.kind === 'video' ? (
|
||||||
|
<video
|
||||||
|
key={resource.id}
|
||||||
|
src={playback.src}
|
||||||
|
poster={playback.poster}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
className="absolute inset-0 size-full"
|
||||||
|
/*
|
||||||
|
* A resolved source that will not load is an ORDINARY state
|
||||||
|
* here, not an edge case: media filenames are content-
|
||||||
|
* addressed, so re-rendering a video leaves the old row
|
||||||
|
* pointing at a file that no longer exists, and the seed
|
||||||
|
* deliberately reports that rather than resolving it. Without
|
||||||
|
* this the viewer gets a black rectangle with a scrubber that
|
||||||
|
* does nothing and no explanation.
|
||||||
|
*/
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{playback?.kind === 'iframe' ? (
|
||||||
|
/*
|
||||||
|
* The sandbox keeps the frame from navigating the top window or
|
||||||
|
* opening downloads; `allow-same-origin` is safe and necessary
|
||||||
|
* here because the frame is cross-origin, so "same origin"
|
||||||
|
* means the video host's own, not PIG's.
|
||||||
|
*/
|
||||||
|
<iframe
|
||||||
|
key={resource.id}
|
||||||
|
src={playback.src}
|
||||||
|
title={resource.title}
|
||||||
|
className="absolute inset-0 size-full border-0"
|
||||||
|
allow="autoplay; fullscreen; picture-in-picture; clipboard-write"
|
||||||
|
allowFullScreen
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-presentation"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!playback || failed ? (
|
||||||
|
<p className="absolute inset-0 flex items-center justify-center bg-surface-2 p-6 text-center text-sm text-muted">
|
||||||
|
{failed
|
||||||
|
? 'This video could not be loaded. The recording may have been replaced — ask an admin to refresh it.'
|
||||||
|
: 'This video has no playable source.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 p-4 sm:p-5">
|
||||||
|
<Badge tone="neutral">{LEARN_TRACK_LABELS[resource.track]}</Badge>
|
||||||
|
{duration ? <Badge tone="neutral" className="nums">{duration}</Badge> : null}
|
||||||
|
{playback?.kind === 'iframe' && resource.watchUrl ? (
|
||||||
|
<a
|
||||||
|
href={resource.watchUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="tap ml-auto inline-flex min-w-0 items-center gap-1.5 text-sm font-medium text-accent-fg underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4 shrink-0" aria-hidden />
|
||||||
|
<span className="min-w-0 break-words">Open on {host ?? 'the video host'}</span>
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* The 16:9 area a video card leads with.
|
||||||
|
*
|
||||||
|
* There are no thumbnail images — nothing renders a frame of a Cap embed
|
||||||
|
* without loading the embed, and loading nine of them to decorate a grid is
|
||||||
|
* how a page becomes unusable on a phone. So the poster is generated: a
|
||||||
|
* gradient picked deterministically from the resource id, a watermark glyph
|
||||||
|
* for the track, and the two things a reader actually needs on it — an
|
||||||
|
* unmistakable play affordance and the duration.
|
||||||
|
*
|
||||||
|
* Deterministic, not random, because a card that re-tints on every render
|
||||||
|
* reads as a bug and destroys the sense that these are distinct objects.
|
||||||
|
*/
|
||||||
|
import { BookOpen, LineChart, MonitorPlay, Play } from 'lucide-react';
|
||||||
|
import type { LearnTrack } from '@pig/core';
|
||||||
|
import { cn } from '@/components/ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tint is a pair of semantic tokens, so the whole set re-tints with the
|
||||||
|
* user's accent and inverts correctly in dark mode without a second palette.
|
||||||
|
*/
|
||||||
|
const TINTS = [
|
||||||
|
'from-accent-subtle via-surface-2 to-surface',
|
||||||
|
'from-surface-2 via-accent-subtle to-surface',
|
||||||
|
'from-surface via-surface-2 to-accent-subtle',
|
||||||
|
'from-accent-subtle via-surface to-surface-2',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TRACK_GLYPHS: Record<LearnTrack, typeof Play> = {
|
||||||
|
supply: LineChart,
|
||||||
|
demand: BookOpen,
|
||||||
|
platform: MonitorPlay,
|
||||||
|
};
|
||||||
|
|
||||||
|
function tintFor(seed: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let index = 0; index < seed.length; index += 1) {
|
||||||
|
hash = (hash * 31 + seed.charCodeAt(index)) % 100_000;
|
||||||
|
}
|
||||||
|
return TINTS[hash % TINTS.length] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LearnPoster({
|
||||||
|
seed,
|
||||||
|
track,
|
||||||
|
duration,
|
||||||
|
size = 'card',
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
seed: string;
|
||||||
|
track: LearnTrack;
|
||||||
|
duration: string | null;
|
||||||
|
/** `row` drops the ornament and shrinks the play button for a list thumbnail. */
|
||||||
|
size?: 'card' | 'row';
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const Glyph = TRACK_GLYPHS[track];
|
||||||
|
const compact = size === 'row';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative aspect-video w-full min-w-0 overflow-hidden bg-gradient-to-br',
|
||||||
|
tintFor(seed),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
Texture, so a generated poster reads as an image rather than as a card
|
||||||
|
that failed to load. All three layers are the palette's own tokens at
|
||||||
|
low alpha, which is what keeps them legible in both themes without a
|
||||||
|
second set of values for dark.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-[repeating-linear-gradient(135deg,hsl(var(--fg)/0.04)_0px,hsl(var(--fg)/0.04)_1px,transparent_1px,transparent_10px)]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-[radial-gradient(circle_at_28%_18%,hsl(var(--surface)/0.8),transparent_62%)]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{/* The track's glyph, at card size only — at thumbnail size it collides
|
||||||
|
with the play button and reads as a second, broken control. */}
|
||||||
|
{compact ? null : (
|
||||||
|
<Glyph
|
||||||
|
className="absolute -bottom-6 -right-4 size-32 text-fg/[0.06]"
|
||||||
|
strokeWidth={1.25}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-full border border-border',
|
||||||
|
'bg-surface/85 text-fg shadow-sm backdrop-blur-sm',
|
||||||
|
'transition-transform duration-200 group-hover:scale-105 group-focus-visible:scale-105',
|
||||||
|
compact ? 'size-9' : 'size-14',
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Play className={compact ? 'size-4' : 'size-6'} fill="currentColor" strokeWidth={0} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{duration ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'nums absolute rounded-md bg-fg/85 px-1.5 py-0.5 text-xs font-medium text-bg',
|
||||||
|
compact ? 'bottom-1 right-1' : 'bottom-2 right-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* What is behind the door, shown to someone standing outside it.
|
||||||
|
*
|
||||||
|
* The locked concept panel is deliberate, not an oversight: a code-holder is
|
||||||
|
* shown that supply and demand material exists and is behind sign-in, because
|
||||||
|
* the point of this page for an outsider is partly to advertise the rest of
|
||||||
|
* it. That argument only pays off if the panel *sells* — a grey "members only"
|
||||||
|
* box tells a visitor they are unwelcome and nothing else — so each track is
|
||||||
|
* named, described and given its own tile.
|
||||||
|
*
|
||||||
|
* The server never sends a single row of the locked tracks. This panel is a
|
||||||
|
* signpost, not a redaction.
|
||||||
|
*/
|
||||||
|
import { KeyRound, LineChart, Lock, MonitorPlay, Users } from 'lucide-react';
|
||||||
|
import { LEARN_TRACK_DESCRIPTIONS, LEARN_TRACK_LABELS, type LearnTrack } from '@pig/core';
|
||||||
|
import { Button, Card } from '@/components/ui';
|
||||||
|
|
||||||
|
const TRACK_ICONS: Record<LearnTrack, typeof Lock> = {
|
||||||
|
supply: LineChart,
|
||||||
|
demand: Users,
|
||||||
|
platform: MonitorPlay,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LearnTrackTeaser {
|
||||||
|
track: LearnTrack;
|
||||||
|
/** `code` reads as an invitation; `members` reads as a locked door. */
|
||||||
|
access: 'code' | 'members';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LearnTrackPanel({
|
||||||
|
heading,
|
||||||
|
description,
|
||||||
|
teasers,
|
||||||
|
showSignIn = true,
|
||||||
|
}: {
|
||||||
|
heading: string;
|
||||||
|
description: string;
|
||||||
|
teasers: readonly LearnTrackTeaser[];
|
||||||
|
showSignIn?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="flex min-w-0 flex-col gap-5 p-5 sm:p-6">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">{heading}</h2>
|
||||||
|
<p className="min-w-0 max-w-2xl text-sm leading-6 text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid min-w-0 list-none gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{teasers.map(({ track, access }) => {
|
||||||
|
const Icon = TRACK_ICONS[track];
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={track}
|
||||||
|
className="flex min-w-0 flex-col gap-2 rounded-xl border border-border bg-surface-2/60 p-4"
|
||||||
|
>
|
||||||
|
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg bg-accent-subtle text-accent-fg">
|
||||||
|
<Icon className="size-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<p className="min-w-0 font-semibold leading-snug">{LEARN_TRACK_LABELS[track]}</p>
|
||||||
|
<p className="min-w-0 break-words text-sm leading-6 text-muted">
|
||||||
|
{LEARN_TRACK_DESCRIPTIONS[track]}
|
||||||
|
</p>
|
||||||
|
<p className="mt-auto inline-flex min-w-0 items-center gap-1.5 pt-2 text-xs font-medium text-muted">
|
||||||
|
{access === 'code' ? (
|
||||||
|
<>
|
||||||
|
<KeyRound className="size-3.5 shrink-0 text-accent-fg" aria-hidden />
|
||||||
|
<span className="min-w-0 text-accent-fg">Opens with your code</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lock className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
<span className="min-w-0">Members only</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{showSignIn ? (
|
||||||
|
<div className="flex min-w-0 flex-col gap-2 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="min-w-0 text-sm text-muted">
|
||||||
|
Concept training is for the go-to-market team. Sign in with your PIG account to watch
|
||||||
|
it.
|
||||||
|
</p>
|
||||||
|
<Button variant="primary" className="shrink-0" asChild>
|
||||||
|
<a href="/">Sign in</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* A concept video, as a browsable card.
|
||||||
|
*
|
||||||
|
* Concepts are market education — someone scans the shelf and picks what they
|
||||||
|
* need — so this is a poster-led grid tile. The platform track is a course you
|
||||||
|
* work through in order and is rendered as a list instead; see
|
||||||
|
* `LearnWalkthroughList`.
|
||||||
|
*/
|
||||||
|
import { formatLearnDuration } from '@pig/core';
|
||||||
|
import { Badge, Card } from '@/components/ui';
|
||||||
|
import { ArchiveControl } from './ArchiveControl';
|
||||||
|
import { LearnPoster } from './LearnPoster';
|
||||||
|
import type { LearnResourceView } from './model';
|
||||||
|
|
||||||
|
export function LearnVideoCard({
|
||||||
|
resource,
|
||||||
|
managing,
|
||||||
|
onPlay,
|
||||||
|
}: {
|
||||||
|
resource: LearnResourceView;
|
||||||
|
managing: boolean;
|
||||||
|
onPlay: (resource: LearnResourceView) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPlay(resource)}
|
||||||
|
// The ring is inset because the card clips its overflow, and an offset
|
||||||
|
// ring on a clipped child is a focus indicator nobody can see.
|
||||||
|
className="flex min-w-0 flex-1 flex-col text-left focus-visible:ring-inset focus-visible:ring-offset-0"
|
||||||
|
>
|
||||||
|
<LearnPoster
|
||||||
|
seed={resource.id}
|
||||||
|
track={resource.track}
|
||||||
|
duration={formatLearnDuration(resource.durationSeconds)}
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5 p-4">
|
||||||
|
{/* break-words, not truncate: a title is the only way to tell two
|
||||||
|
walkthroughs apart, and an unbroken word at 393px is what drags
|
||||||
|
the whole page sideways. */}
|
||||||
|
<h3 className="min-w-0 break-words font-semibold leading-snug">{resource.title}</h3>
|
||||||
|
{resource.summary ? (
|
||||||
|
<p className="line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
|
||||||
|
{resource.summary}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{managing ? (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 flex min-w-0 items-start justify-between gap-2 p-2">
|
||||||
|
<Badge
|
||||||
|
tone={resource.visibility === 'code' ? 'accent' : 'neutral'}
|
||||||
|
className="pointer-events-auto bg-surface/90 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{resource.visibility === 'code' ? 'Shared by code' : 'Members only'}
|
||||||
|
</Badge>
|
||||||
|
<ArchiveControl
|
||||||
|
id={resource.id}
|
||||||
|
title={resource.title}
|
||||||
|
className="pointer-events-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* The platform track, as an ordered course.
|
||||||
|
*
|
||||||
|
* Product how-to has a running order — `sortOrder` is the curriculum, and
|
||||||
|
* "your first hour in PIG" is not interchangeable with the margin report. A
|
||||||
|
* grid of equal tiles says "pick one"; a numbered list says "start here", so
|
||||||
|
* the two tracks are rendered as different objects rather than one
|
||||||
|
* undifferentiated grid.
|
||||||
|
*/
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { formatLearnDuration } from '@pig/core';
|
||||||
|
import { Badge, Card } from '@/components/ui';
|
||||||
|
import { ArchiveControl } from './ArchiveControl';
|
||||||
|
import { LearnPoster } from './LearnPoster';
|
||||||
|
import { bySortOrder, type LearnResourceView } from './model';
|
||||||
|
|
||||||
|
export function LearnWalkthroughList({
|
||||||
|
resources,
|
||||||
|
managing,
|
||||||
|
onPlay,
|
||||||
|
}: {
|
||||||
|
resources: readonly LearnResourceView[];
|
||||||
|
managing: boolean;
|
||||||
|
onPlay: (resource: LearnResourceView) => void;
|
||||||
|
}) {
|
||||||
|
const ordered = bySortOrder(resources);
|
||||||
|
|
||||||
|
return (
|
||||||
|
/* Width is the caller's business — this list sits in a 1024px page column
|
||||||
|
for a code-holder and in a capped column inside the shell for a member,
|
||||||
|
and a cap here would fight one of them. */
|
||||||
|
<ol className="flex min-w-0 list-none flex-col gap-3">
|
||||||
|
{ordered.map((resource, index) => (
|
||||||
|
<li key={resource.id} className="min-w-0">
|
||||||
|
<Card className="group relative flex min-w-0 flex-col overflow-hidden transition-shadow hover:shadow-md sm:flex-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPlay(resource)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 p-3 text-left focus-visible:ring-inset focus-visible:ring-offset-0 sm:gap-4 sm:p-4"
|
||||||
|
>
|
||||||
|
<div className="w-28 shrink-0 overflow-hidden rounded-lg border border-border sm:w-44">
|
||||||
|
<LearnPoster
|
||||||
|
seed={resource.id}
|
||||||
|
track={resource.track}
|
||||||
|
duration={formatLearnDuration(resource.durationSeconds)}
|
||||||
|
size="row"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<p className="nums text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-accent-fg">
|
||||||
|
Step {index + 1}
|
||||||
|
</p>
|
||||||
|
<h3 className="min-w-0 break-words font-semibold leading-snug">
|
||||||
|
{resource.title}
|
||||||
|
</h3>
|
||||||
|
{resource.summary ? (
|
||||||
|
<p className="line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
|
||||||
|
{resource.summary}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
className="hidden size-5 shrink-0 text-muted transition-transform group-hover:translate-x-0.5 sm:block"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{managing ? (
|
||||||
|
/* A right-hand rail on a desktop row, a strip underneath on a
|
||||||
|
phone — squeezed into 393px beside the text it left the title
|
||||||
|
wrapping one word to a line. */
|
||||||
|
<div className="flex min-w-0 shrink-0 flex-row items-center justify-between gap-2 border-t border-border p-2 sm:flex-col sm:items-end sm:justify-center sm:border-l sm:border-t-0">
|
||||||
|
<Badge tone={resource.visibility === 'code' ? 'accent' : 'neutral'}>
|
||||||
|
{resource.visibility === 'code' ? 'By code' : 'Members'}
|
||||||
|
</Badge>
|
||||||
|
<ArchiveControl id={resource.id} title={resource.title} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* The shapes the Learn page reads off the wire, and the one decision every
|
||||||
|
* player has to make: iframe or `<video>`.
|
||||||
|
*
|
||||||
|
* The API is mid-migration. It serialises `embedUrl` today, and a self-hosted
|
||||||
|
* provider is landing that resolves to `LearnEmbed` — a discriminated union
|
||||||
|
* carrying `kind`. Rather than wait for the shape to settle, this reads
|
||||||
|
* whichever of the three forms is present, in order of how much the server has
|
||||||
|
* actually told us: an explicit `embed` object, then a `kind` beside the url,
|
||||||
|
* then an inference from the url itself. The inference is the only branch that
|
||||||
|
* guesses, and it guesses in the safe direction — a relative path is bytes
|
||||||
|
* this origin serves, so it becomes a `<video>` and never an iframe pointed at
|
||||||
|
* our own origin.
|
||||||
|
*/
|
||||||
|
import { LEARN_MEDIA_PATH_PREFIX, type LearnTrack, type LearnVisibility } from '@pig/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural rather than an import of `LearnEmbed`, deliberately. This file
|
||||||
|
* describes *untrusted JSON*, not the server's type: a field the server has
|
||||||
|
* not sent yet must be optional here or the compiler will assert a guarantee
|
||||||
|
* the response does not carry.
|
||||||
|
*/
|
||||||
|
export interface LearnEmbedPayload {
|
||||||
|
kind?: string;
|
||||||
|
src?: string;
|
||||||
|
poster?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LearnResourceView {
|
||||||
|
id: string;
|
||||||
|
track: LearnTrack;
|
||||||
|
title: string;
|
||||||
|
summary: string | null;
|
||||||
|
provider: string;
|
||||||
|
visibility: LearnVisibility;
|
||||||
|
durationSeconds: number | null;
|
||||||
|
sortOrder: number;
|
||||||
|
publishedAt: string;
|
||||||
|
/** The settled shape. Present once the self-hosted provider lands. */
|
||||||
|
embed?: LearnEmbedPayload | null;
|
||||||
|
/** The discriminator on its own, if it arrives beside the url instead. */
|
||||||
|
embedKind?: string | null;
|
||||||
|
/** Today's shape: a resolved url with no discriminator. */
|
||||||
|
embedUrl?: string | null;
|
||||||
|
watchUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberFeed {
|
||||||
|
tracks: Record<LearnTrack, LearnResourceView[]>;
|
||||||
|
canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicFeed {
|
||||||
|
track: LearnTrack;
|
||||||
|
expiresAt: string;
|
||||||
|
resources: LearnResourceView[];
|
||||||
|
lockedTracks: LearnTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LearnPlayback =
|
||||||
|
| { kind: 'iframe'; src: string }
|
||||||
|
| { kind: 'video'; src: string; poster?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A source with no host is a source on this origin, and this origin serves
|
||||||
|
* media files, not embeddable documents. Protocol-relative (`//host/…`) is
|
||||||
|
* excluded because it is another origin wearing a relative path's clothes.
|
||||||
|
*/
|
||||||
|
function isSelfHostedSource(src: string): boolean {
|
||||||
|
if (src.startsWith(LEARN_MEDIA_PATH_PREFIX)) return true;
|
||||||
|
return src.startsWith('/') && !src.startsWith('//');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function learnPlayback(resource: LearnResourceView): LearnPlayback | null {
|
||||||
|
const embed = resource.embed;
|
||||||
|
if (embed?.src) {
|
||||||
|
if (embed.kind === 'video') return { kind: 'video', src: embed.src, poster: embed.poster };
|
||||||
|
if (embed.kind === 'iframe') return { kind: 'iframe', src: embed.src };
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = embed?.src ?? resource.embedUrl;
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
if (resource.embedKind === 'video') return { kind: 'video', src };
|
||||||
|
if (resource.embedKind === 'iframe') return { kind: 'iframe', src };
|
||||||
|
|
||||||
|
const selfHosted = resource.provider === 'pig' || isSelfHostedSource(src);
|
||||||
|
return selfHosted ? { kind: 'video', src } : { kind: 'iframe', src };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `sortOrder` is the curriculum's running order and the reason these are a
|
||||||
|
* course rather than a pile. Sorted here as well as in the API because the
|
||||||
|
* page renders two different feeds and only one of them is guaranteed to have
|
||||||
|
* come through the member query's ordering.
|
||||||
|
*/
|
||||||
|
export function bySortOrder(resources: readonly LearnResourceView[]): LearnResourceView[] {
|
||||||
|
return [...resources].sort(
|
||||||
|
(a, b) => a.sortOrder - b.sortOrder || a.publishedAt.localeCompare(b.publishedAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host of a share link, for a label. Never used to build a src. */
|
||||||
|
export function watchHost(watchUrl: string | null | undefined): string | null {
|
||||||
|
if (!watchUrl) return null;
|
||||||
|
try {
|
||||||
|
return new URL(watchUrl).hostname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+297
-606
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -12,7 +12,17 @@ export default defineConfig({
|
|||||||
// Proxy in development so the browser sees one origin, matching how
|
// Proxy in development so the browser sees one origin, matching how
|
||||||
// production serves the API and the app together. Auth sessions are
|
// production serves the API and the app together. Auth sessions are
|
||||||
// per-origin, so a split origin in dev but not prod hides real bugs.
|
// per-origin, so a split origin in dev but not prod hides real bugs.
|
||||||
proxy: { '/api': { target: 'http://localhost:8920', changeOrigin: true } },
|
/*
|
||||||
|
* `/media` is proxied as well as `/api`, because Learn videos PIG hosts
|
||||||
|
* itself are served from the API on a non-/api path. Without this entry
|
||||||
|
* Vite answers the <video> request with index.html and the player shows a
|
||||||
|
* black box with working controls and no error — the same silent
|
||||||
|
* 200-text/html failure the API guards against for its own routes.
|
||||||
|
*/
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:8920', changeOrigin: true },
|
||||||
|
'/media': { target: 'http://localhost:8920', changeOrigin: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
|||||||
@@ -111,6 +111,105 @@ satisfies every check that only asks whether something responded.
|
|||||||
`scripts/deploy.sh` now asserts the body is non-empty and contains the
|
`scripts/deploy.sh` now asserts the body is non-empty and contains the
|
||||||
application's mount point for this reason.
|
application's mount point for this reason.
|
||||||
|
|
||||||
|
## Learn videos
|
||||||
|
|
||||||
|
PIG hosts its own Learn videos. There is no video service to configure, no
|
||||||
|
embed host and — because a native `<video>` is not an iframe — nothing to add
|
||||||
|
to the proxy's `frame-src`. The files are covered by `default-src 'self'`.
|
||||||
|
|
||||||
|
### Where they live
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Host directory | `PIG_MEDIA_HOST_DIR`, normally `/opt/pig/media` |
|
||||||
|
| Inside the container | `/app/media`, bind-mounted **read-only** |
|
||||||
|
| Read by the app from | `PIG_MEDIA_DIR` (compose sets it to `/app/media`) |
|
||||||
|
| Served at | `/media/learn/<filename>` |
|
||||||
|
| From source, no container | `PIG_MEDIA_DIR=./media`, relative to the repository root |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/pig/media
|
||||||
|
sudo chown "$USER" /opt/pig/media
|
||||||
|
# then in /opt/pig/.env
|
||||||
|
# PIG_MEDIA_HOST_DIR=/opt/pig/media
|
||||||
|
```
|
||||||
|
|
||||||
|
**Create the directory before `compose up`.** Docker creates a missing bind
|
||||||
|
source itself, as an empty directory owned by root — every video then 404s and
|
||||||
|
you cannot copy a file in without `sudo`.
|
||||||
|
|
||||||
|
The mount is read-only. PIG never writes a video: a file arrives by being
|
||||||
|
copied onto the host, so the write path is not reachable over HTTP at all.
|
||||||
|
Deploys do not touch the directory, and neither does a rollback — the videos
|
||||||
|
outlive any particular release.
|
||||||
|
|
||||||
|
### Naming: content-addressed, and why
|
||||||
|
|
||||||
|
A filename is `<slug>.<hash>.<ext>`, for example
|
||||||
|
`pig-tour.7f3a91c2.mp4`. Only `[A-Za-z0-9._-]` is accepted, with `mp4`, `webm`
|
||||||
|
or `m4v` as the extension; anything else is refused both as a stored source and
|
||||||
|
as a file read.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
slug=pig-tour
|
||||||
|
hash=$(sha256sum "$slug.mp4" | cut -c1-8)
|
||||||
|
mv "$slug.mp4" "$slug.$hash.mp4"
|
||||||
|
```
|
||||||
|
|
||||||
|
The hash is load-bearing, not decoration:
|
||||||
|
|
||||||
|
**The media FILES are served unauthenticated. The LISTING is not.** Who learns
|
||||||
|
that a video exists — its title, its track, whether it is code-visible at all —
|
||||||
|
is decided by `/api/learn` and `/api/learn/public`. The bytes are handed to
|
||||||
|
anyone who can name the file. This is how every video platform works: a gated
|
||||||
|
manifest in front of segments on an open CDN. It is also what a `<video>`
|
||||||
|
element requires, since a media element re-requests byte ranges on every seek
|
||||||
|
and carries no bearer token while doing it.
|
||||||
|
|
||||||
|
*What it costs.* A URL, once shared, is a permanent public link to that video.
|
||||||
|
Someone given the share code can copy the `src` out of the page and post it,
|
||||||
|
and **rotating the Learn access code does not close it**. The remedies are to
|
||||||
|
rename the file (a new hash, therefore a new URL) or delete it. We accept that:
|
||||||
|
these are product demos meant to be shareable with the code. The material that
|
||||||
|
must never leak is the supply and demand concept tracks, and those are gated by
|
||||||
|
the listing — which is where the boundary genuinely is.
|
||||||
|
|
||||||
|
*What the hash buys.* There is no directory index and a wrong guess is a flat
|
||||||
|
404, so an unguessable name makes the exposure "whoever has the link" rather
|
||||||
|
than "the internet". That is precisely an unlisted video.
|
||||||
|
|
||||||
|
### Publishing one
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp pig-tour.7f3a91c2.mp4 /opt/pig/media/
|
||||||
|
pnpm db:demo -- --hosted # inserts rows for the files that are present
|
||||||
|
```
|
||||||
|
|
||||||
|
The seed discovers files by slug, so the hash never has to be written into the
|
||||||
|
manifest. It is idempotent — the unique key on `(track, provider, external_id)`
|
||||||
|
does that work — and it inserts a row **only when the file is on disk**, because
|
||||||
|
a Learn card whose video 404s reads as a broken product rather than a missing
|
||||||
|
one. Rows for files that have since disappeared are reported, never deleted:
|
||||||
|
deleting them would empty the curriculum the first time someone ran the seed
|
||||||
|
with the media directory unmounted.
|
||||||
|
|
||||||
|
`pnpm db:demo -- --clear` removes the `DEMO — ` rows and leaves these alone.
|
||||||
|
`pnpm db:demo -- --clear-hosted` removes these and leaves the files on disk.
|
||||||
|
|
||||||
|
### Checking it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI https://primeintellectgrowth.com/media/learn/pig-tour.7f3a91c2.mp4
|
||||||
|
# accept-ranges: bytes <- without this, the scrubber does nothing
|
||||||
|
curl -s -r 0-99 -o /dev/null -D - \
|
||||||
|
https://primeintellectgrowth.com/media/learn/pig-tour.7f3a91c2.mp4
|
||||||
|
# HTTP/2 206 ... content-range: bytes 0-99/<size>
|
||||||
|
```
|
||||||
|
|
||||||
|
A 200 where a 206 is expected means something in front of PIG is buffering the
|
||||||
|
response and dropping the range — the video will play from the start and refuse
|
||||||
|
to seek.
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
### By hand
|
### By hand
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ services:
|
|||||||
NOTION_REDIRECT_URI: ${NOTION_REDIRECT_URI:-}
|
NOTION_REDIRECT_URI: ${NOTION_REDIRECT_URI:-}
|
||||||
BUZZ_PRIVATE_KEY: ${BUZZ_PRIVATE_KEY:-}
|
BUZZ_PRIVATE_KEY: ${BUZZ_PRIVATE_KEY:-}
|
||||||
BUZZ_AUTH_TAG: ${BUZZ_AUTH_TAG:-}
|
BUZZ_AUTH_TAG: ${BUZZ_AUTH_TAG:-}
|
||||||
|
# Where the Learn videos are, INSIDE the container. Always this path; the
|
||||||
|
# host side of the mount is what varies. Named separately from
|
||||||
|
# PIG_MEDIA_HOST_DIR so the two never get swapped — one is a path in this
|
||||||
|
# filesystem, the other a path on yours.
|
||||||
|
PIG_MEDIA_DIR: /app/media
|
||||||
|
volumes:
|
||||||
|
# The Learn videos PIG serves itself.
|
||||||
|
#
|
||||||
|
# READ-ONLY, and that is the point: the application only ever reads these
|
||||||
|
# files, so nothing it could be tricked into doing can write to, replace
|
||||||
|
# or delete a video. Uploads are deliberately not a feature — a file gets
|
||||||
|
# here by being copied onto the host, which keeps the write path outside
|
||||||
|
# anything reachable over HTTP.
|
||||||
|
#
|
||||||
|
# The host directory must EXIST before `compose up`. Docker creates a
|
||||||
|
# missing bind source as an empty directory owned by root, which then
|
||||||
|
# serves 404s for every video and cannot be written to without sudo.
|
||||||
|
- ${PIG_MEDIA_HOST_DIR:-./media}:/app/media:ro
|
||||||
# Bound to loopback: TLS termination belongs to the reverse proxy in front,
|
# Bound to loopback: TLS termination belongs to the reverse proxy in front,
|
||||||
# not to this container.
|
# not to this container.
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Learn — video scripts
|
||||||
|
|
||||||
|
Five ~30-second platform-track walkthroughs for the Prime Intellect GTM team.
|
||||||
|
Each one becomes a `learn_resources` row on the `platform` track: the **title**
|
||||||
|
and **summary** below are that row's `title` and `summary`, and the **slug** is
|
||||||
|
the capture filename.
|
||||||
|
|
||||||
|
Everything asserted in these scripts was checked against the page component,
|
||||||
|
the route handler and a running instance on `http://127.0.0.1:8925` seeded with
|
||||||
|
the demo book. Nothing is described that the product does not do.
|
||||||
|
|
||||||
|
## Constraints these scripts are written to
|
||||||
|
|
||||||
|
The audio is Chatterbox TTS in Karti's cloned voice, sped to 1.20x. Measured on
|
||||||
|
that service: ~2.83 words/sec at 1.0x, ~3.4 words/sec at 1.20x. A 30-second
|
||||||
|
finished clip is therefore **95–105 words total**, opener and closer included.
|
||||||
|
|
||||||
|
Every script opens with exactly `Hi team, check out this feature we worked on:`
|
||||||
|
(9 words) and closes with exactly `thanks for watching!` (3 words). Word counts
|
||||||
|
below are for the full narration block, opener and closer included.
|
||||||
|
|
||||||
|
## Why these five
|
||||||
|
|
||||||
|
A GTM team at a company that buys GPU capacity and resells it needs, in order:
|
||||||
|
the number the business turns on, the dates that can cost it money, the join it
|
||||||
|
does all day, the way its existing book gets into the tool, and the boundary of
|
||||||
|
the agent sitting next to all of it.
|
||||||
|
|
||||||
|
1. **Overview and margin** — the only question a generic CRM cannot answer, and
|
||||||
|
the one product argument that has to land verbatim: cost is charged against
|
||||||
|
the full commitment.
|
||||||
|
2. **The quarterly calendar** — renewals and closes, plus export authorisations
|
||||||
|
and compliance artefacts, which exist nowhere else in the product and whose
|
||||||
|
expiry is a legal event rather than a commercial one.
|
||||||
|
3. **Capacity to allocations** — the load-bearing join. Every figure in scripts
|
||||||
|
one and two is downstream of it, so a seller who cannot do this cannot use
|
||||||
|
the product.
|
||||||
|
4. **Importing** — nobody adopts a CRM they cannot get their book into. This is
|
||||||
|
the first ten minutes of every deployment.
|
||||||
|
5. **Piggy** — a GTM team will be asked "can it do X". The useful video is the
|
||||||
|
one that draws the line, because the answer to "can it write to the CRM" is
|
||||||
|
no and being wrong about that in front of a customer is expensive.
|
||||||
|
|
||||||
|
Deliberately not filmed: `/growth`, `/demand`, `/supply`, `/contracts`,
|
||||||
|
`/facts`. They are good pages, but each is either a conventional pipeline board
|
||||||
|
a GTM team already understands or an internal review queue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview and the margin question
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Word count:** 102
|
||||||
|
|
||||||
|
```narration
|
||||||
|
Hi team, check out this feature we worked on: The Overview opens on gross margin, sold ratio and idle capacity, not deal counts. Underneath, every commitment you're paying for and not selling, ranked by what the idle hours cost, with the break-even price for the rest of the block. The important bit: cost is charged against the full commitment, not just the hours that sold, because unsold hours are already paid for. Margin shows the same ledger per block, with cost per GPU-hour and break-even for each one. If a block reads cost covered, further sales are pure upside. thanks for watching!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shot list**
|
||||||
|
|
||||||
|
| ~sec | Route | On screen |
|
||||||
|
|---|---|---|
|
||||||
|
| 0–4 | `/` | Full page at the top. The four stat tiles: Gross margin, Sold ratio, Idle capacity, Open deals. |
|
||||||
|
| 4–8 | `/` | Slow zoom on the Sold ratio and Idle capacity tiles — "4.9M of 6.2M GPU-hrs sold", "1.3M hrs bought and unsold". |
|
||||||
|
| 8–16 | `/` | The warning card "Capacity you are paying for and not selling", scrolling the ranked rows. Pause on the row reading "break even above $0.76/GPU-hr". |
|
||||||
|
| 16–22 | `/` | The "The book" card: Revenue, Cost of committed capacity, Gross margin, and the footnote about the full commitment. Hold on the footnote. |
|
||||||
|
| 22–27 | `/margin` | Navigate. Header stats, then the "By commitment" table — Sold, Sellable, Sold ratio, Cost/hr, Break even. |
|
||||||
|
| 27–30 | `/margin` | Hold on a row whose Break even column reads "Cost covered". |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The quarterly calendar
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Word count:** 98
|
||||||
|
|
||||||
|
```narration
|
||||||
|
Hi team, check out this feature we worked on: The Calendar is a quarter at a time, one lane per kind. Bars are spans, diamonds are points, so you read what overlaps what in one pass. Top of the page is licence to operate: export authorisations and compliance artefacts, because an expired authorisation converts lawful business into unlawful business. Below that, weighted pipeline, deals closing, renewals whose notice date lands this quarter, obligations due, and every capacity window. The agenda underneath is the same events as a tappable list, and it works on a phone. thanks for watching!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shot list**
|
||||||
|
|
||||||
|
| ~sec | Route | On screen |
|
||||||
|
|---|---|---|
|
||||||
|
| 0–4 | `/calendar` | Top of the page, quarter label visible in the heading. Step once with the next-quarter arrow and back, to show the navigator. |
|
||||||
|
| 4–10 | `/calendar` | The Quarter timeline card. Scroll its pane sideways so the lanes and the July/August/September header move. |
|
||||||
|
| 10–17 | `/calendar` | The "Licence to operate" card. Hold on the export-authorisation row and its "in 45d" countdown. |
|
||||||
|
| 17–23 | `/calendar` | The five stat tiles: Weighted pipeline, Deals closing, Renewals, Obligations due, Authorisations expiring. |
|
||||||
|
| 23–27 | `/calendar` | The Agenda, scrolling through a day group — allocation windows and capacity windows with their values and Running badges. |
|
||||||
|
| 27–30 | `/calendar` | Same page at 393px wide, agenda in view, to show the phone layout. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Capacity to allocations
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Word count:** 96
|
||||||
|
|
||||||
|
```narration
|
||||||
|
Hi team, check out this feature we worked on: Capacity has two tabs. Availability shows every block we hold: how much is sold, how much is held, how much is still sellable, our cost, and the break-even price. Match a requirement takes what the customer wants, GPU type, count, dates, a price ceiling, and scores the blocks we already own, with the reasoning written out. Allocate joins that commitment to a demand deal. The server re-checks the window and live holds when you save. That join is the row margin is computed from. thanks for watching!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shot list**
|
||||||
|
|
||||||
|
| ~sec | Route | On screen |
|
||||||
|
|---|---|---|
|
||||||
|
| 0–4 | `/capacity` | Availability tab. The card grid; hold on one card's sold/held bar and the "hrs sellable" figure. |
|
||||||
|
| 4–9 | `/capacity` | Same card's Cost and Break even rows, then the "Allocate or hold" button. |
|
||||||
|
| 9–13 | `/capacity` | Switch to "Match a requirement". The empty form. |
|
||||||
|
| 13–18 | `/capacity` | Type `H100_80GB`, 64 GPUs, leave high-speed interconnect ticked, press "Find capacity". |
|
||||||
|
| 18–23 | `/capacity` | The result cards: the fit percentage badge and the rationale lines, e.g. "Exact GPU match" and "Infiniband fabric meets the training requirement". |
|
||||||
|
| 23–28 | `/capacity` | Press "Allocate this capacity". The Reserve capacity sheet opens — commitment, demand deal, GPU-hours, sell price, and the "Quote vs break even" line reacting as a price is typed. |
|
||||||
|
| 28–30 | `/capacity` | Hold on the "Create allocation" button and the note that saving re-checks the window and live holds. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Importing your book
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Word count:** 103
|
||||||
|
|
||||||
|
```narration
|
||||||
|
Hi team, check out this feature we worked on: Import gets you off the spreadsheet. Pick what you're importing: accounts, contacts, demand deals or supply deals. Then a CSV or Excel file, a Notion database, or a bounded Google Sheets range, read-only, with the token encrypted on the server. PIG guesses the column mapping, you pick a stable source key, then run a dry run. You see every create, every update and every error before anything is written. Fix the errors, commit, and it goes in atomically. Re-import with the same key column later and it updates the same records. thanks for watching!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shot list**
|
||||||
|
|
||||||
|
| ~sec | Route | On screen |
|
||||||
|
|---|---|---|
|
||||||
|
| 0–4 | `/imports` | Top of the page and the four entity cards. Click Accounts. |
|
||||||
|
| 4–9 | `/imports` | The "1. Choose source" card. Click across File, Notion, Google Sheets so all three are seen; land on Google Sheets and hold on the "Read only" badge and the encrypted-token line. |
|
||||||
|
| 9–13 | `/imports` | Back on File. Choose a CSV; the "Parsed" badge, the file name, and the row and column counts appear. |
|
||||||
|
| 13–19 | `/imports` | "2. Map source columns". Hold on the "Stable source key" select and its note that repeated imports update the same record. Scroll the mapped field list. |
|
||||||
|
| 19–24 | `/imports` | Press "Run dry-run preview". The "3. Review the exact plan" card with the create / update / errors badges. |
|
||||||
|
| 24–28 | `/imports` | Scroll the preview table — the Decision column, and a row carrying a validation error. |
|
||||||
|
| 28–30 | `/imports` | Press "Commit reviewed import"; the confirmation line reporting rows created and updated. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Piggy, and what it will not do
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Word count:** 101
|
||||||
|
|
||||||
|
```narration
|
||||||
|
Hi team, check out this feature we worked on: Piggy is docked on every page. Ask it about the book and it answers through scoped PIG tools, one per page: margin on Margin, idle capacity on Capacity, the same calendar projection the Calendar page renders. It gets aggregates, not the raw ledger, so it quotes rather than recomputes. What it cannot do matters as much: no shell, no filesystem, no browser, and this chat cannot write CRM records. On a record it reads only that record and cannot pivot to another. Check the source record before you act. thanks for watching!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shot list**
|
||||||
|
|
||||||
|
| ~sec | Route | On screen |
|
||||||
|
|---|---|---|
|
||||||
|
| 0–4 | `/piggy` | The workspace. The "Read-only workspace" pill and the "Inspection boundary" note. |
|
||||||
|
| 4–8 | `/margin` | Open the dock from the header while standing on Margin, so the dock is visibly attached to the page. |
|
||||||
|
| 8–15 | `/margin` | Ask "how is the book doing". Show the tool step appearing in the transcript, then the answer quoting the same figures the page shows. |
|
||||||
|
| 15–20 | `/capacity` | Move to Capacity, ask "what is idle and what does it cost". Show the different tool name in the transcript. |
|
||||||
|
| 20–25 | `/piggy` | Hold on the line under the composer: "Piggy reads only through scoped PIG tools. It has no shell, filesystem or browser access, and this chat cannot write CRM records." |
|
||||||
|
| 25–30 | `/accounts` | Open an account, press "Ask Piggy", and show the answer citing that record — then the footer line "Read-only session · Check source records before acting on material terms." |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification notes
|
||||||
|
|
||||||
|
Kept so the next person does not re-derive them.
|
||||||
|
|
||||||
|
- Overview stat tiles, the idle-alert card and the full-commitment footnote:
|
||||||
|
`apps/web/src/pages/Overview.tsx`; data from `GET /api/dashboard`.
|
||||||
|
- Margin table columns and the "Cost covered" / "Sold out" break-even
|
||||||
|
treatment: `apps/web/src/pages/Margin.tsx`; `GET /api/capacity/margin`.
|
||||||
|
- Calendar lane ordering, the compliance card and the five totals:
|
||||||
|
`apps/web/src/pages/Calendar.tsx`. The thirteen event kinds are
|
||||||
|
`CALENDAR_EVENT_KINDS` in `packages/core/src/calendar.ts`.
|
||||||
|
- Availability figures, the matcher form and its rationale strings:
|
||||||
|
`apps/web/src/pages/Capacity.tsx`; `POST /api/capacity/match` returns
|
||||||
|
`score` plus a `rationale` array. The reserve sheet, the "Quote vs break
|
||||||
|
even" line and the server re-check on save:
|
||||||
|
`apps/web/src/components/AllocationSheet.tsx`.
|
||||||
|
- Import entities, mapping, dry run and atomic commit:
|
||||||
|
`apps/web/src/pages/Imports.tsx`. Accepted file types are `.csv` and
|
||||||
|
`.xlsx`. Google Sheets is read-only with server-side encrypted tokens and a
|
||||||
|
bounded A1 range: `apps/web/src/components/GoogleSheetsSource.tsx`.
|
||||||
|
- Piggy's tools are one per route — `pig_get_margin_summary`,
|
||||||
|
`pig_get_idle_capacity`, `pig_get_pipeline`, `pig_get_calendar_ahead`,
|
||||||
|
`pig_get_workspace_summary` — mapped in `apps/piggy/src/page-routes.ts` and
|
||||||
|
implemented in `apps/piggy/src/page-tools.ts`. The record tool
|
||||||
|
`pig_get_record` takes no id and cannot inspect another record
|
||||||
|
(`apps/piggy/src/chat-tools.ts`). Results are aggregated because interactive
|
||||||
|
chat runs at `max_tokens` 1024 over at most four turns.
|
||||||
|
- **Caveat for whoever schedules these:** `learn.ts` is not mounted in
|
||||||
|
`apps/api/src/app.ts`, so `/api/learn/*` currently answers 404 and these rows
|
||||||
|
cannot be created yet. See AGENTS.md §7.
|
||||||
+208
-9
@@ -28,6 +28,15 @@
|
|||||||
* (see `LEARN_FRAME_SRC_HOSTS`). Do not add a row whose URL shape has not been
|
* (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
|
* checked against the running service — the id extraction is what decides
|
||||||
* whether a hostile path becomes a trusted embed.
|
* 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
|
// 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];
|
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 {
|
export interface LearnProviderDefinition {
|
||||||
provider: LearnProvider;
|
provider: LearnProvider;
|
||||||
label: string;
|
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
|
* 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
|
* 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.
|
* design exercise under time pressure.
|
||||||
*/
|
*/
|
||||||
enabled: boolean;
|
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[];
|
hosts: readonly string[];
|
||||||
/** Path prefixes whose NEXT segment is the id, and nothing after it. */
|
/** Path prefixes whose NEXT segment is the id, and nothing after it. */
|
||||||
idSegmentPrefixes: readonly string[];
|
idSegmentPrefixes: readonly string[];
|
||||||
@@ -103,8 +187,14 @@ export interface LearnProviderDefinition {
|
|||||||
idPattern: RegExp;
|
idPattern: RegExp;
|
||||||
embed(externalId: string): string;
|
embed(externalId: string): string;
|
||||||
watch(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',
|
provider: 'cap',
|
||||||
label: 'Cap',
|
label: 'Cap',
|
||||||
|
kind: 'iframe',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
hosts: ['video.karti.ai'],
|
hosts: ['video.karti.ai'],
|
||||||
idSegmentPrefixes: ['s', 'embed'],
|
idSegmentPrefixes: ['s', 'embed'],
|
||||||
@@ -137,6 +228,7 @@ export const LEARN_PROVIDER_TABLE: readonly LearnProviderDefinition[] = [
|
|||||||
{
|
{
|
||||||
provider: 'loom',
|
provider: 'loom',
|
||||||
label: 'Loom',
|
label: 'Loom',
|
||||||
|
kind: 'iframe',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
hosts: ['www.loom.com', 'loom.com'],
|
hosts: ['www.loom.com', 'loom.com'],
|
||||||
idSegmentPrefixes: ['share', 'embed'],
|
idSegmentPrefixes: ['share', 'embed'],
|
||||||
@@ -148,6 +240,7 @@ export const LEARN_PROVIDER_TABLE: readonly LearnProviderDefinition[] = [
|
|||||||
{
|
{
|
||||||
provider: 'youtube_nocookie',
|
provider: 'youtube_nocookie',
|
||||||
label: 'YouTube',
|
label: 'YouTube',
|
||||||
|
kind: 'iframe',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
// The nocookie host only, never youtube.com: the point of listing YouTube
|
// The nocookie host only, never youtube.com: the point of listing YouTube
|
||||||
// at all is the privacy-preserving embed, and accepting the ordinary host
|
// 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}`,
|
watch: (id) => `https://www.youtube-nocookie.com/embed/${id}`,
|
||||||
frameSrc: 'https://www.youtube-nocookie.com',
|
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(
|
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);
|
).map((definition) => definition.frameSrc);
|
||||||
|
|
||||||
export function learnProviderDefinition(
|
export function learnProviderDefinition(
|
||||||
@@ -189,19 +316,36 @@ export type LearnEmbedResolution =
|
|||||||
externalId: string;
|
externalId: string;
|
||||||
/** Canonical share link. Safe to show a human; never an iframe source. */
|
/** Canonical share link. Safe to show a human; never an iframe source. */
|
||||||
watchUrl: string;
|
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;
|
embedUrl: string;
|
||||||
|
/** What to render, and which element to render it in. */
|
||||||
|
embed: LearnEmbed;
|
||||||
}
|
}
|
||||||
| { ok: false; reason: LearnEmbedRejection };
|
| { ok: false; reason: LearnEmbedRejection };
|
||||||
|
|
||||||
export const LEARN_EMBED_REJECTION_MESSAGES: Record<LearnEmbedRejection, string> = {
|
export const LEARN_EMBED_REJECTION_MESSAGES: Record<LearnEmbedRejection, string> = {
|
||||||
malformed_url: 'That is not a URL.',
|
malformed_url: 'That is not a URL.',
|
||||||
insecure_scheme: 'Only https links can be embedded.',
|
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.',
|
provider_disabled: 'That provider is recognised but not enabled yet.',
|
||||||
unrecognised_path: 'That looks like the right host but not a share link.',
|
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.',
|
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.
|
* 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.
|
* cannot carry an attacker's bytes into an attribute.
|
||||||
*/
|
*/
|
||||||
export function resolveLearnEmbed(raw: string): LearnEmbedResolution {
|
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;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(raw.trim());
|
parsed = new URL(trimmed);
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, reason: 'malformed_url' };
|
return { ok: false, reason: 'malformed_url' };
|
||||||
}
|
}
|
||||||
@@ -260,6 +417,37 @@ export function resolveLearnEmbed(raw: string): LearnEmbedResolution {
|
|||||||
externalId,
|
externalId,
|
||||||
watchUrl: definition.watch(externalId),
|
watchUrl: definition.watch(externalId),
|
||||||
embedUrl: definition.embed(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.
|
* must not be framed on the strength of having been persisted once.
|
||||||
*/
|
*/
|
||||||
export function learnEmbedUrl(provider: LearnProvider, externalId: string): string | null {
|
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);
|
const definition = learnProviderDefinition(provider);
|
||||||
if (!definition || !definition.enabled) return null;
|
if (!definition || !definition.enabled) return null;
|
||||||
if (!definition.idPattern.test(externalId)) 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. */
|
/** 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,
|
"when": 1786655800000,
|
||||||
"tag": "0012_viewer_team_role",
|
"tag": "0012_viewer_team_role",
|
||||||
"breakpoints": true
|
"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
|
* is more useful than one that opens on a loss, which reads as a broken
|
||||||
* product rather than an under-utilised book.
|
* 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 { and, eq, like, or } from 'drizzle-orm';
|
||||||
|
import { readdir } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
import { createDatabase } from '../client';
|
import { createDatabase } from '../client';
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
@@ -932,37 +940,15 @@ async function seedDemo() {
|
|||||||
// is the opposite of what a demo seed is for — so the titles are illustrative
|
// 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.
|
// 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
|
// PLATFORM rows are no longer seeded here. The five real recordings in
|
||||||
// account sees. The concept rows are `members`, and the CHECK constraint on
|
// HOSTED_LEARN_MANIFEST cover that track now, and leaving three illustrative
|
||||||
// the table would refuse them any other way round.
|
// 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 = [
|
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,
|
track: 'supply' as const,
|
||||||
title: `${PREFIX}How neocloud capacity is actually priced`,
|
title: `${PREFIX}How neocloud capacity is actually priced`,
|
||||||
@@ -1027,6 +1013,12 @@ async function seedDemo() {
|
|||||||
if (inserted.length) learnAdded += 1;
|
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
|
// -------------------------------------------------- agent-derived facts
|
||||||
//
|
//
|
||||||
// Without these the fact-review queue and every provenance tooltip are
|
// Without these the fact-review queue and every provenance tooltip are
|
||||||
@@ -1199,11 +1191,247 @@ async function seedDemo() {
|
|||||||
console.log(
|
console.log(
|
||||||
` ${LEARN_RESOURCES.length} learn resources (${learnAdded} new) — 3 platform walkthroughs behind the share code`,
|
` ${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('\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');
|
// ------------------------------------------------------- PIG-hosted learn
|
||||||
(shouldClear ? clear() : seedDemo())
|
//
|
||||||
|
// 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))
|
.then(() => process.exit(0))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Demo seed failed:', error);
|
console.error('Demo seed failed:', error);
|
||||||
|
|||||||
Reference in New Issue
Block a user