Skip to content

Implementation Plan — edX LMS

Context

The Open Learning Hub monorepo ships a full Next.js 16 LMS under apps/lms/ (multi-tenant catalogue, learn routes, Auth.js + Kysely, CMS integration, admin surfaces) plus the authoring app under apps/cms/. Shared engineering rules live at the repository root .cursor/rules/; LMS-only rules and this reference set live under apps/lms/.cursor/rules/. See the repository root AGENTS.md and apps/lms/AGENTS.md for day-to-day agent and contributor entry points.

This document remains a phased implementation plan (historical sequencing and reference context). Some phases below may already be delivered in tree — when a phase conflicts with the as-built app, treat the code and subsystem references as authoritative and update this plan accordingly.

This plan sequences the build so each phase delivers a runnable, type-safe, WCAG-AA-compliant slice while honouring the mandatory rules: zero any, server-only Auth.js/Kysely, all types under src/types/, multi-tenant scoping by tenant_id, ISR revalidate = 60, and npm run check green after every change.

Each phase includes a Reference context block. Read those docs before starting the phase; where a phase and a subsystem reference disagree, the subsystem reference named as authoritative wins and this plan should be corrected before implementation continues.

Phase Numbering Note

Two inserted phases intentionally use decimal labels to preserve existing references while avoiding a disruptive renumbering pass:

Inserted phase Sits between
Phase 3.5 — Email Provider & Account Flows Phase 3 and Phase 4
Phase 7.5 — Assignment Entry Placeholder Phase 7 and Phase 8
Phase 10.5 — APIs & Well-Known Files Phase 10 and Phase 11

When creating new external tickets or PRs, cite both the phase number and title so these inserted phases remain unambiguous.


