Skip to content

feat(lessons): AI-generated HTML artifact lessons — sandboxed, tenant-themed custom lesson pages #722

Description

@guillermoscript

Summary

Let a lesson's content be a self-contained HTML document (its own CSS, JS and layout) that the teacher creates with AI. The page renders it in a sandbox, the same way Claude renders an Artifact. A lesson then stops being "Markdown in the house style" and gets a personality of its own: interactive diagrams, step-throughs, tabs, reveal cards, mini-simulations, a visual design that fits the topic.

End goal: a student opens a lesson and sees a custom, well-designed, readable HTML page that their teacher made with AI. The teacher sees exactly the same page while authoring and in the course preview.

Scope: lesson content only. Chrome, navigation, the completion button, comments, the AI tutor, resources and checkpoints stay owned by the app.


Why

  • Today every lesson is an MDX string (lessons.content) rendered through one component map (components/lesson/mdx-components.tsx). Every school's lessons look the same, and anything richer than callouts, quizzes and code blocks is impossible.
  • Teachers already produce this kind of content in Claude and ChatGPT (artifacts, canvases), but they have nowhere to put it.
  • The platform already proved the pattern twice. We are extending it, not inventing it:
    • The artifact exercise type (20260311000000_add_artifact_exercise_type.sql) stores exercise_config.artifact_html and renders it through srcDoc with sandbox="allow-scripts", plus a postMessage bridge (components/exercises/artifact-exercise.tsx:125-181).
    • The MCP server already has a mcp-server/views/artifact-sandbox view.

Current state (research)

