openlearn-next 0.7.9 → 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 (56) hide show
  1. package/README.md +13 -4
  2. package/cli-data.mjs +1 -1
  3. package/dist/assets/{ActivityWorkspaceWidget-DbspLRxB.js → ActivityWorkspaceWidget-TtBuNORs.js} +1 -1
  4. package/dist/assets/AdminDirectoryView-D03WOMc-.js +5 -0
  5. package/dist/assets/{AssessmentCenter-DbJAcTvr.js → AssessmentCenter-MUvoJySb.js} +1 -1
  6. package/dist/assets/{AssignmentFilePreview-DEVTb4YJ.js → AssignmentFilePreview-dVhnTYmq.js} +1 -1
  7. package/dist/assets/{BatchPickerModal-czw6eA6e.js → BatchPickerModal-QALdhrX6.js} +1 -1
  8. package/dist/assets/{ClassesView-B1F0tTtB.js → ClassesView-CKwlRz-A.js} +5 -5
  9. package/dist/assets/{ClassroomCountdownWidget-C9pv3GLy.js → ClassroomCountdownWidget-DyWyForY.js} +1 -1
  10. package/dist/assets/{CloudDriveModal-goDB6dlC.js → CloudDriveModal-C8UAn3MO.js} +1 -1
  11. package/dist/assets/{ComputerLabView-Csqiqvw2.js → ComputerLabView-E5b3XTkk.js} +1 -1
  12. package/dist/assets/{CourseManagement-BD-4z2OZ.js → CourseManagement-3jSlq7lV.js} +1 -1
  13. package/dist/assets/{CourseWizardModal-NXwhuph_.js → CourseWizardModal-BAkW5VJX.js} +1 -1
  14. package/dist/assets/{CoursewareHubPanel-BuKp0mmW.js → CoursewareHubPanel-BkjcusjZ.js} +1 -1
  15. package/dist/assets/{Dashboard-CFDWNYSw.js → Dashboard-Ha2fZAqD.js} +2 -2
  16. package/dist/assets/{ExportWeightModal-dBxYVUP4.js → ExportWeightModal-D2Y13384.js} +1 -1
  17. package/dist/assets/{HelpTour-D3QcGDUz.js → HelpTour-DBq2otcV.js} +1 -1
  18. package/dist/assets/{HelpView-DSZC-1F0.js → HelpView-BVLRnMxl.js} +4 -4
  19. package/dist/assets/{ImportLessonsModal-m_s44eus.js → ImportLessonsModal-CQlq4b81.js} +1 -1
  20. package/dist/assets/ImportModal-Ct0I5cAL.js +3 -0
  21. package/dist/assets/{InteractiveCoursewareViewer-C7faFOFD.js → InteractiveCoursewareViewer-DGKh1gO8.js} +1 -1
  22. package/dist/assets/InteractiveWhiteboard-BbZlYpRu.js +65 -0
  23. package/dist/assets/{LazyCourseware-DNn0O8lA.js → LazyCourseware-zONqinCM.js} +2 -2
  24. package/dist/assets/{LazyWhiteboard-BxpydfnE.js → LazyWhiteboard-BV98LEZ8.js} +2 -2
  25. package/dist/assets/{LessonEditorView-CMCgnrJD.js → LessonEditorView-LXRkKxdY.js} +1 -1
  26. package/dist/assets/LiveClassroomView-TaHtHWkY.js +63 -0
  27. package/dist/assets/{NotificationDetailModal-BMrt09PY.js → NotificationDetailModal-BF_-pn0c.js} +1 -1
  28. package/dist/assets/{PluginView-B6gCF1ZB.js → PluginView-sVMPXae9.js} +1 -1
  29. package/dist/assets/{ProcessLogsModal-DmsOrZnV.js → ProcessLogsModal-D97xLgnL.js} +1 -1
  30. package/dist/assets/{QuizGeneratorModal-BNW6tZ78.js → QuizGeneratorModal-CNY8ISjR.js} +1 -1
  31. package/dist/assets/{StudentAssignmentEvalPanel-DMwBSOXf.js → StudentAssignmentEvalPanel-CUmM5DbT.js} +1 -1
  32. package/dist/assets/{StudentAssignmentView-cQpPme4_.js → StudentAssignmentView-B-1MtwKj.js} +1 -1
  33. package/dist/assets/{StudentLessonView-CpGgYV-b.js → StudentLessonView-CqrcvVND.js} +1 -1
  34. package/dist/assets/{StudentView-BfXi6-NQ.js → StudentView-Bi9kgSrN.js} +2 -2
  35. package/dist/assets/{SystemResourceLibraryModal-d4vfqM_9.js → SystemResourceLibraryModal-DDALKrJC.js} +1 -1
  36. package/dist/assets/{TeacherView-DSJCvFE-.js → TeacherView-C5JmoKHk.js} +2 -2
  37. package/dist/assets/{TimetableView-D9Rhnvfj.js → TimetableView-BOLB-kZC.js} +4 -4
  38. package/dist/assets/index-Bc9003lB.js +131 -0
  39. package/dist/assets/index-D4aq9poS.css +1 -0
  40. package/dist/assets/pluginParsers-DD3QL1Xl.js +1 -0
  41. package/dist/assets/{student-workspace-DO1mSl_I.js → student-workspace-C4UgD58C.js} +1 -1
  42. package/dist/assets/{vendor-icons-DLOPlM4i.js → vendor-icons-CaXLq01o.js} +1 -1
  43. package/dist/env-validation.cjs +14 -6
  44. package/dist/index.html +3 -3
  45. package/dist/server.cjs +1310 -778
  46. package/migrations/045_ai_providers_owner_and_scope.sql +20 -0
  47. package/migrations/README.md +1 -0
  48. package/migrations/schema-golden.json +13 -3
  49. package/package.json +5 -3
  50. package/dist/assets/AdminDirectoryView-C2ZFIwHx.js +0 -5
  51. package/dist/assets/ImportModal-h5ShIMzO.js +0 -2
  52. package/dist/assets/InteractiveWhiteboard-10fno-bJ.js +0 -65
  53. package/dist/assets/LiveClassroomView-BA0us3bY.js +0 -61
  54. package/dist/assets/index-DCnGJbif.css +0 -1
  55. package/dist/assets/index-DuxQtTr9.js +0 -131
  56. package/dist/assets/pluginParsers-HRw6CVog.js +0 -1
package/dist/server.cjs CHANGED
@@ -507,6 +507,74 @@ var init_plugin_metrics = __esm({
507
507
  }
508
508
  });
509
509
 
510
+ // packages/core/capability/catalog.ts
511
+ function validateManifestCapabilities(capabilities) {
512
+ const unknown = [];
513
+ for (const cap of capabilities) {
514
+ if (typeof cap !== "string" || !KNOWN_CAPABILITIES.has(cap)) {
515
+ unknown.push(typeof cap === "string" ? cap : String(cap));
516
+ }
517
+ }
518
+ return { valid: unknown.length === 0, unknown };
519
+ }
520
+ var CAP_API_WRITE, CAP_API_ADMIN, CAP_GRADES_WRITE, CAP_AGENT_TOOL, CAP_AI_INVOKE, CAP_TASK_REGISTER, CAP_STUDENT_DATA_READ, CAP_STUDENT_DATA_WRITE, CAP, CAPABILITIES, KNOWN_CAPABILITIES;
521
+ var init_catalog = __esm({
522
+ "packages/core/capability/catalog.ts"() {
523
+ "use strict";
524
+ CAP_API_WRITE = "api:write";
525
+ CAP_API_ADMIN = "api:admin";
526
+ CAP_GRADES_WRITE = "grades:write";
527
+ CAP_AGENT_TOOL = "agent:tool";
528
+ CAP_AI_INVOKE = "ai:invoke";
529
+ CAP_TASK_REGISTER = "task:register";
530
+ CAP_STUDENT_DATA_READ = "student-data:read";
531
+ CAP_STUDENT_DATA_WRITE = "student-data:write";
532
+ CAP = {
533
+ // ── 资源型(ActionDescriptor.capabilityRequired)──────────────────────
534
+ ASSIGNMENT_MANAGE: "assignment:manage",
535
+ ASSIGNMENT_READ: "assignment:read",
536
+ ASSIGNMENT_REVIEW: "assignment:review",
537
+ ASSIGNMENT_SUBMIT: "assignment:submit",
538
+ ASSIGNMENT_WRITE: "assignment:write",
539
+ LESSON_DELETE: "lesson:delete",
540
+ LESSON_READ: "lesson:read",
541
+ LESSON_WRITE: "lesson:write",
542
+ MANAGEMENT_READ: "management:read",
543
+ MANAGEMENT_WRITE: "management:write",
544
+ PLUGIN_READ: "plugin:read",
545
+ PLUGIN_WRITE: "plugin:write",
546
+ PROCESS_READ: "process:read",
547
+ PROCESS_WRITE: "process:write",
548
+ STUDENT_WRITE: "student:write",
549
+ VFS_READ: "vfs:read",
550
+ VFS_WRITE: "vfs:write",
551
+ WHITEBOARD_READ: "whiteboard:read",
552
+ WHITEBOARD_WRITE: "whiteboard:write",
553
+ // ── 历史上只出现在 capabilitiesProposed、从未作为 capabilityRequired ──
554
+ // 保留是为了不让内置 management 插件在升级后突然被标成「未知串」。
555
+ CLASS_READ: "class:read",
556
+ CLASS_WRITE: "class:write",
557
+ STUDENT_READ: "student:read",
558
+ // ── 方法级(method-policy / 服务代理的 Security Barrier 3)───────────
559
+ API_WRITE: CAP_API_WRITE,
560
+ API_ADMIN: CAP_API_ADMIN,
561
+ GRADES_WRITE: CAP_GRADES_WRITE,
562
+ AGENT_TOOL: CAP_AGENT_TOOL,
563
+ AI_INVOKE: CAP_AI_INVOKE,
564
+ TASK_REGISTER: CAP_TASK_REGISTER,
565
+ STUDENT_DATA_READ: CAP_STUDENT_DATA_READ,
566
+ STUDENT_DATA_WRITE: CAP_STUDENT_DATA_WRITE,
567
+ // ── 平台内部 ─────────────────────────────────────────────────────────
568
+ // 片段命令(segment gate)用,插件片段可声明;不在 AI 工具箱里。
569
+ SEGMENT_GATE_EVAL: "segment:gate_eval"
570
+ };
571
+ CAPABILITIES = Object.freeze(
572
+ Array.from(new Set(Object.values(CAP)))
573
+ );
574
+ KNOWN_CAPABILITIES = new Set(CAPABILITIES);
575
+ }
576
+ });
577
+
510
578
  // packages/core/esm-loader/manifest-schema.ts
511
579
  var import_zod, classroomToolSchema, helpDocSchema, quickActivitySchema, timelineSegmentSchema, paletteItemSchema, cockpitWidgetSchema, stageCardSchema, classroomTopbarActionSchema, classroomAttributionAwardSchema, studentCompetencyDimensionSchema, studentProfileWidgetSchema, canvasWidgetSchema, barometerMetricSchema, peerReviewRubricSchema, peerReviewBadgeSchema, preclassPasscodeActionSchema, preclassPasscodeAddonSchema, commandPaletteItemSchema, quickInsertItemSchema, aiPromptTemplateSchema, aiSkillProviderSchema, classTabSchema, classBatchActionSchema, studentRowPanelSchema, editorHeaderActionSchema, postClassWidgetSchema, coursewareViewerToolbarSchema, coursewareViewerOverlaySchema, portalTelemetrySchema, portalCourseBadgeSchema, portalTeachingModeSchema, portalInsightSchema, portalPreflightSchema, portalLaunchActionSchema, countdownWidgetSchema, countdownActionSchema, studentCountdownSchema, studentQuickActionsItemSchema, studentQuickActionsActionSchema, studentQuickActionsFabSchema, notificationTabsSchema, pacingDashboardSchema, diagnosticFeedSchema, collabCanvasSchema, topbarPillSchema, attributionActionSchema, leaderboardActionSchema, studentProfileActionSchema, studentProfileTimelineItemSchema, classroomHeaderActionSchema, classroomAgendaActionSchema, whiteboardDockPluginSchema, classroomAuditEventSchema, peerReviewActionSchema, peerReviewShowcaseWidgetSchema, seatingToolbarSchema, seatingLegendSchema, seatingSeatBadgeSchema, seatingSeatActionsSchema, seatingSummarySchema, whiteboardAutosaveStatusSchema, whiteboardAutosaveActionSchema, teacherPanelSchema, studentFullscreenSchema, globalSettingSchema, navUserMenuSchema, frontendSlotEntrySchema, frontendSlotsShape, pluginLocalesSchema, contributesSchema, deploySchema, apiRouteSchema, apiSchema, requiresItemSchema, manifestSchema, requiresItemV3Schema, manifestSchemaV3;
