This commit is contained in:
Rotimi Best
2026-06-25 23:53:22 +01:00
4 changed files with 108 additions and 12 deletions
+65
View File
@@ -411,3 +411,68 @@ When moving from `apps/dashboard/src/lib/components/` to `apps/dashboard/src/lib
3. **Update exports** in `features/ui/index.ts`
4. **Update imports**: `import { Component } from '$features/ui';`
5. **Delete old files** and verify no remaining references
## Cursor Cloud specific instructions
The full setup/run flow lives in `README.md` and `DEV_SETUP_NOTES.md`. The notes below cover only the non-obvious, cloud-VM-specific caveats. The update script already runs `pnpm install` on startup.
### Node version (important)
The sandbox injects `/exec-daemon/node` (Node 22) at the front of `PATH`, so a bare `node`/`pnpm` runs under Node 22 even though the repo requires Node `^20.19.3` (`.nvmrc`). Node 20.19.3 is installed via nvm and set as the nvm default. Before running any `pnpm`/dev command, prepend the repo Node to `PATH`:
```bash
export PATH="$HOME/.nvm/versions/node/v20.19.3/bin:$PATH"
```
`pnpm install` itself succeeds under either version (there is no `engine-strict`), but run the dev servers and DB scripts on Node 20.
### Services & how to run them
Docker is installed and provides Postgres 16 + Redis 7 (the app does not run them natively). After a fresh VM boot the Docker daemon is usually not running — start it (it needs the snapshot's `/etc/docker/daemon.json`, which already sets `fuse-overlayfs` + disables the containerd snapshotter, plus `iptables-legacy`):
```bash
sudo dockerd > /tmp/dockerd.log 2>&1 &
sudo chmod 666 /var/run/docker.sock # or prefix docker commands with sudo
docker compose -f docker/docker-compose.yaml up -d postgres redis
```
The per-app `.env` files (`apps/api/.env`, `apps/dashboard/.env`, `apps/jobs/.env`, `packages/db/.env`) are gitignored but persist in the VM snapshot with a matching `PRIVATE_SERVER_KEY`. If any are missing, recreate them per `README.md` (generate secrets with `openssl rand -hex 32`; the two `PRIVATE_SERVER_KEY` values must match).
The Postgres docker volume persists in the snapshot already seeded with demo data (login `[email protected]` / `123456`). Re-seed only if needed: `pnpm --filter @cio/db db:setup:seed`.
Run the app (two long-lived processes; use tmux):
- `pnpm api:dev` → API on `http://localhost:3002` + 5 BullMQ workers.
- `pnpm dashboard:dev` → dashboard on `http://localhost:5173` + the `@cio/ui` Tailwind CSS watcher.
Do not use `pnpm dev` (it trips turbo's concurrency cap). Build shared package `dist/` once with `pnpm build` if you see `Failed to resolve entry for package "@cio/*"`.
### Known caveats
- **Vite SSR circular dependency (layerchart):** authenticated/chart pages occasionally render "Something unexpected occurred" on a cold load. Reload the page (or restart `dashboard:dev`) and it renders — it is intermittent, not a setup failure.
- **MinIO is optional and not started by default.** Without it, image/media thumbnails show "Failed to load"; that is expected. Start it with `docker compose -f docker-compose.yaml --profile minio up -d minio minio-init` and add the `OBJECT_STORAGE_*` vars from `README.md` to `apps/api/.env`.
- **Pre-existing lint/test issues (not environment problems):** `pnpm --filter @cio/api lint` fails (missing ESLint v9 `eslint.config.*`); `pnpm --filter @cio/dashboard lint` runs but reports pre-existing errors; api `vitest run` passes 61 tests but 5 files fail to load `@cio/core/services/*/*` subpaths (Vite nested-wildcard exports quirk; Node resolves them fine); `pnpm --filter @cio/dashboard test` (jest) fails to parse `jest.config.ts`. The pre-commit gate `pnpm format:check` passes.
- The optional `@cio/storybook` build fails on an unresolved `@lucide/svelte/icons/bot` import; it does not affect api/dashboard.
### Deployment mode: self-hosted vs cloud (affects which features you can test)
A single flag, `PUBLIC_IS_SELFHOSTED`, switches the whole product between **self-hosted** and **cloud** behavior. It is read in two places: the dashboard via `$env/static/public` (e.g. `apps/dashboard/src/lib/features/app/layout-setup.ts`) and the API/db layer via `@cio/core/config/env` (e.g. `apps/api/src/services/license.ts`, `apps/api/src/middlewares/license.ts`, `apps/api/src/services/onboarding.ts`). `FEATURE_AUDIT.md` §5 is the source-of-truth feature-by-feature map.
**This VM's local env is configured for CLOUD mode** (`PUBLIC_IS_SELFHOSTED=false` in both `apps/dashboard/.env` and `apps/api/.env`) so multi-tenant and all license-gated features are testable. The README contributor default is self-hosted; to switch this VM back, set `PUBLIC_IS_SELFHOSTED=true` in both files (or remove it from `apps/api/.env`) and restart both dev servers.
- **Self-hosted (`PUBLIC_IS_SELFHOSTED=true`)** — the README contributor default:
- Single organization, single domain: creating a 2nd org is blocked (`onboarding.ts`), the "add org" UI is hidden, and the org is auto-assigned a `selfhosted` `ENTERPRISE` plan.
- Enterprise features `sso`, `token-auth`, `no-tracking` are gated behind `LICENSE_KEY`, verified against the external `https://enterprise-api.classroomio.dev`. Without a key, `requireLicense` returns 403.
- Polar billing and PostHog/Umami analytics are off.
- **Cloud (`PUBLIC_IS_SELFHOSTED` unset or `false`)**:
- Multi-tenant: multiple orgs per user, "add org" shown; org is resolved from the **subdomain** (`acme.<PRIVATE_APP_HOST>`) or a custom domain. Locally there are no subdomains, so an org public site is simulated with the `?org=<siteName>` query param (sets the `_orgSiteName` cookie — see `layout-setup.ts`).
- The license gate is a **no-op**, so all enterprise features are unlocked **without** a `LICENSE_KEY` — i.e. cloud mode is the easiest way to exercise SSO/token-auth/multi-org in dev.
- Polar billing and analytics activate but are themselves gated by their own keys (absent keys just no-op), so they don't block startup.
Non-obvious gotcha: the README local-dev setup only sets `PUBLIC_IS_SELFHOSTED=true` in `apps/dashboard/.env`; `apps/api/.env` leaves it unset, so the **API behaves as cloud** (license no-op, 2nd org allowed) while the **dashboard UI is self-hosted**. To exercise a coherent mode, set the flag the same way in both files. The flag is read at process start, so restart the dev servers after editing it.
### Seeded tenants (good for multi-tenant testing)
The seed (`packages/db/src/utils/seed/organizationmember.ts`) creates **three independent organizations**, each with its own admin + student. All accounts use password `123456`:
| Org (siteName) | Admin | Student | Example courses |
|---|---|---|---|
| Udemy Test (`udemy-test`) | `[email protected]` | `[email protected]` | Modern Web Development with React; Getting started with MVC; Data Science with Python and Pandas |
| Coursera Test (`coursera-test`) | `[email protected]` | `[email protected]` | SOC 2 Security Basics; HIPAA Awareness 2026 |
| Skillshare Test (`skillshare-test`) | `[email protected]` | `[email protected]` | Product Management Fundamentals |
In cloud mode you can demonstrate tenant isolation on the single local instance by visiting each org's public catalog via the `?org=<siteName>` param, e.g. `http://localhost:5173/?org=coursera-test` vs `?org=udemy-test` vs `?org=skillshare-test` — each renders its own branded catalog and courses. (Note: a user enrolled in courses across orgs becomes a member of multiple tenants, so after login the dashboard may open whichever org that user most recently used.)
+2 -1
View File
@@ -23,6 +23,7 @@ import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { handleError } from '@api/utils/errors';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { orgMemberMiddleware } from '@api/middlewares/org-member';
import { orgAdminOrAutomationKeyMiddleware } from '@api/middlewares/org-admin-or-automation-key';
import { assertMcpAutomationUsageAllowed, recordMcpAutomationUsage } from '@api/services/organization/automation-usage';
import { zValidator } from '@hono/zod-validator';
@@ -44,7 +45,7 @@ export const tagsRouter = new Hono()
return handleError(c, error, 'Failed to fetch public tags');
}
})
.get('/', authMiddleware, orgAdminMiddleware, async (c) => {
.get('/', authMiddleware, orgMemberMiddleware, async (c) => {
try {
const orgId = c.req.header('cio-org-id')!;
const groups = await getOrganizationTagGroups(orgId);
@@ -1,8 +1,6 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import CopyIcon from '@lucide/svelte/icons/copy';
import EyeIcon from '@lucide/svelte/icons/eye';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
@@ -43,12 +41,22 @@
}: Props = $props();
const showPublicCourseLinks = $derived(isPublished && courseType === 'PUBLIC' && slug.trim().length > 0);
const courseSettingsPath = $derived(resolve(`/courses/${id}/settings`, {}));
function redirect(url: string) {
goto(resolve(url, {}));
}
function scrollToSettingsSection(sectionId: string) {
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function handleShareCourse() {
if (page.url.pathname === courseSettingsPath) {
scrollToSettingsSection('share');
return;
}
redirect(`/courses/${id}/settings#share`);
}
@@ -57,13 +65,29 @@
}
function handlePublishCourse() {
if (page.url.pathname === courseSettingsPath) {
scrollToSettingsSection('publish');
return;
}
redirect(`/courses/${id}/settings#publish`);
}
function handleDeleteCourse() {
if (page.url.pathname === courseSettingsPath) {
scrollToSettingsSection('delete');
return;
}
const isOnOrgCoursesList = /\/org\/[^/]+\/courses\/?$/.test(page.url.pathname);
if (isOnOrgCoursesList) {
$deleteCourseModal.open = true;
$deleteCourseModal.id = id;
$deleteCourseModal.title = title;
return;
}
redirect(`/courses/${id}/settings#delete`);
}
function handleCloneCourse() {
@@ -92,17 +116,14 @@
{#if lmsPublicQuickOnly}
<DropdownMenu.Item onclick={handleViewCourseSite}>
<ExternalLinkIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.view_course_site')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
<CopyIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.copy_course_url')}
</DropdownMenu.Item>
{:else}
{#if includeViewAsStudent}
<DropdownMenu.Item onclick={() => onViewAsStudent?.()}>
<EyeIcon size={16} class="mr-2" />
{$t('course.header.view_as_student')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
@@ -110,12 +131,10 @@
{#if isPublished}
<DropdownMenu.Item onclick={handleViewCourseSite}>
<ExternalLinkIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.view_course_site')}
</DropdownMenu.Item>
{#if showPublicCourseLinks}
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
<CopyIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.copy_course_url')}
</DropdownMenu.Item>
{/if}
@@ -307,6 +307,17 @@
loadCourseTags(courseId);
});
$effect(() => {
const sectionId = $page.url.hash.replace('#', '').trim();
if (!sectionId) {
return;
}
requestAnimationFrame(() => {
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
const selectedTagChips = $derived.by(() => {
const allTags = tagApi.tagGroups.flatMap((group) =>
group.tags.map((tag) => ({