diff --git a/.env.example b/.env.example
index 718b2963..7f3c2868 100644
--- a/.env.example
+++ b/.env.example
@@ -12,14 +12,6 @@ POSTGRES_DB=classroomio
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
-# Public URL where the API is reachable from browsers
-# Local: http://localhost:3081 | Production: https://api.yourdomain.com
-PUBLIC_SERVER_URL=http://localhost:3081
-
-# Comma-separated origins allowed by CORS
-# Local: http://localhost:3082 | Production: https://app.yourdomain.com
-TRUSTED_ORIGINS=http://localhost:3082,http://localhost:5173
-
# Auth secret for session signing (use a long random string in production)
BETTER_AUTH_SECRET=replace-with-a-long-random-secret
@@ -42,9 +34,21 @@ PRIVATE_SERVER_URL=http://api:3081
# When set, invite emails and other links point here instead of app.classroomio.com
DASHBOARD_ORIGIN=http://localhost:3082
+# Optional direct API origin. Dashboard browser calls are proxied through
+# DASHBOARD_ORIGIN, so normal self-hosted auth does not need this to share a
+# cookie domain with the dashboard.
+# Local: http://localhost:3081 | Production fallback: https://api.yourdomain.com
+PUBLIC_SERVER_URL=http://localhost:3081
+
+# Optional comma-separated origins allowed when browsers call the API directly.
+# Normal self-hosted dashboard auth/API traffic is same-origin through the dashboard proxy.
+# DASHBOARD_ORIGIN is trusted automatically.
+TRUSTED_ORIGINS=
+
# CSP: external domains allowed by Content-Security-Policy.
# These are runtime env vars — set them when running the container (no rebuild needed).
-# Comma-separated. Include your CDN, API, video, and font domains.
+# Comma-separated. Include your CDN, video, and font domains. You do not need
+# to add the API here for normal dashboard traffic because API calls are proxied same-origin.
# ALLOWED_EXTERNAL_DOMAINS overrides all per-directive vars below.
ALLOWED_EXTERNAL_DOMAINS=
# Per-directive overrides (optional, comma-separated):
@@ -96,10 +100,6 @@ GOOGLE_CLIENT_SECRET=
# PUBLIC_GOOGLE_PICKER_CLIENT_ID=
# PUBLIC_GOOGLE_PICKER_API_KEY=
-# Cookie domain for auth. Set to your root domain (e.g. .yourdomain.com) when API and dashboard use different subdomains.
-# Without this, auth cookies may not be set and login will fail.
-AUTH_COOKIE_DOMAIN=
-
# Dashboard domain routing
PRIVATE_APP_HOST=localhost
PRIVATE_APP_SUBDOMAINS=app
diff --git a/PRD-TRACKER.md b/PRD-TRACKER.md
index 753eb532..c0fa5065 100644
--- a/PRD-TRACKER.md
+++ b/PRD-TRACKER.md
@@ -13,12 +13,14 @@ Use this file to keep a single prioritized view of all PRDs.
| 5 | 3 | `prd/course-cohorts` | todo |
| 6 | 7 | `prd/email-template-customization` | todo |
| 7 | - | `prd/selective-exercise-assignment` | todo |
+| - | - | `prd/scorm-support` | todo |
| - | 4 | `prd/course-content-search` | todo |
| - | 9 | `prd/onboarding-ai-bootstrap` | todo |
| - | 10 | `prd/pricing-improvements` | todo |
| - | 12 | `prd/public-courses` | todo |
| - | - | `prd/customer-course-documentation` | todo |
| - | - | `prd/walkthrough-widget` | draft |
+| - | - | `prd/copy-page-as-markdown` | todo |
| - | 15 | `prd/webhooks` | todo |
## DONE (Completed PRDs)
diff --git a/README.md b/README.md
index bdb45390..979c2934 100644
--- a/README.md
+++ b/README.md
@@ -8,35 +8,46 @@
-[](https://app.codacy.com/gh/classroomio/classroomio?utm_source=github.com&utm_medium=referral&utm_content=classroomio/classroomio&utm_campaign=Badge_Grade)
-
## ✨ About ClassroomIO.com
-
+
-Streamline training for everyone with ClassroomIO.com. Our all-in-one platform empowers bootcamps, educators, and businesses to manage training programs easily. With our platform, anyone can run multiple classes and cohorts all from one UI. The application is mobile-first, which means that students can access your lesson materials from any device.
+ClassroomIO is an open source LMS for companies. Run compliance/employee training, customer education, and partner certification programs — all from one platform. Self-host it on your own infrastructure or use the cloud version.
-### Mission: Provide Students with the Best Learning Experience
+### Who It's For
-At ClassroomIO, our mission is to provide students with the best possible learning experience. We believe in empowering educators with the tools they need to deliver high-quality education that is accessible, engaging, and effective.
+| Use Case | What You Get |
+|---|---|
+| **Compliance Training** | Deadline tracking, renewals & retake intervals, grace periods, waivers, certificates with custom IDs |
+| **Customer Education** | Branded academy portal, programs (cohorts with goals), AI lesson tutor, multilingual content |
+| **Partner Training** | Partner workspaces, branded certificates, custom domains, embeddable widgets, multilingual support |
### Key Features
-1. **📚 Advanced Course Management:** You can create unlimited courses, create lessons, invite students, add assignments, grade their assignments, and even generate certificates.
-2. **👨👩👦 Multi-Teacher Management:** You can invite other teachers into your organization and assign them individual courses.
-3. **🤖 AI Integration:** We've got OpenAI integration for quick course creation where you can generate course content, lesson outlines, and even generate assignments right from your lesson notes.
-4. **💬 Forum:** Students can ask questions in your dedicated community and get answers from either you or other students.
-5. **💻 Dedicated Student Dashboard:** Once you create an account, you get a dedicated dashboard where your students can access all their courses, assignments, and more.
-6. **🔒 Fully open source:** You can self-host the entire stack on your servers.
+**Course & Content**
+- **Course management** — unlimited courses, lessons, exercises, grading, and certificates
+- **Programs** — group courses into cohorts with goals, team management, and progress tracking
+- **AI Course Builder** — generate outlines, lesson content, and assignments (Gemini, GPT-4o, Claude)
+- **AI Lesson Tutor** — in-lesson AI assistant that helps learners as they study
-### Roadmap Features
+**Compliance & Certification**
+- **Compliance tracking** — status, deadlines, grace periods, renewals, and waivers
+- **Certificates** — issue branded certificates with custom IDs
-1. **Forms:** Instead of using Google Forms to collect vital information from your students, you will be able to create forms directly within the dashboard.
-2. **Course Templates:** You can clone a full course or share templates with other people.
-3. **Analytics:** You can track data about your students across multiple courses.
-4. **Run Courses on Messengers:** Students can just join a channel on slack/discord/telegram and a bot automatically sends daily lesson content to your students without you doing anything.
+**Learner & Org Experience**
+- **Multi-org & multi-teacher** — invite teachers, assign courses, manage multiple organizations
+- **Student dashboard** — learners access all courses, assignments, and progress in one place
+- **Multilingual** — deliver content in 10+ languages
-Please reach out to me on [twitter](https://x.com/rotimi_best) if you have any feature request.
+**Integrations & Developer Tools**
+- **REST API + Webhooks** — enroll users, trigger automations, receive events (`certificate.issued`, `enrollment.completed`, and more)
+- **MCP server** — `@classroomio/mcp` on npm for AI-native integrations
+- **Embeddable widget** — embed your course catalog on any website
+
+**Platform**
+- **Fully open source** — self-host the entire stack on your own servers
+
+For what's coming next, see the [public roadmap](https://classroomio.com/roadmap).
## Built With
@@ -119,9 +130,9 @@ The repository also contains shared packages under `packages/` (for example `pac
- Go to `apps/dashboard` and `apps/api`.
- Duplicate the `.env.example` file and rename it to `.env`
- - Populate them with required values (at minimum):
+ - Populate them with required values (at minimum):
- `apps/api/.env`: `DATABASE_URL`, `REDIS_URL`, `AUTH_BEARER_TOKEN`, `BETTER_AUTH_SECRET`
- - `apps/dashboard/.env`: `PUBLIC_SERVER_URL`, `PRIVATE_SERVER_KEY`, `PUBLIC_IS_SELFHOSTED`
+ - `apps/dashboard/.env`: `PRIVATE_SERVER_URL`, `PRIVATE_SERVER_KEY`, `PUBLIC_IS_SELFHOSTED`, `DASHBOARD_ORIGIN`
- Optional for self-hosted Enterprise-only features (SSO, token-auth, no-tracking): set `LICENSE_KEY` in `apps/api/.env`
6. Start local infrastructure for API (Postgres + Redis) and seed the DB:
diff --git a/apps/api/.env.example b/apps/api/.env.example
index 53f64650..0ad0bf06 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -41,6 +41,14 @@ CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_BUCKET_DOMAIN=""
CLOUDFLARE_SECRET_ACCESS_KEY=""
+# HMAC secret used to sign the `cio_hls` playback cookie. The same value must
+# be set on the tenant-router Worker (`wrangler secret put HLS_SIGNING_SECRET`)
+# so segments served from R2 at the edge verify against this signature.
+# Without it the cookie endpoints return 503 and HLS playback won't start —
+# uploads still work, so leave it unset for upload-only local development.
+# Generate one with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
+HLS_SIGNING_SECRET=""
+
# Sentry (server-side error tracking). Leave SENTRY_DSN unset to disable.
# Skipped automatically in dev (NODE_ENV !== 'production') and self-hosted (PUBLIC_IS_SELFHOSTED=true).
SENTRY_DSN=""
diff --git a/apps/api/README.md b/apps/api/README.md
index 92072dbc..41dfcebd 100644
--- a/apps/api/README.md
+++ b/apps/api/README.md
@@ -5,6 +5,86 @@
1. Certificate download for students.
2. Uploading videos to cloudflare (for storage) and Muse.ai (for transcription)
+## Running locally with video upload + playback
+
+Lesson videos run through an HLS pipeline on Cloud. The browser encodes a
+360/720/1080 ABR ladder with Mediabunny, uploads segments to R2 via presigned
+PUTs, and the player loads `/hls/{assetId}/master.m3u8` from the API.
+
+How `/hls/*` resolves depends on the environment:
+
+| | URL the browser hits | Who serves the bytes |
+| ----------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| Cloud production | `https:///hls/...` | Cloudflare Worker (`apps/tenant-router`) — intercepts the path and streams directly from an R2 binding. Render is never in the bytestream. |
+| Local dev / self-hosted | `${PUBLIC_SERVER_URL}/hls/...` (direct to API) | Hono streaming route on the API — session-auth, then `getS3Client().send(GetObjectCommand)`. |
+
+**Self-hosted (`PUBLIC_IS_SELFHOSTED=true`) skips HLS entirely** and falls
+back to raw-MP4 upload — no R2-specific code path. Everything below is for
+Cloud-style local development.
+
+### Required env
+
+`apps/api/.env`:
+
+```bash
+# Object storage — Cloudflare R2 OR an S3-compatible local backend (MinIO, etc.)
+CLOUDFLARE_ACCOUNT_ID=…
+CLOUDFLARE_ACCESS_KEY=…
+CLOUDFLARE_SECRET_ACCESS_KEY=…
+# (or OBJECT_STORAGE_ENDPOINT / OBJECT_STORAGE_ACCESS_KEY_ID / OBJECT_STORAGE_SECRET_ACCESS_KEY)
+
+# Optional — only needed if you want extract-audio → Whisper transcripts
+OPENAI_API_KEY=…
+
+# Optional — Redis enables the BullMQ media job pipeline. Without it the
+# asset row is still created, but no transcription/thumbnail jobs run.
+REDIS_URL=redis://localhost:6379
+
+# Only required when you want to exercise the production cookie-mint path
+# (`POST /organization/assets/:assetId/hls/cookie`). Locally the Hono /hls/*
+# route uses session auth, so most of the time you can leave this unset.
+# Same value must also be set as a Cloudflare secret on tenant-router for prod.
+HLS_SIGNING_SECRET=…
+```
+
+`apps/dashboard/.env`:
+
+```bash
+# Already required by the existing API client — used as the HLS base URL too.
+PUBLIC_SERVER_URL=http://localhost:3000
+```
+
+### Run
+
+```bash
+pnpm --filter @cio/api dev
+pnpm --filter @cio/dashboard dev
+# (optional) pnpm --filter @cio/jobs dev # for transcription + thumbnail jobs
+```
+
+Drop a video into a lesson; the dashboard encodes locally, you'll see
+segments arrive under `videos/{assetId}/hls/…` in R2 and an `assets` row with
+`hls_manifest_key` populated. Playback flows directly browser → API → R2 —
+**no wrangler dev needed locally.**
+
+### Production setup (one-time)
+
+For Cloud deployment, generate the HMAC signing key the Worker uses to verify
+playback cookies and set it on both sides:
+
+```bash
+node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
+```
+
+```bash
+# On Render (apps/api service env):
+HLS_SIGNING_SECRET=
+
+# On Cloudflare (apps/tenant-router):
+pnpm --filter @cio/tenant-router exec wrangler secret put HLS_SIGNING_SECRET
+# paste the same value when prompted
+```
+
## Debugging Fly.io
View logs from fly.io in real time
diff --git a/apps/api/package.json b/apps/api/package.json
index 49820da9..8a976cfb 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -40,8 +40,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.388.0",
"@aws-sdk/s3-request-presigner": "^3.675.0",
- "@bull-board/api": "^7.0.0",
- "@bull-board/hono": "^7.0.0",
+ "@cio/core": "workspace:*",
"@cio/ai-assistant": "workspace:*",
"@cio/analytics": "workspace:*",
"@cio/certificates": "workspace:*",
@@ -50,6 +49,7 @@
"@cio/jobs": "workspace:*",
"@cio/question-types": "workspace:*",
"@cio/utils": "workspace:*",
+ "@getworkbench/hono": "0.2.1",
"@hono/node-server": "^1.19.7",
"@hono/standard-validator": "^0.1.5",
"@hono/zod-validator": "^0.7.6",
diff --git a/apps/api/src/__tests__/agent-lesson-content.test.ts b/apps/api/src/__tests__/agent-lesson-content.test.ts
index 15c0629a..81cd2a89 100644
--- a/apps/api/src/__tests__/agent-lesson-content.test.ts
+++ b/apps/api/src/__tests__/agent-lesson-content.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { normalizeAgentLessonContent } from '../services/agent/lesson-content';
+import { normalizeAgentLessonContent } from '@cio/core/services/agent/lesson-content';
describe('normalizeAgentLessonContent', () => {
it('removes a leading heading that duplicates the lesson title', () => {
diff --git a/apps/api/src/__tests__/ai-credits-usage.test.ts b/apps/api/src/__tests__/ai-credits-usage.test.ts
index 0899212e..12127b0d 100644
--- a/apps/api/src/__tests__/ai-credits-usage.test.ts
+++ b/apps/api/src/__tests__/ai-credits-usage.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { computePurchasedTokenOverflow } from '@api/services/agent/usage';
+import { computePurchasedTokenOverflow } from '@cio/core/services/agent/usage';
describe('computePurchasedTokenOverflow', () => {
it('returns 0 when usage stays within allowance', () => {
diff --git a/apps/api/src/__tests__/balance-answer-positions.test.ts b/apps/api/src/__tests__/balance-answer-positions.test.ts
new file mode 100644
index 00000000..415701b2
--- /dev/null
+++ b/apps/api/src/__tests__/balance-answer-positions.test.ts
@@ -0,0 +1,122 @@
+import { describe, expect, it } from 'vitest';
+import { balanceCorrectAnswerPositions } from '@cio/core/services/agent/balance-answer-positions';
+import { QUESTION_TYPE } from '@cio/utils/validation/constants';
+
+type Option = { label: string; isCorrect: boolean };
+type Question = { questionTypeId: number; question: string; options: Option[] };
+
+function radio(question: string, labels: string[], correctIndex: number): Question {
+ return {
+ questionTypeId: QUESTION_TYPE.RADIO,
+ question,
+ options: labels.map((label, i) => ({ label, isCorrect: i === correctIndex }))
+ };
+}
+
+function correctPosition(question: Question): number {
+ return question.options.findIndex((option) => option.isCorrect);
+}
+
+describe('balanceCorrectAnswerPositions', () => {
+ it('spreads the correct RADIO position across the batch instead of clustering', () => {
+ // 12 questions, all authored with the answer at index 0 ("A is always right").
+ const questions = Array.from({ length: 12 }, (_, i) => radio(`Q${i}`, ['alpha', 'bravo', 'charlie', 'delta'], 0));
+
+ const balanced = balanceCorrectAnswerPositions(questions);
+ const counts = [0, 0, 0, 0];
+ balanced.forEach((question) => counts[correctPosition(question)]++);
+
+ // 12 questions / 4 options → exactly 3 per position when perfectly balanced.
+ expect(counts).toEqual([3, 3, 3, 3]);
+ });
+
+ it('keeps the same correct option, only its position changes', () => {
+ const questions = [radio('Q', ['alpha', 'bravo', 'charlie', 'delta'], 0)];
+ const [balanced] = balanceCorrectAnswerPositions(questions);
+
+ const correct = balanced.options.find((option) => option.isCorrect);
+ expect(correct?.label).toBe('alpha');
+ expect(balanced.options).toHaveLength(4);
+ expect(new Set(balanced.options.map((o) => o.label))).toEqual(new Set(['alpha', 'bravo', 'charlie', 'delta']));
+ // Exactly one correct option survives.
+ expect(balanced.options.filter((o) => o.isCorrect)).toHaveLength(1);
+ });
+
+ it('is deterministic for identical input', () => {
+ const build = () => Array.from({ length: 8 }, (_, i) => radio(`Q${i}`, ['a', 'b', 'c', 'd'], 0));
+
+ const first = balanceCorrectAnswerPositions(build()).map(correctPosition);
+ const second = balanceCorrectAnswerPositions(build()).map(correctPosition);
+ expect(first).toEqual(second);
+ });
+
+ it('does not shuffle options that reference other positions', () => {
+ const questions = [radio('Q', ['Paris', 'London', 'Both of the above', 'All of the above'], 3)];
+ const [balanced] = balanceCorrectAnswerPositions(questions);
+
+ expect(balanced.options.map((o) => o.label)).toEqual(['Paris', 'London', 'Both of the above', 'All of the above']);
+ });
+
+ it('leaves naturally-ordered numeric ranges untouched', () => {
+ const questions = [radio('Range?', ['0-10', '11-20', '21-30', '31-40'], 1)];
+ const [balanced] = balanceCorrectAnswerPositions(questions);
+
+ expect(balanced.options.map((o) => o.label)).toEqual(['0-10', '11-20', '21-30', '31-40']);
+ });
+
+ it('balances each option-count group independently', () => {
+ const threeOption = Array.from({ length: 6 }, (_, i) => radio(`T${i}`, ['x', 'y', 'z'], 0));
+ const fourOption = Array.from({ length: 8 }, (_, i) => radio(`F${i}`, ['a', 'b', 'c', 'd'], 0));
+
+ const balanced = balanceCorrectAnswerPositions([...threeOption, ...fourOption]);
+
+ const threeCounts = [0, 0, 0];
+ balanced.slice(0, 6).forEach((q) => threeCounts[correctPosition(q)]++);
+ expect(threeCounts).toEqual([2, 2, 2]);
+
+ const fourCounts = [0, 0, 0, 0];
+ balanced.slice(6).forEach((q) => fourCounts[correctPosition(q)]++);
+ expect(fourCounts).toEqual([2, 2, 2, 2]);
+ });
+
+ it('shuffles CHECKBOX options without dropping correct answers', () => {
+ // Correct answers authored at the top; expect them not all clustered after shuffle.
+ const questions: Question[] = Array.from({ length: 10 }, (_, i) => ({
+ questionTypeId: QUESTION_TYPE.CHECKBOX,
+ question: `C${i}`,
+ options: [
+ { label: 'one', isCorrect: true },
+ { label: 'two', isCorrect: true },
+ { label: 'three', isCorrect: false },
+ { label: 'four', isCorrect: false }
+ ]
+ }));
+
+ const balanced = balanceCorrectAnswerPositions(questions);
+
+ balanced.forEach((question) => {
+ expect(question.options.filter((o) => o.isCorrect)).toHaveLength(2);
+ expect(new Set(question.options.map((o) => o.label))).toEqual(new Set(['one', 'two', 'three', 'four']));
+ });
+
+ // Index 0 should not be correct in every single question once shuffled.
+ const indexZeroAlwaysCorrect = balanced.every((q) => q.options[0].isCorrect);
+ expect(indexZeroAlwaysCorrect).toBe(false);
+ });
+
+ it('leaves TRUE_FALSE and malformed questions untouched', () => {
+ const trueFalse: Question = {
+ questionTypeId: QUESTION_TYPE.TRUE_FALSE,
+ question: 'TF',
+ options: [
+ { label: 'True', isCorrect: true },
+ { label: 'False', isCorrect: false }
+ ]
+ };
+ const noCorrect = radio('Q', ['a', 'b', 'c'], -1);
+
+ const [balancedTf, balancedNone] = balanceCorrectAnswerPositions([trueFalse, noCorrect]);
+ expect(balancedTf.options.map((o) => o.label)).toEqual(['True', 'False']);
+ expect(balancedNone.options.map((o) => o.label)).toEqual(['a', 'b', 'c']);
+ });
+});
diff --git a/apps/api/src/__tests__/course-go-live-readiness.test.ts b/apps/api/src/__tests__/course-go-live-readiness.test.ts
index b3777431..9e293704 100644
--- a/apps/api/src/__tests__/course-go-live-readiness.test.ts
+++ b/apps/api/src/__tests__/course-go-live-readiness.test.ts
@@ -4,13 +4,13 @@ import { getCourseById } from '@cio/db/queries/course';
import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/queries/course/content';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import { getOrganizationById } from '@cio/db/queries/organization';
-import { updateCourse } from '../services/course/course';
-import { ensureCourseSlug, generateUniqueCourseSlug } from '../services/course/landing-page';
+import { updateCourse } from '@cio/core/services/course/course';
+import { ensureCourseSlug, generateUniqueCourseSlug } from '@cio/core/services/course/landing-page';
import {
evaluateCourseGoLiveReadiness,
getCourseGoLiveReadiness,
publishCourseWhenReady
-} from '../services/course/go-live-readiness';
+} from '@cio/core/services/course/go-live-readiness';
vi.mock('@cio/db/queries/course', () => ({
getCourseById: vi.fn()
@@ -28,16 +28,19 @@ vi.mock('@cio/db/queries/organization', () => ({
getOrganizationById: vi.fn()
}));
-vi.mock('../services/course/course', () => ({
+vi.mock('@cio/core/services/course/course', () => ({
updateCourse: vi.fn()
}));
-vi.mock('../services/course/landing-page', () => ({
- buildCourseBaseUrl: vi.fn(() => 'https://school.example.com'),
+vi.mock('@cio/core/services/course/landing-page', () => ({
ensureCourseSlug: vi.fn(),
generateUniqueCourseSlug: vi.fn()
}));
+vi.mock('@cio/core/config/dashboard-url', () => ({
+ getDashboardBaseUrl: vi.fn(() => 'https://school.example.com')
+}));
+
const mockedGetCourseById = vi.mocked(getCourseById);
const mockedGetCourseContentItems = vi.mocked(getCourseContentItems);
const mockedGetCourseOrganizationId = vi.mocked(getCourseOrganizationId);
diff --git a/apps/api/src/__tests__/question-update.test.ts b/apps/api/src/__tests__/question-update.test.ts
index 134bad0d..9745e599 100644
--- a/apps/api/src/__tests__/question-update.test.ts
+++ b/apps/api/src/__tests__/question-update.test.ts
@@ -3,7 +3,7 @@ import {
buildUpdatedQuestions,
type CurrentQuestionForPatch,
type QuestionPatch
-} from '@api/services/agent/question-update';
+} from '@cio/core/services/agent/question-update';
import { QUESTION_TYPE } from '@cio/utils/validation/constants';
const EXERCISE_ID = 'ex-1';
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 37d16874..5737ebd7 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -20,8 +20,10 @@ import { jobsRouter } from '@api/routes/jobs';
import { licenseRouter } from '@api/routes/license';
import { logger } from 'hono/logger';
// ROUTES
+import { hlsRouter } from '@api/routes/hls';
import { mailRouter } from '@api/routes/mail';
import { mediaRouter } from '@api/routes/media';
+import { transcriptsRouter } from '@api/routes/transcripts';
import { mountQueueDashboard } from '@api/routes/admin/queues';
import { onboardingRouter } from '@api/routes/onboarding';
import { organizationRouter } from '@api/routes/organization';
@@ -43,7 +45,13 @@ export const app = new Hono()
// Middleware
.use('*', logger())
.use('*', prettyJSON())
- .use('*', secureHeaders())
+ // `Cross-Origin-Resource-Policy: same-origin` (Hono's default) blocks the
+ // dashboard from embedding api-served media (e.g. HLS segments via
+ // ``) when the dashboard and api live on different origins in
+ // local dev / self-hosted. We're an API server — every response is
+ // designed for cross-origin consumption, so the right value is
+ // `cross-origin`. CORS still gates which origins can read the bytes.
+ .use('*', secureHeaders({ crossOriginResourcePolicy: 'cross-origin' }))
.use('*', async (c, next) => {
if (isPublicCorsPath(c.req.path)) return next();
@@ -193,6 +201,8 @@ export const app = new Hono()
.route('/account', accountRouter)
.route('/course', courseRouter)
.route('/domain', domainRouter)
+ .route('/hls', hlsRouter)
+ .route('/transcripts', transcriptsRouter)
.route('/mail', mailRouter)
.route('/media', mediaRouter)
.route('/jobs', jobsRouter)
diff --git a/apps/api/src/config/dashboard-url.ts b/apps/api/src/config/dashboard-url.ts
deleted file mode 100644
index 93a117e8..00000000
--- a/apps/api/src/config/dashboard-url.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { env } from './env';
-
-/**
- * Returns the base URL for the dashboard app, used for invite links, email links, etc.
- * When DASHBOARD_ORIGIN is set (e.g. self-hosted), it takes precedence.
- * Otherwise: dev → localhost:5173, prod → app.classroomio.com (or subdomain for cloud).
- */
-export function getDashboardBaseUrl(orgSiteName?: string): string {
- if (env.DASHBOARD_ORIGIN) {
- return env.DASHBOARD_ORIGIN.replace(/\/$/, '');
- }
-
- if (env.NODE_ENV === 'development') {
- return 'http://localhost:5173';
- }
-
- const subdomain = orgSiteName || 'app';
-
- return `https://${subdomain}.classroomio.com`;
-}
diff --git a/apps/api/src/constants/index.ts b/apps/api/src/constants/index.ts
index 08874efa..bfd06a33 100644
--- a/apps/api/src/constants/index.ts
+++ b/apps/api/src/constants/index.ts
@@ -1,13 +1,18 @@
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
export * from './rate-limiter';
export const API_PORT = env.PORT ? parseInt(env.PORT) : 3002;
export const API_SERVER_URL = env.PUBLIC_SERVER_URL || `http://localhost:${API_PORT}`;
-export const TRUSTED_ORIGINS = env.TRUSTED_ORIGINS
+const configuredTrustedOrigins = env.TRUSTED_ORIGINS
? env.TRUSTED_ORIGINS.split(',')
: ['http://localhost:5173', 'http://localhost:5180', 'https://*.classroomio.com', 'https://*.myclassroomio.com'];
+export const TRUSTED_ORIGINS = [
+ ...configuredTrustedOrigins.map((origin) => origin.trim()).filter(Boolean),
+ ...(env.DASHBOARD_ORIGIN ? [env.DASHBOARD_ORIGIN.trim().replace(/\/$/, '')] : [])
+];
+
const {
CLOUDFLARE_BUCKET_DOMAIN,
CLOUDFLARE_ACCESS_KEY,
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index f995f89c..9e62c1c1 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -4,8 +4,8 @@ import './instrument';
import { API_PORT } from '@api/constants';
import { app } from '@api/app';
import { configureOpenAPI } from '@api/utils/openapi';
-import { connectRedis } from '@api/utils/redis/redis';
-import { env } from '@api/config/env';
+import { connectRedis } from '@cio/core/utils/redis/redis';
+import { env } from '@cio/core/config/env';
import { preloadVerifiedCustomDomainOriginsRegistry } from '@api/utils/origins';
import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev';
diff --git a/apps/api/src/middlewares/api-key.ts b/apps/api/src/middlewares/api-key.ts
index 7c16e4d0..29f3771b 100644
--- a/apps/api/src/middlewares/api-key.ts
+++ b/apps/api/src/middlewares/api-key.ts
@@ -1,6 +1,6 @@
import { Context, Next } from 'hono';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
/**
* Middleware to authenticate requests using API key
diff --git a/apps/api/src/middlewares/course-member.ts b/apps/api/src/middlewares/course-member.ts
index aa406a0e..31229633 100644
--- a/apps/api/src/middlewares/course-member.ts
+++ b/apps/api/src/middlewares/course-member.ts
@@ -2,7 +2,7 @@ import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
import { isUserCourseMemberOrOrgAdmin } from '@cio/db/queries/group';
-import { ensureProgramCourseAccess } from '@api/services/course/course';
+import { ensureProgramCourseAccess } from '@cio/core/services/course/course';
/**
* Middleware to check if the authenticated user is a member of a course's group
diff --git a/apps/api/src/middlewares/license.ts b/apps/api/src/middlewares/license.ts
index 86a95d87..5b1025fb 100644
--- a/apps/api/src/middlewares/license.ts
+++ b/apps/api/src/middlewares/license.ts
@@ -1,7 +1,7 @@
import type { Context, Next } from 'hono';
import type { LicenseFeatureId } from '@cio/utils/license';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
import { isFeatureLicensed } from '@api/services/license';
/**
diff --git a/apps/api/src/middlewares/org-admin-or-api-key.ts b/apps/api/src/middlewares/org-admin-or-api-key.ts
index ed3a347c..7f87ac03 100644
--- a/apps/api/src/middlewares/org-admin-or-api-key.ts
+++ b/apps/api/src/middlewares/org-admin-or-api-key.ts
@@ -1,7 +1,7 @@
import { Context, Next } from 'hono';
import { ErrorCodes } from '@api/utils/errors';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
import { isUserOrgAdmin } from '@cio/db/queries/organization';
export const orgAdminOrApiKeyMiddleware = async (c: Context, next: Next) => {
diff --git a/apps/api/src/middlewares/rate-limiter.ts b/apps/api/src/middlewares/rate-limiter.ts
index 3a27b200..9a84aaa6 100644
--- a/apps/api/src/middlewares/rate-limiter.ts
+++ b/apps/api/src/middlewares/rate-limiter.ts
@@ -8,8 +8,8 @@ import {
import type { Context, MiddlewareHandler } from 'hono';
import { RedisRateLimiter } from '@api/utils/redis/limiter';
import { userKeyGenerator } from '../utils/redis/key-generators';
-import { env } from '@api/config/env';
-import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
+import { env } from '@cio/core/config/env';
+import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
/** Dashboard SSR and other internal callers authenticate with PRIVATE_SERVER_KEY. */
export function isTrustedServerApiKeyRequest(c: Context): boolean {
diff --git a/apps/api/src/middlewares/signup-guard.ts b/apps/api/src/middlewares/signup-guard.ts
index ea65c13a..b2b29fe3 100644
--- a/apps/api/src/middlewares/signup-guard.ts
+++ b/apps/api/src/middlewares/signup-guard.ts
@@ -6,7 +6,7 @@ import {
hasActiveOrganizationInviteForEmail
} from '@cio/db/queries/organization';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
type OrgSettings = {
signup?: {
diff --git a/apps/api/src/routes/account/account.ts b/apps/api/src/routes/account/account.ts
index 5eb06156..b21814b7 100644
--- a/apps/api/src/routes/account/account.ts
+++ b/apps/api/src/routes/account/account.ts
@@ -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')!;
diff --git a/apps/api/src/routes/admin/queues.ts b/apps/api/src/routes/admin/queues.ts
index 21a674d7..0935bd35 100644
--- a/apps/api/src/routes/admin/queues.ts
+++ b/apps/api/src/routes/admin/queues.ts
@@ -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): 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 {
diff --git a/apps/api/src/routes/agent/agent.ts b/apps/api/src/routes/agent/agent.ts
index 27f249bc..25775021 100644
--- a/apps/api/src/routes/agent/agent.ts
+++ b/apps/api/src/routes/agent/agent.ts
@@ -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);
diff --git a/apps/api/src/routes/course/content.ts b/apps/api/src/routes/course/content.ts
index a9b60dae..b757dd5a 100644
--- a/apps/api/src/routes/course/content.ts
+++ b/apps/api/src/routes/course/content.ts
@@ -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';
diff --git a/apps/api/src/routes/course/course.ts b/apps/api/src/routes/course/course.ts
index b03eee35..548ca958 100644
--- a/apps/api/src/routes/course/course.ts
+++ b/apps/api/src/routes/course/course.ts
@@ -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 {
diff --git a/apps/api/src/routes/course/exercise.ts b/apps/api/src/routes/course/exercise.ts
index 13401beb..dacb9bb6 100644
--- a/apps/api/src/routes/course/exercise.ts
+++ b/apps/api/src/routes/course/exercise.ts
@@ -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 {
diff --git a/apps/api/src/routes/course/lesson-language.ts b/apps/api/src/routes/course/lesson-language.ts
index b5cc7155..3cb644bb 100644
--- a/apps/api/src/routes/course/lesson-language.ts
+++ b/apps/api/src/routes/course/lesson-language.ts
@@ -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';
diff --git a/apps/api/src/routes/course/people.ts b/apps/api/src/routes/course/people.ts
index ab62c295..85034240 100644
--- a/apps/api/src/routes/course/people.ts
+++ b/apps/api/src/routes/course/people.ts
@@ -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';
diff --git a/apps/api/src/routes/course/presign.ts b/apps/api/src/routes/course/presign.ts
index 8ce8d0c5..7c8fcd56 100644
--- a/apps/api/src/routes/course/presign.ts
+++ b/apps/api/src/routes/course/presign.ts
@@ -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 = {
diff --git a/apps/api/src/routes/course/section.ts b/apps/api/src/routes/course/section.ts
index 675a0389..4ad760e6 100644
--- a/apps/api/src/routes/course/section.ts
+++ b/apps/api/src/routes/course/section.ts
@@ -8,7 +8,7 @@ import {
promoteUngroupedSection,
reorderCourseSections,
updateCourseSectionService
-} from '@api/services/course/section';
+} from '@cio/core/services/course/section';
import {
ZCourseSectionCreate,
ZCourseSectionGetParam,
diff --git a/apps/api/src/routes/hls/hls.ts b/apps/api/src/routes/hls/hls.ts
new file mode 100644
index 00000000..e77408a7
--- /dev/null
+++ b/apps/api/src/routes/hls/hls.ts
@@ -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 = {
+ 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 = {};
+ 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 = {};
+ 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 `` 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) =>
+ 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 | 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 = {
+ '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 = {
+ '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);
+ }
+ });
diff --git a/apps/api/src/routes/hls/index.ts b/apps/api/src/routes/hls/index.ts
new file mode 100644
index 00000000..68cfffd3
--- /dev/null
+++ b/apps/api/src/routes/hls/index.ts
@@ -0,0 +1 @@
+export * from './hls';
diff --git a/apps/api/src/routes/invite/invite.ts b/apps/api/src/routes/invite/invite.ts
index 625bf143..7614af35 100644
--- a/apps/api/src/routes/invite/invite.ts
+++ b/apps/api/src/routes/invite/invite.ts
@@ -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');
+ }
+ }
);
diff --git a/apps/api/src/routes/org-site/public-course.ts b/apps/api/src/routes/org-site/public-course.ts
index 18c6e563..bd631317 100644
--- a/apps/api/src/routes/org-site/public-course.ts
+++ b/apps/api/src/routes/org-site/public-course.ts
@@ -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');
+ }
});
diff --git a/apps/api/src/routes/organization/assets.ts b/apps/api/src/routes/organization/assets.ts
index 375f094a..917d2962 100644
--- a/apps/api/src/routes/organization/assets.ts
+++ b/apps/api/src/routes/organization/assets.ts
@@ -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
diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts
index c3b0d59b..01498aa4 100644
--- a/apps/api/src/routes/organization/organization.ts
+++ b/apps/api/src/routes/organization/organization.ts
@@ -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(
{
diff --git a/apps/api/src/routes/transcripts/index.ts b/apps/api/src/routes/transcripts/index.ts
new file mode 100644
index 00000000..dbb64ac5
--- /dev/null
+++ b/apps/api/src/routes/transcripts/index.ts
@@ -0,0 +1 @@
+export * from './transcripts';
diff --git a/apps/api/src/routes/transcripts/transcripts.ts b/apps/api/src/routes/transcripts/transcripts.ts
new file mode 100644
index 00000000..abee5bd4
--- /dev/null
+++ b/apps/api/src/routes/transcripts/transcripts.ts
@@ -0,0 +1,101 @@
+/**
+ * Transcript VTT proxy.
+ *
+ * `` elements inside `` 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 = {};
+ 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 ``
+ * 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 = {};
+ 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) =>
+ 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 | 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);
+ });
diff --git a/apps/api/src/routes/unsplash/unsplash.ts b/apps/api/src/routes/unsplash/unsplash.ts
index f0ffb649..54ed9cc2 100644
--- a/apps/api/src/routes/unsplash/unsplash.ts
+++ b/apps/api/src/routes/unsplash/unsplash.ts
@@ -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';
diff --git a/apps/api/src/services/account/index.ts b/apps/api/src/services/account/index.ts
index e39a26bf..5d482c9f 100644
--- a/apps/api/src/services/account/index.ts
+++ b/apps/api/src/services/account/index.ts
@@ -2,3 +2,4 @@ export * from './profile';
export * from './workspaces';
export * from './quota';
export * from './grace';
+export * from './view-as-student';
diff --git a/apps/api/src/services/account/profile.ts b/apps/api/src/services/account/profile.ts
index 42766170..0130a8c8 100644
--- a/apps/api/src/services/account/profile.ts
+++ b/apps/api/src/services/account/profile.ts
@@ -10,7 +10,7 @@ import { getProfileByEmail, getProfileById, updateProfile } from '@cio/db/querie
import type { OrganizationWithMemberAndPlans } from '@cio/db/queries/organization/types';
import { ROLE } from '@cio/utils/constants';
import type { TProfile } from '@cio/db/types';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
import { getLicenseStatus } from '@api/services/license';
export type GetAccountDataResult = {
@@ -26,10 +26,12 @@ export type GetAccountDataResult = {
* @returns Account data with profile and organizations
*/
export async function getAccountData(userId: string): Promise {
+ const isSelfHosted = env.PUBLIC_IS_SELFHOSTED === 'true';
+
let [profile, organizations, licenseStatus] = await Promise.all([
getProfileById(userId),
getOrganizationByProfileId(userId),
- getLicenseStatus()
+ isSelfHosted ? getLicenseStatus() : Promise.resolve({ valid: true, features: [] as string[] })
]);
if (!profile) {
@@ -59,7 +61,7 @@ export async function getAccountData(userId: string): Promise {
+ return mintLoginLinkToken({
+ userId: user.id,
+ email: user.email,
+ ttlMinutes: VIEW_AS_STUDENT_TTL_MINUTES
+ });
+}
diff --git a/apps/api/src/services/agent/chat-history.ts b/apps/api/src/services/agent/chat-history.ts
index bf92bd6c..7cd07605 100644
--- a/apps/api/src/services/agent/chat-history.ts
+++ b/apps/api/src/services/agent/chat-history.ts
@@ -12,7 +12,13 @@ import {
const MAX_PERSISTED_TOOL_OUTPUT_CHARS = 2000;
const DROPPED_PART_TYPES = new Set(['step-start', 'reasoning']);
-const TOOLS_WITH_FULL_OUTPUT = new Set(['generate_course_plan', 'ask_template_questions', 'fetch_documentation_url']);
+const TOOLS_WITH_FULL_OUTPUT = new Set([
+ 'generate_course_plan',
+ 'ask_template_questions',
+ 'fetch_documentation_url',
+ 'get_course_structure',
+ 'get_exercise_details'
+]);
function isRecord(value: unknown): value is Record {
return value !== null && typeof value === 'object' && !Array.isArray(value);
diff --git a/apps/api/src/services/agent/compact.ts b/apps/api/src/services/agent/compact.ts
index 44fa2649..bccf5d67 100644
--- a/apps/api/src/services/agent/compact.ts
+++ b/apps/api/src/services/agent/compact.ts
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { AppError } from '@api/utils/errors';
import { ErrorCodes } from '@cio/utils/constants';
import { getChatConversation, saveChatMessages } from '@api/services/agent/chat-history';
-import { summarizeConversation, type SummarizeOptions } from '@api/services/agent/summarize';
+import { summarizeConversation, type SummarizeOptions } from '@cio/core/services/agent/summarize';
export async function compactConversation(conversationId: string, userId: string): Promise<{ messages: unknown[] }> {
const conversation = await getChatConversation(conversationId, userId);
diff --git a/apps/api/src/services/agent/run-state.ts b/apps/api/src/services/agent/run-state.ts
index 66b53820..0bca9840 100644
--- a/apps/api/src/services/agent/run-state.ts
+++ b/apps/api/src/services/agent/run-state.ts
@@ -15,6 +15,7 @@ import {
import { isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
import { getCourseOrganizationId } from '@cio/db/queries/tag';
import type { TAiAgentRun, TAiAgentRunEvent, TAiAgentRunStatus, TAiAgentRunStep } from '@cio/db/types';
+import { QUEUE_NAMES, enqueueAgentCourseGenerationRun, getQueue, isRedisConfigured } from '@cio/jobs';
import { AppError } from '@api/utils/errors';
const ACTIVE_RUN_STATUSES = new Set(['queued', 'running', 'waiting_for_input', 'paused']);
@@ -90,6 +91,106 @@ async function buildAgentRunDetail(run: TAiAgentRun): Promise {
return { run, steps, events };
}
+function getBullmqJobId(run: TAiAgentRun): string | null {
+ const bullmq = run.executionCursor.bullmq;
+
+ if (!bullmq || typeof bullmq !== 'object') {
+ return null;
+ }
+
+ const jobId = (bullmq as Record).jobId;
+
+ return typeof jobId === 'string' && jobId.length > 0 ? jobId : null;
+}
+
+async function removeQueuedBullmqJob(run: TAiAgentRun): Promise {
+ if (!isRedisConfigured()) return;
+
+ const jobId = getBullmqJobId(run);
+
+ if (!jobId) return;
+
+ try {
+ const removed = await getQueue(QUEUE_NAMES.agentCourseGeneration).remove(jobId);
+
+ await createAgentRunEvent({
+ runId: run.id,
+ eventType: 'run.queue_cancel_checked',
+ message: removed === 1 ? 'Queued BullMQ job removed' : 'BullMQ job was already active or missing',
+ payload: { jobId, removed }
+ });
+ } catch (error) {
+ await createAgentRunEvent({
+ runId: run.id,
+ eventType: 'run.queue_cancel_failed',
+ message: 'Could not remove queued BullMQ job; worker will stop at the next checkpoint',
+ payload: { jobId, error: error instanceof Error ? error.message : String(error) }
+ });
+ }
+}
+
+async function enqueueAgentRun(run: TAiAgentRun, reason: 'created' | 'resumed' | 'retried'): Promise {
+ if (!isRedisConfigured()) {
+ await createAgentRunEvent({
+ runId: run.id,
+ eventType: 'run.enqueue_skipped',
+ message: 'Redis is not configured; run state was saved but no BullMQ job was enqueued',
+ payload: { reason }
+ });
+
+ return run;
+ }
+
+ try {
+ const jobId = await enqueueAgentCourseGenerationRun(
+ {
+ runId: run.id,
+ requestedByUserId: run.userId,
+ organizationId: run.orgId
+ },
+ { attempt: run.attempt }
+ );
+ const updatedRun = await updateOwnedAgentRun(run.id, run.userId, {
+ status: 'queued',
+ executionCursor: {
+ ...run.executionCursor,
+ bullmq: {
+ queue: 'agent-course-generation',
+ jobId: jobId ?? null,
+ enqueuedAt: new Date().toISOString(),
+ reason
+ }
+ }
+ });
+
+ await createAgentRunEvent({
+ runId: run.id,
+ eventType: 'run.enqueued',
+ message: 'Run enqueued for background course generation',
+ payload: { jobId: jobId ?? null, reason }
+ });
+
+ return updatedRun;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Failed to enqueue agent run';
+ await updateOwnedAgentRun(run.id, run.userId, {
+ status: 'failed',
+ phase: 'failed',
+ lastError: { code: 'AGENT_RUN_ENQUEUE_FAILED', message },
+ finishedAt: new Date().toISOString()
+ });
+
+ await createAgentRunEvent({
+ runId: run.id,
+ eventType: 'run.enqueue_failed',
+ message: 'Failed to enqueue background course generation',
+ payload: { reason, error: { code: 'AGENT_RUN_ENQUEUE_FAILED', message } }
+ });
+
+ throw new AppError(message, 'AGENT_RUN_ENQUEUE_FAILED', 500);
+ }
+}
+
async function updateOwnedAgentRun(
runId: string,
userId: string,
@@ -132,7 +233,9 @@ export async function createAgentRunState(input: CreateAgentRunStateInput): Prom
payload: { phase: run.phase, status: run.status }
});
- return buildAgentRunDetail(run);
+ const enqueuedRun = await enqueueAgentRun(run, 'created');
+
+ return buildAgentRunDetail(enqueuedRun);
}
export async function listAgentRunStates(courseId: string, userId: string, orgId: string): Promise {
@@ -158,6 +261,8 @@ export async function cancelAgentRunState(runId: string, userId: string, orgId:
throw new AppError('Completed agent runs cannot be canceled', 'AGENT_RUN_ALREADY_COMPLETED', 409);
}
+ await removeQueuedBullmqJob(run);
+
const now = new Date().toISOString();
const updatedRun = await updateOwnedAgentRun(runId, userId, {
status: 'canceled',
@@ -181,10 +286,16 @@ export async function cancelAgentRunState(runId: string, userId: string, orgId:
export async function resumeAgentRunState(runId: string, userId: string, orgId: string): Promise {
const run = await getOwnedAgentRun(runId, userId, orgId);
- if (run.status === 'queued' || run.status === 'running') {
+ if (run.status === 'running') {
return buildAgentRunDetail(run);
}
+ if (run.status === 'queued') {
+ const enqueuedRun = await enqueueAgentRun(run, 'resumed');
+
+ return buildAgentRunDetail(enqueuedRun);
+ }
+
if (run.status === 'completed') {
throw new AppError('Completed agent runs cannot be resumed', 'AGENT_RUN_ALREADY_COMPLETED', 409);
}
@@ -212,7 +323,9 @@ export async function resumeAgentRunState(runId: string, userId: string, orgId:
payload: { previousStatus: run.status, phase: run.phase }
});
- return buildAgentRunDetail(updatedRun);
+ const enqueuedRun = await enqueueAgentRun(updatedRun, 'resumed');
+
+ return buildAgentRunDetail(enqueuedRun);
}
export async function retryAgentRunState(runId: string, userId: string, orgId: string): Promise {
@@ -243,7 +356,9 @@ export async function retryAgentRunState(runId: string, userId: string, orgId: s
payload: { previousAttempt: run.attempt, nextAttempt: updatedRun.attempt }
});
- return buildAgentRunDetail(updatedRun);
+ const enqueuedRun = await enqueueAgentRun(updatedRun, 'retried');
+
+ return buildAgentRunDetail(enqueuedRun);
}
export async function appendAgentRunInstructionState(
diff --git a/apps/api/src/services/agent/student-tools.ts b/apps/api/src/services/agent/student-tools.ts
index e353d2cb..9ee15771 100644
--- a/apps/api/src/services/agent/student-tools.ts
+++ b/apps/api/src/services/agent/student-tools.ts
@@ -6,12 +6,12 @@ import { db } from '@cio/db';
import * as schema from '@cio/db/schema';
import type { AiTutorSettings } from '@cio/ai-assistant';
-import { getLesson } from '@api/services/lesson/lesson';
-import { getExercise } from '@api/services/exercise/exercise';
-import { listCourseSections } from '@api/services/course/section';
+import { getLesson } from '@cio/core/services/lesson/lesson';
+import { getExercise } from '@cio/core/services/exercise/exercise';
+import { listCourseSections } from '@cio/core/services/course/section';
import { AppError } from '@api/utils/errors';
-import { AgentEvent, trackAgentEvent } from '@api/utils/tinybird';
-import { verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse } from './chat-context';
+import { AgentEvent, trackAgentEvent } from '@cio/core/utils/tinybird';
+import { verifyExerciseBelongsToCourse, verifyLessonBelongsToCourse } from '@cio/core/services/agent/chat-context';
/**
* Student agent tools — read-only, course-scoped.
diff --git a/apps/api/src/services/agent/tutor-usage.ts b/apps/api/src/services/agent/tutor-usage.ts
index d5495d3b..d727aba9 100644
--- a/apps/api/src/services/agent/tutor-usage.ts
+++ b/apps/api/src/services/agent/tutor-usage.ts
@@ -16,7 +16,7 @@ import {
import { AppError } from '@api/utils/errors';
import { getEffectiveAiTutorSettings } from './tutor-config';
-import { enforceTokenBalance } from './usage';
+import { enforceTokenBalance } from '@cio/core/services/agent/usage';
export const STUDENT_TUTOR_CAP = STUDENT_TUTOR_MONTHLY_CAP;
diff --git a/apps/api/src/services/analytics/reads.ts b/apps/api/src/services/analytics/reads.ts
index 6821eec0..a158dd5a 100644
--- a/apps/api/src/services/analytics/reads.ts
+++ b/apps/api/src/services/analytics/reads.ts
@@ -5,8 +5,8 @@ import {
selectPopularCourseTypes
} from '@cio/db/queries/analytics';
import { DASH_ANALYTICS_TTL_SECONDS, dashAnalyticsKey } from '@api/utils/redis/key-generators';
-import { env } from '@api/config/env';
-import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
+import { env } from '@cio/core/config/env';
+import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
export type LandingStats = {
totals: {
diff --git a/apps/api/src/services/assets/assets.ts b/apps/api/src/services/assets/assets.ts
deleted file mode 100644
index 3a685560..00000000
--- a/apps/api/src/services/assets/assets.ts
+++ /dev/null
@@ -1,539 +0,0 @@
-import { AppError, ErrorCodes } from '@api/utils/errors';
-import type {
- TAssetAttach,
- TAssetCreateUpload,
- TAssetDetach,
- TAssetExportQuery,
- TAssetListQuery,
- TAssetStorageQuery,
- TAssetUpdate,
- TYouTubeMetadataQuery
-} from '@cio/utils/validation/assets';
-import type { TTranscriptResponse } from '@cio/utils/validation/media';
-import {
- countAssetUsagesByAsset,
- createAssetUsage,
- createOrGetAssetByStorageKey,
- deleteAsset,
- deleteAssetUsage,
- getAssetById,
- getAssetStorageSummaryByOrg,
- listAssetsByOrg,
- listAssetsForExport,
- listAssetUsagesByAsset,
- updateAsset
-} from '@cio/db/queries/assets';
-import { getMediaTranscriptByAsset } from '@cio/db/queries';
-
-import { generateTranscriptVttPresignedUrl, TRANSCRIPT_VTT_PRESIGN_SECONDS } from '@api/utils/s3';
-import { getStorageConfig } from '@api/config/storage';
-
-const YOUTUBE_VIDEO_ID_REGEX = /^[a-zA-Z0-9_-]{11}$/;
-
-type YouTubeOEmbedResponse = {
- title?: unknown;
- thumbnail_url?: unknown;
-};
-
-type YouTubeWatchPageMetadata = {
- title: string | null;
- durationSeconds: number | null;
- thumbnailUrl: string | null;
-};
-
-function assertAssetExists(asset: T | null): T {
- if (!asset) {
- throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
- }
-
- return asset;
-}
-
-function normalizeUrl(url: string): URL | null {
- const trimmed = url.trim();
- if (!trimmed) {
- return null;
- }
-
- try {
- return new URL(trimmed);
- } catch {
- try {
- return new URL(`https://${trimmed}`);
- } catch {
- return null;
- }
- }
-}
-
-function extractYouTubeVideoId(url: string): string | null {
- const parsedUrl = normalizeUrl(url);
- if (!parsedUrl) {
- return null;
- }
-
- const host = parsedUrl.hostname.toLowerCase();
- let videoId: string | null = null;
-
- if (host === 'youtu.be' || host === 'www.youtu.be') {
- const [pathSegment] = parsedUrl.pathname.split('/').filter(Boolean);
- videoId = pathSegment ?? null;
- } else if (
- host === 'youtube.com' ||
- host === 'www.youtube.com' ||
- host === 'm.youtube.com' ||
- host === 'music.youtube.com' ||
- host === 'youtube-nocookie.com' ||
- host === 'www.youtube-nocookie.com'
- ) {
- if (parsedUrl.pathname === '/watch') {
- videoId = parsedUrl.searchParams.get('v');
- } else {
- const [firstSegment, secondSegment] = parsedUrl.pathname.split('/').filter(Boolean);
- if (firstSegment === 'embed' || firstSegment === 'shorts' || firstSegment === 'live') {
- videoId = secondSegment ?? null;
- }
- }
- }
-
- if (!videoId || !YOUTUBE_VIDEO_ID_REGEX.test(videoId)) {
- return null;
- }
-
- return videoId;
-}
-
-function decodeHtmlEntities(value: string): string {
- return value
- .replaceAll('&', '&')
- .replaceAll('"', '"')
- .replaceAll(''', "'")
- .replaceAll(''', "'")
- .replaceAll('<', '<')
- .replaceAll('>', '>');
-}
-
-function extractMetaContent(html: string, pattern: RegExp): string | null {
- const match = html.match(pattern);
- const value = match?.[1];
- if (!value) {
- return null;
- }
-
- const decoded = decodeHtmlEntities(value.trim());
- return decoded.length ? decoded : null;
-}
-
-function extractDurationSeconds(html: string): number | null {
- const lengthSecondsMatch = html.match(/"lengthSeconds":"(\d+)"/);
- if (lengthSecondsMatch?.[1]) {
- const parsed = Number.parseInt(lengthSecondsMatch[1], 10);
- return Number.isFinite(parsed) ? parsed : null;
- }
-
- const approxDurationMsMatch = html.match(/"approxDurationMs":"(\d+)"/);
- if (approxDurationMsMatch?.[1]) {
- const parsed = Number.parseInt(approxDurationMsMatch[1], 10);
- if (Number.isFinite(parsed) && parsed >= 0) {
- return Math.round(parsed / 1000);
- }
- }
-
- return null;
-}
-
-async function fetchYouTubeOEmbed(videoUrl: string): Promise<{ title: string | null; thumbnailUrl: string | null }> {
- try {
- const response = await fetch(`https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(videoUrl)}`);
- if (!response.ok) {
- return {
- title: null,
- thumbnailUrl: null
- };
- }
-
- const data = (await response.json()) as YouTubeOEmbedResponse;
- const title = typeof data.title === 'string' && data.title.trim().length ? data.title.trim() : null;
- const thumbnailUrl =
- typeof data.thumbnail_url === 'string' && data.thumbnail_url.trim().length ? data.thumbnail_url.trim() : null;
-
- return {
- title,
- thumbnailUrl
- };
- } catch {
- return {
- title: null,
- thumbnailUrl: null
- };
- }
-}
-
-async function fetchYouTubeWatchPageMetadata(videoId: string): Promise {
- try {
- const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`);
- if (!response.ok) {
- return {
- title: null,
- durationSeconds: null,
- thumbnailUrl: null
- };
- }
-
- const html = await response.text();
- const title =
- extractMetaContent(html, / {
- try {
- const { startMediaJob } = await import('@api/services/jobs');
- await startMediaJob({
- organizationId: input.organizationId,
- assetId: input.assetId,
- storageKey: input.storageKey,
- triggeredByProfileId: input.triggeredByProfileId,
- withTranscription: Boolean(process.env.OPENAI_API_KEY)
- });
- } catch (error) {
- console.error('enqueueMediaPostProcessingForAsset failed:', error);
- }
-}
-
-export async function getAssetService(orgId: string, assetId: string) {
- try {
- const asset = await getAssetById(assetId, orgId);
- return assertAssetExists(asset);
- } catch (error) {
- if (error instanceof AppError) {
- throw error;
- }
-
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to fetch asset',
- ErrorCodes.ASSET_FETCH_FAILED,
- 500
- );
- }
-}
-
-export async function updateAssetService(orgId: string, assetId: string, data: TAssetUpdate) {
- try {
- const updated = await updateAsset(assetId, orgId, data);
- return assertAssetExists(updated);
- } catch (error) {
- if (error instanceof AppError) {
- throw error;
- }
-
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to update asset',
- ErrorCodes.ASSET_UPDATE_FAILED,
- 500
- );
- }
-}
-
-/**
- * Set the chosen thumbnail URL for an asset. Accepts URLs that are either
- * (a) one of the asset's auto-generated candidates, or (b) a freshly uploaded
- * URL on the org's media bucket. Anything else is rejected so callers can't
- * point the asset at an arbitrary remote image.
- */
-export async function selectAssetThumbnailService(orgId: string, assetId: string, thumbnailUrl: string) {
- try {
- const asset = await getAssetById(assetId, orgId);
- assertAssetExists(asset);
-
- const candidates = (asset?.thumbnailCandidates ?? []) as string[];
- const mediaBase = getStorageConfig().mediaPublicBaseUrl?.replace(/\/$/, '') ?? null;
- const isCandidate = candidates.includes(thumbnailUrl);
- const isOnMediaBucket = Boolean(mediaBase && thumbnailUrl.startsWith(`${mediaBase}/`));
-
- if (!isCandidate && !isOnMediaBucket) {
- throw new AppError(
- 'Thumbnail URL must be a generated candidate or a media-bucket upload',
- ErrorCodes.VALIDATION_ERROR,
- 400
- );
- }
-
- const updated = await updateAsset(assetId, orgId, { thumbnailUrl });
- return assertAssetExists(updated);
- } catch (error) {
- if (error instanceof AppError) {
- throw error;
- }
-
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to update asset thumbnail',
- ErrorCodes.ASSET_UPDATE_FAILED,
- 500
- );
- }
-}
-
-export async function deleteAssetService(orgId: string, assetId: string) {
- try {
- await getAssetService(orgId, assetId);
-
- const usageCount = await countAssetUsagesByAsset(assetId, orgId);
- if (usageCount > 0) {
- throw new AppError('Asset is still in use', ErrorCodes.ASSET_IN_USE, 409);
- }
-
- const deleted = await deleteAsset(assetId, orgId);
- return assertAssetExists(deleted);
- } catch (error) {
- if (error instanceof AppError) {
- throw error;
- }
-
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to delete asset',
- ErrorCodes.ASSET_DELETE_FAILED,
- 500
- );
- }
-}
-
-export async function getAssetUsageGraphService(orgId: string, assetId: string) {
- try {
- await getAssetService(orgId, assetId);
- const [usages, usageCount] = await Promise.all([
- listAssetUsagesByAsset(assetId, orgId),
- countAssetUsagesByAsset(assetId, orgId)
- ]);
-
- return {
- usageCount,
- usages
- };
- } catch (error) {
- if (error instanceof AppError) {
- throw error;
- }
-
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to fetch asset usage',
- ErrorCodes.ASSET_USAGE_FETCH_FAILED,
- 500
- );
- }
-}
-
-export async function attachAssetService(orgId: string, assetId: string, profileId: string, input: TAssetAttach) {
- try {
- await getAssetService(orgId, assetId);
- return await createAssetUsage({
- organizationId: orgId,
- assetId,
- targetType: input.targetType,
- targetId: input.targetId,
- slotType: input.slotType,
- slotKey: input.slotKey ?? null,
- position: input.position ?? null,
- createdByProfileId: profileId
- });
- } catch (error) {
- if (error instanceof AppError) {
- throw error;
- }
-
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to attach asset',
- ErrorCodes.ASSET_ATTACH_FAILED,
- 500
- );
- }
-}
-
-export async function detachAssetService(orgId: string, assetId: string, input: TAssetDetach) {
- try {
- await getAssetService(orgId, assetId);
- const detached = await deleteAssetUsage(orgId, assetId, input);
- if (!detached) {
- throw new AppError('Asset usage not found', ErrorCodes.ASSET_USAGE_NOT_FOUND, 404);
- }
-
- return detached;
- } catch (error) {
- if (error instanceof AppError) {
- throw error;
- }
-
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to detach asset',
- ErrorCodes.ASSET_DETACH_FAILED,
- 500
- );
- }
-}
-
-export async function exportOrganizationAssetsService(orgId: string, query: TAssetExportQuery) {
- try {
- return await listAssetsForExport(orgId, query);
- } catch (error) {
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to export assets',
- ErrorCodes.ASSET_EXPORT_FAILED,
- 500
- );
- }
-}
-
-export async function getOrganizationAssetStorageService(orgId: string, query: TAssetStorageQuery) {
- try {
- return await getAssetStorageSummaryByOrg(orgId, query);
- } catch (error) {
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to fetch asset storage summary',
- ErrorCodes.ASSET_STORAGE_FETCH_FAILED,
- 500
- );
- }
-}
-
-export async function getTranscriptForOrganizationAssetService(
- orgId: string,
- assetId: string
-): Promise {
- try {
- const asset = assertAssetExists(await getAssetById(assetId));
-
- if (asset.organizationId !== orgId) {
- throw new AppError('Asset not found', ErrorCodes.ASSET_NOT_FOUND, 404);
- }
-
- const row = await getMediaTranscriptByAsset(assetId, orgId);
- if (!row) {
- return null;
- }
-
- const vttUrl = await generateTranscriptVttPresignedUrl(row.vttStorageKey, row.vttBucket);
- const vttUrlExpiresAt = new Date(Date.now() + TRANSCRIPT_VTT_PRESIGN_SECONDS * 1000).toISOString();
-
- return {
- language: row.language,
- segments: row.segments,
- vttUrl,
- vttUrlExpiresAt,
- durationSeconds: row.durationSeconds ?? null
- };
- } catch (error) {
- if (error instanceof AppError) throw error;
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to fetch transcript',
- ErrorCodes.ASSET_FETCH_FAILED,
- 500
- );
- }
-}
-
-export async function getYouTubeMetadataService(_orgId: string, query: TYouTubeMetadataQuery) {
- const videoId = extractYouTubeVideoId(query.url);
- if (!videoId) {
- throw new AppError('Invalid YouTube URL', ErrorCodes.VALIDATION_ERROR, 400, 'url');
- }
-
- const sourceUrl = `https://www.youtube.com/watch?v=${videoId}`;
-
- try {
- const [oembed, watchPageMetadata] = await Promise.all([
- fetchYouTubeOEmbed(sourceUrl),
- fetchYouTubeWatchPageMetadata(videoId)
- ]);
-
- return {
- videoId,
- sourceUrl,
- title: oembed.title ?? watchPageMetadata.title ?? 'YouTube video',
- durationSeconds: watchPageMetadata.durationSeconds,
- thumbnailUrl:
- oembed.thumbnailUrl ?? watchPageMetadata.thumbnailUrl ?? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
- };
- } catch (error) {
- throw new AppError(
- error instanceof Error ? error.message : 'Failed to fetch YouTube metadata',
- ErrorCodes.ASSET_FETCH_FAILED,
- 500
- );
- }
-}
diff --git a/apps/api/src/services/assets/index.ts b/apps/api/src/services/assets/index.ts
index df644664..a154971e 100644
--- a/apps/api/src/services/assets/index.ts
+++ b/apps/api/src/services/assets/index.ts
@@ -1 +1 @@
-export * from './assets';
+export * from '@cio/core/services/assets/assets';
diff --git a/apps/api/src/services/course-import/course-import.ts b/apps/api/src/services/course-import/course-import.ts
index 3fd86471..82abeae4 100644
--- a/apps/api/src/services/course-import/course-import.ts
+++ b/apps/api/src/services/course-import/course-import.ts
@@ -1,7 +1,7 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
-import { env } from '@api/config/env';
-import { getDashboardBaseUrl } from '@api/config/dashboard-url';
-import { createCourse, updateCourse } from '@api/services/course/course';
+import { env } from '@cio/core/config/env';
+import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
+import { createCourse, updateCourse } from '@cio/core/services/course/course';
import {
createExercise,
deleteExerciseService,
@@ -14,13 +14,13 @@ import {
deleteCourseSectionService,
listCourseSections,
updateCourseSectionService
-} from '@api/services/course/section';
+} from '@cio/core/services/course/section';
import { createLesson, listLessons, updateLessonService, deleteLessonService } from '@api/services/lesson';
import {
deleteLessonLanguageService,
upsertLessonLanguageService,
updateLessonLanguageService
-} from '@api/services/lesson-language';
+} from '@cio/core/services/lesson-language';
import {
createCourseImportDraft,
getCourseImportDraftById,
@@ -128,14 +128,6 @@ async function ensureCourseSlug(courseId: string, title: string | null | undefin
return updated.slug ?? uniqueSlug;
}
-function buildCourseBaseUrl(organization: TOrganization) {
- if (organization.customDomain && organization.isCustomDomainVerified) {
- return `https://${organization.customDomain}`;
- }
-
- return getDashboardBaseUrl(organization.siteName ?? undefined);
-}
-
async function resolveCourseUrl(organizationId: string, courseId: string, title: string) {
const organization = await getOrganizationById(organizationId);
if (!organization) {
@@ -143,7 +135,7 @@ async function resolveCourseUrl(organizationId: string, courseId: string, title:
}
const slug = await ensureCourseSlug(courseId, title);
- return `${buildCourseBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
+ return `${getDashboardBaseUrl(organization)}/course/${encodeURIComponent(slug)}`;
}
async function resolveUnsplashBannerImage(courseTitle: string, query?: string) {
diff --git a/apps/api/src/services/course/invite.ts b/apps/api/src/services/course/invite.ts
index 31dbcc6d..c1735486 100644
--- a/apps/api/src/services/course/invite.ts
+++ b/apps/api/src/services/course/invite.ts
@@ -26,7 +26,7 @@ import { ROLE } from '@cio/utils/constants';
import type { TCreateCourseInvite } from '@cio/utils/validation/course/invite';
import type { TNewCourseInviteAudit } from '@db/types';
import crypto from 'node:crypto';
-import { getDashboardBaseUrl } from '@api/config/dashboard-url';
+import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName } from '@cio/email';
@@ -37,6 +37,16 @@ import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { db } from '@cio/db/drizzle';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
+/**
+ * Minimal org-url bundle threaded through invite/email paths so every URL
+ * we generate respects the org's verified custom domain.
+ */
+type OrgUrlInfo = {
+ siteName: string | null;
+ customDomain: string | null;
+ isCustomDomainVerified: boolean | null;
+};
+
type InviteStatus = 'ACTIVE' | 'EXPIRED' | 'USED_UP' | 'REVOKED';
type InvitePreset = 'ONE_TIME_24H' | 'MULTI_USE_7D' | 'MULTI_USE_30D' | 'CUSTOM';
@@ -189,11 +199,12 @@ function getInviteStatus(invite: {
}
/**
- * Builds the course enroll URL for invite links (emails, etc.).
- * Uses DASHBOARD_URL when set (self-hosted), otherwise org subdomain in production.
+ * Builds the course enroll URL for invite links (emails, etc.). Respects the
+ * org's verified custom domain when present, otherwise falls back to the
+ * tenant subdomain.
*/
-export function buildEnrollLink(courseSlug: string, token: string, orgSiteName?: string): string {
- const base = getDashboardBaseUrl(orgSiteName);
+export function buildEnrollLink(courseSlug: string, token: string, org?: OrgUrlInfo | null): string {
+ const base = getDashboardBaseUrl(org ?? undefined);
return `${base}/course/${encodeURIComponent(courseSlug)}/enroll?invite_token=${encodeURIComponent(token)}`;
}
@@ -308,7 +319,7 @@ async function createSingleInvite(
metadata: Record | undefined,
targetEmail: string | null,
courseSlug: string,
- orgSiteName: string | null
+ org: OrgUrlInfo
) {
const token = generateToken();
const tokenHash = hashToken(token);
@@ -338,7 +349,7 @@ async function createSingleInvite(
return {
...created,
- inviteLink: buildEnrollLink(courseSlug, token, orgSiteName ?? undefined)
+ inviteLink: buildEnrollLink(courseSlug, token, org)
};
}
@@ -346,12 +357,12 @@ async function sendStudentJoinEmails(input: {
courseId: string;
courseName: string;
orgName: string;
- orgSiteName: string | null;
+ org: OrgUrlInfo;
studentId: string;
studentEmail: string;
}) {
try {
- const loginUrl = getDashboardBaseUrl(input.orgSiteName ?? undefined);
+ const loginUrl = getDashboardBaseUrl(input.org);
await enqueueTransactionalEmail('studentCourseWelcome', {
to: input.studentEmail,
fields: {
@@ -402,7 +413,7 @@ async function createEmailInviteAndSend(input: {
orgName: string;
courseName: string;
courseSlug: string;
- orgSiteName: string | null;
+ org: OrgUrlInfo;
metadata?: Record;
shouldSend: boolean;
}) {
@@ -421,7 +432,7 @@ async function createEmailInviteAndSend(input: {
},
input.recipientEmail,
input.courseSlug,
- input.orgSiteName
+ input.org
);
if (!input.shouldSend) {
@@ -503,7 +514,11 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
const courseTitle = course[0].title;
const courseSlug = course[0].slug ?? (await ensureCourseSlug(course[0].id, courseTitle));
- const orgSiteName = courseOrgData.orgSiteName ?? null;
+ const orgUrlInfo: OrgUrlInfo = {
+ siteName: courseOrgData.orgSiteName ?? null,
+ customDomain: courseOrgData.orgCustomDomain ?? null,
+ isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified ?? null
+ };
if (recipients.valid.length === 0) {
const createdInvite = await createSingleInvite(
@@ -515,7 +530,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
data.metadata,
null,
courseSlug,
- orgSiteName
+ orgUrlInfo
);
return {
@@ -545,7 +560,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
orgName,
courseName,
courseSlug,
- orgSiteName,
+ org: orgUrlInfo,
metadata: data.metadata,
shouldSend: data.sendEmail
})
@@ -659,7 +674,7 @@ export async function enrollInCourse(
courseId,
courseName: title,
orgName: org.name,
- orgSiteName: org.siteName,
+ org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified },
studentId: user.id,
studentEmail: normalizedEmail
});
@@ -925,7 +940,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: course.id,
courseName: course.title,
orgName: organization.name,
- orgSiteName: organization.siteName
+ orgSiteName: organization.siteName,
+ orgCustomDomain: organization.customDomain,
+ orgIsCustomDomainVerified: organization.isCustomDomainVerified
};
}
@@ -1010,7 +1027,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: course.id,
courseName: course.title,
orgName: organization.name,
- orgSiteName: organization.siteName
+ orgSiteName: organization.siteName,
+ orgCustomDomain: organization.customDomain,
+ orgIsCustomDomainVerified: organization.isCustomDomainVerified
};
});
@@ -1021,7 +1040,11 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
courseId: result.courseId,
courseName: result.courseName,
orgName: result.orgName,
- orgSiteName: result.orgSiteName,
+ org: {
+ siteName: result.orgSiteName,
+ customDomain: result.orgCustomDomain,
+ isCustomDomainVerified: result.orgIsCustomDomainVerified
+ },
studentId: user.id,
studentEmail: normalizedEmail
});
diff --git a/apps/api/src/services/course/people.ts b/apps/api/src/services/course/people.ts
index e194fd7b..98b81cab 100644
--- a/apps/api/src/services/course/people.ts
+++ b/apps/api/src/services/course/people.ts
@@ -11,7 +11,7 @@ import {
import { ROLE } from '@cio/utils/constants';
import type { TAddCourseMembers } from '@cio/utils/validation/course/people';
import type { TGroupmember } from '@cio/db/types';
-import { getDashboardBaseUrl } from '@api/config/dashboard-url';
+import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getCourseWithOrgData } from '@cio/db/queries/course';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName } from '@cio/email';
@@ -68,7 +68,11 @@ export async function addMember(
if (courseOrgData) {
const courseName = courseOrgData.courseTitle || '';
const orgName = courseOrgData.orgName || 'ClassroomIO';
- const loginUrl = getDashboardBaseUrl(courseOrgData.orgSiteName ?? undefined);
+ const loginUrl = getDashboardBaseUrl({
+ siteName: courseOrgData.orgSiteName,
+ customDomain: courseOrgData.orgCustomDomain,
+ isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified
+ });
// Get student profile data
let studentEmail: string | null = null;
@@ -190,7 +194,11 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
}
// Send welcome emails for teachers (TUTOR role = 2, ADMIN role = 1)
- const baseUrl = getDashboardBaseUrl(orgSiteName);
+ const baseUrl = getDashboardBaseUrl({
+ siteName: orgSiteName,
+ customDomain: courseOrgData.orgCustomDomain,
+ isCustomDomainVerified: courseOrgData.orgIsCustomDomainVerified
+ });
const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`;
const teacherEmailPromises: Promise[] = [];
diff --git a/apps/api/src/services/course/public-course.ts b/apps/api/src/services/course/public-course.ts
index effb3d01..62aa6492 100644
--- a/apps/api/src/services/course/public-course.ts
+++ b/apps/api/src/services/course/public-course.ts
@@ -3,10 +3,11 @@ import {
generateTranscriptVttPresignedUrl,
generateVideoDownloadPresignedUrls,
TRANSCRIPT_VTT_PRESIGN_SECONDS
-} from '@api/utils/s3';
+} from '@cio/core/utils/s3';
import { fetchQuestionsAndOptions, transformQuestions } from '@api/services/exercise/utils';
import { getMediaTranscriptByAsset } from '@cio/db/queries';
import { getAssetsByIds } from '@cio/db/queries/assets';
+import { mintHlsToken } from '@cio/core/services/assets/assets';
import {
getCourseRowBySlug,
getPublicCourseItem as getPublicCourseItemQuery,
@@ -72,6 +73,43 @@ export interface PublicExerciseQuestion {
* For exercises, questions + options are returned with answer keys included
* because PUBLIC courses grade entirely on the client.
*/
+/**
+ * Mint a `cio_hls` cookie for an anonymous learner viewing a public
+ * lesson. The asset id is **derived from the public course tree**, not
+ * accepted from the caller — so a learner who only knows a random asset
+ * UUID cannot mint a cookie. They have to know a public course slug +
+ * lesson slug whose video actually exposes the asset.
+ */
+export async function issuePublicHlsCookieService(courseSlug: string, itemSlug: string) {
+ try {
+ const item = await getPublicCourseItemQuery(courseSlug, itemSlug);
+ if (!item || item.kind !== 'lesson') {
+ throw new AppError('Public lesson not found', ErrorCodes.NOT_FOUND, 404);
+ }
+
+ const video = item.video;
+ if (video?.type !== 'upload' || !video.assetId) {
+ throw new AppError('Lesson has no playable HLS asset', ErrorCodes.VALIDATION_ERROR, 400);
+ }
+
+ const assetId = video.assetId;
+ const assets = await getAssetsByIds([assetId], item.courseOrganizationId ?? undefined);
+ const asset = assets[0];
+ if (!asset?.hlsManifestKey) {
+ throw new AppError('Lesson has no playable HLS asset', ErrorCodes.VALIDATION_ERROR, 400);
+ }
+
+ return await mintHlsToken(assetId);
+ } catch (error) {
+ if (error instanceof AppError) throw error;
+ throw new AppError(
+ error instanceof Error ? error.message : 'Failed to issue HLS cookie',
+ ErrorCodes.INTERNAL_ERROR,
+ 500
+ );
+ }
+}
+
export async function getPublicCourseItemService(
courseSlug: string,
itemSlug: string
@@ -87,17 +125,30 @@ export async function getPublicCourseItemService(
if (video?.type === 'upload') {
let storageKey: string | undefined;
+ let hlsManifestKey: string | null = null;
if (video.assetId) {
const assets = await getAssetsByIds([video.assetId], courseOrganizationId ?? undefined);
storageKey = assets[0]?.storageKey ?? undefined;
+ hlsManifestKey = assets[0]?.hlsManifestKey ?? null;
}
if (!storageKey && video.key) {
storageKey = video.key;
}
- if (storageKey) {
+ // HLS assets: serve the manifest URL via the api route (auth'd by
+ // the cio_hls cookie that the public-cookie endpoint mints when the
+ // learner loads the lesson). Don't presign storageKey — for HLS
+ // it's null anyway.
+ if (hlsManifestKey) {
+ const { key: _omitKey, ...rest } = video;
+ video = {
+ ...rest,
+ link: `/hls/${hlsManifestKey}`.replace(/\/{2,}/g, '/'),
+ hls: true
+ };
+ } else if (storageKey) {
const urls = await generateVideoDownloadPresignedUrls([storageKey]);
const signedLink = urls[storageKey];
diff --git a/apps/api/src/services/dash.ts b/apps/api/src/services/dash.ts
index 8bee2f76..44e6427a 100644
--- a/apps/api/src/services/dash.ts
+++ b/apps/api/src/services/dash.ts
@@ -1,5 +1,5 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
import {
getCourseStats,
getDashOrgStats,
@@ -9,7 +9,7 @@ import {
getTotalCertificatesIssued
} from '@cio/db/queries/dash';
import { dashLoginActivityKey } from '@api/utils/redis/key-generators';
-import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
+import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
import { OrganisationAnalytics } from '@api/types';
import { getOrgIdBySiteName } from '@cio/db/queries';
diff --git a/apps/api/src/services/exercise/index.ts b/apps/api/src/services/exercise/index.ts
index 0ccfe408..3d4ba47e 100644
--- a/apps/api/src/services/exercise/index.ts
+++ b/apps/api/src/services/exercise/index.ts
@@ -1 +1 @@
-export * from './exercise';
+export * from '@cio/core/services/exercise/exercise';
diff --git a/apps/api/src/services/exercise/video-recording.ts b/apps/api/src/services/exercise/video-recording.ts
index 44c1d518..cded5c59 100644
--- a/apps/api/src/services/exercise/video-recording.ts
+++ b/apps/api/src/services/exercise/video-recording.ts
@@ -1,6 +1,6 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
-import { getStorageConfig } from '@api/config/storage';
-import { generateVideoDownloadPresignedUrls, generateVideoUploadPresignedUrl } from '@api/utils/s3';
+import { getStorageConfig } from '@cio/core/config/storage';
+import { generateVideoDownloadPresignedUrls, generateVideoUploadPresignedUrl } from '@cio/core/utils/s3';
import { createAsset, getAssetById, updateAsset } from '@cio/db/queries/assets';
import { getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
import { getQuestionAnswersBySubmissionId, getSubmissionById } from '@cio/db/queries/submission';
diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts
index f883650a..e6ecc870 100644
--- a/apps/api/src/services/index.ts
+++ b/apps/api/src/services/index.ts
@@ -9,4 +9,4 @@ export * from './newsfeed';
export * from './onboarding';
export * from './submission';
export * from './dash';
-export * from './course/course';
+export * from '@cio/core/services/course/course';
diff --git a/apps/api/src/services/jobs/email-jobs.ts b/apps/api/src/services/jobs/email-jobs.ts
index 7ec349fd..1a138ecc 100644
--- a/apps/api/src/services/jobs/email-jobs.ts
+++ b/apps/api/src/services/jobs/email-jobs.ts
@@ -2,7 +2,7 @@ import { enqueueEmailSend, isRedisConfigured } from '@cio/jobs';
import { EmailRegistry, type EmailId, type EmailSchemaFor } from '@cio/email';
import * as z from 'zod';
-import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
+import { logRedisUnavailableOnce } from '@cio/core/utils/redis/redis';
type Recipient = string | string[];
diff --git a/apps/api/src/services/jobs/media-jobs.ts b/apps/api/src/services/jobs/media-jobs.ts
index de708655..2721d4fa 100644
--- a/apps/api/src/services/jobs/media-jobs.ts
+++ b/apps/api/src/services/jobs/media-jobs.ts
@@ -18,7 +18,7 @@ import {
isRedisConfigured
} from '@cio/jobs';
-import { logRedisUnavailableOnce } from '@api/utils/redis/redis';
+import { logRedisUnavailableOnce } from '@cio/core/utils/redis/redis';
import type { TMediaJob } from '@db/types';
export interface StartTranscriptionOnlyMediaJobInput {
@@ -41,7 +41,11 @@ export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOn
throw new AppError('Asset not found', ErrorCodes.NOT_FOUND, 404);
}
- if (asset.kind !== 'video' || asset.provider !== 'upload' || !asset.storageKey) {
+ // HLS uploads carry their pre-extracted audio in `hls_audio_key` and leave
+ // `storage_key` null. Raw MP4 uploads keep using `storage_key`.
+ const transcriptionSourceKey = asset.hlsAudioKey ?? asset.storageKey;
+
+ if (asset.kind !== 'video' || asset.provider !== 'upload' || !transcriptionSourceKey) {
throw new AppError('Asset cannot be transcribed', ErrorCodes.ASSET_NOT_TRANSCRIBABLE, 400);
}
@@ -71,7 +75,7 @@ export async function startTranscriptionOnlyMediaJob(input: StartTranscriptionOn
const enqueueResult = await enqueueTranscriptionOnly({
mediaJobId: job.id,
assetId: input.assetId,
- storageKey: asset.storageKey,
+ storageKey: transcriptionSourceKey,
actorContext: {
userId: input.triggeredByProfileId,
organizationId: input.organizationId
diff --git a/apps/api/src/services/lesson/index.ts b/apps/api/src/services/lesson/index.ts
index 552af8e0..5598ffe8 100644
--- a/apps/api/src/services/lesson/index.ts
+++ b/apps/api/src/services/lesson/index.ts
@@ -1 +1 @@
-export * from './lesson';
+export * from '@cio/core/services/lesson/lesson';
diff --git a/apps/api/src/services/license.ts b/apps/api/src/services/license.ts
index f80f87dd..31c85506 100644
--- a/apps/api/src/services/license.ts
+++ b/apps/api/src/services/license.ts
@@ -1,4 +1,4 @@
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
import { LICENSE_FEATURE, type LicenseFeatureId } from '@cio/utils/license';
export type LicenseStatus = {
diff --git a/apps/api/src/services/media.ts b/apps/api/src/services/media.ts
index 0676c1d8..c2546296 100644
--- a/apps/api/src/services/media.ts
+++ b/apps/api/src/services/media.ts
@@ -2,9 +2,9 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { MAX_IMAGE_SIZE } from '@api/constants/upload';
import { ALLOWED_IMAGE_TYPES } from '@cio/utils/validation';
-import { getStorageConfig } from '@api/config/storage';
-import { generateFileKey } from '@api/utils/upload';
-import { uploadToS3 } from '@api/utils/s3';
+import { getStorageConfig } from '@cio/core/config/storage';
+import { generateFileKey } from '@cio/core/utils/upload';
+import { uploadToS3 } from '@cio/core/utils/s3';
/**
* Uploads an image file to object storage and returns the public URL
diff --git a/apps/api/src/services/newsfeed/newsfeed.ts b/apps/api/src/services/newsfeed/newsfeed.ts
index c50b94e3..9a17fb7c 100644
--- a/apps/api/src/services/newsfeed/newsfeed.ts
+++ b/apps/api/src/services/newsfeed/newsfeed.ts
@@ -1,5 +1,6 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
-import { sanitizeHtml } from '@api/utils/sanitize-html';
+import { sanitizeHtml } from '@cio/core/utils/sanitize-html';
+import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants/domains';
import type { TCourseNewsfeed, TNewCourseNewsfeed, TNewCourseNewsfeedComment } from '@cio/db/types';
import type { TNewsfeedCreate, TNewsfeedUpdate } from '@cio/utils/validation/newsfeed';
import {
@@ -17,7 +18,7 @@ import {
updateNewsfeedComment
} from '@cio/db/queries/newsfeed';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
import { buildEmailFromName } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
@@ -358,7 +359,7 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
const orgName = feedData.organization?.name || 'ClassroomIO';
const orgSiteName = feedData.organization?.siteName || 'app';
- const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
+ const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
const recipients = feedData.courseMembers
.map((member) => member.email)
@@ -402,7 +403,7 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
const orgName = feedData.organization?.name || 'ClassroomIO';
const orgSiteName = feedData.organization?.siteName || 'app';
- const postLink = `https://${orgSiteName}.classroomio.com/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
+ const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
await enqueueTransactionalEmail('newsfeedComment', {
to: feedData.author.email,
diff --git a/apps/api/src/services/onboarding.ts b/apps/api/src/services/onboarding.ts
index 4f7027a8..bc692590 100644
--- a/apps/api/src/services/onboarding.ts
+++ b/apps/api/src/services/onboarding.ts
@@ -10,7 +10,7 @@ import {
} from '@cio/db/queries';
import { getProfileById, updateProfile } from '@cio/db/queries/auth';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
import { ROLE } from '@cio/utils/constants';
import { PLAN } from '@cio/utils/plans';
import { db } from '@cio/db/drizzle';
diff --git a/apps/api/src/services/org/domain.ts b/apps/api/src/services/org/domain.ts
index 92bf0123..51018bf4 100644
--- a/apps/api/src/services/org/domain.ts
+++ b/apps/api/src/services/org/domain.ts
@@ -1,6 +1,6 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
export type DomainSetupStatus =
| 'reconnect_required'
diff --git a/apps/api/src/services/organization.ts b/apps/api/src/services/organization.ts
index 24ff6051..5b9b44d4 100644
--- a/apps/api/src/services/organization.ts
+++ b/apps/api/src/services/organization.ts
@@ -49,6 +49,7 @@ import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/inv
import { trustCustomDomainHostname, untrustCustomDomainHostname } from '@cio/db/utils';
const PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT = 4;
+const ORG_COURSES_PAGE_SIZE = 6;
/**
* Creates a new organization with the current user as owner
@@ -252,7 +253,11 @@ export async function removeAudienceMember(orgId: string, memberId: number, remo
* @param siteName - The organization siteName
* @returns Array of published courses with lesson counts
*/
-export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
+export async function getPublicCourses(
+ siteName: string,
+ tagSlugs?: string[],
+ pagination?: { page?: number; limit?: number }
+) {
try {
const orgResult = await getOrgIdBySiteName(siteName);
const org = orgResult[0];
@@ -261,23 +266,28 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
throw new AppError('Organization not found', ErrorCodes.ORG_NOT_FOUND, 404);
}
+ const isPaginated = pagination !== undefined;
+ const limit = isPaginated ? (pagination.limit ?? ORG_COURSES_PAGE_SIZE) : PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT;
+ const page = isPaginated ? (pagination.page ?? 1) : 1;
+ const offset = (page - 1) * limit;
+
let filteredCourseIds: string[] | undefined = undefined;
if (tagSlugs && tagSlugs.length > 0) {
filteredCourseIds = await getCourseIdsByTagSlugs(org.id, tagSlugs);
if (filteredCourseIds.length === 0) {
return {
courses: [],
- hasMoreCourses: false
+ hasMoreCourses: false,
+ total: 0,
+ page,
+ limit,
+ totalPages: 0
};
}
}
const totalCourses = await countPublishedCoursesBySiteName(siteName, filteredCourseIds);
- const courses = await getPublishedCoursesBySiteName(
- siteName,
- filteredCourseIds,
- PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT
- );
+ const courses = await getPublishedCoursesBySiteName(siteName, filteredCourseIds, limit, offset);
const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization(
org.id,
courses.map((course) => course.id)
@@ -288,7 +298,11 @@ export async function getPublicCourses(siteName: string, tagSlugs?: string[]) {
...course,
tags: tagsByCourseId[course.id] ?? []
})),
- hasMoreCourses: totalCourses > courses.length
+ hasMoreCourses: offset + courses.length < totalCourses,
+ total: totalCourses,
+ page,
+ limit,
+ totalPages: Math.ceil(totalCourses / limit)
};
} catch (error) {
if (error instanceof AppError) throw error;
@@ -555,11 +569,7 @@ async function resolveOrgPlanTriggeredBy(params: {
}
}
- throw new AppError(
- 'Could not resolve organization member for plan',
- ErrorCodes.ORG_PLAN_CREATE_FAILED,
- 400
- );
+ throw new AppError('Could not resolve organization member for plan', ErrorCodes.ORG_PLAN_CREATE_FAILED, 400);
}
/**
diff --git a/apps/api/src/services/organization/audience.ts b/apps/api/src/services/organization/audience.ts
index d60f6647..4c1cdf31 100644
--- a/apps/api/src/services/organization/audience.ts
+++ b/apps/api/src/services/organization/audience.ts
@@ -27,7 +27,7 @@ import { updateOrganizationAudienceMember } from '@cio/db/queries/organization';
import { ROLE } from '@cio/utils/constants';
import crypto from 'node:crypto';
-import { getDashboardBaseUrl } from '@api/config/dashboard-url';
+import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getProfilesByEmails } from '@cio/db/queries/auth';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
@@ -195,7 +195,7 @@ async function enrollAudienceStudentProfilesInCourses(
}
let emailsSent = 0;
- const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
+ const loginUrl = getDashboardBaseUrl(organization);
if (shouldSendEmail && toInsert.length > 0) {
const emailPromises = toInsert
@@ -254,7 +254,7 @@ async function enrollAudienceStudentProfilesInPrograms(
}
const programNameById = new Map(programs.map((program) => [program.id, program.name || 'Program']));
- const loginUrl = getDashboardBaseUrl(organization.siteName ?? undefined);
+ const loginUrl = getDashboardBaseUrl(organization);
const validProgramIds = programs.map((program) => program.id);
const validProfiles = uniqueProfileIds.filter((profileId) => validProfileIds.has(profileId));
const pairs = validProfiles.flatMap((profileId) => validProgramIds.map((programId) => ({ programId, profileId })));
@@ -354,7 +354,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
});
const invites = await createOrganizationInvites(inviteInputs.map((i) => i.row));
- const inviteByEmail = new Map(invites.map((inv) => [inv.email.toLowerCase(), inv]));
+ const inviteByEmail = new Map(invites.map((inv) => [(inv.email ?? '').toLowerCase(), inv]));
const tokenByEmail = new Map(inviteInputs.map((i) => [i.email.toLowerCase(), i.token]));
await createOrganizationInviteAudits(
diff --git a/apps/api/src/services/organization/invite.ts b/apps/api/src/services/organization/invite.ts
index c7e1b49b..4c2553a0 100644
--- a/apps/api/src/services/organization/invite.ts
+++ b/apps/api/src/services/organization/invite.ts
@@ -6,14 +6,18 @@ import {
createOrganizationInviteAudit,
createOrganizationMember,
createOrganizationMembers,
+ createLinkInvite,
getActivePendingOrgInviteForEmail,
getOrganizationById,
getOrganizationInviteByTokenHash,
+ getOrgLinkInvite,
+ getOrgLinkInviteWithOrg,
revokeActiveOrganizationInvitesByEmails,
selectOrganizationInviteWithOrgByInviteId,
selectOrganizationInviteWithOrgByTokenHash,
selectOrganizationMemberByOrgAndNormalizedEmail,
selectOrganizationMemberByOrgAndProfile,
+ setLinkInviteRevoked,
updateOrganizationMemberById
} from '@cio/db/queries/organization';
import { getCourseGroupIds } from '@cio/db/queries/course';
@@ -24,7 +28,7 @@ import { ROLE } from '@cio/utils/constants';
import type { TNewOrganizationInviteAudit } from '@db/types';
import crypto from 'node:crypto';
import { db, type DbOrTxClient } from '@cio/db/drizzle';
-import { getDashboardBaseUrl } from '@api/config/dashboard-url';
+import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { enqueueTransactionalEmail } from '@api/services/jobs';
@@ -338,7 +342,8 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
}
- if (row.invite.email.toLowerCase().trim() !== normalizedEmail) {
+ const inviteEmail = row.invite.email ?? '';
+ if (inviteEmail.toLowerCase().trim() !== normalizedEmail) {
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
}
@@ -447,6 +452,151 @@ export async function acceptOrganizationInvite(token: string, user: TAuthUser, c
};
}
+// ─── Link Invite ────────────────────────────────────────────────────────────
+
+const LINK_INVITE_FAR_FUTURE_MS = 100 * 365 * 24 * 60 * 60 * 1000;
+
+/**
+ * Returns the current org link invite if one already exists, otherwise creates one.
+ * One link invite per org — subsequent calls return the same record.
+ */
+export async function getOrCreateLinkInvite(orgId: string, roleId: number, profileId: string) {
+ if (roleId !== ROLE.ADMIN && roleId !== ROLE.TUTOR) {
+ throw new AppError('Invalid organization role for link invite', ErrorCodes.VALIDATION_ERROR, 400, 'roleId');
+ }
+
+ const existing = await getOrgLinkInvite(orgId);
+
+ if (existing) {
+ const token = (existing.metadata as Record)?.token as string | undefined;
+ return { id: existing.id, token: token ?? '', roleId: existing.roleId, isRevoked: existing.isRevoked };
+ }
+
+ const token = generateToken();
+ const tokenHash = hashToken(token);
+ const expiresAt = new Date(Date.now() + LINK_INVITE_FAR_FUTURE_MS).toISOString();
+
+ const invite = await createLinkInvite({
+ organizationId: orgId,
+ roleId,
+ tokenHash,
+ createdByProfileId: profileId,
+ expiresAt,
+ metadata: { token, source: 'ORG_SETTINGS_LINK_INVITE' }
+ });
+
+ await recordOrganizationInviteAudit(invite.id, orgId, 'CREATED', {
+ actorProfileId: profileId,
+ metadata: { roleId, type: 'LINK' }
+ });
+
+ return { id: invite.id, token, roleId: invite.roleId, isRevoked: invite.isRevoked };
+}
+
+/**
+ * Returns current link invite for the org (for GET endpoint — returns null if none created yet).
+ */
+export async function fetchOrgLinkInvite(orgId: string) {
+ const row = await getOrgLinkInvite(orgId);
+
+ if (!row) return null;
+
+ const token = (row.metadata as Record)?.token as string | undefined;
+ return { id: row.id, token: token ?? '', roleId: row.roleId, isRevoked: row.isRevoked };
+}
+
+/**
+ * Enables or disables the org link invite (toggle isRevoked).
+ */
+export async function toggleOrgLinkInvite(orgId: string, isRevoked: boolean, profileId: string) {
+ const updated = await setLinkInviteRevoked(orgId, isRevoked, profileId);
+
+ if (!updated) {
+ throw new AppError('Link invite not found', ErrorCodes.NOT_FOUND, 404);
+ }
+
+ const token = (updated.metadata as Record)?.token as string | undefined;
+ return { id: updated.id, token: token ?? '', roleId: updated.roleId, isRevoked: updated.isRevoked };
+}
+
+/**
+ * Server-only preview for a link invite (API-key protected at route layer).
+ */
+export async function previewLinkInvite(token: string, context: TInviteRequestContext = {}) {
+ const tokenHash = hashToken(token);
+ const data = await getOrgLinkInviteWithOrg(db, tokenHash);
+
+ if (!data) {
+ throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
+ }
+
+ await recordOrganizationInviteAudit(data.invite.id, data.invite.organizationId, 'PREVIEWED', {
+ ipAddress: context.ipAddress,
+ userAgent: context.userAgent
+ });
+
+ return {
+ invite: {
+ id: data.invite.id,
+ roleId: data.invite.roleId,
+ roleLabel: getRoleLabel(data.invite.roleId),
+ isRevoked: data.invite.isRevoked
+ },
+ organization: data.organization
+ };
+}
+
+/**
+ * Accepts a link invite — adds the authenticated user to the org.
+ * The invite is NOT consumed (reusable). Anyone with the link can join.
+ */
+export async function acceptLinkInvite(token: string, user: TAuthUser, context: TInviteRequestContext = {}) {
+ if (!user.id || !user.email) {
+ throw new AppError('Authenticated user email is required', ErrorCodes.UNAUTHORIZED, 401);
+ }
+
+ const normalizedEmail = user.email.toLowerCase().trim();
+ const tokenHash = hashToken(token);
+
+ const result = await db.transaction(async (tx) => {
+ const row = await getOrgLinkInviteWithOrg(tx, tokenHash);
+
+ if (!row) {
+ throw new AppError('Invalid invite link', ErrorCodes.NOT_FOUND, 404);
+ }
+
+ if (row.invite.isRevoked) {
+ throw new AppError('This invite link has been disabled', ErrorCodes.UNAUTHORIZED, 403);
+ }
+
+ await syncOrgMemberForOrgInvite(tx, {
+ organizationId: row.invite.organizationId,
+ roleId: row.invite.roleId,
+ normalizedEmail,
+ userId: user.id
+ });
+
+ await markUserAndProfileEmailVerified(user.id, tx);
+
+ return { organization: row.organization, roleId: row.invite.roleId, inviteId: row.invite.id };
+ });
+
+ await recordOrganizationInviteAudit(result.inviteId, result.organization.id, 'ACCEPTED', {
+ actorProfileId: user.id,
+ targetEmail: normalizedEmail,
+ ipAddress: context.ipAddress,
+ userAgent: context.userAgent,
+ metadata: { source: 'LINK_INVITE' }
+ });
+
+ const siteName = result.organization.siteName || '';
+ const redirectTo = result.roleId === ROLE.STUDENT ? '/lms' : siteName ? `/org/${siteName}` : '/org';
+
+ return { organizationId: result.organization.id, roleId: result.roleId, redirectTo };
+}
+
+// ─── End Link Invite ─────────────────────────────────────────────────────────
+
/**
* Returns the pending org invite for the currently logged-in user, if one exists.
* Used by the LMS dashboard to prompt the student to accept on first load.
@@ -503,7 +653,8 @@ export async function acceptOrganizationInviteById(
throw new AppError('This invite has expired', ErrorCodes.VALIDATION_ERROR, 400);
}
- if (row.invite.email.toLowerCase().trim() !== normalizedEmail) {
+ const inviteEmailById = row.invite.email ?? '';
+ if (inviteEmailById.toLowerCase().trim() !== normalizedEmail) {
throw new AppError('This invite is for a different email address', ErrorCodes.UNAUTHORIZED, 403);
}
diff --git a/apps/api/src/services/program/goal.ts b/apps/api/src/services/program/goal.ts
index e13dec76..2ace890e 100644
--- a/apps/api/src/services/program/goal.ts
+++ b/apps/api/src/services/program/goal.ts
@@ -1,6 +1,6 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { enqueueTransactionalEmail } from '@api/services/jobs';
-import { getDashboardBaseUrl } from '@api/config/dashboard-url';
+import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import {
countAssignmentsByStatus,
createProgramGoal,
@@ -408,7 +408,11 @@ export async function runProgramGoalReminderScan(): Promise<{
if (!matchesReminder) continue;
- const loginUrl = getDashboardBaseUrl(row.organizationSiteName ?? undefined);
+ const loginUrl = getDashboardBaseUrl({
+ siteName: row.organizationSiteName,
+ customDomain: row.organizationCustomDomain,
+ isCustomDomainVerified: row.organizationIsCustomDomainVerified
+ });
const dayKey = now.toISOString().slice(0, 10);
try {
diff --git a/apps/api/src/services/submission/submission.ts b/apps/api/src/services/submission/submission.ts
index 544b7f0d..e7d6b321 100644
--- a/apps/api/src/services/submission/submission.ts
+++ b/apps/api/src/services/submission/submission.ts
@@ -41,8 +41,8 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from
import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types';
-import { getDashboardBaseUrl } from '@api/config/dashboard-url';
-import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@api/utils/s3';
+import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
+import { generateDocumentDownloadPresignedUrls, generateVideoDownloadPresignedUrls } from '@cio/core/utils/s3';
import { syncComplianceProgressFromSubmission } from '@api/services/course/compliance';
type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed';
diff --git a/apps/api/src/services/tag.ts b/apps/api/src/services/tag.ts
index b714f1d4..f514b153 100644
--- a/apps/api/src/services/tag.ts
+++ b/apps/api/src/services/tag.ts
@@ -114,6 +114,10 @@ export async function getPublicOrganizationTagGroups(siteName: string) {
for (const group of groups) {
for (const tag of group.tags) {
+ if (tag.courseCount <= 0) {
+ continue;
+ }
+
const groupId = tag.groupId || group.id;
const existing = tagsByGroupId.get(groupId) ?? [];
@@ -124,10 +128,12 @@ export async function getPublicOrganizationTagGroups(siteName: string) {
}
}
- return groups.map((group) => ({
- ...group,
- tags: tagsByGroupId.get(group.id) ?? []
- }));
+ return groups
+ .map((group) => ({
+ ...group,
+ tags: tagsByGroupId.get(group.id) ?? []
+ }))
+ .filter((group) => group.tags.length > 0);
} catch (error) {
if (error instanceof AppError) {
throw error;
diff --git a/apps/api/src/services/v1/course.ts b/apps/api/src/services/v1/course.ts
index a9d25749..6d4479b6 100644
--- a/apps/api/src/services/v1/course.ts
+++ b/apps/api/src/services/v1/course.ts
@@ -12,7 +12,7 @@ import {
deleteCourse as deleteCourseService,
getCourse,
updateCourse as updateCourseService
-} from '@api/services/course/course';
+} from '@cio/core/services/course/course';
import {
createCourseImportDraftService,
getCourseImportStructureService,
diff --git a/apps/api/src/services/widget-payload.ts b/apps/api/src/services/widget-payload.ts
index 68cf82dc..8b472e0a 100644
--- a/apps/api/src/services/widget-payload.ts
+++ b/apps/api/src/services/widget-payload.ts
@@ -13,7 +13,7 @@ import {
} from '@cio/utils/validation/widget';
import type { TWidget } from '@db/types';
import * as csstree from 'css-tree';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const DEFAULT_EMBED_CDN_BASE_URL = 'https://assets.cdn.clsrio.com';
diff --git a/apps/api/src/utils/course-completion.ts b/apps/api/src/utils/course-completion.ts
index 8c282b7a..7dcca63a 100644
--- a/apps/api/src/utils/course-completion.ts
+++ b/apps/api/src/utils/course-completion.ts
@@ -1,4 +1,4 @@
-import { getDashboardBaseUrl } from '@api/config/dashboard-url';
+import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import {
getCourseProgress as getCourseProgressQuery,
type TCourseCertificationRow
@@ -67,7 +67,11 @@ export function scheduleCertificationCompletionWork(params: {
return;
}
- const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
+ const base = getDashboardBaseUrl({
+ siteName: courseRow.orgSiteName,
+ customDomain: courseRow.orgCustomDomain,
+ isCustomDomainVerified: courseRow.orgIsCustomDomainVerified
+ });
const certificateUrl = `${base}/courses/${courseId}/certificates`;
await enqueueTransactionalEmail('studentCourseCompletion', {
diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts
index b85d11ec..b11c51ba 100644
--- a/apps/api/src/utils/errors.ts
+++ b/apps/api/src/utils/errors.ts
@@ -1,24 +1,13 @@
-import type { ContentfulStatusCode } from 'hono/utils/http-status';
import type { Context } from 'hono';
+import type { ContentfulStatusCode } from 'hono/utils/http-status';
-import { ErrorCodes, type ErrorCode } from '@cio/utils/constants';
+import { AppError, ErrorCodes, type ErrorCode } from '@cio/utils/errors';
-export { ErrorCodes, type ErrorCode };
-
-/**
- * Standard application error with code for easy identification
- */
-export class AppError extends Error {
- constructor(
- error: string | Error,
- public code: string,
- public statusCode: ContentfulStatusCode = 500,
- public field?: string
- ) {
- super(error instanceof Error ? error.message : error);
- this.name = 'AppError';
- }
-}
+// AppError moved to `@cio/utils/errors` so it can be thrown from
+// non-api workspace packages (notably `@cio/core`). Re-exported
+// here to keep the long-standing `@api/utils/errors` import path working
+// for the rest of the api codebase.
+export { AppError, ErrorCodes, type ErrorCode };
/**
* Standard error response format
@@ -68,7 +57,7 @@ export const handleError = (
code: responseCode,
field: isServerError ? undefined : error.field
},
- error.statusCode
+ error.statusCode as ContentfulStatusCode
);
}
diff --git a/apps/api/src/utils/openapi/index.ts b/apps/api/src/utils/openapi/index.ts
index 2d9c8a9e..24f544bc 100644
--- a/apps/api/src/utils/openapi/index.ts
+++ b/apps/api/src/utils/openapi/index.ts
@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { Scalar } from '@scalar/hono-api-reference';
-import { env } from '@api/config/env';
+import { env } from '@cio/core/config/env';
import { AuthSession } from '@api/types/auth';
export function configureOpenAPI(app: Hono) {
diff --git a/apps/api/src/utils/redis/limiter.ts b/apps/api/src/utils/redis/limiter.ts
index 9af69321..684e4cf9 100644
--- a/apps/api/src/utils/redis/limiter.ts
+++ b/apps/api/src/utils/redis/limiter.ts
@@ -5,7 +5,7 @@ import {
RATE_LIMIT_KEY_PREFIX
} from '@api/constants/rate-limiter';
-import { redis, type RedisClient } from './redis';
+import { redis, type RedisClient } from '@cio/core/utils/redis/redis';
export class RedisRateLimiter {
private redis: RedisClient;
diff --git a/apps/api/src/utils/redis/oauth-handoff.ts b/apps/api/src/utils/redis/oauth-handoff.ts
index 3ea220f8..60cce2df 100644
--- a/apps/api/src/utils/redis/oauth-handoff.ts
+++ b/apps/api/src/utils/redis/oauth-handoff.ts
@@ -1,4 +1,4 @@
-import { logRedisUnavailableOnce, redis } from './redis';
+import { logRedisUnavailableOnce, redis } from '@cio/core/utils/redis/redis';
const KEY = (token: string) => `oauth-handoff:${token}`;
const TTL_SECONDS = 120; // OAuth callbacks usually finish in < 2s; 2 minutes gives plenty of slack.
diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json
index 27eed244..1a801d9b 100644
--- a/apps/api/tsconfig.json
+++ b/apps/api/tsconfig.json
@@ -35,6 +35,7 @@
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"],
"references": [
+ { "path": "../../packages/core" },
{ "path": "../../packages/db" },
{ "path": "../../packages/utils" },
{ "path": "../../packages/email" },
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index 1a833e4f..2c93b193 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -56,6 +56,7 @@
"d3-sankey": "^0.12.3",
"dayjs": "^1.11.10",
"encoding": "^0.1.13",
+ "hls.js": "^1.6.16",
"hotkeys-js": "^3.11.2",
"html-to-image": "^1.11.11",
"is-valid-domain": "^0.1.6",
@@ -65,6 +66,7 @@
"ky": "^1.10.0",
"lodash": "^4.17.21",
"marked": "^7.0.5",
+ "mediabunny": "^1.45.2",
"openai-edge": "^1.2.2",
"papaparse": "^5.4.1",
"pluralize": "^8.0.0",
diff --git a/apps/dashboard/src/app.html b/apps/dashboard/src/app.html
index 2a5f7dc0..62a2b94d 100644
--- a/apps/dashboard/src/app.html
+++ b/apps/dashboard/src/app.html
@@ -10,61 +10,9 @@
%sveltekit.head%
-
-
-
-
-
-
-
-
-
-
-
-
-
Loading...
-
%sveltekit.body%