* fix(email): make team org invites more transactional Use org-branded sender and subject instead of the default "Best from ClassroomIO" marketing identity. Include the inviter name in the body, remove promotional emojis, and thread optional subject overrides through the email job pipeline. Co-authored-by: Rotimi Best <[email protected]> * fix(email): escape invite fields and sanitize email headers Address Greptile review: escape inviter/org/role values in the team invite template, sanitize display names in buildEmailFromName, and strip control characters from dynamic subjects. Co-authored-by: Rotimi Best <[email protected]> * fix(email): use RFC 5322 backslash escaping in display names Co-authored-by: Rotimi Best <[email protected]> * test(email): add vitest coverage for email header helpers Cover escapeHtml, sanitizeEmailDisplayName, sanitizeEmailSubject, and buildEmailFromName. Collapse repeated whitespace when stripping control characters from header values. Co-authored-by: Rotimi Best <[email protected]> * fix(email): strip all ASCII control chars from email headers Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
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