Area Today File
Storage lessons.content holds MDX text. There is no format column. embed_code holds raw HTML. A BEFORE UPDATE trigger snapshots content into content_versions. lib/database.types.ts:3835, 20260215000001_create_version_triggers.sql
Authoring The Content step has two modes: Visual (block editor, blocksToMdx/mdxToBlocks) and MDX. Saving goes through the createLesson/updateLesson server actions. components/teacher/lesson-editor/lesson-content-step.tsx:35-83, app/actions/teacher/lessons.ts:21,86
Student render The server runs serializeLessonMdx (next-mdx-remote-client), then MDXClient. There is no sanitizer. The page uses the admin client plus requireCourseAccess. app/[locale]/dashboard/student/courses/[courseId]/lessons/[lessonId]/page.tsx:59,278, lesson-content.tsx:176
Other renders The public preview (#426) and the teacher preview reuse the same LessonContent. (public)/courses/[id]/lessons/[lessonId]/page.tsx:111, teacher/.../preview/lessons/[lessonId]/page.tsx:135
Read RLS Access requires a tenant match AND (staff, OR a published preview lesson, OR the author, OR has_course_access()). Anon can read preview lessons, with column grants that include content and embed_code. 20260724150000_content_entitlement_rls.sql:61, 20260722120000_lesson_preview.sql:20
AI Every model in lib/ai/config.ts is gpt-4o-mini through AI SDK v7. Lesson stubs come from generateObject (app/actions/admin/ai-course.ts). All AI routes are gated only by role and aiGenerationLimiter; none calls requirePlanFeature. lib/ai/config.ts, lib/rate-limit.ts:106
AI tutor Raw lesson.content is concatenated into the prompt (the prompt itself is hardcoded in Spanish). app/api/chat/lesson-task/route.ts:43,75, lib/ai/prompts.ts:40
Layout The lesson sits in h-[calc(100dvh-4rem)] with an inner scroll area. Scroll drives the reading-progress bar. Completion is a manual button that is blocked while required checkpoints are missing. page.tsx:338, components/student/lesson-scroll-area.tsx, lesson-navigation.tsx:77,302
Theming TenantCssVars writes :root / .dark variables. resolvePresetVars() returns both the light and dark maps. components/tenant/tenant-css-vars-server.tsx
Security baseline There is no CSP (next.config.ts has no headers(), proxy.ts sets none). There is no HTML sanitizer dependency. next.config.ts, proxy.ts, package.json
MCP lms_create_lesson, lms_update_lesson_content and lms_view_lesson are all MDX-only. The views parse MDX with remark-mdx. mcp-server/src/tools/lessons.ts:184,336, student.ts:227, mcp-server/views/shared/lesson/mdx.ts
Mobile (lms-app) Queries Supabase directly and renders content with react-native-markdown-display. embed_code is shown in a WebView. ../lms-app/app/(student)/lesson/[id].tsx:94

⚠️ Found during research: stored XSS path (related, fix first or together)

The student lesson page renders lessons.embed_code with dangerouslySetInnerHTML in the default embedMode = 'trusted' (lesson-content.tsx:41,50,160). Only the public preview passes embedMode="sandboxed". embed_code has no UI writer, but a teacher's lesson UPDATE through PostgREST is probably enough to set it. That would run arbitrary script on the tenant origin, inside the student's session. This is exactly the threat this feature has to design out. Proposal: route embed_code through the same sandboxed frame built here, or file and fix it on its own. The column-level UPDATE grant still needs verifying.


Proposed design

1. Data model (migration)

Keep the heavy HTML off the lessons row. Lesson lists select('*'), and the anon column grants on lessons would otherwise need rework.

ALTER TABLE lessons
  ADD COLUMN content_format text NOT NULL DEFAULT 'mdx'
  CHECK (content_format IN ('mdx', 'html'));

CREATE TABLE lesson_artifacts (
  lesson_id   int  PRIMARY KEY REFERENCES lessons(id) ON DELETE CASCADE,
  tenant_id   uuid NOT NULL REFERENCES tenants(id),
  html        text NOT NULL CHECK (octet_length(html) <= 512000),
  text_extract text NOT NULL DEFAULT '',   -- visible text: AI tutor context, search, mobile/a11y fallback
  source      text NOT NULL CHECK (source IN ('ai', 'manual', 'mcp', 'converted')),
  model       text,                        -- which model produced the current version
  updated_by  uuid REFERENCES auth.users(id),
  updated_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE lesson_artifact_versions (   -- every AI generation/refine + manual save; restore = copy back
  version_id  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  lesson_id   int NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
  tenant_id   uuid NOT NULL,
  html        text NOT NULL,
  prompt      text,                        -- the instruction that produced it (null for manual)
  created_by  uuid,
  created_at  timestamptz NOT NULL DEFAULT now()
);
  • RLS: read on lesson_artifacts is EXISTS (select 1 from lessons l where l.id = lesson_id), so it inherits the whole lesson gate (tenant, entitlement, preview, staff) instead of copying it. Write is limited to tenant staff or the course author, mirroring lessons UPDATE. The versions table is staff/author only. An explicit anon policy covers preview lessons only.
  • The MDX content is kept when a lesson switches to html. Switching back to mdx is lossless and gives a fallback for surfaces that cannot run HTML.
  • text_extract is computed server-side on every save. Script, style and hidden/aria-hidden subtrees are stripped and whitespace collapsed.
  • After the migration: npm run db:types, and update docs/DATABASE_SCHEMA.md.

2. Rendering: <LessonArtifactFrame> (one component, every surface)

It is used by the student lesson page, the teacher preview, the public preview, the editor's live preview and (later) embed_code.

Sandbox rules (non-negotiable):

  • <iframe sandbox="allow-scripts" srcDoc={wrapped} title={lesson.title}>, never allow-same-origin. The frame gets an opaque origin: no tenant cookies, no Supabase session, no localStorage of the tenant origin, no parent.document.
  • No allow-top-navigation, allow-popups, allow-forms or allow-modals.
  • The server builds the document (lib/lesson-artifact/wrap.ts) and prepends a <meta http-equiv="Content-Security-Policy"> as the first child of <head>, before any author markup, so the author cannot precede or override it:
    default-src 'none';
    script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net;
    style-src 'unsafe-inline' https://fonts.googleapis.com;
    font-src https://fonts.gstatic.com;
    img-src data: blob: https://<project>.supabase.co/storage/v1/object/public/;
    media-src https://<project>.supabase.co/storage/v1/object/public/;
    connect-src 'none'; form-action 'none'; base-uri 'none';
    
    connect-src 'none' and the img-src allowlist close fetch and beacon exfiltration. form-action 'none' blocks posting a fake login anywhere.
  • The wrapper also strips <base> and <meta http-equiv="refresh">, and injects the bridge runtime (below).
  • ⚠️ srcdoc inherits the parent's CSP. Nothing breaks today because there is no app CSP. If we add a nonce-based CSP later, inline scripts in the frame will stop running. Phase 3 covers that by moving to a dedicated sandbox origin.

Bridge (lms-artifact/1, zod-validated both ways):

  • The host validates event.source === iframe.contentWindow. It cannot check the origin, which is "null". The host never sends secrets: no user id, email, token or tenant id.
  • Host → frame:
    • init { colorScheme, tokens, locale, reducedMotion }
    • theme { colorScheme, tokens } on each dark-mode toggle
  • Frame → host:
    • ready
    • resize { height }: the injected ResizeObserver auto-heights the frame, so there is no double scrollbar inside lesson-scroll-area
    • progress { ratio }: feeds the existing reading-progress bar
    • open-link { href }: the host checks it is http(s), then opens it with noopener
  • Completion, XP and checkpoints never come from the frame. The host button stays the only writer.

Tenant theming: the wrapper injects tenant tokens as --lms-primary, --lms-primary-foreground, --lms-background, --lms-foreground, --lms-muted, --lms-border, --lms-radius and --lms-font-sans, both light and dark, taken from resolvePresetVars(). It also sets data-theme and color-scheme on <html>. Generated lessons use those variables, so a lesson follows the school's brand and the viewer's theme. That fits PRODUCT.md: brand comes from color, and structure stays consistent.

3. Student and teacher experience

  • Student lesson page: when content_format = 'html', render <LessonArtifactFrame> in place of MDXClient. The header, lesson nav, completion button, resources, comments, AI tutor and video checkpoints stay unchanged. There are skeleton and error states, and an "artifact failed to load" fallback that shows text_extract.
  • Teacher preview and public preview use the same component, so what the teacher sees is what the student sees.
  • AI tutor: lesson-task/route.ts sends text_extract (not raw HTML) for html lessons. That keeps markup, scripts and token bloat out of the prompt and lowers the prompt-injection surface. The prompt also marks lesson content as data, not instructions.

4. Authoring (teacher, with AI)

A third mode in lesson-content-step.tsx: Visual · MDX · Artifact.

  • Split view: a prompt/refine chat on the left, a live <LessonArtifactFrame> on the right. Toggles for desktop / phone width and light / dark, and a Code tab with a monospace editor. The repo has no code-editor dependency, so v1 uses a textarea and CodeMirror comes later.
  • Generate: "Describe the lesson" is prefilled from the title, description, course context, existing MDX content and the author's locale.
  • Refine: "make section 2 an interactive quiz", "add a diagram of the TCP handshake". The current HTML goes to the model and a full new document comes back. v1 always returns a full document, which is simpler and robust.
  • Convert: a one-click "Convert this MDX lesson to an artifact" (source = 'converted'). MDX is kept.
  • Versions: a dropdown lists every generation and manual save, with restore.
  • Route: POST /api/teacher/lessons/[lessonId]/artifact/generate streams with streamText. HTML streams naturally into the preview; srcDoc updates are debounced (about 300 ms), with a final swap when the stream ends. Guards follow generate-questions/route.ts:
    1. auth
    2. tenant
    3. role (teacher/admin)
    4. course ownership, or tenant admin (ux(admin): a tenant admin cannot open or add lessons to a course they did not author #690 rules)
    5. aiGenerationLimiter
    6. requirePlanFeature(tenantId, 'ai_lesson_artifacts')
  • Plan gate: a new key ai_lesson_artifacts, added to FEATURE_REQUIRED_PLAN (tier TBD, see open questions). Backfill it into every platform_plans JSON before any gate reads it, following 20260901170000_backfill_plan_feature_keys.sql, because the gate is closed by default. It needs a gate site, or plan-feature-gate-contract.test.ts fails. Rendering stays ungated: a school that downgrades keeps its existing artifact lessons and only loses AI generation. Pasting HTML manually is open question 4.
  • Model: add a lessonArtifact entry to lib/ai/config.ts. gpt-4o-mini produces weak visual design and is a poor fit here, so this needs a stronger model (see open questions).
  • System prompt: lib/ai/prompts/lesson-artifact.ts is the "lesson artifact design guide". It requires:
    • one self-contained file
    • scripts only from the CDN allowlist; no network calls, forms, password or credential inputs, or tracking
    • --lms-* tokens for brand colors, with both light and dark themes
    • responsive from 360px, with no horizontal page scroll
    • reading-first typography: 65–75ch measure, semantic h1h3, generous spacing
    • WCAG AA contrast, alt text, keyboard-operable interactives, prefers-reduced-motion
    • interactivity only when it teaches (reveal, tabs, stepper, self-check, SVG diagrams)
    • output in the author's language
    • a size budget of about 150 KB
  • Server-side lint on save (a warning, or a refusal for hard violations):
    • size over the cap → refuse
    • <input type=password> → refuse
    • script hosts outside the allowlist → warn, they will be blocked
    • <form> → warn, it will not submit
    • <base> / meta refresh → stripped

5. MCP (teachers authoring from Claude/ChatGPT directly)

  • lms_update_lesson_content accepts format: 'mdx' | 'html', or add a new lms_set_lesson_artifact tool. It writes through the same lint and text_extract path, through a shared lib and not a copy. Read mcp-apps-builder first.
  • lms_view_lesson and lms_get_lesson render html lessons through the existing views/artifact-sandbox pattern, keeping the same no-allow-same-origin rule.
  • This is where the "like Claude artifacts" loop really closes: the teacher iterates on the lesson in Claude, and one tool call publishes it to the school.

6. Mobile (lms-app, follow-up issue there)

Render lesson_artifacts.html in a WebView: JS on, originWhitelist limited to the CDN allowlist, the same CSP wrapper, and setSupportMultipleWindows={false}. Until then, fall back to text_extract.


Threat model

Threat Mitigation
A malicious or compromised teacher account runs script against students (session theft, acting as the student) Opaque-origin sandbox, never allow-same-origin. The frame cannot reach cookies, the Supabase session or the parent DOM.
Phishing: a fake "log in again" form inside the lesson form-action 'none', connect-src 'none', no popups or top navigation, lint refuses password inputs, and the prompt forbids credential UI.
Data exfiltration, tracking pixels connect-src 'none', img-src/media-src limited to data:, blob: and our Storage.
postMessage spoofing Check event.source, zod-parse every message, the host sends no secrets, and completion/XP never come from the frame.
Prompt injection into the AI tutor via hidden text The tutor gets text_extract, with hidden subtrees stripped, and treats it as data.
Cross-tenant read lesson_artifacts RLS derives from the lessons gate. Queries still filter tenant_id explicitly.
Resource abuse (busy loops, miners) Size cap and lint. Browsers that do not process-isolate sandboxed frames can still freeze the tab; Phase 3's separate origin plus a "report lesson" link.
Future app CSP breaking srcdoc Documented. Phase 3 dedicated sandbox origin.

Phases

Phase 1: MVP (the end goal)

  • Migration: content_format, lesson_artifacts, lesson_artifact_versions, RLS, anon preview policy, types, schema doc
  • lib/lesson-artifact/: wrap.ts (CSP prepend, strip, tokens, bridge runtime), bridge.ts (zod protocol), extract-text.ts, lint.ts, plus unit tests
  • <LessonArtifactFrame> with auto-height, theme sync, progress, link bridge, and loading/error/fallback states
  • Student lesson page, teacher preview and public preview render html lessons
  • Editor "Artifact" mode: generate, refine, code tab, viewport and theme toggles, save through updateLesson
  • /api/teacher/lessons/[lessonId]/artifact/generate (streaming, guards, limiter, plan gate) and the lessonArtifact model config
  • ai_lesson_artifacts plan key, backfill migration, FEATURE_REQUIRED_PLAN, gate site
  • AI tutor uses text_extract for html lessons
  • en/es strings for every new UI

Phase 2

  • Convert MDX → artifact; version history UI with restore
  • MCP tool and view support
  • lms-app WebView render (issue in lms-app)
  • embed_code moved onto <LessonArtifactFrame> (if not already fixed on its own)

Phase 3

  • Dedicated sandbox origin (e.g. usercontent.<platform-domain>) serving the document with a real Content-Security-Policy: sandbox allow-scripts … header, frame-ancestors limited to tenant subdomains, and a short-lived signed token for access
  • Inline checkpoints from inside the artifact: the frame posts checkpoint { id }, and the host renders the existing LessonCheckpoint in a sheet, so grading stays on the host
  • Image upload to the lesson-resources bucket from the artifact editor, referenced by public URL (already in img-src)

Acceptance criteria

  • A teacher can describe a lesson, get an AI-generated HTML artifact, refine it in conversation, edit the code, preview it at phone and desktop width in light and dark, and save it.
  • A student with access sees the same artifact on the lesson page, sized to its content, with no nested scrollbar. It follows the tenant brand and the light/dark toggle.
  • The teacher preview and public preview (for preview lessons) show the identical render.
  • Inside the frame, document.cookie is empty, parent.document throws, fetch('https://example.com') is blocked by CSP, and a <form> submit does nothing.
  • A student from another tenant, or without an entitlement, cannot read the lesson_artifacts row through PostgREST.
  • A tenant on a plan without ai_lesson_artifacts gets PlanFeatureError from the generate route, and existing artifact lessons still render.
  • Switching a lesson back to MDX restores the original MDX content untouched.
  • The AI tutor on an html lesson answers from the lesson's text, and its prompt contains no <script>.
  • npm run build, npm run typecheck and npm run test:unit pass.

Test plan

  • Unit (tests/unit/lesson-artifact/*):
    • the CSP meta is the first child of <head>, even when the author supplies their own <head>, <meta> or a doctype-less fragment
    • <base> and meta refresh are stripped
    • lint refuses password inputs and oversize documents
    • extract-text drops script, style and hidden nodes
    • the bridge rejects malformed messages
    • a plan-contract test entry for the new key
  • E2E (tests/playwright/lesson-artifact.spec.ts, --workers=1, lvh.me):
    • a teacher saves an html lesson (with a stubbed generation response, so there is no model call in CI), then a student views it: the iframe sandbox attribute equals allow-scripts, and the frame's document.cookie === '' and blocked fetch are asserted through frameLocator + evaluate
    • a cross-tenant student (alice@student.com) gets an empty result for the row
    • a plan-gate case uses plan-gate-fixtures.ts
    • screenshots in light and dark, phone and desktop

Open questions

  1. Plan tier for ai_lesson_artifacts: pro, next to ai_grading, or starter to drive adoption?
  2. Model: which stronger model to put behind lessonArtifact? It has to be good at front-end design. That means adding a provider SDK or an OpenAI model tier, and deciding how to cap per-tenant cost.
  3. CDN scripts: allow the allowlist (Chart.js, Mermaid, KaTeX, three.js are all very useful for lessons) or go fully offline in v1?
  4. Manual HTML paste without AI: gated with the AI feature, or open to every plan since rendering is ungated?
  5. One format per lesson (v1), or mixed (an MDX lesson with embedded artifact sections) later?
  6. External links: open them directly after the http(s) check, or show an "you're leaving the lesson" interstitial?

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    db-migrationRequires a Supabase migrationenhancementNew feature or requestuxUser journey / experience

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions