openlearn-next 0.3.20 → 0.3.22

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 (43) hide show
  1. package/README.md +161 -38
  2. package/dist/assets/{AdminDirectoryView-CGo33RNh.js → AdminDirectoryView-D-AOtp1o.js} +1 -1
  3. package/dist/assets/{BatchPickerModal-CasfiwEQ.js → BatchPickerModal-C9FH3J57.js} +1 -1
  4. package/dist/assets/{ClassesView-DIyJwydJ.js → ClassesView-DTuyCvYd.js} +1 -1
  5. package/dist/assets/{CloudDriveModal-C6fpWLLE.js → CloudDriveModal-Bi-aMuax.js} +1 -1
  6. package/dist/assets/{ComputerLabView-DGha_J8B.js → ComputerLabView-DWMtMSHV.js} +1 -1
  7. package/dist/assets/{CourseManagement-2o8YUTDb.js → CourseManagement-CeelAyst.js} +1 -1
  8. package/dist/assets/{CourseWizardModal-Clo7hCCp.js → CourseWizardModal-m7TDgcB2.js} +1 -1
  9. package/dist/assets/{CoursewareHubPanel-CEK_QEc5.js → CoursewareHubPanel-C4LQjt4v.js} +1 -1
  10. package/dist/assets/{Dashboard-CBIiLzje.js → Dashboard-CiBtG7Ge.js} +2 -2
  11. package/dist/assets/{ExportWeightModal-DkDuPsiM.js → ExportWeightModal-C4d-0uTM.js} +1 -1
  12. package/dist/assets/{HelpTour-Dlc1vvy3.js → HelpTour-00eT17Vg.js} +1 -1
  13. package/dist/assets/{HelpView-g1BzpeC9.js → HelpView-B9Pny6u9.js} +4 -4
  14. package/dist/assets/{ImportLessonsModal-DMyN1EmC.js → ImportLessonsModal-hKRuw9p4.js} +1 -1
  15. package/dist/assets/{ImportModal-B2acbUT8.js → ImportModal-CVXHZ1Ei.js} +1 -1
  16. package/dist/assets/{InteractiveCoursewareViewer-DEicS9GQ.js → InteractiveCoursewareViewer-DMMAs2k_.js} +1 -1
  17. package/dist/assets/InteractiveWhiteboard-jpweQ094.js +77 -0
  18. package/dist/assets/{LazyCourseware-Bjy3qJWw.js → LazyCourseware-DVgyrg3r.js} +2 -2
  19. package/dist/assets/{LazyWhiteboard-D4Bj3dzG.js → LazyWhiteboard-BSzsAPXC.js} +2 -2
  20. package/dist/assets/{LessonEditorView-DpU2g0Uq.js → LessonEditorView-D4g48fr5.js} +1 -1
  21. package/dist/assets/LiveClassroomView-HQGhVwd_.js +1 -0
  22. package/dist/assets/{NotificationDetailModal-DC3AYUtD.js → NotificationDetailModal-1MOkBDr_.js} +1 -1
  23. package/dist/assets/{PluginView-S6TW3dLz.js → PluginView-D8cQ8mLM.js} +3 -3
  24. package/dist/assets/{ProcessLogsModal-MtK-Pe4R.js → ProcessLogsModal-gZ9LILYM.js} +1 -1
  25. package/dist/assets/{QuizGeneratorModal-Bh6XOC83.js → QuizGeneratorModal-BFCJe1cm.js} +1 -1
  26. package/dist/assets/{StudentAssignmentView-BCwbft0k.js → StudentAssignmentView-C34nleZT.js} +1 -1
  27. package/dist/assets/{StudentLessonView-CewqFUl5.js → StudentLessonView-DNbu2o04.js} +1 -1
  28. package/dist/assets/{StudentPreviewModal-Blww1Gv5.js → StudentPreviewModal-BXbKzQgo.js} +1 -1
  29. package/dist/assets/{StudentView-CR-84lLY.js → StudentView-BF2WjF4_.js} +2 -2
  30. package/dist/assets/{SystemResourceLibraryModal-Dyv2YJ7c.js → SystemResourceLibraryModal-Ce-Dis6M.js} +1 -1
  31. package/dist/assets/TeacherView-BySyCiZG.js +2 -0
  32. package/dist/assets/{TimetableView-Do_LWpV2.js → TimetableView-0VaTfpz3.js} +4 -4
  33. package/dist/assets/index-C7jMgry5.js +216 -0
  34. package/dist/assets/index-huP36ZNf.css +1 -0
  35. package/dist/assets/{vendor-icons-B2DRJfZU.js → vendor-icons-30iiuCQb.js} +1 -1
  36. package/dist/index.html +2 -2
  37. package/dist/server.cjs +2730 -357
  38. package/package.json +21 -22
  39. package/dist/assets/InteractiveWhiteboard-DbpOL5pI.js +0 -78
  40. package/dist/assets/LiveClassroomView-Bqrth_T2.js +0 -1
  41. package/dist/assets/TeacherView-Cszhv8bO.js +0 -2
  42. package/dist/assets/index-Ch_mD6B0.css +0 -1
  43. package/dist/assets/index-CnuEzAfQ.js +0 -202
package/dist/server.cjs CHANGED
@@ -343,7 +343,7 @@ var init_token = __esm({
343
343
  });
344
344
 
345
345
  // packages/core/di/interfaces.ts
