feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)

New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
+7 -9
View File
@@ -19,14 +19,15 @@ import { syncUserWithProfile } from './auth/hooks/sync-user';
import { tokenExchange } from './auth/plugins/token-exchange';
import { trackLoginHook } from './auth/hooks/track-login';
export { mintLoginLinkToken } from './auth/login-link';
/**
* Cloud (multi-tenant) only. Routes OAuth/SSO callbacks to the canonical
* production URL while completing the flow on whichever tenant host the
* user signed in from (<org>.myclassroomio.com or a BYOD domain).
*
* Self-hosted instances run with one apex (api.<domain> + app.<domain>)
* and use AUTH_COOKIE_DOMAIN for cross-subdomain cookies, so the proxy
* isn't needed there.
* Self-hosted instances proxy browser auth through the dashboard origin, so
* the OAuth proxy plugin is only needed for cloud tenant/BYOD domains.
*/
function buildOAuthProxyPlugin() {
if (process.env.PUBLIC_IS_SELFHOSTED === 'true') {
@@ -74,12 +75,9 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
},
advanced: {
cookiePrefix: 'classroomio',
// Cloud (multi-tenant): host-only cookies on each tenant/BYOD domain.
// Self-hosted: cross-subdomain cookies under AUTH_COOKIE_DOMAIN so the
// session set on `api.<apex>` is also sent to `app.<apex>`.
crossSubDomainCookies: process.env.AUTH_COOKIE_DOMAIN?.trim()
? { enabled: true, domain: process.env.AUTH_COOKIE_DOMAIN.trim() }
: { enabled: false },
// Browser auth is first-party through tenant-router or the dashboard proxy,
// so cookies should stay host-only on the dashboard/public-site origin.
crossSubDomainCookies: { enabled: false },
database: {
generateId: false
}
+44
View File
@@ -0,0 +1,44 @@
import { SignJWT } from 'jose';
/**
* Default lifetime for a minted login-link token. Login links are single-purpose
* cross-host sign-in handoffs, so they are intentionally short-lived.
*/
const DEFAULT_TTL_MINUTES = 10;
function getLoginLinkSecret(): Uint8Array {
const secret = process.env.BETTER_AUTH_SECRET?.trim();
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is required for login links');
}
return new TextEncoder().encode(secret);
}
/**
* Mint a short-lived HS256 JWT consumed by the `login-link` auth plugin
* (`GET /api/auth/login-link?token=…`). The payload matches `ZLoginLinkPayload`
* (`sub` + `email` + `type`); the consumer re-verifies the user and email before
* creating a session, so callers must only ever mint for a user they have
* already authenticated (self sign-in), never an arbitrary target.
*/
export async function mintLoginLinkToken({
userId,
email,
ttlMinutes = DEFAULT_TTL_MINUTES
}: {
userId: string;
email: string;
ttlMinutes?: number;
}): Promise<string> {
return new SignJWT({
email: email.toLowerCase().trim(),
type: 'login-link'
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuedAt()
.setExpirationTime(`${ttlMinutes}m`)
.sign(getLoginLinkSecret());
}
+18 -9
View File
@@ -1,4 +1,11 @@
const { TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING, PUBLIC_SERVER_URL } = process.env;
const {
DASHBOARD_ORIGIN,
PUBLIC_IS_SELFHOSTED,
PUBLIC_SERVER_URL,
TRUSTED_ORIGINS: TRUSTED_ORIGINS_STRING
} = process.env;
const dashboardOrigin = DASHBOARD_ORIGIN?.trim().replace(/\/$/, '');
const DEFAULT_TRUSTED_ORIGINS = [
'http://localhost:5173',
@@ -16,13 +23,15 @@ const DEFAULT_TRUSTED_ORIGINS = [
'https://login.okta.com'
];
export const TRUSTED_ORIGINS = TRUSTED_ORIGINS_STRING
? [
...DEFAULT_TRUSTED_ORIGINS,
...TRUSTED_ORIGINS_STRING.split(',')
.map((s) => s.trim())
export const TRUSTED_ORIGINS = [
...DEFAULT_TRUSTED_ORIGINS,
...(TRUSTED_ORIGINS_STRING
? TRUSTED_ORIGINS_STRING.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
]
: DEFAULT_TRUSTED_ORIGINS;
: []),
...(dashboardOrigin ? [dashboardOrigin] : [])
];
export const BASE_URL = PUBLIC_SERVER_URL || 'http://localhost:3002';
export const BASE_URL =
PUBLIC_IS_SELFHOSTED === 'true' && dashboardOrigin ? dashboardOrigin : PUBLIC_SERVER_URL || 'http://localhost:3002';
@@ -253,6 +253,21 @@ export async function upsertAgentRunStep(input: UpsertAgentRunStepInput): Promis
}
}
export async function getAgentRunStep(runId: string, stepKey: string): Promise<TAiAgentRunStep | null> {
try {
const [row] = await db
.select()
.from(schema.aiAgentRunStep)
.where(and(eq(schema.aiAgentRunStep.runId, runId), eq(schema.aiAgentRunStep.stepKey, stepKey)))
.limit(1);
return row ?? null;
} catch (error) {
console.error('getAgentRunStep error:', error);
throw new Error('Failed to get agent run step');
}
}
export async function listAgentRunSteps(runId: string): Promise<TAiAgentRunStep[]> {
try {
return await db
+125
View File
@@ -217,6 +217,131 @@ export async function updateAsset(
}
}
export interface InitHlsAssetInput {
organizationId: string;
createdByProfileId: string;
title: string | null;
byteSize: number | null;
mimeType: string | null;
}
/**
* Insert an asset row in `processing` status for an HLS upload. The
* storage layout (storageKey / hlsManifestKey / hlsAudioKey) is filled in
* by finalizeHlsAsset once the browser encoder has written everything to
* R2 — we just need an id to namespace the segments under.
*/
export async function createHlsAssetPlaceholder(
input: InitHlsAssetInput,
dbClient: DbOrTxClient = db
): Promise<TAsset> {
try {
const [created] = await dbClient
.insert(schema.asset)
.values({
organizationId: input.organizationId,
kind: 'video',
provider: 'upload',
storageProvider: 's3',
title: input.title,
byteSize: input.byteSize,
mimeType: input.mimeType,
isExternal: false,
status: 'processing',
createdByProfileId: input.createdByProfileId
})
.returning();
if (!created) {
throw new Error('Failed to create HLS asset placeholder');
}
return created;
} catch (error) {
console.error('createHlsAssetPlaceholder error:', error);
throw new Error(
`Failed to create HLS asset placeholder: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export interface FinalizeHlsAssetInput {
manifestKey: string;
audioKey: string | null;
durationSeconds: number | null;
aspectRatio: string | null;
byteSize: number;
thumbnailUrl: string | null;
thumbnailCandidates: string[];
metadata: Record<string, unknown>;
}
export async function finalizeHlsAsset(
assetId: string,
orgId: string,
input: FinalizeHlsAssetInput,
dbClient: DbOrTxClient = db
): Promise<TAsset | null> {
try {
const [updated] = await dbClient
.update(schema.asset)
.set({
hlsManifestKey: input.manifestKey,
hlsAudioKey: input.audioKey,
// storageKey stays null for HLS assets so lesson-media.ts (which
// signs storageKey as the canonical video URL) doesn't overwrite
// the HLS manifest URL. The transcription enqueue path reads
// hlsAudioKey for HLS assets — see startTranscriptionOnlyMediaJob.
durationSeconds: input.durationSeconds,
aspectRatio: input.aspectRatio,
byteSize: input.byteSize,
thumbnailUrl: input.thumbnailUrl,
thumbnailCandidates: input.thumbnailCandidates,
metadata: input.metadata,
status: 'active',
updatedAt: new Date().toISOString()
})
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return updated || null;
} catch (error) {
console.error('finalizeHlsAsset error:', error);
throw new Error(`Failed to finalize HLS asset: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export interface FinalizeHls1080Input {
byteSize: number;
metadata: Record<string, unknown>;
}
export async function finalizeHls1080Rendition(
assetId: string,
orgId: string,
input: FinalizeHls1080Input,
dbClient: DbOrTxClient = db
): Promise<TAsset | null> {
try {
const [updated] = await dbClient
.update(schema.asset)
.set({
byteSize: input.byteSize,
metadata: input.metadata,
updatedAt: new Date().toISOString()
})
.where(and(eq(schema.asset.id, assetId), eq(schema.asset.organizationId, orgId)))
.returning();
return updated || null;
} catch (error) {
console.error('finalizeHls1080Rendition error:', error);
throw new Error(
`Failed to finalize HLS 1080p rendition: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export async function archiveAsset(
assetId: string,
orgId: string,
+19 -1
View File
@@ -56,7 +56,8 @@ export interface TStudentCourse extends TBaseCourse {
export const getPublishedCoursesBySiteName = async (
siteName: string,
courseIds?: string[],
limit?: number
limit?: number,
offset?: number
): Promise<TBaseCourse[]> => {
try {
if (courseIds && courseIds.length === 0) {
@@ -95,6 +96,7 @@ export const getPublishedCoursesBySiteName = async (
.groupBy(schema.course.id)
.orderBy(desc(schema.course.createdAt))
.limit(limit)
.offset(offset ?? 0)
: await db
.select({
course: schema.course,
@@ -300,6 +302,8 @@ export type CourseWithRelations = TCourse & {
id: string;
name: string;
siteName: string | null;
customDomain: string | null;
isCustomDomainVerified: boolean | null;
theme: string | null;
} | null;
};
@@ -342,6 +346,8 @@ export async function getCourseWithRelations(
id: string;
name: string;
siteName: string | null;
customDomain: string | null;
isCustomDomainVerified: boolean | null;
theme: string | null;
} | null;
};
@@ -362,6 +368,8 @@ export async function getCourseWithRelations(
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName,
customDomain: schema.organization.customDomain,
isCustomDomainVerified: schema.organization.isCustomDomainVerified,
theme: schema.organization.theme
}
})
@@ -402,6 +410,8 @@ export async function getCourseWithRelations(
id: groupData[0].organization.id,
name: groupData[0].organization.name,
siteName: groupData[0].organization.siteName ?? null,
customDomain: groupData[0].organization.customDomain ?? null,
isCustomDomainVerified: groupData[0].organization.isCustomDomainVerified ?? null,
theme: groupData[0].organization.theme ?? null
}
: null;
@@ -616,6 +626,8 @@ export type TCourseCertificationRow = {
} | null;
title: string;
orgSiteName: string | null;
orgCustomDomain: string | null;
orgIsCustomDomainVerified: boolean | null;
orgName: string;
};
@@ -628,6 +640,8 @@ export async function getCourseCertificationRow(courseId: string): Promise<TCour
certificate: schema.course.certificate,
title: schema.course.title,
orgSiteName: schema.organization.siteName,
orgCustomDomain: schema.organization.customDomain,
orgIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
orgName: schema.organization.name
})
.from(schema.course)
@@ -1065,6 +1079,8 @@ export async function getCourseWithOrgData(courseId: string): Promise<{
courseTitle: string | null;
orgName: string | null;
orgSiteName: string | null;
orgCustomDomain: string | null;
orgIsCustomDomainVerified: boolean | null;
groupId: string | null;
} | null> {
try {
@@ -1073,6 +1089,8 @@ export async function getCourseWithOrgData(courseId: string): Promise<{
courseTitle: schema.course.title,
orgName: schema.organization.name,
orgSiteName: schema.organization.siteName,
orgCustomDomain: schema.organization.customDomain,
orgIsCustomDomainVerified: schema.organization.isCustomDomainVerified,
groupId: schema.course.groupId
})
.from(schema.course)
+8 -2
View File
@@ -348,6 +348,8 @@ export type TCourseInviteAcceptBundle = {
id: string;
name: string;
siteName: string;
customDomain: string | null;
isCustomDomainVerified: boolean | null;
};
};
@@ -370,7 +372,9 @@ export async function selectCourseInviteAcceptBundleByTokenHash(
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
siteName: schema.organization.siteName,
customDomain: schema.organization.customDomain,
isCustomDomainVerified: schema.organization.isCustomDomainVerified
}
})
.from(schema.courseInvite)
@@ -392,7 +396,9 @@ export async function selectCourseInviteAcceptBundleByTokenHash(
},
organization: {
...row.organization,
siteName: row.organization.siteName ?? ''
siteName: row.organization.siteName ?? '',
customDomain: row.organization.customDomain ?? null,
isCustomDomainVerified: row.organization.isCustomDomainVerified ?? null
}
};
} catch (error) {
@@ -295,6 +295,12 @@ export interface PublicLessonContent {
/** S3 object key for uploads; used server-side for presigning (stripped before API response). */
key?: string;
assetId?: string;
/**
* When true, `link` points at an HLS master playlist served by the
* tenant-router Worker (prod) or API streaming route (local). The
* shared media player attaches hls.js based on this flag.
*/
hls?: boolean;
transcript?: {
vttUrl: string;
vttUrlExpiresAt: string;
@@ -54,6 +54,38 @@ export async function upsertMediaTranscript(
}
}
/**
* Update only the editable content (segments + derived text) of an asset's
* transcript. Scoped by asset + organization. Returns the updated row or
* `null` when no transcript exists for the asset.
*/
export async function updateMediaTranscriptContent(
assetId: string,
organizationId: string,
patch: { segments: TMediaTranscript['segments']; text: string },
dbClient: DbOrTxClient = db
): Promise<TMediaTranscript | null> {
try {
const now = new Date().toISOString();
const [row] = await dbClient
.update(schema.mediaTranscript)
.set({
segments: patch.segments,
text: patch.text,
updatedAt: now
})
.where(
and(eq(schema.mediaTranscript.assetId, assetId), eq(schema.mediaTranscript.organizationId, organizationId))
)
.returning();
return row ?? null;
} catch (error) {
console.error('updateMediaTranscriptContent error:', error);
throw new Error('Failed to update media transcript content');
}
}
export async function getMediaTranscriptByAsset(
assetId: string,
organizationId: string,
@@ -1,5 +1,6 @@
export * from './organization';
export * from './quiz';
export * from './invite';
export * from './link-invite';
export * from './automation-key';
export * from './automation-usage';
@@ -257,10 +257,12 @@ export async function getLatestOrgInvitesByEmails(
const latestByEmail = new Map<string, TLatestOrgInviteByEmail>();
for (const row of rows) {
if (!row.email) continue;
const key = row.email.toLowerCase();
const existing = latestByEmail.get(key);
if (!existing || new Date(row.createdAt) > new Date(existing.createdAt)) {
latestByEmail.set(key, row);
latestByEmail.set(key, { ...row, email: row.email });
}
}
@@ -0,0 +1,117 @@
import * as schema from '@db/schema';
import { and, eq, isNull } from 'drizzle-orm';
import { db, type DbOrTxClient } from '@db/drizzle';
import type { TOrganizationInvite } from '@db/types';
export type TLinkInviteRow = TOrganizationInvite & {
organization: { id: string; name: string; siteName: string };
};
export async function getOrgLinkInvite(organizationId: string): Promise<TOrganizationInvite | null> {
try {
const [row] = await db
.select()
.from(schema.organizationInvite)
.where(
and(
eq(schema.organizationInvite.organizationId, organizationId),
eq(schema.organizationInvite.type, 'LINK'),
eq(schema.organizationInvite.isRevoked, false)
)
)
.limit(1);
return row ?? null;
} catch (error) {
console.error('getOrgLinkInvite error:', error);
throw new Error(`Failed to get org link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function createLinkInvite(data: {
organizationId: string;
roleId: number;
tokenHash: string;
createdByProfileId: string;
expiresAt: string;
metadata: Record<string, unknown>;
}): Promise<TOrganizationInvite> {
try {
const [created] = await db
.insert(schema.organizationInvite)
.values({
...data,
type: 'LINK',
email: null,
isRevoked: false
})
.returning();
if (!created) {
throw new Error('Failed to create link invite');
}
return created;
} catch (error) {
console.error('createLinkInvite error:', error);
throw new Error(`Failed to create link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function setLinkInviteRevoked(
organizationId: string,
isRevoked: boolean,
profileId: string
): Promise<TOrganizationInvite | null> {
try {
const [updated] = await db
.update(schema.organizationInvite)
.set({
isRevoked,
revokedByProfileId: isRevoked ? profileId : null,
revokedAt: isRevoked ? new Date().toISOString() : null,
updatedAt: new Date().toISOString()
})
.where(
and(eq(schema.organizationInvite.organizationId, organizationId), eq(schema.organizationInvite.type, 'LINK'))
)
.returning();
return updated ?? null;
} catch (error) {
console.error('setLinkInviteRevoked error:', error);
throw new Error(`Failed to update link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
export async function getOrgLinkInviteWithOrg(
dbClient: DbOrTxClient,
tokenHash: string
): Promise<{ invite: TOrganizationInvite; organization: { id: string; name: string; siteName: string } } | null> {
try {
const [row] = await dbClient
.select({
invite: schema.organizationInvite,
organization: {
id: schema.organization.id,
name: schema.organization.name,
siteName: schema.organization.siteName
}
})
.from(schema.organizationInvite)
.innerJoin(schema.organization, eq(schema.organizationInvite.organizationId, schema.organization.id))
.where(and(eq(schema.organizationInvite.tokenHash, tokenHash), eq(schema.organizationInvite.type, 'LINK')))
.limit(1);
if (!row) return null;
return {
...row,
organization: { ...row.organization, siteName: row.organization.siteName ?? '' }
};
} catch (error) {
console.error('getOrgLinkInviteWithOrg error:', error);
throw new Error(`Failed to load link invite: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
@@ -913,9 +913,7 @@ export const getOrganizationPlanBySubscriptionId = async (
return plan ?? null;
} catch (error) {
console.error('getOrganizationPlanBySubscriptionId error:', error);
throw new Error(
`Failed to fetch organization plan: ${error instanceof Error ? error.message : 'Unknown error'}`
);
throw new Error(`Failed to fetch organization plan: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
+8 -2
View File
@@ -104,6 +104,8 @@ export async function listAssignmentsForReminderScan(): Promise<
programName: string;
organizationName: string;
organizationSiteName: string | null;
organizationCustomDomain: string | null;
organizationIsCustomDomainVerified: boolean | null;
}>
> {
try {
@@ -116,7 +118,9 @@ export async function listAssignmentsForReminderScan(): Promise<
programId: schema.program.id,
programName: schema.program.name,
organizationName: schema.organization.name,
organizationSiteName: schema.organization.siteName
organizationSiteName: schema.organization.siteName,
organizationCustomDomain: schema.organization.customDomain,
organizationIsCustomDomainVerified: schema.organization.isCustomDomainVerified
})
.from(schema.programGoalAssignment)
.innerJoin(schema.programMember, eq(schema.programGoalAssignment.programMemberId, schema.programMember.id))
@@ -140,7 +144,9 @@ export async function listAssignmentsForReminderScan(): Promise<
programId: row.programId,
programName: row.programName,
organizationName: row.organizationName,
organizationSiteName: row.organizationSiteName
organizationSiteName: row.organizationSiteName,
organizationCustomDomain: row.organizationCustomDomain,
organizationIsCustomDomainVerified: row.organizationIsCustomDomainVerified
}));
} catch (error) {
console.error('listAssignmentsForReminderScan error:', error);
+7 -1
View File
@@ -68,6 +68,7 @@ export const organizationInviteEventType = pgEnum('ORGANIZATION_INVITE_EVENT_TYP
'EMAIL_FAILED',
'ABUSE_BLOCKED'
]);
export const organizationInviteType = pgEnum('ORGANIZATION_INVITE_TYPE', ['EMAIL', 'LINK']);
export const user = pgTable('user', {
id: uuid()
@@ -677,6 +678,8 @@ export const course = pgTable(
created_at: number;
description: string;
}[];
skills?: string[];
tools?: string[];
lessonTabsOrder?: {
id: 1 | 2 | 3 | 4;
name: string;
@@ -1034,6 +1037,8 @@ export const asset = pgTable(
provider: varchar().default('upload').notNull(),
storageProvider: varchar('storage_provider').default('s3').notNull(),
storageKey: text('storage_key'),
hlsManifestKey: text('hls_manifest_key'),
hlsAudioKey: text('hls_audio_key'),
sourceUrl: text('source_url'),
mimeType: text('mime_type'),
byteSize: bigint('byte_size', { mode: 'number' }),
@@ -1386,7 +1391,8 @@ export const organizationInvite = pgTable(
organizationId: uuid('organization_id').notNull(),
// Role assigned when invite is accepted (e.g ADMIN, TUTOR)
roleId: bigint('role_id', { mode: 'number' }).notNull(),
email: text().notNull(),
type: organizationInviteType('type').default('EMAIL').notNull(),
email: text(),
tokenHash: text('token_hash').notNull(),
createdByProfileId: uuid('created_by_profile_id').notNull(),
acceptedByProfileId: uuid('accepted_by_profile_id'),
+5 -19
View File
@@ -3,8 +3,8 @@ import 'dotenv/config';
import * as schema from '../schema';
import { eq } from 'drizzle-orm';
import { SignJWT } from 'jose';
import { db } from '../drizzle';
import { mintLoginLinkToken } from '../auth/login-link';
const LOGIN_LINK_TTL_MINUTES = 10;
@@ -12,16 +12,6 @@ function getBaseUrl(): string {
return process.env.PUBLIC_SERVER_URL?.trim() || 'http://localhost:3002';
}
function getSecret(): Uint8Array {
const secret = process.env.BETTER_AUTH_SECRET?.trim();
if (!secret) {
throw new Error('BETTER_AUTH_SECRET is required');
}
return new TextEncoder().encode(secret);
}
function getArgs() {
const email = process.argv[2]?.trim().toLowerCase();
const redirect = process.argv[3]?.trim() || '/';
@@ -42,15 +32,11 @@ async function main() {
throw new Error(`User not found for email: ${email}`);
}
const token = await new SignJWT({
const token = await mintLoginLinkToken({
userId: user.id,
email,
type: 'login-link'
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime(`${LOGIN_LINK_TTL_MINUTES}m`)
.sign(getSecret());
ttlMinutes: LOGIN_LINK_TTL_MINUTES
});
const url = new URL('/api/auth/login-link', getBaseUrl());
url.searchParams.set('token', token);