openlearn-next 0.3.21 → 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-CPmu_uF8.js → BatchPickerModal-C9FH3J57.js} +1 -1
  4. package/dist/assets/{ClassesView-TVobZ_Vs.js → ClassesView-DTuyCvYd.js} +1 -1
  5. package/dist/assets/{CloudDriveModal-ul9v6wHK.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-DFQsUxE1.js → CourseManagement-CeelAyst.js} +1 -1
  8. package/dist/assets/{CourseWizardModal-D92teHMD.js → CourseWizardModal-m7TDgcB2.js} +1 -1
  9. package/dist/assets/{CoursewareHubPanel-CEK_QEc5.js → CoursewareHubPanel-C4LQjt4v.js} +1 -1
  10. package/dist/assets/{Dashboard-CY138ODd.js → Dashboard-CiBtG7Ge.js} +2 -2
  11. package/dist/assets/{ExportWeightModal-BvoODxwp.js → ExportWeightModal-C4d-0uTM.js} +1 -1
  12. package/dist/assets/{HelpTour-Dlc1vvy3.js → HelpTour-00eT17Vg.js} +1 -1
  13. package/dist/assets/{HelpView-CHkXbtfB.js → HelpView-B9Pny6u9.js} +4 -4
  14. package/dist/assets/{ImportLessonsModal-YuTV3mnv.js → ImportLessonsModal-hKRuw9p4.js} +1 -1
  15. package/dist/assets/{ImportModal-B2acbUT8.js → ImportModal-CVXHZ1Ei.js} +1 -1
  16. package/dist/assets/{InteractiveCoursewareViewer-BBeuViik.js → InteractiveCoursewareViewer-DMMAs2k_.js} +1 -1
  17. package/dist/assets/InteractiveWhiteboard-jpweQ094.js +77 -0
  18. package/dist/assets/{LazyCourseware-Du9MgZyH.js → LazyCourseware-DVgyrg3r.js} +2 -2
  19. package/dist/assets/{LazyWhiteboard-CpSzdON0.js → LazyWhiteboard-BSzsAPXC.js} +2 -2
  20. package/dist/assets/{LessonEditorView-D9YYZQyP.js → LessonEditorView-D4g48fr5.js} +1 -1
  21. package/dist/assets/LiveClassroomView-HQGhVwd_.js +1 -0
  22. package/dist/assets/{NotificationDetailModal-BthQNwyd.js → NotificationDetailModal-1MOkBDr_.js} +1 -1
  23. package/dist/assets/{PluginView-CQLEVURc.js → PluginView-D8cQ8mLM.js} +3 -3
  24. package/dist/assets/{ProcessLogsModal-MtK-Pe4R.js → ProcessLogsModal-gZ9LILYM.js} +1 -1
  25. package/dist/assets/{QuizGeneratorModal-Bp4pUx-H.js → QuizGeneratorModal-BFCJe1cm.js} +1 -1
  26. package/dist/assets/{StudentAssignmentView-B-jN_m_B.js → StudentAssignmentView-C34nleZT.js} +1 -1
  27. package/dist/assets/{StudentLessonView-8-zC7BLx.js → StudentLessonView-DNbu2o04.js} +1 -1
  28. package/dist/assets/{StudentPreviewModal-DZdcKy1x.js → StudentPreviewModal-BXbKzQgo.js} +1 -1
  29. package/dist/assets/{StudentView-Cg5G4Guz.js → StudentView-BF2WjF4_.js} +2 -2
  30. package/dist/assets/{SystemResourceLibraryModal-CJ16MGxn.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 +2689 -341
  38. package/package.json +21 -22
  39. package/dist/assets/InteractiveWhiteboard-BASy3Ak8.js +0 -78
  40. package/dist/assets/LiveClassroomView-B2lKKiZK.js +0 -1
  41. package/dist/assets/TeacherView-_n9QWf7y.js +0 -2
  42. package/dist/assets/index-BApV4fuy.js +0 -202
  43. package/dist/assets/index-Ch_mD6B0.css +0 -1
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, path22, handler) {
966
+ route(method, path24, handler) {
877
967
  const upperMethod = method.toUpperCase();
878
- const normalized = path22.startsWith("/") ? path22 : "/" + path22;
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(path22, handler) {
890
- this.route("GET", path22, handler);
979
+ get(path24, handler) {
980
+ this.route("GET", path24, handler);
891
981
  }
892
- post(path22, handler) {
893
- this.route("POST", path22, handler);
982
+ post(path24, handler) {
983
+ this.route("POST", path24, handler);
894
984
  }
895
- put(path22, handler) {
896
- this.route("PUT", path22, handler);
985
+ put(path24, handler) {
986
+ this.route("PUT", path24, handler);
897
987
  }
898
- patch(path22, handler) {
899
- this.route("PATCH", path22, handler);
988
+ patch(path24, handler) {
989
+ this.route("PATCH", path24, handler);
900
990
  }
901
- delete(path22, handler) {
902
- this.route("DELETE", path22, 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 path22 = methodOrPath;
996
+ const path24 = methodOrPath;
907
997
  const handler = pathOrHandler;
908
- this.registerStream("GET", path22, handler);
909
- this.registerStream("POST", path22, handler);
998
+ this.registerStream("GET", path24, handler);
999
+ this.registerStream("POST", path24, handler);
910
1000
  } else {
911
1001
  const method = methodOrPath.toUpperCase();
912
- const path22 = pathOrHandler;
1002
+ const path24 = pathOrHandler;
913
1003
  const handler = maybeHandler;
914
- this.registerStream(method, path22, handler);
1004
+ this.registerStream(method, path24, handler);
915
1005
  }
916
1006
  }
917
- registerStream(method, path22, handler) {
918
- const normalized = path22.startsWith("/") ? path22 : "/" + path22;
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, path22) {
1022
+ isStream(method, path24) {
933
1023
  const upperMethod = method.toUpperCase();
934
- const normalized = path22.startsWith("/") ? path22 : "/" + path22;
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, path22) {
1036
+ match(method, path24) {
947
1037
  const upperMethod = method.toUpperCase();
948
- const normalized = path22.startsWith("/") ? path22 : "/" + path22;
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
  },
@@ -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");
9784
+ }
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");
8343
9794
  }
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;
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);
@@ -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 });
@@ -9354,7 +11003,7 @@ var import_node_fs3 = __toESM(require("node:fs"), 1);
9354
11003
  var import_node_path3 = __toESM(require("node:path"), 1);
9355
11004
  var import_node_url = require("node:url");
9356
11005
  var import_meta2 = {};
9357
- var FALLBACK_VERSION = "0.3.20";
11006
+ var FALLBACK_VERSION = "0.3.21";
9358
11007
  function readPlatformVersion() {
9359
11008
  const bases = [];
9360
11009
  if (typeof __dirname !== "undefined") bases.push(__dirname);
@@ -9682,11 +11331,11 @@ var PluginDistributionManager = class {
9682
11331
  return;
9683
11332
  }
9684
11333
  const filePath = this.pluginHost.getPluginFilePath(this.pluginHost.resolvePluginUuid(pluginId));
9685
- const fs17 = await import("node:fs");
9686
- if (!fs17.existsSync(filePath)) {
11334
+ const fs18 = await import("node:fs");
11335
+ if (!fs18.existsSync(filePath)) {
9687
11336
  throw new Error(`Cannot reload plugin "${pluginId}": source file missing at ${filePath}`);
9688
11337
  }
9689
- const code = fs17.readFileSync(filePath, "utf-8");
11338
+ const code = fs18.readFileSync(filePath, "utf-8");
9690
11339
  await this.pluginHost.reloadPlugin(pluginId, code);
9691
11340
  }
9692
11341
  async updateFromZip(zipBuffer, options = {}) {
@@ -9910,7 +11559,7 @@ var PluginHost = class {
9910
11559
  /**
9911
11560
  * 判断指定插件的某个请求路径是否为流式路由 (SSE)
9912
11561
  */
9913
- isStreamRoute(pluginIdOrManifestId, method, path22) {
11562
+ isStreamRoute(pluginIdOrManifestId, method, path24) {
9914
11563
  const upperMethod = method.toUpperCase();
9915
11564
  const resolvedId = this.resolvePluginUuid(pluginIdOrManifestId);
9916
11565
  const instance = this.pluginInstances.get(resolvedId) ?? this.pluginInstances.get(pluginIdOrManifestId);
@@ -9919,7 +11568,7 @@ var PluginHost = class {
9919
11568
  for (const r of manifest.api.routes) {
9920
11569
  if (r.method.toUpperCase() === upperMethod && r.streaming === true) {
9921
11570
  const compiled = compileRoutePattern(r.path);
9922
- if (compiled.regex.test(path22)) {
11571
+ if (compiled.regex.test(path24)) {
9923
11572
  return true;
9924
11573
  }
9925
11574
  }
@@ -9927,14 +11576,14 @@ var PluginHost = class {
9927
11576
  }
9928
11577
  if (!instance) return false;
9929
11578
  if (instance.context?.http) {
9930
- return instance.context.http.isStream(upperMethod, path22);
11579
+ return instance.context.http.isStream(upperMethod, path24);
9931
11580
  }
9932
11581
  if (instance.workerRef) {
9933
11582
  const routes = instance.workerRef.serviceHost.getRegisteredRoutes();
9934
11583
  for (const r of routes) {
9935
11584
  if (r.method === upperMethod && r.isStream) {
9936
11585
  const compiled = compileRoutePattern(r.pattern);
9937
- if (compiled.regex.test(path22)) {
11586
+ if (compiled.regex.test(path24)) {
9938
11587
  return true;
9939
11588
  }
9940
11589
  }
@@ -10912,6 +12561,7 @@ var PluginHost = class {
10912
12561
  } catch (termErr) {
10913
12562
  console.error(`[PluginHost] Worker termination error for "${pluginId}":`, termErr);
10914
12563
  } finally {
12564
+ this.resourceTracker.disposeAll(pluginId);
10915
12565
  this.pluginStates.set(pluginId, "inactive" /* INACTIVE */);
10916
12566
  this.pluginInstances.delete(pluginId);
10917
12567
  this.db.prepare("UPDATE plugins SET status = ? WHERE id = ?").run("inactive", pluginId);
@@ -10998,6 +12648,7 @@ var PluginHost = class {
10998
12648
  await this.deactivatePlugin(pluginId);
10999
12649
  }
11000
12650
  }
12651
+ this.resourceTracker.disposeAll(pluginId);
11001
12652
  const state = this.pluginStates.get(pluginId) ?? "installed" /* INSTALLED */;
11002
12653
  const row = this.db.prepare("SELECT manifest, file_path FROM plugins WHERE id = ?").get(pluginId);
11003
12654
  if (state === "uninstalled" /* UNINSTALLED */ && !row) {
@@ -11031,6 +12682,12 @@ var PluginHost = class {
11031
12682
  } catch (e) {
11032
12683
  console.warn(`[PluginHost] Failed to drop plugin tables for "${pluginId}":`, e);
11033
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
+ }
11034
12691
  if (this.expressApp && this._registeredRoutes.has(manifestId)) {
11035
12692
  try {
11036
12693
  const route = this._registeredRoutes.get(manifestId);
@@ -11186,8 +12843,8 @@ var PluginHost = class {
11186
12843
  dependencies: manifest.dependencies
11187
12844
  };
11188
12845
  import_fs5.default.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8");
11189
- const { execSync: execSync2 } = await import("node:child_process");
11190
- execSync2(
12846
+ const { execSync } = await import("node:child_process");
12847
+ execSync(
11191
12848
  "npm install --production --no-audit --no-fund --legacy-peer-deps --ignore-scripts --registry=https://registry.npmmirror.com",
11192
12849
  {
11193
12850
  cwd: pluginDir,
@@ -11213,8 +12870,8 @@ var PluginHost = class {
11213
12870
  if (import_fs5.default.existsSync(deployScriptPath)) {
11214
12871
  console.log(`[PluginHost] Running deploy script: node ${deployScriptPath}`);
11215
12872
  try {
11216
- const { execSync: execSync2 } = await import("node:child_process");
11217
- execSync2(`node "${deployScriptPath}" "${process.cwd()}"`, { timeout: 12e4 });
12873
+ const { execSync } = await import("node:child_process");
12874
+ execSync(`node "${deployScriptPath}" "${process.cwd()}"`, { timeout: 12e4 });
11218
12875
  console.log(`[PluginHost] Deploy script completed for plugin "${manifest.id}"`);
11219
12876
  } catch (deployErr) {
11220
12877
  console.error(`[PluginHost] Deploy script failed for plugin "${manifest.id}":`, deployErr.message);
@@ -11435,8 +13092,8 @@ var PluginHost = class {
11435
13092
  ),
11436
13093
  "utf-8"
11437
13094
  );
11438
- const { execSync: execSync2 } = await import("node:child_process");
11439
- execSync2(
13095
+ const { execSync } = await import("node:child_process");
13096
+ execSync(
11440
13097
  "npm install --production --no-audit --no-fund --legacy-peer-deps --registry=https://registry.npmmirror.com",
11441
13098
  {
11442
13099
  cwd: pluginDir,
@@ -11944,8 +13601,8 @@ var HotReloadController = class {
11944
13601
  const timer = setTimeout(async () => {
11945
13602
  this.debounceTimers.delete(pluginId);
11946
13603
  try {
11947
- const fs17 = await import("fs/promises");
11948
- const newSourceCode = await fs17.readFile(filePath, "utf-8");
13604
+ const fs18 = await import("fs/promises");
13605
+ const newSourceCode = await fs18.readFile(filePath, "utf-8");
11949
13606
  console.log(`[HotReload] File changed: ${filePath} \u2192 triggering reload for "${pluginId}"`);
11950
13607
  await this.pluginHost.reloadPlugin(pluginId, newSourceCode);
11951
13608
  } catch (err) {
@@ -18385,6 +20042,7 @@ var Kernel = class {
18385
20042
  getServiceRegistryKernel: async () => this.platformServiceRegistryKernel
18386
20043
  });
18387
20044
  this.serviceRegistry.register(IAuthSessionBridgeToken, new AuthSessionBridgeService(this.db));
20045
+ this.serviceRegistry.register(ICoursewareRuntimeScriptRegistryToken, new CoursewareRuntimeScriptRegistry());
18388
20046
  this.commandBus.setInterceptor(async (command) => {
18389
20047
  const action = this.actionRegistry.getActionByCommandType(command.type);
18390
20048
  if (action) {
@@ -18867,6 +20525,43 @@ function setupPresence({ io, eventBus, lookupStudentClassIds }) {
18867
20525
  }
18868
20526
  }
18869
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
+ );
18870
20565
  socket.on(
18871
20566
  "teacher-broadcast-fullscreen",
18872
20567
  (data) => {
@@ -19338,6 +21033,7 @@ var runOpenAIAgentChat = async (provider, request) => {
19338
21033
  };
19339
21034
 
19340
21035
  // server/middleware/auth.ts
21036
+ var SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
19341
21037
  function getValidSession(token) {
19342
21038
  let sessionRow;
19343
21039
  try {
@@ -19356,11 +21052,21 @@ function getValidSession(token) {
19356
21052
  kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
19357
21053
  return null;
19358
21054
  }
19359
- kernelContainer.db.prepare("UPDATE client_sessions SET updated_at = ? WHERE id = ?").run(now, token);
19360
- 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
+ }
19361
21064
  if (session && !session.userId && session.studentId) {
19362
21065
  session.userId = session.studentId;
19363
21066
  }
21067
+ if (session.role && !["teacher", "student", "administrator", "admin"].includes(session.role)) {
21068
+ session.role = "anonymous";
21069
+ }
19364
21070
  return session;
19365
21071
  }
19366
21072
  function getCookieToken(req) {
@@ -19385,8 +21091,10 @@ function getActorId(req) {
19385
21091
  if (session.username === "admin" || session.userId === "usr_admin" || role === "admin" || role === "administrator") {
19386
21092
  role = "administrator";
19387
21093
  }
21094
+ const rawUserId = session.userId || session.studentId || "demo";
21095
+ const safeUserId = String(rawUserId).replace(/[:\s]/g, "_").slice(0, 64);
19388
21096
  if (role) {
19389
- return `user:${session.userId || session.studentId || "demo"}:${role}`;
21097
+ return `user:${safeUserId}:${role}`;
19390
21098
  }
19391
21099
  return "anonymous";
19392
21100
  } catch {
@@ -19932,6 +21640,7 @@ function detectPromptInjection(input) {
19932
21640
  // server/routes/shared.ts
19933
21641
  var import_path7 = __toESM(require("path"), 1);
19934
21642
  var import_crypto5 = __toESM(require("crypto"), 1);
21643
+ init_interfaces();
19935
21644
 
19936
21645
  // server/utils/bridge-sdk.ts
19937
21646
  var BRIDGE_SDK_CODE = `(function() {
@@ -20711,7 +22420,7 @@ var BRIDGE_SDK_CODE = `(function() {
20711
22420
 
20712
22421
  // server/routes/shared.ts
20713
22422
  function validateMagicBytes(buffer, fileName) {
20714
- const MAGIC_BYTES = {
22423
+ const MAGIC_BYTES2 = {
20715
22424
  ".pdf": [[37, 80, 68, 70]],
20716
22425
  // %PDF
20717
22426
  ".pptx": [[80, 75, 3, 4]],
@@ -20727,11 +22436,34 @@ function validateMagicBytes(buffer, fileName) {
20727
22436
  // RIFF
20728
22437
  };
20729
22438
  const ext = import_path7.default.extname(fileName || "").toLowerCase();
20730
- const signatures = MAGIC_BYTES[ext];
22439
+ const signatures = MAGIC_BYTES2[ext];
20731
22440
  if (!signatures) return true;
20732
22441
  return signatures.some((sig) => sig.every((byte, i) => buffer[i] === byte));
20733
22442
  }
20734
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
+ }
20735
22467
  function injectLmsSdk(htmlContent, req, cwInfo) {
20736
22468
  const token = getCookieToken(req);
20737
22469
  let studentInfo = {
@@ -20789,6 +22521,7 @@ function injectLmsSdk(htmlContent, req, cwInfo) {
20789
22521
  }
20790
22522
  studentInfo.attempt_id = attempt.id;
20791
22523
  }
22524
+ const runtimeScripts = collectCoursewareRuntimeScripts(cwInfo);
20792
22525
  const injection = `
20793
22526
  <!-- LMS Courseware SDK Inject -->
20794
22527
  <script>
@@ -20798,7 +22531,7 @@ window.__LMS_COURSEWARE__ = {
20798
22531
  name: ${JSON.stringify(cwInfo.name)}
20799
22532
  };
20800
22533
  </script>
20801
- <script>${BRIDGE_SDK_CODE}</script>
22534
+ <script>${BRIDGE_SDK_CODE}</script>${runtimeScripts.head}
20802
22535
  `;
20803
22536
  let html = htmlContent;
20804
22537
  html = html.replace(
@@ -20826,6 +22559,13 @@ window.__LMS_COURSEWARE__ = {
20826
22559
  } else {
20827
22560
  html = injection + html;
20828
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
+ }
20829
22569
  return html;
20830
22570
  }
20831
22571
  var generateStudentNumber = (db2) => {
@@ -21150,7 +22890,7 @@ var import_path9 = __toESM(require("path"), 1);
21150
22890
  var import_fs9 = __toESM(require("fs"), 1);
21151
22891
  function registerResourcesRoutes(ctx) {
21152
22892
  const { app } = ctx;
21153
- app.get("/api/resources", (req, res) => {
22893
+ app.get("/api/resources", requireAuth(), (req, res) => {
21154
22894
  try {
21155
22895
  const resources = kernelContainer.db.prepare("SELECT id, name, type, created_at FROM system_resources ORDER BY created_at DESC").all();
21156
22896
  res.json(resources);
@@ -21158,7 +22898,7 @@ function registerResourcesRoutes(ctx) {
21158
22898
  sendSafeError(res, e);
21159
22899
  }
21160
22900
  });
21161
- app.get("/api/resources/:id", (req, res) => {
22901
+ app.get("/api/resources/:id", requireAuth(), (req, res) => {
21162
22902
  try {
21163
22903
  const resource = kernelContainer.db.prepare("SELECT * FROM system_resources WHERE id = ?").get(req.params.id);
21164
22904
  if (!resource) return res.status(404).send("Resource not found");
@@ -21258,7 +22998,7 @@ function registerResourcesRoutes(ctx) {
21258
22998
  sendSafeError(res, e);
21259
22999
  }
21260
23000
  });
21261
- app.get("/api/resources/:id/*", (req, res) => {
23001
+ app.get("/api/resources/:id/*", requireAuth(), (req, res) => {
21262
23002
  try {
21263
23003
  const resource = kernelContainer.db.prepare("SELECT * FROM system_resources WHERE id = ?").get(req.params.id);
21264
23004
  if (!resource) return res.status(404).send("Resource not found");
@@ -21490,7 +23230,7 @@ function registerCoursewareRoutes(ctx) {
21490
23230
  app.get("/api/courseware", requireAuth(), async (req, res) => {
21491
23231
  try {
21492
23232
  const actorId = getActorId(req) || "system";
21493
- const cmd = kernelContainer.commandBus.createCommand("courseware.list", {}, actorId);
23233
+ const cmd = kernelContainer.commandBus.createCommand("courseware.list", {}, actorId, { silent: true });
21494
23234
  const result = await kernelContainer.commandBus.execute(cmd);
21495
23235
  res.json(result);
21496
23236
  } catch (e) {
@@ -21613,13 +23353,21 @@ function registerCoursewareRoutes(ctx) {
21613
23353
  "INSERT INTO submission_raw (id, attempt_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?)"
21614
23354
  ).run(rawId, attemptId, eventType, JSON.stringify(payload), Date.now());
21615
23355
  const extracted = extractScoreCommentCompletion(payload);
21616
- const score = extracted.score;
21617
23356
  const comment = extracted.comment;
21618
23357
  const completion = extracted.completion;
21619
- 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) {
21620
23368
  let parsedScore = null;
21621
- if (score !== void 0 && score !== null) {
21622
- const num = parseFloat(score);
23369
+ if (extracted.score !== void 0 && extracted.score !== null) {
23370
+ const num = parseFloat(extracted.score);
21623
23371
  if (!isNaN(num)) {
21624
23372
  parsedScore = num;
21625
23373
  }
@@ -21638,13 +23386,16 @@ function registerCoursewareRoutes(ctx) {
21638
23386
  ).run(
21639
23387
  "res_" + import_crypto8.default.randomBytes(8).toString("hex"),
21640
23388
  attemptId,
21641
- parsedScore,
23389
+ aggregatedScore !== null ? aggregatedScore : parsedScore,
21642
23390
  comment || null,
21643
23391
  parsedCompletion,
21644
- JSON.stringify(payload)
23392
+ JSON.stringify({
23393
+ ...payload && typeof payload === "object" ? payload : {},
23394
+ ...aggregationExtra
23395
+ })
21645
23396
  );
21646
23397
  } else {
21647
- const finalScore = parsedScore !== null ? parsedScore : existing.score;
23398
+ const finalScore = aggregatedScore !== null ? aggregatedScore : parsedScore !== null ? parsedScore : existing.score;
21648
23399
  const finalComment = comment || existing.comment;
21649
23400
  const finalCompletion = parsedCompletion !== null ? parsedCompletion : existing.completion;
21650
23401
  let mergedExtra = {};
@@ -21655,6 +23406,7 @@ function registerCoursewareRoutes(ctx) {
21655
23406
  if (payload && typeof payload === "object") {
21656
23407
  mergedExtra = { ...mergedExtra, ...payload };
21657
23408
  }
23409
+ mergedExtra = { ...mergedExtra, ...aggregationExtra };
21658
23410
  kernelContainer.db.prepare(
21659
23411
  "UPDATE submission_result SET score = ?, comment = ?, completion = ?, extra_json = ? WHERE attempt_id = ?"
21660
23412
  ).run(finalScore, finalComment, finalCompletion, JSON.stringify(mergedExtra), attemptId);
@@ -21707,17 +23459,17 @@ function registerCoursewareRoutes(ctx) {
21707
23459
  parsedCompletion = num;
21708
23460
  }
21709
23461
  }
21710
- 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;
21711
23470
  const cmd = kernelContainer.commandBus.createCommand(
21712
23471
  "courseware.submit_attempt",
21713
- {
21714
- attemptId,
21715
- score: parsedScore,
21716
- comment,
21717
- completion: parsedCompletion,
21718
- status,
21719
- extra
21720
- },
23472
+ payload,
21721
23473
  actorId
21722
23474
  );
21723
23475
  const result = await kernelContainer.commandBus.execute(cmd);
@@ -21727,11 +23479,65 @@ function registerCoursewareRoutes(ctx) {
21727
23479
  sendSafeError(res, e);
21728
23480
  }
21729
23481
  });
21730
- app.get("/api/courseware/attempts", (req, res) => {
23482
+ app.post("/api/courseware/attempts/:attemptId/adopt", (req, res) => {
21731
23483
  try {
21732
- const rows = kernelContainer.db.prepare(
21733
- `
21734
- 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,
21735
23541
  cw.name as coursewareName, cw.uuid as coursewareUuid,
21736
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,
21737
23543
  a.student_id as studentId,
@@ -21739,26 +23545,40 @@ function registerCoursewareRoutes(ctx) {
21739
23545
  (
21740
23546
  SELECT COUNT(*) FROM assignment_submissions sub
21741
23547
  JOIN assignments ast ON sub.assignment_id = ast.id
21742
- WHERE sub.student_id = a.student_id
23548
+ WHERE sub.student_id = a.student_id
21743
23549
  AND ast.title = '\u4E92\u52A8\u8BFE\u4EF6: ' || cw.name
21744
23550
  ) as isPromoted
21745
23551
  FROM courseware_attempt a
21746
23552
  JOIN courseware cw ON a.courseware_id = cw.id
21747
23553
  LEFT JOIN students s ON a.student_id = s.id
21748
23554
  LEFT JOIN submission_result r ON a.id = r.attempt_id
21749
- ORDER BY a.started_at DESC
21750
- `
21751
- ).all();
21752
- 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
+ );
21753
23571
  } catch (e) {
21754
23572
  sendSafeError(res, e);
21755
23573
  }
21756
23574
  });
21757
- app.post("/api/courseware/debug", (req, res) => {
23575
+ app.post("/api/courseware/debug", requireAuth(), (req, res) => {
21758
23576
  try {
21759
23577
  const { msg, url, student, courseware } = req.body;
21760
- const logMsg = `[CLIENT DEBUG] ${msg} | URL: ${url} | Student: ${JSON.stringify(student)} | Courseware: ${JSON.stringify(courseware)}`;
21761
- 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}`);
21762
23582
  const logFile2 = import_path10.default.join(process.cwd(), "client_debug.log");
21763
23583
  import_fs10.default.appendFileSync(logFile2, `${(/* @__PURE__ */ new Date()).toISOString()} - ${logMsg}
21764
23584
  `);
@@ -21767,13 +23587,20 @@ function registerCoursewareRoutes(ctx) {
21767
23587
  sendSafeError(res, e);
21768
23588
  }
21769
23589
  });
21770
- app.get("/api/courseware/attempts/:attemptId/raw", async (req, res) => {
23590
+ app.get("/api/courseware/attempts/:attemptId/raw", requireAuth(), async (req, res) => {
21771
23591
  try {
21772
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
+ }
21773
23600
  const cmd = kernelContainer.commandBus.createCommand(
21774
23601
  "courseware.get_attempt_raw_data",
21775
23602
  { attemptId },
21776
- "teacher-demo"
23603
+ getActorId(req)
21777
23604
  );
21778
23605
  const result = await kernelContainer.commandBus.execute(cmd);
21779
23606
  res.json(result);
@@ -21781,9 +23608,16 @@ function registerCoursewareRoutes(ctx) {
21781
23608
  sendSafeError(res, e);
21782
23609
  }
21783
23610
  });
21784
- app.get("/api/courseware/attempts/:attemptId/progress", (req, res) => {
23611
+ app.get("/api/courseware/attempts/:attemptId/progress", requireAuth(), (req, res) => {
21785
23612
  try {
21786
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
+ }
21787
23621
  const result = kernelContainer.db.prepare("SELECT score, comment, completion, extra_json FROM submission_result WHERE attempt_id = ?").get(attemptId);
21788
23622
  if (!result) {
21789
23623
  return res.json({ progress: null });
@@ -21800,7 +23634,7 @@ function registerCoursewareRoutes(ctx) {
21800
23634
  sendSafeError(res, e);
21801
23635
  }
21802
23636
  });
21803
- app.post("/api/courseware/attempts/:attemptId/promote", async (req, res) => {
23637
+ app.post("/api/courseware/attempts/:attemptId/promote", requireAuth("teacher", "administrator"), async (req, res) => {
21804
23638
  try {
21805
23639
  const { attemptId } = req.params;
21806
23640
  const { lessonId, classId } = req.body;
@@ -21833,24 +23667,25 @@ function registerCoursewareRoutes(ctx) {
21833
23667
  }
21834
23668
  }
21835
23669
  const assignmentTitle = `\u4E92\u52A8\u8BFE\u4EF6: ${coursewareName}`;
21836
- let assignment = kernelContainer.db.prepare("SELECT id FROM assignments WHERE class_id = ? AND lesson_id = ? AND title = ?").get(classId, lessonId, assignmentTitle);
21837
- let assignmentId = assignment?.id;
21838
- if (!assignmentId) {
21839
- assignmentId = "ast-cw-" + import_crypto8.default.randomBytes(8).toString("hex");
21840
- kernelContainer.db.prepare(
21841
- "INSERT INTO assignments (id, class_id, lesson_id, title, description, content, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
21842
- ).run(
21843
- assignmentId,
21844
- classId,
21845
- lessonId,
21846
- assignmentTitle,
21847
- `\u6765\u81EA\u4E92\u52A8\u8BFE\u4EF6 [${coursewareName}] \u7684\u968F\u5802\u5B66\u4E60\u63D0\u4EA4\u6570\u636E\u8BB0\u5F55`,
21848
- JSON.stringify({ type: "interactive_courseware", attemptId, coursewareUuid: attempt.courseware_uuid }),
21849
- Date.now()
21850
- );
21851
- }
21852
- kernelContainer.db.prepare(
21853
- `
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
+ }
23687
+ kernelContainer.db.prepare(
23688
+ `
21854
23689
  INSERT INTO assignment_submissions (assignment_id, student_id, content, score, feedback, submitted_at, graded_at, status)
21855
23690
  VALUES (?, ?, ?, ?, ?, ?, ?, 'graded')
21856
23691
  ON CONFLICT(assignment_id, student_id) DO UPDATE SET
@@ -21861,24 +23696,27 @@ function registerCoursewareRoutes(ctx) {
21861
23696
  graded_at = excluded.graded_at,
21862
23697
  status = 'graded'
21863
23698
  `
21864
- ).run(
21865
- assignmentId,
21866
- studentId,
21867
- attempt.extra_json || "{}",
21868
- finalScore,
21869
- `\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?"}`,
21870
- Date.now(),
21871
- Date.now()
21872
- );
21873
- kernelContainer.db.prepare(
21874
- `
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
+ `
21875
23710
  INSERT INTO student_lesson_progress (student_id, lesson_id, completed, progress_percent, completed_segments, assigned_at)
21876
23711
  VALUES (?, ?, 1, 100, '[]', ?)
21877
23712
  ON CONFLICT(student_id, lesson_id) DO UPDATE SET
21878
23713
  completed = 1,
21879
23714
  progress_percent = 100
21880
23715
  `
21881
- ).run(studentId, lessonId, Date.now());
23716
+ ).run(studentId, lessonId, Date.now());
23717
+ return assignmentId2;
23718
+ });
23719
+ const assignmentId = promoteTx();
21882
23720
  io.emit("student-progress-updated", {
21883
23721
  studentId,
21884
23722
  lessonId,
@@ -21913,10 +23751,20 @@ var import_path11 = __toESM(require("path"), 1);
21913
23751
  var import_fs11 = __toESM(require("fs"), 1);
21914
23752
  function registerBridgeRoutes(ctx) {
21915
23753
  const { app } = ctx;
21916
- app.get("/bridge.js", (_req, res) => {
23754
+ app.get("/bridge.js", (req, res) => {
21917
23755
  res.setHeader("Content-Type", "application/javascript; charset=utf-8");
21918
23756
  res.setHeader("Access-Control-Allow-Origin", "*");
21919
- 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);
21920
23768
  });
21921
23769
  app.get("/runtime/:uuid", (req, res, next) => {
21922
23770
  if (req.path.endsWith("/")) return next();
@@ -22166,7 +24014,7 @@ function requireWhiteboardWriteAccess() {
22166
24014
  }
22167
24015
  function registerLessonsRoutes(ctx) {
22168
24016
  const { app, io } = ctx;
22169
- app.get("/api/lessons", (req, res) => {
24017
+ app.get("/api/lessons", requireAuth(), (req, res) => {
22170
24018
  const lessons = kernelContainer.db.prepare(
22171
24019
  `
22172
24020
  SELECT l.*, u.name as creator_name,
@@ -22178,7 +24026,7 @@ function registerLessonsRoutes(ctx) {
22178
24026
  ).all();
22179
24027
  res.json(lessons);
22180
24028
  });
22181
- app.get("/api/lessons/:lessonId/eval-submissions", (req, res) => {
24029
+ app.get("/api/lessons/:lessonId/eval-submissions", requireAuth(), (req, res) => {
22182
24030
  try {
22183
24031
  const { lessonId } = req.params;
22184
24032
  const rows = kernelContainer.db.prepare(
@@ -22194,7 +24042,7 @@ function registerLessonsRoutes(ctx) {
22194
24042
  sendSafeError(res, err);
22195
24043
  }
22196
24044
  });
22197
- app.get("/api/lessons/:lessonId/eval-grades", (req, res) => {
24045
+ app.get("/api/lessons/:lessonId/eval-grades", requireAuth("teacher", "administrator"), (req, res) => {
22198
24046
  try {
22199
24047
  const { lessonId } = req.params;
22200
24048
  const submissions = kernelContainer.db.prepare(
@@ -22244,7 +24092,7 @@ function registerLessonsRoutes(ctx) {
22244
24092
  sendSafeError(res, err);
22245
24093
  }
22246
24094
  });
22247
- app.get("/api/eval-submissions/:submissionId/reviews", (req, res) => {
24095
+ app.get("/api/eval-submissions/:submissionId/reviews", requireAuth(), (req, res) => {
22248
24096
  try {
22249
24097
  const { submissionId } = req.params;
22250
24098
  const rows = kernelContainer.db.prepare(
@@ -22260,7 +24108,7 @@ function registerLessonsRoutes(ctx) {
22260
24108
  sendSafeError(res, err);
22261
24109
  }
22262
24110
  });
22263
- app.get("/api/lessons/:lessonId/students/:studentId/eval-status", (req, res) => {
24111
+ app.get("/api/lessons/:lessonId/students/:studentId/eval-status", requireAuth(), (req, res) => {
22264
24112
  try {
22265
24113
  const { lessonId, studentId } = req.params;
22266
24114
  const submission = kernelContainer.db.prepare(
@@ -22361,7 +24209,7 @@ function registerLessonsRoutes(ctx) {
22361
24209
  sendSafeError(res, e);
22362
24210
  }
22363
24211
  });
22364
- app.get("/api/lessons/:id/whiteboard", (req, res) => {
24212
+ app.get("/api/lessons/:id/whiteboard", requireAuth(), (req, res) => {
22365
24213
  const id = req.params.id;
22366
24214
  const elements = kernelContainer.db.prepare("SELECT * FROM whiteboard_elements WHERE lesson_id = ?").all(id);
22367
24215
  if (!id.startsWith("assignment-") && !id.startsWith("snapshot-")) {
@@ -22519,6 +24367,18 @@ function registerLessonsRoutes(ctx) {
22519
24367
  dataObj.submissions[studentId] = { answer, score, time: Date.now() };
22520
24368
  kernelContainer.db.prepare("UPDATE whiteboard_elements SET data = ? WHERE id = ?").run(JSON.stringify(dataObj), elementId);
22521
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
+ });
22522
24382
  res.json({ success: true, isCorrect, score, studentId });
22523
24383
  } catch (e) {
22524
24384
  sendSafeError(res, e);
@@ -22592,11 +24452,14 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
22592
24452
  if (!ownership.allowed) {
22593
24453
  return res.status(ownership.status).json({ success: false, error: ownership.error });
22594
24454
  }
22595
- kernelContainer.db.prepare("DELETE FROM whiteboard_elements WHERE lesson_id = ?").run(id);
22596
- kernelContainer.db.prepare("DELETE FROM student_lesson_progress WHERE lesson_id = ?").run(id);
22597
- kernelContainer.db.prepare("DELETE FROM schedules WHERE lesson_id = ?").run(id);
22598
- kernelContainer.db.prepare("DELETE FROM assignments WHERE lesson_id = ?").run(id);
22599
- 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();
22600
24463
  if (result.changes === 0) {
22601
24464
  return res.status(404).json({ error: "Lesson not found" });
22602
24465
  }
@@ -22605,7 +24468,7 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
22605
24468
  sendSafeError(res, e);
22606
24469
  }
22607
24470
  });
22608
- app.get("/api/lessons/:id/stats", (req, res) => {
24471
+ app.get("/api/lessons/:id/stats", requireAuth(), (req, res) => {
22609
24472
  try {
22610
24473
  const { id } = req.params;
22611
24474
  const whiteboardCount = kernelContainer.db.prepare("SELECT COUNT(*) as count FROM whiteboard_elements WHERE lesson_id = ?").get(id).count;
@@ -22629,26 +24492,29 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
22629
24492
  const newId = (0, import_uuid16.v7)();
22630
24493
  const now = Date.now();
22631
24494
  const newTitle = `\u526F\u672C-${original.title}`;
22632
- kernelContainer.db.prepare(
22633
- "INSERT INTO lessons (id, title, content, timeline, progress_mode, progress_conditions, creator_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
22634
- ).run(
22635
- newId,
22636
- newTitle,
22637
- original.content,
22638
- original.timeline,
22639
- original.progress_mode,
22640
- original.progress_conditions,
22641
- creatorId,
22642
- now,
22643
- now
22644
- );
22645
- const whiteboardElements = kernelContainer.db.prepare("SELECT * FROM whiteboard_elements WHERE lesson_id = ?").all(id);
22646
- const insertElement = kernelContainer.db.prepare(
22647
- "INSERT INTO whiteboard_elements (id, lesson_id, type, data, created_at) VALUES (?, ?, ?, ?, ?)"
22648
- );
22649
- for (const el of whiteboardElements) {
22650
- insertElement.run((0, import_uuid16.v7)(), newId, el.type, el.data, now);
22651
- }
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();
22652
24518
  const cloned = kernelContainer.db.prepare(
22653
24519
  `
22654
24520
  SELECT l.*, u.name as creator_name, 0 as enrollment_count
@@ -22666,9 +24532,11 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
22666
24532
 
22667
24533
  // server/routes/workspace.ts
22668
24534
  var import_path12 = __toESM(require("path"), 1);
24535
+ var DIAGNOSTIC_MIN_INTERVAL_MS = 1e3;
24536
+ var diagnosticLastReportAt = /* @__PURE__ */ new Map();
22669
24537
  function registerWorkspaceRoutes(ctx) {
22670
24538
  const { app, MF_REMOTE_CACHE: MF_REMOTE_CACHE2 } = ctx;
22671
- app.get("/api/events", requireAuth("administrator"), (req, res) => {
24539
+ app.get("/api/events", requireAuth("administrator", "teacher"), (req, res) => {
22672
24540
  try {
22673
24541
  const events = kernelContainer.db.prepare("SELECT * FROM events ORDER BY timestamp DESC LIMIT 50").all();
22674
24542
  res.json(events);
@@ -22676,6 +24544,69 @@ function registerWorkspaceRoutes(ctx) {
22676
24544
  sendSafeError(res, e);
22677
24545
  }
22678
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
+ });
22679
24610
  app.get("/api/mfe/remotes", (req, res) => {
22680
24611
  try {
22681
24612
  const name = req.query.name;
@@ -23062,7 +24993,7 @@ var import_crypto9 = __toESM(require("crypto"), 1);
23062
24993
  var import_bcryptjs2 = __toESM(require("bcryptjs"), 1);
23063
24994
  function registerRosterRoutes(ctx) {
23064
24995
  const { app, io, loginLimiter } = ctx;
23065
- app.get("/api/classes", (req, res) => {
24996
+ app.get("/api/classes", requireAuth(), (req, res) => {
23066
24997
  try {
23067
24998
  const classes = kernelContainer.db.prepare(
23068
24999
  `
@@ -23076,22 +25007,25 @@ function registerRosterRoutes(ctx) {
23076
25007
  ).all();
23077
25008
  res.json(classes);
23078
25009
  } catch (e) {
23079
- res.status(500).json({ error: e.message });
25010
+ sendSafeError(res, e);
23080
25011
  }
23081
25012
  });
23082
- app.get("/api/students", (req, res) => {
25013
+ app.get("/api/students", requireAuth(), (req, res) => {
23083
25014
  try {
23084
- 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();
23085
25018
  res.json(students);
23086
25019
  } catch (e) {
23087
- res.status(500).json({ error: e.message });
25020
+ sendSafeError(res, e);
23088
25021
  }
23089
25022
  });
23090
- app.get("/api/classes/:id/students", (req, res) => {
25023
+ app.get("/api/classes/:id/students", requireAuth(), (req, res) => {
23091
25024
  try {
23092
25025
  const students = kernelContainer.db.prepare(
23093
25026
  `
23094
- 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
23095
25029
  INNER JOIN class_students cs ON s.id = cs.student_id
23096
25030
  WHERE cs.class_id = ?
23097
25031
  ORDER BY cs.joined_at DESC
@@ -23568,7 +25502,7 @@ function registerRosterRoutes(ctx) {
23568
25502
  sendSafeError(res, e);
23569
25503
  }
23570
25504
  });
23571
- app.get("/api/students/:id/read_notifications", (req, res) => {
25505
+ app.get("/api/students/:id/read_notifications", requireAuth(), (req, res) => {
23572
25506
  try {
23573
25507
  const rows = kernelContainer.db.prepare("SELECT notification_id FROM student_read_notifications WHERE student_id = ?").all(req.params.id);
23574
25508
  res.json(rows.map((r) => r.notification_id));
@@ -23690,7 +25624,7 @@ function registerRosterRoutes(ctx) {
23690
25624
  res.status(550).json({ error: e.message });
23691
25625
  }
23692
25626
  });
23693
- app.get("/api/labs", (req, res) => {
25627
+ app.get("/api/labs", requireAuth(), (req, res) => {
23694
25628
  try {
23695
25629
  const labs = kernelContainer.db.prepare("SELECT * FROM computer_labs ORDER BY created_at DESC").all();
23696
25630
  res.json(labs);
@@ -23726,7 +25660,7 @@ function registerRosterRoutes(ctx) {
23726
25660
  sendSafeError(res, e);
23727
25661
  }
23728
25662
  });
23729
- app.get("/api/classes/:classId/seats", (req, res) => {
25663
+ app.get("/api/classes/:classId/seats", requireAuth(), (req, res) => {
23730
25664
  try {
23731
25665
  const classInfo = kernelContainer.db.prepare("SELECT lab_id FROM classes WHERE id = ?").get(req.params.classId);
23732
25666
  const labId = classInfo ? classInfo.lab_id : null;
@@ -23811,11 +25745,8 @@ function registerRosterRoutes(ctx) {
23811
25745
  sendSafeError(res, e);
23812
25746
  }
23813
25747
  });
23814
- app.get("/api/students/:id/export", (req, res) => {
25748
+ app.get("/api/students/:id/export", requireAuth("teacher", "administrator"), (req, res) => {
23815
25749
  try {
23816
- if (!checkIsTeacherOrAdmin(req)) {
23817
- return res.status(403).json({ error: "Only teachers and administrators can export student data" });
23818
- }
23819
25750
  const studentId = req.params.id;
23820
25751
  const student = kernelContainer.db.prepare("SELECT * FROM students WHERE id = ?").get(studentId);
23821
25752
  if (!student) return res.status(404).json({ error: "Student not found" });
@@ -23875,10 +25806,16 @@ function registerRosterRoutes(ctx) {
23875
25806
  sendSafeError(res, e);
23876
25807
  }
23877
25808
  });
23878
- app.get("/api/students/:id/progress", (req, res) => {
25809
+ app.get("/api/students/:id/progress", requireAuth(), (req, res) => {
23879
25810
  try {
25811
+ const session = req.session;
25812
+ const isPrivileged = session && (session.role === "teacher" || session.role === "administrator");
25813
+ const currentUserId = session?.userId || session?.studentId;
23880
25814
  const studentRow = kernelContainer.db.prepare("SELECT id, student_number FROM students WHERE id = ? OR student_number = ?").get(req.params.id, req.params.id);
23881
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
+ }
23882
25819
  const progress = kernelContainer.db.prepare(
23883
25820
  `
23884
25821
  SELECT slp.*, l.title as lesson_title
@@ -23988,7 +25925,7 @@ function registerRosterRoutes(ctx) {
23988
25925
  sendSafeError(res, e);
23989
25926
  }
23990
25927
  });
23991
- app.get("/api/classes/:id/progress", (req, res) => {
25928
+ app.get("/api/classes/:id/progress", requireAuth(), (req, res) => {
23992
25929
  try {
23993
25930
  const progress = kernelContainer.db.prepare(
23994
25931
  `
@@ -24005,7 +25942,7 @@ function registerRosterRoutes(ctx) {
24005
25942
  sendSafeError(res, e);
24006
25943
  }
24007
25944
  });
24008
- app.get("/api/classes/:classId/lessons/:lessonId/progress", (req, res) => {
25945
+ app.get("/api/classes/:classId/lessons/:lessonId/progress", requireAuth(), (req, res) => {
24009
25946
  try {
24010
25947
  const progress = kernelContainer.db.prepare(
24011
25948
  `
@@ -24035,7 +25972,7 @@ function registerRosterRoutes(ctx) {
24035
25972
  sendSafeError(res, e);
24036
25973
  }
24037
25974
  });
24038
- app.get("/api/classes/:classId/dashboard", (req, res) => {
25975
+ app.get("/api/classes/:classId/dashboard", requireAuth(), (req, res) => {
24039
25976
  try {
24040
25977
  const assignments = kernelContainer.db.prepare("SELECT * FROM assignments WHERE class_id = ? ORDER BY created_at DESC").all(req.params.classId);
24041
25978
  const recentSubmissions = kernelContainer.db.prepare(
@@ -24188,7 +26125,7 @@ function registerRosterRoutes(ctx) {
24188
26125
  // server/routes/assignments.ts
24189
26126
  function registerAssignmentsRoutes(ctx) {
24190
26127
  const { app } = ctx;
24191
- app.get("/api/classes/:classId/assignments", (req, res) => {
26128
+ app.get("/api/classes/:classId/assignments", requireAuth(), (req, res) => {
24192
26129
  try {
24193
26130
  const assignments = kernelContainer.db.prepare("SELECT * FROM assignments WHERE class_id = ? ORDER BY created_at DESC").all(req.params.classId);
24194
26131
  res.json(assignments);
@@ -24326,7 +26263,7 @@ Output pure JSON only without markdown code blocks, matching this structure:
24326
26263
  sendSafeError(res, e);
24327
26264
  }
24328
26265
  });
24329
- app.get("/api/assignments/:id/submissions", (req, res) => {
26266
+ app.get("/api/assignments/:id/submissions", requireAuth(), (req, res) => {
24330
26267
  try {
24331
26268
  const submissions = kernelContainer.db.prepare(
24332
26269
  `
@@ -24441,10 +26378,389 @@ Provide a grade score (0-100) and brief feedback. Ensure you output in this exac
24441
26378
  );
24442
26379
  }
24443
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
+
24444
26760
  // server/routes/schedules.ts
24445
26761
  function registerSchedulesRoutes(ctx) {
24446
26762
  const { app } = ctx;
24447
- app.get("/api/schedules/today", (req, res) => {
26763
+ app.get("/api/schedules/today", requireAuth(), (req, res) => {
24448
26764
  try {
24449
26765
  const clientDate = req.query.date || (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
24450
26766
  const schedules = kernelContainer.db.prepare(
@@ -24469,10 +26785,10 @@ function registerSchedulesRoutes(ctx) {
24469
26785
  ).all(clientDate, clientDate);
24470
26786
  res.json({ success: true, schedules });
24471
26787
  } catch (e) {
24472
- res.status(500).json({ error: e.message });
26788
+ sendSafeError(res, e);
24473
26789
  }
24474
26790
  });
24475
- app.get("/api/schedules", (req, res) => {
26791
+ app.get("/api/schedules", requireAuth(), (req, res) => {
24476
26792
  try {
24477
26793
  const schedules = kernelContainer.db.prepare(
24478
26794
  `
@@ -24485,10 +26801,10 @@ function registerSchedulesRoutes(ctx) {
24485
26801
  ).all();
24486
26802
  res.json(schedules);
24487
26803
  } catch (e) {
24488
- res.status(500).json({ error: e.message });
26804
+ sendSafeError(res, e);
24489
26805
  }
24490
26806
  });
24491
- app.get("/api/classes/:classId/schedules", (req, res) => {
26807
+ app.get("/api/classes/:classId/schedules", requireAuth(), (req, res) => {
24492
26808
  try {
24493
26809
  const schedules = kernelContainer.db.prepare(
24494
26810
  `
@@ -24501,7 +26817,7 @@ function registerSchedulesRoutes(ctx) {
24501
26817
  ).all(req.params.classId);
24502
26818
  res.json(schedules);
24503
26819
  } catch (e) {
24504
- res.status(500).json({ error: e.message });
26820
+ sendSafeError(res, e);
24505
26821
  }
24506
26822
  });
24507
26823
  app.post("/api/classes/:classId/schedules", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24536,7 +26852,7 @@ function registerSchedulesRoutes(ctx) {
24536
26852
  }
24537
26853
  });
24538
26854
  } catch (e) {
24539
- res.status(500).json({ error: e.message });
26855
+ sendSafeError(res, e);
24540
26856
  }
24541
26857
  });
24542
26858
  app.put("/api/classes/:classId/schedules/:scheduleId", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24559,7 +26875,7 @@ function registerSchedulesRoutes(ctx) {
24559
26875
  );
24560
26876
  res.json({ success: true });
24561
26877
  } catch (e) {
24562
- res.status(500).json({ error: e.message });
26878
+ sendSafeError(res, e);
24563
26879
  }
24564
26880
  });
24565
26881
  app.delete("/api/classes/:classId/schedules/:scheduleId", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24568,7 +26884,7 @@ function registerSchedulesRoutes(ctx) {
24568
26884
  kernelContainer.db.prepare("DELETE FROM attendance WHERE schedule_id = ?").run(req.params.scheduleId);
24569
26885
  res.json({ success: true });
24570
26886
  } catch (e) {
24571
- res.status(500).json({ error: e.message });
26887
+ sendSafeError(res, e);
24572
26888
  }
24573
26889
  });
24574
26890
  app.post("/api/classes/:classId/schedules/batch", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24597,7 +26913,7 @@ function registerSchedulesRoutes(ctx) {
24597
26913
  transaction(schedules);
24598
26914
  res.json({ success: true, count: schedules.length });
24599
26915
  } catch (e) {
24600
- res.status(500).json({ error: e.message });
26916
+ sendSafeError(res, e);
24601
26917
  }
24602
26918
  });
24603
26919
  app.post("/api/timetable/ocr", requireAuth("teacher", "administrator"), async (req, res) => {
@@ -24760,7 +27076,7 @@ function registerSchedulesRoutes(ctx) {
24760
27076
  stack: e.stack,
24761
27077
  cause: e.cause
24762
27078
  });
24763
- res.status(500).json({ error: e.message });
27079
+ sendSafeError(res, e);
24764
27080
  }
24765
27081
  });
24766
27082
  }
@@ -24769,7 +27085,7 @@ function registerSchedulesRoutes(ctx) {
24769
27085
  init_interfaces();
24770
27086
  function registerGradingRoutes(ctx) {
24771
27087
  const { app } = ctx;
24772
- app.get("/api/classes/:classId/attendance-summary", (req, res) => {
27088
+ app.get("/api/classes/:classId/attendance-summary", requireAuth(), (req, res) => {
24773
27089
  try {
24774
27090
  const classId = req.params.classId;
24775
27091
  const db2 = kernelContainer.db;
@@ -24880,10 +27196,10 @@ function registerGradingRoutes(ctx) {
24880
27196
  }).sort((a, b) => a.date.localeCompare(b.date));
24881
27197
  res.json(filtered);
24882
27198
  } catch (e) {
24883
- res.status(500).json({ error: e.message });
27199
+ sendSafeError(res, e);
24884
27200
  }
24885
27201
  });
24886
- app.get("/api/schedules/:scheduleId/attendance", (req, res) => {
27202
+ app.get("/api/schedules/:scheduleId/attendance", requireAuth(), (req, res) => {
24887
27203
  try {
24888
27204
  const attendance = kernelContainer.db.prepare(
24889
27205
  `
@@ -24895,7 +27211,7 @@ function registerGradingRoutes(ctx) {
24895
27211
  ).all(req.params.scheduleId);
24896
27212
  res.json(attendance);
24897
27213
  } catch (e) {
24898
- res.status(500).json({ error: e.message });
27214
+ sendSafeError(res, e);
24899
27215
  }
24900
27216
  });
24901
27217
  app.post("/api/schedules/:scheduleId/attendance", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24910,10 +27226,10 @@ function registerGradingRoutes(ctx) {
24910
27226
  ).run(req.params.scheduleId, studentId, status, Date.now());
24911
27227
  res.json({ success: true });
24912
27228
  } catch (e) {
24913
- res.status(500).json({ error: e.message });
27229
+ sendSafeError(res, e);
24914
27230
  }
24915
27231
  });
24916
- app.get("/api/classes/:classId/grade-weights", (req, res) => {
27232
+ app.get("/api/classes/:classId/grade-weights", requireAuth(), (req, res) => {
24917
27233
  try {
24918
27234
  const weights = kernelContainer.db.prepare("SELECT * FROM class_grade_weights WHERE class_id = ?").get(req.params.classId);
24919
27235
  if (!weights) {
@@ -24927,7 +27243,7 @@ function registerGradingRoutes(ctx) {
24927
27243
  }
24928
27244
  res.json(weights);
24929
27245
  } catch (e) {
24930
- res.status(500).json({ error: e.message });
27246
+ sendSafeError(res, e);
24931
27247
  }
24932
27248
  });
24933
27249
  app.post("/api/classes/:classId/grade-weights", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24955,15 +27271,15 @@ function registerGradingRoutes(ctx) {
24955
27271
  ).run(req.params.classId, att, prog, assign, ex, Date.now());
24956
27272
  res.json({ success: true });
24957
27273
  } catch (e) {
24958
- res.status(500).json({ error: e.message });
27274
+ sendSafeError(res, e);
24959
27275
  }
24960
27276
  });
24961
- app.get("/api/classes/:classId/exams", (req, res) => {
27277
+ app.get("/api/classes/:classId/exams", requireAuth(), (req, res) => {
24962
27278
  try {
24963
27279
  const exams = kernelContainer.db.prepare("SELECT * FROM exams WHERE class_id = ? ORDER BY created_at DESC").all(req.params.classId);
24964
27280
  res.json(exams);
24965
27281
  } catch (e) {
24966
- res.status(500).json({ error: e.message });
27282
+ sendSafeError(res, e);
24967
27283
  }
24968
27284
  });
24969
27285
  app.post("/api/classes/:classId/exams", requireAuth("teacher", "administrator"), (req, res) => {
@@ -24979,15 +27295,15 @@ function registerGradingRoutes(ctx) {
24979
27295
  ).run(examId, req.params.classId, title, description || "", max_score || 100, Date.now());
24980
27296
  res.json({ success: true, examId });
24981
27297
  } catch (e) {
24982
- res.status(500).json({ error: e.message });
27298
+ sendSafeError(res, e);
24983
27299
  }
24984
27300
  });
24985
- app.get("/api/exams/:examId/scores", (req, res) => {
27301
+ app.get("/api/exams/:examId/scores", requireAuth(), (req, res) => {
24986
27302
  try {
24987
27303
  const scores = kernelContainer.db.prepare("SELECT * FROM exam_scores WHERE exam_id = ?").all(req.params.examId);
24988
27304
  res.json(scores);
24989
27305
  } catch (e) {
24990
- res.status(500).json({ error: e.message });
27306
+ sendSafeError(res, e);
24991
27307
  }
24992
27308
  });
24993
27309
  app.post("/api/exams/:examId/scores", requireAuth("teacher", "administrator"), (req, res) => {
@@ -25016,10 +27332,10 @@ function registerGradingRoutes(ctx) {
25016
27332
  transaction(scores);
25017
27333
  res.json({ success: true });
25018
27334
  } catch (e) {
25019
- res.status(500).json({ error: e.message });
27335
+ sendSafeError(res, e);
25020
27336
  }
25021
27337
  });
25022
- app.get("/api/classes/:classId/semester-grades", (req, res) => {
27338
+ app.get("/api/classes/:classId/semester-grades", requireAuth(), (req, res) => {
25023
27339
  try {
25024
27340
  const classId = req.params.classId;
25025
27341
  const semesterName = req.query.semesterName || "2026\u5E74\u6625\u5B63\u5B66\uFFFD?";
@@ -25161,7 +27477,7 @@ function registerGradingRoutes(ctx) {
25161
27477
  });
25162
27478
  res.json({ success: true, weights, students: result });
25163
27479
  } catch (e) {
25164
- res.status(500).json({ error: e.message });
27480
+ sendSafeError(res, e);
25165
27481
  }
25166
27482
  });
25167
27483
  app.post("/api/grade-sync", requireAuth("teacher", "administrator"), async (req, res) => {
@@ -25174,16 +27490,16 @@ function registerGradingRoutes(ctx) {
25174
27490
  await gradeService.saveSemesterGrade(lessonId, studentId, Math.round(Number(grade)));
25175
27491
  res.json({ success: true });
25176
27492
  } catch (e) {
25177
- res.status(500).json({ success: false, error: e.message });
27493
+ sendSafeError(res, e);
25178
27494
  }
25179
27495
  });
25180
- app.get("/api/classes/:classId/points-dimensions", async (req, res) => {
27496
+ app.get("/api/classes/:classId/points-dimensions", requireAuth(), async (req, res) => {
25181
27497
  try {
25182
27498
  const dimensionRegistry = await kernelContainer.serviceRegistry.resolve(IPointsDimensionRegistryToken);
25183
27499
  const dimensions = dimensionRegistry.listDimensions();
25184
27500
  res.json({ success: true, dimensions });
25185
27501
  } catch (e) {
25186
- res.status(500).json({ success: false, error: e.message });
27502
+ sendSafeError(res, e);
25187
27503
  }
25188
27504
  });
25189
27505
  app.post("/api/students/:studentId/points", requireAuth("teacher", "administrator"), async (req, res) => {
@@ -25211,10 +27527,10 @@ function registerGradingRoutes(ctx) {
25211
27527
  });
25212
27528
  res.json({ success: true, logItem });
25213
27529
  } catch (e) {
25214
- res.status(500).json({ success: false, error: e.message });
27530
+ sendSafeError(res, e);
25215
27531
  }
25216
27532
  });
25217
- app.get("/api/students/:studentId/points-logs", async (req, res) => {
27533
+ app.get("/api/students/:studentId/points-logs", requireAuth(), async (req, res) => {
25218
27534
  try {
25219
27535
  const { studentId } = req.params;
25220
27536
  const { classId } = req.query;
@@ -25223,7 +27539,7 @@ function registerGradingRoutes(ctx) {
25223
27539
  const summary = await ledgerService.getStudentDimensionSummary(studentId, classId || "");
25224
27540
  res.json({ success: true, logs, summary });
25225
27541
  } catch (e) {
25226
- res.status(500).json({ success: false, error: e.message });
27542
+ sendSafeError(res, e);
25227
27543
  }
25228
27544
  });
25229
27545
  app.post("/api/classes/:classId/semester-reports/archive", requireAuth("teacher", "administrator"), (req, res) => {
@@ -25276,7 +27592,7 @@ function registerGradingRoutes(ctx) {
25276
27592
  transaction(reports);
25277
27593
  res.json({ success: true });
25278
27594
  } catch (e) {
25279
- res.status(500).json({ error: e.message });
27595
+ sendSafeError(res, e);
25280
27596
  }
25281
27597
  });
25282
27598
  app.post(
@@ -25377,16 +27693,16 @@ ${examsText}
25377
27693
  res.json({ success: true, aiEvaluation: text });
25378
27694
  } catch (e) {
25379
27695
  console.error("AI Semester Evaluation error:", e);
25380
- res.status(500).json({ error: e.message });
27696
+ sendSafeError(res, e);
25381
27697
  }
25382
27698
  }
25383
27699
  );
25384
27700
  }
25385
27701
 
25386
27702
  // server/routes/plugins.ts
25387
- var import_express2 = __toESM(require("express"), 1);
25388
- var import_path14 = __toESM(require("path"), 1);
25389
- 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);
25390
27706
  var import_semver3 = __toESM(require("semver"), 1);
25391
27707
 
25392
27708
  // server/services/version-fetcher.ts
@@ -25407,6 +27723,14 @@ function cached(key) {
25407
27723
  function setCache(key, result) {
25408
27724
  cache.set(key, { result, timestamp: Date.now() });
25409
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
+ }
25410
27734
  function repoUrl(source) {
25411
27735
  if (source.type === "github-release") {
25412
27736
  return `https://github.com/${source.repo}.git`;
@@ -25483,9 +27807,6 @@ function latestFromReleases(releases, installed, source) {
25483
27807
  return { version: null, isPrerelease: false, downloadUrl: null, changelog: null };
25484
27808
  }
25485
27809
  async function checkVersion(source, installedVersion) {
25486
- const key = cacheKey(source);
25487
- const hit = cached(key);
25488
- if (hit) return { ...hit, installedVersion };
25489
27810
  const base = {
25490
27811
  hasUpdate: false,
25491
27812
  installedVersion,
@@ -25494,9 +27815,16 @@ async function checkVersion(source, installedVersion) {
25494
27815
  downloadUrl: null,
25495
27816
  changelog: null
25496
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 };
25497
27825
  try {
25498
- const url = repoUrl(source);
25499
- 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], {
25500
27828
  timeout: 8e3,
25501
27829
  encoding: "utf-8",
25502
27830
  stdio: ["ignore", "pipe", "pipe"]
@@ -25517,7 +27845,7 @@ async function checkVersion(source, installedVersion) {
25517
27845
  } catch {
25518
27846
  }
25519
27847
  try {
25520
- const apiUrl = apiReleasesUrl(source);
27848
+ const apiUrl = apiReleasesUrl(safeSource);
25521
27849
  const resp = await fetch(apiUrl, {
25522
27850
  headers: { "User-Agent": "OpenLearnV2-PluginUpdater/1.0" },
25523
27851
  signal: AbortSignal.timeout(1e4)
@@ -25526,7 +27854,11 @@ async function checkVersion(source, installedVersion) {
25526
27854
  return { ...base, error: `\u66F4\u65B0\u6E90\u54CD\u5E94\u5F02\u5E38: HTTP ${resp.status}` };
25527
27855
  }
25528
27856
  const releases = await resp.json();
25529
- const { version, isPrerelease, downloadUrl, changelog } = latestFromReleases(releases, installedVersion, source);
27857
+ const { version, isPrerelease, downloadUrl, changelog } = latestFromReleases(
27858
+ releases,
27859
+ installedVersion,
27860
+ safeSource
27861
+ );
25530
27862
  const result = {
25531
27863
  ...base,
25532
27864
  hasUpdate: version !== null,
@@ -25898,16 +28230,16 @@ data: ${JSON.stringify({ error: msg })}
25898
28230
 
25899
28231
  // server/routes/plugins.ts
25900
28232
  function findLocalPluginSource(manifestId) {
25901
- const root = import_path14.default.resolve(process.cwd(), "v2_plugins");
25902
- 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;
25903
28235
  let best = null;
25904
28236
  try {
25905
- for (const entry of import_fs13.default.readdirSync(root, { withFileTypes: true })) {
28237
+ for (const entry of import_fs14.default.readdirSync(root, { withFileTypes: true })) {
25906
28238
  if (!entry.isDirectory()) continue;
25907
- const manifestPath = import_path14.default.join(root, entry.name, "manifest.json");
25908
- 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;
25909
28241
  try {
25910
- const m = JSON.parse(import_fs13.default.readFileSync(manifestPath, "utf-8"));
28242
+ const m = JSON.parse(import_fs14.default.readFileSync(manifestPath, "utf-8"));
25911
28243
  if (m?.id !== manifestId || !m?.version) continue;
25912
28244
  if (!best || import_semver3.default.gt(m.version, best.version)) best = { dir: entry.name, version: m.version };
25913
28245
  } catch {
@@ -25943,11 +28275,11 @@ function registerPluginsRoutes(ctx) {
25943
28275
  const { app, buildOpenAITools: buildOpenAITools2, executeAgentToolCall: executeAgentToolCall2, buildOpenAIChatUrl: buildOpenAIChatUrl2, runGeminiAgentChat: runGeminiAgentChat2, runOpenAIAgentChat: runOpenAIAgentChat2 } = ctx;
25944
28276
  app.get("/api/docs/plugin-guide", (req, res) => {
25945
28277
  try {
25946
- const docPath = import_path14.default.join(process.cwd(), "docs_plugin_guide.md");
25947
- 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)) {
25948
28280
  return res.status(404).json({ success: false, error: "Document not found" });
25949
28281
  }
25950
- const content = import_fs13.default.readFileSync(docPath, "utf-8");
28282
+ const content = import_fs14.default.readFileSync(docPath, "utf-8");
25951
28283
  res.json({ success: true, content });
25952
28284
  } catch (e) {
25953
28285
  sendSafeError(res, e);
@@ -25958,11 +28290,11 @@ function registerPluginsRoutes(ctx) {
25958
28290
  const limit = parseInt(req.query.limit) || 200;
25959
28291
  const component = req.query.component;
25960
28292
  const levelFilter = req.query.level;
25961
- const logFile2 = import_path14.default.resolve(process.cwd(), "logs", "openlearn.log");
25962
- 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)) {
25963
28295
  return res.json({ success: true, logs: [] });
25964
28296
  }
25965
- const content = import_fs13.default.readFileSync(logFile2, "utf-8");
28297
+ const content = import_fs14.default.readFileSync(logFile2, "utf-8");
25966
28298
  const lines = content.split("\n").filter(Boolean);
25967
28299
  let parsedLogs = lines.map((line) => {
25968
28300
  try {
@@ -26023,7 +28355,7 @@ function registerPluginsRoutes(ctx) {
26023
28355
  sendSafeError(res, e);
26024
28356
  }
26025
28357
  });
26026
- app.get("/api/plugins/market", async (req, res) => {
28358
+ app.get("/api/plugins/market", requireAuth(), async (req, res) => {
26027
28359
  try {
26028
28360
  const plugins = kernelContainer.db.prepare("SELECT id, manifest FROM plugins WHERE manifest LIKE '%updateSource%'").all();
26029
28361
  const results = [];
@@ -26049,7 +28381,7 @@ function registerPluginsRoutes(ctx) {
26049
28381
  sendSafeError(res, e);
26050
28382
  }
26051
28383
  });
26052
- app.post("/api/plugins/:id(*)/check-update", async (req, res) => {
28384
+ app.post("/api/plugins/:id(*)/check-update", requireAuth(), async (req, res) => {
26053
28385
  try {
26054
28386
  const rawId = decodeURIComponent(req.params.id);
26055
28387
  const pluginId = kernelContainer.pluginHost.resolvePluginUuid(rawId);
@@ -26252,7 +28584,7 @@ function registerPluginsRoutes(ctx) {
26252
28584
  app.post(
26253
28585
  "/api/plugins/upload-zip-raw",
26254
28586
  requireAuth("administrator"),
26255
- import_express2.default.raw({ type: "application/octet-stream", limit: "400mb" }),
28587
+ import_express3.default.raw({ type: "application/octet-stream", limit: "400mb" }),
26256
28588
  async (req, res) => {
26257
28589
  try {
26258
28590
  const zipBuffer = req.body;
@@ -26299,7 +28631,7 @@ function registerPluginsRoutes(ctx) {
26299
28631
  app.post(
26300
28632
  "/api/plugins/:id(*)/update-zip-raw",
26301
28633
  requireAuth("administrator"),
26302
- import_express2.default.raw({ type: "application/octet-stream", limit: "400mb" }),
28634
+ import_express3.default.raw({ type: "application/octet-stream", limit: "400mb" }),
26303
28635
  async (req, res) => {
26304
28636
  try {
26305
28637
  const targetPluginId = decodeURIComponent(req.params.id);
@@ -26330,7 +28662,7 @@ function registerPluginsRoutes(ctx) {
26330
28662
  }
26331
28663
  }
26332
28664
  );
26333
- app.post("/api/plugins/execute-command", async (req, res) => {
28665
+ app.post("/api/plugins/execute-command", requireAuth(), async (req, res) => {
26334
28666
  try {
26335
28667
  const { type, payload } = req.body;
26336
28668
  if (!type) {
@@ -26524,7 +28856,7 @@ async function startServer() {
26524
28856
  config: { port: Number(process.env.PORT) || 9e3 }
26525
28857
  });
26526
28858
  try {
26527
- const migrationsDir = import_path15.default.join(process.cwd(), "migrations");
28859
+ const migrationsDir = import_path17.default.join(process.cwd(), "migrations");
26528
28860
  const migrations = loadMigrationsFromDirectory(migrationsDir);
26529
28861
  if (migrations.length > 0) {
26530
28862
  runMigrations(kernelContainer.db, migrations);
@@ -26538,7 +28870,7 @@ async function startServer() {
26538
28870
  registerOfficialActivities(activityRegistry, kernelContainer.actionRegistry);
26539
28871
  await kernelContainer.serviceRegistry.register(IActivityRegistryToken, activityRegistry);
26540
28872
  console.log(`[ActivityEcosystem] Registered ${activityRegistry.listProviders().length} official activity providers.`);
26541
- const app = (0, import_express3.default)();
28873
+ const app = (0, import_express4.default)();
26542
28874
  kernelContainer.pluginHost.setExpressApp(app);
26543
28875
  const PORT = parseInt(process.env.PORT || "9000", 10);
26544
28876
  app.set("trust proxy", 1);
@@ -26586,10 +28918,25 @@ async function startServer() {
26586
28918
  standardHeaders: true,
26587
28919
  legacyHeaders: false
26588
28920
  });
26589
- app.use(import_express3.default.json({ limit: "10mb" }));
26590
- app.use(import_express3.default.urlencoded({ limit: "10mb", extended: true }));
26591
- app.use("/uploads", import_express3.default.static(import_path15.default.join(process.cwd(), "uploads")));
26592
- 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" }));
26593
28940
  const configuredOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean) : [];
26594
28941
  const isOriginAllowed = (origin, hostHeader) => {
26595
28942
  if (!origin) return true;
@@ -26714,6 +29061,7 @@ async function startServer() {
26714
29061
  registerAdminRoutes(ctx);
26715
29062
  registerRosterRoutes(ctx);
26716
29063
  registerAssignmentsRoutes(ctx);
29064
+ registerAssignmentHubRoutes(ctx);
26717
29065
  registerSchedulesRoutes(ctx);
26718
29066
  registerGradingRoutes(ctx);
26719
29067
  registerPluginsRoutes(ctx);
@@ -26762,10 +29110,10 @@ async function startServer() {
26762
29110
  });
26763
29111
  app.use(vite.middlewares);
26764
29112
  } else {
26765
- const distPath = __dirname || import_path15.default.join(process.cwd(), "dist");
26766
- 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));
26767
29115
  app.get("*", (req, res) => {
26768
- res.sendFile(import_path15.default.join(distPath, "index.html"));
29116
+ res.sendFile(import_path17.default.join(distPath, "index.html"));
26769
29117
  });
26770
29118
  }
26771
29119
  const HOST = process.env.HOST || "0.0.0.0";