346
- 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;
346
+ 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;
347
347
  var init_interfaces = __esm({
348
348
  "packages/core/di/interfaces.ts"() {
349
349
  init_token();
@@ -402,6 +402,9 @@ var init_interfaces = __esm({
402
402
  IPlatformServiceRegistryToken = new Token(
403
403
  "@openlearn/core:IPlatformServiceRegistryService"
404
404
  );
405
+ ICoursewareRuntimeScriptRegistryToken = new Token(
406
+ "@openlearn/core:ICoursewareRuntimeScriptRegistry"
407
+ );
405
408
  }
406
409
  });
407
410
 
@@ -682,6 +685,93 @@ var init_ai_service = __esm({
682
685
  }
683
686
  });
684
687
 
688
+ // packages/core/di/courseware-runtime-script-registry.ts
689
+ var DEFAULT_PRIORITY, DEFAULT_POSITION, CoursewareRuntimeScriptRegistry;
690
+ var init_courseware_runtime_script_registry = __esm({
691
+ "packages/core/di/courseware-runtime-script-registry.ts"() {
692
+ DEFAULT_PRIORITY = 100;
693
+ DEFAULT_POSITION = "body-end";
694
+ CoursewareRuntimeScriptRegistry = class _CoursewareRuntimeScriptRegistry {
695
+ constructor() {
696
+ /** key = `${owner}::${id}`,保证不同插件可以使用相同的脚本 id */
697
+ this.scripts = /* @__PURE__ */ new Map();
698
+ }
699
+ static key(owner, id) {
700
+ return `${owner}::${id}`;
701
+ }
702
+ /**
703
+ * 注册(或覆盖)一个课件运行时脚本。
704
+ *
705
+ * @param owner - 注册方,通常是 `ctx.pluginId`
706
+ * @param script - 脚本描述;同一 owner 下相同 id 视为覆盖
707
+ */
708
+ register(owner, script) {
709
+ if (!owner) throw new Error("CoursewareRuntimeScriptRegistry.register: owner is required");
710
+ if (!script || !script.id) throw new Error("CoursewareRuntimeScriptRegistry.register: script.id is required");
711
+ if (typeof script.source !== "string" || !script.source.trim()) {
712
+ throw new Error("CoursewareRuntimeScriptRegistry.register: script.source must be a non-empty string");
713
+ }
714
+ this.scripts.set(_CoursewareRuntimeScriptRegistry.key(owner, script.id), {
715
+ ...script,
716
+ owner,
717
+ priority: typeof script.priority === "number" ? script.priority : DEFAULT_PRIORITY,
718
+ position: script.position === "head" ? "head" : DEFAULT_POSITION
719
+ });
720
+ }
721
+ /** 注销某个 owner 下的单个脚本 */
722
+ unregister(owner, id) {
723
+ this.scripts.delete(_CoursewareRuntimeScriptRegistry.key(owner, id));
724
+ }
725
+ /**
726
+ * 清理脚本。
727
+ *
728
+ * @param owner - 省略时清空所有 owner(谨慎使用,仅用于测试或平台重置)
729
+ */
730
+ clear(owner) {
731
+ if (!owner) {
732
+ this.scripts.clear();
733
+ return;
734
+ }
735
+ const prefix = `${owner}::`;
736
+ for (const key of [...this.scripts.keys()]) {
737
+ if (key.startsWith(prefix)) this.scripts.delete(key);
738
+ }
739
+ }
740
+ /**
741
+ * 列出对目标课件生效的脚本(已按 priority 升序排序)。
742
+ *
743
+ * @param courseware - 目标课件的 `id` / `uuid`;省略时只返回「全局脚本」(两者均未限定者)
744
+ */
745
+ list(courseware) {
746
+ const matched = [];
747
+ for (const entry of this.scripts.values()) {
748
+ if (this.matches(entry, courseware)) matched.push(entry);
749
+ }
750
+ return matched.map((entry, index) => ({ entry, index })).sort((a, b) => a.entry.priority - b.entry.priority || a.index - b.index).map((item) => item.entry);
751
+ }
752
+ /** 列出当前所有注册方(便于诊断与测试) */
753
+ listOwners() {
754
+ return [...new Set([...this.scripts.values()].map((entry) => entry.owner))];
755
+ }
756
+ /**
757
+ * 判定脚本是否对目标课件生效。
758
+ *
759
+ * - 未限定 `coursewareId` 也未限定 `coursewareUuid` → 全局脚本,对所有课件生效;
760
+ * - 限定了任一者 → 需要与传入的 `courseware` 对应字段精确相等(两者都命中任一即算命中)。
761
+ */
762
+ matches(entry, courseware) {
763
+ const scopedById = typeof entry.coursewareId === "string" && entry.coursewareId.length > 0;
764
+ const scopedByUuid = typeof entry.coursewareUuid === "string" && entry.coursewareUuid.length > 0;
765
+ if (!scopedById && !scopedByUuid) return true;
766
+ if (!courseware) return false;
767
+ if (scopedById && courseware.id && entry.coursewareId === courseware.id) return true;
768
+ if (scopedByUuid && courseware.uuid && entry.coursewareUuid === courseware.uuid) return true;
769
+ return false;
770
+ }
771
+ };
772
+ }
773
+ });
774
+
685
775
  // packages/core/plugin-host/types.ts
686
776
  var PLUGIN_SHARED_MODULES;
687
777
  var init_types = __esm({
@@ -873,9 +963,9 @@ var init_http_router = __esm({
873
963
  /**
874
964
  * 注册指定动词与路径的 Handler
875
965
  */
876
- route(method, path21, handler) {
966
+ route(method, path24, handler) {
877
967
  const upperMethod = method.toUpperCase();
878
- const normalized = path21.startsWith("/") ? path21 : "/" + path21;
968
+ const normalized = path24.startsWith("/") ? path24 : "/" + path24;
879
969
  const { regex, paramNames } = compileRoutePattern(normalized);
880
970
  this.routes.push({
881
971
  method: upperMethod,
@@ -886,36 +976,36 @@ var init_http_router = __esm({
886
976
  isStream: false
887
977
  });
888
978
  }
889
- get(path21, handler) {
890
- this.route("GET", path21, handler);
979
+ get(path24, handler) {
980
+ this.route("GET", path24, handler);
891
981
  }
892
- post(path21, handler) {
893
- this.route("POST", path21, handler);
982
+ post(path24, handler) {
983
+ this.route("POST", path24, handler);
894
984
  }
895
- put(path21, handler) {
896
- this.route("PUT", path21, handler);
985
+ put(path24, handler) {
986
+ this.route("PUT", path24, handler);
897
987
  }
898
- patch(path21, handler) {
899
- this.route("PATCH", path21, handler);
988
+ patch(path24, handler) {
989
+ this.route("PATCH", path24, handler);
900
990
  }
901
- delete(path21, handler) {
902
- this.route("DELETE", path21, handler);
991
+ delete(path24, handler) {
992
+ this.route("DELETE", path24, handler);
903
993
  }
904
994
  stream(methodOrPath, pathOrHandler, maybeHandler) {
905
995
  if (typeof pathOrHandler === "function") {
906
- const path21 = methodOrPath;
996
+ const path24 = methodOrPath;
907
997
  const handler = pathOrHandler;
908
- this.registerStream("GET", path21, handler);
909
- this.registerStream("POST", path21, handler);
998
+ this.registerStream("GET", path24, handler);
999
+ this.registerStream("POST", path24, handler);
910
1000
  } else {
911
1001
  const method = methodOrPath.toUpperCase();
912
- const path21 = pathOrHandler;
1002
+ const path24 = pathOrHandler;
913
1003
  const handler = maybeHandler;
914
- this.registerStream(method, path21, handler);
1004
+ this.registerStream(method, path24, handler);
915
1005
  }
916
1006
  }
917
- registerStream(method, path21, handler) {
918
- const normalized = path21.startsWith("/") ? path21 : "/" + path21;
1007
+ registerStream(method, path24, handler) {
1008
+ const normalized = path24.startsWith("/") ? path24 : "/" + path24;
919
1009
  const { regex, paramNames } = compileRoutePattern(normalized);
920
1010
  this.routes.push({
921
1011
  method: method.toUpperCase(),
@@ -929,9 +1019,9 @@ var init_http_router = __esm({
929
1019
  /**
930
1020
  * 检查指定动词与路径是否为流式路由
931
1021
  */
932
- isStream(method, path21) {
1022
+ isStream(method, path24) {
933
1023
  const upperMethod = method.toUpperCase();
934
- const normalized = path21.startsWith("/") ? path21 : "/" + path21;
1024
+ const normalized = path24.startsWith("/") ? path24 : "/" + path24;
935
1025
  for (const entry of this.routes) {
936
1026
  if (entry.method !== upperMethod) continue;
937
1027
  if (entry.regex.test(normalized)) {
@@ -943,9 +1033,9 @@ var init_http_router = __esm({
943
1033
  /**
944
1034
  * 匹配路由并提取路由参数
945
1035
  */
946
- match(method, path21) {
1036
+ match(method, path24) {
947
1037
  const upperMethod = method.toUpperCase();
948
- const normalized = path21.startsWith("/") ? path21 : "/" + path21;
1038
+ const normalized = path24.startsWith("/") ? path24 : "/" + path24;
949
1039
  for (const entry of this.routes) {
950
1040
  if (entry.method !== upperMethod) continue;
951
1041
  const match = normalized.match(entry.regex);
@@ -1122,6 +1212,21 @@ __export(context_builder_exports, {
1122
1212
  createSafeFunction: () => createSafeFunction,
1123
1213
  registerSharedModule: () => registerSharedModule
1124
1214
  });
1215
+ function assertSafeSqlIdentifier(value, label) {
1216
+ if (typeof value !== "string" || !SQL_IDENTIFIER_PATTERN.test(value)) {
1217
+ throw new Error(`[SEC] Invalid SQL identifier for ${label}: ${JSON.stringify(value)}`);
1218
+ }
1219
+ return value;
1220
+ }
1221
+ function assertSafeCreateTableSchema(schema) {
1222
+ if (typeof schema !== "string" || schema.length === 0 || schema.length > 4e3) {
1223
+ throw new Error("[SEC] createTable schema must be a non-empty string (max 4000 chars)");
1224
+ }
1225
+ if (schema.includes(";")) {
1226
+ throw new Error('[SEC] createTable schema must not contain ";" (multiple statements are not allowed)');
1227
+ }
1228
+ return schema;
1229
+ }
1125
1230
  function registerSharedModule(name, mod) {
1126
1231
  sharedModules[name] = mod;
1127
1232
  }
@@ -1452,15 +1557,19 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
1452
1557
  const tablePrefix = `plugin_${pluginId.replace(/[^a-zA-Z0-9_]/g, "_")}_`;
1453
1558
  const dbApi = {
1454
1559
  async ensureTable(tableName, schema) {
1560
+ assertSafeSqlIdentifier(tableName, "ensureTable(tableName)");
1561
+ assertSafeCreateTableSchema(schema);
1455
1562
  const fullName = tablePrefix + tableName;
1456
1563
  db2.exec(`CREATE TABLE IF NOT EXISTS ${fullName} (${schema})`);
1457
1564
  },
1458
1565
  table(tableName) {
1566
+ assertSafeSqlIdentifier(tableName, "table(tableName)");
1459
1567
  return tablePrefix + tableName;
1460
1568
  },
1461
1569
  async dropAllTables() {
1462
1570
  const tables = db2.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?`).all(tablePrefix + "%");
1463
1571
  for (const t of tables) {
1572
+ if (!/^plugin_[A-Za-z0-9_]+$/.test(t.name)) continue;
1464
1573
  db2.exec(`DROP TABLE IF EXISTS ${t.name}`);
1465
1574
  }
1466
1575
  },
@@ -1558,7 +1667,7 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
1558
1667
  }
1559
1668
  };
1560
1669
  }
1561
- var sharedModules;
1670
+ var SQL_IDENTIFIER_PATTERN, sharedModules;
1562
1671
  var init_context_builder = __esm({
1563
1672
  "packages/core/plugin-host/context-builder.ts"() {
1564
1673
  init_types();
@@ -1567,6 +1676,7 @@ var init_context_builder = __esm({
1567
1676
  init_logger();
1568
1677
  init_interfaces();
1569
1678
  init_plugin_namespace();
1679
+ SQL_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
1570
1680
  sharedModules = {};
1571
1681
  }
1572
1682
  });
@@ -2842,6 +2952,7 @@ var init_di = __esm({
2842
2952
  init_interfaces();
2843
2953
  init_storage_service();
2844
2954
  init_ai_service();
2955
+ init_courseware_runtime_script_registry();
2845
2956
  init_container();
2846
2957
  }
2847
2958
  });
@@ -3387,15 +3498,29 @@ parentPort.on('message', async function(msg) {
3387
3498
  var dbService = rawServices['@openlearn/core:IDatabase'];
3388
3499
  var dbApi = dbService ? {
3389
3500
  ensureTable: function(tableName, schema) {
3501
+ // SEC: tableName / schema \u7531\u63D2\u4EF6\u63D0\u4F9B\uFF0C\u5C5E\u4E0D\u53EF\u4FE1\u8F93\u5165\u3002\u76F4\u63A5\u62FC\u63A5\u4F1A\u5BFC\u81F4 SQL \u6CE8\u5165
3502
+ // \uFF08\u4F8B\u5982 tableName = "t (x); DROP TABLE events; --"\uFF09\u3002\u6B64\u5904\u5F3A\u5236\u6807\u8BC6\u7B26\u767D\u540D\u5355
3503
+ // \u5E76\u7981\u6B62 schema \u4E2D\u7684\u5206\u53F7\uFF0C\u907F\u514D\u591A\u8BED\u53E5\u6CE8\u5165\u3002\u4E0E inline \u6A21\u5F0F\u4FDD\u6301\u4E00\u81F4\u3002
3504
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(String(tableName))) {
3505
+ throw new Error("[SEC] Invalid SQL identifier for ensureTable: " + String(tableName));
3506
+ }
3507
+ if (typeof schema !== "string" || schema.length === 0 || schema.length > 4000 || schema.indexOf(";") !== -1) {
3508
+ throw new Error("[SEC] Invalid CREATE TABLE schema fragment: must be non-empty and contain no semicolon");
3509
+ }
3390
3510
  var fullName = tablePrefix + tableName;
3391
3511
  return dbService.prepareAndRun('CREATE TABLE IF NOT EXISTS ' + fullName + ' (' + schema + ')', []);
3392
3512
  },
3393
3513
  table: function(tableName) {
3514
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(String(tableName))) {
3515
+ throw new Error("[SEC] Invalid SQL identifier for table(): " + String(tableName));
3516
+ }
3394
3517
  return tablePrefix + tableName;
3395
3518
  },
3396
3519
  dropAllTables: async function() {
3397
3520
  var tables = await dbService.prepareAndAll("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?", [tablePrefix + '%']);
3398
3521
  for (var i = 0; i < tables.length; i++) {
3522
+ // SEC: \u8868\u540D\u6765\u81EA sqlite_master\uFF0C\u4ECD\u4E8C\u6B21\u6821\u9A8C\u540E\u518D\u62FC\u8FDB DDL
3523
+ if (!/^plugin_[A-Za-z0-9_]+$/.test(String(tables[i].name))) continue;
3399
3524
  await dbService.prepareAndRun('DROP TABLE IF EXISTS ' + tables[i].name, []);
3400
3525
  }
3401
3526
  },
@@ -3592,12 +3717,12 @@ parentPort.on('message', async function(msg) {
3592
3717
  });
3593
3718
  `;
3594
3719
  }
3595
- var import_node_worker_threads, import_fs4, import_node_path3, import_uuid12, ALL_SERVICE_TOKENS, MAX_WORKERS, ACTIVATE_TIMEOUT_MS, ACTIVATE_PROGRESS_SLIDE_MS, WorkerRegistry, WorkerManager;
3720
+ var import_node_worker_threads, import_fs4, import_node_path4, import_uuid12, ALL_SERVICE_TOKENS, MAX_WORKERS, ACTIVATE_TIMEOUT_MS, ACTIVATE_PROGRESS_SLIDE_MS, WorkerRegistry, WorkerManager;
3596
3721
  var init_worker_manager = __esm({
3597
3722
  "packages/core/worker-runtime/worker-manager.ts"() {
3598
3723
  import_node_worker_threads = require("node:worker_threads");
3599
3724
  import_fs4 = __toESM(require("fs"), 1);
3600
- import_node_path3 = __toESM(require("node:path"), 1);
3725
+ import_node_path4 = __toESM(require("node:path"), 1);
3601
3726
  init_transport();
3602
3727
  init_service_host();
3603
3728
  init_logger();
@@ -3885,7 +4010,7 @@ var init_worker_manager = __esm({
3885
4010
  const bootstrapDataUrl = `data:text/javascript;base64,${encodedBootstrap}`;
3886
4011
  let worker;
3887
4012
  try {
3888
- const resolvedPluginDir = pluginDir && import_fs4.default.existsSync(import_node_path3.default.join(pluginDir, "index.js")) ? pluginDir : void 0;
4013
+ const resolvedPluginDir = pluginDir && import_fs4.default.existsSync(import_node_path4.default.join(pluginDir, "index.js")) ? pluginDir : void 0;
3889
4014
  worker = new import_node_worker_threads.Worker(new URL(bootstrapDataUrl), {
3890
4015
  // Pass both the DB id (`pluginId` — used as the actor/registry key)
3891
4016
  // and `manifestId` (used as the namespace prefix for command types).
@@ -4129,8 +4254,8 @@ __export(server_exports, {
4129
4254
  startServer: () => startServer
4130
4255
  });
4131
4256
  module.exports = __toCommonJS(server_exports);
4132
- var import_express3 = __toESM(require("express"), 1);
4133
- var import_path15 = __toESM(require("path"), 1);
4257
+ var import_express4 = __toESM(require("express"), 1);
4258
+ var import_path17 = __toESM(require("path"), 1);
4134
4259
  var import_dotenv = __toESM(require("dotenv"), 1);
4135
4260
  var import_os2 = __toESM(require("os"), 1);
4136
4261
  var import_child_process3 = require("child_process");
@@ -4172,6 +4297,14 @@ var EventBus = class {
4172
4297
 
4173
4298
  // packages/core/command-bus/index.ts
4174
4299
  var import_uuid = require("uuid");
4300
+ var DEFAULT_QUIET_COMMANDS = /* @__PURE__ */ new Set([
4301
+ "courseware.list",
4302
+ "courseware.get_attempt_raw_data",
4303
+ "whiteboard.query",
4304
+ "whiteboard.get_element",
4305
+ "vfs.read_path",
4306
+ "vfs.list_dir"
4307
+ ]);
4175
4308
  var CommandBus = class {
4176
4309
  constructor(eventBus) {
4177
4310
  this.eventBus = eventBus;
@@ -4215,9 +4348,12 @@ var CommandBus = class {
4215
4348
  if (!handler) {
4216
4349
  throw new Error(`No handler registered for command: ${normalizedCommand.type}`);
4217
4350
  }
4218
- console.log(
4219
- `[CommandBus] Executing: ${normalizedCommand.type} (ID: ${normalizedCommand.id}) by ${normalizedCommand.actorId}`
4220
- );
4351
+ const isQuiet = normalizedCommand.metadata?.silent === true || DEFAULT_QUIET_COMMANDS.has(normalizedCommand.type);
4352
+ if (!isQuiet || process.env.DEBUG?.includes("commandbus") || process.env.DEBUG_COMMAND_BUS === "true") {
4353
+ console.log(
4354
+ `[CommandBus] Executing: ${normalizedCommand.type} (ID: ${normalizedCommand.id}) by ${normalizedCommand.actorId}`
4355
+ );
4356
+ }
4221
4357
  try {
4222
4358
  const result = await handler.execute(normalizedCommand);
4223
4359
  return result;
@@ -4281,7 +4417,7 @@ var ActionRegistry = class {
4281
4417
  };
4282
4418
 
4283
4419
  // packages/core/capability-system/index.ts
4284
- var CapabilityGuard = class {
4420
+ var CapabilityGuard = class _CapabilityGuard {
4285
4421
  constructor() {
4286
4422
  this.actorCapabilities = /* @__PURE__ */ new Map();
4287
4423
  this.actorCapabilities.set("user-demo", ["*:*:*"]);
@@ -4321,11 +4457,33 @@ var CapabilityGuard = class {
4321
4457
  revokeAll(actorId) {
4322
4458
  this.actorCapabilities.delete(actorId);
4323
4459
  }
4460
+ static extractRole(actorId) {
4461
+ if (!actorId || typeof actorId !== "string") return null;
4462
+ const lastColon = actorId.lastIndexOf(":");
4463
+ if (lastColon === -1) return null;
4464
+ const role = actorId.slice(lastColon + 1);
4465
+ if (["administrator", "admin", "teacher", "student", "anonymous"].includes(role)) return role;
4466
+ return null;
4467
+ }
4324
4468
  check(actorId, requiredCap) {
4325
- const isAdmin = actorId === "role:administrator" || actorId?.endsWith(":administrator") || actorId === "admin" || actorId === "usr_admin" || actorId === "admin-demo";
4469
+ const role = _CapabilityGuard.extractRole(actorId);
4470
+ const isAdmin = actorId === "role:administrator" || actorId === "admin" || actorId === "usr_admin" || actorId === "admin-demo" || role === "administrator" || role === "admin";
4326
4471
  if (isAdmin) return true;
4327
- if (actorId?.endsWith(":teacher")) {
4328
- const teacherCaps = ["lesson:*", "whiteboard:*", "management:*", "quiz:*", "vfs:*", "process:*", "plugin:*"];
4472
+ if (role === "teacher") {
4473
+ const teacherCaps = [
4474
+ "lesson:*",
4475
+ "whiteboard:*",
4476
+ "management:*",
4477
+ "quiz:*",
4478
+ "vfs:*",
4479
+ "process:*",
4480
+ "plugin:*",
4481
+ "student:write",
4482
+ "assignment:read",
4483
+ "assignment:submit",
4484
+ "assignment:review",
4485
+ "assignment:manage"
4486
+ ];
4329
4487
  const [reqRes2, reqAct2] = requiredCap.split(":");
4330
4488
  if (teacherCaps.some((c) => {
4331
4489
  const [res, act] = c.split(":");
@@ -4334,8 +4492,15 @@ var CapabilityGuard = class {
4334
4492
  return true;
4335
4493
  }
4336
4494
  }
4337
- if (actorId?.endsWith(":student")) {
4338
- const studentCaps = ["student:write", "lesson:read", "whiteboard:read"];
4495
+ if (role === "student") {
4496
+ const studentCaps = [
4497
+ "student:write",
4498
+ "lesson:read",
4499
+ "whiteboard:read",
4500
+ "assignment:read",
4501
+ "assignment:submit",
4502
+ "assignment:review"
4503
+ ];
4339
4504
  const [reqRes2, reqAct2] = requiredCap.split(":");
4340
4505
  if (studentCaps.some((c) => {
4341
4506
  const [res, act] = c.split(":");
@@ -5061,15 +5226,89 @@ db.exec(`
5061
5226
  updated_at TEXT DEFAULT (datetime('now'))
5062
5227
  );
5063
5228
 
5229
+ -- \u2500\u2500 \u4F5C\u4E1A\u4E2D\u5FC3\uFF08Assignment Hub\uFF09\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5230
+ -- plugin_assignments \u662F\u4F5C\u4E1A\u5B9E\u4F53\uFF1A\u767D\u677F\u4E0A\u7684\u300C\u8BFE\u5802\u4F5C\u4E1A\u4EFB\u52A1\u300D\u6559\u5B66\u5BF9\u8C61\u53EA\u662F\u5B83\u7684
5231
+ -- \u4E00\u4E2A\u6295\u5F71\u7247\u6BB5\uFF08element_id\uFF09\uFF0Cclass_id \u8BA9\u5B83\u540C\u65F6\u51FA\u73B0\u5728\u73ED\u7EA7\u4F5C\u4E1A\u6210\u7EE9\u9875\u3002
5232
+ CREATE TABLE IF NOT EXISTS plugin_assignments (
5233
+ id TEXT PRIMARY KEY,
5234
+ class_id TEXT,
5235
+ lesson_id TEXT,
5236
+ element_id TEXT,
5237
+ title TEXT NOT NULL,
5238
+ description TEXT,
5239
+ instructions TEXT,
5240
+ due_at INTEGER,
5241
+ allow_late INTEGER NOT NULL DEFAULT 1,
5242
+ allow_text INTEGER NOT NULL DEFAULT 1,
5243
+ allow_link INTEGER NOT NULL DEFAULT 0,
5244
+ max_files INTEGER NOT NULL DEFAULT 10,
5245
+ max_file_size INTEGER NOT NULL DEFAULT 20971520,
5246
+ allowed_ext TEXT NOT NULL DEFAULT '',
5247
+ peer_review_mode TEXT NOT NULL DEFAULT 'assigned',
5248
+ peer_review_count INTEGER NOT NULL DEFAULT 2,
5249
+ peer_review_due_at INTEGER,
5250
+ teacher_weight REAL NOT NULL DEFAULT 0.6,
5251
+ peer_weight REAL NOT NULL DEFAULT 0.4,
5252
+ status TEXT NOT NULL DEFAULT 'published',
5253
+ created_by TEXT,
5254
+ created_at INTEGER NOT NULL,
5255
+ updated_at INTEGER NOT NULL
5256
+ );
5257
+
5258
+ -- \u4E00\u4E2A\u4F5C\u4E1A\u4E00\u4E2A\u5B66\u751F\u4E00\u884C\uFF08assignment_id \u975E\u7A7A\u65F6\uFF09\uFF1B\u4FDD\u7559 lesson_id \u5217\u4EE5\u517C\u5BB9\u65E7\u6570\u636E
5064
5259
  CREATE TABLE IF NOT EXISTS plugin_submissions (
5065
5260
  id TEXT PRIMARY KEY,
5066
- lesson_id TEXT NOT NULL,
5261
+ assignment_id TEXT,
5262
+ lesson_id TEXT,
5067
5263
  student_id TEXT NOT NULL,
5068
- file_path TEXT NOT NULL,
5264
+ file_path TEXT,
5069
5265
  version INTEGER NOT NULL DEFAULT 1,
5070
5266
  created_at INTEGER NOT NULL,
5071
- updated_at INTEGER NOT NULL,
5072
- UNIQUE(lesson_id, student_id)
5267
+ updated_at INTEGER NOT NULL
5268
+ );
5269
+
5270
+ -- \u6BCF\u6B21\u63D0\u4EA4\u90FD\u7559\u6863\uFF08\u91CD\u4EA4\u4E0D\u8986\u76D6\u5386\u53F2\uFF09
5271
+ CREATE TABLE IF NOT EXISTS plugin_submission_versions (
5272
+ id TEXT PRIMARY KEY,
5273
+ submission_id TEXT NOT NULL,
5274
+ assignment_id TEXT,
5275
+ student_id TEXT NOT NULL,
5276
+ version INTEGER NOT NULL,
5277
+ files_json TEXT NOT NULL DEFAULT '[]',
5278
+ text_content TEXT,
5279
+ link_url TEXT,
5280
+ is_late INTEGER NOT NULL DEFAULT 0,
5281
+ submitted_at INTEGER NOT NULL,
5282
+ UNIQUE(submission_id, version)
5283
+ );
5284
+
5285
+ -- \u4E0A\u4F20\u7684\u5B9E\u4F53\u6587\u4EF6\uFF08\u78C1\u76D8\u5B58\u50A8 + \u5143\u6570\u636E\uFF09\uFF0C\u4E0B\u8F7D\u7AEF\u70B9\u6309\u6B64\u8868\u9274\u6743
5286
+ CREATE TABLE IF NOT EXISTS plugin_assignment_files (
5287
+ id TEXT PRIMARY KEY,
5288
+ assignment_id TEXT NOT NULL,
5289
+ submission_id TEXT,
5290
+ version_id TEXT,
5291
+ student_id TEXT NOT NULL,
5292
+ original_name TEXT NOT NULL,
5293
+ stored_path TEXT NOT NULL,
5294
+ size INTEGER NOT NULL DEFAULT 0,
5295
+ mime TEXT,
5296
+ sha256 TEXT,
5297
+ uploaded_at INTEGER NOT NULL,
5298
+ deleted_at INTEGER
5299
+ );
5300
+
5301
+ -- \u4E92\u8BC4\u4EFB\u52A1\uFF08\u8C01\u8BC4\u8C01 / \u533F\u540D / \u622A\u6B62\uFF09\uFF0C\u7531\u5206\u914D\u7B56\u7565\u751F\u6210
5302
+ CREATE TABLE IF NOT EXISTS plugin_peer_review_tasks (
5303
+ id TEXT PRIMARY KEY,
5304
+ assignment_id TEXT NOT NULL,
5305
+ submission_id TEXT NOT NULL,
5306
+ reviewer_id TEXT NOT NULL,
5307
+ anonymous INTEGER NOT NULL DEFAULT 1,
5308
+ status TEXT NOT NULL DEFAULT 'pending',
5309
+ due_at INTEGER,
5310
+ created_at INTEGER NOT NULL,
5311
+ UNIQUE(submission_id, reviewer_id)
5073
5312
  );
5074
5313
 
5075
5314
  CREATE TABLE IF NOT EXISTS plugin_peer_reviews (
@@ -5079,6 +5318,11 @@ db.exec(`
5079
5318
  score INTEGER NOT NULL,
5080
5319
  comment TEXT,
5081
5320
  created_at INTEGER NOT NULL,
5321
+ assignment_id TEXT,
5322
+ task_id TEXT,
5323
+ anonymous INTEGER NOT NULL DEFAULT 1,
5324
+ status TEXT NOT NULL DEFAULT 'submitted',
5325
+ updated_at INTEGER,
5082
5326
  UNIQUE(submission_id, reviewer_id)
5083
5327
  );
5084
5328
 
@@ -5091,7 +5335,12 @@ db.exec(`
5091
5335
  peer_weight REAL NOT NULL DEFAULT 0.4,
5092
5336
  calculated_final_score INTEGER,
5093
5337
  status TEXT NOT NULL DEFAULT 'draft',
5094
- graded_at INTEGER
5338
+ graded_at INTEGER,
5339
+ assignment_id TEXT,
5340
+ peer_average_score REAL,
5341
+ source TEXT NOT NULL DEFAULT 'teacher',
5342
+ published_at INTEGER,
5343
+ graded_by TEXT
5095
5344
  );
5096
5345
 
5097
5346
  -- Performance Indexes
@@ -5104,7 +5353,22 @@ db.exec(`
5104
5353
  CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(type, timestamp);
5105
5354
  CREATE INDEX IF NOT EXISTS idx_assignments_class ON assignments(class_id);
5106
5355
  CREATE INDEX IF NOT EXISTS idx_attendance_schedule ON attendance(schedule_id);
5356
+
5357
+ -- \u4F5C\u4E1A\u4E2D\u5FC3\u7D22\u5F15
5358
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_assignments_element ON plugin_assignments(element_id);
5359
+ CREATE INDEX IF NOT EXISTS idx_plugin_assignments_lesson ON plugin_assignments(lesson_id);
5360
+ CREATE INDEX IF NOT EXISTS idx_plugin_assignments_class ON plugin_assignments(class_id);
5361
+ CREATE INDEX IF NOT EXISTS idx_plugin_submission_versions_submission ON plugin_submission_versions(submission_id);
5362
+ CREATE INDEX IF NOT EXISTS idx_plugin_assignment_files_owner ON plugin_assignment_files(assignment_id, student_id);
5363
+ CREATE INDEX IF NOT EXISTS idx_plugin_peer_review_tasks_reviewer ON plugin_peer_review_tasks(reviewer_id, status);
5107
5364
  `);
5365
+ try {
5366
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_submissions_assignment_student ON plugin_submissions(assignment_id, student_id)");
5367
+ db.exec(
5368
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_submissions_legacy ON plugin_submissions(lesson_id, student_id) WHERE assignment_id IS NULL"
5369
+ );
5370
+ } catch {
5371
+ }
5108
5372
  try {
5109
5373
  db.exec("ALTER TABLE lessons ADD COLUMN creator_id TEXT");
5110
5374
  } catch {
@@ -6606,7 +6870,632 @@ ${htmlContent}
6606
6870
  }
6607
6871
  };
6608
6872
 
6873
+ // packages/plugins/score-monitor-script.ts
6874
+ var SCORE_MONITOR_SCRIPT_ID = "score-variable-monitor";
6875
+ var SCORE_MONITOR_SCRIPT_OWNER = "@openlearn/plugin-builtin";
6876
+ var SCORE_MONITOR_SCRIPT = `(function () {
6877
+ 'use strict';
6878
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
6879
+ if (window.__LMS_WATCH_DISABLED__ === true) return;
6880
+ if (window.__LMS_WATCH__ === false) return;
6881
+ if (window.__LMS_SCORE_WATCHER_READY__ === true) return;
6882
+ window.__LMS_SCORE_WATCHER_READY__ = true;
6883
+
6884
+ var INTERVAL_MS = 800;
6885
+ var SILENCE_MS = 1200;
6886
+ var MAX_SUBMITS = 60;
6887
+ var KEY_RE = /score|point|grade|mark|correct|right/i;
6888
+ var SKIP_RE = /^_|^on[A-Z]|^webkit|^moz|^ms/;
6889
+ var NUM_RE = /[0-9]+(?:[.][0-9]+)?/;
6890
+ var RATIO_RE = /([0-9]+(?:[.][0-9]+)?)[^0-9]*[/\uFF0F|\u4E4B][^0-9]*([0-9]+)/;
6891
+ var DOM_SELECTORS = [
6892
+ '#score', '#points', '#grade', '#finalScore', '#totalScore', '#scoreDisplay', '#score-num',
6893
+ '.score', '.points', '.grade', '.final-score',
6894
+ '[id*="score" i]', '[id*="point" i]', '[id*="grade" i]'
6895
+ ];
6896
+
6897
+ var state = { snapshot: null, pendingChanged: null, timer: null, submitted: 0, stopped: false };
6898
+ var seenElements = [];
6899
+
6900
+ function num(value) {
6901
+ if (typeof value === 'number') return isFinite(value) ? value : null;
6902
+ if (typeof value !== 'string') return null;
6903
+ var match = value.match(RATIO_RE);
6904
+ if (match) {
6905
+ var part = parseFloat(match[1]);
6906
+ var whole = parseFloat(match[2]);
6907
+ if (whole > 0) return Math.round((part / whole) * 10000) / 100;
6908
+ }
6909
+ var found = value.match(NUM_RE);
6910
+ if (!found) return null;
6911
+ var parsed = parseFloat(found[0]);
6912
+ return isFinite(parsed) ? parsed : null;
6913
+ }
6914
+
6915
+ function selectorKey(selector) {
6916
+ return 'dom_' + selector.replace(/[^A-Za-z0-9_-]+/g, '_');
6917
+ }
6918
+
6919
+ function isShown(el) {
6920
+ if (!el || !el.getBoundingClientRect) return false;
6921
+ var rect = el.getBoundingClientRect();
6922
+ if (rect.width <= 0 || rect.height <= 0) return false;
6923
+ var node = el;
6924
+ while (node && node.nodeType === 1) {
6925
+ var style = null;
6926
+ try { style = window.getComputedStyle(node); } catch (err) { style = null; }
6927
+ if (style && (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')) return false;
6928
+ node = node.parentNode;
6929
+ }
6930
+ return true;
6931
+ }
6932
+
6933
+ function resolvePath(path) {
6934
+ try {
6935
+ var node = window;
6936
+ var parts = String(path).split('.');
6937
+ for (var i = 0; i < parts.length; i++) {
6938
+ if (node === null || typeof node === 'undefined') return null;
6939
+ node = node[parts[i]];
6940
+ }
6941
+ return node;
6942
+ } catch (err) {
6943
+ return null;
6944
+ }
6945
+ }
6946
+
6947
+ function snapshot() {
6948
+ var result = {};
6949
+ var explicit = window.__LMS_WATCH__;
6950
+ var names = [];
6951
+ if (typeof explicit === 'string' && explicit) names.push(explicit);
6952
+ else if (Object.prototype.toString.call(explicit) === '[object Array]') {
6953
+ for (var i = 0; i < explicit.length; i++) if (typeof explicit[i] === 'string') names.push(explicit[i]);
6954
+ }
6955
+ if (names.length) {
6956
+ for (var n = 0; n < names.length; n++) {
6957
+ var value = num(resolvePath(names[n]));
6958
+ if (value !== null) result[names[n]] = value;
6959
+ }
6960
+ } else {
6961
+ var keys = Object.keys(window);
6962
+ for (var k = 0; k < keys.length; k++) {
6963
+ var key = keys[k];
6964
+ if (SKIP_RE.test(key) || !KEY_RE.test(key)) continue;
6965
+ var raw = null;
6966
+ try { raw = window[key]; } catch (err) { continue; }
6967
+ if (typeof raw !== 'number' && typeof raw !== 'string') continue;
6968
+ var numValue = num(raw);
6969
+ if (numValue !== null) result[key] = numValue;
6970
+ }
6971
+ }
6972
+ seenElements = [];
6973
+ for (var s = 0; s < DOM_SELECTORS.length; s++) {
6974
+ var selector = DOM_SELECTORS[s];
6975
+ var nodes = [];
6976
+ try { nodes = document.querySelectorAll(selector); } catch (err) { nodes = []; }
6977
+ for (var t = 0; t < nodes.length; t++) {
6978
+ var el = nodes[t];
6979
+ if (seenElements.indexOf(el) !== -1) continue;
6980
+ seenElements.push(el);
6981
+ if (!isShown(el)) continue;
6982
+ var text = (el.textContent || '').trim();
6983
+ if (!text || text.length > 40) continue;
6984
+ var domValue = num(text);
6985
+ if (domValue === null) continue;
6986
+ if (typeof result[selectorKey(selector)] === 'undefined') result[selectorKey(selector)] = domValue;
6987
+ }
6988
+ }
6989
+ return result;
6990
+ }
6991
+
6992
+ function diff(prev, next) {
6993
+ var changed = {};
6994
+ var keys = Object.keys(next);
6995
+ for (var i = 0; i < keys.length; i++) {
6996
+ var key = keys[i];
6997
+ if (!prev || prev[key] !== next[key]) changed[key] = next[key];
6998
+ }
6999
+ return changed;
7000
+ }
7001
+
7002
+ function report(payload) {
7003
+ try {
7004
+ if (window.LMS && typeof window.LMS.saveProgress === 'function') {
7005
+ window.LMS.saveProgress(payload);
7006
+ return true;
7007
+ }
7008
+ } catch (err) { /* \u5FFD\u7565\u5E76\u5C1D\u8BD5\u515C\u5E95\u901A\u9053 */ }
7009
+ try {
7010
+ var student = window.__LMS_STUDENT__ || {};
7011
+ var courseware = window.__LMS_COURSEWARE__ || {};
7012
+ window.parent.postMessage(
7013
+ { type: 'LMS_SAVE_PROGRESS', uuid: courseware.uuid, attempt_id: student.attempt_id, payload: payload },
7014
+ '*'
7015
+ );
7016
+ return true;
7017
+ } catch (err) {
7018
+ return false;
7019
+ }
7020
+ }
7021
+
7022
+ function flush() {
7023
+ if (state.stopped) return;
7024
+ if (window.__LMS_WATCH_DISABLED__ === true) { state.stopped = true; return; }
7025
+ var changed = state.pendingChanged || {};
7026
+ state.pendingChanged = null;
7027
+ var keys = Object.keys(changed);
7028
+ if (!keys.length) return;
7029
+ if (state.submitted >= MAX_SUBMITS) return;
7030
+ var best = null;
7031
+ for (var i = 0; i < keys.length; i++) {
7032
+ var value = changed[keys[i]];
7033
+ if (typeof value !== 'number') continue;
7034
+ if (best === null || value > best) best = value;
7035
+ }
7036
+ if (best === null) return;
7037
+ var snap = {};
7038
+ var all = state.snapshot || {};
7039
+ var allKeys = Object.keys(all);
7040
+ for (var j = 0; j < allKeys.length; j++) snap[allKeys[j]] = all[allKeys[j]];
7041
+ snap._changed = keys;
7042
+ snap._at = Date.now();
7043
+ try { console.warn('Score variable changed (' + keys.join(', ') + ') -> ' + best); } catch (err) { /* noop */ }
7044
+ var ok = report({ score: best, watch: snap });
7045
+ if (ok) state.submitted = state.submitted + 1;
7046
+ }
7047
+
7048
+ function tick() {
7049
+ if (state.stopped) return;
7050
+ if (window.__LMS_WATCH_DISABLED__ === true) { state.stopped = true; return; }
7051
+ if (state.submitted >= MAX_SUBMITS) { state.stopped = true; return; }
7052
+ var student = window.__LMS_STUDENT__;
7053
+ if (!student || !student.attempt_id) return;
7054
+ var next = snapshot();
7055
+ var changed = diff(state.snapshot, next);
7056
+ state.snapshot = next;
7057
+ if (!Object.keys(changed).length) return;
7058
+ state.pendingChanged = Object.assign(state.pendingChanged || {}, changed);
7059
+ if (state.timer) clearTimeout(state.timer);
7060
+ state.timer = setTimeout(function () { state.timer = null; flush(); }, SILENCE_MS);
7061
+ }
7062
+
7063
+ function init() {
7064
+ state.snapshot = snapshot();
7065
+ setInterval(tick, INTERVAL_MS);
7066
+ try {
7067
+ if (!window.__LMS_SCORE_WATCHERS__ || Object.prototype.toString.call(window.__LMS_SCORE_WATCHERS__) !== '[object Array]') {
7068
+ window.__LMS_SCORE_WATCHERS__ = [];
7069
+ }
7070
+ window.__LMS_SCORE_WATCHERS__.push({
7071
+ stop: function () {
7072
+ state.stopped = true;
7073
+ if (state.timer) { clearTimeout(state.timer); state.timer = null; }
7074
+ }
7075
+ });
7076
+ } catch (err) { /* noop */ }
7077
+ if (typeof MutationObserver === 'function' && document.documentElement) {
7078
+ var scheduled = false;
7079
+ var observer = new MutationObserver(function () {
7080
+ if (scheduled) return;
7081
+ scheduled = true;
7082
+ setTimeout(function () { scheduled = false; tick(); }, 300);
7083
+ });
7084
+ try {
7085
+ observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
7086
+ } catch (err) { /* noop */ }
7087
+ }
7088
+ try { console.warn('[native] score monitor armed, watching score-like variables'); } catch (err) { /* noop */ }
7089
+ }
7090
+
7091
+ if (document.body) init();
7092
+ else document.addEventListener('DOMContentLoaded', init);
7093
+ })();`;
7094
+
7095
+ // packages/plugins/courseware-score.ts
7096
+ var DEFAULT_SCORE_POLICY = "LATEST";
7097
+ var GLOBAL_SCORE_CONFIG_KEY = "*";
7098
+ var SCORE_CONFIG_TABLE = "courseware_score_config";
7099
+ var DEFAULT_SCORE_CONFIG = {
7100
+ courseware_id: GLOBAL_SCORE_CONFIG_KEY,
7101
+ courseware_name: null,
7102
+ score_policy: DEFAULT_SCORE_POLICY,
7103
+ score_fields: "",
7104
+ raw_full_score: 100,
7105
+ target_full_score: 100,
7106
+ weight_percentage: 100,
7107
+ lesson_id: null,
7108
+ updated_at: 0
7109
+ };
7110
+ var BUILTIN_SCORE_CONFIG = { ...DEFAULT_SCORE_CONFIG };
7111
+ function toNumber(value) {
7112
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
7113
+ if (typeof value === "boolean") return null;
7114
+ if (typeof value === "string") {
7115
+ const trimmed = value.trim();
7116
+ if (!trimmed) return null;
7117
+ const cleaned = trimmed.endsWith("%") ? trimmed.slice(0, -1).trim() : trimmed;
7118
+ if (!cleaned || !/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(cleaned)) return null;
7119
+ const num = Number(cleaned);
7120
+ return Number.isFinite(num) ? num : null;
7121
+ }
7122
+ return null;
7123
+ }
7124
+ function clamp(value, min, max) {
7125
+ if (!Number.isFinite(value)) return min;
7126
+ if (value < min) return min;
7127
+ if (value > max) return max;
7128
+ return value;
7129
+ }
7130
+ function round2(value) {
7131
+ if (!Number.isFinite(value)) return value;
7132
+ return Math.round(value * 100) / 100;
7133
+ }
7134
+ function isKnownScorePolicy(value) {
7135
+ if (typeof value !== "string") return false;
7136
+ const raw = value.trim().toUpperCase().replace(/[\s-]+/g, "_");
7137
+ return KNOWN_POLICY_ALIASES.has(raw);
7138
+ }
7139
+ var KNOWN_POLICY_ALIASES = /* @__PURE__ */ new Set([
7140
+ "LATEST",
7141
+ "LAST",
7142
+ "LATEST_SCORE",
7143
+ "\u6700\u65B0",
7144
+ "\u6700\u8FD1",
7145
+ "MAX",
7146
+ "MAXIMUM",
7147
+ "HIGHEST",
7148
+ "MAX_SCORE",
7149
+ "\u6700\u9AD8",
7150
+ "\u6700\u9AD8\u5206",
7151
+ "AVERAGE",
7152
+ "AVG",
7153
+ "MEAN",
7154
+ "\u5E73\u5747",
7155
+ "\u5E73\u5747\u5206",
7156
+ "FIRST",
7157
+ "EARLIEST",
7158
+ "\u9996\u4E2A",
7159
+ "\u6700\u65E9"
7160
+ ]);
7161
+ function normalizeScorePolicy(value) {
7162
+ const raw = typeof value === "string" ? value.trim().toUpperCase().replace(/[\s-]+/g, "_") : "";
7163
+ if (["MAX", "MAXIMUM", "HIGHEST", "MAX_SCORE", "\u6700\u9AD8", "\u6700\u9AD8\u5206"].includes(raw)) return "MAX";
7164
+ if (["AVERAGE", "AVG", "MEAN", "\u5E73\u5747", "\u5E73\u5747\u5206"].includes(raw)) return "AVERAGE";
7165
+ if (["FIRST", "EARLIEST", "\u9996\u4E2A", "\u6700\u65E9"].includes(raw)) return "FIRST";
7166
+ return "LATEST";
7167
+ }
7168
+ function parseScoreFields(fields) {
7169
+ if (Array.isArray(fields)) {
7170
+ return fields.map((f) => String(f ?? "").trim()).filter(Boolean);
7171
+ }
7172
+ if (typeof fields !== "string" || !fields.trim()) return [];
7173
+ return fields.split(/[\n\r,,;;|]+/).map((f) => f.trim()).filter(Boolean);
7174
+ }
7175
+ function getNested(raw, path24) {
7176
+ if (raw === null || raw === void 0) return void 0;
7177
+ if (!path24) return void 0;
7178
+ const parts = String(path24).split(".").map((p) => p.trim()).filter(Boolean);
7179
+ let cursor = raw;
7180
+ for (let i = 0; i < parts.length; i += 1) {
7181
+ if (cursor === null || cursor === void 0) return void 0;
7182
+ const part = parts[i];
7183
+ if (/^\d+$/.test(part)) {
7184
+ const idx = Number(part);
7185
+ cursor = Array.isArray(cursor) ? cursor[idx] : cursor[part];
7186
+ } else {
7187
+ cursor = cursor[part];
7188
+ }
7189
+ if (i < parts.length - 1 && typeof cursor === "string" && /^[[{]/.test(cursor.trim())) {
7190
+ try {
7191
+ cursor = JSON.parse(cursor);
7192
+ } catch (e) {
7193
+ }
7194
+ }
7195
+ }
7196
+ return cursor;
7197
+ }
7198
+ var COMMON_SCORE_KEYS = [
7199
+ "score",
7200
+ "points",
7201
+ "point",
7202
+ "grade",
7203
+ "mark",
7204
+ "correct",
7205
+ "right",
7206
+ "userScore",
7207
+ "totalScore",
7208
+ "finalScore",
7209
+ "rawScore"
7210
+ ];
7211
+ var COMMON_SCORE_CONTAINERS = ["data", "body", "payload", "detail", "summary", "progress"];
7212
+ var SCAN_MAX_DEPTH = 4;
7213
+ var SCAN_MAX_NODES = 300;
7214
+ function scanForScore(node, depth, budget) {
7215
+ if (node === null || node === void 0) return null;
7216
+ budget.count += 1;
7217
+ if (budget.count > SCAN_MAX_NODES || depth > SCAN_MAX_DEPTH) return null;
7218
+ if (typeof node === "string") {
7219
+ const trimmed = node.trim();
7220
+ if (!trimmed) return null;
7221
+ if (/^[[{]/.test(trimmed)) {
7222
+ try {
7223
+ return scanForScore(JSON.parse(trimmed), depth + 1, budget);
7224
+ } catch (e) {
7225
+ return null;
7226
+ }
7227
+ }
7228
+ const keyValue = /(?:^|[?&\s,;])(?:score|point|points|grade|mark|correct|right)=([+-]?\d+(?:\.\d+)?)/i.exec(trimmed);
7229
+ if (keyValue) return Number(keyValue[1]);
7230
+ const ratio = /^([+-]?\d+(?:\.\d+)?)\s*\/\s*([+-]?\d+(?:\.\d+)?)$/.exec(trimmed);
7231
+ if (ratio) return Number(ratio[1]);
7232
+ return null;
7233
+ }
7234
+ if (typeof node !== "object") return null;
7235
+ for (const key of COMMON_SCORE_KEYS) {
7236
+ const value = toNumber(node[key]);
7237
+ if (value !== null) return value;
7238
+ }
7239
+ for (const key of Object.keys(node)) {
7240
+ const child = node[key];
7241
+ if (child === null || child === void 0) continue;
7242
+ if (typeof child !== "object" && typeof child !== "string") continue;
7243
+ const value = scanForScore(child, depth + 1, budget);
7244
+ if (value !== null) return value;
7245
+ }
7246
+ return null;
7247
+ }
7248
+ function extractScoreFromFields(raw, fields) {
7249
+ for (const field of parseScoreFields(fields)) {
7250
+ const value = toNumber(getNested(raw, field));
7251
+ if (value !== null) return value;
7252
+ }
7253
+ return null;
7254
+ }
7255
+ function pickScoreFromPayload(payload, fields) {
7256
+ const byFields = extractScoreFromFields(payload, fields);
7257
+ if (byFields !== null) return byFields;
7258
+ if (typeof payload === "string") return scanForScore(payload, 0, { count: 0 });
7259
+ if (payload === null || typeof payload !== "object") return null;
7260
+ for (const key of COMMON_SCORE_KEYS) {
7261
+ const value = toNumber(payload[key]);
7262
+ if (value !== null) return value;
7263
+ }
7264
+ for (const container of COMMON_SCORE_CONTAINERS) {
7265
+ const nested = payload[container];
7266
+ if (nested === null || typeof nested !== "object") continue;
7267
+ for (const key of COMMON_SCORE_KEYS) {
7268
+ const value = toNumber(nested[key]);
7269
+ if (value !== null) return value;
7270
+ }
7271
+ }
7272
+ return scanForScore(payload, 0, { count: 0 });
7273
+ }
7274
+ function aggregateScores(scores, policy) {
7275
+ const list = (Array.isArray(scores) ? scores : []).map((s) => toNumber(s)).filter((n) => n !== null);
7276
+ if (list.length === 0) return null;
7277
+ const resolved = normalizeScorePolicy(policy);
7278
+ if (resolved === "MAX") return list.reduce((a, b) => b > a ? b : a, list[0]);
7279
+ if (resolved === "AVERAGE") return list.reduce((a, b) => a + b, 0) / list.length;
7280
+ if (resolved === "FIRST") return list[0];
7281
+ return list[list.length - 1];
7282
+ }
7283
+ function normalizeScoreConfig(row) {
7284
+ if (!row || typeof row !== "object") return { ...BUILTIN_SCORE_CONFIG };
7285
+ const positive = (value, fallback) => {
7286
+ const num = toNumber(value);
7287
+ return num !== null && num > 0 ? num : fallback;
7288
+ };
7289
+ const weight = toNumber(row.weight_percentage);
7290
+ const updatedAt = toNumber(row.updated_at);
7291
+ return {
7292
+ courseware_id: String(row.courseware_id ?? GLOBAL_SCORE_CONFIG_KEY),
7293
+ courseware_name: row.courseware_name === void 0 || row.courseware_name === null ? null : String(row.courseware_name),
7294
+ score_policy: normalizeScorePolicy(row.score_policy),
7295
+ score_fields: typeof row.score_fields === "string" ? row.score_fields : "",
7296
+ raw_full_score: positive(row.raw_full_score, 100),
7297
+ target_full_score: positive(row.target_full_score, 100),
7298
+ weight_percentage: weight === null ? 100 : clamp(weight, 0, 1e3),
7299
+ lesson_id: row.lesson_id === void 0 || row.lesson_id === null ? null : String(row.lesson_id),
7300
+ updated_at: updatedAt ?? 0
7301
+ };
7302
+ }
7303
+ function resolveScoreConfig(db2, coursewareId) {
7304
+ const id = coursewareId === null || coursewareId === void 0 ? "" : String(coursewareId).trim();
7305
+ if (id && id !== GLOBAL_SCORE_CONFIG_KEY) {
7306
+ try {
7307
+ const row = db2.prepare(`SELECT * FROM ${SCORE_CONFIG_TABLE} WHERE courseware_id = ?`).get(id);
7308
+ if (row) return { config: normalizeScoreConfig(row), source: "courseware" };
7309
+ } catch (e) {
7310
+ }
7311
+ }
7312
+ try {
7313
+ const global = db2.prepare(`SELECT * FROM ${SCORE_CONFIG_TABLE} WHERE courseware_id = ?`).get(GLOBAL_SCORE_CONFIG_KEY);
7314
+ if (global) return { config: normalizeScoreConfig(global), source: "global" };
7315
+ } catch (e) {
7316
+ }
7317
+ return { config: { ...BUILTIN_SCORE_CONFIG }, source: "builtin" };
7318
+ }
7319
+ function listScoreConfigs(db2) {
7320
+ try {
7321
+ const rows = db2.prepare(
7322
+ `SELECT * FROM ${SCORE_CONFIG_TABLE} ORDER BY CASE WHEN courseware_id = '${GLOBAL_SCORE_CONFIG_KEY}' THEN 0 ELSE 1 END, updated_at DESC`
7323
+ ).all();
7324
+ return (rows || []).map(normalizeScoreConfig);
7325
+ } catch (e) {
7326
+ return [];
7327
+ }
7328
+ }
7329
+ function pickInput(input, camel, snake) {
7330
+ if (input[camel] !== void 0) return input[camel];
7331
+ if (input[snake] !== void 0) return input[snake];
7332
+ return void 0;
7333
+ }
7334
+ function saveScoreConfig(db2, input) {
7335
+ if (!input || typeof input !== "object") throw new Error("save_score_config: invalid payload");
7336
+ const coursewareId = String(
7337
+ pickInput(input, "coursewareId", "courseware_id") ?? ""
7338
+ ).trim();
7339
+ if (!coursewareId) throw new Error("save_score_config: coursewareId is required");
7340
+ if (coursewareId.length > 128) throw new Error("save_score_config: coursewareId is too long");
7341
+ const policyRaw = pickInput(input, "scorePolicy", "score_policy");
7342
+ if (policyRaw !== void 0 && policyRaw !== null && String(policyRaw).trim() !== "" && !isKnownScorePolicy(String(policyRaw))) {
7343
+ throw new Error(
7344
+ `save_score_config: unknown scorePolicy "${policyRaw}" (expected one of LATEST / MAX / AVERAGE / FIRST)`
7345
+ );
7346
+ }
7347
+ const fieldsRaw = pickInput(input, "scoreFields", "score_fields");
7348
+ const fields = Array.isArray(fieldsRaw) ? parseScoreFields(fieldsRaw).join(",") : parseScoreFields(fieldsRaw ?? "").join(",");
7349
+ if (fields.length > 2e3) throw new Error("save_score_config: scoreFields is too long");
7350
+ const nameRaw = pickInput(input, "coursewareName", "courseware_name");
7351
+ const lessonRaw = pickInput(input, "lessonId", "lesson_id");
7352
+ let existing = null;
7353
+ try {
7354
+ const row = db2.prepare(`SELECT * FROM ${SCORE_CONFIG_TABLE} WHERE courseware_id = ?`).get(coursewareId);
7355
+ if (row) existing = normalizeScoreConfig(row);
7356
+ } catch (e) {
7357
+ throw new Error("save_score_config: courseware_score_config table is missing (migration 004 not applied)");
7358
+ }
7359
+ const rawFull = toNumber(pickInput(input, "rawFullScore", "raw_full_score"));
7360
+ const targetFull = toNumber(pickInput(input, "targetFullScore", "target_full_score"));
7361
+ const weight = toNumber(pickInput(input, "weightPercentage", "weight_percentage"));
7362
+ if (rawFull !== void 0 && rawFull !== null && rawFull <= 0) throw new Error("save_score_config: rawFullScore must be > 0");
7363
+ if (targetFull !== void 0 && targetFull !== null && targetFull <= 0)
7364
+ throw new Error("save_score_config: targetFullScore must be > 0");
7365
+ if (weight !== void 0 && weight !== null && (weight < 0 || weight > 1e3))
7366
+ throw new Error("save_score_config: weightPercentage must be within [0, 1000]");
7367
+ const next = {
7368
+ courseware_id: coursewareId,
7369
+ courseware_name: nameRaw === void 0 ? existing?.courseware_name ?? (coursewareId === GLOBAL_SCORE_CONFIG_KEY ? "\u5168\u5C40\u9ED8\u8BA4\u7B56\u7565" : null) : nameRaw === null ? null : String(nameRaw).slice(0, 200),
7370
+ score_policy: policyRaw === void 0 || policyRaw === null || String(policyRaw).trim() === "" ? existing?.score_policy ?? DEFAULT_SCORE_POLICY : normalizeScorePolicy(policyRaw),
7371
+ score_fields: fields || (fieldsRaw === void 0 ? existing?.score_fields ?? "" : ""),
7372
+ raw_full_score: rawFull ?? existing?.raw_full_score ?? 100,
7373
+ target_full_score: targetFull ?? existing?.target_full_score ?? 100,
7374
+ weight_percentage: weight ?? existing?.weight_percentage ?? 100,
7375
+ lesson_id: lessonRaw === void 0 ? existing?.lesson_id ?? null : lessonRaw === null ? null : String(lessonRaw).slice(0, 128),
7376
+ updated_at: Date.now()
7377
+ };
7378
+ db2.prepare(
7379
+ `INSERT INTO ${SCORE_CONFIG_TABLE}
7380
+ (courseware_id, courseware_name, score_policy, score_fields, raw_full_score, target_full_score, weight_percentage, lesson_id, updated_at)
7381
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
7382
+ ON CONFLICT(courseware_id) DO UPDATE SET
7383
+ courseware_name = excluded.courseware_name,
7384
+ score_policy = excluded.score_policy,
7385
+ score_fields = excluded.score_fields,
7386
+ raw_full_score = excluded.raw_full_score,
7387
+ target_full_score = excluded.target_full_score,
7388
+ weight_percentage = excluded.weight_percentage,
7389
+ lesson_id = excluded.lesson_id,
7390
+ updated_at = excluded.updated_at`
7391
+ ).run(
7392
+ next.courseware_id,
7393
+ next.courseware_name,
7394
+ next.score_policy,
7395
+ next.score_fields,
7396
+ next.raw_full_score,
7397
+ next.target_full_score,
7398
+ next.weight_percentage,
7399
+ next.lesson_id,
7400
+ next.updated_at
7401
+ );
7402
+ return next;
7403
+ }
7404
+ function resolveCoursewareIdFromAttempt(db2, attemptId) {
7405
+ if (!attemptId) return null;
7406
+ try {
7407
+ const row = db2.prepare("SELECT courseware_id FROM courseware_attempt WHERE id = ?").get(attemptId);
7408
+ return row && row.courseware_id ? String(row.courseware_id) : null;
7409
+ } catch (e) {
7410
+ return null;
7411
+ }
7412
+ }
7413
+ function collectScoreSamples(db2, attemptId, fields) {
7414
+ if (!attemptId) return [];
7415
+ let rows = [];
7416
+ try {
7417
+ rows = db2.prepare("SELECT event_type, payload_json, created_at FROM submission_raw WHERE attempt_id = ? ORDER BY created_at ASC").all(attemptId) || [];
7418
+ } catch (e) {
7419
+ return [];
7420
+ }
7421
+ const samples = [];
7422
+ for (const row of rows) {
7423
+ let payload = null;
7424
+ try {
7425
+ payload = JSON.parse(row?.payload_json ?? "null");
7426
+ } catch (e) {
7427
+ payload = null;
7428
+ }
7429
+ if (payload === null || payload === void 0) continue;
7430
+ const value = pickScoreFromPayload(payload, fields);
7431
+ if (value === null) continue;
7432
+ samples.push({
7433
+ value,
7434
+ at: toNumber(row?.created_at) ?? 0,
7435
+ eventType: String(row?.event_type ?? "")
7436
+ });
7437
+ }
7438
+ return samples;
7439
+ }
7440
+ function aggregateAttemptScore(db2, attemptId, options = {}) {
7441
+ const coursewareId = options.coursewareId === void 0 ? resolveCoursewareIdFromAttempt(db2, attemptId) : options.coursewareId;
7442
+ const resolved = resolveScoreConfig(db2, coursewareId);
7443
+ const hasExplicitConfig = resolved.source !== "builtin";
7444
+ const policyOverride = options.policy;
7445
+ const policy = policyOverride === void 0 || policyOverride === null || String(policyOverride).trim() === "" ? resolved.config.score_policy : normalizeScorePolicy(policyOverride);
7446
+ const scoreFields = options.scoreFields === void 0 ? resolved.config.score_fields : options.scoreFields;
7447
+ const rawFull = toNumber(options.rawFullScore);
7448
+ const targetFull = toNumber(options.targetFullScore);
7449
+ const weight = toNumber(options.weightPercentage);
7450
+ const config = {
7451
+ ...resolved.config,
7452
+ score_policy: policy,
7453
+ score_fields: typeof scoreFields === "string" ? scoreFields : parseScoreFields(scoreFields).join(","),
7454
+ raw_full_score: rawFull !== null && rawFull > 0 ? rawFull : resolved.config.raw_full_score,
7455
+ target_full_score: targetFull !== null && targetFull > 0 ? targetFull : resolved.config.target_full_score,
7456
+ weight_percentage: weight !== null ? clamp(weight, 0, 1e3) : resolved.config.weight_percentage
7457
+ };
7458
+ const sampleDetails = collectScoreSamples(db2, attemptId, scoreFields);
7459
+ const samples = sampleDetails.map((s) => s.value);
7460
+ const rawAggregate = aggregateScores(samples, policy);
7461
+ let normalized = null;
7462
+ if (rawAggregate !== null) {
7463
+ if (!hasExplicitConfig) {
7464
+ normalized = rawAggregate;
7465
+ } else {
7466
+ const scaled = rawAggregate / config.raw_full_score * config.target_full_score;
7467
+ normalized = round2(clamp(scaled, 0, config.target_full_score));
7468
+ }
7469
+ }
7470
+ return {
7471
+ policy,
7472
+ config,
7473
+ hasExplicitConfig,
7474
+ samples,
7475
+ sampleDetails,
7476
+ rawAggregate,
7477
+ normalized,
7478
+ finalScore: normalized,
7479
+ weightPercentage: config.weight_percentage
7480
+ };
7481
+ }
7482
+ function describeAggregation(aggregation) {
7483
+ return {
7484
+ policy: aggregation.policy,
7485
+ sample_count: aggregation.samples.length,
7486
+ samples: aggregation.samples.slice(-50),
7487
+ raw_aggregate: aggregation.rawAggregate,
7488
+ normalized: aggregation.normalized,
7489
+ raw_full_score: aggregation.config.raw_full_score,
7490
+ target_full_score: aggregation.config.target_full_score,
7491
+ weight_percentage: aggregation.weightPercentage,
7492
+ configured: aggregation.hasExplicitConfig,
7493
+ aggregated_at: Date.now()
7494
+ };
7495
+ }
7496
+
6609
7497
  // packages/plugins/builtin.ts
7498
+ var nativeRuntimeScriptRegistry = null;
6610
7499
  function copyFolderSync(src, dest) {
6611
7500
  if (import_fs3.default.cpSync) {
6612
7501
  import_fs3.default.cpSync(src, dest, { recursive: true });
@@ -6647,6 +7536,21 @@ var BuiltinPlugin = {
6647
7536
  const pluginHost = await ctx.resolve(IPluginHostToken);
6648
7537
  const lifecycleManager = await ctx.resolve(IPluginLifecycleManagerToken);
6649
7538
  const distributionManager = await ctx.resolve(IPluginDistributionManagerToken);
7539
+ try {
7540
+ const runtimeScriptRegistry = await ctx.resolve(ICoursewareRuntimeScriptRegistryToken);
7541
+ if (runtimeScriptRegistry && typeof runtimeScriptRegistry.register === "function") {
7542
+ runtimeScriptRegistry.register(SCORE_MONITOR_SCRIPT_OWNER, {
7543
+ id: SCORE_MONITOR_SCRIPT_ID,
7544
+ description: "\u5E73\u53F0\u539F\u751F\u5206\u6570\u53D8\u91CF\u76D1\u89C6\u5668\uFF1A\u91C7\u96C6\u8BFE\u4EF6\u5185\u4EE3\u8868\u5206\u6570\u7684\u53D8\u91CF\u5E76\u6301\u7EED\u4E0A\u62A5\u4E3A\u6210\u7EE9\u6837\u672C",
7545
+ source: SCORE_MONITOR_SCRIPT,
7546
+ position: "body-end",
7547
+ priority: 200
7548
+ });
7549
+ nativeRuntimeScriptRegistry = runtimeScriptRegistry;
7550
+ }
7551
+ } catch (err) {
7552
+ console.warn("[builtin] \u6CE8\u518C\u539F\u751F\u5206\u6570\u53D8\u91CF\u76D1\u89C6\u5668\u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", err.message);
7553
+ }
6650
7554
  const createLessonCmdType = "lesson.create";
6651
7555
  await actionRegistry.register({
6652
7556
  id: "core-lesson-create",
@@ -7975,6 +8879,17 @@ var BuiltinPlugin = {
7975
8879
  attemptId
7976
8880
  );
7977
8881
  }
8882
+ let aggregation = null;
8883
+ try {
8884
+ const attemptRow = db2.prepare("SELECT courseware_id FROM courseware_attempt WHERE id = ?").get(attemptId);
8885
+ aggregation = aggregateAttemptScore(db2, attemptId, {
8886
+ coursewareId: attemptRow?.courseware_id ?? null
8887
+ });
8888
+ } catch (aggErr) {
8889
+ console.warn("[courseware.submit_attempt] score aggregation failed, fallback to latest score:", aggErr);
8890
+ }
8891
+ const aggregatedScore = aggregation && aggregation.finalScore !== null ? aggregation.finalScore : null;
8892
+ const aggregationExtra = aggregation ? { score_aggregation: describeAggregation(aggregation) } : {};
7978
8893
  const existing = db2.prepare("SELECT * FROM submission_result WHERE attempt_id = ?").get(attemptId);
7979
8894
  if (!existing) {
7980
8895
  db2.prepare(
@@ -7982,13 +8897,13 @@ var BuiltinPlugin = {
7982
8897
  ).run(
7983
8898
  "res_" + import_crypto3.default.randomBytes(8).toString("hex"),
7984
8899
  attemptId,
7985
- score !== void 0 ? score : null,
8900
+ aggregatedScore !== null ? aggregatedScore : score !== void 0 ? score : null,
7986
8901
  comment || null,
7987
8902
  completion !== void 0 ? completion : null,
7988
- JSON.stringify(extra)
8903
+ JSON.stringify({ ...extra, ...aggregationExtra })
7989
8904
  );
7990
8905
  } else {
7991
- const finalScore = score !== void 0 ? score : existing.score;
8906
+ const finalScore = aggregatedScore !== null ? aggregatedScore : score !== void 0 ? score : existing.score;
7992
8907
  const finalComment = comment || existing.comment;
7993
8908
  const finalCompletion = completion !== void 0 ? completion : existing.completion;
7994
8909
  let mergedExtra = {};
@@ -7996,7 +8911,7 @@ var BuiltinPlugin = {
7996
8911
  mergedExtra = JSON.parse(existing.extra_json || "{}");
7997
8912
  } catch (e) {
7998
8913
  }
7999
- mergedExtra = { ...mergedExtra, ...extra };
8914
+ mergedExtra = { ...mergedExtra, ...extra, ...aggregationExtra };
8000
8915
  db2.prepare(
8001
8916
  "UPDATE submission_result SET score = ?, comment = ?, completion = ?, extra_json = ? WHERE attempt_id = ?"
8002
8917
  ).run(finalScore, finalComment, finalCompletion, JSON.stringify(mergedExtra), attemptId);
@@ -8012,31 +8927,158 @@ var BuiltinPlugin = {
8012
8927
  return { success: true };
8013
8928
  }
8014
8929
  });
8015
- const getAttemptRawDataCmdType = "courseware.get_attempt_raw_data";
8930
+ const getScoreConfigCmdType = "courseware.get_score_config";
8016
8931
  await actionRegistry.register({
8017
- id: "core-courseware-get-attempt-raw-data",
8018
- commandType: getAttemptRawDataCmdType,
8019
- description: "Get all captured raw events/submits for a student attempt",
8932
+ id: "core-courseware-get-score-config",
8933
+ commandType: getScoreConfigCmdType,
8934
+ description: "\u8BFB\u53D6\u4E92\u52A8\u8BFE\u4EF6\u7684\u6210\u7EE9\u5F52\u96C6\u914D\u7F6E\uFF08\u7B56\u7565 / \u5B57\u6BB5 / \u6EE1\u5206 / \u6743\u91CD\uFF09",
8020
8935
  capabilityRequired: "lesson:read",
8021
8936
  inputSchema: {
8022
8937
  type: "OBJECT",
8023
8938
  properties: {
8024
- attemptId: { type: "STRING", description: "ID of the attempt to query" }
8025
- },
8026
- required: ["attemptId"]
8939
+ coursewareId: { type: "STRING", description: "\u8BFE\u4EF6 ID\uFF08\u7701\u7565\u6216\u4F20 '*' \u65F6\u8BFB\u53D6\u5168\u5C40\u9ED8\u8BA4\u914D\u7F6E\uFF09" }
8940
+ }
8027
8941
  }
8028
8942
  });
8029
- await commandBus.registerHandler(getAttemptRawDataCmdType, {
8943
+ await commandBus.registerHandler(getScoreConfigCmdType, {
8030
8944
  async execute(command) {
8031
- const payload = command.payload;
8032
- const { attemptId } = payload;
8033
- const rows = db2.prepare("SELECT * FROM submission_raw WHERE attempt_id = ? ORDER BY created_at ASC").all(attemptId);
8034
- return rows;
8945
+ const payload = command.payload || {};
8946
+ const resolved = resolveScoreConfig(db2, payload.coursewareId ?? payload.courseware_id ?? null);
8947
+ return { success: true, source: resolved.source, ...resolved.config };
8035
8948
  }
8036
8949
  });
8037
- const listCoursewareCmdType = "courseware.list";
8950
+ const saveScoreConfigCmdType = "courseware.save_score_config";
8038
8951
  await actionRegistry.register({
8039
- id: "core-courseware-list",
8952
+ id: "core-courseware-save-score-config",
8953
+ commandType: saveScoreConfigCmdType,
8954
+ description: "\u4FDD\u5B58\u4E92\u52A8\u8BFE\u4EF6\u7684\u6210\u7EE9\u5F52\u96C6\u914D\u7F6E",
8955
+ capabilityRequired: "lesson:write",
8956
+ inputSchema: {
8957
+ type: "OBJECT",
8958
+ properties: {
8959
+ coursewareId: { type: "STRING", description: "\u8BFE\u4EF6 ID\uFF08\u4F20 '*' \u8868\u793A\u5168\u5C40\u9ED8\u8BA4\u7B56\u7565\uFF09" },
8960
+ coursewareName: { type: "STRING", description: "\u8BFE\u4EF6\u540D\u79F0\uFF08\u4FBF\u4E8E\u8BC6\u522B\uFF09" },
8961
+ scorePolicy: { type: "STRING", description: "LATEST | MAX | AVERAGE | FIRST" },
8962
+ scoreFields: { type: "STRING", description: "\u5206\u6570\u6240\u5728\u5B57\u6BB5\u8DEF\u5F84\uFF0C\u9017\u53F7\u5206\u9694\uFF0C\u5982 score,data.points" },
8963
+ rawFullScore: { type: "NUMBER", description: "\u8BFE\u4EF6\u539F\u59CB\u6EE1\u5206" },
8964
+ targetFullScore: { type: "NUMBER", description: "\u5F52\u96C6\u540E\u7684\u76EE\u6807\u6EE1\u5206" },
8965
+ weightPercentage: { type: "NUMBER", description: "\u8BE5\u8BFE\u4EF6\u5728\u603B\u8BC4\u4E2D\u7684\u6743\u91CD\uFF08\u767E\u5206\u6BD4\uFF09" },
8966
+ lessonId: { type: "STRING", description: "\u5173\u8054\u8BFE\u65F6 ID\uFF08\u53EF\u9009\uFF09" }
8967
+ },
8968
+ required: ["coursewareId"]
8969
+ }
8970
+ });
8971
+ await commandBus.registerHandler(saveScoreConfigCmdType, {
8972
+ async execute(command) {
8973
+ const saved = saveScoreConfig(db2, command.payload || {});
8974
+ await eventBus.publish({
8975
+ id: (0, import_uuid6.v7)(),
8976
+ type: "courseware.score_config_saved",
8977
+ source: "builtin.courseware",
8978
+ payload: { ...saved },
8979
+ timestamp: Date.now(),
8980
+ correlationId: command.id
8981
+ });
8982
+ return { success: true, ...saved };
8983
+ }
8984
+ });
8985
+ const listScoreConfigsCmdType = "courseware.list_score_configs";
8986
+ await actionRegistry.register({
8987
+ id: "core-courseware-list-score-configs",
8988
+ commandType: listScoreConfigsCmdType,
8989
+ description: "\u5217\u51FA\u6240\u6709\u5DF2\u914D\u7F6E\u7684\u8BFE\u4EF6\u6210\u7EE9\u5F52\u96C6\u7B56\u7565",
8990
+ capabilityRequired: "lesson:read",
8991
+ inputSchema: { type: "OBJECT", properties: {} }
8992
+ });
8993
+ await commandBus.registerHandler(listScoreConfigsCmdType, {
8994
+ async execute() {
8995
+ return { success: true, configs: listScoreConfigs(db2) };
8996
+ }
8997
+ });
8998
+ const regradeCmdType = "courseware.regrade_attempts";
8999
+ await actionRegistry.register({
9000
+ id: "core-courseware-regrade-attempts",
9001
+ commandType: regradeCmdType,
9002
+ description: "\u6309\u5F53\u524D\u6210\u7EE9\u5F52\u96C6\u7B56\u7565\u91CD\u7B97\u65E2\u6709 attempt \u7684\u5B98\u65B9\u6210\u7EE9\uFF08\u6539\u914D\u7F6E\u540E\u56DE\u586B\uFF09",
9003
+ capabilityRequired: "lesson:write",
9004
+ inputSchema: {
9005
+ type: "OBJECT",
9006
+ properties: {
9007
+ attemptId: { type: "STRING", description: "\u5355\u4E2A attempt ID\uFF08\u4E0E coursewareId \u4E8C\u9009\u4E00\uFF09" },
9008
+ coursewareId: { type: "STRING", description: "\u6309\u8BFE\u4EF6\u6279\u91CF\u91CD\u7B97\uFF08\u6700\u591A 2000 \u4E2A attempt\uFF09" },
9009
+ limit: { type: "NUMBER", description: "\u6279\u91CF\u91CD\u7B97\u4E0A\u9650\uFF0C\u9ED8\u8BA4 500" }
9010
+ }
9011
+ }
9012
+ });
9013
+ await commandBus.registerHandler(regradeCmdType, {
9014
+ async execute(command) {
9015
+ const payload = command.payload || {};
9016
+ const attemptId = typeof payload.attemptId === "string" ? payload.attemptId.trim() : "";
9017
+ const coursewareId = typeof payload.coursewareId === "string" ? payload.coursewareId.trim() : "";
9018
+ if (!attemptId && !coursewareId) {
9019
+ throw new Error("courseware.regrade_attempts: attemptId or coursewareId is required");
9020
+ }
9021
+ const requestedLimit = Number(payload.limit);
9022
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 2e3) : 500;
9023
+ let attemptIds = [];
9024
+ if (attemptId) {
9025
+ attemptIds = [attemptId];
9026
+ } else {
9027
+ const rows = db2.prepare("SELECT id FROM courseware_attempt WHERE courseware_id = ? ORDER BY started_at DESC LIMIT ?").all(coursewareId, limit);
9028
+ attemptIds = (rows || []).map((r) => String(r.id));
9029
+ }
9030
+ const regraded = [];
9031
+ let failed = 0;
9032
+ for (const id of attemptIds) {
9033
+ try {
9034
+ const agg = aggregateAttemptScore(db2, id, { coursewareId: coursewareId || null });
9035
+ if (agg.finalScore === null) continue;
9036
+ const current = db2.prepare("SELECT * FROM submission_result WHERE attempt_id = ?").get(id);
9037
+ if (!current) continue;
9038
+ let mergedExtra = {};
9039
+ try {
9040
+ mergedExtra = JSON.parse(current.extra_json || "{}");
9041
+ } catch (e) {
9042
+ }
9043
+ mergedExtra = { ...mergedExtra, score_aggregation: describeAggregation(agg) };
9044
+ db2.prepare("UPDATE submission_result SET score = ?, extra_json = ? WHERE attempt_id = ?").run(
9045
+ agg.finalScore,
9046
+ JSON.stringify(mergedExtra),
9047
+ id
9048
+ );
9049
+ regraded.push({ attemptId: id, score: agg.finalScore, samples: agg.samples.length, policy: agg.policy });
9050
+ } catch (e) {
9051
+ failed += 1;
9052
+ }
9053
+ }
9054
+ return { success: true, scanned: attemptIds.length, regraded: regraded.length, failed, results: regraded };
9055
+ }
9056
+ });
9057
+ const getAttemptRawDataCmdType = "courseware.get_attempt_raw_data";
9058
+ await actionRegistry.register({
9059
+ id: "core-courseware-get-attempt-raw-data",
9060
+ commandType: getAttemptRawDataCmdType,
9061
+ description: "Get all captured raw events/submits for a student attempt",
9062
+ capabilityRequired: "lesson:read",
9063
+ inputSchema: {
9064
+ type: "OBJECT",
9065
+ properties: {
9066
+ attemptId: { type: "STRING", description: "ID of the attempt to query" }
9067
+ },
9068
+ required: ["attemptId"]
9069
+ }
9070
+ });
9071
+ await commandBus.registerHandler(getAttemptRawDataCmdType, {
9072
+ async execute(command) {
9073
+ const payload = command.payload;
9074
+ const { attemptId } = payload;
9075
+ const rows = db2.prepare("SELECT * FROM submission_raw WHERE attempt_id = ? ORDER BY created_at ASC").all(attemptId);
9076
+ return rows;
9077
+ }
9078
+ });
9079
+ const listCoursewareCmdType = "courseware.list";
9080
+ await actionRegistry.register({
9081
+ id: "core-courseware-list",
8040
9082
  commandType: listCoursewareCmdType,
8041
9083
  description: "List all uploaded interactive coursewares",
8042
9084
  capabilityRequired: "lesson:read",
@@ -8090,6 +9132,14 @@ var BuiltinPlugin = {
8090
9132
  });
8091
9133
  },
8092
9134
  deactivate: async () => {
9135
+ try {
9136
+ if (nativeRuntimeScriptRegistry && typeof nativeRuntimeScriptRegistry.clear === "function") {
9137
+ nativeRuntimeScriptRegistry.clear(SCORE_MONITOR_SCRIPT_OWNER);
9138
+ nativeRuntimeScriptRegistry = null;
9139
+ }
9140
+ } catch (err) {
9141
+ console.warn("[builtin] \u56DE\u6536\u539F\u751F\u5206\u6570\u53D8\u91CF\u76D1\u89C6\u5668\u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", err.message);
9142
+ }
8093
9143
  }
8094
9144
  };
8095
9145
 
@@ -8295,6 +9345,27 @@ var AiPlannerPlugin = {
8295
9345
  // packages/plugins/assignment-eval.ts
8296
9346
  var import_uuid8 = require("uuid");
8297
9347
  var import_plugin_sdk6 = require("@openlearn/plugin-sdk");
9348
+ var PRIVILEGED_ROLES = ["teacher", "administrator", "admin"];
9349
+ function parseActorId(actorId) {
9350
+ const match = /^user:([^:]+):(.+)$/.exec(String(actorId || ""));
9351
+ if (!match) return null;
9352
+ return { userId: match[1], role: match[2] };
9353
+ }
9354
+ function isPrivilegedActor(actorId) {
9355
+ const actor = parseActorId(actorId);
9356
+ if (!actor) return true;
9357
+ return PRIVILEGED_ROLES.includes(actor.role);
9358
+ }
9359
+ function actorUserId(actorId) {
9360
+ return parseActorId(actorId)?.userId ?? actorId ?? null;
9361
+ }
9362
+ function assertStudentOwnership(actorId, studentId, action) {
9363
+ if (isPrivilegedActor(actorId)) return;
9364
+ const actor = parseActorId(actorId);
9365
+ if (!actor || actor.userId !== studentId) {
9366
+ throw new Error(`Access Denied: Students can only ${action} their own assignment`);
9367
+ }
9368
+ }
8298
9369
  var AssignmentEvalPlugin = {
8299
9370
  manifest: {
8300
9371
  id: "@openlearn/plugin-assignment-eval",
@@ -8305,140 +9376,632 @@ var AssignmentEvalPlugin = {
8305
9376
  "@openlearn/core:ICommandBusService@^1.0.0",
8306
9377
  "@openlearn/core:IActionRegistryService@^1.0.0",
8307
9378
  "@openlearn/core:IDatabase@^1.0.0",
8308
- "@openlearn/core:ISemesterGradeService@^1.0.0"
9379
+ "@openlearn/core:ISemesterGradeService@^1.0.0",
9380
+ "@openlearn/core:IEventBusService@^1.0.0"
8309
9381
  ],
8310
- capabilitiesProposed: ["lesson:read", "lesson:write"],
9382
+ capabilitiesProposed: ["assignment:read", "assignment:submit", "assignment:review", "assignment:manage"],
8311
9383
  engines: { openlearn: ">=0.2.5" }
8312
9384
  },
8313
9385
  activate: async (ctx) => {
8314
9386
  const commandBus = ctx.services.commandBus;
8315
9387
  const actionRegistry = ctx.services.actionRegistry;
9388
+ const eventBus = ctx.services.eventBus;
8316
9389
  const db2 = await ctx.resolve(import_plugin_sdk6.IDatabaseToken);
8317
9390
  const gradeService = await ctx.resolve(import_plugin_sdk6.ISemesterGradeServiceToken);
9391
+ const OWNED_COMMAND_TYPES = [
9392
+ "assignment.create",
9393
+ "assignment.list",
9394
+ "assignment.get",
9395
+ "assignment.submit",
9396
+ "assignment.peer_review",
9397
+ "assignment.assign_peer_reviews",
9398
+ "assignment.grade"
9399
+ ];
9400
+ const OWNED_ACTION_IDS = [
9401
+ "eval-assignment-create",
9402
+ "eval-assignment-list",
9403
+ "eval-assignment-get",
9404
+ "eval-assignment-submit",
9405
+ "eval-assignment-peer-review",
9406
+ "eval-assignment-assign-peer-reviews",
9407
+ "eval-assignment-grade"
9408
+ ];
9409
+ for (const type of OWNED_COMMAND_TYPES) {
9410
+ commandBus.unregisterHandler(type);
9411
+ }
9412
+ for (const id of OWNED_ACTION_IDS) {
9413
+ actionRegistry.unregister(id);
9414
+ }
8318
9415
  commandBus.unregisterHandler("assignment.submit");
8319
9416
  commandBus.unregisterHandler("assignment.grade");
9417
+ actionRegistry.unregister("core-assignment-create");
8320
9418
  actionRegistry.unregister("core-assignment-submit");
8321
9419
  actionRegistry.unregister("core-assignment-grade");
8322
- const submitCmd = "assignment.submit";
9420
+ const loadAssignment = (assignmentId) => db2.prepare("SELECT * FROM plugin_assignments WHERE id = ?").get(assignmentId);
9421
+ const resolveAssignment = (assignmentId, lessonId) => {
9422
+ if (assignmentId) {
9423
+ const row = loadAssignment(assignmentId);
9424
+ if (!row) throw new Error(`Assignment not found: ${assignmentId}`);
9425
+ return row;
9426
+ }
9427
+ if (lessonId) {
9428
+ const existing = db2.prepare("SELECT * FROM plugin_assignments WHERE lesson_id = ? ORDER BY created_at ASC LIMIT 1").get(lessonId);
9429
+ if (existing) return existing;
9430
+ const now = Date.now();
9431
+ const autoId = "asg-" + (0, import_uuid8.v7)();
9432
+ db2.prepare(
9433
+ `INSERT INTO plugin_assignments
9434
+ (id, class_id, lesson_id, element_id, title, description, status, created_by, created_at, updated_at)
9435
+ VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?, ?)`
9436
+ ).run(autoId, null, lessonId, null, "\u672C\u8BFE\u4F5C\u4E1A", "\u7531\u8BFE\u65F6\u4F5C\u4E1A\u5165\u53E3\u81EA\u52A8\u521B\u5EFA", null, now, now);
9437
+ return loadAssignment(autoId);
9438
+ }
9439
+ throw new Error("Missing required params: assignmentId or lessonId");
9440
+ };
9441
+ const latestVersionOfSubmission = (submissionId) => db2.prepare(
9442
+ "SELECT id, version, text_content, link_url, submitted_at, is_late FROM plugin_submission_versions WHERE submission_id = ? ORDER BY version DESC LIMIT 1"
9443
+ ).get(submissionId);
9444
+ const filesOfVersion = (versionId) => db2.prepare(
9445
+ "SELECT id, original_name, size, mime FROM plugin_assignment_files WHERE version_id = ? AND deleted_at IS NULL ORDER BY uploaded_at ASC"
9446
+ ).all(versionId);
9447
+ const buildPeerReviewTasks = (assignmentId, reviewerId) => {
9448
+ const tasks = db2.prepare(
9449
+ "SELECT id, submission_id, anonymous, status, due_at, created_at FROM plugin_peer_review_tasks WHERE assignment_id = ? AND reviewer_id = ? ORDER BY created_at ASC"
9450
+ ).all(assignmentId, reviewerId);
9451
+ return tasks.map((task) => {
9452
+ const version = latestVersionOfSubmission(task.submission_id);
9453
+ const review = db2.prepare(
9454
+ "SELECT score, comment, status, updated_at FROM plugin_peer_reviews WHERE submission_id = ? AND reviewer_id = ?"
9455
+ ).get(task.submission_id, reviewerId);
9456
+ const submissionUpdatedAt = db2.prepare("SELECT updated_at FROM plugin_submissions WHERE id = ?").get(task.submission_id)?.updated_at ?? null;
9457
+ return {
9458
+ taskId: task.id,
9459
+ submissionId: task.submission_id,
9460
+ status: task.status,
9461
+ anonymous: Number(task.anonymous) === 1,
9462
+ dueAt: task.due_at ?? null,
9463
+ createdAt: task.created_at,
9464
+ // 我评完之后作者又改过提交 → 提醒复核
9465
+ stale: Boolean(
9466
+ review?.status === "submitted" && submissionUpdatedAt && Number(review.updated_at || 0) < Number(submissionUpdatedAt)
9467
+ ),
9468
+ review: review ? { score: review.score, comment: review.comment || "", submittedAt: review.updated_at ?? null } : null,
9469
+ submission: version ? {
9470
+ version: version.version,
9471
+ textContent: version.text_content || "",
9472
+ linkUrl: version.link_url || "",
9473
+ submittedAt: version.submitted_at,
9474
+ isLate: Number(version.is_late) === 1,
9475
+ files: filesOfVersion(version.id)
9476
+ } : null
9477
+ };
9478
+ });
9479
+ };
9480
+ const buildPeerProgress = (assignmentId) => {
9481
+ const nameOf = (studentId) => {
9482
+ try {
9483
+ const row = db2.prepare("SELECT name FROM students WHERE id = ?").get(studentId);
9484
+ return row?.name || studentId;
9485
+ } catch {
9486
+ return studentId;
9487
+ }
9488
+ };
9489
+ const tasks = db2.prepare("SELECT reviewer_id, status FROM plugin_peer_review_tasks WHERE assignment_id = ?").all(assignmentId);
9490
+ const byReviewer = /* @__PURE__ */ new Map();
9491
+ for (const task of tasks) {
9492
+ const entry = byReviewer.get(task.reviewer_id) || { studentId: task.reviewer_id, name: nameOf(task.reviewer_id), pending: 0, submitted: 0 };
9493
+ if (task.status === "submitted") entry.submitted += 1;
9494
+ else entry.pending += 1;
9495
+ byReviewer.set(task.reviewer_id, entry);
9496
+ }
9497
+ const flags = [];
9498
+ for (const entry of byReviewer.values()) {
9499
+ if (entry.pending > 0) {
9500
+ flags.push({
9501
+ type: "peer_review_pending",
9502
+ reviewerId: entry.studentId,
9503
+ detail: `${entry.name} \u8FD8\u6709 ${entry.pending} \u4EFD\u4E92\u8BC4\u672A\u5B8C\u6210`
9504
+ });
9505
+ }
9506
+ if (entry.submitted >= 2) {
9507
+ const full = db2.prepare(
9508
+ "SELECT COUNT(*) AS c FROM plugin_peer_reviews WHERE assignment_id = ? AND reviewer_id = ? AND score >= 100"
9509
+ ).get(assignmentId, entry.studentId).c;
9510
+ if (full >= entry.submitted) {
9511
+ flags.push({
9512
+ type: "all_full_marks",
9513
+ reviewerId: entry.studentId,
9514
+ detail: `${entry.name} \u7684 ${entry.submitted} \u4EFD\u4E92\u8BC4\u5168\u90E8\u7ED9\u4E86\u6EE1\u5206\uFF0C\u5EFA\u8BAE\u590D\u6838`
9515
+ });
9516
+ }
9517
+ }
9518
+ }
9519
+ const gaps = db2.prepare(
9520
+ `SELECT r.reviewer_id AS reviewerId, r.score AS peerScore, g.teacher_score AS teacherScore, s.student_id AS authorId
9521
+ FROM plugin_peer_reviews r
9522
+ JOIN plugin_grades g ON g.submission_id = r.submission_id
9523
+ JOIN plugin_submissions s ON s.id = r.submission_id
9524
+ WHERE r.assignment_id = ? AND g.teacher_score IS NOT NULL`
9525
+ ).all(assignmentId);
9526
+ for (const gap of gaps) {
9527
+ if (Math.abs(Number(gap.peerScore) - Number(gap.teacherScore)) <= 25) continue;
9528
+ flags.push({
9529
+ type: "score_gap",
9530
+ reviewerId: gap.reviewerId,
9531
+ detail: `${nameOf(gap.reviewerId)} \u7ED9 ${nameOf(gap.authorId)} \u7684\u4E92\u8BC4\u5206 ${gap.peerScore}\uFF0C\u4E0E\u6559\u5E08\u8BC4\u5206 ${gap.teacherScore} \u76F8\u5DEE\u8F83\u5927`
9532
+ });
9533
+ }
9534
+ const submissionCount = db2.prepare("SELECT COUNT(*) AS c FROM plugin_submissions WHERE assignment_id = ?").get(assignmentId).c;
9535
+ const completed = tasks.filter((t) => t.status === "submitted").length;
9536
+ return {
9537
+ submissions: submissionCount,
9538
+ tasks: tasks.length,
9539
+ completed,
9540
+ pending: tasks.length - completed,
9541
+ reviewers: Array.from(byReviewer.values()).sort((a, b) => b.pending - a.pending),
9542
+ flags
9543
+ };
9544
+ };
9545
+ const publishEvent2 = async (type, payload, correlationId) => {
9546
+ if (!eventBus || typeof eventBus.publish !== "function") return;
9547
+ try {
9548
+ await eventBus.publish({
9549
+ id: (0, import_uuid8.v7)(),
9550
+ type,
9551
+ source: "assignment-eval",
9552
+ payload,
9553
+ timestamp: Date.now(),
9554
+ correlationId
9555
+ });
9556
+ } catch (err) {
9557
+ ctx.log?.warn?.(`[assignment-eval] \u53D1\u5E03\u4E8B\u4EF6 ${type} \u5931\u8D25: ${err?.message}`);
9558
+ }
9559
+ };
9560
+ await actionRegistry.register({
9561
+ id: "eval-assignment-create",
9562
+ commandType: "assignment.create",
9563
+ description: "\u521B\u5EFA\u6216\u66F4\u65B0\u4F5C\u4E1A\u5B9E\u4F53\uFF08\u53EF\u540C\u65F6\u7ED1\u5B9A\u8BFE\u65F6\u3001\u73ED\u7EA7\u4E0E\u767D\u677F\u5143\u7D20\uFF09",
9564
+ capabilityRequired: "assignment:manage",
9565
+ inputSchema: {
9566
+ type: "OBJECT",
9567
+ properties: {
9568
+ assignmentId: { type: "STRING", description: "\u5DF2\u5B58\u5728\u5219\u66F4\u65B0\uFF0C\u7F3A\u7701\u4E3A\u65B0\u5EFA" },
9569
+ title: { type: "STRING", description: "\u4F5C\u4E1A\u6807\u9898" },
9570
+ description: { type: "STRING", description: "\u4F5C\u4E1A\u8BF4\u660E" },
9571
+ instructions: { type: "STRING", description: "\u4F5C\u7B54\u8981\u6C42" },
9572
+ lessonId: { type: "STRING", description: "\u5173\u8054\u8BFE\u65F6 ID" },
9573
+ classId: { type: "STRING", description: "\u5173\u8054\u73ED\u7EA7 ID" },
9574
+ elementId: { type: "STRING", description: "\u767D\u677F\u6559\u5B66\u5BF9\u8C61 ID" },
9575
+ dueAt: { type: "NUMBER", description: "\u622A\u6B62\u65F6\u95F4\uFF08\u6BEB\u79D2\u65F6\u95F4\u6233\uFF09" },
9576
+ allowLate: { type: "BOOLEAN", description: "\u662F\u5426\u5141\u8BB8\u8FDF\u4EA4" },
9577
+ allowText: { type: "BOOLEAN", description: "\u662F\u5426\u5141\u8BB8\u6587\u672C\u4F5C\u7B54" },
9578
+ allowLink: { type: "BOOLEAN", description: "\u662F\u5426\u5141\u8BB8\u94FE\u63A5\u4F5C\u7B54" },
9579
+ maxFiles: { type: "NUMBER", description: "\u6700\u591A\u6587\u4EF6\u6570" },
9580
+ maxFileSize: { type: "NUMBER", description: "\u5355\u6587\u4EF6\u5927\u5C0F\u4E0A\u9650\uFF08\u5B57\u8282\uFF09" },
9581
+ peerReviewCount: { type: "NUMBER", description: "\u6BCF\u4EBA\u9700\u8981\u4E92\u8BC4\u7684\u4EFD\u6570" },
9582
+ teacherWeight: { type: "NUMBER", description: "\u6559\u5E08\u8BC4\u5206\u6743\u91CD" },
9583
+ peerWeight: { type: "NUMBER", description: "\u4E92\u8BC4\u5E73\u5747\u5206\u6743\u91CD" },
9584
+ status: { type: "STRING", description: "published / draft / archived" }
9585
+ },
9586
+ required: ["title"]
9587
+ }
9588
+ });
9589
+ await commandBus.registerHandler("assignment.create", {
9590
+ async execute(command) {
9591
+ const payload = command.payload;
9592
+ const now = Date.now();
9593
+ const teacherWeight = payload.teacherWeight === void 0 ? 0.6 : Number(payload.teacherWeight);
9594
+ const peerWeight = payload.peerWeight === void 0 ? 0.4 : Number(payload.peerWeight);
9595
+ if (Math.abs(teacherWeight + peerWeight - 1) > 1e-3) {
9596
+ throw new Error("Access Denied: The sum of teacherWeight and peerWeight must equal 1.0");
9597
+ }
9598
+ const assignmentId = payload.assignmentId || "asg-" + (0, import_uuid8.v7)();
9599
+ const existing = loadAssignment(assignmentId);
9600
+ if (existing) {
9601
+ db2.prepare(
9602
+ `UPDATE plugin_assignments SET
9603
+ class_id = ?, lesson_id = ?, element_id = ?, title = ?, description = ?, instructions = ?,
9604
+ due_at = ?, allow_late = ?, allow_text = ?, allow_link = ?, max_files = ?, max_file_size = ?,
9605
+ peer_review_count = ?, teacher_weight = ?, peer_weight = ?, status = ?, updated_at = ?
9606
+ WHERE id = ?`
9607
+ ).run(
9608
+ payload.classId ?? existing.class_id,
9609
+ payload.lessonId ?? existing.lesson_id,
9610
+ payload.elementId ?? existing.element_id,
9611
+ payload.title ?? existing.title,
9612
+ payload.description ?? null,
9613
+ payload.instructions ?? null,
9614
+ payload.dueAt ?? existing.due_at,
9615
+ payload.allowLate === void 0 ? existing.allow_late : payload.allowLate ? 1 : 0,
9616
+ payload.allowText === void 0 ? existing.allow_text : payload.allowText ? 1 : 0,
9617
+ payload.allowLink === void 0 ? existing.allow_link : payload.allowLink ? 1 : 0,
9618
+ payload.maxFiles ?? existing.max_files,
9619
+ payload.maxFileSize ?? existing.max_file_size,
9620
+ payload.peerReviewCount ?? existing.peer_review_count,
9621
+ teacherWeight,
9622
+ peerWeight,
9623
+ payload.status ?? existing.status,
9624
+ now,
9625
+ assignmentId
9626
+ );
9627
+ } else {
9628
+ db2.prepare(
9629
+ `INSERT INTO plugin_assignments
9630
+ (id, class_id, lesson_id, element_id, title, description, instructions, due_at, allow_late, allow_text,
9631
+ allow_link, max_files, max_file_size, peer_review_count, teacher_weight, peer_weight, status,
9632
+ created_by, created_at, updated_at)
9633
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
9634
+ ).run(
9635
+ assignmentId,
9636
+ payload.classId ?? null,
9637
+ payload.lessonId ?? null,
9638
+ payload.elementId ?? null,
9639
+ payload.title,
9640
+ payload.description ?? null,
9641
+ payload.instructions ?? null,
9642
+ payload.dueAt ?? null,
9643
+ payload.allowLate === false ? 0 : 1,
9644
+ payload.allowText === false ? 0 : 1,
9645
+ payload.allowLink ? 1 : 0,
9646
+ payload.maxFiles ?? 10,
9647
+ payload.maxFileSize ?? 20971520,
9648
+ payload.peerReviewCount ?? 2,
9649
+ teacherWeight,
9650
+ peerWeight,
9651
+ payload.status ?? "published",
9652
+ actorUserId(command.actorId),
9653
+ now,
9654
+ now
9655
+ );
9656
+ }
9657
+ return { success: true, assignmentId, created: !existing };
9658
+ }
9659
+ });
9660
+ await actionRegistry.register({
9661
+ id: "eval-assignment-list",
9662
+ commandType: "assignment.list",
9663
+ description: "\u6309\u8BFE\u65F6 / \u73ED\u7EA7 / \u767D\u677F\u5143\u7D20 / \u5B66\u751F\u5217\u51FA\u4F5C\u4E1A",
9664
+ capabilityRequired: "assignment:read",
9665
+ inputSchema: {
9666
+ type: "OBJECT",
9667
+ properties: {
9668
+ lessonId: { type: "STRING" },
9669
+ classId: { type: "STRING" },
9670
+ elementId: { type: "STRING" },
9671
+ studentId: { type: "STRING", description: "\u9644\u5E26\u8BE5\u5B66\u751F\u7684\u63D0\u4EA4\u6982\u8981" },
9672
+ includeArchived: { type: "BOOLEAN" }
9673
+ }
9674
+ }
9675
+ });
9676
+ await commandBus.registerHandler("assignment.list", {
9677
+ async execute(command) {
9678
+ const payload = command.payload;
9679
+ const where = [];
9680
+ const params = [];
9681
+ if (payload.lessonId) {
9682
+ where.push("lesson_id = ?");
9683
+ params.push(payload.lessonId);
9684
+ }
9685
+ if (payload.classId) {
9686
+ where.push("class_id = ?");
9687
+ params.push(payload.classId);
9688
+ }
9689
+ if (payload.elementId) {
9690
+ where.push("element_id = ?");
9691
+ params.push(payload.elementId);
9692
+ }
9693
+ if (!payload.includeArchived) {
9694
+ where.push("status != 'archived'");
9695
+ }
9696
+ const clause = where.length ? `WHERE ${where.join(" AND ")}` : "";
9697
+ const rows = db2.prepare(`SELECT * FROM plugin_assignments ${clause} ORDER BY created_at DESC LIMIT 200`).all(...params);
9698
+ const studentId = payload.studentId;
9699
+ if (!studentId) return { success: true, assignments: rows };
9700
+ const assignments = rows.map((row) => {
9701
+ const submission = db2.prepare("SELECT id, version, file_path, updated_at FROM plugin_submissions WHERE assignment_id = ? AND student_id = ?").get(row.id, studentId);
9702
+ const grade = submission ? db2.prepare("SELECT calculated_final_score, status FROM plugin_grades WHERE submission_id = ?").get(submission.id) : void 0;
9703
+ return { ...row, submission: submission || null, grade: grade || null };
9704
+ });
9705
+ return { success: true, assignments };
9706
+ }
9707
+ });
9708
+ await actionRegistry.register({
9709
+ id: "eval-assignment-get",
9710
+ commandType: "assignment.get",
9711
+ description: "\u8BFB\u53D6\u5355\u4E2A\u4F5C\u4E1A\u8BE6\u60C5\uFF08\u542B\u63D0\u4EA4\u7248\u672C\u4E0E\u7EDF\u8BA1\uFF09",
9712
+ capabilityRequired: "assignment:read",
9713
+ inputSchema: {
9714
+ type: "OBJECT",
9715
+ properties: {
9716
+ assignmentId: { type: "STRING" },
9717
+ studentId: { type: "STRING", description: "\u9644\u5E26\u8BE5\u5B66\u751F\u7684\u63D0\u4EA4\u4E0E\u7248\u672C\u5386\u53F2" }
9718
+ },
9719
+ required: ["assignmentId"]
9720
+ }
9721
+ });
9722
+ await commandBus.registerHandler("assignment.get", {
9723
+ async execute(command) {
9724
+ const { assignmentId, studentId } = command.payload;
9725
+ const assignment = loadAssignment(assignmentId);
9726
+ if (!assignment) throw new Error(`Assignment not found: ${assignmentId}`);
9727
+ const submissions = db2.prepare("SELECT id, student_id, version, updated_at FROM plugin_submissions WHERE assignment_id = ?").all(assignmentId);
9728
+ const grades = db2.prepare("SELECT submission_id, calculated_final_score, status FROM plugin_grades WHERE assignment_id = ?").all(assignmentId);
9729
+ const gradedIds = new Set(grades.filter((g) => g.status === "confirmed").map((g) => g.submission_id));
9730
+ const result = {
9731
+ success: true,
9732
+ assignment,
9733
+ stats: {
9734
+ submissionCount: submissions.length,
9735
+ gradedCount: gradedIds.size,
9736
+ pendingPeerReviews: db2.prepare("SELECT COUNT(*) AS c FROM plugin_peer_review_tasks WHERE assignment_id = ? AND status = 'pending'").get(assignmentId).c
9737
+ }
9738
+ };
9739
+ if (studentId) {
9740
+ const submission = db2.prepare("SELECT * FROM plugin_submissions WHERE assignment_id = ? AND student_id = ?").get(assignmentId, studentId);
9741
+ if (submission) {
9742
+ result.submission = submission;
9743
+ result.versions = db2.prepare("SELECT * FROM plugin_submission_versions WHERE submission_id = ? ORDER BY version DESC").all(submission.id);
9744
+ result.grade = db2.prepare("SELECT * FROM plugin_grades WHERE submission_id = ?").get(submission.id) || null;
9745
+ } else {
9746
+ result.submission = null;
9747
+ result.versions = [];
9748
+ result.grade = null;
9749
+ }
9750
+ result.myPeerReviewTasks = db2.prepare("SELECT * FROM plugin_peer_review_tasks WHERE assignment_id = ? AND reviewer_id = ?").all(assignmentId, studentId);
9751
+ result.peerReviewTasks = buildPeerReviewTasks(assignmentId, studentId);
9752
+ }
9753
+ if (command.payload.includePeerProgress) {
9754
+ result.peerProgress = buildPeerProgress(assignmentId);
9755
+ }
9756
+ return result;
9757
+ }
9758
+ });
8323
9759
  await actionRegistry.register({
8324
9760
  id: "eval-assignment-submit",
8325
- commandType: submitCmd,
8326
- description: "\u5B66\u751F\u63D0\u4EA4\u4F5C\u4E1A\u4F5C\u54C1\uFF0C\u652F\u6301\u591A\u6B21\u8986\u76D6\u4E0A\u4F20",
8327
- capabilityRequired: "lesson:write",
9761
+ commandType: "assignment.submit",
9762
+ description: "\u5B66\u751F\u63D0\u4EA4\u4F5C\u4E1A\u4F5C\u54C1\uFF0C\u652F\u6301\u591A\u6B21\u63D0\u4EA4\u5E76\u4FDD\u7559\u7248\u672C\u5386\u53F2",
9763
+ capabilityRequired: "assignment:submit",
8328
9764
  inputSchema: {
8329
9765
  type: "OBJECT",
8330
9766
  properties: {
8331
- lessonId: { type: "STRING", description: "\u5173\u8054\u7684\u8BFE\u65F6 ID" },
9767
+ assignmentId: { type: "STRING", description: "\u4F5C\u4E1A\u5B9E\u4F53 ID\uFF08\u63A8\u8350\uFF09" },
9768
+ lessonId: { type: "STRING", description: "\u65E7\u7248\u8BFE\u65F6\u4F5C\u4E1A\u5165\u53E3\uFF1A\u4EC5\u4F20\u8BFE\u65F6 ID \u65F6\u81EA\u52A8\u5F52\u5C5E\u5230\u8BE5\u8BFE\u65F6\u7684\u4F5C\u4E1A" },
8332
9769
  studentId: { type: "STRING", description: "\u63D0\u4EA4\u4F5C\u54C1\u7684\u5B66\u751F ID" },
8333
- filePath: { type: "STRING", description: "\u6587\u4EF6\u76F8\u5BF9\u865A\u62DF\u6587\u4EF6\u7CFB\u7EDF\u7684\u5B58\u50A8\u8DEF\u5F84" }
9770
+ filePath: { type: "STRING", description: "\u65E7\u7248\u517C\u5BB9\uFF1A\u6587\u672C\u5F62\u5F0F\u7684\u6587\u4EF6\u8DEF\u5F84\u63CF\u8FF0" },
9771
+ fileIds: { type: "ARRAY", items: { type: "STRING" }, description: "\u5DF2\u4E0A\u4F20\u6587\u4EF6\u7684\u5143\u6570\u636E ID \u5217\u8868" },
9772
+ textContent: { type: "STRING", description: "\u6587\u672C\u4F5C\u7B54\u5185\u5BB9" },
9773
+ linkUrl: { type: "STRING", description: "\u94FE\u63A5\u4F5C\u7B54\u5185\u5BB9" }
8334
9774
  },
8335
- required: ["lessonId", "studentId", "filePath"]
9775
+ required: ["studentId"]
8336
9776
  }
8337
9777
  });
8338
- await commandBus.registerHandler(submitCmd, {
9778
+ await commandBus.registerHandler("assignment.submit", {
8339
9779
  async execute(command) {
8340
- const { lessonId, studentId, filePath } = command.payload;
8341
- if (!lessonId || !studentId || !filePath) {
8342
- throw new Error("Missing required params: lessonId, studentId, filePath");
9780
+ const payload = command.payload;
9781
+ const { studentId, textContent, linkUrl, filePath } = payload;
9782
+ if (!studentId) {
9783
+ throw new Error("Missing required params: studentId");
8343
9784
  }
8344
- const existing = db2.prepare("SELECT id, version FROM plugin_submissions WHERE lesson_id = ? AND student_id = ?").get(lessonId, studentId);
8345
- let submissionId;
8346
- let version = 1;
9785
+ assertStudentOwnership(command.actorId, studentId, "submit");
9786
+ const assignment = resolveAssignment(payload.assignmentId, payload.lessonId);
9787
+ const now = Date.now();
9788
+ const isLate = assignment.due_at !== null && now > Number(assignment.due_at) ? 1 : 0;
9789
+ if (isLate && !assignment.allow_late) {
9790
+ throw new Error("Access Denied: Assignment is closed (late submission is not allowed)");
9791
+ }
9792
+ if (textContent && !assignment.allow_text) {
9793
+ throw new Error("Access Denied: Text answers are not allowed for this assignment");
9794
+ }
9795
+ if (linkUrl && !assignment.allow_link) {
9796
+ throw new Error("Access Denied: Link answers are not allowed for this assignment");
9797
+ }
9798
+ const fileIds = Array.isArray(payload.fileIds) ? payload.fileIds.filter((f) => typeof f === "string") : [];
9799
+ if (fileIds.length > Number(assignment.max_files)) {
9800
+ throw new Error(`Access Denied: At most ${assignment.max_files} files are allowed`);
9801
+ }
9802
+ const existing = db2.prepare("SELECT id, version FROM plugin_submissions WHERE assignment_id = ? AND student_id = ?").get(assignment.id, studentId);
9803
+ const submissionId = existing?.id || "sub-" + (0, import_uuid8.v7)();
9804
+ const version = existing ? existing.version + 1 : 1;
9805
+ const versionId = "sv-" + (0, import_uuid8.v7)();
8347
9806
  if (existing) {
8348
- submissionId = existing.id;
8349
- version = existing.version + 1;
8350
- db2.prepare("UPDATE plugin_submissions SET file_path = ?, version = ?, updated_at = ? WHERE id = ?").run(
8351
- filePath,
8352
- version,
8353
- Date.now(),
8354
- submissionId
8355
- );
9807
+ db2.prepare(
9808
+ "UPDATE plugin_submissions SET lesson_id = ?, file_path = ?, version = ?, updated_at = ? WHERE id = ?"
9809
+ ).run(assignment.lesson_id ?? null, filePath ?? null, version, now, submissionId);
8356
9810
  } else {
8357
- submissionId = "sub-" + (0, import_uuid8.v7)();
8358
9811
  db2.prepare(
8359
- "INSERT INTO plugin_submissions (id, lesson_id, student_id, file_path, version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
8360
- ).run(submissionId, lessonId, studentId, filePath, version, Date.now(), Date.now());
9812
+ `INSERT INTO plugin_submissions (id, assignment_id, lesson_id, student_id, file_path, version, created_at, updated_at)
9813
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
9814
+ ).run(submissionId, assignment.id, assignment.lesson_id ?? null, studentId, filePath ?? null, version, now, now);
8361
9815
  }
8362
- return { success: true, submissionId, version };
9816
+ if (fileIds.length > 0) {
9817
+ const placeholders = fileIds.map(() => "?").join(", ");
9818
+ db2.prepare(
9819
+ `UPDATE plugin_assignment_files SET submission_id = ?, version_id = ?
9820
+ WHERE assignment_id = ? AND student_id = ? AND id IN (${placeholders})`
9821
+ ).run(submissionId, versionId, assignment.id, studentId, ...fileIds);
9822
+ }
9823
+ const attached = db2.prepare("SELECT id, original_name, size, mime FROM plugin_assignment_files WHERE version_id = ?").all(versionId);
9824
+ const filesJson = JSON.stringify(
9825
+ attached.length > 0 ? attached.map((f) => ({ fileId: f.id, name: f.original_name, size: f.size, mime: f.mime })) : filePath ? [{ name: String(filePath), path: String(filePath) }] : []
9826
+ );
9827
+ db2.prepare(
9828
+ `INSERT INTO plugin_submission_versions
9829
+ (id, submission_id, assignment_id, student_id, version, files_json, text_content, link_url, is_late, submitted_at)
9830
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
9831
+ ).run(versionId, submissionId, assignment.id, studentId, version, filesJson, textContent ?? null, linkUrl ?? null, isLate, now);
9832
+ await publishEvent2(
9833
+ "assignment.submitted",
9834
+ {
9835
+ assignmentId: assignment.id,
9836
+ lessonId: assignment.lesson_id,
9837
+ classId: assignment.class_id,
9838
+ studentId,
9839
+ submissionId,
9840
+ version,
9841
+ isLate: isLate === 1
9842
+ },
9843
+ command.id
9844
+ );
9845
+ return { success: true, submissionId, version, versionId, assignmentId: assignment.id, isLate: isLate === 1 };
8363
9846
  }
8364
9847
  });
8365
- const peerReviewCmd = "assignment.peer_review";
8366
9848
  await actionRegistry.register({
8367
9849
  id: "eval-assignment-peer-review",
8368
- commandType: peerReviewCmd,
8369
- description: "\u5B66\u751F\u5BF9\u540C\u5B66\u5DF2\u63D0\u4EA4\u7684\u4F5C\u4E1A\u4F5C\u54C1\u8FDB\u884C\u516C\u5F00\u4E92\u8BC4\u8BC4\u5206",
8370
- capabilityRequired: "lesson:read",
9850
+ commandType: "assignment.peer_review",
9851
+ description: "\u5B66\u751F\u5BF9\u5206\u914D\u5230\u7684\u540C\u5B66\u4F5C\u4E1A\u8FDB\u884C\u4E92\u8BC4\u8BC4\u5206",
9852
+ capabilityRequired: "assignment:review",
8371
9853
  inputSchema: {
8372
9854
  type: "OBJECT",
8373
9855
  properties: {
8374
9856
  submissionId: { type: "STRING", description: "\u88AB\u8BC4\u4EF7\u7684\u4F5C\u4E1A\u63D0\u4EA4\u7269 ID" },
8375
9857
  reviewerId: { type: "STRING", description: "\u6267\u884C\u8BC4\u4EF7\u7684\u5B66\u751F ID" },
8376
9858
  score: { type: "INTEGER", description: "\u4E92\u8BC4\u5206\u6570 (0-100)" },
8377
- comment: { type: "STRING", description: "\u4E92\u8BC4\u610F\u89C1" }
9859
+ comment: { type: "STRING", description: "\u4E92\u8BC4\u610F\u89C1" },
9860
+ taskId: { type: "STRING", description: "\u4E92\u8BC4\u4EFB\u52A1 ID\uFF08\u5206\u914D\u5F0F\u4E92\u8BC4\u65F6\u63D0\u4F9B\uFF09" }
8378
9861
  },
8379
9862
  required: ["submissionId", "reviewerId", "score"]
8380
9863
  }
8381
9864
  });
8382
- await commandBus.registerHandler(peerReviewCmd, {
9865
+ await commandBus.registerHandler("assignment.peer_review", {
8383
9866
  async execute(command) {
8384
- const { submissionId, reviewerId, score, comment } = command.payload;
9867
+ const { submissionId, reviewerId, score, comment, taskId } = command.payload;
8385
9868
  if (!submissionId || !reviewerId || score === void 0) {
8386
9869
  throw new Error("Missing required params: submissionId, reviewerId, score");
8387
9870
  }
9871
+ assertStudentOwnership(command.actorId, reviewerId, "review");
8388
9872
  const parsedScore = Math.round(Number(score));
8389
9873
  if (isNaN(parsedScore) || parsedScore < 0 || parsedScore > 100) {
8390
9874
  throw new Error("Access Denied: Score must be between 0 and 100");
8391
9875
  }
8392
- const submission = db2.prepare("SELECT student_id FROM plugin_submissions WHERE id = ?").get(submissionId);
9876
+ const submission = db2.prepare("SELECT id, assignment_id, student_id FROM plugin_submissions WHERE id = ?").get(submissionId);
8393
9877
  if (!submission) {
8394
9878
  throw new Error(`Submission not found: ${submissionId}`);
8395
9879
  }
8396
9880
  if (submission.student_id === reviewerId) {
8397
9881
  throw new Error("Access Denied: Students are not allowed to evaluate their own assignments");
8398
9882
  }
9883
+ const task = db2.prepare("SELECT id, due_at, status FROM plugin_peer_review_tasks WHERE submission_id = ? AND reviewer_id = ?").get(submissionId, reviewerId);
9884
+ if (!task && submission.assignment_id) {
9885
+ const taskCount = db2.prepare("SELECT COUNT(*) AS c FROM plugin_peer_review_tasks WHERE assignment_id = ?").get(submission.assignment_id).c;
9886
+ if (taskCount > 0) {
9887
+ throw new Error("Access Denied: This submission is not assigned to you for peer review");
9888
+ }
9889
+ }
9890
+ const assignmentRow = submission.assignment_id ? loadAssignment(submission.assignment_id) : void 0;
9891
+ const resolvedTaskId = taskId || task?.id || null;
9892
+ const dueAt = task?.due_at ?? assignmentRow?.peer_review_due_at ?? null;
9893
+ if (dueAt !== null && dueAt !== void 0 && Date.now() > Number(dueAt)) {
9894
+ throw new Error("Access Denied: The peer review deadline has passed");
9895
+ }
9896
+ const now = Date.now();
8399
9897
  const reviewId = "rev-" + (0, import_uuid8.v7)();
8400
9898
  db2.prepare(
8401
9899
  `
8402
- INSERT INTO plugin_peer_reviews (id, submission_id, reviewer_id, score, comment, created_at)
8403
- VALUES (?, ?, ?, ?, ?, ?)
9900
+ INSERT INTO plugin_peer_reviews
9901
+ (id, submission_id, reviewer_id, score, comment, created_at, assignment_id, task_id, anonymous, status, updated_at)
9902
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'submitted', ?)
8404
9903
  ON CONFLICT(submission_id, reviewer_id) DO UPDATE SET
8405
9904
  score = excluded.score,
8406
9905
  comment = excluded.comment,
8407
- created_at = excluded.created_at
9906
+ created_at = excluded.created_at,
9907
+ assignment_id = excluded.assignment_id,
9908
+ task_id = excluded.task_id,
9909
+ status = 'submitted',
9910
+ updated_at = excluded.updated_at
8408
9911
  `
8409
- ).run(reviewId, submissionId, reviewerId, parsedScore, comment || "", Date.now());
9912
+ ).run(
9913
+ reviewId,
9914
+ submissionId,
9915
+ reviewerId,
9916
+ parsedScore,
9917
+ comment || "",
9918
+ now,
9919
+ submission.assignment_id,
9920
+ resolvedTaskId,
9921
+ now
9922
+ );
9923
+ if (task) {
9924
+ db2.prepare("UPDATE plugin_peer_review_tasks SET status = 'submitted' WHERE id = ?").run(task.id);
9925
+ }
8410
9926
  return { success: true, reviewId };
8411
9927
  }
8412
9928
  });
8413
- const gradeCmd = "assignment.grade";
9929
+ await actionRegistry.register({
9930
+ id: "eval-assignment-assign-peer-reviews",
9931
+ commandType: "assignment.assign_peer_reviews",
9932
+ description: "\u4E3A\u4F5C\u4E1A\u7684\u6BCF\u4EFD\u63D0\u4EA4\u968F\u673A\u5206\u914D\u4E92\u8BC4\u4EBA\uFF08\u53CC\u76F2\uFF0C\u6392\u9664\u672C\u4EBA\uFF09",
9933
+ capabilityRequired: "assignment:manage",
9934
+ inputSchema: {
9935
+ type: "OBJECT",
9936
+ properties: {
9937
+ assignmentId: { type: "STRING", description: "\u4F5C\u4E1A\u5B9E\u4F53 ID" },
9938
+ reviewerCount: { type: "NUMBER", description: "\u6BCF\u4EFD\u63D0\u4EA4\u5206\u914D\u51E0\u540D\u4E92\u8BC4\u4EBA\uFF08\u9ED8\u8BA4\u53D6\u4F5C\u4E1A\u914D\u7F6E\uFF09" },
9939
+ dueAt: { type: "NUMBER", description: "\u4E92\u8BC4\u622A\u6B62\u65F6\u95F4\uFF08\u6BEB\u79D2\u65F6\u95F4\u6233\uFF09" }
9940
+ },
9941
+ required: ["assignmentId"]
9942
+ }
9943
+ });
9944
+ await commandBus.registerHandler("assignment.assign_peer_reviews", {
9945
+ async execute(command) {
9946
+ const { assignmentId, dueAt } = command.payload;
9947
+ const assignment = loadAssignment(assignmentId);
9948
+ if (!assignment) throw new Error(`Assignment not found: ${assignmentId}`);
9949
+ const reviewerCount = Math.max(1, Math.min(10, Number(command.payload.reviewerCount ?? assignment.peer_review_count) || 1));
9950
+ const submissions = db2.prepare("SELECT id, student_id FROM plugin_submissions WHERE assignment_id = ? ORDER BY created_at ASC").all(assignmentId);
9951
+ const pools = /* @__PURE__ */ new Map();
9952
+ let created = 0;
9953
+ const now = Date.now();
9954
+ for (const submission of submissions) {
9955
+ const candidates = submissions.filter((s) => s.student_id !== submission.student_id);
9956
+ if (candidates.length === 0) continue;
9957
+ candidates.sort((a, b) => (pools.get(a.student_id) || 0) - (pools.get(b.student_id) || 0));
9958
+ const picked = candidates.slice(0, Math.min(reviewerCount, candidates.length));
9959
+ for (const candidate of picked) {
9960
+ const taskId = "prt-" + (0, import_uuid8.v7)();
9961
+ const result = db2.prepare(
9962
+ `INSERT INTO plugin_peer_review_tasks
9963
+ (id, assignment_id, submission_id, reviewer_id, anonymous, status, due_at, created_at)
9964
+ VALUES (?, ?, ?, ?, 1, 'pending', ?, ?)
9965
+ ON CONFLICT(submission_id, reviewer_id) DO NOTHING`
9966
+ ).run(taskId, assignmentId, submission.id, candidate.student_id, dueAt ?? assignment.peer_review_due_at ?? null, now);
9967
+ if (result.changes > 0) {
9968
+ created += 1;
9969
+ pools.set(candidate.student_id, (pools.get(candidate.student_id) || 0) + 1);
9970
+ }
9971
+ }
9972
+ }
9973
+ if (dueAt !== void 0 && dueAt !== null) {
9974
+ db2.prepare("UPDATE plugin_assignments SET peer_review_due_at = ?, updated_at = ? WHERE id = ?").run(
9975
+ Number(dueAt),
9976
+ now,
9977
+ assignmentId
9978
+ );
9979
+ }
9980
+ return { success: true, created, submissions: submissions.length };
9981
+ }
9982
+ });
8414
9983
  await actionRegistry.register({
8415
9984
  id: "eval-assignment-grade",
8416
- commandType: gradeCmd,
9985
+ commandType: "assignment.grade",
8417
9986
  description: "\u6559\u5E08\u5BF9\u5DF2\u63D0\u4EA4\u7684\u4F5C\u4E1A\u8FDB\u884C\u7EC8\u8BC4\u6253\u5206\uFF0C\u5E76\u53EF\u52A8\u6001\u8BBE\u5B9A\u6298\u7B97\u6743\u91CD\u4E0E\u53D1\u5E03\u6210\u7EE9",
8418
- capabilityRequired: "lesson:write",
9987
+ capabilityRequired: "assignment:manage",
8419
9988
  inputSchema: {
8420
9989
  type: "OBJECT",
8421
9990
  properties: {
8422
9991
  submissionId: { type: "STRING", description: "\u88AB\u8BC4\u4EF7\u7684\u4F5C\u4E1A\u63D0\u4EA4\u7269 ID" },
8423
9992
  teacherScore: { type: "INTEGER", description: "\u6559\u5E08\u7ED9\u51FA\u7684\u5E73\u65F6\u5206 (0-100)" },
8424
9993
  teacherComment: { type: "STRING", description: "\u6559\u5E08\u8BC4\u8BED\u53CD\u9988" },
8425
- teacherWeight: { type: "NUMBER", description: "\u6559\u5E08\u6253\u5206\u6743\u91CD (\u9ED8\u8BA4 0.6)" },
8426
- peerWeight: { type: "NUMBER", description: "\u5B66\u751F\u4E92\u8BC4\u5E73\u5747\u5206\u6743\u91CD (\u9ED8\u8BA4 0.4)" },
9994
+ teacherWeight: { type: "NUMBER", description: "\u6559\u5E08\u6253\u5206\u6743\u91CD (\u9ED8\u8BA4\u53D6\u4F5C\u4E1A\u914D\u7F6E)" },
9995
+ peerWeight: { type: "NUMBER", description: "\u5B66\u751F\u4E92\u8BC4\u5E73\u5747\u5206\u6743\u91CD (\u9ED8\u8BA4\u53D6\u4F5C\u4E1A\u914D\u7F6E)" },
8427
9996
  status: { type: "STRING", description: "\u72B6\u6001\uFF0Cconfirmed \u4E3A\u786E\u8BA4\u5E76\u540C\u6B65\uFF0Cdraft \u4E3A\u8349\u7A3F" }
8428
9997
  },
8429
9998
  required: ["submissionId", "teacherScore"]
8430
9999
  }
8431
10000
  });
8432
- await commandBus.registerHandler(gradeCmd, {
10001
+ await commandBus.registerHandler("assignment.grade", {
8433
10002
  async execute(command) {
8434
- const {
8435
- submissionId,
8436
- teacherScore,
8437
- teacherComment,
8438
- teacherWeight = 0.6,
8439
- peerWeight = 0.4,
8440
- status = "draft"
8441
- } = command.payload;
10003
+ const payload = command.payload;
10004
+ const { submissionId, teacherScore, teacherComment, status = "draft" } = payload;
8442
10005
  if (!submissionId || teacherScore === void 0) {
8443
10006
  throw new Error("Missing required params: submissionId, teacherScore");
8444
10007
  }
@@ -8446,29 +10009,34 @@ var AssignmentEvalPlugin = {
8446
10009
  if (isNaN(parsedTeacherScore) || parsedTeacherScore < 0 || parsedTeacherScore > 100) {
8447
10010
  throw new Error("Access Denied: Teacher score must be between 0 and 100");
8448
10011
  }
8449
- const totalWeight = Number(teacherWeight) + Number(peerWeight);
8450
- if (Math.abs(totalWeight - 1) > 1e-3) {
8451
- throw new Error("Access Denied: The sum of teacherWeight and peerWeight must equal 1.0");
8452
- }
8453
- const submission = db2.prepare("SELECT lesson_id, student_id FROM plugin_submissions WHERE id = ?").get(submissionId);
10012
+ const submission = db2.prepare("SELECT id, assignment_id, lesson_id, student_id FROM plugin_submissions WHERE id = ?").get(submissionId);
8454
10013
  if (!submission) {
8455
10014
  throw new Error(`Submission not found: ${submissionId}`);
8456
10015
  }
10016
+ const assignment = submission.assignment_id ? loadAssignment(submission.assignment_id) : void 0;
10017
+ const teacherWeight = payload.teacherWeight === void 0 ? Number(assignment?.teacher_weight ?? 0.6) : Number(payload.teacherWeight);
10018
+ const peerWeight = payload.peerWeight === void 0 ? Number(assignment?.peer_weight ?? 0.4) : Number(payload.peerWeight);
10019
+ const totalWeight = teacherWeight + peerWeight;
10020
+ if (Math.abs(totalWeight - 1) > 1e-3) {
10021
+ throw new Error("Access Denied: The sum of teacherWeight and peerWeight must equal 1.0");
10022
+ }
8457
10023
  const reviews = db2.prepare("SELECT score FROM plugin_peer_reviews WHERE submission_id = ?").all(submissionId);
8458
- let peerAverageScore = 0;
10024
+ let peerAverageScore = null;
8459
10025
  let calculatedFinalScore = parsedTeacherScore;
8460
10026
  if (reviews.length > 0) {
8461
10027
  const sum = reviews.reduce((acc, r) => acc + r.score, 0);
8462
10028
  peerAverageScore = sum / reviews.length;
8463
10029
  calculatedFinalScore = Math.round(parsedTeacherScore * teacherWeight + peerAverageScore * peerWeight);
8464
10030
  }
10031
+ const now = Date.now();
8465
10032
  const gradeId = "grd-" + (0, import_uuid8.v7)();
8466
10033
  db2.prepare(
8467
10034
  `
8468
10035
  INSERT INTO plugin_grades (
8469
- id, submission_id, teacher_score, teacher_comment, teacher_weight, peer_weight, calculated_final_score, status, graded_at
10036
+ id, submission_id, teacher_score, teacher_comment, teacher_weight, peer_weight,
10037
+ calculated_final_score, status, graded_at, assignment_id, peer_average_score, source, published_at, graded_by
8470
10038
  )
8471
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
10039
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'teacher', ?, ?)
8472
10040
  ON CONFLICT(submission_id) DO UPDATE SET
8473
10041
  teacher_score = excluded.teacher_score,
8474
10042
  teacher_comment = excluded.teacher_comment,
@@ -8476,7 +10044,11 @@ var AssignmentEvalPlugin = {
8476
10044
  peer_weight = excluded.peer_weight,
8477
10045
  calculated_final_score = excluded.calculated_final_score,
8478
10046
  status = excluded.status,
8479
- graded_at = excluded.graded_at
10047
+ graded_at = excluded.graded_at,
10048
+ assignment_id = excluded.assignment_id,
10049
+ peer_average_score = excluded.peer_average_score,
10050
+ published_at = excluded.published_at,
10051
+ graded_by = excluded.graded_by
8480
10052
  `
8481
10053
  ).run(
8482
10054
  gradeId,
@@ -8487,12 +10059,74 @@ var AssignmentEvalPlugin = {
8487
10059
  peerWeight,
8488
10060
  calculatedFinalScore,
8489
10061
  status,
8490
- Date.now()
10062
+ now,
10063
+ submission.assignment_id,
10064
+ peerAverageScore,
10065
+ status === "confirmed" ? now : null,
10066
+ actorUserId(command.actorId)
8491
10067
  );
8492
10068
  if (status === "confirmed") {
8493
- await gradeService.saveSemesterGrade(submission.lesson_id, submission.student_id, calculatedFinalScore);
10069
+ let projectedByHost = false;
10070
+ if (submission.lesson_id) {
10071
+ try {
10072
+ await gradeService.saveSemesterGrade(submission.lesson_id, submission.student_id, calculatedFinalScore);
10073
+ projectedByHost = true;
10074
+ } catch (err) {
10075
+ ctx.log?.warn?.(`[assignment-eval] \u540C\u6B65\u5B66\u671F\u6210\u7EE9\u5931\u8D25: ${err?.message}`);
10076
+ }
10077
+ }
10078
+ if (!projectedByHost && assignment && assignment.class_id) {
10079
+ db2.prepare(
10080
+ `INSERT INTO assignments (id, class_id, lesson_id, title, description, content, created_at)
10081
+ VALUES (?, ?, ?, ?, ?, ?, ?)
10082
+ ON CONFLICT(id) DO UPDATE SET
10083
+ class_id = excluded.class_id,
10084
+ lesson_id = excluded.lesson_id,
10085
+ title = excluded.title,
10086
+ description = excluded.description`
10087
+ ).run(assignment.id, assignment.class_id, assignment.lesson_id, assignment.title, "", "", now);
10088
+ db2.prepare(
10089
+ `INSERT INTO assignment_submissions
10090
+ (assignment_id, student_id, content, score, feedback, submitted_at, graded_at, status)
10091
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'graded')
10092
+ ON CONFLICT(assignment_id, student_id) DO UPDATE SET
10093
+ score = excluded.score,
10094
+ feedback = excluded.feedback,
10095
+ graded_at = excluded.graded_at,
10096
+ status = 'graded'`
10097
+ ).run(
10098
+ assignment.id,
10099
+ submission.student_id,
10100
+ "",
10101
+ calculatedFinalScore,
10102
+ teacherComment || "",
10103
+ now,
10104
+ now
10105
+ );
10106
+ }
10107
+ await publishEvent2(
10108
+ "assignment.graded",
10109
+ {
10110
+ assignmentId: submission.assignment_id,
10111
+ lessonId: submission.lesson_id,
10112
+ studentId: submission.student_id,
10113
+ submissionId,
10114
+ score: calculatedFinalScore,
10115
+ teacherScore: parsedTeacherScore,
10116
+ peerAverageScore,
10117
+ feedback: teacherComment || ""
10118
+ },
10119
+ command.id
10120
+ );
8494
10121
  }
8495
- return { success: true, calculatedFinalScore };
10122
+ return {
10123
+ success: true,
10124
+ calculatedFinalScore,
10125
+ peerAverageScore,
10126
+ teacherWeight,
10127
+ peerWeight,
10128
+ status
10129
+ };
8496
10130
  }
8497
10131
  });
8498
10132
  },
@@ -8775,6 +10409,9 @@ var AuthSessionBridgeService = class {
8775
10409
  }
8776
10410
  };
8777
10411
 
10412
+ // packages/core/kernel/index.ts
10413
+ init_courseware_runtime_script_registry();
10414
+
8778
10415
  // packages/core/plugin-host/index.ts
8779
10416
  var import_uuid13 = require("uuid");
8780
10417
  var import_fs5 = __toESM(require("fs"), 1);
@@ -8782,7 +10419,7 @@ var import_jszip2 = __toESM(require("jszip"), 1);
8782
10419
  var import_path4 = __toESM(require("path"), 1);
8783
10420
  var import_express = __toESM(require("express"), 1);
8784
10421
  var import_node_module = require("node:module");
8785
- var import_node_url = require("node:url");
10422
+ var import_node_url2 = require("node:url");
8786
10423
 
8787
10424
  // packages/core/esm-loader/install-utils.ts
8788
10425
  var import_jszip = __toESM(require("jszip"), 1);
@@ -8867,7 +10504,19 @@ async function validateAndBundleZip(zipBuffer) {
8867
10504
  const rawManifest = JSON.parse(manifestJson);
8868
10505
  rawManifest.main ||= "index.js";
8869
10506
  const manifest = manifestSchema.parse(rawManifest);
8870
- const entryFile = zip.file(manifest.main);
10507
+ let entryFile = zip.file(manifest.main);
10508
+ let resolvedMain = manifest.main;
10509
+ if (!entryFile && manifest.main.startsWith("dist/")) {
10510
+ const fallback = manifest.main.slice("dist/".length);
10511
+ const fallbackEntry = zip.file(fallback);
10512
+ if (fallbackEntry) {
10513
+ console.warn(
10514
+ `[install-utils] manifest.main "${manifest.main}" not found in ZIP; falling back to "${fallback}". Plugin author should update build script to drop the dist/ prefix.`
10515
+ );
10516
+ entryFile = fallbackEntry;
10517
+ resolvedMain = fallback;
10518
+ }
10519
+ }
8871
10520
  if (!entryFile) {
8872
10521
  throw new Error(`Entry file "${manifest.main}" specified in manifest not found in ZIP package`);
8873
10522
  }
@@ -8884,7 +10533,7 @@ async function validateAndBundleZip(zipBuffer) {
8884
10533
  fs4.writeFileSync(filePath, content);
8885
10534
  }
8886
10535
  const bundledCode = await bundlePlugin(entryCode, tmpDir);
8887
- return { manifest, bundledCode, entryFileName: manifest.main };
10536
+ return { manifest, bundledCode, entryFileName: resolvedMain };
8888
10537
  } finally {
8889
10538
  try {
8890
10539
  fs4.rmSync(tmpDir, { recursive: true, force: true });
@@ -9350,7 +10999,34 @@ var HotReloadActivationError = class extends HotReloadError {
9350
10999
  init_interfaces();
9351
11000
 
9352
11001
  // packages/core/version.ts
9353
- var PLATFORM_VERSION = "0.3.18";
11002
+ var import_node_fs3 = __toESM(require("node:fs"), 1);
11003
+ var import_node_path3 = __toESM(require("node:path"), 1);
11004
+ var import_node_url = require("node:url");
11005
+ var import_meta2 = {};
11006
+ var FALLBACK_VERSION = "0.3.21";
11007
+ function readPlatformVersion() {
11008
+ const bases = [];
11009
+ if (typeof __dirname !== "undefined") bases.push(__dirname);
11010
+ try {
11011
+ bases.push(import_node_path3.default.dirname((0, import_node_url.fileURLToPath)(import_meta2.url)));
11012
+ } catch {
11013
+ }
11014
+ for (const base of bases) {
11015
+ let dir = base;
11016
+ for (; ; ) {
11017
+ try {
11018
+ const pkg = JSON.parse(import_node_fs3.default.readFileSync(import_node_path3.default.join(dir, "package.json"), "utf-8"));
11019
+ if (pkg.name === "openlearn-next" && typeof pkg.version === "string") return pkg.version;
11020
+ } catch {
11021
+ }
11022
+ const parent = import_node_path3.default.dirname(dir);
11023
+ if (parent === dir) break;
11024
+ dir = parent;
11025
+ }
11026
+ }
11027
+ return FALLBACK_VERSION;
11028
+ }
11029
+ var PLATFORM_VERSION = readPlatformVersion();
9354
11030
  var OPENLEARN_VERSION = PLATFORM_VERSION;
9355
11031
 
9356
11032
  // packages/core/plugin-host/plugin-runtime-composition.ts
@@ -9655,11 +11331,11 @@ var PluginDistributionManager = class {
9655
11331
  return;
9656
11332
  }
9657
11333
  const filePath = this.pluginHost.getPluginFilePath(this.pluginHost.resolvePluginUuid(pluginId));
9658
- const fs16 = await import("node:fs");
9659
- if (!fs16.existsSync(filePath)) {
11334
+ const fs18 = await import("node:fs");
11335
+ if (!fs18.existsSync(filePath)) {
9660
11336
  throw new Error(`Cannot reload plugin "${pluginId}": source file missing at ${filePath}`);
9661
11337
  }
9662
- const code = fs16.readFileSync(filePath, "utf-8");
11338
+ const code = fs18.readFileSync(filePath, "utf-8");
9663
11339
  await this.pluginHost.reloadPlugin(pluginId, code);
9664
11340
  }
9665
11341
  async updateFromZip(zipBuffer, options = {}) {
@@ -9689,7 +11365,7 @@ var PluginDistributionManager = class {
9689
11365
 
9690
11366
  // packages/core/plugin-host/index.ts
9691
11367
  init_http_router();
9692
- var import_meta2 = {};
11368
+ var import_meta3 = {};
9693
11369
  var VALID_TRANSITIONS = {
9694
11370
  ["installed" /* INSTALLED */]: ["activating" /* ACTIVATING */, "uninstalled" /* UNINSTALLED */],
9695
11371
  ["activating" /* ACTIVATING */]: ["active" /* ACTIVE */, "error" /* ERROR */],
@@ -9724,7 +11400,7 @@ function resolveHostSdkDir() {
9724
11400
  const bases = [];
9725
11401
  if (typeof __dirname !== "undefined") bases.push(__dirname);
9726
11402
  try {
9727
- bases.push(import_path4.default.dirname((0, import_node_url.fileURLToPath)(import_meta2.url)));
11403
+ bases.push(import_path4.default.dirname((0, import_node_url2.fileURLToPath)(import_meta3.url)));
9728
11404
  } catch {
9729
11405
  }
9730
11406
  for (const base of bases) {
@@ -9883,7 +11559,7 @@ var PluginHost = class {
9883
11559
  /**
9884
11560
  * 判断指定插件的某个请求路径是否为流式路由 (SSE)
9885
11561
  */
9886
- isStreamRoute(pluginIdOrManifestId, method, path21) {
11562
+ isStreamRoute(pluginIdOrManifestId, method, path24) {
9887
11563
  const upperMethod = method.toUpperCase();
9888
11564
  const resolvedId = this.resolvePluginUuid(pluginIdOrManifestId);
9889
11565
  const instance = this.pluginInstances.get(resolvedId) ?? this.pluginInstances.get(pluginIdOrManifestId);
@@ -9892,7 +11568,7 @@ var PluginHost = class {
9892
11568
  for (const r of manifest.api.routes) {
9893
11569
  if (r.method.toUpperCase() === upperMethod && r.streaming === true) {
9894
11570
  const compiled = compileRoutePattern(r.path);
9895
- if (compiled.regex.test(path21)) {
11571
+ if (compiled.regex.test(path24)) {
9896
11572
  return true;
9897
11573
  }
9898
11574
  }
@@ -9900,14 +11576,14 @@ var PluginHost = class {
9900
11576
  }
9901
11577
  if (!instance) return false;
9902
11578
  if (instance.context?.http) {
9903
- return instance.context.http.isStream(upperMethod, path21);
11579
+ return instance.context.http.isStream(upperMethod, path24);
9904
11580
  }
9905
11581
  if (instance.workerRef) {
9906
11582
  const routes = instance.workerRef.serviceHost.getRegisteredRoutes();
9907
11583
  for (const r of routes) {
9908
11584
  if (r.method === upperMethod && r.isStream) {
9909
11585
  const compiled = compileRoutePattern(r.pattern);
9910
- if (compiled.regex.test(path21)) {
11586
+ if (compiled.regex.test(path24)) {
9911
11587
  return true;
9912
11588
  }
9913
11589
  }
@@ -10550,7 +12226,7 @@ var PluginHost = class {
10550
12226
  let mod;
10551
12227
  if (process.env.NODE_ENV !== "test" && row.file_path && import_fs5.default.existsSync(row.file_path)) {
10552
12228
  this.ensureHostSdkResolution();
10553
- const fileUrl = (0, import_node_url.pathToFileURL)(row.file_path);
12229
+ const fileUrl = (0, import_node_url2.pathToFileURL)(row.file_path);
10554
12230
  mod = await import(`${fileUrl.href}?t=${Date.now()}`);
10555
12231
  } else {
10556
12232
  let sourceCode = "";
@@ -10885,6 +12561,7 @@ var PluginHost = class {
10885
12561
  } catch (termErr) {
10886
12562
  console.error(`[PluginHost] Worker termination error for "${pluginId}":`, termErr);
10887
12563
  } finally {
12564
+ this.resourceTracker.disposeAll(pluginId);
10888
12565
  this.pluginStates.set(pluginId, "inactive" /* INACTIVE */);
10889
12566
  this.pluginInstances.delete(pluginId);
10890
12567
  this.db.prepare("UPDATE plugins SET status = ? WHERE id = ?").run("inactive", pluginId);
@@ -10971,6 +12648,7 @@ var PluginHost = class {
10971
12648
  await this.deactivatePlugin(pluginId);
10972
12649
  }
10973
12650
  }
12651
+ this.resourceTracker.disposeAll(pluginId);
10974
12652
  const state = this.pluginStates.get(pluginId) ?? "installed" /* INSTALLED */;
10975
12653
  const row = this.db.prepare("SELECT manifest, file_path FROM plugins WHERE id = ?").get(pluginId);
10976
12654
  if (state === "uninstalled" /* UNINSTALLED */ && !row) {
@@ -11004,6 +12682,12 @@ var PluginHost = class {
11004
12682
  } catch (e) {
11005
12683
  console.warn(`[PluginHost] Failed to drop plugin tables for "${pluginId}":`, e);
11006
12684
  }
12685
+ try {
12686
+ const capService = await this.serviceRegistry.resolve(ICapabilityServiceToken);
12687
+ await capService.revokeAll(`plugin:${manifestId}`);
12688
+ } catch (capErr) {
12689
+ console.warn(`[PluginHost] Failed to revoke capabilities for "${pluginId}":`, capErr);
12690
+ }
11007
12691
  if (this.expressApp && this._registeredRoutes.has(manifestId)) {
11008
12692
  try {
11009
12693
  const route = this._registeredRoutes.get(manifestId);
@@ -11159,8 +12843,8 @@ var PluginHost = class {
11159
12843
  dependencies: manifest.dependencies
11160
12844
  };
11161
12845
  import_fs5.default.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8");
11162
- const { execSync: execSync2 } = await import("node:child_process");
11163
- execSync2(
12846
+ const { execSync } = await import("node:child_process");
12847
+ execSync(
11164
12848
  "npm install --production --no-audit --no-fund --legacy-peer-deps --ignore-scripts --registry=https://registry.npmmirror.com",
11165
12849
  {
11166
12850
  cwd: pluginDir,
@@ -11186,8 +12870,8 @@ var PluginHost = class {
11186
12870
  if (import_fs5.default.existsSync(deployScriptPath)) {
11187
12871
  console.log(`[PluginHost] Running deploy script: node ${deployScriptPath}`);
11188
12872
  try {
11189
- const { execSync: execSync2 } = await import("node:child_process");
11190
- execSync2(`node "${deployScriptPath}" "${process.cwd()}"`, { timeout: 12e4 });
12873
+ const { execSync } = await import("node:child_process");
12874
+ execSync(`node "${deployScriptPath}" "${process.cwd()}"`, { timeout: 12e4 });
11191
12875
  console.log(`[PluginHost] Deploy script completed for plugin "${manifest.id}"`);
11192
12876
  } catch (deployErr) {
11193
12877
  console.error(`[PluginHost] Deploy script failed for plugin "${manifest.id}":`, deployErr.message);
@@ -11408,8 +13092,8 @@ var PluginHost = class {
11408
13092
  ),
11409
13093
  "utf-8"
11410
13094
  );
11411
- const { execSync: execSync2 } = await import("node:child_process");
11412
- execSync2(
13095
+ const { execSync } = await import("node:child_process");
13096
+ execSync(
11413
13097
  "npm install --production --no-audit --no-fund --legacy-peer-deps --registry=https://registry.npmmirror.com",
11414
13098
  {
11415
13099
  cwd: pluginDir,
@@ -11917,8 +13601,8 @@ var HotReloadController = class {
11917
13601
  const timer = setTimeout(async () => {
11918
13602
  this.debounceTimers.delete(pluginId);
11919
13603
  try {
11920
- const fs16 = await import("fs/promises");
11921
- const newSourceCode = await fs16.readFile(filePath, "utf-8");
13604
+ const fs18 = await import("fs/promises");
13605
+ const newSourceCode = await fs18.readFile(filePath, "utf-8");
11922
13606
  console.log(`[HotReload] File changed: ${filePath} \u2192 triggering reload for "${pluginId}"`);
11923
13607
  await this.pluginHost.reloadPlugin(pluginId, newSourceCode);
11924
13608
  } catch (err) {
@@ -18358,6 +20042,7 @@ var Kernel = class {
18358
20042
  getServiceRegistryKernel: async () => this.platformServiceRegistryKernel
18359
20043
  });
18360
20044
  this.serviceRegistry.register(IAuthSessionBridgeToken, new AuthSessionBridgeService(this.db));
20045
+ this.serviceRegistry.register(ICoursewareRuntimeScriptRegistryToken, new CoursewareRuntimeScriptRegistry());
18361
20046
  this.commandBus.setInterceptor(async (command) => {
18362
20047
  const action = this.actionRegistry.getActionByCommandType(command.type);
18363
20048
  if (action) {
@@ -18840,6 +20525,43 @@ function setupPresence({ io, eventBus, lookupStudentClassIds }) {
18840
20525
  }
18841
20526
  }
18842
20527
  );
20528
+ socket.on(
20529
+ "student-client-error",
20530
+ (data) => {
20531
+ if (!data || !data.studentId || !data.error) return;
20532
+ if (session && !isTeacherOrAdmin && session.userId !== data.studentId) {
20533
+ console.warn(`[Presence Security] Student ${session.userId} attempted to report error as ${data.studentId}`);
20534
+ return;
20535
+ }
20536
+ const studentName = data.studentName || onlineStudents.get(data.studentId)?.name || data.studentId;
20537
+ const lessonId = data.lessonId || activeStudentLessons.get(data.studentId) || null;
20538
+ const classId = data.classId || null;
20539
+ console.warn(
20540
+ `[Client Diagnostics] Student ${data.studentId} (${studentName}) reported error [${data.error.type || "runtime"}]: ${data.error.message || data.error.title} (Lesson: ${lessonId || "N/A"})`
20541
+ );
20542
+ eventBus.publish({
20543
+ id: `evt_err_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
20544
+ type: "student.client_error",
20545
+ source: "student_client",
20546
+ payload: {
20547
+ studentId: data.studentId,
20548
+ studentName,
20549
+ lessonId,
20550
+ classId,
20551
+ error: data.error
20552
+ },
20553
+ timestamp: data.error.timestamp || Date.now(),
20554
+ correlationId: lessonId || void 0
20555
+ });
20556
+ io.emit("student-error-alert", {
20557
+ studentId: data.studentId,
20558
+ studentName,
20559
+ lessonId,
20560
+ classId,
20561
+ error: data.error
20562
+ });
20563
+ }
20564
+ );
18843
20565
  socket.on(
18844
20566
  "teacher-broadcast-fullscreen",
18845
20567
  (data) => {
@@ -19311,6 +21033,7 @@ var runOpenAIAgentChat = async (provider, request) => {
19311
21033
  };
19312
21034
 
19313
21035
  // server/middleware/auth.ts
21036
+ var SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
19314
21037
  function getValidSession(token) {
19315
21038
  let sessionRow;
19316
21039
  try {
@@ -19329,11 +21052,21 @@ function getValidSession(token) {
19329
21052
  kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
19330
21053
  return null;
19331
21054
  }
19332
- kernelContainer.db.prepare("UPDATE client_sessions SET updated_at = ? WHERE id = ?").run(now, token);
19333
- const session = JSON.parse(sessionRow.session_data);
21055
+ if (!sessionRow.updated_at || now - sessionRow.updated_at > SESSION_REFRESH_INTERVAL_MS) {
21056
+ kernelContainer.db.prepare("UPDATE client_sessions SET updated_at = ? WHERE id = ?").run(now, token);
21057
+ }
21058
+ let session;
21059
+ try {
21060
+ session = JSON.parse(sessionRow.session_data);
21061
+ } catch {
21062
+ return null;
21063
+ }
19334
21064
  if (session && !session.userId && session.studentId) {
19335
21065
  session.userId = session.studentId;
19336
21066
  }
21067
+ if (session.role && !["teacher", "student", "administrator", "admin"].includes(session.role)) {
21068
+ session.role = "anonymous";
21069
+ }
19337
21070
  return session;
19338
21071
  }
19339
21072
  function getCookieToken(req) {
@@ -19358,8 +21091,10 @@ function getActorId(req) {
19358
21091
  if (session.username === "admin" || session.userId === "usr_admin" || role === "admin" || role === "administrator") {
19359
21092
  role = "administrator";
19360
21093
  }
21094
+ const rawUserId = session.userId || session.studentId || "demo";
21095
+ const safeUserId = String(rawUserId).replace(/[:\s]/g, "_").slice(0, 64);
19361
21096
  if (role) {
19362
- return `user:${session.userId || session.studentId || "demo"}:${role}`;
21097
+ return `user:${safeUserId}:${role}`;
19363
21098
  }
19364
21099
  return "anonymous";
19365
21100
  } catch {
@@ -19905,6 +21640,7 @@ function detectPromptInjection(input) {
19905
21640
  // server/routes/shared.ts
19906
21641
  var import_path7 = __toESM(require("path"), 1);
19907
21642
  var import_crypto5 = __toESM(require("crypto"), 1);
21643
+ init_interfaces();
19908
21644
 
19909
21645
  // server/utils/bridge-sdk.ts
19910
21646
  var BRIDGE_SDK_CODE = `(function() {
@@ -20684,7 +22420,7 @@ var BRIDGE_SDK_CODE = `(function() {
20684
22420
 
20685
22421
  // server/routes/shared.ts
20686
22422
  function validateMagicBytes(buffer, fileName) {
20687
- const MAGIC_BYTES = {
22423
+ const MAGIC_BYTES2 = {
20688
22424
  ".pdf": [[37, 80, 68, 70]],
20689
22425
  // %PDF
20690
22426
  ".pptx": [[80, 75, 3, 4]],
@@ -20700,11 +22436,34 @@ function validateMagicBytes(buffer, fileName) {
20700
22436
  // RIFF
20701
22437
  };
20702
22438
  const ext = import_path7.default.extname(fileName || "").toLowerCase();
20703
- const signatures = MAGIC_BYTES[ext];
22439
+ const signatures = MAGIC_BYTES2[ext];
20704
22440
  if (!signatures) return true;
20705
22441
  return signatures.some((sig) => sig.every((byte, i) => buffer[i] === byte));
20706
22442
  }
20707
22443
  var BLOCKED_EXTENSIONS = [".exe", ".sh", ".bat", ".cmd", ".dll", ".so", ".dylib", ".scr", ".msi", ".ps1"];
22444
+ function collectCoursewareRuntimeScripts(cwInfo) {
22445
+ const empty = { head: "", bodyEnd: "" };
22446
+ try {
22447
+ const registry = kernelContainer?.serviceRegistry?.resolve?.(ICoursewareRuntimeScriptRegistryToken);
22448
+ if (!registry || typeof registry.list !== "function") return empty;
22449
+ const scripts = registry.list({ id: cwInfo.id, uuid: cwInfo.uuid }) || [];
22450
+ if (!scripts.length) return empty;
22451
+ let head = "";
22452
+ let bodyEnd = "";
22453
+ for (const script of scripts) {
22454
+ if (!script || typeof script.source !== "string" || !script.source.trim()) continue;
22455
+ const tag = `
22456
+ <!-- Courseware Runtime Script (${script.owner}/${script.id}) -->
22457
+ <script>${script.source}</script>`;
22458
+ if (script.position === "head") head += tag;
22459
+ else bodyEnd += tag;
22460
+ }
22461
+ return { head, bodyEnd };
22462
+ } catch (err) {
22463
+ console.warn("[injectLmsSdk] \u8BFB\u53D6\u8BFE\u4EF6\u8FD0\u884C\u65F6\u811A\u672C\u6269\u5C55\u70B9\u5931\u8D25\uFF08\u5DF2\u5FFD\u7565\uFF09:", err.message);
22464
+ return empty;
22465
+ }
22466
+ }
20708
22467
  function injectLmsSdk(htmlContent, req, cwInfo) {
20709
22468
  const token = getCookieToken(req);
20710
22469
  let studentInfo = {
@@ -20762,6 +22521,7 @@ function injectLmsSdk(htmlContent, req, cwInfo) {
20762
22521
  }
20763
22522
  studentInfo.attempt_id = attempt.id;
20764
22523
  }
22524
+ const runtimeScripts = collectCoursewareRuntimeScripts(cwInfo);
20765
22525
  const injection = `
20766
22526
  <!-- LMS Courseware SDK Inject -->
20767
22527
  <script>
@@ -20771,7 +22531,7 @@ window.__LMS_COURSEWARE__ = {
20771
22531
  name: ${JSON.stringify(cwInfo.name)}
20772
22532
  };
20773
22533
  </script>
20774
- <script>${BRIDGE_SDK_CODE}</script>
22534
+ <script>${BRIDGE_SDK_CODE}</script>${runtimeScripts.head}
20775
22535
  `;
20776
22536
  let html = htmlContent;
20777
22537
  html = html.replace(
@@ -20799,6 +22559,13 @@ window.__LMS_COURSEWARE__ = {
20799
22559
  } else {
20800
22560
  html = injection + html;
20801
22561
  }
22562
+ if (runtimeScripts.bodyEnd) {
22563
+ if (/<\/body>/i.test(html)) {
22564
+ html = html.replace(/<\/body>/i, `${runtimeScripts.bodyEnd}</body>`);
22565
+ } else {
22566
+ html += runtimeScripts.bodyEnd;
22567
+ }
22568
+ }
20802
22569
  return html;
20803
22570
  }
20804
22571
  var generateStudentNumber = (db2) => {
@@ -21123,7 +22890,7 @@ var import_path9 = __toESM(require("path"), 1);
21123
22890
  var import_fs9 = __toESM(require("fs"), 1);
21124
22891
  function registerResourcesRoutes(ctx) {
21125
22892
  const { app } = ctx;
21126
- app.get("/api/resources", (req, res) => {
22893
+ app.get("/api/resources", requireAuth(), (req, res) => {
21127
22894
  try {
21128
22895
  const resources = kernelContainer.db.prepare("SELECT id, name, type, created_at FROM system_resources ORDER BY created_at DESC").all();
21129
22896
  res.json(resources);
@@ -21131,7 +22898,7 @@ function registerResourcesRoutes(ctx) {
21131
22898
  sendSafeError(res, e);
21132
22899
  }
21133
22900
  });
21134
- app.get("/api/resources/:id", (req, res) => {
22901
+ app.get("/api/resources/:id", requireAuth(), (req, res) => {
21135
22902
  try {
21136
22903
  const resource = kernelContainer.db.prepare("SELECT * FROM system_resources WHERE id = ?").get(req.params.id);
21137
22904
  if (!resource) return res.status(404).send("Resource not found");
@@ -21231,7 +22998,7 @@ function registerResourcesRoutes(ctx) {
21231
22998
  sendSafeError(res, e);
21232
22999
  }
21233
23000
  });
21234
- app.get("/api/resources/:id/*", (req, res) => {
23001
+ app.get("/api/resources/:id/*", requireAuth(), (req, res) => {
21235
23002
  try {
21236
23003
  const resource = kernelContainer.db.prepare("SELECT * FROM system_resources WHERE id = ?").get(req.params.id);
21237
23004
  if (!resource) return res.status(404).send("Resource not found");
@@ -21463,7 +23230,7 @@ function registerCoursewareRoutes(ctx) {
21463
23230
  app.get("/api/courseware", requireAuth(), async (req, res) => {
21464
23231
  try {
21465
23232
  const actorId = getActorId(req) || "system";
21466
- const cmd = kernelContainer.commandBus.createCommand("courseware.list", {}, actorId);
23233
+ const cmd = kernelContainer.commandBus.createCommand("courseware.list", {}, actorId, { silent: true });
21467
23234
  const result = await kernelContainer.commandBus.execute(cmd);
21468
23235
  res.json(result);
21469
23236
  } catch (e) {
@@ -21586,13 +23353,21 @@ function registerCoursewareRoutes(ctx) {
21586
23353
  "INSERT INTO submission_raw (id, attempt_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?)"
21587
23354
  ).run(rawId, attemptId, eventType, JSON.stringify(payload), Date.now());
21588
23355
  const extracted = extractScoreCommentCompletion(payload);
21589
- const score = extracted.score;
21590
23356
  const comment = extracted.comment;
21591
23357
  const completion = extracted.completion;
21592
- if (score !== void 0 || comment !== void 0 || completion !== void 0) {
23358
+ let aggregatedScore = null;
23359
+ let aggregationExtra = {};
23360
+ try {
23361
+ const aggregation = aggregateAttemptScore(kernelContainer.db, attemptId);
23362
+ if (aggregation.finalScore !== null) aggregatedScore = aggregation.finalScore;
23363
+ aggregationExtra = { score_aggregation: describeAggregation(aggregation) };
23364
+ } catch (aggErr) {
23365
+ console.warn("[courseware.log] score aggregation failed, fallback to latest score:", aggErr);
23366
+ }
23367
+ if (aggregatedScore !== null || comment !== void 0 || completion !== void 0) {
21593
23368
  let parsedScore = null;
21594
- if (score !== void 0 && score !== null) {
21595
- const num = parseFloat(score);
23369
+ if (extracted.score !== void 0 && extracted.score !== null) {
23370
+ const num = parseFloat(extracted.score);
21596
23371
  if (!isNaN(num)) {
21597
23372
  parsedScore = num;
21598
23373
  }
@@ -21611,13 +23386,16 @@ function registerCoursewareRoutes(ctx) {
21611
23386
  ).run(
21612
23387
  "res_" + import_crypto8.default.randomBytes(8).toString("hex"),
21613
23388
  attemptId,
21614
- parsedScore,
23389
+ aggregatedScore !== null ? aggregatedScore : parsedScore,
21615
23390
  comment || null,
21616
23391
  parsedCompletion,
21617
- JSON.stringify(payload)
23392
+ JSON.stringify({
23393
+ ...payload && typeof payload === "object" ? payload : {},
23394
+ ...aggregationExtra
23395
+ })
21618
23396
  );
21619
23397
  } else {
21620
- const finalScore = parsedScore !== null ? parsedScore : existing.score;
23398
+ const finalScore = aggregatedScore !== null ? aggregatedScore : parsedScore !== null ? parsedScore : existing.score;
21621
23399
  const finalComment = comment || existing.comment;
21622
23400
  const finalCompletion = parsedCompletion !== null ? parsedCompletion : existing.completion;
21623
23401
  let mergedExtra = {};
@@ -21628,6 +23406,7 @@ function registerCoursewareRoutes(ctx) {
21628
23406
  if (payload && typeof payload === "object") {
21629
23407
  mergedExtra = { ...mergedExtra, ...payload };
21630
23408
  }
23409
+ mergedExtra = { ...mergedExtra, ...aggregationExtra };
21631
23410
  kernelContainer.db.prepare(
21632
23411
  "UPDATE submission_result SET score = ?, comment = ?, completion = ?, extra_json = ? WHERE attempt_id = ?"
21633
23412
  ).run(finalScore, finalComment, finalCompletion, JSON.stringify(mergedExtra), attemptId);
@@ -21680,17 +23459,17 @@ function registerCoursewareRoutes(ctx) {
21680
23459
  parsedCompletion = num;
21681
23460
  }
21682
23461
  }
21683
- const actorId = session.userId || getActorId(req) || "student";
23462
+ const normalizedActorId = getActorId(req);
23463
+ const actorId = normalizedActorId && normalizedActorId !== "anonymous" ? normalizedActorId : `user:${session.userId || session.studentId || "student"}:${session.role || "student"}`;
23464
+ const payload = { attemptId };
23465
+ if (parsedScore !== null) payload.score = parsedScore;
23466
+ if (parsedCompletion !== null) payload.completion = parsedCompletion;
23467
+ if (comment !== void 0 && comment !== null) payload.comment = comment;
23468
+ if (status !== void 0 && status !== null) payload.status = status;
23469
+ if (extra && typeof extra === "object" && !Array.isArray(extra)) payload.extra = extra;
21684
23470
  const cmd = kernelContainer.commandBus.createCommand(
21685
23471
  "courseware.submit_attempt",
21686
- {
21687
- attemptId,
21688
- score: parsedScore,
21689
- comment,
21690
- completion: parsedCompletion,
21691
- status,
21692
- extra
21693
- },
23472
+ payload,
21694
23473
  actorId
21695
23474
  );
21696
23475
  const result = await kernelContainer.commandBus.execute(cmd);
@@ -21700,11 +23479,65 @@ function registerCoursewareRoutes(ctx) {
21700
23479
  sendSafeError(res, e);
21701
23480
  }
21702
23481
  });
21703
- app.get("/api/courseware/attempts", (req, res) => {
23482
+ app.post("/api/courseware/attempts/:attemptId/adopt", (req, res) => {
21704
23483
  try {
21705
- const rows = kernelContainer.db.prepare(
21706
- `
21707
- SELECT a.id as attemptId, a.started_at, a.finished_at, a.status,
23484
+ const { attemptId } = req.params;
23485
+ const token = getCookieToken(req);
23486
+ const session = req.session || (token ? getValidSession(token) : null);
23487
+ if (!session) {
23488
+ return res.status(401).json({ error: "Authentication required to adopt an attempt" });
23489
+ }
23490
+ const db2 = kernelContainer.db;
23491
+ const attemptRow = db2.prepare("SELECT id, courseware_id, student_id, status FROM courseware_attempt WHERE id = ?").get(attemptId);
23492
+ if (!attemptRow) {
23493
+ return res.status(404).json({ error: "Attempt not found" });
23494
+ }
23495
+ if (session.role === "teacher" || session.role === "administrator") {
23496
+ return res.json({ attemptId, adopted: false, reused: true, role: session.role });
23497
+ }
23498
+ const studentId = session.userId || session.studentId;
23499
+ if (!studentId) {
23500
+ return res.status(400).json({ error: "Session has no student identity" });
23501
+ }
23502
+ if (attemptRow.student_id === studentId) {
23503
+ return res.json({ attemptId, adopted: false, reused: true });
23504
+ }
23505
+ const UNOWNED_OWNERS = ["guest", "teacher", "teacher_preview", ""];
23506
+ if (UNOWNED_OWNERS.includes(attemptRow.student_id)) {
23507
+ const info = db2.prepare(
23508
+ "UPDATE courseware_attempt SET student_id = ? WHERE id = ? AND student_id IN ('guest','teacher','teacher_preview','')"
23509
+ ).run(studentId, attemptId);
23510
+ if (info.changes > 0) {
23511
+ io.emit("courseware-attempt-updated", { attemptId, type: "adopt" });
23512
+ return res.json({ attemptId, adopted: true, reused: true });
23513
+ }
23514
+ }
23515
+ let own = db2.prepare("SELECT id FROM courseware_attempt WHERE courseware_id = ? AND student_id = ? AND status = ?").get(attemptRow.courseware_id, studentId, "active");
23516
+ if (!own) {
23517
+ const newId = "att_" + import_crypto8.default.randomBytes(8).toString("hex");
23518
+ db2.prepare(
23519
+ "INSERT INTO courseware_attempt (id, courseware_id, student_id, started_at, status) VALUES (?, ?, ?, ?, ?)"
23520
+ ).run(newId, attemptRow.courseware_id, studentId, Date.now(), "active");
23521
+ own = { id: newId };
23522
+ }
23523
+ io.emit("courseware-attempt-updated", { attemptId: own.id, type: "adopt" });
23524
+ return res.json({
23525
+ attemptId: own.id,
23526
+ adopted: false,
23527
+ reused: true,
23528
+ reason: "attempt-owned-by-another-student"
23529
+ });
23530
+ } catch (e) {
23531
+ sendSafeError(res, e);
23532
+ }
23533
+ });
23534
+ app.get("/api/courseware/attempts", requireAuth(), (req, res) => {
23535
+ try {
23536
+ const session = req.session;
23537
+ const isStaff = session?.role === "teacher" || session?.role === "administrator" || session?.subRole === "administrator";
23538
+ const coursewareUuid = typeof req.query.coursewareUuid === "string" ? req.query.coursewareUuid.trim() : "";
23539
+ const baseSql = `
23540
+ SELECT a.id as attemptId, a.started_at, a.finished_at, a.status,
21708
23541
  cw.name as coursewareName, cw.uuid as coursewareUuid,
21709
23542
  COALESCE(s.name, CASE WHEN a.student_id = 'teacher' THEN 'Teacher (Test)' WHEN a.student_id = 'guest' THEN 'Guest Student' ELSE a.student_id END) as studentName,
21710
23543
  a.student_id as studentId,
@@ -21712,26 +23545,40 @@ function registerCoursewareRoutes(ctx) {
21712
23545
  (
21713
23546
  SELECT COUNT(*) FROM assignment_submissions sub
21714
23547
  JOIN assignments ast ON sub.assignment_id = ast.id
21715
- WHERE sub.student_id = a.student_id
23548
+ WHERE sub.student_id = a.student_id
21716
23549
  AND ast.title = '\u4E92\u52A8\u8BFE\u4EF6: ' || cw.name
21717
23550
  ) as isPromoted
21718
23551
  FROM courseware_attempt a
21719
23552
  JOIN courseware cw ON a.courseware_id = cw.id
21720
23553
  LEFT JOIN students s ON a.student_id = s.id
21721
23554
  LEFT JOIN submission_result r ON a.id = r.attempt_id
21722
- ORDER BY a.started_at DESC
21723
- `
21724
- ).all();
21725
- res.json(rows);
23555
+ `;
23556
+ const sql = coursewareUuid ? `${baseSql} WHERE cw.uuid = ? ORDER BY a.started_at DESC` : `${baseSql} ORDER BY a.started_at DESC`;
23557
+ const stmt = kernelContainer.db.prepare(sql);
23558
+ const rows = coursewareUuid ? stmt.all(coursewareUuid) : stmt.all();
23559
+ if (isStaff) {
23560
+ res.json(rows);
23561
+ return;
23562
+ }
23563
+ res.json(
23564
+ rows.map((row) => {
23565
+ const sanitized = { ...row };
23566
+ delete sanitized.extra_json;
23567
+ delete sanitized.comment;
23568
+ return sanitized;
23569
+ })
23570
+ );
21726
23571
  } catch (e) {
21727
23572
  sendSafeError(res, e);
21728
23573
  }
21729
23574
  });
21730
- app.post("/api/courseware/debug", (req, res) => {
23575
+ app.post("/api/courseware/debug", requireAuth(), (req, res) => {
21731
23576
  try {
21732
23577
  const { msg, url, student, courseware } = req.body;
21733
- const logMsg = `[CLIENT DEBUG] ${msg} | URL: ${url} | Student: ${JSON.stringify(student)} | Courseware: ${JSON.stringify(courseware)}`;
21734
- console.log(`\x1B[35m[CLIENT DEBUG]\x1B[0m ${msg}`);
23578
+ const safeMsg = typeof msg === "string" ? msg.slice(0, 2e3) : String(msg).slice(0, 2e3);
23579
+ const safeUrl = typeof url === "string" ? url.slice(0, 1e3) : String(url).slice(0, 1e3);
23580
+ const logMsg = `[CLIENT DEBUG] ${safeMsg} | URL: ${safeUrl} | Student: ${JSON.stringify(student)?.slice(0, 1e3)} | Courseware: ${JSON.stringify(courseware)?.slice(0, 1e3)}`;
23581
+ console.log(`\x1B[35m[CLIENT DEBUG]\x1B[0m ${safeMsg}`);
21735
23582
  const logFile2 = import_path10.default.join(process.cwd(), "client_debug.log");
21736
23583
  import_fs10.default.appendFileSync(logFile2, `${(/* @__PURE__ */ new Date()).toISOString()} - ${logMsg}
21737
23584
  `);
@@ -21740,13 +23587,20 @@ function registerCoursewareRoutes(ctx) {
21740
23587
  sendSafeError(res, e);
21741
23588
  }
21742
23589
  });
21743
- app.get("/api/courseware/attempts/:attemptId/raw", async (req, res) => {
23590
+ app.get("/api/courseware/attempts/:attemptId/raw", requireAuth(), async (req, res) => {
21744
23591
  try {
21745
23592
  const { attemptId } = req.params;
23593
+ const session = req.session;
23594
+ if (session.role !== "teacher" && session.role !== "administrator") {
23595
+ const owner = kernelContainer.db.prepare("SELECT student_id FROM courseware_attempt WHERE id = ?").get(attemptId);
23596
+ if (!owner || owner.student_id !== (session.userId || session.studentId)) {
23597
+ return res.status(403).json({ error: "Forbidden: Cannot read another student attempt" });
23598
+ }
23599
+ }
21746
23600
  const cmd = kernelContainer.commandBus.createCommand(
21747
23601
  "courseware.get_attempt_raw_data",
21748
23602
  { attemptId },
21749
- "teacher-demo"
23603
+ getActorId(req)
21750
23604
  );
21751
23605
  const result = await kernelContainer.commandBus.execute(cmd);
21752
23606
  res.json(result);
@@ -21754,9 +23608,16 @@ function registerCoursewareRoutes(ctx) {
21754
23608
  sendSafeError(res, e);
21755
23609
  }
21756
23610
  });
21757
- app.get("/api/courseware/attempts/:attemptId/progress", (req, res) => {
23611
+ app.get("/api/courseware/attempts/:attemptId/progress", requireAuth(), (req, res) => {
21758
23612
  try {
21759
23613
  const { attemptId } = req.params;
23614
+ const session = req.session;
23615
+ if (session && session.role !== "teacher" && session.role !== "administrator" && session.subRole !== "administrator") {
23616
+ const owner = kernelContainer.db.prepare("SELECT student_id FROM courseware_attempt WHERE id = ?").get(attemptId);
23617
+ if (!owner || owner.student_id !== (session.userId || session.studentId)) {
23618
+ return res.status(403).json({ success: false, error: "Forbidden: Cannot read another student attempt" });
23619
+ }
23620
+ }
21760
23621
  const result = kernelContainer.db.prepare("SELECT score, comment, completion, extra_json FROM submission_result WHERE attempt_id = ?").get(attemptId);
21761
23622
  if (!result) {
21762
23623
  return res.json({ progress: null });
@@ -21773,7 +23634,7 @@ function registerCoursewareRoutes(ctx) {
21773
23634
  sendSafeError(res, e);
21774
23635
  }
21775
23636
  });
21776
- app.post("/api/courseware/attempts/:attemptId/promote", async (req, res) => {
23637
+ app.post("/api/courseware/attempts/:attemptId/promote", requireAuth("teacher", "administrator"), async (req, res) => {
21777
23638
  try {
21778
23639
  const { attemptId } = req.params;
21779
23640
  const { lessonId, classId } = req.body;
@@ -21806,24 +23667,25 @@ function registerCoursewareRoutes(ctx) {
21806
23667
  }
21807
23668
  }
21808
23669
  const assignmentTitle = `\u4E92\u52A8\u8BFE\u4EF6: ${coursewareName}`;
21809
- let assignment = kernelContainer.db.prepare("SELECT id FROM assignments WHERE class_id = ? AND lesson_id = ? AND title = ?").get(classId, lessonId, assignmentTitle);
21810
- let assignmentId = assignment?.id;
21811
- if (!assignmentId) {
21812
- assignmentId = "ast-cw-" + import_crypto8.default.randomBytes(8).toString("hex");
23670
+ const promoteTx = kernelContainer.db.transaction(() => {
23671
+ let assignment = kernelContainer.db.prepare("SELECT id FROM assignments WHERE class_id = ? AND lesson_id = ? AND title = ?").get(classId, lessonId, assignmentTitle);
23672
+ let assignmentId2 = assignment?.id;
23673
+ if (!assignmentId2) {
23674
+ assignmentId2 = "ast-cw-" + import_crypto8.default.randomBytes(8).toString("hex");
23675
+ kernelContainer.db.prepare(
23676
+ "INSERT INTO assignments (id, class_id, lesson_id, title, description, content, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
23677
+ ).run(
23678
+ assignmentId2,
23679
+ classId,
23680
+ lessonId,
23681
+ assignmentTitle,
23682
+ `\u6765\u81EA\u4E92\u52A8\u8BFE\u4EF6 [${coursewareName}] \u7684\u968F\u5802\u5B66\u4E60\u63D0\u4EA4\u6570\u636E\u8BB0\u5F55`,
23683
+ JSON.stringify({ type: "interactive_courseware", attemptId, coursewareUuid: attempt.courseware_uuid }),
23684
+ Date.now()
23685
+ );
23686
+ }
21813
23687
  kernelContainer.db.prepare(
21814
- "INSERT INTO assignments (id, class_id, lesson_id, title, description, content, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
21815
- ).run(
21816
- assignmentId,
21817
- classId,
21818
- lessonId,
21819
- assignmentTitle,
21820
- `\u6765\u81EA\u4E92\u52A8\u8BFE\u4EF6 [${coursewareName}] \u7684\u968F\u5802\u5B66\u4E60\u63D0\u4EA4\u6570\u636E\u8BB0\u5F55`,
21821
- JSON.stringify({ type: "interactive_courseware", attemptId, coursewareUuid: attempt.courseware_uuid }),
21822
- Date.now()
21823
- );
21824
- }
21825
- kernelContainer.db.prepare(
21826
- `
23688
+ `
21827
23689
  INSERT INTO assignment_submissions (assignment_id, student_id, content, score, feedback, submitted_at, graded_at, status)
21828
23690
  VALUES (?, ?, ?, ?, ?, ?, ?, 'graded')
21829
23691
  ON CONFLICT(assignment_id, student_id) DO UPDATE SET
@@ -21834,24 +23696,27 @@ function registerCoursewareRoutes(ctx) {
21834
23696
  graded_at = excluded.graded_at,
21835
23697
  status = 'graded'
21836
23698
  `
21837
- ).run(
21838
- assignmentId,
21839
- studentId,
21840
- attempt.extra_json || "{}",
21841
- finalScore,
21842
- `\u7531\u6559\u5E08\u5728\u8BFE\u5802\u4E2D\u4FDD\u5B58\u5F55\u5165\u3002\u8BFE\u4EF6\u5B8C\u6210\u5EA6: ${Math.round(completion * 100)}%\u3002\u8BFE\u4EF6\u539F\u59CB\u53CD\uFFFD?: ${attempt.comment || "\uFFFD?"}`,
21843
- Date.now(),
21844
- Date.now()
21845
- );
21846
- kernelContainer.db.prepare(
21847
- `
23699
+ ).run(
23700
+ assignmentId2,
23701
+ studentId,
23702
+ attempt.extra_json || "{}",
23703
+ finalScore,
23704
+ `\u7531\u6559\u5E08\u5728\u8BFE\u5802\u4E2D\u4FDD\u5B58\u5F55\u5165\u3002\u8BFE\u4EF6\u5B8C\u6210\u5EA6: ${Math.round(completion * 100)}%\u3002\u8BFE\u4EF6\u539F\u59CB\u53CD\uFFFD?: ${attempt.comment || "\uFFFD?"}`,
23705
+ Date.now(),
23706
+ Date.now()
23707
+ );
23708
+ kernelContainer.db.prepare(
23709
+ `
21848
23710
  INSERT INTO student_lesson_progress (student_id, lesson_id, completed, progress_percent, completed_segments, assigned_at)
21849
23711
  VALUES (?, ?, 1, 100, '[]', ?)
21850
23712
  ON CONFLICT(student_id, lesson_id) DO UPDATE SET
21851
23713
  completed = 1,
21852
23714
  progress_percent = 100
21853
23715
  `
21854
- ).run(studentId, lessonId, Date.now());
23716
+ ).run(studentId, lessonId, Date.now());
23717
+ return assignmentId2;
23718
+ });
23719
+ const assignmentId = promoteTx();
21855
23720
  io.emit("student-progress-updated", {
21856
23721
  studentId,
21857
23722
  lessonId,
@@ -21886,10 +23751,20 @@ var import_path11 = __toESM(require("path"), 1);
21886
23751
  var import_fs11 = __toESM(require("fs"), 1);
21887
23752
  function registerBridgeRoutes(ctx) {
21888
23753
  const { app } = ctx;
21889
- app.get("/bridge.js", (_req, res) => {
23754
+ app.get("/bridge.js", (req, res) => {
21890
23755
  res.setHeader("Content-Type", "application/javascript; charset=utf-8");
21891
23756
  res.setHeader("Access-Control-Allow-Origin", "*");
21892
- res.send(BRIDGE_SDK_CODE);
23757
+ const cw = typeof req.query.cw === "string" ? req.query.cw : "";
23758
+ const cwName = typeof req.query.name === "string" ? req.query.name : "";
23759
+ let runtimeScripts = "";
23760
+ try {
23761
+ const scripts = collectCoursewareRuntimeScripts({ id: cw, name: cwName, uuid: cw });
23762
+ runtimeScripts = scripts.head + scripts.bodyEnd;
23763
+ } catch (e) {
23764
+ runtimeScripts = "";
23765
+ }
23766
+ res.send(runtimeScripts ? `${BRIDGE_SDK_CODE}
23767
+ ${runtimeScripts}` : BRIDGE_SDK_CODE);
21893
23768
  });
21894
23769
  app.get("/runtime/:uuid", (req, res, next) => {
21895
23770
  if (req.path.endsWith("/")) return next();
@@ -22139,7 +24014,7 @@ function requireWhiteboardWriteAccess() {
22139
24014
  }
22140
24015
  function registerLessonsRoutes(ctx) {
22141
24016
  const { app, io } = ctx;
22142
- app.get("/api/lessons", (req, res) => {
24017
+ app.get("/api/lessons", requireAuth(), (req, res) => {
22143
24018
  const lessons = kernelContainer.db.prepare(
22144
24019
  `
22145
24020
  SELECT l.*, u.name as creator_name,
@@ -22151,7 +24026,7 @@ function registerLessonsRoutes(ctx) {
22151
24026
  ).all();
22152
24027
  res.json(lessons);
22153
24028
  });
22154
- app.get("/api/lessons/:lessonId/eval-submissions", (req, res) => {
24029
+ app.get("/api/lessons/:lessonId/eval-submissions", requireAuth(), (req, res) => {
22155
24030
  try {
22156
24031
  const { lessonId } = req.params;
22157
24032
  const rows = kernelContainer.db.prepare(
@@ -22167,7 +24042,7 @@ function registerLessonsRoutes(ctx) {
22167
24042
  sendSafeError(res, err);
22168
24043
  }
22169
24044
  });
22170
- app.get("/api/lessons/:lessonId/eval-grades", (req, res) => {
24045
+ app.get("/api/lessons/:lessonId/eval-grades", requireAuth("teacher", "administrator"), (req, res) => {
22171
24046
  try {
22172
24047
  const { lessonId } = req.params;
22173
24048
  const submissions = kernelContainer.db.prepare(
@@ -22217,7 +24092,7 @@ function registerLessonsRoutes(ctx) {
22217
24092
  sendSafeError(res, err);
22218
24093
  }
22219
24094
  });
22220
- app.get("/api/eval-submissions/:submissionId/reviews", (req, res) => {
24095
+ app.get("/api/eval-submissions/:submissionId/reviews", requireAuth(), (req, res) => {
22221
24096
  try {
22222
24097
  const { submissionId } = req.params;
22223
24098
  const rows = kernelContainer.db.prepare(
@@ -22233,7 +24108,7 @@ function registerLessonsRoutes(ctx) {
22233
24108
  sendSafeError(res, err);
22234
24109
  }
22235
24110
  });
22236
- app.get("/api/lessons/:lessonId/students/:studentId/eval-status", (req, res) => {
24111
+ app.get("/api/lessons/:lessonId/students/:studentId/eval-status", requireAuth(), (req, res) => {
22237
24112
  try {
22238
24113
  const { lessonId, studentId } = req.params;
22239
24114
  const submission = kernelContainer.db.prepare(
@@ -22334,7 +24209,7 @@ function registerLessonsRoutes(ctx) {
22334
24209
  sendSafeError(res, e);
22335
24210
  }
22336
24211
  });
22337
- app.get("/api/lessons/:id/whiteboard", (req, res) => {
24212
+ app.get("/api/lessons/:id/whiteboard", requireAuth(), (req, res) => {
22338
24213
  const id = req.params.id;
22339
24214
  const elements = kernelContainer.db.prepare("SELECT * FROM whiteboard_elements WHERE lesson_id = ?").all(id);
22340
24215
  if (!id.startsWith("assignment-") && !id.startsWith("snapshot-")) {
@@ -22492,6 +24367,18 @@ function registerLessonsRoutes(ctx) {
22492
24367
  dataObj.submissions[studentId] = { answer, score, time: Date.now() };
22493
24368
  kernelContainer.db.prepare("UPDATE whiteboard_elements SET data = ? WHERE id = ?").run(JSON.stringify(dataObj), elementId);
22494
24369
  io.to(`lesson-${lessonId}`).emit("whiteboard-sync", { type: "element-updated", elementId });
24370
+ io.emit("whiteboard-quiz-answered", {
24371
+ lessonId,
24372
+ elementId,
24373
+ studentId,
24374
+ studentName: session.studentName || session.name || null,
24375
+ answer,
24376
+ score,
24377
+ isCorrect,
24378
+ time: Date.now(),
24379
+ correctAnswer: dataObj.correctAnswer || null,
24380
+ question: dataObj.question || null
24381
+ });
22495
24382
  res.json({ success: true, isCorrect, score, studentId });
22496
24383
  } catch (e) {
22497
24384
  sendSafeError(res, e);
@@ -22565,11 +24452,14 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
22565
24452
  if (!ownership.allowed) {
22566
24453
  return res.status(ownership.status).json({ success: false, error: ownership.error });
22567
24454
  }
22568
- kernelContainer.db.prepare("DELETE FROM whiteboard_elements WHERE lesson_id = ?").run(id);
22569
- kernelContainer.db.prepare("DELETE FROM student_lesson_progress WHERE lesson_id = ?").run(id);
22570
- kernelContainer.db.prepare("DELETE FROM schedules WHERE lesson_id = ?").run(id);
22571
- kernelContainer.db.prepare("DELETE FROM assignments WHERE lesson_id = ?").run(id);
22572
- const result = kernelContainer.db.prepare("DELETE FROM lessons WHERE id = ?").run(id);
24455
+ const delTx = kernelContainer.db.transaction(() => {
24456
+ kernelContainer.db.prepare("DELETE FROM whiteboard_elements WHERE lesson_id = ?").run(id);
24457
+ kernelContainer.db.prepare("DELETE FROM student_lesson_progress WHERE lesson_id = ?").run(id);
24458
+ kernelContainer.db.prepare("DELETE FROM schedules WHERE lesson_id = ?").run(id);
24459
+ kernelContainer.db.prepare("DELETE FROM assignments WHERE lesson_id = ?").run(id);
24460
+ return kernelContainer.db.prepare("DELETE FROM lessons WHERE id = ?").run(id);
24461
+ });
24462
+ const result = delTx();
22573
24463
  if (result.changes === 0) {
22574
24464
  return res.status(404).json({ error: "Lesson not found" });
22575
24465
  }
@@ -22578,7 +24468,7 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
22578
24468
  sendSafeError(res, e);
22579
24469
  }
22580
24470
  });
22581
- app.get("/api/lessons/:id/stats", (req, res) => {
24471
+ app.get("/api/lessons/:id/stats", requireAuth(), (req, res) => {
22582
24472
  try {
22583
24473
  const { id } = req.params;
22584
24474
  const whiteboardCount = kernelContainer.db.prepare("SELECT COUNT(*) as count FROM whiteboard_elements WHERE lesson_id = ?").get(id).count;
@@ -22602,26 +24492,29 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
22602
24492
  const newId = (0, import_uuid16.v7)();
22603
24493
  const now = Date.now();
22604
24494
  const newTitle = `\u526F\u672C-${original.title}`;
22605
- kernelContainer.db.prepare(
22606
- "INSERT INTO lessons (id, title, content, timeline, progress_mode, progress_conditions, creator_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
22607
- ).run(
22608
- newId,
22609
- newTitle,
22610
- original.content,
22611
- original.timeline,
22612
- original.progress_mode,
22613
- original.progress_conditions,
22614
- creatorId,
22615
- now,
22616
- now
22617
- );
22618
- const whiteboardElements = kernelContainer.db.prepare("SELECT * FROM whiteboard_elements WHERE lesson_id = ?").all(id);
22619
- const insertElement = kernelContainer.db.prepare(
22620
- "INSERT INTO whiteboard_elements (id, lesson_id, type, data, created_at) VALUES (?, ?, ?, ?, ?)"
22621
- );
22622
- for (const el of whiteboardElements) {
22623
- insertElement.run((0, import_uuid16.v7)(), newId, el.type, el.data, now);
22624
- }
24495
+ const cloneTx = kernelContainer.db.transaction(() => {
24496
+ kernelContainer.db.prepare(
24497
+ "INSERT INTO lessons (id, title, content, timeline, progress_mode, progress_conditions, creator_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
24498
+ ).run(
24499
+ newId,
24500
+ newTitle,
24501
+ original.content,
24502
+ original.timeline,
24503
+ original.progress_mode,
24504
+ original.progress_conditions,
24505
+ creatorId,
24506
+ now,
24507
+ now
24508
+ );
24509
+ const whiteboardElements = kernelContainer.db.prepare("SELECT * FROM whiteboard_elements WHERE lesson_id = ?").all(id);
24510
+ const insertElement = kernelContainer.db.prepare(
24511
+ "INSERT INTO whiteboard_elements (id, lesson_id, type, data, created_at) VALUES (?, ?, ?, ?, ?)"
24512
+ );
24513
+ for (const el of whiteboardElements) {
24514
+ insertElement.run((0, import_uuid16.v7)(), newId, el.type, el.data, now);
24515
+ }
24516
+ });
24517
+ cloneTx();
22625
24518
  const cloned = kernelContainer.db.prepare(
22626
24519
  `
22627
24520
  SELECT l.*, u.name as creator_name, 0 as enrollment_count
@@ -22639,9 +24532,11 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
22639
24532
 
22640
24533
  // server/routes/workspace.ts
22641
24534
  var import_path12 = __toESM(require("path"), 1);
24535
+ var DIAGNOSTIC_MIN_INTERVAL_MS = 1e3;
24536
+ var diagnosticLastReportAt = /* @__PURE__ */ new Map();
22642
24537
  function registerWorkspaceRoutes(ctx) {
22643
24538
  const { app, MF_REMOTE_CACHE: MF_REMOTE_CACHE2 } = ctx;
22644
- app.get("/api/events", requireAuth("administrator"), (req, res) => {
24539
+ app.get("/api/events", requireAuth("administrator", "teacher"), (req, res) => {
22645
24540
  try {
22646
24541
  const events = kernelContainer.db.prepare("SELECT * FROM events ORDER BY timestamp DESC LIMIT 50").all();
22647
24542
  res.json(events);
@@ -22649,6 +24544,69 @@ function registerWorkspaceRoutes(ctx) {
22649
24544
  sendSafeError(res, e);
22650
24545
  }
22651
24546
  });
24547
+ app.post("/api/diagnostics/report", requireAuth(), (req, res) => {
24548
+ try {
24549
+ const data = req.body || {};
24550
+ const session = req.session || {};
24551
+ const actorId = session.userId || session.studentId || "unknown";
24552
+ if (!data.studentId || !data.error) {
24553
+ return res.json({ success: true });
24554
+ }
24555
+ const isTeacherOrAdmin = session.role === "teacher" || session.role === "administrator";
24556
+ if (!isTeacherOrAdmin && actorId !== data.studentId) {
24557
+ console.warn(`[Diagnostics Security] ${actorId} attempted to report error as ${data.studentId}`);
24558
+ return res.status(403).json({ success: false, error: "Forbidden: studentId mismatch" });
24559
+ }
24560
+ const now = Date.now();
24561
+ const last = diagnosticLastReportAt.get(actorId) || 0;
24562
+ if (now - last < DIAGNOSTIC_MIN_INTERVAL_MS) {
24563
+ return res.status(429).json({ success: false, error: "Too many diagnostic reports" });
24564
+ }
24565
+ diagnosticLastReportAt.set(actorId, now);
24566
+ if (diagnosticLastReportAt.size > 5e3) {
24567
+ for (const [k, v] of diagnosticLastReportAt) {
24568
+ if (now - v > 6e4) diagnosticLastReportAt.delete(k);
24569
+ }
24570
+ }
24571
+ const studentId = String(data.studentId).slice(0, 64);
24572
+ const studentName = isTeacherOrAdmin ? String(data.studentName || studentId).slice(0, 100) : String(session.username || session.studentId || studentId).slice(0, 100);
24573
+ const errorPayload = {
24574
+ type: typeof data.error?.type === "string" ? data.error.type.slice(0, 64) : "runtime",
24575
+ message: typeof data.error?.message === "string" ? data.error.message.slice(0, 2e3) : void 0,
24576
+ title: typeof data.error?.title === "string" ? data.error.title.slice(0, 200) : void 0,
24577
+ timestamp: Number(data.error?.timestamp) || now
24578
+ };
24579
+ console.warn(
24580
+ `[Client Diagnostics HTTP] Student ${studentId} (${studentName}) reported error [${errorPayload.type}]: ${errorPayload.message || errorPayload.title || ""}`
24581
+ );
24582
+ kernelContainer.eventBus.publish({
24583
+ id: `evt_err_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
24584
+ type: "student.client_error",
24585
+ source: "student_client",
24586
+ payload: {
24587
+ studentId,
24588
+ studentName,
24589
+ lessonId: data.lessonId || null,
24590
+ classId: data.classId || null,
24591
+ error: errorPayload
24592
+ },
24593
+ timestamp: errorPayload.timestamp,
24594
+ correlationId: data.lessonId || void 0
24595
+ });
24596
+ if (ctx.io) {
24597
+ ctx.io.emit("student-error-alert", {
24598
+ studentId,
24599
+ studentName,
24600
+ lessonId: data.lessonId || null,
24601
+ classId: data.classId || null,
24602
+ error: errorPayload
24603
+ });
24604
+ }
24605
+ res.json({ success: true });
24606
+ } catch (e) {
24607
+ sendSafeError(res, e);
24608
+ }
24609
+ });
22652
24610
  app.get("/api/mfe/remotes", (req, res) => {
22653
24611
  try {
22654
24612
  const name = req.query.name;
@@ -23035,7 +24993,7 @@ var import_crypto9 = __toESM(require("crypto"), 1);
23035
24993
  var import_bcryptjs2 = __toESM(require("bcryptjs"), 1);
23036
24994
  function registerRosterRoutes(ctx) {
23037
24995
  const { app, io, loginLimiter } = ctx;
23038
- app.get("/api/classes", (req, res) => {
24996
+ app.get("/api/classes", requireAuth(), (req, res) => {
23039
24997
  try {
23040
24998
  const classes = kernelContainer.db.prepare(
23041
24999
  `
@@ -23049,22 +25007,25 @@ function registerRosterRoutes(ctx) {
23049
25007
  ).all();
23050
25008
  res.json(classes);
23051
25009
  } catch (e) {
23052
- res.status(500).json({ error: e.message });
25010
+ sendSafeError(res, e);
23053
25011
  }
23054
25012
  });
23055
- app.get("/api/students", (req, res) => {
25013
+ app.get("/api/students", requireAuth(), (req, res) => {
23056
25014
  try {
23057
- const students = kernelContainer.db.prepare("SELECT * FROM students ORDER BY created_at DESC").all();
25015
+ const students = kernelContainer.db.prepare(
25016
+ "SELECT id, student_number, name, email, avatar, locked_lesson_id, private_notes, created_at FROM students ORDER BY created_at DESC"
25017
+ ).all();
23058
25018
  res.json(students);
23059
25019
  } catch (e) {
23060
- res.status(500).json({ error: e.message });
25020
+ sendSafeError(res, e);
23061
25021
  }
23062
25022
  });
23063
- app.get("/api/classes/:id/students", (req, res) => {
25023
+ app.get("/api/classes/:id/students", requireAuth(), (req, res) => {
23064
25024
  try {
23065
25025
  const students = kernelContainer.db.prepare(
23066
25026
  `
23067
- SELECT s.* FROM students s
25027
+ SELECT s.id, s.student_number, s.name, s.email, s.avatar, s.locked_lesson_id, s.private_notes, s.created_at, cs.joined_at
25028
+ FROM students s
23068
25029
  INNER JOIN class_students cs ON s.id = cs.student_id
23069
25030
  WHERE cs.class_id = ?
23070
25031
  ORDER BY cs.joined_at DESC
@@ -23541,7 +25502,7 @@ function registerRosterRoutes(ctx) {
23541
25502
  sendSafeError(res, e);
23542
25503
  }
23543
25504
  });
23544
- app.get("/api/students/:id/read_notifications", (req, res) => {
25505
+ app.get("/api/students/:id/read_notifications", requireAuth(), (req, res) => {
23545
25506
  try {
23546
25507
  const rows = kernelContainer.db.prepare("SELECT notification_id FROM student_read_notifications WHERE student_id = ?").all(req.params.id);
23547
25508
  res.json(rows.map((r) => r.notification_id));
@@ -23663,7 +25624,7 @@ function registerRosterRoutes(ctx) {
23663
25624
  res.status(550).json({ error: e.message });
23664
25625
  }
23665
25626
  });
23666
- app.get("/api/labs", (req, res) => {
25627
+ app.get("/api/labs", requireAuth(), (req, res) => {
23667
25628
  try {
23668
25629
  const labs = kernelContainer.db.prepare("SELECT * FROM computer_labs ORDER BY created_at DESC").all();
23669
25630
  res.json(labs);
@@ -23699,7 +25660,7 @@ function registerRosterRoutes(ctx) {
23699
25660
  sendSafeError(res, e);
23700
25661
  }
23701
25662
  });
23702
- app.get("/api/classes/:classId/seats", (req, res) => {
25663
+ app.get("/api/classes/:classId/seats", requireAuth(), (req, res) => {
23703
25664
  try {
23704
25665
  const classInfo = kernelContainer.db.prepare("SELECT lab_id FROM classes WHERE id = ?").get(req.params.classId);
23705
25666
  const labId = classInfo ? classInfo.lab_id : null;
@@ -23784,11 +25745,8 @@ function registerRosterRoutes(ctx) {
23784
25745
  sendSafeError(res, e);
23785
25746
  }
23786
25747
  });
23787
- app.get("/api/students/:id/export", (req, res) => {
25748
+ app.get("/api/students/:id/export", requireAuth("teacher", "administrator"), (req, res) => {
23788
25749
  try {
23789
- if (!checkIsTeacherOrAdmin(req)) {
23790
- return res.status(403).json({ error: "Only teachers and administrators can export student data" });
23791
- }
23792
25750
  const studentId = req.params.id;
23793
25751
  const student = kernelContainer.db.prepare("SELECT * FROM students WHERE id = ?").get(studentId);
23794
25752
  if (!student) return res.status(404).json({ error: "Student not found" });
@@ -23848,10 +25806,16 @@ function registerRosterRoutes(ctx) {
23848
25806
  sendSafeError(res, e);
23849
25807
  }
23850
25808
  });
23851
- app.get("/api/students/:id/progress", (req, res) => {
25809
+ app.get("/api/students/:id/progress", requireAuth(), (req, res) => {
23852
25810
  try {
25811
+ const session = req.session;
25812
+ const isPrivileged = session && (session.role === "teacher" || session.role === "administrator");
25813
+ const currentUserId = session?.userId || session?.studentId;
23853
25814
  const studentRow = kernelContainer.db.prepare("SELECT id, student_number FROM students WHERE id = ? OR student_number = ?").get(req.params.id, req.params.id);
23854
25815
  const studentId = studentRow ? studentRow.id : req.params.id;
25816
+ if (!isPrivileged && currentUserId !== studentId && currentUserId !== (studentRow?.student_number ?? null)) {
25817
+ return res.status(403).json({ error: "Forbidden: Cannot read another student progress" });
25818
+ }
23855
25819
  const progress = kernelContainer.db.prepare(
23856
25820
  `
23857
25821
  SELECT slp.*, l.title as lesson_title
@@ -23961,7 +25925,7 @@ function registerRosterRoutes(ctx) {
23961
25925
  sendSafeError(res, e);
23962
25926
  }
23963
25927
  });
23964
- app.get("/api/classes/:id/progress", (req, res) => {
25928
+ app.get("/api/classes/:id/progress", requireAuth(), (req, res) => {
23965
25929
  try {
23966
25930
  const progress = kernelContainer.db.prepare(
23967
25931
  `
@@ -23978,7 +25942,7 @@ function registerRosterRoutes(ctx) {
23978
25942
  sendSafeError(res, e);
23979
25943
  }
23980
25944
  });
23981
- app.get("/api/classes/:classId/lessons/:lessonId/progress", (req, res) => {
25945
+ app.get("/api/classes/:classId/lessons/:lessonId/progress", requireAuth(), (req, res) => {
23982
25946
  try {
23983
25947
  const progress = kernelContainer.db.prepare(
23984
25948
  `
@@ -24008,7 +25972,7 @@ function registerRosterRoutes(ctx) {
24008
25972
  sendSafeError(res, e);
24009
25973
  }
24010
25974
  });
24011
- app.get("/api/classes/:classId/dashboard", (req, res) => {
25975
+ app.get("/api/classes/:classId/dashboard", requireAuth(), (req, res) => {
24012
25976
  try {
24013
25977
  const assignments = kernelContainer.db.prepare("SELECT * FROM assignments WHERE class_id = ? ORDER BY created_at DESC").all(req.params.classId);
24014
25978
  const recentSubmissions = kernelContainer.db.prepare(
@@ -24161,7 +26125,7 @@ function registerRosterRoutes(ctx) {
24161
26125
  // server/routes/assignments.ts
24162
26126
  function registerAssignmentsRoutes(ctx) {
24163
26127
  const { app } = ctx;
24164
- app.get("/api/classes/:classId/assignments", (req, res) => {
26128
+ app.get("/api/classes/:classId/assignments", requireAuth(), (req, res) => {
24165
26129
  try {
24166
26130
  const assignments = kernelContainer.db.prepare("SELECT * FROM assignments WHERE class_id = ? ORDER BY created_at DESC").all(req.params.classId);
24167
26131
  res.json(assignments);
@@ -24299,7 +26263,7 @@ Output pure JSON only without markdown code blocks, matching this structure:
24299
26263
  sendSafeError(res, e);
24300
26264
  }
24301
26265
  });
24302
- app.get("/api/assignments/:id/submissions", (req, res) => {
26266
+ app.get("/api/assignments/:id/submissions", requireAuth(), (req, res) => {
24303
26267
  try {
24304
26268
  const submissions = kernelContainer.db.prepare(
24305
26269
  `
@@ -24414,10 +26378,389 @@ Provide a grade score (0-100) and brief feedback. Ensure you output in this exac
24414
26378
  );
24415
26379
  }
24416
26380
 
26381
+ // server/routes/assignment-hub.ts
26382
+ var import_path15 = __toESM(require("path"), 1);
26383
+ var import_fs13 = __toESM(require("fs"), 1);
26384
+ var import_crypto10 = __toESM(require("crypto"), 1);
26385
+ var import_express2 = __toESM(require("express"), 1);
26386
+
26387
+ // server/utils/upload.ts
26388
+ var import_path14 = __toESM(require("path"), 1);
26389
+ var MAGIC_BYTES = {
26390
+ ".pdf": [[37, 80, 68, 70]],
26391
+ ".pptx": [[80, 75, 3, 4]],
26392
+ ".zip": [[80, 75, 3, 4]],
26393
+ ".jpg": [[255, 216, 255]],
26394
+ ".jpeg": [[255, 216, 255]],
26395
+ ".png": [[137, 80, 78, 71]],
26396
+ ".gif": [[71, 73, 70, 56]],
26397
+ ".webp": [[82, 73, 70, 70]]
26398
+ };
26399
+ var BLOCKED_EXTENSIONS2 = [".exe", ".sh", ".bat", ".cmd", ".dll", ".so", ".dylib", ".scr", ".msi", ".ps1"];
26400
+ function validateMagicBytes2(buffer, fileName) {
26401
+ const ext = import_path14.default.extname(fileName || "").toLowerCase();
26402
+ const signatures = MAGIC_BYTES[ext];
26403
+ if (!signatures) return true;
26404
+ return signatures.some((sig) => sig.every((byte, i) => buffer[i] === byte));
26405
+ }
26406
+ var SIZE_LIMITS = {
26407
+ courseware: 50 * 1024 * 1024,
26408
+ plugin: 10 * 1024 * 1024,
26409
+ assignment: 20 * 1024 * 1024,
26410
+ generic: 10 * 1024 * 1024
26411
+ };
26412
+
26413
+ // server/routes/assignment-hub.ts
26414
+ var ALLOWED_ASSIGNMENT_EXT = /* @__PURE__ */ new Set([
26415
+ ".pdf",
26416
+ ".doc",
26417
+ ".docx",
26418
+ ".ppt",
26419
+ ".pptx",
26420
+ ".xls",
26421
+ ".xlsx",
26422
+ ".csv",
26423
+ ".txt",
26424
+ ".md",
26425
+ ".rtf",
26426
+ ".odt",
26427
+ ".odp",
26428
+ ".ods",
26429
+ ".png",
26430
+ ".jpg",
26431
+ ".jpeg",
26432
+ ".gif",
26433
+ ".webp",
26434
+ ".bmp",
26435
+ ".svg",
26436
+ ".heic",
26437
+ ".zip",
26438
+ ".py",
26439
+ ".js",
26440
+ ".mjs",
26441
+ ".cjs",
26442
+ ".ts",
26443
+ ".tsx",
26444
+ ".jsx",
26445
+ ".java",
26446
+ ".c",
26447
+ ".h",
26448
+ ".cpp",
26449
+ ".cs",
26450
+ ".go",
26451
+ ".rs",
26452
+ ".rb",
26453
+ ".php",
26454
+ ".sql",
26455
+ ".json",
26456
+ ".xml",
26457
+ ".html",
26458
+ ".css",
26459
+ ".ino"
26460
+ ]);
26461
+ var ZIP_CONTAINER_EXT = /* @__PURE__ */ new Set([".zip", ".pptx", ".docx", ".xlsx", ".odt", ".odp", ".ods", ".epub"]);
26462
+ var MAX_FILE_SIZE = 50 * 1024 * 1024;
26463
+ function safeSegment(value) {
26464
+ const cleaned = String(value || "").replace(/[^A-Za-z0-9._-]/g, "");
26465
+ return cleaned.length > 0 ? cleaned.slice(0, 96) : "unknown";
26466
+ }
26467
+ function decodeFileName(raw) {
26468
+ const text = String(raw ?? "").trim();
26469
+ if (!text) return "";
26470
+ try {
26471
+ return decodeURIComponent(text);
26472
+ } catch {
26473
+ return text;
26474
+ }
26475
+ }
26476
+ function assignmentStorageDir(assignmentId, studentId) {
26477
+ return import_path15.default.join(process.cwd(), "storage", "assignments", safeSegment(assignmentId), safeSegment(studentId));
26478
+ }
26479
+ function registerAssignmentHubRoutes(ctx) {
26480
+ const { app } = ctx;
26481
+ const describeRequester = (req) => {
26482
+ const session = req.session;
26483
+ const role = session?.role;
26484
+ const isPrivileged = role === "teacher" || role === "administrator" || role === "admin";
26485
+ const studentId = session?.userId || session?.studentId || null;
26486
+ return { isPrivileged, studentId: studentId ? String(studentId) : null };
26487
+ };
26488
+ app.post("/api/assignments", requireAuth("teacher", "administrator"), async (req, res) => {
26489
+ try {
26490
+ const cmd = kernelContainer.commandBus.createCommand("assignment.create", req.body || {}, getActorId(req));
26491
+ const result = await kernelContainer.commandBus.execute(cmd);
26492
+ res.json(result);
26493
+ } catch (e) {
26494
+ sendSafeError(res, e);
26495
+ }
26496
+ });
26497
+ app.get("/api/assignments", requireAuth(), async (req, res) => {
26498
+ try {
26499
+ const payload = {
26500
+ lessonId: req.query.lessonId,
26501
+ classId: req.query.classId,
26502
+ elementId: req.query.elementId,
26503
+ includeArchived: req.query.includeArchived === "1" || req.query.includeArchived === "true"
26504
+ };
26505
+ const { isPrivileged, studentId } = describeRequester(req);
26506
+ const requestedStudent = typeof req.query.studentId === "string" ? req.query.studentId : void 0;
26507
+ if (isPrivileged && requestedStudent) {
26508
+ payload.studentId = requestedStudent;
26509
+ } else if (!isPrivileged && studentId) {
26510
+ payload.studentId = studentId;
26511
+ }
26512
+ const cmd = kernelContainer.commandBus.createCommand("assignment.list", payload, getActorId(req), {
26513
+ silent: true
26514
+ });
26515
+ const result = await kernelContainer.commandBus.execute(cmd);
26516
+ res.json(result);
26517
+ } catch (e) {
26518
+ sendSafeError(res, e);
26519
+ }
26520
+ });
26521
+ app.get("/api/assignments/:assignmentId", requireAuth(), async (req, res) => {
26522
+ try {
26523
+ const { isPrivileged, studentId } = describeRequester(req);
26524
+ const payload = { assignmentId: req.params.assignmentId };
26525
+ const requestedStudent = typeof req.query.studentId === "string" ? req.query.studentId : void 0;
26526
+ if (isPrivileged && requestedStudent) {
26527
+ payload.studentId = requestedStudent;
26528
+ } else if (!isPrivileged && studentId) {
26529
+ payload.studentId = studentId;
26530
+ }
26531
+ if (isPrivileged) {
26532
+ payload.includePeerProgress = true;
26533
+ }
26534
+ const cmd = kernelContainer.commandBus.createCommand("assignment.get", payload, getActorId(req), { silent: true });
26535
+ const result = await kernelContainer.commandBus.execute(cmd);
26536
+ res.json(result);
26537
+ } catch (e) {
26538
+ sendSafeError(res, e);
26539
+ }
26540
+ });
26541
+ app.post(
26542
+ "/api/assignments/:assignmentId/files",
26543
+ requireAuth(),
26544
+ import_express2.default.raw({ type: () => true, limit: MAX_FILE_SIZE + 1024 * 1024 }),
26545
+ async (req, res) => {
26546
+ try {
26547
+ const assignmentId = req.params.assignmentId;
26548
+ const { isPrivileged, studentId: sessionStudentId } = describeRequester(req);
26549
+ const headerStudent = decodeFileName(req.header("x-student-id") || "");
26550
+ const ownerStudentId = isPrivileged ? headerStudent || sessionStudentId : sessionStudentId;
26551
+ if (!ownerStudentId) {
26552
+ return res.status(400).json({ success: false, error: "Missing studentId" });
26553
+ }
26554
+ const assignment = kernelContainer.db.prepare("SELECT id, max_files, max_file_size, status FROM plugin_assignments WHERE id = ?").get(assignmentId);
26555
+ if (!assignment) {
26556
+ return res.status(404).json({ success: false, error: `Assignment not found: ${assignmentId}` });
26557
+ }
26558
+ const fileName = decodeFileName(req.header("x-file-name") || "");
26559
+ if (!fileName) {
26560
+ return res.status(400).json({ success: false, error: "Missing X-File-Name header" });
26561
+ }
26562
+ const ext = import_path15.default.extname(fileName).toLowerCase();
26563
+ if (!ALLOWED_ASSIGNMENT_EXT.has(ext)) {
26564
+ return res.status(400).json({ success: false, error: `File type not allowed: ${ext || "(none)"}` });
26565
+ }
26566
+ if (BLOCKED_EXTENSIONS2.includes(ext)) {
26567
+ return res.status(400).json({ success: false, error: `File type blocked: ${ext}` });
26568
+ }
26569
+ const buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
26570
+ if (buffer.length === 0) {
26571
+ return res.status(400).json({ success: false, error: "Empty file body" });
26572
+ }
26573
+ const sizeLimit = Math.min(Number(assignment.max_file_size) || SIZE_LIMITS.assignment, MAX_FILE_SIZE);
26574
+ if (buffer.length > sizeLimit) {
26575
+ return res.status(413).json({ success: false, error: `File too large (limit ${sizeLimit} bytes)` });
26576
+ }
26577
+ if (ZIP_CONTAINER_EXT.has(ext) && !(buffer[0] === 80 && buffer[1] === 75)) {
26578
+ return res.status(400).json({ success: false, error: "File content does not match its extension" });
26579
+ }
26580
+ if (!validateMagicBytes2(buffer, fileName)) {
26581
+ return res.status(400).json({ success: false, error: "File content does not match its extension" });
26582
+ }
26583
+ const existingCount = kernelContainer.db.prepare(
26584
+ "SELECT COUNT(*) AS c FROM plugin_assignment_files WHERE assignment_id = ? AND student_id = ? AND deleted_at IS NULL"
26585
+ ).get(assignmentId, ownerStudentId).c;
26586
+ if (existingCount >= (Number(assignment.max_files) || 10)) {
26587
+ return res.status(409).json({ success: false, error: `At most ${assignment.max_files} files are allowed` });
26588
+ }
26589
+ const dir = assignmentStorageDir(assignmentId, ownerStudentId);
26590
+ import_fs13.default.mkdirSync(dir, { recursive: true });
26591
+ const storedName = `${import_crypto10.default.randomUUID()}${ext}`;
26592
+ const storedPath = import_path15.default.join(dir, storedName);
26593
+ import_fs13.default.writeFileSync(storedPath, buffer);
26594
+ const fileId = "af-" + import_crypto10.default.randomUUID();
26595
+ const sha256 = import_crypto10.default.createHash("sha256").update(buffer).digest("hex");
26596
+ kernelContainer.db.prepare(
26597
+ `INSERT INTO plugin_assignment_files
26598
+ (id, assignment_id, submission_id, version_id, student_id, original_name, stored_path, size, mime, sha256, uploaded_at)
26599
+ VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`
26600
+ ).run(
26601
+ fileId,
26602
+ assignmentId,
26603
+ ownerStudentId,
26604
+ fileName,
26605
+ import_path15.default.relative(process.cwd(), storedPath),
26606
+ buffer.length,
26607
+ req.header("content-type") || null,
26608
+ sha256,
26609
+ Date.now()
26610
+ );
26611
+ res.json({
26612
+ success: true,
26613
+ file: { id: fileId, name: fileName, size: buffer.length, sha256, studentId: ownerStudentId }
26614
+ });
26615
+ } catch (e) {
26616
+ sendSafeError(res, e);
26617
+ }
26618
+ }
26619
+ );
26620
+ app.get("/api/assignments/:assignmentId/files", requireAuth(), async (req, res) => {
26621
+ try {
26622
+ const { isPrivileged, studentId } = describeRequester(req);
26623
+ const rows = kernelContainer.db.prepare(
26624
+ `SELECT id, student_id, original_name, size, mime, sha256, uploaded_at, version_id
26625
+ FROM plugin_assignment_files
26626
+ WHERE assignment_id = ? AND deleted_at IS NULL
26627
+ ORDER BY uploaded_at DESC`
26628
+ ).all(req.params.assignmentId);
26629
+ const files = isPrivileged ? rows : rows.filter((r) => r.student_id === studentId);
26630
+ res.json({ success: true, files });
26631
+ } catch (e) {
26632
+ sendSafeError(res, e);
26633
+ }
26634
+ });
26635
+ app.get("/api/assignments/:assignmentId/files/:fileId", requireAuth(), async (req, res) => {
26636
+ try {
26637
+ const file = kernelContainer.db.prepare(
26638
+ `SELECT id, student_id, original_name, stored_path, mime, deleted_at
26639
+ FROM plugin_assignment_files WHERE id = ? AND assignment_id = ?`
26640
+ ).get(req.params.fileId, req.params.assignmentId);
26641
+ const { isPrivileged, studentId } = describeRequester(req);
26642
+ if (!file || file.deleted_at) {
26643
+ return res.status(404).json({ success: false, error: "File not found" });
26644
+ }
26645
+ if (!isPrivileged && file.student_id !== studentId) {
26646
+ const assigned = kernelContainer.db.prepare(
26647
+ `SELECT 1 AS ok FROM plugin_assignment_files f
26648
+ JOIN plugin_submission_versions v ON v.id = f.version_id
26649
+ JOIN plugin_peer_review_tasks t ON t.submission_id = v.submission_id
26650
+ WHERE f.id = ? AND t.reviewer_id = ? LIMIT 1`
26651
+ ).get(req.params.fileId, studentId);
26652
+ if (!assigned) {
26653
+ return res.status(403).json({ success: false, error: "Forbidden: Cannot read another student file" });
26654
+ }
26655
+ }
26656
+ const absPath = import_path15.default.resolve(process.cwd(), file.stored_path);
26657
+ const root = import_path15.default.join(process.cwd(), "storage", "assignments");
26658
+ if (!absPath.startsWith(root) || !import_fs13.default.existsSync(absPath)) {
26659
+ return res.status(404).json({ success: false, error: "File not found on disk" });
26660
+ }
26661
+ res.setHeader("X-Content-Type-Options", "nosniff");
26662
+ res.setHeader("Cache-Control", "private, no-store");
26663
+ res.download(absPath, file.original_name);
26664
+ } catch (e) {
26665
+ sendSafeError(res, e);
26666
+ }
26667
+ });
26668
+ app.delete("/api/assignments/:assignmentId/files/:fileId", requireAuth(), async (req, res) => {
26669
+ try {
26670
+ const file = kernelContainer.db.prepare(
26671
+ `SELECT id, student_id, version_id, deleted_at FROM plugin_assignment_files
26672
+ WHERE id = ? AND assignment_id = ?`
26673
+ ).get(req.params.fileId, req.params.assignmentId);
26674
+ const { isPrivileged, studentId } = describeRequester(req);
26675
+ if (!file || file.deleted_at) {
26676
+ return res.status(404).json({ success: false, error: "File not found" });
26677
+ }
26678
+ if (!isPrivileged && file.student_id !== studentId) {
26679
+ return res.status(403).json({ success: false, error: "Forbidden: Cannot delete another student file" });
26680
+ }
26681
+ if (file.version_id) {
26682
+ return res.status(409).json({ success: false, error: "File already attached to a submission version" });
26683
+ }
26684
+ kernelContainer.db.prepare("UPDATE plugin_assignment_files SET deleted_at = ? WHERE id = ?").run(Date.now(), req.params.fileId);
26685
+ res.json({ success: true });
26686
+ } catch (e) {
26687
+ sendSafeError(res, e);
26688
+ }
26689
+ });
26690
+ app.post("/api/assignments/:assignmentId/submit", requireAuth(), async (req, res) => {
26691
+ try {
26692
+ const { isPrivileged, studentId } = describeRequester(req);
26693
+ const body = req.body || {};
26694
+ const targetStudentId = isPrivileged ? String(body.studentId || "") : String(studentId || "");
26695
+ if (!targetStudentId) {
26696
+ return res.status(400).json({ success: false, error: "Missing studentId" });
26697
+ }
26698
+ const cmd = kernelContainer.commandBus.createCommand(
26699
+ "assignment.submit",
26700
+ {
26701
+ assignmentId: req.params.assignmentId,
26702
+ studentId: targetStudentId,
26703
+ fileIds: Array.isArray(body.fileIds) ? body.fileIds : [],
26704
+ textContent: body.textContent,
26705
+ linkUrl: body.linkUrl
26706
+ },
26707
+ getActorId(req)
26708
+ );
26709
+ const result = await kernelContainer.commandBus.execute(cmd);
26710
+ res.json(result);
26711
+ } catch (e) {
26712
+ sendSafeError(res, e);
26713
+ }
26714
+ });
26715
+ app.post("/api/assignments/:assignmentId/peer-review", requireAuth(), async (req, res) => {
26716
+ try {
26717
+ const { isPrivileged, studentId } = describeRequester(req);
26718
+ const body = req.body || {};
26719
+ const reviewerId = isPrivileged ? String(body.reviewerId || body.studentId || "") : String(studentId || "");
26720
+ if (!reviewerId) {
26721
+ return res.status(400).json({ success: false, error: "Missing reviewerId" });
26722
+ }
26723
+ const submissionId = String(body.submissionId || "");
26724
+ if (!submissionId) {
26725
+ return res.status(400).json({ success: false, error: "Missing submissionId" });
26726
+ }
26727
+ const cmd = kernelContainer.commandBus.createCommand(
26728
+ "assignment.peer_review",
26729
+ {
26730
+ submissionId,
26731
+ reviewerId,
26732
+ score: body.score,
26733
+ comment: body.comment,
26734
+ taskId: body.taskId
26735
+ },
26736
+ getActorId(req)
26737
+ );
26738
+ const result = await kernelContainer.commandBus.execute(cmd);
26739
+ res.json(result);
26740
+ } catch (e) {
26741
+ sendSafeError(res, e);
26742
+ }
26743
+ });
26744
+ app.post("/api/assignments/:assignmentId/assign-peer-reviews", requireAuth("teacher", "administrator"), async (req, res) => {
26745
+ try {
26746
+ const body = req.body || {};
26747
+ const cmd = kernelContainer.commandBus.createCommand(
26748
+ "assignment.assign_peer_reviews",
26749
+ { assignmentId: req.params.assignmentId, reviewerCount: body.reviewerCount, dueAt: body.dueAt },
26750
+ getActorId(req)
26751
+ );
26752
+ const result = await kernelContainer.commandBus.execute(cmd);
26753
+ res.json(result);
26754
+ } catch (e) {
26755
+ sendSafeError(res, e);
26756
+ }
26757
+ });
26758
+ }
26759
+
24417
26760
  // server/routes/schedules.ts
24418
26761
  function registerSchedulesRoutes(ctx) {
24419
26762
  const { app } = ctx;
24420
- app.get("/api/schedules/today", (req, res) => {
26763
+ app.get("/api/schedules/today", requireAuth(), (req, res) => {
24421
26764
  try {
24422
26765
  const clientDate = req.query.date || (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
24423
26766
  const schedules = kernelContainer.db.prepare(
@@ -24442,10 +26785,10 @@ function registerSchedulesRoutes(ctx) {
24442
26785
  ).all(clientDate, clientDate);
24443
26786
  res.json({ success: true, schedules });
24444
26787
  } catch (e) {
24445
- res.status(500).json({ error: e.message });
26788
+ sendSafeError(res, e);
24446
26789
  }
24447
26790
  });
24448
- app.get("/api/schedules", (req, res) => {
26791
+ app.get("/api/schedules", requireAuth(), (req, res) => {
24449
26792
  try {
24450
26793
  const schedules = kernelContainer.db.prepare(
24451
26794
  `
@@ -24458,10 +26801,10 @@ function registerSchedulesRoutes(ctx) {
24458
26801
  ).all();
24459
26802
  res.json(schedules);
24460
26803
  } catch (e) {
24461
- res.status(500).json({ error: e.message });
26804
+ sendSafeError(res, e);
24462
26805
  }
24463
26806
  });
24464
- app.get("/api/classes/:classId/schedules", (req, res) => {
26807
+ app.get("/api/classes/:classId/schedules", requireAuth(), (req, res) => {
24465
26808
  try {
24466
26809
  const schedules = kernelContainer.db.prepare(
24467
26810
  `
@@ -24474,7 +26817,7 @@ function registerSchedulesRoutes(ctx) {
24474
26817
  ).all(req.params.classId);
24475
26818
  res.json(schedules);
24476
26819
  } catch (e) {
24477
- res.status(500).json({ error: e.message });
26820
+ sendSafeError(res, e);
24478
26821
  }
24479
26822
  });
24480
26823
  app.post("/api/classes/:classId/schedules", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24509,7 +26852,7 @@ function registerSchedulesRoutes(ctx) {
24509
26852
  }
24510
26853
  });
24511
26854
  } catch (e) {
24512
- res.status(500).json({ error: e.message });
26855
+ sendSafeError(res, e);
24513
26856
  }
24514
26857
  });
24515
26858
  app.put("/api/classes/:classId/schedules/:scheduleId", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24532,7 +26875,7 @@ function registerSchedulesRoutes(ctx) {
24532
26875
  );
24533
26876
  res.json({ success: true });
24534
26877
  } catch (e) {
24535
- res.status(500).json({ error: e.message });
26878
+ sendSafeError(res, e);
24536
26879
  }
24537
26880
  });
24538
26881
  app.delete("/api/classes/:classId/schedules/:scheduleId", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24541,7 +26884,7 @@ function registerSchedulesRoutes(ctx) {
24541
26884
  kernelContainer.db.prepare("DELETE FROM attendance WHERE schedule_id = ?").run(req.params.scheduleId);
24542
26885
  res.json({ success: true });
24543
26886
  } catch (e) {
24544
- res.status(500).json({ error: e.message });
26887
+ sendSafeError(res, e);
24545
26888
  }
24546
26889
  });
24547
26890
  app.post("/api/classes/:classId/schedules/batch", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24570,7 +26913,7 @@ function registerSchedulesRoutes(ctx) {
24570
26913
  transaction(schedules);
24571
26914
  res.json({ success: true, count: schedules.length });
24572
26915
  } catch (e) {
24573
- res.status(500).json({ error: e.message });
26916
+ sendSafeError(res, e);
24574
26917
  }
24575
26918
  });
24576
26919
  app.post("/api/timetable/ocr", requireAuth("teacher", "administrator"), async (req, res) => {
@@ -24733,7 +27076,7 @@ function registerSchedulesRoutes(ctx) {
24733
27076
  stack: e.stack,
24734
27077
  cause: e.cause
24735
27078
  });
24736
- res.status(500).json({ error: e.message });
27079
+ sendSafeError(res, e);
24737
27080
  }
24738
27081
  });
24739
27082
  }
@@ -24742,7 +27085,7 @@ function registerSchedulesRoutes(ctx) {
24742
27085
  init_interfaces();
24743
27086
  function registerGradingRoutes(ctx) {
24744
27087
  const { app } = ctx;
24745
- app.get("/api/classes/:classId/attendance-summary", (req, res) => {
27088
+ app.get("/api/classes/:classId/attendance-summary", requireAuth(), (req, res) => {
24746
27089
  try {
24747
27090
  const classId = req.params.classId;
24748
27091
  const db2 = kernelContainer.db;
@@ -24853,10 +27196,10 @@ function registerGradingRoutes(ctx) {
24853
27196
  }).sort((a, b) => a.date.localeCompare(b.date));
24854
27197
  res.json(filtered);
24855
27198
  } catch (e) {
24856
- res.status(500).json({ error: e.message });
27199
+ sendSafeError(res, e);
24857
27200
  }
24858
27201
  });
24859
- app.get("/api/schedules/:scheduleId/attendance", (req, res) => {
27202
+ app.get("/api/schedules/:scheduleId/attendance", requireAuth(), (req, res) => {
24860
27203
  try {
24861
27204
  const attendance = kernelContainer.db.prepare(
24862
27205
  `
@@ -24868,7 +27211,7 @@ function registerGradingRoutes(ctx) {
24868
27211
  ).all(req.params.scheduleId);
24869
27212
  res.json(attendance);
24870
27213
  } catch (e) {
24871
- res.status(500).json({ error: e.message });
27214
+ sendSafeError(res, e);
24872
27215
  }
24873
27216
  });
24874
27217
  app.post("/api/schedules/:scheduleId/attendance", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24883,10 +27226,10 @@ function registerGradingRoutes(ctx) {
24883
27226
  ).run(req.params.scheduleId, studentId, status, Date.now());
24884
27227
  res.json({ success: true });
24885
27228
  } catch (e) {
24886
- res.status(500).json({ error: e.message });
27229
+ sendSafeError(res, e);
24887
27230
  }
24888
27231
  });
24889
- app.get("/api/classes/:classId/grade-weights", (req, res) => {
27232
+ app.get("/api/classes/:classId/grade-weights", requireAuth(), (req, res) => {
24890
27233
  try {
24891
27234
  const weights = kernelContainer.db.prepare("SELECT * FROM class_grade_weights WHERE class_id = ?").get(req.params.classId);
24892
27235
  if (!weights) {
@@ -24900,7 +27243,7 @@ function registerGradingRoutes(ctx) {
24900
27243
  }
24901
27244
  res.json(weights);
24902
27245
  } catch (e) {
24903
- res.status(500).json({ error: e.message });
27246
+ sendSafeError(res, e);
24904
27247
  }
24905
27248
  });
24906
27249
  app.post("/api/classes/:classId/grade-weights", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24928,15 +27271,15 @@ function registerGradingRoutes(ctx) {
24928
27271
  ).run(req.params.classId, att, prog, assign, ex, Date.now());
24929
27272
  res.json({ success: true });
24930
27273
  } catch (e) {
24931
- res.status(500).json({ error: e.message });
27274
+ sendSafeError(res, e);
24932
27275
  }
24933
27276
  });
24934
- app.get("/api/classes/:classId/exams", (req, res) => {
27277
+ app.get("/api/classes/:classId/exams", requireAuth(), (req, res) => {
24935
27278
  try {
24936
27279
  const exams = kernelContainer.db.prepare("SELECT * FROM exams WHERE class_id = ? ORDER BY created_at DESC").all(req.params.classId);
24937
27280
  res.json(exams);
24938
27281
  } catch (e) {
24939
- res.status(500).json({ error: e.message });
27282
+ sendSafeError(res, e);
24940
27283
  }
24941
27284
  });
24942
27285
  app.post("/api/classes/:classId/exams", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24952,15 +27295,15 @@ function registerGradingRoutes(ctx) {
24952
27295
  ).run(examId, req.params.classId, title, description || "", max_score || 100, Date.now());
24953
27296
  res.json({ success: true, examId });
24954
27297
  } catch (e) {
24955
- res.status(500).json({ error: e.message });
27298
+ sendSafeError(res, e);
24956
27299
  }
24957
27300
  });
24958
- app.get("/api/exams/:examId/scores", (req, res) => {
27301
+ app.get("/api/exams/:examId/scores", requireAuth(), (req, res) => {
24959
27302
  try {
24960
27303
  const scores = kernelContainer.db.prepare("SELECT * FROM exam_scores WHERE exam_id = ?").all(req.params.examId);
24961
27304
  res.json(scores);
24962
27305
  } catch (e) {
24963
- res.status(500).json({ error: e.message });
27306
+ sendSafeError(res, e);
24964
27307
  }
24965
27308
  });
24966
27309
  app.post("/api/exams/:examId/scores", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24989,10 +27332,10 @@ function registerGradingRoutes(ctx) {
24989
27332
  transaction(scores);
24990
27333
  res.json({ success: true });
24991
27334
  } catch (e) {
24992
- res.status(500).json({ error: e.message });
27335
+ sendSafeError(res, e);
24993
27336
  }
24994
27337
  });
24995
- app.get("/api/classes/:classId/semester-grades", (req, res) => {
27338
+ app.get("/api/classes/:classId/semester-grades", requireAuth(), (req, res) => {
24996
27339
  try {
24997
27340
  const classId = req.params.classId;
24998
27341
  const semesterName = req.query.semesterName || "2026\u5E74\u6625\u5B63\u5B66\uFFFD?";
@@ -25134,7 +27477,7 @@ function registerGradingRoutes(ctx) {
25134
27477
  });
25135
27478
  res.json({ success: true, weights, students: result });
25136
27479
  } catch (e) {
25137
- res.status(500).json({ error: e.message });
27480
+ sendSafeError(res, e);
25138
27481
  }
25139
27482
  });
25140
27483
  app.post("/api/grade-sync", requireAuth("teacher", "administrator"), async (req, res) => {
@@ -25147,16 +27490,16 @@ function registerGradingRoutes(ctx) {
25147
27490
  await gradeService.saveSemesterGrade(lessonId, studentId, Math.round(Number(grade)));
25148
27491
  res.json({ success: true });
25149
27492
  } catch (e) {
25150
- res.status(500).json({ success: false, error: e.message });
27493
+ sendSafeError(res, e);
25151
27494
  }
25152
27495
  });
25153
- app.get("/api/classes/:classId/points-dimensions", async (req, res) => {
27496
+ app.get("/api/classes/:classId/points-dimensions", requireAuth(), async (req, res) => {
25154
27497
  try {
25155
27498
  const dimensionRegistry = await kernelContainer.serviceRegistry.resolve(IPointsDimensionRegistryToken);
25156
27499
  const dimensions = dimensionRegistry.listDimensions();
25157
27500
  res.json({ success: true, dimensions });
25158
27501
  } catch (e) {
25159
- res.status(500).json({ success: false, error: e.message });
27502
+ sendSafeError(res, e);
25160
27503
  }
25161
27504
  });
25162
27505
  app.post("/api/students/:studentId/points", requireAuth("teacher", "administrator"), async (req, res) => {
@@ -25184,10 +27527,10 @@ function registerGradingRoutes(ctx) {
25184
27527
  });
25185
27528
  res.json({ success: true, logItem });
25186
27529
  } catch (e) {
25187
- res.status(500).json({ success: false, error: e.message });
27530
+ sendSafeError(res, e);
25188
27531
  }
25189
27532
  });
25190
- app.get("/api/students/:studentId/points-logs", async (req, res) => {
27533
+ app.get("/api/students/:studentId/points-logs", requireAuth(), async (req, res) => {
25191
27534
  try {
25192
27535
  const { studentId } = req.params;
25193
27536
  const { classId } = req.query;
@@ -25196,7 +27539,7 @@ function registerGradingRoutes(ctx) {
25196
27539
  const summary = await ledgerService.getStudentDimensionSummary(studentId, classId || "");
25197
27540
  res.json({ success: true, logs, summary });
25198
27541
  } catch (e) {
25199
- res.status(500).json({ success: false, error: e.message });
27542
+ sendSafeError(res, e);
25200
27543
  }
25201
27544
  });
25202
27545
  app.post("/api/classes/:classId/semester-reports/archive", requireAuth("teacher", "administrator"), (req, res) => {
@@ -25249,7 +27592,7 @@ function registerGradingRoutes(ctx) {
25249
27592
  transaction(reports);
25250
27593
  res.json({ success: true });
25251
27594
  } catch (e) {
25252
- res.status(500).json({ error: e.message });
27595
+ sendSafeError(res, e);
25253
27596
  }
25254
27597
  });
25255
27598
  app.post(
@@ -25350,16 +27693,16 @@ ${examsText}
25350
27693
  res.json({ success: true, aiEvaluation: text });
25351
27694
  } catch (e) {
25352
27695
  console.error("AI Semester Evaluation error:", e);
25353
- res.status(500).json({ error: e.message });
27696
+ sendSafeError(res, e);
25354
27697
  }
25355
27698
  }
25356
27699
  );
25357
27700
  }
25358
27701
 
25359
27702
  // server/routes/plugins.ts
25360
- var import_express2 = __toESM(require("express"), 1);
25361
- var import_path14 = __toESM(require("path"), 1);
25362
- var import_fs13 = __toESM(require("fs"), 1);
27703
+ var import_express3 = __toESM(require("express"), 1);
27704
+ var import_path16 = __toESM(require("path"), 1);
27705
+ var import_fs14 = __toESM(require("fs"), 1);
25363
27706
  var import_semver3 = __toESM(require("semver"), 1);
25364
27707
 
25365
27708
  // server/services/version-fetcher.ts
@@ -25380,6 +27723,14 @@ function cached(key) {
25380
27723
  function setCache(key, result) {
25381
27724
  cache.set(key, { result, timestamp: Date.now() });
25382
27725
  }
27726
+ var REPO_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
27727
+ function normalizeSource(source) {
27728
+ if (!source) return null;
27729
+ if (source.type !== "github-release" && source.type !== "gitee-release") return null;
27730
+ if (typeof source.repo !== "string" || source.repo.length > 140) return null;
27731
+ if (!REPO_PATTERN.test(source.repo)) return null;
27732
+ return { type: source.type, repo: source.repo };
27733
+ }
25383
27734
  function repoUrl(source) {
25384
27735
  if (source.type === "github-release") {
25385
27736
  return `https://github.com/${source.repo}.git`;
@@ -25456,9 +27807,6 @@ function latestFromReleases(releases, installed, source) {
25456
27807
  return { version: null, isPrerelease: false, downloadUrl: null, changelog: null };
25457
27808
  }
25458
27809
  async function checkVersion(source, installedVersion) {
25459
- const key = cacheKey(source);
25460
- const hit = cached(key);
25461
- if (hit) return { ...hit, installedVersion };
25462
27810
  const base = {
25463
27811
  hasUpdate: false,
25464
27812
  installedVersion,
@@ -25467,9 +27815,16 @@ async function checkVersion(source, installedVersion) {
25467
27815
  downloadUrl: null,
25468
27816
  changelog: null
25469
27817
  };
27818
+ const safeSource = normalizeSource(source);
27819
+ if (!safeSource) {
27820
+ return { ...base, error: "\u65E0\u6548\u7684\u66F4\u65B0\u6E90\uFF1A\u4EC5\u652F\u6301 owner/name \u5F62\u5F0F\u7684 GitHub / Gitee \u4ED3\u5E93" };
27821
+ }
27822
+ const key = cacheKey(safeSource);
27823
+ const hit = cached(key);
27824
+ if (hit) return { ...hit, installedVersion };
25470
27825
  try {
25471
- const url = repoUrl(source);
25472
- const stdout = (0, import_child_process2.execSync)(`git ls-remote --tags "${url}"`, {
27826
+ const url = repoUrl(safeSource);
27827
+ const stdout = (0, import_child_process2.execFileSync)("git", ["ls-remote", "--tags", url], {
25473
27828
  timeout: 8e3,
25474
27829
  encoding: "utf-8",
25475
27830
  stdio: ["ignore", "pipe", "pipe"]
@@ -25490,7 +27845,7 @@ async function checkVersion(source, installedVersion) {
25490
27845
  } catch {
25491
27846
  }
25492
27847
  try {
25493
- const apiUrl = apiReleasesUrl(source);
27848
+ const apiUrl = apiReleasesUrl(safeSource);
25494
27849
  const resp = await fetch(apiUrl, {
25495
27850
  headers: { "User-Agent": "OpenLearnV2-PluginUpdater/1.0" },
25496
27851
  signal: AbortSignal.timeout(1e4)
@@ -25499,7 +27854,11 @@ async function checkVersion(source, installedVersion) {
25499
27854
  return { ...base, error: `\u66F4\u65B0\u6E90\u54CD\u5E94\u5F02\u5E38: HTTP ${resp.status}` };
25500
27855
  }
25501
27856
  const releases = await resp.json();
25502
- const { version, isPrerelease, downloadUrl, changelog } = latestFromReleases(releases, installedVersion, source);
27857
+ const { version, isPrerelease, downloadUrl, changelog } = latestFromReleases(
27858
+ releases,
27859
+ installedVersion,
27860
+ safeSource
27861
+ );
25503
27862
  const result = {
25504
27863
  ...base,
25505
27864
  hasUpdate: version !== null,
@@ -25517,7 +27876,7 @@ async function checkVersion(source, installedVersion) {
25517
27876
  }
25518
27877
 
25519
27878
  // server/routes/plugin-api-gateway.ts
25520
- var import_node_path4 = __toESM(require("node:path"), 1);
27879
+ var import_node_path5 = __toESM(require("node:path"), 1);
25521
27880
  init_http_router();
25522
27881
  var RESERVED_ACTIONS = /* @__PURE__ */ new Set(["config", "toggle", "contributions", "check-update", "one-click-update"]);
25523
27882
  var FORBIDDEN_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
@@ -25651,7 +28010,7 @@ async function pluginApiGatewayMiddleware(req, res, next) {
25651
28010
  res.status(400).json({ success: false, error: "Path traversal attempt detected" });
25652
28011
  return;
25653
28012
  }
25654
- const normalizedSubPath = import_node_path4.default.posix.normalize("/" + rawSubPath);
28013
+ const normalizedSubPath = import_node_path5.default.posix.normalize("/" + rawSubPath);
25655
28014
  if (normalizedSubPath.includes("..")) {
25656
28015
  res.status(400).json({ success: false, error: "Path traversal attempt detected" });
25657
28016
  return;
@@ -25871,16 +28230,16 @@ data: ${JSON.stringify({ error: msg })}
25871
28230
 
25872
28231
  // server/routes/plugins.ts
25873
28232
  function findLocalPluginSource(manifestId) {
25874
- const root = import_path14.default.resolve(process.cwd(), "v2_plugins");
25875
- if (!import_fs13.default.existsSync(root)) return null;
28233
+ const root = import_path16.default.resolve(process.cwd(), "v2_plugins");
28234
+ if (!import_fs14.default.existsSync(root)) return null;
25876
28235
  let best = null;
25877
28236
  try {
25878
- for (const entry of import_fs13.default.readdirSync(root, { withFileTypes: true })) {
28237
+ for (const entry of import_fs14.default.readdirSync(root, { withFileTypes: true })) {
25879
28238
  if (!entry.isDirectory()) continue;
25880
- const manifestPath = import_path14.default.join(root, entry.name, "manifest.json");
25881
- if (!import_fs13.default.existsSync(manifestPath)) continue;
28239
+ const manifestPath = import_path16.default.join(root, entry.name, "manifest.json");
28240
+ if (!import_fs14.default.existsSync(manifestPath)) continue;
25882
28241
  try {
25883
- const m = JSON.parse(import_fs13.default.readFileSync(manifestPath, "utf-8"));
28242
+ const m = JSON.parse(import_fs14.default.readFileSync(manifestPath, "utf-8"));
25884
28243
  if (m?.id !== manifestId || !m?.version) continue;
25885
28244
  if (!best || import_semver3.default.gt(m.version, best.version)) best = { dir: entry.name, version: m.version };
25886
28245
  } catch {
@@ -25916,11 +28275,11 @@ function registerPluginsRoutes(ctx) {
25916
28275
  const { app, buildOpenAITools: buildOpenAITools2, executeAgentToolCall: executeAgentToolCall2, buildOpenAIChatUrl: buildOpenAIChatUrl2, runGeminiAgentChat: runGeminiAgentChat2, runOpenAIAgentChat: runOpenAIAgentChat2 } = ctx;
25917
28276
  app.get("/api/docs/plugin-guide", (req, res) => {
25918
28277
  try {
25919
- const docPath = import_path14.default.join(process.cwd(), "docs_plugin_guide.md");
25920
- if (!import_fs13.default.existsSync(docPath)) {
28278
+ const docPath = import_path16.default.join(process.cwd(), "docs_plugin_guide.md");
28279
+ if (!import_fs14.default.existsSync(docPath)) {
25921
28280
  return res.status(404).json({ success: false, error: "Document not found" });
25922
28281
  }
25923
- const content = import_fs13.default.readFileSync(docPath, "utf-8");
28282
+ const content = import_fs14.default.readFileSync(docPath, "utf-8");
25924
28283
  res.json({ success: true, content });
25925
28284
  } catch (e) {
25926
28285
  sendSafeError(res, e);
@@ -25931,11 +28290,11 @@ function registerPluginsRoutes(ctx) {
25931
28290
  const limit = parseInt(req.query.limit) || 200;
25932
28291
  const component = req.query.component;
25933
28292
  const levelFilter = req.query.level;
25934
- const logFile2 = import_path14.default.resolve(process.cwd(), "logs", "openlearn.log");
25935
- if (!import_fs13.default.existsSync(logFile2)) {
28293
+ const logFile2 = import_path16.default.resolve(process.cwd(), "logs", "openlearn.log");
28294
+ if (!import_fs14.default.existsSync(logFile2)) {
25936
28295
  return res.json({ success: true, logs: [] });
25937
28296
  }
25938
- const content = import_fs13.default.readFileSync(logFile2, "utf-8");
28297
+ const content = import_fs14.default.readFileSync(logFile2, "utf-8");
25939
28298
  const lines = content.split("\n").filter(Boolean);
25940
28299
  let parsedLogs = lines.map((line) => {
25941
28300
  try {
@@ -25996,7 +28355,7 @@ function registerPluginsRoutes(ctx) {
25996
28355
  sendSafeError(res, e);
25997
28356
  }
25998
28357
  });
25999
- app.get("/api/plugins/market", async (req, res) => {
28358
+ app.get("/api/plugins/market", requireAuth(), async (req, res) => {
26000
28359
  try {
26001
28360
  const plugins = kernelContainer.db.prepare("SELECT id, manifest FROM plugins WHERE manifest LIKE '%updateSource%'").all();
26002
28361
  const results = [];
@@ -26022,7 +28381,7 @@ function registerPluginsRoutes(ctx) {
26022
28381
  sendSafeError(res, e);
26023
28382
  }
26024
28383
  });
26025
- app.post("/api/plugins/:id(*)/check-update", async (req, res) => {
28384
+ app.post("/api/plugins/:id(*)/check-update", requireAuth(), async (req, res) => {
26026
28385
  try {
26027
28386
  const rawId = decodeURIComponent(req.params.id);
26028
28387
  const pluginId = kernelContainer.pluginHost.resolvePluginUuid(rawId);
@@ -26225,7 +28584,7 @@ function registerPluginsRoutes(ctx) {
26225
28584
  app.post(
26226
28585
  "/api/plugins/upload-zip-raw",
26227
28586
  requireAuth("administrator"),
26228
- import_express2.default.raw({ type: "application/octet-stream", limit: "400mb" }),
28587
+ import_express3.default.raw({ type: "application/octet-stream", limit: "400mb" }),
26229
28588
  async (req, res) => {
26230
28589
  try {
26231
28590
  const zipBuffer = req.body;
@@ -26272,7 +28631,7 @@ function registerPluginsRoutes(ctx) {
26272
28631
  app.post(
26273
28632
  "/api/plugins/:id(*)/update-zip-raw",
26274
28633
  requireAuth("administrator"),
26275
- import_express2.default.raw({ type: "application/octet-stream", limit: "400mb" }),
28634
+ import_express3.default.raw({ type: "application/octet-stream", limit: "400mb" }),
26276
28635
  async (req, res) => {
26277
28636
  try {
26278
28637
  const targetPluginId = decodeURIComponent(req.params.id);
@@ -26303,7 +28662,7 @@ function registerPluginsRoutes(ctx) {
26303
28662
  }
26304
28663
  }
26305
28664
  );
26306
- app.post("/api/plugins/execute-command", async (req, res) => {
28665
+ app.post("/api/plugins/execute-command", requireAuth(), async (req, res) => {
26307
28666
  try {
26308
28667
  const { type, payload } = req.body;
26309
28668
  if (!type) {
@@ -26497,7 +28856,7 @@ async function startServer() {
26497
28856
  config: { port: Number(process.env.PORT) || 9e3 }
26498
28857
  });
26499
28858
  try {
26500
- const migrationsDir = import_path15.default.join(process.cwd(), "migrations");
28859
+ const migrationsDir = import_path17.default.join(process.cwd(), "migrations");
26501
28860
  const migrations = loadMigrationsFromDirectory(migrationsDir);
26502
28861
  if (migrations.length > 0) {
26503
28862
  runMigrations(kernelContainer.db, migrations);
@@ -26511,7 +28870,7 @@ async function startServer() {
26511
28870
  registerOfficialActivities(activityRegistry, kernelContainer.actionRegistry);
26512
28871
  await kernelContainer.serviceRegistry.register(IActivityRegistryToken, activityRegistry);
26513
28872
  console.log(`[ActivityEcosystem] Registered ${activityRegistry.listProviders().length} official activity providers.`);
26514
- const app = (0, import_express3.default)();
28873
+ const app = (0, import_express4.default)();
26515
28874
  kernelContainer.pluginHost.setExpressApp(app);
26516
28875
  const PORT = parseInt(process.env.PORT || "9000", 10);
26517
28876
  app.set("trust proxy", 1);
@@ -26559,10 +28918,25 @@ async function startServer() {
26559
28918
  standardHeaders: true,
26560
28919
  legacyHeaders: false
26561
28920
  });
26562
- app.use(import_express3.default.json({ limit: "10mb" }));
26563
- app.use(import_express3.default.urlencoded({ limit: "10mb", extended: true }));
26564
- app.use("/uploads", import_express3.default.static(import_path15.default.join(process.cwd(), "uploads")));
26565
- app.use("/plugins", import_express3.default.static(import_path15.default.join(process.cwd(), "plugins")));
28921
+ app.use(import_express4.default.json({ limit: "10mb" }));
28922
+ app.use(import_express4.default.urlencoded({ limit: "10mb", extended: true }));
28923
+ app.use(
28924
+ "/uploads",
28925
+ (req, res, next) => {
28926
+ const token = req.headers.cookie?.match?.(/edu_os_token=([^;]+)/)?.[1];
28927
+ if (!token) {
28928
+ }
28929
+ next();
28930
+ },
28931
+ import_express4.default.static(import_path17.default.join(process.cwd(), "uploads"), {
28932
+ // 禁用目录索引与隐藏文件
28933
+ index: false,
28934
+ dotfiles: "ignore",
28935
+ // 缓存控制:静态资源可缓存 1h,接口不受影响
28936
+ maxAge: "1h"
28937
+ })
28938
+ );
28939
+ app.use("/plugins", import_express4.default.static(import_path17.default.join(process.cwd(), "plugins"), { index: false, dotfiles: "ignore" }));
26566
28940
  const configuredOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean) : [];
26567
28941
  const isOriginAllowed = (origin, hostHeader) => {
26568
28942
  if (!origin) return true;
@@ -26687,6 +29061,7 @@ async function startServer() {
26687
29061
  registerAdminRoutes(ctx);
26688
29062
  registerRosterRoutes(ctx);
26689
29063
  registerAssignmentsRoutes(ctx);
29064
+ registerAssignmentHubRoutes(ctx);
26690
29065
  registerSchedulesRoutes(ctx);
26691
29066
  registerGradingRoutes(ctx);
26692
29067
  registerPluginsRoutes(ctx);
@@ -26735,10 +29110,10 @@ async function startServer() {
26735
29110
  });
26736
29111
  app.use(vite.middlewares);
26737
29112
  } else {
26738
- const distPath = __dirname || import_path15.default.join(process.cwd(), "dist");
26739
- app.use(import_express3.default.static(distPath));
29113
+ const distPath = __dirname || import_path17.default.join(process.cwd(), "dist");
29114
+ app.use(import_express4.default.static(distPath));
26740
29115
  app.get("*", (req, res) => {
26741
- res.sendFile(import_path15.default.join(distPath, "index.html"));
29116
+ res.sendFile(import_path17.default.join(distPath, "index.html"));
26742
29117
  });
26743
29118
  }
26744
29119
  const HOST = process.env.HOST || "0.0.0.0";
@@ -26795,24 +29170,22 @@ async function startServer() {
26795
29170
  const green = "\x1B[32m";
26796
29171
  const cyan = "\x1B[36m";
26797
29172
  const dim = "\x1B[2m";
29173
+ const hyperlink = Boolean(process.stdout.isTTY) && process.env.TERM !== "dumb";
29174
+ const link = (url) => hyperlink ? `${OSC}${url}${ST}${url}${OSC}${ST}` : url;
26798
29175
  console.log(`
26799
- ${bold}${green}Educational OS Kernel${reset} v${PLATFORM_VERSION} ready:
29176
+ ${bold}${green}OpenLearn Next${reset} v${PLATFORM_VERSION} ready:
26800
29177
  `);
26801
- console.log(
26802
- ` ${dim}\u279C${reset} ${bold}Local:${reset} ${bold}${cyan}${OSC}${localUrl}${ST}${localUrl}${OSC}${ST}${reset}`
26803
- );
29178
+ console.log(` ${dim}\u279C${reset} ${bold}Local:${reset} ${bold}${cyan}${link(localUrl)}${reset}`);
26804
29179
  if (isAnyHost) {
26805
29180
  const netIps = getNetworkIps();
26806
29181
  for (const ip of netIps) {
26807
29182
  const netUrl = `http://${ip}:${PORT}`;
26808
- console.log(
26809
- ` ${dim}\u279C${reset} ${bold}Network:${reset} ${bold}${cyan}${OSC}${netUrl}${ST}${netUrl}${OSC}${ST}${reset}`
26810
- );
29183
+ console.log(` ${dim}\u279C${reset} ${bold}Network:${reset} ${bold}${cyan}${link(netUrl)}${reset}`);
26811
29184
  }
26812
29185
  }
26813
29186
  console.log("");
26814
29187
  if (process.env.OPEN_BROWSER === "true") {
26815
- console.log(` ${dim}\u279C Auto-opening browser: ${primaryUrl}${reset}
29188
+ console.log(` ${dim}\u279C Auto-opening browser: ${cyan}${link(primaryUrl)}${reset}
26816
29189
  `);
26817
29190
  openBrowser(primaryUrl);
26818
29191
  }