From 0dc32b52107c17e35dfadb3da3314a93928b4104 Mon Sep 17 00:00:00 2001 From: Abdulmuizz Hamzat <103611443+buildwithhamzat@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:33:20 +0100 Subject: [PATCH] refactor: selfhosting docs (#798)f * Remove the run-docker-full-stack.sh script to streamline the deployment process and eliminate unnecessary complexity. * fix: update documentation to clarify script behavior for building and pulling images * update docs * fix: update script URL to use classroomio.com for downloads * fix: humanize selfhosting docs and minor amendment * fix: attend to comment * fix: humanize docs * update docs * fix: adjusted the Restarting ClassroomIO... function * fix: update commands and documentation for environment variable handling and upgrades * chore: added space for rebuild --- .env.example | 6 +- FEATURE_AUDIT.md | 636 ----------------- README.md | 6 +- apps/docs/blume.config.ts | 29 +- apps/docs/content/docs/enterprise-sso.mdx | 2 +- apps/docs/content/docs/self-hosted/101.mdx | 74 ++ .../content/docs/self-hosted/architecture.mdx | 84 +++ .../docs/content/docs/self-hosted/backups.mdx | 96 +++ .../docs/self-hosted/configuration/csp.mdx | 10 +- .../docs/self-hosted/configuration/email.mdx | 4 +- .../docs/self-hosted/configuration/index.mdx | 19 +- .../configuration/integrations.mdx | 6 +- .../self-hosted/configuration/secrets.mdx | 34 +- .../docs/self-hosted/configuration/sso.mdx | 34 - .../self-hosted/configuration/storage.mdx | 25 +- .../self-hosted/configuration/versions.mdx | 21 +- .../docs/content/docs/self-hosted/coolify.mdx | 30 +- .../content/docs/self-hosted/digitalocean.mdx | 60 +- apps/docs/content/docs/self-hosted/docker.mdx | 247 ++++--- .../docs/content/docs/self-hosted/dokploy.mdx | 36 +- .../content/docs/self-hosted/enterprise.mdx | 57 ++ apps/docs/content/docs/self-hosted/index.mdx | 43 +- .../docs/content/docs/self-hosted/railway.mdx | 30 +- .../docs/self-hosted/troubleshooting.mdx | 128 ++++ apps/docs/content/docs/token-auth.mdx | 2 +- apps/docs/package.json | 2 +- apps/docs/scripts/package-assets.mjs | 2 + .../classroomio.sh/+page.server.ts | 13 + classroomio.sh | 666 ++++++++++++++++++ docker-compose.images.yaml | 4 +- docker-compose.yaml | 2 +- docker/docs/SELF_HOST.md | 106 +-- pnpm-lock.yaml | 339 +++++---- run-docker-full-stack.sh | 326 --------- 34 files changed, 1721 insertions(+), 1458 deletions(-) delete mode 100644 FEATURE_AUDIT.md create mode 100644 apps/docs/content/docs/self-hosted/101.mdx create mode 100644 apps/docs/content/docs/self-hosted/architecture.mdx create mode 100644 apps/docs/content/docs/self-hosted/backups.mdx delete mode 100644 apps/docs/content/docs/self-hosted/configuration/sso.mdx create mode 100644 apps/docs/content/docs/self-hosted/enterprise.mdx create mode 100644 apps/docs/content/docs/self-hosted/troubleshooting.mdx create mode 100644 apps/website/src/routes/(redirects)/classroomio.sh/+page.server.ts create mode 100755 classroomio.sh delete mode 100755 run-docker-full-stack.sh diff --git a/.env.example b/.env.example index 8ec0b0c0..7da69879 100644 --- a/.env.example +++ b/.env.example @@ -26,7 +26,7 @@ POSTGRES_PASSWORD=postgres # Auth secret for session signing. REQUIRED — the stack refuses to start if this is empty. BETTER_AUTH_SECRET= -# Dashboard-to-API auth key (leave blank — auto-generated by ./run-docker-full-stack.sh). +# Dashboard-to-API auth key (leave blank — auto-generated by ./classroomio.sh). # Must be the same value in both API and dashboard. PRIVATE_SERVER_KEY= @@ -82,10 +82,10 @@ SMTP_PASSWORD= SMTP_SENDER= # ────────────────────────────────────────────── -# Optional — Object Storage (auto-configured by ./run-docker-full-stack.sh) +# Optional — Object Storage (auto-configured by ./classroomio.sh) # ────────────────────────────────────────────── -# MinIO is included by default. On first run ./run-docker-full-stack.sh RANDOMIZES the +# MinIO is included by default. On first run ./classroomio.sh RANDOMIZES the # MinIO credentials below and mirrors them into OBJECT_STORAGE_ACCESS_KEY_ID/SECRET. # These minioadmin placeholders are only used if you skip the script. MINIO_ROOT_USER=minioadmin diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md deleted file mode 100644 index 16d1b887..00000000 --- a/FEATURE_AUDIT.md +++ /dev/null @@ -1,636 +0,0 @@ -# ClassroomIO — Feature & Flow Map (QA Audit) - -> **Purpose of this document:** a reverse‑engineered, citation‑backed map of every -> feature in the ClassroomIO application, produced for QA to build test cases and -> record demo videos. **Files are the source of truth.** Every claim is backed by a -> file reference. Claims that could not be traced to code are marked -> `⚠️ UNVERIFIED`. Ambiguities live in **§7 Open Questions**, not in the body. -> -> Reference format: `` `path/to/file` `` → symbol/lines — what it proves. -> Scope (per QA request): full depth on the user‑facing product -> (`apps/dashboard`, `apps/api`, `apps/website`, `apps/embeds` + the packages that -> back features); a lighter **Supporting Infrastructure** summary for the rest. -> PRD folders (`prd/*`) are used only to cross‑check intent — code wins on conflict. - ---- - -## 1. System Overview - -**What it is.** ClassroomIO is an open‑source **Learning Management System (LMS) for -companies** — compliance/employee training, customer education, and partner -certification. It supports multi‑organization workspaces, courses/lessons/exercises, -cohorts (cohorts), certificates, an AI course builder + in‑lesson tutor, a public -REST API, an MCP server, and embeddable course widgets. It can be **self‑hosted** or -run as the **cloud** product. (`README.md` → lines 11–50 — feature summary; -`README.md` → 84–91 — monorepo apps.) - -**Tech stack** (cited from manifests): -- **Monorepo**: pnpm + Turbo. `package.json` → root scripts/workspaces; - `pnpm-workspace.yaml` → packages globs (`apps/*`, `packages/*`, - `packages/course-app/src/*`); `turbo.json`. -- **Frontend**: SvelteKit + Svelte 5 + TailwindCSS. `README.md` → 52–58 ("Built With"); - `apps/dashboard/svelte.config.js`. -- **Backend API**: Hono (TypeScript). `apps/api/src/app.ts` → `new Hono()` app; - `ARCHITECHTURE.md` → layered architecture (Routes → Services → Queries). -- **Database**: PostgreSQL via Drizzle ORM. `packages/db/src/schema.ts` (~3,400 lines - of `pgTable`/`pgEnum`); `packages/db/src/drizzle.ts`. -- **Auth**: Better Auth (sessions, OAuth, SSO). `apps/api/src/app.ts` → 12, 107–188 - (`auth.handler`, `/api/auth/*`); `packages/db/src/auth.ts`. -- **Background jobs**: BullMQ + Redis. `apps/jobs`, `apps/api/src/routes/admin/queues.ts` - (`mountQueueDashboard`), `mediaJob`/`jobStep`/`deadLetterJob` tables in `schema.ts`. -- **Object storage**: S3‑compatible / MinIO. `.env.example` → 77–89 (OBJECT_STORAGE_*); - `README.md` → 176–191. -- **Billing**: Polar. `apps/dashboard/src/routes/api/polar/*`. -- **AI providers**: OpenAI / Google (Gemini) / Anthropic. `.env.example` → 110–115; - `packages/utils/src/agent-models` (per `README.md` → 236). - -**High‑level architecture** (`ARCHITECHTURE.md` → 9–51): -``` -SvelteKit dashboard (apps/dashboard) ──RPC (Hono client)──▶ Hono API (apps/api) - │ server-side SSR proxy (.server.ts, PRIVATE_SERVER_KEY) │ - │ ▼ - Public org sites / embeds Services → Queries (Drizzle) - (apps/website, apps/embeds) │ - ▼ - PostgreSQL · Redis/BullMQ · S3/MinIO -``` -- Layered API: **Routes** (HTTP/validation/auth) → **Services** (business logic/ - transactions) → **Queries** (pure Drizzle). `ARCHITECHTURE.md` → 44–50. -- Shared **Zod validation** in `packages/utils/src/validation/*`, used by both API and - dashboard. `ARCHITECHTURE.md` → 45; `AGENTS.md` → 17–25. -- Dashboard talks to the API through a typed Hono RPC client and an SSR auth proxy - authenticated with `PRIVATE_SERVER_KEY`. `README.md` → 149–156. - ---- - -## 2. Feature Inventory - -Status legend: **Complete** = route+service+schema present; **Partial** = present but -gated/limited or doc claims exceed code; **Stub/Planned** = PRD only, little/no code. - -| # | Feature | Purpose | Primary files | Status | -|---|---------|---------|---------------|--------| -| 1 | Authentication & sessions | Email/password, OAuth, session mgmt | `apps/api/src/app.ts:107`, `packages/db/src/auth.ts` | Complete | -| 2 | Signup gating | Org‑scoped/invite‑only/self‑host bootstrap rules | `apps/api/src/middlewares/signup-guard.ts` | Complete | -| 3 | Onboarding & org creation | Create profile + first organization | `apps/api/src/services/onboarding.ts`, `apps/dashboard/src/routes/(auth)/onboarding` | Complete | -| 4 | Organizations (workspaces) | Multi‑org, settings, members, plan | `apps/api/src/routes/organization/organization.ts`, `schema.ts:1949` | Complete | -| 5 | Team / people management | Invite teachers, assign roles | `apps/api/src/routes/course/people.ts`, `apps/dashboard/src/lib/features/people` | Complete | -| 6 | Courses | Create/manage courses, sections, content | `apps/api/src/routes/course/course.ts`, `schema.ts:634` | Complete | -| 7 | Lessons & lesson editor | Lesson content, multilingual, comments | `apps/api/src/routes/course/lesson.ts`, `.../lesson-language.ts`, `schema.ts:956` | Complete | -| 8 | Exercises & questions | Quizzes, question types, sections, grading | `apps/api/src/routes/course/exercise.ts`, `.../submission.ts`, `.../mark.ts`, `packages/question-types` | Complete | -| 9 | Course people / enrollment | Enroll students, roster | `apps/api/src/routes/course/people.ts` | Complete | -| 10 | Course invites | Email + link invites with audit | `apps/api/src/routes/course/invite.ts`, `apps/api/src/services/course/invite.ts`, `schema.ts:1282` | Complete | -| 11 | Org invites | Invite org members (EMAIL/LINK) | `apps/api/src/routes/invite/invite.ts`, `schema.ts:1384` | Complete | -| 12 | Compliance training | Deadlines, renewals, grace, records | `apps/api/src/routes/course/compliance.ts`, `apps/api/src/routes/internal`, `schema.ts:768` (`courseCompletionRecord`) | Complete | -| 13 | Certificates | Issue branded certificates | `packages/certificates/src/render.ts`, `schema.ts:850` (`courseCertificateIssue`) | Complete | -| 14 | Cohorts | Group courses, goals, members, newsfeed | `apps/api/src/routes/cohort/cohort.ts`, `schema.ts` cohort tables | Complete | -| 15 | Community Q&A | Per‑course questions/answers | `apps/api/src/routes/community/community.ts`, `schema.ts:1558` | Complete | -| 16 | Course newsfeed | Course/cohort announcements + comments | `apps/api/src/routes/course/newsfeed.ts`, `schema.ts:1605` | Complete | -| 17 | Attendance | Track group/live‑class attendance | `apps/api/src/routes/course/attendance.ts`, `schema.ts:321` (`groupAttendance`) | Complete | -| 18 | AI Course Assistant | In‑course AI authoring/plan/edit chat | `apps/api/src/routes/agent/*`, `packages/ai-assistant`, `schema.ts:3163` (`aiAgentRun`) | Complete (key‑gated) | -| 19 | AI Lesson Tutor | In‑lesson learner AI helper + fair‑use caps | `apps/api/src/routes/course/ai-tutor.ts`, `apps/api/src/routes/organization/ai-tutor.ts`, `schema.ts:3013` (`aiTutorMessageCount`) | Complete (key‑gated) | -| 20 | AI credits / token billing | Token usage, balance, purchases | `schema.ts:2919` (`aiTokenUsage`), `apps/dashboard/src/lib/features/agent/api/credit-purchase.server.ts` | Complete (cloud billing) | -| 21 | Course import | Import from prompt/PDF/course | `apps/api/src/routes/organization/course-import.ts`, `apps/api/src/services/course-import`, `schema.ts:2603` | Complete | -| 22 | Media manager | Upload/manage video/docs/media, transcripts | `apps/api/src/routes/media/media.ts`, `.../transcripts.ts`, `.../hls.ts`, `apps/dashboard/src/lib/features/media`, `schema.ts:1028` (`asset`) | Complete | -| 23 | Tags | Tag groups/tags/assignments for courses | `apps/api/src/routes/organization/tags.ts`, `schema.ts:2158` | Complete | -| 24 | Org public site (org‑site) | Branded public course catalog/pages | `apps/api/src/routes/org-site`, `apps/dashboard/src/routes/(org-site)` | Complete | -| 25 | Custom domains | Cloudflare custom domain mapping | `apps/api/src/routes/domain/domain.ts`, `apps/tenant-router` | Complete | -| 26 | Course widget embed | Embeddable course catalog widget | `apps/api/src/routes/widgets`, `apps/dashboard/src/routes/(app)/widgets`, `apps/embeds`, `schema.ts:2255` (`widget`) | Complete | -| 27 | Public REST API (v1) | API‑key automation: courses, audience | `apps/api/src/routes/v1/*`, `apps/api/src/services/v1`, `schema.ts:2539` (`organizationApiKey`) | Complete | -| 28 | Automation / API keys | Issue mcp/api/zapier keys with scopes | `apps/api/src/routes/organization/automation.ts`, `apps/api/src/middlewares/automation-key*.ts` | Complete | -| 29 | MCP server | `@classroomio/mcp` AI‑native course authoring | `packages/mcp/src/*` | Complete | -| 30 | Enterprise SSO | OIDC SSO (Okta/Google Workspace/Auth0) | `apps/api/src/routes/organization/sso.ts`, `apps/api/src/routes/sso/discovery.ts`, `schema.ts:2479` (`organizationSsoConfig`) | Complete (licensed) | -| 31 | Token auth | Token‑based auth policy | `apps/api/src/routes/organization/token-auth.ts`, `schema.ts:2513` (`organizationTokenAuth`) | Complete (licensed) | -| 32 | Licensing | Self‑host license‑gated features | `apps/api/src/routes/license.ts`, `apps/api/src/middlewares/license.ts`, `packages/utils/src/license/*` | Complete | -| 33 | Billing (Polar) | Subscriptions + AI token packs | `apps/dashboard/src/routes/api/polar/*` | Complete (cloud only) | -| 34 | Analytics | Login/page events, org/course/country daily | `apps/api/src/routes/dash`, `apps/api/src/services/analytics`, `schema.ts:167–285` | Complete | -| 35 | Email / notifications | Transactional emails via jobs | `apps/api/src/routes/mail`, `packages/email`, `apps/api/src/services/jobs` | Complete | -| 36 | Background jobs | Media/email queues, BullMQ dashboard | `apps/jobs`, `apps/api/src/routes/jobs`, `apps/api/src/routes/admin/queues.ts` | Complete | -| 37 | Unsplash image search | Image picker for content | `apps/api/src/routes/unsplash/unsplash.ts` | Complete | -| 38 | Marketing website | classroomio.com landing | `apps/website` | Complete | -| 39 | Documentation site | classroomio.com/docs | `apps/docs` | Complete | -| 40 | Outbound webhooks | Emit `certificate.issued`, etc. | `prd/webhooks` (todo) | **Stub/Planned ⚠️** | -| 41 | SCORM support | SCORM package support | `prd/scorm-support` (todo) | **Stub/Planned ⚠️** | -| 42 | Notification system | In‑app notifications | `prd/notification-system` (todo) | **Stub/Planned ⚠️** | - -> Items 40–42 are listed in `PRD-TRACKER.md` → 9, 16, 24 as **todo**; treat as not yet -> shipped. See **§7**. - ---- - -## 3. Per‑Feature Detail - -### 3.1 Authentication & Sessions -- **Purpose:** authenticate users (email/password + OAuth) and attach org roles to the session. -- **How it works:** All `/api/auth/*` requests are handled by Better Auth via - `auth.handler`. `apps/api/src/app.ts` → 107–188. A global middleware resolves the - session on every request and sets `user`, `session`, and `orgRoles` into context. - `apps/api/src/app.ts` → 60–97. The app rebuilds the request host from - `X-Forwarded-Host`/`-Proto` (Cloudflare proxy) and uses a Redis "oauth handoff" to - shrink large OAuth callback cookies into a short token. `apps/api/src/app.ts` → 110–173. -- **User flow:** Login page → submit email/password (or OAuth) → Better Auth sets - session cookie → dashboard reads `/session`. `apps/dashboard/src/routes/(auth)/login`; - `apps/api/src/app.ts` → 189–199 (`/session`). -- **Key files:** `apps/api/src/app.ts`, `packages/db/src/auth.ts`, - `packages/db/src/auth/hooks/create-profile.ts`. -- **Edge cases:** OAuth cookie > 500 chars routed through Redis token (`app.ts:160`); - `getSession` failures fall through as anonymous (`app.ts:71–84`); session carries - `orgRoles` map used by RBAC middlewares (`app.ts:88`). - -### 3.2 Signup Gating (`signupGuard`) -- **Purpose:** enforce org‑level and deployment‑level signup rules server‑side. -- **How it works:** `apps/api/src/middlewares/signup-guard.ts` → 129–159. With a - `cio-org-id` header: rejects when `org.disableSignup` (403 `SIGNUP_DISABLED`, line 148) - or when `settings.signup.inviteOnly` and the email lacks an active invite (403 - `INVITE_REQUIRED`, lines 97–113). Without the header: cloud passes through; **self‑hosted - requires org context** unless no org exists yet (bootstrap) or the email already belongs - to the org. Lines 75–91 (`canProceedWithoutOrgContext`). -- **User flow:** Signup form → POST `/api/auth/sign-up/*` (guarded, `app.ts:106`) → allowed - / `SIGNUP_DISABLED` / `INVITE_REQUIRED` / `ORG_CONTEXT_REQUIRED`. -- **Edge cases (tester probes):** invite‑only org without invite; disabled signup; - self‑hosted with no `cio-org-id` and an existing org; unparseable body → 400 `INVALID_REQUEST`. - -### 3.3 Onboarding & Organization Creation -- **Purpose:** create the user profile and first organization, then send a welcome email. -- **How it works:** `apps/api/src/services/onboarding.ts`. `createOrganizationWithOwner` - validates site‑name uniqueness (409 `SITENAME_EXISTS`, lines 36–40), creates org + admin - member in a transaction (lines 44–61), and on **self‑hosted** assigns the org an - `ENTERPRISE` plan with provider `selfhosted` (lines 64–77). **Self‑hosted blocks a second - org** (403, lines 28–33). `completeOnboarding` enqueues a `welcome` email (lines 121–134). -- **User flow:** Signup → `(auth)/onboarding` → enter name/org/site name → org+admin created → dashboard. -- **Edge cases:** duplicate site name (409 + Postgres `23505` fallback, lines 93–95); - second‑org attempt on self‑host; profile not found (404). - -### 3.4 Organizations (Workspaces) -- **Purpose:** the top‑level tenant; owns courses, members, settings, plan, branding. -- **How it works:** `apps/api/src/routes/organization/organization.ts` (CRUD + settings); - `organization` table `schema.ts:1949` (siteName, settings, branding, `disableSignup`). - Members live in `organizationmember` (`schema.ts:1754`) with `roleId` FK to `role`. -- **User flow:** Org switcher → settings → update name/site/branding/signup policy. - `apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-switcher.svelte`; - `apps/dashboard/src/lib/features/settings`. -- **Edge cases:** unique `siteName`; self‑hosted = single org (see §5); org settings drive - signup gating (§3.2). - -### 3.5 Courses, Sections & Content -- **Purpose:** author and deliver courses (self‑paced, live‑class, compliance, public). -- **How it works:** course sub‑routers under `apps/api/src/routes/course/` — `course.ts`, - `section.ts`, `content.ts`, `lesson.ts`, `exercise.ts`, `people.ts`, `newsfeed.ts`, - `attendance.ts`, `compliance.ts`, `mark.ts`, `submission.ts`, `invite.ts`, - `lesson-language.ts`, `payment-request.ts`, `presign.ts`, `ai-tutor.ts`, `katex.ts` - (`index.ts` composes them). Course type enum `SELF_PACED|LIVE_CLASS|COMPLIANCE|PUBLIC` - (`schema.ts:26`). Course row `schema.ts:634`. -- **User flow:** Courses → New course → add sections/lessons → add exercises → publish → - students enroll. `apps/dashboard/src/routes/(app)/courses/[id]`. -- **Edge cases:** course type drives compliance/public behavior; locale enum (`schema.ts:27`, - 10 languages) drives multilingual lessons. - -### 3.6 Lessons & Multilingual Editor -- **Purpose:** lesson content with notes/slides/video, comments, translations. -- **How it works:** `lesson` (`schema.ts:956`), `lessonComment` (`schema.ts:516`), - `lessonCompletion` (`schema.ts:1490`), `lessonLanguage` + `…History` (`schema.ts:1706`, - `1732`). Routes: `apps/api/src/routes/course/lesson.ts`, `.../lesson-language.ts`. - PRDs `prd/lesson-edit [DONE]`, `prd/lessons-list [DONE]` confirm intent. -- **User flow:** Open lesson → edit content → add translation → student marks complete. -- **Edge cases:** language history retained; completion tracked per student. - -### 3.7 Exercises, Question Types & Grading -- **Purpose:** quizzes/assessments with multiple question types and grading. -- **How it works:** `exercise` (`schema.ts:1175`), `exerciseSection` (`schema.ts:1217`), - `question`/`questionType`/`questionAnswer` (`schema.ts:1803–1913`), `submission`/ - `submissionstatus` (`schema.ts:571,617`), `exerciseTemplate` (`schema.ts:2401`). - Routes `exercise.ts`, `submission.ts`, `mark.ts`. Shared types in `packages/question-types`. - PRDs: `exercise-question-types`, `exercise-sections [DONE]`, `video-recording-question-type [DONE]`. -- **User flow:** Author exercise → add questions (incl. video‑recording type) → student - submits → tutor/admin marks → status recorded. -- **Edge cases:** section‑based exercises; video‑recording answers (media pipeline); - `prd/selective-exercise-assignment` is **todo** (assign specific exercises to specific - students) — verify it is *not* yet available. - -### 3.8 Enrollment, Course Invites & Secure Invites -- **Purpose:** add students to courses by email or shareable link, with audit trail. -- **How it works:** `apps/api/src/services/course/invite.ts`; `courseInvite` - (`schema.ts:1282`) + `courseInviteAudit` (`schema.ts:1342`); event‑type enum - `schema.ts:53`. Org invites: `organizationInvite` (`schema.ts:1384`, EMAIL/LINK enum - `schema.ts:71`) + `…Audit` (`schema.ts:1448`), routes `apps/api/src/routes/invite/invite.ts`. - PRD `secure-student-invites [DONE]`. -- **User flow:** Course → People → Invite (email or link) → invitee accepts via - `apps/dashboard/src/routes/invite/[hash]` or `/invite/link/[hash]` → enrolled. -- **Edge cases:** invite expiry/custom expiry (`AGENTS.md:231` references - `customExpiresAt`); link vs email; audit events recorded; invite‑only signup interplay (§3.2). - -### 3.9 Compliance Training -- **Purpose:** deadline tracking, renewals/retake intervals, grace periods, completion records. -- **How it works:** `apps/api/src/routes/course/compliance.ts` and internal compliance - router `apps/api/src/routes/internal` (`AGENTS.md:87–98`); `courseCompletionRecord` - (`schema.ts:768`), `courseCompletionNotificationEvent` (`schema.ts:824`), - `courseCertificateIssue` (`schema.ts:850`). Course type `COMPLIANCE` (`schema.ts:26`). - PRD `compliance-training-platform [DONE]`. -- **User flow:** Mark course as compliance → set deadlines/renewal → learners complete → - completion record + certificate; renewal/grace events tracked. -- **Edge cases:** grace periods, renewal intervals, waivers (per README 21), notification events. - -### 3.10 Certificates -- **Purpose:** issue branded completion certificates with custom IDs. -- **How it works:** `packages/certificates/src/render.ts` + `templates/` render the - certificate; `courseCertificateIssue` (`schema.ts:850`) records issuance. -- **User flow:** Learner completes course → certificate issued → downloadable/branded. -- **Edge cases:** custom certificate IDs (README 21); template selection. - -### 3.11 Cohorts & Goals -- **Purpose:** group courses into cohorts with goals, members, and progress tracking. -- **How it works:** `apps/api/src/routes/cohort/cohort.ts`; tables `cohort` - (`schema.ts:2646`), `programCourse` (`2677`), `programMember` (`2704`), `programNewsfeed` - +`Comment` (`2739`,`2767`), `programGoal`/`…Assignment` (`2829`,`2878`) with goal‑type/ - deadline/status enums (`2803–2828`), status enum `ACTIVE|INACTIVE|ARCHIVED` (`2644`). - Middlewares `cohort-member.ts`, `cohort-team-member.ts`. PRD `cohorts [DONE]`. -- **User flow:** Create cohort → add courses + members → set goals/deadlines → track - progress → cohort newsfeed. `apps/dashboard/src/routes/(app)/cohorts/[id]`. -- **Edge cases:** goal deadline kinds; assignment status; archived cohorts. - -### 3.12 Community Q&A & Newsfeed -- **Purpose:** per‑course discussion (questions/answers) and announcements. -- **How it works:** `apps/api/src/routes/community/community.ts` with route‑specific - `middlewares/question-author-or-team` (`ARCHITECHTURE.md:241–252`); tables - `communityQuestion`/`communityAnswer` (`schema.ts:1558`,`1522`), `courseNewsfeed`/ - `…Comment` (`1605`,`1632`). Comment‑author middlewares `newsfeed-comment-author-or-team.ts`, - `cohort-newsfeed-comment-author-or-team.ts`. -- **User flow:** Course → Community → ask/answer; team can moderate. Newsfeed posts + - comments at course and cohort level. -- **Edge cases:** author‑or‑team authorization (only author or course team can edit/delete). - -### 3.13 AI Course Assistant (authoring chat) -- **Purpose:** in‑course AI that generates outlines, lesson content, and edits. -- **How it works:** `apps/api/src/routes/agent/{agent,history,runs}.ts`; `packages/ai-assistant`; - run tracking in `aiAgentRun`/`…Step`/`…Event` (`schema.ts:3163`,`3225`,`3259`), - conversation/model context/documents (`3083`,`3106`,`3138`). **Disabled until a provider - key is set**; `GET /agent/status` flips to `enabled:true` when any of OPENAI/GOOGLE/ - ANTHROPIC key is present. `README.md:223–238`. Selected model persisted in `localStorage` - (`classroomio-ai-chat-model`), provider resolved server‑side; missing key → 503 - `AI_NOT_CONFIGURED` (`README.md:236`). -- **User flow:** Course → AI button (only shown when enabled) → chat to build/edit course. -- **Edge cases:** no key → AI button hidden / 503; per‑model provider resolution; optional - Tinybird observability (`TINYBIRD_TOKEN`). - -### 3.14 AI Lesson Tutor + Fair‑Use Caps -- **Purpose:** in‑lesson AI helper for learners, with usage caps. -- **How it works:** `apps/api/src/routes/course/ai-tutor.ts`, - `apps/api/src/routes/organization/ai-tutor.ts`; `aiTutorMessageCount` (`schema.ts:3013`) - + `aiTutorCapEvent` (`3050`). Settings UI `apps/dashboard/src/lib/features/ai-tutor-settings`. - PRD `ai-tutor-fair-use`. -- **User flow:** Learner in lesson → ask tutor → counts toward cap → cap event when exceeded. -- **Edge cases:** per‑org cap configuration; cap‑exceeded behavior. - -### 3.15 AI Credits / Token Billing -- **Purpose:** meter AI token usage and sell token packs. -- **How it works:** `aiTokenUsage` (`schema.ts:2919`), `aiCreditBalance` (`2950`), - `aiCreditPurchase` (`2974`); purchases recorded by Polar `order.paid` webhook → - `CreditPurchaseApiServer.recordPurchase`. `apps/dashboard/src/routes/api/polar/webhook/+server.ts` - → 33–82; token‑pack constants `@cio/utils/plans` (`TOKEN_PACK.TOKENS_PER_UNIT`, line 53). -- **User flow:** Buy tokens (`/api/polar/buy-tokens`) → Polar checkout → webhook credits org balance. -- **Edge cases:** **cloud only** (Polar); quantity/metadata parsing; UUID validation of - `triggeredBy` (lines 61–66). - -### 3.16 Course Import -- **Purpose:** create courses from a prompt, PDF, or existing course. -- **How it works:** `apps/api/src/routes/organization/course-import.ts`, - `apps/api/src/services/course-import`; `courseImportDraft` (`schema.ts:2603`) with source - enum `prompt|pdf|course` (`schema.ts:29`) and draft status `DRAFT|PUBLISHED|ARCHIVED` (`30`). -- **User flow:** Org → Import course → choose source → review draft → publish. -- **Edge cases:** draft lifecycle; PDF parsing pipeline (jobs/media). - -### 3.17 Media Manager (video/docs, transcripts, HLS) -- **Purpose:** upload and manage media; transcode video to HLS; generate transcripts. -- **How it works:** routes `media/media.ts`, `transcripts.ts`, `hls.ts`, `course/presign.ts` - (S3 presigned uploads). Tables `asset`/`assetUsage` (`schema.ts:1028`,`1086`), - `mediaJob`/`jobStep`/`mediaTranscript`/`deadLetterJob` (`schema.ts:3297`,`3357`,`3393`,`3447`), - `videoTranscripts` (`916`). Jobs processed by `apps/jobs`. Dashboard - `apps/dashboard/src/lib/features/media`. PRD `media-manager [DONE]`. Storage via MinIO/S3 - (`.env.example:77–89`). -- **User flow:** Media → upload → background transcode/transcribe → use asset in lesson. -- **Edge cases:** job retries / dead‑letter; HLS cross‑origin headers (`app.ts:48–54`); - Google Drive picker optional (`.env.example:99–101`). - -### 3.18 Tags -- **Purpose:** categorize courses via tag groups/tags. -- **How it works:** `apps/api/src/routes/organization/tags.ts`; `tagGroup`/`tag`/ - `tagAssignment` (`schema.ts:2158`,`2188`,`2225`). PRD `tags-feature [DONE]`. -- **User flow:** Org → Tags → create groups/tags → assign to courses → filter catalog. - -### 3.19 Org Public Site (org‑site) & Theming -- **Purpose:** branded public catalog and course landing pages for an org. -- **How it works:** API `apps/api/src/routes/org-site` (mounted `/org-site/course`, - `app.ts:217`); dashboard public routes `apps/dashboard/src/routes/(org-site)/{course,courses}`. - Theme picker PRD `org-landing-page-theme-picker [DONE]`. -- **User flow:** Visitor → org public site → browse courses → view course → enroll/sign up. -- **Edge cases:** self‑host vs cloud routing differences (see §5); org branding/theme. - -### 3.20 Custom Domains -- **Purpose:** map a customer's own domain to their org site. -- **How it works:** `apps/api/src/routes/domain/domain.ts` (mounted `/domain`, `app.ts:203`); - routing app `apps/tenant-router`. PRD `custom-domain-cloudflare [DONE]`. -- **User flow:** Org settings → add custom domain → Cloudflare mapping → site served on domain. - -### 3.21 Course Widget Embed -- **Purpose:** embed an org's course catalog on any external website. -- **How it works:** API `apps/api/src/routes/widgets` (mounted `/widgets`, `app.ts:221`); - dashboard `apps/dashboard/src/routes/(app)/widgets/[widgetId]` + `widget-preview`; build - artifacts `apps/embeds` (`embeds:publish`, `README.md:256–257`). Tables `widget`/ - `widgetCourse`/`widgetVersion` (`schema.ts:2255`,`2305`,`2336`) with status/layout/ - selection enums (`42–52`). PRD `course-widget-embed [DONE]`. -- **User flow:** Widgets → create widget → choose courses/layout → publish → copy embed - snippet (`embed-preview.html`). -- **Edge cases:** draft vs published widget; manual vs published course selection mode. - -### 3.22 Public REST API (v1) & Automation Keys -- **Purpose:** programmatic access for integrations/automation. -- **How it works:** `apps/api/src/routes/v1/{courses,audience}.ts` mounted at - `/public-api/v1` (`app.ts:218`, `v1/index.ts`). Auth via `automationKeyMiddleware` + - `automationKeyScopesMiddleware(['public_api:*'])` on every route; OpenAPI documented via - `hono-openapi`. Courses endpoints: list/create/get/update/delete, list students, export, - get/put structure (`v1/courses.ts`). Keys: `organizationApiKey` (`schema.ts:2539`, type - `mcp|api|zapier`), usage tracked in `organizationAutomationUsage` (`2574`); managed via - `apps/api/src/routes/organization/automation.ts`. PRD `public-api [DONE]`. -- **User flow:** Org → API keys → create key with scopes → call `/public-api/v1/...` with key. -- **Edge cases:** scope enforcement (403 on missing scope); usage categories `read|write|publish` - (`schema.ts:32`); per‑org isolation via `orgId` from key context. - -### 3.23 MCP Server -- **Purpose:** AI‑native course authoring through the Model Context Protocol. -- **How it works:** `packages/mcp/src/{index.ts,api-client.ts,config.ts,tools/}`; published - as `@classroomio/mcp` (`README.md:258`). Talks to the public API using an automation key. - PRD `mcp-course-authoring [DONE]`. -- **User flow:** Configure MCP client with API key → AI tools create/update courses. - -### 3.24 Enterprise SSO & Token Auth (licensed) -- **Purpose:** OIDC SSO and token‑based auth for enterprise self‑host. -- **How it works:** `apps/api/src/routes/organization/sso.ts`, - `apps/api/src/routes/sso/discovery.ts` (mounted `/organization/sso`, `/sso`, `app.ts:211,213`); - `organizationSsoConfig` (`schema.ts:2479`), provider enum `OKTA|GOOGLE_WORKSPACE|AUTH0` - (`schema.ts:2444`), `organizationAuthPolicy` (`2447`). Token auth: - `apps/api/src/routes/organization/token-auth.ts`, `organizationTokenAuth` (`2513`). - **License‑gated on self‑host** via `requireLicense('sso'|'token-auth')` - (`apps/api/src/middlewares/license.ts`). PRD `enterprise-sso [DONE]`. -- **User flow:** Org → SSO settings → register IdP (discovery fetches - `.well-known/openid-configuration`) → users sign in via IdP. -- **Edge cases:** trusted OIDC origins allow‑listed (`packages/db/src/constants.ts:14–24`); - cloud has no license restriction (see §5). - -### 3.25 Licensing -- **Purpose:** gate Enterprise self‑host features behind a license key. -- **How it works:** `apps/api/src/routes/license.ts` (mounted `/license`, `app.ts:209`), - `apps/api/src/services/license.ts` (`isFeatureLicensed`), feature ids `sso`, - `token-auth`, `no-tracking` (`packages/utils/src/license/constants.ts:8–10`), - plan→feature mapping `packages/utils/src/license/plan-features.ts`. Dashboard side - `apps/dashboard/src/lib/features/license/api/license.svelte.ts` (`licenseApi.hasAccess`). -- **User flow:** Set `LICENSE_KEY` (`.env.example:117–118`) → licensed features unlock. -- **Edge cases:** `requireLicense` is a **no‑op on cloud** (`license.ts:15–19`). - -### 3.26 Billing (Polar) — cloud -- **Purpose:** paid subscriptions and AI token packs. -- **How it works:** `apps/dashboard/src/routes/api/polar/{subscribe,buy-tokens,portal,webhook}`. - Webhook handles `order.paid` (token packs → credit balance) and `subscription.*` - (create/update/cancel org plan). `webhook/+server.ts` → 30–168. Plans enum - `EARLY_ADOPTER|ENTERPRISE|BASIC` (`schema.ts:28`), org plan stored in `organizationPlan` - (`schema.ts:1667`). -- **User flow:** Upgrade → Polar checkout → webhook activates/updates plan; manage via portal. -- **Edge cases:** **cloud only**; subscription cancel → `cancelOrgPlan`; webhook secret - `POLAR_WEBHOOK_SECRET` required. - -### 3.27 Analytics -- **Purpose:** usage analytics for orgs/courses. -- **How it works:** `apps/api/src/routes/dash` + `apps/api/src/services/analytics`; tables - `analyticsLoginEvents`, `analyticsPageEvent`, `analyticsOrgDaily`, `analyticsCourseDaily`, - `analyticsCountryDaily` (`schema.ts:167–285`). Dashboard `apps/dashboard/src/lib/features/analytics`. - Client tracking (PostHog/Umami) gated by deployment mode / license (§5). -- **User flow:** Org → Analytics dashboard → view login/page/course metrics. - -### 3.28 Email / Notifications & Background Jobs -- **Purpose:** transactional email and async processing. -- **How it works:** `apps/api/src/routes/mail`, templates in `packages/email`, - enqueued via `enqueueTransactionalEmail` (`apps/api/src/services/jobs`, - `onboarding.ts:128`). Workers in `apps/jobs`; BullMQ dev dashboard at `/admin/queues` - (`apps/api/src/routes/admin/queues.ts`, `app.ts:232–234`). Email providers: Zoho/ZeptoMail - or SMTP (`.env.example:62–74`). -- **User flow:** trigger (signup, invite, completion) → email queued → worker sends. -- **Edge cases:** idempotency keys (`onboarding.ts:133`); dead‑letter jobs (`schema.ts:3447`). -- **Note:** an in‑app **notification system** is `prd/notification-system` **todo** — not shipped. - ---- - -## 4. Roles & Responsibilities - -**Org roles** are integers: `ADMIN=1`, `TUTOR=2`, `STUDENT=3` -(`packages/utils/src/constants/roles.ts:1–4`). They are stored on -`organizationmember.roleId` (`schema.ts:1767`, FK to `role` table `schema.ts:1913`) and -surfaced into the session as an `orgRoles` map keyed by org id (`apps/api/src/app.ts:88`). -RBAC middlewares read `orgRoles[orgId]` (org id from the `cio-org-id` header) — **no -per‑request DB lookup** (`apps/api/src/middlewares/org-team-member.ts:5–53`). - -| Role | Can do | Cannot do | Enforced by | -|------|--------|-----------|-------------| -| **ADMIN** (1) | Full org control: settings, members, billing, courses, SSO, API keys, all team actions | — | `org-admin.ts`, `org-team-member.ts` (admin∈team) | -| **TUTOR** (2) | Team actions: author/manage courses, grade, moderate community/newsfeed | Org admin‑only ops (org settings, billing, member roles) | `org-team-member.ts:44` (ADMIN or TUTOR), `course-team-member.ts` | -| **STUDENT** (3) | Enroll, learn, submit exercises, post community Q&A, use tutor | Authoring/grading/team/admin actions | absence of team/admin role → 403 | -| **Guest / anonymous** | Public org sites, public courses, embeds, public API (with key) | Any authed action | session = null path (`app.ts:77–84`); public CORS paths (`app.ts:60–67`) | - -**Resource‑scoped middlewares** (`apps/api/src/middlewares/`): -- `authMiddleware` — requires a session (`auth.ts`). -- `orgMemberMiddleware` — must be org member; `orgTeamMemberMiddleware` — ADMIN|TUTOR; - `orgAdminMiddleware` — ADMIN only (`ARCHITECHTURE.md:233–237`). -- `courseMemberMiddleware` / `courseTeamMemberMiddleware` — course‑level membership/team. -- `cohortMemberMiddleware` / `cohortTeamMemberMiddleware` — cohort-level. -- `apiKeyMiddleware` / `automationKeyMiddleware` (+ `automationKeyScopesMiddleware`) — - machine auth for webhooks/public API; `authOrApiKeyMiddleware`, - `authOrAutomationKeyMiddleware`, and `*-or-automation-key`/`*-or-api-key` variants allow - either a session or a key. -- `requireLicense(feature)` — self‑host license gate (`license.ts`). -- `workspaceNotReadonlyMiddleware` — blocks writes on read‑only workspaces - (`workspace-not-readonly.ts`). -- `signupGuard` — signup rules (§3.2). `rateLimiter` — global rate limit (`app.ts:98`). -- Route‑specific author/team guards: `question-author-or-team`, - `newsfeed-comment-author-or-team`, `cohort-newsfeed-comment-author-or-team`. - -**Public API scopes:** automation keys carry scopes like `public_api:*`, enforced per route -(`apps/api/src/routes/v1/courses.ts:47`); usage categorized `read|write|publish` -(`schema.ts:32`). - ---- - -## 5. Deployment Modes — Self‑Hosted vs Cloud - -The single switch is **`PUBLIC_IS_SELFHOSTED`** (`=true` ⇒ self‑hosted). Source of truth: -`.env.example:26` (Docker stack), `apps/dashboard/.env.example`, and the local‑dev value -`apps/dashboard/.env` (`PUBLIC_IS_SELFHOSTED=true`, `README.md:155`). The flag is read in -both the API/db layer (`process.env.PUBLIC_IS_SELFHOSTED`) and the dashboard -(`$env/static/public`). Every behavioral difference below is traced to a flag check. - -### 5.1 Self‑Hosted mode (`PUBLIC_IS_SELFHOSTED=true`) -- **Setup:** Docker Compose full stack — `cp .env.example .env` then - `./run-docker-full-stack.sh` (auto‑generates `PRIVATE_SERVER_KEY`, `BETTER_AUTH_SECRET`); - `README.md:240–249`, `docker-compose.yaml`, `docker/Dockerfile.{api,dashboard}`, - `docker/docs/SELF_HOST.md`. Bundled Postgres, Redis, MinIO. -- **Single organization:** org creation **blocks a second org** - (`apps/api/src/services/onboarding.ts:28–33`); the new org is auto‑assigned an - `ENTERPRISE` plan with provider `selfhosted` (`onboarding.ts:64–77`). The "add org" UI is - **hidden** (`apps/dashboard/src/routes/(app)/org/[slug]/+layout.svelte:29–31`) and the - org‑site→LMS redirect is **skipped** (`.../+layout.server.ts:16`). PRD `self-host-single-org.md`. -- **Signup:** requires org context unless bootstrapping the first org or the email already - belongs to the org (`signup-guard.ts:75–91`). -- **Licensing:** Enterprise features (`sso`, `token-auth`, `no-tracking`) are **gated by - `LICENSE_KEY`** — `requireLicense` actively checks (`middlewares/license.ts:15–22`); - `.env.example:117–118`. -- **Tracking:** cloud analytics (PostHog/Umami) are **disabled** - (`apps/dashboard/src/lib/utils/functions/appSetup.ts:23–27`); a `no-tracking` license also - suppresses tracking (`appSetup.ts:29–37`). -- **URLs/links:** `BASE_URL` and invite/email links use `DASHBOARD_ORIGIN` when self‑hosted - (`packages/db/src/constants.ts:36–37`; `.env.example:32–35`). Dashboard SSR calls the API - internally via `PRIVATE_SERVER_URL` (`.env.example:28–30`). -- **Storage/email:** MinIO + Zoho/SMTP from `.env` (`.env.example:62–89`). - -### 5.2 Cloud mode (`PUBLIC_IS_SELFHOSTED` ≠ `true`) -- **Multi‑org:** users can create/switch multiple orgs (no single‑org block); "add org" UI - shown (`+layout.svelte:29`). -- **Billing:** Polar subscriptions + AI token packs are active - (`apps/dashboard/src/routes/api/polar/*`); plan set via subscription webhooks - (`webhook/+server.ts`). License gate is a **no‑op** — cloud has no license restrictions - (`middlewares/license.ts:15–19`). -- **Tracking:** PostHog/Umami/UserJot analytics enabled (`appSetup.ts:17–27`). -- **Signup:** org context optional unless org settings restrict it (`signup-guard.ts:132–139`). -- **Infra:** hosted Postgres/Redis/S3; dashboard deployed on Cloudflare Workers - (`apps/dashboard/wrangler.jsonc`, `svelte.config.js`). - -### 5.3 Mode‑exclusive features (summary) -| Concern | Self‑Hosted | Cloud | -|---|---|---| -| Number of orgs | One only (`onboarding.ts:28`) | Many | -| Enterprise SSO / token‑auth | Requires `LICENSE_KEY` (`license.ts`) | Always allowed (no license) | -| Billing (Polar) | Not used (plan = selfhosted ENTERPRISE) | Subscriptions + token packs | -| Analytics tracking | Off (unless… still off) (`appSetup.ts:24`) | On | -| API base / links | `DASHBOARD_ORIGIN` (`constants.ts:37`) | `PUBLIC_SERVER_URL` | -| Add‑org modal | Hidden (`+layout.svelte:29`) | Shown | - -> Both modes exist in source and share one codebase; **neither mode is -> `⚠️ NOT FOUND`.** The product is one app with flag‑driven divergence. - ---- - -## 6. End‑to‑End Flow Diagrams (text) - -**A. Signup → Onboarding → Workspace** -``` -Login/Signup (apps/dashboard/.../(auth)/signup) - → POST /api/auth/sign-up/* [signupGuard] (app.ts:106; signup-guard.ts) - → Better Auth creates user + profile (auth.ts; create-profile.ts) - → (auth)/onboarding: createOrganizationWithOwner (onboarding.ts:19) - · self-host: block 2nd org, assign ENTERPRISE plan - → completeOnboarding → enqueue 'welcome' email (onboarding.ts:121) - → Dashboard /lms or /org/[slug] -``` - -**B. Author a course** -``` -Courses → New course (course.ts) - → add sections (section.ts) → lessons (lesson.ts) + translations (lesson-language.ts) - → add exercises/questions (exercise.ts, question-types) - → [optional] AI Assistant builds content (agent/*; requires provider key) - → publish → appears on org site / widget -``` - -**C. Student learns → certificate (compliance)** -``` -Invite (course/invite.ts) → accept /invite/[hash] - → enroll (people.ts) → study lessons → mark complete (lessonCompletion) - → submit exercises (submission.ts) → tutor grades (mark.ts) - → completion recorded (courseCompletionRecord schema.ts:768) - → certificate issued (packages/certificates; courseCertificateIssue schema.ts:850) - → compliance: deadlines/renewals tracked (course/compliance.ts; internal/*) -``` - -**D. Public API automation (integration)** -``` -Org → create automation key with scopes (organization/automation.ts; organizationApiKey) - → external client: GET/POST /public-api/v1/courses - [automationKeyMiddleware + automationKeyScopesMiddleware('public_api:*')] - → service resolves orgId from key → returns course data (v1/courses.ts; services/v1) -``` - -**E. Cloud upgrade / buy AI tokens (cloud only)** -``` -Upgrade or Buy tokens (api/polar/subscribe | buy-tokens) - → Polar checkout - → Polar webhook (api/polar/webhook/+server.ts) - · subscription.created → createOrgPlan - · order.paid (token_pack) → recordPurchase → aiCreditBalance -``` - ---- - -## 7. Open Questions & Gaps - -1. **Outbound webhooks — doc vs code mismatch.** `README.md:42–43` advertises - "REST API + Webhooks … receive events (`certificate.issued`, `enrollment.completed`)", - but the only webhook code is **inbound Polar billing** - (`apps/dashboard/src/routes/api/polar/webhook`) and `prd/webhooks` is **todo** - (`PRD-TRACKER.md:24`). No outbound event‑emission/delivery system was found in - `apps/api/src` or `packages/db/src`. **Code wins: outbound webhooks appear unimplemented.** - QA should not test event delivery as a shipped feature. ⚠️ -2. **SCORM support** — README does not promise it, but `prd/scorm-support` is **todo** - (`PRD-TRACKER.md:16`). No SCORM code located. ⚠️ Treat as not shipped. -3. **In‑app notification system** — `prd/notification-system` is **todo** - (`PRD-TRACKER.md:9`). Transactional *email* exists (§3.28); in‑app notifications appear - unbuilt. Verify before demoing. ⚠️ -4. **Selective exercise assignment** — `prd/selective-exercise-assignment` is **todo** - (`PRD-TRACKER.md:15`). Assigning specific exercises to specific students may not exist; - confirm against `exercise.ts` behavior. ⚠️ -5. **Course templates / cohorts / import variants** — `prd/course-templates`, - `prd/course-cohorts`, `prd/course-import` are **todo** (`PRD-TRACKER.md:10,12,13`), - yet related schema exists (`exerciseTemplate` `schema.ts:2401`; `courseImportDraft` - `schema.ts:2603`; cohorts are DONE). Scope of what's actually wired end‑to‑end vs. - schema‑only should be verified per feature before writing test cases. ⚠️ -6. **Anthropic in AI picker** — code supports Anthropic, but it is "not currently in the - picker UI" (`README.md:231`, `.env.example:112`). The picker exposes Gemini + GPT‑4o; - note the `.env.example:111` "Gemini 3 Flash" vs `README.md:228` "Gemini 2.5 Flash" - wording inconsistency — verify the actual model id in `packages/utils/src/agent-models`. -7. **Two operating sub‑modes within self‑host** (single‑org bootstrap vs. licensed - Enterprise) interact with `signupGuard` and `requireLicense`. Exact UX when - `LICENSE_KEY` is absent on self‑host (SSO/token‑auth hidden vs. 403) should be confirmed - in the dashboard `license` feature before recording a self‑host SSO demo. -8. **Tenant‑router / domain routing** (`apps/tenant-router`, `/domain`) was inventoried but - not deeply traced (infra scope). Custom‑domain happy‑path is PRD‑DONE; failure/SSL - provisioning states are unverified here. - ---- - -## Appendix — Supporting Infrastructure (light) - -These exist in source but are infra/dev tooling rather than demoable product features: - -- `apps/jobs` — BullMQ worker process; consumes media/email queues - (paired with `apps/api/src/routes/jobs`, `mediaJob`/`jobStep` tables). Dev queue UI at - `/admin/queues` (`apps/api/src/routes/admin/queues.ts`). -- `apps/tenant-router` — multi‑tenant/custom‑domain request routing (backs §3.20). -- `apps/course-app` / `packages/course-app` — course‑app runtime modules - (`pnpm-workspace.yaml:4`). -- `apps/docs` — documentation site (`pnpm docs:dev`). -- `apps/website` — marketing site (`pnpm website:dev`). -- `packages/core` — shared config incl. env access (`packages/core/src/config/env.ts`, - used by `signup-guard.ts`, `license.ts`, `onboarding.ts`). -- `packages/storybook` — UI component stories (`pnpm storybook:dev`). -- `packages/analytics` — analytics helpers (paired with `apps/api/src/services/analytics`). -- `packages/tsconfig` — shared TS config. -- `packages/ui` — shared Svelte component library (`@cio/ui`); theme classes require `ui:` - prefix in dashboard (`AGENTS.md:313`). -- `packages/utils` — shared Zod validation, plans, license, agent‑models, constants. -- `packages/db` — Drizzle schema/queries/migrations/seed; `pnpm --filter @cio/db db:setup:seed` - seeds demo data and the demo login `admin@test.com` / `123456` (`README.md:215–219`). - ---- - -### How this audit was verified -Every section traces to files read during the audit: `README.md`, `ARCHITECHTURE.md`, -`AGENTS.md`, `.env.example`, `PRD-TRACKER.md`, `apps/api/src/app.ts`, -`packages/db/src/schema.ts`, the role/RBAC middlewares, `signup-guard.ts`, -`onboarding.ts`, `appSetup.ts`, the Polar webhook, the v1 public‑API router, and the -license constants/middleware. No application was run and no tests were executed — -functional testing is deferred to the QA phase per the audit brief. diff --git a/README.md b/README.md index 90f79fb0..1f44e7ce 100644 --- a/README.md +++ b/README.md @@ -255,12 +255,12 @@ Notes: ```bash cp .env.example .env # copy env template, edit for your domain -./run-docker-full-stack.sh +./classroomio.sh start # pulls pre-built images; use --build to build from source ``` -The script reads root `.env` via `docker compose --env-file .env` and auto-generates a secure `PRIVATE_SERVER_KEY` (and `BETTER_AUTH_SECRET`) when missing. +The script reads root `.env` via `docker compose --env-file .env` and auto-generates a secure `PRIVATE_SERVER_KEY` (and `BETTER_AUTH_SECRET`) when missing. Run `./classroomio.sh` with no arguments for the full lifecycle menu (install, start, stop, restart, upgrade, logs, backup). -See [`.env.example`](.env.example) for the full list of environment variables with required/optional grouping, and [`docker/docs/SELF_HOST.md`](docker/docs/SELF_HOST.md) for the complete Docker self-hosting guide. To raise upload caps (documents, videos, assignment files), set the `UPLOAD_MAX_*_MB` vars in `.env` — see the [docs](https://classroomio.com/docs/self-hosted/docker#optional--upload-file-size-limits). +See [`.env.example`](.env.example) for the full list of environment variables with required/optional grouping, and [`docker/docs/SELF_HOST.md`](docker/docs/SELF_HOST.md) for the complete Docker self-hosting guide. To raise upload caps (documents, videos, assignment files), set the `UPLOAD_MAX_*_MB` vars in `.env` — see the [docs](https://classroomio.com/docs/self-hosted/configuration/storage#upload-file-size-limits). ## Publishing diff --git a/apps/docs/blume.config.ts b/apps/docs/blume.config.ts index 070203ef..bcb0b5e2 100644 --- a/apps/docs/blume.config.ts +++ b/apps/docs/blume.config.ts @@ -168,8 +168,14 @@ export default defineConfig({ root: '/self-hosted', items: [ '/self-hosted', - { - label: 'Platforms', + '/self-hosted/101', + '/self-hosted/architecture', + { + label: 'Install', + display: 'group', + // Pinned open — it's what most visitors came for. The other groups + // start collapsed (and auto-expand when they contain the current page). + collapsed: false, items: [ '/self-hosted/docker', '/self-hosted/digitalocean', @@ -179,7 +185,9 @@ export default defineConfig({ ] }, { - label: 'Configuration', + label: 'Configure', + display: 'group', + collapsed: true, items: [ '/self-hosted/configuration', '/self-hosted/configuration/secrets', @@ -187,10 +195,21 @@ export default defineConfig({ '/self-hosted/configuration/storage', '/self-hosted/configuration/csp', '/self-hosted/configuration/integrations', - '/self-hosted/configuration/sso', - '/self-hosted/configuration/versions' + '/self-hosted/enterprise' ] }, + { + label: 'Manage', + display: 'group', + collapsed: true, + items: ['/self-hosted/configuration/versions', '/self-hosted/backups'] + }, + { + label: 'Troubleshoot', + display: 'group', + collapsed: true, + items: ['/self-hosted/troubleshooting'] + } ] } ] diff --git a/apps/docs/content/docs/enterprise-sso.mdx b/apps/docs/content/docs/enterprise-sso.mdx index 16d2d722..b518dac0 100644 --- a/apps/docs/content/docs/enterprise-sso.mdx +++ b/apps/docs/content/docs/enterprise-sso.mdx @@ -19,7 +19,7 @@ Set `LICENSE_KEY` in the API service environment. Local development: `apps/api/.env`. Docker Compose: repository root `.env`. If you do not use Enterprise-only features, leave `LICENSE_KEY` unset. -See [Self Hosting → SSO & licensing](/self-hosted/configuration/sso) for per-platform placement. +See [Self Hosting → Enterprise & licensing](/self-hosted/enterprise) for per-platform placement. ::: ## Supported Providers diff --git a/apps/docs/content/docs/self-hosted/101.mdx b/apps/docs/content/docs/self-hosted/101.mdx new file mode 100644 index 00000000..14432c61 --- /dev/null +++ b/apps/docs/content/docs/self-hosted/101.mdx @@ -0,0 +1,74 @@ +--- +title: Self-hosting 101 +description: What you get when you self-host ClassroomIO, how the stack fits together, and how self-hosted differs from the cloud product. +--- + +ClassroomIO is an open-source learning platform you can run entirely on your own infrastructure. +This page explains what you're actually deploying before you pick an +[install path](/self-hosted#which-should-i-choose). + +## What you get + +The full product: the same code that powers the cloud version. Self-hosted, you run: + +- The **dashboard** your team and students use (courses, exercises, communities, certificates). +- The **API** behind it. +- A background **jobs worker** for the heavy lifting: video processing, transcription, AI course + generation, and email sending. +- The infrastructure they need: **Postgres** (database), **Redis** (queues and caching), and + **MinIO** (S3-compatible object storage for uploads, swappable for AWS S3 or Cloudflare R2). + +That's the ~7 containers the Docker stack starts (MinIO ships with a small one-shot init container +that creates its buckets). They're deliberately separate services: the parts that spike CPU and RAM +(video transcodes) are isolated from the parts that serve your users. How they talk to each other +is on the [Architecture](/self-hosted/architecture) page; what hardware they need is in the +[server requirements](/self-hosted#server-requirements). + +## Self-hosted vs cloud + +| | Cloud (app.classroomio.com) | Self-hosted | +|---|---|---| +| Infrastructure | Managed for you | Yours — your server, your domain | +| Your data | Hosted by ClassroomIO | Stays on your infrastructure | +| Updates | Automatic | You choose when ([pin a version](/self-hosted/configuration/versions)) | +| Organizations | Multiple per account, plan-based feature tiers | Single organization with all core features | +| Enterprise features (SSO, token auth, no-tracking) | On the Enterprise plan | Via a [license key](/self-hosted/enterprise) | + +Self-hosted mode is controlled by one flag, `PUBLIC_IS_SELFHOSTED=true`, which is already set in +the shipped Docker configuration. It locks the instance to a single organization with the full core +feature set, and points invite links and emails at your own domain. + +**About telemetry:** self-hosted mode disables error reporting (Sentry) and the in-app feedback +widget out of the box. Product analytics (PostHog, Umami) are only disabled by the `no-tracking` +enterprise license feature. See [Enterprise & licensing](/self-hosted/enterprise). + +## How licensing works + +ClassroomIO is **open core** under the [AGPL-3.0 license](https://github.com/classroomio/classroomio/blob/main/LICENSE): + +- **Everything an LMS needs is free:** courses, students, uploads, video, AI features, + communities, certificates. No seat limits, no artificial caps. You never need a license key to + run a complete platform. +- **A small set of enterprise features** (SSO, token auth, and no-tracking mode) are gated behind + a paid `LICENSE_KEY`. They exist so organizations with compliance requirements can fund the + project. Details on the [Enterprise & licensing](/self-hosted/enterprise) page. + +There is exactly one codebase and one set of Docker images. A license key unlocks features in the +same build you already run, so you never migrate editions. + +## Where to next + + + + Docker, DigitalOcean, Railway, Coolify, or Dokploy. + + + The services, how they connect, and why the jobs worker needs RAM. + + + Every environment variable the stack reads. + + + SSO, token auth, no-tracking, and how license validation works. + + diff --git a/apps/docs/content/docs/self-hosted/architecture.mdx b/apps/docs/content/docs/self-hosted/architecture.mdx new file mode 100644 index 00000000..ffd9d21d --- /dev/null +++ b/apps/docs/content/docs/self-hosted/architecture.mdx @@ -0,0 +1,84 @@ +--- +title: Architecture +description: The services in a self-hosted ClassroomIO stack, how they authenticate to each other, and why the jobs worker drives your RAM requirement. +--- + +Every install path (Docker, DigitalOcean, Railway, Coolify, Dokploy) runs the same topology, so +this page applies no matter which one you picked. + +## The services + +```mermaid +flowchart LR + B[Browser] -->|HTTPS| D[dashboard :3082] + D -->|Bearer PRIVATE_SERVER_KEY| A[api :3081] + A --> P[(postgres)] + A --> R[(redis)] + A --> M[(minio / S3)] + J[jobs worker] --> R + J --> P + J --> M +``` + +| Service | Image | Port | Role | +|---------|-------|------|------| +| `dashboard` | `classroomio/dashboard` | 3082 | The SvelteKit web app. Server-side renders pages and proxies browser API calls, so user traffic is same-origin. | +| `api` | `classroomio/api` | 3081 | The backend. Runs **database migrations on boot**, then serves the API. | +| `jobs` | `classroomio/jobs` | — | Background worker (BullMQ). Drains the Redis queues: video processing, transcription, AI course generation, emails, notifications, maintenance. Ships with ffmpeg. | +| `postgres` | `postgres:16-alpine` | internal | The database. All state lives here (volume `postgres-data`). | +| `redis` | `redis:7-alpine` | internal | Job queues and caching, persisted with AOF (volume `redis-data`). | +| `minio` | `minio/minio` | 9000/9001 | S3-compatible object storage for uploads (volume `minio-data`). Swappable for AWS S3 / Cloudflare R2. | +| `minio-init` | `minio/mc` | — | One-shot: creates the `videos`, `documents`, and `media` buckets, makes `media` publicly downloadable, then exits. | + +The app services are pinned by one variable, `CIO_VERSION`. See +[Versions & upgrades](/self-hosted/configuration/versions). + +## Startup order + +The compose file encodes the dependency chain with healthchecks: + +1. **postgres** and **redis** start first. Postgres only reports healthy once it answers a real + query (`SELECT 1`), not just `pg_isready`, because Postgres can accept connections while still + in crash recovery, which would surface as + [57P03 sign-in errors](/self-hosted/troubleshooting#sign-in-fails-with-57p03-right-after-startup). +2. **api** starts, runs any pending database migrations, then listens. A failed migration exits the + container instead of serving a half-migrated database. +3. **dashboard** starts only after the API is healthy. + +This is why a first boot takes a minute or two: the API is migrating before anything responds. + +## The api ↔ dashboard handshake + +The dashboard's server side calls the API over the internal container network +(`PRIVATE_SERVER_URL`, default `http://api:3081`) and authenticates every request with: + +``` +Authorization: Bearer +``` + +The API rejects requests where the key is missing or wrong (401), and refuses to operate without +one configured (500). Two consequences for operators: + +- **The value must be identical in the api and dashboard services.** The compose file passes the + same `.env` value to both, and `./classroomio.sh` generates a strong one automatically. On PaaS + platforms where you set env per service, a mismatch is the classic cause of a dashboard that + renders but can't load any data. +- The compose file **fails fast** if `PRIVATE_SERVER_KEY` (or `BETTER_AUTH_SECRET`) is empty. + See [Environment & secrets](/self-hosted/configuration/secrets). + +## The jobs worker and your RAM + +The jobs worker is why the [server requirements](/self-hosted#server-requirements) recommend 8 GB. + +It processes queues for media (probe → thumbnail → audio extraction), transcription (Whisper), +emails, notifications, AI course generation, and periodic maintenance (including reaping stuck +jobs). Video work runs **ffmpeg as a subprocess**, streaming files via disk rather than holding +whole videos in memory, but transcodes still spike RAM well above the stack's idle usage. A server +that looks comfortable at idle can OOM-kill Postgres the first time someone uploads a lecture +video; see [Troubleshooting → OOM kills](/self-hosted/troubleshooting#containers-are-oom-killed). + +The worker is required, not optional: without it (or without Redis), uploads queue forever at +"processing" and most emails never send +([details](/self-hosted/troubleshooting#uploads-stuck-at-processing)). It also scales +independently: the image runs all workers in one process by default, but you can override the +container command to run, say, media processing on separate hardware from email sending. diff --git a/apps/docs/content/docs/self-hosted/backups.mdx b/apps/docs/content/docs/self-hosted/backups.mdx new file mode 100644 index 00000000..efef862c --- /dev/null +++ b/apps/docs/content/docs/self-hosted/backups.mdx @@ -0,0 +1,96 @@ +--- +title: Backups +description: Back up and restore a self-hosted ClassroomIO — Postgres dumps and object-storage archives, automated or manual. +--- + +Two things hold all of your data: the **Postgres database** (courses, users, organizations) and +**object storage** (uploaded videos, documents, media). Back up both. + +## With the script + +```zsh title="Terminal" +./classroomio.sh backup +``` + +This writes two timestamped files into `./backups/`: + +- `classroomio-db-.sql.gz`: a full `pg_dump` of the database. +- `classroomio-minio-.tar.gz`: an archive of the bundled MinIO volume. + +If you use external S3/R2 instead of the bundled MinIO, the second step is skipped. Use your +provider's backup or replication tools for the buckets. + +`./classroomio.sh upgrade` runs this backup automatically before pulling new images, so every +upgrade has a restore point. + +:::warning[Copy backups off the server] +A dead disk takes `./backups/` with it. Sync the files to another machine, an object-storage +bucket, or your provider's snapshot system on a schedule, e.g. a nightly cron: + +```zsh title="crontab" +0 3 * * * cd /path/to/classroomio && ./classroomio.sh backup && rsync -a backups/ user@other-host:classroomio-backups/ +``` +::: + +## Manual commands + +The script runs the equivalent of: + +```zsh title="Terminal" +# Postgres (adjust user/db if you changed POSTGRES_USER / POSTGRES_DB) +docker compose --env-file .env -p classroomio -f docker-compose.images.yaml \ + exec -T postgres pg_dump -U postgres classroomio | gzip > backups/db.sql.gz + +# MinIO volume +docker run --rm -v classroomio_minio-data:/data:ro -v "$PWD/backups":/backup \ + alpine tar czf /backup/minio.tar.gz -C /data . +``` + +## Restoring + + + +### Stop the app services + +Keep Postgres running, stop everything that writes to it: + +```zsh title="Terminal" +docker compose --env-file .env -p classroomio -f docker-compose.images.yaml stop api dashboard jobs +``` + + +### Restore the database + +```zsh title="Terminal" +gunzip -c backups/classroomio-db-.sql.gz | \ + docker compose --env-file .env -p classroomio -f docker-compose.images.yaml \ + exec -T postgres psql -U postgres classroomio +``` + +Restoring into a non-empty database can conflict with existing rows. For a clean restore, drop and +recreate the database first (`dropdb`/`createdb` inside the postgres container), then run the +command above. + + +### Restore object storage + +```zsh title="Terminal" +docker run --rm -v classroomio_minio-data:/data -v "$PWD/backups":/backup \ + alpine sh -c "cd /data && tar xzf /backup/classroomio-minio-.tar.gz" +``` + + +### Start everything again + +```zsh title="Terminal" +./classroomio.sh start +``` + + + +:::danger[The fastest way to lose data is a cleanup command] +`docker compose down -v` and `docker volume prune` **delete** the `postgres-data`, `redis-data`, +and `minio-data` volumes. Never run them on a production host. See +[Troubleshooting → disk space](/self-hosted/troubleshooting#no-space-left-on-device) for safe +alternatives. +::: diff --git a/apps/docs/content/docs/self-hosted/configuration/csp.mdx b/apps/docs/content/docs/self-hosted/configuration/csp.mdx index df5ed25f..9addb840 100644 --- a/apps/docs/content/docs/self-hosted/configuration/csp.mdx +++ b/apps/docs/content/docs/self-hosted/configuration/csp.mdx @@ -4,8 +4,8 @@ description: Allow the external CDN, font, media, and frame domains your self-ho --- When `PUBLIC_IS_SELFHOSTED=true`, the dashboard ships a strict Content Security Policy that allows only -`'self'`. If you load assets from external domains — a CDN, custom fonts, embedded video, an external -media/storage host — you must add those domains, or the browser blocks them. +`'self'`. If you load assets from external domains (a CDN, custom fonts, embedded video, an external +media/storage host), you must add those domains, or the browser blocks them. ## Variables @@ -29,13 +29,13 @@ ALLOWED_EXTERNAL_DOMAINS=https://fonts.googleapis.com,https://storage.yourdomain CSP_MEDIA_SRC_DOMAINS=https://storage.yourdomain.com ``` -:::note[Runtime — no rebuild needed] -CSP domain variables are read at **container startup**. Change them and restart the container — you do +:::note[Runtime: no rebuild needed] +CSP domain variables are read at **container startup**. Change them and restart the container. You do not need to rebuild any image. ::: :::warning[You don't need to add the API] Normal dashboard traffic is proxied to the API **same-origin**, so the API host does not need to be in -the CSP. Add external hosts only — your CDN, video provider, font host, and object-storage/media domain +the CSP. Add external hosts only: your CDN, video provider, font host, and object-storage/media domain (the most common one, so browsers can load uploaded media). ::: diff --git a/apps/docs/content/docs/self-hosted/configuration/email.mdx b/apps/docs/content/docs/self-hosted/configuration/email.mdx index 7c85e5aa..e5641cce 100644 --- a/apps/docs/content/docs/self-hosted/configuration/email.mdx +++ b/apps/docs/content/docs/self-hosted/configuration/email.mdx @@ -3,7 +3,7 @@ title: Email (SMTP) description: Configure SMTP so ClassroomIO can send signup verification, password reset, and invite emails. --- -ClassroomIO sends transactional email — **signup verification, password resets, and invite emails** — +ClassroomIO sends transactional email (**signup verification, password resets, and invite emails**) over SMTP. Without it the app still runs, but it can't send any of those messages, so have mail credentials ready before you deploy. @@ -33,6 +33,6 @@ whole compose stack. The jobs worker won't send invites or notifications if SMTP ## Providers -Any standard SMTP provider works — including transactional services such as **Zoho / ZeptoMail**, +Any standard SMTP provider works, including transactional services such as **Zoho / ZeptoMail**, Postmark, Resend (SMTP), Amazon SES, or Mailgun. See the [Nodemailer SMTP docs](https://nodemailer.com/smtp/) for provider-specific setup and port/TLS options. diff --git a/apps/docs/content/docs/self-hosted/configuration/index.mdx b/apps/docs/content/docs/self-hosted/configuration/index.mdx index 3beb99b6..61f55dd1 100644 --- a/apps/docs/content/docs/self-hosted/configuration/index.mdx +++ b/apps/docs/content/docs/self-hosted/configuration/index.mdx @@ -3,20 +3,21 @@ title: Configuration description: Shared configuration reference for self-hosting ClassroomIO — secrets, email, storage, CSP, integrations, licensing, and versions. --- -However you deploy ClassroomIO — [Docker](/self-hosted/docker), [Railway](/self-hosted/railway), or -[Coolify](/self-hosted/coolify) — the stack reads the **same set of environment variables**. This -section is the single source of truth for what each group of variables does. The provider guides show -you *where* to set them for that platform; these pages explain *what* to set and *why*. +However you deploy ClassroomIO ([Docker](/self-hosted/docker), [DigitalOcean](/self-hosted/digitalocean), +[Railway](/self-hosted/railway), [Coolify](/self-hosted/coolify), or [Dokploy](/self-hosted/dokploy)), +the stack reads the **same set of environment variables**. This section is the single source of truth +for what each group of variables does. The provider guides show you *where* to set them for that +platform; these pages explain *what* to set and *why*. ## How to read these pages ClassroomIO is configured entirely through environment variables. On Docker they live in one root -`.env` file that every `docker compose` command reads via `--env-file .env`; on Railway and Coolify you -set the same variables on each service. +`.env` file that every `docker compose` command reads via `--env-file .env`; on PaaS platforms +(Railway, Coolify, Dokploy) you set the same variables on each service. :::note[Value = required, blank = optional] Throughout these pages and the provider guides, a variable shown **with a value** is one you should -set for a working deployment. A variable left **blank after `=`** is optional — fill it in only if you +set for a working deployment. A variable left **blank after `=`** is optional: fill it in only if you use that feature. Leaving an optional variable blank never causes a build or boot error. ::: @@ -24,7 +25,7 @@ use that feature. Leaving an optional variable blank never causes a build or boo - Generate `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` — the stack won't boot without them. + Generate `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY`: the stack won't boot without them. SMTP for signup verification, password resets, and invite emails. @@ -38,7 +39,7 @@ use that feature. Leaving an optional variable blank never causes a build or boo Google OAuth, Unsplash, OpenAI transcription and AI course generation. - + Set `LICENSE_KEY` to unlock Enterprise features such as SSO. diff --git a/apps/docs/content/docs/self-hosted/configuration/integrations.mdx b/apps/docs/content/docs/self-hosted/configuration/integrations.mdx index 805a3c00..1ab0bd48 100644 --- a/apps/docs/content/docs/self-hosted/configuration/integrations.mdx +++ b/apps/docs/content/docs/self-hosted/configuration/integrations.mdx @@ -3,7 +3,7 @@ title: Integrations description: Optional integrations for self-hosted ClassroomIO — Google OAuth, Unsplash, OpenAI, and branding. --- -These integrations are all **optional** — ClassroomIO runs without any of them. Add a variable only if +These integrations are all **optional**: ClassroomIO runs without any of them. Add a variable only if you use that feature; leaving it blank never causes a build or boot error. ## Google OAuth @@ -43,6 +43,6 @@ Enables the in-app Unsplash image search. | `PRIVATE_APP_SUBDOMAINS` | Subdomains served by the dashboard | :::note[Enterprise features] -`LICENSE_KEY` unlocks Enterprise-only integrations such as SSO — see -[SSO & licensing](/self-hosted/configuration/sso). +`LICENSE_KEY` unlocks Enterprise-only integrations such as SSO. See +[Enterprise & licensing](/self-hosted/enterprise). ::: diff --git a/apps/docs/content/docs/self-hosted/configuration/secrets.mdx b/apps/docs/content/docs/self-hosted/configuration/secrets.mdx index 29965e92..fb2d6dcd 100644 --- a/apps/docs/content/docs/self-hosted/configuration/secrets.mdx +++ b/apps/docs/content/docs/self-hosted/configuration/secrets.mdx @@ -16,23 +16,23 @@ openssl rand -hex 32 # PRIVATE_SERVER_KEY (must be identical in the api and | Variable | What it's for | Where it goes | |----------|---------------|---------------| | `BETTER_AUTH_SECRET` | Signs authentication sessions. | The **api** service. | -| `PRIVATE_SERVER_KEY` | Authenticates server-side dashboard → API requests. | **Both** the api and dashboard — the value must be **identical** in each. | +| `PRIVATE_SERVER_KEY` | Authenticates server-side dashboard → API requests. | **Both** the api and dashboard; the value must be **identical** in each. | :::danger[The stack won't start without them] -The compose file **fails fast** if `BETTER_AUTH_SECRET` or `PRIVATE_SERVER_KEY` is empty — the +The compose file **fails fast** if `BETTER_AUTH_SECRET` or `PRIVATE_SERVER_KEY` is empty: the containers refuse to boot rather than run with a predictable, guessable session-signing secret. Set both to a strong random value (e.g. `openssl rand -hex 32`). ::: :::note[Who generates them] -Only the source-build path's `run-docker-full-stack.sh` auto-generates these when missing. The -pre-built image path, Railway, and Coolify do **not** run that script — you generate the secrets -yourself and paste them in. +On Docker, `./classroomio.sh` auto-generates both secrets when they're missing (on install and +start, for the pull and build paths alike). Railway, Coolify, and Dokploy do **not** run that +script. There you generate the secrets yourself and paste them in. ::: ## Database credentials -Postgres runs inside the stack (or as a managed plugin on Railway). Set a strong password — the +Postgres runs inside the stack (or as a managed plugin on Railway). Set a strong password. The defaults are for local use only. | Variable | Description | Default | @@ -41,6 +41,26 @@ defaults are for local use only. | `POSTGRES_USER` | Database user | `postgres` | | `POSTGRES_PASSWORD` | Database password — set a **strong** value in production | `postgres` | +### Rotating `POSTGRES_PASSWORD` after install + +:::danger[Editing `.env` alone doesn't rotate it] +The official Postgres image only reads `POSTGRES_PASSWORD` when it initializes a **fresh, +empty** data directory. Once `./classroomio.sh install` has run once, the data volume +already exists — changing `POSTGRES_PASSWORD` in `.env` afterward and restarting does +**not** change the database's actual password. It only changes the password `api`/`jobs` +*attempt* to connect with, so a mismatch breaks every DB connection. + +To actually rotate it after install: + +1. Set the new password inside Postgres itself: + ```zsh title="Terminal" + docker compose --env-file .env -p classroomio -f docker-compose.images.yaml \ + exec postgres psql -U postgres -c "ALTER ROLE postgres WITH PASSWORD '';" + ``` +2. Update `POSTGRES_PASSWORD` in `.env` to the same value. +3. Run `./classroomio.sh restart` to recreate `api`/`jobs`/`postgres` with the matching value. +::: + ## Mode & routing basics | Variable | Description | Default | @@ -50,7 +70,7 @@ defaults are for local use only. | `DASHBOARD_ORIGIN` | Public dashboard origin, used as the auth base URL and in invite/email links | `http://localhost:3082` | :::warning[Use an `https://` origin in production] -`DASHBOARD_ORIGIN` (and `ORIGIN` on the dashboard) must be the real `https://` URL your users visit — +`DASHBOARD_ORIGIN` (and `ORIGIN` on the dashboard) must be the real `https://` URL your users visit: auth session cookies are `Secure`, so login silently fails over plain HTTP on any public domain. See [Self Hosting → HTTPS is required in production](/self-hosted#https-is-required-in-production). ::: diff --git a/apps/docs/content/docs/self-hosted/configuration/sso.mdx b/apps/docs/content/docs/self-hosted/configuration/sso.mdx deleted file mode 100644 index 935d4576..00000000 --- a/apps/docs/content/docs/self-hosted/configuration/sso.mdx +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: SSO & licensing -description: Set LICENSE_KEY to unlock Enterprise features such as SSO on your self-hosted ClassroomIO. ---- - -Enterprise-only features — **SSO**, **token auth**, and no-tracking mode — require an Enterprise -**license key**. If you don't use any Enterprise feature, you don't need a key; leave `LICENSE_KEY` -unset and everything else works. - -## Setting `LICENSE_KEY` - -`LICENSE_KEY` is read by the **api** service. Set it wherever that service takes its environment: - -| Deployment | Where to set it | -|------------|-----------------| -| Local development | `apps/api/.env` | -| Docker Compose | repository root `.env` | -| Railway | the `cio-api` service environment | -| Coolify | the resource's environment variables | - -```zsh title=".env" -LICENSE_KEY= -``` - -## Getting a license - -[Book a call](http://cal.com/classroomio/enterprise) to get an Enterprise license key. - -## Feature docs - -Once the key is set, configure the features themselves: - -- [Enterprise SSO](/enterprise-sso) — Single Sign-On with OIDC (Okta, Azure AD, and more). -- [Token Auth](/token-auth) — issue signed tokens to log users in from your own app. diff --git a/apps/docs/content/docs/self-hosted/configuration/storage.mdx b/apps/docs/content/docs/self-hosted/configuration/storage.mdx index 241ddd50..ab729614 100644 --- a/apps/docs/content/docs/self-hosted/configuration/storage.mdx +++ b/apps/docs/content/docs/self-hosted/configuration/storage.mdx @@ -7,7 +7,7 @@ ClassroomIO stores uploaded files and videos in **object storage**. You have two **bundled MinIO** alongside the stack, or point ClassroomIO at an **external S3-compatible** service (AWS S3, Cloudflare R2, etc.). Both speak the S3 API and use the same `OBJECT_STORAGE_*` variables. -## Option 1 — Bundled MinIO (default) +## Option 1: Bundled MinIO (default) The Docker and Coolify compose files include **MinIO** under the `minio` profile, and the startup script auto-configures it. You only change these for custom credentials. @@ -23,17 +23,26 @@ script auto-configures it. You only change these for custom credentials. | `OBJECT_STORAGE_FORCE_PATH_STYLE` | `true` (required for MinIO) | | `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` | `http://localhost:9000/media` | -:::warning[MinIO is your durability boundary] -Single-node MinIO has no redundancy — its volume is the only thing protecting your uploads. Give it -**persistent storage** and **back it up**. If you'd rather not operate storage yourself, use external -**Cloudflare R2 / AWS S3** instead (R2 has no egress fees) — see below. +:::note[Changing your domain later] +If you change `DASHBOARD_ORIGIN` after Install, `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` is +re-derived from it automatically the next time you run `./classroomio.sh restart` (or +`start`/`upgrade`). If you've bundled MinIO but manually pinned +`OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` to a custom media CDN, note that a domain change will +overwrite it back to `/media` — re-set it afterward if you need the custom +value. ::: -## Option 2 — External S3 / Cloudflare R2 +:::warning[MinIO is your durability boundary] +Single-node MinIO has no redundancy: its volume is the only thing protecting your uploads. Give it +**persistent storage** and **back it up**. If you'd rather not operate storage yourself, use external +**Cloudflare R2 / AWS S3** instead (R2 has no egress fees); see below. +::: + +## Option 2: External S3 / Cloudflare R2 The `OBJECT_STORAGE_*` variables speak the S3 API, so they work with **any S3-compatible provider**, including Cloudflare R2 and AWS S3. Point them at the provider and start **without** the bundled MinIO -(`./run-docker-full-stack.sh --no-minio`, or leave the `minio` profile off). +(`./classroomio.sh start --no-minio`, or leave the `minio` profile off). | Variable | Description | |----------|-------------| @@ -48,7 +57,7 @@ including Cloudflare R2 and AWS S3. Point them at the provider and start **witho :::note[Legacy `CLOUDFLARE_*` variables] ClassroomIO also accepts a legacy Cloudflare R2 config (`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_ACCESS_KEY`, `CLOUDFLARE_SECRET_ACCESS_KEY`, …). It is a **fallback** used only when the `OBJECT_STORAGE_*` variables -are absent — prefer `OBJECT_STORAGE_*` for new deployments (it covers R2 too). For step-by-step R2 bucket +are absent; prefer `OBJECT_STORAGE_*` for new deployments (it covers R2 too). For step-by-step R2 bucket and CORS setup, see the [Cloudflare Setup](/guides/contributing/cloudflare-setup) guide. ::: diff --git a/apps/docs/content/docs/self-hosted/configuration/versions.mdx b/apps/docs/content/docs/self-hosted/configuration/versions.mdx index 40159e0d..4facc1df 100644 --- a/apps/docs/content/docs/self-hosted/configuration/versions.mdx +++ b/apps/docs/content/docs/self-hosted/configuration/versions.mdx @@ -9,7 +9,7 @@ ClassroomIO publishes versioned images. `CIO_VERSION` controls which tag you run - **Pin an exact release** (e.g. `1.0.0`) in production, so a merge to `main` never restarts you onto changed images unattended. -- `latest` is the freshest `main` build — it moves on **every merge** (rolling; may include unreleased +- `latest` is the freshest `main` build: it moves on **every merge** (rolling; may include unreleased code). The version tags (`1.0.0`, `1.0`, `1`) are the immutable, pinned releases. ```zsh title=".env" @@ -25,7 +25,13 @@ Before deploying, confirm the tag you set actually exists at ### Pre-built images (Docker) -Bump `CIO_VERSION` in `.env`, then re-pull and recreate: +```zsh title="Terminal" +./classroomio.sh upgrade +``` + +The script [backs up](/self-hosted/backups) Postgres and MinIO first, lets you pin a new +`CIO_VERSION`, then re-pulls and recreates. Running compose by hand is equivalent (minus the +backup): bump `CIO_VERSION` in `.env`, then: ```zsh title="Terminal" docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull @@ -34,12 +40,15 @@ docker compose -f docker-compose.images.yaml --env-file .env --profile minio up ### Build from source (Docker) -`git pull`, then re-run `./run-docker-full-stack.sh --build` to rebuild. +`git pull`, then re-run `./classroomio.sh upgrade --build` to rebuild. Like the pre-built path, +this backs up Postgres and MinIO first, then rebuilds and restarts. (`start --build` also +rebuilds, but skips the backup step — use `upgrade --build` when upgrading an existing +deployment with real data.) -### Railway / Coolify +### Railway / Coolify / Dokploy / DigitalOcean -Set `CIO_VERSION` to the newer release and redeploy. Because you pin a version, a new release never -restarts you onto changed images unattended. +Set `CIO_VERSION` to the newer release and redeploy (on a DigitalOcean-style VPS: `dc pull && dc up -d`). +Because you pin a version, a new release never restarts you onto changed images unattended. ## Rolling back diff --git a/apps/docs/content/docs/self-hosted/coolify.mdx b/apps/docs/content/docs/self-hosted/coolify.mdx index 73908285..99bc10d9 100644 --- a/apps/docs/content/docs/self-hosted/coolify.mdx +++ b/apps/docs/content/docs/self-hosted/coolify.mdx @@ -3,7 +3,7 @@ title: Coolify description: Deploy ClassroomIO on Coolify using the pull-based Docker Compose stack, with a video walkthrough. --- -[Coolify](https://coolify.io) is a self-hosted PaaS (an open-source Heroku/Netlify alternative) you run on your own VPS. It runs ClassroomIO from the published `classroomio/*` images — your Coolify host **pulls** them and never builds anything. +[Coolify](https://coolify.io) is a self-hosted PaaS (an open-source Heroku/Netlify alternative) you run on your own VPS. It runs ClassroomIO from the published `classroomio/*` images: your Coolify host **pulls** them and never builds anything. ## Watch the walkthrough @@ -40,7 +40,7 @@ This uses a **Docker Compose resource**: you paste the compose file and set ever ### Create a project and add a Docker Compose resource -Log in to Coolify and **create a project**. When you add a resource, Coolify asks what type it is — GitHub, a Docker image, a service, or a **Docker Compose** file. Choose **Docker Compose**, then **paste the contents of [`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml)** into the editor. +Log in to Coolify and **create a project**. When you add a resource, Coolify asks what type it is: GitHub, a Docker image, a service, or a **Docker Compose** file. Choose **Docker Compose**, then **paste the contents of [`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml)** into the editor. ![Showing list of options to choose from](/docs/coolify-02-services.webp) @@ -74,13 +74,13 @@ SMTP_SENDER=ClassroomIO # Object storage + domains: see the full reference below ``` -👉 The essentials above get you started, but the complete list of what to set — object storage, domains, and every optional variable — is in the [**Environment variable reference**](#environment-variable-reference) below. Copy that full block before you deploy. +The essentials above get you started, but the complete list of what to set (object storage, domains, and every optional variable) is in the [**Environment variable reference**](#environment-variable-reference) below. Copy that full block before you deploy. :::note[How to read the reference: value = required, blank = optional] There's no way to guess which variables Coolify needs, so the [Environment variable reference](#environment-variable-reference) spells it out. The rule is simple: **every variable shown with a value must be set** for a smooth deployment (secrets, database, domains, object -storage, and SMTP are all needed). **Every variable left blank after `=` is optional** — fill it in +storage, and SMTP are all needed). **Every variable left blank after `=` is optional**: fill it in only if you use that integration. Leaving an optional one blank never causes a build or boot error. ::: @@ -93,7 +93,7 @@ only if you use that integration. Leaving an optional one blank never causes a b `CIO_VERSION` chooses the tag pulled for `classroomio/{api,dashboard,jobs}`. **Confirm the tag you use actually exists** at [hub.docker.com/r/classroomio](https://hub.docker.com/r/classroomio/) before deploying. :::note[Pin a released version] -Set `CIO_VERSION=1.0.0` to pin an exact release. `latest` is the freshest `main` build — it moves +Set `CIO_VERSION=1.0.0` to pin an exact release. `latest` is the freshest `main` build: it moves on every merge (rolling; may include unreleased code), while the version tags (`1.0.0`, `1.0`, `1`) are immutable, pinned releases. Pinning an exact release makes upgrades deliberate and deploys reproducible. @@ -107,7 +107,7 @@ reproducible. Open the **dashboard** service → **Settings** and set its domain, including the internal port, e.g. `https://dash.yourdomain.com:3082`. Coolify maps that FQDN to the container's port `3082` and issues the certificate through its proxy. -The dashboard proxies API calls server-side, so you normally only expose the dashboard publicly — the `api` service stays internal. +The dashboard proxies API calls server-side, so you normally only expose the dashboard publicly. The `api` service stays internal. ![Setting the dashboard service domain in Coolify](/docs/coolify-05-dashboard-domain.webp) @@ -115,7 +115,7 @@ The dashboard proxies API calls server-side, so you normally only expose the das ### Deploy -Click **Deploy**. Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION}`, starts Postgres + Redis, the API container runs database setup on first boot, then the dashboard and the **jobs worker** come up. Watch the **logs** — they tell you when every service is up. +Click **Deploy**. Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION}`, starts Postgres + Redis, the API container runs database setup on first boot, then the dashboard and the **jobs worker** come up. Watch the **logs**: they tell you when every service is up. ![Coolify deploy logs showing the stack coming up](/docs/coolify-06-deploy-logs.webp) @@ -123,7 +123,7 @@ Click **Deploy**. Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION} ### Open the app and sign up -Once the logs show the stack is healthy, open your dashboard URL (`https://dash.yourdomain.com`) in a new browser tab and **sign up**. The **first** account becomes the organization **admin**. Every signup or sign-in after that joins as a student — unless you invite them with a specific role. +Once the logs show the stack is healthy, open your dashboard URL (`https://dash.yourdomain.com`) in a new browser tab and **sign up**. The **first** account becomes the organization **admin**. Every signup or sign-in after that joins as a student, unless you invite them with a specific role. ![The running ClassroomIO dashboard sign-up page](/docs/railway-12-signup.webp) @@ -132,11 +132,11 @@ Once the logs show the stack is healthy, open your dashboard URL (`https://dash. ## Environment variable reference -You set these on the resource's Environment Variables page (Step 3). This is the complete picture — copy the block below, then replace the placeholder values with your own. +You set these on the resource's Environment Variables page (Step 3). This is the complete picture: copy the block below, then replace the placeholder values with your own. :::note[Value = required, blank = optional] **Every variable shown with a value below is required** for a working deployment. **Every variable -left blank after `=` is optional** — fill it in only if you use that feature. A blank optional +left blank after `=` is optional**: fill it in only if you use that feature. A blank optional variable never causes a build or boot error, so don't put junk into fields you aren't using. ::: @@ -184,7 +184,7 @@ SMTP_PASSWORD= SMTP_SENDER=ClassroomIO # ── Optional — leave blank unless you need them ── -CIO_VERSION=1.0.0 # pinned release; blank = latest (rolling main build) +CIO_VERSION= # pin a release, e.g. 1.0.0; blank = latest (rolling main build) GOOGLE_CLIENT_ID= # Google OAuth sign-in GOOGLE_CLIENT_SECRET= OPENAI_API_KEY= # transcription + AI course generation @@ -201,7 +201,7 @@ OBJECT_STORAGE_REGION= # only for some external S3 providers SKIP_DB_SETUP= # blank = run DB setup on first boot (leave blank) ``` -### Required — set all of these +### Required: set all of these | Variable | What it's for | |----------|---------------| @@ -229,10 +229,10 @@ SKIP_DB_SETUP= # blank = run DB setup on first boot (leave blank) Of the above, `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` are the two the compose file **fails fast** on: leave either empty and the containers refuse to boot (so ClassroomIO never runs with a predictable session-signing secret). The rest won't abort the boot, but the deployment -won't work correctly without them — set them all. +won't work correctly without them. Set them all. ::: -### Optional — leave blank unless you need them +### Optional: leave blank unless you need them | Variable | Enables | |----------|---------| @@ -255,7 +255,7 @@ Enable the **bundled MinIO** with `COMPOSE_PROFILES=minio` and Coolify runs it a (`OBJECT_STORAGE_PUBLIC_ENDPOINT` / `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL`) so browsers can load media. :::warning[MinIO is your durability boundary] -Single-node MinIO has no redundancy — its volume is the only thing protecting your uploads, so give it +Single-node MinIO has no redundancy: its volume is the only thing protecting your uploads, so give it persistent storage in Coolify and back it up. ::: diff --git a/apps/docs/content/docs/self-hosted/digitalocean.mdx b/apps/docs/content/docs/self-hosted/digitalocean.mdx index 2ca69d5c..34ae4f1b 100644 --- a/apps/docs/content/docs/self-hosted/digitalocean.mdx +++ b/apps/docs/content/docs/self-hosted/digitalocean.mdx @@ -3,7 +3,7 @@ title: DigitalOcean description: Deploy ClassroomIO to a DigitalOcean droplet with Docker Compose and Caddy, including automatic HTTPS. --- -Run ClassroomIO on a plain **DigitalOcean droplet** — no PaaS in between. You get the +Run ClassroomIO on a plain **DigitalOcean droplet**, no PaaS in between. You get the [pull-based Docker Compose stack](/self-hosted/docker) plus a [Caddy](https://caddyserver.com) reverse proxy that obtains and renews HTTPS certificates automatically. @@ -12,7 +12,7 @@ AWS Lightsail**, or any Ubuntu 24.04 server you can SSH into. :::note[How this differs from the PaaS guides] [Railway](/self-hosted/railway), [Coolify](/self-hosted/coolify) and [Dokploy](/self-hosted/dokploy) -terminate HTTPS for you. On a raw VPS you provide that yourself — this page covers the reverse proxy, +terminate HTTPS for you. On a raw VPS you provide that yourself: this page covers the reverse proxy, the certificates, and closing your internal ports off from the internet. ::: @@ -35,12 +35,12 @@ Prefer to follow along? This video walks through the whole deployment end to end | A | `app` | your droplet's IP | | A | `s3` | your droplet's IP | -- An **SMTP provider** for signup verification, password resets, and invites — see +- An **SMTP provider** for signup verification, password resets, and invites. See [Configuration → Email (SMTP)](/self-hosted/configuration/email). :::note[No domain? You can still get real HTTPS] Use [sslip.io](https://sslip.io), a public wildcard-DNS service where `..sslip.io` -resolves to that IP — so no records are needed. In Step 1 set +resolves to that IP, so no records are needed. In Step 1 set `export DOMAIN=.sslip.io` instead of your own domain, and **every other command on this page is identical**. Read the [caveat](#a-note-on-sslipio) before using it for anything real. ::: @@ -152,7 +152,7 @@ sudo usermod -aG docker cio ``` :::warning[Use the literal username, not `$USER`] -`$USER` expands to whoever runs the shell — run it from a root session and it grants **root** rather +`$USER` expands to whoever runs the shell. Run it from a root session and it grants **root** rather than your user, and every later Docker command fails with `permission denied` on `/var/run/docker.sock`. Check with `getent group docker`. ::: @@ -168,7 +168,7 @@ id | grep -o docker # -> docker docker compose version # must be v2.24.0 or newer ``` -Compose **v2.24.0+** is required — Step 6 uses the `!override` YAML tag. Older versions +Compose **v2.24.0+** is required: Step 6 uses the `!override` YAML tag. Older versions *concatenate* port lists instead of replacing them, which would leave your internal services exposed to the internet. @@ -181,8 +181,8 @@ mkdir -p ~/classroomio && cd ~/classroomio curl -O https://raw.githubusercontent.com/classroomio/classroomio/main/docker-compose.images.yaml ``` -This is the same [pull-based stack](/self-hosted/docker#run-from-pre-built-images-recommended) used -by the other guides — it pulls the published `classroomio/*` images and builds nothing. +This is the same [pull-based stack](/self-hosted/docker#quick-start) used +by the other guides. It pulls the published `classroomio/*` images and builds nothing. @@ -242,11 +242,11 @@ Then fill in your SMTP credentials with `nano .env`. Three of these break deployments more often than all the others combined: -- **`OBJECT_STORAGE_PUBLIC_ENDPOINT`** — where the browser fetches video from. Left at the template +- **`OBJECT_STORAGE_PUBLIC_ENDPOINT`**: where the browser fetches video from. Left at the template default of `localhost`, every video URL points at the *visitor's own machine*. -- **`DASHBOARD_ORIGIN`** — must match the browser URL exactly, `https://` included, no trailing +- **`DASHBOARD_ORIGIN`**: must match the browser URL exactly, `https://` included, no trailing slash. A mismatch breaks login with CSRF errors. -- **`CSP_MEDIA_SRC_DOMAINS` / `CSP_CONNECT_SRC_DOMAINS`** — because storage is on a second hostname, +- **`CSP_MEDIA_SRC_DOMAINS` / `CSP_CONNECT_SRC_DOMAINS`**: because storage is on a second hostname, the browser blocks its media unless these allow it. Symptom: the page looks fine, images and video silently don't load, and the only clue is in the browser console. @@ -261,7 +261,7 @@ Every remaining variable is documented in [Configuration](/self-hosted/configura ### Add Caddy and close the internal ports -Right now each part of ClassroomIO runs on its own port number — the dashboard on `3082`, file +Right now each part of ClassroomIO runs on its own port number: the dashboard on `3082`, file storage on `9000`. That leaves two problems: nobody wants to visit `yoursite.com:3082`, and neither port is encrypted, so there would be no padlock in the browser. @@ -328,14 +328,14 @@ EOF ``` Those eight lines are the entire HTTPS setup. The first time someone visits your site, Caddy asks -Let's Encrypt for a free certificate, installs it, and from then on renews it before it expires — -you never touch it again. +Let's Encrypt for a free certificate, installs it, and from then on renews it before it expires. +You never touch it again. You could use nginx instead, but you'd also be installing certbot, setting up a renewal timer, and hand-writing the forwarding rules that Caddy applies by default. :::warning[Your firewall does not cover Docker] -You set up a firewall back in Step 3. It does **not** apply to Docker containers — Docker writes its +You set up a firewall back in Step 3. It does **not** apply to Docker containers: Docker writes its own networking rules underneath it, so a container that publishes a port stays reachable from the internet even when `ufw` says that port is blocked. @@ -367,7 +367,7 @@ dc pull :::warning[This downloads ~12 GB and takes 10–20 minutes] The images are large (`api` 3.7 GB, `dashboard` 3.9 GB, `jobs` 4.2 GB). On a 2-vCPU droplet the -**progress bars sit still for minutes at a time — this is normal, it is not frozen.** Don't cancel it. +**progress bars sit still for minutes at a time. This is normal, it is not frozen.** Don't cancel it. ::: @@ -378,7 +378,7 @@ The images are large (`api` 3.7 GB, `dashboard` 3.9 GB, `jobs` 4.2 GB). On a 2-v dc up -d ``` -Services start in dependency order — Postgres and Redis must report healthy before the API starts, +Services start in dependency order: Postgres and Redis must report healthy before the API starts, because the API runs database migrations on boot. A full start takes about **40 seconds**. ```zsh title="Terminal" @@ -396,7 +396,7 @@ cio-postgres Up About a minute (healthy) cio-redis Up About a minute (healthy) ``` -`cio-minio-init` is absent on purpose — it creates the storage buckets and exits. Confirm it worked: +`cio-minio-init` is absent on purpose: it creates the storage buckets and exits. Confirm it worked: ```zsh title="Terminal" docker logs cio-minio-init | tail -4 @@ -425,19 +425,19 @@ for p in 3081 3082 9000 9001; do done ``` -All four must report `no response`. If any is reachable, your Compose version ignored `!override` — -check that `docker compose version` is v2.24.0 or newer. +All four must report `no response`. If any is reachable, your Compose version ignored `!override`. +Check that `docker compose version` is v2.24.0 or newer. ### Sign up -Open `https://app.` and create the first account — it becomes the organization +Open `https://app.` and create the first account: it becomes the organization **admin**. Every signup after that joins as a student unless you invite them with a specific role. Then verify uploads end to end: create a course, upload an image, and upload a short video. Watch the worker process it with `dc logs -f jobs`. In your browser's DevTools the media URLs must point at -`https://s3./...` — if you see `localhost:9000`, `OBJECT_STORAGE_PUBLIC_ENDPOINT` is +`https://s3./...`. If you see `localhost:9000`, `OBJECT_STORAGE_PUBLIC_ENDPOINT` is wrong. @@ -453,7 +453,7 @@ dc down # stop, keeping all data ``` :::danger[Never use `dc down -v`] -The `-v` flag deletes the `postgres-data`, `redis-data` and `minio-data` volumes — your database and +The `-v` flag deletes the `postgres-data`, `redis-data` and `minio-data` volumes: your database and every upload. Plain `dc down` is always safe. ::: @@ -498,7 +498,7 @@ own. **For production, use your own domain.** ``` 3. Change the two site addresses in `~/classroomio/Caddyfile`. -4. Apply, then clear cached media URLs — presigned URLs are cached in Redis for 50 minutes, so +4. Apply, then clear cached media URLs: presigned URLs are cached in Redis for 50 minutes, so without this the old hostname keeps being served: ```zsh title="Terminal" @@ -506,10 +506,14 @@ own. **For production, use your own domain.** docker exec cio-redis sh -c "redis-cli --scan --pattern 'presign:download:*' | xargs -r redis-cli del" ``` -Existing uploads keep working — media URLs are generated per request from the new endpoint. +Existing uploads keep working. Media URLs are generated per request from the new endpoint. ## Troubleshooting +These are the droplet-specific issues. For general stack problems (sign-in errors right after +startup, stuck uploads, OOM kills, disk space), see the shared +[Troubleshooting](/self-hosted/troubleshooting) page. + | Symptom | Cause | | ------- | ----- | | `permission denied` on `/var/run/docker.sock` | Your user isn't in the `docker` group, or you haven't logged out and back in since adding it | @@ -549,8 +553,8 @@ This deployment reads the same variables as every other self-hosted install: ## Upgrades -Check which versions exist, then pin one — never guess a tag, as an unpublished one fails the pull -with `manifest unknown`: +Check which versions exist, then pin one: never guess a tag, since an unpublished one fails the pull +with `manifest unknown`. ```zsh title="Terminal" curl -s "https://hub.docker.com/v2/repositories/classroomio/dashboard/tags?page_size=20&ordering=last_updated" \ @@ -561,5 +565,5 @@ dc pull && dc up -d ``` Migrations run automatically on API startup. Roll back by setting `CIO_VERSION` to the previous -release and repeating — which is why you pin an exact version rather than `latest`. See +release and repeating, which is why you pin an exact version rather than `latest`. See [Configuration → Versions & upgrades](/self-hosted/configuration/versions). diff --git a/apps/docs/content/docs/self-hosted/docker.mdx b/apps/docs/content/docs/self-hosted/docker.mdx index 16cae9ab..4cbefe98 100644 --- a/apps/docs/content/docs/self-hosted/docker.mdx +++ b/apps/docs/content/docs/self-hosted/docker.mdx @@ -1,37 +1,129 @@ --- title: Docker -description: Self-host ClassroomIO with Docker — pull pre-built images (recommended) or build from source. +description: Self-host ClassroomIO with Docker — one script installs, starts, upgrades, and backs up the whole stack. --- -Docker is one way to self-host ClassroomIO. There are two paths: - -- **Run from pre-built images (recommended)** — pull the published `classroomio/{api,dashboard,jobs}` images. You only need one compose file and a `.env`; no repository clone and no heavy local build. -- **Build from source** — clone the repo and build the images yourself (needs ~8 GB RAM for the dashboard build). +Run ClassroomIO anywhere Docker runs. One script manages the entire lifecycle: you copy it, +run it, and pick a number. New to self-hosting? Start with +[Self-hosting 101](/self-hosted/101) to see what you're deploying. ![Selfhost ClassroomIO](/docs/selfhost-classroomio.webp) -## Run from Pre-built Images (recommended) +## Prerequisites -### Prerequisites +- Docker installed and running (Compose v2 or the legacy `docker-compose` both work). +- A server that meets the [requirements](/self-hosted#server-requirements) (8 GB RAM recommended). -- Docker installed and running. +## Quick start -### Steps - -1. Download the pull-based compose file and an env template: + + +### Get the script ```zsh title="Terminal" -curl -O https://raw.githubusercontent.com/classroomio/classroomio/main/docker-compose.images.yaml -curl -o .env https://raw.githubusercontent.com/classroomio/classroomio/main/.env.example +mkdir classroomio && cd classroomio +curl -fsSLO https://classroomio.com/classroomio.sh +chmod +x classroomio.sh +``` + + +### Run it and pick Install + +```zsh title="Terminal" +./classroomio.sh ``` -2. Edit `.env`. At minimum set `CIO_VERSION` (pin a release), `DASHBOARD_ORIGIN`, the auth secrets, and SMTP: +Choose **1) Install**. The script downloads the compose file, creates `.env` from the template, +generates the auth secrets and MinIO credentials for you, then pulls the images and starts the +stack. Re-running Install is safe: it never overwrites an existing `.env` or compose file. + + +### Edit the required values + +Everything else is auto-generated or defaulted. For a real deployment, set only: + +```zsh title=".env" +CIO_VERSION=1.0.0 # pin a release for production +DASHBOARD_ORIGIN=https://app.yourdomain.com # your public https:// URL +POSTGRES_PASSWORD= # required in production +SMTP_HOST= # email — signup, resets, invites +SMTP_PORT= +SMTP_USER= +SMTP_PASSWORD= +SMTP_SENDER= +``` + +Then apply: `./classroomio.sh restart` + +:::warning[Changing `POSTGRES_PASSWORD` after Install does nothing on its own] +Postgres only applies `POSTGRES_PASSWORD` the first time it initializes its data volume. If +you're editing `.env` after Install has already run, see [rotating it after +install](/self-hosted/configuration/secrets#rotating-postgres_password-after-install) — +just restarting/recreating containers will break the DB connection instead of fixing it. +::: + + + +The dashboard is at http://localhost:3082, the API at http://localhost:3081, and the MinIO console +at http://localhost:9001 (credentials are in your `.env`). + +:::warning[Pin `CIO_VERSION` in production] +The default tags track moving builds: `latest` changes on every merge to `main`. Pin an exact +release (e.g. `CIO_VERSION=1.4.2`) so an upstream merge can never change your deployment +unannounced. The script warns you about this too. See +[Versions & upgrades](/self-hosted/configuration/versions). +::: + +:::note[Serving on a real domain?] +Use an `https://` origin: session cookies are `Secure`, so +[login silently fails over plain HTTP](/self-hosted#https-is-required-in-production). +::: + +## The menu + +| Option | What it does | +|--------|--------------| +| 1 Install | First-time setup: fetch files, create `.env`, generate secrets, pull and start. | +| 2 Start | Start the stack (also fills in any missing secrets first). | +| 3 Stop | Stop containers. Data volumes are kept. | +| 4 Restart | Restart containers, e.g. after editing `.env`. | +| 5 Upgrade | **Backs up first**, then pulls newer images and restarts. | +| 6 View logs | Follow logs for all services or one (`./classroomio.sh logs api`). | +| 7 Backup | Dump Postgres and archive the MinIO volume into `./backups/`. See [Backups](/self-hosted/backups). | + +Every option is also a subcommand (`./classroomio.sh upgrade`), so you can script or cron it. + + + +To run local code changes, clone the repo and use the same script with `--build` +(needs `git` and ~8 GB RAM for the dashboard build): + +```zsh title="Terminal" +git clone https://github.com/classroomio/classroomio.git +cd classroomio +cp .env.example .env +./classroomio.sh start --build +``` + +Without `--build` the script pulls the published `classroomio/*` images at your `CIO_VERSION`, +which is the faster default. `--no-minio` excludes the bundled MinIO if you use external +[S3/R2 storage](/self-hosted/configuration/storage). + + + +## Configuration reference + +Docker self-hosting uses a single root `.env` file: every compose command reads it via +`--env-file .env`, and the compose file itself tracks `main` (it's just the stack topology; +`CIO_VERSION` pins the app images). + + ```zsh title=".env" CIO_VERSION=1.0.0 # pin a release (not "latest") so upgrades are deliberate DASHBOARD_ORIGIN=https://app.yourdomain.com -BETTER_AUTH_SECRET= -PRIVATE_SERVER_KEY= # must be identical in the api and dashboard +BETTER_AUTH_SECRET= # auto-generated by the script if left blank +PRIVATE_SERVER_KEY= # auto-generated; must be identical in api and dashboard POSTGRES_PASSWORD= # Email (required for signup/reset/invites) SMTP_HOST= @@ -41,78 +133,13 @@ SMTP_PASSWORD= SMTP_SENDER= ``` -:::danger[Set strong secrets — the stack won't start without them] -This path does **not** run `run-docker-full-stack.sh`, so secrets are not auto-generated. You must set -`BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` yourself, or the compose file fails fast. See -[Configuration → Environment & secrets](/self-hosted/configuration/secrets). -::: +See [`.env.example`](https://github.com/classroomio/classroomio/blob/main/.env.example) for the +full annotated template. -3. Pull and start the stack (MinIO is included via the `minio` profile): + -```zsh title="Terminal" -docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull -docker compose -f docker-compose.images.yaml --env-file .env --profile minio up -d -``` - -4. Verify: - -```zsh title="Terminal" -curl -sS http://localhost:3081/ # API — returns JSON -curl -I http://localhost:3082/ # Dashboard — returns 200 -``` - -- Dashboard: http://localhost:3082 -- API: http://localhost:3081 -- MinIO Console: http://localhost:9001 (log in with `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` from your `.env`) - -## Build from Source - -Use this if you want to build the images locally (e.g. to run code changes). - -### Prerequisites - -- Docker Desktop installed and running -- `git` installed -- ~8 GB RAM available for the dashboard build - -### Steps - -1. Clone the repository: - -```zsh title="Terminal" -git clone https://github.com/classroomio/classroomio.git -cd classroomio -``` - -2. Copy the env template and edit it: - -```zsh title="Terminal" -cp .env.example .env -``` - -Open `.env` and set `DASHBOARD_ORIGIN` for your domain. For local development the defaults work as-is — the startup script generates `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` when they are missing, and randomizes the MinIO credentials. - -3. Build and start the full stack: - -```zsh title="Terminal" -./run-docker-full-stack.sh --build -``` - -The script builds and starts Postgres, Redis, MinIO (object storage), the API, the dashboard, and the background **jobs** worker. - -:::note[Default is pull, not build] -Run without `--build` and the script **pulls** the published `classroomio/*` images at the -`CIO_VERSION` in your `.env` instead of building locally — that's the faster default path. -::: - -## One `.env`, One Command - -Docker self-hosting uses a single root `.env` file. Every `docker compose` command reads it via `--env-file .env` — you never need separate env files per service. See [`.env.example`](https://github.com/classroomio/classroomio/blob/main/.env.example) for the full template. - -## Configuration - -Every variable the stack reads is documented once in the shared -[Configuration](/self-hosted/configuration) section — set the ones you need in your root `.env`: +Every variable the stack reads is documented in the shared +[Configuration](/self-hosted/configuration) section: @@ -130,51 +157,15 @@ Every variable the stack reads is documented once in the shared Google OAuth, Unsplash, OpenAI, branding. - - `LICENSE_KEY` for Enterprise features. + + `LICENSE_KEY` for SSO, token auth, and no-tracking. ## Upgrading -ClassroomIO publishes versioned images and `CIO_VERSION` controls which tag you run. To upgrade the -pre-built images path, bump `CIO_VERSION` and re-pull: +Run `./classroomio.sh upgrade`: it backs up Postgres and MinIO first, then pulls the images at +your (possibly newly pinned) `CIO_VERSION` and restarts. Rollback = set `CIO_VERSION` back and +upgrade again. Details: [Versions & upgrades](/self-hosted/configuration/versions). -```zsh title="Terminal" -docker compose -f docker-compose.images.yaml --env-file .env --profile minio pull -docker compose -f docker-compose.images.yaml --env-file .env --profile minio up -d -``` - -For the build-from-source path, `git pull` and re-run `./run-docker-full-stack.sh --build`. See -[Configuration → Versions & upgrades](/self-hosted/configuration/versions) for pinning, rollback, and -the pre-upgrade backup steps. - -## Common Commands - -```zsh title="Terminal" -# Start the full stack (pulls pre-built images by default) -./run-docker-full-stack.sh - -# Build from source instead of pulling -./run-docker-full-stack.sh --build - -# Start without MinIO (requires an external S3-compatible store) -./run-docker-full-stack.sh --no-minio -``` - -See [docker/docs/SELF_HOST.md](https://github.com/classroomio/classroomio/blob/main/docker/docs/SELF_HOST.md) for the full Docker reference including troubleshooting. - -## Troubleshooting: "No space left on device" - -```zsh title="Terminal" -docker system df # check usage -docker builder prune -f # free build cache -docker image prune -a -f # free unused images -docker container prune -f # free stopped containers -``` - -:::warning[Volume prune deletes data] -Avoid `docker volume prune` after `docker compose ... down` — it can remove your database and storage volumes. -::: - -Then restart: `./run-docker-full-stack.sh` +Something not working? See [Troubleshooting](/self-hosted/troubleshooting). diff --git a/apps/docs/content/docs/self-hosted/dokploy.mdx b/apps/docs/content/docs/self-hosted/dokploy.mdx index e3c9182b..1e465827 100644 --- a/apps/docs/content/docs/self-hosted/dokploy.mdx +++ b/apps/docs/content/docs/self-hosted/dokploy.mdx @@ -3,10 +3,10 @@ title: Dokploy description: Deploy ClassroomIO on Dokploy using the pull-based Docker Compose stack. --- -[Dokploy](https://dokploy.com) is an open-source, self-hosted PaaS — a Heroku/Vercel alternative you run -on your own VPS. It deploys ClassroomIO from the published `classroomio/*` images using the **same +[Dokploy](https://dokploy.com) is an open-source, self-hosted PaaS (a Heroku/Vercel alternative you run +on your own VPS). It deploys ClassroomIO from the published `classroomio/*` images using the **same pull-based Docker Compose file** as the [Docker](/self-hosted/docker) and [Coolify](/self-hosted/coolify) -guides — Dokploy **pulls** the images and never builds anything. +guides. Dokploy **pulls** the images and never builds anything. ## Watch the walkthrough @@ -17,18 +17,18 @@ steps below mirror it. ## Prerequisites -- A running **Dokploy instance** with a **server connected** to it. Dokploy runs on any VPS — +- A running **Dokploy instance** with a **server connected** to it. Dokploy runs on any VPS: DigitalOcean, Hetzner, AWS (Lightsail/EC2), or another provider. See Dokploy's [installation guide](https://docs.dokploy.com/docs/core/installation) to set this up. - A server sized for ClassroomIO. Dokploy's own baseline stack needs ~2 GB RAM, and ClassroomIO's - seven services need **8 GB for reliable operation** — provision for both. See + seven services need **8 GB for reliable operation**, so provision for both. See [Server requirements](/self-hosted#server-requirements). - A **domain** with a DNS **A record pointing at your server's public IP**. Dokploy's built-in Traefik proxy terminates HTTPS and issues the certificate. -- An **SMTP provider** for signup verification, password reset, and invite emails — see +- An **SMTP provider** for signup verification, password reset, and invite emails. See [Configuration → Email (SMTP)](/self-hosted/configuration/email). - *Optional:* an **OpenAI API key** (transcription + AI course generation) and Google OAuth / Unsplash - keys — see [Configuration → Integrations](/self-hosted/configuration/integrations). + keys. See [Configuration → Integrations](/self-hosted/configuration/integrations). :::note[Two secrets to generate first] Dokploy does **not** run our secret-generation script, so generate `BETTER_AUTH_SECRET` and @@ -64,7 +64,7 @@ In the Dokploy dashboard, open **Projects** and click **Create Project**. Give i ### Add a Compose service Inside the project, click **Create Service** and choose **Compose** from the service types. When -prompted, **select the server** you connected to Dokploy — this is the VPS ClassroomIO runs on. +prompted, **select the server** you connected to Dokploy. This is the VPS ClassroomIO runs on. ![Adding a Compose service and selecting the server](/docs/dokploy-02-compose-service.webp) @@ -72,10 +72,10 @@ prompted, **select the server** you connected to Dokploy — this is the VPS Cla ### Choose the Raw (manual) provider and paste the compose file -Dokploy asks where the compose file comes from — GitHub, GitLab, Bitbucket, or **Raw**. Choose **Raw** +Dokploy asks where the compose file comes from: GitHub, GitLab, Bitbucket, or **Raw**. Choose **Raw** (the manual option) and paste the contents of -[`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml) -— the same pull-based stack used for the [Docker](/self-hosted/docker) and [Coolify](/self-hosted/coolify) +[`docker-compose.images.yaml`](https://github.com/classroomio/classroomio/blob/main/docker-compose.images.yaml), +the same pull-based stack used for the [Docker](/self-hosted/docker) and [Coolify](/self-hosted/coolify) deployments. ![Choosing the Raw provider and pasting the compose file](/docs/dokploy-03-raw-compose.webp) @@ -101,7 +101,7 @@ SMTP_PASSWORD= SMTP_SENDER=ClassroomIO ``` -The full variable reference — object storage, CSP, integrations, licensing, versions — lives in the +The full variable reference (object storage, CSP, integrations, licensing, versions) lives in the shared [Configuration](/self-hosted/configuration) section. Copy what you need before deploying. ![Setting the environment variables in Dokploy](/docs/dokploy-04-env-vars.webp) @@ -111,7 +111,7 @@ shared [Configuration](/self-hosted/configuration) section. Copy what you need b ### Point your domain at the server Before the domain will work, add a DNS **A record** for it (e.g. `dash.yourdomain.com`) pointing at -your server's public IP. Do this for the dashboard domain — and any other domain you plan to use (such +your server's public IP. Do this for the dashboard domain, and any other domain you plan to use (such as storage). DNS can take a few minutes to propagate. @@ -121,7 +121,7 @@ as storage). DNS can take a few minutes to propagate. Click **Deploy**. Dokploy pulls `classroomio/{api,dashboard,jobs}` plus Postgres, Redis, and MinIO, then starts the stack. The API runs database setup on first boot. Watch the **logs** until every -service is up — this is your first deploy. +service is up. This is your first deploy. ![Dokploy deploy logs showing the stack coming up](/docs/dokploy-05-deploy-logs.webp) @@ -131,12 +131,12 @@ service is up — this is your first deploy. Open the service's **Domains** section and click **Add Domain**. In the modal that appears: -- **Service:** select **dashboard** — the service this domain routes to. +- **Service:** select **dashboard**, the service this domain routes to. - **Host:** your dashboard domain, e.g. `dash.yourdomain.com`. -- **Container Port:** `3082` — the port the dashboard listens on. +- **Container Port:** `3082`, the port the dashboard listens on. - **HTTPS:** turn it on, then choose **Let's Encrypt** as the certificate provider. -Create the domain. Dokploy provisions the certificate through Traefik — it usually only takes a moment. +Create the domain. Dokploy provisions the certificate through Traefik, which usually only takes a moment. ![Adding the dashboard domain with port 3082 and Let's Encrypt](/docs/dokploy-06-add-domain.webp) @@ -174,7 +174,7 @@ shared [Configuration](/self-hosted/configuration) section: Google OAuth, Unsplash, OpenAI. - + `LICENSE_KEY` for Enterprise features. diff --git a/apps/docs/content/docs/self-hosted/enterprise.mdx b/apps/docs/content/docs/self-hosted/enterprise.mdx new file mode 100644 index 00000000..8974f7b8 --- /dev/null +++ b/apps/docs/content/docs/self-hosted/enterprise.mdx @@ -0,0 +1,57 @@ +--- +title: Enterprise & licensing +description: What a ClassroomIO enterprise license unlocks on self-hosted — SSO, token auth, no-tracking — and how LICENSE_KEY validation works. +--- + +ClassroomIO is [open core](/self-hosted/101#how-licensing-works): the complete LMS is free to +self-host, and a small set of enterprise features is unlocked by a paid license key. No key means +everything else still works, so leave `LICENSE_KEY` unset until you need one of these. + +## What a license unlocks + +| Feature | What it does | +|---------|--------------| +| **SSO** | Single Sign-On with OIDC (Okta, Azure AD, and more). Setup: [Enterprise SSO](/enterprise-sso). | +| **Token auth** | Issue signed tokens to log users in from your own app. Setup: [Token Auth](/token-auth). | +| **No-tracking** | Disables product analytics in the dashboard (PostHog, Umami) and third-party embeds. Without this feature, self-hosted mode already disables error reporting and the feedback widget, but not product analytics. | + +Without a valid license, the SSO and token-auth APIs return `403 FEATURE_REQUIRES_LICENSE` and +their settings are hidden in the dashboard UI. + +## Setting `LICENSE_KEY` + +`LICENSE_KEY` is read by the **api** service. Set it wherever that service takes its environment: + +| Deployment | Where to set it | +|------------|-----------------| +| Docker | repository root `.env` | +| Railway | the `cio-api` service environment | +| Coolify / Dokploy | the resource's environment variables | +| Local development | `apps/api/.env` | + +```zsh title=".env" +LICENSE_KEY= +``` + +Restart the stack with `./classroomio.sh restart` after setting it (this recreates the +`api` container so it picks up the new value — `docker compose restart api` on its own +does **not**, since it doesn't recreate the container). + +## How validation works + +The api service validates the key against ClassroomIO's license server +(`enterprise-api.classroomio.dev`) and caches the result in memory for **one hour**. + +What that means operationally: + +- **The api container needs outbound HTTPS** to that host. In an air-gapped or egress-restricted + network, licensed features can't activate. Contact ClassroomIO about your environment before + buying. +- If a check fails (network blip, license server unreachable), licensed features switch off and + the failure is also cached, so it can take **up to an hour** (or an api restart) after + connectivity returns before they come back. +- Everything outside the licensed feature list is unaffected by license-server reachability. + +## Getting a license + +[Book a call](https://cal.com/classroomio/enterprise) to get an enterprise license key. diff --git a/apps/docs/content/docs/self-hosted/index.mdx b/apps/docs/content/docs/self-hosted/index.mdx index e1026461..e5a655d7 100644 --- a/apps/docs/content/docs/self-hosted/index.mdx +++ b/apps/docs/content/docs/self-hosted/index.mdx @@ -1,10 +1,12 @@ --- title: Self Hosting description: Run ClassroomIO on your own infrastructure — with Docker, DigitalOcean, Railway, Coolify, or Dokploy. +sidebar: + label: Overview --- ClassroomIO is open source, and you can run the whole platform on your own infrastructure. Every -option below runs the same stack — the API, the dashboard, and the background jobs worker, backed by +option below runs the same stack: the API, the dashboard, and the background jobs worker, backed by Postgres and Redis. Pick the path that matches where you want it to run. @@ -14,7 +16,7 @@ Pick the path that matches where you want it to run. Run anywhere Docker runs. Pull the pre-built images, or build from source. - A plain VPS, end to end — Docker Compose plus Caddy for automatic HTTPS. + A plain VPS, end to end: Docker Compose plus Caddy for automatic HTTPS. Deploy to Railway from a template. The quickest managed option. @@ -49,16 +51,18 @@ pick, have mail credentials ready before you start. - Object storage (**MinIO** or S3-compatible) if you want file and video uploads. - *Optional:* an **OpenAI API key** for transcription and AI course generation. -For the full environment variable reference — secrets, email, storage, CSP, integrations, licensing, -and versions — see the [Configuration](/self-hosted/configuration) section, which documents every -variable the stack reads. +The [Configuration](/self-hosted/configuration) section has the full environment variable reference +(secrets, email, storage, CSP, integrations, licensing, versions) and documents every variable the +stack reads. ## Server requirements -ClassroomIO is not a single container — it runs **seven services**: Postgres, Redis, MinIO (object -storage), the API, the dashboard, and a background **jobs worker** that handles video processing, -transcription, and AI course generation. The jobs worker spikes RAM whenever it runs, so a server's -idle memory use understates what you actually need. +ClassroomIO is not a single container. It runs **seven services**: Postgres, Redis, MinIO (object +storage), the API, the dashboard, a background **jobs worker** that handles video processing, +transcription, and AI course generation, and **`minio-init`**, a one-shot container that creates +the MinIO buckets on first start and then exits, so it won't show up in `docker ps` afterward. The +jobs worker spikes RAM whenever it runs, so a server's idle memory use understates what you +actually need. | Resource | Minimum | Recommended | | -------- | ---------------- | ------------- | @@ -67,31 +71,30 @@ idle memory use understates what you actually need. | Disk | 20 GB + uploads | 40 GB+ SSD | :::warning[Don't under-provision RAM] -A **1 GB server cannot run ClassroomIO at all** — it exhausts memory on the base stack before the -app is even up. **2 GB is only enough for a PaaS control plane** (Coolify, Dokploy) with little left -over for the app. Target **8 GB for reliable operation**, especially once the jobs worker starts -processing video or AI generation. +A **1 GB** server can't run ClassroomIO at all: it exhausts memory on the base stack before the app +is even up. **2 GB** is only enough for a PaaS control plane (Coolify, Dokploy), with little left +over for the app itself. Aim for **8 GB** for reliable operation, especially once the jobs worker +starts processing video or AI generation. ::: :::note[Running a PaaS on the same box?] -Coolify and Dokploy need roughly **2 GB for themselves** on top of ClassroomIO's requirement. If -they share the server, size it for both — e.g. 8 GB for ClassroomIO plus the PaaS overhead. +Coolify and Dokploy need roughly **2 GB** for themselves on top of ClassroomIO's requirement. If +they share the server, size it for both, e.g. 8 GB for ClassroomIO plus the PaaS overhead. ::: -Building the images **from source** is a separate, heavier job that needs ~8 GB RAM at build time — -see the [Docker guide](/self-hosted/docker#build-from-source). Pulling the pre-built images (the -default) avoids that. +Building the images **from source** is a separate, heavier job that needs ~8 GB RAM at build time. +See the [Docker guide](/self-hosted/docker). Pulling the pre-built images (the default) avoids that. ## HTTPS is required in production :::warning[Serve over HTTPS on any real domain] ClassroomIO's authentication session cookies are marked `Secure`, so browsers refuse to store or -send them over plain HTTP — **login silently fails**. Plain HTTP works only for local evaluation on +send them over plain HTTP. **Login silently fails**. Plain HTTP works only for local evaluation on `localhost`. Any internet-facing deployment **must** be served over HTTPS. ::: Terminate TLS at a reverse proxy in front of the stack: -- **Railway** and **Coolify** issue the certificate and terminate HTTPS for you — nothing extra to do. +- **Railway** and **Coolify** issue the certificate and terminate HTTPS for you: nothing extra to do. - **Raw Docker** on your own server: put **nginx**, **Caddy**, or **Traefik** in front, and set `DASHBOARD_ORIGIN` / `ORIGIN` to the `https://` URL your users visit. diff --git a/apps/docs/content/docs/self-hosted/railway.mdx b/apps/docs/content/docs/self-hosted/railway.mdx index 3dfd2414..b6d35a68 100644 --- a/apps/docs/content/docs/self-hosted/railway.mdx +++ b/apps/docs/content/docs/self-hosted/railway.mdx @@ -15,7 +15,7 @@ Prefer to follow along? This video walks through the whole deployment end to end ## Prerequisites -- A **Railway account** (a paid plan is recommended — the stack runs several always-on services). +- A **Railway account** (a paid plan is recommended, since the stack runs several always-on services). - An **SMTP provider** (or Zoho/ZeptoMail) for signup verification, password reset, and invite emails. See [SMTP Setup](#smtp-setup). - *Optional:* a **custom domain** for the dashboard and storage, an **OpenAI API key** (transcription + AI course generation), and Google OAuth / Unsplash keys. @@ -32,7 +32,7 @@ See [Configuration → Environment & secrets](/self-hosted/configuration/secrets ## Step-by-step walkthrough -The one-click template provisions **all of the services** for you — Postgres, Redis, `cio-api`, `cio-dashboard`, `cio-jobs`, and `cio-minio` (object storage). The flow is: **configure a few values → deploy → connect the dashboard domain → sign up**. +The one-click template provisions **all of the services** for you: Postgres, Redis, `cio-api`, `cio-dashboard`, `cio-jobs`, and `cio-minio` (object storage). The flow is: **configure a few values → deploy → connect the dashboard domain → sign up**. @@ -49,10 +49,10 @@ Click the **Deploy on Railway** button above. Railway opens the template page wh ### Review the preconfigured services -The template comes with every service preconfigured — most need nothing from you: +The template comes with every service preconfigured: most need nothing from you: -- **Redis** and **Postgres** — no configuration required. -- **cio-dashboard** — no required configuration (optionally set branding such as the public OG image; not required). +- **Redis** and **Postgres**: no configuration required. +- **cio-dashboard**: no required configuration (optionally set branding such as the public OG image; not required). The next three steps cover the services that **do** need input: `cio-jobs`, `cio-minio`, and `cio-api`. @@ -78,7 +78,7 @@ The next three steps cover the services that **do** need input: `cio-jobs`, `cio Set the storage credentials on `cio-minio`: -- `MINIO_SERVER_URL` — optional but recommended (fixes MinIO console redirects behind the proxy); the template presets it to the generated `cio-minio` domain, so you normally just confirm it +- `MINIO_SERVER_URL`: optional but recommended (fixes MinIO console redirects behind the proxy); the template presets it to the generated `cio-minio` domain, so you normally just confirm it - `MINIO_ROOT_USER` - `MINIO_ROOT_PASSWORD` @@ -97,16 +97,16 @@ On `cio-api`, set the SMTP host and any additional values you want. See the [Env ### Deploy -Click **Deploy**. Railway begins building and bringing the services online — Redis, Postgres, and the rest come up first. +Click **Deploy**. Railway begins building and bringing the services online: Redis, Postgres, and the rest come up first. ![Railway deploying the ClassroomIO services after clicking Deploy](/docs/railway-07-deploying.webp) -### First boot may crash — this is expected +### First boot may crash: this is expected :::warning[Only if you use the Railway-generated subdomain] -On the first deploy, `cio-api` can crash. This is expected **only when you rely on the Railway-generated subdomain**: the API needs the dashboard's public domain (Better Auth's base URL), which doesn't exist yet. If you're using your **own custom subdomain**, this crash doesn't happen — skip ahead to signing up. +On the first deploy, `cio-api` can crash. This is expected **only when you rely on the Railway-generated subdomain**: the API needs the dashboard's public domain (Better Auth's base URL), which doesn't exist yet. If you're using your **own custom subdomain**, this crash doesn't happen. Skip ahead to signing up. ::: ![cio-api crashing on first boot before the dashboard domain exists](/docs/railway-08-first-boot-crash.webp) @@ -141,7 +141,7 @@ Open the generated dashboard URL in a **new browser tab** and **sign up** to cre ## Environment variable reference -You set these values **while configuring each service on the template page** (the configure steps above) — you don't enter them again after deploying. Use this section as the reference for what each service's variables should be. +You set these values **while configuring each service on the template page** (the configure steps above); you don't enter them again after deploying. Use this section as the reference for what each service's variables should be. ### cio-api @@ -198,7 +198,7 @@ Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL ### cio-jobs (background worker) -The jobs worker has **no public domain** — it drains the Redis queue (video processing, transcription, AI course generation, and emails). It shares the same Postgres, Redis, storage, email, and OpenAI settings as the API: +The jobs worker has **no public domain**: it drains the Redis queue (video processing, transcription, AI course generation, and emails). It shares the same Postgres, Redis, storage, email, and OpenAI settings as the API: ```zsh title="env" DATABASE_URL="${{db.DATABASE_URL}}" @@ -225,7 +225,7 @@ OPENAI_API_KEY= ### cio-minio (object storage) -`cio-minio` ships **in the template** — its `/data` volume is already attached and the `videos`, +`cio-minio` ships **in the template**: its `/data` volume is already attached and the `videos`, `documents`, and `media` buckets are already seeded. You only set its credentials during configuration: @@ -239,11 +239,11 @@ MINIO_SERVER_URL=https://${{cio-minio.RAILWAY_PUBLIC_DOMAIN}} The api and jobs services reach MinIO over **two** endpoints: the private `OBJECT_STORAGE_ENDPOINT` (server uploads/reads) and the public `OBJECT_STORAGE_PUBLIC_ENDPOINT` + `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` -(browser-facing presigned downloads and public images) — already wired in the env blocks above. +(browser-facing presigned downloads and public images), already wired in the env blocks above. `OBJECT_STORAGE_FORCE_PATH_STYLE=true` is required for MinIO. :::warning[MinIO is your durability boundary] -Single-node MinIO has no redundancy — the Railway Volume is the only thing protecting your uploads, so +Single-node MinIO has no redundancy: the Railway Volume is the only thing protecting your uploads, so back it up. See [Configuration → Object storage & uploads](/self-hosted/configuration/storage) for external Cloudflare R2 / AWS S3 alternatives. ::: @@ -254,7 +254,7 @@ See [Configuration → Email (SMTP)](/self-hosted/configuration/email) for the S ### SSO (Enterprise) -SSO requires a `LICENSE_KEY` on the `cio-api` service. See [Configuration → SSO & licensing](/self-hosted/configuration/sso). +SSO requires a `LICENSE_KEY` on the `cio-api` service. See [Enterprise & licensing](/self-hosted/enterprise). ## Upgrades diff --git a/apps/docs/content/docs/self-hosted/troubleshooting.mdx b/apps/docs/content/docs/self-hosted/troubleshooting.mdx new file mode 100644 index 00000000..ba02dd5c --- /dev/null +++ b/apps/docs/content/docs/self-hosted/troubleshooting.mdx @@ -0,0 +1,128 @@ +--- +title: Troubleshooting +description: Diagnose common self-hosted ClassroomIO failures — sign-in errors, stuck uploads, out-of-memory kills, and disk issues. +--- + +Start here when something misbehaves. Every entry below is a failure mode we've seen in real +deployments, with its cause and fix. + +First, always check what's actually running: + +```zsh title="Terminal" +./classroomio.sh logs # follow all service logs +docker ps # anything restarting or exited? +``` + +## Quick reference + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Sign-in fails right after startup with a database error (`57P03`) | Postgres is still starting | Wait 30–60 s and retry, [details](#sign-in-fails-with-57p03-right-after-startup) | +| Login silently fails on your domain (no error, just back to the login page) | Serving over plain HTTP — session cookies are `Secure` | Serve over HTTPS — [details](/self-hosted#https-is-required-in-production) | +| Login loops or CSRF errors | `DASHBOARD_ORIGIN` doesn't match the URL users actually visit | Set it to the exact `https://` origin, restart | +| Uploads stuck at "processing" forever, no error | The `jobs` worker isn't running, or Redis is unreachable | [details](#uploads-stuck-at-processing) | +| Containers randomly killed, stack unstable | Out of memory — a transcode can exhaust a 4 GB server | [details](#containers-are-oom-killed) | +| `No space left on device`, MinIO "minimum free drive threshold" | Disk full (images, build cache, uploads) | [details](#no-space-left-on-device) | +| API container exits immediately at boot | Database migration failed | [details](#api-container-exits-at-boot) | +| No signup / invite / reset emails, logs show `ECONNREFUSED` | SMTP not configured or wrong host/port | Check [Email settings](/self-hosted/configuration/email) | +| Videos never get captions; no transcription | `OPENAI_API_KEY` not set | [details](#transcription-never-happens) | +| Crash loops with `read ECONNABORTED` during managed-Redis maintenance | A bug fixed upstream | Upgrade — [details](#crash-loops-during-redis-maintenance) | + +## Sign-in fails with 57P03 right after startup + +**Symptom:** immediately after starting the stack, sign-in returns a database error with Postgres +code `57P03` ("the database system is in recovery mode" / "is starting up"). + +**Cause:** Postgres reports itself ready (`pg_isready` passes) before it has finished crash +recovery, so the API can connect but queries fail. + +**Fix:** wait 30–60 seconds and retry. Recovery completes on its own. Current compose files +already guard against this: the Postgres healthcheck requires a successful `SELECT 1`, not just +`pg_isready`, so dependent services wait until queries actually work. If you see this repeatedly, +your compose file predates that healthcheck. Refresh it: + +```zsh title="Terminal" +curl -fsSLO https://raw.githubusercontent.com/classroomio/classroomio/main/docker-compose.images.yaml +./classroomio.sh restart +``` + +## Uploads stuck at "processing" + +Video processing, transcription, AI course generation, and most emails don't run in the API. They +are queued in Redis and executed by the separate **jobs** worker. If that worker is down (or the API +has no `REDIS_URL`), jobs are created but nothing ever picks them up: uploads sit at "processing" +with no error anywhere in the UI. + +```zsh title="Terminal" +docker ps | grep jobs # is cio-jobs up? +./classroomio.sh logs jobs # any errors? +``` + +The bundled compose file always starts the worker; on PaaS platforms make sure the jobs service +wasn't scaled to zero or crashed on boot. + +## Containers are OOM-killed + +The jobs worker spikes RAM while processing video, enough to get Postgres or other containers +killed on a 4 GB server even though the stack idles comfortably. See +[server requirements](/self-hosted#server-requirements). + +- Confirm with `docker inspect | grep -i oom` and `free -h`. +- Add swap as a buffer, or resize to 8 GB for reliable operation. + +## "No space left on device" + +Also surfaces as MinIO's "Storage reached its minimum free drive threshold". + +```zsh title="Terminal" +df -h # check host disk +docker system df # check Docker usage +docker builder prune -f # free build cache +docker image prune -a -f # free unused images +docker container prune -f # free stopped containers +``` + +Then restart: `./classroomio.sh start` + +:::danger[Never use `docker volume prune` or `down -v`] +`docker volume prune` removes volumes not attached to a running container: run after +`docker compose down`, it deletes your **database, Redis, and uploads** (`postgres-data`, +`redis-data`, `minio-data`). `docker compose down -v` does the same explicitly. Use only the prune +commands above to free space. +::: + +## API container exits at boot + +The API runs database migrations before it starts listening. If a migration fails, the container +exits rather than serve a half-migrated database. That's deliberate. + +```zsh title="Terminal" +./classroomio.sh logs api # the migration error is at the end +``` + +Common causes: wrong `DATABASE_URL`/`POSTGRES_*` credentials after an edit, or Postgres not yet +healthy. Fix the cause and `./classroomio.sh start` again. Migrations are safe to re-run. + +## Transcription never happens + +Transcription (video captions) requires `OPENAI_API_KEY` on the **jobs** service. Without it, media +jobs still complete (thumbnails and processing work), but the transcription step is skipped with a +warning in the jobs logs. Set the key and re-trigger transcription; see +[Integrations](/self-hosted/configuration/integrations). + +## Crash loops during Redis maintenance + +Older versions could crash-loop with cascading `read ECONNABORTED` errors when a managed Redis +provider (e.g. Redis Cloud) ran scheduled maintenance or failover. This was fixed upstream: the +stack now reconnects and resumes automatically. If you hit it, upgrade: + +```zsh title="Terminal" +./classroomio.sh upgrade +``` + +## Platform-specific issues + +Deploying on a DigitalOcean-style VPS with Caddy? That guide has its own +[troubleshooting table](/self-hosted/digitalocean#troubleshooting) covering certificates, firewalls, +and proxy issues. For anything else, [open an issue](https://github.com/classroomio/classroomio/issues) +and include the output of `./classroomio.sh logs`. diff --git a/apps/docs/content/docs/token-auth.mdx b/apps/docs/content/docs/token-auth.mdx index 7683fcf3..285e910f 100644 --- a/apps/docs/content/docs/token-auth.mdx +++ b/apps/docs/content/docs/token-auth.mdx @@ -12,7 +12,7 @@ Token Auth requires an **Enterprise** plan. **Cloud:** [Contact us](mailto:support@classroomio.com) to upgrade. -**Self-hosted:** Requires an Enterprise license key. Set `LICENSE_KEY` in the API service environment — see [Self Hosting → SSO & licensing](/self-hosted/configuration/sso). +**Self-hosted:** Requires an Enterprise license key. Set `LICENSE_KEY` in the API service environment — see [Self Hosting → Enterprise & licensing](/self-hosted/enterprise). ::: ## How It Works diff --git a/apps/docs/package.json b/apps/docs/package.json index 492194c1..74b35bda 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -17,7 +17,7 @@ "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "4.1.18", "astro": "7.0.9", - "blume": "1.1.2", + "blume": "1.2.0", "mermaid": "^11.16.0", "tailwindcss": "4.1.18" }, diff --git a/apps/docs/scripts/package-assets.mjs b/apps/docs/scripts/package-assets.mjs index 562aba1e..99674da6 100644 --- a/apps/docs/scripts/package-assets.mjs +++ b/apps/docs/scripts/package-assets.mjs @@ -45,6 +45,8 @@ const REDIRECTS = `# Generated by scripts/package-assets.mjs — do not edit. /docs/contributor-guides /docs/guides/contributing 301 /docs/contributor-guides/* /docs/guides/contributing/:splat 301 + +/docs/self-hosted/configuration/sso /docs/self-hosted/enterprise 301 ${GUIDE_REDIRECTS} `; diff --git a/apps/website/src/routes/(redirects)/classroomio.sh/+page.server.ts b/apps/website/src/routes/(redirects)/classroomio.sh/+page.server.ts new file mode 100644 index 00000000..ef523854 --- /dev/null +++ b/apps/website/src/routes/(redirects)/classroomio.sh/+page.server.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; +import { client } from '$lib/utils/posthog'; + +export const prerender = false; + +export const load = ({ request }) => { + client.capture({ + distinctId: request.headers.get('x-forwarded-for') || new Date().getTime().toString(), + event: 'classroomio.sh downloaded' + }); + + redirect(307, 'https://raw.githubusercontent.com/classroomio/classroomio/main/classroomio.sh'); +}; diff --git a/classroomio.sh b/classroomio.sh new file mode 100755 index 00000000..450c92a7 --- /dev/null +++ b/classroomio.sh @@ -0,0 +1,666 @@ +#!/usr/bin/env bash +# +# ClassroomIO self-host lifecycle manager. +# +# Run with no arguments for an interactive menu, or pass a subcommand directly: +# +# ./classroomio.sh # interactive menu +# ./classroomio.sh install # first-time setup: fetch files, create .env, start +# ./classroomio.sh start # non-interactive start (env auto-setup + up -d) +# ./classroomio.sh stop|restart|upgrade|logs|backup +# +# The script is standalone: run it in an empty directory and `install` downloads +# docker-compose.images.yaml + .env.example from GitHub. Inside a repo checkout it +# uses the local files. It never overwrites an existing .env or compose file. +# +# The compose file tracks `main` (the stack topology); CIO_VERSION in .env pins the +# app images. Pin an exact release (e.g. 1.4.2) in production. + +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_NAME="classroomio" +ENV_FILE="${ROOT_DIR}/.env" +ENV_EXAMPLE_FILE="${ROOT_DIR}/.env.example" +BUILD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.yaml" +IMAGES_COMPOSE_FILE="${ROOT_DIR}/docker-compose.images.yaml" +RAW_BASE_URL="https://raw.githubusercontent.com/classroomio/classroomio/main" + +# Default to PULLING pre-built images (fast). Pass --build to build from source. +USE_IMAGES=true +COMPOSE_FILE="${IMAGES_COMPOSE_FILE}" +# --profile flags live in ONE variable so every command (start, stop, logs, backup) +# sees the same profile state. --no-minio empties it. +PROFILE_ARGS="--profile minio" + +print_usage() { + cat <<'USAGE' +Usage: ./classroomio.sh [options] [command] [args] + +Commands: + install First-time setup: fetch compose file + .env.example (if missing), + create .env, generate secrets, pull images and start the stack. + start Start the stack (auto-generates missing secrets first). + stop Stop all containers (data volumes are kept). + restart Restart all containers. + upgrade Back up first, then pull newer images and restart. + logs [svc] Follow logs (all services, or one: api, dashboard, jobs, postgres, ...). + backup Dump Postgres and archive the MinIO volume into ./backups/. + +Run without a command to get an interactive menu. + +Options: + --build Use docker-compose.yaml and build images from source instead of pulling. + Requires a local repo checkout (git clone) — not available in a + standalone directory set up via 'install'. + --no-minio Exclude the bundled MinIO (requires external S3-compatible storage). + -h, --help Show this help message. +USAGE +} + +# ────────────────────────────────────────────── +# Compose CLI detection: `docker compose` (v2) or legacy `docker-compose`. +# ────────────────────────────────────────────── + +COMPOSE_CMD="" + +detect_compose() { + if ! command -v docker >/dev/null 2>&1; then + echo "Error: docker is not installed or not on PATH." + exit 1 + fi + if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD="docker compose" + elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" + else + echo "Error: neither 'docker compose' nor 'docker-compose' is available." + exit 1 + fi +} + +# All compose invocations go through this so the env file, project name, compose +# file, and profiles are consistent everywhere. Word-splitting of COMPOSE_CMD and +# PROFILE_ARGS is intentional. +compose() { + # shellcheck disable=SC2086 + ${COMPOSE_CMD} --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${PROFILE_ARGS} "$@" +} + +# ────────────────────────────────────────────── +# .env helpers +# ────────────────────────────────────────────── + +get_env_value() { + local key="$1" + local line + line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)" + printf '%s' "${line#*=}" +} + +upsert_env_value() { + local key="$1" + local value="$2" + local tmp_file + + tmp_file="$(mktemp)" + awk -v key="${key}" -v value="${value}" ' + BEGIN { updated = 0 } + $0 ~ ("^" key "=") { + if (updated == 0) { + print key "=" value; + updated = 1; + } + next; + } + { print } + END { + if (updated == 0) { + print key "=" value; + } + } + ' "${ENV_FILE}" > "${tmp_file}" + + mv "${tmp_file}" "${ENV_FILE}" +} + +is_insecure_token_value() { + local value="$1" + local normalized + + normalized="$(printf '%s' "${value}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + + if [[ "${normalized}" =~ ^\"(.*)\"$ ]]; then + normalized="${BASH_REMATCH[1]}" + elif [[ "${normalized}" =~ ^\'(.*)\'$ ]]; then + normalized="${BASH_REMATCH[1]}" + fi + + case "${normalized}" in + "" | "replace-with-a-long-random-token" | "local-dev-api-key" | "changeme" | "replace-me") + return 0 + ;; + replace-with-* | *local-dev* | *change-this* | *your-*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +generate_secure_token() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return + fi + + if command -v node >/dev/null 2>&1; then + node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + return + fi + + if command -v python3 >/dev/null 2>&1; then + python3 -c "import secrets; print(secrets.token_hex(32))" + return + fi + + if [[ -r /dev/urandom ]]; then + head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' + echo + return + fi + + # Last resort only: no openssl, node, python3, or /dev/urandom. A timestamp-derived + # value is weak (guessable within the install's time window) but better than failing. + date +%s%N | shasum | awk '{ print $1 }' +} + +ensure_secure_auth_tokens() { + if [[ ! -f "${ENV_FILE}" ]]; then + touch "${ENV_FILE}" + fi + + local private_server_key + private_server_key="$(get_env_value PRIVATE_SERVER_KEY)" + + # PRIVATE_SERVER_KEY authenticates the dashboard's server-side calls to the API and must be + # identical in both services. Generate a strong value when it's empty or a known placeholder. + if is_insecure_token_value "${private_server_key}"; then + upsert_env_value PRIVATE_SERVER_KEY "$(generate_secure_token)" + echo "Generated secure PRIVATE_SERVER_KEY in .env" + fi +} + +ensure_secure_betterauth_secret() { + if [[ ! -f "${ENV_FILE}" ]]; then + touch "${ENV_FILE}" + fi + + local secret + secret="$(get_env_value BETTER_AUTH_SECRET)" + + # Only (re)generate when the value is empty or a known placeholder; never clobber + # a strong secret the user has set themselves. + if is_insecure_token_value "${secret}"; then + upsert_env_value BETTER_AUTH_SECRET "$(generate_secure_token)" + echo "Generated secure BETTER_AUTH_SECRET in .env" + fi +} + +is_local_origin() { + local value="$1" + case "${value}" in + "" | *localhost* | *127.0.0.1* | *0.0.0.0*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Point browser-facing storage URLs at the public domain when DASHBOARD_ORIGIN is not +# localhost. MinIO media is served on :9000 — operators must reverse-proxy /media to it. +# `mode` is "bundled" (ensure_minio_env) or "external" (ensure_storage_env's --no-minio +# path). For bundled MinIO, OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL is script-owned, so we +# keep it in sync with DASHBOARD_ORIGIN on every run — otherwise changing DASHBOARD_ORIGIN +# after install would leave media links pointed at the old domain forever. For external +# storage it's operator-owned (their CDN/bucket URL), so we only fill in a default and +# never clobber an explicit value. +derive_public_storage_urls() { + local mode="$1" + local dashboard_origin public_endpoint media_base + dashboard_origin="$(get_env_value DASHBOARD_ORIGIN)" + public_endpoint="$(get_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT)" + media_base="$(get_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL)" + + if ! is_local_origin "${dashboard_origin}"; then + local origin="${dashboard_origin%/}" + local derived="${origin}/media" + # Public media is served from the public-download `media` bucket, so deriving its base + # URL from the domain is what fixes broken media links in served pages. We deliberately + # do NOT touch OBJECT_STORAGE_PUBLIC_ENDPOINT (the S3 API endpoint) — pointing it at the + # dashboard origin would break presigned URLs for the non-public videos/documents buckets. + if [[ "${mode}" == "bundled" ]]; then + if [[ "${media_base}" != "${derived}" ]]; then + upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${derived}" + echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${derived})." + echo " -> Ensure your reverse proxy routes ${origin}/media to MinIO (port 9000)." + echo " -> For presigned access to the videos/documents buckets behind a domain, expose" + echo " MinIO's S3 API on its own route and set OBJECT_STORAGE_PUBLIC_ENDPOINT explicitly." + fi + else + if is_local_origin "${media_base}"; then + upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${derived}" + echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${derived})." + fi + fi + else + # Local demo: ensure localhost defaults are present. + if [[ -z "${public_endpoint}" ]]; then + upsert_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT "http://localhost:9000" + fi + if [[ -z "${media_base}" ]]; then + upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "http://localhost:9000/media" + fi + fi +} + +ensure_minio_env() { + if [[ ! -f "${ENV_FILE}" ]]; then + touch "${ENV_FILE}" + fi + + local minio_password + minio_password="$(get_env_value MINIO_ROOT_PASSWORD)" + + # First provision: randomize credentials whenever the MinIO password is still empty or the + # well-known `minioadmin` placeholder shipped in .env.example. (Previously this gated on + # OBJECT_STORAGE_ENDPOINT being empty, but .env.example ships it non-empty — so on a normal + # copy-the-example first run the randomization was always skipped and minioadmin/minioadmin + # survived.) MinIO uses its root credentials as the S3 access key / secret. + if [[ -z "${minio_password}" || "${minio_password}" =~ ^[[:space:]]*$ || "${minio_password}" == "minioadmin" ]]; then + local minio_user + minio_user="cio-$(generate_secure_token | cut -c1-16)" + minio_password="$(generate_secure_token)" + + upsert_env_value MINIO_ROOT_USER "${minio_user}" + upsert_env_value MINIO_ROOT_PASSWORD "${minio_password}" + upsert_env_value OBJECT_STORAGE_ENDPOINT "http://minio:9000" + upsert_env_value OBJECT_STORAGE_ACCESS_KEY_ID "${minio_user}" + upsert_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY "${minio_password}" + upsert_env_value OBJECT_STORAGE_FORCE_PATH_STYLE "true" + echo "Provisioned MinIO with randomized credentials in .env" + fi + + derive_public_storage_urls "bundled" +} + +# ────────────────────────────────────────────── +# Shared checks +# ────────────────────────────────────────────── + +# When MinIO is excluded the user must supply external object storage, or uploads/media +# silently break. Fail fast instead. The endpoint must be set AND not still point at the +# bundled MinIO host (which won't be running with --no-minio). +ensure_storage_env() { + if [[ "${PROFILE_ARGS}" == *"minio"* ]]; then + ensure_minio_env + else + local external_endpoint + external_endpoint="$(get_env_value OBJECT_STORAGE_ENDPOINT)" + if [[ -z "${external_endpoint}" || "${external_endpoint}" == *"minio:9000"* ]]; then + echo "Error: --no-minio was passed but no external object storage is configured." + echo "OBJECT_STORAGE_ENDPOINT is unset or still points at the bundled MinIO (minio:9000)." + echo "Configure an external S3-compatible store via OBJECT_STORAGE_* in .env," + echo "or drop --no-minio to use the bundled MinIO." + exit 1 + fi + derive_public_storage_urls "external" + fi +} + +warn_if_unpinned_version() { + local version + version="$(get_env_value CIO_VERSION)" + if [[ -z "${version}" || "${version}" == "latest" ]]; then + echo + echo "WARNING: CIO_VERSION is '${version:-unset}' — you are tracking the rolling 'latest' tag." + echo " 'latest' moves on every merge to main and may include unreleased code." + echo " For production, pin an exact release in .env, e.g.: CIO_VERSION=1.4.2" + echo " Available tags: https://hub.docker.com/r/classroomio/api/tags" + echo + fi +} + +# `docker compose up -d` returns once containers are *started*, not *healthy*. On a fresh +# deployment the API runs DB migrations before it listens, so the endpoints aren't reachable +# immediately. Poll with a retry instead of a single curl — and never let a not-yet-ready +# check abort the script (set -euo pipefail is active), so first runs end cleanly. +check_endpoint() { + local name="$1" url="$2" curl_flags="$3" attempts="$4" + local i=0 + while ((i < attempts)); do + if curl "${curl_flags}" --max-time 5 "${url}" >/dev/null 2>&1; then + echo "${name} is reachable on ${url}" + return 0 + fi + i=$((i + 1)) + sleep 5 + done + echo "Note: ${name} is not reachable yet at ${url} (waited $((attempts * 5))s)." + echo " It may still be starting — the first run migrates the database. Follow logs with:" + echo " ./classroomio.sh logs" + return 0 +} + +wait_for_endpoints() { + if command -v curl >/dev/null 2>&1; then + echo + echo "Waiting for services to come up (first run can take a minute while the API migrates)..." + check_endpoint "API" "http://localhost:3081/" "-fsS" 24 + check_endpoint "Dashboard" "http://localhost:3082/" "-fsSI" 12 + else + echo + echo "curl not found, skipped endpoint checks." + fi +} + +fetch_if_missing() { + local file="$1" url="$2" + if [[ -f "${file}" ]]; then + echo "Keeping existing $(basename "${file}") (not overwritten)." + return 0 + fi + echo "Downloading $(basename "${file}")..." + # Download to a temp name and move into place so a failed transfer can never + # leave a partial file that a re-run would mistake for a kept local copy. + local tmp="${file}.download" + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 --retry-delay 2 -o "${tmp}" "${url}" || { rm -f "${tmp}"; echo "Error: failed to download ${url}"; exit 1; } + elif command -v wget >/dev/null 2>&1; then + wget -q -O "${tmp}" "${url}" || { rm -f "${tmp}"; echo "Error: failed to download ${url}"; exit 1; } + else + echo "Error: need curl or wget to download $(basename "${file}")." + exit 1 + fi + mv "${tmp}" "${file}" +} + +prepare_env_and_secrets() { + ensure_secure_auth_tokens + ensure_secure_betterauth_secret + ensure_storage_env +} + +# ────────────────────────────────────────────── +# Commands +# ────────────────────────────────────────────── + +cmd_install() { + echo "Installing ClassroomIO..." + echo + + if [[ "${USE_IMAGES}" != "true" && ! -f "${BUILD_COMPOSE_FILE}" ]]; then + echo "Error: --build install requires a local repo checkout (docker-compose.yaml not found)." + echo "Clone the repository instead, or run 'install' without --build to pull pre-built images." + exit 1 + fi + + if [[ "${USE_IMAGES}" == "true" ]]; then + # The compose file tracks main (stack topology only — CIO_VERSION pins the app + # images). Never overwritten; refresh manually if you need a newer topology: + # curl -fsSLO ${RAW_BASE_URL}/docker-compose.images.yaml + fetch_if_missing "${IMAGES_COMPOSE_FILE}" "${RAW_BASE_URL}/docker-compose.images.yaml" + fi + fetch_if_missing "${ENV_EXAMPLE_FILE}" "${RAW_BASE_URL}/.env.example" + + if [[ -f "${ENV_FILE}" ]]; then + echo "Keeping existing .env (not overwritten)." + else + cp "${ENV_EXAMPLE_FILE}" "${ENV_FILE}" + echo "Created .env from .env.example" + fi + + prepare_env_and_secrets + warn_if_unpinned_version + + echo "Review these values in .env before going to production:" + echo " DASHBOARD_ORIGIN (your public https:// dashboard URL)" + echo " SMTP_* (outgoing email — invites, notifications)" + echo " CIO_VERSION (pin an exact release for production)" + echo + + cmd_start +} + +cmd_start() { + prepare_env_and_secrets + warn_if_unpinned_version + + echo "Starting ClassroomIO..." + if [[ "${PROFILE_ARGS}" == *"minio"* ]]; then + echo "Including MinIO (object storage, default)..." + fi + if [[ "${USE_IMAGES}" == "true" ]]; then + echo "Using pre-built images from Docker Hub (CIO_VERSION=$(get_env_value CIO_VERSION))..." + compose pull + compose up -d + else + compose up --build -d + fi + + echo + echo "Current service status:" + compose ps + + wait_for_endpoints + + echo + echo "Done. Full stack is running." +} + +cmd_stop() { + echo "Stopping ClassroomIO (data volumes are kept)..." + compose stop + echo "Stopped. Start again with: ./classroomio.sh start" +} + +cmd_restart() { + prepare_env_and_secrets + echo "Restarting ClassroomIO..." + # `compose restart` only bounces the container *process* — it never re-reads .env, so + # edits to DASHBOARD_ORIGIN/SMTP_*/LICENSE_KEY/etc. were silently ignored. Recreate + # instead: --force-recreate guarantees an actual restart even when config is + # unchanged, without pulling new images (that's what `upgrade` is for). + compose up -d --force-recreate + compose ps + wait_for_endpoints +} + +cmd_logs() { + compose logs -f --tail=100 "$@" +} + +cmd_backup() { + local ts backup_dir db_user db_name + ts="$(date +%Y%m%d-%H%M%S)" + backup_dir="${ROOT_DIR}/backups" + mkdir -p "${backup_dir}" + + db_user="$(get_env_value POSTGRES_USER)" + db_user="${db_user:-postgres}" + db_name="$(get_env_value POSTGRES_DB)" + db_name="${db_name:-classroomio}" + + echo "Backing up Postgres database '${db_name}'..." + local db_file="${backup_dir}/classroomio-db-${ts}.sql.gz" + # pg_dump failure must fail the backup (and abort an upgrade) — don't let gzip's + # exit code mask it. + if ! compose exec -T postgres pg_dump -U "${db_user}" "${db_name}" | gzip > "${db_file}"; then + rm -f "${db_file}" + echo "Error: Postgres backup failed. Is the stack running? (./classroomio.sh start)" + return 1 + fi + echo " -> ${db_file}" + + # MinIO volume name is derived from the compose project name — same variable the + # -p flag uses, so a project rename can't silently desync the backup target. + local minio_volume="${PROJECT_NAME}_minio-data" + if docker volume inspect "${minio_volume}" >/dev/null 2>&1; then + echo "Backing up MinIO volume '${minio_volume}'..." + local minio_file="classroomio-minio-${ts}.tar.gz" + # Git Bash (MSYS) rewrites container paths like /data into Windows paths; disable + # conversion for this one command and pre-convert the host dir with cygpath. + # On Linux/macOS cygpath doesn't exist and the fallback keeps the path as-is. + local backup_dir_host + backup_dir_host="$(cygpath -w "${backup_dir}" 2>/dev/null || printf '%s' "${backup_dir}")" + if ! MSYS_NO_PATHCONV=1 docker run --rm \ + -v "${minio_volume}:/data:ro" \ + -v "${backup_dir_host}:/backup" \ + alpine tar czf "/backup/${minio_file}" -C /data .; then + echo "Error: MinIO volume backup failed." + return 1 + fi + echo " -> ${backup_dir}/${minio_file}" + else + # Not an error: --no-minio installs use external storage and have no local volume. + echo "MinIO volume '${minio_volume}' not found — skipping object-storage backup." + echo " (Using external S3/R2? Back that up with your provider's tools.)" + fi + + echo "Backup complete: ${backup_dir}" + echo "Copy backups off this machine — a server failure takes local backups with it." +} + +cmd_upgrade() { + prepare_env_and_secrets + echo "Upgrading ClassroomIO..." + echo + echo "Step 1/3: backing up before touching images..." + cmd_backup + + echo + if [[ "${USE_IMAGES}" == "true" ]]; then + # CIO_VERSION only selects a published image tag — meaningless for --build, + # which always builds whatever is in the local checkout. + local current_version + current_version="$(get_env_value CIO_VERSION)" + echo "Step 2/3: current CIO_VERSION is '${current_version:-unset}'." + if [[ -t 0 ]]; then + read -r -p "Enter a new version tag to pin (or press Enter to keep the current one): " new_version + if [[ -n "${new_version}" ]]; then + upsert_env_value CIO_VERSION "${new_version}" + echo "Set CIO_VERSION=${new_version} in .env" + fi + fi + warn_if_unpinned_version + else + echo "Step 2/3: building from source — CIO_VERSION doesn't apply." + fi + + if [[ "${USE_IMAGES}" == "true" ]]; then + echo "Step 3/3: pulling images and restarting..." + compose pull + compose up -d + else + echo "Step 3/3: rebuilding from source and restarting..." + compose up --build -d + fi + compose ps + wait_for_endpoints + echo + echo "Upgrade complete." + if [[ "${USE_IMAGES}" == "true" ]]; then + echo "Roll back by setting CIO_VERSION back in .env and running: ./classroomio.sh upgrade" + fi +} + +show_menu() { + echo "ClassroomIO self-host manager" + echo + echo " 1) Install (first-time setup: fetch files, create .env, start)" + echo " 2) Start" + echo " 3) Stop" + echo " 4) Restart" + echo " 5) Upgrade (backs up first, then pulls new images)" + echo " 6) View logs" + echo " 7) Backup (Postgres dump + MinIO volume archive)" + echo " q) Quit" + echo + read -r -p "Pick an option: " choice + case "${choice}" in + 1) cmd_install ;; + 2) cmd_start ;; + 3) cmd_stop ;; + 4) cmd_restart ;; + 5) cmd_upgrade ;; + 6) cmd_logs ;; + 7) cmd_backup ;; + q|Q) exit 0 ;; + *) + echo "Unknown option: ${choice}" + exit 1 + ;; + esac +} + +# ────────────────────────────────────────────── +# Argument parsing +# ────────────────────────────────────────────── + +# Options may appear before or after the command: `./classroomio.sh start --build` +# and `./classroomio.sh --build start` both work. +COMMAND="" +COMMAND_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --build) + USE_IMAGES=false + COMPOSE_FILE="${BUILD_COMPOSE_FILE}" + shift + ;; + --no-minio) + PROFILE_ARGS="" + shift + ;; + -h|--help) + print_usage + exit 0 + ;; + install|start|stop|restart|upgrade|logs|backup) + if [[ -n "${COMMAND}" ]]; then + echo "Unexpected extra command: $1 (already running '${COMMAND}')" + print_usage + exit 1 + fi + COMMAND="$1" + shift + ;; + *) + if [[ "${COMMAND}" == "logs" ]]; then + COMMAND_ARGS+=("$1") + shift + else + echo "Unknown option: $1" + print_usage + exit 1 + fi + ;; + esac +done + +detect_compose + +case "${COMMAND}" in + "") show_menu ;; + install) cmd_install ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_restart ;; + upgrade) cmd_upgrade ;; + logs) cmd_logs "${COMMAND_ARGS[@]+"${COMMAND_ARGS[@]}"}" ;; + backup) cmd_backup ;; +esac diff --git a/docker-compose.images.yaml b/docker-compose.images.yaml index a9bb65db..91466ac6 100644 --- a/docker-compose.images.yaml +++ b/docker-compose.images.yaml @@ -132,8 +132,8 @@ services: OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY:-} OBJECT_STORAGE_FORCE_PATH_STYLE: ${OBJECT_STORAGE_FORCE_PATH_STYLE:-true} OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-} - # Auth secrets — required. Set strong values in .env (the pull path has no script to - # generate them); the stack refuses to start otherwise. + # Auth secrets — required. ./classroomio.sh generates them into .env; if you run + # compose directly, set strong values yourself — the stack refuses to start otherwise. PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:?set a strong PRIVATE_SERVER_KEY in .env} DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN:-} LICENSE_KEY: ${LICENSE_KEY:-} diff --git a/docker-compose.yaml b/docker-compose.yaml index d0888f2b..8a564453 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -126,7 +126,7 @@ services: OBJECT_STORAGE_SECRET_ACCESS_KEY: ${OBJECT_STORAGE_SECRET_ACCESS_KEY:-} OBJECT_STORAGE_FORCE_PATH_STYLE: ${OBJECT_STORAGE_FORCE_PATH_STYLE:-true} OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-} - # Auth secrets — required. Generated by run-docker-full-stack.sh; set strong values + # Auth secrets — required. Generated by classroomio.sh; set strong values # in .env if you start the stack with `docker compose` directly. PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:?set a strong PRIVATE_SERVER_KEY in .env} DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN:-} diff --git a/docker/docs/SELF_HOST.md b/docker/docs/SELF_HOST.md index 4b7d8843..e38de7e5 100644 --- a/docker/docs/SELF_HOST.md +++ b/docker/docs/SELF_HOST.md @@ -12,20 +12,20 @@ git clone https://github.com/classroomio/classroomio.git cd classroomio cp .env.example .env # Edit .env — set your dashboard domain and secrets -./run-docker-full-stack.sh +./classroomio.sh start ``` -The script builds and starts: **postgres**, **redis**, **minio** (object storage), **api** (`localhost:3081`), **dashboard** (`localhost:3082`), and the **jobs** worker (background processing). Database schema setup runs automatically inside the `api` container on startup (see [`docker/entrypoint-api.sh`](../entrypoint-api.sh); skip with `SKIP_DB_SETUP=true`). +The script pulls and starts: **postgres**, **redis**, **minio** (object storage), **api** (`localhost:3081`), **dashboard** (`localhost:3082`), and the **jobs** worker (background processing). Pass `--build` to build the application images from this checkout instead of pulling published ones. Database schema setup runs automatically inside the `api` container on startup (see [`docker/entrypoint-api.sh`](../entrypoint-api.sh); skip with `SKIP_DB_SETUP=true`). It auto-generates secure values for `PRIVATE_SERVER_KEY` and `BETTER_AUTH_SECRET` when they are missing or left at placeholders, and randomizes MinIO credentials on first provision. -> **The jobs worker is required, not optional.** Without it, video processing, captions/transcription, AI course generation, and most emails are enqueued in Redis but never run — uploads stay stuck "processing" with no error. See [Background Jobs Worker](#background-jobs-worker) below. +> **The jobs worker is required, not optional.** Without it, video processing, captions/transcription, AI course generation, and most emails are enqueued in Redis but never run: uploads stay stuck "processing" with no error. See [Background Jobs Worker](#background-jobs-worker) below. ## Run from Pre-built Images (no build) -The Quick Start builds images from source — that needs the full repo and ~8 GB RAM for the -dashboard build. Most self-hosters should instead **pull** the published `classroomio/{api,dashboard,jobs}` -images. You only need [`docker-compose.images.yaml`](../../docker-compose.images.yaml) and a `.env`: +The Quick Start already pulls the published `classroomio/{api,dashboard,jobs}` images by default. +To skip cloning the full repository, you only need +[`docker-compose.images.yaml`](../../docker-compose.images.yaml) and a `.env`: ```bash # 1. Get just the compose file + env template (no full clone required) @@ -37,10 +37,18 @@ CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env p CIO_VERSION=1.4.2 docker compose -f docker-compose.images.yaml --env-file .env up -d ``` -From a full clone you can also use the helper: `./run-docker-full-stack.sh --images`. +Easier: let the lifecycle script do all of the above (it also auto-generates secrets). It works in +an empty directory: `install` downloads the compose file and `.env.example` for you: -> Secrets aren't auto-generated in this path (the helper script does that). Set `BETTER_AUTH_SECRET` -> and `PRIVATE_SERVER_KEY` to strong random values yourself. +```bash +curl -fsSLO https://classroomio.com/classroomio.sh +chmod +x classroomio.sh +./classroomio.sh install +``` + +> If you run the raw `docker compose` commands instead of the script, secrets aren't +> auto-generated. Set `BETTER_AUTH_SECRET` and `PRIVATE_SERVER_KEY` to strong random values +> yourself. ## Versioning & Upgrades @@ -65,30 +73,40 @@ CIO_VERSION=1.5.0 docker compose -f docker-compose.images.yaml --env-file .env u Schema migrations run automatically on `api` startup (`db:setup`), so upgrades need no manual DB step. If a release consolidates ("squashes") older migrations, the startup baseline detects an -existing instance and adopts the new baseline as already-applied — your database is upgraded in +existing instance and adopts the new baseline as already-applied, and your database is upgraded in place without re-running schema that already exists. -(Maintainers: every published image is boot-smoke-tested in CI before it ships — see +(Maintainers: every published image is boot-smoke-tested in CI before it ships. See [`PUBLISHING_IMAGES.md`](PUBLISHING_IMAGES.md).) ## Environment Variables All Docker services read from a single root `.env` file. See [`.env.example`](../../.env.example) for the full annotated template with required/optional grouping. -Key points: +Two variables actually need your attention: `PUBLIC_IS_SELFHOSTED=true` and `DASHBOARD_ORIGIN`, +which you should set to your real domain in production (it also drives public media URLs; see +[Production Object Storage](#production-object-storage)). Everything else below has a sane +default. -- **Required:** `PUBLIC_IS_SELFHOSTED=true`, `DASHBOARD_ORIGIN` (set this to your real domain in production — it also drives public media URLs, see [Production Object Storage](#production-object-storage)). -- **API routing:** Browser dashboard calls go to the dashboard origin and are proxied to `PRIVATE_SERVER_URL` (default: `http://api:3081`). You do not need API/dashboard cookie-domain matching for normal dashboard auth. -- **Direct API access (optional):** Set `PUBLIC_SERVER_URL` and `TRUSTED_ORIGINS` only if browsers or third-party clients need to call the API origin directly. -- **CSP (runtime):** `ALLOWED_EXTERNAL_DOMAINS` (overrides all) or per-directive: `CSP_SCRIPT_SRC_DOMAINS`, `CSP_STYLE_SRC_DOMAINS`, `CSP_CONNECT_SRC_DOMAINS`, `CSP_FRAME_SRC_DOMAINS`, `CSP_FONT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`. These are read at container startup — no image rebuild needed. The API does not need to be added for normal dashboard calls. -- **Auth cookies:** No cookie-domain env is needed. The dashboard proxy makes auth first-party and Better Auth sets host-only dashboard cookies. -- **Auto-generated:** `PRIVATE_SERVER_KEY`, `BETTER_AUTH_SECRET` (by `./run-docker-full-stack.sh`). A strong value you set yourself is never overwritten. -- **Auto-configured:** All `MINIO_*` / `OBJECT_STORAGE_*` vars (by the startup script, with randomized MinIO credentials). -- **Email (effectively required):** SMTP (or Zoho). See [Email](#email) — without it, signup verification, password reset, and invites do not send. -- **Optional:** Google OAuth, Unsplash, `LICENSE_KEY` (enterprise). -- **Optional — upload limits:** `UPLOAD_MAX_*_MB` vars (documents, images, videos, assignment files, etc.). See [`.env.example`](../../.env.example). Passed through to both `api` and `dashboard` containers; unset values use built-in defaults. Raise your reverse-proxy body-size limit too if you increase these. +Routing and auth are handled for you. Browser dashboard calls go to the dashboard origin and get +proxied to `PRIVATE_SERVER_URL` (default `http://api:3081`), so normal dashboard auth needs no +cookie-domain matching between the API and dashboard: the proxy makes it first-party, and Better +Auth sets host-only cookies. Only set `PUBLIC_SERVER_URL` and `TRUSTED_ORIGINS` if a browser or +third-party client needs to call the API directly. CSP is read at container startup rather than +baked into the image, so `ALLOWED_EXTERNAL_DOMAINS` (or the per-directive `CSP_SCRIPT_SRC_DOMAINS`, +`CSP_STYLE_SRC_DOMAINS`, `CSP_CONNECT_SRC_DOMAINS`, `CSP_FRAME_SRC_DOMAINS`, `CSP_FONT_SRC_DOMAINS`, +`CSP_MEDIA_SRC_DOMAINS` vars) take effect after `./classroomio.sh restart` (no image rebuild +needed), and none of it needs to include the API for normal dashboard calls. -`PRIVATE_SERVER_KEY` must be the same value in both API and dashboard — the script ensures this. +`PRIVATE_SERVER_KEY` and `BETTER_AUTH_SECRET` are generated for you by `./classroomio.sh` if you +leave them blank, and a value you set yourself is never overwritten; the script also ensures +`PRIVATE_SERVER_KEY` matches between API and dashboard. The same goes for the `MINIO_*` / +`OBJECT_STORAGE_*` vars: the startup script configures them with randomized credentials. Email is +effectively required: without SMTP (or Zoho) configured, signup verification, password resets, and +invites silently don't send (see [Email](#email)). Google OAuth, Unsplash, and `LICENSE_KEY` stay +off until you set them. If you raise the `UPLOAD_MAX_*_MB` vars (documents, images, videos, +assignment files; full list in [`.env.example`](../../.env.example)), set the same values on both +`api` and `dashboard`, and raise your reverse-proxy body-size limit to match. ## Verify @@ -109,7 +127,7 @@ docker exec cio-api pnpm --filter @cio/db db:setup:seed ## Command Reference -All commands use this prefix — abbreviated as `dc` below: +All commands use this prefix, abbreviated as `dc` below: ``` dc = docker compose --env-file .env -p classroomio -f docker-compose.yaml @@ -117,14 +135,18 @@ dc = docker compose --env-file .env -p classroomio -f docker-compose.yaml | Task | Command | |------|---------| -| Full stack (pulls pre-built images, with MinIO) | `./run-docker-full-stack.sh` | -| Build from source instead of pulling | `./run-docker-full-stack.sh --build` | -| Exclude MinIO | `./run-docker-full-stack.sh --no-minio` | +| Interactive lifecycle menu (install/start/stop/upgrade/logs/backup) | `./classroomio.sh` | +| Full stack (pulls pre-built images, with MinIO) | `./classroomio.sh start` | +| Build from source instead of pulling | `./classroomio.sh start --build` | +| Exclude MinIO | `./classroomio.sh start --no-minio` | +| Back up Postgres + MinIO volume | `./classroomio.sh backup` | +| Upgrade published images (backs up first) | `./classroomio.sh upgrade` | +| Upgrade a source build (backs up first, then rebuilds) | `./classroomio.sh upgrade --build` | | API-only smoke test | `dc up --build -d postgres redis api` | | Service status | `dc ps` | | Stream logs | `dc logs -f api dashboard` | | Rebuild one service | `dc up -d --build api` | -| Restart (env change, no rebuild) | `dc restart api dashboard` | +| Restart (recreates containers, applies .env changes) | `./classroomio.sh restart` | | Stop | `dc down` | | Stop + delete volumes | `dc down -v` | @@ -177,11 +199,11 @@ Data is preserved in the `minio-data` volume. The `jobs` service (`cio-jobs`) is a BullMQ worker that processes everything ClassroomIO does asynchronously: -- **Video processing** — probe, thumbnails, audio extraction, transcoding -- **Transcription / captions** — via OpenAI Whisper (needs `OPENAI_API_KEY`) -- **AI course generation** — the "generate a course with AI" agent -- **Emails** — invites, notifications, and other non-auth emails -- **Maintenance** — periodic cleanup +- **Video processing:** probe, thumbnails, audio extraction, transcoding +- **Transcription / captions:** via OpenAI Whisper (needs `OPENAI_API_KEY`) +- **AI course generation:** the "generate a course with AI" agent +- **Emails:** invites, notifications, and other non-auth emails +- **Maintenance:** periodic cleanup It shares Redis and the database with the API. It is started automatically by the full-stack script. If it is not running, jobs accumulate in Redis and silently never complete. @@ -211,7 +233,7 @@ The bundled MinIO is fine for a single host. Two things matter for a real deploy 1. **Public media URLs must point at your domain, not `localhost`.** Media URLs are embedded in pages served to browsers, so `http://localhost:9000/...` breaks for every remote visitor. The startup script derives `OBJECT_STORAGE_PUBLIC_ENDPOINT` and - `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` from `DASHBOARD_ORIGIN` when it is a real domain — make + `OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL` from `DASHBOARD_ORIGIN` when it is a real domain. Make sure your reverse proxy routes `/media` to MinIO on port 9000 (or set those two variables explicitly). 2. **External S3 / Cloudflare R2 instead of MinIO.** Run with `--no-minio` and set the @@ -222,7 +244,7 @@ The bundled MinIO is fine for a single host. Two things matter for a real deploy ## Backups & Persistence All state lives in named Docker volumes: `postgres-data`, `redis-data`, `minio-data` -(see `docker-compose.yaml`). **`docker compose ... down -v` deletes them — and all your +(see `docker-compose.yaml`). **`docker compose ... down -v` deletes them, and all your data.** Back up regularly: ```bash @@ -240,7 +262,7 @@ Restore the database into a fresh stack with `psql`/`pg_restore` against `cio-po API replicas**, set `SKIP_DB_SETUP=true` on all but one to avoid concurrent migration races, and run setup once out-of-band. The worker queues can also be scaled independently by overriding the `jobs` container command with the per-queue `start:*` scripts (e.g. `start:media`, -`start:media-transcribe`) — see `apps/jobs/package.json`. +`start:media-transcribe`). See `apps/jobs/package.json`. ## HLS Video Playback @@ -259,8 +281,8 @@ docker compose --env-file .env -p classroomio -f docker-compose.yaml up -d --bui # Both (e.g. shared packages/* changed) docker compose --env-file .env -p classroomio -f docker-compose.yaml up -d --build api dashboard -# Full stack -./run-docker-full-stack.sh +# Full stack (backs up first) +./classroomio.sh upgrade --build ``` ## Troubleshooting @@ -277,7 +299,7 @@ docker container prune -f # free stopped containers > **Warning:** `docker volume prune` removes volumes not used by any container. If run after `docker compose ... down`, it deletes `postgres-data`, `redis-data`, and `minio-data`. Use only the prunes above to free space safely. -Then restart: `./run-docker-full-stack.sh` +Then restart: `./classroomio.sh start` ### "The database system is in recovery mode" (57P03) @@ -293,7 +315,7 @@ docker compose --env-file .env -p classroomio -f docker-compose.yaml restart min ### SMTP Errors in Logs -`ECONNREFUSED 127.0.0.1:465` means SMTP is not configured. This does not block startup — configure SMTP or Zoho when you need email. +`ECONNREFUSED 127.0.0.1:465` means SMTP is not configured. This does not block startup; configure SMTP or Zoho when you need email. ### Port Conflicts @@ -304,5 +326,5 @@ lsof -nP -iTCP:3082 -sTCP:LISTEN ## Related -- [Self-hosting docs](https://classroomio.com/docs/self-hosted/docker) — published guide with Docker, Railway, and Coolify instructions -- [`docker/docs/PUBLISHING_IMAGES.md`](PUBLISHING_IMAGES.md) — publishing Docker images to Docker Hub +- [Self-hosting docs](https://classroomio.com/docs/self-hosted/docker): published guide with Docker, Railway, and Coolify instructions +- [`docker/docs/PUBLISHING_IMAGES.md`](PUBLISHING_IMAGES.md): publishing Docker images to Docker Hub diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d17edf1..288de06c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -635,8 +635,8 @@ importers: specifier: 7.0.9 version: 7.0.9(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.4)(@vercel/functions@3.7.5(@aws-sdk/credential-provider-web-identity@3.958.0)(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.54.0)(sass@1.97.1)(tsx@4.21.0)(yaml@2.9.0) blume: - specifier: 1.1.2 - version: 1.1.2(864af55737aed5e342af6da019f32ece) + specifier: 1.2.0 + version: 1.2.0(864af55737aed5e342af6da019f32ece) mermaid: specifier: ^11.16.0 version: 11.16.0 @@ -1561,7 +1561,7 @@ importers: version: 2.1.1 jsrepo: specifier: ^3.5.1 - version: 3.5.1(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3)) + version: 3.5.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3)) layerchart: specifier: 2.0.0-next.27 version: 2.0.0-next.27(patch_hash=51dabc4b4462058655e01199047915e5582cfd0492e97d6ecd0c244dc17e5a3a)(svelte@5.46.0) @@ -2166,18 +2166,10 @@ packages: resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} @@ -2723,10 +2715,6 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -3019,15 +3007,9 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - '@emnapi/core@1.7.1': - resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} - '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -4900,9 +4882,6 @@ packages: cpu: [x64] os: [win32] - '@napi-rs/wasm-runtime@1.1.1': - resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} - '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -7645,9 +7624,6 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -8772,8 +8748,8 @@ packages: bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} - blume@1.1.2: - resolution: {integrity: sha512-AnW/vqsP4Fp2t1ZMW6GWYoWNbsoShuaoLBA1xGebW4W2ZH+vNR9UJcxGdOxZ3idF9OkyJbNjZTkY8J3S7JuIAg==} + blume@1.2.0: + resolution: {integrity: sha512-VA8TJfSgTp1bRvCPJjufZl+coMRcDSQZvQ5Mx4Qut14TjdO1nB6PY2oOv6zZI11IAQpX/ft9l7i1KzNeXXxNLQ==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: @@ -12565,9 +12541,6 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} - oniguruma-parser@0.12.1: - resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} - oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -16643,7 +16616,7 @@ snapshots: '@babel/code-frame@7.27.1': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -16700,7 +16673,7 @@ snapshots: '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -16715,7 +16688,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -16741,7 +16714,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -16754,7 +16727,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.29.0) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -16769,8 +16742,8 @@ snapshots: '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.11 @@ -16781,7 +16754,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -16789,7 +16762,7 @@ snapshots: '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -16804,7 +16777,7 @@ snapshots: dependencies: '@babel/core': 7.28.5 '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -16813,7 +16786,7 @@ snapshots: dependencies: '@babel/core': 7.28.5 '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -16822,7 +16795,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -16842,7 +16815,7 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.3 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -16851,7 +16824,7 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -16860,31 +16833,27 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-option@7.27.1': {} '@babel/helper-wrap-function@7.28.3': dependencies: - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -16892,7 +16861,7 @@ snapshots: '@babel/helpers@7.28.4': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/helpers@7.29.2': dependencies: @@ -16901,7 +16870,7 @@ snapshots: '@babel/parser@7.28.5': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/parser@7.29.7': dependencies: @@ -16949,22 +16918,42 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.28.5)': dependencies: @@ -16981,15 +16970,30 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.5)': dependencies: @@ -17004,52 +17008,92 @@ snapshots: '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.28.5)': dependencies: @@ -17238,7 +17282,7 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.28.5 transitivePeerDependencies: - supports-color @@ -17522,7 +17566,7 @@ snapshots: dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.28.6 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 esutils: 2.0.3 '@babel/preset-typescript@7.28.5(@babel/core@7.28.5)': @@ -17553,7 +17597,7 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/template@7.28.6': dependencies: @@ -17568,7 +17612,7 @@ snapshots: '@babel/helper-globals': 7.28.0 '@babel/parser': 7.28.5 '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -17587,13 +17631,8 @@ snapshots: '@babel/types@7.28.5': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 '@babel/types@7.29.7': dependencies: @@ -17833,22 +17872,11 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.7.1': - dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -19096,7 +19124,7 @@ snapshots: '@jest/transform@29.7.0': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 babel-plugin-istanbul: 6.1.1 @@ -19310,13 +19338,6 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true - '@napi-rs/wasm-runtime@1.1.1': - dependencies: - '@emnapi/core': 1.7.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.1 - optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -19641,9 +19662,12 @@ snapshots: '@oxc-parser/binding-openharmony-arm64@0.112.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.112.0': + '@oxc-parser/binding-wasm32-wasi@0.112.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true '@oxc-parser/binding-win32-arm64-msvc@0.112.0': @@ -22605,11 +22629,6 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 - optional: true - '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -22619,8 +22638,8 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -23866,6 +23885,19 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@29.7.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + babel-plugin-istanbul@6.1.1: dependencies: '@babel/helper-plugin-utils': 7.28.6 @@ -23878,7 +23910,7 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: - '@babel/template': 7.27.2 + '@babel/template': 7.28.6 '@babel/types': 7.29.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 @@ -23930,12 +23962,37 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + babel-preset-jest@29.6.3(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + babel-preset-jest@29.6.3(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -24076,7 +24133,7 @@ snapshots: bluebird@3.4.7: {} - blume@1.1.2(864af55737aed5e342af6da019f32ece): + blume@1.2.0(864af55737aed5e342af6da019f32ece): dependencies: '@astrojs/check': 0.9.9(prettier@3.8.2)(typescript@6.0.3) '@astrojs/markdown-satteri': 0.3.4 @@ -25902,7 +25959,7 @@ snapshots: eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.6 + eventsource-parser: 3.0.8 execa@5.1.1: dependencies: @@ -25969,7 +26026,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.14.0 + qs: 6.15.0 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -26954,7 +27011,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 @@ -26964,7 +27021,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 @@ -27060,10 +27117,10 @@ snapshots: jest-config@29.7.0(@types/node@20.19.27)(ts-node@10.9.2(@types/node@20.19.27)(typescript@5.9.3)): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) + babel-jest: 29.7.0(@babel/core@7.29.0) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -27091,10 +27148,10 @@ snapshots: jest-config@29.7.0(@types/node@24.10.4)(ts-node@10.9.2(@types/node@20.19.27)(typescript@5.9.3)): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) + babel-jest: 29.7.0(@babel/core@7.29.0) chalk: 4.1.2 ci-info: 3.9.0 deepmerge: 4.3.1 @@ -27180,7 +27237,7 @@ snapshots: jest-message-util@29.7.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 '@jest/types': 29.6.3 '@types/stack-utils': 2.0.3 chalk: 4.1.2 @@ -27276,15 +27333,15 @@ snapshots: jest-snapshot@29.7.0: dependencies: - '@babel/core': 7.28.5 - '@babel/generator': 7.28.5 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.5) - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.5) + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@babel/types': 7.29.7 '@jest/expect-utils': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) chalk: 4.1.2 expect: 29.7.0 graceful-fs: 4.2.11 @@ -27484,14 +27541,17 @@ snapshots: dompurify: 2.5.8 html2canvas: 1.4.1 - jsrepo@3.5.1(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3)): + jsrepo@3.5.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(svelte@5.46.0)(vue@3.5.26(typescript@6.0.3)): dependencies: commander: 14.0.3 - oxc-parser: 0.112.0 + oxc-parser: 0.112.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) unconfig: 7.5.0 optionalDependencies: svelte: 5.46.0 vue: 3.5.26(typescript@6.0.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' jszip@3.10.1: dependencies: @@ -28764,8 +28824,6 @@ snapshots: dependencies: mimic-function: 5.0.1 - oniguruma-parser@0.12.1: {} - oniguruma-parser@0.12.2: {} oniguruma-to-es@2.3.0: @@ -28776,7 +28834,7 @@ snapshots: oniguruma-to-es@4.3.4: dependencies: - oniguruma-parser: 0.12.1 + oniguruma-parser: 0.12.2 regex: 6.1.0 regex-recursion: 6.0.2 @@ -28838,7 +28896,7 @@ snapshots: lodash.isequal: 4.5.0 vali-date: 1.0.0 - oxc-parser@0.112.0: + oxc-parser@0.112.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: '@oxc-project/types': 0.112.0 optionalDependencies: @@ -28858,10 +28916,13 @@ snapshots: '@oxc-parser/binding-linux-x64-gnu': 0.112.0 '@oxc-parser/binding-linux-x64-musl': 0.112.0 '@oxc-parser/binding-openharmony-arm64': 0.112.0 - '@oxc-parser/binding-wasm32-wasi': 0.112.0 + '@oxc-parser/binding-wasm32-wasi': 0.112.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@oxc-parser/binding-win32-arm64-msvc': 0.112.0 '@oxc-parser/binding-win32-ia32-msvc': 0.112.0 '@oxc-parser/binding-win32-x64-msvc': 0.112.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' p-limit@1.3.0: dependencies: @@ -28949,7 +29010,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.27.1 + '@babel/code-frame': 7.29.0 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -29203,9 +29264,9 @@ snapshots: dependencies: postcss: 8.5.6 - postcss-scss@4.0.9(postcss@8.5.6): + postcss-scss@4.0.9(postcss@8.5.19): dependencies: - postcss: 8.5.6 + postcss: 8.5.19 postcss-selector-parser@6.0.10: dependencies: @@ -30611,8 +30672,8 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - postcss: 8.5.6 - postcss-scss: 4.0.9(postcss@8.5.6) + postcss: 8.5.19 + postcss-scss: 4.0.9(postcss@8.5.19) postcss-selector-parser: 7.1.1 optionalDependencies: svelte: 5.46.0 diff --git a/run-docker-full-stack.sh b/run-docker-full-stack.sh deleted file mode 100755 index cddf8c55..00000000 --- a/run-docker-full-stack.sh +++ /dev/null @@ -1,326 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -BUILD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.yaml" -IMAGES_COMPOSE_FILE="${ROOT_DIR}/docker-compose.images.yaml" -PROJECT_NAME="classroomio" -ENV_FILE="${ROOT_DIR}/.env" -# Default to PULLING pre-built images (fast). Pass --build to build from source instead. -USE_IMAGES=true -BUILD_IMAGES=false -COMPOSE_FILE="${IMAGES_COMPOSE_FILE}" -COMPOSE_PROFILES="--profile minio" - -print_usage() { - cat <<'USAGE' -Usage: ./run-docker-full-stack.sh [--build] [--no-build] [--no-minio] - -By default this PULLS the pre-built classroomio/* images from Docker Hub at the tag in -CIO_VERSION (.env) — no local build, no full repo needed. - -Options: - --build Build the images from source (docker-compose.yaml) instead of pulling. - --no-build With --build, start containers without rebuilding. - --no-minio Exclude MinIO (object storage). By default MinIO is included. - --images Deprecated alias — pulling images is now the default. - -h, --help Show this help message. -USAGE -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --build) - USE_IMAGES=false - BUILD_IMAGES=true - COMPOSE_FILE="${BUILD_COMPOSE_FILE}" - shift - ;; - --no-build) - BUILD_IMAGES=false - shift - ;; - --no-minio) - COMPOSE_PROFILES="" - shift - ;; - --images) - # Pulling is the default now; kept for backward compatibility. - USE_IMAGES=true - BUILD_IMAGES=false - COMPOSE_FILE="${IMAGES_COMPOSE_FILE}" - shift - ;; - -h|--help) - print_usage - exit 0 - ;; - *) - echo "Unknown option: $1" - print_usage - exit 1 - ;; - esac -done - -get_env_value() { - local key="$1" - local line - line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)" - printf '%s' "${line#*=}" -} - -upsert_env_value() { - local key="$1" - local value="$2" - local tmp_file - - tmp_file="$(mktemp)" - awk -v key="${key}" -v value="${value}" ' - BEGIN { updated = 0 } - $0 ~ ("^" key "=") { - if (updated == 0) { - print key "=" value; - updated = 1; - } - next; - } - { print } - END { - if (updated == 0) { - print key "=" value; - } - } - ' "${ENV_FILE}" > "${tmp_file}" - - mv "${tmp_file}" "${ENV_FILE}" -} - -is_insecure_token_value() { - local value="$1" - local normalized - - normalized="$(printf '%s' "${value}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" - - if [[ "${normalized}" =~ ^\"(.*)\"$ ]]; then - normalized="${BASH_REMATCH[1]}" - elif [[ "${normalized}" =~ ^\'(.*)\'$ ]]; then - normalized="${BASH_REMATCH[1]}" - fi - - case "${normalized}" in - "" | "replace-with-a-long-random-token" | "local-dev-api-key" | "changeme" | "replace-me") - return 0 - ;; - replace-with-* | *local-dev* | *change-this* | *your-*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -generate_secure_token() { - if command -v openssl >/dev/null 2>&1; then - openssl rand -hex 32 - return - fi - - if command -v node >/dev/null 2>&1; then - node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" - return - fi - - if command -v python3 >/dev/null 2>&1; then - python3 -c "import secrets; print(secrets.token_hex(32))" - return - fi - - date +%s%N | shasum | awk '{ print $1 }' -} - -ensure_secure_auth_tokens() { - if [[ ! -f "${ENV_FILE}" ]]; then - touch "${ENV_FILE}" - fi - - local private_server_key - private_server_key="$(get_env_value PRIVATE_SERVER_KEY)" - - # PRIVATE_SERVER_KEY authenticates the dashboard's server-side calls to the API and must be - # identical in both services. Generate a strong value when it's empty or a known placeholder. - if is_insecure_token_value "${private_server_key}"; then - upsert_env_value PRIVATE_SERVER_KEY "$(generate_secure_token)" - echo "Generated secure PRIVATE_SERVER_KEY in .env" - fi -} - -ensure_secure_betterauth_secret() { - if [[ ! -f "${ENV_FILE}" ]]; then - touch "${ENV_FILE}" - fi - - local secret - secret="$(get_env_value BETTER_AUTH_SECRET)" - - # Only (re)generate when the value is empty or a known placeholder; never clobber - # a strong secret the user has set themselves. - if is_insecure_token_value "${secret}"; then - upsert_env_value BETTER_AUTH_SECRET "$(generate_secure_token)" - echo "Generated secure BETTER_AUTH_SECRET in .env" - fi -} - -is_local_origin() { - local value="$1" - case "${value}" in - "" | *localhost* | *127.0.0.1* | *0.0.0.0*) - return 0 - ;; - *) - return 1 - ;; - esac -} - -# Point browser-facing storage URLs at the public domain when DASHBOARD_ORIGIN is not -# localhost. MinIO media is served on :9000 — operators must reverse-proxy /media to it. -# We only override values that are still empty/localhost so explicit S3/R2 config is kept. -derive_public_storage_urls() { - local dashboard_origin public_endpoint media_base - dashboard_origin="$(get_env_value DASHBOARD_ORIGIN)" - public_endpoint="$(get_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT)" - media_base="$(get_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL)" - - if ! is_local_origin "${dashboard_origin}"; then - local origin="${dashboard_origin%/}" - # Public media is served from the public-download `media` bucket, so deriving its base - # URL from the domain is what fixes broken media links in served pages. We deliberately - # do NOT touch OBJECT_STORAGE_PUBLIC_ENDPOINT (the S3 API endpoint) — pointing it at the - # dashboard origin would break presigned URLs for the non-public videos/documents buckets. - if is_local_origin "${media_base}"; then - upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "${origin}/media" - fi - echo "Derived OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL from DASHBOARD_ORIGIN (${origin}/media)." - echo " -> Ensure your reverse proxy routes ${origin}/media to MinIO (port 9000)." - echo " -> For presigned access to the videos/documents buckets behind a domain, expose" - echo " MinIO's S3 API on its own route and set OBJECT_STORAGE_PUBLIC_ENDPOINT explicitly." - else - # Local demo: ensure localhost defaults are present. - if [[ -z "${public_endpoint}" ]]; then - upsert_env_value OBJECT_STORAGE_PUBLIC_ENDPOINT "http://localhost:9000" - fi - if [[ -z "${media_base}" ]]; then - upsert_env_value OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL "http://localhost:9000/media" - fi - fi -} - -ensure_minio_env() { - if [[ ! -f "${ENV_FILE}" ]]; then - touch "${ENV_FILE}" - fi - - local minio_password - minio_password="$(get_env_value MINIO_ROOT_PASSWORD)" - - # First provision: randomize credentials whenever the MinIO password is still empty or the - # well-known `minioadmin` placeholder shipped in .env.example. (Previously this gated on - # OBJECT_STORAGE_ENDPOINT being empty, but .env.example ships it non-empty — so on a normal - # copy-the-example first run the randomization was always skipped and minioadmin/minioadmin - # survived.) MinIO uses its root credentials as the S3 access key / secret. - if [[ -z "${minio_password}" || "${minio_password}" =~ ^[[:space:]]*$ || "${minio_password}" == "minioadmin" ]]; then - local minio_user - minio_user="cio-$(generate_secure_token | cut -c1-16)" - minio_password="$(generate_secure_token)" - - upsert_env_value MINIO_ROOT_USER "${minio_user}" - upsert_env_value MINIO_ROOT_PASSWORD "${minio_password}" - upsert_env_value OBJECT_STORAGE_ENDPOINT "http://minio:9000" - upsert_env_value OBJECT_STORAGE_ACCESS_KEY_ID "${minio_user}" - upsert_env_value OBJECT_STORAGE_SECRET_ACCESS_KEY "${minio_password}" - upsert_env_value OBJECT_STORAGE_FORCE_PATH_STYLE "true" - echo "Provisioned MinIO with randomized credentials in .env" - fi - - derive_public_storage_urls -} - -if ! command -v docker >/dev/null 2>&1; then - echo "Error: docker is not installed or not on PATH." - exit 1 -fi - -ensure_secure_auth_tokens -ensure_secure_betterauth_secret - -if [[ "${COMPOSE_PROFILES}" == *"minio"* ]]; then - ensure_minio_env -else - # MinIO excluded: the user must supply external object storage, or uploads/media - # silently break. Fail fast instead. The endpoint must be set AND not still point at the - # bundled MinIO host (which won't be running with --no-minio). - external_endpoint="$(get_env_value OBJECT_STORAGE_ENDPOINT)" - if [[ -z "${external_endpoint}" || "${external_endpoint}" == *"minio:9000"* ]]; then - echo "Error: --no-minio was passed but no external object storage is configured." - echo "OBJECT_STORAGE_ENDPOINT is unset or still points at the bundled MinIO (minio:9000)." - echo "Configure an external S3-compatible store via OBJECT_STORAGE_* in .env," - echo "or drop --no-minio to use the bundled MinIO." - exit 1 - fi - derive_public_storage_urls -fi - -echo "Starting ClassroomIO Docker full stack..." -if [[ "${COMPOSE_PROFILES}" != "" ]]; then - echo "Including MinIO (object storage, default)..." -fi -if [[ "${USE_IMAGES}" == "true" ]]; then - echo "Using pre-built images from Docker Hub (CIO_VERSION=$(get_env_value CIO_VERSION))..." - docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} pull - docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up -d -elif [[ "${BUILD_IMAGES}" == "true" ]]; then - docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up --build -d -else - docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ${COMPOSE_PROFILES} up -d -fi - -echo -echo "Current service status:" -docker compose --env-file "${ENV_FILE}" -p "${PROJECT_NAME}" -f "${COMPOSE_FILE}" ps - -# `docker compose up -d` returns once containers are *started*, not *healthy*. On a fresh -# deployment the API runs DB migrations before it listens, so the endpoints aren't reachable -# immediately. Poll with a retry instead of a single curl — and never let a not-yet-ready -# check abort the script (set -euo pipefail is active), so first runs end cleanly. -check_endpoint() { - local name="$1" url="$2" curl_flags="$3" attempts="$4" - local i=0 - while ((i < attempts)); do - if curl ${curl_flags} --max-time 5 "${url}" >/dev/null 2>&1; then - echo "${name} is reachable on ${url}" - return 0 - fi - i=$((i + 1)) - sleep 5 - done - echo "Note: ${name} is not reachable yet at ${url} (waited $((attempts * 5))s)." - echo " It may still be starting — the first run migrates the database. Follow logs with:" - echo " docker compose --env-file \"${ENV_FILE}\" -p \"${PROJECT_NAME}\" -f \"${COMPOSE_FILE}\" logs -f" - return 0 -} - -if command -v curl >/dev/null 2>&1; then - echo - echo "Waiting for services to come up (first run can take a minute while the API migrates)..." - check_endpoint "API" "http://localhost:3081/" "-fsS" 24 - check_endpoint "Dashboard" "http://localhost:3082/" "-fsSI" 12 -else - echo - echo "curl not found, skipped endpoint checks." -fi - -echo -echo "Done. Full stack is running."