512
580
  var init_manifest_schema = __esm({
@@ -1800,7 +1868,7 @@ var init_token = __esm({
1800
1868
  });
1801
1869
 
1802
1870
  // packages/core/di/interfaces.ts
1803
- var ICommandBusServiceToken4, IEventBusServiceToken3, IActionRegistryServiceToken4, ICapabilityServiceToken, IProcessServiceToken2, IStorageServiceToken, IAIServiceToken, IDatabaseToken4, IPluginHostToken, IPluginLifecycleManagerToken, IPluginDistributionManagerToken, IPluginRuntimeCompositionToken, IUnifiedExtensionRegistryToken, IPluginCapabilityGatewayToken, ICapabilityRegistryToken, IAuthSessionBridgeToken, ISemesterGradeServiceToken, IPointsDimensionRegistryToken, IPointsLedgerServiceToken, ILessonEngineServiceToken, IClassroomRuntimeServiceToken, IPresenceEngineServiceToken, ITeachingCollaborationServiceToken, ILearningAnalyticsServiceToken, IAICapabilityServiceToken, ICapabilityRuntimeServiceToken, ICapabilityGovernanceServiceToken, IPlatformServiceRegistryToken, ICoursewareRuntimeScriptRegistryToken, IClassroomLifecycleServiceToken, IInteractionRuntimeServiceToken, IClassroomCountdownServiceToken, IAssessmentGraderRegistryToken, IStageGuardServiceToken, PLUGIN_RESOLVABLE_TOKENS, PLUGIN_RESOLVABLE_TOKEN_NAMES, INetworkProxyServiceToken;
1871
+ var ICommandBusServiceToken4, IEventBusServiceToken3, IActionRegistryServiceToken4, ICapabilityServiceToken, IProcessServiceToken2, IStorageServiceToken, IAIServiceToken, IDatabaseToken4, IPluginHostToken, IPluginLifecycleManagerToken, IPluginDistributionManagerToken, IPluginRuntimeCompositionToken, IUnifiedExtensionRegistryToken, IPluginCapabilityGatewayToken, ICapabilityRegistryToken, IAuthSessionBridgeToken, ISemesterGradeServiceToken, IPointsDimensionRegistryToken, IPointsLedgerServiceToken, IClassRosterServiceToken, ILessonEngineServiceToken, IClassroomRuntimeServiceToken, IPresenceEngineServiceToken, ITeachingCollaborationServiceToken, ILearningAnalyticsServiceToken, IAICapabilityServiceToken, ICapabilityRuntimeServiceToken, ICapabilityGovernanceServiceToken, IPlatformServiceRegistryToken, ICoursewareRuntimeScriptRegistryToken, IClassroomLifecycleServiceToken, IInteractionRuntimeServiceToken, IClassroomCountdownServiceToken, IAssessmentGraderRegistryToken, IStageGuardServiceToken, PLUGIN_RESOLVABLE_TOKENS, PLUGIN_RESOLVABLE_TOKEN_NAMES, INetworkProxyServiceToken;
1804
1872
  var init_interfaces = __esm({
1805
1873
  "packages/core/di/interfaces.ts"() {
1806
1874
  "use strict";
@@ -1839,6 +1907,7 @@ var init_interfaces = __esm({
1839
1907
  "@openlearn/core:IPointsDimensionRegistry"
1840
1908
  );
1841
1909
  IPointsLedgerServiceToken = new Token("@openlearn/core:IPointsLedgerService");
1910
+ IClassRosterServiceToken = new Token("@openlearn/core:IClassRosterService");
1842
1911
  ILessonEngineServiceToken = new Token("@openlearn/core:ILessonEngineService");
1843
1912
  IClassroomRuntimeServiceToken = new Token(
1844
1913
  "@openlearn/core:IClassroomRuntimeService"
@@ -1889,6 +1958,7 @@ var init_interfaces = __esm({
1889
1958
  ISemesterGradeServiceToken,
1890
1959
  IPointsDimensionRegistryToken,
1891
1960
  IPointsLedgerServiceToken,
1961
+ IClassRosterServiceToken,
1892
1962
  ILessonEngineServiceToken,
1893
1963
  IClassroomRuntimeServiceToken,
1894
1964
  IPresenceEngineServiceToken,
@@ -3234,6 +3304,95 @@ var init_ai_event_bus = __esm({
3234
3304
  }
3235
3305
  });
3236
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
+
3237
3396
  // packages/core/di/ai-service.ts
3238
3397
  var AIService;
3239
3398
  var init_ai_service = __esm({
@@ -3242,6 +3401,7 @@ var init_ai_service = __esm({
3242
3401
  init_provider_gateway();
3243
3402
  init_ai_event_bus();
3244
3403
  init_api_key_crypto();
3404
+ init_ai_provider_resolver();
3245
3405
  AIService = class {
3246
3406
  constructor(db2) {
3247
3407
  this.db = db2;
@@ -3289,16 +3449,11 @@ var init_ai_service = __esm({
3289
3449
  this._contextDelegates.unregister?.(id);
3290
3450
  }
3291
3451
  async generateText(prompt, options) {
3292
- const provider = this.db.prepare(
3293
- "SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE api_key IS NOT NULL AND api_key != '' LIMIT 1"
3294
- ).get();
3295
- if (!provider || !provider.api_key) {
3296
- 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");
3297
- }
3298
- const apiKey = decryptApiKey(provider.api_key);
3299
- if (!apiKey || !apiKey.trim()) {
3300
- 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");
3301
- }
3452
+ const provider = resolveAccessibleAiProvider(this.db, {
3453
+ userId: options?.userId,
3454
+ providerId: options?.providerId
3455
+ });
3456
+ const apiKey = provider.api_key;
3302
3457
  if (looksLikeCiphertext(apiKey)) {
3303
3458
  throw new Error(
3304
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"
@@ -4209,18 +4364,11 @@ function checkMethodPolicy(input) {
4209
4364
  if (tokens.uiService && token === tokens.uiService) return null;
4210
4365
  return null;
4211
4366
  }
4212
- var CAP_API_WRITE, CAP_API_ADMIN, CAP_GRADES_WRITE, CAP_AGENT_TOOL, CAP_AI_INVOKE, CAP_TASK_REGISTER, CAP_STUDENT_DATA_READ, CAP_STUDENT_DATA_WRITE, HIGH_RISK_PATH_PREFIXES, IRREVERSIBLE_PATH_SUFFIXES, ENTITY_DELETE_RE;
4367
+ var HIGH_RISK_PATH_PREFIXES, IRREVERSIBLE_PATH_SUFFIXES, ENTITY_DELETE_RE;
4213
4368
  var init_method_policy = __esm({
4214
4369
  "packages/core/plugin-host/method-policy.ts"() {
4215
4370
  "use strict";
4216
- CAP_API_WRITE = "api:write";
4217
- CAP_API_ADMIN = "api:admin";
4218
- CAP_GRADES_WRITE = "grades:write";
4219
- CAP_AGENT_TOOL = "agent:tool";
4220
- CAP_AI_INVOKE = "ai:invoke";
4221
- CAP_TASK_REGISTER = "task:register";
4222
- CAP_STUDENT_DATA_READ = "student-data:read";
4223
- CAP_STUDENT_DATA_WRITE = "student-data:write";
4371
+ init_catalog();
4224
4372
  HIGH_RISK_PATH_PREFIXES = Object.freeze([
4225
4373
  "/api/plugins",
4226
4374
  // 插件增删改、一键更新(可执行任意第三方代码)
@@ -4244,51 +4392,238 @@ var init_method_policy = __esm({
4244
4392
  }
4245
4393
  });
4246
4394
 
4247
- // packages/core/command-bus/plugin-command.ts
4248
- function bindPluginCommand(command, pluginActorId) {
4249
- const actor = typeof pluginActorId === "string" ? pluginActorId.trim() : "";
4250
- if (!actor) {
4251
- throw new Error("[Security] Refusing to dispatch a plugin command without a host-assigned actor");
4395
+ // packages/core/plugin-host/sql-guard.ts
4396
+ function sanitizeSql(sql) {
4397
+ let inSingleQuote = false;
4398
+ let inDoubleQuote = false;
4399
+ let inBacktick = false;
4400
+ let inBracket = false;
4401
+ let result = "";
4402
+ const len = sql.length;
4403
+ for (let i = 0; i < len; i++) {
4404
+ const ch = sql[i];
4405
+ const next = i + 1 < len ? sql[i + 1] : "";
4406
+ if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "-" && next === "-") {
4407
+ while (i < len && sql[i] !== "\n") i++;
4408
+ result += " ";
4409
+ continue;
4410
+ }
4411
+ if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "/" && next === "*") {
4412
+ i += 2;
4413
+ while (i < len && !(sql[i] === "*" && i + 1 < len && sql[i + 1] === "/")) {
4414
+ i++;
4415
+ }
4416
+ i++;
4417
+ result += " ";
4418
+ continue;
4419
+ }
4420
+ if (ch === "'" && !inDoubleQuote && !inBacktick && !inBracket) {
4421
+ if (inSingleQuote) {
4422
+ if (next === "'") {
4423
+ i++;
4424
+ } else {
4425
+ inSingleQuote = false;
4426
+ }
4427
+ } else {
4428
+ inSingleQuote = true;
4429
+ }
4430
+ result += " ";
4431
+ continue;
4432
+ }
4433
+ if (inSingleQuote) {
4434
+ result += " ";
4435
+ continue;
4436
+ }
4437
+ if (ch === "[" && !inDoubleQuote && !inBacktick) {
4438
+ inBracket = true;
4439
+ result += " ";
4440
+ continue;
4441
+ }
4442
+ if (ch === "]" && inBracket) {
4443
+ inBracket = false;
4444
+ result += " ";
4445
+ continue;
4446
+ }
4447
+ if (ch === "`" && !inDoubleQuote) {
4448
+ inBacktick = !inBacktick;
4449
+ result += " ";
4450
+ continue;
4451
+ }
4452
+ if (ch === '"' && !inBacktick) {
4453
+ inDoubleQuote = !inDoubleQuote;
4454
+ result += " ";
4455
+ continue;
4456
+ }
4457
+ result += ch;
4252
4458
  }
4253
- const raw = command && typeof command === "object" ? { ...command } : {};
4254
- const metadata = raw.metadata && typeof raw.metadata === "object" ? { ...raw.metadata } : {};
4255
- delete metadata.approved;
4256
- return { ...raw, actorId: actor, metadata };
4459
+ return result;
4257
4460
  }
4258
- function pluginActorIdFor(manifestId, pluginId) {
4259
- const id = (manifestId || pluginId || "").trim();
4260
- if (!id) {
4261
- throw new Error("[Security] Refusing to dispatch a plugin command without a host-assigned actor");
4461
+ function extractFlatTables(flatSql, tables) {
4462
+ const ddlRegex = /\b(?:CREATE|DROP|ALTER)\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?([a-zA-Z_][\w]*)/gi;
4463
+ for (const m of flatSql.matchAll(ddlRegex)) {
4464
+ if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
4465
+ tables.add(m[1].toLowerCase());
4466
+ }
4467
+ }
4468
+ const renameToRegex = /\bRENAME\s+TO\s+([a-zA-Z_][\w]*)/gi;
4469
+ for (const m of flatSql.matchAll(renameToRegex)) {
4470
+ if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
4471
+ tables.add(m[1].toLowerCase());
4472
+ }
4473
+ }
4474
+ const simpleClauseRegex = /\b(?:INTO|UPDATE(?:\s+OR\s+(?:ROLLBACK|ABORT|REPLACE|FAIL|IGNORE))?|JOIN)\s+([a-zA-Z_][\w]*)/gi;
4475
+ for (const m of flatSql.matchAll(simpleClauseRegex)) {
4476
+ if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
4477
+ tables.add(m[1].toLowerCase());
4478
+ }
4479
+ }
4480
+ const fromRegex = /\bFROM\s+([^;]+?)(?=\bWHERE\b|\bGROUP\b|\bHAVING\b|\bORDER\b|\bLIMIT\b|\bUNION\b|\bEXCEPT\b|\bINTERSECT\b|\bWINDOW\b|\bVALUES\b|\bSET\b|\bINTO\b|\bJOIN\b|\bLEFT\b|\bRIGHT\b|\bINNER\b|\bOUTER\b|\bCROSS\b|\bNATURAL\b|\bON\b|\bUSING\b|\bSELECT\b|;|$)/gi;
4481
+ for (const match of flatSql.matchAll(fromRegex)) {
4482
+ const clause = match[1];
4483
+ if (!clause) continue;
4484
+ const parts = clause.split(",");
4485
+ for (const part of parts) {
4486
+ const trimmed2 = part.trim();
4487
+ if (!trimmed2) continue;
4488
+ const tokenMatch = /^[a-zA-Z_][\w]*/.exec(trimmed2);
4489
+ if (tokenMatch) {
4490
+ const tableName = tokenMatch[0].toLowerCase();
4491
+ if (tableName !== SUBQUERY_PLACEHOLDER) {
4492
+ tables.add(tableName);
4493
+ }
4494
+ }
4495
+ }
4262
4496
  }
4263
- return id.startsWith("plugin:") ? id : `plugin:${id}`;
4264
4497
  }
4265
- var init_plugin_command = __esm({
4266
- "packages/core/command-bus/plugin-command.ts"() {
4267
- "use strict";
4498
+ function extractReferencedTables(cleanSql) {
4499
+ const tables = /* @__PURE__ */ new Set();
4500
+ let working = cleanSql;
4501
+ const parenRegex = /\(([^()]*)\)/g;
4502
+ let depth = 0;
4503
+ while (parenRegex.test(working) && depth++ < 100) {
4504
+ working = working.replace(parenRegex, (_full, inner) => {
4505
+ extractFlatTables(inner, tables);
4506
+ const trimmedInner = inner.trim();
4507
+ if (/^(?:SELECT|VALUES|WITH)\b/i.test(trimmedInner)) {
4508
+ return ` ${SUBQUERY_PLACEHOLDER} `;
4509
+ }
4510
+ return ` ${inner} `;
4511
+ });
4268
4512
  }
4269
- });
4270
-
4271
- // packages/core/plugin-host/plugin-namespace.ts
4272
- function resolvePluginCommandType(type, pluginId) {
4273
- const prefix = pluginId + ".";
4274
- if (type.startsWith(prefix)) return type;
4275
- if (UUID_V7_PREFIX.test(type)) return type;
4276
- return prefix + type;
4513
+ working = working.replace(/[()]/g, " ");
4514
+ extractFlatTables(working, tables);
4515
+ return tables;
4277
4516
  }
4278
- function stripPluginCommandPrefix(type, pluginId) {
4279
- const prefix = pluginId + ".";
4280
- if (type.startsWith(prefix)) return type.slice(prefix.length);
4281
- if (UUID_V7_PREFIX.test(type)) {
4282
- const idx = type.indexOf(".");
4283
- return type.slice(idx + 1);
4517
+ function assertPluginSqlAllowed(options) {
4518
+ const { sql, ownPrefix, allowedPrefixes = [], researchAllowed = false } = options;
4519
+ if (typeof sql !== "string" || sql.trim() === "") return;
4520
+ for (const opRegex of FORBIDDEN_OPERATIONS) {
4521
+ if (opRegex.test(sql)) {
4522
+ throw new Error(`Dangerous statement is forbidden: "${sql.trim()}"`);
4523
+ }
4524
+ }
4525
+ const clean = sanitizeSql(sql);
4526
+ const tables = extractReferencedTables(clean);
4527
+ if (/\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\b/i.test(clean) && tables.size === 0) {
4528
+ throw new Error(`Worker plugin SQL rejected: unable to resolve target table in "${sql.trim()}"`);
4529
+ }
4530
+ const isDdl = /^\s*(DROP|CREATE|ALTER|TRUNCATE)\b/i.test(clean);
4531
+ const words = clean.toLowerCase().match(/\b[a-zA-Z_][\w]*\b/g) || [];
4532
+ for (const word of words) {
4533
+ if (FORBIDDEN_CORE_TABLES_SET.has(word)) {
4534
+ if (tables.has(word)) {
4535
+ if (isDdl) {
4536
+ throw new Error(
4537
+ `Worker plugin is not permitted to perform DDL on table "${word}" (core security table is strictly forbidden)`
4538
+ );
4539
+ }
4540
+ throw new Error(
4541
+ `Worker plugin is not permitted to access table "${word}" (core security table is strictly forbidden)`
4542
+ );
4543
+ }
4544
+ }
4545
+ }
4546
+ const ownPrefixLower = ownPrefix ? ownPrefix.toLowerCase() : "";
4547
+ const validPrefixes = [ownPrefixLower, ...allowedPrefixes.map((p) => p.toLowerCase())].filter(Boolean);
4548
+ for (const table of tables) {
4549
+ if (table === "plugin_migrations") continue;
4550
+ if (researchAllowed && table.startsWith("plugin_research_")) continue;
4551
+ const isOwn = validPrefixes.some((p) => table.startsWith(p));
4552
+ if (isOwn) continue;
4553
+ if (table === "sqlite_master" || table === "sqlite_schema" || table === "sqlite_temp_master") {
4554
+ const strippedComments = sql.replace(/--.*$/gm, " ").replace(/\/\*[\s\S]*?\*\//g, " ").trim();
4555
+ const isAllowedMetaQuery = /^\s*SELECT\s+name\s+FROM\s+(?:sqlite_master|sqlite_schema)\s+WHERE\s+type\s*=\s*['"]table['"]\s+AND\s+name\s+LIKE\s+['"][A-Za-z0-9_%]+['"]\s*$/i.test(
4556
+ strippedComments
4557
+ ) && ownPrefixLower && strippedComments.toLowerCase().includes(ownPrefixLower);
4558
+ if (isAllowedMetaQuery) continue;
4559
+ throw new Error(`Direct access to sqlite metadata table "${table}" is forbidden`);
4560
+ }
4561
+ if (isDdl) {
4562
+ throw new Error(
4563
+ `Worker plugin is not permitted to perform DDL on table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
4564
+ );
4565
+ }
4566
+ throw new Error(
4567
+ `Worker plugin is not permitted to access table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
4568
+ );
4284
4569
  }
4285
- return type;
4286
4570
  }
4287
- var UUID_V7_PREFIX;
4288
- var init_plugin_namespace = __esm({
4289
- "packages/core/plugin-host/plugin-namespace.ts"() {
4571
+ var FORBIDDEN_CORE_TABLES, FORBIDDEN_CORE_TABLES_SET, FORBIDDEN_OPERATIONS, SUBQUERY_PLACEHOLDER;
4572
+ var init_sql_guard = __esm({
4573
+ "packages/core/plugin-host/sql-guard.ts"() {
4290
4574
  "use strict";
4291
- UUID_V7_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}\./i;
4575
+ FORBIDDEN_CORE_TABLES = Object.freeze([
4576
+ "users",
4577
+ "client_sessions",
4578
+ "plugins",
4579
+ "plugin_storage",
4580
+ "ai_providers",
4581
+ "processes",
4582
+ "pending_commands",
4583
+ "classes",
4584
+ "students",
4585
+ "class_students",
4586
+ "lessons",
4587
+ "assignments",
4588
+ "assignment_submissions",
4589
+ "attendance",
4590
+ "schedules",
4591
+ "class_grade_weights",
4592
+ "exams",
4593
+ "exam_scores",
4594
+ "student_semester_reports",
4595
+ "student_rollcalls",
4596
+ "student_seats",
4597
+ "student_read_notifications",
4598
+ "student_lesson_progress",
4599
+ "courseware",
4600
+ "courseware_attempt",
4601
+ "system_resources",
4602
+ "events",
4603
+ "_migrations",
4604
+ "site_settings",
4605
+ "agent_conversations",
4606
+ "classroom_sessions",
4607
+ "lesson_quiz_submissions",
4608
+ "courseware_score_config",
4609
+ "demo_data_registry",
4610
+ "whiteboard_elements",
4611
+ "student_point_logs",
4612
+ "lianyun_research_activities",
4613
+ "lianyun_research_groups",
4614
+ "lianyun_research_submissions",
4615
+ "lianyun_research_reviews"
4616
+ ]);
4617
+ FORBIDDEN_CORE_TABLES_SET = new Set(FORBIDDEN_CORE_TABLES.map((t) => t.toLowerCase()));
4618
+ FORBIDDEN_OPERATIONS = [
4619
+ /\bATTACH(?:\s+DATABASE)?\b/i,
4620
+ /\bDETACH(?:\s+DATABASE)?\b/i,
4621
+ /\bPRAGMA\b/i,
4622
+ /\bVACUUM\b/i,
4623
+ /\bCREATE\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i,
4624
+ /\bDROP\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i
4625
+ ];
4626
+ SUBQUERY_PLACEHOLDER = "__ol_subquery__";
4292
4627
  }
4293
4628
  });
4294
4629
 
@@ -4318,6 +4653,108 @@ var init_plugin_tables = __esm({
4318
4653
  }
4319
4654
  });
4320
4655
 
4656
+ // packages/core/plugin-host/inline-db-guard.ts
4657
+ function isSqlGuardAllModesEnabled(env = process.env) {
4658
+ const raw = env.OPENLEARN_SQL_GUARD_ALL_MODES?.trim().toLowerCase();
4659
+ return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
4660
+ }
4661
+ function createGuardedPluginDatabase(db2, pluginId, manifestId) {
4662
+ const ownPrefix = pluginTablePrefix(pluginId, manifestId);
4663
+ const allowedPrefixes = listAllowedPluginTablePrefixes(pluginId, manifestId);
4664
+ const assertSql = (sql) => {
4665
+ try {
4666
+ assertPluginSqlAllowed({ sql: typeof sql === "string" ? sql : String(sql ?? ""), ownPrefix, allowedPrefixes });
4667
+ } catch (err) {
4668
+ const message = err?.message ? String(err.message) : String(err);
4669
+ const wrapped = new Error(`${SQL_GUARD_ERROR_PREFIX}\uFF1A${message}\uFF08\u63D2\u4EF6 "${pluginId}"\uFF09`);
4670
+ wrapped.code = "PLUGIN_SQL_REJECTED";
4671
+ throw wrapped;
4672
+ }
4673
+ };
4674
+ return new Proxy(db2, {
4675
+ get(target, prop, receiver) {
4676
+ const value = Reflect.get(target, prop, receiver);
4677
+ if (typeof value !== "function") return value;
4678
+ if (prop === "prepare") {
4679
+ return (sql, ...rest) => {
4680
+ assertSql(sql);
4681
+ return value.call(target, sql, ...rest);
4682
+ };
4683
+ }
4684
+ if (prop === "exec") {
4685
+ return (sql, ...rest) => {
4686
+ assertSql(sql);
4687
+ return value.call(target, sql, ...rest);
4688
+ };
4689
+ }
4690
+ if (prop === "pragma") {
4691
+ return (source, ...rest) => {
4692
+ assertSql(`PRAGMA ${String(source ?? "")}`);
4693
+ return value.call(target, source, ...rest);
4694
+ };
4695
+ }
4696
+ return value.bind(target);
4697
+ }
4698
+ });
4699
+ }
4700
+ var SQL_GUARD_ERROR_PREFIX;
4701
+ var init_inline_db_guard = __esm({
4702
+ "packages/core/plugin-host/inline-db-guard.ts"() {
4703
+ "use strict";
4704
+ init_sql_guard();
4705
+ init_plugin_tables();
4706
+ SQL_GUARD_ERROR_PREFIX = "[PluginHost] SQL \u88AB\u62D2\u7EDD";
4707
+ }
4708
+ });
4709
+
4710
+ // packages/core/command-bus/plugin-command.ts
4711
+ function bindPluginCommand(command, pluginActorId) {
4712
+ const actor = typeof pluginActorId === "string" ? pluginActorId.trim() : "";
4713
+ if (!actor) {
4714
+ throw new Error("[Security] Refusing to dispatch a plugin command without a host-assigned actor");
4715
+ }
4716
+ const raw = command && typeof command === "object" ? { ...command } : {};
4717
+ const metadata = raw.metadata && typeof raw.metadata === "object" ? { ...raw.metadata } : {};
4718
+ delete metadata.approved;
4719
+ return { ...raw, actorId: actor, metadata };
4720
+ }
4721
+ function pluginActorIdFor(manifestId, pluginId) {
4722
+ const id = (manifestId || pluginId || "").trim();
4723
+ if (!id) {
4724
+ throw new Error("[Security] Refusing to dispatch a plugin command without a host-assigned actor");
4725
+ }
4726
+ return id.startsWith("plugin:") ? id : `plugin:${id}`;
4727
+ }
4728
+ var init_plugin_command = __esm({
4729
+ "packages/core/command-bus/plugin-command.ts"() {
4730
+ "use strict";
4731
+ }
4732
+ });
4733
+
4734
+ // packages/core/plugin-host/plugin-namespace.ts
4735
+ function resolvePluginCommandType(type, pluginId) {
4736
+ const prefix = pluginId + ".";
4737
+ if (type.startsWith(prefix)) return type;
4738
+ if (UUID_V7_PREFIX.test(type)) return type;
4739
+ return prefix + type;
4740
+ }
4741
+ function stripPluginCommandPrefix(type, pluginId) {
4742
+ const prefix = pluginId + ".";
4743
+ if (type.startsWith(prefix)) return type.slice(prefix.length);
4744
+ if (UUID_V7_PREFIX.test(type)) {
4745
+ const idx = type.indexOf(".");
4746
+ return type.slice(idx + 1);
4747
+ }
4748
+ return type;
4749
+ }
4750
+ var UUID_V7_PREFIX;
4751
+ var init_plugin_namespace = __esm({
4752
+ "packages/core/plugin-host/plugin-namespace.ts"() {
4753
+ "use strict";
4754
+ UUID_V7_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}\./i;
4755
+ }
4756
+ });
4757
+
4321
4758
  // packages/core/plugin-host/context-builder.ts
4322
4759
  var context_builder_exports = {};
4323
4760
  __export(context_builder_exports, {
@@ -4720,15 +5157,51 @@ function wrapInteractionRuntimeService(service, tracker, pluginId) {
4720
5157
  listActivityProviders: createSafeFunction(() => service.listActivityProviders())
4721
5158
  };
4722
5159
  }
4723
- function wrapPluginRuntimeService(raw, tokenName, tracker, pluginId) {
5160
+ function wrapPluginRuntimeService(raw, tokenName, tracker, pluginId, manifestId) {
4724
5161
  if (tokenName === IClassroomLifecycleServiceToken.name) {
4725
5162
  return wrapClassroomLifecycleService(raw, tracker, pluginId);
4726
5163
  }
4727
5164
  if (tokenName === IInteractionRuntimeServiceToken.name) {
4728
5165
  return wrapInteractionRuntimeService(raw, tracker, pluginId);
4729
5166
  }
5167
+ if (tokenName === "@openlearn/core:IDatabase" && isSqlGuardAllModesEnabled()) {
5168
+ return createGuardedPluginDatabase(raw, pluginId, manifestId);
5169
+ }
4730
5170
  return raw;
4731
5171
  }
5172
+ function wrapRosterService(service, pluginId, capabilities) {
5173
+ const CAP_ROSTER_READ = "management:read";
5174
+ const safe = (fn) => createSafeFunction(fn);
5175
+ const assertReadable = (method, detail) => {
5176
+ const hasCap = hasCapability(capabilities ?? [], CAP_ROSTER_READ);
5177
+ recordSecurityAudit("plugin.roster_read", {
5178
+ pluginId,
5179
+ method,
5180
+ ...detail,
5181
+ hasCapability: hasCap,
5182
+ ...hasCap ? {} : { denied: true }
5183
+ });
5184
+ if (hasCap) return;
5185
+ console.error(
5186
+ `[Security] \u63D2\u4EF6 "${pluginId}" \u8C03\u7528 roster.${method} \u88AB\u62D2\uFF1A\u7F3A\u5C11\u80FD\u529B "${CAP_ROSTER_READ}"\u3002\u8BF7\u5728 manifest.capabilitiesProposed \u58F0\u660E\u5B83\uFF08\u6559\u5E08\u89D2\u8272\u9ED8\u8BA4\u5177\u5907\uFF09\u3002`
5187
+ );
5188
+ throw new PluginCapabilityDeniedError(pluginId, CAP_ROSTER_READ, "read", method);
5189
+ };
5190
+ return {
5191
+ listClasses: safe(async () => {
5192
+ assertReadable("listClasses", {});
5193
+ return service.listClasses();
5194
+ }),
5195
+ listClassStudents: safe(async (classId) => {
5196
+ assertReadable("listClassStudents", { classId });
5197
+ return service.listClassStudents(classId);
5198
+ }),
5199
+ getStudent: safe(async (studentId) => {
5200
+ assertReadable("getStudent", { studentId });
5201
+ return service.getStudent(studentId);
5202
+ })
5203
+ };
5204
+ }
4732
5205
  function wrapPointsLedgerService(service, pluginId, capabilities) {
4733
5206
  const auditAccess = (studentId, method, operation, requiredCap) => {
4734
5207
  const hasCap = hasCapability(capabilities ?? [], requiredCap);
@@ -4741,6 +5214,9 @@ function wrapPointsLedgerService(service, pluginId, capabilities) {
4741
5214
  ...hasCap ? {} : { denied: true }
4742
5215
  });
4743
5216
  if (!hasCap) {
5217
+ console.error(
5218
+ `[Security] \u63D2\u4EF6 "${pluginId}" \u8C03\u7528 pointsLedger.${method} \u88AB\u62D2\uFF1A\u7F3A\u5C11\u80FD\u529B "${requiredCap}"\uFF08${operation} / student=${studentId}\uFF09\u3002\u8BF7\u5728 manifest.capabilitiesProposed \u58F0\u660E\u5B83\u3002`
5219
+ );
4744
5220
  throw new PluginCapabilityDeniedError(pluginId, requiredCap, operation, method);
4745
5221
  }
4746
5222
  };
@@ -4857,6 +5333,7 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
4857
5333
  const aiService = await serviceRegistry.resolve(IAIServiceToken);
4858
5334
  const pointsDimensionRegistry = await serviceRegistry.tryResolve(IPointsDimensionRegistryToken);
4859
5335
  const pointsLedgerService = await serviceRegistry.tryResolve(IPointsLedgerServiceToken);
5336
+ const rosterService = await serviceRegistry.tryResolve(IClassRosterServiceToken);
4860
5337
  const wrappedCommandBus = wrapCommandBus(commandBusService, tracker, pluginId, manifest.id, actionRegistryService);
4861
5338
  const wrappedEventBus = wrapEventBus(eventBusService, tracker, pluginId);
4862
5339
  const wrappedProcessManager = wrapProcessManager(
@@ -4873,6 +5350,7 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
4873
5350
  const wrappedAI = wrapAI(aiService, pluginId, tracker);
4874
5351
  const wrappedPointsDimension = pointsDimensionRegistry ? wrapPointsDimensionRegistry(pointsDimensionRegistry, tracker, pluginId) : null;
4875
5352
  const wrappedPointsLedger = pointsLedgerService ? wrapPointsLedgerService(pointsLedgerService, pluginId, manifest.capabilitiesProposed) : null;
5353
+ const wrappedRoster = rosterService ? wrapRosterService(rosterService, pluginId, manifest.capabilitiesProposed) : null;
4876
5354
  Object.setPrototypeOf(wrappedCommandBus, null);
4877
5355
  Object.setPrototypeOf(wrappedEventBus, null);
4878
5356
  Object.setPrototypeOf(wrappedProcessManager, null);
@@ -4900,7 +5378,8 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
4900
5378
  storage: wrappedStorage,
4901
5379
  ai: wrappedAI,
4902
5380
  pointsDimension: wrappedPointsDimension,
4903
- pointsLedger: wrappedPointsLedger
5381
+ pointsLedger: wrappedPointsLedger,
5382
+ roster: wrappedRoster
4904
5383
  };
4905
5384
  if (skipTokens && skipTokens.size > 0) {
4906
5385
  const TOKEN_TO_SERVICE_KEY = {
@@ -4912,7 +5391,8 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
4912
5391
  "@openlearn/core:IStorageService": "storage",
4913
5392
  "@openlearn/core:IAIService": "ai",
4914
5393
  "@openlearn/core:IPointsDimensionRegistry": "pointsDimension",
4915
- "@openlearn/core:IPointsLedgerService": "pointsLedger"
5394
+ "@openlearn/core:IPointsLedgerService": "pointsLedger",
5395
+ "@openlearn/core:IClassRosterService": "roster"
4916
5396
  };
4917
5397
  for (const tokenName of skipTokens) {
4918
5398
  const serviceKey = TOKEN_TO_SERVICE_KEY[tokenName];
@@ -5023,9 +5503,9 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
5023
5503
  `[PluginHost] Plugin "${manifest.id}" cannot resolve "${token}" by name. Use the Token object imported from @openlearn/plugin-sdk, or one of the SDK-exported Token names.`
5024
5504
  );
5025
5505
  }
5026
- return serviceRegistry.resolveByName(token).then((raw) => wrapPluginRuntimeService(raw, token, tracker, pluginId));
5506
+ return serviceRegistry.resolveByName(token).then((raw) => wrapPluginRuntimeService(raw, token, tracker, pluginId, manifest.id));
5027
5507
  }
5028
- return serviceRegistry.resolve(token).then((raw) => wrapPluginRuntimeService(raw, token.name, tracker, pluginId));
5508
+ return serviceRegistry.resolve(token).then((raw) => wrapPluginRuntimeService(raw, token.name, tracker, pluginId, manifest.id));
5029
5509
  },
5030
5510
  // V3.2: 插件向 DI 容器提供服务(对应 manifest.provides)
5031
5511
  provide: async (token, instance) => {
@@ -5125,6 +5605,7 @@ var init_context_builder = __esm({
5125
5605
  init_audit_log();
5126
5606
  init_method_policy();
5127
5607
  init_errors2();
5608
+ init_inline_db_guard();
5128
5609
  init_extension_points();
5129
5610
  init_interfaces();
5130
5611
  init_plugin_command();
@@ -7518,241 +7999,6 @@ var init_event_forwarder = __esm({
7518
7999
  }
7519
8000
  });
7520
8001
 
7521
- // packages/core/plugin-host/sql-guard.ts
7522
- function sanitizeSql(sql) {
7523
- let inSingleQuote = false;
7524
- let inDoubleQuote = false;
7525
- let inBacktick = false;
7526
- let inBracket = false;
7527
- let result = "";
7528
- const len = sql.length;
7529
- for (let i = 0; i < len; i++) {
7530
- const ch = sql[i];
7531
- const next = i + 1 < len ? sql[i + 1] : "";
7532
- if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "-" && next === "-") {
7533
- while (i < len && sql[i] !== "\n") i++;
7534
- result += " ";
7535
- continue;
7536
- }
7537
- if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "/" && next === "*") {
7538
- i += 2;
7539
- while (i < len && !(sql[i] === "*" && i + 1 < len && sql[i + 1] === "/")) {
7540
- i++;
7541
- }
7542
- i++;
7543
- result += " ";
7544
- continue;
7545
- }
7546
- if (ch === "'" && !inDoubleQuote && !inBacktick && !inBracket) {
7547
- if (inSingleQuote) {
7548
- if (next === "'") {
7549
- i++;
7550
- } else {
7551
- inSingleQuote = false;
7552
- }
7553
- } else {
7554
- inSingleQuote = true;
7555
- }
7556
- result += " ";
7557
- continue;
7558
- }
7559
- if (inSingleQuote) {
7560
- result += " ";
7561
- continue;
7562
- }
7563
- if (ch === "[" && !inDoubleQuote && !inBacktick) {
7564
- inBracket = true;
7565
- result += " ";
7566
- continue;
7567
- }
7568
- if (ch === "]" && inBracket) {
7569
- inBracket = false;
7570
- result += " ";
7571
- continue;
7572
- }
7573
- if (ch === "`" && !inDoubleQuote) {
7574
- inBacktick = !inBacktick;
7575
- result += " ";
7576
- continue;
7577
- }
7578
- if (ch === '"' && !inBacktick) {
7579
- inDoubleQuote = !inDoubleQuote;
7580
- result += " ";
7581
- continue;
7582
- }
7583
- result += ch;
7584
- }
7585
- return result;
7586
- }
7587
- function extractFlatTables(flatSql, tables) {
7588
- const ddlRegex = /\b(?:CREATE|DROP|ALTER)\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?([a-zA-Z_][\w]*)/gi;
7589
- for (const m of flatSql.matchAll(ddlRegex)) {
7590
- if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
7591
- tables.add(m[1].toLowerCase());
7592
- }
7593
- }
7594
- const renameToRegex = /\bRENAME\s+TO\s+([a-zA-Z_][\w]*)/gi;
7595
- for (const m of flatSql.matchAll(renameToRegex)) {
7596
- if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
7597
- tables.add(m[1].toLowerCase());
7598
- }
7599
- }
7600
- const simpleClauseRegex = /\b(?:INTO|UPDATE(?:\s+OR\s+(?:ROLLBACK|ABORT|REPLACE|FAIL|IGNORE))?|JOIN)\s+([a-zA-Z_][\w]*)/gi;
7601
- for (const m of flatSql.matchAll(simpleClauseRegex)) {
7602
- if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
7603
- tables.add(m[1].toLowerCase());
7604
- }
7605
- }
7606
- const fromRegex = /\bFROM\s+([^;]+?)(?=\bWHERE\b|\bGROUP\b|\bHAVING\b|\bORDER\b|\bLIMIT\b|\bUNION\b|\bEXCEPT\b|\bINTERSECT\b|\bWINDOW\b|\bVALUES\b|\bSET\b|\bINTO\b|\bJOIN\b|\bLEFT\b|\bRIGHT\b|\bINNER\b|\bOUTER\b|\bCROSS\b|\bNATURAL\b|\bON\b|\bUSING\b|\bSELECT\b|;|$)/gi;
7607
- for (const match of flatSql.matchAll(fromRegex)) {
7608
- const clause = match[1];
7609
- if (!clause) continue;
7610
- const parts = clause.split(",");
7611
- for (const part of parts) {
7612
- const trimmed2 = part.trim();
7613
- if (!trimmed2) continue;
7614
- const tokenMatch = /^[a-zA-Z_][\w]*/.exec(trimmed2);
7615
- if (tokenMatch) {
7616
- const tableName = tokenMatch[0].toLowerCase();
7617
- if (tableName !== SUBQUERY_PLACEHOLDER) {
7618
- tables.add(tableName);
7619
- }
7620
- }
7621
- }
7622
- }
7623
- }
7624
- function extractReferencedTables(cleanSql) {
7625
- const tables = /* @__PURE__ */ new Set();
7626
- let working = cleanSql;
7627
- const parenRegex = /\(([^()]*)\)/g;
7628
- let depth = 0;
7629
- while (parenRegex.test(working) && depth++ < 100) {
7630
- working = working.replace(parenRegex, (_full, inner) => {
7631
- extractFlatTables(inner, tables);
7632
- const trimmedInner = inner.trim();
7633
- if (/^(?:SELECT|VALUES|WITH)\b/i.test(trimmedInner)) {
7634
- return ` ${SUBQUERY_PLACEHOLDER} `;
7635
- }
7636
- return ` ${inner} `;
7637
- });
7638
- }
7639
- working = working.replace(/[()]/g, " ");
7640
- extractFlatTables(working, tables);
7641
- return tables;
7642
- }
7643
- function assertPluginSqlAllowed(options) {
7644
- const { sql, ownPrefix, allowedPrefixes = [], researchAllowed = false } = options;
7645
- if (typeof sql !== "string" || sql.trim() === "") return;
7646
- for (const opRegex of FORBIDDEN_OPERATIONS) {
7647
- if (opRegex.test(sql)) {
7648
- throw new Error(`Dangerous statement is forbidden: "${sql.trim()}"`);
7649
- }
7650
- }
7651
- const clean = sanitizeSql(sql);
7652
- const tables = extractReferencedTables(clean);
7653
- if (/\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\b/i.test(clean) && tables.size === 0) {
7654
- throw new Error(`Worker plugin SQL rejected: unable to resolve target table in "${sql.trim()}"`);
7655
- }
7656
- const isDdl = /^\s*(DROP|CREATE|ALTER|TRUNCATE)\b/i.test(clean);
7657
- const words = clean.toLowerCase().match(/\b[a-zA-Z_][\w]*\b/g) || [];
7658
- for (const word of words) {
7659
- if (FORBIDDEN_CORE_TABLES_SET.has(word)) {
7660
- if (tables.has(word)) {
7661
- if (isDdl) {
7662
- throw new Error(
7663
- `Worker plugin is not permitted to perform DDL on table "${word}" (core security table is strictly forbidden)`
7664
- );
7665
- }
7666
- throw new Error(
7667
- `Worker plugin is not permitted to access table "${word}" (core security table is strictly forbidden)`
7668
- );
7669
- }
7670
- }
7671
- }
7672
- const ownPrefixLower = ownPrefix ? ownPrefix.toLowerCase() : "";
7673
- const validPrefixes = [ownPrefixLower, ...allowedPrefixes.map((p) => p.toLowerCase())].filter(Boolean);
7674
- for (const table of tables) {
7675
- if (table === "plugin_migrations") continue;
7676
- if (researchAllowed && table.startsWith("plugin_research_")) continue;
7677
- const isOwn = validPrefixes.some((p) => table.startsWith(p));
7678
- if (isOwn) continue;
7679
- if (table === "sqlite_master" || table === "sqlite_schema" || table === "sqlite_temp_master") {
7680
- const strippedComments = sql.replace(/--.*$/gm, " ").replace(/\/\*[\s\S]*?\*\//g, " ").trim();
7681
- const isAllowedMetaQuery = /^\s*SELECT\s+name\s+FROM\s+(?:sqlite_master|sqlite_schema)\s+WHERE\s+type\s*=\s*['"]table['"]\s+AND\s+name\s+LIKE\s+['"][A-Za-z0-9_%]+['"]\s*$/i.test(
7682
- strippedComments
7683
- ) && ownPrefixLower && strippedComments.toLowerCase().includes(ownPrefixLower);
7684
- if (isAllowedMetaQuery) continue;
7685
- throw new Error(`Direct access to sqlite metadata table "${table}" is forbidden`);
7686
- }
7687
- if (isDdl) {
7688
- throw new Error(
7689
- `Worker plugin is not permitted to perform DDL on table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
7690
- );
7691
- }
7692
- throw new Error(
7693
- `Worker plugin is not permitted to access table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
7694
- );
7695
- }
7696
- }
7697
- var FORBIDDEN_CORE_TABLES, FORBIDDEN_CORE_TABLES_SET, FORBIDDEN_OPERATIONS, SUBQUERY_PLACEHOLDER;
7698
- var init_sql_guard = __esm({
7699
- "packages/core/plugin-host/sql-guard.ts"() {
7700
- "use strict";
7701
- FORBIDDEN_CORE_TABLES = Object.freeze([
7702
- "users",
7703
- "client_sessions",
7704
- "plugins",
7705
- "plugin_storage",
7706
- "ai_providers",
7707
- "processes",
7708
- "pending_commands",
7709
- "classes",
7710
- "students",
7711
- "class_students",
7712
- "lessons",
7713
- "assignments",
7714
- "assignment_submissions",
7715
- "attendance",
7716
- "schedules",
7717
- "class_grade_weights",
7718
- "exams",
7719
- "exam_scores",
7720
- "student_semester_reports",
7721
- "student_rollcalls",
7722
- "student_seats",
7723
- "student_read_notifications",
7724
- "student_lesson_progress",
7725
- "courseware",
7726
- "courseware_attempt",
7727
- "system_resources",
7728
- "events",
7729
- "_migrations",
7730
- "site_settings",
7731
- "agent_conversations",
7732
- "classroom_sessions",
7733
- "lesson_quiz_submissions",
7734
- "courseware_score_config",
7735
- "demo_data_registry",
7736
- "whiteboard_elements",
7737
- "student_point_logs",
7738
- "lianyun_research_activities",
7739
- "lianyun_research_groups",
7740
- "lianyun_research_submissions",
7741
- "lianyun_research_reviews"
7742
- ]);
7743
- FORBIDDEN_CORE_TABLES_SET = new Set(FORBIDDEN_CORE_TABLES.map((t) => t.toLowerCase()));
7744
- FORBIDDEN_OPERATIONS = [
7745
- /\bATTACH(?:\s+DATABASE)?\b/i,
7746
- /\bDETACH(?:\s+DATABASE)?\b/i,
7747
- /\bPRAGMA\b/i,
7748
- /\bVACUUM\b/i,
7749
- /\bCREATE\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i,
7750
- /\bDROP\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i
7751
- ];
7752
- SUBQUERY_PLACEHOLDER = "__ol_subquery__";
7753
- }
7754
- });
7755
-
7756
8002
  // packages/core/worker-runtime/service-host.ts
7757
8003
  function sanitizeClonableValue(val, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
7758
8004
  if (depth > 12) return null;
@@ -9050,6 +9296,7 @@ var init_di = __esm({
9050
9296
  init_storage_service();
9051
9297
  init_ai_service();
9052
9298
  init_courseware_runtime_script_registry();
9299
+ init_ai_provider_resolver();
9053
9300
  init_container();
9054
9301
  }
9055
9302
  });
@@ -10309,6 +10556,15 @@ var CommandBus = class {
10309
10556
  hasHandler(commandType) {
10310
10557
  return this.handlers.has(commandType) || this.legacyHandlers.has(commandType);
10311
10558
  }
10559
+ /**
10560
+ * 只读枚举已注册的命令类型(modern + legacy)。
10561
+ *
10562
+ * 供插件激活后的「handler 有、ActionDescriptor 没有」自检使用(Issue #369):
10563
+ * 自检要能看清「这次激活新增了哪些命令」,而不是去读私有 Map。
10564
+ */
10565
+ listRegisteredCommandTypes() {
10566
+ return Array.from(/* @__PURE__ */ new Set([...this.handlers.keys(), ...this.legacyHandlers.keys()]));
10567
+ }
10312
10568
  async execute(command) {
10313
10569
  const actorId = typeof command.actorId === "string" ? command.actorId.trim() : "";
10314
10570
  if (!actorId) {
@@ -10411,6 +10667,20 @@ var ActionRegistry = class {
10411
10667
  getActionByCommandType(commandType) {
10412
10668
  return Array.from(this.actions.values()).find((a) => a.commandType === commandType);
10413
10669
  }
10670
+ /** 已登记描述符覆盖的 commandType(去重、只读)。 */
10671
+ listRegisteredCommandTypes() {
10672
+ return Array.from(new Set(Array.from(this.actions.values()).map((a) => a.commandType))).sort();
10673
+ }
10674
+ /**
10675
+ * 给定命令类型里**没有** ActionDescriptor 的那些。
10676
+ *
10677
+ * 命令总线是 default-deny:这种命令会在用户点击时被 403 `COMMAND_NOT_REGISTERED` 拒绝。
10678
+ * 插件激活后用它把「注册了 handler 却忘了登记描述符」提前打成一条 WARN(Issue #369)。
10679
+ */
10680
+ listMissingDescriptors(commandTypes) {
10681
+ const registered = new Set(Array.from(this.actions.values()).map((a) => a.commandType));
10682
+ return Array.from(new Set(commandTypes)).filter((type) => !registered.has(type)).sort();
10683
+ }
10414
10684
  };
10415
10685
 
10416
10686
  // packages/core/capability/event/capability-event-bus.ts
@@ -10678,6 +10948,7 @@ var InvocationEngine = class {
10678
10948
  };
10679
10949
 
10680
10950
  // packages/core/capability/guard/capability-guard.ts
10951
+ init_catalog();
10681
10952
  var CapabilityGuard = class _CapabilityGuard {
10682
10953
  constructor() {
10683
10954
  this.actorCapabilities = /* @__PURE__ */ new Map();
@@ -10693,20 +10964,20 @@ var CapabilityGuard = class _CapabilityGuard {
10693
10964
  ]);
10694
10965
  this.actorCapabilities.set("anonymous", []);
10695
10966
  this.actorCapabilities.set("agent-system-0", [
10696
- "lesson:write",
10697
- "lesson:delete",
10698
- "whiteboard:write",
10967
+ CAP.LESSON_WRITE,
10968
+ CAP.LESSON_DELETE,
10969
+ CAP.WHITEBOARD_WRITE,
10699
10970
  "quiz:write",
10700
- "plugin:read",
10701
- "vfs:read",
10702
- "vfs:write",
10703
- "management:write",
10704
- "management:read",
10705
- "process:write",
10706
- "process:read"
10971
+ CAP.PLUGIN_READ,
10972
+ CAP.VFS_READ,
10973
+ CAP.VFS_WRITE,
10974
+ CAP.MANAGEMENT_WRITE,
10975
+ CAP.MANAGEMENT_READ,
10976
+ CAP.PROCESS_WRITE,
10977
+ CAP.PROCESS_READ
10707
10978
  ]);
10708
10979
  this.actorCapabilities.set("teacher-demo", ["lesson:*", "whiteboard:*", "management:*", "quiz:*", "vfs:*"]);
10709
- this.actorCapabilities.set("student-demo", ["student:write", "lesson:read", "whiteboard:read"]);
10980
+ this.actorCapabilities.set("student-demo", [CAP.STUDENT_WRITE, CAP.LESSON_READ, CAP.WHITEBOARD_READ]);
10710
10981
  }
10711
10982
  grant(actorId, cap) {
10712
10983
  const caps = this.actorCapabilities.get(actorId) || [];
@@ -10740,11 +11011,11 @@ var CapabilityGuard = class _CapabilityGuard {
10740
11011
  "vfs:*",
10741
11012
  "process:*",
10742
11013
  "plugin:*",
10743
- "student:write",
10744
- "assignment:read",
10745
- "assignment:submit",
10746
- "assignment:review",
10747
- "assignment:manage"
11014
+ CAP.STUDENT_WRITE,
11015
+ CAP.ASSIGNMENT_READ,
11016
+ CAP.ASSIGNMENT_SUBMIT,
11017
+ CAP.ASSIGNMENT_REVIEW,
11018
+ CAP.ASSIGNMENT_MANAGE
10748
11019
  ];
10749
11020
  const [reqRes2, reqAct2] = requiredCap.split(":");
10750
11021
  if (teacherCaps.some((c) => {
@@ -10756,12 +11027,12 @@ var CapabilityGuard = class _CapabilityGuard {
10756
11027
  }
10757
11028
  if (role === "student") {
10758
11029
  const studentCaps = [
10759
- "student:write",
10760
- "lesson:read",
10761
- "whiteboard:read",
10762
- "assignment:read",
10763
- "assignment:submit",
10764
- "assignment:review"
11030
+ CAP.STUDENT_WRITE,
11031
+ CAP.LESSON_READ,
11032
+ CAP.WHITEBOARD_READ,
11033
+ CAP.ASSIGNMENT_READ,
11034
+ CAP.ASSIGNMENT_SUBMIT,
11035
+ CAP.ASSIGNMENT_REVIEW
10765
11036
  ];
10766
11037
  const [reqRes2, reqAct2] = requiredCap.split(":");
10767
11038
  if (studentCaps.some((c) => {
@@ -12100,18 +12371,25 @@ function generateUnambiguousPassword(length = 20) {
12100
12371
  }
12101
12372
  return out;
12102
12373
  }
12374
+ function resolveSeedPasswordMode(env = process.env) {
12375
+ return env.OPENLEARN_SEED_PASSWORD_MODE?.trim().toLowerCase() === "random" ? "random" : "default";
12376
+ }
12103
12377
  function resolveSeedCredential(username, options) {
12104
12378
  if (options?.allowWellKnown) {
12105
- return { password: username, mustChange: false, generated: false };
12379
+ return { password: username, mustChange: false, generated: false, source: "playwright" };
12106
12380
  }
12107
12381
  const envValue = options?.envValue?.trim();
12108
12382
  if (envValue) {
12109
- return { password: envValue, mustChange: envValue === username, generated: false };
12383
+ return { password: envValue, mustChange: envValue === username, generated: false, source: "env" };
12110
12384
  }
12111
12385
  if (options?.vitestFallback) {
12112
- return { password: `Vitest-${username}-NotDefault9`, mustChange: false, generated: false };
12386
+ return { password: `Vitest-${username}-NotDefault9`, mustChange: false, generated: false, source: "vitest" };
12387
+ }
12388
+ const mode = options?.mode ?? resolveSeedPasswordMode();
12389
+ if (mode === "random") {
12390
+ return { password: generateUnambiguousPassword(20), mustChange: true, generated: true, source: "random" };
12113
12391
  }
12114
- return { password: generateUnambiguousPassword(20), mustChange: true, generated: true };
12392
+ return { password: username, mustChange: true, generated: false, source: "default" };
12115
12393
  }
12116
12394
  function resolveBootstrapCredentialDir() {
12117
12395
  if (process.env.OPENLEARN_DATA_DIR?.trim()) {
@@ -12135,6 +12413,11 @@ function persistGeneratedSeedCredentials(lines) {
12135
12413
  console.warn(`[SECURITY] \u65E0\u6CD5\u5199\u5165 ${filePath}\uFF0C\u8BF7\u4ECE\u4E0A\u65B9\u65E5\u5FD7\u6284\u5F55\u521D\u59CB\u53E3\u4EE4\u3002`, err);
12136
12414
  }
12137
12415
  }
12416
+ function logDefaultSeedCredentials(usernames) {
12417
+ console.warn(
12418
+ "[SECURITY] \u9996\u6B21\u542F\u52A8\u5DF2\u521B\u5EFA\u79CD\u5B50\u8D26\u53F7\uFF0C\u521D\u59CB\u53E3\u4EE4 = \u7528\u6237\u540D\uFF08\u9996\u6B21\u767B\u5F55\u540E\u5FC5\u987B\u4FEE\u6539\uFF09\uFF1A\n" + usernames.map((name) => ` ${name} / ${name}`).join("\n") + "\n \u6539\u5BC6\u524D\uFF0C\u9664\u300C\u6539\u5BC6 / \u767B\u51FA\u300D\u4EE5\u5916\u7684\u6240\u6709\u63A5\u53E3\u90FD\u4F1A\u88AB\u62D2\u7EDD\uFF08403 FORBIDDEN_DEFAULT_PASSWORD\uFF09\u3002\n \u5982\u9700\u968F\u673A\u521D\u59CB\u53E3\u4EE4\uFF0C\u8BBE\u7F6E OPENLEARN_SEED_PASSWORD_MODE=random \u540E\u91CD\u5EFA\u6570\u636E\u5E93\u3002"
12419
+ );
12420
+ }
12138
12421
  function verifyPassword(pwd, storedHash) {
12139
12422
  if (storedHash.startsWith("$2")) {
12140
12423
  return { valid: import_bcryptjs.default.compareSync(pwd, storedHash), needsUpgrade: false };
@@ -12248,16 +12531,19 @@ function initializeDatabase(instance) {
12248
12531
  const countObj = instance.prepare("SELECT COUNT(*) as cnt FROM users").get();
12249
12532
  const allowWellKnown = isPlaywrightTestFlag(process.env.PLAYWRIGHT_TEST);
12250
12533
  const vitestFallback = Boolean(process.env.VITEST) && !allowWellKnown;
12534
+ const seedMode = resolveSeedPasswordMode();
12251
12535
  if (countObj && countObj.cnt === 0) {
12252
12536
  const adminCred = resolveSeedCredential("admin", {
12253
12537
  envValue: process.env.OPENLEARN_ADMIN_PASSWORD,
12254
12538
  allowWellKnown,
12255
- vitestFallback
12539
+ vitestFallback,
12540
+ mode: seedMode
12256
12541
  });
12257
12542
  const teacherCred = resolveSeedCredential("teacher", {
12258
12543
  envValue: process.env.OPENLEARN_TEACHER_PASSWORD,
12259
12544
  allowWellKnown,
12260
- vitestFallback
12545
+ vitestFallback,
12546
+ mode: seedMode
12261
12547
  });
12262
12548
  const insertStmt = instance.prepare(
12263
12549
  "INSERT INTO users (id, username, password_hash, role, name, created_at, must_change_password) VALUES (?, ?, ?, ?, ?, ?, ?)"
@@ -12284,8 +12570,12 @@ function initializeDatabase(instance) {
12284
12570
  if (adminCred.generated) generated.push(`admin ${adminCred.password}`);
12285
12571
  if (teacherCred.generated) generated.push(`teacher ${teacherCred.password}`);
12286
12572
  if (generated.length > 0) persistGeneratedSeedCredentials(generated);
12287
- } else if (!allowWellKnown) {
12288
- const seeded = instance.prepare("SELECT id, username, password_hash FROM users WHERE username IN (?, ?)").all("admin", "teacher");
12573
+ const defaulted = [];
12574
+ if (adminCred.source === "default") defaulted.push("admin");
12575
+ if (teacherCred.source === "default") defaulted.push("teacher");
12576
+ if (defaulted.length > 0) logDefaultSeedCredentials(defaulted);
12577
+ } else {
12578
+ const seeded = allowWellKnown ? [] : instance.prepare("SELECT id, username, password_hash FROM users WHERE username IN (?, ?)").all("admin", "teacher");
12289
12579
  for (const user of seeded) {
12290
12580
  if (!verifyPassword(user.username, user.password_hash).valid) continue;
12291
12581
  instance.prepare("UPDATE users SET must_change_password = 1 WHERE id = ?").run(user.id);
@@ -13205,8 +13495,8 @@ var ManagementPlugin = {
13205
13495
  });
13206
13496
  await commandBus.registerHandler(classTemplateDownloadCmd, {
13207
13497
  async execute() {
13208
- const headers = "Class Name,Class Desc,Student Name,Student Email";
13209
- 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";
13210
13500
  const content = `${headers}
13211
13501
  ${sampleRow}`;
13212
13502
  const fileId = (0, import_uuid4.v7)();
@@ -13238,8 +13528,8 @@ ${sampleRow}`;
13238
13528
  });
13239
13529
  await commandBus.registerHandler(studentTemplateDownloadCmd, {
13240
13530
  async execute() {
13241
- const headers = "Student Name,Student Email";
13242
- 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";
13243
13533
  const content = `${headers}
13244
13534
  ${sampleRow}`;
13245
13535
  const fileId = (0, import_uuid4.v7)();
@@ -14920,6 +15210,45 @@ var BuiltinPlugin = {
14920
15210
  "page_meta"
14921
15211
  // 分页与大纲元数据
14922
15212
  ];
15213
+ const normalizePluginElementData = (dataObj) => {
15214
+ const pluginId = typeof dataObj.pluginId === "string" ? dataObj.pluginId.trim() : "";
15215
+ if (!pluginId) {
15216
+ console.warn("[Whiteboard] plugin \u5143\u7D20\u672A\u58F0\u660E pluginId\uFF0C\u5C06\u53EA\u80FD\u6309 widgetId \u67E5\u627E\u7EC4\u4EF6\uFF1B\u5EFA\u8BAE\u8865\u4E0A manifest id\u3002");
15217
+ }
15218
+ const teacher = typeof dataObj.teacherWidgetId === "string" ? dataObj.teacherWidgetId.trim() : "";
15219
+ const student = typeof dataObj.studentWidgetId === "string" ? dataObj.studentWidgetId.trim() : "";
15220
+ if (!teacher && !student) {
15221
+ throw new Error(
15222
+ "plugin \u5143\u7D20\u5FC5\u987B\u81F3\u5C11\u58F0\u660E teacherWidgetId \u6216 studentWidgetId \u4E4B\u4E00\uFF0C\u5426\u5219\u6559\u5E08\u7AEF / \u5B66\u751F\u7AEF\u90FD\u4F1A\u505C\u5728\u52A0\u8F7D\u6001\u3002"
15223
+ );
15224
+ }
15225
+ if (!teacher) {
15226
+ dataObj.teacherWidgetId = student;
15227
+ console.warn(`[Whiteboard] plugin \u5143\u7D20\u53EA\u58F0\u660E\u4E86 studentWidgetId\uFF0C\u5DF2\u56DE\u9000 teacherWidgetId="${student}"\u3002`);
15228
+ } else if (!student) {
15229
+ dataObj.studentWidgetId = teacher;
15230
+ console.warn(`[Whiteboard] plugin \u5143\u7D20\u53EA\u58F0\u660E\u4E86 teacherWidgetId\uFF0C\u5DF2\u56DE\u9000 studentWidgetId="${teacher}"\u3002`);
15231
+ }
15232
+ };
15233
+ const buildElementData = (type, rawData, segmentId, page) => {
15234
+ let dataObj = null;
15235
+ try {
15236
+ const parsed = JSON.parse(rawData);
15237
+ dataObj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
15238
+ } catch {
15239
+ if (type === "plugin") {
15240
+ throw new Error(
15241
+ 'plugin \u5143\u7D20\u7684 data \u5FC5\u987B\u662F\u5408\u6CD5 JSON \u5B57\u7B26\u4E32\uFF08\u4F8B\u5982 {"pluginId":"ext-x","teacherWidgetId":"w"}\uFF09\u3002'
15242
+ );
15243
+ }
15244
+ }
15245
+ if (!dataObj) return rawData;
15246
+ if (segmentId && !dataObj.segmentId) dataObj.segmentId = segmentId;
15247
+ if (page !== void 0 && dataObj.page === void 0) dataObj.page = page;
15248
+ else if (dataObj.page === void 0) dataObj.page = 0;
15249
+ if (type === "plugin") normalizePluginElementData(dataObj);
15250
+ return JSON.stringify(dataObj);
15251
+ };
14923
15252
  const isAllowedElementType = (type) => {
14924
15253
  if (!type || typeof type !== "string") return false;
14925
15254
  if (KNOWN_ELEMENT_TYPES2.includes(type)) return true;
@@ -14968,20 +15297,7 @@ var BuiltinPlugin = {
14968
15297
  throw new Error(`\u4E0D\u652F\u6301\u7684\u5143\u7D20\u7C7B\u578B "${payload.type}"\u3002${hint}`);
14969
15298
  }
14970
15299
  const elementId = (0, import_uuid7.v7)();
14971
- let dataStr = payload.data;
14972
- try {
14973
- const dataObj = JSON.parse(dataStr);
14974
- if (payload.segmentId && !dataObj.segmentId) {
14975
- dataObj.segmentId = payload.segmentId;
14976
- }
14977
- if (payload.page !== void 0 && dataObj.page === void 0) {
14978
- dataObj.page = payload.page;
14979
- } else if (dataObj.page === void 0) {
14980
- dataObj.page = 0;
14981
- }
14982
- dataStr = JSON.stringify(dataObj);
14983
- } catch {
14984
- }
15300
+ const dataStr = buildElementData(payload.type, payload.data, payload.segmentId, payload.page);
14985
15301
  const stmt = db2.prepare(
14986
15302
  "INSERT INTO whiteboard_elements (id, lesson_id, type, data, created_at) VALUES (?, ?, ?, ?, ?)"
14987
15303
  );
@@ -15210,15 +15526,7 @@ var BuiltinPlugin = {
15210
15526
  throw new Error(`\u4E0D\u652F\u6301\u7684\u5143\u7D20\u7C7B\u578B "${el.type}"\u3002\u5DF2\u77E5\u7C7B\u578B\uFF1A${KNOWN_ELEMENT_TYPES2.join(", ")}`);
15211
15527
  }
15212
15528
  const elementId = (0, import_uuid7.v7)();
15213
- let dataStr = el.data;
15214
- try {
15215
- const dataObj = JSON.parse(dataStr);
15216
- if (el.segmentId && !dataObj.segmentId) dataObj.segmentId = el.segmentId;
15217
- if (el.page !== void 0 && dataObj.page === void 0) dataObj.page = el.page;
15218
- else if (dataObj.page === void 0) dataObj.page = 0;
15219
- dataStr = JSON.stringify(dataObj);
15220
- } catch {
15221
- }
15529
+ const dataStr = buildElementData(el.type, el.data, el.segmentId, el.page);
15222
15530
  insertStmt.run(elementId, payload.lessonId, el.type, dataStr, Date.now());
15223
15531
  elementIds.push(elementId);
15224
15532
  }
@@ -18042,6 +18350,40 @@ var SemesterGradeService = class {
18042
18350
  }
18043
18351
  };
18044
18352
 
18353
+ // packages/core/di/roster-service.ts
18354
+ var ClassRosterService = class {
18355
+ constructor(db2) {
18356
+ this.db = db2;
18357
+ }
18358
+ async listClasses() {
18359
+ const rows = this.db.prepare(
18360
+ `SELECT c.id AS id, c.name AS name, COUNT(cs.student_id) AS student_count
18361
+ FROM classes c
18362
+ LEFT JOIN class_students cs ON cs.class_id = c.id
18363
+ GROUP BY c.id, c.name
18364
+ ORDER BY c.created_at DESC, c.name ASC`
18365
+ ).all();
18366
+ return rows.map((row) => ({ id: row.id, name: row.name, studentCount: Number(row.student_count) }));
18367
+ }
18368
+ async listClassStudents(classId) {
18369
+ const rows = this.db.prepare(
18370
+ `SELECT s.id AS id, s.name AS name, s.student_number AS student_number
18371
+ FROM class_students cs
18372
+ JOIN students s ON s.id = cs.student_id
18373
+ WHERE cs.class_id = ?
18374
+ ORDER BY s.name ASC`
18375
+ ).all(classId);
18376
+ return rows.map(toStudentRef);
18377
+ }
18378
+ async getStudent(studentId) {
18379
+ const row = this.db.prepare("SELECT id, name, student_number FROM students WHERE id = ?").get(studentId);
18380
+ return row ? toStudentRef(row) : null;
18381
+ }
18382
+ };
18383
+ function toStudentRef(row) {
18384
+ return { id: row.id, name: row.name, studentNumber: row.student_number ?? null };
18385
+ }
18386
+
18045
18387
  // packages/core/di/points-dimension-registry.ts
18046
18388
  var PointsDimensionRegistry = class {
18047
18389
  constructor() {
@@ -20253,6 +20595,7 @@ init_types();
20253
20595
  init_errors2();
20254
20596
  init_interfaces();
20255
20597
  init_plugin_grants();
20598
+ init_catalog();
20256
20599
  var PluginHostLifecycle = class extends PluginHostHttp {
20257
20600
  /**
20258
20601
  * 激活插件。
@@ -20310,12 +20653,47 @@ var PluginHostLifecycle = class extends PluginHostHttp {
20310
20653
  const currentState = this.pluginStates.get(pluginId) ?? "installed" /* INSTALLED */;
20311
20654
  this.validateTransition(pluginId, currentState, "activating" /* ACTIVATING */);
20312
20655
  this.setPluginState(pluginId, "activating" /* ACTIVATING */);
20656
+ const commandTypesBefore = this.listHostCommandTypes();
20313
20657
  const preloaded = this.preloadedPlugins.get(pluginId);
20314
20658
  if (preloaded) {
20315
20659
  await this.activatePreloaded(pluginId, preloaded);
20660
+ this.auditMissingActionDescriptors(pluginId, commandTypesBefore);
20316
20661
  return;
20317
20662
  }
20318
20663
  await this.activateFromDatabase(pluginId, currentState);
20664
+ this.auditMissingActionDescriptors(pluginId, commandTypesBefore);
20665
+ }
20666
+ /** 宿主 CommandBus 当前注册的命令类型;解析失败返回空集(自检不能影响激活)。 */
20667
+ listHostCommandTypes() {
20668
+ try {
20669
+ const bus = this.serviceRegistry.resolve(ICommandBusServiceToken4);
20670
+ return new Set(bus.listRegisteredCommandTypes?.() ?? []);
20671
+ } catch {
20672
+ return /* @__PURE__ */ new Set();
20673
+ }
20674
+ }
20675
+ /**
20676
+ * 插件激活后的命令契约自检(Issue #369)。
20677
+ *
20678
+ * 命令总线是 default-deny:handler 有、ActionDescriptor 没有的命令会在用户点击时
20679
+ * 403 `COMMAND_NOT_REGISTERED`。静态扫描抓不到循环注册 / 对象表 / `regHandler()` 包装,
20680
+ * 所以改为激活后做一次运行时差集,把缺口提前打成立即可见的 WARN。
20681
+ *
20682
+ * 自检只服务可观测性:任何异常都不能影响激活结果,故整体 try/catch 且只打日志。
20683
+ */
20684
+ auditMissingActionDescriptors(pluginId, before) {
20685
+ try {
20686
+ if (this.inflightActivate.size > 1) return;
20687
+ const added = Array.from(this.listHostCommandTypes()).filter((type) => !before.has(type));
20688
+ if (added.length === 0) return;
20689
+ const registry4 = this.serviceRegistry.resolve(IActionRegistryServiceToken4);
20690
+ const missing = registry4.listMissingDescriptors?.(added) ?? [];
20691
+ if (missing.length === 0) return;
20692
+ console.warn(
20693
+ `[PluginHost] \u63D2\u4EF6 "${pluginId}" \u6709 ${missing.length} \u4E2A\u547D\u4EE4 handler \u6CA1\u6709 ActionDescriptor\uFF1A${missing.join(", ")}\u3002\u8FD9\u4E9B\u547D\u4EE4\u4F1A\u88AB default-deny \u4EE5 403 COMMAND_NOT_REGISTERED \u62D2\u7EDD\uFF0C\u8BF7\u4E3A\u6BCF\u4E2A registerHandler \u540C\u65F6\u6CE8\u518C actionRegistry.register(...)\u3002`
20694
+ );
20695
+ } catch {
20696
+ }
20319
20697
  }
20320
20698
  /**
20321
20699
  * preloaded 插件的激活(Phase 8,内置插件路径)。
@@ -20566,6 +20944,12 @@ var PluginHostLifecycle = class extends PluginHostHttp {
20566
20944
  async grantCapabilities(actorId, caps, opts) {
20567
20945
  if (caps.length === 0) return;
20568
20946
  try {
20947
+ const { unknown } = validateManifestCapabilities(caps);
20948
+ if (unknown.length > 0) {
20949
+ console.error(
20950
+ `[PluginHost] "${actorId}" \u7684 manifest \u58F0\u660E\u4E86\u672A\u77E5\u80FD\u529B\uFF1A${unknown.join(", ")}\u3002\u8FD9\u4E9B\u4E32\u4E0D\u4F1A\u751F\u6548\uFF08\u5B88\u536B\u53EA\u8BA4 ActionDescriptor.capabilityRequired\uFF09\u3002\u5408\u6CD5\u6E05\u5355\u89C1 packages/core/capability/catalog.ts \u4E0E docs/reference/plugin-capability-matrix.md\u3002`
20951
+ );
20952
+ }
20569
20953
  const capService = await this.serviceRegistry.resolve(ICapabilityServiceToken);
20570
20954
  for (const cap of caps) {
20571
20955
  if (typeof cap !== "string" || isBlockedPluginCapability(cap)) {
@@ -20906,7 +21290,7 @@ var import_node_fs11 = __toESM(require("node:fs"), 1);
20906
21290
  var import_node_path13 = __toESM(require("node:path"), 1);
20907
21291
  var import_node_url5 = require("node:url");
20908
21292
  var import_meta7 = {};
20909
- var FALLBACK_VERSION = "0.7.9";
21293
+ var FALLBACK_VERSION = "0.7.12";
20910
21294
  function readPlatformVersion() {
20911
21295
  const bases = [];
20912
21296
  if (typeof __dirname !== "undefined") bases.push(__dirname);
@@ -30615,6 +30999,7 @@ var Kernel = class {
30615
30999
  this.serviceRegistry.register(ISemesterGradeServiceToken, new SemesterGradeService(this.db));
30616
31000
  this.serviceRegistry.register(IPointsDimensionRegistryToken, new PointsDimensionRegistry());
30617
31001
  this.serviceRegistry.register(IPointsLedgerServiceToken, new PointsLedgerService(this.db));
31002
+ this.serviceRegistry.register(IClassRosterServiceToken, new ClassRosterService(this.db));
30618
31003
  this.serviceRegistry.register(ILessonEngineServiceToken, { getRuntime: async () => this.lessonRuntime });
30619
31004
  this.serviceRegistry.register(IClassroomRuntimeServiceToken, {
30620
31005
  getRuntimeKernel: async () => this.classroomRuntime
@@ -32470,6 +32855,299 @@ var import_helmet = __toESM(require("helmet"), 1);
32470
32855
 
32471
32856
  // server/security/rate-limit.ts
32472
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
32473
33151
  function loginRateLimitKey(req) {
32474
33152
  const body = req.body ?? {};
32475
33153
  const raw = body.username ?? body.studentId ?? body.studentName ?? "";
@@ -32486,6 +33164,18 @@ function writeRateLimitKey(req) {
32486
33164
  return ip;
32487
33165
  }
32488
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
+ }
32489
33179
  var limiterValidation = {
32490
33180
  xForwardedForHeader: false,
32491
33181
  trustProxy: false,
@@ -32524,6 +33214,17 @@ function createAiLimiter(max) {
32524
33214
  validate: limiterValidation
32525
33215
  });
32526
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
+ }
32527
33228
 
32528
33229
  // server/security/trust-proxy.ts
32529
33230
  function resolveTrustProxy(raw) {
@@ -32808,21 +33509,29 @@ function validateStartupEnv(env = process.env) {
32808
33509
  if (isProduction) {
32809
33510
  const adminPassword = trimmed(env, "OPENLEARN_ADMIN_PASSWORD");
32810
33511
  if (adminPassword && adminPassword.toLowerCase() === "admin") {
32811
- errors.push({
33512
+ warnings.push({
32812
33513
  key: "OPENLEARN_ADMIN_PASSWORD",
32813
- severity: "error",
32814
- message: '\u751F\u4EA7\u73AF\u5883\u7981\u6B62\u628A OPENLEARN_ADMIN_PASSWORD \u8BBE\u4E3A\u7528\u6237\u540D "admin"\u3002\u8BF7\u8BBE\u7F6E\u5F3A\u53E3\u4EE4\u3002'
33514
+ severity: "warning",
33515
+ message: "OPENLEARN_ADMIN_PASSWORD \u4E0E\u7528\u6237\u540D\u76F8\u540C\u3002\u8FD9\u4E0D\u6BD4\u7559\u7A7A\u66F4\u5F31\uFF08\u7559\u7A7A\u65F6\u521D\u59CB\u53E3\u4EE4\u540C\u6837\u662F admin\uFF09\uFF0C\u4F46\u8BF7\u786E\u8BA4\u9996\u6B21\u767B\u5F55\u540E\u7ACB\u5373\u6539\u5BC6\uFF1B\u751F\u4EA7\u90E8\u7F72\u5EFA\u8BAE\u76F4\u63A5\u8BBE\u7F6E\u4E3A\u5F3A\u53E3\u4EE4\u3002"
32815
33516
  });
32816
33517
  }
32817
33518
  const teacherPassword = trimmed(env, "OPENLEARN_TEACHER_PASSWORD");
32818
33519
  if (teacherPassword && teacherPassword.toLowerCase() === "teacher") {
32819
- errors.push({
33520
+ warnings.push({
32820
33521
  key: "OPENLEARN_TEACHER_PASSWORD",
32821
- severity: "error",
32822
- message: '\u751F\u4EA7\u73AF\u5883\u7981\u6B62\u628A OPENLEARN_TEACHER_PASSWORD \u8BBE\u4E3A\u7528\u6237\u540D "teacher"\u3002\u8BF7\u8BBE\u7F6E\u5F3A\u53E3\u4EE4\u3002'
33522
+ severity: "warning",
33523
+ message: "OPENLEARN_TEACHER_PASSWORD \u4E0E\u7528\u6237\u540D\u76F8\u540C\u3002\u8BF7\u786E\u8BA4\u9996\u6B21\u767B\u5F55\u540E\u7ACB\u5373\u6539\u5BC6\uFF0C\u751F\u4EA7\u90E8\u7F72\u5EFA\u8BAE\u8BBE\u7F6E\u4E3A\u5F3A\u53E3\u4EE4\u3002"
32823
33524
  });
32824
33525
  }
32825
33526
  }
33527
+ const seedMode = trimmed(env, "OPENLEARN_SEED_PASSWORD_MODE");
33528
+ if (seedMode && !["default", "random"].includes(seedMode.toLowerCase())) {
33529
+ errors.push({
33530
+ key: "OPENLEARN_SEED_PASSWORD_MODE",
33531
+ severity: "error",
33532
+ message: `\u53EA\u652F\u6301 "default"\uFF08\u53E3\u4EE4 = \u7528\u6237\u540D\uFF0C\u9996\u6B21\u767B\u5F55\u5F3A\u5236\u6539\u5BC6\uFF09\u6216 "random"\uFF08\u968F\u673A\u53E3\u4EE4\u5199\u5165 bootstrap-credentials.txt\uFF09\uFF0C\u6536\u5230 "${seedMode}"\u3002`
33533
+ });
33534
+ }
32826
33535
  const metricsToken = trimmed(env, "METRICS_TOKEN");
32827
33536
  if (metricsToken && metricsToken.length < 16) {
32828
33537
  warnings.push({
@@ -33892,297 +34601,6 @@ function startBackupScheduler(deps) {
33892
34601
  };
33893
34602
  }
33894
34603
 
33895
- // server/security/route-scope.ts
33896
- var AUTH_MIDDLEWARE_MARKER = Symbol.for("openlearn.routeScope.authMiddleware");
33897
- var DATA_SCOPE_MARKER = Symbol.for("openlearn.routeScope.dataScope");
33898
- function markAuthMiddleware(middleware, roles) {
33899
- Object.defineProperty(middleware, AUTH_MIDDLEWARE_MARKER, {
33900
- value: { roles: [...roles] },
33901
- enumerable: false,
33902
- configurable: true
33903
- });
33904
- return middleware;
33905
- }
33906
- function markDataScopeMiddleware(middleware, scope) {
33907
- Object.defineProperty(middleware, DATA_SCOPE_MARKER, {
33908
- value: scope,
33909
- enumerable: false,
33910
- configurable: true
33911
- });
33912
- return middleware;
33913
- }
33914
- function readMarker(handle, marker) {
33915
- if (typeof handle !== "function") return void 0;
33916
- return handle[marker];
33917
- }
33918
- function collectAuthRoutes(app) {
33919
- const router = app._router ?? app.router;
33920
- const stack = router?.stack ?? [];
33921
- const result = [];
33922
- for (const layer of stack) {
33923
- const route = layer?.route;
33924
- if (!route) continue;
33925
- const handles = (route.stack ?? []).map((entry) => entry.handle);
33926
- const authMeta = handles.map((handle) => readMarker(handle, AUTH_MIDDLEWARE_MARKER)).find(Boolean);
33927
- if (!authMeta) continue;
33928
- const scope = handles.map((handle) => readMarker(handle, DATA_SCOPE_MARKER)).find(Boolean);
33929
- const methods = Object.keys(route.methods ?? {}).filter((method) => method !== "_all" && route.methods[method]).map((method) => method.toUpperCase());
33930
- for (const method of methods) {
33931
- result.push({
33932
- method,
33933
- path: String(route.path),
33934
- scope,
33935
- roles: [...authMeta.roles]
33936
- });
33937
- }
33938
- }
33939
- return result;
33940
- }
33941
- function isWhitelisted(route, whitelist) {
33942
- return whitelist.some(
33943
- (entry) => entry.method.toUpperCase() === route.method && normalizePath2(entry.path) === normalizePath2(route.path)
33944
- );
33945
- }
33946
- function normalizePath2(path48) {
33947
- if (!path48) return "/";
33948
- return path48.endsWith("/") && path48.length > 1 ? path48.slice(0, -1) : path48;
33949
- }
33950
- function auditRouteScopes(app, whitelist = []) {
33951
- const declared = [];
33952
- const roleGated = [];
33953
- const undeclared = [];
33954
- for (const route of collectAuthRoutes(app)) {
33955
- if (route.scope) {
33956
- declared.push(route);
33957
- continue;
33958
- }
33959
- if (route.roles.length > 0) {
33960
- roleGated.push(route);
33961
- continue;
33962
- }
33963
- if (isWhitelisted(route, whitelist)) continue;
33964
- undeclared.push(route);
33965
- }
33966
- return { declared, roleGated, undeclared };
33967
- }
33968
- function formatRouteScopeViolation(route) {
33969
- return `${route.method} ${route.path}`;
33970
- }
33971
- function assertRouteDataScopes(app, options = {}) {
33972
- const logger2 = options.logger ?? console;
33973
- const audit = auditRouteScopes(app, options.whitelist ?? []);
33974
- if (audit.undeclared.length > 0) {
33975
- const lines = audit.undeclared.map(formatRouteScopeViolation);
33976
- const message = `[RouteScope] ${audit.undeclared.length} \u4E2A\u5DF2\u8BA4\u8BC1\u8DEF\u7531\u672A\u58F0\u660E\u6570\u636E\u8303\u56F4\uFF08requireScopedAuth\uFF09\uFF1A
33977
- - ${lines.join("\n - ")}`;
33978
- if (options.enforce) {
33979
- logger2.error(message);
33980
- throw new Error(message);
33981
- }
33982
- logger2.warn(message);
33983
- }
33984
- if (audit.declared.length > 0) {
33985
- logger2.info?.(
33986
- `[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`
33987
- );
33988
- }
33989
- return audit;
33990
- }
33991
-
33992
- // server/utils/password-gate.ts
33993
- var PASSWORD_GATE_SKIP_ENV = "OPENLEARN_SKIP_PASSWORD_CHANGE_GATE";
33994
- var PASSWORD_GATE_SKIP_ENV_ALIAS = "DEV_BYPASS_PASSWORD_CHANGE_GATE";
33995
- var productionWarningEmitted = false;
33996
- function isTruthyFlag2(value) {
33997
- if (typeof value !== "string") return false;
33998
- const normalized = value.trim().toLowerCase();
33999
- return normalized === "1" || normalized === "true";
34000
- }
34001
- function isPasswordChangeGateSkipped(env = process.env) {
34002
- const requested = isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV]) || isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV_ALIAS]);
34003
- if (!requested) return false;
34004
- if ((env.NODE_ENV ?? "") === "production") {
34005
- if (!productionWarningEmitted) {
34006
- productionWarningEmitted = true;
34007
- console.warn(
34008
- `[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`
34009
- );
34010
- }
34011
- return false;
34012
- }
34013
- return true;
34014
- }
34015
-
34016
- // server/services/session-role.ts
34017
- var SESSION_ROLE_WHITELIST = /* @__PURE__ */ new Set(["administrator", "teacher", "student", "anonymous"]);
34018
- function resolveSessionRole(session) {
34019
- if (!session) return "anonymous";
34020
- for (const candidate of [session.subRole, session.role]) {
34021
- if (typeof candidate !== "string" || candidate.length === 0) continue;
34022
- const normalized = candidate === "admin" ? "administrator" : candidate;
34023
- if (SESSION_ROLE_WHITELIST.has(normalized)) return normalized;
34024
- }
34025
- return "anonymous";
34026
- }
34027
- function isAdministrator(session) {
34028
- return resolveSessionRole(session) === "administrator";
34029
- }
34030
-
34031
- // server/middleware/auth.ts
34032
- var SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
34033
- function getValidSession(token) {
34034
- let sessionRow;
34035
- try {
34036
- sessionRow = kernelContainer.db.prepare("SELECT * FROM client_sessions WHERE id = ?").get(token);
34037
- } catch {
34038
- return null;
34039
- }
34040
- if (!sessionRow) return null;
34041
- const now = Date.now();
34042
- if (sessionRow.expires_at && sessionRow.expires_at < now) {
34043
- kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
34044
- return null;
34045
- }
34046
- const idleTimeout = 24 * 60 * 60 * 1e3;
34047
- if (sessionRow.updated_at && now - sessionRow.updated_at > idleTimeout) {
34048
- kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
34049
- return null;
34050
- }
34051
- if (!sessionRow.updated_at || now - sessionRow.updated_at > SESSION_REFRESH_INTERVAL_MS) {
34052
- kernelContainer.db.prepare("UPDATE client_sessions SET updated_at = ? WHERE id = ?").run(now, token);
34053
- }
34054
- let session;
34055
- try {
34056
- session = JSON.parse(sessionRow.session_data);
34057
- } catch {
34058
- return null;
34059
- }
34060
- if (session && !session.userId && session.studentId) {
34061
- session.userId = session.studentId;
34062
- }
34063
- if (session.role && !["teacher", "student", "administrator", "admin"].includes(session.role)) {
34064
- session.role = "anonymous";
34065
- }
34066
- return session;
34067
- }
34068
- function getCookieToken(req) {
34069
- const rc = req.headers.cookie;
34070
- if (!rc) return null;
34071
- const parts = rc.split(";");
34072
- for (const part of parts) {
34073
- const trimmed2 = part.trim();
34074
- if (trimmed2.startsWith("edu_os_token=")) {
34075
- return trimmed2.substring("edu_os_token=".length);
34076
- }
34077
- }
34078
- return null;
34079
- }
34080
- function getActorId(req) {
34081
- const token = getCookieToken(req);
34082
- if (!token) return "anonymous";
34083
- try {
34084
- const session = getValidSession(token);
34085
- if (!session) return "anonymous";
34086
- const role = resolveSessionRole(session);
34087
- const rawUserId = session.userId || session.studentId || "demo";
34088
- const safeUserId = String(rawUserId).replace(/[:\s]/g, "_").slice(0, 64);
34089
- return `user:${safeUserId}:${role}`;
34090
- } catch {
34091
- return "anonymous";
34092
- }
34093
- }
34094
- function requireAuth(...roles) {
34095
- const middleware = (req, res, next) => {
34096
- const token = getCookieToken(req);
34097
- if (!token) return res.status(401).json({ success: false, error: "Authentication required" });
34098
- const session = getValidSession(token);
34099
- if (!session) return res.status(401).json({ success: false, error: "Session expired or invalid" });
34100
- if (roles.length > 0) {
34101
- const userRole = resolveSessionRole(session);
34102
- const effectiveRoles = roles.map((r) => r === "admin" ? "administrator" : r);
34103
- if (!effectiveRoles.includes(userRole)) {
34104
- return res.status(403).json({ success: false, error: `Role ${session.role} not allowed. Required: ${roles.join(", ")}` });
34105
- }
34106
- }
34107
- req.session = session;
34108
- next();
34109
- };
34110
- return markAuthMiddleware(middleware, roles);
34111
- }
34112
- var PASSWORD_CHANGE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
34113
- "/api/auth/change-password",
34114
- "/api/auth/logout",
34115
- "/api/auth/session",
34116
- "/api/auth/me",
34117
- "/api/client-errors"
34118
- ]);
34119
- function enforcePasswordChanged(req, res, next) {
34120
- if (process.env.PLAYWRIGHT_TEST) {
34121
- return next();
34122
- }
34123
- if (isPasswordChangeGateSkipped()) {
34124
- return next();
34125
- }
34126
- const path48 = req.path || "";
34127
- if (!path48.startsWith("/api/")) {
34128
- return next();
34129
- }
34130
- if (PASSWORD_CHANGE_EXEMPT_PATHS.has(path48)) {
34131
- return next();
34132
- }
34133
- const token = getCookieToken(req);
34134
- if (token) {
34135
- const session = getValidSession(token);
34136
- if (session && session.mustChangePassword) {
34137
- res.status(403).json({
34138
- success: false,
34139
- code: "FORBIDDEN_DEFAULT_PASSWORD",
34140
- error: "Default password must be changed first"
34141
- });
34142
- return;
34143
- }
34144
- }
34145
- next();
34146
- }
34147
- function socketAuthMiddleware(socket, next) {
34148
- try {
34149
- const cookieHeader = socket.handshake.headers.cookie;
34150
- let token = null;
34151
- if (cookieHeader) {
34152
- const parts = cookieHeader.split(";");
34153
- for (const part of parts) {
34154
- const trimmed2 = part.trim();
34155
- if (trimmed2.startsWith("edu_os_token=")) {
34156
- token = trimmed2.substring("edu_os_token=".length);
34157
- break;
34158
- }
34159
- }
34160
- }
34161
- if (!token && socket.handshake.auth?.token) {
34162
- token = socket.handshake.auth.token;
34163
- }
34164
- if (process.env.NODE_ENV === "test" && !token) {
34165
- return next();
34166
- }
34167
- if (!token) {
34168
- return next(new Error("Authentication required: missing edu_os_token"));
34169
- }
34170
- const session = getValidSession(token);
34171
- if (!session) {
34172
- return next(new Error("Authentication required: session expired or invalid"));
34173
- }
34174
- if (session.mustChangePassword && !isPasswordChangeGateSkipped()) {
34175
- return next(new Error("FORBIDDEN_DEFAULT_PASSWORD"));
34176
- }
34177
- socket.data.session = session;
34178
- socket.data.userId = session.userId;
34179
- socket.data.role = session.role;
34180
- next();
34181
- } catch (err) {
34182
- next(new Error(`Authentication error: ${err.message}`));
34183
- }
34184
- }
34185
-
34186
34604
  // server/middleware/csrf.ts
34187
34605
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
34188
34606
  var EXEMPT_PREFIXES = [
@@ -38099,9 +38517,12 @@ var generateStudentNumber = (db2) => {
38099
38517
  return `S${nextSeq.toString().padStart(3, "0")}`;
38100
38518
  };
38101
38519
 
38520
+ // server/services/ai-provider-resolver.ts
38521
+ init_ai_provider_resolver();
38522
+
38102
38523
  // server/routes/os.ts
38103
38524
  function registerOsRoutes(ctx) {
38104
- const { app, runGeminiAgentChat: runGeminiAgentChat2, runOpenAIAgentChat: runOpenAIAgentChat2, activityRegistry, aiLimiter } = ctx;
38525
+ const { app, runOpenAIAgentChat: runOpenAIAgentChat2, activityRegistry, aiLimiter } = ctx;
38105
38526
  app.get("/api/ping", (_req, res) => {
38106
38527
  res.json({ ok: true, ts: Date.now() });
38107
38528
  });
@@ -38320,13 +38741,10 @@ function registerOsRoutes(ctx) {
38320
38741
  }
38321
38742
  let callerRole;
38322
38743
  let userId;
38323
- const token = getCookieToken(req);
38324
- if (token) {
38325
- const session = getValidSession(token);
38326
- if (session) {
38327
- callerRole = session.role;
38328
- userId = session.userId;
38329
- }
38744
+ const session = req.session || (getCookieToken(req) ? getValidSession(getCookieToken(req)) : null);
38745
+ if (session) {
38746
+ callerRole = session.role;
38747
+ userId = session.userId || session.username;
38330
38748
  }
38331
38749
  const convKey = `agent:${userId || "anonymous"}:${currentLessonId || "global"}`;
38332
38750
  const MEMORY_TURNS = 20;
@@ -38336,19 +38754,17 @@ function registerOsRoutes(ctx) {
38336
38754
  content: r.content
38337
38755
  }));
38338
38756
  let provider;
38339
- if (providerId) {
38340
- provider = kernelContainer.db.prepare("SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE id = ?").get(providerId);
38341
- } else {
38342
- provider = kernelContainer.db.prepare(
38343
- "SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE api_key IS NOT NULL AND api_key != '' LIMIT 1"
38344
- ).get();
38345
- }
38346
- if (provider?.api_key) provider.api_key = decryptApiKey(provider.api_key);
38347
- if (!provider || !provider.api_key || !provider.api_key.trim()) {
38348
- 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({
38349
38765
  success: false,
38350
- error: "NO_AI_PROVIDER",
38351
- 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
38352
38768
  });
38353
38769
  }
38354
38770
  const actorId = getActorId(req);
@@ -41812,7 +42228,7 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
41812
42228
  * 的按学科硬编码字符串拼接,与课题内容无关。此方法用真实的 LLM 调用取代它。
41813
42229
  */
41814
42230
  async generateOutlineWithAi(params) {
41815
- const { title, category, description, timeline, lang = "zh" } = params;
42231
+ const { title, category, description, timeline, lang = "zh", providerId, userId } = params;
41816
42232
  const trimmedTitle = (title || "").trim();
41817
42233
  if (!trimmedTitle) {
41818
42234
  const err = new Error(lang === "zh" ? "\u8BFE\u7A0B\u8BFE\u9898\u4E0D\u80FD\u4E3A\u7A7A" : "Course title is required");
@@ -41852,7 +42268,12 @@ ${timelineText}` : "- \u8BFE\u5802\u65F6\u95F4\u8F74\u73AF\u8282\uFF1A\uFF08\u65
41852
42268
  - \`## \u{1F4E5} \u8BFE\u540E\u4F5C\u4E1A\`\uFF1A\u4E00\u4EFD\u660E\u786E\u7684\u8BFE\u540E\u4EFB\u52A1\u4E0E\u63D0\u4EA4\u8981\u6C42\u3002
41853
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
41854
42270
  5. \u7BC7\u5E45\u63A7\u5236\u5728 600-1000 \u5B57\uFF0C\u8BED\u8A00${lang === "zh" ? "\u4F7F\u7528\u7B80\u4F53\u4E2D\u6587" : "use English"}\u3002`;
41855
- 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
+ });
41856
42277
  const outline = stripReasoningArtifacts(stripOuterCodeFence(String(raw || "").trim()));
41857
42278
  if (!outline) {
41858
42279
  const err = new Error(
@@ -42116,13 +42537,17 @@ function registerLessonsRoutes(ctx) {
42116
42537
  safeAiLimiter,
42117
42538
  async (req, res) => {
42118
42539
  try {
42119
- 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;
42120
42543
  const content = await lessonService.generateOutlineWithAi({
42121
42544
  title,
42122
42545
  category,
42123
42546
  description,
42124
42547
  timeline,
42125
- lang
42548
+ lang,
42549
+ providerId,
42550
+ userId
42126
42551
  });
42127
42552
  res.json({ success: true, content });
42128
42553
  } catch (e) {
@@ -45167,6 +45592,26 @@ function resolveWritableStudentId(session, rawId) {
45167
45592
  if (isTeacher(session) && teacherCanSeeStudent(kernelContainer.getReadDb(), session, studentId)) return studentId;
45168
45593
  return null;
45169
45594
  }
45595
+ var SEED_STATUS_TTL_MS = 15e3;
45596
+ var seedStatusCache = null;
45597
+ function readSeedDefaultStatus() {
45598
+ const now = Date.now();
45599
+ if (seedStatusCache && seedStatusCache.expiresAt > now) return seedStatusCache.payload;
45600
+ const accounts = [];
45601
+ try {
45602
+ const rows = kernelContainer.db.prepare("SELECT username, password_hash FROM users WHERE username IN (?, ?)").all("admin", "teacher");
45603
+ for (const row of rows) {
45604
+ if (verifyPassword(row.username, row.password_hash).valid) accounts.push(row.username);
45605
+ }
45606
+ } catch {
45607
+ }
45608
+ const payload = { active: accounts.length > 0, accounts };
45609
+ seedStatusCache = { payload, expiresAt: now + SEED_STATUS_TTL_MS };
45610
+ return payload;
45611
+ }
45612
+ function invalidateSeedStatusCache() {
45613
+ seedStatusCache = null;
45614
+ }
45170
45615
  function registerRosterRoutes(ctx) {
45171
45616
  const { app, io, loginLimiter } = ctx;
45172
45617
  const rosterService = new RosterService(kernelContainer.db, kernelContainer.getReadDb());
@@ -45448,6 +45893,7 @@ function registerRosterRoutes(ctx) {
45448
45893
  return res.status(400).json({ error: "New password must be different from the username" });
45449
45894
  }
45450
45895
  kernelContainer.db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?").run(hashPassword(newPassword), session.userId);
45896
+ invalidateSeedStatusCache();
45451
45897
  kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id != ? AND session_data LIKE ?").run(token, `%${session.userId}%`);
45452
45898
  try {
45453
45899
  const updatedSession = { ...session, mustChangePassword: false };
@@ -45615,6 +46061,13 @@ function registerRosterRoutes(ctx) {
45615
46061
  sendSafeError(res, e);
45616
46062
  }
45617
46063
  });
46064
+ app.get("/api/auth/seed-status", (_req, res) => {
46065
+ try {
46066
+ res.json(readSeedDefaultStatus());
46067
+ } catch (e) {
46068
+ sendSafeError(res, e);
46069
+ }
46070
+ });
45618
46071
  app.post("/api/auth/login", loginLimiter, (req, res) => {
45619
46072
  try {
45620
46073
  const { entrance, username, password, studentId } = req.body;
@@ -48180,7 +48633,7 @@ var ScheduleService = class {
48180
48633
  * Timetable OCR 课表图像识别与数据清洗
48181
48634
  */
48182
48635
  async processTimetableOcr(imageBase64, options = {}) {
48183
- const { lang = "zh", providerId } = options;
48636
+ const { lang = "zh", providerId, userId } = options;
48184
48637
  if (!imageBase64) {
48185
48638
  throw new Error("imageBase64 is required");
48186
48639
  }
@@ -48211,21 +48664,11 @@ var ScheduleService = class {
48211
48664
 
48212
48665
  \u8FD4\u56DE\u683C\u5F0F\u793A\u4F8B\uFF1A
48213
48666
  [{"dayOfWeek":1,"periodNumber":1,"className":"\u9AD8\u4E00(13)","subject":"\u4FE1\u606F","timeSlot":"08:00-08:40","location":"312","teacherName":""}]`;
48214
- let provider;
48215
- if (providerId) {
48216
- provider = this.db.prepare("SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE id = ?").get(providerId);
48217
- }
48218
- if (!provider) {
48219
- provider = this.db.prepare(
48220
- "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"
48221
- ).get();
48222
- }
48223
- if (provider?.api_key) provider.api_key = decryptApiKey(provider.api_key);
48224
- if (!provider || !provider.api_key || !provider.api_key.trim()) {
48225
- throw new Error(
48226
- 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".'
48227
- );
48228
- }
48667
+ const provider = resolveAccessibleAiProvider(this.db, {
48668
+ providerId,
48669
+ userId,
48670
+ lang
48671
+ });
48229
48672
  let chatUrl = provider.api_url.trim();
48230
48673
  if (!chatUrl.endsWith("/chat/completions")) {
48231
48674
  chatUrl = chatUrl.endsWith("/") ? chatUrl + "chat/completions" : chatUrl + "/chat/completions";
@@ -48441,7 +48884,9 @@ function registerSchedulesRoutes(ctx) {
48441
48884
  const startTime = Date.now();
48442
48885
  try {
48443
48886
  const { imageBase64, lang = "zh", providerId } = req.body;
48444
- 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 });
48445
48890
  res.json({
48446
48891
  success: true,
48447
48892
  entries: result.entries,
@@ -48827,7 +49272,7 @@ var GradingService = class {
48827
49272
  }
48828
49273
  // ── 6. AI 学期综合总评评语生成 ────────────────────────────────────────────────
48829
49274
  async generateSemesterAiEvaluation(params) {
48830
- 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;
48831
49276
  const student = this.db.prepare("SELECT name FROM students WHERE id = ?").get(studentId);
48832
49277
  if (!student) {
48833
49278
  throw new Error("Student not found");
@@ -48885,13 +49330,10 @@ ${examsText}
48885
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
48886
49331
  - \u5BF9\u672A\u6765\u7684\u671F\u8BB8\uFF0C\u6FC0\u52B1\u5B66\u751F\u5728\u4E0B\u5B66\u671F\u7EE7\u7EED\u52AA\u529B\u3002
48887
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`;
48888
- 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(
48889
- "SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE api_key IS NOT NULL AND api_key != '' LIMIT 1"
48890
- ).get();
48891
- if (provider?.api_key) provider.api_key = decryptApiKey(provider.api_key);
48892
- if (!provider || !provider.api_key || !provider.api_key.trim()) {
48893
- 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");
48894
- }
49333
+ const provider = resolveAccessibleAiProvider(this.db, {
49334
+ providerId,
49335
+ userId
49336
+ });
48895
49337
  let chatUrl = provider.api_url.trim();
48896
49338
  if (!chatUrl.endsWith("/chat/completions")) {
48897
49339
  chatUrl = chatUrl.endsWith("/") ? chatUrl + "chat/completions" : chatUrl + "/chat/completions";
@@ -49164,11 +49606,14 @@ function registerGradingRoutes(ctx) {
49164
49606
  const { classId, studentId } = req.params;
49165
49607
  if (gate(req, res, classId, "staff")) return;
49166
49608
  const { semesterName = "2026\u5E74\u6625\u5B63\u5B66\u671F", providerId } = req.body;
49609
+ const session = req.session;
49610
+ const userId = session?.userId || session?.username;
49167
49611
  const aiEvaluation = await gradingService.generateSemesterAiEvaluation({
49168
49612
  classId,
49169
49613
  studentId,
49170
49614
  semesterName,
49171
- providerId
49615
+ providerId,
49616
+ userId
49172
49617
  });
49173
49618
  res.json({ success: true, aiEvaluation });
49174
49619
  } catch (e) {
@@ -49596,7 +50041,8 @@ async function pluginApiGatewayMiddleware(req, res, next) {
49596
50041
  }
49597
50042
  }
49598
50043
  const clientIp = req.ip || req.socket?.remoteAddress || "unknown-ip";
49599
- 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}`;
49600
50046
  const windowMs = matchedRule?.rateLimit?.windowMs ?? 60 * 1e3;
49601
50047
  const maxRequests = matchedRule?.rateLimit?.max ?? 120;
49602
50048
  if (!rateLimiter.consume(rateLimitKey, maxRequests, windowMs)) {
@@ -50585,16 +51031,9 @@ function listInstalledManifestVersions() {
50585
51031
  return installed;
50586
51032
  }
50587
51033
  function registerPluginsRoutes(ctx) {
50588
- const {
50589
- app,
50590
- aiLimiter,
50591
- buildOpenAITools: buildOpenAITools2,
50592
- executeAgentToolCall: executeAgentToolCall2,
50593
- buildOpenAIChatUrl: buildOpenAIChatUrl2,
50594
- runGeminiAgentChat: runGeminiAgentChat2,
50595
- runOpenAIAgentChat: runOpenAIAgentChat2
50596
- } = ctx;
50597
- 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;
50598
51037
  app.get("/api/docs/plugin-guide", (req, res) => {
50599
51038
  try {
50600
51039
  const docPath = import_path22.default.join(process.cwd(), "docs_plugin_guide.md");
@@ -51630,9 +52069,13 @@ function registerPluginsRoutes(ctx) {
51630
52069
  }
51631
52070
  });
51632
52071
  app.all("/api/plugins/:pluginId/*", pluginApiGatewayMiddleware);
51633
- app.get("/api/ai-providers", requireAuth("teacher", "administrator"), (req, res) => {
52072
+ app.get("/api/ai-providers", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
51634
52073
  try {
51635
- 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);
51636
52079
  const masked = providers.map((p) => {
51637
52080
  let apiKey;
51638
52081
  try {
@@ -51640,54 +52083,122 @@ function registerPluginsRoutes(ctx) {
51640
52083
  } catch {
51641
52084
  apiKey = "[UNDECRYPTABLE]";
51642
52085
  }
51643
- return { ...p, api_key: apiKey };
52086
+ return {
52087
+ ...p,
52088
+ api_key: apiKey,
52089
+ is_shared: Number(p.is_shared) === 1
52090
+ };
51644
52091
  });
51645
52092
  res.json(masked);
51646
52093
  } catch (e) {
51647
52094
  sendSafeError(res, e);
51648
52095
  }
51649
52096
  });
51650
- app.post("/api/ai-providers", requireAuth("administrator"), (req, res) => {
52097
+ app.post("/api/ai-providers", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
51651
52098
  try {
51652
- 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;
51653
52103
  if (!name || !api_url || !model_name) {
51654
52104
  return res.status(400).json({ error: "Missing name, api_url or model_name" });
51655
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
+ }
51656
52110
  const id = "prov_" + Date.now();
51657
52111
  const now = Date.now();
51658
52112
  const encryptedKey = api_key ? encryptApiKey(api_key) : "";
51659
52113
  kernelContainer.db.prepare(
51660
- "INSERT INTO ai_providers (id, name, api_url, api_key, model_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
51661
- ).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
+ );
51662
52126
  res.json({ success: true, id });
51663
52127
  } catch (e) {
51664
52128
  sendSafeError(res, e);
51665
52129
  }
51666
52130
  });
51667
- app.put("/api/ai-providers/:id", requireAuth("administrator"), (req, res) => {
52131
+ app.put("/api/ai-providers/:id", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
51668
52132
  try {
51669
- 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;
51670
52137
  if (!name || !api_url || !model_name) {
51671
52138
  return res.status(400).json({ error: "Missing name, api_url or model_name" });
51672
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
+ }
51673
52166
  const now = Date.now();
51674
52167
  let finalKey;
51675
52168
  if (api_key && api_key.trim() !== "" && !api_key.includes("****")) {
51676
52169
  finalKey = encryptApiKey(api_key);
51677
52170
  } else {
51678
- const existing = kernelContainer.db.prepare("SELECT api_key FROM ai_providers WHERE id = ?").get(req.params.id);
51679
52171
  finalKey = existing?.api_key || "";
51680
52172
  }
51681
52173
  kernelContainer.db.prepare(
51682
- "UPDATE ai_providers SET name = ?, api_url = ?, api_key = ?, model_name = ?, updated_at = ? WHERE id = ?"
51683
- ).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);
51684
52176
  res.json({ success: true });
51685
52177
  } catch (e) {
51686
52178
  sendSafeError(res, e);
51687
52179
  }
51688
52180
  });
51689
- app.delete("/api/ai-providers/:id", requireAuth("administrator"), (req, res) => {
52181
+ app.delete("/api/ai-providers/:id", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
51690
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
+ }
51691
52202
  kernelContainer.db.prepare("DELETE FROM ai_providers WHERE id = ?").run(req.params.id);
51692
52203
  res.json({ success: true });
51693
52204
  } catch (e) {
@@ -51770,65 +52281,82 @@ function registerPluginsRoutes(ctx) {
51770
52281
  sendSafeError(res, e);
51771
52282
  }
51772
52283
  });
51773
- app.post("/api/ai-providers/test", requireAuth("administrator"), safeAiLimiter, async (req, res) => {
51774
- try {
51775
- const { api_url, api_key: providedKey, model_name } = req.body;
51776
- if (!api_url || !model_name) {
51777
- return res.status(400).json({ error: "api_url and model_name are required" });
51778
- }
51779
- let api_key = "";
51780
- if (providedKey && providedKey.includes("****")) {
51781
- const existing = kernelContainer.db.prepare("SELECT api_key FROM ai_providers WHERE api_url = ? AND model_name = ? LIMIT 1").get(api_url, model_name);
51782
- api_key = existing ? decryptApiKey(existing.api_key) : "";
51783
- } else if (providedKey) {
51784
- api_key = providedKey.includes(":") ? decryptApiKey(providedKey) : providedKey;
51785
- }
51786
- let cleanUrl = api_url.trim();
51787
- if (!cleanUrl.endsWith("/chat/completions")) {
51788
- cleanUrl = cleanUrl.endsWith("/") ? cleanUrl + "chat/completions" : cleanUrl + "/chat/completions";
51789
- }
51790
- const urlSafety = isSafeExternalUrl(cleanUrl);
51791
- if (!urlSafety.safe) {
51792
- return res.status(400).json({ error: `Blocked unsafe AI provider URL: ${urlSafety.reason}` });
51793
- }
51794
- let response;
52284
+ app.post(
52285
+ "/api/ai-providers/test",
52286
+ requireScopedAuth("self", "teacher", "administrator"),
52287
+ safeAiLimiter,
52288
+ async (req, res) => {
51795
52289
  try {
51796
- response = await fetchWithSafeRedirects(cleanUrl, {
51797
- method: "POST",
51798
- headers: {
51799
- "Content-Type": "application/json",
51800
- Authorization: `Bearer ${api_key || ""}`
51801
- },
51802
- body: JSON.stringify({
51803
- model: model_name,
51804
- messages: [{ role: "user", content: "Say connected" }],
51805
- max_tokens: 5
51806
- }),
51807
- timeoutMs: 1e4
51808
- });
51809
- } catch (err) {
51810
- const isTimeout = err instanceof AIFetchTimeoutError || err?.name === "TimeoutError" || err?.name === "AbortError" || /abort|timeout/i.test(err?.message ?? "");
51811
- if (isTimeout) {
51812
- 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({
51813
52351
  success: false,
51814
- 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)}`
51815
52353
  });
51816
52354
  }
51817
- throw err;
51818
- }
51819
- const responseText = await response.text();
51820
- if (response.ok) {
51821
- res.json({ success: true, message: "Successfully connected and received response." });
51822
- } else {
51823
- res.status(response.status).json({
51824
- success: false,
51825
- error: `API responded with status ${response.status}: ${responseText.slice(0, 200)}`
51826
- });
52355
+ } catch (e) {
52356
+ sendSafeError(res, e, 500, "Connection failed");
51827
52357
  }
51828
- } catch (e) {
51829
- sendSafeError(res, e, 500, "Connection failed");
51830
52358
  }
51831
- });
52359
+ );
51832
52360
  }
51833
52361
 
51834
52362
  // server/services/assessment-service.ts
@@ -58273,12 +58801,16 @@ async function startServer() {
58273
58801
  const defaultWriteLimit = process.env.PLAYWRIGHT_TEST ? 1e4 : isDev ? 600 : 180;
58274
58802
  const writeLimiter = createWriteLimiter(defaultWriteLimit);
58275
58803
  const viewStateLimiter = createWriteLimiter(process.env.PLAYWRIGHT_TEST ? 1e4 : isDev ? 600 : 120);
58804
+ const pluginApiLimiter = createSessionWriteLimiter(process.env.PLAYWRIGHT_TEST ? 1e4 : 600);
58276
58805
  const aiLimiter = createAiLimiter(process.env.PLAYWRIGHT_TEST ? 1e3 : 10);
58277
58806
  app.use((req, res, next) => {
58278
58807
  if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
58279
58808
  if (/^\/api\/classroom\/sessions\/[^/]+\/view-state\/?$/.test(req.path)) {
58280
58809
  return viewStateLimiter(req, res, next);
58281
58810
  }
58811
+ if (req.path.startsWith("/api/plugins/")) {
58812
+ return pluginApiLimiter(req, res, next);
58813
+ }
58282
58814
  return writeLimiter(req, res, next);
58283
58815
  }
58284
58816
  next();