Guiding Principles (apply to every phase)

  • RSC by default; 'use client' only where interactivity is essential.
  • Zod validates at every boundary (route handlers, server actions, edX CMS responses, Auth callbacks).
  • Every multi-tenant query scopes by tenant_id; every per-user query scopes by user_id.
  • Auth.js + Kysely are server-only; never imported from client components; AUTH_SECRET / DATABASE_URL never reach the client bundle.
  • All types live in src/types/; no inline structural types beyond local helpers.
  • Accessibility (WCAG 2.1 AA) is part of "done", not a follow-up.
  • Theme support is mandatory: every UI surface must render correctly in light, dark, and system modes. next-themes is configured with attribute="class", defaultTheme="dark", enableSystem, and disableTransitionOnChange in src/app/\[locale\]/layout.tsx; the root <html> includes the dark class for first paint. Tailwind v4 dark variants (@custom-variant dark (&:where(.dark, .dark *))) are wired in globals.css. The theme toggle exposes all three options (Light / Dark / System) and persists user preference; "System" updates live with prefers-color-scheme.
  • Vitest for every component: every custom component under src/components/** (excluding primitives from @open-learning-hub/ui, which are upstream-tested in the shared package) ships with a co-located *.test.tsx covering: render-without-crash, accessible name / role assertions, key interactive behaviour, and assertions in both light and dark themes via a shared renderWithTheme(ui, { theme }) helper. New components without a passing test are not "done".
  • All user-facing copy lives in messages/{locale}.json. Every string a user can see — page text, button labels, form labels, validation messages, toast/sonner copy, email subjects and bodies, error pages, empty states, aria-label / aria-description values, <title> and meta description — is authored as a translation key and read via useTranslations() (client) or getTranslations() (server). No hardcoded English literals in JSX, server actions, schema error maps, or email templates. Keys are added to all six locale files (en/es/fr/de/pt/zh) in the same change; English may be the source-of-truth value and other locales may copy English temporarily, but the key must exist everywhere or the build fails. Exceptions: data-testid values, internal log messages (src/lib/log.ts), thrown developer errors, and code identifiers — these are never user-visible.
  • Protected-route redirects use Auth.js-compatible callbackUrl everywhere. If a page accepts a legacy next query in the future, it must normalize it to callbackUrl at the boundary; proxy.ts and new links should not emit next.
  • Server actions may revalidate LMS-owned tags such as progress:<userId>, but the CMS webhook allowlist in the monorepo root .cursor/rules/050-apis.mdc remains limited to CMS-owned prefixes. Do not introduce an enrollments: tag unless the API rule is updated in the same change.
  • Each PR ends with npm run check and npm run build green; no commits made by the agent.

Phase 0 — Project Foundations

Goal: a runnable scaffold with linting, typing, env, and folder layout aligned to the rules.

Reference context:

  • tech-stack.md — framework, runtime, dependencies, scripts, and locale threading.
  • monorepo root .cursor/rules/070-code-quality.mdc — mandatory npm run check, strict TypeScript, and validation gates.
  • monorepo root .cursor/rules/080-data-auth-integration.mdc — server-only Auth.js/Kysely and environment-variable boundaries.
  • monorepo root .cursor/rules/100-documentation.mdc — docs update expectations.
  • monorepo root .cursor/rules/120-security.mdc — environment-variable secrecy and rate-limit driver configuration.
  1. Confirm Node version against the monorepo root package.json engines field (>=24.0.0; CI may pin a specific 24.x line).
  2. Create folder skeleton:
    • src/app/ (already exists)
    • src/components/ (feature components; import shadcn from @open-learning-hub/ui)
    • src/lib/ (utilities, CMS client, formatting)
    • src/types/ (all shared types)
    • src/db/ (Kysely setup, types, migrations, seeds)
    • src/auth/ (Auth.js config, callbacks)
    • src/schemas/ (Zod schemas, OpenAPI registry)
    • src/i18n/ + messages/{en,es,fr,de,pt,zh}.json
    • tests/unit/, tests/e2e/
  3. Wire config:
    • tsconfig.json paths (@/*src/*).
    • biome.json already present — verify rules cover noExplicitAny.
    • Add .env.local.template (committed; keys + dev defaults, no secrets) and .env.local (git-ignored; copy of the template a developer fills in locally). The template carries AUTH_SECRET, DATABASE_URL, tenant defaults, and the CMS variables below.
    • CMS variables — replace the earlier EDX_CMS_* placeholders:
      • CMS_API_URL — base URL of the headless CMS API. Local dev default: http://localhost:3000/api/v1/.
      • CMS_PROJECT_UUID — project scope for all CMS reads. Configure it in .env.local using the current CMS setup output; see .env.local.template.
      • CMS_API_UUID — sent verbatim as the x-api-key header on every CMS request. Configure it in .env.local using a generated CMS API key; see .env.local.template.
      • CMS_WEBHOOK_SECRET — shared secret for POST /api/cms/revalidate; committed templates use a placeholder only.
    • Rate-limit variables:
      • RATE_LIMIT_DRIVERmemory for dev/test, upstash for production.
      • UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN — required only when RATE_LIMIT_DRIVER=upstash.
    • Email/account-flow variables:
      • EMAIL_FROM — verified sender address for transactional email.
      • RESEND_API_KEY — primary email provider credential.
      • SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD — Nodemailer fallback values.
      • APP_BASE_DOMAIN — base host for tenant subdomains, e.g. lvh.me:3001 in local development.
    • Add src/lib/env.ts — Zod-validated process.env accessor; throw at boot if missing.
  4. Install runtime deps in tracked groups (do not blanket-install — defer to phase that needs them):
    • Phase 1: shadcn CLI init, next-intl, next-themes, Tailwind v4 already present.
    • Phase 2: kysely, pg, better-sqlite3 (+ Kysely dialect), tsx.
    • Phase 3: next-auth@beta (v5), bcryptjs, @types/bcryptjs.
    • Phase 3.5: resend, nodemailer, @types/nodemailer.
    • Phase 4: zod, @asteasolutions/zod-to-openapi, swagger-ui-react.
  • Later phases: @portabletext/react, react-hook-form, @hookform/resolvers, zustand, icon libs, vitest + @testing-library/react + happy-dom, @playwright/test, sanitize-html, marked.
  1. Add scripts not yet present: test, test:unit, test:e2e, db:migrate, db:seed, openapi:generate. Keep existing check / verify / docs:* intact.
  2. Commit gate: npm run check clean, npm run build succeeds on the empty scaffold.

Phase 1 — Design System & Layout Shell

Goal: shadcn/ui installed, theme tokens defined, locale-aware root layout, public website chrome.

Reference context:

  • apps/lms/.cursor/rules/020-lms-system.mdcUI conventions (shadcn/ui baseline, links to CMS delta list).
  • monorepo root .cursor/rules/060-wcag.mdc — semantic landmarks, keyboard support, focus rings, and shared accessibility patterns.
  • monorepo root .cursor/rules/030-coding-best-practices.mdc — React/Next.js, Tailwind v4, and component organization conventions.
  • tech-stack.md — shadcn, Tailwind v4, next-intl, next-themes, and test tooling.
  1. Initialise shadcn (new-york, neutral, OKLCH, Tailwind v4 via @theme inline in globals.css). Generate primitives: button, input, label, form, card, dialog, dropdown-menu, sheet, tabs, toast (sonner), skeleton, badge, separator, avatar.
  2. Configure next-themes (Light / Dark / System) and next-intl (locales: en/es/fr/de/pt/zh; default en); add proxy.ts for locale negotiation (Next.js 16 — not middleware.ts).
    • <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange> in src/app/\[locale\]/layout.tsx (root <html> includes dark for first paint).
    • globals.css defines OKLCH tokens for both :root (light) and .dark (dark) so system swap is purely class-driven.
    • <html suppressHydrationWarning> to silence the next-themes hydration warning.
  3. Build app/layout.tsx:
    • Locale provider, theme provider, sonner Toaster.
    • Semantic landmarks: <header>, <main id="main">, skip-link, <footer>.
  4. Public chrome components in src/components/site/:
    • SiteHeader (logo, nav, locale switcher, theme toggle, sign-in CTA).
    • SiteFooter.
    • MainNav with aria-current and keyboard support.
    • ThemeToggle — dropdown with three explicit items (Light / Dark / System), aria-label="Toggle theme", current selection reflected via aria-checked. Hydration-safe (renders an empty placeholder until mounted).
    • All visible labels (nav items, sign-in CTA, theme menu items, locale names) are translation keys; aria-label values too.
  5. Component-test infrastructure (foundation for the per-component Vitest rule):
    • Add vitest.config.ts (happy-dom, setupFiles: ['tests/setup.ts'], path aliases mirror tsconfig.json).
    • tests/setup.ts@testing-library/jest-dom, matchMedia polyfill (required for next-themes system detection), next-intl test provider.
    • tests/utils/render-with-theme.tsx — wraps RTL render with <ThemeProvider> and <NextIntlClientProvider>; import @tests/utils/render-with-theme; accepts { theme: 'light' | 'dark' | 'system', locale }; default locale en.
    • Document the convention in .cursor/rules/030-testing.mdc: each component gets a co-located kebab-case.test.tsx next to kebab-case.tsx (see 031-file-naming.mdc); minimum assertions = renders, has accessible name, key interaction works, renders in both light and dark theme.
    • Add npm run test:components (alias for vitest run src/components) and include it in npm run verify.
  6. Define WCAG focus utility classes in globals.css (focus-visible:ring-2 ring-offset-2).
  7. Shared accessibility primitives:
    • AccessibilityAnnouncer — reusable polite/assertive live-region component for navigation state changes, form submissions, and dynamic status messages.
    • useFocusTrap — focus-trapping/restoration hook for dialogs, sheets, overlays, and future modal flows.
  8. data-testid conventions documented in .cursor/rules/030-testing.mdc; EVERY custom component carries an id. This enables us to refer to a component effectively.
  9. Ship Vitest specs for every component created in this phase: SiteHeader, SiteFooter, MainNav, ThemeToggle, locale switcher, AccessibilityAnnouncer, useFocusTrap. (Per the guiding principle — applies to every subsequent phase too.)

Phase 2 — Persistence Layer (Kysely + migrations)

Goal: type-safe DB layer for both PG (prod) and SQLite (dev) with seed data.

Reference context:

  • monorepo root .cursor/rules/080-data-auth-integration.mdc — handwritten Database type, migrations, server-only DB access, and tenant scoping.
  • monorepo root .cursor/rules/120-security.mdc — authorization, rate-limit storage expectations, and secret handling.
  • user-management.mdusers, roles, course_admin_assignments, invitations, audit_log, and withTenantOverride().
  • tenant-configuration.md — tenant status, feature flags, custom domains, suspension, and host resolution.
  • course-enrollment-system.md — enrollment status machine, indexes, audit events, and payment-provider seam.
  • entry-pagination.mdentry_progress, last_viewed_at, resume, and progress indexing.
  • quiz-system.mdquiz_attempts, attempt_number, feedback_json, and scoring persistence.
  • analytics.md — query shapes that drive required indexes and fixture coverage.
  1. src/db/types.ts — handwritten Kysely Database interface. Tables (initial):
    • tenants(id, slug, name, status, feature_flags_json, custom_domain, created_at, updated_at, suspended_at)status ∈ {'active','suspended','archived'}; see tenant-configuration.md for feature flags, custom domains, suspension, and archival.
    • users(id, tenant_id, email, password_hash, role, name, locale, email_verified_at, token_version, created_at, updated_at)role ∈ {'student','course_admin','tenant_admin','super_admin'}. tenant_id is nullable only for super_admin; locale defaults to en.
    • course_admin_assignments(id, user_id, tenant_id, course_slug, assigned_by, assigned_at) — composite unique on (user_id, tenant_id, course_slug); per-course scope for course_admin.
    • invitations(id, email, tenant_id, role, course_slug, token_hash, invited_by, expires_at, accepted_at)course_slug required when role = 'course_admin'.
    • enrollments(id, user_id, tenant_id, course_slug, status, enrolled_at, completed_at)status ∈ {'pending','active','completed','cancelled'}
    • entry_progress(id, user_id, tenant_id, course_slug, entry_slug, completed_at, last_viewed_at)last_viewed_at TIMESTAMPTZ NULL supports multi-device resume.
    • quiz_attempts(id, user_id, tenant_id, course_slug, entry_slug, attempt_number, score, passed, answers_json, feedback_json, started_at, submitted_at)attempt_number is sequential per (user_id, tenant_id, course_slug, entry_slug).
    • verification_tokens(id, user_id, token_hash, type, expires_at, used_at, created_at)type ∈ {'email_verify','password_reset','magic_link','email_change'}. Tokens are hashed at rest; used_at enforces single-use. Invitations use the separate invitations table (not this type enum). Shape is consistent with user-management.md and Phase 3.5 account flows; supersedes the Auth.js-Adapter shape (token, identifier, expires, type) earlier referenced here.
    • oauth_accounts — links OAuth provider subjects to users (Google sign-in); see migration 0002_oauth_accounts.ts.
    • audit_log(id, actor_user_id, action, target_kind, target_id, tenant_id, metadata, created_at) — required for impersonation, tenant mutations, enrollment transitions, and admin role/assignment changes.
    • sessions / accounts only if Auth.js DB strategy is later chosen — for JWT default these are unnecessary.
  2. src/db/client.ts — dialect picker (PG vs SQLite) driven by DATABASE_URL; export db: Kysely<Database>.
  3. src/db/migrations/ — numbered tsx migration scripts; runner script (scripts/db-migrate.ts) using Kysely's Migrator.
  4. scripts/db-seed.ts — dev seed: one tenant, one tenant_admin, one course_admin (with a row in course_admin_assignments for the demo course), two students, one super_admin with tenant_id = NULL. Default accounts and sign-in hosts: Development seed data.
  5. Indices: (tenant_id, email) unique on users; (tenant_id, user_id, course_slug) unique on enrollments; (tenant_id, course_slug, status) on enrollments for rosters; (user_id, tenant_id, course_slug, entry_slug) unique on entry_progress; (user_id, tenant_id, course_slug, entry_slug, attempt_number DESC) on quiz_attempts.
  6. Unit tests for query helpers (src/db/queries/*) using SQLite in-memory.

Phase 3 — Auth.js (v5) Integration

Goal: Credentials sign-in, JWT sessions enriched with userId, role, tenantId, route protection via proxy.ts.

Reference context:

  • monorepo root .cursor/rules/080-data-auth-integration.mdc — Auth.js v5 exports, JWT/session callbacks, and privileged DB re-checks.
  • monorepo root .cursor/rules/120-security.mdc — protected-route, token, and tenant isolation requirements.
  • user-management.md — personas, permissions[], guards, redirect taxonomy, route gates, and token-version invalidation.
  • tenant-configuration.md — host resolution order, suspended/archived tenant behavior, and custom-domain aliases.
  1. src/auth/config.ts — NextAuth v5 config:
    • Credentials provider: Zod-validated {email, password}; tenant is resolved from the request host, not from form input; bcryptjs.compare against users.password_hash.
    • Session strategy jwt; jwt callback loads userId/role/tenantId/tokenVersion/permissions[]/assignedCourses[] (course slugs from course_admin_assignments); session callback exposes them.
    • pages.signIn = '/learn/sign-in', pages.error = '/learn/sign-in/error'.
  2. src/auth/index.ts — re-export auth, signIn, signOut, handlers.
  3. app/api/auth/\[...nextauth\]/route.ts — wire handlers.GET/POST.
  4. Augment session type in src/types/next-auth.d.ts.
  5. proxy.ts — Next.js 16 proxy:
    • Resolve tenant from the host per tenant-configuration.md: exact custom-domain match, subdomain match, apex/www public site, unknown host 404. Suspended tenants return branded 503 before auth routing.
    • Match /learn/dashboard, /admin, /learn/course/:slug/(enroll|entry/.*), redirect unauthenticated → /learn/sign-in?callbackUrl=….
    • Admin gate: tenant_admin and super_admin may access /admin and subpaths. course_admin may access only /admin/courses/\[slug\]/... when slug ∈ assignedCourses (other /admin paths return 404); see user-management.md F7 and proxy.ts.
  6. src/auth/guards.ts — shared requireSession(), requireRole(...), requireCourseAccess(courseSlug), and withTenantOverride(...) APIs. Server components, server actions, and route handlers use these helpers instead of duplicating tenant/role checks inline.
  7. src/components/auth/sign-in-form.tsx — react-hook-form + Zod; calls signInAction server action that rate-limits (IP + normalized email, 10 / 15 min) then calls signIn('credentials', …). Field labels, placeholders, submit copy, and Zod error map all use translation keys.
  8. src/lib/server-only.tsimport 'server-only' re-export utility used by every Kysely/Auth.js module.
  9. Tests: unit (callbacks, password verify), e2e (sign-in happy path, tenant_admin/super_admin redirect to /admin, course_admin redirect to assigned-course dashboard, unauthenticated redirect with callbackUrl= preservation, course_admin blocked from unassigned course slug, tenant mismatch forces sign-out).

Phase 3.5 — Email Provider & Account Flows

Goal: self-service account creation, email verification, password reset, magic-link sign-in, and invitation acceptance are implemented through a shared transactional email layer.

Reference context:

  • user-management.md — personas, self-service sign-up, invitation acceptance, redirect/error taxonomy, and token-version invalidation.
  • monorepo root .cursor/rules/080-data-auth-integration.mdc — Auth.js provider boundaries, password hashing, and server-only email/auth code.
  • monorepo root .cursor/rules/120-security.mdc — sign-up/auth rate limits, token secrecy, and user-enumeration avoidance.
  • monorepo root .cursor/rules/100-documentation.mdc and the guiding i18n principle above — email subjects/bodies and account-flow UI copy are translation-keyed.
  1. Environment: add EMAIL_FROM, RESEND_API_KEY, SMTP_* fallback variables, and APP_BASE_DOMAIN (e.g. lvh.me:3001 in dev) to .env.local.template; production secrets stay out of client-visible variables.
  2. src/lib/email/{client,templates}.tssendEmail() interface with Resend as primary and Nodemailer SMTP fallback. Keep provider details server-only; log sanitized delivery metadata through src/lib/log.ts.
  3. Sign-up route (/learn/sign-up) — Zod-validated form, tenant resolved from host, bcrypt hash, create users row with email_verified_at = null, role student, and locale default, then send verification email.
  4. Verify route (/learn/verify/\[token\]) — consume single-use verification_tokens row, set users.email_verified_at, bump/refresh session state if needed, redirect to sign-in with callbackUrl.
  5. Forgot/reset routes (/learn/forgot, /learn/reset/\[token\]) — token TTL 1 hour, single-use, rate-limited by IP + normalized email; no user-existence leaks in responses.
  6. Magic-link sign-in — custom magic_link rows in verification_tokens, requested via /learn/magic and consumed at /learn/magic/\[token\] (requestMagicLink / consumeMagicLink server actions); Credentials remains the sign-in provider after token consumption (not the Auth.js Email provider).
  7. Invitation acceptance route (/invite/\[token\]) — supports new-user sign-up, existing-user sign-in, role grant, course-admin assignment, accepted_at, and friendly expired/already-used states per user-management.md F10.
  8. Admin hooks for Phase 9 — server actions to resend verification, issue password-reset email, and revoke active sessions by bumping users.token_version.
  9. Tests: token expiry, single-use enforcement, rate limits, duplicate email handling, invitation acceptance for new and existing users, e2e sign-up → verify → sign-in.

Phase 4 — Schemas, CMS Client, OpenAPI

Goal: typed boundary into the headless CMS and a generated OpenAPI surface for our own API routes.

Reference context:

  • monorepo root .cursor/rules/050-apis.mdc — Zod-to-OpenAPI expectations, CMS webhook tags, and public API constraints.
  • monorepo root .cursor/rules/120-security.mdc — input validation, public/private env vars, and webhook rate limits.
  • tech-stack.md — locale threading and CMS integration stack.
  • hierarchy-structure.md — authoritative Course → Module → Entry shape, ordering, and CMS/DB boundary.
  • quiz-system.mdQuiz vs QuizPublic schema split and quiz wire shape.
  1. src/schemas/ — Zod schemas with zod-to-openapi registry:
    • Course, Module, Entry (type ∈ {'lesson','quiz','assignment'}), Quiz, QuizQuestion, EnrollmentRequest, QuizAttemptSubmission.
    • PageHierarchyNode and a generic paginated<T> envelope (used by every list-style CMS call).
    • Quiz is server-only and includes correct answers / accepted answers for grading; QuizPublic strips those fields before quiz data reaches the client bundle.
  2. src/lib/cms/client.ts — typed fetch wrapper around the headless CMS:
    • Source of truth. Upstream project is open-learning-hub/edx-cms; local dev URL is http://localhost:3000/; the contract is published at http://localhost:3000/openapi.yaml (cache locally during this phase if useful, but Zod schemas remain the validation boundary per the guiding principle).
    • Base URL and auth. All requests go through CMS_API_URL and carry an x-api-key: ${CMS_API_UUID} header. Read both via the Zod-validated src/lib/env.ts accessor — no direct process.env reads in feature code.
    • Methods. getPublishedCourses(), getCourseBySlug(slug), getEntry(courseSlug, entrySlug), and getPageHierarchy() which calls GET /projects/{CMS_PROJECT_UUID}/page-hierarchy — the canonical source for the public-site menu / navigation tree, consumed by MainNav (Phase 1) and the marketing routes (Phase 5).
    • Pagination. Endpoints support page (default 1) and pageSize (default 20, max 100) query params. Expose a typed paginate({ page, pageSize }) helper and reuse the paginated<T> Zod envelope. Document the max=100 ceiling so callers don't over-request.
    • Locale threading. Every CMS method accepts a locale parameter and appends ?locale=<locale> to the CMS query string. Authenticated routes pass users.locale; public routes pass the next-intl resolved locale. CMS-authored copy is rendered as returned; see tech-stack.md.
    • ISR + tagging. All responses parsed with Zod; use next: { revalidate: 60, tags: [...] } with per-resource tags (course:<slug>, cms:page-hierarchy, etc.) so admins can invalidate the menu independently of course caches.
    • Binary assets. CMS widget payloads may contain /api/assets/{uuid} paths (session-auth on the CMS host only). The LMS rewrites those to same-origin proxies — GET /api/cms/site/assets/{uuid} for site pages (public; scoped to CMS_PROJECT_UUID upstream) and GET /api/cms/courses/{slug}/assets/{uuid} for published course payloads (public; resolves the course project by slug and streams from GET /api/v1/projects/{projectId}/assets/{uuid} with the server-side API key). Unknown or unpublished course slugs return 404.
    • Error shape. Throws a typed CmsIntegrationError on schema mismatch or non-2xx responses; includes the request path and status for log correlation.
  3. src/lib/cms/published.ts — server-side filter: published-only used by generateStaticParams and runtime fetch.
  4. scripts/openapi-generate.ts (tsx) — emit public/openapi.yaml from the Zod registry. YAML matches the upstream edx-cms convention (http://localhost:3000/openapi.yaml) so both contracts share a single format. Use the yaml package; do not emit JSON.
  5. app/\[locale\]/admin/api-docs/page.tsx — Swagger UI loaded against /openapi.yaml (gated to tenant_admin / super_admin). Note: an earlier draft of this plan put the route under app/api/docs/..., but /api/* is reserved for route handlers and is Disallow-ed in robots.txt; the admin tree is the correct home.
  6. Unit tests: schema parsing fixtures, published filter, error shape.

Phase 5 — Public Website (no auth)

Goal: statically generated marketing surface and course catalogue.

Reference context:

  • monorepo root .cursor/rules/050-apis.mdc — published-only sitemap/metadata expectations and ISR policy.
  • apps/lms/.cursor/rules/020-lms-system.mdc — shadcn UI baseline for public surfaces (see UI conventions).
  • monorepo root .cursor/rules/060-wcag.mdc — semantic page structure, alt text, contrast, and keyboard navigation.
  • hierarchy-structure.md — public course metadata and CMS-authored hierarchy boundaries.
  • tech-stack.md — Portable Text, locale threading, and public CMS content rules.
  1. app/page.tsx — home: hero, featured courses (tenant-scoped LMS course_settings + CMS published summaries).
  2. app/\[slug\]/page.tsx — generic CMS page; generateStaticParams from published pages; generateMetadata from CMS; revalidate = 60.
  3. app/blog/page.tsx, app/blog/\[slug\]/page.tsx — list + detail; Portable Text rendering via @portabletext/react with sanitised serializers.
  4. app/learn/page.tsx — course list (no enrollment data); links to enroll.
  5. app/learn/course/\[slug\]/page.tsx unauthenticated/unenrolled branch — public course landing with published metadata, preview-safe summary, and sign-in/enroll CTA. The enrolled branch is completed in Phase 6.
  6. MainNav is built from getPageHierarchy() (Phase 4 client) rather than a hand-maintained list, so the rendered menu cannot drift from the CMS structure.
  7. Marketing copy not sourced from CMS (CTAs, footer links, 404/500 fallbacks) uses translation keys. CMS-authored body content remains untranslated by us — locale support for CMS content is the CMS's responsibility and out of scope.
  8. Accessibility passes: contrast, headings hierarchy, alt text, keyboard nav; document patterns in src/components/site/README.md (only if requested — default no new docs).

Note: per-tenant well-known files (favicon, icons, manifest, robots, sitemap, OG/Twitter cards, .well-known/*) are sequenced in Phase 10.5 rather than here, so they can build on the routable content created in Phases 5–9 and the security headers added in Phase 10.


Phase 6 — Enrollment Flow

Goal: authenticated user can enrol in a published course.

Reference context:

  • course-enrollment-system.md — authoritative Phase 6 contract: state machine, route flow, server action, audit, errors, and paid-course 501 behavior.
  • user-management.mdrequireSession(), requireCourseAccess(), RBAC, invitation personas, and access-denial taxonomy.
  • entry-pagination.md — entry-layer access depends on active/completed enrollment.
  • monorepo root .cursor/rules/120-security.mdc — enrollment rate limit and tenant isolation.
  1. app/learn/course/\[slug\]/enroll/page.tsx — server component, requires await auth().
  2. Server action enrollInCourse(courseSlug):
    • Validate slug with Zod; verify course is published via CMS client.
    • Insert/upsert into enrollments scoped by tenant_id + user_id; status active for free courses.
    • Paid courses return 501 Not Implemented with user-safe copy until a real payment phase ships. Keep only the provider-neutral seam in src/server/payments/* described by course-enrollment-system.md; do not add a development simulatePaymentSuccess action.
    • Re-enrollment from cancelled updates the same row, bumps enrolled_at, clears completed_at, and writes the audit event.
    • revalidateTag('progress:'+userId); redirect to /learn/course/\[slug\].
  3. app/learn/course/\[slug\]/page.tsx — enrolled branch of the course home (auth + active/completed enrollment-required guard); shows modules, entries, completion badges, and a Continue CTA. Unenrolled users see the public landing/CTA from Phase 5.
  4. UI: EnrollButton, CourseModuleList, EntryRow (completed / locked / available). EnrollButton, status badges (pending/active/completed), and "Awaiting payment" copy are translation keys.
  5. Error states: unpublished/never-published course returns 404; course archived after prior publication returns 410; duplicate active enrollment is idempotent redirect; pending paid-course enrollment shows the payment-unavailable state until the payment phase exists.
  6. Tests: state-machine unit coverage for allowed/forbidden transitions, idempotent insert, paid-course 501, archived-course 410, e2e enrol → redirect → see course home; unenrolled user sees enroll CTA, not protected entries.

Phase 7 — Entry Pagination & Progress

Goal: sequential lesson playback with prev/next and completion tracking.

Reference context:

  • entry-pagination.md — authoritative Phase 7 contract: flattened ordering, getEntryAccess, gating, resume, preview mode, server actions, and tests.
  • toggleable-navigation.md — authoritative CourseSidebar layout, non-persisted Zustand store, keyboard toggle, and sidebar test IDs.
  • hierarchy-structure.md — Course → Module → Entry ordering and slug scope.
  • course-enrollment-system.md — enrollment guard consumed by getEntryAccess.
  • user-management.md — admin preview permissions and same-tenant/cross-tenant denial rules.
  • monorepo root .cursor/rules/060-wcag.mdc — keyboard, focus, live-region, and landmark requirements.
  1. app/learn/course/\[slug\]/entry/\[entrySlug\]/page.tsx:
    • Server-side: load course (CMS), call getEntryAccess({ user, course, entry }), locate entry, compute prev/next siblings from the flattened CMS order.
    • Render Portable Text body, attached resources, EntryNav (prev/next disabled at boundaries). Prev/next labels and completion badge text use translation keys.
  2. src/lib/course/pagination.ts — pure getEntryPagination(entries, currentEntrySlug) helper over the flattened list. Module boundaries are visual only; prev/next crosses modules.
  3. src/lib/course/gating.ts — central getEntryAccess(...) returns the modes specified in entry-pagination.md; callers must not reimplement gating.
  4. markEntryComplete server action — upsert entry_progress; no-op in preview mode; revalidate course:<slug> and progress:<userId>.
  5. recordEntryView server action/helper — called from the entry RSC during render to update last_viewed_at; no-op in preview mode; failures log and do not break rendering.
  6. Sequential gating policy: by default, entries are accessible if all prior entries in the same module are complete; cross-module gating is off. Deep-link to a locked entry redirects to the first available entry with ?notice=<reasonKey>; the destination toast uses a translation key.
  7. Admin preview-as-student mode (?preview=1) — available to assigned course_admin, tenant_admin, and super_admin; bypasses enrollment and gating but is read-only and renders PreviewBanner.
  8. CourseSidebar — implement the toggleable-navigation.md contract: non-persisted Zustand store, CSS flexbox layout, no react-resizable-panels, sidebar-toggle, module groups, locked-row toast, and [ shortcut.
  9. useEntryShortcuts (client) — / for prev/next plus [ for sidebar toggle; ignores form/contenteditable focus; respects aria-disabled; live-region announcements use translation keys.
  10. Resume/Continue target — course home and dashboard link to first entry, first incomplete entry, or most recently viewed entry per entry-pagination.md.
  11. Tests: unit (pagination, getEntryAccess, resume target), component (EntryNav, CourseSidebar, PreviewBanner states), e2e (linear progression, locked deep-link redirect, sidebar lock toast, preview mode writes nothing, completion badge appears).

Phase 7.5 — Assignment Entry Placeholder

Goal: assignment entries are routable and visibly marked as placeholder behavior without implying full submission/grading support.

Reference context:

  • hierarchy-structure.mdassignment is an Entry discriminator with manual placeholder completion.
  • entry-pagination.md — assignment completion is manual mark-complete in Phase 7; real submission/grading is deferred.
  • monorepo root .cursor/rules/060-wcag.mdc — placeholder UI and buttons remain accessible.
  1. Treat _type: 'assignment' as a supported CMS entry shape in schemas and routing so courses containing assignments do not crash.
  2. Render assignment body/instructions with the same safe Portable Text renderer used for lessons.
  3. Use the same manual MarkCompleteButton path as lessons; do not add file upload, grading queue, or submission persistence in V1.
  4. Translation-keyed placeholder copy clearly states that assignment submissions are not yet implemented.
  5. Tests: assignment entry renders, can be marked complete, participates in gating, and has no upload/submission controls.

Phase 8 — Quiz System

Goal: render quiz entries, accept attempts, score, persist, gate progression.

Reference context:

  • quiz-system.md — authoritative Phase 8 contract: CMS shape, grading, retry policy, review mode, Quiz/QuizPublic, accessibility, and tests.
  • entry-pagination.md — completion handoff, preview-mode read-only behavior, and getEntryAccess.
  • hierarchy-structure.md — quiz entry as an Entry discriminator.
  • monorepo root .cursor/rules/050-apis.mdc — OpenAPI registry and tag naming.
  • monorepo root .cursor/rules/060-wcag.mdc — form accessibility, focus management, and status announcements.
  1. Quiz schemas already in src/schemas/. Question types: multiple_choice, true_false, short_answer. Expose separate Quiz (server-only, includes correct answers) and QuizPublic (client-safe, strips correct answers / accepted answers) schemas.
  2. QuizPlayer (client) — react-hook-form + Zod; per-question validation; one-pass submission.
  3. Server action submitQuizAttempt({courseSlug, entrySlug, answers}):
    • Call getEntryAccess(...); reject unless mode === 'enrolled' and gated === false. Preview mode returns previewReadOnly and writes nothing.
    • Re-fetch quiz from CMS (never trust client question shape), grade server-side, persist to quiz_attempts with attempt_number, answers_json, and feedback_json, write entry_progress if passed === true or the new attempt_number === maxAttempts.
    • Return {score, passed, correctCount, totalCount, attemptNumber, attemptsRemaining, perQuestionFeedback?} per quiz policy.
  4. Retry policy: quiz.maxAttempts from CMS; when exhausted, allow review-only mode.
  5. QuizResults page section — accessible feedback, focus management on submit. Question-type-agnostic UI chrome (Submit, Retry, Review, score line, pass/fail banner, per-question feedback labels) uses translation keys. Question and answer text is CMS-sourced and rendered as-is.
  6. Tests: scoring (unit), retry exhaustion (unit), schema rejection for invalid quiz shapes, tampered client submission graded against server-fetched quiz, preview mode writes nothing, e2e submit → pass → next entry unlocked, e2e attempts exhausted → review-only → next entry unlocked.

Phase 9 — Dashboards

Goal: student dashboard + course-admin dashboard + LMS-admin dashboard.

Reference context:

  • analytics.md — authoritative query contracts, source tables, dashboard consumers, cache policy, security/privacy, and tests.
  • user-management.md — RBAC, withTenantOverride(), impersonation, role management, and audit requirements.
  • tenant-configuration.md — feature flags, tenant suspension/archival, and super-admin tenant operations.
  • monorepo root .cursor/rules/080-data-auth-integration.mdc — per-user and multi-tenant query scoping.
  • monorepo root .cursor/rules/120-security.mdc — server-side authorization and rate-limit expectations for mutations.
  1. app/learn/dashboard/page.tsx — enrolled courses, in-progress entries, recent quiz scores. RSC; data via Kysely scoped by user_id+tenant_id.
  2. app/admin/courses/\[slug\]/dashboard/page.tsx — gated to course_admin (when slug ∈ assignedCourses), tenant_admin, or super_admin. Roster, results, per-entry analytics. No content editing. Query shapes, source tables, and cache policy are specified in analytics.md.
  3. app/admin/page.tsx — gated by role ∈ {'tenant_admin','super_admin'}:
    • User list with role promotion within { student, course_admin, tenant_admin } (server action; bumps users.token_version and refreshes JWT via update() pattern).
    • Course-admin assignment management (course_admin_assignments).
    • Enrollment overview.
    • Link to OpenAPI / Swagger UI.
    • super_admin only: /admin/tenants and cross-tenant user search via withTenantOverride(); impersonation start/stop writes to audit_log.
  4. Settings helpers — getTenantFeatures(tenantId) for tenant-tier flags; getCourseFeatures(db, { tenantId, courseSlug }) for per-course flags (e.g. sequential gating). Client components receive resolved booleans only.
  5. Tenant lifecycle admin — super_admin can create, activate, suspend/archive tenants, and manage custom domains per tenant-configuration.md; every mutation writes audit_log and tenant suspension bumps affected users' token_version.
  6. Zustand store(s) only for ephemeral UI (filters, table sort) — never for layout/visibility flags (per root AGENTS.md / LMS AGENTS.md persist-SSR rule).
  7. Section headings, empty states, table column headers, feature-flag labels, and role names rendered to admins all use translation keys.
  8. Tests: e2e tenant_admin promotes student to course_admin and assigns a course; non-admin gets 403; course_admin blocked from unassigned course; cross-tenant access returns 404; student sees only their data; suspended tenant routes return 503; token invalidation after suspension is enforced.

Phase 10 — Error Handling, Security Headers, Observability

Goal: production-grade boundaries.

Reference context:

  • monorepo root .cursor/rules/090-error-handling.mdc — PostHog observability, structured logging, redaction, trace IDs, and user-safe errors.
  • monorepo root .cursor/rules/120-security.mdc — security headers, rate limits, CSRF, and secret handling.
  • monorepo root .cursor/rules/080-data-auth-integration.mdc — auth/database error mapping and server-only boundaries.
  • monorepo root .cursor/rules/060-wcag.mdc — accessible error surfaces, focus restoration, and live regions.
  1. app/error.tsx, app/global-error.tsx, route-level error.tsx — name exports ErrorPage (never Error); friendly messages; never expose stack/SQL. ErrorPage, not-found, and per-segment error copy use translation keys; never expose raw error messages to users.
  2. app/not-found.tsx global + per-segment.
  3. proxy.ts applies all headers specified in monorepo root .cursor/rules/120-security.mdc §Security Headers; do not duplicate a partial header list here.
  4. src/lib/log.ts — structured server logger with redaction of PII / secrets. Emit, redaction, Edge-safety, and parity requirements are specified in monorepo root .cursor/rules/091-structured-logging.mdc; PostHog observability and correlation guidance is in .cursor/rules/090-error-handling.mdc §Observability and .cursor/rules/092-analytics-observability.mdc.
  5. PostHog: wired through the shared @open-learning-hub/observability package (client provider + consent gate, server posthog-node, OpenTelemetry log forwarding in src/instrumentation.ts) so server components, route handlers, and client components share one reporting surface. See admin/observability.md.
  6. CSRF: rely on Auth.js cookies (SameSite=Lax); double-submit token for state-changing API routes outside Auth.js.
  7. Sanitise all CMS rawHtml blocks at the server fetch boundary (src/lib/cms/sanitize-cms-html.ts) before rendering anywhere outside trusted Portable Text serializers.
  8. Rate limiting: implement the driver abstraction and endpoint policy from monorepo root .cursor/rules/120-security.mdc; auth, sign-up, enrollment, quiz submission, and CMS revalidation routes must return 429 on exhaustion.

Phase 10.5 — APIs & Well-Known Files

Goal: every publicly addressable per-tenant asset (favicon, icons, manifest, robots, sitemap, social cards, .well-known/*) is served from the CMS site document with a static public/fallback/ failover, per monorepo root .cursor/rules/050-apis.mdc.

Status update (Aug 2026): discovery surfaces are implemented for LMS (/sitemap.xml, /robots.txt, /.well-known/change-password, llms.txt, llms-full.txt, and scoped .md variants). See seo-and-discovery.md for the canonical runtime contract.

Reference context:

  • monorepo root .cursor/rules/050-apis.mdc — authoritative contract for well-known files, caching, webhook payloads, and implementation order.
  • monorepo root .cursor/rules/120-security.mdc — CMS webhook rate limit and secret validation.
  • monorepo root .cursor/rules/090-error-handling.mdc — logging and graceful fallback behavior.
  • tenant-configuration.md — suspended tenant behavior and host resolution.

Implementation order of preference (from the rule): Next.js 16 App Router file conventions → /api/* route handlers (only when no convention fits) → public/ static fallbacks.

  1. Shared infrastructure

    • src/lib/cms/site.tsgetSiteForHost(host) helper that resolves the tenant from the request host (reusing the proxy.ts resolver from Phase 3) and returns the CMS site document; cached with unstable_cache and tagged site:<host> so admins can invalidate per tenant.
    • public/fallback/favicon.ico, icon-192.png, icon-512.png, apple-icon.png, generic OG/Twitter cards.
    • All handlers: server-only, Uint8Array for binary payloads, try/catch around every CMS fetch with fallback to the static asset using the same Cache-Control headers as the dynamic response. No any.
    • Suspended tenants follow tenant-configuration.md: tenant pages and well-known files return the branded 503 unless a route is explicitly required by legal/security policy.
  2. Favicon (/favicon.ico)

    • src/app/api/favicon/route.ts — primary CMS-sourced favicon; falls back to public/fallback/favicon.ico on missing field or fetch error.
    • next.config.* rewrite: /favicon.ico/api/favicon.
    • Cache-Control: public, max-age=31536000, immutable.
  3. PWA / device icons

    • src/app/icon.tsx — generates /icon.png (192 + 512) from site.icon.
    • src/app/apple-icon.tsx — generates /apple-icon.png (180×180).
    • Fallbacks in public/fallback/icon-{192,512}.png, public/fallback/apple-icon.png.
    • Cache-Control: public, max-age=31536000, immutable.
  4. Web App Manifest (/manifest.webmanifest)

    • src/app/manifest.tsname, short_name, theme_color, background_color, icons from CMS; tenant-host fallback for name.
    • Do not hand-roll <link rel="manifest"> — Next.js emits it automatically.
    • Cache-Control: public, max-age=3600.
  5. Robots (/robots.txt)

    • src/app/robots.ts — per-host rules.
    • Disallow: /admin, /api, /learn/dashboard, /learn/course/*/entry/*, /learn/verify, /learn/reset. Allow everything else.
    • Emit Sitemap: https://<host>/sitemap.xml referencing the request host.
    • Cache-Control: public, max-age=3600.
  6. Sitemap (/sitemap.xml)

    • src/app/sitemap.ts — published-only (mirrors root AGENTS.md / security rules and the generateStaticParams rule). Drafts, archived, unlisted excluded.
    • Sources: (main) CMS pages, (main)/blog/\[slug\], (lms)/learn and public course landing pages /learn/course/\[slug\]. Exclude post-enrol-only routes; lastModified from CMS updated_at.
    • export const revalidate = 60 (matches site-wide ISR).
    • When published-entry count exceeds 50,000 URLs, switch to a sitemap index: sitemap.ts returns the index, chunks at sitemap/\[id\]/sitemap.ts (Next.js multi-sitemap convention).
    • Never include URLs that 404, redirect, or require auth.
  7. Social card images

    • src/app/opengraph-image.tsx (1200×630) and src/app/twitter-image.tsx (1200×600).
    • Source site.og_image; fallback to next/og ImageResponse rendered with site.name + tenant theme color.
    • Per-route override allowed (e.g. (main)/blog/\[slug\]/opengraph-image.tsx using the post hero).
    • Cache-Control: public, max-age=31536000, immutable.
  8. .well-known files

    • src/app/api/well-known/security/route.ts/.well-known/security.txt (RFC 9116). Fields: Contact (from site.security_contact, fallback mailto:security@<host>), Expires (≤ 1 year), Preferred-Languages. Optional: Policy, Acknowledgments, Canonical. Content-Type: text/plain; charset=utf-8. Cache-Control: public, max-age=86400.
    • /.well-known/change-password — implemented as a redirect in next.config.ts/learn/forgot.
  9. AEO / answer-engine surfaces

    • src/app/llms.txt/route.ts exposes a default-locale URL index of published public pages.
    • src/app/llms-full.txt/route.ts exposes a bounded markdown corpus (public pages + blog + course landing summaries), excluding auth-gated entry content.
    • /*.md routes are rewritten to src/app/api/aeo/markdown/[...path]/route.ts and return markdown projections for published discovery URLs only.
    • src/lib/seo/url-inventory.ts is the shared allowlist used by sitemap, robots, llms, and markdown routes.
  10. CMS revalidation webhook

  • src/app/api/cms/revalidate/route.tsPOST /api/cms/revalidate, authenticated by CMS_WEBHOOK_SECRET, validates tag payloads, rate-limits at 60 requests/min/IP, and calls revalidateTag for accepted CMS tags.
  • Payload and allowed tag prefixes are specified in monorepo root .cursor/rules/050-apis.mdc §Inbound Webhooks.
  1. Tests (Vitest, per the rule's "shared requirements" block — also rolled into Phase 11's aggregate suite):
  • For each handler: happy path, missing CMS field, CMS network error, malformed asset URL.
  • One e2e Playwright spec per tenant: /favicon.ico, /icon.png, /apple-icon.png, /manifest.webmanifest, /robots.txt, /sitemap.xml, /opengraph-image, /twitter-image, /.well-known/security.txt, /.well-known/change-password — assert 200 (or expected 307 for change-password), correct Content-Type, and matching Cache-Control from the policy table.
  • CMS revalidation webhook: valid tag, multiple tags, missing secret, invalid secret, invalid tag prefix, malformed body, and rate-limit exhaustion.
  1. Documentation hook — Phase 12 appends OpenAPI / Swagger pointers to 050 - APIs.mdc and verifies the surface above against the rule's Caching Policy Table.

Phase 11 — Testing & CI

Goal: enforce regressions cannot land.

Reference context:

  • monorepo root .cursor/rules/070-code-quality.mdc — full-codebase validation and zero TypeScript/lint errors.
  • monorepo root .cursor/rules/060-wcag.mdc — axe sweeps, keyboard coverage, and accessible interaction expectations.
  • tech-stack.md — Vitest, happy-dom, Playwright, and test tooling.
  • Subsystem testing sections in course-enrollment-system.md, entry-pagination.md, quiz-system.md, analytics.md, and tenant-configuration.md.
  1. Vitest config with happy-dom; coverage thresholds for src/db/queries, src/lib/cms, src/auth, schemas, and 100% file coverage on src/components/** (every component has a co-located test, enforced by a CI check that fails when a .tsxlacks a sibling.test.tsx).
  2. Playwright config with DATA_DIR=./data/test; project-level seeds; unique resource names per test (per AGENTS.md test convention).
  3. Suites:
    • Component (per-component Vitest specs — already required in every phase; this suite is the aggregate run)
    • Auth (sign-in, redirect, role gates)
    • Enrollment (enrol, idempotency, tenant isolation)
    • Entry pagination + gating
    • Quiz (pass, fail, retry exhaustion)
    • Admin (promotion, audit visibility)
    • i18n smoke (each locale renders home + sign-in); assert at least one Spanish-only key renders on es to prove the locale switcher is wired, not just the file shipped
    • i18n regression (Playwright): visit each top-level route on es and assert no English-locale-only string from en.json appears in the rendered HTML
    • Theme smoke (each top-level route renders in light, dark, and system with prefers-color-scheme toggled at the Playwright level)
    • WCAG sweep with @axe-core/playwright — run in both light and dark
  4. CI workflow (separate task; document command) runs npm run verify && npm run test:unit && npm run test:components && npm run test:e2e.
  5. i18n CI checkscripts/check-i18n.mjs (run from root npm run check):
    1. Greps src/app/** and src/components/** for JSX text nodes, aria-label=, placeholder=, title= containing two or more letters with a space (heuristic for English sentences) and not wrapped in t(...) / a known allowlist; fails on any hit.
    2. Diffs key sets across messages/*.json and fails on any locale missing a key present in en.json.

Phase 12 — Documentation & Hand-off

Goal: reference docs, generated documentation, and hand-off notes stay aligned with the implemented product.

Reference context:

  • monorepo root .cursor/rules/100-documentation.mdc — README, API docs, JSDoc, and reference-doc maintenance.
  • docs/zensical/scripts/sync_docs.py and docs/zensical/zensical.toml — mirror .cursor/rules into docs-source/ plus Zensical project config; public/docs/ comes from npm run docs:publish / predev, not from the Python script alone.
  • All reference docs in apps/lms/docs/reference/ — ensure implementation notes and generated docs do not drift.
  • apps/lms/.cursor/rules/130-roadmap.mdc — non-binding future-feature proposals; do not fold roadmap items into the completed V1 plan unless separately approved.
  1. Update reference docs with realised behaviour where implementation diverged from the design specs. The current authoritative docs are not stubs; this phase is for drift correction, examples, and any implementation-specific decisions discovered during the build.
  2. Update docs/zensical source markdown to mirror; run npm run docs:check.
  3. Append OpenAPI / Swagger pointers to 050 - APIs.mdc and verify the Phase 10.5 well-known surface matches the rule's Caching Policy Table exactly.
  4. Final npm run verify (check + build + docs:check).

Critical Files to Create / Modify

  • src/app/layout.tsx, src/app/page.tsx — extend
  • src/app/(main)/..., src/app/learn/..., src/app/admin/..., src/app/api/... — new route trees
  • src/auth/config.ts, src/auth/index.ts, src/auth/guards.ts, app/api/auth/\[...nextauth\]/route.ts
  • proxy.ts (root) — locale + auth + headers
  • src/db/{client,types,migrations,seeds,queries}/...
  • src/lib/cms/{client,published,page-hierarchy,site}.ts
  • src/lib/course/{pagination,gating}.ts
  • src/lib/email/{client,templates}.ts
  • src/lib/log.ts, src/lib/analytics/{config,server,events}.ts, src/instrumentation.ts
  • src/schemas/*
  • src/types/{next-auth.d.ts, domain.ts, ...}
  • src/components/{site,auth,course,quiz,dashboard,admin}/...
  • messages/{en,es,fr,de,pt,zh}.json
  • scripts/{db-migrate,db-seed,openapi-generate,check-i18n}.ts
  • src/lib/i18n/keys.ts — typed t helper re-export plus utilities (e.g. formatRole(role)) that map DB enum values to translation keys, so role/status enums are translated at the boundary, not stringified raw
  • .env.example

Reuse Already in the Repo

  • biome.json, .prettierrc.json, .prettierignore — keep as the lint/format source of truth (Biome for code, Prettier for *.md/mdx/mdc).
  • src/app/globals.css — extend with Tailwind v4 @theme inline tokens; do not introduce a tailwind.config.*.
  • docs/ Zensical pipeline — reuse for published docs; do not duplicate.
  • package.json predev already runs docs:publish — leave intact.

Verification

After each phase:

  1. npm run check (Biome auto-fix) — must be clean.
  2. npm run build — must compile with zero TS errors and zero any.
  3. npm run test:unit — relevant suites green.
  4. npm run test:e2e — flows for the phase green (Phases 3+).
  5. Manual smoke: npm run dev → visit the routes touched by the phase; verify auth gates, ISR (60s), keyboard navigation, screen-reader landmarks, locale switch.
  6. Final phase: npm run verify and axe Playwright sweep clean across all locales.

Resolved Decisions (from user)

  • Tenancy: Subdomain-based by default (<tenant>.host) with optional custom-domain aliases per tenant-configuration.md. proxy.ts resolves the tenant from the host, rejects suspended tenants, attaches tenant context to the request, and passes it through to RSCs / server actions. Local dev uses lvh.me (or equivalent) so subdomains work without /etc/hosts edits. Auth.js Credentials provider takes only email + password; tenant comes from the host, not the form.
  • Payments: Free-course enrollment ships in Phase 6. Paid courses keep the provider-neutral seam from course-enrollment-system.md, but return 501 Not Implemented until a real payment phase ships; no development simulatePaymentSuccess action is part of V1.
  • Admin / email flows: Full self-serve email flows are in scope. Adds a dedicated Phase 3.5 — Email Provider & Account Flows between Auth.js and CMS integration: Resend (primary) with Nodemailer fallback, verification_tokens table, sign-up + email verification, forgot-password, tenant_admin/super_admin-triggered reset, magic-link sign-in via custom magic_link tokens (Credentials + optional Google OAuth).
  • Node runtime: Node ≥ 22 via monorepo root package.json engines; CI may pin a specific 22.x line (see root AGENTS.md baseline).
  • CMS integration: Headless CMS is the open-learning-hub/edx-cms project. Local dev runs on http://localhost:3000/; OpenAPI at /openapi.yaml. Auth via x-api-key header carrying CMS_API_UUID. Project scope via CMS_PROJECT_UUID. Page hierarchy (menu) sourced from /projects/{projectId}/page-hierarchy. Pagination uses page + pageSize (max 100).

Decisions Applied in Phases

The decisions above are already folded into the phase steps. Do not add a second appendix-level implementation path that conflicts with the phase text; update the owning phase and its reference context instead.

Open Questions for the User

(none — proceeding with the resolved decisions above)