openlearn-next 0.7.10 → 0.7.12

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 (54) hide show
  1. package/README.md +9 -2
  2. package/dist/assets/{ActivityWorkspaceWidget-BU88GGUG.js → ActivityWorkspaceWidget-TtBuNORs.js} +1 -1
  3. package/dist/assets/AdminDirectoryView-D03WOMc-.js +5 -0
  4. package/dist/assets/{AssessmentCenter-BoSWbJMt.js → AssessmentCenter-MUvoJySb.js} +1 -1
  5. package/dist/assets/{AssignmentFilePreview-D_POvLmv.js → AssignmentFilePreview-dVhnTYmq.js} +1 -1
  6. package/dist/assets/{BatchPickerModal-DiHd_6Tg.js → BatchPickerModal-QALdhrX6.js} +1 -1
  7. package/dist/assets/{ClassesView-CWKqzWH0.js → ClassesView-CKwlRz-A.js} +5 -5
  8. package/dist/assets/{ClassroomCountdownWidget-B5-KUVU8.js → ClassroomCountdownWidget-DyWyForY.js} +1 -1
  9. package/dist/assets/{CloudDriveModal-CjcvdDiw.js → CloudDriveModal-C8UAn3MO.js} +1 -1
  10. package/dist/assets/{ComputerLabView-CNTcmWAh.js → ComputerLabView-E5b3XTkk.js} +1 -1
  11. package/dist/assets/{CourseManagement-ha_iSnLU.js → CourseManagement-3jSlq7lV.js} +1 -1
  12. package/dist/assets/{CourseWizardModal-oVT9mYSG.js → CourseWizardModal-BAkW5VJX.js} +1 -1
  13. package/dist/assets/{CoursewareHubPanel-BuKp0mmW.js → CoursewareHubPanel-BkjcusjZ.js} +1 -1
  14. package/dist/assets/{Dashboard-Vb9coh9_.js → Dashboard-Ha2fZAqD.js} +2 -2
  15. package/dist/assets/{ExportWeightModal-BEesz6rd.js → ExportWeightModal-D2Y13384.js} +1 -1
  16. package/dist/assets/{HelpTour-D3QcGDUz.js → HelpTour-DBq2otcV.js} +1 -1
  17. package/dist/assets/{HelpView--Um9DFYx.js → HelpView-BVLRnMxl.js} +4 -4
  18. package/dist/assets/{ImportLessonsModal-DXGopj4j.js → ImportLessonsModal-CQlq4b81.js} +1 -1
  19. package/dist/assets/ImportModal-Ct0I5cAL.js +3 -0
  20. package/dist/assets/{InteractiveCoursewareViewer-DzgF9_LU.js → InteractiveCoursewareViewer-DGKh1gO8.js} +1 -1
  21. package/dist/assets/InteractiveWhiteboard-BbZlYpRu.js +65 -0
  22. package/dist/assets/{LazyCourseware-6yb_QiYS.js → LazyCourseware-zONqinCM.js} +2 -2
  23. package/dist/assets/{LazyWhiteboard-CsF9GB6J.js → LazyWhiteboard-BV98LEZ8.js} +2 -2
  24. package/dist/assets/{LessonEditorView-CZTZ7Q0B.js → LessonEditorView-LXRkKxdY.js} +1 -1
  25. package/dist/assets/LiveClassroomView-TaHtHWkY.js +63 -0
  26. package/dist/assets/{NotificationDetailModal-mUeIQP4R.js → NotificationDetailModal-BF_-pn0c.js} +1 -1
  27. package/dist/assets/{PluginView-CT3-zS2n.js → PluginView-sVMPXae9.js} +1 -1
  28. package/dist/assets/{ProcessLogsModal-DmsOrZnV.js → ProcessLogsModal-D97xLgnL.js} +1 -1
  29. package/dist/assets/{QuizGeneratorModal-ViKxNX5Q.js → QuizGeneratorModal-CNY8ISjR.js} +1 -1
  30. package/dist/assets/{StudentAssignmentEvalPanel-BRxHMABY.js → StudentAssignmentEvalPanel-CUmM5DbT.js} +1 -1
  31. package/dist/assets/{StudentAssignmentView-g-ewJIS3.js → StudentAssignmentView-B-1MtwKj.js} +1 -1
  32. package/dist/assets/{StudentLessonView-Dbvr3Xlo.js → StudentLessonView-CqrcvVND.js} +1 -1
  33. package/dist/assets/{StudentView-20vfEk4v.js → StudentView-Bi9kgSrN.js} +2 -2
  34. package/dist/assets/{SystemResourceLibraryModal-CDKeaKf8.js → SystemResourceLibraryModal-DDALKrJC.js} +1 -1
  35. package/dist/assets/{TeacherView-KIKuhXGF.js → TeacherView-C5JmoKHk.js} +2 -2
  36. package/dist/assets/{TimetableView-7V2AwWml.js → TimetableView-BOLB-kZC.js} +4 -4
  37. package/dist/assets/index-Bc9003lB.js +131 -0
  38. package/dist/assets/index-D4aq9poS.css +1 -0
  39. package/dist/assets/pluginParsers-DD3QL1Xl.js +1 -0
  40. package/dist/assets/{student-workspace-DstiW0pM.js → student-workspace-C4UgD58C.js} +1 -1
  41. package/dist/assets/{vendor-icons-DLOPlM4i.js → vendor-icons-CaXLq01o.js} +1 -1
  42. package/dist/index.html +3 -3
  43. package/dist/server.cjs +630 -433
  44. package/migrations/045_ai_providers_owner_and_scope.sql +20 -0
  45. package/migrations/README.md +1 -0
  46. package/migrations/schema-golden.json +13 -3
  47. package/package.json +4 -2
  48. package/dist/assets/AdminDirectoryView-Cdsk9Mdr.js +0 -5
  49. package/dist/assets/ImportModal-h5ShIMzO.js +0 -2
  50. package/dist/assets/InteractiveWhiteboard-DJqgP_tU.js +0 -65
  51. package/dist/assets/LiveClassroomView-D2ugupT9.js +0 -61
  52. package/dist/assets/index-C4cHQZyx.css +0 -1
  53. package/dist/assets/index-XduPa2-x.js +0 -131
  54. package/dist/assets/pluginParsers-BO6U1E-h.js +0 -1
package/dist/server.cjs CHANGED
@@ -3304,6 +3304,95 @@ var init_ai_event_bus = __esm({
3304
3304
  }
3305
3305
  });
3306
3306
 
3307
+ // packages/core/di/ai-provider-resolver.ts
3308
+ function hasOwnerColumns(db2) {
3309
+ try {
3310
+ const cols = db2.prepare("PRAGMA table_info(ai_providers)").all();
3311
+ return cols.some((c) => c.name === "owner_id");
3312
+ } catch {
3313
+ return false;
3314
+ }
3315
+ }
3316
+ function resolveAccessibleAiProvider(db2, options = {}) {
3317
+ const { providerId, userId, lang = "zh" } = options;
3318
+ const supportsOwner = hasOwnerColumns(db2);
3319
+ let provider;
3320
+ if (providerId) {
3321
+ const row = supportsOwner ? db2.prepare(
3322
+ "SELECT id, name, api_url, api_key, model_name, owner_id, is_shared, created_at, updated_at FROM ai_providers WHERE id = ?"
3323
+ ).get(providerId) : db2.prepare("SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE id = ?").get(providerId);
3324
+ if (!row) {
3325
+ throw new AiProviderError(
3326
+ lang === "zh" ? "\u672A\u627E\u5230\u6307\u5B9A\u7684 AI \u63D0\u4F9B\u5546" : "Specified AI Provider not found",
3327
+ 404,
3328
+ "PROVIDER_NOT_FOUND"
3329
+ );
3330
+ }
3331
+ if (supportsOwner) {
3332
+ const isShared = Number(row.is_shared) === 1 || row.owner_id === null;
3333
+ const isOwner = Boolean(userId && row.owner_id === userId);
3334
+ if (!isShared && !isOwner) {
3335
+ throw new AiProviderError(
3336
+ lang === "zh" ? "\u65E0\u6743\u4F7F\u7528\u8BE5 AI \u63D0\u4F9B\u5546\uFF08\u975E\u672C\u4EBA\u79C1\u6709\u4E14\u672A\u5171\u4EAB\uFF09" : "Forbidden: You do not have access to this private AI provider",
3337
+ 403,
3338
+ "PROVIDER_ACCESS_DENIED"
3339
+ );
3340
+ }
3341
+ }
3342
+ provider = row;
3343
+ } else {
3344
+ if (!supportsOwner) {
3345
+ provider = db2.prepare(
3346
+ "SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE api_key IS NOT NULL AND api_key != '' ORDER BY created_at DESC LIMIT 1"
3347
+ ).get();
3348
+ } else if (userId) {
3349
+ provider = db2.prepare(
3350
+ `SELECT id, name, api_url, api_key, model_name, owner_id, is_shared, created_at, updated_at
3351
+ FROM ai_providers
3352
+ WHERE (owner_id = ? OR is_shared = 1 OR owner_id IS NULL)
3353
+ AND api_key IS NOT NULL AND api_key != ''
3354
+ ORDER BY (CASE WHEN owner_id = ? THEN 0 WHEN is_shared = 1 THEN 1 ELSE 2 END), created_at DESC
3355
+ LIMIT 1`
3356
+ ).get(userId, userId);
3357
+ } else {
3358
+ provider = db2.prepare(
3359
+ `SELECT id, name, api_url, api_key, model_name, owner_id, is_shared, created_at, updated_at
3360
+ FROM ai_providers
3361
+ WHERE (is_shared = 1 OR owner_id IS NULL)
3362
+ AND api_key IS NOT NULL AND api_key != ''
3363
+ ORDER BY (CASE WHEN is_shared = 1 THEN 0 ELSE 1 END), created_at DESC
3364
+ LIMIT 1`
3365
+ ).get();
3366
+ }
3367
+ }
3368
+ if (provider?.api_key) {
3369
+ provider.api_key = decryptApiKey(provider.api_key);
3370
+ }
3371
+ if (!provider || !provider.api_key || !provider.api_key.trim()) {
3372
+ throw new AiProviderError(
3373
+ lang === "zh" ? "\u672A\u68C0\u6D4B\u5230\u53EF\u7528\u7684 AI \u63D0\u4F9B\u5546\u3002\u8BF7\u524D\u5F80\u914D\u7F6E\u5927\u6A21\u578B\u670D\u52A1\u3002" : "No active AI Provider configured. Please add and configure an AI Provider in settings.",
3374
+ 400,
3375
+ "NO_AI_PROVIDER"
3376
+ );
3377
+ }
3378
+ return provider;
3379
+ }
3380
+ var AiProviderError;
3381
+ var init_ai_provider_resolver = __esm({
3382
+ "packages/core/di/ai-provider-resolver.ts"() {
3383
+ "use strict";
3384
+ init_api_key_crypto();
3385
+ AiProviderError = class extends Error {
3386
+ constructor(message, status = 400, code = "AI_PROVIDER_ERROR") {
3387
+ super(message);
3388
+ this.name = "AiProviderError";
3389
+ this.status = status;
3390
+ this.code = code;
3391
+ }
3392
+ };
3393
+ }
3394
+ });
3395
+
3307
3396
  // packages/core/di/ai-service.ts
