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 byuser_id. - Auth.js + Kysely are server-only; never imported from client components;
AUTH_SECRET/DATABASE_URLnever 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-themesis configured withattribute="class",defaultTheme="dark",enableSystem, anddisableTransitionOnChangeinsrc/app/\[locale\]/layout.tsx; the root<html>includes thedarkclass for first paint. Tailwind v4 dark variants (@custom-variant dark (&:where(.dark, .dark *))) are wired inglobals.css. The theme toggle exposes all three options (Light / Dark / System) and persists user preference; "System" updates live withprefers-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.tsxcovering: render-without-crash, accessible name / role assertions, key interactive behaviour, and assertions in both light and dark themes via a sharedrenderWithTheme(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-descriptionvalues,<title>and meta description — is authored as a translation key and read viauseTranslations()(client) orgetTranslations()(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-testidvalues, 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
callbackUrleverywhere. If a page accepts a legacynextquery in the future, it must normalize it tocallbackUrlat the boundary;proxy.tsand new links should not emitnext. - Server actions may revalidate LMS-owned tags such as
progress:<userId>, but the CMS webhook allowlist in the monorepo root.cursor/rules/050-apis.mdcremains limited to CMS-owned prefixes. Do not introduce anenrollments:tag unless the API rule is updated in the same change. - Each PR ends with
npm run checkandnpm run buildgreen; 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— mandatorynpm 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.
- Confirm Node version against the monorepo root
package.jsonenginesfield (>=24.0.0; CI may pin a specific 24.x line). - 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}.jsontests/unit/,tests/e2e/
- Wire config:
tsconfig.jsonpaths (@/*→src/*).biome.jsonalready present — verify rules covernoExplicitAny.- 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 carriesAUTH_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.localusing the current CMS setup output; see.env.local.template.CMS_API_UUID— sent verbatim as thex-api-keyheader on every CMS request. Configure it in.env.localusing a generated CMS API key; see.env.local.template.CMS_WEBHOOK_SECRET— shared secret forPOST /api/cms/revalidate; committed templates use a placeholder only.
- Rate-limit variables:
RATE_LIMIT_DRIVER—memoryfor dev/test,upstashfor production.UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKEN— required only whenRATE_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:3001in local development.
- Add
src/lib/env.ts— Zod-validatedprocess.envaccessor; throw at boot if missing.
- 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.
- Phase 1: shadcn CLI init,
- Later phases:
@portabletext/react,react-hook-form,@hookform/resolvers,zustand, icon libs,vitest+@testing-library/react+happy-dom,@playwright/test,sanitize-html,marked.
- Add scripts not yet present:
test,test:unit,test:e2e,db:migrate,db:seed,openapi:generate. Keep existingcheck/verify/docs:*intact. - Commit gate:
npm run checkclean,npm run buildsucceeds 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.mdc— UI 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.
- Initialise shadcn (
new-york,neutral, OKLCH, Tailwind v4 via@theme inlineinglobals.css). Generate primitives:button,input,label,form,card,dialog,dropdown-menu,sheet,tabs,toast(sonner),skeleton,badge,separator,avatar. - Configure
next-themes(Light / Dark / System) andnext-intl(locales: en/es/fr/de/pt/zh; defaulten); addproxy.tsfor locale negotiation (Next.js 16 — notmiddleware.ts).<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>insrc/app/\[locale\]/layout.tsx(root<html>includesdarkfor first paint).globals.cssdefines OKLCH tokens for both:root(light) and.dark(dark) sosystemswap is purely class-driven.<html suppressHydrationWarning>to silence the next-themes hydration warning.
- Build
app/layout.tsx:- Locale provider, theme provider, sonner Toaster.
- Semantic landmarks:
<header>,<main id="main">, skip-link,<footer>.
- Public chrome components in
src/components/site/:SiteHeader(logo, nav, locale switcher, theme toggle, sign-in CTA).SiteFooter.MainNavwitharia-currentand keyboard support.ThemeToggle— dropdown with three explicit items (Light / Dark / System),aria-label="Toggle theme", current selection reflected viaaria-checked. Hydration-safe (renders an empty placeholder untilmounted).- All visible labels (nav items, sign-in CTA, theme menu items, locale names) are translation keys;
aria-labelvalues too.
- Component-test infrastructure (foundation for the per-component Vitest rule):
- Add
vitest.config.ts(happy-dom,setupFiles: ['tests/setup.ts'], path aliases mirrortsconfig.json). tests/setup.ts—@testing-library/jest-dom,matchMediapolyfill (required fornext-themessystem detection),next-intltest provider.tests/utils/render-with-theme.tsx— wraps RTLrenderwith<ThemeProvider>and<NextIntlClientProvider>; import@tests/utils/render-with-theme; accepts{ theme: 'light' | 'dark' | 'system', locale }; default localeen.- Document the convention in
.cursor/rules/030-testing.mdc: each component gets a co-locatedkebab-case.test.tsxnext tokebab-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 forvitest run src/components) and include it innpm run verify.
- Add
- Define WCAG focus utility classes in
globals.css(focus-visible:ring-2 ring-offset-2). - 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.
data-testidconventions documented in.cursor/rules/030-testing.mdc; EVERY custom component carries an id. This enables us to refer to a component effectively.- 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— handwrittenDatabasetype, 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.md—users, roles,course_admin_assignments,invitations,audit_log, andwithTenantOverride().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.md—entry_progress,last_viewed_at, resume, and progress indexing.quiz-system.md—quiz_attempts,attempt_number,feedback_json, and scoring persistence.analytics.md— query shapes that drive required indexes and fixture coverage.
src/db/types.ts— handwritten KyselyDatabaseinterface. Tables (initial):tenants(id, slug, name, status, feature_flags_json, custom_domain, created_at, updated_at, suspended_at)—status ∈ {'active','suspended','archived'}; seetenant-configuration.mdfor 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_idis nullable only forsuper_admin;localedefaults toen.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 forcourse_admin.invitations(id, email, tenant_id, role, course_slug, token_hash, invited_by, expires_at, accepted_at)—course_slugrequired whenrole = '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 NULLsupports 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_numberis 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_atenforces single-use. Invitations use the separateinvitationstable (not this type enum). Shape is consistent withuser-management.mdand Phase 3.5 account flows; supersedes the Auth.js-Adapter shape (token,identifier,expires,type) earlier referenced here.oauth_accounts— links OAuth provider subjects tousers(Google sign-in); see migration0002_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/accountsonly if Auth.js DB strategy is later chosen — for JWT default these are unnecessary.
src/db/client.ts— dialect picker (PG vs SQLite) driven byDATABASE_URL; exportdb: Kysely<Database>.src/db/migrations/— numberedtsxmigration scripts; runner script (scripts/db-migrate.ts) using Kysely'sMigrator.scripts/db-seed.ts— dev seed: one tenant, onetenant_admin, onecourse_admin(with a row incourse_admin_assignmentsfor the demo course), two students, onesuper_adminwithtenant_id = NULL. Default accounts and sign-in hosts: Development seed data.- 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 onentry_progress;(user_id, tenant_id, course_slug, entry_slug, attempt_number DESC)onquiz_attempts. - 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.
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.compareagainstusers.password_hash. - Session strategy
jwt;jwtcallback loadsuserId/role/tenantId/tokenVersion/permissions[]/assignedCourses[](course slugs fromcourse_admin_assignments);sessioncallback exposes them. pages.signIn = '/learn/sign-in',pages.error = '/learn/sign-in/error'.
- Credentials provider: Zod-validated
src/auth/index.ts— re-exportauth,signIn,signOut,handlers.app/api/auth/\[...nextauth\]/route.ts— wirehandlers.GET/POST.- Augment session type in
src/types/next-auth.d.ts. proxy.ts— Next.js 16 proxy:- Resolve tenant from the host per
tenant-configuration.md: exact custom-domain match, subdomain match, apex/wwwpublic 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_adminandsuper_adminmay access/adminand subpaths.course_adminmay access only/admin/courses/\[slug\]/...whenslug ∈ assignedCourses(other/adminpaths return 404); seeuser-management.mdF7 andproxy.ts.
- Resolve tenant from the host per
src/auth/guards.ts— sharedrequireSession(),requireRole(...),requireCourseAccess(courseSlug), andwithTenantOverride(...)APIs. Server components, server actions, and route handlers use these helpers instead of duplicating tenant/role checks inline.src/components/auth/sign-in-form.tsx— react-hook-form + Zod; callssignInActionserver action that rate-limits (IP + normalized email, 10 / 15 min) then callssignIn('credentials', …). Field labels, placeholders, submit copy, and Zod error map all use translation keys.src/lib/server-only.ts—import 'server-only're-export utility used by every Kysely/Auth.js module.- Tests: unit (callbacks, password verify), e2e (sign-in happy path,
tenant_admin/super_adminredirect to/admin,course_adminredirect to assigned-course dashboard, unauthenticated redirect withcallbackUrl=preservation,course_adminblocked 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.mdcand the guiding i18n principle above — email subjects/bodies and account-flow UI copy are translation-keyed.
- Environment: add
EMAIL_FROM,RESEND_API_KEY,SMTP_*fallback variables, andAPP_BASE_DOMAIN(e.g.lvh.me:3001in dev) to.env.local.template; production secrets stay out of client-visible variables. src/lib/email/{client,templates}.ts—sendEmail()interface with Resend as primary and Nodemailer SMTP fallback. Keep provider details server-only; log sanitized delivery metadata throughsrc/lib/log.ts.- Sign-up route (
/learn/sign-up) — Zod-validated form, tenant resolved from host, bcrypt hash, createusersrow withemail_verified_at = null, rolestudent, and locale default, then send verification email. - Verify route (
/learn/verify/\[token\]) — consume single-useverification_tokensrow, setusers.email_verified_at, bump/refresh session state if needed, redirect to sign-in withcallbackUrl. - 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. - Magic-link sign-in — custom
magic_linkrows inverification_tokens, requested via/learn/magicand consumed at/learn/magic/\[token\](requestMagicLink/consumeMagicLinkserver actions); Credentials remains the sign-in provider after token consumption (not the Auth.js Email provider). - 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 peruser-management.mdF10. - Admin hooks for Phase 9 — server actions to resend verification, issue password-reset email, and revoke active sessions by bumping
users.token_version. - 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.md—QuizvsQuizPublicschema split and quiz wire shape.
src/schemas/— Zod schemas withzod-to-openapiregistry:Course,Module,Entry(type ∈ {'lesson','quiz','assignment'}),Quiz,QuizQuestion,EnrollmentRequest,QuizAttemptSubmission.PageHierarchyNodeand a genericpaginated<T>envelope (used by every list-style CMS call).Quizis server-only and includes correct answers / accepted answers for grading;QuizPublicstrips those fields before quiz data reaches the client bundle.
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 athttp://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_URLand carry anx-api-key: ${CMS_API_UUID}header. Read both via the Zod-validatedsrc/lib/env.tsaccessor — no directprocess.envreads in feature code. - Methods.
getPublishedCourses(),getCourseBySlug(slug),getEntry(courseSlug, entrySlug), andgetPageHierarchy()which callsGET /projects/{CMS_PROJECT_UUID}/page-hierarchy— the canonical source for the public-site menu / navigation tree, consumed byMainNav(Phase 1) and the marketing routes (Phase 5). - Pagination. Endpoints support
page(default1) andpageSize(default20, max100) query params. Expose a typedpaginate({ page, pageSize })helper and reuse thepaginated<T>Zod envelope. Document the max=100 ceiling so callers don't over-request. - Locale threading. Every CMS method accepts a
localeparameter and appends?locale=<locale>to the CMS query string. Authenticated routes passusers.locale; public routes pass thenext-intlresolved locale. CMS-authored copy is rendered as returned; seetech-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 toCMS_PROJECT_UUIDupstream) andGET /api/cms/courses/{slug}/assets/{uuid}for published course payloads (public; resolves the course project by slug and streams fromGET /api/v1/projects/{projectId}/assets/{uuid}with the server-side API key). Unknown or unpublished course slugs return 404. - Error shape. Throws a typed
CmsIntegrationErroron schema mismatch or non-2xx responses; includes the request path and status for log correlation.
- Source of truth. Upstream project is open-learning-hub/edx-cms; local dev URL is
src/lib/cms/published.ts— server-side filter: published-only used bygenerateStaticParamsand runtime fetch.scripts/openapi-generate.ts(tsx) — emitpublic/openapi.yamlfrom the Zod registry. YAML matches the upstream edx-cms convention (http://localhost:3000/openapi.yaml) so both contracts share a single format. Use theyamlpackage; do not emit JSON.app/\[locale\]/admin/api-docs/page.tsx— Swagger UI loaded against/openapi.yaml(gated totenant_admin/super_admin). Note: an earlier draft of this plan put the route underapp/api/docs/..., but/api/*is reserved for route handlers and isDisallow-ed inrobots.txt; the admin tree is the correct home.- 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.
app/page.tsx— home: hero, featured courses (tenant-scoped LMScourse_settings+ CMS published summaries).app/\[slug\]/page.tsx— generic CMS page;generateStaticParamsfrom published pages;generateMetadatafrom CMS;revalidate = 60.app/blog/page.tsx,app/blog/\[slug\]/page.tsx— list + detail; Portable Text rendering via@portabletext/reactwith sanitised serializers.app/learn/page.tsx— course list (no enrollment data); links to enroll.app/learn/course/\[slug\]/page.tsxunauthenticated/unenrolled branch — public course landing with published metadata, preview-safe summary, and sign-in/enroll CTA. The enrolled branch is completed in Phase 6.MainNavis built fromgetPageHierarchy()(Phase 4 client) rather than a hand-maintained list, so the rendered menu cannot drift from the CMS structure.- 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.
- 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-course501behavior.user-management.md—requireSession(),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.
app/learn/course/\[slug\]/enroll/page.tsx— server component, requiresawait auth().- Server action
enrollInCourse(courseSlug):- Validate slug with Zod; verify course is published via CMS client.
- Insert/upsert into
enrollmentsscoped bytenant_id+user_id; statusactivefor free courses. - Paid courses return
501 Not Implementedwith user-safe copy until a real payment phase ships. Keep only the provider-neutral seam insrc/server/payments/*described bycourse-enrollment-system.md; do not add a developmentsimulatePaymentSuccessaction. - Re-enrollment from
cancelledupdates the same row, bumpsenrolled_at, clearscompleted_at, and writes the audit event. revalidateTag('progress:'+userId); redirect to/learn/course/\[slug\].
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.- UI:
EnrollButton,CourseModuleList,EntryRow(completed / locked / available).EnrollButton, status badges (pending/active/completed), and "Awaiting payment" copy are translation keys. - 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.
- Tests: state-machine unit coverage for allowed/forbidden transitions, idempotent insert, paid-course
501, archived-course410, 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— authoritativeCourseSidebarlayout, 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 bygetEntryAccess.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.
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.
- Server-side: load course (CMS), call
src/lib/course/pagination.ts— puregetEntryPagination(entries, currentEntrySlug)helper over the flattened list. Module boundaries are visual only; prev/next crosses modules.src/lib/course/gating.ts— centralgetEntryAccess(...)returns the modes specified inentry-pagination.md; callers must not reimplement gating.markEntryCompleteserver action — upsertentry_progress; no-op in preview mode; revalidatecourse:<slug>andprogress:<userId>.recordEntryViewserver action/helper — called from the entry RSC during render to updatelast_viewed_at; no-op in preview mode; failures log and do not break rendering.- 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. - Admin preview-as-student mode (
?preview=1) — available to assignedcourse_admin,tenant_admin, andsuper_admin; bypasses enrollment and gating but is read-only and rendersPreviewBanner. CourseSidebar— implement thetoggleable-navigation.mdcontract: non-persisted Zustand store, CSS flexbox layout, noreact-resizable-panels,sidebar-toggle, module groups, locked-row toast, and[shortcut.useEntryShortcuts(client) —←/→for prev/next plus[for sidebar toggle; ignores form/contenteditable focus; respectsaria-disabled; live-region announcements use translation keys.- Resume/Continue target — course home and dashboard link to first entry, first incomplete entry, or most recently viewed entry per
entry-pagination.md. - Tests: unit (pagination,
getEntryAccess, resume target), component (EntryNav,CourseSidebar,PreviewBannerstates), 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.md—assignmentis 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.
- Treat
_type: 'assignment'as a supported CMS entry shape in schemas and routing so courses containing assignments do not crash. - Render assignment body/instructions with the same safe Portable Text renderer used for lessons.
- Use the same manual
MarkCompleteButtonpath as lessons; do not add file upload, grading queue, or submission persistence in V1. - Translation-keyed placeholder copy clearly states that assignment submissions are not yet implemented.
- 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, andgetEntryAccess.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.
- Quiz schemas already in
src/schemas/. Question types:multiple_choice,true_false,short_answer. Expose separateQuiz(server-only, includes correct answers) andQuizPublic(client-safe, strips correct answers / accepted answers) schemas. QuizPlayer(client) — react-hook-form + Zod; per-question validation; one-pass submission.- Server action
submitQuizAttempt({courseSlug, entrySlug, answers}):- Call
getEntryAccess(...); reject unlessmode === 'enrolled'andgated === false. Preview mode returnspreviewReadOnlyand writes nothing. - Re-fetch quiz from CMS (never trust client question shape), grade server-side, persist to
quiz_attemptswithattempt_number,answers_json, andfeedback_json, writeentry_progressifpassed === trueor the newattempt_number === maxAttempts. - Return
{score, passed, correctCount, totalCount, attemptNumber, attemptsRemaining, perQuestionFeedback?}per quiz policy.
- Call
- Retry policy:
quiz.maxAttemptsfrom CMS; when exhausted, allow review-only mode. QuizResultspage 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.- 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.
app/learn/dashboard/page.tsx— enrolled courses, in-progress entries, recent quiz scores. RSC; data via Kysely scoped byuser_id+tenant_id.app/admin/courses/\[slug\]/dashboard/page.tsx— gated tocourse_admin(whenslug ∈ assignedCourses),tenant_admin, orsuper_admin. Roster, results, per-entry analytics. No content editing. Query shapes, source tables, and cache policy are specified inanalytics.md.app/admin/page.tsx— gated byrole ∈ {'tenant_admin','super_admin'}:- User list with role promotion within
{ student, course_admin, tenant_admin }(server action; bumpsusers.token_versionand refreshes JWT viaupdate()pattern). - Course-admin assignment management (
course_admin_assignments). - Enrollment overview.
- Link to OpenAPI / Swagger UI.
super_adminonly:/admin/tenantsand cross-tenant user search viawithTenantOverride(); impersonation start/stop writes toaudit_log.
- User list with role promotion within
- 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. - Tenant lifecycle admin —
super_admincan create, activate, suspend/archive tenants, and manage custom domains pertenant-configuration.md; every mutation writesaudit_logand tenant suspension bumps affected users'token_version. - Zustand store(s) only for ephemeral UI (filters, table sort) — never for layout/visibility flags (per root
AGENTS.md/ LMSAGENTS.mdpersist-SSR rule). - Section headings, empty states, table column headers, feature-flag labels, and role names rendered to admins all use translation keys.
- Tests: e2e
tenant_adminpromotes student tocourse_adminand assigns a course; non-admin gets 403;course_adminblocked 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.
app/error.tsx,app/global-error.tsx, route-levelerror.tsx— name exportsErrorPage(neverError); friendly messages; never expose stack/SQL.ErrorPage,not-found, and per-segment error copy use translation keys; never expose raw error messages to users.app/not-found.tsxglobal + per-segment.proxy.tsapplies all headers specified in monorepo root.cursor/rules/120-security.mdc§Security Headers; do not duplicate a partial header list here.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.- PostHog: wired through the shared
@open-learning-hub/observabilitypackage (client provider + consent gate, serverposthog-node, OpenTelemetry log forwarding insrc/instrumentation.ts) so server components, route handlers, and client components share one reporting surface. Seeadmin/observability.md. - CSRF: rely on Auth.js cookies (
SameSite=Lax); double-submit token for state-changing API routes outside Auth.js. - Sanitise all CMS
rawHtmlblocks at the server fetch boundary (src/lib/cms/sanitize-cms-html.ts) before rendering anywhere outside trusted Portable Text serializers. - 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 return429on 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.
-
Shared infrastructure
src/lib/cms/site.ts—getSiteForHost(host)helper that resolves the tenant from the request host (reusing theproxy.tsresolver from Phase 3) and returns the CMS site document; cached withunstable_cacheand taggedsite:<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,
Uint8Arrayfor binary payloads, try/catch around every CMS fetch with fallback to the static asset using the sameCache-Controlheaders as the dynamic response. Noany. - 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.
-
Favicon (
/favicon.ico)src/app/api/favicon/route.ts— primary CMS-sourced favicon; falls back topublic/fallback/favicon.icoon missing field or fetch error.next.config.*rewrite:/favicon.ico→/api/favicon.Cache-Control: public, max-age=31536000, immutable.
-
PWA / device icons
src/app/icon.tsx— generates/icon.png(192 + 512) fromsite.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.
-
Web App Manifest (
/manifest.webmanifest)src/app/manifest.ts—name,short_name,theme_color,background_color,iconsfrom CMS; tenant-host fallback forname.- Do not hand-roll
<link rel="manifest">— Next.js emits it automatically. Cache-Control: public, max-age=3600.
-
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.xmlreferencing the request host. Cache-Control: public, max-age=3600.
-
Sitemap (
/sitemap.xml)src/app/sitemap.ts— published-only (mirrors rootAGENTS.md/ security rules and thegenerateStaticParamsrule). Drafts, archived, unlisted excluded.- Sources:
(main)CMS pages,(main)/blog/\[slug\],(lms)/learnand public course landing pages/learn/course/\[slug\]. Exclude post-enrol-only routes;lastModifiedfrom CMSupdated_at. export const revalidate = 60(matches site-wide ISR).- When published-entry count exceeds 50,000 URLs, switch to a sitemap index:
sitemap.tsreturns the index, chunks atsitemap/\[id\]/sitemap.ts(Next.js multi-sitemap convention). - Never include URLs that 404, redirect, or require auth.
-
Social card images
src/app/opengraph-image.tsx(1200×630) andsrc/app/twitter-image.tsx(1200×600).- Source
site.og_image; fallback tonext/ogImageResponserendered withsite.name+ tenant theme color. - Per-route override allowed (e.g.
(main)/blog/\[slug\]/opengraph-image.tsxusing the post hero). Cache-Control: public, max-age=31536000, immutable.
-
.well-knownfilessrc/app/api/well-known/security/route.ts→/.well-known/security.txt(RFC 9116). Fields:Contact(fromsite.security_contact, fallbackmailto: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 innext.config.ts→/learn/forgot.
-
AEO / answer-engine surfaces
src/app/llms.txt/route.tsexposes a default-locale URL index of published public pages.src/app/llms-full.txt/route.tsexposes a bounded markdown corpus (public pages + blog + course landing summaries), excluding auth-gated entry content./*.mdroutes are rewritten tosrc/app/api/aeo/markdown/[...path]/route.tsand return markdown projections for published discovery URLs only.src/lib/seo/url-inventory.tsis the shared allowlist used by sitemap, robots, llms, and markdown routes.
-
CMS revalidation webhook
src/app/api/cms/revalidate/route.ts—POST /api/cms/revalidate, authenticated byCMS_WEBHOOK_SECRET, validates tag payloads, rate-limits at 60 requests/min/IP, and callsrevalidateTagfor accepted CMS tags.- Payload and allowed tag prefixes are specified in monorepo root
.cursor/rules/050-apis.mdc§Inbound Webhooks.
- 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), correctContent-Type, and matchingCache-Controlfrom the policy table. - CMS revalidation webhook: valid tag, multiple tags, missing secret, invalid secret, invalid tag prefix, malformed body, and rate-limit exhaustion.
- Documentation hook — Phase 12 appends OpenAPI / Swagger pointers to
050 - APIs.mdcand 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, andtenant-configuration.md.
- Vitest config with
happy-dom; coverage thresholds forsrc/db/queries,src/lib/cms,src/auth, schemas, and 100% file coverage onsrc/components/**(every component has a co-located test, enforced by a CI check that fails when a.tsxlacks a sibling.test.tsx). - Playwright config with
DATA_DIR=./data/test; project-level seeds; unique resource names per test (perAGENTS.mdtest convention). - 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
esto prove the locale switcher is wired, not just the file shipped - i18n regression (Playwright): visit each top-level route on
esand assert no English-locale-only string fromen.jsonappears in the rendered HTML - Theme smoke (each top-level route renders in light, dark, and system with
prefers-color-schemetoggled at the Playwright level) - WCAG sweep with
@axe-core/playwright— run in both light and dark
- CI workflow (separate task; document command) runs
npm run verify && npm run test:unit && npm run test:components && npm run test:e2e. - i18n CI check —
scripts/check-i18n.mjs(run from rootnpm run check):- Greps
src/app/**andsrc/components/**for JSX text nodes,aria-label=,placeholder=,title=containing two or more letters with a space (heuristic for English sentences) and not wrapped int(...)/ a known allowlist; fails on any hit. - Diffs key sets across
messages/*.jsonand fails on any locale missing a key present inen.json.
- Greps
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.pyanddocs/zensical/zensical.toml— mirror.cursor/rulesintodocs-source/plus Zensical project config;public/docs/comes fromnpm 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.
- 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.
- Update
docs/zensicalsource markdown to mirror; runnpm run docs:check. - Append OpenAPI / Swagger pointers to
050 - APIs.mdcand verify the Phase 10.5 well-known surface matches the rule's Caching Policy Table exactly. - Final
npm run verify(check + build + docs:check).
Critical Files to Create / Modify¶
src/app/layout.tsx,src/app/page.tsx— extendsrc/app/(main)/...,src/app/learn/...,src/app/admin/...,src/app/api/...— new route treessrc/auth/config.ts,src/auth/index.ts,src/auth/guards.ts,app/api/auth/\[...nextauth\]/route.tsproxy.ts(root) — locale + auth + headerssrc/db/{client,types,migrations,seeds,queries}/...src/lib/cms/{client,published,page-hierarchy,site}.tssrc/lib/course/{pagination,gating}.tssrc/lib/email/{client,templates}.tssrc/lib/log.ts,src/lib/analytics/{config,server,events}.ts,src/instrumentation.tssrc/schemas/*src/types/{next-auth.d.ts, domain.ts, ...}src/components/{site,auth,course,quiz,dashboard,admin}/...messages/{en,es,fr,de,pt,zh}.jsonscripts/{db-migrate,db-seed,openapi-generate,check-i18n}.tssrc/lib/i18n/keys.ts— typedthelper 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 inlinetokens; do not introduce atailwind.config.*.docs/Zensical pipeline — reuse for published docs; do not duplicate.package.jsonpredevalready runsdocs:publish— leave intact.
Verification¶
After each phase:
npm run check(Biome auto-fix) — must be clean.npm run build— must compile with zero TS errors and zeroany.npm run test:unit— relevant suites green.npm run test:e2e— flows for the phase green (Phases 3+).- Manual smoke:
npm run dev→ visit the routes touched by the phase; verify auth gates, ISR (60s), keyboard navigation, screen-reader landmarks, locale switch. - Final phase:
npm run verifyandaxePlaywright sweep clean across all locales.
Resolved Decisions (from user)¶
- Tenancy: Subdomain-based by default (
<tenant>.host) with optional custom-domain aliases pertenant-configuration.md.proxy.tsresolves the tenant from the host, rejects suspended tenants, attaches tenant context to the request, and passes it through to RSCs / server actions. Local dev useslvh.me(or equivalent) so subdomains work without/etc/hostsedits. Auth.js Credentials provider takes onlyemail+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 return501 Not Implementeduntil a real payment phase ships; no developmentsimulatePaymentSuccessaction 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_tokenstable, sign-up + email verification, forgot-password,tenant_admin/super_admin-triggered reset, magic-link sign-in via custommagic_linktokens (Credentials + optional Google OAuth). - Node runtime: Node ≥ 22 via monorepo root
package.jsonengines; CI may pin a specific 22.x line (see rootAGENTS.mdbaseline). - CMS integration: Headless CMS is the open-learning-hub/edx-cms project. Local dev runs on
http://localhost:3000/; OpenAPI at/openapi.yaml. Auth viax-api-keyheader carryingCMS_API_UUID. Project scope viaCMS_PROJECT_UUID. Page hierarchy (menu) sourced from/projects/{projectId}/page-hierarchy. Pagination usespage+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)