openlearn-next 0.7.4 → 0.7.6

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 (61) hide show
  1. package/README.md +4 -4
  2. package/cli-doctor.mjs +18 -5
  3. package/cli.mjs +49 -0
  4. package/dist/assets/{ActivityWorkspaceWidget-CMN8elwQ.js → ActivityWorkspaceWidget-0DUqiqFK.js} +1 -1
  5. package/dist/assets/{AdminDirectoryView-Dlz7q-xf.js → AdminDirectoryView-DL9zZi-_.js} +1 -1
  6. package/dist/assets/AssessmentCenter-B2JzXBGe.js +1 -0
  7. package/dist/assets/AssignmentFilePreview-Bk751Blz.js +3 -0
  8. package/dist/assets/{BatchPickerModal-CzutdtIz.js → BatchPickerModal-9ekXDUUj.js} +1 -1
  9. package/dist/assets/{ClassesView-RkFPpWRu.js → ClassesView-PCt8I3MU.js} +5 -5
  10. package/dist/assets/{ClassroomCountdownWidget-gs42lIGW.js → ClassroomCountdownWidget-HxelcHUp.js} +1 -1
  11. package/dist/assets/{CloudDriveModal-C6JtK08M.js → CloudDriveModal-Mp59sr-C.js} +1 -1
  12. package/dist/assets/ComputerLabView-FStILvFC.js +1 -0
  13. package/dist/assets/{CourseManagement-DNeaJcT9.js → CourseManagement-BQw2idgZ.js} +1 -1
  14. package/dist/assets/{CourseWizardModal-vw876VJr.js → CourseWizardModal-B7-e8otR.js} +1 -1
  15. package/dist/assets/{CoursewareHubPanel-CyxDIJAF.js → CoursewareHubPanel-DLjH-UzF.js} +1 -1
  16. package/dist/assets/{Dashboard-C1iYnfCh.js → Dashboard-D3LXLQNC.js} +2 -2
  17. package/dist/assets/{ExportWeightModal-CTZ-O_wi.js → ExportWeightModal-vd7XgaJ0.js} +1 -1
  18. package/dist/assets/{HelpTour-D_q1z_MI.js → HelpTour-BLuiS8rr.js} +1 -1
  19. package/dist/assets/{HelpView-wyKldm_R.js → HelpView-C4BHoNqR.js} +1 -1
  20. package/dist/assets/{ImportLessonsModal-C09Z-qtx.js → ImportLessonsModal-Y6uIIemO.js} +1 -1
  21. package/dist/assets/{ImportModal-DqjqNeH4.js → ImportModal-H5s5MyZY.js} +1 -1
  22. package/dist/assets/{InteractiveCoursewareViewer-V97euiJF.js → InteractiveCoursewareViewer-D9GzG28E.js} +1 -1
  23. package/dist/assets/InteractiveWhiteboard-DswIV-ES.js +65 -0
  24. package/dist/assets/{LazyCourseware-0iFYlRGj.js → LazyCourseware-BEoTTg5h.js} +2 -2
  25. package/dist/assets/LazyWhiteboard-CMBBxnFX.js +2 -0
  26. package/dist/assets/LessonEditorView-BgJtG8Ks.js +1 -0
  27. package/dist/assets/{LiveClassroomView-CaS1DzSS.js → LiveClassroomView-Czwt5WW-.js} +7 -7
  28. package/dist/assets/{NotificationDetailModal-4MdNF4tw.js → NotificationDetailModal-D42vEs3S.js} +1 -1
  29. package/dist/assets/{PluginView-BqTbAp30.js → PluginView-BcOWIjLu.js} +1 -1
  30. package/dist/assets/{ProcessLogsModal-Cfr-T9Gd.js → ProcessLogsModal-_BwkmJVw.js} +1 -1
  31. package/dist/assets/{QuizGeneratorModal-xTq22vDq.js → QuizGeneratorModal-DiItok6z.js} +1 -1
  32. package/dist/assets/StudentAssignmentEvalPanel-DHoZc3g0.js +1 -0
  33. package/dist/assets/{StudentAssignmentView-Cy_JdPYB.js → StudentAssignmentView-UK_7_MF3.js} +1 -1
  34. package/dist/assets/StudentLessonView-C11Qkdi_.js +1 -0
  35. package/dist/assets/{StudentView-CpJsG7FT.js → StudentView-j0hx3IOi.js} +2 -2
  36. package/dist/assets/{SystemResourceLibraryModal-YZ85mv_v.js → SystemResourceLibraryModal-dEdNKbk2.js} +1 -1
  37. package/dist/assets/TeacherView-COZw2tFl.js +2 -0
  38. package/dist/assets/{TimetableView-D_4XmrRN.js → TimetableView-AkReskNG.js} +1 -1
  39. package/dist/assets/{UTIF-FfCsrHhe.js → UTIF-4E-wdh3X.js} +1 -1
  40. package/dist/assets/index-DN3E-Fmw.js +131 -0
  41. package/dist/assets/index-DPky2leG.css +1 -0
  42. package/dist/assets/{index.es-DyEF0CZ9.js → index.es-WXdI0L-F.js} +1 -1
  43. package/dist/assets/pluginParsers-k90WUDrU.js +1 -0
  44. package/dist/assets/student-workspace-BDw1i4Vl.js +1 -0
  45. package/dist/assets/{vendor-icons-CXrZ3R2V.js → vendor-icons-hc6MjYFv.js} +1 -1
  46. package/dist/assets/{vendor-pdf-C1Vh2Aqq.js → vendor-pdf-By0fofA0.js} +3 -3
  47. package/dist/assets/{vendor-pptx-Bzn6whfK.js → vendor-pptx-EjNUtdws.js} +178 -178
  48. package/dist/index.html +3 -3
  49. package/dist/server.cjs +602 -200
  50. package/package.json +6 -4
  51. package/scripts/assert-node-runtime.mjs +165 -0
  52. package/dist/assets/AssessmentCenter-CaoAI9Ke.js +0 -1
  53. package/dist/assets/ComputerLabView-Dr-g8FrU.js +0 -1
  54. package/dist/assets/InteractiveWhiteboard-COLMh_-w.js +0 -65
  55. package/dist/assets/LazyWhiteboard-CcoBIx6V.js +0 -2
  56. package/dist/assets/LessonEditorView-BWmtKwmP.js +0 -1
  57. package/dist/assets/StudentLessonView-B3fKejCU.js +0 -1
  58. package/dist/assets/TeacherView-B3zhzd5g.js +0 -2
  59. package/dist/assets/index-BzXADCXZ.js +0 -131
  60. package/dist/assets/index-C6hlh8pw.css +0 -1
  61. package/dist/assets/pluginParsers-PzgFzxS9.js +0 -1