3308
3397
  var AIService;
3309
3398
  var init_ai_service = __esm({
@@ -3312,6 +3401,7 @@ var init_ai_service = __esm({
3312
3401
  init_provider_gateway();
3313
3402
  init_ai_event_bus();
3314
3403
  init_api_key_crypto();
3404
+ init_ai_provider_resolver();
3315
3405
  AIService = class {
3316
3406
  constructor(db2) {
3317
3407
  this.db = db2;
@@ -3359,16 +3449,11 @@ var init_ai_service = __esm({
3359
3449
  this._contextDelegates.unregister?.(id);
3360
3450
  }
3361
3451
  async generateText(prompt, options) {
3362
- const provider = this.db.prepare(
3363
- "SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE api_key IS NOT NULL AND api_key != '' LIMIT 1"
3364
- ).get();
3365
- if (!provider || !provider.api_key) {
3366
- throw new Error("\u672A\u68C0\u6D4B\u5230\u53EF\u7528\u7684 AI \u63D0\u4F9B\u5546\u3002\u8BF7\u524D\u5F80\u300C\u7CFB\u7EDF\u7BA1\u7406 -> AI \u63D0\u4F9B\u5546\u7BA1\u7406\u300D\u6DFB\u52A0\u5E76\u914D\u7F6E\u5927\u6A21\u578B\u670D\u52A1\u3002");
3367
- }
3368
- const apiKey = decryptApiKey(provider.api_key);
3369
- if (!apiKey || !apiKey.trim()) {
3370
- throw new Error("AI Provider API Key \u89E3\u5BC6\u4E3A\u7A7A\uFF0C\u8BF7\u524D\u5F80\u300C\u7CFB\u7EDF\u7BA1\u7406 -> AI \u63D0\u4F9B\u5546\u7BA1\u7406\u300D\u91CD\u65B0\u4FDD\u5B58\u5BC6\u94A5\u3002");
3371
- }
3452
+ const provider = resolveAccessibleAiProvider(this.db, {
3453
+ userId: options?.userId,
3454
+ providerId: options?.providerId
3455
+ });
3456
+ const apiKey = provider.api_key;
3372
3457
  if (looksLikeCiphertext(apiKey)) {
3373
3458
  throw new Error(
3374
3459
  "AI Provider API Key \u89E3\u5BC6\u5931\u8D25\uFF1AENCRYPTION_KEY \u4E0E\u52A0\u5BC6\u65F6\u4E0D\u4E00\u81F4\uFF08\u5E38\u89C1\u4E8E PM2 \u5C06 ENCRYPTION_KEY \u7F6E\u7A7A\u6216\u5BC6\u94A5\u88AB\u8F6E\u6362\uFF09\u3002\u8BF7\u6062\u590D\u539F ENCRYPTION_KEY\uFF0C\u6216\u524D\u5F80\u300C\u7CFB\u7EDF\u7BA1\u7406 -> AI \u63D0\u4F9B\u5546\u7BA1\u7406\u300D\u91CD\u65B0\u4FDD\u5B58 API Key\u3002"
@@ -9211,6 +9296,7 @@ var init_di = __esm({
9211
9296
  init_storage_service();
9212
9297
  init_ai_service();
9213
9298
  init_courseware_runtime_script_registry();
9299
+ init_ai_provider_resolver();
9214
9300
  init_container();
9215
9301
  }
9216
9302
  });
@@ -13409,8 +13495,8 @@ var ManagementPlugin = {
13409
13495
  });
13410
13496
  await commandBus.registerHandler(classTemplateDownloadCmd, {
13411
13497
  async execute() {
13412
- const headers = "Class Name,Class Desc,Student Name,Student Email";
13413
- const sampleRow = "Class 101,Introduction to English,John Doe,john@example.com\nClass 101,Introduction to English,Jane Smith,jane@example.com";
13498
+ const headers = "Class Name,Class Desc,Student Number,Student Name,Student Email,Initial Password,Student Notes,Gender";
13499
+ const sampleRow = "Class 101,Introduction to English,20260101,John Doe,john@example.com,,,Male\nClass 101,Introduction to English,20260102,Jane Smith,jane@example.com,,,Female";
13414
13500
  const content = `${headers}
13415
13501
  ${sampleRow}`;
13416
13502
  const fileId = (0, import_uuid4.v7)();
@@ -13442,8 +13528,8 @@ ${sampleRow}`;
13442
13528
  });
13443
13529
  await commandBus.registerHandler(studentTemplateDownloadCmd, {
13444
13530
  async execute() {
13445
- const headers = "Student Name,Student Email";
13446
- const sampleRow = "Alice Cooper,alice@example.com\nBob Dylan,bob@example.com";
13531
+ const headers = "Student Number,Student Name,Student Email,Initial Password,Student Notes,Gender";
13532
+ const sampleRow = "20260101,Alice Cooper,alice@example.com,,,Male\n20260102,Bob Dylan,bob@example.com,,,Female";
13447
13533
  const content = `${headers}
13448
13534
  ${sampleRow}`;
13449
13535
  const fileId = (0, import_uuid4.v7)();
@@ -21204,7 +21290,7 @@ var import_node_fs11 = __toESM(require("node:fs"), 1);
21204
21290
  var import_node_path13 = __toESM(require("node:path"), 1);
21205
21291
  var import_node_url5 = require("node:url");
21206
21292
  var import_meta7 = {};
21207
- var FALLBACK_VERSION = "0.7.10";
21293
+ var FALLBACK_VERSION = "0.7.12";
21208
21294
  function readPlatformVersion() {
21209
21295
  const bases = [];
21210
21296
  if (typeof __dirname !== "undefined") bases.push(__dirname);
@@ -32769,6 +32855,299 @@ var import_helmet = __toESM(require("helmet"), 1);
32769
32855
 
32770
32856
  // server/security/rate-limit.ts
32771
32857
  var import_express_rate_limit = __toESM(require("express-rate-limit"), 1);
32858
+
32859
+ // server/security/route-scope.ts
32860
+ var AUTH_MIDDLEWARE_MARKER = Symbol.for("openlearn.routeScope.authMiddleware");
32861
+ var DATA_SCOPE_MARKER = Symbol.for("openlearn.routeScope.dataScope");
32862
+ function markAuthMiddleware(middleware, roles) {
32863
+ Object.defineProperty(middleware, AUTH_MIDDLEWARE_MARKER, {
32864
+ value: { roles: [...roles] },
32865
+ enumerable: false,
32866
+ configurable: true
32867
+ });
32868
+ return middleware;
32869
+ }
32870
+ function markDataScopeMiddleware(middleware, scope) {
32871
+ Object.defineProperty(middleware, DATA_SCOPE_MARKER, {
32872
+ value: scope,
32873
+ enumerable: false,
32874
+ configurable: true
32875
+ });
32876
+ return middleware;
32877
+ }
32878
+ function readMarker(handle, marker) {
32879
+ if (typeof handle !== "function") return void 0;
32880
+ return handle[marker];
32881
+ }
32882
+ function collectAuthRoutes(app) {
32883
+ const router = app._router ?? app.router;
32884
+ const stack = router?.stack ?? [];
32885
+ const result = [];
32886
+ for (const layer of stack) {
32887
+ const route = layer?.route;
32888
+ if (!route) continue;
32889
+ const handles = (route.stack ?? []).map((entry) => entry.handle);
32890
+ const authMeta = handles.map((handle) => readMarker(handle, AUTH_MIDDLEWARE_MARKER)).find(Boolean);
32891
+ if (!authMeta) continue;
32892
+ const scope = handles.map((handle) => readMarker(handle, DATA_SCOPE_MARKER)).find(Boolean);
32893
+ const methods = Object.keys(route.methods ?? {}).filter((method) => method !== "_all" && route.methods[method]).map((method) => method.toUpperCase());
32894
+ for (const method of methods) {
32895
+ result.push({
32896
+ method,
32897
+ path: String(route.path),
32898
+ scope,
32899
+ roles: [...authMeta.roles]
32900
+ });
32901
+ }
32902
+ }
32903
+ return result;
32904
+ }
32905
+ function isWhitelisted(route, whitelist) {
32906
+ return whitelist.some(
32907
+ (entry) => entry.method.toUpperCase() === route.method && normalizePath2(entry.path) === normalizePath2(route.path)
32908
+ );
32909
+ }
32910
+ function normalizePath2(path48) {
32911
+ if (!path48) return "/";
32912
+ return path48.endsWith("/") && path48.length > 1 ? path48.slice(0, -1) : path48;
32913
+ }
32914
+ function auditRouteScopes(app, whitelist = []) {
32915
+ const declared = [];
32916
+ const roleGated = [];
32917
+ const undeclared = [];
32918
+ for (const route of collectAuthRoutes(app)) {
32919
+ if (route.scope) {
32920
+ declared.push(route);
32921
+ continue;
32922
+ }
32923
+ if (route.roles.length > 0) {
32924
+ roleGated.push(route);
32925
+ continue;
32926
+ }
32927
+ if (isWhitelisted(route, whitelist)) continue;
32928
+ undeclared.push(route);
32929
+ }
32930
+ return { declared, roleGated, undeclared };
32931
+ }
32932
+ function formatRouteScopeViolation(route) {
32933
+ return `${route.method} ${route.path}`;
32934
+ }
32935
+ function assertRouteDataScopes(app, options = {}) {
32936
+ const logger2 = options.logger ?? console;
32937
+ const audit = auditRouteScopes(app, options.whitelist ?? []);
32938
+ if (audit.undeclared.length > 0) {
32939
+ const lines = audit.undeclared.map(formatRouteScopeViolation);
32940
+ const message = `[RouteScope] ${audit.undeclared.length} \u4E2A\u5DF2\u8BA4\u8BC1\u8DEF\u7531\u672A\u58F0\u660E\u6570\u636E\u8303\u56F4\uFF08requireScopedAuth\uFF09\uFF1A
32941
+ - ${lines.join("\n - ")}`;
32942
+ if (options.enforce) {
32943
+ logger2.error(message);
32944
+ throw new Error(message);
32945
+ }
32946
+ logger2.warn(message);
32947
+ }
32948
+ if (audit.declared.length > 0) {
32949
+ logger2.info?.(
32950
+ `[RouteScope] \u5DF2\u58F0\u660E\u6570\u636E\u8303\u56F4 ${audit.declared.length} \u6761\uFF0C\u89D2\u8272\u95E8\u7981\u8DEF\u7531 ${audit.roleGated.length} \u6761\uFF0C\u672A\u58F0\u660E ${audit.undeclared.length} \u6761\uFF08\u767D\u540D\u5355 ${options.whitelist?.length ?? 0} \u6761\uFF09\u3002`
32951
+ );
32952
+ }
32953
+ return audit;
32954
+ }
32955
+
32956
+ // server/utils/password-gate.ts
32957
+ var PASSWORD_GATE_SKIP_ENV = "OPENLEARN_SKIP_PASSWORD_CHANGE_GATE";
32958
+ var PASSWORD_GATE_SKIP_ENV_ALIAS = "DEV_BYPASS_PASSWORD_CHANGE_GATE";
32959
+ var productionWarningEmitted = false;
32960
+ function isTruthyFlag2(value) {
32961
+ if (typeof value !== "string") return false;
32962
+ const normalized = value.trim().toLowerCase();
32963
+ return normalized === "1" || normalized === "true";
32964
+ }
32965
+ function isPasswordChangeGateSkipped(env = process.env) {
32966
+ const requested = isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV]) || isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV_ALIAS]);
32967
+ if (!requested) return false;
32968
+ if ((env.NODE_ENV ?? "") === "production") {
32969
+ if (!productionWarningEmitted) {
32970
+ productionWarningEmitted = true;
32971
+ console.warn(
32972
+ `[SECURITY] ${PASSWORD_GATE_SKIP_ENV}/${PASSWORD_GATE_SKIP_ENV_ALIAS} \u5DF2\u914D\u7F6E\uFF0C\u4F46\u751F\u4EA7\u73AF\u5883\uFF08NODE_ENV=production\uFF09\u4E25\u683C\u5FFD\u7565\u8BE5\u5F00\u5173\uFF1A\u5F3A\u5236\u6539\u5BC6\u9632\u7EBF\u4E0D\u5931\u6548\u3002\u8BF7\u7ACB\u5373\u4ECE\u751F\u4EA7\u73AF\u5883\u79FB\u9664\u8BE5\u53D8\u91CF\u3002`
32973
+ );
32974
+ }
32975
+ return false;
32976
+ }
32977
+ return true;
32978
+ }
32979
+
32980
+ // server/services/session-role.ts
32981
+ var SESSION_ROLE_WHITELIST = /* @__PURE__ */ new Set(["administrator", "teacher", "student", "anonymous"]);
32982
+ function resolveSessionRole(session) {
32983
+ if (!session) return "anonymous";
32984
+ for (const candidate of [session.subRole, session.role]) {
32985
+ if (typeof candidate !== "string" || candidate.length === 0) continue;
32986
+ const normalized = candidate === "admin" ? "administrator" : candidate;
32987
+ if (SESSION_ROLE_WHITELIST.has(normalized)) return normalized;
32988
+ }
32989
+ return "anonymous";
32990
+ }
32991
+ function isAdministrator(session) {
32992
+ return resolveSessionRole(session) === "administrator";
32993
+ }
32994
+
32995
+ // server/middleware/auth.ts
32996
+ var SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
32997
+ function getValidSession(token) {
32998
+ let sessionRow;
32999
+ try {
33000
+ sessionRow = kernelContainer.db.prepare("SELECT * FROM client_sessions WHERE id = ?").get(token);
33001
+ } catch {
33002
+ return null;
33003
+ }
33004
+ if (!sessionRow) return null;
33005
+ const now = Date.now();
33006
+ if (sessionRow.expires_at && sessionRow.expires_at < now) {
33007
+ kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
33008
+ return null;
33009
+ }
33010
+ const idleTimeout = 24 * 60 * 60 * 1e3;
33011
+ if (sessionRow.updated_at && now - sessionRow.updated_at > idleTimeout) {
33012
+ kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
33013
+ return null;
33014
+ }
33015
+ if (!sessionRow.updated_at || now - sessionRow.updated_at > SESSION_REFRESH_INTERVAL_MS) {
33016
+ kernelContainer.db.prepare("UPDATE client_sessions SET updated_at = ? WHERE id = ?").run(now, token);
33017
+ }
33018
+ let session;
33019
+ try {
33020
+ session = JSON.parse(sessionRow.session_data);
33021
+ } catch {
33022
+ return null;
33023
+ }
33024
+ if (session && !session.userId && session.studentId) {
33025
+ session.userId = session.studentId;
33026
+ }
33027
+ if (session.role && !["teacher", "student", "administrator", "admin"].includes(session.role)) {
33028
+ session.role = "anonymous";
33029
+ }
33030
+ return session;
33031
+ }
33032
+ function getCookieToken(req) {
33033
+ const rc = req.headers.cookie;
33034
+ if (!rc) return null;
33035
+ const parts = rc.split(";");
33036
+ for (const part of parts) {
33037
+ const trimmed2 = part.trim();
33038
+ if (trimmed2.startsWith("edu_os_token=")) {
33039
+ return trimmed2.substring("edu_os_token=".length);
33040
+ }
33041
+ }
33042
+ return null;
33043
+ }
33044
+ function getActorId(req) {
33045
+ const token = getCookieToken(req);
33046
+ if (!token) return "anonymous";
33047
+ try {
33048
+ const session = getValidSession(token);
33049
+ if (!session) return "anonymous";
33050
+ const role = resolveSessionRole(session);
33051
+ const rawUserId = session.userId || session.studentId || "demo";
33052
+ const safeUserId = String(rawUserId).replace(/[:\s]/g, "_").slice(0, 64);
33053
+ return `user:${safeUserId}:${role}`;
33054
+ } catch {
33055
+ return "anonymous";
33056
+ }
33057
+ }
33058
+ function requireAuth(...roles) {
33059
+ const middleware = (req, res, next) => {
33060
+ const token = getCookieToken(req);
33061
+ if (!token) return res.status(401).json({ success: false, error: "Authentication required" });
33062
+ const session = getValidSession(token);
33063
+ if (!session) return res.status(401).json({ success: false, error: "Session expired or invalid" });
33064
+ if (roles.length > 0) {
33065
+ const userRole = resolveSessionRole(session);
33066
+ const effectiveRoles = roles.map((r) => r === "admin" ? "administrator" : r);
33067
+ if (!effectiveRoles.includes(userRole)) {
33068
+ return res.status(403).json({ success: false, error: `Role ${session.role} not allowed. Required: ${roles.join(", ")}` });
33069
+ }
33070
+ }
33071
+ req.session = session;
33072
+ next();
33073
+ };
33074
+ return markAuthMiddleware(middleware, roles);
33075
+ }
33076
+ var PASSWORD_CHANGE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
33077
+ "/api/auth/change-password",
33078
+ "/api/auth/logout",
33079
+ "/api/auth/session",
33080
+ "/api/auth/me",
33081
+ "/api/client-errors"
33082
+ ]);
33083
+ function enforcePasswordChanged(req, res, next) {
33084
+ if (process.env.PLAYWRIGHT_TEST) {
33085
+ return next();
33086
+ }
33087
+ if (isPasswordChangeGateSkipped()) {
33088
+ return next();
33089
+ }
33090
+ const path48 = req.path || "";
33091
+ if (!path48.startsWith("/api/")) {
33092
+ return next();
33093
+ }
33094
+ if (PASSWORD_CHANGE_EXEMPT_PATHS.has(path48)) {
33095
+ return next();
33096
+ }
33097
+ const token = getCookieToken(req);
33098
+ if (token) {
33099
+ const session = getValidSession(token);
33100
+ if (session && session.mustChangePassword) {
33101
+ res.status(403).json({
33102
+ success: false,
33103
+ code: "FORBIDDEN_DEFAULT_PASSWORD",
33104
+ error: "Default password must be changed first"
33105
+ });
33106
+ return;
33107
+ }
33108
+ }
33109
+ next();
33110
+ }
33111
+ function socketAuthMiddleware(socket, next) {
33112
+ try {
33113
+ const cookieHeader = socket.handshake.headers.cookie;
33114
+ let token = null;
33115
+ if (cookieHeader) {
33116
+ const parts = cookieHeader.split(";");
33117
+ for (const part of parts) {
33118
+ const trimmed2 = part.trim();
33119
+ if (trimmed2.startsWith("edu_os_token=")) {
33120
+ token = trimmed2.substring("edu_os_token=".length);
33121
+ break;
33122
+ }
33123
+ }
33124
+ }
33125
+ if (!token && socket.handshake.auth?.token) {
33126
+ token = socket.handshake.auth.token;
33127
+ }
33128
+ if (process.env.NODE_ENV === "test" && !token) {
33129
+ return next();
33130
+ }
33131
+ if (!token) {
33132
+ return next(new Error("Authentication required: missing edu_os_token"));
33133
+ }
33134
+ const session = getValidSession(token);
33135
+ if (!session) {
33136
+ return next(new Error("Authentication required: session expired or invalid"));
33137
+ }
33138
+ if (session.mustChangePassword && !isPasswordChangeGateSkipped()) {
33139
+ return next(new Error("FORBIDDEN_DEFAULT_PASSWORD"));
33140
+ }
33141
+ socket.data.session = session;
33142
+ socket.data.userId = session.userId;
33143
+ socket.data.role = session.role;
33144
+ next();
33145
+ } catch (err) {
33146
+ next(new Error(`Authentication error: ${err.message}`));
33147
+ }
33148
+ }
33149
+
33150
+ // server/security/rate-limit.ts
32772
33151
  function loginRateLimitKey(req) {
32773
33152
  const body = req.body ?? {};
32774
33153
  const raw = body.username ?? body.studentId ?? body.studentName ?? "";
@@ -32785,6 +33164,18 @@ function writeRateLimitKey(req) {
32785
33164
  return ip;
32786
33165
  }
32787
33166
  }
33167
+ function sessionRateLimitKey(req) {
33168
+ try {
33169
+ const token = getCookieToken(req);
33170
+ const session = token ? getValidSession(token) : null;
33171
+ if (session) {
33172
+ const identity = String(session.userId || session.studentId || session.username || "").trim();
33173
+ if (identity) return `session:${identity.slice(0, 64)}`;
33174
+ }
33175
+ } catch {
33176
+ }
33177
+ return `ip:${writeRateLimitKey(req)}`;
33178
+ }
32788
33179
  var limiterValidation = {
32789
33180
  xForwardedForHeader: false,
32790
33181
  trustProxy: false,
@@ -32823,6 +33214,17 @@ function createAiLimiter(max) {
32823
33214
  validate: limiterValidation
32824
33215
  });
32825
33216
  }
33217
+ function createSessionWriteLimiter(max) {
33218
+ return (0, import_express_rate_limit.default)({
33219
+ windowMs: 60 * 1e3,
33220
+ max,
33221
+ keyGenerator: sessionRateLimitKey,
33222
+ message: { error: "\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002Too many requests, please try again later." },
33223
+ standardHeaders: true,
33224
+ legacyHeaders: false,
33225
+ validate: limiterValidation
33226
+ });
33227
+ }
32826
33228
 
32827
33229
  // server/security/trust-proxy.ts
32828
33230
  function resolveTrustProxy(raw) {
@@ -34199,297 +34601,6 @@ function startBackupScheduler(deps) {
34199
34601
  };
34200
34602
  }
34201
34603
 
34202
- // server/security/route-scope.ts
34203
- var AUTH_MIDDLEWARE_MARKER = Symbol.for("openlearn.routeScope.authMiddleware");
34204
- var DATA_SCOPE_MARKER = Symbol.for("openlearn.routeScope.dataScope");
34205
- function markAuthMiddleware(middleware, roles) {
34206
- Object.defineProperty(middleware, AUTH_MIDDLEWARE_MARKER, {
34207
- value: { roles: [...roles] },
34208
- enumerable: false,
34209
- configurable: true
34210
- });
34211
- return middleware;
34212
- }
34213
- function markDataScopeMiddleware(middleware, scope) {
34214
- Object.defineProperty(middleware, DATA_SCOPE_MARKER, {
34215
- value: scope,
34216
- enumerable: false,
34217
- configurable: true
34218
- });
34219
- return middleware;
34220
- }
34221
- function readMarker(handle, marker) {
34222
- if (typeof handle !== "function") return void 0;
34223
- return handle[marker];
34224
- }
34225
- function collectAuthRoutes(app) {
34226
- const router = app._router ?? app.router;
34227
- const stack = router?.stack ?? [];
34228
- const result = [];
34229
- for (const layer of stack) {
34230
- const route = layer?.route;
34231
- if (!route) continue;
34232
- const handles = (route.stack ?? []).map((entry) => entry.handle);
34233
- const authMeta = handles.map((handle) => readMarker(handle, AUTH_MIDDLEWARE_MARKER)).find(Boolean);
34234
- if (!authMeta) continue;
34235
- const scope = handles.map((handle) => readMarker(handle, DATA_SCOPE_MARKER)).find(Boolean);
34236
- const methods = Object.keys(route.methods ?? {}).filter((method) => method !== "_all" && route.methods[method]).map((method) => method.toUpperCase());
34237
- for (const method of methods) {
34238
- result.push({
34239
- method,
34240
- path: String(route.path),
34241
- scope,
34242
- roles: [...authMeta.roles]
34243
- });
34244
- }
34245
- }
34246
- return result;
34247
- }
34248
- function isWhitelisted(route, whitelist) {
34249
- return whitelist.some(
34250
- (entry) => entry.method.toUpperCase() === route.method && normalizePath2(entry.path) === normalizePath2(route.path)
34251
- );
34252
- }
34253
- function normalizePath2(path48) {
34254
- if (!path48) return "/";
34255
- return path48.endsWith("/") && path48.length > 1 ? path48.slice(0, -1) : path48;
34256
- }
34257
- function auditRouteScopes(app, whitelist = []) {
34258
- const declared = [];
34259
- const roleGated = [];
34260
- const undeclared = [];
34261
- for (const route of collectAuthRoutes(app)) {
34262
- if (route.scope) {
34263
- declared.push(route);
34264
- continue;
34265
- }
34266
- if (route.roles.length > 0) {
34267
- roleGated.push(route);
34268
- continue;
34269
- }
34270
- if (isWhitelisted(route, whitelist)) continue;
34271
- undeclared.push(route);
34272
- }
34273
- return { declared, roleGated, undeclared };
34274
- }
34275
- function formatRouteScopeViolation(route) {
34276
- return `${route.method} ${route.path}`;
34277
- }
34278
- function assertRouteDataScopes(app, options = {}) {
34279
- const logger2 = options.logger ?? console;
34280
- const audit = auditRouteScopes(app, options.whitelist ?? []);
34281
- if (audit.undeclared.length > 0) {
34282
- const lines = audit.undeclared.map(formatRouteScopeViolation);
34283
- const message = `[RouteScope] ${audit.undeclared.length} \u4E2A\u5DF2\u8BA4\u8BC1\u8DEF\u7531\u672A\u58F0\u660E\u6570\u636E\u8303\u56F4\uFF08requireScopedAuth\uFF09\uFF1A
34284
- - ${lines.join("\n - ")}`;
34285
- if (options.enforce) {
34286
- logger2.error(message);
34287
- throw new Error(message);
34288
- }
34289
- logger2.warn(message);
34290
- }
34291
- if (audit.declared.length > 0) {
34292
- logger2.info?.(
34293
- `[RouteScope] \u5DF2\u58F0\u660E\u6570\u636E\u8303\u56F4 ${audit.declared.length} \u6761\uFF0C\u89D2\u8272\u95E8\u7981\u8DEF\u7531 ${audit.roleGated.length} \u6761\uFF0C\u672A\u58F0\u660E ${audit.undeclared.length} \u6761\uFF08\u767D\u540D\u5355 ${options.whitelist?.length ?? 0} \u6761\uFF09\u3002`
34294
- );
34295
- }
34296
- return audit;
34297
- }
34298
-
34299
- // server/utils/password-gate.ts
34300
- var PASSWORD_GATE_SKIP_ENV = "OPENLEARN_SKIP_PASSWORD_CHANGE_GATE";
34301
- var PASSWORD_GATE_SKIP_ENV_ALIAS = "DEV_BYPASS_PASSWORD_CHANGE_GATE";
34302
- var productionWarningEmitted = false;
34303
- function isTruthyFlag2(value) {
34304
- if (typeof value !== "string") return false;
34305
- const normalized = value.trim().toLowerCase();
34306
- return normalized === "1" || normalized === "true";
34307
- }
34308
- function isPasswordChangeGateSkipped(env = process.env) {
34309
- const requested = isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV]) || isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV_ALIAS]);
34310
- if (!requested) return false;
34311
- if ((env.NODE_ENV ?? "") === "production") {
34312
- if (!productionWarningEmitted) {
34313
- productionWarningEmitted = true;
34314
- console.warn(
34315
- `[SECURITY] ${PASSWORD_GATE_SKIP_ENV}/${PASSWORD_GATE_SKIP_ENV_ALIAS} \u5DF2\u914D\u7F6E\uFF0C\u4F46\u751F\u4EA7\u73AF\u5883\uFF08NODE_ENV=production\uFF09\u4E25\u683C\u5FFD\u7565\u8BE5\u5F00\u5173\uFF1A\u5F3A\u5236\u6539\u5BC6\u9632\u7EBF\u4E0D\u5931\u6548\u3002\u8BF7\u7ACB\u5373\u4ECE\u751F\u4EA7\u73AF\u5883\u79FB\u9664\u8BE5\u53D8\u91CF\u3002`
34316
- );
34317
- }
34318
- return false;
34319
- }
34320
- return true;
34321
- }
34322
-
34323
- // server/services/session-role.ts
34324
- var SESSION_ROLE_WHITELIST = /* @__PURE__ */ new Set(["administrator", "teacher", "student", "anonymous"]);
34325
- function resolveSessionRole(session) {
34326
- if (!session) return "anonymous";
34327
- for (const candidate of [session.subRole, session.role]) {
34328
- if (typeof candidate !== "string" || candidate.length === 0) continue;
34329
- const normalized = candidate === "admin" ? "administrator" : candidate;
34330
- if (SESSION_ROLE_WHITELIST.has(normalized)) return normalized;
34331
- }
34332
- return "anonymous";
34333
- }
34334
- function isAdministrator(session) {
34335
- return resolveSessionRole(session) === "administrator";
34336
- }
34337
-
34338
- // server/middleware/auth.ts
34339
- var SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
34340
- function getValidSession(token) {
34341
- let sessionRow;
34342
- try {
34343
- sessionRow = kernelContainer.db.prepare("SELECT * FROM client_sessions WHERE id = ?").get(token);
34344
- } catch {
34345
- return null;
34346
- }
34347
- if (!sessionRow) return null;
34348
- const now = Date.now();
34349
- if (sessionRow.expires_at && sessionRow.expires_at < now) {
34350
- kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
34351
- return null;
34352
- }
34353
- const idleTimeout = 24 * 60 * 60 * 1e3;
34354
- if (sessionRow.updated_at && now - sessionRow.updated_at > idleTimeout) {
34355
- kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
34356
- return null;
34357
- }
34358
- if (!sessionRow.updated_at || now - sessionRow.updated_at > SESSION_REFRESH_INTERVAL_MS) {
34359
- kernelContainer.db.prepare("UPDATE client_sessions SET updated_at = ? WHERE id = ?").run(now, token);
34360
- }
34361
- let session;
34362
- try {
34363
- session = JSON.parse(sessionRow.session_data);
34364
- } catch {
34365
- return null;
34366
- }
34367
- if (session && !session.userId && session.studentId) {
34368
- session.userId = session.studentId;
34369
- }
34370
- if (session.role && !["teacher", "student", "administrator", "admin"].includes(session.role)) {
34371
- session.role = "anonymous";
34372
- }
34373
- return session;
34374
- }
34375
- function getCookieToken(req) {
34376
- const rc = req.headers.cookie;
34377
- if (!rc) return null;
34378
- const parts = rc.split(";");
34379
- for (const part of parts) {
34380
- const trimmed2 = part.trim();
34381
- if (trimmed2.startsWith("edu_os_token=")) {
34382
- return trimmed2.substring("edu_os_token=".length);
34383
- }
34384
- }
34385
- return null;
34386
- }
34387
- function getActorId(req) {
34388
- const token = getCookieToken(req);
34389
- if (!token) return "anonymous";
34390
- try {
34391
- const session = getValidSession(token);
34392
- if (!session) return "anonymous";
34393
- const role = resolveSessionRole(session);
34394
- const rawUserId = session.userId || session.studentId || "demo";
34395
- const safeUserId = String(rawUserId).replace(/[:\s]/g, "_").slice(0, 64);
34396
- return `user:${safeUserId}:${role}`;
34397
- } catch {
34398
- return "anonymous";
34399
- }
34400
- }
34401
- function requireAuth(...roles) {
34402
- const middleware = (req, res, next) => {
34403
- const token = getCookieToken(req);
34404
- if (!token) return res.status(401).json({ success: false, error: "Authentication required" });
34405
- const session = getValidSession(token);
34406
- if (!session) return res.status(401).json({ success: false, error: "Session expired or invalid" });
34407
- if (roles.length > 0) {
34408
- const userRole = resolveSessionRole(session);
34409
- const effectiveRoles = roles.map((r) => r === "admin" ? "administrator" : r);
34410
- if (!effectiveRoles.includes(userRole)) {
34411
- return res.status(403).json({ success: false, error: `Role ${session.role} not allowed. Required: ${roles.join(", ")}` });
34412
- }
34413
- }
34414
- req.session = session;
34415
- next();
34416
- };
34417
- return markAuthMiddleware(middleware, roles);
34418
- }
34419
- var PASSWORD_CHANGE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
34420
- "/api/auth/change-password",
34421
- "/api/auth/logout",
34422
- "/api/auth/session",
34423
- "/api/auth/me",
34424
- "/api/client-errors"
34425
- ]);
34426
- function enforcePasswordChanged(req, res, next) {
34427
- if (process.env.PLAYWRIGHT_TEST) {
34428
- return next();
34429
- }
34430
- if (isPasswordChangeGateSkipped()) {
34431
- return next();
34432
- }
34433
- const path48 = req.path || "";
34434
- if (!path48.startsWith("/api/")) {
34435
- return next();
34436
- }
34437
- if (PASSWORD_CHANGE_EXEMPT_PATHS.has(path48)) {
34438
- return next();
34439
- }
34440
- const token = getCookieToken(req);
34441
- if (token) {
34442
- const session = getValidSession(token);
34443
- if (session && session.mustChangePassword) {
34444
- res.status(403).json({
34445
- success: false,
34446
- code: "FORBIDDEN_DEFAULT_PASSWORD",
34447
- error: "Default password must be changed first"
34448
- });
34449
- return;
34450
- }
34451
- }
34452
- next();
34453
- }
34454
- function socketAuthMiddleware(socket, next) {
34455
- try {
34456
- const cookieHeader = socket.handshake.headers.cookie;
34457
- let token = null;
34458
- if (cookieHeader) {
34459
- const parts = cookieHeader.split(";");
34460
- for (const part of parts) {
34461
- const trimmed2 = part.trim();
34462
- if (trimmed2.startsWith("edu_os_token=")) {
34463
- token = trimmed2.substring("edu_os_token=".length);
34464
- break;
34465
- }
34466
- }
34467
- }
34468
- if (!token && socket.handshake.auth?.token) {
34469
- token = socket.handshake.auth.token;
34470
- }
34471
- if (process.env.NODE_ENV === "test" && !token) {
34472
- return next();
34473
- }
34474
- if (!token) {
34475
- return next(new Error("Authentication required: missing edu_os_token"));
34476
- }
34477
- const session = getValidSession(token);
34478
- if (!session) {
34479
- return next(new Error("Authentication required: session expired or invalid"));
34480
- }
34481
- if (session.mustChangePassword && !isPasswordChangeGateSkipped()) {
34482
- return next(new Error("FORBIDDEN_DEFAULT_PASSWORD"));
34483
- }
34484
- socket.data.session = session;
34485
- socket.data.userId = session.userId;
34486
- socket.data.role = session.role;
34487
- next();
34488
- } catch (err) {
34489
- next(new Error(`Authentication error: ${err.message}`));
34490
- }
34491
- }
34492
-
34493
34604
  // server/middleware/csrf.ts
34494
34605
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
34495
34606
  var EXEMPT_PREFIXES = [
@@ -38406,9 +38517,12 @@ var generateStudentNumber = (db2) => {
38406
38517
  return `S${nextSeq.toString().padStart(3, "0")}`;
38407
38518
  };
38408
38519
 
38520
+ // server/services/ai-provider-resolver.ts
38521
+ init_ai_provider_resolver();
38522
+
38409
38523
  // server/routes/os.ts
38410
38524
  function registerOsRoutes(ctx) {
38411
- const { app, runGeminiAgentChat: runGeminiAgentChat2, runOpenAIAgentChat: runOpenAIAgentChat2, activityRegistry, aiLimiter } = ctx;
38525
+ const { app, runOpenAIAgentChat: runOpenAIAgentChat2, activityRegistry, aiLimiter } = ctx;
38412
38526
  app.get("/api/ping", (_req, res) => {
38413
38527
  res.json({ ok: true, ts: Date.now() });
38414
38528
  });
@@ -38627,13 +38741,10 @@ function registerOsRoutes(ctx) {
38627
38741
  }
38628
38742
  let callerRole;
38629
38743
  let userId;
38630
- const token = getCookieToken(req);
38631
- if (token) {
38632
- const session = getValidSession(token);
38633
- if (session) {
38634
- callerRole = session.role;
38635
- userId = session.userId;
38636
- }
38744
+ const session = req.session || (getCookieToken(req) ? getValidSession(getCookieToken(req)) : null);
38745
+ if (session) {
38746
+ callerRole = session.role;
38747
+ userId = session.userId || session.username;
38637
38748
  }
38638
38749
  const convKey = `agent:${userId || "anonymous"}:${currentLessonId || "global"}`;
38639
38750
  const MEMORY_TURNS = 20;
@@ -38643,19 +38754,17 @@ function registerOsRoutes(ctx) {
38643
38754
  content: r.content
38644
38755
  }));
38645
38756
  let provider;
38646
- if (providerId) {
38647
- provider = kernelContainer.db.prepare("SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE id = ?").get(providerId);
38648
- } else {
38649
- provider = kernelContainer.db.prepare(
38650
- "SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE api_key IS NOT NULL AND api_key != '' LIMIT 1"
38651
- ).get();
38652
- }
38653
- if (provider?.api_key) provider.api_key = decryptApiKey(provider.api_key);
38654
- if (!provider || !provider.api_key || !provider.api_key.trim()) {
38655
- return res.status(400).json({
38757
+ try {
38758
+ provider = resolveAccessibleAiProvider(kernelContainer.db, {
38759
+ providerId,
38760
+ userId,
38761
+ lang
38762
+ });
38763
+ } catch (err) {
38764
+ return res.status(err.status || 400).json({
38656
38765
  success: false,
38657
- error: "NO_AI_PROVIDER",
38658
- message: lang === "zh" ? "\u672A\u68C0\u6D4B\u5230\u53EF\u7528\u7684 AI \u63D0\u4F9B\u5546\u3002\u8BF7\u524D\u5F80\u300C\u7CFB\u7EDF\u7BA1\u7406 -> AI \u63D0\u4F9B\u5546\u7BA1\u7406\u300D\u6DFB\u52A0\u5E76\u914D\u7F6E\u5927\u6A21\u578B\u670D\u52A1\u3002" : 'No active AI Provider configured. Please add and configure an AI Provider in "System Management -> AI Provider Management".'
38766
+ error: err.code || "NO_AI_PROVIDER",
38767
+ message: err.message
38659
38768
  });
38660
38769
  }
38661
38770
  const actorId = getActorId(req);
@@ -42119,7 +42228,7 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
42119
42228
  * 的按学科硬编码字符串拼接,与课题内容无关。此方法用真实的 LLM 调用取代它。
42120
42229
  */
42121
42230
  async generateOutlineWithAi(params) {
42122
- const { title, category, description, timeline, lang = "zh" } = params;
42231
+ const { title, category, description, timeline, lang = "zh", providerId, userId } = params;
42123
42232
  const trimmedTitle = (title || "").trim();
42124
42233
  if (!trimmedTitle) {
42125
42234
  const err = new Error(lang === "zh" ? "\u8BFE\u7A0B\u8BFE\u9898\u4E0D\u80FD\u4E3A\u7A7A" : "Course title is required");
@@ -42159,7 +42268,12 @@ ${timelineText}` : "- \u8BFE\u5802\u65F6\u95F4\u8F74\u73AF\u8282\uFF1A\uFF08\u65
42159
42268
  - \`## \u{1F4E5} \u8BFE\u540E\u4F5C\u4E1A\`\uFF1A\u4E00\u4EFD\u660E\u786E\u7684\u8BFE\u540E\u4EFB\u52A1\u4E0E\u63D0\u4EA4\u8981\u6C42\u3002
42160
42269
  4. \u5185\u5BB9\u5FC5\u987B\u7D27\u6263\u8BFE\u9898\u672C\u8EAB\uFF0C\u7981\u6B62\u4F7F\u7528"\u516C\u5F0F A""\u6A21\u5757 1"\u4E4B\u7C7B\u7684\u5360\u4F4D\u7B26\u6216\u4E0E\u8BFE\u9898\u65E0\u5173\u7684\u901A\u7528\u5957\u8BDD\u3002
42161
42270
  5. \u7BC7\u5E45\u63A7\u5236\u5728 600-1000 \u5B57\uFF0C\u8BED\u8A00${lang === "zh" ? "\u4F7F\u7528\u7B80\u4F53\u4E2D\u6587" : "use English"}\u3002`;
42162
- const raw = await this.aiService.generateText(prompt, { temperature: 0.6, maxTokens: 2048 });
42271
+ const raw = await this.aiService.generateText(prompt, {
42272
+ temperature: 0.6,
42273
+ maxTokens: 2048,
42274
+ userId,
42275
+ providerId
42276
+ });
42163
42277
  const outline = stripReasoningArtifacts(stripOuterCodeFence(String(raw || "").trim()));
42164
42278
  if (!outline) {
42165
42279
  const err = new Error(
@@ -42423,13 +42537,17 @@ function registerLessonsRoutes(ctx) {
42423
42537
  safeAiLimiter,
42424
42538
  async (req, res) => {
42425
42539
  try {
42426
- const { title, category, description, timeline, lang } = req.body || {};
42540
+ const { title, category, description, timeline, lang, providerId } = req.body || {};
42541
+ const session = req.session;
42542
+ const userId = session?.userId || session?.username;
42427
42543
  const content = await lessonService.generateOutlineWithAi({
42428
42544
  title,
42429
42545
  category,
42430
42546
  description,
42431
42547
  timeline,
42432
- lang
42548
+ lang,
42549
+ providerId,
42550
+ userId
42433
42551
  });
42434
42552
  res.json({ success: true, content });
42435
42553
  } catch (e) {
@@ -48515,7 +48633,7 @@ var ScheduleService = class {
48515
48633
  * Timetable OCR 课表图像识别与数据清洗
48516
48634
  */
48517
48635
  async processTimetableOcr(imageBase64, options = {}) {
48518
- const { lang = "zh", providerId } = options;
48636
+ const { lang = "zh", providerId, userId } = options;
48519
48637
  if (!imageBase64) {
48520
48638
  throw new Error("imageBase64 is required");
48521
48639
  }
@@ -48546,21 +48664,11 @@ var ScheduleService = class {
48546
48664
 
48547
48665
  \u8FD4\u56DE\u683C\u5F0F\u793A\u4F8B\uFF1A
48548
48666
  [{"dayOfWeek":1,"periodNumber":1,"className":"\u9AD8\u4E00(13)","subject":"\u4FE1\u606F","timeSlot":"08:00-08:40","location":"312","teacherName":""}]`;
48549
- let provider;
48550
- if (providerId) {
48551
- provider = this.db.prepare("SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE id = ?").get(providerId);
48552
- }
48553
- if (!provider) {
48554
- provider = this.db.prepare(
48555
- "SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE api_key IS NOT NULL AND api_key != '' ORDER BY created_at DESC LIMIT 1"
48556
- ).get();
48557
- }
48558
- if (provider?.api_key) provider.api_key = decryptApiKey(provider.api_key);
48559
- if (!provider || !provider.api_key || !provider.api_key.trim()) {
48560
- throw new Error(
48561
- lang === "zh" ? "\u672A\u68C0\u6D4B\u5230\u53EF\u7528\u7684 AI \u63D0\u4F9B\u5546\u3002\u8BF7\u524D\u5F80\u300C\u7CFB\u7EDF\u7BA1\u7406 -> AI \u63D0\u4F9B\u5546\u7BA1\u7406\u300D\u6DFB\u52A0\u5E76\u914D\u7F6E\u5927\u6A21\u578B\u670D\u52A1\u3002" : 'No AI provider configured. Please add and configure an AI Provider in "System Management -> AI Provider Management".'
48562
- );
48563
- }
48667
+ const provider = resolveAccessibleAiProvider(this.db, {
48668
+ providerId,
48669
+ userId,
48670
+ lang
48671
+ });
48564
48672
  let chatUrl = provider.api_url.trim();
48565
48673
  if (!chatUrl.endsWith("/chat/completions")) {
48566
48674
  chatUrl = chatUrl.endsWith("/") ? chatUrl + "chat/completions" : chatUrl + "/chat/completions";
@@ -48776,7 +48884,9 @@ function registerSchedulesRoutes(ctx) {
48776
48884
  const startTime = Date.now();
48777
48885
  try {
48778
48886
  const { imageBase64, lang = "zh", providerId } = req.body;
48779
- const result = await scheduleService.processTimetableOcr(imageBase64, { lang, providerId });
48887
+ const session = req.session;
48888
+ const userId = session?.userId || session?.username;
48889
+ const result = await scheduleService.processTimetableOcr(imageBase64, { lang, providerId, userId });
48780
48890
  res.json({
48781
48891
  success: true,
48782
48892
  entries: result.entries,
@@ -49162,7 +49272,7 @@ var GradingService = class {
49162
49272
  }
49163
49273
  // ── 6. AI 学期综合总评评语生成 ────────────────────────────────────────────────
49164
49274
  async generateSemesterAiEvaluation(params) {
49165
- const { classId, studentId, semesterName = "2026\u5E74\u6625\u5B63\u5B66\u671F", providerId } = params;
49275
+ const { classId, studentId, semesterName = "2026\u5E74\u6625\u5B63\u5B66\u671F", providerId, userId } = params;
49166
49276
  const student = this.db.prepare("SELECT name FROM students WHERE id = ?").get(studentId);
49167
49277
  if (!student) {
49168
49278
  throw new Error("Student not found");
@@ -49220,13 +49330,10 @@ ${examsText}
49220
49330
  - \u6307\u51FA\u5176\u53EF\u4EE5\u6539\u8FDB\u7684\u5730\u65B9\uFF08\u5982\u8FDB\u5EA6\u843D\u540E\u3001\u8003\u8BD5\u53D1\u6325\u4E0D\u4F73\u7B49\uFF09\uFF0C\u8BED\u6C14\u8981\u975E\u5E38\u6E29\u67D4\u3001\u59D4\u5A49\uFF0C\u7ED9\u4E88\u5176\u4FE1\u5FC3\u3002
49221
49331
  - \u5BF9\u672A\u6765\u7684\u671F\u8BB8\uFF0C\u6FC0\u52B1\u5B66\u751F\u5728\u4E0B\u5B66\u671F\u7EE7\u7EED\u52AA\u529B\u3002
49222
49332
  3. \u957F\u5EA6\u63A7\u5236\u5728 150-250 \u5B57\u4E4B\u95F4\u3002\u4E0D\u8981\u5305\u542B\u4EFB\u4F55 Markdown \u683C\u5F0F\uFF0C\u53EA\u8FD4\u56DE\u7EAF\u6587\u672C\u8BC4\u8BED\u3002`;
49223
- const provider = providerId ? this.db.prepare("SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE id = ?").get(providerId) : this.db.prepare(
49224
- "SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE api_key IS NOT NULL AND api_key != '' LIMIT 1"
49225
- ).get();
49226
- if (provider?.api_key) provider.api_key = decryptApiKey(provider.api_key);
49227
- if (!provider || !provider.api_key || !provider.api_key.trim()) {
49228
- throw new Error("\u672A\u68C0\u6D4B\u5230\u53EF\u7528\u7684 AI \u63D0\u4F9B\u5546\u3002\u8BF7\u524D\u5F80\u300C\u7CFB\u7EDF\u7BA1\u7406 -> AI \u63D0\u4F9B\u5546\u7BA1\u7406\u300D\u6DFB\u52A0\u5E76\u914D\u7F6E\u5927\u6A21\u578B\u670D\u52A1\u3002");
49229
- }
49333
+ const provider = resolveAccessibleAiProvider(this.db, {
49334
+ providerId,
49335
+ userId
49336
+ });
49230
49337
  let chatUrl = provider.api_url.trim();
49231
49338
  if (!chatUrl.endsWith("/chat/completions")) {
49232
49339
  chatUrl = chatUrl.endsWith("/") ? chatUrl + "chat/completions" : chatUrl + "/chat/completions";
@@ -49499,11 +49606,14 @@ function registerGradingRoutes(ctx) {
49499
49606
  const { classId, studentId } = req.params;
49500
49607
  if (gate(req, res, classId, "staff")) return;
49501
49608
  const { semesterName = "2026\u5E74\u6625\u5B63\u5B66\u671F", providerId } = req.body;
49609
+ const session = req.session;
49610
+ const userId = session?.userId || session?.username;
49502
49611
  const aiEvaluation = await gradingService.generateSemesterAiEvaluation({
49503
49612
  classId,
49504
49613
  studentId,
49505
49614
  semesterName,
49506
- providerId
49615
+ providerId,
49616
+ userId
49507
49617
  });
49508
49618
  res.json({ success: true, aiEvaluation });
49509
49619
  } catch (e) {
@@ -49931,7 +50041,8 @@ async function pluginApiGatewayMiddleware(req, res, next) {
49931
50041
  }
49932
50042
  }
49933
50043
  const clientIp = req.ip || req.socket?.remoteAddress || "unknown-ip";
49934
- const rateLimitKey = `${clientIp}:${resolvedUuid || pluginId}:${method}:${normalizedSubPath}`;
50044
+ const identityKey = auth.isAuthenticated ? `u:${String(auth.userId || auth.username || auth.actorId).slice(0, 64)}` : `ip:${clientIp}`;
50045
+ const rateLimitKey = `${identityKey}:${resolvedUuid || pluginId}:${method}:${normalizedSubPath}`;
49935
50046
  const windowMs = matchedRule?.rateLimit?.windowMs ?? 60 * 1e3;
49936
50047
  const maxRequests = matchedRule?.rateLimit?.max ?? 120;
49937
50048
  if (!rateLimiter.consume(rateLimitKey, maxRequests, windowMs)) {
@@ -50920,16 +51031,9 @@ function listInstalledManifestVersions() {
50920
51031
  return installed;
50921
51032
  }
50922
51033
  function registerPluginsRoutes(ctx) {
50923
- const {
50924
- app,
50925
- aiLimiter,
50926
- buildOpenAITools: buildOpenAITools2,
50927
- executeAgentToolCall: executeAgentToolCall2,
50928
- buildOpenAIChatUrl: buildOpenAIChatUrl2,
50929
- runGeminiAgentChat: runGeminiAgentChat2,
50930
- runOpenAIAgentChat: runOpenAIAgentChat2
50931
- } = ctx;
50932
- const safeAiLimiter = typeof aiLimiter === "function" ? aiLimiter : (_req, _res, next) => next();
51034
+ const { app, aiLimiter } = ctx;
51035
+ const noopMiddleware = (_req, _res, next) => next();
51036
+ const safeAiLimiter = typeof aiLimiter === "function" ? aiLimiter : noopMiddleware;
50933
51037
  app.get("/api/docs/plugin-guide", (req, res) => {
50934
51038
  try {
50935
51039
  const docPath = import_path22.default.join(process.cwd(), "docs_plugin_guide.md");
@@ -51965,9 +52069,13 @@ function registerPluginsRoutes(ctx) {
51965
52069
  }
51966
52070
  });
51967
52071
  app.all("/api/plugins/:pluginId/*", pluginApiGatewayMiddleware);
51968
- app.get("/api/ai-providers", requireAuth("teacher", "administrator"), (req, res) => {
52072
+ app.get("/api/ai-providers", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
51969
52073
  try {
51970
- const providers = kernelContainer.db.prepare("SELECT * FROM ai_providers ORDER BY created_at DESC").all();
52074
+ const session = req.session;
52075
+ const currentUserId = session?.userId || session?.username;
52076
+ const providers = kernelContainer.db.prepare(
52077
+ "SELECT id, name, api_url, api_key, model_name, owner_id, is_shared, created_at, updated_at FROM ai_providers WHERE (owner_id = ? OR is_shared = 1) ORDER BY created_at DESC"
52078
+ ).all(currentUserId);
51971
52079
  const masked = providers.map((p) => {
51972
52080
  let apiKey;
51973
52081
  try {
@@ -51975,54 +52083,122 @@ function registerPluginsRoutes(ctx) {
51975
52083
  } catch {
51976
52084
  apiKey = "[UNDECRYPTABLE]";
51977
52085
  }
51978
- return { ...p, api_key: apiKey };
52086
+ return {
52087
+ ...p,
52088
+ api_key: apiKey,
52089
+ is_shared: Number(p.is_shared) === 1
52090
+ };
51979
52091
  });
51980
52092
  res.json(masked);
51981
52093
  } catch (e) {
51982
52094
  sendSafeError(res, e);
51983
52095
  }
51984
52096
  });
51985
- app.post("/api/ai-providers", requireAuth("administrator"), (req, res) => {
52097
+ app.post("/api/ai-providers", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
51986
52098
  try {
51987
- const { name, api_url, api_key, model_name } = req.body;
52099
+ const session = req.session;
52100
+ const currentUserId = session?.userId || session?.username;
52101
+ const isAdmin = isAdministrator2(session);
52102
+ const { name, api_url, api_key, model_name, is_shared } = req.body;
51988
52103
  if (!name || !api_url || !model_name) {
51989
52104
  return res.status(400).json({ error: "Missing name, api_url or model_name" });
51990
52105
  }
52106
+ const wantsShared = is_shared === 1 || is_shared === true || is_shared === "1";
52107
+ if (wantsShared && !isAdmin) {
52108
+ return res.status(403).json({ error: "Only administrators can create platform shared providers" });
52109
+ }
51991
52110
  const id = "prov_" + Date.now();
51992
52111
  const now = Date.now();
51993
52112
  const encryptedKey = api_key ? encryptApiKey(api_key) : "";
51994
52113
  kernelContainer.db.prepare(
51995
- "INSERT INTO ai_providers (id, name, api_url, api_key, model_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
51996
- ).run(id, name, api_url, encryptedKey, model_name, now, now);
52114
+ "INSERT INTO ai_providers (id, name, api_url, api_key, model_name, owner_id, is_shared, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
52115
+ ).run(
52116
+ id,
52117
+ name.trim(),
52118
+ api_url.trim(),
52119
+ encryptedKey,
52120
+ model_name.trim(),
52121
+ currentUserId,
52122
+ wantsShared && isAdmin ? 1 : 0,
52123
+ now,
52124
+ now
52125
+ );
51997
52126
  res.json({ success: true, id });
51998
52127
  } catch (e) {
51999
52128
  sendSafeError(res, e);
52000
52129
  }
52001
52130
  });
52002
- app.put("/api/ai-providers/:id", requireAuth("administrator"), (req, res) => {
52131
+ app.put("/api/ai-providers/:id", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
52003
52132
  try {
52004
- const { name, api_url, api_key, model_name } = req.body;
52133
+ const session = req.session;
52134
+ const currentUserId = session?.userId || session?.username;
52135
+ const isAdmin = isAdministrator2(session);
52136
+ const { name, api_url, api_key, model_name, is_shared } = req.body;
52005
52137
  if (!name || !api_url || !model_name) {
52006
52138
  return res.status(400).json({ error: "Missing name, api_url or model_name" });
52007
52139
  }
52140
+ const existing = kernelContainer.db.prepare("SELECT id, api_key, owner_id, is_shared FROM ai_providers WHERE id = ?").get(req.params.id);
52141
+ if (!existing) {
52142
+ return res.status(404).json({ error: "AI provider not found" });
52143
+ }
52144
+ const isExistingShared = Number(existing.is_shared) === 1;
52145
+ if (isExistingShared) {
52146
+ if (!isAdmin) {
52147
+ return res.status(403).json({ error: "Forbidden: Only administrators can modify platform shared providers" });
52148
+ }
52149
+ } else {
52150
+ if (existing.owner_id !== currentUserId) {
52151
+ return res.status(403).json({
52152
+ error: "Forbidden: You do not have permission to modify another user's private AI provider"
52153
+ });
52154
+ }
52155
+ }
52156
+ let nextShared = existing.is_shared ?? 0;
52157
+ if (is_shared !== void 0) {
52158
+ const wantsShared = is_shared === 1 || is_shared === true || is_shared === "1";
52159
+ if (wantsShared !== (Number(existing.is_shared) === 1)) {
52160
+ if (!isAdmin) {
52161
+ return res.status(403).json({ error: "Forbidden: Only administrators can change platform shared status" });
52162
+ }
52163
+ nextShared = wantsShared ? 1 : 0;
52164
+ }
52165
+ }
52008
52166
  const now = Date.now();
52009
52167
  let finalKey;
52010
52168
  if (api_key && api_key.trim() !== "" && !api_key.includes("****")) {
52011
52169
  finalKey = encryptApiKey(api_key);
52012
52170
  } else {
52013
- const existing = kernelContainer.db.prepare("SELECT api_key FROM ai_providers WHERE id = ?").get(req.params.id);
52014
52171
  finalKey = existing?.api_key || "";
52015
52172
  }
52016
52173
  kernelContainer.db.prepare(
52017
- "UPDATE ai_providers SET name = ?, api_url = ?, api_key = ?, model_name = ?, updated_at = ? WHERE id = ?"
52018
- ).run(name, api_url, finalKey, model_name, now, req.params.id);
52174
+ "UPDATE ai_providers SET name = ?, api_url = ?, api_key = ?, model_name = ?, is_shared = ?, updated_at = ? WHERE id = ?"
52175
+ ).run(name.trim(), api_url.trim(), finalKey, model_name.trim(), nextShared, now, req.params.id);
52019
52176
  res.json({ success: true });
52020
52177
  } catch (e) {
52021
52178
  sendSafeError(res, e);
52022
52179
  }
52023
52180
  });
52024
- app.delete("/api/ai-providers/:id", requireAuth("administrator"), (req, res) => {
52181
+ app.delete("/api/ai-providers/:id", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
52025
52182
  try {
52183
+ const session = req.session;
52184
+ const currentUserId = session?.userId || session?.username;
52185
+ const isAdmin = isAdministrator2(session);
52186
+ const existing = kernelContainer.db.prepare("SELECT id, owner_id, is_shared FROM ai_providers WHERE id = ?").get(req.params.id);
52187
+ if (!existing) {
52188
+ return res.status(404).json({ error: "AI provider not found" });
52189
+ }
52190
+ const isExistingShared = Number(existing.is_shared) === 1;
52191
+ if (isExistingShared) {
52192
+ if (!isAdmin) {
52193
+ return res.status(403).json({ error: "Forbidden: Only administrators can delete platform shared providers" });
52194
+ }
52195
+ } else {
52196
+ if (existing.owner_id !== currentUserId) {
52197
+ return res.status(403).json({
52198
+ error: "Forbidden: You do not have permission to delete another user's private AI provider"
52199
+ });
52200
+ }
52201
+ }
52026
52202
  kernelContainer.db.prepare("DELETE FROM ai_providers WHERE id = ?").run(req.params.id);
52027
52203
  res.json({ success: true });
52028
52204
  } catch (e) {
@@ -52105,65 +52281,82 @@ function registerPluginsRoutes(ctx) {
52105
52281
  sendSafeError(res, e);
52106
52282
  }
52107
52283
  });
52108
- app.post("/api/ai-providers/test", requireAuth("administrator"), safeAiLimiter, async (req, res) => {
52109
- try {
52110
- const { api_url, api_key: providedKey, model_name } = req.body;
52111
- if (!api_url || !model_name) {
52112
- return res.status(400).json({ error: "api_url and model_name are required" });
52113
- }
52114
- let api_key = "";
52115
- if (providedKey && providedKey.includes("****")) {
52116
- const existing = kernelContainer.db.prepare("SELECT api_key FROM ai_providers WHERE api_url = ? AND model_name = ? LIMIT 1").get(api_url, model_name);
52117
- api_key = existing ? decryptApiKey(existing.api_key) : "";
52118
- } else if (providedKey) {
52119
- api_key = providedKey.includes(":") ? decryptApiKey(providedKey) : providedKey;
52120
- }
52121
- let cleanUrl = api_url.trim();
52122
- if (!cleanUrl.endsWith("/chat/completions")) {
52123
- cleanUrl = cleanUrl.endsWith("/") ? cleanUrl + "chat/completions" : cleanUrl + "/chat/completions";
52124
- }
52125
- const urlSafety = isSafeExternalUrl(cleanUrl);
52126
- if (!urlSafety.safe) {
52127
- return res.status(400).json({ error: `Blocked unsafe AI provider URL: ${urlSafety.reason}` });
52128
- }
52129
- let response;
52284
+ app.post(
52285
+ "/api/ai-providers/test",
52286
+ requireScopedAuth("self", "teacher", "administrator"),
52287
+ safeAiLimiter,
52288
+ async (req, res) => {
52130
52289
  try {
52131
- response = await fetchWithSafeRedirects(cleanUrl, {
52132
- method: "POST",
52133
- headers: {
52134
- "Content-Type": "application/json",
52135
- Authorization: `Bearer ${api_key || ""}`
52136
- },
52137
- body: JSON.stringify({
52138
- model: model_name,
52139
- messages: [{ role: "user", content: "Say connected" }],
52140
- max_tokens: 5
52141
- }),
52142
- timeoutMs: 1e4
52143
- });
52144
- } catch (err) {
52145
- const isTimeout = err instanceof AIFetchTimeoutError || err?.name === "TimeoutError" || err?.name === "AbortError" || /abort|timeout/i.test(err?.message ?? "");
52146
- if (isTimeout) {
52147
- return res.status(504).json({
52290
+ const session = req.session;
52291
+ const currentUserId = session?.userId || session?.username;
52292
+ const { api_url, api_key: providedKey, model_name, id } = req.body;
52293
+ if (!api_url || !model_name) {
52294
+ return res.status(400).json({ error: "api_url and model_name are required" });
52295
+ }
52296
+ let api_key = "";
52297
+ if (providedKey && providedKey.includes("****")) {
52298
+ let existing;
52299
+ if (id) {
52300
+ existing = kernelContainer.db.prepare("SELECT api_key FROM ai_providers WHERE id = ? AND (owner_id = ? OR is_shared = 1)").get(id, currentUserId);
52301
+ } else {
52302
+ existing = kernelContainer.db.prepare(
52303
+ "SELECT api_key FROM ai_providers WHERE api_url = ? AND model_name = ? AND (owner_id = ? OR is_shared = 1) LIMIT 1"
52304
+ ).get(api_url, model_name, currentUserId);
52305
+ }
52306
+ if (!existing) {
52307
+ return res.status(403).json({ error: "Forbidden: Cannot access key for this provider" });
52308
+ }
52309
+ api_key = decryptApiKey(existing.api_key);
52310
+ } else if (providedKey) {
52311
+ api_key = providedKey.includes(":") ? decryptApiKey(providedKey) : providedKey;
52312
+ }
52313
+ let cleanUrl = api_url.trim();
52314
+ if (!cleanUrl.endsWith("/chat/completions")) {
52315
+ cleanUrl = cleanUrl.endsWith("/") ? cleanUrl + "chat/completions" : cleanUrl + "/chat/completions";
52316
+ }
52317
+ const urlSafety = isSafeExternalUrl(cleanUrl);
52318
+ if (!urlSafety.safe) {
52319
+ return res.status(400).json({ error: `Blocked unsafe AI provider URL: ${urlSafety.reason}` });
52320
+ }
52321
+ let response;
52322
+ try {
52323
+ response = await fetchWithSafeRedirects(cleanUrl, {
52324
+ method: "POST",
52325
+ headers: {
52326
+ "Content-Type": "application/json",
52327
+ Authorization: `Bearer ${api_key || ""}`
52328
+ },
52329
+ body: JSON.stringify({
52330
+ model: model_name,
52331
+ messages: [{ role: "user", content: "Say connected" }],
52332
+ max_tokens: 5
52333
+ }),
52334
+ timeoutMs: 1e4
52335
+ });
52336
+ } catch (err) {
52337
+ const isTimeout = err instanceof AIFetchTimeoutError || err?.name === "TimeoutError" || err?.name === "AbortError" || /abort|timeout/i.test(err?.message ?? "");
52338
+ if (isTimeout) {
52339
+ return res.status(504).json({
52340
+ success: false,
52341
+ error: "AI Provider request timed out after 10000ms. Please verify the URL or provider availability."
52342
+ });
52343
+ }
52344
+ throw err;
52345
+ }
52346
+ const responseText = await response.text();
52347
+ if (response.ok) {
52348
+ res.json({ success: true, message: "Successfully connected and received response." });
52349
+ } else {
52350
+ res.status(response.status).json({
52148
52351
  success: false,
52149
- error: "AI Provider request timed out after 10000ms. Please verify the URL or provider availability."
52352
+ error: `API responded with status ${response.status}: ${responseText.slice(0, 200)}`
52150
52353
  });
52151
52354
  }
52152
- throw err;
52153
- }
52154
- const responseText = await response.text();
52155
- if (response.ok) {
52156
- res.json({ success: true, message: "Successfully connected and received response." });
52157
- } else {
52158
- res.status(response.status).json({
52159
- success: false,
52160
- error: `API responded with status ${response.status}: ${responseText.slice(0, 200)}`
52161
- });
52355
+ } catch (e) {
52356
+ sendSafeError(res, e, 500, "Connection failed");
52162
52357
  }
52163
- } catch (e) {
52164
- sendSafeError(res, e, 500, "Connection failed");
52165
52358
  }
52166
- });
52359
+ );
52167
52360
  }
52168
52361
 
52169
52362
  // server/services/assessment-service.ts
@@ -58608,12 +58801,16 @@ async function startServer() {
58608
58801
  const defaultWriteLimit = process.env.PLAYWRIGHT_TEST ? 1e4 : isDev ? 600 : 180;
58609
58802
  const writeLimiter = createWriteLimiter(defaultWriteLimit);
58610
58803
  const viewStateLimiter = createWriteLimiter(process.env.PLAYWRIGHT_TEST ? 1e4 : isDev ? 600 : 120);
58804
+ const pluginApiLimiter = createSessionWriteLimiter(process.env.PLAYWRIGHT_TEST ? 1e4 : 600);
58611
58805
  const aiLimiter = createAiLimiter(process.env.PLAYWRIGHT_TEST ? 1e3 : 10);
58612
58806
  app.use((req, res, next) => {
58613
58807
  if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
58614
58808
  if (/^\/api\/classroom\/sessions\/[^/]+\/view-state\/?$/.test(req.path)) {
58615
58809
  return viewStateLimiter(req, res, next);
58616
58810
  }
58811
+ if (req.path.startsWith("/api/plugins/")) {
58812
+ return pluginApiLimiter(req, res, next);
58813
+ }
58617
58814
  return writeLimiter(req, res, next);
58618
58815
  }
58619
58816
  next();