Files
openmaic/OpenMAIC/findings.md
2026-08-16 14:58:47 +08:00

219 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`,也不读取前序模块实际产出。
- 大课模块复用了服务端单课件生成管线,发布后的学习路径最终仍进入原 `<Stage>`。
- 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` 先渲染原 `<Stage>`,随后仅在 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/<id>/{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/`