* fix(api): return Zod validation errors from public API routes Public API handlers now surface ZodError details as 400 VALIDATION_ERROR responses instead of generic 500 INTERNAL_ERROR payloads. Course import services also rethrow ZodError so validation failures are not wrapped as server errors. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * refactor(utils): centralize course type values without db dependency Define COURSE_TYPE_VALUES once in @cio/utils/constants and use it for ZCourseType validation and the Drizzle COURSE_TYPE pgEnum. Removes the circular @cio/utils -> @cio/db import while keeping course-import schemas aligned with all four course types including PUBLIC. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(validation): default missing course metadata allowNewStudent to true Courses stored without allowNewStudent in metadata failed structure export validation. Align Zod preprocessing with runtime behavior where absent values mean enrollments are open. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * chore(db): drop unused COURSE_TYPE_VALUES re-export from schema Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
ClassroomIO Backend
Supported Features
- Certificate download for students.
- 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://<tenant-host>/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:
# 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:
# Already required by the existing API client — used as the HLS base URL too.
PUBLIC_SERVER_URL=http://localhost:3000
Run
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:
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
# On Render (apps/api service env):
HLS_SIGNING_SECRET=<paste>
# 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