You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
The Content step has two modes: Visual (block editor, blocksToMdx/mdxToBlocks) and MDX. Saving goes through the createLesson/updateLesson server actions.
The server runs serializeLessonMdx (next-mdx-remote-client), then MDXClient. There is no sanitizer. The page uses the admin client plus requireCourseAccess.
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.
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).
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.
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.
ALTERTABLE lessons
ADD COLUMN content_format textNOT NULL DEFAULT 'mdx'CHECK (content_format IN ('mdx', 'html'));
CREATETABLElesson_artifacts (
lesson_id intPRIMARY KEYREFERENCES lessons(id) ON DELETE CASCADE,
tenant_id uuid NOT NULLREFERENCES tenants(id),
html textNOT NULLCHECK (octet_length(html) <=512000),
text_extract textNOT NULL DEFAULT '', -- visible text: AI tutor context, search, mobile/a11y fallback
source textNOT NULLCHECK (source IN ('ai', 'manual', 'mcp', 'converted')),
model text, -- which model produced the current version
updated_by uuid REFERENCESauth.users(id),
updated_at timestamptzNOT NULL DEFAULT now()
);
CREATETABLElesson_artifact_versions ( -- every AI generation/refine + manual save; restore = copy back
version_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
lesson_id intNOT NULLREFERENCES lessons(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
html textNOT NULL,
prompt text, -- the instruction that produced it (null for manual)
created_by uuid,
created_at timestamptzNOT 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:
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.
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:
Plan gate: a new key ai_lesson_artifacts, added to FEATURE_REQUIRED_PLAN (tier TBD, see open questions). Backfill it into everyplatform_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
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.
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
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
Plan tier for ai_lesson_artifacts: pro, next to ai_grading, or starter to drive adoption?
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.
CDN scripts: allow the allowlist (Chart.js, Mermaid, KaTeX, three.js are all very useful for lessons) or go fully offline in v1?
Manual HTML paste without AI: gated with the AI feature, or open to every plan since rendering is ungated?
One format per lesson (v1), or mixed (an MDX lesson with embedded artifact sections) later?
External links: open them directly after the http(s) check, or show an "you're leaving the lesson" interstitial?
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
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.20260311000000_add_artifact_exercise_type.sql) storesexercise_config.artifact_htmland renders it throughsrcDocwithsandbox="allow-scripts", plus apostMessagebridge (components/exercises/artifact-exercise.tsx:125-181).mcp-server/views/artifact-sandboxview.Current state (research)
lessons.contentholds MDX text. There is no format column.embed_codeholds raw HTML. A BEFORE UPDATE trigger snapshotscontentintocontent_versions.lib/database.types.ts:3835,20260215000001_create_version_triggers.sqlblocksToMdx/mdxToBlocks) and MDX. Saving goes through thecreateLesson/updateLessonserver actions.components/teacher/lesson-editor/lesson-content-step.tsx:35-83,app/actions/teacher/lessons.ts:21,86serializeLessonMdx(next-mdx-remote-client), thenMDXClient. There is no sanitizer. The page uses the admin client plusrequireCourseAccess.app/[locale]/dashboard/student/courses/[courseId]/lessons/[lessonId]/page.tsx:59,278,lesson-content.tsx:176LessonContent.(public)/courses/[id]/lessons/[lessonId]/page.tsx:111,teacher/.../preview/lessons/[lessonId]/page.tsx:135has_course_access()). Anon can read preview lessons, with column grants that includecontentandembed_code.20260724150000_content_entitlement_rls.sql:61,20260722120000_lesson_preview.sql:20lib/ai/config.tsisgpt-4o-minithrough AI SDK v7. Lesson stubs come fromgenerateObject(app/actions/admin/ai-course.ts). All AI routes are gated only by role andaiGenerationLimiter; none callsrequirePlanFeature.lib/ai/config.ts,lib/rate-limit.ts:106lesson.contentis concatenated into the prompt (the prompt itself is hardcoded in Spanish).app/api/chat/lesson-task/route.ts:43,75,lib/ai/prompts.ts:40h-[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,302TenantCssVarswrites:root/.darkvariables.resolvePresetVars()returns both the light and dark maps.components/tenant/tenant-css-vars-server.tsxnext.config.tshas noheaders(),proxy.tssets none). There is no HTML sanitizer dependency.next.config.ts,proxy.ts,package.jsonlms_create_lesson,lms_update_lesson_contentandlms_view_lessonare 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.tslms-app)contentwithreact-native-markdown-display.embed_codeis shown in aWebView.../lms-app/app/(student)/lesson/[id].tsx:94The student lesson page renders
lessons.embed_codewithdangerouslySetInnerHTMLin the defaultembedMode = 'trusted'(lesson-content.tsx:41,50,160). Only the public preview passesembedMode="sandboxed".embed_codehas 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: routeembed_codethrough 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
lessonsrow. Lesson listsselect('*'), and the anon column grants onlessonswould otherwise need rework.lesson_artifactsisEXISTS (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.contentis kept when a lesson switches tohtml. Switching back tomdxis lossless and gives a fallback for surfaces that cannot run HTML.text_extractis computed server-side on every save. Script, style andhidden/aria-hiddensubtrees are stripped and whitespace collapsed.npm run db:types, and updatedocs/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}>, neverallow-same-origin. The frame gets an opaque origin: no tenant cookies, no Supabase session, nolocalStorageof the tenant origin, noparent.document.allow-top-navigation,allow-popups,allow-formsorallow-modals.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:connect-src 'none'and theimg-srcallowlist close fetch and beacon exfiltration.form-action 'none'blocks posting a fake login anywhere.<base>and<meta http-equiv="refresh">, and injects the bridge runtime (below).Bridge (
lms-artifact/1, zod-validated both ways):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.init { colorScheme, tokens, locale, reducedMotion }theme { colorScheme, tokens }on each dark-mode togglereadyresize { height }: the injected ResizeObserver auto-heights the frame, so there is no double scrollbar insidelesson-scroll-areaprogress { ratio }: feeds the existing reading-progress baropen-link { href }: the host checks it ishttp(s), then opens it withnoopenerTenant theming: the wrapper injects tenant tokens as
--lms-primary,--lms-primary-foreground,--lms-background,--lms-foreground,--lms-muted,--lms-border,--lms-radiusand--lms-font-sans, both light and dark, taken fromresolvePresetVars(). It also setsdata-themeandcolor-schemeon<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
content_format = 'html', render<LessonArtifactFrame>in place ofMDXClient. 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 showstext_extract.lesson-task/route.tssendstext_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.<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.source = 'converted'). MDX is kept.POST /api/teacher/lessons/[lessonId]/artifact/generatestreams withstreamText. HTML streams naturally into the preview;srcDocupdates are debounced (about 300 ms), with a final swap when the stream ends. Guards followgenerate-questions/route.ts:aiGenerationLimiterrequirePlanFeature(tenantId, 'ai_lesson_artifacts')ai_lesson_artifacts, added toFEATURE_REQUIRED_PLAN(tier TBD, see open questions). Backfill it into everyplatform_plansJSON before any gate reads it, following20260901170000_backfill_plan_feature_keys.sql, because the gate is closed by default. It needs a gate site, orplan-feature-gate-contract.test.tsfails. Rendering stays ungated: a school that downgrades keeps its existing artifact lessons and only loses AI generation. Pasting HTML manually is open question 4.lessonArtifactentry tolib/ai/config.ts.gpt-4o-miniproduces weak visual design and is a poor fit here, so this needs a stronger model (see open questions).lib/ai/prompts/lesson-artifact.tsis the "lesson artifact design guide". It requires:--lms-*tokens for brand colors, with both light and dark themesh1–h3, generous spacingalttext, keyboard-operable interactives,prefers-reduced-motion<input type=password>→ refuse<form>→ warn, it will not submit<base>/meta refresh→ stripped5. MCP (teachers authoring from Claude/ChatGPT directly)
lms_update_lesson_contentacceptsformat: 'mdx' | 'html', or add a newlms_set_lesson_artifacttool. It writes through the same lint andtext_extractpath, through a shared lib and not a copy. Readmcp-apps-builderfirst.lms_view_lessonandlms_get_lessonrender html lessons through the existingviews/artifact-sandboxpattern, keeping the same no-allow-same-originrule.6. Mobile (
lms-app, follow-up issue there)Render
lesson_artifacts.htmlin aWebView: JS on,originWhitelistlimited to the CDN allowlist, the same CSP wrapper, andsetSupportMultipleWindows={false}. Until then, fall back totext_extract.Threat model
allow-same-origin. The frame cannot reach cookies, the Supabase session or the parent DOM.form-action 'none',connect-src 'none', no popups or top navigation, lint refuses password inputs, and the prompt forbids credential UI.connect-src 'none',img-src/media-srclimited todata:,blob:and our Storage.event.source, zod-parse every message, the host sends no secrets, and completion/XP never come from the frame.text_extract, with hidden subtrees stripped, and treats it as data.lesson_artifactsRLS derives from thelessonsgate. Queries still filtertenant_idexplicitly.Phases
Phase 1: MVP (the end goal)
content_format,lesson_artifacts,lesson_artifact_versions, RLS, anon preview policy, types, schema doclib/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 statesupdateLesson/api/teacher/lessons/[lessonId]/artifact/generate(streaming, guards, limiter, plan gate) and thelessonArtifactmodel configai_lesson_artifactsplan key, backfill migration,FEATURE_REQUIRED_PLAN, gate sitetext_extractfor html lessonsPhase 2
lms-appWebView render (issue inlms-app)embed_codemoved onto<LessonArtifactFrame>(if not already fixed on its own)Phase 3
usercontent.<platform-domain>) serving the document with a realContent-Security-Policy: sandbox allow-scripts …header,frame-ancestorslimited to tenant subdomains, and a short-lived signed token for accesscheckpoint { id }, and the host renders the existingLessonCheckpointin a sheet, so grading stays on the hostlesson-resourcesbucket from the artifact editor, referenced by public URL (already inimg-src)Acceptance criteria
document.cookieis empty,parent.documentthrows,fetch('https://example.com')is blocked by CSP, and a<form>submit does nothing.lesson_artifactsrow through PostgREST.ai_lesson_artifactsgetsPlanFeatureErrorfrom the generate route, and existing artifact lessons still render.<script>.npm run build,npm run typecheckandnpm run test:unitpass.Test plan
tests/unit/lesson-artifact/*):<head>, even when the author supplies their own<head>,<meta>or a doctype-less fragment<base>andmeta refreshare strippedextract-textdrops script, style and hidden nodestests/playwright/lesson-artifact.spec.ts,--workers=1,lvh.me):sandboxattribute equalsallow-scripts, and the frame'sdocument.cookie === ''and blockedfetchare asserted throughframeLocator+evaluatealice@student.com) gets an empty result for the rowplan-gate-fixtures.tsOpen questions
ai_lesson_artifacts:pro, next toai_grading, orstarterto drive adoption?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.