# Findings & Decisions ## Requirements - 麦洛教育目标形态包含用户桌面端、运营端和后台数据服务。 - 用户端保留原单课件生成 Agent,并使用运营端发布的大型连续课程。 - 大型课程制作仅开放给运营端。 - 主 Agent 围绕课程目标拆解全局框架,并为每个子模块生成提示词;人工确认后串行调用原单课件生成 Agent。 - 发布后的每个模块完整保留原互动课堂、HTML 代码互动、原教师/助教、多 Agent 讨论、Quiz、PBL、白板和音频。 - 不新增或重写课堂交互内核;改造集中在编排、权限、发布、目录和服务端外围。 ## Research Findings - 2026-08-16 用户正式确认:平台账号是课程资源、购买权益和跨设备学习记录的永久主体;设备身份只用于持有证明,未登录游客数据必须支持显式迁移到账号。 - 当前 generation job store 的 `updateClassroomGenerationJob` 可用任意 patch 覆盖或清空 `ownerPrincipalId`,create 又始终省略 owner;因此先要把 owner 绑定从普通更新中剥离成单独的 compare-and-set/显式迁移操作。 - 当前 observe helper 故意只返回 `void`,无法直接复用作 enforcement。最小安全演进应新增独立的 evaluate/enforce helper,保留 shadow 路径原响应,同时由显式环境模式决定是否 fail closed;不能偷偷把现有 observe 改成返回授权结果。 - 在真实 session/device-proof provider 未接入前,production resolver 必须继续返回 `unavailable`;可先让创建路由在解析到 authenticated principal 时原子写 owner,并在 enforcement 模式下对 unavailable/anonymous/legacy-unowned 返回稳定错误,默认仍保持 shadow 兼容。 - generation job 的 POST/GET-list 目前没有身份测试;list 在非 server 角色会返回全部 job。required enforcement 竖切必须同时处理 create、list、detail、cancel、resume、delete,不能只保护单资源路由而留下全局枚举。 - 大型课程 runner 直接调用 job store 创建运营内部 job;owner 绑定参数必须保持可选,避免把 ops 编排误当 learner 账号资源。公开 API 创建才从已验证 principal 派生 owner/guest binding。 - required 模式若先从全局 job 文件中截取 `limit=20` 再按 owner 过滤,会让活跃多用户场景中的本人任务被其他用户的新任务挤出结果;正确顺序必须是“归属过滤 → 时间排序 → 用户 limit”。文件仓库阶段可在 required 模式读取全集后过滤,未来数据库迁移应下推为带 owner 索引的查询。 - 持久化 JSON 可能被旧版本或人工操作写成 owner+guest 同时存在;授权分类与迁移都必须将这种双归属视为损坏并 fail closed,不能因为先检查 owner 就忽略 guest。 - owner 字段除运行时防御外还应从普通 update patch 的 TypeScript 类型中移除;显式迁移函数是唯一允许写归属字段的代码路径。 - Phase 7 已落 provider-neutral 授权底座:`RequestPrincipal` 不绑定具体登录供应商,当前生产 resolver 恒为 `unavailable` 且不读取任何身份 header;纯 owner policy 对 legacy/unknown 给出 indeterminate,对 ops/service 身份不提供隐式越权。 - generation job 的 detail/cancel/delete/resume 已接 observe-only seam,observer 只返回 `void` 且隔离同步/异步异常,现有状态码与响应体不依赖授权 decision;`ownerPrincipalId` 为可选字段,旧 JSON 仍可读取,新任务目前不会伪造 owner。 - 独立审查未发现 observe-only 竖切阻断问题;未来接真实身份/遥测时必须增加超时或有界异步投递,并在创建资源时原子绑定不可被普通更新清空的 owner。 - Phase 7 完成全量出站链路分级:proxy-media、Azure Voices、MinerU Cloud 验证已 DNS pin;专用 server 已隐藏 provider verify/probe,但 image/video/TTS/voice/ASR/PDF/extract 及 LLM BYOK 生成入口仍可能把客户端 baseUrl 交给二次解析的 fetch/SDK。 - 旧 `validateUrlForSSRF()` 是“先解析校验、后由调用方重新解析连接”,仍有 DNS rebinding TOCTOU;`ALLOW_LOCAL_NETWORKS` 又同时放开 operator self-hosted 与不可信客户端 URL,必须拆成 server-managed public、operator-private 和 learner-BYOK 三类信任策略。 - 当前最直接的链式 SSRF 是 provider 返回的二次 URL:MinerU Cloud 的 upload URL/ZIP URL、Qwen TTS 的 audio URL会被直接请求;这些 URL 即使首个 API endpoint 是公网,也必须再次执行 public allowlist/DNS pin/redirect/大小限制。 - 自托管 model probe/PDF、企业 HTTP proxy 和 AI SDK 流式调用不能直接替换成 GET-only public helper;需要支持任意 method/body/stream 的 secure transport,或由可信 egress proxy 执行目的地址策略。 - `fetchPinnedPublicUrl` 已扩展为有界 GET/PUT:只接受静态可测量 body,禁止覆盖 Host/Content-Length/Transfer-Encoding,上传前先做 50 MiB 默认上限,仍以单次 DNS 解析固定 Host/SNI 和连接地址。 - MinerU 预签名 PUT 与结果 ZIP GET、Qwen TTS 返回音频 GET、AliDoc OSS 图片 GET 均已逐资源重新 pin;不把首个 API Bearer 转发给二次 URL,拒绝重定向并限制请求/响应体。 - dedicated server 现通过中央 provider access policy 禁止 unmanaged client key/baseUrl;所有统一 LLM/TTS/ASR/PDF/image/video/web-search resolver 只接受运营配置。桌面/all、ops、learner BYOK 和运营配置的 Ollama/private self-hosted 保持原行为;无网络 `unpdf` 是明确例外。 - 当前已有两阶段大课骨架:框架生成后停在 `framework_ready`,人工确认后按顺序创建并运行 classroom generation job。 - `CourseModuleSpec` 目前只有标题、说明、目标、前置条件和时长,没有主 Agent 输出的模块级 `generationPrompt`。 - runner 仅把说明、目标、前置条件和泛化连续性提示拼成 requirement;没有传递 `languageDirective`,也不读取前序模块实际产出。 - 大课模块复用了服务端单课件生成管线,发布后的学习路径最终仍进入原 ``。 - learner 模式在原 Stage 外额外挂载了自包含 `AssistantPanel`,形成与原 ChatArea 并行的第二套助教。 - 大课后台生成没有使用前台 Interactive Mode 的专用 outline prompt,因此可能生成很少或没有 HTML 互动场景。 - 首页在材料、互动模式或职教模式开启时只 toast 后 return,没有进入仍存在的 `/generation-preview`。 - `/courses` 页面和 `/api/courses/**` 缺少运营角色服务端鉴权;发布 Token 通过 `NEXT_PUBLIC_*` 暴露到浏览器。 - 大型课程 manifest 只保存 `coursewareId`,没有固定模块版本和 `contentHash`。 - 当前目录没有 Git 仓库;已有定向测试曾达到 14 个文件、83 个测试通过,i18n 检查仍有多语言缺失键。 - `middleware.ts` 只在配置 `ACCESS_CODE` 时校验 HMAC cookie;未配置时全部请求放行,且页面请求即使未认证也继续进入前端。 - 首页 `startBackgroundGeneration` 在存在课程材料、互动模式或职教模式时于 `app/page.tsx:169` 仅 toast 后返回;否则 POST `/api/generate-classroom`。 - `/generation-preview` 通过 `sessionStorage['generationSession']` 恢复输入,说明首页恢复前台流程必须先写入兼容的 generation session,再导航,不能只做裸 `router.push`。 - `app/classroom/[id]/page.tsx` 先渲染原 ``,随后仅在 learner query 下额外挂载 `AssistantPanel`;删除该 import/条件挂载即可恢复原课堂助教唯一性,不需触碰 Stage。 - 首页大型课程入口目前无条件渲染;`lib/config/feature-flags.ts` 还通过 `NEXT_PUBLIC_COURSEWARE_PUBLISH_TOKEN` 决定浏览器侧发布能力,课程构建页直接读取并发送该公开变量。 - 前台生成会话的最小必需结构由 `app/generation-preview/types.ts` 定义:`sessionId`、`requirements`、`pdfText`、`currentStep`,并可带 `documentSources`、图片、outline 与 preview phase。 - 无材料时可直接写入 `requirements.requirement/language/webSearch/interactiveMode/vocationalTestMode` 等状态;有材料时必须先用 `storeDocumentBlob` 持久化每个 Blob,再写入含 `storageKey` 的有序 `documentSources`。 - 现有 `home-to-generation` 与 `full-happy-path` E2E 仍断言普通首页提交进入 `/generation-preview`,但当前产品决定应调整为:普通无材料模式走后台任务,材料/互动/职教模式走前台预览。 - `isCoursewarePublishEnabled()` 和 `getCoursewarePublishToken()` 当前均依赖 `NEXT_PUBLIC_COURSEWARE_PUBLISH_TOKEN`;课程构建页把它作为 Bearer header 发回同源 API,不能作为运营安全边界。 - `UserRequirements` 已原生支持 `webSearch`、`interactiveMode` 与 `taskEngineMode`;职教开关应映射到 `taskEngineMode`,而不是另造 session 字段。 - generation-preview 的 active steps 会在 `documentSources` 存在且 `pdfText` 为空时自动执行材料解析;`storeDocumentBlob(File)` 返回 IndexedDB storage key,正好用于首页会话构造。 - 语言由 `useI18n()` 提供 `locale`;`UserRequirements` 类型本身没有 `language` 字段,当前生成语言主要由服务端 outline 结果的 `languageDirective` 决定,因此首页无需为了恢复链路新增未使用的 language 字段。 - 部署边界现采用双层校验:服务实例角色决定是否具备 `manage_courses`,ops 生产环境还必须由现有 HMAC `ACCESS_CODE` cookie 认证;生产未配置角色默认 learner。 - `COURSEWARE_PUBLISH_TOKEN` 已收回服务端:同源 ops 发布由角色+ACCESS_CODE 会话授权,独立 server 部署仍用 Bearer Token 接收远端 ops 发布。 - `readCourseRecord` 目前直接把 JSON 断言为 `CourseRecord`,没有 schema migration;新增框架字段必须在读取时规范化,或让所有消费者都安全 fallback,否则旧文件会在 runner 访问新字段时失效。 - `validateCourseFramework` 目前只校验标题、语言、摘要和 4–15 个模块;模块索引会重排,时长会夹在 5–60 分钟,但主 Agent 输出没有课程目标、连续性契约、模块生成提示词或知识边界。 - `runFrameworkPhase` 手工复制模块记录,只保存标题与说明;应复用 `emptyModuleRecord` 并保存生成提示词快照,确保之后框架再生成或模型输出变化时有可审计输入。 - `buildModuleRequirement` 当前硬编码 15–25 分钟,忽略 `estimatedMinutes`,也没有拼入 `languageDirective`;这是 Phase 2 可以在大课编排层修复的明确数据丢失点。 - Phase 2 已把新模型输出和旧存量分成两条边界:新输出严格要求课程目标、连续性契约、模块提示词和知识边界;旧 JSON 只在 `read/list` 时确定性规范化,读取不会主动改写磁盘。 - 运营确认页已改用共享 `CourseDetail` 类型,并在确认前展示目标学习者、语言、整课目标、四项连续性契约及每模块知识边界/生成提示词。 - course runner 原先只按 run key 去重,不同 key 的整课生成与单模块重试可以并发;现在 runner 以 courseId 做互斥,所有 start/resume/regenerate 入口也会在调度前检查并立即登记运行。 - 后台 `GenerateClassroomInput` 目前没有 `interactiveMode`,构造的 `UserRequirements` 也只有 requirement;因此大课 runner 即使复用单课件管线,也只能进入 package 的普通 `requirements-to-outlines` prompt。 - 原前台 Interactive Mode 是 app 层 `INTERACTIVE_OUTLINES` 模板(目标 70% widgets);package 的 `generateSceneOutlinesFromRequirements` 负责统一解析/补 id。最小复用方式是在服务端仅替换传给该函数的 outline `aiCall` prompt,继续复用其解析与后续 content/action/persist 管线。 - classroom job 成功记录只保存 classroomId 和 scenesCount;runner 可从服务端持久化 classroom JSON 读取四类 Scene 实际内容,生成有界、确定性的 output digest,再把此前成功模块的 digest 注入下一模块 requirement,无需改课堂运行时。 - Phase 3 已在模块成功后从持久化 classroom 提取四类 Scene 的实际语义证据,保存 `semanticHash`;后续模块记录 `continuityInputRefs` 并只在所有前序模块有成功实际产出时继续。 - 全课程流水线现在遇到模块失败即暂停;失败模块修复后,同一个课程 run 会按顺序继续剩余 pending 模块,避免在知识断层上生成后续课件。 - 后台 Interactive Mode 复用原 app prompt 模板,只替换 outline AI call,仍走 package 的解析、content、actions 和原子持久化;持久化前强制至少一个 `interactive` Scene 具有非空 HTML。 - output digest 会过滤 script/style、data URI 和 learner runtime,并保留所有前序模块的哈希索引;详细 evidence 受 12k 上限约束且优先近期模块。 - Phase 4 漂移根因是 course manifest 只保存 `coursewareId`,learner 又查询 latest;正确不可变身份必须是 `coursewareId + coursewareVersion + contentHash`,传输 URL 只能由该身份派生。 - 文件型 courseware 与 manifest 原先都允许同版本覆盖或覆盖 latest;现已改成历史记录/append-only,并在单进程发布锁中完成版本分配和保存。多实例仍需要数据库唯一键或跨进程事务作为后续部署约束。 - 服务端生成的课堂素材位于 `data/classrooms//{media,audio}`,Scene 中却保留 `/api/classroom-media/...` URL;冻结发布必须先把这些 URL 解析成字节和稳定 bundle ref,不能让 learner 依赖运营端源文件。 - 原序列化同时保存 `audioRef` 与 `audioUrl`,而播放器优先 URL;冻结包必须在已有 `audioRef` 时删除远端 URL,才能真正离线且不漂移。 - 大课发布要求每条 speech 有持久化音频,因此大型课程生成统一启用 TTS;缺少 provider/字节时允许运营预览,但发布门禁失败并指出缺失资源。 - Agent persona/voice 与 Stage 初始 whiteboard 原先未完整冻结;它们属于发布数据契约而非课堂运行时逻辑,已在外围序列化/导入和 server publisher 中补齐。 - “冻结学习包”只禁止 outline/scene/media 的课程生成调用;原 ChatArea/Roundtable、动态评分和 PBL 教学运行时仍可正常调用模型。 - 受保护核心广泛回归的 3 个失败属于既有 Chat director 契约内部漂移,隔离重跑仍稳定复现:当前 director prompt 明确写“课堂只有 teacher + user”,但旧测试仍期待 assistant/student fallback;web evidence 的源码具备一次性 take/attachment 路径,但这两条 route fixture 没有把 URL带入 child turn。Phase 4 未修改 `lib/chat/**` 或 `/api/chat/pi`,因此按用户边界记录而不在本改造中修写 Chat 内核。 - 现有 Playwright 已覆盖 learner 部署拒绝 ops、首页普通后台任务和互动/材料前台单课件路径,但没有大型课程的真实发布→manifest→精确模块 URL;Phase 5 应优先用无 LLM 的 Vitest/route 集成夹具补这条数据边界,再用少量浏览器 smoke 验证 UI 导航。 - Playwright 原先在 development 启动服务且未声明部署角色;development 按设计 fallback 为 `all`,导致 learner 边界用例与其环境互相矛盾。E2E 默认服务现显式固定为 learner;未来 ops 浏览器测试应使用独立 project/server 配置。 - learner 角色下 `/` 不保证提前打开 `MAIC-Database`;`classroom-interaction.spec.ts` 的旧 seed helper 直接 `indexedDB.open()` 后在缺表时同步抛错却没有 reject,Promise 因而悬挂到超时。该失败属于 E2E fixture 假设(注释仍写 DB v8,而当前已 v17),可在测试层显式初始化 Dexie/捕获事务创建错误,不需改课堂运行时。 - 课堂文档的当前权威存储是 `BrowserDocumentStore` 使用的 `maic-documents` v1(`stages/scenes/outlines`);Quiz、Playback 和 iframe E2E 已按该契约显式 seed。`classroom-interaction.spec.ts` 仍写入旧 `MAIC-Database` 的 `stageOutlines`,应仅在测试夹具中迁移到前者。 - Phase 5 已有无 LLM 的真实文件仓储集成证据:两模块冻结发布生成 schema v2 精确 pin manifest;中途冻结失败会回滚已暂存模块且 learner 端不可见;manifest v1 在模块独立发布 v2 后仍精确指向 v1。 - 三端最终边界审计表明:当前闭环仅在“同进程、单副本、共享本地文件库”条件下成立。Docker 未传构建期公开角色;server 尚非无头 allowlist;ops → server 没有跨进程 manifest 事务接口;文件库+内存锁不支持多实例/Vercel。 - learner 可见 manifest 目前暴露与源 classroom 相同的 `coursewareId`,而 raw `/api/classroom` 没有资源权限;多用户共享 server 下 job 列表/读取/取消/删除也没有 owner。这两项属于正式上线前必须建立的资源级边界。 - 已定位的可独立修复外围漂移还包括:learner `/tasks` 把 `/api/courses` 403 与正常 job 绑在同一 `Promise.all`;首页无 `/learn` 目录入口;`/publish` 未纳入 ops gating;上游成功模块重生成不会级联失效下游;浏览器重发的 ZIP 内部 version 可能仍为 1。 - 合并 Playwright 单 worker 回归已在干净 learner dev server 上 11/11 通过:先前的 iframe 场景标题和 generation redirect 失败不可隔离复现,与并行干扰/残留 server 一致,不应对原 Stage 代码做“修复”。 - 源 classroom 与公开 module id 可以在外围完整解耦:新发布使用 `course_{courseId}_module_{index}` 稳定公开 id,registry 私有保存 `sourceClassroomId`,公开 summary 不返回;learner/server 可用该私有关联拒绝 raw classroom 旁路,同时兼容拦截旧的 shared-id 记录。 - 源 classroom 反查是授权路径,不能复用“公开列表跳过损坏 JSON”的宽容语义;文件 repo 因此增加了严格 `hasPublishedSource` 反查,任一 registry 文件损坏时 raw source 请求 fail closed。 - 浏览器 packager 无法无竞态地预知 next version;可行契约是 server 在 per-courseware lock 内分配版本,仅改写被 content hash 明确排除的 `bundle.json.meta.version`,再保存 ZIP。learner 必须同时校验 registry、包内 version 和 hash。 - 上游模块重生成不能仅修目标模块;当前做法是一次原子清空 `N..end` 的所有尝试/输出字段并将课程置为 generating,随后顺序重建;失败会保留后续 pending,既有 resume 可续跑。 - `NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE` 必须在 `pnpm build` 之前注入;只在容器运行时设置会造成权限与 UI 不一致。Docker 现在对 learner/ops/server 分别构建,并在运行层固定相同角色。 - learner 冻结课堂的“不再生成”应是路由外层明确策略,而不只依赖 bundle 恰好没有 outlines;现在 learner mode 即使遇到异常缓存也不调 Scene/媒体生成,作者课堂恢复逻辑不变。 - 跨进程发布的可复用边界已定位:ops 可用 `packagePersistedClassroom(..., version: 1)` 生成完整 ZIP 模板;server 的 `publishCourseware` 会在每个 courseware 锁内分配真实版本、重写 bundle.json version 并重验 hash/完整性,因此运输层不应复制这些门禁。 - 当前大课发布 route 的事务边界全在 ops 进程:本地写 unpublished courseware、二次验证源快照、切 published 再写 manifest。独立部署必须把“metadata + 全部 ZIP”作为一次 server-only 批量请求,并在 server 一个 course 互斥边界内复用 `publishCourseware`、`publishCourse` 和 status rollback。 - 现有 `/api/coursewares` 只能单 ZIP multipart 上传,已有 `COURSEWARE_MAX_UPLOAD_BYTES` 单模块上限和 Bearer 校验;大课远程 transport 需在此之上再增加整批 Content-Length/实际 File size 上限,不能仅信任客户头。 - 文件 repo 可以把本批版本状态补偿为 `unpublished`,bundle byte store 也支持删除单个版本;当前 manifest repo 是全局实例,为做真实隔离仓库测试,`publishCourse` 需增加可选 `CourseManifestRepo` 注入,生产默认仍用原全局 repo。 - `runCoursePublishExclusive` 已提供每 course 单进程互斥;server 批量接口可直接复用。它不是跨副本锁,因此文档仍必须保留“多实例上线前迁到数据库事务”限制。 - 远程发布的安全重试可不新增独立 idempotency 表:server 在 staging 前解析/重算全部 incoming ZIP hash,再将 latest schema-v2 manifest 的课程 metadata 与 module index/id/title/description/hash 精确比较;完全一致时直接返回旧 manifest,超时重发不会产生空转版本。 - ops 详情当前只查本地 courseware repo 并比较私有 `sourceClassroomId`;独立 server 下必然误显示“未发布”。最小闭环是在 ops `CourseRecord` 保存一份精确 publication receipt(manifest version + 每模块 source classroom/digest + id/version/hash),详情仅在 receipt 与当前源身份一致时显示已发布;从任意模块开始重生时清除整课 receipt。 - ACCESS_CODE 原先的 HMAC 会话没有过期语义、登录限流或 Cookie 写请求的 Origin 检查;现在 Node/Edge 共用同一时效契约(默认 7 天),登录使用有界单进程限流,只在显式信任代理头时按客户 IP 分桶。 - `server` 角色不能只是“隐藏运营入口”;它必须是无头服务。当前 middleware 已将全部非 API 页面以直接 404 隐藏,并将 courses/ops/access-code 信任域从 server API 面移除;其余课堂/生成 API 仍等待 owner 契约,不应被误宣称为已完成多用户安全化。 - 项目已有 `capBodyStream`,可在 `formData()` 之前包裹请求 stream,因此整批上传能同时防 Content-Length 谎报和 chunked 无上限缓冲;解析后仍逐模块检查 File.size。 - internal server 接口不应用 request Host 生成 registry `bundleUrl`,否则会把 ops 访问的内网 hostname 固化给 learner;生产必须用严格 origin 形式的 `COURSEWARE_PUBLIC_BASE_URL`。 - publication receipt 存在时必须 fail closed:若当前 classroom/digest 与 receipt 不符,不能再 fallback 到本地 latest `sourceClassroomId`;只有完全没有 receipt 的 legacy/shared-FS 记录可用旧查询兼容。 - 除模块级 `invalidateCourseModulesFrom` 外,整个框架重生路径会直接将 `framework/modules` 置空;该原子更新也必须同时清除 publication receipt,避免新框架尚未产生时运营端仍持有旧发布身份。 - 新冻结包使用 `contentHashVersion: 2`:哈希规范化时忽略仅表示导出时刻的 `manifest.exportedAt`,因此相同课堂字节在网络超时后重新打包仍能命中 server 幂等判断;无版本字段的历史包继续按原始 v1 规则验证,避免破坏已发布内容。 - ops → server 已使用一次 multipart 传输 metadata+全部 ZIP;remote/local 共用同一 commit 内核,同内容重复发布均返回原 manifest,响应丢失不会制造空转版本。 - 大课模块的 registry 记录私有保留 `courseId/moduleIndex/sourceClassroomId`;公开 catalog/detail/download 只在 schema-v2 manifest 包含精确 id/version/hash pin 后可见。 - 发布回执新增全量 canonical classroom `sourceRevisionHash`,不再仅靠会丢弃 script/style/media/action 细节的语义摘要判断当前性;HTML 脚本单独变化也会使发布状态失效。 - classroom/courseware 文件库已在路由、facade、record store 和 byte store 多层校验 id/version 与 resolved path;同版本幂等命中前会读回实际 ZIP,核对完整性、内部身份、私有归属和精确字节,缺失/损坏不再返回假成功。 - `OPS_PUBLIC_ORIGIN` 是 cookie-authenticated 运营写请求的 canonical Origin;`X-Forwarded-Host` 即使用于可信 IP 限流,也不再能扩充 CSRF 允许域。 - server 端其余课堂/单课件 API 仍需要 learner identity/entitlement 契约;不能为了隐藏高成本 API 而直接禁用原 Chat、Quiz、PBL、TTS 或用户端单课件生成。 - `/api/proxy-media` 不是原课堂教学运行时必需合约,且存在 DNS-rebinding 窗口;server 角色现已对其直接 404,同时 SSRF IP 分类补齐 RFC6598 `100.64.0.0/10`(含阿里云 `100.100.100.200`)。其他用户控制 URL 的服务端 fetch 仍应在正式身份契约阶段统一切换到 pinned-resolution safe fetch。 - server 无头边界已额外隐藏 provider 探测/验证、MP4 export、开发态 persistence、usage 和无 owner 的全局 job 列表;单课件 POST 与原课堂运行时端点保留,等待 learner capability。 - generation job DELETE 原先可绕过 raw classroom 读取保护删掉已发布源;现在会先做 fail-closed published-source 反查,命中时 job 与 classroom 都保留。 - QA/TTS 进程内限流默认不信任 XFF/X-Real-IP,仅在 `LEARNER_RATE_LIMIT_TRUST_PROXY_HEADERS=true` 且入口覆写头时启用;活跃 key 采用硬上限+共享 overflow bucket,不再无界增长或每次全表 reap。 - manifest metadata/hash 相同不足以证明一次重试仍可用;幂等返回前必须读取 exact bundle bytes,并复用完整单课件 publish gate 验证内部 id/version/hash、重算 hash、complete 与 portable 资源。对象丢失或只篡改未参与 hash 的 `bundle.json` 都必须拒绝旧幂等命中并生成新版本。 - 文件补偿事务不能提供数据库隔离,但大课 record 的私有 courseId/moduleIndex 可以建立最小可见性门禁:catalog、detail、download 只有在任一已提交 schema-v2 manifest 精确 pin 该 id/version/hash 时才公开,因此逐模块 promotion 到 manifest commit 之间不会泄漏给 learner。 - 同源共享文件 fallback 已复用与远程 server 相同的批量 transaction helper,远近端现在对相同 metadata + 内容都幂等;显式重复点击也是 no-op,当前没有 forceNewVersion 契约。 - Phase 6 已完成可在单实例文件仓库边界内可靠验证的三端隔离、跨进程发布、冻结内容身份、源 revision 和高风险 server deny;数据库事务、对象存储、共享锁/限流及 learner owner 不是同一层面的“收尾”,已独立迁入生产化阶段。 - 专用 `role=server` 已通过 404 隔离 `/api/proxy-media` 并补齐 `100.64.0.0/10`,但 `learner/all` 公网部署仍需要连接级 DNS pin:仅在校验阶段解析域名不足以抵御 fetch 时的二次解析和重定向变址。 - 身份机制不能替代 SSRF 防护;safe-fetch 可在不决定账号模型的前提下先落地。job/classroom owner、课堂运行时调用额度与单课件生成权益则会改变公开/登录产品体验,必须在身份方案确定后实施。 - 项目已直接依赖 `undici`,可通过自定义 dispatcher/连接 lookup 把请求连接绑定到预先验证的 IP,同时保留 URL hostname 供 Host 与 TLS SNI 使用;现有 `/api/proxy-media` 的逐跳 URL 校验仍使用全局 fetch,尚未绑定到验证结果。 - 当前依赖中没有 NextAuth/Better Auth/Lucia/Clerk/JWT/Jose 等正式账号或 Session 库,只有 `pg` 和面向运行时/文档/资产的存储包;现有 `PERSISTENCE_DEV_TOKEN + x-learner-key` 已在源码中明确标注为开发态、客户端可伪造,不能升级命名后当作 learner 身份。 - 现成 Postgres document/runtime/asset stores 可以承载学习状态,但没有 users/devices/sessions/entitlements/resource ownership schema;正式身份必须新增独立 auth 数据模型,并由服务端 claim 派生 learner principal,不能继续信任浏览器提交的 learnerKey。 - learner 身份的推荐终态不是账号 Session 与设备 capability 二选一:账号内部 UUID 是权益、跨设备和找回主体;桌面端用系统浏览器 OAuth 2.1 Authorization Code + PKCE,并用设备密钥/DPoP 证明令牌持有;打开课堂时再签发 5–15 分钟、绑定 `coursewareId + version + contentHash` 的 runtime grant。 - `RequestPrincipal` 应统一承载 user/device/ops/service 四类主体,owner、learnerKey 和配额主体都只能从服务端 claim 派生。job/classroom/render/voice/draft 必须服务端写 owner;跨 owner 查询统一 404,随机 ID 不能代替授权。 - 课堂 runtime grant 只约束外围 API:Chat/QA/Quiz/PBL/TTS 先校验 exact classroom/courseware 资源、action scope、entitlement 与 quota,再进入现有 handler;不需要修改 Stage、ChatArea/Roundtable、Quiz/PBL 或播放内核。 - 当前 `/api/qa` 按 coursewareId 读取 latest,未来必须改为 grant 固定的 version/hash;现有大课 manifest exact pin 不能在动态答疑层被 latest 查询绕过。 - 身份实施前仍需产品确认:正式发布是否强制登录/保留邀请码游客;桌面技术形态与登录渠道;是否首版多租户;目录/manifest/bundle 是否公开或需 entitlement;离线下载不可撤回的接受度;BYOK 是否开放及是否经过平台;学习进度是否需要跨设备/教师/证书级可信。 - 无需等待产品选择的安全不变量是:内部 UUID 主体、服务端派生 owner、短期 exact-resource grant、动态高成本 API fail closed、事务配额预留、旧 `legacy_unowned` 数据不自动认领、原课堂内核保持不变。 - server 精确路径策略不能只看 GET:Next.js 可从 GET 派生 HEAD,而尾斜杠会绕过 未规范化的精确比较。当前全局 job 列表已同时隐藏 GET/HEAD,内部发布在路径 规范化后仍只允许 POST。Azure voices 列表属于 provider 探测,也不再暴露于专用 server。 - remote ops 不挂载 server courseware registry,因此已发布 source 保护必须同时查当前 `CourseRecord.publication` 回执。回执与 registry 反查都是授权路径,损坏时 fail closed。 - 发布最终复核与 receipt 落盘之间存在 source DELETE TOCTOU;当前用同进程多 source 原子 claim 与单 source delete claim 互斥,冲突返回 409。这与其他内存锁一样不跨副本。 - 当前 publication receipt 会在重生时清除;旧已发布 source 是否必须永久保留没有 产品契约,本轮不新增历史索引。跨进程提交后本地 receipt 写失败/响应丢失的窗口也只能 由持久 idempotency/outbox 与数据库事务最终消除。 - courseware record/bundle 之外,course manifest 文件仓库也必须在自身边界校验 id 和 safe positive version,并只解析配置根目录的直接子文件;不能只依赖当前 HTTP 路由的上游校验。 - 新的 public URL fetch 边界采用“每跳单次 `dns.lookup(all)` → 任一非 global-unicast 即整跳拒绝 → Undici Agent lookup 只返回已验证集合 → 手动重定向重新建 Agent”的连接级契约;URL hostname 保持不变,因此 Host/TLS SNI 不需要改写为 IP。 - public media policy 明确忽略全局 `ALLOW_LOCAL_NETWORKS`,拒绝凭据 URL、IPv4 mapped IPv6、CGNAT/metadata、文档/benchmark/multicast/reserved 与 IPv6 transition/documentation 范围;内部自托管 provider 的例外不能外溢成匿名公网代理。 - `/api/proxy-media` 已改为 8 KiB 声明+实际 JSON 流上限、最多 5 次逐跳 pinned redirect、25 MiB transport/读取上限和泛化错误。连接 dispatcher 只在响应体读取/取消后关闭,避免释放过早又不遗留 socket。 - 远端发布的 ops 进程没有 server 端 courseware registry,因此“是否为已发布 source”的删除保护不能只查本地 registry;现以 current `CourseRecord.publication.modules[].sourceClassroomId` 作为现有远端回执 fallback,并对损坏回执 fail closed。框架/模块重生成清掉 current receipt 后是否永久保留旧 source,仍是历史审计产品语义,不在本批擅自增加永久索引。 - 发布从课堂快照、冻结、远端提交到回执落盘期间,与 job DELETE 共享按 source classroomId 的同步 claim;这样删除无法在最后 revalidate 与 receipt 写入之间穿过。该 gate 仍是单 Node 进程语义,多实例要由数据库 CAS/锁取代。 - 专用 server 对 ownerless 全局 job list 的隐藏必须同时覆盖 GET 与 Next 自动派生的 HEAD;内部 publish 也必须在带尾斜杠时维持“仅 POST”。无客户端调用的 `/api/azure-voices` 属 provider 探测,已从专用 server 面隐藏。 - course manifest 文件仓储现在也在自身边界校验 courseId、safe-positive version 与 direct-child 路径,不再只依赖上游 route/transaction;这与 courseware record/ZIP 的多层路径防护一致。 - outbound 初盘显示剩余风险不是一个入口:provider probe、Azure voices、PDF/ASR/TTS、image/video adapter、文档解析、模型 SDK 和素材下载都有 URL/baseUrl fetch。必须区分客户端 URL 与运营配置的 managed URL;后者可能是私网自托管服务,不能被 public-only policy 静默破坏。 - Azure voices 与 MinerU Cloud 这类明确公网、单次 GET 且拒绝重定向的路径可直接复用 pinned helper;image/video/voice/AI SDK 涉及 POST、轮询、provider fallback 或 SDK 自己的 fetch 注入,应按 adapter 设计逐批迁移,不能用一次全局替换冒险。 - `/api/provider/probe-models` 原本也支持 `ALLOW_LOCAL_NETWORKS` 下的本地自托管模型。正式方案要么在身份保护后用 operator 精确 host allowlist + pinned connection,要么保持专用 server deny;不应为了 SSRF 修复把桌面自托管能力无提示删除,也不能继续用“允许任意私网”的全局开关。 - 身份供应商未定不妨碍先建立纯策略,但必须显式区分 `anonymous` 与 `resolver_unavailable`:当前没有 learner authenticator,后者不能伪装成游客。ownership 决策只接受服务端 owner/principal/capability,不从 IP、ACCESS_CODE、`anon:*`、`x-learner-key` 或 body 推导。 - observe-only authz seam 只有在接入至少一个真实资源时才有价值;首个选择 generation job,可让旧记录的 owner missing 与未来 owner match/mismatch 被结构化观察,同时保持所有现有 status/body 不变。observer 不能返回 allow,抛错也不得影响请求。 - 永久权益主体仍需用户确认。推荐以正式账号内部 UUID 为永久 owner,设备 capability 仅作为账号绑定持有证明或可迁移游客身份;具体手机号/邮箱/微信/SSO 供应商可以以后再选。 - public pinned helper 已最小支持调用方提供认证头,同时拒绝覆盖 Host,保持原 URL hostname 驱动 Host/SNI。Azure voices 与 MinerU Cloud probe 已迁移;它们拒绝重定向,因此订阅 key/Bearer 不会跨 host 转发。 - 为保留原自托管能力,本批没有把 model probe、PDF self-hosted、AliDocMind SDK 强制切成 public-only;AI SDK 与 image/video adapter 也未动。这些调用点仍需“public pinned / operator exact private allowlist / managed internal”三类明确策略后再迁移。 - 账号 owner 传递到课堂的最小边界已确认:`generateClassroom` 的服务端 options 可承载 owner,job runner 从已落盘 job 读取 owner 后传入;不应把 owner 放进客户端可控的 `GenerateClassroomInput`。 - 当前 `PersistedClassroomData` 没有归属字段,`/api/classroom` 的 POST/GET 也没有资源级授权;发布可见性门仅保护“已发布原始课堂”,不能代替 owner 校验。 - 课堂归属必须保存在 Stage/Scene 之外的持久化包装中,避免改动原互动课堂 DSL 与播放内核。 - 课堂重复 POST 是一个隐含的 owner 篡改面:对已存在课堂必须先按旧 owner 授权,并保留旧归属;只有首次持久化才从当前服务端 principal 创建 owner/guest 绑定。 - 生成课堂必须以已落盘 job 的 owner 为权威来源,不再从长任务结束时的请求上下文推导;当前 runner 已按此传递。 ## 2026-08-16 生成任务恢复故障发现 - Python 普通课件 job `Xjk5UcLtOx` 在本次检查时仍可推进到 `scene 5/10`、已完成 4 个场景;说明当前后端没有持续挂死,用户点击“继续”确实启动了新的生成尝试。 - 初一英语大型课程 `S9V3Mm0AWy` 的模块 1 job 在本地时间 12:09 左右开始,最后一次模型使用记录在 12:17:44;当前 Next 进程启动于 12:19:07,之后旧 runner 不再更新该 job,12:43 左右读时被判为 `Stale job: process may have restarted during generation`。这能证明“进程重启/runner 失联”是本次大课失败的直接机制,但不能单凭现有日志区分崩溃和人工重启。 - `readClassroomGenerationJob` 的 stale 判断只比较 `updatedAt`,并返回内存中的 failed 视图;它不知道当前进程是否仍持有活跃 runner,也没有 heartbeat/lease,因此长模型调用超过 30 分钟时可能误报,进程重启时又只能在下次读取才暴露。 - `runClassroomGenerationJob` 的 `runningJobs`、`runningControllers` 和大课 `runningRuns` 都是进程内 Map;磁盘只保存状态和原始输入,不保存 runner 接管 token 或可恢复执行上下文。 - `generateClassroom` 的 Stage、outlines 和 `generatedScenes` 都在内存中;正式 `persistClassroom` 只在所有场景、媒体/TTS 阶段结束后调用,取消或进程终止不会留下正式 classroom,也没有场景级 checkpoint。 - 普通 job 的 resume 路由与 runner 注释明确规定“从 persisted input 重新开始”;大型课程 resume 只跳过 `succeeded` 模块。故障发生在模块 1 时,后续模块尚未成功,用户看到的就是整门大课重跑,而不是框架被重新生成。 - 课程 reconciliation 把 stale/failed classroom job 变成 `module.status = failed` 并清空 `jobId`;随后普通 resume 创建新 job,旧 job 的任何中间产出没有引用入口。这是大课无法复用模块内 checkpoint 的第二个结构性缺口。 - 恢复协议必须把“逻辑 job、attempt/lease、checkpoint”和“正式课堂可见性”分开:checkpoint 用于恢复,正式 classroom 仍保持完成后原子发布;旧任务没有 checkpoint 时才退化为从头重跑。 ## Technical Decisions | Decision | Rationale | |---|---| | 第一批先做权限、入口和 learner 外围收敛 | 都是 P0 边界问题,可在不碰课堂内核的情况下独立验证 | | 权限同时区分部署能力与请求用户角色 | 仅隐藏 UI 或仅靠环境变量不足以保护运营 API | | 平行 AssistantPanel 从课堂外层取消挂载 | 恢复原 ChatArea/Roundtable 的唯一性,无需修改课堂内核 | | 大课连续性字段采用兼容性 fallback | 已有文件型课程记录不能因新增字段失效 | | 新框架字段在边界处规范化,runner 只消费规范化结果 | 避免把旧数据兼容判断散落到生成管线和 UI | | `generationPrompt` 由主 Agent 生成并在模块记录中保留快照 | 满足主 Agent 拆解职责,并让每次子课件生成输入可追溯 | | 同一课程同一时间只允许一个生成 run | 保证模块顺序和快照语义,避免恢复、整课流水线与单模块重试互相覆盖 | | 新大课 manifest 只接受精确 version/hash pins | 防止模块单独重发后旧大课静默切到 latest | | legacy 无 pin manifest 要求运营重新发布 | 历史记录没有保存当时版本,不能把当前 latest 伪装为原始事实 | | 服务端课堂先冻结成自包含 bundle,再提交课程 manifest | learner 不依赖运营端媒体 URL,同时仍通过原 import → Stage 链路上课 | | 大课冻结发布强制 TTS、Agent roster 与非空互动 HTML | 发布后保证原有语音、教师人格和 HTML 互动能力可重放 | ## Protected Runtime Evidence - `components/stage.tsx` 装配 `PlaybackChromeRoot` 与 `InteractiveIframeHost`。 - `components/stage/scene-renderer.tsx` 是 slide/quiz/interactive/pbl 的唯一分派器。 - `components/edit/PlaybackChromeRoot.tsx` 装配 ActionEngine、PlaybackEngine、Canvas、Roundtable 和 ChatArea。 - `InteractiveIframeHost.tsx`、iframe pool 和 widget messaging 维持 HTML iframe 保活及安全契约。 - `lib/bundle/serialize.ts` 与 import builder 已保留 scene content、actions、scene whiteboards 和 multiAgent 字段。 ## Issues Encountered | Issue | Resolution | |---|---| | DeepSeek Harness 首次任务未进入工具清单 | 重启桌面应用后已成功加载并完成只读自检 | | 无 Git 历史,现有定制改动均为未版本化状态 | 使用计划文件记录范围,限制并行任务触及不同文件,并逐批验证 | | 用户要求停止使用 DeepSeek 算力 | 后续不再调用 Harness;主线程完成实现与本地验证 | ## Resources - 产品与部署文档:`../docs/` - 大课编排:`lib/course-framework/` - 服务端单课件生成:`lib/server/classroom-generation.ts` - 课程运行时入口:`app/classroom/[id]/page.tsx` - 原课堂:`components/stage.tsx`、`components/edit/PlaybackChromeRoot.tsx` - 发布与学习包:`lib/bundle/`、`lib/course-manifest-repo/`