openlearn-next 0.7.8 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +4 -2
  2. package/cli-data.mjs +1 -1
  3. package/dist/assets/{ActivityWorkspaceWidget-BXS1IamH.js → ActivityWorkspaceWidget-BU88GGUG.js} +1 -1
  4. package/dist/assets/{AdminDirectoryView-479SfYyD.js → AdminDirectoryView-Cdsk9Mdr.js} +1 -1
  5. package/dist/assets/{AssessmentCenter-CJ-pchOQ.js → AssessmentCenter-BoSWbJMt.js} +1 -1
  6. package/dist/assets/{AssignmentFilePreview-wjnrKjsp.js → AssignmentFilePreview-D_POvLmv.js} +1 -1
  7. package/dist/assets/{BatchPickerModal-JvoD-1pF.js → BatchPickerModal-DiHd_6Tg.js} +1 -1
  8. package/dist/assets/{ClassesView-CB8q2kW0.js → ClassesView-CWKqzWH0.js} +1 -1
  9. package/dist/assets/{ClassroomCountdownWidget-BqzBpAvi.js → ClassroomCountdownWidget-B5-KUVU8.js} +1 -1
  10. package/dist/assets/{CloudDriveModal-DHxOnL4m.js → CloudDriveModal-CjcvdDiw.js} +1 -1
  11. package/dist/assets/{ComputerLabView-BEDe7WiU.js → ComputerLabView-CNTcmWAh.js} +1 -1
  12. package/dist/assets/CourseManagement-ha_iSnLU.js +1 -0
  13. package/dist/assets/{CourseWizardModal-D3UPFtT_.js → CourseWizardModal-oVT9mYSG.js} +1 -1
  14. package/dist/assets/{CoursewareHubPanel-CqJqcZGs.js → CoursewareHubPanel-BuKp0mmW.js} +1 -1
  15. package/dist/assets/{Dashboard-C3BaEHIG.js → Dashboard-Vb9coh9_.js} +1 -1
  16. package/dist/assets/{ExportWeightModal-iF0pVevG.js → ExportWeightModal-BEesz6rd.js} +1 -1
  17. package/dist/assets/{HelpTour-6ChRqWnB.js → HelpTour-D3QcGDUz.js} +1 -1
  18. package/dist/assets/{HelpView-D3iYhzBz.js → HelpView--Um9DFYx.js} +1 -1
  19. package/dist/assets/ImportLessonsModal-DXGopj4j.js +1 -0
  20. package/dist/assets/{ImportModal-DAHGdI9h.js → ImportModal-h5ShIMzO.js} +1 -1
  21. package/dist/assets/{InteractiveCoursewareViewer-DX9xnk6j.js → InteractiveCoursewareViewer-DzgF9_LU.js} +1 -1
  22. package/dist/assets/{InteractiveWhiteboard-41mi9keH.js → InteractiveWhiteboard-DJqgP_tU.js} +7 -7
  23. package/dist/assets/{LazyCourseware-DKyUHXaE.js → LazyCourseware-6yb_QiYS.js} +2 -2
  24. package/dist/assets/{LazyWhiteboard-fXS2P44c.js → LazyWhiteboard-CsF9GB6J.js} +2 -2
  25. package/dist/assets/LessonEditorView-CZTZ7Q0B.js +1 -0
  26. package/dist/assets/{LiveClassroomView-m0hKdcVK.js → LiveClassroomView-D2ugupT9.js} +1 -1
  27. package/dist/assets/{NotificationDetailModal-C2tg_IoJ.js → NotificationDetailModal-mUeIQP4R.js} +1 -1
  28. package/dist/assets/{PluginView-BPRUpCjV.js → PluginView-CT3-zS2n.js} +1 -1
  29. package/dist/assets/{ProcessLogsModal-CULHzXp7.js → ProcessLogsModal-DmsOrZnV.js} +1 -1
  30. package/dist/assets/{QuizGeneratorModal-CYxngpW9.js → QuizGeneratorModal-ViKxNX5Q.js} +1 -1
  31. package/dist/assets/{StudentAssignmentEvalPanel-Dfe4IOs5.js → StudentAssignmentEvalPanel-BRxHMABY.js} +1 -1
  32. package/dist/assets/{StudentAssignmentView-IfRo3yJk.js → StudentAssignmentView-g-ewJIS3.js} +1 -1
  33. package/dist/assets/{StudentLessonView-Dw2uxBhq.js → StudentLessonView-Dbvr3Xlo.js} +1 -1
  34. package/dist/assets/{StudentView-DVYUSrVU.js → StudentView-20vfEk4v.js} +2 -2
  35. package/dist/assets/{SystemResourceLibraryModal-BhbpfIBt.js → SystemResourceLibraryModal-CDKeaKf8.js} +1 -1
  36. package/dist/assets/{TeacherView-CZjIejet.js → TeacherView-KIKuhXGF.js} +2 -2
  37. package/dist/assets/{TimetableView-CupOIfEy.js → TimetableView-7V2AwWml.js} +1 -1
  38. package/dist/assets/{index-BDFyco9S.css → index-C4cHQZyx.css} +1 -1
  39. package/dist/assets/index-XduPa2-x.js +131 -0
  40. package/dist/assets/{pluginParsers-C9W6GBVx.js → pluginParsers-BO6U1E-h.js} +1 -1
  41. package/dist/assets/{student-workspace-CsbA8Cs4.js → student-workspace-DstiW0pM.js} +1 -1
  42. package/dist/assets/{vendor-icons-DaYNfRAc.js → vendor-icons-DLOPlM4i.js} +1 -1
  43. package/dist/env-validation.cjs +14 -6
  44. package/dist/index.html +3 -3
  45. package/dist/server.cjs +1489 -867
  46. package/package.json +2 -2
  47. package/dist/assets/CourseManagement-BjxQC6sq.js +0 -1
  48. package/dist/assets/ImportLessonsModal-BOwj7ezj.js +0 -1
  49. package/dist/assets/LessonEditorView-Dx-hOKtt.js +0 -1
  50. package/dist/assets/index-BiHQqzdm.js +0 -131
package/dist/server.cjs CHANGED
@@ -507,6 +507,74 @@ var init_plugin_metrics = __esm({
507
507
  }
508
508
  });
509
509
 
510
+ // packages/core/capability/catalog.ts
511
+ function validateManifestCapabilities(capabilities) {
512
+ const unknown = [];
513
+ for (const cap of capabilities) {
514
+ if (typeof cap !== "string" || !KNOWN_CAPABILITIES.has(cap)) {
515
+ unknown.push(typeof cap === "string" ? cap : String(cap));
516
+ }
517
+ }
518
+ return { valid: unknown.length === 0, unknown };
519
+ }
520
+ var CAP_API_WRITE, CAP_API_ADMIN, CAP_GRADES_WRITE, CAP_AGENT_TOOL, CAP_AI_INVOKE, CAP_TASK_REGISTER, CAP_STUDENT_DATA_READ, CAP_STUDENT_DATA_WRITE, CAP, CAPABILITIES, KNOWN_CAPABILITIES;
521
+ var init_catalog = __esm({
522
+ "packages/core/capability/catalog.ts"() {
523
+ "use strict";
524
+ CAP_API_WRITE = "api:write";
525
+ CAP_API_ADMIN = "api:admin";
526
+ CAP_GRADES_WRITE = "grades:write";
527
+ CAP_AGENT_TOOL = "agent:tool";
528
+ CAP_AI_INVOKE = "ai:invoke";
529
+ CAP_TASK_REGISTER = "task:register";
530
+ CAP_STUDENT_DATA_READ = "student-data:read";
531
+ CAP_STUDENT_DATA_WRITE = "student-data:write";
532
+ CAP = {
533
+ // ── 资源型(ActionDescriptor.capabilityRequired)──────────────────────
534
+ ASSIGNMENT_MANAGE: "assignment:manage",
535
+ ASSIGNMENT_READ: "assignment:read",
536
+ ASSIGNMENT_REVIEW: "assignment:review",
537
+ ASSIGNMENT_SUBMIT: "assignment:submit",
538
+ ASSIGNMENT_WRITE: "assignment:write",
539
+ LESSON_DELETE: "lesson:delete",
540
+ LESSON_READ: "lesson:read",
541
+ LESSON_WRITE: "lesson:write",
542
+ MANAGEMENT_READ: "management:read",
543
+ MANAGEMENT_WRITE: "management:write",
544
+ PLUGIN_READ: "plugin:read",
545
+ PLUGIN_WRITE: "plugin:write",
546
+ PROCESS_READ: "process:read",
547
+ PROCESS_WRITE: "process:write",
548
+ STUDENT_WRITE: "student:write",
549
+ VFS_READ: "vfs:read",
550
+ VFS_WRITE: "vfs:write",
551
+ WHITEBOARD_READ: "whiteboard:read",
552
+ WHITEBOARD_WRITE: "whiteboard:write",
553
+ // ── 历史上只出现在 capabilitiesProposed、从未作为 capabilityRequired ──
554
+ // 保留是为了不让内置 management 插件在升级后突然被标成「未知串」。
555
+ CLASS_READ: "class:read",
556
+ CLASS_WRITE: "class:write",
557
+ STUDENT_READ: "student:read",
558
+ // ── 方法级(method-policy / 服务代理的 Security Barrier 3)───────────
559
+ API_WRITE: CAP_API_WRITE,
560
+ API_ADMIN: CAP_API_ADMIN,
561
+ GRADES_WRITE: CAP_GRADES_WRITE,
562
+ AGENT_TOOL: CAP_AGENT_TOOL,
563
+ AI_INVOKE: CAP_AI_INVOKE,
564
+ TASK_REGISTER: CAP_TASK_REGISTER,
565
+ STUDENT_DATA_READ: CAP_STUDENT_DATA_READ,
566
+ STUDENT_DATA_WRITE: CAP_STUDENT_DATA_WRITE,
567
+ // ── 平台内部 ─────────────────────────────────────────────────────────
568
+ // 片段命令(segment gate)用,插件片段可声明;不在 AI 工具箱里。
569
+ SEGMENT_GATE_EVAL: "segment:gate_eval"
570
+ };
571
+ CAPABILITIES = Object.freeze(
572
+ Array.from(new Set(Object.values(CAP)))
573
+ );
574
+ KNOWN_CAPABILITIES = new Set(CAPABILITIES);
575
+ }
576
+ });
577
+
510
578
  // packages/core/esm-loader/manifest-schema.ts
511
579
  var import_zod, classroomToolSchema, helpDocSchema, quickActivitySchema, timelineSegmentSchema, paletteItemSchema, cockpitWidgetSchema, stageCardSchema, classroomTopbarActionSchema, classroomAttributionAwardSchema, studentCompetencyDimensionSchema, studentProfileWidgetSchema, canvasWidgetSchema, barometerMetricSchema, peerReviewRubricSchema, peerReviewBadgeSchema, preclassPasscodeActionSchema, preclassPasscodeAddonSchema, commandPaletteItemSchema, quickInsertItemSchema, aiPromptTemplateSchema, aiSkillProviderSchema, classTabSchema, classBatchActionSchema, studentRowPanelSchema, editorHeaderActionSchema, postClassWidgetSchema, coursewareViewerToolbarSchema, coursewareViewerOverlaySchema, portalTelemetrySchema, portalCourseBadgeSchema, portalTeachingModeSchema, portalInsightSchema, portalPreflightSchema, portalLaunchActionSchema, countdownWidgetSchema, countdownActionSchema, studentCountdownSchema, studentQuickActionsItemSchema, studentQuickActionsActionSchema, studentQuickActionsFabSchema, notificationTabsSchema, pacingDashboardSchema, diagnosticFeedSchema, collabCanvasSchema, topbarPillSchema, attributionActionSchema, leaderboardActionSchema, studentProfileActionSchema, studentProfileTimelineItemSchema, classroomHeaderActionSchema, classroomAgendaActionSchema, whiteboardDockPluginSchema, classroomAuditEventSchema, peerReviewActionSchema, peerReviewShowcaseWidgetSchema, seatingToolbarSchema, seatingLegendSchema, seatingSeatBadgeSchema, seatingSeatActionsSchema, seatingSummarySchema, whiteboardAutosaveStatusSchema, whiteboardAutosaveActionSchema, teacherPanelSchema, studentFullscreenSchema, globalSettingSchema, navUserMenuSchema, frontendSlotEntrySchema, frontendSlotsShape, pluginLocalesSchema, contributesSchema, deploySchema, apiRouteSchema, apiSchema, requiresItemSchema, manifestSchema, requiresItemV3Schema, manifestSchemaV3;
