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
+20 -1
View File
@@ -1,4 +1,4 @@
import { getAccountData, updateUser } from '@api/services/account';
import { createViewAsStudentToken, getAccountData, updateUser } from '@api/services/account';
import { Hono } from '@api/utils/hono';
import { ZUpdateProfile } from '@cio/utils/validation/account';
@@ -51,6 +51,25 @@ export const accountRouter = new Hono()
return handleError(c, error, 'Failed to update profile');
}
})
.post('/view-as-student-token', authMiddleware, async (c) => {
const user = c.get('user')!;
try {
// Self sign-in only — mints a login-link token for the current user so they
// can be auto-authenticated on the org domain to preview the student experience.
const token = await createViewAsStudentToken({ id: user.id, email: user.email });
return c.json(
{
success: true,
data: { token }
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to create view-as-student token');
}
})
.get('/profile', authMiddleware, async (c) => {
const user = c.get('user')!;
+14 -18
View File
@@ -1,15 +1,12 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { HonoAdapter } from '@bull-board/hono';
import { serveStatic } from '@hono/node-server/serve-static';
import { workbench } from '@getworkbench/hono';
import type { Context, Hono, Next } from 'hono';
import { QUEUE_NAMES, getQueue, isRedisConfigured } from '@cio/jobs';
import { env } from '@api/config/env';
import { getQueue, isRedisConfigured, listQueueNames } from '@cio/jobs';
import { env } from '@cio/core/config/env';
/**
* Mounts the bull-board UI at `/admin/queues` for inspecting BullMQ state
* (waiting/active/completed/failed jobs, payloads, retries, etc.).
* Mounts the Workbench UI at `/admin/queues` for inspecting BullMQ state
* (jobs, flows, metrics, schedulers, retries, etc.).
*
* Access control:
* - Development: open (matches existing dev ergonomics).
@@ -29,25 +26,24 @@ export function mountQueueDashboard(app: Hono<any, any, any>): void {
const isProduction = env.NODE_ENV === 'production';
if (isProduction && !canEnforceProdAuth()) {
console.info(
'BullMQ dashboard (/admin/queues) not mounted in production. Set QUEUE_DASHBOARD_ADMIN_EMAILS or QUEUE_DASHBOARD_TOKEN to enable it.'
'Queue dashboard (/admin/queues) not mounted in production. Set QUEUE_DASHBOARD_ADMIN_EMAILS or QUEUE_DASHBOARD_TOKEN to enable it.'
);
return;
}
const serverAdapter = new HonoAdapter(serveStatic);
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: Object.values(QUEUE_NAMES).map((name) => new BullMQAdapter(getQueue(name))),
serverAdapter
});
if (isProduction) {
app.use('/admin/queues/*', queueDashboardAuth);
app.use('/admin/queues', queueDashboardAuth);
}
app.route('/admin/queues', serverAdapter.registerPlugin());
app.route(
'/admin/queues',
workbench({
basePath: '/admin/queues',
queues: listQueueNames().map((name) => getQueue(name)),
title: 'ClassroomIO Jobs'
})
);
}
function canEnforceProdAuth(): boolean {
+18 -14
View File
@@ -1,3 +1,4 @@
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
@@ -28,7 +29,7 @@ import {
getTokenBalance,
isOrgOnPaidPlan,
recordTokenUsage
} from '@api/services/agent/usage';
} from '@cio/core/services/agent/usage';
import {
enforceStudentTutorPolicy,
getStudentTutorStatus,
@@ -38,7 +39,7 @@ import {
incrementStudentTutorCount
} from '@api/services/agent/tutor-usage';
import { buildStudentAgentTools } from '@api/services/agent/student-tools';
import { parseAndStoreDocument } from '@api/services/agent/document';
import { parseAndStoreDocument } from '@cio/core/services/agent/document';
import { recordCreditPurchase } from '@api/services/agent/credit-purchase';
import { generateCourseMeta } from '@api/services/agent/title-generation';
import { generateFieldText } from '@api/services/agent/text-generation';
@@ -55,15 +56,15 @@ import {
import { createModel, getProviderConfigForProvider, pickAnyConfiguredProvider } from '@cio/ai-assistant/providers';
import { AGENT_MODELS, DEFAULT_PICKER_MODEL_ID } from '@cio/utils/agent-models';
import { buildSystemPrompt, buildContextMessage } from '@cio/ai-assistant/prompt';
import { trackAgentEvent, AgentEvent } from '@api/utils/tinybird';
import { redis } from '@api/utils/redis/redis';
import { trackAgentEvent, AgentEvent } from '@cio/core/utils/tinybird';
import { redis } from '@cio/core/utils/redis/redis';
import { db } from '@cio/db';
import * as schema from '@cio/db/schema';
import { eq } from 'drizzle-orm';
import { listCourseSections } from '@api/services/course/section';
import { getLesson } from '@api/services/lesson/lesson';
import { getExercise } from '@api/services/exercise/exercise';
import { sanitizeDanglingToolCalls } from '@api/services/agent/sanitize-tool-calls';
import { listCourseSections } from '@cio/core/services/course/section';
import { getLesson } from '@cio/core/services/lesson/lesson';
import { getExercise } from '@cio/core/services/exercise/exercise';
import { sanitizeDanglingToolCalls } from '@cio/core/services/agent/sanitize-tool-calls';
import {
collectDocumentIds,
getActiveCourseTemplateId,
@@ -71,10 +72,10 @@ import {
loadDocumentsText,
verifyExerciseBelongsToCourse,
verifyLessonBelongsToCourse
} from '@api/services/agent/chat-context';
import { buildAgentTools } from '@api/services/agent/chat-tools';
import { buildModelContextMessages } from '@api/services/agent/model-context';
import { summarizeConversation } from '@api/services/agent/summarize';
} from '@cio/core/services/agent/chat-context';
import { buildAgentTools, filterToolsForChatMode } from '@cio/core/services/agent/chat-tools';
import { buildModelContextMessages } from '@cio/core/services/agent/model-context';
import { summarizeConversation } from '@cio/core/services/agent/summarize';
import { agentHistoryRouter } from './history';
import { agentRunsRouter } from './runs';
@@ -530,7 +531,7 @@ const agentCoreRouter = new Hono()
const agentTools =
role === AgentRole.STUDENT
? buildStudentAgentTools(orgId, user.id, courseId, studentPolicy!.settings)
: buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid });
: filterToolsForChatMode(buildAgentTools(orgId, user.id, courseId, messages, { isOrgOnPaidPlan: isOrgPaid }));
const contextManaged = await buildModelContextMessages({
conversationId,
@@ -677,7 +678,10 @@ const agentCoreRouter = new Hono()
});
if (error instanceof AppError) {
return c.json({ success: false, error: error.message, code: error.code }, error.statusCode);
return c.json(
{ success: false, error: error.message, code: error.code },
error.statusCode as ContentfulStatusCode
);
}
console.error('Agent chat error:', error);
+1 -1
View File
@@ -1,5 +1,5 @@
import { ZCourseContentDelete, ZCourseContentReorder, ZCourseContentUpdate } from '@cio/utils/validation/course';
import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@api/services/course/content';
import { deleteCourseContent, reorderCourseContent, updateCourseContent } from '@cio/core/services/course/content';
import { Hono } from '@api/utils/hono';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
+2 -2
View File
@@ -25,7 +25,7 @@ import {
getCourseAnalytics,
getCourseProgress,
updateCourse
} from '@api/services/course/course';
} from '@cio/core/services/course/course';
import { assertCertificateDownloadAllowed, evaluateCourseCertification } from '@api/services/course/completion';
import { getCourseTags, replaceCourseTags } from '@api/services/tag';
@@ -62,7 +62,7 @@ import { presignRouter } from '@api/routes/course/presign';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { sectionRouter } from '@api/routes/course/section';
import { submissionRouter } from '@api/routes/course/submission';
import { updateCourseLandingPageService } from '@api/services/course/landing-page';
import { updateCourseLandingPageService } from '@cio/core/services/course/landing-page';
import { zValidator } from '@hono/zod-validator';
function slugifyForFilename(value: string): string {
+1 -1
View File
@@ -20,7 +20,7 @@ import {
getExercise,
listExercises,
updateExerciseService
} from '@api/services/exercise/exercise';
} from '@cio/core/services/exercise/exercise';
import { fetchAllTemplatesMetadata, fetchTemplateById, fetchTemplatesByTag } from '@api/services/exercise/template';
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import {
@@ -9,7 +9,7 @@ import {
listLessonLanguages,
updateLessonLanguageService,
upsertLessonLanguageService
} from '@api/services/lesson-language';
} from '@cio/core/services/lesson-language';
import { Hono } from '@api/utils/hono';
import type { TLocale } from '@db/types';
+1 -1
View File
@@ -9,7 +9,7 @@ import { addMembers, deleteMember, listCourseMembers, updateMember } from '@api/
import { Hono } from '@api/utils/hono';
import { ZCourseUserAnalyticsParam } from '@cio/utils/validation/course';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { getUserCourseAnalytics } from '@api/services/course/course';
import { getUserCourseAnalytics } from '@cio/core/services/course/course';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
+2 -2
View File
@@ -9,11 +9,11 @@ import {
generateDocumentUploadPresignedUrl,
generateVideoDownloadPresignedUrls,
generateVideoUploadPresignedUrl
} from '@api/utils/s3';
} from '@cio/core/utils/s3';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import { generateFileKey } from '@api/utils/upload';
import { generateFileKey } from '@cio/core/utils/upload';
// Response schemas for OpenAPI documentation
const PresignUploadResponse = {
+1 -1
View File
@@ -8,7 +8,7 @@ import {
promoteUngroupedSection,
reorderCourseSections,
updateCourseSectionService
} from '@api/services/course/section';
} from '@cio/core/services/course/section';
import {
ZCourseSectionCreate,
ZCourseSectionGetParam,
+172
View File
@@ -0,0 +1,172 @@
/**
* HLS playback streaming.
*
* Same URL shape as the production Cloudflare Worker that fronts the
* tenant-router (`/hls/{assetId}/{path}`). In production the Worker
* intercepts these requests at the edge and serves directly from an R2
* binding, so Render is never in the bytestream. This Hono route only
* fires in environments where the Worker isn't in front of the api —
* primarily local development, but it also acts as a defensive fallback
* if the Worker route ever misfires.
*
* Auth: standard session via `authMiddleware`. We then verify the
* caller's session belongs to the same organization that owns the
* asset. No separate `cio_hls` HMAC cookie is needed here — that's only
* how the Worker authorizes requests in production.
*/
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getAssetById } from '@cio/db/queries/assets';
import { getS3Client, getStorageConfig } from '@cio/core/config/storage';
import { HLS_COOKIE_NAME, verifyHlsToken } from '@cio/core/services/assets/assets';
import { resolveTrustedBrowserOriginForCors } from '@api/utils/origins';
import { getCookie } from 'hono/cookie';
import { Hono } from '@api/utils/hono';
const HLS_CONTENT_TYPES: Record<string, string> = {
m3u8: 'application/vnd.apple.mpegurl',
ts: 'video/mp2t',
m4s: 'video/iso.segment',
mp4: 'video/mp4',
m4a: 'audio/mp4',
aac: 'audio/aac'
};
function contentTypeFor(path: string): string {
const dot = path.lastIndexOf('.');
if (dot === -1) return 'application/octet-stream';
const ext = path.slice(dot + 1).toLowerCase();
return HLS_CONTENT_TYPES[ext] ?? 'application/octet-stream';
}
export const hlsRouter = new Hono()
.options('/:assetId/*', (c) => {
const origin = c.req.header('origin');
let corsHeaders: Record<string, string> = {};
if (origin) {
const allowed = resolveTrustedBrowserOriginForCors(origin);
if (allowed) {
corsHeaders = {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '600',
'Cross-Origin-Resource-Policy': 'cross-origin'
};
}
}
return new Response(null, { status: 204, headers: corsHeaders });
})
/**
* GET/HEAD /hls/:assetId/* — stream an HLS manifest or segment from R2.
*
* No `authMiddleware` here — public lessons play through the same route
* using only the HMAC cookie. The handler validates either the cookie
* or org-member session itself.
*/
.on(['GET', 'HEAD'], '/:assetId/*', async (c) => {
const origin = c.req.header('origin');
let corsHeaders: Record<string, string> = {};
if (origin) {
const allowed = resolveTrustedBrowserOriginForCors(origin);
if (allowed) {
corsHeaders = {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Credentials': 'true',
// Override the `Cross-Origin-Resource-Policy: same-origin` that
// app-wide `secureHeaders()` sets — the dashboard origin needs to
// load `<video>` segments from this api origin in local dev /
// self-hosted, and CORP=same-origin blocks the response even
// when CORS allows the request.
'Cross-Origin-Resource-Policy': 'cross-origin'
};
}
}
const withCors = (body: BodyInit | null, status: number, extraHeaders?: Record<string, string>) =>
new Response(body, { status, headers: { ...corsHeaders, ...extraHeaders } });
const assetId = c.req.param('assetId');
if (!assetId) return withCors(null, 400, { 'Content-Type': 'application/json' });
const fullPath = c.req.path;
const marker = `/hls/${assetId}/`;
const idx = fullPath.indexOf(marker);
const relative = idx === -1 ? '' : fullPath.slice(idx + marker.length);
if (!relative || relative.includes('..')) {
return withCors(null, 400, { 'Content-Type': 'application/json' });
}
let authorized = false;
let denyReason: 'no_token_or_session' | 'token_invalid' | 'session_no_role' | 'asset_missing' =
'no_token_or_session';
const token = getCookie(c, HLS_COOKIE_NAME);
if (token) {
const verified = await verifyHlsToken(token);
if (verified?.assetId === assetId) {
authorized = true;
} else {
denyReason = 'token_invalid';
}
}
if (!authorized) {
const orgRoles = (c.get('orgRoles') as Record<string, number> | undefined) ?? {};
if (Object.keys(orgRoles).length) {
const asset = await getAssetById(assetId);
if (!asset) {
denyReason = 'asset_missing';
} else if (orgRoles[asset.organizationId] !== undefined) {
// role 0 still counts as membership — match `orgMemberMiddleware`'s
// `roleId === undefined` rejection rule instead of a truthy check.
authorized = true;
} else {
denyReason = 'session_no_role';
}
}
}
if (!authorized) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`[hls] 403 for asset=${assetId} reason=${denyReason} cookiePresent=${Boolean(token)} hasSession=${Boolean(c.get('user'))}`
);
}
return withCors(null, 403, { 'Content-Type': 'application/json' });
}
const config = getStorageConfig();
const client = getS3Client();
const objectKey = `${assetId}/${relative}`;
try {
if (c.req.method === 'HEAD') {
const head = await client.send(new HeadObjectCommand({ Bucket: config.bucketVideos, Key: objectKey }));
const headHeaders: Record<string, string> = {
'Content-Type': contentTypeFor(relative),
'Cache-Control': relative.endsWith('.m3u8') ? 'private, max-age=10' : 'private, max-age=31536000, immutable'
};
if (head.ContentLength != null) headHeaders['Content-Length'] = String(head.ContentLength);
return withCors(null, 200, headHeaders);
}
const result = await client.send(new GetObjectCommand({ Bucket: config.bucketVideos, Key: objectKey }));
if (!result.Body) {
return withCors(null, 502);
}
const headers: Record<string, string> = {
'Content-Type': contentTypeFor(relative),
'Cache-Control': relative.endsWith('.m3u8') ? 'private, max-age=10' : 'private, max-age=31536000, immutable'
};
if (result.ContentLength != null) headers['Content-Length'] = String(result.ContentLength);
return withCors(result.Body as unknown as ReadableStream, 200, headers);
} catch (error) {
if (error instanceof Error && error.name === 'NoSuchKey') {
return withCors(null, 404);
}
console.error('hls stream error:', error);
return withCors(null, 500);
}
});
+1
View File
@@ -0,0 +1 @@
export * from './hls';
+66
View File
@@ -2,9 +2,11 @@ import { ZCourseInviteTokenParam } from '@cio/utils/validation/course/invite';
import { previewStudentInvite } from '@api/services/course/invite';
import { ZOrganizationInviteTokenParam } from '@cio/utils/validation/organization/invite';
import {
acceptLinkInvite,
acceptOrganizationInvite,
acceptOrganizationInviteById,
getPendingOrgInviteForUser,
previewLinkInvite,
previewOrganizationInvite
} from '@api/services/organization/invite';
@@ -42,6 +44,24 @@ const acceptOrganizationInviteRateLimit = createRateLimiter({
}
});
const previewLinkInviteRateLimit = createRateLimiter({
windowMs: 60 * 1000,
maxRequests: 30,
message: 'Too many link invite preview requests. Please try again later.',
keyGenerator: (c) => `link_invite_preview:${extractClientIp(c)}:${c.req.param('token')}`
});
const acceptLinkInviteRateLimit = createRateLimiter({
windowMs: 60 * 60 * 1000,
maxRequests: 20,
message: 'Too many link invite join attempts. Please try again later.',
keyGenerator: (c) => {
const user = c.get('user');
const actor = user?.id ? `user:${user.id}` : `ip:${extractClientIp(c)}`;
return `link_invite_accept:${actor}:${c.req.param('token')}`;
}
});
const pendingInviteRateLimit = createRateLimiter({
windowMs: 60 * 1000,
maxRequests: 30,
@@ -203,4 +223,50 @@ export const inviteRouter = new Hono()
return handleError(c, error, 'Failed to accept organization invite');
}
}
)
/**
* GET /invite/link/:token/preview
* Server-only preview for link invites (API key)
*/
.get(
'/link/:token/preview',
apiKeyMiddleware,
previewLinkInviteRateLimit,
zValidator('param', ZOrganizationInviteTokenParam),
async (c) => {
try {
const { token } = c.req.valid('param');
const preview = await previewLinkInvite(token, {
ipAddress: extractClientIp(c),
userAgent: c.req.header('user-agent') || null
});
return c.json({ success: true, data: preview }, 200);
} catch (error) {
return handleError(c, error, 'Failed to load link invite');
}
}
)
/**
* POST /invite/link/:token/accept
* Authenticated link invite acceptance — adds the user to the org
*/
.post(
'/link/:token/accept',
authMiddleware,
acceptLinkInviteRateLimit,
zValidator('param', ZOrganizationInviteTokenParam),
async (c) => {
try {
const { token } = c.req.valid('param');
const user = c.get('user')!;
const result = await acceptLinkInvite(
token,
{ id: user.id, email: user.email },
{ ipAddress: extractClientIp(c), userAgent: c.req.header('user-agent') || null }
);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to accept link invite');
}
}
);
+32 -1
View File
@@ -1,8 +1,13 @@
import { ZPublicCourseBySlugParam, ZPublicCourseItemParam } from '@cio/utils/validation/course';
import { getPublicCourseItemService, getPublicCourseTreeService } from '@api/services/course/public-course';
import {
getPublicCourseItemService,
getPublicCourseTreeService,
issuePublicHlsCookieService
} from '@api/services/course/public-course';
import { Hono } from '@api/utils/hono';
import { handleError } from '@api/utils/errors';
import { setCookie } from 'hono/cookie';
import { zValidator } from '@hono/zod-validator';
/**
@@ -52,4 +57,30 @@ export const publicCourseRouter = new Hono()
} catch (error) {
return handleError(c, error, 'Failed to load public course item');
}
})
/**
* POST /org-site/course/:courseSlug/item/:itemSlug/hls-cookie
* Anonymous-safe. Verifies that the public lesson exposes an HLS asset
* and mints the short-lived `cio_hls` cookie the tenant-router Worker
* (prod) and API streaming route (local) both validate before serving
* segments. Caller is expected to call this once per lesson page load
* before attaching hls.js.
*/
.post('/:courseSlug/item/:itemSlug/hls-cookie', zValidator('param', ZPublicCourseItemParam), async (c) => {
try {
const { courseSlug, itemSlug } = c.req.valid('param');
const result = await issuePublicHlsCookieService(courseSlug, itemSlug);
setCookie(c, result.cookieName, result.token, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: result.maxAgeSeconds
});
return c.json({ success: true as const, data: { expiresAt: result.expiresAt } });
} catch (error) {
return handleError(c, error, 'Failed to issue HLS cookie');
}
});
+231 -1
View File
@@ -6,23 +6,39 @@ import {
ZAssetGetParam,
ZAssetListQuery,
ZAssetStorageQuery,
ZAssetThumbnailSelect,
ZAssetUpdate,
ZAssetUsageParams,
ZBatchPresignHls,
ZBatchPresignHls1080,
ZFinalizeHls1080,
ZFinalizeHlsAsset,
ZInitHlsAsset,
ZYouTubeMetadataQuery
} from '@cio/utils/validation/assets';
import { ZUpdateTranscript } from '@cio/utils/validation/media';
import {
abortHlsAssetService,
attachAssetService,
batchPresignHlsService,
batchPresignHls1080Service,
createAssetFromUploadService,
deleteAssetService,
detachAssetService,
exportOrganizationAssetsService,
finalizeHls1080Service,
finalizeHlsAssetService,
getAssetService,
getAssetUsageGraphService,
getOrganizationAssetStorageService,
getTranscriptForOrganizationAssetService,
getYouTubeMetadataService,
initHlsAssetService,
issueHlsCookieService,
listOrganizationAssetsService,
updateAssetService
selectAssetThumbnailService,
updateAssetService,
updateTranscriptForOrganizationAssetService
} from '@api/services/assets';
import { Hono } from '@api/utils/hono';
@@ -31,6 +47,7 @@ import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { zValidator } from '@hono/zod-validator';
import { setCookie } from 'hono/cookie';
export const assetsRouter = new Hono()
/**
@@ -82,6 +99,160 @@ export const assetsRouter = new Hono()
return handleError(c, error, 'Failed to create asset');
}
})
/**
* POST /organization/assets/hls/init
* Reserve an assetId for an HLS upload. Returns the asset id and the
* key prefix the browser encoder should write segments under.
*/
.post('/hls/init', authMiddleware, orgMemberMiddleware, zValidator('json', ZInitHlsAsset), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const data = c.req.valid('json');
const result = await initHlsAssetService(orgId, user.id, data);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to init HLS asset');
}
})
/**
* POST /organization/assets/:assetId/hls/presign
* Batch-presign PUT URLs for segment/playlist/audio/thumbnail uploads.
*/
.post(
'/:assetId/hls/presign',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZBatchPresignHls),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await batchPresignHlsService(orgId, assetId, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to presign HLS uploads');
}
}
)
/**
* POST /organization/assets/:assetId/hls/finalize
* Flip an HLS asset from `processing` to `active` once the browser
* encoder has written everything and persist its metadata.
*/
.post(
'/:assetId/hls/finalize',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZFinalizeHlsAsset),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { assetId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await finalizeHlsAssetService(orgId, assetId, user.id, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to finalize HLS asset');
}
}
)
/**
* POST /organization/assets/:assetId/hls/1080/presign
* Batch-presign PUT URLs for a manual p1080 rendition on an active HLS asset.
*/
.post(
'/:assetId/hls/1080/presign',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZBatchPresignHls1080),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await batchPresignHls1080Service(orgId, assetId, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to presign 1080p HLS uploads');
}
}
)
/**
* POST /organization/assets/:assetId/hls/1080/finalize
* Persist p1080 rendition metadata after the browser encoder updates the manifest.
*/
.post(
'/:assetId/hls/1080/finalize',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZFinalizeHls1080),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const data = c.req.valid('json');
const result = await finalizeHls1080Service(orgId, assetId, data);
return c.json({ success: true, data: result }, 200);
} catch (error) {
return handleError(c, error, 'Failed to finalize 1080p HLS rendition');
}
}
)
/**
* DELETE /organization/assets/:assetId/hls
* Abort a `processing` HLS asset: best-effort delete R2 objects under
* `videos/{assetId}/` and remove the asset row. Called by the encoder
* when it errors or is aborted mid-upload, so we don't leak stale
* `processing` rows + partial segment trees.
*/
.delete('/:assetId/hls', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const deleted = await abortHlsAssetService(orgId, assetId);
return c.json({ success: true, data: deleted }, 200);
} catch (error) {
return handleError(c, error, 'Failed to abort HLS asset');
}
})
/**
* POST /organization/assets/:assetId/hls/cookie
* After entitlement check, set a 15-minute HMAC-signed cookie scoped to
* this asset id. The tenant-router Worker verifies the cookie locally
* on every /hls/{assetId}/* segment request — no round-trip back to Render.
*/
.post('/:assetId/hls/cookie', authMiddleware, orgMemberMiddleware, zValidator('param', ZAssetGetParam), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const { token, expiresAt, cookieName, maxAgeSeconds } = await issueHlsCookieService(orgId, assetId);
setCookie(c, cookieName, token, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'Lax',
maxAge: maxAgeSeconds
});
return c.json({ success: true, data: { expiresAt } }, 200);
} catch (error) {
return handleError(c, error, 'Failed to issue HLS cookie');
}
})
/**
* GET /organization/assets/export
* Export organization assets with usage graph
@@ -172,6 +343,36 @@ export const assetsRouter = new Hono()
return handleError(c, error, 'Failed to fetch transcript');
}
})
/**
* PUT /organization/assets/:assetId/transcript
* Update transcript segment text (org admins only). Captions re-render from
* these segments — no R2 write.
*/
.put(
'/:assetId/transcript',
authMiddleware,
orgAdminMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZUpdateTranscript),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const { segments } = c.req.valid('json');
const data = await updateTranscriptForOrganizationAssetService(orgId, assetId, segments);
return c.json(
{
success: true,
data
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update transcript');
}
}
)
/**
* GET /organization/assets/:assetId
* Get a single asset by id
@@ -222,6 +423,35 @@ export const assetsRouter = new Hono()
}
}
)
/**
* PUT /organization/assets/:assetId/thumbnail
* Choose a thumbnail for an asset (from generated candidates or a fresh upload).
*/
.put(
'/:assetId/thumbnail',
authMiddleware,
orgMemberMiddleware,
zValidator('param', ZAssetGetParam),
zValidator('json', ZAssetThumbnailSelect),
async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const { assetId } = c.req.valid('param');
const { thumbnailUrl } = c.req.valid('json');
const updated = await selectAssetThumbnailService(orgId, assetId, thumbnailUrl);
return c.json(
{
success: true,
data: updated
},
200
);
} catch (error) {
return handleError(c, error, 'Failed to update asset thumbnail');
}
}
)
/**
* DELETE /organization/assets/:assetId
* Delete an unused asset
@@ -2,6 +2,7 @@ import {
ZAssignAudienceCourses,
ZAudienceInviteByEmail,
ZCancelOrgPlan,
ZCreateLinkInvite,
ZCreateOrgPlan,
ZCreateOrganization,
ZGetAudienceQuery,
@@ -14,6 +15,7 @@ import {
ZInviteTeamMembers,
ZLMSExercisesParam,
ZRemoveTeamMember,
ZToggleLinkInvite,
ZUpdateOrgPlan,
ZUpdateOrganization
} from '@cio/utils/validation/organization';
@@ -57,7 +59,12 @@ import { automationRouter } from '@api/routes/organization/automation';
import { courseImportRouter } from '@api/routes/organization/course-import';
import { getLMSExercisesService } from '@api/services/exercise';
import { handleError } from '@api/utils/errors';
import { inviteTeamMembers } from '@api/services/organization/invite';
import {
fetchOrgLinkInvite,
getOrCreateLinkInvite,
inviteTeamMembers,
toggleOrgLinkInvite
} from '@api/services/organization/invite';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgMemberOrAutomationKeyMiddleware } from '@api/middlewares/org-member-or-automation-key';
@@ -178,6 +185,49 @@ export const organizationRouter = new Hono()
return handleError(c, error, 'Failed to invite team members');
}
})
/**
* GET /organization/link-invite
* Returns the current org link invite (null if none created yet)
*/
.get('/link-invite', authMiddleware, orgAdminMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const data = await fetchOrgLinkInvite(orgId);
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch link invite');
}
})
/**
* POST /organization/link-invite
* Get-or-create the org link invite
*/
.post('/link-invite', authMiddleware, orgAdminMiddleware, zValidator('json', ZCreateLinkInvite), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { roleId } = c.req.valid('json');
const data = await getOrCreateLinkInvite(orgId, roleId, user.id);
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to create link invite');
}
})
/**
* PATCH /organization/link-invite
* Enable or disable the org link invite
*/
.patch('/link-invite', authMiddleware, orgAdminMiddleware, zValidator('json', ZToggleLinkInvite), async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const user = c.get('user')!;
const { isRevoked } = c.req.valid('json');
const data = await toggleOrgLinkInvite(orgId, isRevoked, user.id);
return c.json({ success: true, data }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update link invite');
}
})
/**
* DELETE /organization/team/:memberId
* Removes a team member from an organization
@@ -332,12 +382,17 @@ export const organizationRouter = new Hono()
*/
.get('/courses/public', zValidator('query', ZGetCoursesBySiteName), async (c) => {
try {
const { siteName, tags } = c.req.valid('query');
const { siteName, tags, page, limit } = c.req.valid('query');
const tagSlugs = tags
?.split(',')
.map((value) => value.trim())
.filter(Boolean);
const courses = await getPublicCourses(siteName, tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined);
const pagination = page !== undefined || limit !== undefined ? { page, limit } : undefined;
const courses = await getPublicCourses(
siteName,
tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined,
pagination
);
return c.json(
{
+1
View File
@@ -0,0 +1 @@
export * from './transcripts';
@@ -0,0 +1,101 @@
/**
* Transcript VTT proxy.
*
* `<track>` elements inside `<video crossorigin="anonymous">` can only
* load resources that return CORS headers. We serve the VTT through the
* api the same way we do HLS segments, rendering it on the fly from the
* stored transcript segments (the structured source of truth) so edits
* are reflected immediately without an R2 round trip. The path is
* same-origin behind the tenant-router Worker in prod and a CORS-allowed
* cross-origin call in local dev.
*
* Auth mirrors `/hls/*`:
* 1. Valid `cio_hls` HMAC cookie whose `aid` matches the assetId, OR
* 2. Authenticated session with org-member access to the asset's org.
*/
import { getAssetById } from '@cio/db/queries/assets';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { segmentsToWebVtt } from '@cio/utils/functions';
import { HLS_COOKIE_NAME, verifyHlsToken } from '@cio/core/services/assets/assets';
import { resolveTrustedBrowserOriginForCors } from '@api/utils/origins';
import { getCookie } from 'hono/cookie';
import { Hono } from '@api/utils/hono';
export const transcriptsRouter = new Hono()
.options('/:assetId/track.vtt', (c) => {
const origin = c.req.header('origin');
let corsHeaders: Record<string, string> = {};
if (origin) {
const allowed = resolveTrustedBrowserOriginForCors(origin);
if (allowed) {
corsHeaders = {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '600',
'Cross-Origin-Resource-Policy': 'cross-origin'
};
}
}
return new Response(null, { status: 204, headers: corsHeaders });
})
/**
* GET/HEAD /transcripts/:assetId/track.vtt — render a transcript VTT
* from the stored segments with cross-origin CORS so a `<track crossorigin>`
* element in the dashboard can load it.
*/
.on(['GET', 'HEAD'], '/:assetId/track.vtt', async (c) => {
const assetId = c.req.param('assetId');
if (!assetId) return c.json({ success: false, message: 'Missing assetId' }, 400);
const origin = c.req.header('origin');
let corsHeaders: Record<string, string> = {};
if (origin) {
const allowed = resolveTrustedBrowserOriginForCors(origin);
if (allowed) {
corsHeaders = {
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Credentials': 'true',
'Cross-Origin-Resource-Policy': 'cross-origin'
};
}
}
const withCors = (body: BodyInit | null, status: number, extraHeaders?: Record<string, string>) =>
new Response(body, { status, headers: { ...corsHeaders, ...extraHeaders } });
const asset = await getAssetById(assetId);
if (!asset) return withCors(null, 404);
let authorized = false;
const token = getCookie(c, HLS_COOKIE_NAME);
if (token) {
const verified = await verifyHlsToken(token);
if (verified?.assetId === assetId) authorized = true;
}
if (!authorized) {
const orgRoles = (c.get('orgRoles') as Record<string, number> | undefined) ?? {};
if (orgRoles[asset.organizationId]) authorized = true;
}
if (!authorized) {
return withCors(null, 403);
}
const transcript = await getMediaTranscriptByAsset(assetId, asset.organizationId);
if (!transcript) return withCors(null, 404);
const vttHeaders = {
'Content-Type': 'text/vtt; charset=utf-8',
'Cache-Control': 'private, no-cache'
};
if (c.req.method === 'HEAD') {
return withCors(null, 200, vttHeaders);
}
const vtt = segmentsToWebVtt(transcript.segments);
return withCors(vtt, 200, vttHeaders);
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { Hono } from '@api/utils/hono';
import { env } from '@api/config/env';
import { env } from '@cio/core/config/env';
import { handleError } from '@api/utils/errors';
import { z } from 'zod';