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
+48
View File
@@ -0,0 +1,48 @@
import { TENANT_ROOT_DOMAIN, BRAND_ROOT_DOMAIN } from '@cio/utils/constants/domains';
import { env } from './env';
/**
* Minimal org shape consumed by `getDashboardBaseUrl`. Accepts the three
* fields that decide which host the org's URLs live on. All optional so
* callers without an org context can still ask for a sensible default.
*/
export type DashboardOrg = {
siteName?: string | null;
customDomain?: string | null;
isCustomDomainVerified?: boolean | null;
};
/**
* Returns the base URL for the dashboard app, used for invite links, login
* URLs, course URLs, and email-embedded links.
*
* Resolution order (highest precedence first):
* 1. Org's verified `customDomain` → `https://<customDomain>`
* 2. `DASHBOARD_ORIGIN` env var → that value (self-hosted override)
* 3. `NODE_ENV === 'development'` → `http://localhost:5173`
* 4. Org's `siteName` → `https://<siteName>.myclassroomio.com`
* 5. Final fallback → `https://app.classroomio.com`
*
* `customDomain` wins over `DASHBOARD_ORIGIN` because per-org BYOD must
* override the platform-wide self-hosted dashboard host for that org's URLs.
*/
export function getDashboardBaseUrl(org?: DashboardOrg): string {
if (org?.customDomain && org.isCustomDomainVerified) {
return `https://${org.customDomain}`;
}
if (env.DASHBOARD_ORIGIN) {
return env.DASHBOARD_ORIGIN.replace(/\/$/, '');
}
if (env.NODE_ENV === 'development') {
return 'http://localhost:5173';
}
if (org?.siteName) {
return `https://${org.siteName}.${TENANT_ROOT_DOMAIN}`;
}
return `https://app.${BRAND_ROOT_DOMAIN}`;
}
+100
View File
@@ -0,0 +1,100 @@
import * as z from 'zod';
const envSchema = z.object({
// Legacy Cloudflare R2 config (used when OBJECT_STORAGE_* vars are absent)
CLOUDFLARE_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
CLOUDFLARE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(),
APPROXIMATED_API_KEY: z.string().optional(),
APPROXIMATED_TARGET_ADDRESS: z.string().optional(),
/** Public IP customers point their A record to (Approximated edge IP). */
APPROXIMATED_DNS_TARGET_IP: z.string().optional(),
/** Optional CNAME target customers can point to instead of A record. */
APPROXIMATED_DNS_TARGET_CNAME: z.string().optional(),
// S3-compatible storage (MinIO, AWS S3, or other S3-compatible backends)
OBJECT_STORAGE_ENDPOINT: z.string().optional(),
OBJECT_STORAGE_REGION: z.string().optional(),
OBJECT_STORAGE_ACCESS_KEY_ID: z.string().optional(),
OBJECT_STORAGE_SECRET_ACCESS_KEY: z.string().optional(),
OBJECT_STORAGE_FORCE_PATH_STYLE: z.string().optional(),
OBJECT_STORAGE_BUCKET_VIDEOS: z.string().optional(),
OBJECT_STORAGE_BUCKET_DOCUMENTS: z.string().optional(),
OBJECT_STORAGE_BUCKET_MEDIA: z.string().optional(),
/** Public URL base for media bucket (images, thumbnails). Required for image uploads. */
OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: z.string().optional(),
/** Optional: endpoint for presigned URLs when different from internal (e.g. browser-reachable) */
OBJECT_STORAGE_PUBLIC_ENDPOINT: z.string().optional(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
OPENAPI_URL: z.string().optional(),
PORT: z.string().optional(),
REDIS_URL: z.string().optional(),
PUBLIC_EMBED_BASE_URL: z.string().optional(),
PUBLIC_SERVER_URL: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_SENDER: z.string().optional(),
SMTP_USER: z.string().optional(),
ZOHO_TOKEN: z.string().optional(),
TRUSTED_ORIGINS: z.string().optional(),
PRIVATE_SERVER_KEY: z.string().optional(),
PROJECT_ID_VERCEL: z.string().optional(),
TEAM_ID_VERCEL: z.string().optional(),
AUTH_BEARER_TOKEN: z.string().optional(),
UNSPLASH_API_KEY: z.string().optional(),
/** When 'true', instance runs in self-hosted mode: single org, auto-enroll as student, no org switching */
PUBLIC_IS_SELFHOSTED: z.string().optional(),
/** License key for enterprise features (SSO, token-auth, no-tracking, etc.) */
LICENSE_KEY: z.string().optional(),
/** Dashboard origin for invite/email links (e.g. https://app.yourdomain.com). When set, all email links point here instead of app.classroomio.com. Required for self-hosted. */
DASHBOARD_ORIGIN: z.string().optional(),
/**
* Comma-separated list of Better Auth user emails allowed to access the
* BullMQ dashboard at /admin/queues in production. Leave unset to keep the
* dashboard hidden everywhere except dev.
*/
QUEUE_DASHBOARD_ADMIN_EMAILS: z.string().optional(),
/**
* Bearer-token escape hatch for /admin/queues — useful for curl scripts or
* when SSO is unavailable. When set, requests with `Authorization: Bearer
* <token>` (or `?token=<token>`) bypass the email allowlist.
*/
QUEUE_DASHBOARD_TOKEN: z.string().optional(),
/** Optional Bearer token for Jina Reader (higher rate limits). */
JINA_API_KEY: z.string().optional(),
/** Max completed fetch_documentation_url calls per agent conversation (default 15). */
AGENT_MAX_FETCHES_PER_CONVERSATION: z
.string()
.optional()
.transform((v) => {
if (v === undefined || v === '') {
return 15;
}
const parsed = Number(v);
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 15;
}),
/** Sentry error tracking (server-side). Leave SENTRY_DSN unset to disable. */
SENTRY_DSN: z.string().optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
SENTRY_TRACES_SAMPLE_RATE: z.string().optional(),
/** Max fetch_documentation_url calls per org per UTC day (default 500). */
AGENT_MAX_FETCHES_PER_ORG_PER_DAY: z
.string()
.optional()
.transform((v) => {
if (v === undefined || v === '') {
return 500;
}
const parsed = Number(v);
return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 500;
})
});
export const env = envSchema.parse(process.env);
+136
View File
@@ -0,0 +1,136 @@
/**
* Object storage configuration for S3-compatible backends (Cloudflare R2, MinIO, AWS S3).
* Supports both generic OBJECT_STORAGE_* vars and backward-compatible CLOUDFLARE_* fallback.
*/
import { S3Client } from '@aws-sdk/client-s3';
import { env } from './env';
const BUCKET_DEFAULTS = {
VIDEOS: 'videos',
DOCUMENTS: 'documents',
MEDIA: 'media'
} as const;
/** Whether we're using the generic S3-compatible config (MinIO, etc.) vs Cloudflare R2 fallback */
export function useS3CompatibleConfig(): boolean {
return Boolean(
env.OBJECT_STORAGE_ENDPOINT && env.OBJECT_STORAGE_ACCESS_KEY_ID && env.OBJECT_STORAGE_SECRET_ACCESS_KEY
);
}
function isCloudflareConfigured(): boolean {
return Boolean(env.CLOUDFLARE_ACCOUNT_ID && env.CLOUDFLARE_ACCESS_KEY && env.CLOUDFLARE_SECRET_ACCESS_KEY);
}
export interface StorageConfig {
endpoint: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
forcePathStyle: boolean;
bucketVideos: string;
bucketDocuments: string;
bucketMedia: string;
mediaPublicBaseUrl: string | null;
presignUploadExpiresSeconds: number;
presignDownloadExpiresSeconds: number;
}
function resolveStorageConfig(): StorageConfig {
if (useS3CompatibleConfig()) {
const forcePathStyle =
env.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true' || env.OBJECT_STORAGE_FORCE_PATH_STYLE === '1';
return {
endpoint: env.OBJECT_STORAGE_ENDPOINT!,
region: env.OBJECT_STORAGE_REGION || 'us-east-1',
accessKeyId: env.OBJECT_STORAGE_ACCESS_KEY_ID!,
secretAccessKey: env.OBJECT_STORAGE_SECRET_ACCESS_KEY!,
forcePathStyle,
bucketVideos: env.OBJECT_STORAGE_BUCKET_VIDEOS || BUCKET_DEFAULTS.VIDEOS,
bucketDocuments: env.OBJECT_STORAGE_BUCKET_DOCUMENTS || BUCKET_DEFAULTS.DOCUMENTS,
bucketMedia: env.OBJECT_STORAGE_BUCKET_MEDIA || BUCKET_DEFAULTS.MEDIA,
mediaPublicBaseUrl: env.OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL || null,
presignUploadExpiresSeconds: 60 * 60, // 1 hour
presignDownloadExpiresSeconds: 60 * 60 // 1 hour
};
}
if (isCloudflareConfigured()) {
return {
endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: 'auto',
accessKeyId: env.CLOUDFLARE_ACCESS_KEY!,
secretAccessKey: env.CLOUDFLARE_SECRET_ACCESS_KEY!,
forcePathStyle: false,
bucketVideos: BUCKET_DEFAULTS.VIDEOS,
bucketDocuments: BUCKET_DEFAULTS.DOCUMENTS,
bucketMedia: BUCKET_DEFAULTS.MEDIA,
mediaPublicBaseUrl: env.CLOUDFLARE_IMAGE_BUCKET_DOMAIN || null,
presignUploadExpiresSeconds: 60 * 60,
presignDownloadExpiresSeconds: 60 * 60
};
}
throw new Error(
'Object storage not configured. Set either OBJECT_STORAGE_ENDPOINT, OBJECT_STORAGE_ACCESS_KEY_ID, OBJECT_STORAGE_SECRET_ACCESS_KEY (for MinIO/S3) or CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ACCESS_KEY, CLOUDFLARE_SECRET_ACCESS_KEY (for Cloudflare R2).'
);
}
let cachedConfig: StorageConfig | null = null;
export function getStorageConfig(): StorageConfig {
if (!cachedConfig) {
cachedConfig = resolveStorageConfig();
if (useS3CompatibleConfig()) {
console.log('[storage] Using S3-compatible object storage');
} else {
console.log('[storage] Using Cloudflare R2 (fallback)');
}
}
return cachedConfig;
}
/** S3 client for API operations (upload, get, delete). Uses internal endpoint. */
let s3ClientInstance: S3Client | null = null;
export function getS3Client(): S3Client {
if (!s3ClientInstance) {
const config = getStorageConfig();
s3ClientInstance = new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey
},
forcePathStyle: config.forcePathStyle
});
}
return s3ClientInstance;
}
/**
* S3 client for generating presigned URLs. Uses public endpoint when configured
* (e.g. when internal endpoint like minio:9000 is not reachable by browser).
*/
let presignClientInstance: S3Client | null = null;
export function getPresignS3Client(): S3Client {
if (!presignClientInstance) {
const config = getStorageConfig();
const endpoint = env.OBJECT_STORAGE_PUBLIC_ENDPOINT || config.endpoint;
presignClientInstance = new S3Client({
region: config.region,
endpoint,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey
},
forcePathStyle: config.forcePathStyle
});
}
return presignClientInstance;
}
export { BUCKET_DEFAULTS };