512
580
  var init_manifest_schema = __esm({
@@ -1800,7 +1868,7 @@ var init_token = __esm({
1800
1868
  });
1801
1869
 
1802
1870
  // packages/core/di/interfaces.ts
1803
- var ICommandBusServiceToken4, IEventBusServiceToken3, IActionRegistryServiceToken4, ICapabilityServiceToken, IProcessServiceToken2, IStorageServiceToken, IAIServiceToken, IDatabaseToken4, IPluginHostToken, IPluginLifecycleManagerToken, IPluginDistributionManagerToken, IPluginRuntimeCompositionToken, IUnifiedExtensionRegistryToken, IPluginCapabilityGatewayToken, ICapabilityRegistryToken, IAuthSessionBridgeToken, ISemesterGradeServiceToken, IPointsDimensionRegistryToken, IPointsLedgerServiceToken, ILessonEngineServiceToken, IClassroomRuntimeServiceToken, IPresenceEngineServiceToken, ITeachingCollaborationServiceToken, ILearningAnalyticsServiceToken, IAICapabilityServiceToken, ICapabilityRuntimeServiceToken, ICapabilityGovernanceServiceToken, IPlatformServiceRegistryToken, ICoursewareRuntimeScriptRegistryToken, IClassroomLifecycleServiceToken, IInteractionRuntimeServiceToken, IClassroomCountdownServiceToken, IAssessmentGraderRegistryToken, IStageGuardServiceToken, PLUGIN_RESOLVABLE_TOKENS, PLUGIN_RESOLVABLE_TOKEN_NAMES, INetworkProxyServiceToken;
1871
+ var ICommandBusServiceToken4, IEventBusServiceToken3, IActionRegistryServiceToken4, ICapabilityServiceToken, IProcessServiceToken2, IStorageServiceToken, IAIServiceToken, IDatabaseToken4, IPluginHostToken, IPluginLifecycleManagerToken, IPluginDistributionManagerToken, IPluginRuntimeCompositionToken, IUnifiedExtensionRegistryToken, IPluginCapabilityGatewayToken, ICapabilityRegistryToken, IAuthSessionBridgeToken, ISemesterGradeServiceToken, IPointsDimensionRegistryToken, IPointsLedgerServiceToken, IClassRosterServiceToken, ILessonEngineServiceToken, IClassroomRuntimeServiceToken, IPresenceEngineServiceToken, ITeachingCollaborationServiceToken, ILearningAnalyticsServiceToken, IAICapabilityServiceToken, ICapabilityRuntimeServiceToken, ICapabilityGovernanceServiceToken, IPlatformServiceRegistryToken, ICoursewareRuntimeScriptRegistryToken, IClassroomLifecycleServiceToken, IInteractionRuntimeServiceToken, IClassroomCountdownServiceToken, IAssessmentGraderRegistryToken, IStageGuardServiceToken, PLUGIN_RESOLVABLE_TOKENS, PLUGIN_RESOLVABLE_TOKEN_NAMES, INetworkProxyServiceToken;
1804
1872
  var init_interfaces = __esm({
1805
1873
  "packages/core/di/interfaces.ts"() {
1806
1874
  "use strict";
@@ -1839,6 +1907,7 @@ var init_interfaces = __esm({
1839
1907
  "@openlearn/core:IPointsDimensionRegistry"
1840
1908
  );
1841
1909
  IPointsLedgerServiceToken = new Token("@openlearn/core:IPointsLedgerService");
1910
+ IClassRosterServiceToken = new Token("@openlearn/core:IClassRosterService");
1842
1911
  ILessonEngineServiceToken = new Token("@openlearn/core:ILessonEngineService");
1843
1912
  IClassroomRuntimeServiceToken = new Token(
1844
1913
  "@openlearn/core:IClassroomRuntimeService"
@@ -1889,6 +1958,7 @@ var init_interfaces = __esm({
1889
1958
  ISemesterGradeServiceToken,
1890
1959
  IPointsDimensionRegistryToken,
1891
1960
  IPointsLedgerServiceToken,
1961
+ IClassRosterServiceToken,
1892
1962
  ILessonEngineServiceToken,
1893
1963
  IClassroomRuntimeServiceToken,
1894
1964
  IPresenceEngineServiceToken,
@@ -3929,9 +3999,9 @@ var init_http_router = __esm({
3929
3999
  /**
3930
4000
  * 注册指定动词与路径的 Handler
3931
4001
  */
3932
- route(method, path47, handler, options = {}) {
4002
+ route(method, path48, handler, options = {}) {
3933
4003
  const upperMethod = method.toUpperCase();
3934
- const normalized = path47.startsWith("/") ? path47 : "/" + path47;
4004
+ const normalized = path48.startsWith("/") ? path48 : "/" + path48;
3935
4005
  const { regex, paramNames } = compileRoutePattern(normalized);
3936
4006
  this.warnIfUnscoped(upperMethod, normalized, options);
3937
4007
  this.routes.push({
@@ -3944,20 +4014,20 @@ var init_http_router = __esm({
3944
4014
  scope: options.scope
3945
4015
  });
3946
4016
  }
3947
- get(path47, handler, options) {
3948
- this.route("GET", path47, handler, options);
4017
+ get(path48, handler, options) {
4018
+ this.route("GET", path48, handler, options);
3949
4019
  }
3950
- post(path47, handler, options) {
3951
- this.route("POST", path47, handler, options);
4020
+ post(path48, handler, options) {
4021
+ this.route("POST", path48, handler, options);
3952
4022
  }
3953
- put(path47, handler, options) {
3954
- this.route("PUT", path47, handler, options);
4023
+ put(path48, handler, options) {
4024
+ this.route("PUT", path48, handler, options);
3955
4025
  }
3956
- patch(path47, handler, options) {
3957
- this.route("PATCH", path47, handler, options);
4026
+ patch(path48, handler, options) {
4027
+ this.route("PATCH", path48, handler, options);
3958
4028
  }
3959
- delete(path47, handler, options) {
3960
- this.route("DELETE", path47, handler, options);
4029
+ delete(path48, handler, options) {
4030
+ this.route("DELETE", path48, handler, options);
3961
4031
  }
3962
4032
  warnIfUnscoped(method, pattern, options) {
3963
4033
  if (options.scope) return;
@@ -3972,19 +4042,19 @@ var init_http_router = __esm({
3972
4042
  }
3973
4043
  stream(methodOrPath, pathOrHandler, maybeHandler) {
3974
4044
  if (typeof pathOrHandler === "function") {
3975
- const path47 = methodOrPath;
4045
+ const path48 = methodOrPath;
3976
4046
  const handler = pathOrHandler;
3977
- this.registerStream("GET", path47, handler);
3978
- this.registerStream("POST", path47, handler);
4047
+ this.registerStream("GET", path48, handler);
4048
+ this.registerStream("POST", path48, handler);
3979
4049
  } else {
3980
4050
  const method = methodOrPath.toUpperCase();
3981
- const path47 = pathOrHandler;
4051
+ const path48 = pathOrHandler;
3982
4052
  const handler = maybeHandler;
3983
- this.registerStream(method, path47, handler);
4053
+ this.registerStream(method, path48, handler);
3984
4054
  }
3985
4055
  }
3986
- registerStream(method, path47, handler) {
3987
- const normalized = path47.startsWith("/") ? path47 : "/" + path47;
4056
+ registerStream(method, path48, handler) {
4057
+ const normalized = path48.startsWith("/") ? path48 : "/" + path48;
3988
4058
  const { regex, paramNames } = compileRoutePattern(normalized);
3989
4059
  this.routes.push({
3990
4060
  method: method.toUpperCase(),
@@ -3998,9 +4068,9 @@ var init_http_router = __esm({
3998
4068
  /**
3999
4069
  * 检查指定动词与路径是否为流式路由
4000
4070
  */
4001
- isStream(method, path47) {
4071
+ isStream(method, path48) {
4002
4072
  const upperMethod = method.toUpperCase();
4003
- const normalized = path47.startsWith("/") ? path47 : "/" + path47;
4073
+ const normalized = path48.startsWith("/") ? path48 : "/" + path48;
4004
4074
  for (const entry of this.routes) {
4005
4075
  if (entry.method !== upperMethod) continue;
4006
4076
  if (entry.regex.test(normalized)) {
@@ -4012,9 +4082,9 @@ var init_http_router = __esm({
4012
4082
  /**
4013
4083
  * 匹配路由并提取路由参数
4014
4084
  */
4015
- match(method, path47) {
4085
+ match(method, path48) {
4016
4086
  const upperMethod = method.toUpperCase();
4017
- const normalized = path47.startsWith("/") ? path47 : "/" + path47;
4087
+ const normalized = path48.startsWith("/") ? path48 : "/" + path48;
4018
4088
  for (const entry of this.routes) {
4019
4089
  if (entry.method !== upperMethod) continue;
4020
4090
  const match = normalized.match(entry.regex);
@@ -4157,13 +4227,13 @@ function checkMethodPolicy(input) {
4157
4227
  if (tokens.frontendApi && token === tokens.frontendApi) {
4158
4228
  if (isReadOnlyMethod(method)) return null;
4159
4229
  if (method === "request" && !Array.isArray(args)) return null;
4160
- const path47 = extractPathArg(args);
4161
- const isHighRisk = HIGH_RISK_PATH_PREFIXES.some((p) => path47 === p || path47.startsWith(`${p}/`));
4162
- const isIrreversible = IRREVERSIBLE_PATH_SUFFIXES.some((s) => path47.endsWith(s));
4163
- const isEntityDelete = method === "del" && ENTITY_DELETE_RE.test(path47);
4230
+ const path48 = extractPathArg(args);
4231
+ const isHighRisk = HIGH_RISK_PATH_PREFIXES.some((p) => path48 === p || path48.startsWith(`${p}/`));
4232
+ const isIrreversible = IRREVERSIBLE_PATH_SUFFIXES.some((s) => path48.endsWith(s));
4233
+ const isEntityDelete = method === "del" && ENTITY_DELETE_RE.test(path48);
4164
4234
  if (isHighRisk || isIrreversible || isEntityDelete) {
4165
4235
  if (hasCapability(caps, CAP_API_ADMIN)) return null;
4166
- const why = isIrreversible ? `\u4E0D\u53EF\u9006\u64CD\u4F5C '${path47}'` : isEntityDelete ? `\u5B9E\u4F53\u7EA7\u5220\u9664 '${path47}'` : `\u5199\u5165\u9AD8\u5371\u8DEF\u5F84 '${path47 || "<\u672A\u63D0\u4F9B\u8DEF\u5F84>"}'`;
4236
+ const why = isIrreversible ? `\u4E0D\u53EF\u9006\u64CD\u4F5C '${path48}'` : isEntityDelete ? `\u5B9E\u4F53\u7EA7\u5220\u9664 '${path48}'` : `\u5199\u5165\u9AD8\u5371\u8DEF\u5F84 '${path48 || "<\u672A\u63D0\u4F9B\u8DEF\u5F84>"}'`;
4167
4237
  return `${why} \u9700\u8981 manifest \u58F0\u660E '${CAP_API_ADMIN}' \u80FD\u529B`;
4168
4238
  }
4169
4239
  return hasCapability(caps, CAP_API_WRITE) ? null : `\u5199\u64CD\u4F5C\uFF08${method}\uFF09\u9700\u8981 manifest \u58F0\u660E '${CAP_API_WRITE}' \u80FD\u529B\uFF08\u666E\u901A\u53EA\u8BFB\u8C03\u7528\u8BF7\u7528 get\uFF09`;
@@ -4209,18 +4279,11 @@ function checkMethodPolicy(input) {
4209
4279
  if (tokens.uiService && token === tokens.uiService) return null;
4210
4280
  return null;
4211
4281
  }
4212
- var CAP_API_WRITE, CAP_API_ADMIN, CAP_GRADES_WRITE, CAP_AGENT_TOOL, CAP_AI_INVOKE, CAP_TASK_REGISTER, CAP_STUDENT_DATA_READ, CAP_STUDENT_DATA_WRITE, HIGH_RISK_PATH_PREFIXES, IRREVERSIBLE_PATH_SUFFIXES, ENTITY_DELETE_RE;
4282
+ var HIGH_RISK_PATH_PREFIXES, IRREVERSIBLE_PATH_SUFFIXES, ENTITY_DELETE_RE;
4213
4283
  var init_method_policy = __esm({
4214
4284
  "packages/core/plugin-host/method-policy.ts"() {
4215
4285
  "use strict";
4216
- CAP_API_WRITE = "api:write";
4217
- CAP_API_ADMIN = "api:admin";
4218
- CAP_GRADES_WRITE = "grades:write";
4219
- CAP_AGENT_TOOL = "agent:tool";
4220
- CAP_AI_INVOKE = "ai:invoke";
4221
- CAP_TASK_REGISTER = "task:register";
4222
- CAP_STUDENT_DATA_READ = "student-data:read";
4223
- CAP_STUDENT_DATA_WRITE = "student-data:write";
4286
+ init_catalog();
4224
4287
  HIGH_RISK_PATH_PREFIXES = Object.freeze([
4225
4288
  "/api/plugins",
4226
4289
  // 插件增删改、一键更新(可执行任意第三方代码)
@@ -4244,51 +4307,238 @@ var init_method_policy = __esm({
4244
4307
  }
4245
4308
  });
4246
4309
 
4247
- // packages/core/command-bus/plugin-command.ts
4248
- function bindPluginCommand(command, pluginActorId) {
4249
- const actor = typeof pluginActorId === "string" ? pluginActorId.trim() : "";
4250
- if (!actor) {
4251
- throw new Error("[Security] Refusing to dispatch a plugin command without a host-assigned actor");
4310
+ // packages/core/plugin-host/sql-guard.ts
4311
+ function sanitizeSql(sql) {
4312
+ let inSingleQuote = false;
4313
+ let inDoubleQuote = false;
4314
+ let inBacktick = false;
4315
+ let inBracket = false;
4316
+ let result = "";
4317
+ const len = sql.length;
4318
+ for (let i = 0; i < len; i++) {
4319
+ const ch = sql[i];
4320
+ const next = i + 1 < len ? sql[i + 1] : "";
4321
+ if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "-" && next === "-") {
4322
+ while (i < len && sql[i] !== "\n") i++;
4323
+ result += " ";
4324
+ continue;
4325
+ }
4326
+ if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "/" && next === "*") {
4327
+ i += 2;
4328
+ while (i < len && !(sql[i] === "*" && i + 1 < len && sql[i + 1] === "/")) {
4329
+ i++;
4330
+ }
4331
+ i++;
4332
+ result += " ";
4333
+ continue;
4334
+ }
4335
+ if (ch === "'" && !inDoubleQuote && !inBacktick && !inBracket) {
4336
+ if (inSingleQuote) {
4337
+ if (next === "'") {
4338
+ i++;
4339
+ } else {
4340
+ inSingleQuote = false;
4341
+ }
4342
+ } else {
4343
+ inSingleQuote = true;
4344
+ }
4345
+ result += " ";
4346
+ continue;
4347
+ }
4348
+ if (inSingleQuote) {
4349
+ result += " ";
4350
+ continue;
4351
+ }
4352
+ if (ch === "[" && !inDoubleQuote && !inBacktick) {
4353
+ inBracket = true;
4354
+ result += " ";
4355
+ continue;
4356
+ }
4357
+ if (ch === "]" && inBracket) {
4358
+ inBracket = false;
4359
+ result += " ";
4360
+ continue;
4361
+ }
4362
+ if (ch === "`" && !inDoubleQuote) {
4363
+ inBacktick = !inBacktick;
4364
+ result += " ";
4365
+ continue;
4366
+ }
4367
+ if (ch === '"' && !inBacktick) {
4368
+ inDoubleQuote = !inDoubleQuote;
4369
+ result += " ";
4370
+ continue;
4371
+ }
4372
+ result += ch;
4252
4373
  }
4253
- const raw = command && typeof command === "object" ? { ...command } : {};
4254
- const metadata = raw.metadata && typeof raw.metadata === "object" ? { ...raw.metadata } : {};
4255
- delete metadata.approved;
4256
- return { ...raw, actorId: actor, metadata };
4374
+ return result;
4257
4375
  }
4258
- function pluginActorIdFor(manifestId, pluginId) {
4259
- const id = (manifestId || pluginId || "").trim();
4260
- if (!id) {
4261
- throw new Error("[Security] Refusing to dispatch a plugin command without a host-assigned actor");
4376
+ function extractFlatTables(flatSql, tables) {
4377
+ const ddlRegex = /\b(?:CREATE|DROP|ALTER)\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?([a-zA-Z_][\w]*)/gi;
4378
+ for (const m of flatSql.matchAll(ddlRegex)) {
4379
+ if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
4380
+ tables.add(m[1].toLowerCase());
4381
+ }
4382
+ }
4383
+ const renameToRegex = /\bRENAME\s+TO\s+([a-zA-Z_][\w]*)/gi;
4384
+ for (const m of flatSql.matchAll(renameToRegex)) {
4385
+ if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
4386
+ tables.add(m[1].toLowerCase());
4387
+ }
4388
+ }
4389
+ const simpleClauseRegex = /\b(?:INTO|UPDATE(?:\s+OR\s+(?:ROLLBACK|ABORT|REPLACE|FAIL|IGNORE))?|JOIN)\s+([a-zA-Z_][\w]*)/gi;
4390
+ for (const m of flatSql.matchAll(simpleClauseRegex)) {
4391
+ if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
4392
+ tables.add(m[1].toLowerCase());
4393
+ }
4394
+ }
4395
+ const fromRegex = /\bFROM\s+([^;]+?)(?=\bWHERE\b|\bGROUP\b|\bHAVING\b|\bORDER\b|\bLIMIT\b|\bUNION\b|\bEXCEPT\b|\bINTERSECT\b|\bWINDOW\b|\bVALUES\b|\bSET\b|\bINTO\b|\bJOIN\b|\bLEFT\b|\bRIGHT\b|\bINNER\b|\bOUTER\b|\bCROSS\b|\bNATURAL\b|\bON\b|\bUSING\b|\bSELECT\b|;|$)/gi;
4396
+ for (const match of flatSql.matchAll(fromRegex)) {
4397
+ const clause = match[1];
4398
+ if (!clause) continue;
4399
+ const parts = clause.split(",");
4400
+ for (const part of parts) {
4401
+ const trimmed2 = part.trim();
4402
+ if (!trimmed2) continue;
4403
+ const tokenMatch = /^[a-zA-Z_][\w]*/.exec(trimmed2);
4404
+ if (tokenMatch) {
4405
+ const tableName = tokenMatch[0].toLowerCase();
4406
+ if (tableName !== SUBQUERY_PLACEHOLDER) {
4407
+ tables.add(tableName);
4408
+ }
4409
+ }
4410
+ }
4262
4411
  }
4263
- return id.startsWith("plugin:") ? id : `plugin:${id}`;
4264
4412
  }
4265
- var init_plugin_command = __esm({
4266
- "packages/core/command-bus/plugin-command.ts"() {
4267
- "use strict";
4413
+ function extractReferencedTables(cleanSql) {
4414
+ const tables = /* @__PURE__ */ new Set();
4415
+ let working = cleanSql;
4416
+ const parenRegex = /\(([^()]*)\)/g;
4417
+ let depth = 0;
4418
+ while (parenRegex.test(working) && depth++ < 100) {
4419
+ working = working.replace(parenRegex, (_full, inner) => {
4420
+ extractFlatTables(inner, tables);
4421
+ const trimmedInner = inner.trim();
4422
+ if (/^(?:SELECT|VALUES|WITH)\b/i.test(trimmedInner)) {
4423
+ return ` ${SUBQUERY_PLACEHOLDER} `;
4424
+ }
4425
+ return ` ${inner} `;
4426
+ });
4268
4427
  }
4269
- });
4270
-
4271
- // packages/core/plugin-host/plugin-namespace.ts
4272
- function resolvePluginCommandType(type, pluginId) {
4273
- const prefix = pluginId + ".";
4274
- if (type.startsWith(prefix)) return type;
4275
- if (UUID_V7_PREFIX.test(type)) return type;
4276
- return prefix + type;
4428
+ working = working.replace(/[()]/g, " ");
4429
+ extractFlatTables(working, tables);
4430
+ return tables;
4277
4431
  }
4278
- function stripPluginCommandPrefix(type, pluginId) {
4279
- const prefix = pluginId + ".";
4280
- if (type.startsWith(prefix)) return type.slice(prefix.length);
4281
- if (UUID_V7_PREFIX.test(type)) {
4282
- const idx = type.indexOf(".");
4283
- return type.slice(idx + 1);
4432
+ function assertPluginSqlAllowed(options) {
4433
+ const { sql, ownPrefix, allowedPrefixes = [], researchAllowed = false } = options;
4434
+ if (typeof sql !== "string" || sql.trim() === "") return;
4435
+ for (const opRegex of FORBIDDEN_OPERATIONS) {
4436
+ if (opRegex.test(sql)) {
4437
+ throw new Error(`Dangerous statement is forbidden: "${sql.trim()}"`);
4438
+ }
4439
+ }
4440
+ const clean = sanitizeSql(sql);
4441
+ const tables = extractReferencedTables(clean);
4442
+ if (/\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\b/i.test(clean) && tables.size === 0) {
4443
+ throw new Error(`Worker plugin SQL rejected: unable to resolve target table in "${sql.trim()}"`);
4444
+ }
4445
+ const isDdl = /^\s*(DROP|CREATE|ALTER|TRUNCATE)\b/i.test(clean);
4446
+ const words = clean.toLowerCase().match(/\b[a-zA-Z_][\w]*\b/g) || [];
4447
+ for (const word of words) {
4448
+ if (FORBIDDEN_CORE_TABLES_SET.has(word)) {
4449
+ if (tables.has(word)) {
4450
+ if (isDdl) {
4451
+ throw new Error(
4452
+ `Worker plugin is not permitted to perform DDL on table "${word}" (core security table is strictly forbidden)`
4453
+ );
4454
+ }
4455
+ throw new Error(
4456
+ `Worker plugin is not permitted to access table "${word}" (core security table is strictly forbidden)`
4457
+ );
4458
+ }
4459
+ }
4460
+ }
4461
+ const ownPrefixLower = ownPrefix ? ownPrefix.toLowerCase() : "";
4462
+ const validPrefixes = [ownPrefixLower, ...allowedPrefixes.map((p) => p.toLowerCase())].filter(Boolean);
4463
+ for (const table of tables) {
4464
+ if (table === "plugin_migrations") continue;
4465
+ if (researchAllowed && table.startsWith("plugin_research_")) continue;
4466
+ const isOwn = validPrefixes.some((p) => table.startsWith(p));
4467
+ if (isOwn) continue;
4468
+ if (table === "sqlite_master" || table === "sqlite_schema" || table === "sqlite_temp_master") {
4469
+ const strippedComments = sql.replace(/--.*$/gm, " ").replace(/\/\*[\s\S]*?\*\//g, " ").trim();
4470
+ const isAllowedMetaQuery = /^\s*SELECT\s+name\s+FROM\s+(?:sqlite_master|sqlite_schema)\s+WHERE\s+type\s*=\s*['"]table['"]\s+AND\s+name\s+LIKE\s+['"][A-Za-z0-9_%]+['"]\s*$/i.test(
4471
+ strippedComments
4472
+ ) && ownPrefixLower && strippedComments.toLowerCase().includes(ownPrefixLower);
4473
+ if (isAllowedMetaQuery) continue;
4474
+ throw new Error(`Direct access to sqlite metadata table "${table}" is forbidden`);
4475
+ }
4476
+ if (isDdl) {
4477
+ throw new Error(
4478
+ `Worker plugin is not permitted to perform DDL on table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
4479
+ );
4480
+ }
4481
+ throw new Error(
4482
+ `Worker plugin is not permitted to access table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
4483
+ );
4284
4484
  }
4285
- return type;
4286
4485
  }
4287
- var UUID_V7_PREFIX;
4288
- var init_plugin_namespace = __esm({
4289
- "packages/core/plugin-host/plugin-namespace.ts"() {
4486
+ var FORBIDDEN_CORE_TABLES, FORBIDDEN_CORE_TABLES_SET, FORBIDDEN_OPERATIONS, SUBQUERY_PLACEHOLDER;
4487
+ var init_sql_guard = __esm({
4488
+ "packages/core/plugin-host/sql-guard.ts"() {
4290
4489
  "use strict";
4291
- UUID_V7_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}\./i;
4490
+ FORBIDDEN_CORE_TABLES = Object.freeze([
4491
+ "users",
4492
+ "client_sessions",
4493
+ "plugins",
4494
+ "plugin_storage",
4495
+ "ai_providers",
4496
+ "processes",
4497
+ "pending_commands",
4498
+ "classes",
4499
+ "students",
4500
+ "class_students",
4501
+ "lessons",
4502
+ "assignments",
4503
+ "assignment_submissions",
4504
+ "attendance",
4505
+ "schedules",
4506
+ "class_grade_weights",
4507
+ "exams",
4508
+ "exam_scores",
4509
+ "student_semester_reports",
4510
+ "student_rollcalls",
4511
+ "student_seats",
4512
+ "student_read_notifications",
4513
+ "student_lesson_progress",
4514
+ "courseware",
4515
+ "courseware_attempt",
4516
+ "system_resources",
4517
+ "events",
4518
+ "_migrations",
4519
+ "site_settings",
4520
+ "agent_conversations",
4521
+ "classroom_sessions",
4522
+ "lesson_quiz_submissions",
4523
+ "courseware_score_config",
4524
+ "demo_data_registry",
4525
+ "whiteboard_elements",
4526
+ "student_point_logs",
4527
+ "lianyun_research_activities",
4528
+ "lianyun_research_groups",
4529
+ "lianyun_research_submissions",
4530
+ "lianyun_research_reviews"
4531
+ ]);
4532
+ FORBIDDEN_CORE_TABLES_SET = new Set(FORBIDDEN_CORE_TABLES.map((t) => t.toLowerCase()));
4533
+ FORBIDDEN_OPERATIONS = [
4534
+ /\bATTACH(?:\s+DATABASE)?\b/i,
4535
+ /\bDETACH(?:\s+DATABASE)?\b/i,
4536
+ /\bPRAGMA\b/i,
4537
+ /\bVACUUM\b/i,
4538
+ /\bCREATE\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i,
4539
+ /\bDROP\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i
4540
+ ];
4541
+ SUBQUERY_PLACEHOLDER = "__ol_subquery__";
4292
4542
  }
4293
4543
  });
4294
4544
 
@@ -4318,6 +4568,108 @@ var init_plugin_tables = __esm({
4318
4568
  }
4319
4569
  });
4320
4570
 
4571
+ // packages/core/plugin-host/inline-db-guard.ts
4572
+ function isSqlGuardAllModesEnabled(env = process.env) {
4573
+ const raw = env.OPENLEARN_SQL_GUARD_ALL_MODES?.trim().toLowerCase();
4574
+ return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
4575
+ }
4576
+ function createGuardedPluginDatabase(db2, pluginId, manifestId) {
4577
+ const ownPrefix = pluginTablePrefix(pluginId, manifestId);
4578
+ const allowedPrefixes = listAllowedPluginTablePrefixes(pluginId, manifestId);
4579
+ const assertSql = (sql) => {
4580
+ try {
4581
+ assertPluginSqlAllowed({ sql: typeof sql === "string" ? sql : String(sql ?? ""), ownPrefix, allowedPrefixes });
4582
+ } catch (err) {
4583
+ const message = err?.message ? String(err.message) : String(err);
4584
+ const wrapped = new Error(`${SQL_GUARD_ERROR_PREFIX}\uFF1A${message}\uFF08\u63D2\u4EF6 "${pluginId}"\uFF09`);
4585
+ wrapped.code = "PLUGIN_SQL_REJECTED";
4586
+ throw wrapped;
4587
+ }
4588
+ };
4589
+ return new Proxy(db2, {
4590
+ get(target, prop, receiver) {
4591
+ const value = Reflect.get(target, prop, receiver);
4592
+ if (typeof value !== "function") return value;
4593
+ if (prop === "prepare") {
4594
+ return (sql, ...rest) => {
4595
+ assertSql(sql);
4596
+ return value.call(target, sql, ...rest);
4597
+ };
4598
+ }
4599
+ if (prop === "exec") {
4600
+ return (sql, ...rest) => {
4601
+ assertSql(sql);
4602
+ return value.call(target, sql, ...rest);
4603
+ };
4604
+ }
4605
+ if (prop === "pragma") {
4606
+ return (source, ...rest) => {
4607
+ assertSql(`PRAGMA ${String(source ?? "")}`);
4608
+ return value.call(target, source, ...rest);
4609
+ };
4610
+ }
4611
+ return value.bind(target);
4612
+ }
4613
+ });
4614
+ }
4615
+ var SQL_GUARD_ERROR_PREFIX;
4616
+ var init_inline_db_guard = __esm({
4617
+ "packages/core/plugin-host/inline-db-guard.ts"() {
4618
+ "use strict";
4619
+ init_sql_guard();
4620
+ init_plugin_tables();
4621
+ SQL_GUARD_ERROR_PREFIX = "[PluginHost] SQL \u88AB\u62D2\u7EDD";
4622
+ }
4623
+ });
4624
+
4625
+ // packages/core/command-bus/plugin-command.ts
4626
+ function bindPluginCommand(command, pluginActorId) {
4627
+ const actor = typeof pluginActorId === "string" ? pluginActorId.trim() : "";
4628
+ if (!actor) {
4629
+ throw new Error("[Security] Refusing to dispatch a plugin command without a host-assigned actor");
4630
+ }
4631
+ const raw = command && typeof command === "object" ? { ...command } : {};
4632
+ const metadata = raw.metadata && typeof raw.metadata === "object" ? { ...raw.metadata } : {};
4633
+ delete metadata.approved;
4634
+ return { ...raw, actorId: actor, metadata };
4635
+ }
4636
+ function pluginActorIdFor(manifestId, pluginId) {
4637
+ const id = (manifestId || pluginId || "").trim();
4638
+ if (!id) {
4639
+ throw new Error("[Security] Refusing to dispatch a plugin command without a host-assigned actor");
4640
+ }
4641
+ return id.startsWith("plugin:") ? id : `plugin:${id}`;
4642
+ }
4643
+ var init_plugin_command = __esm({
4644
+ "packages/core/command-bus/plugin-command.ts"() {
4645
+ "use strict";
4646
+ }
4647
+ });
4648
+
4649
+ // packages/core/plugin-host/plugin-namespace.ts
4650
+ function resolvePluginCommandType(type, pluginId) {
4651
+ const prefix = pluginId + ".";
4652
+ if (type.startsWith(prefix)) return type;
4653
+ if (UUID_V7_PREFIX.test(type)) return type;
4654
+ return prefix + type;
4655
+ }
4656
+ function stripPluginCommandPrefix(type, pluginId) {
4657
+ const prefix = pluginId + ".";
4658
+ if (type.startsWith(prefix)) return type.slice(prefix.length);
4659
+ if (UUID_V7_PREFIX.test(type)) {
4660
+ const idx = type.indexOf(".");
4661
+ return type.slice(idx + 1);
4662
+ }
4663
+ return type;
4664
+ }
4665
+ var UUID_V7_PREFIX;
4666
+ var init_plugin_namespace = __esm({
4667
+ "packages/core/plugin-host/plugin-namespace.ts"() {
4668
+ "use strict";
4669
+ UUID_V7_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}\./i;
4670
+ }
4671
+ });
4672
+
4321
4673
  // packages/core/plugin-host/context-builder.ts
4322
4674
  var context_builder_exports = {};
4323
4675
  __export(context_builder_exports, {
@@ -4720,15 +5072,51 @@ function wrapInteractionRuntimeService(service, tracker, pluginId) {
4720
5072
  listActivityProviders: createSafeFunction(() => service.listActivityProviders())
4721
5073
  };
4722
5074
  }
4723
- function wrapPluginRuntimeService(raw, tokenName, tracker, pluginId) {
5075
+ function wrapPluginRuntimeService(raw, tokenName, tracker, pluginId, manifestId) {
4724
5076
  if (tokenName === IClassroomLifecycleServiceToken.name) {
4725
5077
  return wrapClassroomLifecycleService(raw, tracker, pluginId);
4726
5078
  }
4727
5079
  if (tokenName === IInteractionRuntimeServiceToken.name) {
4728
5080
  return wrapInteractionRuntimeService(raw, tracker, pluginId);
4729
5081
  }
5082
+ if (tokenName === "@openlearn/core:IDatabase" && isSqlGuardAllModesEnabled()) {
5083
+ return createGuardedPluginDatabase(raw, pluginId, manifestId);
5084
+ }
4730
5085
  return raw;
4731
5086
  }
5087
+ function wrapRosterService(service, pluginId, capabilities) {
5088
+ const CAP_ROSTER_READ = "management:read";
5089
+ const safe = (fn) => createSafeFunction(fn);
5090
+ const assertReadable = (method, detail) => {
5091
+ const hasCap = hasCapability(capabilities ?? [], CAP_ROSTER_READ);
5092
+ recordSecurityAudit("plugin.roster_read", {
5093
+ pluginId,
5094
+ method,
5095
+ ...detail,
5096
+ hasCapability: hasCap,
5097
+ ...hasCap ? {} : { denied: true }
5098
+ });
5099
+ if (hasCap) return;
5100
+ console.error(
5101
+ `[Security] \u63D2\u4EF6 "${pluginId}" \u8C03\u7528 roster.${method} \u88AB\u62D2\uFF1A\u7F3A\u5C11\u80FD\u529B "${CAP_ROSTER_READ}"\u3002\u8BF7\u5728 manifest.capabilitiesProposed \u58F0\u660E\u5B83\uFF08\u6559\u5E08\u89D2\u8272\u9ED8\u8BA4\u5177\u5907\uFF09\u3002`
5102
+ );
5103
+ throw new PluginCapabilityDeniedError(pluginId, CAP_ROSTER_READ, "read", method);
5104
+ };
5105
+ return {
5106
+ listClasses: safe(async () => {
5107
+ assertReadable("listClasses", {});
5108
+ return service.listClasses();
5109
+ }),
5110
+ listClassStudents: safe(async (classId) => {
5111
+ assertReadable("listClassStudents", { classId });
5112
+ return service.listClassStudents(classId);
5113
+ }),
5114
+ getStudent: safe(async (studentId) => {
5115
+ assertReadable("getStudent", { studentId });
5116
+ return service.getStudent(studentId);
5117
+ })
5118
+ };
5119
+ }
4732
5120
  function wrapPointsLedgerService(service, pluginId, capabilities) {
4733
5121
  const auditAccess = (studentId, method, operation, requiredCap) => {
4734
5122
  const hasCap = hasCapability(capabilities ?? [], requiredCap);
@@ -4741,6 +5129,9 @@ function wrapPointsLedgerService(service, pluginId, capabilities) {
4741
5129
  ...hasCap ? {} : { denied: true }
4742
5130
  });
4743
5131
  if (!hasCap) {
5132
+ console.error(
5133
+ `[Security] \u63D2\u4EF6 "${pluginId}" \u8C03\u7528 pointsLedger.${method} \u88AB\u62D2\uFF1A\u7F3A\u5C11\u80FD\u529B "${requiredCap}"\uFF08${operation} / student=${studentId}\uFF09\u3002\u8BF7\u5728 manifest.capabilitiesProposed \u58F0\u660E\u5B83\u3002`
5134
+ );
4744
5135
  throw new PluginCapabilityDeniedError(pluginId, requiredCap, operation, method);
4745
5136
  }
4746
5137
  };
@@ -4857,6 +5248,7 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
4857
5248
  const aiService = await serviceRegistry.resolve(IAIServiceToken);
4858
5249
  const pointsDimensionRegistry = await serviceRegistry.tryResolve(IPointsDimensionRegistryToken);
4859
5250
  const pointsLedgerService = await serviceRegistry.tryResolve(IPointsLedgerServiceToken);
5251
+ const rosterService = await serviceRegistry.tryResolve(IClassRosterServiceToken);
4860
5252
  const wrappedCommandBus = wrapCommandBus(commandBusService, tracker, pluginId, manifest.id, actionRegistryService);
4861
5253
  const wrappedEventBus = wrapEventBus(eventBusService, tracker, pluginId);
4862
5254
  const wrappedProcessManager = wrapProcessManager(
@@ -4873,6 +5265,7 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
4873
5265
  const wrappedAI = wrapAI(aiService, pluginId, tracker);
4874
5266
  const wrappedPointsDimension = pointsDimensionRegistry ? wrapPointsDimensionRegistry(pointsDimensionRegistry, tracker, pluginId) : null;
4875
5267
  const wrappedPointsLedger = pointsLedgerService ? wrapPointsLedgerService(pointsLedgerService, pluginId, manifest.capabilitiesProposed) : null;
5268
+ const wrappedRoster = rosterService ? wrapRosterService(rosterService, pluginId, manifest.capabilitiesProposed) : null;
4876
5269
  Object.setPrototypeOf(wrappedCommandBus, null);
4877
5270
  Object.setPrototypeOf(wrappedEventBus, null);
4878
5271
  Object.setPrototypeOf(wrappedProcessManager, null);
@@ -4900,7 +5293,8 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
4900
5293
  storage: wrappedStorage,
4901
5294
  ai: wrappedAI,
4902
5295
  pointsDimension: wrappedPointsDimension,
4903
- pointsLedger: wrappedPointsLedger
5296
+ pointsLedger: wrappedPointsLedger,
5297
+ roster: wrappedRoster
4904
5298
  };
4905
5299
  if (skipTokens && skipTokens.size > 0) {
4906
5300
  const TOKEN_TO_SERVICE_KEY = {
@@ -4912,7 +5306,8 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
4912
5306
  "@openlearn/core:IStorageService": "storage",
4913
5307
  "@openlearn/core:IAIService": "ai",
4914
5308
  "@openlearn/core:IPointsDimensionRegistry": "pointsDimension",
4915
- "@openlearn/core:IPointsLedgerService": "pointsLedger"
5309
+ "@openlearn/core:IPointsLedgerService": "pointsLedger",
5310
+ "@openlearn/core:IClassRosterService": "roster"
4916
5311
  };
4917
5312
  for (const tokenName of skipTokens) {
4918
5313
  const serviceKey = TOKEN_TO_SERVICE_KEY[tokenName];
@@ -5023,9 +5418,9 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
5023
5418
  `[PluginHost] Plugin "${manifest.id}" cannot resolve "${token}" by name. Use the Token object imported from @openlearn/plugin-sdk, or one of the SDK-exported Token names.`
5024
5419
  );
5025
5420
  }
5026
- return serviceRegistry.resolveByName(token).then((raw) => wrapPluginRuntimeService(raw, token, tracker, pluginId));
5421
+ return serviceRegistry.resolveByName(token).then((raw) => wrapPluginRuntimeService(raw, token, tracker, pluginId, manifest.id));
5027
5422
  }
5028
- return serviceRegistry.resolve(token).then((raw) => wrapPluginRuntimeService(raw, token.name, tracker, pluginId));
5423
+ return serviceRegistry.resolve(token).then((raw) => wrapPluginRuntimeService(raw, token.name, tracker, pluginId, manifest.id));
5029
5424
  },
5030
5425
  // V3.2: 插件向 DI 容器提供服务(对应 manifest.provides)
5031
5426
  provide: async (token, instance) => {
@@ -5125,6 +5520,7 @@ var init_context_builder = __esm({
5125
5520
  init_audit_log();
5126
5521
  init_method_policy();
5127
5522
  init_errors2();
5523
+ init_inline_db_guard();
5128
5524
  init_extension_points();
5129
5525
  init_interfaces();
5130
5526
  init_plugin_command();
@@ -5862,7 +6258,7 @@ function isPidAlive(pid) {
5862
6258
  function writeBootstrapTempFile(code) {
5863
6259
  const file = import_node_path11.default.join(
5864
6260
  import_node_os.default.tmpdir(),
5865
- `${BOOTSTRAP_PREFIX}${process.pid}-${import_node_crypto7.default.randomBytes(8).toString("hex")}${BOOTSTRAP_SUFFIX}`
6261
+ `${BOOTSTRAP_PREFIX}${process.pid}-${import_node_crypto6.default.randomBytes(8).toString("hex")}${BOOTSTRAP_SUFFIX}`
5866
6262
  );
5867
6263
  import_node_fs9.default.writeFileSync(file, code, { encoding: "utf8", mode: 384 });
5868
6264
  return file;
@@ -5925,12 +6321,12 @@ function resolveNetPreloadPath(options = {}) {
5925
6321
  }
5926
6322
  return found;
5927
6323
  }
5928
- var import_node_child_process, import_node_crypto7, import_node_fs9, import_node_os, import_node_path11, import_node_url3, import_meta6, ENV_ALLOWLIST, ENV_DENYLIST, WORKER_DATA_ENV, BOOTSTRAP_PREFIX, BOOTSTRAP_SUFFIX, LEGACY_BOOTSTRAP_STALE_MS;
6324
+ var import_node_child_process, import_node_crypto6, import_node_fs9, import_node_os, import_node_path11, import_node_url3, import_meta6, ENV_ALLOWLIST, ENV_DENYLIST, WORKER_DATA_ENV, BOOTSTRAP_PREFIX, BOOTSTRAP_SUFFIX, LEGACY_BOOTSTRAP_STALE_MS;
5929
6325
  var init_child_spawn = __esm({
5930
6326
  "packages/core/worker-runtime/child-spawn.ts"() {
5931
6327
  "use strict";
5932
6328
  import_node_child_process = require("node:child_process");
5933
- import_node_crypto7 = __toESM(require("node:crypto"), 1);
6329
+ import_node_crypto6 = __toESM(require("node:crypto"), 1);
5934
6330
  import_node_fs9 = __toESM(require("node:fs"), 1);
5935
6331
  import_node_os = __toESM(require("node:os"), 1);
5936
6332
  import_node_path11 = __toESM(require("node:path"), 1);
@@ -7518,241 +7914,6 @@ var init_event_forwarder = __esm({
7518
7914
  }
7519
7915
  });
7520
7916
 
7521
- // packages/core/plugin-host/sql-guard.ts
7522
- function sanitizeSql(sql) {
7523
- let inSingleQuote = false;
7524
- let inDoubleQuote = false;
7525
- let inBacktick = false;
7526
- let inBracket = false;
7527
- let result = "";
7528
- const len = sql.length;
7529
- for (let i = 0; i < len; i++) {
7530
- const ch = sql[i];
7531
- const next = i + 1 < len ? sql[i + 1] : "";
7532
- if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "-" && next === "-") {
7533
- while (i < len && sql[i] !== "\n") i++;
7534
- result += " ";
7535
- continue;
7536
- }
7537
- if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "/" && next === "*") {
7538
- i += 2;
7539
- while (i < len && !(sql[i] === "*" && i + 1 < len && sql[i + 1] === "/")) {
7540
- i++;
7541
- }
7542
- i++;
7543
- result += " ";
7544
- continue;
7545
- }
7546
- if (ch === "'" && !inDoubleQuote && !inBacktick && !inBracket) {
7547
- if (inSingleQuote) {
7548
- if (next === "'") {
7549
- i++;
7550
- } else {
7551
- inSingleQuote = false;
7552
- }
7553
- } else {
7554
- inSingleQuote = true;
7555
- }
7556
- result += " ";
7557
- continue;
7558
- }
7559
- if (inSingleQuote) {
7560
- result += " ";
7561
- continue;
7562
- }
7563
- if (ch === "[" && !inDoubleQuote && !inBacktick) {
7564
- inBracket = true;
7565
- result += " ";
7566
- continue;
7567
- }
7568
- if (ch === "]" && inBracket) {
7569
- inBracket = false;
7570
- result += " ";
7571
- continue;
7572
- }
7573
- if (ch === "`" && !inDoubleQuote) {
7574
- inBacktick = !inBacktick;
7575
- result += " ";
7576
- continue;
7577
- }
7578
- if (ch === '"' && !inBacktick) {
7579
- inDoubleQuote = !inDoubleQuote;
7580
- result += " ";
7581
- continue;
7582
- }
7583
- result += ch;
7584
- }
7585
- return result;
7586
- }
7587
- function extractFlatTables(flatSql, tables) {
7588
- const ddlRegex = /\b(?:CREATE|DROP|ALTER)\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?([a-zA-Z_][\w]*)/gi;
7589
- for (const m of flatSql.matchAll(ddlRegex)) {
7590
- if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
7591
- tables.add(m[1].toLowerCase());
7592
- }
7593
- }
7594
- const renameToRegex = /\bRENAME\s+TO\s+([a-zA-Z_][\w]*)/gi;
7595
- for (const m of flatSql.matchAll(renameToRegex)) {
7596
- if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
7597
- tables.add(m[1].toLowerCase());
7598
- }
7599
- }
7600
- const simpleClauseRegex = /\b(?:INTO|UPDATE(?:\s+OR\s+(?:ROLLBACK|ABORT|REPLACE|FAIL|IGNORE))?|JOIN)\s+([a-zA-Z_][\w]*)/gi;
7601
- for (const m of flatSql.matchAll(simpleClauseRegex)) {
7602
- if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
7603
- tables.add(m[1].toLowerCase());
7604
- }
7605
- }
7606
- const fromRegex = /\bFROM\s+([^;]+?)(?=\bWHERE\b|\bGROUP\b|\bHAVING\b|\bORDER\b|\bLIMIT\b|\bUNION\b|\bEXCEPT\b|\bINTERSECT\b|\bWINDOW\b|\bVALUES\b|\bSET\b|\bINTO\b|\bJOIN\b|\bLEFT\b|\bRIGHT\b|\bINNER\b|\bOUTER\b|\bCROSS\b|\bNATURAL\b|\bON\b|\bUSING\b|\bSELECT\b|;|$)/gi;
7607
- for (const match of flatSql.matchAll(fromRegex)) {
7608
- const clause = match[1];
7609
- if (!clause) continue;
7610
- const parts = clause.split(",");
7611
- for (const part of parts) {
7612
- const trimmed2 = part.trim();
7613
- if (!trimmed2) continue;
7614
- const tokenMatch = /^[a-zA-Z_][\w]*/.exec(trimmed2);
7615
- if (tokenMatch) {
7616
- const tableName = tokenMatch[0].toLowerCase();
7617
- if (tableName !== SUBQUERY_PLACEHOLDER) {
7618
- tables.add(tableName);
7619
- }
7620
- }
7621
- }
7622
- }
7623
- }
7624
- function extractReferencedTables(cleanSql) {
7625
- const tables = /* @__PURE__ */ new Set();
7626
- let working = cleanSql;
7627
- const parenRegex = /\(([^()]*)\)/g;
7628
- let depth = 0;
7629
- while (parenRegex.test(working) && depth++ < 100) {
7630
- working = working.replace(parenRegex, (_full, inner) => {
7631
- extractFlatTables(inner, tables);
7632
- const trimmedInner = inner.trim();
7633
- if (/^(?:SELECT|VALUES|WITH)\b/i.test(trimmedInner)) {
7634
- return ` ${SUBQUERY_PLACEHOLDER} `;
7635
- }
7636
- return ` ${inner} `;
7637
- });
7638
- }
7639
- working = working.replace(/[()]/g, " ");
7640
- extractFlatTables(working, tables);
7641
- return tables;
7642
- }
7643
- function assertPluginSqlAllowed(options) {
7644
- const { sql, ownPrefix, allowedPrefixes = [], researchAllowed = false } = options;
7645
- if (typeof sql !== "string" || sql.trim() === "") return;
7646
- for (const opRegex of FORBIDDEN_OPERATIONS) {
7647
- if (opRegex.test(sql)) {
7648
- throw new Error(`Dangerous statement is forbidden: "${sql.trim()}"`);
7649
- }
7650
- }
7651
- const clean = sanitizeSql(sql);
7652
- const tables = extractReferencedTables(clean);
7653
- if (/\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\b/i.test(clean) && tables.size === 0) {
7654
- throw new Error(`Worker plugin SQL rejected: unable to resolve target table in "${sql.trim()}"`);
7655
- }
7656
- const isDdl = /^\s*(DROP|CREATE|ALTER|TRUNCATE)\b/i.test(clean);
7657
- const words = clean.toLowerCase().match(/\b[a-zA-Z_][\w]*\b/g) || [];
7658
- for (const word of words) {
7659
- if (FORBIDDEN_CORE_TABLES_SET.has(word)) {
7660
- if (tables.has(word)) {
7661
- if (isDdl) {
7662
- throw new Error(
7663
- `Worker plugin is not permitted to perform DDL on table "${word}" (core security table is strictly forbidden)`
7664
- );
7665
- }
7666
- throw new Error(
7667
- `Worker plugin is not permitted to access table "${word}" (core security table is strictly forbidden)`
7668
- );
7669
- }
7670
- }
7671
- }
7672
- const ownPrefixLower = ownPrefix ? ownPrefix.toLowerCase() : "";
7673
- const validPrefixes = [ownPrefixLower, ...allowedPrefixes.map((p) => p.toLowerCase())].filter(Boolean);
7674
- for (const table of tables) {
7675
- if (table === "plugin_migrations") continue;
7676
- if (researchAllowed && table.startsWith("plugin_research_")) continue;
7677
- const isOwn = validPrefixes.some((p) => table.startsWith(p));
7678
- if (isOwn) continue;
7679
- if (table === "sqlite_master" || table === "sqlite_schema" || table === "sqlite_temp_master") {
7680
- const strippedComments = sql.replace(/--.*$/gm, " ").replace(/\/\*[\s\S]*?\*\//g, " ").trim();
7681
- const isAllowedMetaQuery = /^\s*SELECT\s+name\s+FROM\s+(?:sqlite_master|sqlite_schema)\s+WHERE\s+type\s*=\s*['"]table['"]\s+AND\s+name\s+LIKE\s+['"][A-Za-z0-9_%]+['"]\s*$/i.test(
7682
- strippedComments
7683
- ) && ownPrefixLower && strippedComments.toLowerCase().includes(ownPrefixLower);
7684
- if (isAllowedMetaQuery) continue;
7685
- throw new Error(`Direct access to sqlite metadata table "${table}" is forbidden`);
7686
- }
7687
- if (isDdl) {
7688
- throw new Error(
7689
- `Worker plugin is not permitted to perform DDL on table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
7690
- );
7691
- }
7692
- throw new Error(
7693
- `Worker plugin is not permitted to access table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
7694
- );
7695
- }
7696
- }
7697
- var FORBIDDEN_CORE_TABLES, FORBIDDEN_CORE_TABLES_SET, FORBIDDEN_OPERATIONS, SUBQUERY_PLACEHOLDER;
7698
- var init_sql_guard = __esm({
7699
- "packages/core/plugin-host/sql-guard.ts"() {
7700
- "use strict";
7701
- FORBIDDEN_CORE_TABLES = Object.freeze([
7702
- "users",
7703
- "client_sessions",
7704
- "plugins",
7705
- "plugin_storage",
7706
- "ai_providers",
7707
- "processes",
7708
- "pending_commands",
7709
- "classes",
7710
- "students",
7711
- "class_students",
7712
- "lessons",
7713
- "assignments",
7714
- "assignment_submissions",
7715
- "attendance",
7716
- "schedules",
7717
- "class_grade_weights",
7718
- "exams",
7719
- "exam_scores",
7720
- "student_semester_reports",
7721
- "student_rollcalls",
7722
- "student_seats",
7723
- "student_read_notifications",
7724
- "student_lesson_progress",
7725
- "courseware",
7726
- "courseware_attempt",
7727
- "system_resources",
7728
- "events",
7729
- "_migrations",
7730
- "site_settings",
7731
- "agent_conversations",
7732
- "classroom_sessions",
7733
- "lesson_quiz_submissions",
7734
- "courseware_score_config",
7735
- "demo_data_registry",
7736
- "whiteboard_elements",
7737
- "student_point_logs",
7738
- "lianyun_research_activities",
7739
- "lianyun_research_groups",
7740
- "lianyun_research_submissions",
7741
- "lianyun_research_reviews"
7742
- ]);
7743
- FORBIDDEN_CORE_TABLES_SET = new Set(FORBIDDEN_CORE_TABLES.map((t) => t.toLowerCase()));
7744
- FORBIDDEN_OPERATIONS = [
7745
- /\bATTACH(?:\s+DATABASE)?\b/i,
7746
- /\bDETACH(?:\s+DATABASE)?\b/i,
7747
- /\bPRAGMA\b/i,
7748
- /\bVACUUM\b/i,
7749
- /\bCREATE\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i,
7750
- /\bDROP\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i
7751
- ];
7752
- SUBQUERY_PLACEHOLDER = "__ol_subquery__";
7753
- }
7754
- });
7755
-
7756
7917
  // packages/core/worker-runtime/service-host.ts
7757
7918
  function sanitizeClonableValue(val, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
7758
7919
  if (depth > 12) return null;
@@ -7787,12 +7948,12 @@ function researchSqlPluginIds(env = process.env) {
7787
7948
  }
7788
7949
  return /* @__PURE__ */ new Set(["lianyun-course"]);
7789
7950
  }
7790
- var import_node_crypto8, import_promises2, import_node_net2, CORE_WORKER_TOKENS, STACK_CAP, ServiceHost;
7951
+ var import_node_crypto7, import_promises2, import_node_net2, CORE_WORKER_TOKENS, STACK_CAP, ServiceHost;
7791
7952
  var init_service_host = __esm({
7792
7953
  "packages/core/worker-runtime/service-host.ts"() {
7793
7954
  "use strict";
7794
7955
  init_event_forwarder();
7795
- import_node_crypto8 = __toESM(require("node:crypto"), 1);
7956
+ import_node_crypto7 = __toESM(require("node:crypto"), 1);
7796
7957
  import_promises2 = __toESM(require("node:dns/promises"), 1);
7797
7958
  import_node_net2 = __toESM(require("node:net"), 1);
7798
7959
  init_errors3();
@@ -8432,7 +8593,7 @@ var init_service_host = __esm({
8432
8593
  const [event] = msg.args;
8433
8594
  const pluginId = this.pluginId || this.pluginActorId.replace(/^plugin:/, "");
8434
8595
  const enrichedEvent = {
8435
- id: event.id || `evt_${Date.now()}_${import_node_crypto8.default.randomUUID()}`,
8596
+ id: event.id || `evt_${Date.now()}_${import_node_crypto7.default.randomUUID()}`,
8436
8597
  timestamp: event.timestamp || Date.now(),
8437
8598
  ...event,
8438
8599
  source: event.source ? `plugin:${pluginId}.${event.source}` : `plugin:${pluginId}`
@@ -10309,6 +10470,15 @@ var CommandBus = class {
10309
10470
  hasHandler(commandType) {
10310
10471
  return this.handlers.has(commandType) || this.legacyHandlers.has(commandType);
10311
10472
  }
10473
+ /**
10474
+ * 只读枚举已注册的命令类型(modern + legacy)。
10475
+ *
10476
+ * 供插件激活后的「handler 有、ActionDescriptor 没有」自检使用(Issue #369):
10477
+ * 自检要能看清「这次激活新增了哪些命令」,而不是去读私有 Map。
10478
+ */
10479
+ listRegisteredCommandTypes() {
10480
+ return Array.from(/* @__PURE__ */ new Set([...this.handlers.keys(), ...this.legacyHandlers.keys()]));
10481
+ }
10312
10482
  async execute(command) {
10313
10483
  const actorId = typeof command.actorId === "string" ? command.actorId.trim() : "";
10314
10484
  if (!actorId) {
@@ -10372,6 +10542,14 @@ var ActionRegistry = class {
10372
10542
  if (this.actions.has(descriptor.id)) {
10373
10543
  throw new Error(`Action ${descriptor.id} is already registered.`);
10374
10544
  }
10545
+ const owner = this.getActionByCommandType(descriptor.commandType);
10546
+ if (owner) {
10547
+ const message = `Command type "${descriptor.commandType}" is already registered by action ${owner.id}; ${descriptor.id} cannot claim it again.`;
10548
+ if (process.env.VITEST || process.env.NODE_ENV === "test") {
10549
+ throw new Error(message);
10550
+ }
10551
+ console.warn(`[ActionRegistry] ${message}`);
10552
+ }
10375
10553
  this.actions.set(descriptor.id, descriptor);
10376
10554
  }
10377
10555
  unregister(id) {
@@ -10403,6 +10581,20 @@ var ActionRegistry = class {
10403
10581
  getActionByCommandType(commandType) {
10404
10582
  return Array.from(this.actions.values()).find((a) => a.commandType === commandType);
10405
10583
  }
10584
+ /** 已登记描述符覆盖的 commandType(去重、只读)。 */
10585
+ listRegisteredCommandTypes() {
10586
+ return Array.from(new Set(Array.from(this.actions.values()).map((a) => a.commandType))).sort();
10587
+ }
10588
+ /**
10589
+ * 给定命令类型里**没有** ActionDescriptor 的那些。
10590
+ *
10591
+ * 命令总线是 default-deny:这种命令会在用户点击时被 403 `COMMAND_NOT_REGISTERED` 拒绝。
10592
+ * 插件激活后用它把「注册了 handler 却忘了登记描述符」提前打成一条 WARN(Issue #369)。
10593
+ */
10594
+ listMissingDescriptors(commandTypes) {
10595
+ const registered = new Set(Array.from(this.actions.values()).map((a) => a.commandType));
10596
+ return Array.from(new Set(commandTypes)).filter((type) => !registered.has(type)).sort();
10597
+ }
10406
10598
  };
10407
10599
 
10408
10600
  // packages/core/capability/event/capability-event-bus.ts
@@ -10670,6 +10862,7 @@ var InvocationEngine = class {
10670
10862
  };
10671
10863
 
10672
10864
  // packages/core/capability/guard/capability-guard.ts
10865
+ init_catalog();
10673
10866
  var CapabilityGuard = class _CapabilityGuard {
10674
10867
  constructor() {
10675
10868
  this.actorCapabilities = /* @__PURE__ */ new Map();
@@ -10685,20 +10878,20 @@ var CapabilityGuard = class _CapabilityGuard {
10685
10878
  ]);
10686
10879
  this.actorCapabilities.set("anonymous", []);
10687
10880
  this.actorCapabilities.set("agent-system-0", [
10688
- "lesson:write",
10689
- "lesson:delete",
10690
- "whiteboard:write",
10881
+ CAP.LESSON_WRITE,
10882
+ CAP.LESSON_DELETE,
10883
+ CAP.WHITEBOARD_WRITE,
10691
10884
  "quiz:write",
10692
- "plugin:read",
10693
- "vfs:read",
10694
- "vfs:write",
10695
- "management:write",
10696
- "management:read",
10697
- "process:write",
10698
- "process:read"
10885
+ CAP.PLUGIN_READ,
10886
+ CAP.VFS_READ,
10887
+ CAP.VFS_WRITE,
10888
+ CAP.MANAGEMENT_WRITE,
10889
+ CAP.MANAGEMENT_READ,
10890
+ CAP.PROCESS_WRITE,
10891
+ CAP.PROCESS_READ
10699
10892
  ]);
10700
10893
  this.actorCapabilities.set("teacher-demo", ["lesson:*", "whiteboard:*", "management:*", "quiz:*", "vfs:*"]);
10701
- this.actorCapabilities.set("student-demo", ["student:write", "lesson:read", "whiteboard:read"]);
10894
+ this.actorCapabilities.set("student-demo", [CAP.STUDENT_WRITE, CAP.LESSON_READ, CAP.WHITEBOARD_READ]);
10702
10895
  }
10703
10896
  grant(actorId, cap) {
10704
10897
  const caps = this.actorCapabilities.get(actorId) || [];
@@ -10732,11 +10925,11 @@ var CapabilityGuard = class _CapabilityGuard {
10732
10925
  "vfs:*",
10733
10926
  "process:*",
10734
10927
  "plugin:*",
10735
- "student:write",
10736
- "assignment:read",
10737
- "assignment:submit",
10738
- "assignment:review",
10739
- "assignment:manage"
10928
+ CAP.STUDENT_WRITE,
10929
+ CAP.ASSIGNMENT_READ,
10930
+ CAP.ASSIGNMENT_SUBMIT,
10931
+ CAP.ASSIGNMENT_REVIEW,
10932
+ CAP.ASSIGNMENT_MANAGE
10740
10933
  ];
10741
10934
  const [reqRes2, reqAct2] = requiredCap.split(":");
10742
10935
  if (teacherCaps.some((c) => {
@@ -10748,12 +10941,12 @@ var CapabilityGuard = class _CapabilityGuard {
10748
10941
  }
10749
10942
  if (role === "student") {
10750
10943
  const studentCaps = [
10751
- "student:write",
10752
- "lesson:read",
10753
- "whiteboard:read",
10754
- "assignment:read",
10755
- "assignment:submit",
10756
- "assignment:review"
10944
+ CAP.STUDENT_WRITE,
10945
+ CAP.LESSON_READ,
10946
+ CAP.WHITEBOARD_READ,
10947
+ CAP.ASSIGNMENT_READ,
10948
+ CAP.ASSIGNMENT_SUBMIT,
10949
+ CAP.ASSIGNMENT_REVIEW
10757
10950
  ];
10758
10951
  const [reqRes2, reqAct2] = requiredCap.split(":");
10759
10952
  if (studentCaps.some((c) => {
@@ -12092,18 +12285,25 @@ function generateUnambiguousPassword(length = 20) {
12092
12285
  }
12093
12286
  return out;
12094
12287
  }
12288
+ function resolveSeedPasswordMode(env = process.env) {
12289
+ return env.OPENLEARN_SEED_PASSWORD_MODE?.trim().toLowerCase() === "random" ? "random" : "default";
12290
+ }
12095
12291
  function resolveSeedCredential(username, options) {
12096
12292
  if (options?.allowWellKnown) {
12097
- return { password: username, mustChange: false, generated: false };
12293
+ return { password: username, mustChange: false, generated: false, source: "playwright" };
12098
12294
  }
12099
12295
  const envValue = options?.envValue?.trim();
12100
12296
  if (envValue) {
12101
- return { password: envValue, mustChange: envValue === username, generated: false };
12297
+ return { password: envValue, mustChange: envValue === username, generated: false, source: "env" };
12102
12298
  }
12103
12299
  if (options?.vitestFallback) {
12104
- return { password: `Vitest-${username}-NotDefault9`, mustChange: false, generated: false };
12300
+ return { password: `Vitest-${username}-NotDefault9`, mustChange: false, generated: false, source: "vitest" };
12301
+ }
12302
+ const mode = options?.mode ?? resolveSeedPasswordMode();
12303
+ if (mode === "random") {
12304
+ return { password: generateUnambiguousPassword(20), mustChange: true, generated: true, source: "random" };
12105
12305
  }
12106
- return { password: generateUnambiguousPassword(20), mustChange: true, generated: true };
12306
+ return { password: username, mustChange: true, generated: false, source: "default" };
12107
12307
  }
12108
12308
  function resolveBootstrapCredentialDir() {
12109
12309
  if (process.env.OPENLEARN_DATA_DIR?.trim()) {
@@ -12127,6 +12327,11 @@ function persistGeneratedSeedCredentials(lines) {
12127
12327
  console.warn(`[SECURITY] \u65E0\u6CD5\u5199\u5165 ${filePath}\uFF0C\u8BF7\u4ECE\u4E0A\u65B9\u65E5\u5FD7\u6284\u5F55\u521D\u59CB\u53E3\u4EE4\u3002`, err);
12128
12328
  }
12129
12329
  }
12330
+ function logDefaultSeedCredentials(usernames) {
12331
+ console.warn(
12332
+ "[SECURITY] \u9996\u6B21\u542F\u52A8\u5DF2\u521B\u5EFA\u79CD\u5B50\u8D26\u53F7\uFF0C\u521D\u59CB\u53E3\u4EE4 = \u7528\u6237\u540D\uFF08\u9996\u6B21\u767B\u5F55\u540E\u5FC5\u987B\u4FEE\u6539\uFF09\uFF1A\n" + usernames.map((name) => ` ${name} / ${name}`).join("\n") + "\n \u6539\u5BC6\u524D\uFF0C\u9664\u300C\u6539\u5BC6 / \u767B\u51FA\u300D\u4EE5\u5916\u7684\u6240\u6709\u63A5\u53E3\u90FD\u4F1A\u88AB\u62D2\u7EDD\uFF08403 FORBIDDEN_DEFAULT_PASSWORD\uFF09\u3002\n \u5982\u9700\u968F\u673A\u521D\u59CB\u53E3\u4EE4\uFF0C\u8BBE\u7F6E OPENLEARN_SEED_PASSWORD_MODE=random \u540E\u91CD\u5EFA\u6570\u636E\u5E93\u3002"
12333
+ );
12334
+ }
12130
12335
  function verifyPassword(pwd, storedHash) {
12131
12336
  if (storedHash.startsWith("$2")) {
12132
12337
  return { valid: import_bcryptjs.default.compareSync(pwd, storedHash), needsUpgrade: false };
@@ -12240,16 +12445,19 @@ function initializeDatabase(instance) {
12240
12445
  const countObj = instance.prepare("SELECT COUNT(*) as cnt FROM users").get();
12241
12446
  const allowWellKnown = isPlaywrightTestFlag(process.env.PLAYWRIGHT_TEST);
12242
12447
  const vitestFallback = Boolean(process.env.VITEST) && !allowWellKnown;
12448
+ const seedMode = resolveSeedPasswordMode();
12243
12449
  if (countObj && countObj.cnt === 0) {
12244
12450
  const adminCred = resolveSeedCredential("admin", {
12245
12451
  envValue: process.env.OPENLEARN_ADMIN_PASSWORD,
12246
12452
  allowWellKnown,
12247
- vitestFallback
12453
+ vitestFallback,
12454
+ mode: seedMode
12248
12455
  });
12249
12456
  const teacherCred = resolveSeedCredential("teacher", {
12250
12457
  envValue: process.env.OPENLEARN_TEACHER_PASSWORD,
12251
12458
  allowWellKnown,
12252
- vitestFallback
12459
+ vitestFallback,
12460
+ mode: seedMode
12253
12461
  });
12254
12462
  const insertStmt = instance.prepare(
12255
12463
  "INSERT INTO users (id, username, password_hash, role, name, created_at, must_change_password) VALUES (?, ?, ?, ?, ?, ?, ?)"
@@ -12276,8 +12484,12 @@ function initializeDatabase(instance) {
12276
12484
  if (adminCred.generated) generated.push(`admin ${adminCred.password}`);
12277
12485
  if (teacherCred.generated) generated.push(`teacher ${teacherCred.password}`);
12278
12486
  if (generated.length > 0) persistGeneratedSeedCredentials(generated);
12279
- } else if (!allowWellKnown) {
12280
- const seeded = instance.prepare("SELECT id, username, password_hash FROM users WHERE username IN (?, ?)").all("admin", "teacher");
12487
+ const defaulted = [];
12488
+ if (adminCred.source === "default") defaulted.push("admin");
12489
+ if (teacherCred.source === "default") defaulted.push("teacher");
12490
+ if (defaulted.length > 0) logDefaultSeedCredentials(defaulted);
12491
+ } else {
12492
+ const seeded = allowWellKnown ? [] : instance.prepare("SELECT id, username, password_hash FROM users WHERE username IN (?, ?)").all("admin", "teacher");
12281
12493
  for (const user of seeded) {
12282
12494
  if (!verifyPassword(user.username, user.password_hash).valid) continue;
12283
12495
  instance.prepare("UPDATE users SET must_change_password = 1 WHERE id = ?").run(user.id);
@@ -12745,247 +12957,6 @@ var ProcessPlugin = {
12745
12957
  // packages/plugins/management.ts
12746
12958
  var import_uuid4 = require("uuid");
12747
12959
  var import_plugin_sdk3 = require("@openlearn/plugin-sdk");
12748
-
12749
- // packages/core/db/assignment-grade-writer.ts
12750
- var import_node_crypto3 = __toESM(require("node:crypto"), 1);
12751
- var AUTOMATED_SOURCES = /* @__PURE__ */ new Set(["auto", "ai", "system"]);
12752
- function gradeActorRole(actorId) {
12753
- const match = /^user:[^:]+:(administrator|admin|teacher|student)$/.exec(String(actorId ?? ""));
12754
- return match ? match[1] : null;
12755
- }
12756
- function isPrivilegedGradeActor(actorId) {
12757
- const role = gradeActorRole(actorId);
12758
- return role === "teacher" || role === "administrator" || role === "admin";
12759
- }
12760
- function parseScore(raw) {
12761
- if (raw === null || raw === void 0) return { ok: true, value: null };
12762
- if (typeof raw === "string" && raw.trim() === "") return { ok: false };
12763
- const num = typeof raw === "number" ? raw : Number(raw);
12764
- if (!Number.isFinite(num)) return { ok: false };
12765
- const rounded = Math.round(num);
12766
- if (rounded < 0 || rounded > 100) return { ok: false };
12767
- return { ok: true, value: rounded };
12768
- }
12769
- var UPSERT_SUBMISSION = `
12770
- INSERT INTO assignment_submissions
12771
- (assignment_id, student_id, content, score, feedback, submitted_at, graded_at, status, source, academic_year, graded_by)
12772
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
12773
- ON CONFLICT(assignment_id, student_id) DO UPDATE SET
12774
- content = COALESCE(excluded.content, assignment_submissions.content),
12775
- score = excluded.score,
12776
- feedback = COALESCE(excluded.feedback, assignment_submissions.feedback),
12777
- submitted_at = COALESCE(assignment_submissions.submitted_at, excluded.submitted_at),
12778
- graded_at = COALESCE(excluded.graded_at, assignment_submissions.graded_at),
12779
- status = excluded.status,
12780
- source = excluded.source,
12781
- academic_year = COALESCE(excluded.academic_year, assignment_submissions.academic_year),
12782
- graded_by = COALESCE(excluded.graded_by, assignment_submissions.graded_by)
12783
- `;
12784
- var INSERT_HISTORY = `
12785
- INSERT INTO assignment_grade_history
12786
- (id, assignment_id, student_id, old_score, new_score, old_status, new_status,
12787
- old_source, new_source, actor_id, actor_role, reason, created_at)
12788
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
12789
- `;
12790
- function applyGradeChange(db2, input) {
12791
- const assignmentId = String(input.assignmentId ?? "").trim();
12792
- const studentId = String(input.studentId ?? "").trim();
12793
- if (!assignmentId || !studentId) {
12794
- return { ok: false, reason: "invalid-target", assignmentId, studentId };
12795
- }
12796
- const parsed = parseScore(input.score);
12797
- if (!parsed.ok) {
12798
- return { ok: false, reason: "invalid-score", assignmentId, studentId };
12799
- }
12800
- const newScore = parsed.value;
12801
- const newStatus = input.status ?? (newScore === null ? "absent" : "graded");
12802
- if (newScore === null && newStatus !== "absent") {
12803
- return { ok: false, reason: "invalid-score", assignmentId, studentId };
12804
- }
12805
- const now = input.gradedAt ?? Date.now();
12806
- const submittedAt = input.submittedAt ?? now;
12807
- const createIfMissing = input.createIfMissing !== false;
12808
- const automatedIncoming = AUTOMATED_SOURCES.has(input.source);
12809
- const protectedIncoming = input.source !== "manual";
12810
- const actorId = input.actorId ?? (automatedIncoming ? "system" : null);
12811
- const actorRole = input.actorRole ?? gradeActorRole(actorId) ?? (actorId === "system" ? "system" : null);
12812
- let result = { ok: false, reason: "invalid-target", assignmentId, studentId };
12813
- const write = () => {
12814
- const existing = db2.prepare(
12815
- `SELECT score, feedback, status, source, academic_year
12816
- FROM assignment_submissions
12817
- WHERE assignment_id = ? AND student_id = ?
12818
- LIMIT 1`
12819
- ).get(assignmentId, studentId);
12820
- if (!existing && !createIfMissing) {
12821
- result = { ok: false, reason: "submission-not-found", assignmentId, studentId };
12822
- return;
12823
- }
12824
- if (existing && !input.overrideProtected && protectedIncoming) {
12825
- if (existing.status === "absent" && newStatus !== "absent") {
12826
- result = { ok: false, reason: "absent-protected", assignmentId, studentId, oldScore: existing.score };
12827
- return;
12828
- }
12829
- const existingHasGrade = existing.status === "graded" || existing.status === "absent" || existing.score !== null;
12830
- const existingProtected = existing.source === null || existing.source === "manual" || existing.source === "plugin";
12831
- if (existingHasGrade && existingProtected && existing.source !== input.source) {
12832
- result = { ok: false, reason: "manual-protected", assignmentId, studentId, oldScore: existing.score };
12833
- return;
12834
- }
12835
- }
12836
- const assignment = db2.prepare("SELECT academic_year FROM assignments WHERE id = ?").get(assignmentId);
12837
- if (!existing && !assignment) {
12838
- result = { ok: false, reason: "assignment-not-found", assignmentId, studentId };
12839
- return;
12840
- }
12841
- const academicYear = input.academicYear ?? existing?.academic_year ?? assignment?.academic_year ?? null;
12842
- const gradedBy = actorId ?? null;
12843
- db2.prepare(UPSERT_SUBMISSION).run(
12844
- assignmentId,
12845
- studentId,
12846
- input.content ?? null,
12847
- newScore,
12848
- input.feedback ?? null,
12849
- submittedAt,
12850
- now,
12851
- newStatus,
12852
- input.source,
12853
- academicYear,
12854
- gradedBy
12855
- );
12856
- const oldScore = existing?.score ?? null;
12857
- const oldStatus = existing?.status ?? null;
12858
- const oldSource = existing?.source ?? null;
12859
- const scoreChanged = newScore === null ? oldScore !== null : oldScore === null || Number(oldScore) !== newScore;
12860
- const changed = !existing || scoreChanged || oldStatus !== newStatus || oldSource !== input.source;
12861
- let historyId;
12862
- if (changed) {
12863
- historyId = `agh-${import_node_crypto3.default.randomUUID()}`;
12864
- db2.prepare(INSERT_HISTORY).run(
12865
- historyId,
12866
- assignmentId,
12867
- studentId,
12868
- oldScore,
12869
- newScore,
12870
- oldStatus,
12871
- newStatus,
12872
- oldSource,
12873
- input.source,
12874
- actorId,
12875
- actorRole,
12876
- input.reason ?? null,
12877
- now
12878
- );
12879
- }
12880
- result = {
12881
- ok: true,
12882
- assignmentId,
12883
- studentId,
12884
- historyId,
12885
- changed,
12886
- oldScore,
12887
- newScore
12888
- };
12889
- };
12890
- const tx = typeof db2.transaction === "function" ? db2.transaction(write) : null;
12891
- if (typeof tx === "function") tx();
12892
- else write();
12893
- return result;
12894
- }
12895
-
12896
- // packages/core/db/assignment-resubmit.ts
12897
- var import_node_crypto4 = __toESM(require("node:crypto"), 1);
12898
- var RESUBMIT_CONTENT_REPLACED_REASON = "resubmit-content-replaced";
12899
- function keepStatus(current) {
12900
- return current === "graded" || current === "absent" ? current : "submitted";
12901
- }
12902
- function writeContentReplacementHistory(db2, input) {
12903
- const historyId = `agh-${import_node_crypto4.default.randomUUID()}`;
12904
- try {
12905
- db2.prepare(
12906
- `INSERT INTO assignment_grade_history
12907
- (id, assignment_id, student_id, old_score, new_score, old_status, new_status,
12908
- old_source, new_source, actor_id, actor_role, reason, created_at, old_content)
12909
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
12910
- ).run(
12911
- historyId,
12912
- input.assignmentId,
12913
- input.studentId,
12914
- input.score,
12915
- input.score,
12916
- input.status,
12917
- input.status,
12918
- input.source,
12919
- input.source,
12920
- input.actorId ?? null,
12921
- input.actorRole ?? null,
12922
- RESUBMIT_CONTENT_REPLACED_REASON,
12923
- input.now,
12924
- input.oldContent
12925
- );
12926
- return historyId;
12927
- } catch (err) {
12928
- console.warn("[assignment.resubmit] \u5185\u5BB9\u66FF\u6362\u7559\u75D5\u5931\u8D25:", err.message);
12929
- return void 0;
12930
- }
12931
- }
12932
- function applyResubmission(db2, input) {
12933
- const assignmentId = String(input.assignmentId ?? "").trim();
12934
- const studentId = String(input.studentId ?? "").trim();
12935
- if (!assignmentId || !studentId) {
12936
- return { ok: false, contentReplaced: false };
12937
- }
12938
- const now = input.now ?? Date.now();
12939
- const content = input.content ?? "";
12940
- let result = { ok: false, contentReplaced: false };
12941
- const write = () => {
12942
- const existing = db2.prepare(
12943
- `SELECT content, score, feedback, status, source, academic_year
12944
- FROM assignment_submissions
12945
- WHERE assignment_id = ? AND student_id = ?
12946
- LIMIT 1`
12947
- ).get(assignmentId, studentId);
12948
- if (!existing) {
12949
- db2.prepare(
12950
- `INSERT INTO assignment_submissions
12951
- (assignment_id, student_id, content, score, feedback, submitted_at, status)
12952
- VALUES (?, ?, ?, NULL, NULL, ?, 'submitted')`
12953
- ).run(assignmentId, studentId, content, now);
12954
- result = { ok: true, contentReplaced: false, status: "submitted" };
12955
- return;
12956
- }
12957
- const nextStatus = keepStatus(existing.status);
12958
- const contentChanged = (existing.content ?? "") !== content;
12959
- const gradedRow = existing.status === "graded" || existing.status === "absent";
12960
- const contentReplaced = gradedRow && contentChanged;
12961
- let historyId;
12962
- if (contentReplaced) {
12963
- historyId = writeContentReplacementHistory(db2, {
12964
- assignmentId,
12965
- studentId,
12966
- oldContent: existing.content,
12967
- score: existing.score,
12968
- status: existing.status,
12969
- source: existing.source,
12970
- actorId: input.actorId ?? null,
12971
- actorRole: input.actorRole ?? null,
12972
- now
12973
- });
12974
- }
12975
- db2.prepare(
12976
- `UPDATE assignment_submissions
12977
- SET content = ?, submitted_at = ?, status = ?
12978
- WHERE assignment_id = ? AND student_id = ?`
12979
- ).run(content, now, nextStatus, assignmentId, studentId);
12980
- result = { ok: true, contentReplaced, historyId, status: nextStatus };
12981
- };
12982
- const tx = typeof db2.transaction === "function" ? db2.transaction(write) : null;
12983
- if (typeof tx === "function") tx();
12984
- else write();
12985
- return result;
12986
- }
12987
-
12988
- // packages/plugins/management.ts
12989
12960
  function parseUserActor(actorId) {
12990
12961
  const match = /^user:([^:]+):(administrator|admin|teacher|student)$/.exec(String(actorId || ""));
12991
12962
  if (!match) return null;
@@ -13518,137 +13489,6 @@ ${sampleRow}`;
13518
13489
  return { success: true, studentId: payload.studentId, category: payload.category, private_notes: serialized };
13519
13490
  }
13520
13491
  });
13521
- const assignmentCreateCmd = "assignment.create";
13522
- await actionRegistry.register({
13523
- id: "core-assignment-create",
13524
- commandType: assignmentCreateCmd,
13525
- description: "\u4E3A\u6307\u5B9A\u73ED\u7EA7\u521B\u5EFA\u8BFE\u540E\u4F5C\u4E1A",
13526
- capabilityRequired: "management:write",
13527
- inputSchema: {
13528
- type: "OBJECT",
13529
- properties: {
13530
- classId: { type: "STRING", description: "\u76EE\u6807\u73ED\u7EA7 ID" },
13531
- title: { type: "STRING", description: "\u4F5C\u4E1A\u6807\u9898" },
13532
- description: { type: "STRING", description: "\u4F5C\u4E1A\u7684\u7B80\u77ED\u6458\u8981\u6216\u6982\u8FF0" },
13533
- content: { type: "STRING", description: "\u8BE6\u7EC6\u7684\u4F5C\u4E1A\u8BF4\u660E\u6216\u8981\u6C42" }
13534
- },
13535
- required: ["classId", "title"]
13536
- }
13537
- });
13538
- await commandBus.registerHandler(assignmentCreateCmd, {
13539
- async execute(command) {
13540
- const payload = command.payload;
13541
- const assignmentId = (0, import_uuid4.v7)();
13542
- db2.prepare(
13543
- "INSERT INTO assignments (id, class_id, title, description, content, created_at) VALUES (?, ?, ?, ?, ?, ?)"
13544
- ).run(
13545
- assignmentId,
13546
- payload.classId,
13547
- payload.title,
13548
- payload.description || "",
13549
- payload.content || "",
13550
- Date.now()
13551
- );
13552
- return { assignmentId, classId: payload.classId, title: payload.title };
13553
- }
13554
- });
13555
- const assignmentSubmitCmd = "assignment.submit";
13556
- await actionRegistry.register({
13557
- id: "core-assignment-submit",
13558
- commandType: assignmentSubmitCmd,
13559
- description: "\u4EE3\u8868\u5B66\u751F\u63D0\u4EA4\u4F5C\u4E1A\u5185\u5BB9\uFF08\u6587\u672C\u3001Markdown \u6216\u4EE3\u7801\uFF09",
13560
- capabilityRequired: "management:write",
13561
- inputSchema: {
13562
- type: "OBJECT",
13563
- properties: {
13564
- assignmentId: { type: "STRING", description: "\u4F5C\u4E1A ID" },
13565
- studentId: { type: "STRING", description: "\u5B66\u751F ID" },
13566
- content: { type: "STRING", description: "\u63D0\u4EA4\u5185\u5BB9\uFF08\u6587\u672C\u3001Markdown \u6216\u4EE3\u7801\uFF09" }
13567
- },
13568
- required: ["assignmentId", "studentId", "content"]
13569
- }
13570
- });
13571
- await commandBus.registerHandler(assignmentSubmitCmd, {
13572
- async execute(command) {
13573
- const payload = command.payload;
13574
- const result = applyResubmission(db2, {
13575
- assignmentId: payload.assignmentId,
13576
- studentId: payload.studentId,
13577
- content: payload.content,
13578
- actorId: `user:${payload.studentId}:student`,
13579
- actorRole: "student"
13580
- });
13581
- return {
13582
- success: true,
13583
- assignmentId: payload.assignmentId,
13584
- studentId: payload.studentId,
13585
- /** 本次重交是否替换了已批改行的作答内容(内容替换已记入审计历史)。 */
13586
- contentReplaced: result.contentReplaced
13587
- };
13588
- }
13589
- });
13590
- const assignmentGradeCmd = "assignment.grade";
13591
- await actionRegistry.register({
13592
- id: "core-assignment-grade",
13593
- commandType: assignmentGradeCmd,
13594
- description: "\u4E3A\u5B66\u751F\u4F5C\u4E1A\u6253\u5206\u5E76\u7ED9\u51FA\u53CD\u9988\u610F\u89C1",
13595
- capabilityRequired: "management:write",
13596
- inputSchema: {
13597
- type: "OBJECT",
13598
- properties: {
13599
- assignmentId: { type: "STRING", description: "ID of the assignment" },
13600
- studentId: { type: "STRING", description: "ID of the student" },
13601
- score: { type: "INTEGER", description: "\u6EE1\u5206 100 \u7684\u5F97\u5206" },
13602
- feedback: { type: "STRING", description: "\u6709\u5EFA\u8BBE\u6027\u7684\u53CD\u9988\u8BC4\u8BED" }
13603
- },
13604
- required: ["assignmentId", "studentId", "score"]
13605
- }
13606
- });
13607
- await commandBus.registerHandler(assignmentGradeCmd, {
13608
- async execute(command) {
13609
- const payload = command.payload;
13610
- const actorId = command.actorId || null;
13611
- const result = applyGradeChange(db2, {
13612
- assignmentId: payload.assignmentId,
13613
- studentId: payload.studentId,
13614
- score: payload.score,
13615
- feedback: payload.feedback || "",
13616
- status: "graded",
13617
- source: "manual",
13618
- actorId,
13619
- reason: "management-grade",
13620
- createIfMissing: true,
13621
- overrideProtected: isPrivilegedGradeActor(actorId)
13622
- });
13623
- if (!result.ok) {
13624
- const err = new Error(
13625
- result.reason === "invalid-score" ? "\u6210\u7EE9\u5FC5\u987B\u662F 0~100 \u7684\u6570\u503C" : result.reason === "assignment-not-found" ? "\u4F5C\u4E1A\u4E0D\u5B58\u5728\uFF0C\u65E0\u6CD5\u5F55\u5165\u6210\u7EE9" : "\u6210\u7EE9\u5199\u5165\u88AB\u62D2\u7EDD"
13626
- );
13627
- err.reason = result.reason;
13628
- throw err;
13629
- }
13630
- await eventBus.publish({
13631
- id: (0, import_uuid4.v7)(),
13632
- type: "assignment.graded",
13633
- source: "management.student",
13634
- payload: {
13635
- assignmentId: payload.assignmentId,
13636
- studentId: payload.studentId,
13637
- score: result.newScore,
13638
- feedback: payload.feedback || ""
13639
- },
13640
- timestamp: Date.now(),
13641
- correlationId: command.id
13642
- });
13643
- return {
13644
- success: true,
13645
- assignmentId: payload.assignmentId,
13646
- studentId: payload.studentId,
13647
- score: result.newScore,
13648
- historyId: result.historyId
13649
- };
13650
- }
13651
- });
13652
13492
  const scheduleCreateCmd = "schedule.create";
13653
13493
  await actionRegistry.register({
13654
13494
  id: "core-schedule-create",
@@ -14480,10 +14320,10 @@ function parseScoreFields(fields) {
14480
14320
  if (typeof fields !== "string" || !fields.trim()) return [];
14481
14321
  return fields.split(/[\n\r,,;;|]+/).map((f) => f.trim()).filter(Boolean);
14482
14322
  }
14483
- function getNested(raw, path47) {
14323
+ function getNested(raw, path48) {
14484
14324
  if (raw === null || raw === void 0) return void 0;
14485
- if (!path47) return void 0;
14486
- const parts = String(path47).split(".").map((p) => p.trim()).filter(Boolean);
14325
+ if (!path48) return void 0;
14326
+ const parts = String(path48).split(".").map((p) => p.trim()).filter(Boolean);
14487
14327
  let cursor = raw;
14488
14328
  for (let i = 0; i < parts.length; i += 1) {
14489
14329
  if (cursor === null || cursor === void 0) return void 0;
@@ -15284,6 +15124,45 @@ var BuiltinPlugin = {
15284
15124
  "page_meta"
15285
15125
  // 分页与大纲元数据
15286
15126
  ];
15127
+ const normalizePluginElementData = (dataObj) => {
15128
+ const pluginId = typeof dataObj.pluginId === "string" ? dataObj.pluginId.trim() : "";
15129
+ if (!pluginId) {
15130
+ console.warn("[Whiteboard] plugin \u5143\u7D20\u672A\u58F0\u660E pluginId\uFF0C\u5C06\u53EA\u80FD\u6309 widgetId \u67E5\u627E\u7EC4\u4EF6\uFF1B\u5EFA\u8BAE\u8865\u4E0A manifest id\u3002");
15131
+ }
15132
+ const teacher = typeof dataObj.teacherWidgetId === "string" ? dataObj.teacherWidgetId.trim() : "";
15133
+ const student = typeof dataObj.studentWidgetId === "string" ? dataObj.studentWidgetId.trim() : "";
15134
+ if (!teacher && !student) {
15135
+ throw new Error(
15136
+ "plugin \u5143\u7D20\u5FC5\u987B\u81F3\u5C11\u58F0\u660E teacherWidgetId \u6216 studentWidgetId \u4E4B\u4E00\uFF0C\u5426\u5219\u6559\u5E08\u7AEF / \u5B66\u751F\u7AEF\u90FD\u4F1A\u505C\u5728\u52A0\u8F7D\u6001\u3002"
15137
+ );
15138
+ }
15139
+ if (!teacher) {
15140
+ dataObj.teacherWidgetId = student;
15141
+ console.warn(`[Whiteboard] plugin \u5143\u7D20\u53EA\u58F0\u660E\u4E86 studentWidgetId\uFF0C\u5DF2\u56DE\u9000 teacherWidgetId="${student}"\u3002`);
15142
+ } else if (!student) {
15143
+ dataObj.studentWidgetId = teacher;
15144
+ console.warn(`[Whiteboard] plugin \u5143\u7D20\u53EA\u58F0\u660E\u4E86 teacherWidgetId\uFF0C\u5DF2\u56DE\u9000 studentWidgetId="${teacher}"\u3002`);
15145
+ }
15146
+ };
15147
+ const buildElementData = (type, rawData, segmentId, page) => {
15148
+ let dataObj = null;
15149
+ try {
15150
+ const parsed = JSON.parse(rawData);
15151
+ dataObj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
15152
+ } catch {
15153
+ if (type === "plugin") {
15154
+ throw new Error(
15155
+ 'plugin \u5143\u7D20\u7684 data \u5FC5\u987B\u662F\u5408\u6CD5 JSON \u5B57\u7B26\u4E32\uFF08\u4F8B\u5982 {"pluginId":"ext-x","teacherWidgetId":"w"}\uFF09\u3002'
15156
+ );
15157
+ }
15158
+ }
15159
+ if (!dataObj) return rawData;
15160
+ if (segmentId && !dataObj.segmentId) dataObj.segmentId = segmentId;
15161
+ if (page !== void 0 && dataObj.page === void 0) dataObj.page = page;
15162
+ else if (dataObj.page === void 0) dataObj.page = 0;
15163
+ if (type === "plugin") normalizePluginElementData(dataObj);
15164
+ return JSON.stringify(dataObj);
15165
+ };
15287
15166
  const isAllowedElementType = (type) => {
15288
15167
  if (!type || typeof type !== "string") return false;
15289
15168
  if (KNOWN_ELEMENT_TYPES2.includes(type)) return true;
@@ -15332,20 +15211,7 @@ var BuiltinPlugin = {
15332
15211
  throw new Error(`\u4E0D\u652F\u6301\u7684\u5143\u7D20\u7C7B\u578B "${payload.type}"\u3002${hint}`);
15333
15212
  }
15334
15213
  const elementId = (0, import_uuid7.v7)();
15335
- let dataStr = payload.data;
15336
- try {
15337
- const dataObj = JSON.parse(dataStr);
15338
- if (payload.segmentId && !dataObj.segmentId) {
15339
- dataObj.segmentId = payload.segmentId;
15340
- }
15341
- if (payload.page !== void 0 && dataObj.page === void 0) {
15342
- dataObj.page = payload.page;
15343
- } else if (dataObj.page === void 0) {
15344
- dataObj.page = 0;
15345
- }
15346
- dataStr = JSON.stringify(dataObj);
15347
- } catch {
15348
- }
15214
+ const dataStr = buildElementData(payload.type, payload.data, payload.segmentId, payload.page);
15349
15215
  const stmt = db2.prepare(
15350
15216
  "INSERT INTO whiteboard_elements (id, lesson_id, type, data, created_at) VALUES (?, ?, ?, ?, ?)"
15351
15217
  );
@@ -15574,15 +15440,7 @@ var BuiltinPlugin = {
15574
15440
  throw new Error(`\u4E0D\u652F\u6301\u7684\u5143\u7D20\u7C7B\u578B "${el.type}"\u3002\u5DF2\u77E5\u7C7B\u578B\uFF1A${KNOWN_ELEMENT_TYPES2.join(", ")}`);
15575
15441
  }
15576
15442
  const elementId = (0, import_uuid7.v7)();
15577
- let dataStr = el.data;
15578
- try {
15579
- const dataObj = JSON.parse(dataStr);
15580
- if (el.segmentId && !dataObj.segmentId) dataObj.segmentId = el.segmentId;
15581
- if (el.page !== void 0 && dataObj.page === void 0) dataObj.page = el.page;
15582
- else if (dataObj.page === void 0) dataObj.page = 0;
15583
- dataStr = JSON.stringify(dataObj);
15584
- } catch {
15585
- }
15443
+ const dataStr = buildElementData(el.type, el.data, el.segmentId, el.page);
15586
15444
  insertStmt.run(elementId, payload.lessonId, el.type, dataStr, Date.now());
15587
15445
  elementIds.push(elementId);
15588
15446
  }
@@ -16683,6 +16541,155 @@ var BuiltinPlugin = {
16683
16541
  // packages/plugins/ai-planner.ts
16684
16542
  var import_uuid8 = require("uuid");
16685
16543
  var import_plugin_sdk5 = require("@openlearn/plugin-sdk");
16544
+
16545
+ // packages/core/db/assignment-grade-writer.ts
16546
+ var import_node_crypto3 = __toESM(require("node:crypto"), 1);
16547
+ var AUTOMATED_SOURCES = /* @__PURE__ */ new Set(["auto", "ai", "system"]);
16548
+ function gradeActorRole(actorId) {
16549
+ const match = /^user:[^:]+:(administrator|admin|teacher|student)$/.exec(String(actorId ?? ""));
16550
+ return match ? match[1] : null;
16551
+ }
16552
+ function isPrivilegedGradeActor(actorId) {
16553
+ const role = gradeActorRole(actorId);
16554
+ return role === "teacher" || role === "administrator" || role === "admin";
16555
+ }
16556
+ function parseScore(raw) {
16557
+ if (raw === null || raw === void 0) return { ok: true, value: null };
16558
+ if (typeof raw === "string" && raw.trim() === "") return { ok: false };
16559
+ const num = typeof raw === "number" ? raw : Number(raw);
16560
+ if (!Number.isFinite(num)) return { ok: false };
16561
+ const rounded = Math.round(num);
16562
+ if (rounded < 0 || rounded > 100) return { ok: false };
16563
+ return { ok: true, value: rounded };
16564
+ }
16565
+ var UPSERT_SUBMISSION = `
16566
+ INSERT INTO assignment_submissions
16567
+ (assignment_id, student_id, content, score, feedback, submitted_at, graded_at, status, source, academic_year, graded_by)
16568
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
16569
+ ON CONFLICT(assignment_id, student_id) DO UPDATE SET
16570
+ content = COALESCE(excluded.content, assignment_submissions.content),
16571
+ score = excluded.score,
16572
+ feedback = COALESCE(excluded.feedback, assignment_submissions.feedback),
16573
+ submitted_at = COALESCE(assignment_submissions.submitted_at, excluded.submitted_at),
16574
+ graded_at = COALESCE(excluded.graded_at, assignment_submissions.graded_at),
16575
+ status = excluded.status,
16576
+ source = excluded.source,
16577
+ academic_year = COALESCE(excluded.academic_year, assignment_submissions.academic_year),
16578
+ graded_by = COALESCE(excluded.graded_by, assignment_submissions.graded_by)
16579
+ `;
16580
+ var INSERT_HISTORY = `
16581
+ INSERT INTO assignment_grade_history
16582
+ (id, assignment_id, student_id, old_score, new_score, old_status, new_status,
16583
+ old_source, new_source, actor_id, actor_role, reason, created_at)
16584
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
16585
+ `;
16586
+ function applyGradeChange(db2, input) {
16587
+ const assignmentId = String(input.assignmentId ?? "").trim();
16588
+ const studentId = String(input.studentId ?? "").trim();
16589
+ if (!assignmentId || !studentId) {
16590
+ return { ok: false, reason: "invalid-target", assignmentId, studentId };
16591
+ }
16592
+ const parsed = parseScore(input.score);
16593
+ if (!parsed.ok) {
16594
+ return { ok: false, reason: "invalid-score", assignmentId, studentId };
16595
+ }
16596
+ const newScore = parsed.value;
16597
+ const newStatus = input.status ?? (newScore === null ? "absent" : "graded");
16598
+ if (newScore === null && newStatus !== "absent") {
16599
+ return { ok: false, reason: "invalid-score", assignmentId, studentId };
16600
+ }
16601
+ const now = input.gradedAt ?? Date.now();
16602
+ const submittedAt = input.submittedAt ?? now;
16603
+ const createIfMissing = input.createIfMissing !== false;
16604
+ const automatedIncoming = AUTOMATED_SOURCES.has(input.source);
16605
+ const protectedIncoming = input.source !== "manual";
16606
+ const actorId = input.actorId ?? (automatedIncoming ? "system" : null);
16607
+ const actorRole = input.actorRole ?? gradeActorRole(actorId) ?? (actorId === "system" ? "system" : null);
16608
+ let result = { ok: false, reason: "invalid-target", assignmentId, studentId };
16609
+ const write = () => {
16610
+ const existing = db2.prepare(
16611
+ `SELECT score, feedback, status, source, academic_year
16612
+ FROM assignment_submissions
16613
+ WHERE assignment_id = ? AND student_id = ?
16614
+ LIMIT 1`
16615
+ ).get(assignmentId, studentId);
16616
+ if (!existing && !createIfMissing) {
16617
+ result = { ok: false, reason: "submission-not-found", assignmentId, studentId };
16618
+ return;
16619
+ }
16620
+ if (existing && !input.overrideProtected && protectedIncoming) {
16621
+ if (existing.status === "absent" && newStatus !== "absent") {
16622
+ result = { ok: false, reason: "absent-protected", assignmentId, studentId, oldScore: existing.score };
16623
+ return;
16624
+ }
16625
+ const existingHasGrade = existing.status === "graded" || existing.status === "absent" || existing.score !== null;
16626
+ const existingProtected = existing.source === null || existing.source === "manual" || existing.source === "plugin";
16627
+ if (existingHasGrade && existingProtected && existing.source !== input.source) {
16628
+ result = { ok: false, reason: "manual-protected", assignmentId, studentId, oldScore: existing.score };
16629
+ return;
16630
+ }
16631
+ }
16632
+ const assignment = db2.prepare("SELECT academic_year FROM assignments WHERE id = ?").get(assignmentId);
16633
+ if (!existing && !assignment) {
16634
+ result = { ok: false, reason: "assignment-not-found", assignmentId, studentId };
16635
+ return;
16636
+ }
16637
+ const academicYear = input.academicYear ?? existing?.academic_year ?? assignment?.academic_year ?? null;
16638
+ const gradedBy = actorId ?? null;
16639
+ db2.prepare(UPSERT_SUBMISSION).run(
16640
+ assignmentId,
16641
+ studentId,
16642
+ input.content ?? null,
16643
+ newScore,
16644
+ input.feedback ?? null,
16645
+ submittedAt,
16646
+ now,
16647
+ newStatus,
16648
+ input.source,
16649
+ academicYear,
16650
+ gradedBy
16651
+ );
16652
+ const oldScore = existing?.score ?? null;
16653
+ const oldStatus = existing?.status ?? null;
16654
+ const oldSource = existing?.source ?? null;
16655
+ const scoreChanged = newScore === null ? oldScore !== null : oldScore === null || Number(oldScore) !== newScore;
16656
+ const changed = !existing || scoreChanged || oldStatus !== newStatus || oldSource !== input.source;
16657
+ let historyId;
16658
+ if (changed) {
16659
+ historyId = `agh-${import_node_crypto3.default.randomUUID()}`;
16660
+ db2.prepare(INSERT_HISTORY).run(
16661
+ historyId,
16662
+ assignmentId,
16663
+ studentId,
16664
+ oldScore,
16665
+ newScore,
16666
+ oldStatus,
16667
+ newStatus,
16668
+ oldSource,
16669
+ input.source,
16670
+ actorId,
16671
+ actorRole,
16672
+ input.reason ?? null,
16673
+ now
16674
+ );
16675
+ }
16676
+ result = {
16677
+ ok: true,
16678
+ assignmentId,
16679
+ studentId,
16680
+ historyId,
16681
+ changed,
16682
+ oldScore,
16683
+ newScore
16684
+ };
16685
+ };
16686
+ const tx = typeof db2.transaction === "function" ? db2.transaction(write) : null;
16687
+ if (typeof tx === "function") tx();
16688
+ else write();
16689
+ return result;
16690
+ }
16691
+
16692
+ // packages/plugins/ai-planner.ts
16686
16693
  var AiPlannerPlugin = {
16687
16694
  manifest: {
16688
16695
  id: "@openlearn/plugin-ai-planner",
@@ -17319,11 +17326,6 @@ var AssignmentEvalPlugin = {
17319
17326
  for (const id of OWNED_ACTION_IDS) {
17320
17327
  actionRegistry.unregister(id);
17321
17328
  }
17322
- commandBus.unregisterHandler("assignment.submit");
17323
- commandBus.unregisterHandler("assignment.grade");
17324
- actionRegistry.unregister("core-assignment-create");
17325
- actionRegistry.unregister("core-assignment-submit");
17326
- actionRegistry.unregister("core-assignment-grade");
17327
17329
  const loadAssignment = (assignmentId) => db2.prepare("SELECT * FROM plugin_assignments WHERE id = ?").get(assignmentId);
17328
17330
  const resolveAssignment = (assignmentId, lessonId) => {
17329
17331
  if (assignmentId) {
@@ -18262,6 +18264,40 @@ var SemesterGradeService = class {
18262
18264
  }
18263
18265
  };
18264
18266
 
18267
+ // packages/core/di/roster-service.ts
18268
+ var ClassRosterService = class {
18269
+ constructor(db2) {
18270
+ this.db = db2;
18271
+ }
18272
+ async listClasses() {
18273
+ const rows = this.db.prepare(
18274
+ `SELECT c.id AS id, c.name AS name, COUNT(cs.student_id) AS student_count
18275
+ FROM classes c
18276
+ LEFT JOIN class_students cs ON cs.class_id = c.id
18277
+ GROUP BY c.id, c.name
18278
+ ORDER BY c.created_at DESC, c.name ASC`
18279
+ ).all();
18280
+ return rows.map((row) => ({ id: row.id, name: row.name, studentCount: Number(row.student_count) }));
18281
+ }
18282
+ async listClassStudents(classId) {
18283
+ const rows = this.db.prepare(
18284
+ `SELECT s.id AS id, s.name AS name, s.student_number AS student_number
18285
+ FROM class_students cs
18286
+ JOIN students s ON s.id = cs.student_id
18287
+ WHERE cs.class_id = ?
18288
+ ORDER BY s.name ASC`
18289
+ ).all(classId);
18290
+ return rows.map(toStudentRef);
18291
+ }
18292
+ async getStudent(studentId) {
18293
+ const row = this.db.prepare("SELECT id, name, student_number FROM students WHERE id = ?").get(studentId);
18294
+ return row ? toStudentRef(row) : null;
18295
+ }
18296
+ };
18297
+ function toStudentRef(row) {
18298
+ return { id: row.id, name: row.name, studentNumber: row.student_number ?? null };
18299
+ }
18300
+
18265
18301
  // packages/core/di/points-dimension-registry.ts
18266
18302
  var PointsDimensionRegistry = class {
18267
18303
  constructor() {
@@ -18422,7 +18458,7 @@ var PointsLedgerService = class {
18422
18458
  };
18423
18459
 
18424
18460
  // packages/core/di/auth-session-bridge-service.ts
18425
- var import_node_crypto5 = __toESM(require("node:crypto"), 1);
18461
+ var import_node_crypto4 = __toESM(require("node:crypto"), 1);
18426
18462
  var AuthSessionBridgeService = class {
18427
18463
  constructor(db2) {
18428
18464
  this.db = db2;
@@ -18434,7 +18470,7 @@ var AuthSessionBridgeService = class {
18434
18470
  const now = Date.now();
18435
18471
  const maxAgeSeconds = 7 * 24 * 60 * 60;
18436
18472
  const expiresAt = now + maxAgeSeconds * 1e3;
18437
- const sessionToken = "token_" + import_node_crypto5.default.randomBytes(16).toString("hex");
18473
+ const sessionToken = "token_" + import_node_crypto4.default.randomBytes(16).toString("hex");
18438
18474
  if (user.role === "student") {
18439
18475
  const existingStudent = this.db.prepare("SELECT id FROM students WHERE id = ?").get(user.userId);
18440
18476
  if (!existingStudent) {
@@ -19162,7 +19198,7 @@ init_errors2();
19162
19198
  init_interfaces();
19163
19199
 
19164
19200
  // packages/core/plugin-host/student-data-privacy.ts
19165
- var import_node_crypto6 = __toESM(require("node:crypto"), 1);
19201
+ var import_node_crypto5 = __toESM(require("node:crypto"), 1);
19166
19202
  async function dispatchStudentExport(pluginInstances, studentId, timeoutMs = 1e4) {
19167
19203
  const results = {};
19168
19204
  for (const [pluginId, instance] of pluginInstances.entries()) {
@@ -19170,7 +19206,7 @@ async function dispatchStudentExport(pluginInstances, studentId, timeoutMs = 1e4
19170
19206
  if (instance.workerRef?.transport) {
19171
19207
  try {
19172
19208
  const transport = instance.workerRef.transport;
19173
- const invokeId = import_node_crypto6.default.randomUUID();
19209
+ const invokeId = import_node_crypto5.default.randomUUID();
19174
19210
  const responsePromise = new Promise((resolve2) => {
19175
19211
  const timer = setTimeout(() => {
19176
19212
  cleanup();
@@ -19236,7 +19272,7 @@ async function dispatchStudentDelete(pluginInstances, studentId, timeoutMs = 1e4
19236
19272
  if (instance.workerRef?.transport) {
19237
19273
  try {
19238
19274
  const transport = instance.workerRef.transport;
19239
- const invokeId = import_node_crypto6.default.randomUUID();
19275
+ const invokeId = import_node_crypto5.default.randomUUID();
19240
19276
  const responsePromise = new Promise((resolve2) => {
19241
19277
  const timer = setTimeout(() => {
19242
19278
  cleanup();
@@ -20225,7 +20261,7 @@ var PluginHostHttp = class extends PluginHostCore {
20225
20261
  /**
20226
20262
  * 判断指定插件的某个请求路径是否为流式路由 (SSE)
20227
20263
  */
20228
- isStreamRoute(pluginIdOrManifestId, method, path47) {
20264
+ isStreamRoute(pluginIdOrManifestId, method, path48) {
20229
20265
  const upperMethod = method.toUpperCase();
20230
20266
  const resolvedId = this.resolvePluginUuid(pluginIdOrManifestId);
20231
20267
  const instance = this.pluginInstances.get(resolvedId) ?? this.pluginInstances.get(pluginIdOrManifestId);
@@ -20234,7 +20270,7 @@ var PluginHostHttp = class extends PluginHostCore {
20234
20270
  for (const r of manifest.api.routes) {
20235
20271
  if (r.method.toUpperCase() === upperMethod && r.streaming === true) {
20236
20272
  const compiled = compileRoutePattern(r.path);
20237
- if (compiled.regex.test(path47)) {
20273
+ if (compiled.regex.test(path48)) {
20238
20274
  return true;
20239
20275
  }
20240
20276
  }
@@ -20242,14 +20278,14 @@ var PluginHostHttp = class extends PluginHostCore {
20242
20278
  }
20243
20279
  if (!instance) return false;
20244
20280
  if (instance.context?.http) {
20245
- return instance.context.http.isStream(upperMethod, path47);
20281
+ return instance.context.http.isStream(upperMethod, path48);
20246
20282
  }
20247
20283
  if (instance.workerRef) {
20248
20284
  const routes = instance.workerRef.serviceHost.getRegisteredRoutes();
20249
20285
  for (const r of routes) {
20250
20286
  if (r.method === upperMethod && r.isStream) {
20251
20287
  const compiled = compileRoutePattern(r.pattern);
20252
- if (compiled.regex.test(path47)) {
20288
+ if (compiled.regex.test(path48)) {
20253
20289
  return true;
20254
20290
  }
20255
20291
  }
@@ -20473,6 +20509,7 @@ init_types();
20473
20509
  init_errors2();
20474
20510
  init_interfaces();
20475
20511
  init_plugin_grants();
20512
+ init_catalog();
20476
20513
  var PluginHostLifecycle = class extends PluginHostHttp {
20477
20514
  /**
20478
20515
  * 激活插件。
@@ -20530,12 +20567,47 @@ var PluginHostLifecycle = class extends PluginHostHttp {
20530
20567
  const currentState = this.pluginStates.get(pluginId) ?? "installed" /* INSTALLED */;
20531
20568
  this.validateTransition(pluginId, currentState, "activating" /* ACTIVATING */);
20532
20569
  this.setPluginState(pluginId, "activating" /* ACTIVATING */);
20570
+ const commandTypesBefore = this.listHostCommandTypes();
20533
20571
  const preloaded = this.preloadedPlugins.get(pluginId);
20534
20572
  if (preloaded) {
20535
20573
  await this.activatePreloaded(pluginId, preloaded);
20574
+ this.auditMissingActionDescriptors(pluginId, commandTypesBefore);
20536
20575
  return;
20537
20576
  }
20538
20577
  await this.activateFromDatabase(pluginId, currentState);
20578
+ this.auditMissingActionDescriptors(pluginId, commandTypesBefore);
20579
+ }
20580
+ /** 宿主 CommandBus 当前注册的命令类型;解析失败返回空集(自检不能影响激活)。 */
20581
+ listHostCommandTypes() {
20582
+ try {
20583
+ const bus = this.serviceRegistry.resolve(ICommandBusServiceToken4);
20584
+ return new Set(bus.listRegisteredCommandTypes?.() ?? []);
20585
+ } catch {
20586
+ return /* @__PURE__ */ new Set();
20587
+ }
20588
+ }
20589
+ /**
20590
+ * 插件激活后的命令契约自检(Issue #369)。
20591
+ *
20592
+ * 命令总线是 default-deny:handler 有、ActionDescriptor 没有的命令会在用户点击时
20593
+ * 403 `COMMAND_NOT_REGISTERED`。静态扫描抓不到循环注册 / 对象表 / `regHandler()` 包装,
20594
+ * 所以改为激活后做一次运行时差集,把缺口提前打成立即可见的 WARN。
20595
+ *
20596
+ * 自检只服务可观测性:任何异常都不能影响激活结果,故整体 try/catch 且只打日志。
20597
+ */
20598
+ auditMissingActionDescriptors(pluginId, before) {
20599
+ try {
20600
+ if (this.inflightActivate.size > 1) return;
20601
+ const added = Array.from(this.listHostCommandTypes()).filter((type) => !before.has(type));
20602
+ if (added.length === 0) return;
20603
+ const registry4 = this.serviceRegistry.resolve(IActionRegistryServiceToken4);
20604
+ const missing = registry4.listMissingDescriptors?.(added) ?? [];
20605
+ if (missing.length === 0) return;
20606
+ console.warn(
20607
+ `[PluginHost] \u63D2\u4EF6 "${pluginId}" \u6709 ${missing.length} \u4E2A\u547D\u4EE4 handler \u6CA1\u6709 ActionDescriptor\uFF1A${missing.join(", ")}\u3002\u8FD9\u4E9B\u547D\u4EE4\u4F1A\u88AB default-deny \u4EE5 403 COMMAND_NOT_REGISTERED \u62D2\u7EDD\uFF0C\u8BF7\u4E3A\u6BCF\u4E2A registerHandler \u540C\u65F6\u6CE8\u518C actionRegistry.register(...)\u3002`
20608
+ );
20609
+ } catch {
20610
+ }
20539
20611
  }
20540
20612
  /**
20541
20613
  * preloaded 插件的激活(Phase 8,内置插件路径)。
@@ -20786,6 +20858,12 @@ var PluginHostLifecycle = class extends PluginHostHttp {
20786
20858
  async grantCapabilities(actorId, caps, opts) {
20787
20859
  if (caps.length === 0) return;
20788
20860
  try {
20861
+ const { unknown } = validateManifestCapabilities(caps);
20862
+ if (unknown.length > 0) {
20863
+ console.error(
20864
+ `[PluginHost] "${actorId}" \u7684 manifest \u58F0\u660E\u4E86\u672A\u77E5\u80FD\u529B\uFF1A${unknown.join(", ")}\u3002\u8FD9\u4E9B\u4E32\u4E0D\u4F1A\u751F\u6548\uFF08\u5B88\u536B\u53EA\u8BA4 ActionDescriptor.capabilityRequired\uFF09\u3002\u5408\u6CD5\u6E05\u5355\u89C1 packages/core/capability/catalog.ts \u4E0E docs/reference/plugin-capability-matrix.md\u3002`
20865
+ );
20866
+ }
20789
20867
  const capService = await this.serviceRegistry.resolve(ICapabilityServiceToken);
20790
20868
  for (const cap of caps) {
20791
20869
  if (typeof cap !== "string" || isBlockedPluginCapability(cap)) {
@@ -21126,7 +21204,7 @@ var import_node_fs11 = __toESM(require("node:fs"), 1);
21126
21204
  var import_node_path13 = __toESM(require("node:path"), 1);
21127
21205
  var import_node_url5 = require("node:url");
21128
21206
  var import_meta7 = {};
21129
- var FALLBACK_VERSION = "0.7.8";
21207
+ var FALLBACK_VERSION = "0.7.10";
21130
21208
  function readPlatformVersion() {
21131
21209
  const bases = [];
21132
21210
  if (typeof __dirname !== "undefined") bases.push(__dirname);
@@ -23249,10 +23327,10 @@ var UnifiedExtensionRegistry = class {
23249
23327
  init_install_utils();
23250
23328
 
23251
23329
  // packages/core/security/plugin-signature.ts
23252
- var import_node_crypto9 = __toESM(require("node:crypto"), 1);
23330
+ var import_node_crypto8 = __toESM(require("node:crypto"), 1);
23253
23331
  var import_node_fs13 = __toESM(require("node:fs"), 1);
23254
23332
  function computeSha256(input) {
23255
- const hash = import_node_crypto9.default.createHash("sha256");
23333
+ const hash = import_node_crypto8.default.createHash("sha256");
23256
23334
  if (typeof input === "string") {
23257
23335
  if (import_node_fs13.default.existsSync(input)) {
23258
23336
  const fd = import_node_fs13.default.openSync(input, "r");
@@ -23306,10 +23384,10 @@ function verifySignature(integrity, signature, publicKeys, metadata, options) {
23306
23384
  for (const pem of keys) {
23307
23385
  if (!pem || !pem.trim()) continue;
23308
23386
  try {
23309
- const pubKey = import_node_crypto9.default.createPublicKey(pem.trim());
23387
+ const pubKey = import_node_crypto8.default.createPublicKey(pem.trim());
23310
23388
  if (metadata && metadata.id) {
23311
23389
  const boundBuf = buildAttestationPayload(integrity, metadata);
23312
- if (import_node_crypto9.default.verify(null, boundBuf, pubKey, sig)) {
23390
+ if (import_node_crypto8.default.verify(null, boundBuf, pubKey, sig)) {
23313
23391
  return { ok: true, matchedKey: pem };
23314
23392
  }
23315
23393
  if (!allowV1 || metadata.version !== void 0) {
@@ -23318,7 +23396,7 @@ function verifySignature(integrity, signature, publicKeys, metadata, options) {
23318
23396
  }
23319
23397
  if (allowV1) {
23320
23398
  const rawBuf = Buffer.from(integrity.trim(), "utf8");
23321
- if (import_node_crypto9.default.verify(null, rawBuf, pubKey, sig)) {
23399
+ if (import_node_crypto8.default.verify(null, rawBuf, pubKey, sig)) {
23322
23400
  return { ok: true, matchedKey: pem };
23323
23401
  }
23324
23402
  }
@@ -23639,11 +23717,11 @@ var PluginDistributionManager = class {
23639
23717
  return;
23640
23718
  }
23641
23719
  const filePath = this.pluginHost.getPluginFilePath(this.pluginHost.resolvePluginUuid(pluginId));
23642
- const fs40 = await import("node:fs");
23643
- if (!fs40.existsSync(filePath)) {
23720
+ const fs41 = await import("node:fs");
23721
+ if (!fs41.existsSync(filePath)) {
23644
23722
  throw new Error(`Cannot reload plugin "${pluginId}": source file missing at ${filePath}`);
23645
23723
  }
23646
- const code = fs40.readFileSync(filePath, "utf-8");
23724
+ const code = fs41.readFileSync(filePath, "utf-8");
23647
23725
  await this.pluginHost.reloadPlugin(pluginId, code);
23648
23726
  }
23649
23727
  async updateFromZip(zipPath, options = {}) {
@@ -23832,8 +23910,8 @@ var HotReloadController = class {
23832
23910
  const timer = setTimeout(async () => {
23833
23911
  this.debounceTimers.delete(pluginId);
23834
23912
  try {
23835
- const fs40 = await import("fs/promises");
23836
- const newSourceCode = await fs40.readFile(filePath, "utf-8");
23913
+ const fs41 = await import("fs/promises");
23914
+ const newSourceCode = await fs41.readFile(filePath, "utf-8");
23837
23915
  console.log(`[HotReload] File changed: ${filePath} \u2192 triggering reload for "${pluginId}"`);
23838
23916
  await this.pluginHost.reloadPlugin(pluginId, newSourceCode);
23839
23917
  } catch (err) {
@@ -30835,6 +30913,7 @@ var Kernel = class {
30835
30913
  this.serviceRegistry.register(ISemesterGradeServiceToken, new SemesterGradeService(this.db));
30836
30914
  this.serviceRegistry.register(IPointsDimensionRegistryToken, new PointsDimensionRegistry());
30837
30915
  this.serviceRegistry.register(IPointsLedgerServiceToken, new PointsLedgerService(this.db));
30916
+ this.serviceRegistry.register(IClassRosterServiceToken, new ClassRosterService(this.db));
30838
30917
  this.serviceRegistry.register(ILessonEngineServiceToken, { getRuntime: async () => this.lessonRuntime });
30839
30918
  this.serviceRegistry.register(IClassroomRuntimeServiceToken, {
30840
30919
  getRuntimeKernel: async () => this.classroomRuntime
@@ -31307,7 +31386,7 @@ function clearLessonRuntimeState(lessonId) {
31307
31386
  init_plugin_namespace();
31308
31387
 
31309
31388
  // server/classroom-events.ts
31310
- var import_node_crypto10 = require("node:crypto");
31389
+ var import_node_crypto9 = require("node:crypto");
31311
31390
  var CLASSROOM_EVENTS = {
31312
31391
  /** 全班锁屏 / 解锁 */
31313
31392
  CLASSROOM_LOCK_CHANGED: "classroom.lock_changed",
@@ -31327,7 +31406,7 @@ var CLASSROOM_EVENTS = {
31327
31406
  SEGMENT_GATE_OVERRIDE_REVOKED: "segment.gate_override_revoked"
31328
31407
  };
31329
31408
  function newEventId() {
31330
- return `evt_${(0, import_node_crypto10.randomBytes)(8).toString("hex")}`;
31409
+ return `evt_${(0, import_node_crypto9.randomBytes)(8).toString("hex")}`;
31331
31410
  }
31332
31411
  async function publishClassroomEvent(type, payload, options = {}) {
31333
31412
  const event = {
@@ -33028,21 +33107,29 @@ function validateStartupEnv(env = process.env) {
33028
33107
  if (isProduction) {
33029
33108
  const adminPassword = trimmed(env, "OPENLEARN_ADMIN_PASSWORD");
33030
33109
  if (adminPassword && adminPassword.toLowerCase() === "admin") {
33031
- errors.push({
33110
+ warnings.push({
33032
33111
  key: "OPENLEARN_ADMIN_PASSWORD",
33033
- severity: "error",
33034
- message: '\u751F\u4EA7\u73AF\u5883\u7981\u6B62\u628A OPENLEARN_ADMIN_PASSWORD \u8BBE\u4E3A\u7528\u6237\u540D "admin"\u3002\u8BF7\u8BBE\u7F6E\u5F3A\u53E3\u4EE4\u3002'
33112
+ severity: "warning",
33113
+ message: "OPENLEARN_ADMIN_PASSWORD \u4E0E\u7528\u6237\u540D\u76F8\u540C\u3002\u8FD9\u4E0D\u6BD4\u7559\u7A7A\u66F4\u5F31\uFF08\u7559\u7A7A\u65F6\u521D\u59CB\u53E3\u4EE4\u540C\u6837\u662F admin\uFF09\uFF0C\u4F46\u8BF7\u786E\u8BA4\u9996\u6B21\u767B\u5F55\u540E\u7ACB\u5373\u6539\u5BC6\uFF1B\u751F\u4EA7\u90E8\u7F72\u5EFA\u8BAE\u76F4\u63A5\u8BBE\u7F6E\u4E3A\u5F3A\u53E3\u4EE4\u3002"
33035
33114
  });
33036
33115
  }
33037
33116
  const teacherPassword = trimmed(env, "OPENLEARN_TEACHER_PASSWORD");
33038
33117
  if (teacherPassword && teacherPassword.toLowerCase() === "teacher") {
33039
- errors.push({
33118
+ warnings.push({
33040
33119
  key: "OPENLEARN_TEACHER_PASSWORD",
33041
- severity: "error",
33042
- message: '\u751F\u4EA7\u73AF\u5883\u7981\u6B62\u628A OPENLEARN_TEACHER_PASSWORD \u8BBE\u4E3A\u7528\u6237\u540D "teacher"\u3002\u8BF7\u8BBE\u7F6E\u5F3A\u53E3\u4EE4\u3002'
33120
+ severity: "warning",
33121
+ message: "OPENLEARN_TEACHER_PASSWORD \u4E0E\u7528\u6237\u540D\u76F8\u540C\u3002\u8BF7\u786E\u8BA4\u9996\u6B21\u767B\u5F55\u540E\u7ACB\u5373\u6539\u5BC6\uFF0C\u751F\u4EA7\u90E8\u7F72\u5EFA\u8BAE\u8BBE\u7F6E\u4E3A\u5F3A\u53E3\u4EE4\u3002"
33043
33122
  });
33044
33123
  }
33045
33124
  }
33125
+ const seedMode = trimmed(env, "OPENLEARN_SEED_PASSWORD_MODE");
33126
+ if (seedMode && !["default", "random"].includes(seedMode.toLowerCase())) {
33127
+ errors.push({
33128
+ key: "OPENLEARN_SEED_PASSWORD_MODE",
33129
+ severity: "error",
33130
+ message: `\u53EA\u652F\u6301 "default"\uFF08\u53E3\u4EE4 = \u7528\u6237\u540D\uFF0C\u9996\u6B21\u767B\u5F55\u5F3A\u5236\u6539\u5BC6\uFF09\u6216 "random"\uFF08\u968F\u673A\u53E3\u4EE4\u5199\u5165 bootstrap-credentials.txt\uFF09\uFF0C\u6536\u5230 "${seedMode}"\u3002`
33131
+ });
33132
+ }
33046
33133
  const metricsToken = trimmed(env, "METRICS_TOKEN");
33047
33134
  if (metricsToken && metricsToken.length < 16) {
33048
33135
  warnings.push({
@@ -34163,9 +34250,9 @@ function isWhitelisted(route, whitelist) {
34163
34250
  (entry) => entry.method.toUpperCase() === route.method && normalizePath2(entry.path) === normalizePath2(route.path)
34164
34251
  );
34165
34252
  }
34166
- function normalizePath2(path47) {
34167
- if (!path47) return "/";
34168
- return path47.endsWith("/") && path47.length > 1 ? path47.slice(0, -1) : path47;
34253
+ function normalizePath2(path48) {
34254
+ if (!path48) return "/";
34255
+ return path48.endsWith("/") && path48.length > 1 ? path48.slice(0, -1) : path48;
34169
34256
  }
34170
34257
  function auditRouteScopes(app, whitelist = []) {
34171
34258
  const declared = [];
@@ -34343,11 +34430,11 @@ function enforcePasswordChanged(req, res, next) {
34343
34430
  if (isPasswordChangeGateSkipped()) {
34344
34431
  return next();
34345
34432
  }
34346
- const path47 = req.path || "";
34347
- if (!path47.startsWith("/api/")) {
34433
+ const path48 = req.path || "";
34434
+ if (!path48.startsWith("/api/")) {
34348
34435
  return next();
34349
34436
  }
34350
- if (PASSWORD_CHANGE_EXEMPT_PATHS.has(path47)) {
34437
+ if (PASSWORD_CHANGE_EXEMPT_PATHS.has(path48)) {
34351
34438
  return next();
34352
34439
  }
34353
34440
  const token = getCookieToken(req);
@@ -34422,8 +34509,8 @@ function csrfGuard(req, res, next) {
34422
34509
  next();
34423
34510
  return;
34424
34511
  }
34425
- const path48 = req.path;
34426
- if (EXEMPT_PREFIXES.some((p) => path48 === p || path48.startsWith(p))) {
34512
+ const path49 = req.path;
34513
+ if (EXEMPT_PREFIXES.some((p) => path49 === p || path49.startsWith(p))) {
34427
34514
  next();
34428
34515
  return;
34429
34516
  }
@@ -34433,10 +34520,10 @@ function csrfGuard(req, res, next) {
34433
34520
  const origin = req.headers.origin;
34434
34521
  const referer = req.headers.referer;
34435
34522
  const host = req.headers.host;
34436
- const path47 = req.path;
34523
+ const path48 = req.path;
34437
34524
  if (typeof origin === "string" && origin !== "") {
34438
34525
  if (origin === "null") {
34439
- if (!EXEMPT_PREFIXES.some((p) => path47 === p || path47.startsWith(p))) {
34526
+ if (!EXEMPT_PREFIXES.some((p) => path48 === p || path48.startsWith(p))) {
34440
34527
  res.status(403).json({ success: false, code: "FORBIDDEN_CROSS_SITE", error: "Opaque origin write request blocked" });
34441
34528
  return;
34442
34529
  }
@@ -34444,7 +34531,7 @@ function csrfGuard(req, res, next) {
34444
34531
  try {
34445
34532
  const originHost = new URL(origin).host;
34446
34533
  if (host && originHost.toLowerCase() !== host.toLowerCase()) {
34447
- if (!EXEMPT_PREFIXES.some((p) => path47 === p || path47.startsWith(p))) {
34534
+ if (!EXEMPT_PREFIXES.some((p) => path48 === p || path48.startsWith(p))) {
34448
34535
  res.status(403).json({
34449
34536
  success: false,
34450
34537
  code: "FORBIDDEN_CROSS_SITE",
@@ -34462,7 +34549,7 @@ function csrfGuard(req, res, next) {
34462
34549
  try {
34463
34550
  const refererHost = new URL(referer).host;
34464
34551
  if (host && refererHost.toLowerCase() !== host.toLowerCase()) {
34465
- if (!EXEMPT_PREFIXES.some((p) => path47 === p || path47.startsWith(p))) {
34552
+ if (!EXEMPT_PREFIXES.some((p) => path48 === p || path48.startsWith(p))) {
34466
34553
  res.status(403).json({
34467
34554
  success: false,
34468
34555
  code: "FORBIDDEN_CROSS_SITE",
@@ -34480,7 +34567,7 @@ function csrfGuard(req, res, next) {
34480
34567
  }
34481
34568
 
34482
34569
  // packages/core/observability/error-reporter.ts
34483
- var import_node_crypto11 = require("node:crypto");
34570
+ var import_node_crypto10 = require("node:crypto");
34484
34571
  init_logger();
34485
34572
  init_sanitize();
34486
34573
  var testSink2 = null;
@@ -34515,8 +34602,8 @@ function parseSentryDsn(dsn) {
34515
34602
  const projectId = url.pathname.split("/").filter(Boolean).pop();
34516
34603
  if (!projectId || !/^\d+$/.test(projectId)) return null;
34517
34604
  const prefix = url.pathname.split("/").filter(Boolean).slice(0, -1).join("/");
34518
- const path47 = prefix ? `/${prefix}` : "";
34519
- const envelopeUrl = `${url.protocol}//${url.host}${path47}/api/${projectId}/envelope/`;
34605
+ const path48 = prefix ? `/${prefix}` : "";
34606
+ const envelopeUrl = `${url.protocol}//${url.host}${path48}/api/${projectId}/envelope/`;
34520
34607
  return { envelopeUrl, publicKey };
34521
34608
  } catch {
34522
34609
  return null;
@@ -34526,7 +34613,7 @@ function buildSentryEnvelope(report, dsn) {
34526
34613
  const safe = sanitizeErrorReport(report);
34527
34614
  const parsed = parseSentryDsn(dsn);
34528
34615
  if (!parsed) return null;
34529
- const eventId = (0, import_node_crypto11.randomBytes)(16).toString("hex");
34616
+ const eventId = (0, import_node_crypto10.randomBytes)(16).toString("hex");
34530
34617
  const header = JSON.stringify({ event_id: eventId, sent_at: (/* @__PURE__ */ new Date()).toISOString(), dsn });
34531
34618
  const item = JSON.stringify({ type: "event", content_type: "application/json" });
34532
34619
  const event = {
@@ -34631,7 +34718,7 @@ function sendSafeError(res, err, status = 500, fallbackMessage = "Internal serve
34631
34718
  }
34632
34719
 
34633
34720
  // server/middleware/request-observability.ts
34634
- var import_node_crypto12 = require("node:crypto");
34721
+ var import_node_crypto11 = require("node:crypto");
34635
34722
  init_logger();
34636
34723
  init_request_context();
34637
34724
  init_audit_log();
@@ -34775,7 +34862,7 @@ function resolveHttpAccessLogLevel(status, durationMs, mode = resolveHttpAccessL
34775
34862
  }
34776
34863
  function resolveRequestId(header) {
34777
34864
  if (header && REQUEST_ID_RE.test(header)) return header;
34778
- return (0, import_node_crypto12.randomUUID)();
34865
+ return (0, import_node_crypto11.randomUUID)();
34779
34866
  }
34780
34867
  function resolveRequestUserId(req) {
34781
34868
  const attached = req.session;
@@ -34962,9 +35049,9 @@ var import_node_path18 = __toESM(require("node:path"), 1);
34962
35049
  // packages/core/observability/health.ts
34963
35050
  var import_node_fs16 = __toESM(require("node:fs"), 1);
34964
35051
  var import_node_path17 = __toESM(require("node:path"), 1);
34965
- var import_node_crypto13 = require("node:crypto");
35052
+ var import_node_crypto12 = require("node:crypto");
34966
35053
  function probeStorageWritable(dir) {
34967
- const probe = import_node_path17.default.join(dir, `.ready-${process.pid}-${(0, import_node_crypto13.randomBytes)(4).toString("hex")}`);
35054
+ const probe = import_node_path17.default.join(dir, `.ready-${process.pid}-${(0, import_node_crypto12.randomBytes)(4).toString("hex")}`);
34968
35055
  try {
34969
35056
  import_node_fs16.default.mkdirSync(dir, { recursive: true });
34970
35057
  import_node_fs16.default.writeFileSync(probe, "ok", { flag: "w" });
@@ -35242,7 +35329,7 @@ var import_zod2 = require("zod");
35242
35329
  // server/services/course-package-assets.ts
35243
35330
  var import_node_fs18 = __toESM(require("node:fs"), 1);
35244
35331
  var import_node_path19 = __toESM(require("node:path"), 1);
35245
- var import_node_crypto14 = __toESM(require("node:crypto"), 1);
35332
+ var import_node_crypto13 = __toESM(require("node:crypto"), 1);
35246
35333
  var import_jszip3 = __toESM(require("jszip"), 1);
35247
35334
  var import_jspdf = require("jspdf");
35248
35335
 
@@ -35507,7 +35594,7 @@ async function materializePackageAssets(assets) {
35507
35594
  if (isTextAssetType(asset.contentType) && buffer.includes(0)) {
35508
35595
  throw new Error(`\u8BFE\u7A0B\u5305\u8D44\u4EA7 "${asset.id}" \u58F0\u660E\u4E3A ${asset.contentType} \u4F46\u5185\u5BB9\u662F\u4E8C\u8FDB\u5236`);
35509
35596
  }
35510
- const uniqueName = `${Date.now()}-${import_node_crypto14.default.randomBytes(4).toString("hex")}${ext}`;
35597
+ const uniqueName = `${Date.now()}-${import_node_crypto13.default.randomBytes(4).toString("hex")}${ext}`;
35511
35598
  const filePath = import_node_path19.default.join(uploadsDir, uniqueName);
35512
35599
  import_node_fs18.default.writeFileSync(filePath, buffer);
35513
35600
  writtenPaths.push(filePath);
@@ -35537,7 +35624,7 @@ function resolveAssetPlaceholders(value, assets) {
35537
35624
  return value.replace(ASSET_PLACEHOLDER_PATTERN, (match, assetId) => {
35538
35625
  const asset = assets.get(assetId);
35539
35626
  if (!asset) {
35540
- throw new Error(`\u5143\u7D20\u6570\u636E\u5F15\u7528\u4E86\u4E0D\u5B58\u5728\u7684\u8BFE\u7A0B\u5305\u8D44\u4EA7\uFF1A${assetId}`);
35627
+ throw new Error(`\u8BFE\u7A0B\u5185\u5BB9\u5F15\u7528\u4E86\u4E0D\u5B58\u5728\u7684\u8BFE\u7A0B\u5305\u8D44\u4EA7\uFF1A${assetId}`);
35541
35628
  }
35542
35629
  return asset.fileUrl;
35543
35630
  });
@@ -35659,6 +35746,28 @@ function parseCoursePackage(input) {
35659
35746
  }
35660
35747
  return { ok: true, package: parsed.data };
35661
35748
  }
35749
+ var KNOWN_ELEMENT_TYPES = /* @__PURE__ */ new Set([
35750
+ "pen",
35751
+ "highlighter",
35752
+ "rectangle",
35753
+ "circle",
35754
+ "text",
35755
+ "quiz",
35756
+ "rollcall",
35757
+ "timer",
35758
+ "assignment",
35759
+ "hello-world",
35760
+ "html-applet",
35761
+ "code-sandbox",
35762
+ "math-graph",
35763
+ "presentation",
35764
+ "plugin",
35765
+ "page_meta"
35766
+ ]);
35767
+ function isSupportedElementType(type) {
35768
+ if (KNOWN_ELEMENT_TYPES.has(type)) return true;
35769
+ return typeof type === "string" && (type.includes("/") || type.startsWith("plugin:") || type.startsWith("ext-") || type.startsWith("plugin-"));
35770
+ }
35662
35771
  var SEG = {
35663
35772
  intro: "seg-intro",
35664
35773
  lecture: "seg-lecture",
@@ -38966,6 +39075,71 @@ function registerResourcesRoutes(ctx) {
38966
39075
  sendSafeError(res, e);
38967
39076
  }
38968
39077
  });
39078
+ const actionRegistry = kernelContainer.actionRegistry;
39079
+ const resourceIdProp = { id: { type: "STRING", description: "\u8D44\u6E90 ID\uFF08res_ \u524D\u7F00\uFF09" } };
39080
+ try {
39081
+ actionRegistry.register({
39082
+ id: "core-resource-list",
39083
+ commandType: "resource.list",
39084
+ description: "\u5217\u51FA\u5168\u90E8\u7CFB\u7EDF\u516C\u5171\u6559\u5B66\u8D44\u6E90\uFF08\u6309\u521B\u5EFA\u65F6\u95F4\u5012\u5E8F\uFF0C\u4E0D\u542B\u6B63\u6587\uFF09",
39085
+ capabilityRequired: "management:read",
39086
+ // 无参命令沿用仓库既有写法(同 courseware.list / courseware.list_score_configs):
39087
+ // schema 声明为 OBJECT,payload 必须是对象 —— 调用方传 `{}`,不要省略 payload,
39088
+ // 否则 validateJsonSchema 会以 "Expected object, got undefined" 拒绝。
39089
+ inputSchema: { type: "OBJECT", properties: {} }
39090
+ });
39091
+ } catch {
39092
+ }
39093
+ try {
39094
+ actionRegistry.register({
39095
+ id: "core-resource-get",
39096
+ commandType: "resource.get",
39097
+ description: "\u6309 ID \u8BFB\u53D6\u5355\u4E2A\u6559\u5B66\u8D44\u6E90\u7684\u5B8C\u6574\u8BB0\u5F55\uFF08\u542B content \u6B63\u6587\uFF09",
39098
+ capabilityRequired: "management:read",
39099
+ inputSchema: {
39100
+ type: "OBJECT",
39101
+ properties: { ...resourceIdProp },
39102
+ required: ["id"]
39103
+ }
39104
+ });
39105
+ } catch {
39106
+ }
39107
+ try {
39108
+ actionRegistry.register({
39109
+ id: "core-resource-create",
39110
+ commandType: "resource.create",
39111
+ description: "\u521B\u5EFA\u6559\u5B66\u8D44\u6E90\u3002type=html \u65F6 content \u4E3A\u5B8C\u6574 HTML \u6587\u672C\uFF1Btype=folder \u65F6 content \u4E3A [{path, content}] \u6587\u4EF6\u6570\u7EC4\u7684 JSON \u5B57\u7B26\u4E32\u3002HTML \u4E2D\u82E5\u542B\u6210\u7EE9\u5C55\u793A\u4F46\u7F3A\u63D0\u4EA4\u57CB\u70B9\uFF0C\u5E73\u53F0\u4F1A\u81EA\u52A8\u6D3E\u751F\u300C[\u81EA\u52A8\u63D0\u4EA4\u7248]\u300D\u3002",
39112
+ capabilityRequired: "management:write",
39113
+ inputSchema: {
39114
+ type: "OBJECT",
39115
+ properties: {
39116
+ name: { type: "STRING", description: "\u8D44\u6E90\u540D\u79F0\uFF1Btype=html \u65F6\u5EFA\u8BAE\u4EE5 .html \u7ED3\u5C3E" },
39117
+ type: { type: "STRING", description: "\u8D44\u6E90\u7C7B\u578B\uFF1A'html' \u6216 'folder'" },
39118
+ content: {
39119
+ type: "STRING",
39120
+ description: "html \u4E3A HTML \u6587\u672C\uFF1Bfolder \u4E3A [{path, content}] \u7684 JSON \u5B57\u7B26\u4E32"
39121
+ }
39122
+ },
39123
+ required: ["name", "type", "content"]
39124
+ }
39125
+ });
39126
+ } catch {
39127
+ }
39128
+ try {
39129
+ actionRegistry.register({
39130
+ id: "core-resource-delete",
39131
+ commandType: "resource.delete",
39132
+ description: "\u6309 ID \u5220\u9664\u6559\u5B66\u8D44\u6E90\uFF08\u542B system_resources \u884C\u4E0E\u78C1\u76D8\u7F13\u5B58\uFF09\u3002\u6B64\u4E3A\u9AD8\u98CE\u9669\u64CD\u4F5C\u3002",
39133
+ capabilityRequired: "management:write",
39134
+ isHighRisk: true,
39135
+ inputSchema: {
39136
+ type: "OBJECT",
39137
+ properties: { ...resourceIdProp },
39138
+ required: ["id"]
39139
+ }
39140
+ });
39141
+ } catch {
39142
+ }
38969
39143
  const RESOURCE_HANDLERS = {
38970
39144
  "resource.list": {
38971
39145
  execute: async () => {
@@ -42612,7 +42786,7 @@ var import_node_path20 = __toESM(require("node:path"), 1);
42612
42786
  var import_uuid18 = require("uuid");
42613
42787
 
42614
42788
  // server/services/course-package-identity.ts
42615
- var import_node_crypto15 = require("node:crypto");
42789
+ var import_node_crypto14 = require("node:crypto");
42616
42790
  function stableStringify(value) {
42617
42791
  if (value === null || typeof value !== "object") {
42618
42792
  return JSON.stringify(value ?? null) ?? "null";
@@ -42651,7 +42825,7 @@ function canonicalContent(pkg) {
42651
42825
  };
42652
42826
  }
42653
42827
  function computeCoursePackageHash(pkg) {
42654
- return (0, import_node_crypto15.createHash)("sha256").update(stableStringify(canonicalContent(pkg))).digest("hex");
42828
+ return (0, import_node_crypto14.createHash)("sha256").update(stableStringify(canonicalContent(pkg))).digest("hex");
42655
42829
  }
42656
42830
  function resolveCoursePackageIdentity(pkg) {
42657
42831
  const hash = computeCoursePackageHash(pkg);
@@ -42688,24 +42862,6 @@ var CoursePackageDuplicateError = class extends Error {
42688
42862
  this.hasDeclaredId = params.hasDeclaredId;
42689
42863
  }
42690
42864
  };
42691
- var KNOWN_ELEMENT_TYPES = /* @__PURE__ */ new Set([
42692
- "pen",
42693
- "highlighter",
42694
- "rectangle",
42695
- "circle",
42696
- "text",
42697
- "quiz",
42698
- "rollcall",
42699
- "timer",
42700
- "assignment",
42701
- "hello-world",
42702
- "html-applet",
42703
- "code-sandbox",
42704
- "math-graph",
42705
- "presentation",
42706
- "plugin",
42707
- "page_meta"
42708
- ]);
42709
42865
  async function importCoursePackage(pkg, options) {
42710
42866
  const db2 = options.db ?? kernelContainer.db;
42711
42867
  const creatorId = options.creatorId || "teacher";
@@ -42747,8 +42903,7 @@ async function importCoursePackage(pkg, options) {
42747
42903
  let elements = [];
42748
42904
  try {
42749
42905
  elements = pkg.elements.map((element) => {
42750
- const isAllowed = KNOWN_ELEMENT_TYPES.has(element.type) || typeof element.type === "string" && (element.type.includes("/") || element.type.startsWith("plugin:") || element.type.startsWith("ext-") || element.type.startsWith("plugin-"));
42751
- if (!isAllowed) {
42906
+ if (!isSupportedElementType(element.type)) {
42752
42907
  throw new Error(`\u8BFE\u7A0B\u5305\u5305\u542B\u4E0D\u652F\u6301\u7684\u767D\u677F\u5143\u7D20\u7C7B\u578B\uFF1A${element.type}`);
42753
42908
  }
42754
42909
  const data = resolveAssetPlaceholders(element.data, materialized);
@@ -42770,6 +42925,7 @@ async function importCoursePackage(pkg, options) {
42770
42925
  if (data.page === void 0 && data.pageId === void 0) data.page = 0;
42771
42926
  return { type: element.type, data: JSON.stringify(data) };
42772
42927
  });
42928
+ const lessonContent = resolveAssetPlaceholders(pkg.lesson.content ?? "", materialized);
42773
42929
  const write = db2.transaction(() => {
42774
42930
  const inTxDuplicate = findExistingImport(db2, creatorId, identity);
42775
42931
  if (inTxDuplicate && !allowDuplicate) {
@@ -42784,7 +42940,7 @@ async function importCoursePackage(pkg, options) {
42784
42940
  insertLesson.run(
42785
42941
  lessonId,
42786
42942
  pkg.lesson.title,
42787
- pkg.lesson.content ?? "",
42943
+ lessonContent,
42788
42944
  JSON.stringify(timeline),
42789
42945
  pkg.lesson.progressMode ?? "manual",
42790
42946
  creatorId,
@@ -42896,6 +43052,307 @@ function cleanupMaterializedAssets(materialized) {
42896
43052
  }
42897
43053
  }
42898
43054
 
43055
+ // server/services/course-package-export.ts
43056
+ var import_node_fs21 = __toESM(require("node:fs"), 1);
43057
+ var import_node_path21 = __toESM(require("node:path"), 1);
43058
+ var import_node_crypto15 = require("node:crypto");
43059
+ var CoursePackageExportError = class extends Error {
43060
+ constructor(message, status = 500, code = "COURSE_PACKAGE_EXPORT_FAILED") {
43061
+ super(message);
43062
+ this.name = "CoursePackageExportError";
43063
+ this.status = status;
43064
+ this.code = code;
43065
+ }
43066
+ };
43067
+ var UPLOAD_URL_PATTERN = /(?:https?:\/\/[A-Za-z0-9.:[\]_-]+)?\/uploads\/([A-Za-z0-9][A-Za-z0-9._-]*)/g;
43068
+ var EXT_TO_ASSET_TYPE = {
43069
+ ".pdf": "pdf",
43070
+ ".pptx": "pptx",
43071
+ ".docx": "docx",
43072
+ ".xlsx": "xlsx",
43073
+ ".zip": "zip",
43074
+ ".png": "png",
43075
+ ".jpg": "jpg",
43076
+ ".jpeg": "jpg",
43077
+ ".md": "md",
43078
+ ".txt": "txt",
43079
+ ".csv": "csv"
43080
+ };
43081
+ async function exportLessonAsCoursePackage(lessonId, options = {}) {
43082
+ const db2 = options.db ?? kernelContainer.db;
43083
+ const lesson = db2.prepare(
43084
+ "SELECT id, title, content, timeline, progress_mode, creator_id, source_package_id FROM lessons WHERE id = ?"
43085
+ ).get(lessonId);
43086
+ if (!lesson) {
43087
+ throw new CoursePackageExportError("\u8BFE\u7A0B\u4E0D\u5B58\u5728\u6216\u5DF2\u88AB\u5220\u9664", 404, "LESSON_NOT_FOUND");
43088
+ }
43089
+ const elementRows = db2.prepare(
43090
+ "SELECT type, data FROM whiteboard_elements WHERE lesson_id = ? AND type != 'snapshot_marker' ORDER BY created_at ASC, rowid ASC"
43091
+ ).all(lessonId);
43092
+ const rawTimeline = safeJsonParse(lesson.timeline);
43093
+ const { timeline, idRemap } = normalizeTimeline(rawTimeline);
43094
+ const stats = { assets: 0, skipped: [], skippedElements: 0 };
43095
+ const elements = [];
43096
+ const preferredFilenames = /* @__PURE__ */ new Map();
43097
+ for (const row of elementRows) {
43098
+ if (!isSupportedElementType(row.type)) {
43099
+ stats.skippedElements += 1;
43100
+ continue;
43101
+ }
43102
+ const data = safeJsonParse(row.data);
43103
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
43104
+ stats.skippedElements += 1;
43105
+ continue;
43106
+ }
43107
+ const elementData = data;
43108
+ collectPreferredFilenames(elementData, preferredFilenames);
43109
+ elements.push({
43110
+ type: row.type,
43111
+ ...liftElementGeometry(elementData),
43112
+ data: elementData
43113
+ });
43114
+ }
43115
+ const referenced = /* @__PURE__ */ new Set();
43116
+ collectUploadReferences(lesson.content ?? "", referenced);
43117
+ for (const element of elements) collectUploadReferences(element.data, referenced);
43118
+ const pending = [];
43119
+ const placeholderByStoredName = /* @__PURE__ */ new Map();
43120
+ let totalBytes = 0;
43121
+ const usedIds = /* @__PURE__ */ new Set();
43122
+ for (const storedName of referenced) {
43123
+ const ext = import_node_path21.default.extname(storedName).toLowerCase();
43124
+ const contentType = EXT_TO_ASSET_TYPE[ext];
43125
+ if (!contentType || !PACKAGE_ASSET_TYPES.includes(contentType)) {
43126
+ stats.skipped.push({ url: `/uploads/${storedName}`, reason: `\u4E0D\u652F\u6301\u7684\u6587\u4EF6\u7C7B\u578B\uFF1A${ext || "(\u65E0\u6269\u5C55\u540D)"}` });
43127
+ continue;
43128
+ }
43129
+ const filePath = resolveUploadsPath(storedName);
43130
+ if (!filePath) {
43131
+ stats.skipped.push({ url: `/uploads/${storedName}`, reason: "\u6587\u4EF6\u4E0D\u5B58\u5728" });
43132
+ continue;
43133
+ }
43134
+ let buffer;
43135
+ try {
43136
+ buffer = import_node_fs21.default.readFileSync(filePath);
43137
+ } catch (err) {
43138
+ stats.skipped.push({ url: `/uploads/${storedName}`, reason: `\u8BFB\u53D6\u5931\u8D25\uFF1A${err.message}` });
43139
+ continue;
43140
+ }
43141
+ if (buffer.length === 0) {
43142
+ stats.skipped.push({ url: `/uploads/${storedName}`, reason: "\u6587\u4EF6\u4E3A\u7A7A" });
43143
+ continue;
43144
+ }
43145
+ if (!isTextAssetType(contentType) && !validateMagicBytes(buffer, `asset.${contentType}`)) {
43146
+ stats.skipped.push({ url: `/uploads/${storedName}`, reason: "\u6587\u4EF6\u7B7E\u540D\u4E0E\u6269\u5C55\u540D\u4E0D\u7B26" });
43147
+ continue;
43148
+ }
43149
+ if (isTextAssetType(contentType) && buffer.includes(0)) {
43150
+ stats.skipped.push({ url: `/uploads/${storedName}`, reason: "\u6587\u672C\u6587\u4EF6\u5305\u542B\u4E8C\u8FDB\u5236\u5185\u5BB9" });
43151
+ continue;
43152
+ }
43153
+ totalBytes += buffer.length;
43154
+ if (totalBytes > COURSE_PACKAGE_MAX_FILE_BYTES) {
43155
+ throw new CoursePackageExportError(
43156
+ `\u8BFE\u7A0B\u5F15\u7528\u7684\u9644\u4EF6\u5408\u8BA1\u8D85\u8FC7 ${COURSE_PACKAGE_MAX_FILE_BYTES / (1024 * 1024)}MB\uFF0C\u5BFC\u51FA\u540E\u65E0\u6CD5\u518D\u88AB\u5BFC\u5165\u3002\u8BF7\u7CBE\u7B80\u6559\u6848\u4E2D\u7684\u5927\u6587\u4EF6\u540E\u91CD\u8BD5\u3002`,
43157
+ 413,
43158
+ "COURSE_PACKAGE_EXPORT_TOO_LARGE"
43159
+ );
43160
+ }
43161
+ const assetId = buildAssetId(storedName, usedIds);
43162
+ pending.push({
43163
+ id: assetId,
43164
+ filename: preferredFilenames.get(storedName) || storedName,
43165
+ contentType,
43166
+ buffer,
43167
+ storedName
43168
+ });
43169
+ placeholderByStoredName.set(storedName, `\${asset:${assetId}}`);
43170
+ }
43171
+ const content = replaceUploadReferences(lesson.content ?? "", placeholderByStoredName);
43172
+ const validSegmentIds = new Set(timeline.map((segment) => segment.id));
43173
+ const resolvedElements = elements.map((element) => {
43174
+ const { segmentId: rawSegmentId, ...rest } = element;
43175
+ const segmentId = rawSegmentId ? idRemap.get(rawSegmentId) ?? rawSegmentId : void 0;
43176
+ return {
43177
+ ...rest,
43178
+ ...segmentId && validSegmentIds.has(segmentId) ? { segmentId } : {},
43179
+ data: replaceUploadReferences(element.data, placeholderByStoredName)
43180
+ };
43181
+ });
43182
+ const assets = pending.map((asset) => ({
43183
+ id: asset.id,
43184
+ filename: asset.filename,
43185
+ contentType: asset.contentType,
43186
+ base64: asset.buffer.toString("base64")
43187
+ }));
43188
+ stats.assets = assets.length;
43189
+ const pkg = {
43190
+ format: COURSE_PACKAGE_FORMAT,
43191
+ version: COURSE_PACKAGE_VERSION,
43192
+ meta: {
43193
+ // 从课程包导入来的课程沿用原包身份;自行创建的课程用 lesson id ——
43194
+ // 改了标题再导出,身份不变(导入端去重仍然认得「同一个包」)。
43195
+ id: lesson.source_package_id || lesson.id,
43196
+ name: lesson.title,
43197
+ description: `\u7531 OpenLearn \u8BFE\u7A0B\u300C${lesson.title}\u300D\u5BFC\u51FA\uFF1A\u5305\u542B\u6559\u5B66\u73AF\u8282\u3001\u767D\u677F\u5143\u7D20\u4E0E ${assets.length} \u4E2A\u5185\u8054\u9644\u4EF6\u3002`,
43198
+ author: lesson.creator_id ?? "",
43199
+ tags: ["\u5BFC\u51FA", "\u8BFE\u7A0B\u5305"]
43200
+ },
43201
+ lesson: {
43202
+ title: lesson.title,
43203
+ content,
43204
+ progressMode: lesson.progress_mode || "manual",
43205
+ timeline
43206
+ },
43207
+ assets,
43208
+ elements: resolvedElements
43209
+ };
43210
+ const parsed = parseCoursePackage(pkg);
43211
+ if (!parsed.ok) {
43212
+ throw new CoursePackageExportError(`\u5BFC\u51FA\u7684\u8BFE\u7A0B\u5305\u672A\u901A\u8FC7\u7ED3\u6784\u6821\u9A8C\uFF1A${parsed.error}`, 500, "EXPORT_INVALID_PACKAGE");
43213
+ }
43214
+ return { package: parsed.package, stats };
43215
+ }
43216
+ function buildExportFilename(title) {
43217
+ const safe = Array.from(title).filter((ch) => {
43218
+ const code = ch.charCodeAt(0);
43219
+ return code >= 32 && code !== 127;
43220
+ }).join("").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, " ").trim().slice(0, 80);
43221
+ return `${safe || "course"}.openlearn-package.json`;
43222
+ }
43223
+ function collectUploadReferences(value, out) {
43224
+ if (typeof value === "string") {
43225
+ for (const match of value.matchAll(UPLOAD_URL_PATTERN)) {
43226
+ out.add(match[1]);
43227
+ }
43228
+ return;
43229
+ }
43230
+ if (Array.isArray(value)) {
43231
+ for (const item of value) collectUploadReferences(item, out);
43232
+ return;
43233
+ }
43234
+ if (value && typeof value === "object") {
43235
+ for (const item of Object.values(value)) collectUploadReferences(item, out);
43236
+ }
43237
+ }
43238
+ function replaceUploadReferences(value, placeholders) {
43239
+ if (typeof value === "string") {
43240
+ return value.replace(UPLOAD_URL_PATTERN, (match, storedName) => placeholders.get(storedName) ?? match);
43241
+ }
43242
+ if (Array.isArray(value)) {
43243
+ return value.map((item) => replaceUploadReferences(item, placeholders));
43244
+ }
43245
+ if (value && typeof value === "object") {
43246
+ const out = {};
43247
+ for (const [key, item] of Object.entries(value)) {
43248
+ out[key] = replaceUploadReferences(item, placeholders);
43249
+ }
43250
+ return out;
43251
+ }
43252
+ return value;
43253
+ }
43254
+ function collectPreferredFilenames(value, out) {
43255
+ if (!value || typeof value !== "object") return;
43256
+ if (Array.isArray(value)) {
43257
+ for (const item of value) collectPreferredFilenames(item, out);
43258
+ return;
43259
+ }
43260
+ const obj = value;
43261
+ if (typeof obj.fileUrl === "string" && typeof obj.fileName === "string" && obj.fileName.trim()) {
43262
+ for (const match of obj.fileUrl.matchAll(UPLOAD_URL_PATTERN)) {
43263
+ if (!out.has(match[1])) out.set(match[1], obj.fileName.trim());
43264
+ }
43265
+ }
43266
+ for (const item of Object.values(obj)) collectPreferredFilenames(item, out);
43267
+ }
43268
+ function liftElementGeometry(data) {
43269
+ const lifted = {};
43270
+ const take = (key) => {
43271
+ const value = data[key];
43272
+ delete data[key];
43273
+ return value;
43274
+ };
43275
+ const segmentId = take("segmentId");
43276
+ if (typeof segmentId === "string" && segmentId) lifted.segmentId = segmentId;
43277
+ const page = take("page");
43278
+ if (typeof page === "number" && Number.isInteger(page) && page >= 0) lifted.page = page;
43279
+ const pageId = take("pageId");
43280
+ if (typeof pageId === "string" && pageId) lifted.pageId = pageId;
43281
+ const x = take("x");
43282
+ if (typeof x === "number" && Number.isFinite(x)) lifted.x = x;
43283
+ const y = take("y");
43284
+ if (typeof y === "number" && Number.isFinite(y)) lifted.y = y;
43285
+ const width = take("width");
43286
+ if (typeof width === "number" && width > 0) lifted.width = width;
43287
+ const height = take("height");
43288
+ if (typeof height === "number" && height > 0) lifted.height = height;
43289
+ return lifted;
43290
+ }
43291
+ function normalizeTimeline(raw) {
43292
+ const idRemap = /* @__PURE__ */ new Map();
43293
+ if (!Array.isArray(raw)) return { timeline: [], idRemap };
43294
+ const timeline = [];
43295
+ const seen = /* @__PURE__ */ new Set();
43296
+ raw.forEach((item, index) => {
43297
+ if (!item || typeof item !== "object") return;
43298
+ const source = item;
43299
+ const originalId = typeof source.id === "string" && source.id.trim() ? source.id.trim() : "";
43300
+ let id = originalId || `seg-${index + 1}`;
43301
+ if (seen.has(id)) {
43302
+ let suffix = 2;
43303
+ while (seen.has(`${id}-${suffix}`)) suffix += 1;
43304
+ id = `${id}-${suffix}`;
43305
+ }
43306
+ if (originalId && originalId !== id) idRemap.set(originalId, id);
43307
+ seen.add(id);
43308
+ const title = typeof source.title === "string" && source.title.trim() ? source.title.trim() : `\u73AF\u8282 ${index + 1}`;
43309
+ const type = typeof source.type === "string" && source.type.trim() ? source.type.trim() : "lecture";
43310
+ const segment = { id, title, type };
43311
+ if (typeof source.duration === "string" && source.duration.trim()) segment.duration = source.duration.trim();
43312
+ if (typeof source.notes === "string") segment.notes = source.notes;
43313
+ if (typeof source.color === "string" && source.color.trim()) segment.color = source.color.trim();
43314
+ if (Array.isArray(source.gates)) {
43315
+ const gates = source.gates.filter(
43316
+ (gate) => Boolean(gate) && typeof gate === "object" && typeof gate.type === "string"
43317
+ );
43318
+ if (gates.length > 0) segment.gates = gates;
43319
+ }
43320
+ timeline.push(segment);
43321
+ });
43322
+ return { timeline, idRemap };
43323
+ }
43324
+ function resolveUploadsPath(storedName) {
43325
+ const uploadsDir = import_node_path21.default.resolve(getUploadsDir());
43326
+ if (import_node_path21.default.basename(storedName) !== storedName) return null;
43327
+ const target = import_node_path21.default.resolve(uploadsDir, storedName);
43328
+ if (import_node_path21.default.dirname(target) !== uploadsDir) return null;
43329
+ try {
43330
+ const stat = import_node_fs21.default.statSync(target);
43331
+ return stat.isFile() ? target : null;
43332
+ } catch {
43333
+ return null;
43334
+ }
43335
+ }
43336
+ function buildAssetId(storedName, used) {
43337
+ const digest2 = (0, import_node_crypto15.createHash)("sha1").update(storedName).digest("hex").slice(0, 12);
43338
+ let id = `asset-${digest2}`;
43339
+ let suffix = 2;
43340
+ while (used.has(id)) {
43341
+ id = `asset-${digest2}-${suffix}`;
43342
+ suffix += 1;
43343
+ }
43344
+ used.add(id);
43345
+ return id;
43346
+ }
43347
+ function safeJsonParse(raw) {
43348
+ if (typeof raw !== "string" || !raw.trim()) return null;
43349
+ try {
43350
+ return JSON.parse(raw);
43351
+ } catch {
43352
+ return null;
43353
+ }
43354
+ }
43355
+
42899
43356
  // server/routes/course-packages.ts
42900
43357
  var TEMPLATE_FILENAME = "openlearn-course-template.json";
42901
43358
  var MAX_DECODED_ASSET_BYTES = COURSE_PACKAGE_MAX_FILE_BYTES;
@@ -42955,6 +43412,32 @@ function registerCoursePackageRoutes(ctx) {
42955
43412
  sendSafeError(res, e);
42956
43413
  }
42957
43414
  });
43415
+ app.get("/api/lessons/:id/export-package", requireAuth("teacher", "administrator"), async (req, res) => {
43416
+ try {
43417
+ const { id } = req.params;
43418
+ const ownership = checkLessonOwnership(req, id);
43419
+ if (!ownership.allowed) {
43420
+ return res.status(ownership.status).json({ success: false, error: ownership.error });
43421
+ }
43422
+ const { package: pkg, stats } = await exportLessonAsCoursePackage(id);
43423
+ const filename = buildExportFilename(pkg.lesson.title);
43424
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
43425
+ res.setHeader(
43426
+ "Content-Disposition",
43427
+ `attachment; filename="course-package.json"; filename*=UTF-8''${encodeURIComponent(filename)}`
43428
+ );
43429
+ res.setHeader("X-Course-Package-Filename", encodeURIComponent(filename));
43430
+ res.setHeader("X-Course-Package-Assets", String(stats.assets));
43431
+ res.setHeader("X-Course-Package-Skipped", String(stats.skipped.length + stats.skippedElements));
43432
+ res.send(JSON.stringify(pkg, null, 2));
43433
+ } catch (e) {
43434
+ if (e instanceof CoursePackageExportError) {
43435
+ return res.status(e.status).json({ success: false, code: e.code, error: e.message });
43436
+ }
43437
+ console.error("[CoursePackage] \u5BFC\u51FA\u5931\u8D25:", e);
43438
+ sendSafeError(res, e);
43439
+ }
43440
+ });
42958
43441
  }
42959
43442
 
42960
43443
  // server/routes/workspace.ts
@@ -44991,6 +45474,26 @@ function resolveWritableStudentId(session, rawId) {
44991
45474
  if (isTeacher(session) && teacherCanSeeStudent(kernelContainer.getReadDb(), session, studentId)) return studentId;
44992
45475
  return null;
44993
45476
  }
45477
+ var SEED_STATUS_TTL_MS = 15e3;
45478
+ var seedStatusCache = null;
45479
+ function readSeedDefaultStatus() {
45480
+ const now = Date.now();
45481
+ if (seedStatusCache && seedStatusCache.expiresAt > now) return seedStatusCache.payload;
45482
+ const accounts = [];
45483
+ try {
45484
+ const rows = kernelContainer.db.prepare("SELECT username, password_hash FROM users WHERE username IN (?, ?)").all("admin", "teacher");
45485
+ for (const row of rows) {
45486
+ if (verifyPassword(row.username, row.password_hash).valid) accounts.push(row.username);
45487
+ }
45488
+ } catch {
45489
+ }
45490
+ const payload = { active: accounts.length > 0, accounts };
45491
+ seedStatusCache = { payload, expiresAt: now + SEED_STATUS_TTL_MS };
45492
+ return payload;
45493
+ }
45494
+ function invalidateSeedStatusCache() {
45495
+ seedStatusCache = null;
45496
+ }
44994
45497
  function registerRosterRoutes(ctx) {
44995
45498
  const { app, io, loginLimiter } = ctx;
44996
45499
  const rosterService = new RosterService(kernelContainer.db, kernelContainer.getReadDb());
@@ -45272,6 +45775,7 @@ function registerRosterRoutes(ctx) {
45272
45775
  return res.status(400).json({ error: "New password must be different from the username" });
45273
45776
  }
45274
45777
  kernelContainer.db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?").run(hashPassword(newPassword), session.userId);
45778
+ invalidateSeedStatusCache();
45275
45779
  kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id != ? AND session_data LIKE ?").run(token, `%${session.userId}%`);
45276
45780
  try {
45277
45781
  const updatedSession = { ...session, mustChangePassword: false };
@@ -45439,6 +45943,13 @@ function registerRosterRoutes(ctx) {
45439
45943
  sendSafeError(res, e);
45440
45944
  }
45441
45945
  });
45946
+ app.get("/api/auth/seed-status", (_req, res) => {
45947
+ try {
45948
+ res.json(readSeedDefaultStatus());
45949
+ } catch (e) {
45950
+ sendSafeError(res, e);
45951
+ }
45952
+ });
45442
45953
  app.post("/api/auth/login", loginLimiter, (req, res) => {
45443
45954
  try {
45444
45955
  const { entrance, username, password, studentId } = req.body;
@@ -46457,6 +46968,98 @@ function parseAllowedExt(raw) {
46457
46968
  return set.size > 0 ? set : null;
46458
46969
  }
46459
46970
 
46971
+ // packages/core/db/assignment-resubmit.ts
46972
+ var import_node_crypto16 = __toESM(require("node:crypto"), 1);
46973
+ var RESUBMIT_CONTENT_REPLACED_REASON = "resubmit-content-replaced";
46974
+ function keepStatus(current) {
46975
+ return current === "graded" || current === "absent" ? current : "submitted";
46976
+ }
46977
+ function writeContentReplacementHistory(db2, input) {
46978
+ const historyId = `agh-${import_node_crypto16.default.randomUUID()}`;
46979
+ try {
46980
+ db2.prepare(
46981
+ `INSERT INTO assignment_grade_history
46982
+ (id, assignment_id, student_id, old_score, new_score, old_status, new_status,
46983
+ old_source, new_source, actor_id, actor_role, reason, created_at, old_content)
46984
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
46985
+ ).run(
46986
+ historyId,
46987
+ input.assignmentId,
46988
+ input.studentId,
46989
+ input.score,
46990
+ input.score,
46991
+ input.status,
46992
+ input.status,
46993
+ input.source,
46994
+ input.source,
46995
+ input.actorId ?? null,
46996
+ input.actorRole ?? null,
46997
+ RESUBMIT_CONTENT_REPLACED_REASON,
46998
+ input.now,
46999
+ input.oldContent
47000
+ );
47001
+ return historyId;
47002
+ } catch (err) {
47003
+ console.warn("[assignment.resubmit] \u5185\u5BB9\u66FF\u6362\u7559\u75D5\u5931\u8D25:", err.message);
47004
+ return void 0;
47005
+ }
47006
+ }
47007
+ function applyResubmission(db2, input) {
47008
+ const assignmentId = String(input.assignmentId ?? "").trim();
47009
+ const studentId = String(input.studentId ?? "").trim();
47010
+ if (!assignmentId || !studentId) {
47011
+ return { ok: false, contentReplaced: false };
47012
+ }
47013
+ const now = input.now ?? Date.now();
47014
+ const content = input.content ?? "";
47015
+ let result = { ok: false, contentReplaced: false };
47016
+ const write = () => {
47017
+ const existing = db2.prepare(
47018
+ `SELECT content, score, feedback, status, source, academic_year
47019
+ FROM assignment_submissions
47020
+ WHERE assignment_id = ? AND student_id = ?
47021
+ LIMIT 1`
47022
+ ).get(assignmentId, studentId);
47023
+ if (!existing) {
47024
+ db2.prepare(
47025
+ `INSERT INTO assignment_submissions
47026
+ (assignment_id, student_id, content, score, feedback, submitted_at, status)
47027
+ VALUES (?, ?, ?, NULL, NULL, ?, 'submitted')`
47028
+ ).run(assignmentId, studentId, content, now);
47029
+ result = { ok: true, contentReplaced: false, status: "submitted" };
47030
+ return;
47031
+ }
47032
+ const nextStatus = keepStatus(existing.status);
47033
+ const contentChanged = (existing.content ?? "") !== content;
47034
+ const gradedRow = existing.status === "graded" || existing.status === "absent";
47035
+ const contentReplaced = gradedRow && contentChanged;
47036
+ let historyId;
47037
+ if (contentReplaced) {
47038
+ historyId = writeContentReplacementHistory(db2, {
47039
+ assignmentId,
47040
+ studentId,
47041
+ oldContent: existing.content,
47042
+ score: existing.score,
47043
+ status: existing.status,
47044
+ source: existing.source,
47045
+ actorId: input.actorId ?? null,
47046
+ actorRole: input.actorRole ?? null,
47047
+ now
47048
+ });
47049
+ }
47050
+ db2.prepare(
47051
+ `UPDATE assignment_submissions
47052
+ SET content = ?, submitted_at = ?, status = ?
47053
+ WHERE assignment_id = ? AND student_id = ?`
47054
+ ).run(content, now, nextStatus, assignmentId, studentId);
47055
+ result = { ok: true, contentReplaced, historyId, status: nextStatus };
47056
+ };
47057
+ const tx = typeof db2.transaction === "function" ? db2.transaction(write) : null;
47058
+ if (typeof tx === "function") tx();
47059
+ else write();
47060
+ return result;
47061
+ }
47062
+
46460
47063
  // server/services/assignment-service.ts
46461
47064
  function safeSegment(value) {
46462
47065
  const cleaned = String(value || "").replace(/[^A-Za-z0-9._-]/g, "");
@@ -47174,8 +47777,22 @@ function registerAssignmentHubRoutes(ctx) {
47174
47777
  const row = kernelContainer.db.prepare("SELECT class_id FROM plugin_assignments WHERE id = ?").get(assignmentId);
47175
47778
  return row?.class_id;
47176
47779
  };
47780
+ const requireAssignmentCommand = (res, ...types) => {
47781
+ for (const type of types) {
47782
+ if (!kernelContainer.commandBus.hasHandler(type)) {
47783
+ res.status(503).json({
47784
+ success: false,
47785
+ code: "ASSIGNMENT_SERVICE_UNAVAILABLE",
47786
+ error: "Assignment service is unavailable: the assignment-eval plugin is not active."
47787
+ });
47788
+ return true;
47789
+ }
47790
+ }
47791
+ return false;
47792
+ };
47177
47793
  app.post("/api/assignments", requireAuth("teacher", "administrator"), async (req, res) => {
47178
47794
  try {
47795
+ if (requireAssignmentCommand(res, "assignment.create")) return;
47179
47796
  const classId = (req.body || {}).classId || (req.body || {}).class_id;
47180
47797
  if (classId && rejectUnlessHubClass(req, res, String(classId), "staff")) return;
47181
47798
  const cmd = kernelContainer.commandBus.createCommand("assignment.create", req.body || {}, getActorId(req));
@@ -47187,6 +47804,7 @@ function registerAssignmentHubRoutes(ctx) {
47187
47804
  });
47188
47805
  app.get("/api/assignments", requireScopedAuth("class-member"), async (req, res) => {
47189
47806
  try {
47807
+ if (requireAssignmentCommand(res, "assignment.list")) return;
47190
47808
  const payload = {
47191
47809
  lessonId: req.query.lessonId,
47192
47810
  classId: req.query.classId,
@@ -47217,6 +47835,7 @@ function registerAssignmentHubRoutes(ctx) {
47217
47835
  });
47218
47836
  app.get("/api/assignments/:assignmentId", requireScopedAuth("class-member"), async (req, res) => {
47219
47837
  try {
47838
+ if (requireAssignmentCommand(res, "assignment.get")) return;
47220
47839
  const { isPrivileged: isPrivileged2, studentId } = describeRequester(req);
47221
47840
  const payload = { assignmentId: req.params.assignmentId };
47222
47841
  const requestedStudent = typeof req.query.studentId === "string" ? req.query.studentId : void 0;
@@ -47334,6 +47953,7 @@ function registerAssignmentHubRoutes(ctx) {
47334
47953
  });
47335
47954
  app.post("/api/assignments/:assignmentId/submit", requireScopedAuth("class-member"), async (req, res) => {
47336
47955
  try {
47956
+ if (requireAssignmentCommand(res, "assignment.submit")) return;
47337
47957
  const { isPrivileged: isPrivileged2, studentId } = describeRequester(req);
47338
47958
  const body = req.body || {};
47339
47959
  const targetStudentId = isPrivileged2 ? String(body.studentId || "") : String(studentId || "");
@@ -47362,6 +47982,7 @@ function registerAssignmentHubRoutes(ctx) {
47362
47982
  });
47363
47983
  app.post("/api/assignments/:assignmentId/peer-review", requireScopedAuth("class-member"), async (req, res) => {
47364
47984
  try {
47985
+ if (requireAssignmentCommand(res, "assignment.peer_review")) return;
47365
47986
  const { isPrivileged: isPrivileged2, studentId } = describeRequester(req);
47366
47987
  const body = req.body || {};
47367
47988
  const reviewerId = isPrivileged2 ? String(body.reviewerId || body.studentId || "") : String(studentId || "");
@@ -47397,6 +48018,7 @@ function registerAssignmentHubRoutes(ctx) {
47397
48018
  requireAuth("teacher", "administrator"),
47398
48019
  async (req, res) => {
47399
48020
  try {
48021
+ if (requireAssignmentCommand(res, "assignment.assign_peer_reviews")) return;
47400
48022
  if (rejectUnlessHubClass(req, res, hubAssignmentClassId(req.params.assignmentId), "staff")) return;
47401
48023
  const body = req.body || {};
47402
48024
  const cmd = kernelContainer.commandBus.createCommand(
@@ -48899,7 +49521,7 @@ function registerGradingRoutes(ctx) {
48899
49521
  }
48900
49522
 
48901
49523
  // server/routes/plugins.ts
48902
- var import_node_crypto17 = require("node:crypto");
49524
+ var import_node_crypto18 = require("node:crypto");
48903
49525
  init_types();
48904
49526
  init_install_utils();
48905
49527
  var import_path22 = __toESM(require("path"), 1);
@@ -49081,7 +49703,7 @@ async function checkVersion(source, installedVersion) {
49081
49703
  init_audit_log();
49082
49704
 
49083
49705
  // server/routes/plugin-api-gateway.ts
49084
- var import_node_path21 = __toESM(require("node:path"), 1);
49706
+ var import_node_path22 = __toESM(require("node:path"), 1);
49085
49707
  init_http_router();
49086
49708
  init_interfaces();
49087
49709
  var RESERVED_ACTIONS = /* @__PURE__ */ new Set([
@@ -49255,7 +49877,7 @@ async function pluginApiGatewayMiddleware(req, res, next) {
49255
49877
  res.status(400).json({ success: false, error: "Path traversal attempt detected" });
49256
49878
  return;
49257
49879
  }
49258
- const normalizedSubPath = import_node_path21.default.posix.normalize("/" + rawSubPath);
49880
+ const normalizedSubPath = import_node_path22.default.posix.normalize("/" + rawSubPath);
49259
49881
  if (normalizedSubPath.includes("..")) {
49260
49882
  res.status(400).json({ success: false, error: "Path traversal attempt detected" });
49261
49883
  return;
@@ -49692,10 +50314,10 @@ init_fetch_with_retry();
49692
50314
 
49693
50315
  // server/services/community-registry.ts
49694
50316
  var import_semver7 = __toESM(require("semver"), 1);
49695
- var import_node_fs21 = __toESM(require("node:fs"), 1);
49696
- var import_node_path22 = __toESM(require("node:path"), 1);
50317
+ var import_node_fs22 = __toESM(require("node:fs"), 1);
50318
+ var import_node_path23 = __toESM(require("node:path"), 1);
49697
50319
  var import_node_os3 = __toESM(require("node:os"), 1);
49698
- var import_node_crypto16 = require("node:crypto");
50320
+ var import_node_crypto17 = require("node:crypto");
49699
50321
  var COMMUNITY_REGISTRY_ENV = "PLUGIN_COMMUNITY_REGISTRY_URL";
49700
50322
  var REGISTRY_FETCH_TIMEOUT_MS = 1e4;
49701
50323
  var PACKAGE_FETCH_TIMEOUT_MS = 6e4;
@@ -49915,8 +50537,8 @@ async function downloadPluginPackage(downloadUrl, options = {}) {
49915
50537
  if (!response.body) {
49916
50538
  throw new Error("\u4E0B\u8F7D\u63D2\u4EF6\u5305\u5931\u8D25: \u54CD\u5E94\u4F53\u4E3A\u7A7A");
49917
50539
  }
49918
- const zipPath = import_node_path22.default.join(import_node_os3.default.tmpdir(), `plugin-download-${(0, import_node_crypto16.randomUUID)()}.zip`);
49919
- const out = import_node_fs21.default.createWriteStream(zipPath);
50540
+ const zipPath = import_node_path23.default.join(import_node_os3.default.tmpdir(), `plugin-download-${(0, import_node_crypto17.randomUUID)()}.zip`);
50541
+ const out = import_node_fs22.default.createWriteStream(zipPath);
49920
50542
  let received = 0;
49921
50543
  try {
49922
50544
  for await (const chunk of response.body) {
@@ -49938,13 +50560,13 @@ async function downloadPluginPackage(downloadUrl, options = {}) {
49938
50560
  } catch (err) {
49939
50561
  out.destroy();
49940
50562
  try {
49941
- import_node_fs21.default.rmSync(zipPath, { force: true });
50563
+ import_node_fs22.default.rmSync(zipPath, { force: true });
49942
50564
  } catch {
49943
50565
  }
49944
50566
  throw err;
49945
50567
  }
49946
50568
  if (received === 0) {
49947
- import_node_fs21.default.rmSync(zipPath, { force: true });
50569
+ import_node_fs22.default.rmSync(zipPath, { force: true });
49948
50570
  throw new Error("\u63D2\u4EF6\u5305\u4E3A\u7A7A");
49949
50571
  }
49950
50572
  return { zipPath, filename: resolveFilename(response, downloadUrl), bytes: received };
@@ -50175,7 +50797,7 @@ function discardWriteStream(out, zipPath) {
50175
50797
  }
50176
50798
  function streamBodyToTempZip(req, res, maxBytes) {
50177
50799
  return new Promise((resolve2, reject) => {
50178
- const zipPath = import_path22.default.join(import_os2.default.tmpdir(), `plugin-upload-${(0, import_node_crypto17.randomUUID)()}.zip`);
50800
+ const zipPath = import_path22.default.join(import_os2.default.tmpdir(), `plugin-upload-${(0, import_node_crypto18.randomUUID)()}.zip`);
50179
50801
  const out = import_fs19.default.createWriteStream(zipPath);
50180
50802
  let received = 0;
50181
50803
  let settled = false;
@@ -50230,7 +50852,7 @@ async function streamHttpBodyToFile(resp, maxBytes) {
50230
50852
  if (!resp.body) {
50231
50853
  throw new Error("\u54CD\u5E94\u4F53\u4E3A\u7A7A");
50232
50854
  }
50233
- const zipPath = import_path22.default.join(import_os2.default.tmpdir(), `plugin-download-${(0, import_node_crypto17.randomUUID)()}.zip`);
50855
+ const zipPath = import_path22.default.join(import_os2.default.tmpdir(), `plugin-download-${(0, import_node_crypto18.randomUUID)()}.zip`);
50234
50856
  const out = import_fs19.default.createWriteStream(zipPath);
50235
50857
  let received = 0;
50236
50858
  try {
@@ -51677,12 +52299,12 @@ function buildItemSnapshot(input) {
51677
52299
  }
51678
52300
 
51679
52301
  // packages/core/assessment/anonymous.ts
51680
- var import_node_crypto18 = require("node:crypto");
52302
+ var import_node_crypto19 = require("node:crypto");
51681
52303
  function deriveAnonKey(secret, assignmentId, studentId) {
51682
52304
  if (!secret) throw new Error("[assessment] anonymous surveys require a server secret");
51683
52305
  if (!assignmentId) throw new Error("[assessment] assignmentId is required to derive anon key");
51684
52306
  if (!studentId) throw new Error("[assessment] studentId is required to derive anon key");
51685
- return (0, import_node_crypto18.createHmac)("sha256", secret).update(`${assignmentId}:${studentId}`).digest("hex");
52307
+ return (0, import_node_crypto19.createHmac)("sha256", secret).update(`${assignmentId}:${studentId}`).digest("hex");
51686
52308
  }
51687
52309
  function anonymousLabel(anonKey, index) {
51688
52310
  if (typeof index === "number") return `\u533F\u540D ${index + 1}`;
@@ -51905,7 +52527,7 @@ var notFound = (what) => new AssessmentError(404, `${what} not found`);
51905
52527
  var badRequest = (msg) => new AssessmentError(400, msg);
51906
52528
  var conflict = (msg) => new AssessmentError(409, msg);
51907
52529
  var forbidden = (msg) => new AssessmentError(403, msg);
51908
- function safeJsonParse(raw, fallback) {
52530
+ function safeJsonParse2(raw, fallback) {
51909
52531
  if (!raw) return fallback;
51910
52532
  try {
51911
52533
  return JSON.parse(raw);
@@ -51989,8 +52611,8 @@ function toItemRecord(row) {
51989
52611
  sortOrder: row.sort_order,
51990
52612
  type: row.type,
51991
52613
  stem: row.stem,
51992
- config: safeJsonParse(row.config_json, {}),
51993
- answerKey: safeJsonParse(row.answer_key_json, null),
52614
+ config: safeJsonParse2(row.config_json, {}),
52615
+ answerKey: safeJsonParse2(row.answer_key_json, null),
51994
52616
  maxScore: row.max_score
51995
52617
  };
51996
52618
  }
@@ -52061,7 +52683,7 @@ var AssessmentService = class {
52061
52683
  const items = this.readItems(id);
52062
52684
  return {
52063
52685
  ...this.toSummary(row),
52064
- settings: parseAssessmentSettings(row.kind, safeJsonParse(row.settings_json, {})),
52686
+ settings: parseAssessmentSettings(row.kind, safeJsonParse2(row.settings_json, {})),
52065
52687
  items: items.map(toTeacherItemView)
52066
52688
  };
52067
52689
  }
@@ -52339,7 +52961,7 @@ var AssessmentService = class {
52339
52961
  if (attempt.status !== "in_progress") {
52340
52962
  throw badRequest("\u4F5C\u7B54\u5DF2\u63D0\u4EA4\u6216\u7ED3\u675F\uFF0C\u65E0\u6CD5\u8BB0\u5F55\u76D1\u8003\u4E8B\u4EF6");
52341
52963
  }
52342
- const currentProctor = safeJsonParse(attempt.proctor_json, {
52964
+ const currentProctor = safeJsonParse2(attempt.proctor_json, {
52343
52965
  blurCount: 0,
52344
52966
  switchTabCount: 0,
52345
52967
  fullscreenExitCount: 0,
@@ -52412,7 +53034,7 @@ var AssessmentService = class {
52412
53034
  if (!att) {
52413
53035
  notStartedCount++;
52414
53036
  } else {
52415
- proctor = safeJsonParse(att.proctor_json, {});
53037
+ proctor = safeJsonParse2(att.proctor_json, {});
52416
53038
  submittedAt = att.submitted_at;
52417
53039
  if (att.status === "in_progress") {
52418
53040
  inProgressCount++;
@@ -52520,7 +53142,7 @@ var AssessmentService = class {
52520
53142
  ).all(...classIds);
52521
53143
  const now = this.now();
52522
53144
  const result = rows.map((row) => {
52523
- const settings = parseAssessmentSettings(row.kind, safeJsonParse(row.settings_json, {}));
53145
+ const settings = parseAssessmentSettings(row.kind, safeJsonParse2(row.settings_json, {}));
52524
53146
  const attempt = settings.anonymous ? this.findAttemptByIdentity(row.id, null, this.anonKeyFor(row.id, studentId)) : this.findAttemptByIdentity(row.id, studentId, null);
52525
53147
  const settled = attempt ? this.settleIfExpired(attempt, now) : null;
52526
53148
  const completed = Boolean(settled && settled.status !== "in_progress");
@@ -52571,7 +53193,7 @@ var AssessmentService = class {
52571
53193
  const assessment = this.db.prepare("SELECT * FROM assessments WHERE id = ?").get(asg.assessmentId);
52572
53194
  if (!assessment) throw notFound("assessment");
52573
53195
  if (asg.status === "closed") throw conflict("assignment is closed");
52574
- const settings = parseAssessmentSettings(assessment.kind, safeJsonParse(assessment.settings_json, {}));
53196
+ const settings = parseAssessmentSettings(assessment.kind, safeJsonParse2(assessment.settings_json, {}));
52575
53197
  const now = this.now();
52576
53198
  const windowState = readWindowState(this.windowOf(asg), now);
52577
53199
  if (windowState === "before_open") throw conflict("assessment has not opened yet");
@@ -52683,7 +53305,7 @@ var AssessmentService = class {
52683
53305
  type: item.type,
52684
53306
  stem: item.stem,
52685
53307
  maxScore: item.maxScore,
52686
- answer: row ? safeJsonParse(row.answer_json, null) : null,
53308
+ answer: row ? safeJsonParse2(row.answer_json, null) : null,
52687
53309
  answerKey: item.answerKey,
52688
53310
  score: row?.score ?? null,
52689
53311
  isCorrect: row?.is_correct === null || row?.is_correct === void 0 ? null : row.is_correct === 1,
@@ -52754,7 +53376,7 @@ var AssessmentService = class {
52754
53376
  const definition = this.registry.get(item.type);
52755
53377
  if (definition?.category !== "subjective") throw badRequest("AI \u5EFA\u8BAE\u4EC5\u9002\u7528\u4E8E\u4E3B\u89C2\u9898");
52756
53378
  const answerRow = this.db.prepare("SELECT answer_json FROM assessment_answers WHERE attempt_id = ? AND item_id = ?").get(attemptId, itemId);
52757
- const studentAnswer = answerRow?.answer_json ? safeJsonParse(answerRow.answer_json, answerRow.answer_json) : null;
53379
+ const studentAnswer = answerRow?.answer_json ? safeJsonParse2(answerRow.answer_json, answerRow.answer_json) : null;
52758
53380
  if (studentAnswer === null || studentAnswer === void 0 || typeof studentAnswer === "string" && studentAnswer.trim() === "") {
52759
53381
  throw badRequest("\u8BE5\u9898\u5C1A\u65E0\u5B66\u751F\u4F5C\u7B54\uFF0C\u65E0\u6CD5\u751F\u6210\u6279\u6539\u5EFA\u8BAE");
52760
53382
  }
@@ -52834,7 +53456,7 @@ var AssessmentService = class {
52834
53456
  const correct = answers.filter((a) => a.is_correct === 1);
52835
53457
  const distribution = {};
52836
53458
  for (const answer of answers) {
52837
- const parsed = safeJsonParse(answer.answer_json, null);
53459
+ const parsed = safeJsonParse2(answer.answer_json, null);
52838
53460
  const key = Array.isArray(parsed) ? parsed.join(",") : String(parsed);
52839
53461
  distribution[key] = (distribution[key] ?? 0) + 1;
52840
53462
  }
@@ -52874,7 +53496,7 @@ var AssessmentService = class {
52874
53496
  exportCsv(assignmentId, ownerId) {
52875
53497
  const asg = this.requireOwnedAssignment(assignmentId, ownerId);
52876
53498
  const assessment = this.db.prepare("SELECT * FROM assessments WHERE id = ?").get(asg.assessmentId);
52877
- const settings = parseAssessmentSettings(assessment.kind, safeJsonParse(assessment.settings_json, {}));
53499
+ const settings = parseAssessmentSettings(assessment.kind, safeJsonParse2(assessment.settings_json, {}));
52878
53500
  const items = this.readItems(asg.assessmentId).sort((a, b) => a.sortOrder - b.sortOrder);
52879
53501
  const attempts = this.db.prepare("SELECT * FROM assessment_attempts WHERE assignment_id = ? ORDER BY started_at ASC").all(assignmentId);
52880
53502
  const now = this.now();
@@ -52895,7 +53517,7 @@ var AssessmentService = class {
52895
53517
  const answerCells = items.map((item) => {
52896
53518
  const answerRow = this.db.prepare("SELECT * FROM assessment_answers WHERE attempt_id = ? AND item_id = ?").get(row.id, item.id);
52897
53519
  if (!answerRow) return "";
52898
- const parsed = safeJsonParse(answerRow.answer_json, null);
53520
+ const parsed = safeJsonParse2(answerRow.answer_json, null);
52899
53521
  const text = Array.isArray(parsed) ? parsed.map((v) => String(v)).join(" | ") : String(parsed ?? "");
52900
53522
  const scorePart = answerRow.score === null ? "" : ` [${answerRow.score}]`;
52901
53523
  const clipped = text.length > EXPORT_TEXT_LIMIT ? `${text.slice(0, EXPORT_TEXT_LIMIT)}\u2026` : text;
@@ -52935,7 +53557,7 @@ var AssessmentService = class {
52935
53557
  async exportPaper(assessmentId, ownerId, format) {
52936
53558
  const assessment = this.requireOwnedAssessment(assessmentId, ownerId);
52937
53559
  const items = this.readItems(assessmentId).sort((a, b) => a.sortOrder - b.sortOrder);
52938
- const settings = parseAssessmentSettings(assessment.kind, safeJsonParse(assessment.settings_json, {}));
53560
+ const settings = parseAssessmentSettings(assessment.kind, safeJsonParse2(assessment.settings_json, {}));
52939
53561
  const base = `assessment-${assessmentId}-paper`;
52940
53562
  const paper = {
52941
53563
  schema: PAPER_EXPORT_SCHEMA,
@@ -53291,7 +53913,7 @@ var AssessmentService = class {
53291
53913
  description: row.description,
53292
53914
  status: row.status,
53293
53915
  academicYear: row.academic_year,
53294
- settings: parseAssessmentSettings(row.kind, safeJsonParse(row.settings_json, {})),
53916
+ settings: parseAssessmentSettings(row.kind, safeJsonParse2(row.settings_json, {})),
53295
53917
  itemCount: items.length,
53296
53918
  totalScore: roundScore(items.reduce((s, i) => s + i.maxScore, 0)),
53297
53919
  createdAt: row.created_at,
@@ -53320,7 +53942,7 @@ var AssessmentService = class {
53320
53942
  return false;
53321
53943
  }
53322
53944
  if (rules.tags && rules.tags.length > 0) {
53323
- const tags = safeJsonParse(bank.tags_json, []);
53945
+ const tags = safeJsonParse2(bank.tags_json, []);
53324
53946
  if (!rules.tags.some((tag) => tags.includes(tag))) return false;
53325
53947
  }
53326
53948
  return true;
@@ -53336,7 +53958,7 @@ var AssessmentService = class {
53336
53958
  paperItemIds(attempt) {
53337
53959
  const raw = attempt.paper_json;
53338
53960
  if (!raw) return null;
53339
- const parsed = safeJsonParse(raw, {});
53961
+ const parsed = safeJsonParse2(raw, {});
53340
53962
  if (!Array.isArray(parsed.itemIds)) return null;
53341
53963
  const ids = parsed.itemIds.filter((id) => typeof id === "string");
53342
53964
  return ids.length > 0 ? ids : null;
@@ -53357,9 +53979,9 @@ var AssessmentService = class {
53357
53979
  id: row.id,
53358
53980
  type: row.type,
53359
53981
  stem: row.stem,
53360
- config: safeJsonParse(row.config_json, {}),
53361
- answerKey: safeJsonParse(row.answer_key_json, null),
53362
- tags: safeJsonParse(row.tags_json, []),
53982
+ config: safeJsonParse2(row.config_json, {}),
53983
+ answerKey: safeJsonParse2(row.answer_key_json, null),
53984
+ tags: safeJsonParse2(row.tags_json, []),
53363
53985
  difficulty: row.difficulty ?? null,
53364
53986
  createdAt: row.created_at,
53365
53987
  updatedAt: row.updated_at
@@ -53425,7 +54047,7 @@ var AssessmentService = class {
53425
54047
  score: row.score,
53426
54048
  maxScore: row.max_score,
53427
54049
  needsManualReview: row.needs_manual_review === 1,
53428
- proctor: safeJsonParse(row.proctor_json, {})
54050
+ proctor: safeJsonParse2(row.proctor_json, {})
53429
54051
  };
53430
54052
  }
53431
54053
  /** 惰性结算:过期未交卷的尝试在这里落成 submitted(并立即判分)。 */
@@ -53449,11 +54071,11 @@ var AssessmentService = class {
53449
54071
  regrade(attempt, now, submittedAt) {
53450
54072
  const assessmentId = this.assignmentAssessmentId(attempt.assignment_id);
53451
54073
  const assessment = this.db.prepare("SELECT * FROM assessments WHERE id = ?").get(assessmentId);
53452
- const settings = parseAssessmentSettings(assessment.kind, safeJsonParse(assessment.settings_json, {}));
54074
+ const settings = parseAssessmentSettings(assessment.kind, safeJsonParse2(assessment.settings_json, {}));
53453
54075
  const items = this.itemsForAttempt(attempt, this.readItems(assessmentId));
53454
54076
  const answerRows = this.db.prepare("SELECT * FROM assessment_answers WHERE attempt_id = ?").all(attempt.id);
53455
54077
  const answers = {};
53456
- for (const row of answerRows) answers[row.item_id] = safeJsonParse(row.answer_json, null);
54078
+ for (const row of answerRows) answers[row.item_id] = safeJsonParse2(row.answer_json, null);
53457
54079
  const summary = gradeAttempt(this.registry, items, answers, {
53458
54080
  assessmentId,
53459
54081
  attemptId: attempt.id,
@@ -53526,7 +54148,7 @@ var AssessmentService = class {
53526
54148
  const items = this.itemsForAttempt(attempt, this.readItems(assessment.id));
53527
54149
  const answerRows = this.db.prepare("SELECT * FROM assessment_answers WHERE attempt_id = ?").all(attempt.id);
53528
54150
  const answers = {};
53529
- for (const row of answerRows) answers[row.item_id] = safeJsonParse(row.answer_json, null);
54151
+ for (const row of answerRows) answers[row.item_id] = safeJsonParse2(row.answer_json, null);
53530
54152
  let sortedItems = items.sort((a, b) => a.sortOrder - b.sortOrder);
53531
54153
  if (settings.shuffleItems) {
53532
54154
  sortedItems = seededShuffle(sortedItems, attempt.id);
@@ -53571,7 +54193,7 @@ var AssessmentService = class {
53571
54193
  buildResultPayload(attempt) {
53572
54194
  const assignment = this.getAssignment(attempt.assignment_id);
53573
54195
  const assessment = this.db.prepare("SELECT * FROM assessments WHERE id = ?").get(assignment.assessmentId);
53574
- const settings = parseAssessmentSettings(assessment.kind, safeJsonParse(assessment.settings_json, {}));
54196
+ const settings = parseAssessmentSettings(assessment.kind, safeJsonParse2(assessment.settings_json, {}));
53575
54197
  const items = this.itemsForAttempt(attempt, this.readItems(assessment.id)).sort(
53576
54198
  (a, b) => a.sortOrder - b.sortOrder
53577
54199
  );
@@ -53592,7 +54214,7 @@ var AssessmentService = class {
53592
54214
  type: item.type,
53593
54215
  stem: item.stem,
53594
54216
  maxScore: item.maxScore,
53595
- answer: row ? safeJsonParse(row.answer_json, null) : null
54217
+ answer: row ? safeJsonParse2(row.answer_json, null) : null
53596
54218
  };
53597
54219
  if (!showAnalysis) {
53598
54220
  return {