package/dist/server.cjs CHANGED
@@ -944,6 +944,9 @@ var init_manifest_schema = __esm({
944
944
  "stage.display.overlay": import_zod.z.array(frontendSlotEntrySchema).optional(),
945
945
  "stage.display.action": import_zod.z.array(frontendSlotEntrySchema).optional(),
946
946
  "assignment.submission.preview": import_zod.z.array(frontendSlotEntrySchema).optional(),
947
+ // 作业**附件**预览:插件声明自己能处理的文件类型(extensions / mimeTypes),
948
+ // 宿主在预览对话框里按类型匹配后给出入口。数据型贡献(远程插件可用)。
949
+ "assignment.file.preview": import_zod.z.array(frontendSlotEntrySchema).optional(),
947
950
  "student.classroom.overlay": import_zod.z.array(frontendSlotEntrySchema).optional(),
948
951
  "student.profile.tab": import_zod.z.array(frontendSlotEntrySchema).optional(),
949
952
  "whiteboard.renderer": import_zod.z.array(frontendSlotEntrySchema).optional(),
@@ -1188,12 +1191,12 @@ var init_manifest_schema = __esm({
1188
1191
  // packages/core/di/api-key-crypto.ts
1189
1192
  function resolveEncryptionKeyStorageDir(env = process.env, cwd = process.cwd()) {
1190
1193
  if (env.OPENLEARN_DATA_DIR?.trim()) {
1191
- return import_node_path4.default.join(import_node_path4.default.resolve(env.OPENLEARN_DATA_DIR.trim()), "storage");
1194
+ return import_node_path5.default.join(import_node_path5.default.resolve(env.OPENLEARN_DATA_DIR.trim()), "storage");
1192
1195
  }
1193
1196
  if (env.OPENLEARN_DB_PATH?.trim()) {
1194
- return import_node_path4.default.join(import_node_path4.default.dirname(import_node_path4.default.resolve(env.OPENLEARN_DB_PATH.trim())), "storage");
1197
+ return import_node_path5.default.join(import_node_path5.default.dirname(import_node_path5.default.resolve(env.OPENLEARN_DB_PATH.trim())), "storage");
1195
1198
  }
1196
- return import_node_path4.default.join(cwd, "storage");
1199
+ return import_node_path5.default.join(cwd, "storage");
1197
1200
  }
1198
1201
  function normalizeKeyHex(raw) {
1199
1202
  let value = raw.trim();
@@ -1214,7 +1217,7 @@ function readProcessEnvKey(env) {
1214
1217
  return normalizeKeyHex(raw);
1215
1218
  }
1216
1219
  function readDotEnvKey(cwd) {
1217
- const envPath = import_node_path4.default.resolve(cwd, ".env");
1220
+ const envPath = import_node_path5.default.resolve(cwd, ".env");
1218
1221
  if (!import_node_fs4.default.existsSync(envPath)) return null;
1219
1222
  let content = "";
1220
1223
  try {
@@ -1227,7 +1230,7 @@ function readDotEnvKey(cwd) {
1227
1230
  return normalizeKeyHex(match[1] ?? "");
1228
1231
  }
1229
1232
  function keyFilePath(storageDir) {
1230
- return import_node_path4.default.join(storageDir, ENCRYPTION_KEY_FILE_NAME);
1233
+ return import_node_path5.default.join(storageDir, ENCRYPTION_KEY_FILE_NAME);
1231
1234
  }
1232
1235
  function chmodKeyFile(filePath) {
1233
1236
  try {
@@ -1253,7 +1256,7 @@ function keysEqual(left, right) {
1253
1256
  return import_node_crypto.default.timingSafeEqual(Buffer.from(left, "hex"), Buffer.from(right, "hex"));
1254
1257
  }
1255
1258
  function persistGeneratedKey(filePath, generated) {
1256
- import_node_fs4.default.mkdirSync(import_node_path4.default.dirname(filePath), { recursive: true });
1259
+ import_node_fs4.default.mkdirSync(import_node_path5.default.dirname(filePath), { recursive: true });
1257
1260
  try {
1258
1261
  import_node_fs4.default.writeFileSync(filePath, `${generated}
1259
1262
  `, { mode: 384, flag: "wx" });
@@ -1400,13 +1403,13 @@ function encryptStoredPlaintextApiKeys(db2) {
1400
1403
  }
1401
1404
  return { updated, skipped };
1402
1405
  }
1403
- var import_node_crypto, import_node_fs4, import_node_path4, ENCRYPTION_KEY_FILE_NAME, AUTO_GENERATED_KEY_WARNING, HEX_64, EncryptionKeyInvalidError, EncryptionKeyMismatchError, cached;
1406
+ var import_node_crypto, import_node_fs4, import_node_path5, ENCRYPTION_KEY_FILE_NAME, AUTO_GENERATED_KEY_WARNING, HEX_64, EncryptionKeyInvalidError, EncryptionKeyMismatchError, cached;
1404
1407
  var init_api_key_crypto = __esm({
1405
1408
  "packages/core/di/api-key-crypto.ts"() {
1406
1409
  "use strict";
1407
1410
  import_node_crypto = __toESM(require("node:crypto"), 1);
1408
1411
  import_node_fs4 = __toESM(require("node:fs"), 1);
1409
- import_node_path4 = __toESM(require("node:path"), 1);
1412
+ import_node_path5 = __toESM(require("node:path"), 1);
1410
1413
  ENCRYPTION_KEY_FILE_NAME = "encryption-key";
1411
1414
  AUTO_GENERATED_KEY_WARNING = "\u6B63\u5728\u4F7F\u7528\u81EA\u52A8\u751F\u6210\u7684\u52A0\u5BC6\u5BC6\u94A5\uFF08storage/encryption-key\uFF0C\u4E0E bootstrap-credentials.txt \u540C\u76EE\u5F55\uFF0C\u6743\u9650 600\uFF09\u3002\u5EFA\u8BAE\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF ENCRYPTION_KEY \u4E3A\u8BE5\u6587\u4EF6\u4E2D\u7684 64 \u4F4D hex\uFF0C\u5E76\u79BB\u7EBF\u5907\u4EFD\u8BE5\u6587\u4EF6\u3002site-backup \u4E0D\u5305\u542B\u6B64\u6587\u4EF6\u3002\u4E22\u5931\u540E\u65E0\u6CD5\u89E3\u5BC6 AI Provider API Key \u4E0E\u73ED\u7EA7\u4E34\u65F6\u53E3\u4EE4\u3002";
1412
1415
  HEX_64 = /^[0-9a-fA-F]{64}$/;
@@ -1921,10 +1924,10 @@ function getMaxUncompressedSize() {
1921
1924
  }
1922
1925
  function isInsideRoot(root, target) {
1923
1926
  if (!root || !target) return false;
1924
- const rel = path8.relative(path8.resolve(root), path8.resolve(target));
1927
+ const rel = path9.relative(path9.resolve(root), path9.resolve(target));
1925
1928
  if (rel === "") return false;
1926
- if (path8.isAbsolute(rel)) return false;
1927
- return rel !== ".." && !rel.startsWith(".." + path8.sep);
1929
+ if (path9.isAbsolute(rel)) return false;
1930
+ return rel !== ".." && !rel.startsWith(".." + path9.sep);
1928
1931
  }
1929
1932
  function realPathOrNull(target) {
1930
1933
  try {
@@ -1934,7 +1937,7 @@ function realPathOrNull(target) {
1934
1937
  }
1935
1938
  }
1936
1939
  function realPathOrSelf(target) {
1937
- return realPathOrNull(target) ?? path8.resolve(target);
1940
+ return realPathOrNull(target) ?? path9.resolve(target);
1938
1941
  }
1939
1942
  function getMaxUploadBodyBytes() {
1940
1943
  return getMaxUncompressedSize();
@@ -1949,7 +1952,7 @@ function writeBufferToTempZip(buffer, prefix = "plugin-upload-") {
1949
1952
  if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
1950
1953
  throw new Error("[install-utils] \u7A7A\u7684 ZIP Buffer");
1951
1954
  }
1952
- const zipPath = path8.join(os2.tmpdir(), `${prefix}${(0, import_uuid6.v7)()}.zip`);
1955
+ const zipPath = path9.join(os2.tmpdir(), `${prefix}${(0, import_uuid6.v7)()}.zip`);
1953
1956
  fs7.writeFileSync(zipPath, buffer);
1954
1957
  return zipPath;
1955
1958
  }
@@ -2209,13 +2212,13 @@ async function bundlePlugin(entryCode, resolveDir, outfile) {
2209
2212
  const esbuild = await import("esbuild");
2210
2213
  const allowedRoots = [];
2211
2214
  const allowRoot = (dir) => {
2212
- const lexical = path8.resolve(dir);
2215
+ const lexical = path9.resolve(dir);
2213
2216
  if (allowedRoots.some((r) => r.lexical === lexical)) return;
2214
2217
  allowedRoots.push({ lexical, real: realPathOrSelf(lexical) });
2215
2218
  };
2216
2219
  allowRoot(resolveDir);
2217
2220
  const isPermittedPath = (target) => {
2218
- const resolved = path8.resolve(target);
2221
+ const resolved = path9.resolve(target);
2219
2222
  if (allowedRoots.some((r) => isInsideRoot(r.lexical, resolved))) return true;
2220
2223
  const real = realPathOrNull(resolved);
2221
2224
  if (!real) return false;
@@ -2242,14 +2245,14 @@ async function bundlePlugin(entryCode, resolveDir, outfile) {
2242
2245
  setup(build) {
2243
2246
  build.onResolve({ filter: /.*/ }, (args) => {
2244
2247
  if (args.path.includes("core/di/interfaces")) {
2245
- const tsPath = path8.resolve(process.cwd(), "packages/core/di/interfaces.ts");
2248
+ const tsPath = path9.resolve(process.cwd(), "packages/core/di/interfaces.ts");
2246
2249
  if (fs7.existsSync(tsPath)) {
2247
- allowRoot(path8.dirname(tsPath));
2250
+ allowRoot(path9.dirname(tsPath));
2248
2251
  return { path: tsPath };
2249
2252
  }
2250
- const jsPath = path8.resolve(process.cwd(), "packages/core/di/interfaces.js");
2253
+ const jsPath = path9.resolve(process.cwd(), "packages/core/di/interfaces.js");
2251
2254
  if (fs7.existsSync(jsPath)) {
2252
- allowRoot(path8.dirname(jsPath));
2255
+ allowRoot(path9.dirname(jsPath));
2253
2256
  return { path: jsPath };
2254
2257
  }
2255
2258
  }
@@ -2263,8 +2266,8 @@ async function bundlePlugin(entryCode, resolveDir, outfile) {
2263
2266
  };
2264
2267
  }
2265
2268
  if (args.path.startsWith(".")) {
2266
- const importerDir = args.resolveDir || path8.resolve(resolveDir);
2267
- const candidate = path8.resolve(importerDir, args.path);
2269
+ const importerDir = args.resolveDir || path9.resolve(resolveDir);
2270
+ const candidate = path9.resolve(importerDir, args.path);
2268
2271
  if (!isPermittedPath(candidate)) {
2269
2272
  return {
2270
2273
  errors: [
@@ -2316,8 +2319,8 @@ async function extractZipEntries(zip, destDir, maxTotalBytes) {
2316
2319
  let totalBytes = 0;
2317
2320
  for (const [name, file] of Object.entries(zip.files)) {
2318
2321
  if (file.dir) continue;
2319
- const filePath = path8.join(destDir, name);
2320
- fs7.mkdirSync(path8.dirname(filePath), { recursive: true });
2322
+ const filePath = path9.join(destDir, name);
2323
+ fs7.mkdirSync(path9.dirname(filePath), { recursive: true });
2321
2324
  const content = await file.async("nodebuffer");
2322
2325
  totalBytes += content.length;
2323
2326
  if (totalBytes > maxTotalBytes) {
@@ -2373,11 +2376,11 @@ async function validateAndBundleZip(zipPath) {
2373
2376
  throw new Error(`Entry file "${manifest.main}" specified in manifest not found in ZIP package`);
2374
2377
  }
2375
2378
  const entryCode = await entryFile.async("string");
2376
- const tmpDir = path8.join(os2.tmpdir(), `plugin-build-${(0, import_uuid6.v7)()}`);
2379
+ const tmpDir = path9.join(os2.tmpdir(), `plugin-build-${(0, import_uuid6.v7)()}`);
2377
2380
  try {
2378
2381
  fs7.mkdirSync(tmpDir, { recursive: true });
2379
2382
  await extractZipEntries(zip, tmpDir, getMaxUncompressedSize());
2380
- const bundledPath = path8.join(os2.tmpdir(), `plugin-bundle-${(0, import_uuid6.v7)()}.mjs`);
2383
+ const bundledPath = path9.join(os2.tmpdir(), `plugin-bundle-${(0, import_uuid6.v7)()}.mjs`);
2381
2384
  await bundlePlugin(entryCode, tmpDir, bundledPath);
2382
2385
  return { manifest, bundledPath, entryFileName: resolvedMain, package: zip };
2383
2386
  } finally {
@@ -2390,14 +2393,14 @@ async function validateAndBundleZip(zipPath) {
2390
2393
  async function extractManifestFromBundle(_bundledCode) {
2391
2394
  throw new Error("extractManifestFromBundle is not yet implemented");
2392
2395
  }
2393
- var import_jszip, fs7, os2, path8, import_uuid6, PluginCodeSecurityError, LEX_SCAN_LIMIT;
2396
+ var import_jszip, fs7, os2, path9, import_uuid6, PluginCodeSecurityError, LEX_SCAN_LIMIT;
2394
2397
  var init_install_utils = __esm({
2395
2398
  "packages/core/esm-loader/install-utils.ts"() {
2396
2399
  "use strict";
2397
2400
  import_jszip = __toESM(require("jszip"), 1);
2398
2401
  fs7 = __toESM(require("fs"), 1);
2399
2402
  os2 = __toESM(require("os"), 1);
2400
- path8 = __toESM(require("path"), 1);
2403
+ path9 = __toESM(require("path"), 1);
2401
2404
  import_uuid6 = require("uuid");
2402
2405
  init_manifest_schema();
2403
2406
  PluginCodeSecurityError = class extends Error {
@@ -2516,12 +2519,12 @@ function resolveAuditRotationConfig(env = process.env) {
2516
2519
  const maxFiles = Math.floor(readPositiveInt(env.AUDIT_LOG_MAX_FILES, 20, "AUDIT_LOG_MAX_FILES"));
2517
2520
  return { maxBytes: Math.floor(maxSizeMb * 1024 * 1024), maxFiles };
2518
2521
  }
2519
- var import_node_fs5, import_node_path5, import_node_stream, activeStreams, exitHookInstalled, RotatingFileStream;
2522
+ var import_node_fs5, import_node_path6, import_node_stream, activeStreams, exitHookInstalled, RotatingFileStream;
2520
2523
  var init_rotating_file_stream = __esm({
2521
2524
  "packages/core/observability/rotating-file-stream.ts"() {
2522
2525
  "use strict";
2523
2526
  import_node_fs5 = __toESM(require("node:fs"), 1);
2524
- import_node_path5 = __toESM(require("node:path"), 1);
2527
+ import_node_path6 = __toESM(require("node:path"), 1);
2525
2528
  import_node_stream = require("node:stream");
2526
2529
  activeStreams = /* @__PURE__ */ new Set();
2527
2530
  exitHookInstalled = false;
@@ -2535,7 +2538,7 @@ var init_rotating_file_stream = __esm({
2535
2538
  this.filePath = options.filePath;
2536
2539
  this.maxBytes = options.maxBytes;
2537
2540
  this.maxFiles = options.maxFiles;
2538
- this.dir = import_node_path5.default.dirname(this.filePath);
2541
+ this.dir = import_node_path6.default.dirname(this.filePath);
2539
2542
  }
2540
2543
  /** 当前活动文件路径(测试与排障用) */
2541
2544
  get activeFilePath() {
@@ -2640,21 +2643,21 @@ function consoleStream() {
2640
2643
  function createLogger(component) {
2641
2644
  return logger.child({ component });
2642
2645
  }
2643
- var import_pino, import_node_fs6, import_node_path6, defaultLevel, consoleLevel, fileLevel, rootLevel, logDir, logFile, streams, fileLogging, logger;
2646
+ var import_pino, import_node_fs6, import_node_path7, defaultLevel, consoleLevel, fileLevel, rootLevel, logDir, logFile, streams, fileLogging, logger;
2644
2647
  var init_logger = __esm({
2645
2648
  "packages/core/observability/logger.ts"() {
2646
2649
  "use strict";
2647
2650
  import_pino = __toESM(require("pino"), 1);
2648
2651
  import_node_fs6 = __toESM(require("node:fs"), 1);
2649
- import_node_path6 = __toESM(require("node:path"), 1);
2652
+ import_node_path7 = __toESM(require("node:path"), 1);
2650
2653
  init_redaction();
2651
2654
  init_rotating_file_stream();
2652
2655
  defaultLevel = process.env.LOG_LEVEL || (process.env.HTTP_ACCESS_LOG === "all" ? "debug" : process.env.NODE_ENV === "production" ? "info" : "debug");
2653
2656
  consoleLevel = process.env.LOG_CONSOLE_LEVEL || defaultLevel;
2654
2657
  fileLevel = defaultLevel;
2655
2658
  rootLevel = minPinoLevel(consoleLevel, fileLevel);
2656
- logDir = import_node_path6.default.resolve(process.env.LOG_DIR || import_node_path6.default.join(process.cwd(), "logs"));
2657
- logFile = import_node_path6.default.join(logDir, "openlearn.log");
2659
+ logDir = import_node_path7.default.resolve(process.env.LOG_DIR || import_node_path7.default.join(process.cwd(), "logs"));
2660
+ logFile = import_node_path7.default.join(logDir, "openlearn.log");
2658
2661
  streams = [
2659
2662
  { level: consoleLevel, stream: consoleStream() }
2660
2663
  ];
@@ -2787,7 +2790,7 @@ function buildAuditLogger() {
2787
2790
  if (process.env.NODE_ENV === "test" || process.env.VITEST) {
2788
2791
  return (0, import_pino2.default)({ level: "silent" }, import_pino2.default.destination(1));
2789
2792
  }
2790
- const logDir2 = import_node_path7.default.resolve(process.env.LOG_DIR || import_node_path7.default.join(process.cwd(), "logs"));
2793
+ const logDir2 = import_node_path8.default.resolve(process.env.LOG_DIR || import_node_path8.default.join(process.cwd(), "logs"));
2791
2794
  try {
2792
2795
  if (!import_node_fs7.default.existsSync(logDir2)) import_node_fs7.default.mkdirSync(logDir2, { recursive: true });
2793
2796
  import_node_fs7.default.accessSync(logDir2, import_node_fs7.default.constants.W_OK);
@@ -2795,7 +2798,7 @@ function buildAuditLogger() {
2795
2798
  streams2.push({
2796
2799
  level: "info",
2797
2800
  stream: new RotatingFileStream({
2798
- filePath: import_node_path7.default.join(logDir2, "audit.log"),
2801
+ filePath: import_node_path8.default.join(logDir2, "audit.log"),
2799
2802
  maxBytes: rotation.maxBytes,
2800
2803
  maxFiles: rotation.maxFiles
2801
2804
  })
@@ -2820,12 +2823,12 @@ function recordSecurityAudit(event, fields = {}) {
2820
2823
  }
2821
2824
  return record;
2822
2825
  }
2823
- var import_node_fs7, import_node_path7, import_pino2, testSink, auditLogger;
2826
+ var import_node_fs7, import_node_path8, import_pino2, testSink, auditLogger;
2824
2827
  var init_audit_log = __esm({
2825
2828
  "packages/core/observability/audit-log.ts"() {
2826
2829
  "use strict";
2827
2830
  import_node_fs7 = __toESM(require("node:fs"), 1);
2828
- import_node_path7 = __toESM(require("node:path"), 1);
2831
+ import_node_path8 = __toESM(require("node:path"), 1);
2829
2832
  import_pino2 = __toESM(require("pino"), 1);
2830
2833
  init_logger();
2831
2834
  init_request_context();
@@ -3575,6 +3578,19 @@ var init_errors2 = __esm({
3575
3578
  function scopedId(scopeType, scopeId, key) {
3576
3579
  return `${scopeType}\0${scopeId}\0${key}`;
3577
3580
  }
3581
+ function getSharedState(db2, manifestId) {
3582
+ let byManifest = sharedStates.get(db2);
3583
+ if (!byManifest) {
3584
+ byManifest = /* @__PURE__ */ new Map();
3585
+ sharedStates.set(db2, byManifest);
3586
+ }
3587
+ let state = byManifest.get(manifestId);
3588
+ if (!state) {
3589
+ state = { values: /* @__PURE__ */ new Map(), instances: /* @__PURE__ */ new Set() };
3590
+ byManifest.set(manifestId, state);
3591
+ }
3592
+ return state;
3593
+ }
3578
3594
  function normalizeWriteScope(scope) {
3579
3595
  const scopeType = scope?.scopeType ?? "platform";
3580
3596
  const scopeId = scope?.scopeId?.trim() ?? "";
@@ -3583,21 +3599,29 @@ function normalizeWriteScope(scope) {
3583
3599
  }
3584
3600
  return { scopeType, scopeId: scopeType === "platform" ? "" : scopeId };
3585
3601
  }
3586
- var CONFIG_SCOPE_TYPES, ConfigService;
3602
+ var CONFIG_SCOPE_TYPES, sharedStates, instanceCleanup, ConfigService;
3587
3603
  var init_config_service = __esm({
3588
3604
  "packages/core/plugin-host/config-service.ts"() {
3589
3605
  "use strict";
3590
3606
  CONFIG_SCOPE_TYPES = ["platform", "school", "class"];
3607
+ sharedStates = /* @__PURE__ */ new WeakMap();
3608
+ instanceCleanup = new FinalizationRegistry(
3609
+ ({ state, ref }) => {
3610
+ state.instances.delete(ref);
3611
+ }
3612
+ );
3591
3613
  ConfigService = class _ConfigService {
3592
3614
  constructor(db2, manifest) {
3593
- /** In-memory cache (scoped key → value). `null` 表示「明确没有值」,避免反复查询。 */
3594
- this.values = /* @__PURE__ */ new Map();
3595
- /** Change listeners. */
3615
+ /** Change listeners(每个实例独立,广播时按实例分发)。 */
3596
3616
  this.listeners = /* @__PURE__ */ new Set();
3597
3617
  this.tableEnsured = false;
3598
3618
  this.db = db2;
3599
3619
  this.manifestId = manifest.id;
3600
3620
  this.schema = manifest.configuration?.properties ?? {};
3621
+ this.state = getSharedState(db2, manifest.id);
3622
+ const ref = new WeakRef(this);
3623
+ this.state.instances.add(ref);
3624
+ instanceCleanup.register(this, { state: this.state, ref });
3601
3625
  }
3602
3626
  static {
3603
3627
  this.KEY_PREFIX = "config:";
@@ -3660,21 +3684,21 @@ var init_config_service = __esm({
3660
3684
  }
3661
3685
  readScoped(key, scopeType, scopeId) {
3662
3686
  const cacheKey2 = scopedId(scopeType, scopeId, key);
3663
- if (this.values.has(cacheKey2)) {
3664
- const cached3 = this.values.get(cacheKey2);
3687
+ if (this.state.values.has(cacheKey2)) {
3688
+ const cached3 = this.state.values.get(cacheKey2);
3665
3689
  return cached3 === null ? { found: false } : { found: true, value: cached3 };
3666
3690
  }
3667
3691
  const row = this.queryScoped(key, scopeType, scopeId);
3668
3692
  if (!row) {
3669
- this.values.set(cacheKey2, null);
3693
+ this.state.values.set(cacheKey2, null);
3670
3694
  return { found: false };
3671
3695
  }
3672
3696
  try {
3673
3697
  const parsed = JSON.parse(row.value);
3674
- this.values.set(cacheKey2, parsed);
3698
+ this.state.values.set(cacheKey2, parsed);
3675
3699
  return { found: true, value: parsed };
3676
3700
  } catch {
3677
- this.values.set(cacheKey2, row.value);
3701
+ this.state.values.set(cacheKey2, row.value);
3678
3702
  return { found: true, value: row.value };
3679
3703
  }
3680
3704
  }
@@ -3690,21 +3714,21 @@ var init_config_service = __esm({
3690
3714
  }
3691
3715
  readLegacyPlatformValue(key) {
3692
3716
  const cacheKey2 = scopedId("platform", "", `legacy:${key}`);
3693
- if (this.values.has(cacheKey2)) {
3694
- const cached3 = this.values.get(cacheKey2);
3717
+ if (this.state.values.has(cacheKey2)) {
3718
+ const cached3 = this.state.values.get(cacheKey2);
3695
3719
  return cached3 === null ? { found: false } : { found: true, value: cached3 };
3696
3720
  }
3697
3721
  try {
3698
3722
  const row = this.db.prepare("SELECT value FROM plugin_storage WHERE plugin_id = ? AND key = ?").get(this.manifestId, _ConfigService.KEY_PREFIX + key);
3699
3723
  if (!row) {
3700
- this.values.set(cacheKey2, null);
3724
+ this.state.values.set(cacheKey2, null);
3701
3725
  return { found: false };
3702
3726
  }
3703
3727
  const parsed = JSON.parse(row.value);
3704
- this.values.set(cacheKey2, parsed);
3728
+ this.state.values.set(cacheKey2, parsed);
3705
3729
  return { found: true, value: parsed };
3706
3730
  } catch {
3707
- this.values.set(cacheKey2, null);
3731
+ this.state.values.set(cacheKey2, null);
3708
3732
  return { found: false };
3709
3733
  }
3710
3734
  }
@@ -3760,8 +3784,8 @@ var init_config_service = __esm({
3760
3784
  const { scopeType, scopeId } = normalizeWriteScope(scope);
3761
3785
  this.assertScopeAllowed(key, scopeType);
3762
3786
  const cacheKey2 = scopedId(scopeType, scopeId, key);
3763
- const previous = this.values.get(cacheKey2);
3764
- this.values.set(cacheKey2, value);
3787
+ const previous = this.state.values.get(cacheKey2);
3788
+ this.state.values.set(cacheKey2, value);
3765
3789
  try {
3766
3790
  this.ensureTable();
3767
3791
  this.db.prepare(
@@ -3772,16 +3796,32 @@ var init_config_service = __esm({
3772
3796
  ).run(this.manifestId, scopeType, scopeId, key, JSON.stringify(value), Date.now());
3773
3797
  } catch (e) {
3774
3798
  if (previous === void 0) {
3775
- this.values.delete(cacheKey2);
3799
+ this.state.values.delete(cacheKey2);
3776
3800
  } else {
3777
- this.values.set(cacheKey2, previous);
3801
+ this.state.values.set(cacheKey2, previous);
3778
3802
  }
3779
3803
  throw e;
3780
3804
  }
3781
- for (const listener of this.listeners) {
3782
- try {
3783
- listener(key, value, previous === null ? void 0 : previous);
3784
- } catch {
3805
+ this.broadcast(key, value, previous ?? void 0);
3806
+ }
3807
+ /**
3808
+ * 通知所有共享同一 db + manifest.id 的活跃实例(含自身)。
3809
+ *
3810
+ * 管理端通过临时实例写入时,运行中插件的 `onChange` 也因此能收到。
3811
+ * 已回收的实例在遍历时顺手清理。
3812
+ */
3813
+ broadcast(key, newValue, oldValue) {
3814
+ for (const ref of this.state.instances) {
3815
+ const instance = ref.deref();
3816
+ if (!instance) {
3817
+ this.state.instances.delete(ref);
3818
+ continue;
3819
+ }
3820
+ for (const listener of instance.listeners) {
3821
+ try {
3822
+ listener(key, newValue, oldValue);
3823
+ } catch {
3824
+ }
3785
3825
  }
3786
3826
  }
3787
3827
  }
@@ -3814,11 +3854,12 @@ var init_config_service = __esm({
3814
3854
  * 插件在 `activate()` 里通常会马上读配置,提前填充避免每条配置一次查询。
3815
3855
  */
3816
3856
  loadFromDB() {
3857
+ this.state.values.clear();
3817
3858
  try {
3818
3859
  const rows = this.db.prepare(`SELECT scope_type, scope_id, key, value FROM ${_ConfigService.TABLE} WHERE plugin_id = ?`).all(this.manifestId);
3819
3860
  for (const row of rows) {
3820
3861
  const scopeType = CONFIG_SCOPE_TYPES.includes(row.scope_type) ? row.scope_type : "platform";
3821
- this.values.set(scopedId(scopeType, row.scope_id ?? "", row.key), this.parseStored(row.value));
3862
+ this.state.values.set(scopedId(scopeType, row.scope_id ?? "", row.key), this.parseStored(row.value));
3822
3863
  }
3823
3864
  } catch {
3824
3865
  }
@@ -3827,7 +3868,7 @@ var init_config_service = __esm({
3827
3868
  const rows = this.db.prepare(`SELECT key, value FROM plugin_storage WHERE plugin_id = ? AND key LIKE ?`).all(this.manifestId, prefix + "%");
3828
3869
  for (const row of rows) {
3829
3870
  const key = row.key.slice(prefix.length);
3830
- this.values.set(scopedId("platform", "", `legacy:${key}`), this.parseStored(row.value));
3871
+ this.state.values.set(scopedId("platform", "", `legacy:${key}`), this.parseStored(row.value));
3831
3872
  }
3832
3873
  } catch {
3833
3874
  }
@@ -3839,9 +3880,9 @@ var init_config_service = __esm({
3839
3880
  return raw;
3840
3881
  }
3841
3882
  }
3842
- /** 测试/诊断:当前缓存里的作用域条目数。 */
3883
+ /** 测试/诊断:当前共享缓存里的作用域条目数。 */
3843
3884
  cachedEntryCount() {
3844
- return this.values.size;
3885
+ return this.state.values.size;
3845
3886
  }
3846
3887
  };
3847
3888
  }
@@ -3875,9 +3916,9 @@ var init_http_router = __esm({
3875
3916
  /**
3876
3917
  * 注册指定动词与路径的 Handler
3877
3918
  */
3878
- route(method, path46, handler, options = {}) {
3919
+ route(method, path47, handler, options = {}) {
3879
3920
  const upperMethod = method.toUpperCase();
3880
- const normalized = path46.startsWith("/") ? path46 : "/" + path46;
3921
+ const normalized = path47.startsWith("/") ? path47 : "/" + path47;
3881
3922
  const { regex, paramNames } = compileRoutePattern(normalized);
3882
3923
  this.warnIfUnscoped(upperMethod, normalized, options);
3883
3924
  this.routes.push({
@@ -3890,20 +3931,20 @@ var init_http_router = __esm({
3890
3931
  scope: options.scope
3891
3932
  });
3892
3933
  }
3893
- get(path46, handler, options) {
3894
- this.route("GET", path46, handler, options);
3934
+ get(path47, handler, options) {
3935
+ this.route("GET", path47, handler, options);
3895
3936
  }
3896
- post(path46, handler, options) {
3897
- this.route("POST", path46, handler, options);
3937
+ post(path47, handler, options) {
3938
+ this.route("POST", path47, handler, options);
3898
3939
  }
3899
- put(path46, handler, options) {
3900
- this.route("PUT", path46, handler, options);
3940
+ put(path47, handler, options) {
3941
+ this.route("PUT", path47, handler, options);
3901
3942
  }
3902
- patch(path46, handler, options) {
3903
- this.route("PATCH", path46, handler, options);
3943
+ patch(path47, handler, options) {
3944
+ this.route("PATCH", path47, handler, options);
3904
3945
  }
3905
- delete(path46, handler, options) {
3906
- this.route("DELETE", path46, handler, options);
3946
+ delete(path47, handler, options) {
3947
+ this.route("DELETE", path47, handler, options);
3907
3948
  }
3908
3949
  warnIfUnscoped(method, pattern, options) {
3909
3950
  if (options.scope) return;
@@ -3918,19 +3959,19 @@ var init_http_router = __esm({
3918
3959
  }
3919
3960
  stream(methodOrPath, pathOrHandler, maybeHandler) {
3920
3961
  if (typeof pathOrHandler === "function") {
3921
- const path46 = methodOrPath;
3962
+ const path47 = methodOrPath;
3922
3963
  const handler = pathOrHandler;
3923
- this.registerStream("GET", path46, handler);
3924
- this.registerStream("POST", path46, handler);
3964
+ this.registerStream("GET", path47, handler);
3965
+ this.registerStream("POST", path47, handler);
3925
3966
  } else {
3926
3967
  const method = methodOrPath.toUpperCase();
3927
- const path46 = pathOrHandler;
3968
+ const path47 = pathOrHandler;
3928
3969
  const handler = maybeHandler;
3929
- this.registerStream(method, path46, handler);
3970
+ this.registerStream(method, path47, handler);
3930
3971
  }
3931
3972
  }
3932
- registerStream(method, path46, handler) {
3933
- const normalized = path46.startsWith("/") ? path46 : "/" + path46;
3973
+ registerStream(method, path47, handler) {
3974
+ const normalized = path47.startsWith("/") ? path47 : "/" + path47;
3934
3975
  const { regex, paramNames } = compileRoutePattern(normalized);
3935
3976
  this.routes.push({
3936
3977
  method: method.toUpperCase(),
@@ -3944,9 +3985,9 @@ var init_http_router = __esm({
3944
3985
  /**
3945
3986
  * 检查指定动词与路径是否为流式路由
3946
3987
  */
3947
- isStream(method, path46) {
3988
+ isStream(method, path47) {
3948
3989
  const upperMethod = method.toUpperCase();
3949
- const normalized = path46.startsWith("/") ? path46 : "/" + path46;
3990
+ const normalized = path47.startsWith("/") ? path47 : "/" + path47;
3950
3991
  for (const entry of this.routes) {
3951
3992
  if (entry.method !== upperMethod) continue;
3952
3993
  if (entry.regex.test(normalized)) {
@@ -3958,9 +3999,9 @@ var init_http_router = __esm({
3958
3999
  /**
3959
4000
  * 匹配路由并提取路由参数
3960
4001
  */
3961
- match(method, path46) {
4002
+ match(method, path47) {
3962
4003
  const upperMethod = method.toUpperCase();
3963
- const normalized = path46.startsWith("/") ? path46 : "/" + path46;
4004
+ const normalized = path47.startsWith("/") ? path47 : "/" + path47;
3964
4005
  for (const entry of this.routes) {
3965
4006
  if (entry.method !== upperMethod) continue;
3966
4007
  const match = normalized.match(entry.regex);
@@ -4103,13 +4144,13 @@ function checkMethodPolicy(input) {
4103
4144
  if (tokens.frontendApi && token === tokens.frontendApi) {
4104
4145
  if (isReadOnlyMethod(method)) return null;
4105
4146
  if (method === "request" && !Array.isArray(args)) return null;
4106
- const path46 = extractPathArg(args);
4107
- const isHighRisk = HIGH_RISK_PATH_PREFIXES.some((p) => path46 === p || path46.startsWith(`${p}/`));
4108
- const isIrreversible = IRREVERSIBLE_PATH_SUFFIXES.some((s) => path46.endsWith(s));
4109
- const isEntityDelete = method === "del" && ENTITY_DELETE_RE.test(path46);
4147
+ const path47 = extractPathArg(args);
4148
+ const isHighRisk = HIGH_RISK_PATH_PREFIXES.some((p) => path47 === p || path47.startsWith(`${p}/`));
4149
+ const isIrreversible = IRREVERSIBLE_PATH_SUFFIXES.some((s) => path47.endsWith(s));
4150
+ const isEntityDelete = method === "del" && ENTITY_DELETE_RE.test(path47);
4110
4151
  if (isHighRisk || isIrreversible || isEntityDelete) {
4111
4152
  if (hasCapability(caps, CAP_API_ADMIN)) return null;
4112
- const why = isIrreversible ? `\u4E0D\u53EF\u9006\u64CD\u4F5C '${path46}'` : isEntityDelete ? `\u5B9E\u4F53\u7EA7\u5220\u9664 '${path46}'` : `\u5199\u5165\u9AD8\u5371\u8DEF\u5F84 '${path46 || "<\u672A\u63D0\u4F9B\u8DEF\u5F84>"}'`;
4153
+ const why = isIrreversible ? `\u4E0D\u53EF\u9006\u64CD\u4F5C '${path47}'` : isEntityDelete ? `\u5B9E\u4F53\u7EA7\u5220\u9664 '${path47}'` : `\u5199\u5165\u9AD8\u5371\u8DEF\u5F84 '${path47 || "<\u672A\u63D0\u4F9B\u8DEF\u5F84>"}'`;
4113
4154
  return `${why} \u9700\u8981 manifest \u58F0\u660E '${CAP_API_ADMIN}' \u80FD\u529B`;
4114
4155
  }
4115
4156
  return hasCapability(caps, CAP_API_WRITE) ? null : `\u5199\u64CD\u4F5C\uFF08${method}\uFF09\u9700\u8981 manifest \u58F0\u660E '${CAP_API_WRITE}' \u80FD\u529B\uFF08\u666E\u901A\u53EA\u8BFB\u8C03\u7528\u8BF7\u7528 get\uFF09`;
@@ -4609,6 +4650,53 @@ function wrapPointsDimensionRegistry(registry4, tracker, pluginId) {
4609
4650
  })
4610
4651
  };
4611
4652
  }
4653
+ function wrapClassroomLifecycleService(service, tracker, pluginId) {
4654
+ return {
4655
+ getStage: createSafeFunction((lessonId) => service.getStage(lessonId)),
4656
+ transitionStage: createSafeFunction(
4657
+ (lessonId, toStage, actorId, classId) => service.transitionStage(lessonId, toStage, actorId, classId)
4658
+ ),
4659
+ registerStageGuard: createSafeFunction((owner, guard) => {
4660
+ service.registerStageGuard(owner, guard);
4661
+ tracker.track(pluginId, {
4662
+ dispose: () => {
4663
+ service.unregisterStageGuard(owner);
4664
+ }
4665
+ });
4666
+ }),
4667
+ unregisterStageGuard: createSafeFunction((owner) => {
4668
+ service.unregisterStageGuard(owner);
4669
+ })
4670
+ };
4671
+ }
4672
+ function wrapInteractionRuntimeService(service, tracker, pluginId) {
4673
+ return {
4674
+ registerActivityProvider: createSafeFunction((owner, descriptor) => {
4675
+ service.registerActivityProvider(owner, descriptor);
4676
+ const providerId = descriptor?.id;
4677
+ if (providerId) {
4678
+ tracker.track(pluginId, {
4679
+ dispose: () => {
4680
+ service.unregisterActivityProvider(owner, providerId);
4681
+ }
4682
+ });
4683
+ }
4684
+ }),
4685
+ unregisterActivityProvider: createSafeFunction((owner, id) => {
4686
+ service.unregisterActivityProvider(owner, id);
4687
+ }),
4688
+ listActivityProviders: createSafeFunction(() => service.listActivityProviders())
4689
+ };
4690
+ }
4691
+ function wrapPluginRuntimeService(raw, tokenName, tracker, pluginId) {
4692
+ if (tokenName === IClassroomLifecycleServiceToken.name) {
4693
+ return wrapClassroomLifecycleService(raw, tracker, pluginId);
4694
+ }
4695
+ if (tokenName === IInteractionRuntimeServiceToken.name) {
4696
+ return wrapInteractionRuntimeService(raw, tracker, pluginId);
4697
+ }
4698
+ return raw;
4699
+ }
4612
4700
  function wrapPointsLedgerService(service, pluginId, capabilities) {
4613
4701
  const auditAccess = (studentId, method, operation, requiredCap) => {
4614
4702
  const hasCap = hasCapability(capabilities ?? [], requiredCap);
@@ -4903,9 +4991,9 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
4903
4991
  `[PluginHost] Plugin "${manifest.id}" cannot resolve "${token}" by name. Use the Token object imported from @openlearn/plugin-sdk, or one of the SDK-exported Token names.`
4904
4992
  );
4905
4993
  }
4906
- return serviceRegistry.resolveByName(token);
4994
+ return serviceRegistry.resolveByName(token).then((raw) => wrapPluginRuntimeService(raw, token, tracker, pluginId));
4907
4995
  }
4908
- return serviceRegistry.resolve(token);
4996
+ return serviceRegistry.resolve(token).then((raw) => wrapPluginRuntimeService(raw, token.name, tracker, pluginId));
4909
4997
  },
4910
4998
  // V3.2: 插件向 DI 容器提供服务(对应 manifest.provides)
4911
4999
  provide: async (token, instance) => {
@@ -5740,7 +5828,7 @@ function isPidAlive(pid) {
5740
5828
  }
5741
5829
  }
5742
5830
  function writeBootstrapTempFile(code) {
5743
- const file = import_node_path10.default.join(
5831
+ const file = import_node_path11.default.join(
5744
5832
  import_node_os.default.tmpdir(),
5745
5833
  `${BOOTSTRAP_PREFIX}${process.pid}-${import_node_crypto7.default.randomBytes(8).toString("hex")}${BOOTSTRAP_SUFFIX}`
5746
5834
  );
@@ -5754,7 +5842,7 @@ function cleanupStaleBootstrapFiles(now = Date.now()) {
5754
5842
  let cleaned = 0;
5755
5843
  for (const file of files) {
5756
5844
  if (!file.startsWith(BOOTSTRAP_PREFIX) || !file.endsWith(BOOTSTRAP_SUFFIX)) continue;
5757
- const full = import_node_path10.default.join(tmpDir, file);
5845
+ const full = import_node_path11.default.join(tmpDir, file);
5758
5846
  const ownerPid = bootstrapOwnerPid(file);
5759
5847
  if (ownerPid === null) {
5760
5848
  try {
@@ -5779,9 +5867,9 @@ function cleanupStaleBootstrapFiles(now = Date.now()) {
5779
5867
  function defaultNetPreloadStartDirs() {
5780
5868
  const dirs = [];
5781
5869
  try {
5782
- const url = import_meta5.url;
5870
+ const url = import_meta6.url;
5783
5871
  if (typeof url === "string" && url.startsWith("file:")) {
5784
- dirs.push(import_node_path10.default.dirname((0, import_node_url3.fileURLToPath)(url)));
5872
+ dirs.push(import_node_path11.default.dirname((0, import_node_url3.fileURLToPath)(url)));
5785
5873
  }
5786
5874
  } catch {
5787
5875
  }
@@ -5793,11 +5881,11 @@ function defaultNetPreloadStartDirs() {
5793
5881
  function resolveNetPreloadPath(options = {}) {
5794
5882
  const cwd = options.cwd ?? process.cwd();
5795
5883
  const candidates = [
5796
- import_node_path10.default.resolve(cwd, "packages/core/worker-runtime/plugin-net-preload.cjs"),
5797
- import_node_path10.default.resolve(cwd, "dist/plugin-net-preload.cjs")
5884
+ import_node_path11.default.resolve(cwd, "packages/core/worker-runtime/plugin-net-preload.cjs"),
5885
+ import_node_path11.default.resolve(cwd, "dist/plugin-net-preload.cjs")
5798
5886
  ];
5799
5887
  for (const dir of options.startDirs ?? defaultNetPreloadStartDirs()) {
5800
- candidates.unshift(import_node_path10.default.join(dir, "plugin-net-preload.cjs"));
5888
+ candidates.unshift(import_node_path11.default.join(dir, "plugin-net-preload.cjs"));
5801
5889
  }
5802
5890
  const found = candidates.find((candidate) => import_node_fs9.default.existsSync(candidate));
5803
5891
  if (!found) {
@@ -5805,7 +5893,7 @@ function resolveNetPreloadPath(options = {}) {
5805
5893
  }
5806
5894
  return found;
5807
5895
  }
5808
- var import_node_child_process, import_node_crypto7, import_node_fs9, import_node_os, import_node_path10, import_node_url3, import_meta5, ENV_ALLOWLIST, ENV_DENYLIST, WORKER_DATA_ENV, BOOTSTRAP_PREFIX, BOOTSTRAP_SUFFIX, LEGACY_BOOTSTRAP_STALE_MS;
5896
+ var import_node_child_process, import_node_crypto7, import_node_fs9, import_node_os, import_node_path11, import_node_url3, import_meta6, ENV_ALLOWLIST, ENV_DENYLIST, WORKER_DATA_ENV, BOOTSTRAP_PREFIX, BOOTSTRAP_SUFFIX, LEGACY_BOOTSTRAP_STALE_MS;
5809
5897
  var init_child_spawn = __esm({
5810
5898
  "packages/core/worker-runtime/child-spawn.ts"() {
5811
5899
  "use strict";
@@ -5813,13 +5901,13 @@ var init_child_spawn = __esm({
5813
5901
  import_node_crypto7 = __toESM(require("node:crypto"), 1);
5814
5902
  import_node_fs9 = __toESM(require("node:fs"), 1);
5815
5903
  import_node_os = __toESM(require("node:os"), 1);
5816
- import_node_path10 = __toESM(require("node:path"), 1);
5904
+ import_node_path11 = __toESM(require("node:path"), 1);
5817
5905
  import_node_url3 = require("node:url");
5818
5906
  init_child_transport();
5819
5907
  init_child_isolate();
5820
5908
  init_plugin_permission();
5821
5909
  init_plugin_limits();
5822
- import_meta5 = {};
5910
+ import_meta6 = {};
5823
5911
  ENV_ALLOWLIST = [
5824
5912
  "PATH",
5825
5913
  "LANG",
@@ -8923,9 +9011,9 @@ __export(worker_manager_exports, {
8923
9011
  function collectSharedReadPaths(pluginDir) {
8924
9012
  if (!pluginDir) return [];
8925
9013
  const paths = /* @__PURE__ */ new Set();
8926
- const nodeModulesDir = import_node_path11.default.join(import_node_path11.default.dirname(pluginDir), "node_modules");
9014
+ const nodeModulesDir = import_node_path12.default.join(import_node_path12.default.dirname(pluginDir), "node_modules");
8927
9015
  paths.add(nodeModulesDir);
8928
- const sdkLink = import_node_path11.default.join(nodeModulesDir, "@openlearn", "plugin-sdk");
9016
+ const sdkLink = import_node_path12.default.join(nodeModulesDir, "@openlearn", "plugin-sdk");
8929
9017
  try {
8930
9018
  if (import_fs7.default.existsSync(sdkLink)) paths.add(import_fs7.default.realpathSync(sdkLink));
8931
9019
  } catch {
@@ -8964,7 +9052,7 @@ function generateBootstrapCode() {
8964
9052
  const rootPath = process.cwd().replace(/\\/g, "/");
8965
9053
  return composeBootstrapCode(`${rootPath}/package.json`);
8966
9054
  }
8967
- var import_node_worker_threads, import_fs7, import_node_path11, import_uuid12, BASE_WORKER_SERVICE_TOKENS, ALL_SERVICE_TOKENS, POINTS_SERVICE_TOKENS, MAX_WORKERS, ACTIVATE_TIMEOUT_MS, ACTIVATE_PROGRESS_SLIDE_MS, WorkerRegistry, WorkerManager;
9055
+ var import_node_worker_threads, import_fs7, import_node_path12, import_uuid12, BASE_WORKER_SERVICE_TOKENS, ALL_SERVICE_TOKENS, POINTS_SERVICE_TOKENS, MAX_WORKERS, ACTIVATE_TIMEOUT_MS, ACTIVATE_PROGRESS_SLIDE_MS, WorkerRegistry, WorkerManager;
8968
9056
  var init_worker_manager = __esm({
8969
9057
  "packages/core/worker-runtime/worker-manager.ts"() {
8970
9058
  "use strict";
@@ -8978,7 +9066,7 @@ var init_worker_manager = __esm({
8978
9066
  init_plugin_limits();
8979
9067
  init_thread_isolate();
8980
9068
  import_fs7 = __toESM(require("fs"), 1);
8981
- import_node_path11 = __toESM(require("node:path"), 1);
9069
+ import_node_path12 = __toESM(require("node:path"), 1);
8982
9070
  init_transport();
8983
9071
  init_plugin_net_proxy();
8984
9072
  init_service_host();
@@ -9383,7 +9471,7 @@ var init_worker_manager = __esm({
9383
9471
  const encodedBootstrap = Buffer.from(bootstrapCode, "utf-8").toString("base64");
9384
9472
  const bootstrapDataUrl = `data:text/javascript;base64,${encodedBootstrap}`;
9385
9473
  const allowedTokens = computeAllowedWorkerTokens(manifest, serviceTokens);
9386
- const resolvedPluginDir = pluginDir && import_fs7.default.existsSync(import_node_path11.default.join(pluginDir, "index.js")) ? pluginDir : void 0;
9474
+ const resolvedPluginDir = pluginDir && import_fs7.default.existsSync(import_node_path12.default.join(pluginDir, "index.js")) ? pluginDir : void 0;
9387
9475
  let approvedNetHosts = [];
9388
9476
  try {
9389
9477
  approvedNetHosts = listApprovedNetHosts(this.db, [pluginId, manifest.id]);
@@ -9755,6 +9843,193 @@ __export(server_exports, {
9755
9843
  startServer: () => startServer
9756
9844
  });
9757
9845
  module.exports = __toCommonJS(server_exports);
9846
+
9847
+ // server/security/runtime-check.ts
9848
+ var import_node_path = __toESM(require("node:path"), 1);
9849
+ var import_node_module = require("node:module");
9850
+
9851
+ // scripts/assert-node-runtime.mjs
9852
+ var SUPPORTED_NODE_MAJORS = Object.freeze([20, 22, 24]);
9853
+ var SUPPORTED_NODE_TEXT = "Node.js 20.x / 22.x / 24.x";
9854
+ var PINNED_NODE_MAJOR = 22;
9855
+ function parseNodeMajor(version) {
9856
+ if (typeof version !== "string") return null;
9857
+ const matched = /^v?(\d+)\./.exec(version.trim());
9858
+ if (!matched) return null;
9859
+ const major = Number(matched[1]);
9860
+ return Number.isFinite(major) ? major : null;
9861
+ }
9862
+ function checkNodeVersion(version, supported = SUPPORTED_NODE_MAJORS) {
9863
+ const major = parseNodeMajor(version);
9864
+ if (major === null) {
9865
+ return {
9866
+ ok: false,
9867
+ code: "unparseable",
9868
+ major: null,
9869
+ message: `\u65E0\u6CD5\u89E3\u6790 Node \u7248\u672C\u300C${version}\u300D\u3002`
9870
+ };
9871
+ }
9872
+ if (supported.includes(major)) {
9873
+ return { ok: true, code: "supported", major, message: "" };
9874
+ }
9875
+ return {
9876
+ ok: false,
9877
+ code: "unsupported",
9878
+ major,
9879
+ message: `\u5F53\u524D Node \u7248\u672C v${major}.x \u4E0D\u53D7\u652F\u6301\uFF08\u652F\u6301\u8303\u56F4\uFF1A${SUPPORTED_NODE_TEXT}\uFF09\u3002\u672C\u9879\u76EE\u7684\u539F\u751F\u4F9D\u8D56\uFF08better-sqlite3\uFF09\u6309 Node ABI \u7F16\u8BD1\uFF0C\u590D\u7528\u65E7 node_modules \u4F1A\u5728\u8FD0\u884C\u671F\u62A5 ERR_DLOPEN_FAILED / NODE_MODULE_VERSION \u4E0D\u4E00\u81F4\u3002`
9880
+ };
9881
+ }
9882
+ function buildFixGuidance(major) {
9883
+ const pinned = PINNED_NODE_MAJOR;
9884
+ const lines = [
9885
+ "\u600E\u4E48\u4FEE\uFF08\u4EFB\u9009\u5176\u4E00\uFF09\uFF1A",
9886
+ ` 1) \u5207\u5230\u53D7\u652F\u6301\u7684 Node \u7248\u672C\uFF08\u672C\u9879\u76EE\u5B9E\u6D4B ${SUPPORTED_NODE_MAJORS.join(" / ")}\uFF09\uFF1A`,
9887
+ ` nvm install ${pinned} && nvm use ${pinned}`,
9888
+ ` fnm install ${pinned} && fnm use ${pinned}`,
9889
+ ` asdf install nodejs ${pinned}.16.0 && asdf local nodejs ${pinned}.16.0`,
9890
+ " \u4ED3\u5E93\u6839\u7684 .nvmrc / .node-version \u5DF2\u9489\u4F4F " + pinned + "\uFF0Cnvm / fnm \u4F1A\u81EA\u52A8\u91C7\u7528\u3002",
9891
+ " 2) \u7528\u5B98\u65B9\u5BB9\u5668\u955C\u50CF\uFF08\u5185\u542B Node " + pinned + "\uFF0C\u5BBF\u4E3B\u673A\u65E0\u9700\u88C5 Node\uFF09\uFF1A",
9892
+ " docker compose up -d",
9893
+ " 3) \u786E\u5B9E\u8981\u7528\u522B\u7684 Node \u7248\u672C\uFF1A\u5FC5\u987B\u8BA9\u539F\u751F\u6A21\u5757\u6309\u65B0 ABI \u91CD\u65B0\u7F16\u8BD1 \u2014\u2014",
9894
+ " \u4FDD\u7559\u65E7 node_modules \u53EA\u4F1A\u7EE7\u7EED\u590D\u7528 ABI \u4E0D\u5339\u914D\u7684\u4E8C\u8FDB\u5236\uFF1A",
9895
+ " rm -rf node_modules && pnpm install"
9896
+ ];
9897
+ if (major !== null && major === 21) {
9898
+ lines.push(
9899
+ " \u6CE8\uFF1ANode 21 \u4E0D\u662F LTS\uFF0C\u4E14 better-sqlite3 \u5B98\u65B9\u4E0D\u63D0\u4F9B 21.x \u9884\u7F16\u8BD1\u4EA7\u7269\uFF0C",
9900
+ " \u91CD\u88C5\u540E\u4ECD\u9700\u8981\u672C\u5730\u7F16\u8BD1\u5DE5\u5177\u94FE\uFF08python3 + make + g++\uFF09\uFF0C\u4E0D\u5EFA\u8BAE\u7528\u4E8E\u751F\u4EA7\u3002"
9901
+ );
9902
+ }
9903
+ return lines.join("\n");
9904
+ }
9905
+ function formatUnsupportedMessage(version, check = checkNodeVersion(version)) {
9906
+ if (check.ok) return "";
9907
+ const head = `[NodeRuntime] FATAL: ${check.message}`;
9908
+ return `${head}
9909
+
9910
+ ${buildFixGuidance(check.major)}
9911
+ `;
9912
+ }
9913
+ function runCliGuard() {
9914
+ const result = checkNodeVersion(process.version);
9915
+ if (result.ok) return;
9916
+ console.error(formatUnsupportedMessage(process.version, result));
9917
+ process.exit(1);
9918
+ }
9919
+ var invokedDirectly = process.argv[1] && (process.argv[1].endsWith("assert-node-runtime.mjs") || process.argv[1].endsWith("assert-node-runtime") || process.argv[1].includes("assert-node-runtime"));
9920
+ if (invokedDirectly) {
9921
+ runCliGuard();
9922
+ }
9923
+
9924
+ // server/security/runtime-check.ts
9925
+ var import_meta = {};
9926
+ var ABI_PATTERN = /NODE_MODULE_VERSION\s+(\d+)/i;
9927
+ var MISSING_BINDINGS_MARKERS = ["Could not locate the bindings file", "bindings file"];
9928
+ function detectNativeModuleFailure(error) {
9929
+ const runtimeAbi = Number(process.versions.modules);
9930
+ const message = error instanceof Error ? error.message : String(error);
9931
+ const abiMatch = ABI_PATTERN.exec(message);
9932
+ if (abiMatch) {
9933
+ return {
9934
+ kind: "abi-mismatch",
9935
+ // better-sqlite3 的报错里第一个 NODE_MODULE_VERSION 是「模块编译时的 ABI」,
9936
+ // 第二个是「当前运行时要求的 ABI」。
9937
+ moduleAbi: Number(abiMatch[1]),
9938
+ runtimeAbi,
9939
+ detail: message
9940
+ };
9941
+ }
9942
+ if (MISSING_BINDINGS_MARKERS.some((marker) => message.includes(marker))) {
9943
+ return { kind: "missing-bindings", moduleAbi: null, runtimeAbi, detail: message };
9944
+ }
9945
+ return { kind: "unknown", moduleAbi: null, runtimeAbi, detail: message };
9946
+ }
9947
+ function resolveRequire() {
9948
+ if (typeof import_meta !== "undefined" && import_meta.url) {
9949
+ return (0, import_node_module.createRequire)(import_meta.url);
9950
+ }
9951
+ if (typeof __filename !== "undefined") {
9952
+ return (0, import_node_module.createRequire)(__filename);
9953
+ }
9954
+ return (0, import_node_module.createRequire)(import_node_path.default.join(process.cwd(), "package.json"));
9955
+ }
9956
+ function loadBetterSqlite3Binding(loadModule) {
9957
+ const load = loadModule ?? resolveRequire();
9958
+ const Database3 = load("better-sqlite3");
9959
+ const probe = new Database3(":memory:");
9960
+ probe.close();
9961
+ }
9962
+ function checkNativeModule(load = loadBetterSqlite3Binding) {
9963
+ try {
9964
+ load();
9965
+ return null;
9966
+ } catch (error) {
9967
+ const failure = detectNativeModuleFailure(error);
9968
+ if (failure.kind === "abi-mismatch") {
9969
+ return {
9970
+ key: "native-module-abi",
9971
+ severity: "error",
9972
+ message: `\u539F\u751F\u6A21\u5757 better-sqlite3 \u4E0E\u5F53\u524D Node \u8FD0\u884C\u65F6\u4E0D\u517C\u5BB9\uFF1A\u4E8C\u8FDB\u5236\u662F\u6309 ABI ${failure.moduleAbi} \u7F16\u8BD1\u7684\uFF0C\u5F53\u524D\u8FD0\u884C\u65F6\uFF08Node ${process.versions.node}\uFF09\u8981\u6C42 ABI ${failure.runtimeAbi}\u3002
9973
+ \u5E38\u89C1\u539F\u56E0\uFF1Anode_modules \u662F\u5728\u53E6\u4E00\u4E2A Node \u7248\u672C\u4E0B\u5B89\u88C5/\u62F7\u8D1D\u7684\u3002
9974
+ \u4FEE\u590D\uFF1Arm -rf node_modules && pnpm install\uFF08\u6309\u5F53\u524D Node \u91CD\u65B0\u7F16\u8BD1\u539F\u751F\u6A21\u5757\uFF09\uFF0C
9975
+ \u6216\u5207\u56DE\u53D7\u652F\u6301\u7684 Node \u7248\u672C\uFF08${SUPPORTED_NODE_TEXT}\uFF09\u3002`
9976
+ };
9977
+ }
9978
+ if (failure.kind === "missing-bindings") {
9979
+ return {
9980
+ key: "native-module-missing",
9981
+ severity: "error",
9982
+ message: `\u539F\u751F\u6A21\u5757 better-sqlite3 \u7684\u4E8C\u8FDB\u5236\u4E0D\u5B58\u5728\uFF08\u672A\u7F16\u8BD1\u6216\u4F9D\u8D56\u88AB\u88C1\u526A\uFF09\u3002
9983
+ \u4FEE\u590D\uFF1Arm -rf node_modules && pnpm install\uFF08pnpm \u9700\u8981 allowBuilds \u653E\u884C better-sqlite3 \u7684\u6784\u5EFA\u811A\u672C\uFF09\uFF0C
9984
+ \u6216\u6539\u7528\u5B98\u65B9\u5BB9\u5668\u955C\u50CF\uFF08\u5DF2\u5185\u7F6E\u7F16\u8BD1\u597D\u7684\u4E8C\u8FDB\u5236\uFF09\u3002`
9985
+ };
9986
+ }
9987
+ return {
9988
+ key: "native-module-load",
9989
+ severity: "error",
9990
+ message: `\u539F\u751F\u6A21\u5757 better-sqlite3 \u52A0\u8F7D\u5931\u8D25\uFF1A${failure.detail}`
9991
+ };
9992
+ }
9993
+ }
9994
+ function checkRuntimeEnvironment(input = {}) {
9995
+ const errors = [];
9996
+ const warnings = [];
9997
+ const nodeVersion = input.nodeVersion ?? process.version;
9998
+ const nodeCheck = checkNodeVersion(nodeVersion);
9999
+ if (!nodeCheck.ok) {
10000
+ errors.push({
10001
+ key: "node-version",
10002
+ severity: "error",
10003
+ message: `${nodeCheck.message}
10004
+ ${buildFixGuidance(nodeCheck.major)}`
10005
+ });
10006
+ return { errors, warnings };
10007
+ }
10008
+ const nativeIssue = checkNativeModule(input.loadNativeModule);
10009
+ if (nativeIssue) {
10010
+ errors.push(nativeIssue);
10011
+ }
10012
+ return { errors, warnings };
10013
+ }
10014
+ function assertRuntimeEnvironment() {
10015
+ const { errors, warnings } = checkRuntimeEnvironment();
10016
+ for (const warning of warnings) {
10017
+ console.warn(`[NodeRuntime] WARN: ${warning.message}`);
10018
+ }
10019
+ if (errors.length === 0) return;
10020
+ console.error("[NodeRuntime] FATAL: \u8FD0\u884C\u65F6\u73AF\u5883\u4E0D\u6EE1\u8DB3\u8981\u6C42\uFF0C\u62D2\u7EDD\u542F\u52A8\u3002");
10021
+ for (const issue of errors) {
10022
+ console.error(`
10023
+ [NodeRuntime] ${issue.key}
10024
+ ${issue.message}`);
10025
+ }
10026
+ process.exit(1);
10027
+ }
10028
+
10029
+ // server/security/runtime-preflight.ts
10030
+ assertRuntimeEnvironment();
10031
+
10032
+ // server.ts
9758
10033
  var import_express4 = __toESM(require("express"), 1);
9759
10034
  var import_path23 = __toESM(require("path"), 1);
9760
10035
  var import_dotenv = __toESM(require("dotenv"), 1);
@@ -10786,12 +11061,12 @@ var import_url = require("url");
10786
11061
 
10787
11062
  // packages/core/db/backup-manager.ts
10788
11063
  var import_node_fs2 = __toESM(require("node:fs"), 1);
10789
- var import_node_path2 = __toESM(require("node:path"), 1);
11064
+ var import_node_path3 = __toESM(require("node:path"), 1);
10790
11065
  var import_better_sqlite3 = __toESM(require("better-sqlite3"), 1);
10791
11066
 
10792
11067
  // packages/core/db/secure-files.ts
10793
11068
  var import_node_fs = __toESM(require("node:fs"), 1);
10794
- var import_node_path = __toESM(require("node:path"), 1);
11069
+ var import_node_path2 = __toESM(require("node:path"), 1);
10795
11070
  var SECURE_FILE_MODE = 384;
10796
11071
  var SECURE_DIR_MODE = 448;
10797
11072
  var GROUP_OTHER_BITS = 63;
@@ -10835,7 +11110,7 @@ function ensureSecureFile(filePath) {
10835
11110
  hardenFile(filePath);
10836
11111
  return;
10837
11112
  }
10838
- import_node_fs.default.mkdirSync(import_node_path.default.dirname(filePath), { recursive: true });
11113
+ import_node_fs.default.mkdirSync(import_node_path2.default.dirname(filePath), { recursive: true });
10839
11114
  const fd = import_node_fs.default.openSync(filePath, "a", SECURE_FILE_MODE);
10840
11115
  import_node_fs.default.closeSync(fd);
10841
11116
  } catch {
@@ -10853,15 +11128,15 @@ function secureDatabaseFiles(dbPath2) {
10853
11128
  // packages/core/db/backup-manager.ts
10854
11129
  function getDefaultDbPath() {
10855
11130
  if (process.env.OPENLEARN_DB_PATH) {
10856
- return import_node_path2.default.resolve(process.cwd(), process.env.OPENLEARN_DB_PATH);
11131
+ return import_node_path3.default.resolve(process.cwd(), process.env.OPENLEARN_DB_PATH);
10857
11132
  }
10858
- return import_node_path2.default.resolve(process.cwd(), "packages/core/db/educational_os.db");
11133
+ return import_node_path3.default.resolve(process.cwd(), "packages/core/db/educational_os.db");
10859
11134
  }
10860
11135
  function getDefaultBackupDir() {
10861
11136
  if (process.env.BACKUP_DIR) {
10862
- return import_node_path2.default.resolve(process.cwd(), process.env.BACKUP_DIR);
11137
+ return import_node_path3.default.resolve(process.cwd(), process.env.BACKUP_DIR);
10863
11138
  }
10864
- return import_node_path2.default.resolve(process.cwd(), "backups");
11139
+ return import_node_path3.default.resolve(process.cwd(), "backups");
10865
11140
  }
10866
11141
  function validateBackup(filePath) {
10867
11142
  if (!import_node_fs2.default.existsSync(filePath)) {
@@ -10915,7 +11190,7 @@ function pruneByPrefix(backupDir, prefix, keepCount) {
10915
11190
  let pruned = 0;
10916
11191
  for (const file of files.slice(keepCount)) {
10917
11192
  try {
10918
- import_node_fs2.default.unlinkSync(import_node_path2.default.join(backupDir, file));
11193
+ import_node_fs2.default.unlinkSync(import_node_path3.default.join(backupDir, file));
10919
11194
  pruned++;
10920
11195
  } catch {
10921
11196
  }
@@ -10934,15 +11209,15 @@ function pruneMigratorSnapshots(backupDir = getDefaultBackupDir(), keepCount) {
10934
11209
  return pruneByPrefix(backupDir, "pre_migration_", Math.max(1, keepCount ?? resolvePreMigrationKeep()));
10935
11210
  }
10936
11211
  function performBackup(options = {}) {
10937
- const dbPath2 = options.dbPath ? import_node_path2.default.resolve(options.dbPath) : getDefaultDbPath();
10938
- const backupDir = options.backupDir ? import_node_path2.default.resolve(options.backupDir) : getDefaultBackupDir();
11212
+ const dbPath2 = options.dbPath ? import_node_path3.default.resolve(options.dbPath) : getDefaultDbPath();
11213
+ const backupDir = options.backupDir ? import_node_path3.default.resolve(options.backupDir) : getDefaultBackupDir();
10939
11214
  const keep = typeof options.keep === "number" ? options.keep : 10;
10940
11215
  if (!import_node_fs2.default.existsSync(backupDir)) {
10941
11216
  ensureSecureDir(backupDir);
10942
11217
  }
10943
11218
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
10944
11219
  const filename = options.customName || `backup_${timestamp}.db`;
10945
- const backupPath = import_node_path2.default.join(backupDir, filename);
11220
+ const backupPath = import_node_path3.default.join(backupDir, filename);
10946
11221
  if (import_node_fs2.default.existsSync(backupPath)) {
10947
11222
  import_node_fs2.default.unlinkSync(backupPath);
10948
11223
  }
@@ -10979,7 +11254,7 @@ function performBackup(options = {}) {
10979
11254
  }
10980
11255
 
10981
11256
  // packages/core/db/migrator.ts
10982
- var import_meta = {};
11257
+ var import_meta2 = {};
10983
11258
  function splitSqlStatements(sql) {
10984
11259
  return sql.split(";").map((s) => s.trim()).filter((s) => {
10985
11260
  const content = s.replace(/--.*$/gm, "").trim();
@@ -11135,8 +11410,8 @@ function getDefaultMigrationsDir(options = {}) {
11135
11410
  const starts = options.startDirs ? [...options.startDirs] : [];
11136
11411
  if (!options.startDirs && typeof __dirname !== "undefined") starts.push(__dirname);
11137
11412
  try {
11138
- if (!options.startDirs && typeof import_meta !== "undefined" && import_meta.url) {
11139
- starts.push(import_path.default.dirname((0, import_url.fileURLToPath)(import_meta.url)));
11413
+ if (!options.startDirs && typeof import_meta2 !== "undefined" && import_meta2.url) {
11414
+ starts.push(import_path.default.dirname((0, import_url.fileURLToPath)(import_meta2.url)));
11140
11415
  }
11141
11416
  } catch {
11142
11417
  }
@@ -11164,7 +11439,7 @@ function getDefaultMigrationsDir(options = {}) {
11164
11439
 
11165
11440
  // packages/core/db/schema-golden.ts
11166
11441
  var import_node_fs3 = __toESM(require("node:fs"), 1);
11167
- var import_node_path3 = __toESM(require("node:path"), 1);
11442
+ var import_node_path4 = __toESM(require("node:path"), 1);
11168
11443
  var SCHEMA_GOLDEN_FILENAME = "schema-golden.json";
11169
11444
  function extractSchemaSnapshot(db2) {
11170
11445
  const tables = db2.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all();
@@ -11200,7 +11475,7 @@ function formatSchemaGoldenDrifts(drifts) {
11200
11475
  ).join("\n");
11201
11476
  }
11202
11477
  function loadSchemaGolden(migrationsDir) {
11203
- const filePath = import_node_path3.default.join(migrationsDir, SCHEMA_GOLDEN_FILENAME);
11478
+ const filePath = import_node_path4.default.join(migrationsDir, SCHEMA_GOLDEN_FILENAME);
11204
11479
  if (!import_node_fs3.default.existsSync(filePath)) {
11205
11480
  throw new Error(
11206
11481
  `[SchemaGolden] FATAL: ${filePath} \u4E0D\u5B58\u5728\u3002\u91D1\u6837\u4E0E\u8FC1\u79FB\u6587\u4EF6\u540C\u76EE\u5F55\u53D1\u5E03\uFF0C\u7F3A\u5931\u8BF4\u660E\u5B89\u88C5\u5305\u4E0D\u5B8C\u6574\uFF1B\u6E90\u7801\u4ED3\u5E93\u91CC\u8BF7\u8FD0\u884C pnpm db:gen-golden \u751F\u6210\u3002`
@@ -11745,7 +12020,7 @@ function purgeAllExpiredData(db2, options) {
11745
12020
 
11746
12021
  // packages/core/db/index.ts
11747
12022
  init_api_key_crypto();
11748
- var import_meta2 = {};
12023
+ var import_meta3 = {};
11749
12024
  var BCRYPT_ROUNDS = 10;
11750
12025
  function hashPassword(pwd) {
11751
12026
  return import_bcryptjs.default.hashSync(pwd, BCRYPT_ROUNDS);
@@ -11820,8 +12095,8 @@ if (process.env.VITEST) {
11820
12095
  dbPath = import_path2.default.join(__dirname, "educational_os.db");
11821
12096
  } else {
11822
12097
  try {
11823
- if (typeof import_meta2 !== "undefined" && import_meta2.url) {
11824
- const __filename2 = (0, import_url2.fileURLToPath)(import_meta2.url);
12098
+ if (typeof import_meta3 !== "undefined" && import_meta3.url) {
12099
+ const __filename2 = (0, import_url2.fileURLToPath)(import_meta3.url);
11825
12100
  const __dir = import_path2.default.dirname(__filename2);
11826
12101
  dbPath = import_path2.default.join(__dir, "educational_os.db");
11827
12102
  } else {
@@ -14149,10 +14424,10 @@ function parseScoreFields(fields) {
14149
14424
  if (typeof fields !== "string" || !fields.trim()) return [];
14150
14425
  return fields.split(/[\n\r,,;;|]+/).map((f) => f.trim()).filter(Boolean);
14151
14426
  }
14152
- function getNested(raw, path46) {
14427
+ function getNested(raw, path47) {
14153
14428
  if (raw === null || raw === void 0) return void 0;
14154
- if (!path46) return void 0;
14155
- const parts = String(path46).split(".").map((p) => p.trim()).filter(Boolean);
14429
+ if (!path47) return void 0;
14430
+ const parts = String(path47).split(".").map((p) => p.trim()).filter(Boolean);
14156
14431
  let cursor = raw;
14157
14432
  for (let i = 0; i < parts.length; i += 1) {
14158
14433
  if (cursor === null || cursor === void 0) return void 0;
@@ -18152,7 +18427,7 @@ var import_path11 = __toESM(require("path"), 1);
18152
18427
  // packages/core/plugin-host/base.ts
18153
18428
  var import_path5 = __toESM(require("path"), 1);
18154
18429
  var import_express = __toESM(require("express"), 1);
18155
- var import_node_module = require("node:module");
18430
+ var import_node_module2 = require("node:module");
18156
18431
  var import_node_url = require("node:url");
18157
18432
 
18158
18433
  // packages/core/plugin-host/resource-tracker.ts
@@ -18438,7 +18713,7 @@ var ContributionRegistry = class {
18438
18713
  // packages/core/plugin-host/base.ts
18439
18714
  init_types();
18440
18715
  init_errors2();
18441
- var import_meta3 = {};
18716
+ var import_meta4 = {};
18442
18717
  var VALID_TRANSITIONS = {
18443
18718
  ["installed" /* INSTALLED */]: ["activating" /* ACTIVATING */, "uninstalled" /* UNINSTALLED */],
18444
18719
  ["activating" /* ACTIVATING */]: ["active" /* ACTIVE */, "error" /* ERROR */],
@@ -18473,12 +18748,12 @@ function resolveHostSdkDir() {
18473
18748
  const bases = [];
18474
18749
  if (typeof __dirname !== "undefined") bases.push(__dirname);
18475
18750
  try {
18476
- bases.push(import_path5.default.dirname((0, import_node_url.fileURLToPath)(import_meta3.url)));
18751
+ bases.push(import_path5.default.dirname((0, import_node_url.fileURLToPath)(import_meta4.url)));
18477
18752
  } catch {
18478
18753
  }
18479
18754
  for (const base of bases) {
18480
18755
  try {
18481
- const req = (0, import_node_module.createRequire)(import_path5.default.join(base, "__openlearn_resolve__.js"));
18756
+ const req = (0, import_node_module2.createRequire)(import_path5.default.join(base, "__openlearn_resolve__.js"));
18482
18757
  return import_path5.default.dirname(req.resolve("@openlearn/plugin-sdk/package.json"));
18483
18758
  } catch {
18484
18759
  }
@@ -18743,10 +19018,10 @@ var import_semver2 = __toESM(require("semver"), 1);
18743
19018
  init_types();
18744
19019
 
18745
19020
  // packages/core/plugin-host/execution-policy.ts
18746
- var import_node_path8 = __toESM(require("node:path"), 1);
19021
+ var import_node_path9 = __toESM(require("node:path"), 1);
18747
19022
  var import_node_url2 = require("node:url");
18748
19023
  init_types();
18749
- var import_meta4 = {};
19024
+ var import_meta5 = {};
18750
19025
  function trustedInlineAllowlist(env = process.env) {
18751
19026
  return new Set(
18752
19027
  (env.OPENLEARN_TRUSTED_INLINE_PLUGINS ?? "").split(",").map((item) => item.trim()).filter((item) => item.length > 0)
@@ -18754,23 +19029,23 @@ function trustedInlineAllowlist(env = process.env) {
18754
19029
  }
18755
19030
  function builtinPluginsDir() {
18756
19031
  try {
18757
- const url = import_meta4.url;
19032
+ const url = import_meta5.url;
18758
19033
  if (typeof url === "string" && url.startsWith("file:")) {
18759
- return import_node_path8.default.resolve(import_node_path8.default.dirname((0, import_node_url2.fileURLToPath)(url)), "../../plugins");
19034
+ return import_node_path9.default.resolve(import_node_path9.default.dirname((0, import_node_url2.fileURLToPath)(url)), "../../plugins");
18760
19035
  }
18761
19036
  } catch {
18762
19037
  }
18763
19038
  if (typeof __dirname !== "undefined") {
18764
- return import_node_path8.default.resolve(__dirname, "../packages/plugins");
19039
+ return import_node_path9.default.resolve(__dirname, "../packages/plugins");
18765
19040
  }
18766
- return import_node_path8.default.resolve(process.cwd(), "packages/plugins");
19041
+ return import_node_path9.default.resolve(process.cwd(), "packages/plugins");
18767
19042
  }
18768
19043
  function isInsideBuiltinPluginsDir(sourcePath, builtinDir = builtinPluginsDir()) {
18769
19044
  if (!sourcePath) return false;
18770
- const root = import_node_path8.default.resolve(builtinDir);
18771
- const resolved = import_node_path8.default.resolve(sourcePath);
18772
- const relative2 = import_node_path8.default.relative(root, resolved);
18773
- return relative2 === "" || !relative2.startsWith("..") && !import_node_path8.default.isAbsolute(relative2);
19045
+ const root = import_node_path9.default.resolve(builtinDir);
19046
+ const resolved = import_node_path9.default.resolve(sourcePath);
19047
+ const relative2 = import_node_path9.default.relative(root, resolved);
19048
+ return relative2 === "" || !relative2.startsWith("..") && !import_node_path9.default.isAbsolute(relative2);
18774
19049
  }
18775
19050
  function isTrustedInProcessPlugin(manifestId, env = process.env, sourcePath) {
18776
19051
  if (isInsideBuiltinPluginsDir(sourcePath)) return true;
@@ -19733,6 +20008,37 @@ var PluginHostCore = class _PluginHostCore extends PluginHostBase {
19733
20008
  console.warn(`[PluginHost] Failed to revoke stage guards for "${pluginId}":`, e);
19734
20009
  }
19735
20010
  }
20011
+ /**
20012
+ * 回收该插件注册的服务端题型(Issue #320)。
20013
+ *
20014
+ * 题型注册表(`IAssessmentGraderRegistry`)是插件用 `ctx.resolve(token)` 直取的
20015
+ * **裸注册表**,不像 `ctx.services.*` 那样经过 `ResourceTracker` 包装 —— 所以
20016
+ * `disposeAll()` 拿它没办法。不清理的后果已验证为真:
20017
+ *
20018
+ * 1. 停用插件后题型仍留在注册表里 —— 教师继续能出题、继续按旧闭包判分(“幽灵题型”),
20019
+ * `GET /api/assessment-question-types` 也继续把它列给前端;
20020
+ * 2. 重装/升级插件时 `registerQuestionType` 抛 `duplicate question type`,
20021
+ * 表现为「升级后插件激活失败」,必须重启宿主才能恢复。
20022
+ *
20023
+ * 与 `revokePluginStageGuards` 同构:注册方可能传安装行 UUID,也可能传
20024
+ * manifest.id(文档约定的就是后者),两侧都清一遍(幂等)。
20025
+ */
20026
+ async revokePluginQuestionTypes(pluginId, manifestId) {
20027
+ try {
20028
+ const registry4 = await this.serviceRegistry.tryResolve(IAssessmentGraderRegistryToken);
20029
+ if (!registry4 || typeof registry4.clearOwnedQuestionTypes !== "function") return;
20030
+ let removed = 0;
20031
+ for (const owner of /* @__PURE__ */ new Set([pluginId, manifestId])) {
20032
+ if (!owner) continue;
20033
+ removed += registry4.clearOwnedQuestionTypes(owner) ?? 0;
20034
+ }
20035
+ if (removed > 0) {
20036
+ console.log(`[PluginHost] Revoked ${removed} question type(s) for plugin "${pluginId}"`);
20037
+ }
20038
+ } catch (e) {
20039
+ console.warn(`[PluginHost] Failed to revoke question types for "${pluginId}":`, e);
20040
+ }
20041
+ }
19736
20042
  setPluginState(pluginId, state, opts = {}) {
19737
20043
  this.pluginStates.set(pluginId, state);
19738
20044
  if (!opts.persistDb) return;
@@ -19830,7 +20136,7 @@ var PluginHostHttp = class extends PluginHostCore {
19830
20136
  /**
19831
20137
  * 判断指定插件的某个请求路径是否为流式路由 (SSE)
19832
20138
  */
19833
- isStreamRoute(pluginIdOrManifestId, method, path46) {
20139
+ isStreamRoute(pluginIdOrManifestId, method, path47) {
19834
20140
  const upperMethod = method.toUpperCase();
19835
20141
  const resolvedId = this.resolvePluginUuid(pluginIdOrManifestId);
19836
20142
  const instance = this.pluginInstances.get(resolvedId) ?? this.pluginInstances.get(pluginIdOrManifestId);
@@ -19839,7 +20145,7 @@ var PluginHostHttp = class extends PluginHostCore {
19839
20145
  for (const r of manifest.api.routes) {
19840
20146
  if (r.method.toUpperCase() === upperMethod && r.streaming === true) {
19841
20147
  const compiled = compileRoutePattern(r.path);
19842
- if (compiled.regex.test(path46)) {
20148
+ if (compiled.regex.test(path47)) {
19843
20149
  return true;
19844
20150
  }
19845
20151
  }
@@ -19847,14 +20153,14 @@ var PluginHostHttp = class extends PluginHostCore {
19847
20153
  }
19848
20154
  if (!instance) return false;
19849
20155
  if (instance.context?.http) {
19850
- return instance.context.http.isStream(upperMethod, path46);
20156
+ return instance.context.http.isStream(upperMethod, path47);
19851
20157
  }
19852
20158
  if (instance.workerRef) {
19853
20159
  const routes = instance.workerRef.serviceHost.getRegisteredRoutes();
19854
20160
  for (const r of routes) {
19855
20161
  if (r.method === upperMethod && r.isStream) {
19856
20162
  const compiled = compileRoutePattern(r.pattern);
19857
- if (compiled.regex.test(path46)) {
20163
+ if (compiled.regex.test(path47)) {
19858
20164
  return true;
19859
20165
  }
19860
20166
  }
@@ -19928,7 +20234,7 @@ init_context_builder();
19928
20234
 
19929
20235
  // packages/core/plugin-host/declarative-migrator.ts
19930
20236
  var import_node_fs8 = __toESM(require("node:fs"), 1);
19931
- var import_node_path9 = __toESM(require("node:path"), 1);
20237
+ var import_node_path10 = __toESM(require("node:path"), 1);
19932
20238
  init_errors2();
19933
20239
  init_plugin_tables();
19934
20240
  function ensurePluginDeclaredMigrationsTable(db2) {
@@ -19944,7 +20250,7 @@ function ensurePluginDeclaredMigrationsTable(db2) {
19944
20250
  `);
19945
20251
  }
19946
20252
  function parseMigrationFileName(fileName) {
19947
- const baseName = import_node_path9.default.basename(fileName, ".sql");
20253
+ const baseName = import_node_path10.default.basename(fileName, ".sql");
19948
20254
  const match = baseName.match(/^(\d+)[_-]?(.*)$/);
19949
20255
  if (match) {
19950
20256
  const version = parseInt(match[1], 10);
@@ -19967,7 +20273,7 @@ function loadPluginMigrations(migrationsDir) {
19967
20273
  return a.localeCompare(b, void 0, { numeric: true, sensitivity: "base" });
19968
20274
  });
19969
20275
  return files.map((file) => {
19970
- const fullPath = import_node_path9.default.join(migrationsDir, file);
20276
+ const fullPath = import_node_path10.default.join(migrationsDir, file);
19971
20277
  const content = import_node_fs8.default.readFileSync(fullPath, "utf8");
19972
20278
  const { version, baseName } = parseMigrationFileName(file);
19973
20279
  const parsed = parseMigrationSql(baseName, content);
@@ -20609,6 +20915,7 @@ var PluginHostLifecycle = class extends PluginHostHttp {
20609
20915
  this.resourceTracker.disposeAll(pluginId);
20610
20916
  this.pluginInstances.delete(pluginId);
20611
20917
  this.revokePluginContributions(pluginId);
20918
+ await this.revokePluginQuestionTypes(pluginId, deactManifest.id);
20612
20919
  this.setPluginState(pluginId, "inactive" /* INACTIVE */, { persistDb: true });
20613
20920
  if (this._hotReloadController) {
20614
20921
  this._hotReloadController.unregisterPlugin(pluginId);
@@ -20627,6 +20934,7 @@ var PluginHostLifecycle = class extends PluginHostHttp {
20627
20934
  this.resourceTracker.disposeAll(pluginId);
20628
20935
  this.pluginInstances.delete(pluginId);
20629
20936
  this.revokePluginContributions(pluginId);
20937
+ await this.revokePluginQuestionTypes(pluginId, deactManifest.id);
20630
20938
  this.setPluginState(pluginId, "inactive" /* INACTIVE */, { persistDb: true });
20631
20939
  if (actorId) {
20632
20940
  await this.revokeCapabilities(actorId);
@@ -20662,6 +20970,7 @@ var PluginHostLifecycle = class extends PluginHostHttp {
20662
20970
  this.resourceTracker.disposeAll(pluginId);
20663
20971
  this.pluginInstances.delete(pluginId);
20664
20972
  this.revokePluginContributions(pluginId);
20973
+ await this.revokePluginQuestionTypes(pluginId, instance?.manifest?.id ?? pluginId);
20665
20974
  this.setPluginState(pluginId, "inactive" /* INACTIVE */, { persistDb: true });
20666
20975
  if (actorId) {
20667
20976
  await this.revokeCapabilities(actorId);
@@ -20725,26 +21034,26 @@ var import_semver3 = __toESM(require("semver"), 1);
20725
21034
 
20726
21035
  // packages/core/version.ts
20727
21036
  var import_node_fs11 = __toESM(require("node:fs"), 1);
20728
- var import_node_path12 = __toESM(require("node:path"), 1);
21037
+ var import_node_path13 = __toESM(require("node:path"), 1);
20729
21038
  var import_node_url5 = require("node:url");
20730
- var import_meta6 = {};
20731
- var FALLBACK_VERSION = "0.7.4";
21039
+ var import_meta7 = {};
21040
+ var FALLBACK_VERSION = "0.7.6";
20732
21041
  function readPlatformVersion() {
20733
21042
  const bases = [];
20734
21043
  if (typeof __dirname !== "undefined") bases.push(__dirname);
20735
21044
  try {
20736
- bases.push(import_node_path12.default.dirname((0, import_node_url5.fileURLToPath)(import_meta6.url)));
21045
+ bases.push(import_node_path13.default.dirname((0, import_node_url5.fileURLToPath)(import_meta7.url)));
20737
21046
  } catch {
20738
21047
  }
20739
21048
  for (const base of bases) {
20740
21049
  let dir = base;
20741
21050
  for (; ; ) {
20742
21051
  try {
20743
- const pkg = JSON.parse(import_node_fs11.default.readFileSync(import_node_path12.default.join(dir, "package.json"), "utf-8"));
21052
+ const pkg = JSON.parse(import_node_fs11.default.readFileSync(import_node_path13.default.join(dir, "package.json"), "utf-8"));
20744
21053
  if (pkg.name === "openlearn-next" && typeof pkg.version === "string") return pkg.version;
20745
21054
  } catch {
20746
21055
  }
20747
- const parent = import_node_path12.default.dirname(dir);
21056
+ const parent = import_node_path13.default.dirname(dir);
20748
21057
  if (parent === dir) break;
20749
21058
  dir = parent;
20750
21059
  }
@@ -21464,7 +21773,7 @@ init_audit_log();
21464
21773
  // packages/core/plugin-host/dependency-install.ts
21465
21774
  var import_node_child_process2 = require("node:child_process");
21466
21775
  var import_node_fs12 = __toESM(require("node:fs"), 1);
21467
- var import_node_path13 = __toESM(require("node:path"), 1);
21776
+ var import_node_path14 = __toESM(require("node:path"), 1);
21468
21777
  var DependencyInstallError = class extends Error {
21469
21778
  constructor(message, pluginId, cause) {
21470
21779
  super(message);
@@ -21499,7 +21808,7 @@ function resolveRegistry() {
21499
21808
  function assertDependenciesResolved(pluginDir, dependencies, pluginId) {
21500
21809
  const names = dependencyNames(dependencies);
21501
21810
  if (names.length === 0) return;
21502
- const lockPath = import_node_path13.default.join(pluginDir, "package-lock.json");
21811
+ const lockPath = import_node_path14.default.join(pluginDir, "package-lock.json");
21503
21812
  if (!import_node_fs12.default.existsSync(lockPath)) {
21504
21813
  throw new DependencyInstallError(
21505
21814
  `\u4F9D\u8D56\u5B89\u88C5\u672A\u4EA7\u51FA package-lock.json \u2014\u2014 \u65E0\u6CD5\u786E\u8BA4\u4F9D\u8D56\u5DF2\u843D\u76D8\u3002pluginId=${pluginId}\uFF0C\u58F0\u660E\u4F9D\u8D56\uFF1A${names.join(", ")}`,
@@ -21581,7 +21890,7 @@ function installPluginDependencies(pluginDir, opts, runNpm = import_node_child_p
21581
21890
  const { pluginId, dependencies, operation = "install" } = opts;
21582
21891
  const depMap = toNpmDependencyMap(dependencies);
21583
21892
  if (Object.keys(depMap).length === 0) return;
21584
- const pkgJsonPath = import_node_path13.default.join(pluginDir, "package.json");
21893
+ const pkgJsonPath = import_node_path14.default.join(pluginDir, "package.json");
21585
21894
  import_node_fs12.default.writeFileSync(
21586
21895
  pkgJsonPath,
21587
21896
  JSON.stringify({ name: pluginId, version: "0.0.0", dependencies: depMap }, null, 2),
@@ -22411,6 +22720,7 @@ var PluginHostUninstall = class extends PluginHostInstall {
22411
22720
  return pluginId;
22412
22721
  }
22413
22722
  })();
22723
+ await this.revokePluginQuestionTypes(pluginId, manifestId);
22414
22724
  if (state === "uninstalled" /* UNINSTALLED */ && !row) {
22415
22725
  this.pluginStates.delete(pluginId);
22416
22726
  return {
@@ -32668,7 +32978,7 @@ ${formatEnvIssues(warnings)}`);
32668
32978
 
32669
32979
  // server/security/file-permissions.ts
32670
32980
  var import_node_fs14 = __toESM(require("node:fs"), 1);
32671
- var import_node_path14 = __toESM(require("node:path"), 1);
32981
+ var import_node_path15 = __toESM(require("node:path"), 1);
32672
32982
  var FILE_PERMISSION_BASELINE_DOC = "\u4FEE\u590D\uFF1Achmod 600 <file>\uFF08\u76EE\u5F55 chmod 700\uFF09\uFF1B\u8BE6\u89C1 docs/administrator-guide/admin-manual.md \u6587\u4EF6\u6743\u9650\u57FA\u7EBF\u4E00\u8282\u3002";
32673
32983
  function modeOf(p) {
32674
32984
  try {
@@ -32678,13 +32988,13 @@ function modeOf(p) {
32678
32988
  }
32679
32989
  }
32680
32990
  function defaultPaths() {
32681
- const dbPath2 = process.env.OPENLEARN_DB_PATH ? import_node_path14.default.resolve(process.cwd(), process.env.OPENLEARN_DB_PATH) : import_node_path14.default.resolve(process.cwd(), "packages/core/db/educational_os.db");
32682
- const backupDir = process.env.BACKUP_DIR ? import_node_path14.default.resolve(process.cwd(), process.env.BACKUP_DIR) : import_node_path14.default.join(import_node_path14.default.dirname(dbPath2), "backups");
32991
+ const dbPath2 = process.env.OPENLEARN_DB_PATH ? import_node_path15.default.resolve(process.cwd(), process.env.OPENLEARN_DB_PATH) : import_node_path15.default.resolve(process.cwd(), "packages/core/db/educational_os.db");
32992
+ const backupDir = process.env.BACKUP_DIR ? import_node_path15.default.resolve(process.cwd(), process.env.BACKUP_DIR) : import_node_path15.default.join(import_node_path15.default.dirname(dbPath2), "backups");
32683
32993
  return {
32684
- envPath: import_node_path14.default.resolve(process.cwd(), ".env"),
32994
+ envPath: import_node_path15.default.resolve(process.cwd(), ".env"),
32685
32995
  dbPath: dbPath2,
32686
32996
  backupDir,
32687
- encryptionKeyPath: import_node_path14.default.resolve(process.cwd(), "storage", "encryption-key")
32997
+ encryptionKeyPath: import_node_path15.default.resolve(process.cwd(), "storage", "encryption-key")
32688
32998
  };
32689
32999
  }
32690
33000
  function checkSensitiveFilePermissions(options = {}) {
@@ -32719,7 +33029,7 @@ function checkSensitiveFilePermissions(options = {}) {
32719
33029
  if (scanned >= maxBackupFiles) break;
32720
33030
  if (!entry.endsWith(".db")) continue;
32721
33031
  scanned++;
32722
- checkFile(import_node_path14.default.join(backupDir, entry), "600", "backup-file");
33032
+ checkFile(import_node_path15.default.join(backupDir, entry), "600", "backup-file");
32723
33033
  }
32724
33034
  }
32725
33035
  return issues;
@@ -33620,7 +33930,7 @@ var runOpenAIAgentChat = async (provider, request) => {
33620
33930
 
33621
33931
  // server/backup-scheduler.ts
33622
33932
  var import_node_fs15 = __toESM(require("node:fs"), 1);
33623
- var import_node_path15 = __toESM(require("node:path"), 1);
33933
+ var import_node_path16 = __toESM(require("node:path"), 1);
33624
33934
  var HOUR_MS = 60 * 60 * 1e3;
33625
33935
  var DAY_MS2 = 24 * HOUR_MS;
33626
33936
  var WAL_FULL_THRESHOLD_BYTES = 50 * 1024 * 1024;
@@ -33637,8 +33947,8 @@ function shouldFullCheckpoint(walBytes, threshold = WAL_FULL_THRESHOLD_BYTES) {
33637
33947
  }
33638
33948
  function resolveDbPath(explicit) {
33639
33949
  if (explicit) return explicit;
33640
- if (process.env.OPENLEARN_DB_PATH) return import_node_path15.default.resolve(process.cwd(), process.env.OPENLEARN_DB_PATH);
33641
- return import_node_path15.default.resolve(process.cwd(), "packages/core/db/educational_os.db");
33950
+ if (process.env.OPENLEARN_DB_PATH) return import_node_path16.default.resolve(process.cwd(), process.env.OPENLEARN_DB_PATH);
33951
+ return import_node_path16.default.resolve(process.cwd(), "packages/core/db/educational_os.db");
33642
33952
  }
33643
33953
  function startBackupScheduler(deps) {
33644
33954
  const noop = { stop: () => {
@@ -33757,9 +34067,9 @@ function isWhitelisted(route, whitelist) {
33757
34067
  (entry) => entry.method.toUpperCase() === route.method && normalizePath2(entry.path) === normalizePath2(route.path)
33758
34068
  );
33759
34069
  }
33760
- function normalizePath2(path46) {
33761
- if (!path46) return "/";
33762
- return path46.endsWith("/") && path46.length > 1 ? path46.slice(0, -1) : path46;
34070
+ function normalizePath2(path47) {
34071
+ if (!path47) return "/";
34072
+ return path47.endsWith("/") && path47.length > 1 ? path47.slice(0, -1) : path47;
33763
34073
  }
33764
34074
  function auditRouteScopes(app, whitelist = []) {
33765
34075
  const declared = [];
@@ -33937,11 +34247,11 @@ function enforcePasswordChanged(req, res, next) {
33937
34247
  if (isPasswordChangeGateSkipped()) {
33938
34248
  return next();
33939
34249
  }
33940
- const path46 = req.path || "";
33941
- if (!path46.startsWith("/api/")) {
34250
+ const path47 = req.path || "";
34251
+ if (!path47.startsWith("/api/")) {
33942
34252
  return next();
33943
34253
  }
33944
- if (PASSWORD_CHANGE_EXEMPT_PATHS.has(path46)) {
34254
+ if (PASSWORD_CHANGE_EXEMPT_PATHS.has(path47)) {
33945
34255
  return next();
33946
34256
  }
33947
34257
  const token = getCookieToken(req);
@@ -34016,8 +34326,8 @@ function csrfGuard(req, res, next) {
34016
34326
  next();
34017
34327
  return;
34018
34328
  }
34019
- const path47 = req.path;
34020
- if (EXEMPT_PREFIXES.some((p) => path47 === p || path47.startsWith(p))) {
34329
+ const path48 = req.path;
34330
+ if (EXEMPT_PREFIXES.some((p) => path48 === p || path48.startsWith(p))) {
34021
34331
  next();
34022
34332
  return;
34023
34333
  }
@@ -34027,10 +34337,10 @@ function csrfGuard(req, res, next) {
34027
34337
  const origin = req.headers.origin;
34028
34338
  const referer = req.headers.referer;
34029
34339
  const host = req.headers.host;
34030
- const path46 = req.path;
34340
+ const path47 = req.path;
34031
34341
  if (typeof origin === "string" && origin !== "") {
34032
34342
  if (origin === "null") {
34033
- if (!EXEMPT_PREFIXES.some((p) => path46 === p || path46.startsWith(p))) {
34343
+ if (!EXEMPT_PREFIXES.some((p) => path47 === p || path47.startsWith(p))) {
34034
34344
  res.status(403).json({ success: false, code: "FORBIDDEN_CROSS_SITE", error: "Opaque origin write request blocked" });
34035
34345
  return;
34036
34346
  }
@@ -34038,7 +34348,7 @@ function csrfGuard(req, res, next) {
34038
34348
  try {
34039
34349
  const originHost = new URL(origin).host;
34040
34350
  if (host && originHost.toLowerCase() !== host.toLowerCase()) {
34041
- if (!EXEMPT_PREFIXES.some((p) => path46 === p || path46.startsWith(p))) {
34351
+ if (!EXEMPT_PREFIXES.some((p) => path47 === p || path47.startsWith(p))) {
34042
34352
  res.status(403).json({
34043
34353
  success: false,
34044
34354
  code: "FORBIDDEN_CROSS_SITE",
@@ -34056,7 +34366,7 @@ function csrfGuard(req, res, next) {
34056
34366
  try {
34057
34367
  const refererHost = new URL(referer).host;
34058
34368
  if (host && refererHost.toLowerCase() !== host.toLowerCase()) {
34059
- if (!EXEMPT_PREFIXES.some((p) => path46 === p || path46.startsWith(p))) {
34369
+ if (!EXEMPT_PREFIXES.some((p) => path47 === p || path47.startsWith(p))) {
34060
34370
  res.status(403).json({
34061
34371
  success: false,
34062
34372
  code: "FORBIDDEN_CROSS_SITE",
@@ -34109,8 +34419,8 @@ function parseSentryDsn(dsn) {
34109
34419
  const projectId = url.pathname.split("/").filter(Boolean).pop();
34110
34420
  if (!projectId || !/^\d+$/.test(projectId)) return null;
34111
34421
  const prefix = url.pathname.split("/").filter(Boolean).slice(0, -1).join("/");
34112
- const path46 = prefix ? `/${prefix}` : "";
34113
- const envelopeUrl = `${url.protocol}//${url.host}${path46}/api/${projectId}/envelope/`;
34422
+ const path47 = prefix ? `/${prefix}` : "";
34423
+ const envelopeUrl = `${url.protocol}//${url.host}${path47}/api/${projectId}/envelope/`;
34114
34424
  return { envelopeUrl, publicKey };
34115
34425
  } catch {
34116
34426
  return null;
@@ -34551,14 +34861,14 @@ function createSocketIoCors(options) {
34551
34861
 
34552
34862
  // server/routes/health.ts
34553
34863
  var import_node_fs17 = __toESM(require("node:fs"), 1);
34554
- var import_node_path17 = __toESM(require("node:path"), 1);
34864
+ var import_node_path18 = __toESM(require("node:path"), 1);
34555
34865
 
34556
34866
  // packages/core/observability/health.ts
34557
34867
  var import_node_fs16 = __toESM(require("node:fs"), 1);
34558
- var import_node_path16 = __toESM(require("node:path"), 1);
34868
+ var import_node_path17 = __toESM(require("node:path"), 1);
34559
34869
  var import_node_crypto13 = require("node:crypto");
34560
34870
  function probeStorageWritable(dir) {
34561
- const probe = import_node_path16.default.join(dir, `.ready-${process.pid}-${(0, import_node_crypto13.randomBytes)(4).toString("hex")}`);
34871
+ const probe = import_node_path17.default.join(dir, `.ready-${process.pid}-${(0, import_node_crypto13.randomBytes)(4).toString("hex")}`);
34562
34872
  try {
34563
34873
  import_node_fs16.default.mkdirSync(dir, { recursive: true });
34564
34874
  import_node_fs16.default.writeFileSync(probe, "ok", { flag: "w" });
@@ -34575,14 +34885,14 @@ function probeStorageWritable(dir) {
34575
34885
  function listMigrationNames(migrationsDir) {
34576
34886
  try {
34577
34887
  if (!import_node_fs16.default.existsSync(migrationsDir)) return null;
34578
- const names = import_node_fs16.default.readdirSync(migrationsDir).filter((file) => file.endsWith(".sql")).map((file) => import_node_path16.default.basename(file, ".sql"));
34888
+ const names = import_node_fs16.default.readdirSync(migrationsDir).filter((file) => file.endsWith(".sql")).map((file) => import_node_path17.default.basename(file, ".sql"));
34579
34889
  return names.length > 0 ? names : null;
34580
34890
  } catch {
34581
34891
  return null;
34582
34892
  }
34583
34893
  }
34584
34894
  function migrationRecordName(name) {
34585
- return import_node_path16.default.basename(name.replace(/\r/g, "").trim(), ".sql");
34895
+ return import_node_path17.default.basename(name.replace(/\r/g, "").trim(), ".sql");
34586
34896
  }
34587
34897
  function migrationsComplete(expected, applied) {
34588
34898
  if (!expected) return { ok: false, reason: "migrations" };
@@ -34682,7 +34992,7 @@ function newestBackupAgeSeconds(backupDir, now) {
34682
34992
  let newestMs = Number.NEGATIVE_INFINITY;
34683
34993
  for (const name of names) {
34684
34994
  try {
34685
- const stat = import_node_fs17.default.statSync(import_node_path17.default.join(backupDir, name));
34995
+ const stat = import_node_fs17.default.statSync(import_node_path18.default.join(backupDir, name));
34686
34996
  if (!stat.isFile()) continue;
34687
34997
  if (stat.mtimeMs > newestMs) newestMs = stat.mtimeMs;
34688
34998
  } catch {
@@ -34714,7 +35024,7 @@ function defaultCountEvents() {
34714
35024
  function registerHealthRoutes(app, options) {
34715
35025
  const startTime = options?.startTime ?? Date.now();
34716
35026
  const platformVersion = options?.platformVersion ?? PLATFORM_VERSION;
34717
- const migrationsDir = options?.migrationsDir ?? import_node_path17.default.join(process.cwd(), "migrations");
35027
+ const migrationsDir = options?.migrationsDir ?? import_node_path18.default.join(process.cwd(), "migrations");
34718
35028
  const storageDir = options?.storageDir ?? getStorageDir();
34719
35029
  const backupDir = options?.backupDir ?? getBackupsDir();
34720
35030
  const countEvents = options?.countEvents ?? defaultCountEvents;
@@ -34835,7 +35145,7 @@ var import_zod2 = require("zod");
34835
35145
 
34836
35146
  // server/services/course-package-assets.ts
34837
35147
  var import_node_fs18 = __toESM(require("node:fs"), 1);
34838
- var import_node_path18 = __toESM(require("node:path"), 1);
35148
+ var import_node_path19 = __toESM(require("node:path"), 1);
34839
35149
  var import_node_crypto14 = __toESM(require("node:crypto"), 1);
34840
35150
  var import_jszip3 = __toESM(require("jszip"), 1);
34841
35151
  var import_jspdf = require("jspdf");
@@ -35102,7 +35412,7 @@ async function materializePackageAssets(assets) {
35102
35412
  throw new Error(`\u8BFE\u7A0B\u5305\u8D44\u4EA7 "${asset.id}" \u58F0\u660E\u4E3A ${asset.contentType} \u4F46\u5185\u5BB9\u662F\u4E8C\u8FDB\u5236`);
35103
35413
  }
35104
35414
  const uniqueName = `${Date.now()}-${import_node_crypto14.default.randomBytes(4).toString("hex")}${ext}`;
35105
- const filePath = import_node_path18.default.join(uploadsDir, uniqueName);
35415
+ const filePath = import_node_path19.default.join(uploadsDir, uniqueName);
35106
35416
  import_node_fs18.default.writeFileSync(filePath, buffer);
35107
35417
  writtenPaths.push(filePath);
35108
35418
  const slideCount = isEmbeddableAssetType(asset.contentType) ? asset.contentType === "pdf" ? countPdfPages(buffer) : await countPptxSlides(buffer) : 0;
@@ -42066,7 +42376,7 @@ function registerLessonsRoutes(ctx) {
42066
42376
 
42067
42377
  // server/services/course-package-import.ts
42068
42378
  var import_node_fs20 = __toESM(require("node:fs"), 1);
42069
- var import_node_path19 = __toESM(require("node:path"), 1);
42379
+ var import_node_path20 = __toESM(require("node:path"), 1);
42070
42380
  var import_uuid18 = require("uuid");
42071
42381
 
42072
42382
  // server/services/course-package-identity.ts
@@ -42341,8 +42651,8 @@ function findReferencedAsset(rawData, assets) {
42341
42651
  function cleanupMaterializedAssets(materialized) {
42342
42652
  const uploadsDir = getUploadsDir();
42343
42653
  for (const asset of materialized.values()) {
42344
- const fileName = import_node_path19.default.basename(asset.fileUrl);
42345
- const target = import_node_path19.default.join(uploadsDir, fileName);
42654
+ const fileName = import_node_path20.default.basename(asset.fileUrl);
42655
+ const target = import_node_path20.default.join(uploadsDir, fileName);
42346
42656
  try {
42347
42657
  if (import_node_fs20.default.existsSync(target)) {
42348
42658
  import_node_fs20.default.unlinkSync(target);
@@ -46478,6 +46788,85 @@ function registerAssignmentsRoutes(ctx) {
46478
46788
 
46479
46789
  // server/routes/assignment-hub.ts
46480
46790
  var import_express3 = __toESM(require("express"), 1);
46791
+
46792
+ // packages/core/assignment/file-preview-policy.ts
46793
+ function extensionOf(fileName) {
46794
+ if (typeof fileName !== "string") return "";
46795
+ const base = fileName.split(/[\\/]/).pop() || "";
46796
+ const dot = base.lastIndexOf(".");
46797
+ if (dot <= 0 || dot === base.length - 1) return "";
46798
+ return base.slice(dot).toLowerCase();
46799
+ }
46800
+ var PREVIEW_CONTENT_TYPES = Object.freeze({
46801
+ // 文档
46802
+ ".pdf": "application/pdf",
46803
+ ".rtf": "application/rtf",
46804
+ ".odt": "application/vnd.oasis.opendocument.text",
46805
+ ".odp": "application/vnd.oasis.opendocument.presentation",
46806
+ ".ods": "application/vnd.oasis.opendocument.spreadsheet",
46807
+ ".doc": "application/msword",
46808
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
46809
+ ".ppt": "application/vnd.ms-powerpoint",
46810
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
46811
+ ".xls": "application/vnd.ms-excel",
46812
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
46813
+ // 纯文本 / 代码 / 数据 —— 宿主按文本渲染
46814
+ ".txt": "text/plain",
46815
+ ".md": "text/plain",
46816
+ ".csv": "text/plain",
46817
+ ".json": "text/plain",
46818
+ ".xml": "text/plain",
46819
+ ".html": "text/plain",
46820
+ ".css": "text/plain",
46821
+ ".py": "text/plain",
46822
+ ".js": "text/plain",
46823
+ ".mjs": "text/plain",
46824
+ ".cjs": "text/plain",
46825
+ ".ts": "text/plain",
46826
+ ".tsx": "text/plain",
46827
+ ".jsx": "text/plain",
46828
+ ".java": "text/plain",
46829
+ ".c": "text/plain",
46830
+ ".h": "text/plain",
46831
+ ".cpp": "text/plain",
46832
+ ".cs": "text/plain",
46833
+ ".go": "text/plain",
46834
+ ".rs": "text/plain",
46835
+ ".rb": "text/plain",
46836
+ ".php": "text/plain",
46837
+ ".sql": "text/plain",
46838
+ ".ino": "text/plain",
46839
+ // 图片
46840
+ ".png": "image/png",
46841
+ ".jpg": "image/jpeg",
46842
+ ".jpeg": "image/jpeg",
46843
+ ".gif": "image/gif",
46844
+ ".webp": "image/webp",
46845
+ ".bmp": "image/bmp",
46846
+ ".svg": "image/svg+xml",
46847
+ ".heic": "image/heic",
46848
+ // 压缩包
46849
+ ".zip": "application/zip"
46850
+ });
46851
+ var INLINE_SAFE_CONTENT_TYPES = /* @__PURE__ */ new Set([
46852
+ "application/pdf",
46853
+ "text/plain",
46854
+ "image/png",
46855
+ "image/jpeg",
46856
+ "image/gif",
46857
+ "image/webp",
46858
+ "image/bmp"
46859
+ ]);
46860
+ var TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
46861
+ function resolveContentType(fileName) {
46862
+ const extension = extensionOf(fileName);
46863
+ return PREVIEW_CONTENT_TYPES[extension] ?? "application/octet-stream";
46864
+ }
46865
+ function isInlineSafeContentType(contentType) {
46866
+ return INLINE_SAFE_CONTENT_TYPES.has((contentType || "").toLowerCase());
46867
+ }
46868
+
46869
+ // server/routes/assignment-hub.ts
46481
46870
  function decodeFileName(raw) {
46482
46871
  const text = String(raw ?? "").trim();
46483
46872
  if (!text) return "";
@@ -46657,6 +47046,19 @@ function registerAssignmentHubRoutes(ctx) {
46657
47046
  );
46658
47047
  res.setHeader("X-Content-Type-Options", "nosniff");
46659
47048
  res.setHeader("Cache-Control", "private, no-store");
47049
+ const wantsInline = String(req.query.disposition || "").toLowerCase() === "inline";
47050
+ const contentType = resolveContentType(originalName);
47051
+ if (wantsInline && isInlineSafeContentType(contentType)) {
47052
+ res.setHeader("Content-Type", contentType);
47053
+ res.setHeader(
47054
+ "Content-Security-Policy",
47055
+ contentType === "application/pdf" ? "sandbox allow-scripts" : "sandbox"
47056
+ );
47057
+ const safeName = encodeURIComponent(originalName).replace(/['()]/g, escape);
47058
+ res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${safeName}`);
47059
+ res.sendFile(absPath);
47060
+ return;
47061
+ }
46660
47062
  res.download(absPath, originalName);
46661
47063
  } catch (e) {
46662
47064
  sendHubError(res, e);
@@ -48422,7 +48824,7 @@ async function checkVersion(source, installedVersion) {
48422
48824
  init_audit_log();
48423
48825
 
48424
48826
  // server/routes/plugin-api-gateway.ts
48425
- var import_node_path20 = __toESM(require("node:path"), 1);
48827
+ var import_node_path21 = __toESM(require("node:path"), 1);
48426
48828
  init_http_router();
48427
48829
  init_interfaces();
48428
48830
  var RESERVED_ACTIONS = /* @__PURE__ */ new Set([
@@ -48596,7 +48998,7 @@ async function pluginApiGatewayMiddleware(req, res, next) {
48596
48998
  res.status(400).json({ success: false, error: "Path traversal attempt detected" });
48597
48999
  return;
48598
49000
  }
48599
- const normalizedSubPath = import_node_path20.default.posix.normalize("/" + rawSubPath);
49001
+ const normalizedSubPath = import_node_path21.default.posix.normalize("/" + rawSubPath);
48600
49002
  if (normalizedSubPath.includes("..")) {
48601
49003
  res.status(400).json({ success: false, error: "Path traversal attempt detected" });
48602
49004
  return;
@@ -49034,7 +49436,7 @@ init_fetch_with_retry();
49034
49436
  // server/services/community-registry.ts
49035
49437
  var import_semver7 = __toESM(require("semver"), 1);
49036
49438
  var import_node_fs21 = __toESM(require("node:fs"), 1);
49037
- var import_node_path21 = __toESM(require("node:path"), 1);
49439
+ var import_node_path22 = __toESM(require("node:path"), 1);
49038
49440
  var import_node_os3 = __toESM(require("node:os"), 1);
49039
49441
  var import_node_crypto16 = require("node:crypto");
49040
49442
  var COMMUNITY_REGISTRY_ENV = "PLUGIN_COMMUNITY_REGISTRY_URL";
@@ -49256,7 +49658,7 @@ async function downloadPluginPackage(downloadUrl, options = {}) {
49256
49658
  if (!response.body) {
49257
49659
  throw new Error("\u4E0B\u8F7D\u63D2\u4EF6\u5305\u5931\u8D25: \u54CD\u5E94\u4F53\u4E3A\u7A7A");
49258
49660
  }
49259
- const zipPath = import_node_path21.default.join(import_node_os3.default.tmpdir(), `plugin-download-${(0, import_node_crypto16.randomUUID)()}.zip`);
49661
+ const zipPath = import_node_path22.default.join(import_node_os3.default.tmpdir(), `plugin-download-${(0, import_node_crypto16.randomUUID)()}.zip`);
49260
49662
  const out = import_node_fs21.default.createWriteStream(zipPath);
49261
49663
  let received = 0;
49262
49664
  try {