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 @@

-[![Codacy Badge](https://api.codacy.com/project/badge/Grade/629e2bb8994345729513c4d69ccbe3d5)](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 -ClassroomIO Courses page +ClassroomIO Courses page -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 + // `