@headless-lms/server 0.4.0 → 0.5.0
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.
- package/README.md +14 -0
- package/dist/app/community-mail.d.ts +5 -3
- package/dist/app/community-mail.js +60 -12
- package/dist/app/community-mail.js.map +1 -1
- package/dist/app/container.d.ts +24 -3
- package/dist/app/container.js +102 -38
- package/dist/app/container.js.map +1 -1
- package/dist/app/notification-channels.js +1 -0
- package/dist/app/notification-channels.js.map +1 -1
- package/dist/app/notification-reads.d.ts +3 -3
- package/dist/app/notification-reads.js +7 -28
- package/dist/app/notification-reads.js.map +1 -1
- package/dist/app/platform.d.ts +14 -0
- package/dist/app/platform.js +14 -0
- package/dist/app/platform.js.map +1 -0
- package/dist/http/config.d.ts +4 -0
- package/dist/http/errors.js +9 -4
- package/dist/http/errors.js.map +1 -1
- package/dist/http/fastify.d.ts +8 -0
- package/dist/http/mcp/catalogue.d.ts +11 -9
- package/dist/http/mcp/catalogue.js +15 -21
- package/dist/http/mcp/catalogue.js.map +1 -1
- package/dist/http/mcp/challenge.js +3 -3
- package/dist/http/mcp/challenge.js.map +1 -1
- package/dist/http/mcp/route.js +7 -7
- package/dist/http/mcp/route.js.map +1 -1
- package/dist/http/mcp/test-support.d.ts +6 -5
- package/dist/http/mcp/test-support.js +32 -11
- package/dist/http/mcp/test-support.js.map +1 -1
- package/dist/http/mcp/tool.d.ts +3 -9
- package/dist/http/mcp/tool.js +14 -6
- package/dist/http/mcp/tool.js.map +1 -1
- package/dist/http/mcp/tools/automations.js +10 -20
- package/dist/http/mcp/tools/automations.js.map +1 -1
- package/dist/http/mcp/tools/catalog.js +25 -42
- package/dist/http/mcp/tools/catalog.js.map +1 -1
- package/dist/http/mcp/tools/comments.js +9 -19
- package/dist/http/mcp/tools/comments.js.map +1 -1
- package/dist/http/mcp/tools/communities.js +16 -31
- package/dist/http/mcp/tools/communities.js.map +1 -1
- package/dist/http/mcp/tools/community-members.js +9 -16
- package/dist/http/mcp/tools/community-members.js.map +1 -1
- package/dist/http/mcp/tools/community-moderation.js +13 -25
- package/dist/http/mcp/tools/community-moderation.js.map +1 -1
- package/dist/http/mcp/tools/courses.js +11 -21
- package/dist/http/mcp/tools/courses.js.map +1 -1
- package/dist/http/mcp/tools/events.d.ts +6 -0
- package/dist/http/mcp/tools/events.js +170 -0
- package/dist/http/mcp/tools/events.js.map +1 -0
- package/dist/http/mcp/tools/index.d.ts +5 -1
- package/dist/http/mcp/tools/index.js +45 -10
- package/dist/http/mcp/tools/index.js.map +1 -1
- package/dist/http/mcp/tools/organization.d.ts +6 -0
- package/dist/http/mcp/tools/organization.js +214 -0
- package/dist/http/mcp/tools/organization.js.map +1 -0
- package/dist/http/mcp/tools/people.js +30 -49
- package/dist/http/mcp/tools/people.js.map +1 -1
- package/dist/http/plugins/auth.js +37 -8
- package/dist/http/plugins/auth.js.map +1 -1
- package/dist/http/plugins/error-handler.d.ts +5 -1
- package/dist/http/plugins/error-handler.js +10 -2
- package/dist/http/plugins/error-handler.js.map +1 -1
- package/dist/http/plugins/openapi.js +18 -2
- package/dist/http/plugins/openapi.js.map +1 -1
- package/dist/http/plugins/platform.d.ts +7 -0
- package/dist/http/plugins/platform.js +13 -0
- package/dist/http/plugins/platform.js.map +1 -0
- package/dist/http/routes/access.js +54 -36
- package/dist/http/routes/access.js.map +1 -1
- package/dist/http/routes/activities.js +45 -45
- package/dist/http/routes/activities.js.map +1 -1
- package/dist/http/routes/assets.js +30 -65
- package/dist/http/routes/assets.js.map +1 -1
- package/dist/http/routes/automations.js +37 -42
- package/dist/http/routes/automations.js.map +1 -1
- package/dist/http/routes/bundles.js +33 -36
- package/dist/http/routes/bundles.js.map +1 -1
- package/dist/http/routes/communities.js +93 -145
- package/dist/http/routes/communities.js.map +1 -1
- package/dist/http/routes/community-moderation.js +83 -137
- package/dist/http/routes/community-moderation.js.map +1 -1
- package/dist/http/routes/course-analytics.js +32 -15
- package/dist/http/routes/course-analytics.js.map +1 -1
- package/dist/http/routes/courses.js +25 -32
- package/dist/http/routes/courses.js.map +1 -1
- package/dist/http/routes/dashboard.js +9 -9
- package/dist/http/routes/dashboard.js.map +1 -1
- package/dist/http/routes/discussion.js +21 -39
- package/dist/http/routes/discussion.js.map +1 -1
- package/dist/http/routes/downloads.js +41 -47
- package/dist/http/routes/downloads.js.map +1 -1
- package/dist/http/routes/entitlements.js +19 -23
- package/dist/http/routes/entitlements.js.map +1 -1
- package/dist/http/routes/events.d.ts +7 -0
- package/dist/http/routes/events.js +333 -0
- package/dist/http/routes/events.js.map +1 -0
- package/dist/http/routes/integrations.js +40 -73
- package/dist/http/routes/integrations.js.map +1 -1
- package/dist/http/routes/member/assets.js +10 -13
- package/dist/http/routes/member/assets.js.map +1 -1
- package/dist/http/routes/member/chats.js +170 -47
- package/dist/http/routes/member/chats.js.map +1 -1
- package/dist/http/routes/member/comments.js +65 -26
- package/dist/http/routes/member/comments.js.map +1 -1
- package/dist/http/routes/member/community.js +45 -52
- package/dist/http/routes/member/community.js.map +1 -1
- package/dist/http/routes/member/courses.js +12 -19
- package/dist/http/routes/member/courses.js.map +1 -1
- package/dist/http/routes/member/devices.js +9 -10
- package/dist/http/routes/member/devices.js.map +1 -1
- package/dist/http/routes/member/downloads.js +6 -9
- package/dist/http/routes/member/downloads.js.map +1 -1
- package/dist/http/routes/member/events.d.ts +7 -0
- package/dist/http/routes/member/events.js +386 -0
- package/dist/http/routes/member/events.js.map +1 -0
- package/dist/http/routes/member/files.js +71 -46
- package/dist/http/routes/member/files.js.map +1 -1
- package/dist/http/routes/member/index.js +2 -0
- package/dist/http/routes/member/index.js.map +1 -1
- package/dist/http/routes/member/notification-settings.js +15 -23
- package/dist/http/routes/member/notification-settings.js.map +1 -1
- package/dist/http/routes/member/notifications.js +16 -16
- package/dist/http/routes/member/notifications.js.map +1 -1
- package/dist/http/routes/member/org.js +5 -15
- package/dist/http/routes/member/org.js.map +1 -1
- package/dist/http/routes/member/posts.js +67 -68
- package/dist/http/routes/member/posts.js.map +1 -1
- package/dist/http/routes/member/profile.js +15 -6
- package/dist/http/routes/member/profile.js.map +1 -1
- package/dist/http/routes/member/progress.js +7 -11
- package/dist/http/routes/member/progress.js.map +1 -1
- package/dist/http/routes/organizations.js +319 -135
- package/dist/http/routes/organizations.js.map +1 -1
- package/dist/http/routes/platform.d.ts +7 -0
- package/dist/http/routes/platform.js +61 -0
- package/dist/http/routes/platform.js.map +1 -0
- package/dist/http/routes/students.js +12 -75
- package/dist/http/routes/students.js.map +1 -1
- package/dist/http/routes/webhooks.js +30 -39
- package/dist/http/routes/webhooks.js.map +1 -1
- package/dist/http/routes.js +9 -0
- package/dist/http/routes.js.map +1 -1
- package/dist/http/schemas/access.d.ts +346 -64
- package/dist/http/schemas/access.js +39 -12
- package/dist/http/schemas/access.js.map +1 -1
- package/dist/http/schemas/assets.d.ts +19 -14
- package/dist/http/schemas/assets.js +6 -5
- package/dist/http/schemas/assets.js.map +1 -1
- package/dist/http/schemas/automations.d.ts +4 -4
- package/dist/http/schemas/chats.d.ts +211 -32
- package/dist/http/schemas/chats.js +13 -4
- package/dist/http/schemas/chats.js.map +1 -1
- package/dist/http/schemas/community.d.ts +91 -44
- package/dist/http/schemas/community.js +21 -8
- package/dist/http/schemas/community.js.map +1 -1
- package/dist/http/schemas/course-analytics.d.ts +51 -1
- package/dist/http/schemas/course-analytics.js +19 -1
- package/dist/http/schemas/course-analytics.js.map +1 -1
- package/dist/http/schemas/dashboard.d.ts +1 -0
- package/dist/http/schemas/dashboard.js +2 -1
- package/dist/http/schemas/dashboard.js.map +1 -1
- package/dist/http/schemas/discussion.d.ts +22 -72
- package/dist/http/schemas/discussion.js +2 -2
- package/dist/http/schemas/discussion.js.map +1 -1
- package/dist/http/schemas/entitlements.d.ts +9 -12
- package/dist/http/schemas/entitlements.js +11 -5
- package/dist/http/schemas/entitlements.js.map +1 -1
- package/dist/http/schemas/events.d.ts +527 -0
- package/dist/http/schemas/events.js +64 -0
- package/dist/http/schemas/events.js.map +1 -0
- package/dist/http/schemas/files.d.ts +15 -5
- package/dist/http/schemas/files.js +10 -2
- package/dist/http/schemas/files.js.map +1 -1
- package/dist/http/schemas/index.d.ts +11 -9
- package/dist/http/schemas/index.js +11 -9
- package/dist/http/schemas/integrations.d.ts +4 -4
- package/dist/http/schemas/invites.d.ts +3 -47
- package/dist/http/schemas/invites.js +4 -25
- package/dist/http/schemas/invites.js.map +1 -1
- package/dist/http/schemas/member.d.ts +94 -7
- package/dist/http/schemas/member.js +7 -3
- package/dist/http/schemas/member.js.map +1 -1
- package/dist/http/schemas/members.d.ts +333 -69
- package/dist/http/schemas/members.js +67 -25
- package/dist/http/schemas/members.js.map +1 -1
- package/dist/http/schemas/notifications.d.ts +57 -53
- package/dist/http/schemas/platform.d.ts +37 -0
- package/dist/http/schemas/platform.js +19 -0
- package/dist/http/schemas/platform.js.map +1 -0
- package/dist/http/schemas/posts.d.ts +55 -35
- package/dist/http/schemas/posts.js +4 -4
- package/dist/http/schemas/posts.js.map +1 -1
- package/dist/http/schemas/profile.d.ts +1 -1
- package/dist/http/schemas/shared.d.ts +1 -11
- package/dist/http/schemas/shared.js +1 -10
- package/dist/http/schemas/shared.js.map +1 -1
- package/dist/http/schemas/students.d.ts +25 -3
- package/dist/http/schemas/students.js +3 -1
- package/dist/http/schemas/students.js.map +1 -1
- package/dist/http/scope.d.ts +9 -13
- package/dist/http/scope.js +29 -40
- package/dist/http/scope.js.map +1 -1
- package/dist/http/server.js +9 -0
- package/dist/http/server.js.map +1 -1
- package/dist/http/shutdown.d.ts +11 -0
- package/dist/http/shutdown.js +40 -0
- package/dist/http/shutdown.js.map +1 -0
- package/dist/http/sse.d.ts +19 -0
- package/dist/http/sse.js +104 -0
- package/dist/http/sse.js.map +1 -0
- package/dist/http/test-caller.d.ts +13 -0
- package/dist/http/test-caller.js +14 -0
- package/dist/http/test-caller.js.map +1 -0
- package/dist/http/well-known.js +2 -2
- package/dist/http/well-known.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/packages/server/package.js +1 -1
- package/package.json +7 -6
- package/dist/http/routes/member/actor.d.ts +0 -14
- package/dist/http/routes/member/actor.js +0 -11
- package/dist/http/routes/member/actor.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notification-channels.js","names":[],"sources":["../../src/app/notification-channels.ts"],"sourcesContent":["import type {\n ChannelRegistry,\n EmailTemplateId,\n EmailTemplateParams,\n JsonValue,\n NotificationCategory,\n NotificationChannel,\n NotificationTypeDeclaration,\n NotificationTypeRegistry,\n PushDevice,\n PushSender,\n StoredNotification,\n} from '@headless-lms/core/shared/ports';\nimport { DEVICE_WINDOW_MS, PUSH } from '@headless-lms/core/automations';\nimport type { PushConfig } from '@headless-lms/core/types';\nimport { offeringConfigured } from '@headless-lms/core/shared/notification-categories';\nimport { isEmailRejected, isEmailRenderError, type Mailer } from '@headless-lms/core/shared/mailer';\n\nexport const IN_APP = 'inApp';\nexport const EMAIL = 'email';\n\nconst CONTENT_SUBJECTS = new Set(['course', 'download', 'community', 'bundle']);\n\nexport function buildNotificationTypeRegistry(\n declarations: NotificationTypeDeclaration[],\n defaults: { community: NotificationCategory[]; org: NotificationCategory[] },\n configured: string[],\n): NotificationTypeRegistry {\n const byType = new Map<string, NotificationTypeDeclaration>();\n for (const declaration of declarations) {\n if (byType.has(declaration.type)) {\n throw new Error(`notification type ${declaration.type} is declared twice`);\n }\n byType.set(declaration.type, declaration);\n }\n const lists = {\n community: offeringConfigured(defaults.community, configured),\n org: offeringConfigured(defaults.org, configured),\n };\n return {\n list: () =>\n declarations.map(({ type, scopes, reasons }) => ({\n type,\n scopes: [...scopes],\n reasons: [...reasons],\n })),\n get: (type) => byType.get(type),\n defaults: (scope) => structuredClone(lists[scope]),\n };\n}\n\nexport function buildChannelRegistry(channels: NotificationChannel[]): ChannelRegistry {\n const byKey = new Map<string, NotificationChannel>();\n for (const channel of channels) {\n if (channel.key === IN_APP || byKey.has(channel.key)) {\n throw new Error(`notification channel ${channel.key} is registered twice`);\n }\n byKey.set(channel.key, channel);\n }\n const keys = [IN_APP, ...byKey.keys()];\n return {\n list: () => [...keys],\n get: (key) => byKey.get(key),\n };\n}\n\nexport function emailTemplateFor(\n declaration: NotificationTypeDeclaration,\n notification: Pick<StoredNotification, 'scope' | 'matchedReason'>,\n): EmailTemplateId | null {\n const email = declaration.email?.[notification.scope];\n if (!email) {\n return null;\n }\n return email.byReason?.[notification.matchedReason] ?? email.template;\n}\n\nexport function emailParams(notification: StoredNotification): Record<string, JsonValue> {\n const ids: Record<string, JsonValue> = {};\n for (const ref of [notification.parent, notification.subject]) {\n if (ref) {\n ids[`${ref.type}Id`] = ref.id;\n }\n }\n if (notification.subject && CONTENT_SUBJECTS.has(notification.subject.type)) {\n ids.contentId = notification.subject.id;\n }\n if (notification.communityId) {\n ids.communityId = notification.communityId;\n }\n if (notification.spaceId) {\n ids.spaceId = notification.spaceId;\n }\n return { ...ids, ...notification.display };\n}\n\nexport function emailChannel(deps: {\n mailer: Pick<Mailer, 'send'>;\n types: NotificationTypeRegistry;\n emailOf: (orgId: string, userId: string) => Promise<string | null>;\n}): NotificationChannel {\n return {\n key: EMAIL,\n recipient: async (orgId, userId) => {\n const email = await deps.emailOf(orgId, userId);\n return email ? { email } : null;\n },\n deliver: async (notification, to) => {\n const declaration = deps.types.get(notification.type);\n const template = declaration ? emailTemplateFor(declaration, notification) : null;\n if (!to.email) {\n return { accepted: false, deadDeviceIds: [], error: 'no address' };\n }\n if (!template) {\n return {\n accepted: false,\n deadDeviceIds: [],\n error: `no email template for ${notification.type} in ${notification.scope}`,\n };\n }\n try {\n await deps.mailer.send(\n to.email,\n template,\n emailParams(notification) as unknown as EmailTemplateParams[typeof template],\n );\n } catch (err) {\n if (isEmailRejected(err) || isEmailRenderError(err)) {\n return { accepted: false, deadDeviceIds: [], error: err.message };\n }\n throw err;\n }\n return { accepted: true, deadDeviceIds: [] };\n },\n };\n}\n\nexport function renderPushText(template: string, display: Record<string, JsonValue>): string {\n return template.replace(/\\{\\{(\\w+)\\}\\}/g, (_placeholder, key: string) => {\n const value = display[key];\n if (typeof value === 'string') {\n return value;\n }\n if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {\n return value.join(', ');\n }\n return '';\n });\n}\n\nexport function configuredPushSender(\n config: PushConfig,\n sender: PushSender | undefined,\n): PushSender | null {\n const configured = Object.values(config).some((provider) => provider !== undefined);\n return configured && sender ? sender : null;\n}\n\nexport type ActiveDevices = (orgId: string, userId: string, since: Date) => Promise<PushDevice[]>;\n\nexport function pushChannel(deps: {\n sender: PushSender;\n types: NotificationTypeRegistry;\n activeDevices: ActiveDevices;\n now?: () => Date;\n}): NotificationChannel {\n const now = deps.now ?? (() => new Date());\n return {\n key: PUSH,\n recipient: async (orgId, userId) => {\n const since = new Date(now().getTime() - DEVICE_WINDOW_MS);\n const devices = await deps.activeDevices(orgId, userId, since);\n return devices.length > 0 ? { devices } : null;\n },\n deliver: async (notification, to) => {\n const text = deps.types.get(notification.type)?.push?.[notification.scope];\n if (!to.devices) {\n return { accepted: false, deadDeviceIds: [], error: 'no address' };\n }\n if (!text) {\n return {\n accepted: false,\n deadDeviceIds: [],\n error: `no push text for ${notification.type} in ${notification.scope}`,\n };\n }\n const courseId = notification.display.courseId;\n return deps.sender.send(to.devices, {\n title: renderPushText(text.title, notification.display),\n body: renderPushText(text.body, notification.display),\n data: {\n notificationId: notification.id,\n type: notification.type,\n subject: notification.subject,\n parent: notification.parent,\n communityId: notification.communityId,\n spaceId: notification.spaceId,\n courseId: typeof courseId === 'string' ? courseId : null,\n },\n });\n },\n };\n}\n\nexport function buildNotificationRegistries(deps: {\n declarations: NotificationTypeDeclaration[];\n defaults: { community: NotificationCategory[]; org: NotificationCategory[] };\n mailer: Pick<Mailer, 'send'> | null;\n emailOf: (orgId: string, userId: string) => Promise<string | null>;\n push: { sender: PushSender; activeDevices: ActiveDevices } | null;\n channels: NotificationChannel[];\n}): { types: NotificationTypeRegistry; channels: ChannelRegistry } {\n const configured = [\n IN_APP,\n ...(deps.mailer ? [EMAIL] : []),\n ...(deps.push ? [PUSH] : []),\n ...deps.channels.map((channel) => channel.key),\n ];\n const types = buildNotificationTypeRegistry(deps.declarations, deps.defaults, configured);\n const channels = buildChannelRegistry([\n ...(deps.mailer ? [emailChannel({ mailer: deps.mailer, types, emailOf: deps.emailOf })] : []),\n ...(deps.push ? [pushChannel({ ...deps.push, types })] : []),\n ...deps.channels,\n ]);\n return { types, channels };\n}\n"],"mappings":";;;;AAkBA,MAAa,SAAS;AACtB,MAAa,QAAQ;AAErB,MAAM,mCAAmB,IAAI,IAAI;CAAC;CAAU;CAAY;CAAa;AAAQ,CAAC;AAE9E,SAAgB,8BACd,cACA,UACA,YAC0B;CAC1B,MAAM,yBAAS,IAAI,IAAyC;CAC5D,KAAK,MAAM,eAAe,cAAc;EACtC,IAAI,OAAO,IAAI,YAAY,IAAI,GAC7B,MAAM,IAAI,MAAM,qBAAqB,YAAY,KAAK,mBAAmB;EAE3E,OAAO,IAAI,YAAY,MAAM,WAAW;CAC1C;CACA,MAAM,QAAQ;EACZ,WAAW,mBAAmB,SAAS,WAAW,UAAU;EAC5D,KAAK,mBAAmB,SAAS,KAAK,UAAU;CAClD;CACA,OAAO;EACL,YACE,aAAa,KAAK,EAAE,MAAM,QAAQ,eAAe;GAC/C;GACA,QAAQ,CAAC,GAAG,MAAM;GAClB,SAAS,CAAC,GAAG,OAAO;EACtB,EAAE;EACJ,MAAM,SAAS,OAAO,IAAI,IAAI;EAC9B,WAAW,UAAU,gBAAgB,MAAM,MAAM;CACnD;AACF;AAEA,SAAgB,qBAAqB,UAAkD;CACrF,MAAM,wBAAQ,IAAI,IAAiC;CACnD,KAAK,MAAM,WAAW,UAAU;EAC9B,IAAI,QAAQ,QAAA,WAAkB,MAAM,IAAI,QAAQ,GAAG,GACjD,MAAM,IAAI,MAAM,wBAAwB,QAAQ,IAAI,qBAAqB;EAE3E,MAAM,IAAI,QAAQ,KAAK,OAAO;CAChC;CACA,MAAM,OAAO,CAAC,QAAQ,GAAG,MAAM,KAAK,CAAC;CACrC,OAAO;EACL,YAAY,CAAC,GAAG,IAAI;EACpB,MAAM,QAAQ,MAAM,IAAI,GAAG;CAC7B;AACF;AAEA,SAAgB,iBACd,aACA,cACwB;CACxB,MAAM,QAAQ,YAAY,QAAQ,aAAa;CAC/C,IAAI,CAAC,OACH,OAAO;CAET,OAAO,MAAM,WAAW,aAAa,kBAAkB,MAAM;AAC/D;AAEA,SAAgB,YAAY,cAA6D;CACvF,MAAM,MAAiC,CAAC;CACxC,KAAK,MAAM,OAAO,CAAC,aAAa,QAAQ,aAAa,OAAO,GAC1D,IAAI,KACF,IAAI,GAAG,IAAI,KAAK,OAAO,IAAI;CAG/B,IAAI,aAAa,WAAW,iBAAiB,IAAI,aAAa,QAAQ,IAAI,GACxE,IAAI,YAAY,aAAa,QAAQ;CAEvC,IAAI,aAAa,aACf,IAAI,cAAc,aAAa;CAEjC,IAAI,aAAa,SACf,IAAI,UAAU,aAAa;CAE7B,OAAO;EAAE,GAAG;EAAK,GAAG,aAAa;CAAQ;AAC3C;AAEA,SAAgB,aAAa,MAIL;CACtB,OAAO;EACL,KAAK;EACL,WAAW,OAAO,OAAO,WAAW;GAClC,MAAM,QAAQ,MAAM,KAAK,QAAQ,OAAO,MAAM;GAC9C,OAAO,QAAQ,EAAE,MAAM,IAAI;EAC7B;EACA,SAAS,OAAO,cAAc,OAAO;GACnC,MAAM,cAAc,KAAK,MAAM,IAAI,aAAa,IAAI;GACpD,MAAM,WAAW,cAAc,iBAAiB,aAAa,YAAY,IAAI;GAC7E,IAAI,CAAC,GAAG,OACN,OAAO;IAAE,UAAU;IAAO,eAAe,CAAC;IAAG,OAAO;GAAa;GAEnE,IAAI,CAAC,UACH,OAAO;IACL,UAAU;IACV,eAAe,CAAC;IAChB,OAAO,yBAAyB,aAAa,KAAK,MAAM,aAAa;GACvE;GAEF,IAAI;IACF,MAAM,KAAK,OAAO,KAChB,GAAG,OACH,UACA,YAAY,YAAY,CAC1B;GACF,SAAS,KAAK;IACZ,IAAI,gBAAgB,GAAG,KAAK,mBAAmB,GAAG,GAChD,OAAO;KAAE,UAAU;KAAO,eAAe,CAAC;KAAG,OAAO,IAAI;IAAQ;IAElE,MAAM;GACR;GACA,OAAO;IAAE,UAAU;IAAM,eAAe,CAAC;GAAE;EAC7C;CACF;AACF;AAEA,SAAgB,eAAe,UAAkB,SAA4C;CAC3F,OAAO,SAAS,QAAQ,mBAAmB,cAAc,QAAgB;EACvE,MAAM,QAAQ,QAAQ;EACtB,IAAI,OAAO,UAAU,UACnB,OAAO;EAET,IAAI,MAAM,QAAQ,KAAK,KAAK,MAAM,OAAO,SAAS,OAAO,SAAS,QAAQ,GACxE,OAAO,MAAM,KAAK,IAAI;EAExB,OAAO;CACT,CAAC;AACH;AAEA,SAAgB,qBACd,QACA,QACmB;CAEnB,OADmB,OAAO,OAAO,MAAM,CAAC,CAAC,MAAM,aAAa,aAAa,KAAA,CACzD,KAAK,SAAS,SAAS;AACzC;AAIA,SAAgB,YAAY,MAKJ;CACtB,MAAM,MAAM,KAAK,8BAAc,IAAI,KAAK;CACxC,OAAO;EACL,KAAK;EACL,WAAW,OAAO,OAAO,WAAW;GAClC,MAAM,QAAQ,IAAI,KAAK,IAAI,CAAC,CAAC,QAAQ,IAAI,gBAAgB;GACzD,MAAM,UAAU,MAAM,KAAK,cAAc,OAAO,QAAQ,KAAK;GAC7D,OAAO,QAAQ,SAAS,IAAI,EAAE,QAAQ,IAAI;EAC5C;EACA,SAAS,OAAO,cAAc,OAAO;GACnC,MAAM,OAAO,KAAK,MAAM,IAAI,aAAa,IAAI,CAAC,EAAE,OAAO,aAAa;GACpE,IAAI,CAAC,GAAG,SACN,OAAO;IAAE,UAAU;IAAO,eAAe,CAAC;IAAG,OAAO;GAAa;GAEnE,IAAI,CAAC,MACH,OAAO;IACL,UAAU;IACV,eAAe,CAAC;IAChB,OAAO,oBAAoB,aAAa,KAAK,MAAM,aAAa;GAClE;GAEF,MAAM,WAAW,aAAa,QAAQ;GACtC,OAAO,KAAK,OAAO,KAAK,GAAG,SAAS;IAClC,OAAO,eAAe,KAAK,OAAO,aAAa,OAAO;IACtD,MAAM,eAAe,KAAK,MAAM,aAAa,OAAO;IACpD,MAAM;KACJ,gBAAgB,aAAa;KAC7B,MAAM,aAAa;KACnB,SAAS,aAAa;KACtB,QAAQ,aAAa;KACrB,aAAa,aAAa;KAC1B,SAAS,aAAa;KACtB,UAAU,OAAO,aAAa,WAAW,WAAW;IACtD;GACF,CAAC;EACH;CACF;AACF;AAEA,SAAgB,4BAA4B,MAOuB;CACjE,MAAM,aAAa;EACjB;EACA,GAAI,KAAK,SAAS,CAAC,KAAK,IAAI,CAAC;EAC7B,GAAI,KAAK,OAAO,CAAC,IAAI,IAAI,CAAC;EAC1B,GAAG,KAAK,SAAS,KAAK,YAAY,QAAQ,GAAG;CAC/C;CACA,MAAM,QAAQ,8BAA8B,KAAK,cAAc,KAAK,UAAU,UAAU;CAMxF,OAAO;EAAE;EAAO,UALC,qBAAqB;GACpC,GAAI,KAAK,SAAS,CAAC,aAAa;IAAE,QAAQ,KAAK;IAAQ;IAAO,SAAS,KAAK;GAAQ,CAAC,CAAC,IAAI,CAAC;GAC3F,GAAI,KAAK,OAAO,CAAC,YAAY;IAAE,GAAG,KAAK;IAAM;GAAM,CAAC,CAAC,IAAI,CAAC;GAC1D,GAAG,KAAK;EACV,CACuB;CAAE;AAC3B"}
|
|
1
|
+
{"version":3,"file":"notification-channels.js","names":[],"sources":["../../src/app/notification-channels.ts"],"sourcesContent":["import type {\n ChannelRegistry,\n EmailTemplateId,\n EmailTemplateParams,\n JsonValue,\n NotificationCategory,\n NotificationChannel,\n NotificationTypeDeclaration,\n NotificationTypeRegistry,\n PushDevice,\n PushSender,\n StoredNotification,\n} from '@headless-lms/core/shared/ports';\nimport { DEVICE_WINDOW_MS, PUSH } from '@headless-lms/core/automations';\nimport type { PushConfig } from '@headless-lms/core/types';\nimport { offeringConfigured } from '@headless-lms/core/shared/notification-categories';\nimport { isEmailRejected, isEmailRenderError, type Mailer } from '@headless-lms/core/shared/mailer';\n\nexport const IN_APP = 'inApp';\nexport const EMAIL = 'email';\n\nconst CONTENT_SUBJECTS = new Set(['course', 'download', 'community', 'event', 'bundle']);\n\nexport function buildNotificationTypeRegistry(\n declarations: NotificationTypeDeclaration[],\n defaults: { community: NotificationCategory[]; org: NotificationCategory[] },\n configured: string[],\n): NotificationTypeRegistry {\n const byType = new Map<string, NotificationTypeDeclaration>();\n for (const declaration of declarations) {\n if (byType.has(declaration.type)) {\n throw new Error(`notification type ${declaration.type} is declared twice`);\n }\n byType.set(declaration.type, declaration);\n }\n const lists = {\n community: offeringConfigured(defaults.community, configured),\n org: offeringConfigured(defaults.org, configured),\n };\n return {\n list: () =>\n declarations.map(({ type, scopes, reasons }) => ({\n type,\n scopes: [...scopes],\n reasons: [...reasons],\n })),\n get: (type) => byType.get(type),\n defaults: (scope) => structuredClone(lists[scope]),\n };\n}\n\nexport function buildChannelRegistry(channels: NotificationChannel[]): ChannelRegistry {\n const byKey = new Map<string, NotificationChannel>();\n for (const channel of channels) {\n if (channel.key === IN_APP || byKey.has(channel.key)) {\n throw new Error(`notification channel ${channel.key} is registered twice`);\n }\n byKey.set(channel.key, channel);\n }\n const keys = [IN_APP, ...byKey.keys()];\n return {\n list: () => [...keys],\n get: (key) => byKey.get(key),\n };\n}\n\nexport function emailTemplateFor(\n declaration: NotificationTypeDeclaration,\n notification: Pick<StoredNotification, 'scope' | 'matchedReason'>,\n): EmailTemplateId | null {\n const email = declaration.email?.[notification.scope];\n if (!email) {\n return null;\n }\n return email.byReason?.[notification.matchedReason] ?? email.template;\n}\n\nexport function emailParams(notification: StoredNotification): Record<string, JsonValue> {\n const ids: Record<string, JsonValue> = {};\n for (const ref of [notification.parent, notification.subject]) {\n if (ref) {\n ids[`${ref.type}Id`] = ref.id;\n }\n }\n if (notification.subject && CONTENT_SUBJECTS.has(notification.subject.type)) {\n ids.contentId = notification.subject.id;\n }\n if (notification.communityId) {\n ids.communityId = notification.communityId;\n }\n if (notification.spaceId) {\n ids.spaceId = notification.spaceId;\n }\n return { ...ids, ...notification.display };\n}\n\nexport function emailChannel(deps: {\n mailer: Pick<Mailer, 'send'>;\n types: NotificationTypeRegistry;\n emailOf: (orgId: string, userId: string) => Promise<string | null>;\n}): NotificationChannel {\n return {\n key: EMAIL,\n recipient: async (orgId, userId) => {\n const email = await deps.emailOf(orgId, userId);\n return email ? { email } : null;\n },\n deliver: async (notification, to) => {\n const declaration = deps.types.get(notification.type);\n const template = declaration ? emailTemplateFor(declaration, notification) : null;\n if (!to.email) {\n return { accepted: false, deadDeviceIds: [], error: 'no address' };\n }\n if (!template) {\n return {\n accepted: false,\n deadDeviceIds: [],\n error: `no email template for ${notification.type} in ${notification.scope}`,\n };\n }\n try {\n await deps.mailer.send(\n to.email,\n template,\n emailParams(notification) as unknown as EmailTemplateParams[typeof template],\n );\n } catch (err) {\n if (isEmailRejected(err) || isEmailRenderError(err)) {\n return { accepted: false, deadDeviceIds: [], error: err.message };\n }\n throw err;\n }\n return { accepted: true, deadDeviceIds: [] };\n },\n };\n}\n\nexport function renderPushText(template: string, display: Record<string, JsonValue>): string {\n return template.replace(/\\{\\{(\\w+)\\}\\}/g, (_placeholder, key: string) => {\n const value = display[key];\n if (typeof value === 'string') {\n return value;\n }\n if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {\n return value.join(', ');\n }\n return '';\n });\n}\n\nexport function configuredPushSender(\n config: PushConfig,\n sender: PushSender | undefined,\n): PushSender | null {\n const configured = Object.values(config).some((provider) => provider !== undefined);\n return configured && sender ? sender : null;\n}\n\nexport type ActiveDevices = (orgId: string, userId: string, since: Date) => Promise<PushDevice[]>;\n\nexport function pushChannel(deps: {\n sender: PushSender;\n types: NotificationTypeRegistry;\n activeDevices: ActiveDevices;\n now?: () => Date;\n}): NotificationChannel {\n const now = deps.now ?? (() => new Date());\n return {\n key: PUSH,\n recipient: async (orgId, userId) => {\n const since = new Date(now().getTime() - DEVICE_WINDOW_MS);\n const devices = await deps.activeDevices(orgId, userId, since);\n return devices.length > 0 ? { devices } : null;\n },\n deliver: async (notification, to) => {\n const text = deps.types.get(notification.type)?.push?.[notification.scope];\n if (!to.devices) {\n return { accepted: false, deadDeviceIds: [], error: 'no address' };\n }\n if (!text) {\n return {\n accepted: false,\n deadDeviceIds: [],\n error: `no push text for ${notification.type} in ${notification.scope}`,\n };\n }\n const courseId = notification.display.courseId;\n return deps.sender.send(to.devices, {\n title: renderPushText(text.title, notification.display),\n body: renderPushText(text.body, notification.display),\n data: {\n notificationId: notification.id,\n type: notification.type,\n subject: notification.subject,\n parent: notification.parent,\n communityId: notification.communityId,\n spaceId: notification.spaceId,\n courseId: typeof courseId === 'string' ? courseId : null,\n },\n });\n },\n };\n}\n\nexport function buildNotificationRegistries(deps: {\n declarations: NotificationTypeDeclaration[];\n defaults: { community: NotificationCategory[]; org: NotificationCategory[] };\n mailer: Pick<Mailer, 'send'> | null;\n emailOf: (orgId: string, userId: string) => Promise<string | null>;\n push: { sender: PushSender; activeDevices: ActiveDevices } | null;\n channels: NotificationChannel[];\n}): { types: NotificationTypeRegistry; channels: ChannelRegistry } {\n const configured = [\n IN_APP,\n ...(deps.mailer ? [EMAIL] : []),\n ...(deps.push ? [PUSH] : []),\n ...deps.channels.map((channel) => channel.key),\n ];\n const types = buildNotificationTypeRegistry(deps.declarations, deps.defaults, configured);\n const channels = buildChannelRegistry([\n ...(deps.mailer ? [emailChannel({ mailer: deps.mailer, types, emailOf: deps.emailOf })] : []),\n ...(deps.push ? [pushChannel({ ...deps.push, types })] : []),\n ...deps.channels,\n ]);\n return { types, channels };\n}\n"],"mappings":";;;;AAkBA,MAAa,SAAS;AACtB,MAAa,QAAQ;AAErB,MAAM,mCAAmB,IAAI,IAAI;CAAC;CAAU;CAAY;CAAa;CAAS;AAAQ,CAAC;AAEvF,SAAgB,8BACd,cACA,UACA,YAC0B;CAC1B,MAAM,yBAAS,IAAI,IAAyC;CAC5D,KAAK,MAAM,eAAe,cAAc;EACtC,IAAI,OAAO,IAAI,YAAY,IAAI,GAC7B,MAAM,IAAI,MAAM,qBAAqB,YAAY,KAAK,mBAAmB;EAE3E,OAAO,IAAI,YAAY,MAAM,WAAW;CAC1C;CACA,MAAM,QAAQ;EACZ,WAAW,mBAAmB,SAAS,WAAW,UAAU;EAC5D,KAAK,mBAAmB,SAAS,KAAK,UAAU;CAClD;CACA,OAAO;EACL,YACE,aAAa,KAAK,EAAE,MAAM,QAAQ,eAAe;GAC/C;GACA,QAAQ,CAAC,GAAG,MAAM;GAClB,SAAS,CAAC,GAAG,OAAO;EACtB,EAAE;EACJ,MAAM,SAAS,OAAO,IAAI,IAAI;EAC9B,WAAW,UAAU,gBAAgB,MAAM,MAAM;CACnD;AACF;AAEA,SAAgB,qBAAqB,UAAkD;CACrF,MAAM,wBAAQ,IAAI,IAAiC;CACnD,KAAK,MAAM,WAAW,UAAU;EAC9B,IAAI,QAAQ,QAAA,WAAkB,MAAM,IAAI,QAAQ,GAAG,GACjD,MAAM,IAAI,MAAM,wBAAwB,QAAQ,IAAI,qBAAqB;EAE3E,MAAM,IAAI,QAAQ,KAAK,OAAO;CAChC;CACA,MAAM,OAAO,CAAC,QAAQ,GAAG,MAAM,KAAK,CAAC;CACrC,OAAO;EACL,YAAY,CAAC,GAAG,IAAI;EACpB,MAAM,QAAQ,MAAM,IAAI,GAAG;CAC7B;AACF;AAEA,SAAgB,iBACd,aACA,cACwB;CACxB,MAAM,QAAQ,YAAY,QAAQ,aAAa;CAC/C,IAAI,CAAC,OACH,OAAO;CAET,OAAO,MAAM,WAAW,aAAa,kBAAkB,MAAM;AAC/D;AAEA,SAAgB,YAAY,cAA6D;CACvF,MAAM,MAAiC,CAAC;CACxC,KAAK,MAAM,OAAO,CAAC,aAAa,QAAQ,aAAa,OAAO,GAC1D,IAAI,KACF,IAAI,GAAG,IAAI,KAAK,OAAO,IAAI;CAG/B,IAAI,aAAa,WAAW,iBAAiB,IAAI,aAAa,QAAQ,IAAI,GACxE,IAAI,YAAY,aAAa,QAAQ;CAEvC,IAAI,aAAa,aACf,IAAI,cAAc,aAAa;CAEjC,IAAI,aAAa,SACf,IAAI,UAAU,aAAa;CAE7B,OAAO;EAAE,GAAG;EAAK,GAAG,aAAa;CAAQ;AAC3C;AAEA,SAAgB,aAAa,MAIL;CACtB,OAAO;EACL,KAAK;EACL,WAAW,OAAO,OAAO,WAAW;GAClC,MAAM,QAAQ,MAAM,KAAK,QAAQ,OAAO,MAAM;GAC9C,OAAO,QAAQ,EAAE,MAAM,IAAI;EAC7B;EACA,SAAS,OAAO,cAAc,OAAO;GACnC,MAAM,cAAc,KAAK,MAAM,IAAI,aAAa,IAAI;GACpD,MAAM,WAAW,cAAc,iBAAiB,aAAa,YAAY,IAAI;GAC7E,IAAI,CAAC,GAAG,OACN,OAAO;IAAE,UAAU;IAAO,eAAe,CAAC;IAAG,OAAO;GAAa;GAEnE,IAAI,CAAC,UACH,OAAO;IACL,UAAU;IACV,eAAe,CAAC;IAChB,OAAO,yBAAyB,aAAa,KAAK,MAAM,aAAa;GACvE;GAEF,IAAI;IACF,MAAM,KAAK,OAAO,KAChB,GAAG,OACH,UACA,YAAY,YAAY,CAC1B;GACF,SAAS,KAAK;IACZ,IAAI,gBAAgB,GAAG,KAAK,mBAAmB,GAAG,GAChD,OAAO;KAAE,UAAU;KAAO,eAAe,CAAC;KAAG,OAAO,IAAI;IAAQ;IAElE,MAAM;GACR;GACA,OAAO;IAAE,UAAU;IAAM,eAAe,CAAC;GAAE;EAC7C;CACF;AACF;AAEA,SAAgB,eAAe,UAAkB,SAA4C;CAC3F,OAAO,SAAS,QAAQ,mBAAmB,cAAc,QAAgB;EACvE,MAAM,QAAQ,QAAQ;EACtB,IAAI,OAAO,UAAU,UACnB,OAAO;EAET,IAAI,MAAM,QAAQ,KAAK,KAAK,MAAM,OAAO,SAAS,OAAO,SAAS,QAAQ,GACxE,OAAO,MAAM,KAAK,IAAI;EAExB,OAAO;CACT,CAAC;AACH;AAEA,SAAgB,qBACd,QACA,QACmB;CAEnB,OADmB,OAAO,OAAO,MAAM,CAAC,CAAC,MAAM,aAAa,aAAa,KAAA,CACzD,KAAK,SAAS,SAAS;AACzC;AAIA,SAAgB,YAAY,MAKJ;CACtB,MAAM,MAAM,KAAK,8BAAc,IAAI,KAAK;CACxC,OAAO;EACL,KAAK;EACL,WAAW,OAAO,OAAO,WAAW;GAClC,MAAM,QAAQ,IAAI,KAAK,IAAI,CAAC,CAAC,QAAQ,IAAI,gBAAgB;GACzD,MAAM,UAAU,MAAM,KAAK,cAAc,OAAO,QAAQ,KAAK;GAC7D,OAAO,QAAQ,SAAS,IAAI,EAAE,QAAQ,IAAI;EAC5C;EACA,SAAS,OAAO,cAAc,OAAO;GACnC,MAAM,OAAO,KAAK,MAAM,IAAI,aAAa,IAAI,CAAC,EAAE,OAAO,aAAa;GACpE,IAAI,CAAC,GAAG,SACN,OAAO;IAAE,UAAU;IAAO,eAAe,CAAC;IAAG,OAAO;GAAa;GAEnE,IAAI,CAAC,MACH,OAAO;IACL,UAAU;IACV,eAAe,CAAC;IAChB,OAAO,oBAAoB,aAAa,KAAK,MAAM,aAAa;GAClE;GAEF,MAAM,WAAW,aAAa,QAAQ;GACtC,OAAO,KAAK,OAAO,KAAK,GAAG,SAAS;IAClC,OAAO,eAAe,KAAK,OAAO,aAAa,OAAO;IACtD,MAAM,eAAe,KAAK,MAAM,aAAa,OAAO;IACpD,MAAM;KACJ,gBAAgB,aAAa;KAC7B,MAAM,aAAa;KACnB,SAAS,aAAa;KACtB,QAAQ,aAAa;KACrB,aAAa,aAAa;KAC1B,SAAS,aAAa;KACtB,UAAU,OAAO,aAAa,WAAW,WAAW;IACtD;GACF,CAAC;EACH;CACF;AACF;AAEA,SAAgB,4BAA4B,MAOuB;CACjE,MAAM,aAAa;EACjB;EACA,GAAI,KAAK,SAAS,CAAC,KAAK,IAAI,CAAC;EAC7B,GAAI,KAAK,OAAO,CAAC,IAAI,IAAI,CAAC;EAC1B,GAAG,KAAK,SAAS,KAAK,YAAY,QAAQ,GAAG;CAC/C;CACA,MAAM,QAAQ,8BAA8B,KAAK,cAAc,KAAK,UAAU,UAAU;CAMxF,OAAO;EAAE;EAAO,UALC,qBAAqB;GACpC,GAAI,KAAK,SAAS,CAAC,aAAa;IAAE,QAAQ,KAAK;IAAQ;IAAO,SAAS,KAAK;GAAQ,CAAC,CAAC,IAAI,CAAC;GAC3F,GAAI,KAAK,OAAO,CAAC,YAAY;IAAE,GAAG,KAAK;IAAM;GAAM,CAAC,CAAC,IAAI,CAAC;GAC1D,GAAG,KAAK;EACV,CACuB;CAAE;AAC3B"}
|
|
@@ -7,9 +7,9 @@ import { OrganizationService } from "@headless-lms/core/organizations";
|
|
|
7
7
|
import { NotificationReads } from "@headless-lms/core/automations";
|
|
8
8
|
//#region src/app/notification-reads.d.ts
|
|
9
9
|
type NotificationReadsDeps = CommunityMailDeps & {
|
|
10
|
-
content: Pick<ContentService, 'getActivity' | 'getModule' | '
|
|
11
|
-
entitlements: Pick<EntitlementsService, 'communityMemberIds' | '
|
|
12
|
-
organizations: Pick<OrganizationService, 'getOrgUsersByIds'>;
|
|
10
|
+
content: Pick<ContentService, 'getActivity' | 'getModule' | 'findCourse' | 'findEvent' | 'listBundleItems'>;
|
|
11
|
+
entitlements: Pick<EntitlementsService, 'communityMemberIds' | 'grantedOrgUserIds' | 'hasAccess'>;
|
|
12
|
+
organizations: Pick<OrganizationService, 'getOrgUsersByIds' | 'systemActor'>;
|
|
13
13
|
identity: Pick<IdentityService, 'getUserById'>;
|
|
14
14
|
posts: Pick<PostService, 'publishedBetween'>;
|
|
15
15
|
subscriptions: Pick<SubscriptionService, 'mutedAmong'>;
|
|
@@ -4,6 +4,7 @@ import { activityTitle } from "@headless-lms/core/content";
|
|
|
4
4
|
function notificationReads(deps) {
|
|
5
5
|
const { mail } = communityMail(deps);
|
|
6
6
|
const nameOf = (orgId, orgUserId) => mail.displayName(orgId, orgUserId);
|
|
7
|
+
const system = (orgId) => deps.organizations.systemActor(orgId);
|
|
7
8
|
return {
|
|
8
9
|
...mail,
|
|
9
10
|
async threadParticipants(orgId, item) {
|
|
@@ -14,16 +15,18 @@ function notificationReads(deps) {
|
|
|
14
15
|
mutedAmong: (orgId, item, orgUserIds, at) => deps.subscriptions.mutedAmong(orgId, item, orgUserIds, at),
|
|
15
16
|
communityMembers: (orgId, communityId) => deps.entitlements.communityMemberIds(orgId, communityId),
|
|
16
17
|
switchedOn: (orgId, communityId, category, spaceId) => deps.settings.switchedOn(orgId, communityId, category, spaceId),
|
|
17
|
-
|
|
18
|
+
courseMembers: (orgId, courseId) => deps.entitlements.grantedOrgUserIds(orgId, courseId),
|
|
19
|
+
event: (orgId, eventId) => deps.content.findEvent(eventId, system(orgId)),
|
|
20
|
+
eventAttendees: (orgId, eventId) => deps.entitlements.grantedOrgUserIds(orgId, eventId),
|
|
18
21
|
async contentLostBy(orgId, grant) {
|
|
19
|
-
const opened = grant.bundleId ? (await deps.content.listBundleItems(
|
|
22
|
+
const opened = grant.bundleId ? (await deps.content.listBundleItems(grant.bundleId, system(orgId))).map((item) => item.contentId) : grant.contentId ? [grant.contentId] : [];
|
|
20
23
|
const held = await Promise.all(opened.map((contentId) => deps.entitlements.hasAccess(orgId, grant.orgUserId, contentId)));
|
|
21
24
|
return opened.filter((_, index) => !held[index]);
|
|
22
25
|
},
|
|
23
26
|
async activity(orgId, activityId) {
|
|
24
|
-
const activity = await deps.content.getActivity(
|
|
27
|
+
const activity = await deps.content.getActivity(activityId, system(orgId));
|
|
25
28
|
if (!activity) return null;
|
|
26
|
-
const [module, course] = await Promise.all([deps.content.getModule(
|
|
29
|
+
const [module, course] = await Promise.all([deps.content.getModule(activity.moduleId, system(orgId)), deps.content.findCourse(activity.courseId, system(orgId))]);
|
|
27
30
|
if (!course) return null;
|
|
28
31
|
return {
|
|
29
32
|
title: activityTitle(activity),
|
|
@@ -39,30 +42,6 @@ function notificationReads(deps) {
|
|
|
39
42
|
async emailOf(_orgId, userId) {
|
|
40
43
|
return (await deps.identity.getUserById(userId))?.email ?? null;
|
|
41
44
|
},
|
|
42
|
-
async contentInfo(orgId, item) {
|
|
43
|
-
if ("bundleId" in item) {
|
|
44
|
-
const bundle = await deps.content.getBundle(orgId, item.bundleId);
|
|
45
|
-
return bundle ? {
|
|
46
|
-
title: bundle.name,
|
|
47
|
-
type: "bundle"
|
|
48
|
-
} : null;
|
|
49
|
-
}
|
|
50
|
-
const course = await deps.content.getCourse(orgId, item.contentId);
|
|
51
|
-
if (course) return {
|
|
52
|
-
title: course.title,
|
|
53
|
-
type: course.type
|
|
54
|
-
};
|
|
55
|
-
const download = await deps.content.getDownload(orgId, item.contentId);
|
|
56
|
-
if (download) return {
|
|
57
|
-
title: download.title,
|
|
58
|
-
type: download.type
|
|
59
|
-
};
|
|
60
|
-
const community = await deps.content.getCommunity(orgId, item.contentId);
|
|
61
|
-
return community ? {
|
|
62
|
-
title: community.title,
|
|
63
|
-
type: community.type
|
|
64
|
-
} : null;
|
|
65
|
-
},
|
|
66
45
|
async message(orgId, messageId) {
|
|
67
46
|
const message = await deps.chats.findMessage(orgId, messageId);
|
|
68
47
|
return message && message.deletedAt === null ? {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notification-reads.js","names":[],"sources":["../../src/app/notification-reads.ts"],"sourcesContent":["import type { NotificationReads } from '@headless-lms/core/automations';\nimport { activityTitle, type ContentService } from '@headless-lms/core/content';\nimport type {\n ChatService,\n DiscussionService,\n PostService,\n SubscriptionService,\n} from '@headless-lms/core/discussion';\nimport type { EntitlementsService } from '@headless-lms/core/entitlements';\nimport type { IdentityService, NotificationSettingsService } from '@headless-lms/core/identity';\nimport type { OrganizationService } from '@headless-lms/core/organizations';\nimport { communityMail, type CommunityMailDeps } from './community-mail.js';\n\nexport type NotificationReadsDeps = CommunityMailDeps & {\n content: Pick<\n ContentService,\n 'getActivity' | 'getModule' | '
|
|
1
|
+
{"version":3,"file":"notification-reads.js","names":[],"sources":["../../src/app/notification-reads.ts"],"sourcesContent":["import type { NotificationReads } from '@headless-lms/core/automations';\nimport { activityTitle, type ContentService } from '@headless-lms/core/content';\nimport type {\n ChatService,\n DiscussionService,\n PostService,\n SubscriptionService,\n} from '@headless-lms/core/discussion';\nimport type { EntitlementsService } from '@headless-lms/core/entitlements';\nimport type { IdentityService, NotificationSettingsService } from '@headless-lms/core/identity';\nimport type { OrganizationService } from '@headless-lms/core/organizations';\nimport { communityMail, type CommunityMailDeps } from './community-mail.js';\n\nexport type NotificationReadsDeps = CommunityMailDeps & {\n content: Pick<\n ContentService,\n 'getActivity' | 'getModule' | 'findCourse' | 'findEvent' | 'listBundleItems'\n >;\n entitlements: Pick<EntitlementsService, 'communityMemberIds' | 'grantedOrgUserIds' | 'hasAccess'>;\n organizations: Pick<OrganizationService, 'getOrgUsersByIds' | 'systemActor'>;\n identity: Pick<IdentityService, 'getUserById'>;\n posts: Pick<PostService, 'publishedBetween'>;\n subscriptions: Pick<SubscriptionService, 'mutedAmong'>;\n discussion: Pick<DiscussionService, 'commenterIds'>;\n chats: Pick<ChatService, 'unreadInChat' | 'findMessage'>;\n settings: Pick<NotificationSettingsService, 'switchedOn'>;\n};\n\nexport function notificationReads(deps: NotificationReadsDeps): NotificationReads {\n const { mail } = communityMail(deps);\n const nameOf = (orgId: string, orgUserId: string) => mail.displayName(orgId, orgUserId);\n const system = (orgId: string) => deps.organizations.systemActor(orgId);\n return {\n ...mail,\n async threadParticipants(orgId, item) {\n if (item.type === 'post') {\n return deps.discussion.commenterIds(orgId, { postId: item.id });\n }\n if (item.type === 'activity') {\n return deps.discussion.commenterIds(orgId, { activityId: item.id });\n }\n return [];\n },\n mutedAmong: (orgId, item, orgUserIds, at) =>\n deps.subscriptions.mutedAmong(orgId, item, orgUserIds, at),\n communityMembers: (orgId, communityId) =>\n deps.entitlements.communityMemberIds(orgId, communityId),\n switchedOn: (orgId, communityId, category, spaceId) =>\n deps.settings.switchedOn(orgId, communityId, category, spaceId),\n courseMembers: (orgId, courseId) => deps.entitlements.grantedOrgUserIds(orgId, courseId),\n event: (orgId, eventId) => deps.content.findEvent(eventId, system(orgId)),\n eventAttendees: (orgId, eventId) => deps.entitlements.grantedOrgUserIds(orgId, eventId),\n async contentLostBy(orgId, grant) {\n const opened = grant.bundleId\n ? (await deps.content.listBundleItems(grant.bundleId, system(orgId))).map(\n (item) => item.contentId,\n )\n : grant.contentId\n ? [grant.contentId]\n : [];\n const held = await Promise.all(\n opened.map((contentId) => deps.entitlements.hasAccess(orgId, grant.orgUserId, contentId)),\n );\n return opened.filter((_, index) => !held[index]);\n },\n async activity(orgId, activityId) {\n const activity = await deps.content.getActivity(activityId, system(orgId));\n if (!activity) {\n return null;\n }\n const [module, course] = await Promise.all([\n deps.content.getModule(activity.moduleId, system(orgId)),\n deps.content.findCourse(activity.courseId, system(orgId)),\n ]);\n if (!course) {\n return null;\n }\n return {\n title: activityTitle(activity),\n moduleTitle: module?.title ?? '',\n courseId: course.id,\n courseTitle: course.title,\n };\n },\n async userIdsOf(orgId, orgUserIds) {\n const orgUsers = await deps.organizations.getOrgUsersByIds(orgId, orgUserIds);\n return Object.fromEntries(orgUsers.map((orgUser) => [orgUser.id, orgUser.userId]));\n },\n async emailOf(_orgId, userId) {\n return (await deps.identity.getUserById(userId))?.email ?? null;\n },\n async message(orgId, messageId) {\n const message = await deps.chats.findMessage(orgId, messageId);\n return message && message.deletedAt === null\n ? { orgUserId: message.orgUserId, chatId: message.chatId, body: message.body }\n : null;\n },\n async postsPublished(orgId, communityId, from, to) {\n const posts = await deps.posts.publishedBetween(orgId, communityId, from, to);\n const spaceNames = new Map<string, string>();\n for (const spaceId of new Set(posts.map((post) => post.spaceId))) {\n spaceNames.set(spaceId, (await mail.space(orgId, spaceId))?.name ?? '');\n }\n const authorNames = new Map<string, string>();\n for (const orgUserId of new Set(posts.map((post) => post.orgUserId))) {\n authorNames.set(orgUserId, await nameOf(orgId, orgUserId));\n }\n return posts.map((post) => ({\n postId: post.postId,\n spaceId: post.spaceId,\n title: post.title,\n authorName: authorNames.get(post.orgUserId) ?? '',\n spaceName: spaceNames.get(post.spaceId) ?? '',\n commentCount: post.commentCount,\n }));\n },\n unreadInChat: (orgId, chatId, orgUserId) => deps.chats.unreadInChat(orgId, chatId, orgUserId),\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/app/platform.d.ts
|
|
2
|
+
declare const PLATFORM_KEY_MIN_LENGTH = 32;
|
|
3
|
+
interface PlatformConfig {
|
|
4
|
+
key: string;
|
|
5
|
+
accountEmail: string;
|
|
6
|
+
accountName?: string;
|
|
7
|
+
}
|
|
8
|
+
interface PlatformKeyCheck {
|
|
9
|
+
matches(candidate: string): boolean;
|
|
10
|
+
}
|
|
11
|
+
declare function platformKeyCheck(config: PlatformConfig): PlatformKeyCheck;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { PLATFORM_KEY_MIN_LENGTH, PlatformConfig, PlatformKeyCheck, platformKeyCheck };
|
|
14
|
+
//# sourceMappingURL=platform.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
//#region src/app/platform.ts
|
|
3
|
+
const PLATFORM_KEY_MIN_LENGTH = 32;
|
|
4
|
+
const digest = (value) => createHash("sha256").update(value).digest();
|
|
5
|
+
function platformKeyCheck(config) {
|
|
6
|
+
if (config.key.length < 32) throw new Error(`the platform key must be at least 32 characters`);
|
|
7
|
+
if (!config.accountEmail.includes("@")) throw new Error("the platform account needs an email address");
|
|
8
|
+
const expected = digest(config.key);
|
|
9
|
+
return { matches: (candidate) => timingSafeEqual(digest(candidate), expected) };
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { PLATFORM_KEY_MIN_LENGTH, platformKeyCheck };
|
|
13
|
+
|
|
14
|
+
//# sourceMappingURL=platform.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platform.js","names":[],"sources":["../../src/app/platform.ts"],"sourcesContent":["import { createHash, timingSafeEqual } from 'node:crypto';\n\nexport const PLATFORM_KEY_MIN_LENGTH = 32;\n\nexport interface PlatformConfig {\n key: string;\n accountEmail: string;\n accountName?: string;\n}\n\nexport interface PlatformKeyCheck {\n matches(candidate: string): boolean;\n}\n\nconst digest = (value: string) => createHash('sha256').update(value).digest();\n\nexport function platformKeyCheck(config: PlatformConfig): PlatformKeyCheck {\n if (config.key.length < PLATFORM_KEY_MIN_LENGTH) {\n throw new Error(`the platform key must be at least ${PLATFORM_KEY_MIN_LENGTH} characters`);\n }\n if (!config.accountEmail.includes('@')) {\n throw new Error('the platform account needs an email address');\n }\n const expected = digest(config.key);\n return { matches: (candidate) => timingSafeEqual(digest(candidate), expected) };\n}\n"],"mappings":";;AAEA,MAAa,0BAA0B;AAYvC,MAAM,UAAU,UAAkB,WAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO;AAE5E,SAAgB,iBAAiB,QAA0C;CACzE,IAAI,OAAO,IAAI,SAAA,IACb,MAAM,IAAI,MAAM,iDAAyE;CAE3F,IAAI,CAAC,OAAO,aAAa,SAAS,GAAG,GACnC,MAAM,IAAI,MAAM,6CAA6C;CAE/D,MAAM,WAAW,OAAO,OAAO,GAAG;CAClC,OAAO,EAAE,UAAU,cAAc,gBAAgB,OAAO,SAAS,GAAG,QAAQ,EAAE;AAChF"}
|
package/dist/http/config.d.ts
CHANGED
|
@@ -18,6 +18,10 @@ interface ServerConfig {
|
|
|
18
18
|
* can't silently extend paywalled link lifetimes. */
|
|
19
19
|
deliveryExpirySeconds: number;
|
|
20
20
|
memberImageMaxBytes: number;
|
|
21
|
+
memberFileMaxBytes: number;
|
|
22
|
+
/** Postgres URL for the PubSub adapter's session connection. It must reach
|
|
23
|
+
* Postgres directly, not through a transaction-mode pooler. */
|
|
24
|
+
pubsubDatabaseUrl: string;
|
|
21
25
|
/** Everything the composition container needs to wire adapters + services. */
|
|
22
26
|
container: Config;
|
|
23
27
|
}
|
package/dist/http/errors.js
CHANGED
|
@@ -3,8 +3,8 @@ import { NoActiveOrgError } from "./scope.js";
|
|
|
3
3
|
import { InviteError, OrganizationRuleError } from "@headless-lms/core/organizations";
|
|
4
4
|
import { ActionInvocationError, AlreadyConnectedError, InvalidConfigError, UnknownIntegrationError } from "@headless-lms/core/integrations";
|
|
5
5
|
import { AutomationKindMismatchError, InvalidTriggerError, ReservedActionError } from "@headless-lms/core/automations";
|
|
6
|
-
import { hasZodFastifySchemaValidationErrors } from "fastify-type-provider-zod";
|
|
7
6
|
import { ConflictError, ForbiddenError, InvalidInputError, NotFoundError, UnavailableError, ValidationError } from "@headless-lms/core/shared/errors";
|
|
7
|
+
import { hasZodFastifySchemaValidationErrors } from "fastify-type-provider-zod";
|
|
8
8
|
//#region src/http/errors.ts
|
|
9
9
|
const BODY_ERRORS = {
|
|
10
10
|
FST_ERR_CTP_INVALID_JSON_BODY: {
|
|
@@ -39,13 +39,13 @@ function issuePath(context, instancePath) {
|
|
|
39
39
|
function describeIssue(keyword, params, fallback) {
|
|
40
40
|
switch (keyword) {
|
|
41
41
|
case "invalid_type":
|
|
42
|
-
if (params.received === "undefined" || params.received === "null") return "is required";
|
|
42
|
+
if (params.received === "undefined" || params.received === "null" || /received (undefined|null)$/.test(fallback)) return "is required";
|
|
43
43
|
return params.expected ? `must be ${withArticle(params.expected)}` : fallback;
|
|
44
44
|
case "too_small": return params.origin === "string" ? Number(params.minimum) === 1 ? "must not be empty" : `must be at least ${params.minimum} characters` : params.origin === "array" ? `must have at least ${params.minimum} ${Number(params.minimum) === 1 ? "item" : "items"}` : `must be ${params.inclusive === false ? "greater than" : "at least"} ${params.minimum}`;
|
|
45
45
|
case "too_big": return params.origin === "string" ? `must be at most ${params.maximum} characters` : params.origin === "array" ? `must have at most ${params.maximum} items` : `must be ${params.inclusive === false ? "less than" : "at most"} ${params.maximum}`;
|
|
46
46
|
case "unrecognized_keys": return `has ${params.keys && params.keys.length > 1 ? "unknown fields" : "an unknown field"} ${(params.keys ?? []).map((key) => `"${key}"`).join(", ")}`;
|
|
47
47
|
case "invalid_value": return `must be one of ${(params.values ?? []).map((value) => JSON.stringify(value)).join(", ")}`;
|
|
48
|
-
case "invalid_format": return params.format ? `must be a valid ${params.format.replace(/_/g, " ")}` : fallback;
|
|
48
|
+
case "invalid_format": return params.format && params.format !== "regex" ? `must be a valid ${params.format.replace(/_/g, " ")}` : fallback;
|
|
49
49
|
case "invalid_union": return "is not in an accepted shape";
|
|
50
50
|
default: return fallback;
|
|
51
51
|
}
|
|
@@ -73,7 +73,12 @@ function classifyError(error) {
|
|
|
73
73
|
message: problem.message
|
|
74
74
|
}))
|
|
75
75
|
};
|
|
76
|
-
if (error instanceof ConflictError
|
|
76
|
+
if (error instanceof ConflictError) return {
|
|
77
|
+
status: 409,
|
|
78
|
+
error: error.code ?? "conflict",
|
|
79
|
+
message: error.message
|
|
80
|
+
};
|
|
81
|
+
if (error instanceof OrganizationRuleError) return {
|
|
77
82
|
status: 409,
|
|
78
83
|
error: "conflict",
|
|
79
84
|
message: error.message
|
package/dist/http/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","names":[],"sources":["../../src/http/errors.ts"],"sourcesContent":["import {\n ConflictError,\n ForbiddenError,\n InvalidInputError,\n NotFoundError,\n UnavailableError,\n ValidationError,\n} from '@headless-lms/core/shared/errors';\nimport { InviteError, OrganizationRuleError } from '@headless-lms/core/organizations';\nimport {\n ActionInvocationError,\n AlreadyConnectedError,\n InvalidConfigError,\n UnknownIntegrationError,\n} from '@headless-lms/core/integrations';\nimport {\n AutomationKindMismatchError,\n InvalidTriggerError,\n ReservedActionError,\n} from '@headless-lms/core/automations';\nimport { hasZodFastifySchemaValidationErrors } from 'fastify-type-provider-zod';\nimport { NoActiveOrgError } from './scope.js';\nimport { UnauthorizedError } from './plugins/auth.js';\n\nexport type Refusal = {\n status: number;\n error: string;\n message: string;\n issues?: { path: string; message: string }[];\n details?: Record<string, string>;\n};\n\nconst BODY_ERRORS: Record<string, Refusal> = {\n FST_ERR_CTP_INVALID_JSON_BODY: {\n status: 400,\n error: 'invalid_body',\n message: 'The request body must be valid JSON',\n },\n FST_ERR_CTP_EMPTY_JSON_BODY: {\n status: 400,\n error: 'invalid_body',\n message: 'The request body must be valid JSON',\n },\n FST_ERR_CTP_INVALID_MEDIA_TYPE: {\n status: 415,\n error: 'unsupported_media_type',\n message: 'Send the request body as application/json',\n },\n FST_ERR_CTP_BODY_TOO_LARGE: {\n status: 413,\n error: 'payload_too_large',\n message: 'The request body is too large',\n },\n FST_ERR_NOT_FOUND: { status: 404, error: 'not_found', message: 'No such route' },\n};\n\nfunction issuePath(context: string, instancePath: string): string {\n const inner = instancePath\n .split('/')\n .filter((part) => part.length > 0)\n .map((part) => (/^\\d+$/.test(part) ? `[${part}]` : `.${part}`))\n .join('');\n return `${context}${inner}`;\n}\n\ntype IssueParams = {\n expected?: string;\n received?: string;\n minimum?: number | bigint;\n maximum?: number | bigint;\n inclusive?: boolean;\n origin?: string;\n keys?: string[];\n values?: unknown[];\n format?: string;\n};\n\nfunction describeIssue(keyword: string | undefined, params: IssueParams, fallback: string): string {\n switch (keyword) {\n case 'invalid_type':\n if (params.received === 'undefined' || params.received === 'null') {\n return 'is required';\n }\n return params.expected ? `must be ${withArticle(params.expected)}` : fallback;\n case 'too_small':\n return params.origin === 'string'\n ? Number(params.minimum) === 1\n ? 'must not be empty'\n : `must be at least ${params.minimum} characters`\n : params.origin === 'array'\n ? `must have at least ${params.minimum} ${Number(params.minimum) === 1 ? 'item' : 'items'}`\n : `must be ${params.inclusive === false ? 'greater than' : 'at least'} ${params.minimum}`;\n case 'too_big':\n return params.origin === 'string'\n ? `must be at most ${params.maximum} characters`\n : params.origin === 'array'\n ? `must have at most ${params.maximum} items`\n : `must be ${params.inclusive === false ? 'less than' : 'at most'} ${params.maximum}`;\n case 'unrecognized_keys':\n return `has ${params.keys && params.keys.length > 1 ? 'unknown fields' : 'an unknown field'} ${(params.keys ?? []).map((key) => `\"${key}\"`).join(', ')}`;\n case 'invalid_value':\n return `must be one of ${(params.values ?? []).map((value) => JSON.stringify(value)).join(', ')}`;\n case 'invalid_format':\n return params.format ? `must be a valid ${params.format.replace(/_/g, ' ')}` : fallback;\n case 'invalid_union':\n return 'is not in an accepted shape';\n default:\n return fallback;\n }\n}\n\nfunction withArticle(noun: string): string {\n return /^[aeiou]/i.test(noun) ? `an ${noun}` : `a ${noun}`;\n}\n\nexport function classifyError(error: unknown): Refusal | undefined {\n if (error instanceof NotFoundError) {\n return { status: 404, error: 'not_found', message: error.message };\n }\n if (error instanceof InvalidInputError) {\n return { status: 400, error: 'invalid_input', message: error.message };\n }\n if (error instanceof ValidationError) {\n return {\n status: 422,\n error: 'unprocessable',\n message: error.message,\n issues: error.problems.map((problem) => ({\n path: problem.path ? `body.${problem.path}` : 'body',\n message: problem.message,\n })),\n };\n }\n if (error instanceof ConflictError || error instanceof OrganizationRuleError) {\n return { status: 409, error: 'conflict', message: error.message };\n }\n if (error instanceof ForbiddenError || error instanceof NoActiveOrgError) {\n return { status: 403, error: 'forbidden', message: error.message };\n }\n if (error instanceof UnavailableError) {\n return {\n status: 503,\n error: 'unavailable',\n message: error.message,\n ...(error.details && { details: error.details }),\n };\n }\n if (error instanceof UnauthorizedError) {\n return { status: 401, error: 'unauthorized', message: error.message };\n }\n if (error instanceof InviteError) {\n return { status: 400, error: 'invalid_invite', message: error.message };\n }\n if (error instanceof AlreadyConnectedError) {\n return { status: 409, error: 'already_connected', message: error.message };\n }\n if (error instanceof UnknownIntegrationError) {\n return { status: 400, error: 'unknown_integration', message: error.message };\n }\n if (error instanceof InvalidConfigError) {\n return { status: 400, error: 'invalid_config', message: error.message };\n }\n if (error instanceof InvalidTriggerError) {\n return { status: 400, error: 'invalid_trigger', message: error.message };\n }\n if (error instanceof ReservedActionError) {\n return { status: 400, error: 'reserved_action', message: error.message };\n }\n if (error instanceof ActionInvocationError) {\n return {\n status: 502,\n error: 'action_failed',\n message: 'The integration did not respond as expected',\n };\n }\n if (error instanceof AutomationKindMismatchError) {\n return { status: 409, error: 'kind_mismatch', message: error.message };\n }\n if (hasZodFastifySchemaValidationErrors(error)) {\n const context = (error as { validationContext?: string }).validationContext ?? 'request';\n const issues = error.validation.map((issue) => ({\n path: issuePath(context, issue.instancePath),\n message: describeIssue(\n issue.keyword,\n (issue.params ?? {}) as IssueParams,\n issue.message ?? 'is invalid',\n ),\n }));\n const first = issues[0];\n return {\n status: 400,\n error: 'validation',\n message: first ? `${first.path}: ${first.message}` : 'The request is invalid',\n issues,\n };\n }\n const err = error as { statusCode?: number; code?: string };\n if (err.code && BODY_ERRORS[err.code]) {\n return BODY_ERRORS[err.code];\n }\n if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 500) {\n return {\n status: err.statusCode,\n error: 'bad_request',\n message: 'The request could not be processed',\n };\n }\n return undefined;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"errors.js","names":[],"sources":["../../src/http/errors.ts"],"sourcesContent":["import {\n ConflictError,\n ForbiddenError,\n InvalidInputError,\n NotFoundError,\n UnavailableError,\n ValidationError,\n} from '@headless-lms/core/shared/errors';\nimport { InviteError, OrganizationRuleError } from '@headless-lms/core/organizations';\nimport {\n ActionInvocationError,\n AlreadyConnectedError,\n InvalidConfigError,\n UnknownIntegrationError,\n} from '@headless-lms/core/integrations';\nimport {\n AutomationKindMismatchError,\n InvalidTriggerError,\n ReservedActionError,\n} from '@headless-lms/core/automations';\nimport { hasZodFastifySchemaValidationErrors } from 'fastify-type-provider-zod';\nimport { NoActiveOrgError } from './scope.js';\nimport { UnauthorizedError } from './plugins/auth.js';\n\nexport type Refusal = {\n status: number;\n error: string;\n message: string;\n issues?: { path: string; message: string }[];\n details?: Record<string, string>;\n};\n\nconst BODY_ERRORS: Record<string, Refusal> = {\n FST_ERR_CTP_INVALID_JSON_BODY: {\n status: 400,\n error: 'invalid_body',\n message: 'The request body must be valid JSON',\n },\n FST_ERR_CTP_EMPTY_JSON_BODY: {\n status: 400,\n error: 'invalid_body',\n message: 'The request body must be valid JSON',\n },\n FST_ERR_CTP_INVALID_MEDIA_TYPE: {\n status: 415,\n error: 'unsupported_media_type',\n message: 'Send the request body as application/json',\n },\n FST_ERR_CTP_BODY_TOO_LARGE: {\n status: 413,\n error: 'payload_too_large',\n message: 'The request body is too large',\n },\n FST_ERR_NOT_FOUND: { status: 404, error: 'not_found', message: 'No such route' },\n};\n\nfunction issuePath(context: string, instancePath: string): string {\n const inner = instancePath\n .split('/')\n .filter((part) => part.length > 0)\n .map((part) => (/^\\d+$/.test(part) ? `[${part}]` : `.${part}`))\n .join('');\n return `${context}${inner}`;\n}\n\ntype IssueParams = {\n expected?: string;\n received?: string;\n minimum?: number | bigint;\n maximum?: number | bigint;\n inclusive?: boolean;\n origin?: string;\n keys?: string[];\n values?: unknown[];\n format?: string;\n};\n\nfunction describeIssue(keyword: string | undefined, params: IssueParams, fallback: string): string {\n switch (keyword) {\n case 'invalid_type':\n if (\n params.received === 'undefined' ||\n params.received === 'null' ||\n /received (undefined|null)$/.test(fallback)\n ) {\n return 'is required';\n }\n return params.expected ? `must be ${withArticle(params.expected)}` : fallback;\n case 'too_small':\n return params.origin === 'string'\n ? Number(params.minimum) === 1\n ? 'must not be empty'\n : `must be at least ${params.minimum} characters`\n : params.origin === 'array'\n ? `must have at least ${params.minimum} ${Number(params.minimum) === 1 ? 'item' : 'items'}`\n : `must be ${params.inclusive === false ? 'greater than' : 'at least'} ${params.minimum}`;\n case 'too_big':\n return params.origin === 'string'\n ? `must be at most ${params.maximum} characters`\n : params.origin === 'array'\n ? `must have at most ${params.maximum} items`\n : `must be ${params.inclusive === false ? 'less than' : 'at most'} ${params.maximum}`;\n case 'unrecognized_keys':\n return `has ${params.keys && params.keys.length > 1 ? 'unknown fields' : 'an unknown field'} ${(params.keys ?? []).map((key) => `\"${key}\"`).join(', ')}`;\n case 'invalid_value':\n return `must be one of ${(params.values ?? []).map((value) => JSON.stringify(value)).join(', ')}`;\n case 'invalid_format':\n return params.format && params.format !== 'regex'\n ? `must be a valid ${params.format.replace(/_/g, ' ')}`\n : fallback;\n case 'invalid_union':\n return 'is not in an accepted shape';\n default:\n return fallback;\n }\n}\n\nfunction withArticle(noun: string): string {\n return /^[aeiou]/i.test(noun) ? `an ${noun}` : `a ${noun}`;\n}\n\nexport function classifyError(error: unknown): Refusal | undefined {\n if (error instanceof NotFoundError) {\n return { status: 404, error: 'not_found', message: error.message };\n }\n if (error instanceof InvalidInputError) {\n return { status: 400, error: 'invalid_input', message: error.message };\n }\n if (error instanceof ValidationError) {\n return {\n status: 422,\n error: 'unprocessable',\n message: error.message,\n issues: error.problems.map((problem) => ({\n path: problem.path ? `body.${problem.path}` : 'body',\n message: problem.message,\n })),\n };\n }\n if (error instanceof ConflictError) {\n return { status: 409, error: error.code ?? 'conflict', message: error.message };\n }\n if (error instanceof OrganizationRuleError) {\n return { status: 409, error: 'conflict', message: error.message };\n }\n if (error instanceof ForbiddenError || error instanceof NoActiveOrgError) {\n return { status: 403, error: 'forbidden', message: error.message };\n }\n if (error instanceof UnavailableError) {\n return {\n status: 503,\n error: 'unavailable',\n message: error.message,\n ...(error.details && { details: error.details }),\n };\n }\n if (error instanceof UnauthorizedError) {\n return { status: 401, error: 'unauthorized', message: error.message };\n }\n if (error instanceof InviteError) {\n return { status: 400, error: 'invalid_invite', message: error.message };\n }\n if (error instanceof AlreadyConnectedError) {\n return { status: 409, error: 'already_connected', message: error.message };\n }\n if (error instanceof UnknownIntegrationError) {\n return { status: 400, error: 'unknown_integration', message: error.message };\n }\n if (error instanceof InvalidConfigError) {\n return { status: 400, error: 'invalid_config', message: error.message };\n }\n if (error instanceof InvalidTriggerError) {\n return { status: 400, error: 'invalid_trigger', message: error.message };\n }\n if (error instanceof ReservedActionError) {\n return { status: 400, error: 'reserved_action', message: error.message };\n }\n if (error instanceof ActionInvocationError) {\n return {\n status: 502,\n error: 'action_failed',\n message: 'The integration did not respond as expected',\n };\n }\n if (error instanceof AutomationKindMismatchError) {\n return { status: 409, error: 'kind_mismatch', message: error.message };\n }\n if (hasZodFastifySchemaValidationErrors(error)) {\n const context = (error as { validationContext?: string }).validationContext ?? 'request';\n const issues = error.validation.map((issue) => ({\n path: issuePath(context, issue.instancePath),\n message: describeIssue(\n issue.keyword,\n (issue.params ?? {}) as IssueParams,\n issue.message ?? 'is invalid',\n ),\n }));\n const first = issues[0];\n return {\n status: 400,\n error: 'validation',\n message: first ? `${first.path}: ${first.message}` : 'The request is invalid',\n issues,\n };\n }\n const err = error as { statusCode?: number; code?: string };\n if (err.code && BODY_ERRORS[err.code]) {\n return BODY_ERRORS[err.code];\n }\n if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 500) {\n return {\n status: err.statusCode,\n error: 'bad_request',\n message: 'The request could not be processed',\n };\n }\n return undefined;\n}\n"],"mappings":"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"}
|
package/dist/http/fastify.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ResolvedCaller } from "@headless-lms/core/organizations";
|
|
1
2
|
import "fastify";
|
|
2
3
|
import { User } from "@headless-lms/core/types";
|
|
3
4
|
//#region src/http/fastify.d.ts
|
|
@@ -5,6 +6,10 @@ import { User } from "@headless-lms/core/types";
|
|
|
5
6
|
* package entry point so this module — and its augmentation — gets loaded. */
|
|
6
7
|
type AuthUser = User;
|
|
7
8
|
declare module 'fastify' {
|
|
9
|
+
interface FastifyContextConfig {
|
|
10
|
+
/** A server-sent event stream; shutdown waits for it to finish. */
|
|
11
|
+
eventStream?: boolean;
|
|
12
|
+
}
|
|
8
13
|
interface FastifyInstance {
|
|
9
14
|
/** Session guard. Populates `authUser` (and `orgId` when the session has an
|
|
10
15
|
* active org), or throws `UnauthorizedError`. Use for the routes reached
|
|
@@ -12,9 +17,11 @@ declare module 'fastify' {
|
|
|
12
17
|
requireSession(request: FastifyRequest): Promise<void>;
|
|
13
18
|
/** As `requireSession`, but also requires an active org on the session. */
|
|
14
19
|
requireOrgSession(request: FastifyRequest): Promise<void>;
|
|
20
|
+
requirePlatform(request: FastifyRequest): Promise<void>;
|
|
15
21
|
}
|
|
16
22
|
interface FastifyRequest {
|
|
17
23
|
authUser: User;
|
|
24
|
+
authUserId: string;
|
|
18
25
|
/** Domain `organizations.id` for the session's active org — what every
|
|
19
26
|
* domain read keys off. Guaranteed set by `requireOrgSession`; absent under
|
|
20
27
|
* `requireSession` when the session has no active org. */
|
|
@@ -28,6 +35,7 @@ declare module 'fastify' {
|
|
|
28
35
|
* and every other domain row references. Not `authUser.id`: the two match
|
|
29
36
|
* only for a self-signup, never for a person an admin provisioned first. */
|
|
30
37
|
userId: string;
|
|
38
|
+
resolvedCaller?: ResolvedCaller;
|
|
31
39
|
}
|
|
32
40
|
}
|
|
33
41
|
//#endregion
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ToolKind } from "./tool.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Level, Permission } from "@headless-lms/core/types";
|
|
3
3
|
//#region src/http/mcp/catalogue.d.ts
|
|
4
4
|
interface CatalogueField {
|
|
5
5
|
name: string;
|
|
@@ -11,27 +11,29 @@ interface CatalogueTool {
|
|
|
11
11
|
name: string;
|
|
12
12
|
title: string;
|
|
13
13
|
description: string;
|
|
14
|
-
|
|
15
|
-
scope: AccessScope;
|
|
14
|
+
group: string;
|
|
16
15
|
kind: ToolKind;
|
|
17
16
|
idempotent: boolean;
|
|
18
17
|
openWorld: boolean;
|
|
19
18
|
fields: CatalogueField[];
|
|
20
19
|
}
|
|
21
|
-
interface
|
|
22
|
-
|
|
20
|
+
interface CataloguePermission {
|
|
21
|
+
permission: Permission;
|
|
23
22
|
resource: string;
|
|
24
23
|
action: string;
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
label: string;
|
|
25
|
+
description: string;
|
|
26
|
+
about: Level;
|
|
27
|
+
implies: Permission[];
|
|
28
|
+
roles: string[];
|
|
27
29
|
removes: boolean;
|
|
28
30
|
}
|
|
29
31
|
interface McpCatalogue {
|
|
30
32
|
instructions: string;
|
|
31
|
-
|
|
33
|
+
permissions: CataloguePermission[];
|
|
32
34
|
tools: CatalogueTool[];
|
|
33
35
|
}
|
|
34
36
|
declare function mcpCatalogue(): McpCatalogue;
|
|
35
37
|
//#endregion
|
|
36
|
-
export { CatalogueField,
|
|
38
|
+
export { CatalogueField, CataloguePermission, CatalogueTool, McpCatalogue, mcpCatalogue };
|
|
37
39
|
//# sourceMappingURL=catalogue.d.ts.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { toolInputSchema } from "./input-schema.js";
|
|
2
2
|
import { MCP_INSTRUCTIONS } from "./server.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { TOOL_GROUPS } from "./tools/index.js";
|
|
4
|
+
import { ALL_PERMISSIONS, PERMISSIONS, defaultHolders, withImplied } from "@headless-lms/core/organizations";
|
|
5
5
|
//#region src/http/mcp/catalogue.ts
|
|
6
6
|
function typeOf(schema) {
|
|
7
7
|
const variants = schema.anyOf ?? schema.oneOf;
|
|
@@ -27,40 +27,34 @@ function fieldsOf(tool) {
|
|
|
27
27
|
}));
|
|
28
28
|
return [...fields.filter((field) => field.required), ...fields.filter((field) => !field.required)];
|
|
29
29
|
}
|
|
30
|
-
const REMOVING_WRITE_SCOPES = [
|
|
31
|
-
"downloads:write",
|
|
32
|
-
"bundles:write",
|
|
33
|
-
"access:write",
|
|
34
|
-
"comments:moderate",
|
|
35
|
-
"communities:write",
|
|
36
|
-
"community_members:write",
|
|
37
|
-
"community_moderation:write"
|
|
38
|
-
];
|
|
39
30
|
function mcpCatalogue() {
|
|
40
31
|
return {
|
|
41
32
|
instructions: MCP_INSTRUCTIONS,
|
|
42
|
-
|
|
43
|
-
const [resource =
|
|
33
|
+
permissions: ALL_PERMISSIONS.map((permission) => {
|
|
34
|
+
const [resource = permission, action = ""] = permission.split(":");
|
|
35
|
+
const definition = PERMISSIONS[permission];
|
|
44
36
|
return {
|
|
45
|
-
|
|
37
|
+
permission,
|
|
46
38
|
resource,
|
|
47
39
|
action,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
40
|
+
label: definition.label,
|
|
41
|
+
description: definition.description,
|
|
42
|
+
about: definition.about,
|
|
43
|
+
implies: [...withImplied([permission])].filter((p) => p !== permission),
|
|
44
|
+
roles: defaultHolders(permission),
|
|
45
|
+
removes: action === "delete"
|
|
51
46
|
};
|
|
52
47
|
}),
|
|
53
|
-
tools:
|
|
48
|
+
tools: TOOL_GROUPS.flatMap(({ title: group, tools }) => tools.map((tool) => ({
|
|
54
49
|
name: tool.name,
|
|
55
50
|
title: tool.title,
|
|
56
51
|
description: tool.description,
|
|
57
|
-
|
|
58
|
-
scope: tool.scope,
|
|
52
|
+
group,
|
|
59
53
|
kind: tool.kind,
|
|
60
54
|
idempotent: tool.idempotent,
|
|
61
55
|
openWorld: tool.openWorld,
|
|
62
56
|
fields: fieldsOf(tool)
|
|
63
|
-
}))
|
|
57
|
+
})))
|
|
64
58
|
};
|
|
65
59
|
}
|
|
66
60
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"catalogue.js","names":[],"sources":["../../../src/http/mcp/catalogue.ts"],"sourcesContent":["import {\n
|
|
1
|
+
{"version":3,"file":"catalogue.js","names":[],"sources":["../../../src/http/mcp/catalogue.ts"],"sourcesContent":["import {\n ALL_PERMISSIONS,\n defaultHolders,\n PERMISSIONS,\n withImplied,\n} from '@headless-lms/core/organizations';\nimport type { Level, Permission } from '@headless-lms/core/types';\nimport { toolInputSchema } from './input-schema.js';\nimport { MCP_INSTRUCTIONS } from './server.js';\nimport type { Tool, ToolKind } from './tool.js';\nimport { TOOL_GROUPS } from './tools/index.js';\n\nexport interface CatalogueField {\n name: string;\n type: string;\n required: boolean;\n description: string | null;\n}\n\nexport interface CatalogueTool {\n name: string;\n title: string;\n description: string;\n group: string;\n kind: ToolKind;\n idempotent: boolean;\n openWorld: boolean;\n fields: CatalogueField[];\n}\n\nexport interface CataloguePermission {\n permission: Permission;\n resource: string;\n action: string;\n label: string;\n description: string;\n about: Level;\n implies: Permission[];\n roles: string[];\n removes: boolean;\n}\n\nexport interface McpCatalogue {\n instructions: string;\n permissions: CataloguePermission[];\n tools: CatalogueTool[];\n}\n\ntype JsonSchema = {\n type?: string | string[];\n format?: string;\n enum?: unknown[];\n const?: unknown;\n items?: JsonSchema;\n anyOf?: JsonSchema[];\n oneOf?: JsonSchema[];\n description?: string;\n properties?: Record<string, JsonSchema>;\n required?: string[];\n};\n\nfunction typeOf(schema: JsonSchema): string {\n const variants = schema.anyOf ?? schema.oneOf;\n if (variants) {\n return [...new Set(variants.map(typeOf))].join(' | ');\n }\n if (schema.enum) {\n return schema.enum.map((value) => JSON.stringify(value)).join(' | ');\n }\n if (schema.const !== undefined) {\n return JSON.stringify(schema.const);\n }\n if (schema.type === 'array') {\n return `${schema.items ? typeOf(schema.items) : 'unknown'}[]`;\n }\n if (schema.format === 'date-time') {\n return 'date-time';\n }\n if (Array.isArray(schema.type)) {\n return schema.type.join(' | ');\n }\n return schema.type ?? 'unknown';\n}\n\nfunction descriptionOf(schema: JsonSchema): string | null {\n return (\n schema.description ??\n (schema.anyOf ?? schema.oneOf)?.find((variant) => variant.description)?.description ??\n null\n );\n}\n\nfunction fieldsOf(tool: Tool): CatalogueField[] {\n const json = toolInputSchema(tool.input)['~standard'].jsonSchema.input({\n target: 'draft-2020-12',\n }) as JsonSchema;\n const required = new Set(json.required ?? []);\n const fields = Object.entries(json.properties ?? {}).map(([name, schema]) => ({\n name,\n type: typeOf(schema),\n required: required.has(name),\n description: descriptionOf(schema),\n }));\n return [\n ...fields.filter((field) => field.required),\n ...fields.filter((field) => !field.required),\n ];\n}\n\nexport function mcpCatalogue(): McpCatalogue {\n return {\n instructions: MCP_INSTRUCTIONS,\n permissions: ALL_PERMISSIONS.map((permission) => {\n const [resource = permission, action = ''] = permission.split(':');\n const definition = PERMISSIONS[permission];\n return {\n permission,\n resource,\n action,\n label: definition.label,\n description: definition.description,\n about: definition.about,\n implies: [...withImplied([permission])].filter((p) => p !== permission),\n roles: defaultHolders(permission),\n removes: action === 'delete',\n };\n }),\n tools: TOOL_GROUPS.flatMap(({ title: group, tools }) =>\n tools.map((tool) => ({\n name: tool.name,\n title: tool.title,\n description: tool.description,\n group,\n kind: tool.kind,\n idempotent: tool.idempotent,\n openWorld: tool.openWorld,\n fields: fieldsOf(tool),\n })),\n ),\n };\n}\n"],"mappings":";;;;;AA6DA,SAAS,OAAO,QAA4B;CAC1C,MAAM,WAAW,OAAO,SAAS,OAAO;CACxC,IAAI,UACF,OAAO,CAAC,GAAG,IAAI,IAAI,SAAS,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,KAAK;CAEtD,IAAI,OAAO,MACT,OAAO,OAAO,KAAK,KAAK,UAAU,KAAK,UAAU,KAAK,CAAC,CAAC,CAAC,KAAK,KAAK;CAErE,IAAI,OAAO,UAAU,KAAA,GACnB,OAAO,KAAK,UAAU,OAAO,KAAK;CAEpC,IAAI,OAAO,SAAS,SAClB,OAAO,GAAG,OAAO,QAAQ,OAAO,OAAO,KAAK,IAAI,UAAU;CAE5D,IAAI,OAAO,WAAW,aACpB,OAAO;CAET,IAAI,MAAM,QAAQ,OAAO,IAAI,GAC3B,OAAO,OAAO,KAAK,KAAK,KAAK;CAE/B,OAAO,OAAO,QAAQ;AACxB;AAEA,SAAS,cAAc,QAAmC;CACxD,OACE,OAAO,gBACN,OAAO,SAAS,OAAO,MAAA,EAAQ,MAAM,YAAY,QAAQ,WAAW,CAAC,EAAE,eACxE;AAEJ;AAEA,SAAS,SAAS,MAA8B;CAC9C,MAAM,OAAO,gBAAgB,KAAK,KAAK,CAAC,CAAC,YAAY,CAAC,WAAW,MAAM,EACrE,QAAQ,gBACV,CAAC;CACD,MAAM,WAAW,IAAI,IAAI,KAAK,YAAY,CAAC,CAAC;CAC5C,MAAM,SAAS,OAAO,QAAQ,KAAK,cAAc,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,aAAa;EAC5E;EACA,MAAM,OAAO,MAAM;EACnB,UAAU,SAAS,IAAI,IAAI;EAC3B,aAAa,cAAc,MAAM;CACnC,EAAE;CACF,OAAO,CACL,GAAG,OAAO,QAAQ,UAAU,MAAM,QAAQ,GAC1C,GAAG,OAAO,QAAQ,UAAU,CAAC,MAAM,QAAQ,CAC7C;AACF;AAEA,SAAgB,eAA6B;CAC3C,OAAO;EACL,cAAc;EACd,aAAa,gBAAgB,KAAK,eAAe;GAC/C,MAAM,CAAC,WAAW,YAAY,SAAS,MAAM,WAAW,MAAM,GAAG;GACjE,MAAM,aAAa,YAAY;GAC/B,OAAO;IACL;IACA;IACA;IACA,OAAO,WAAW;IAClB,aAAa,WAAW;IACxB,OAAO,WAAW;IAClB,SAAS,CAAC,GAAG,YAAY,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,QAAQ,MAAM,MAAM,UAAU;IACtE,OAAO,eAAe,UAAU;IAChC,SAAS,WAAW;GACtB;EACF,CAAC;EACD,OAAO,YAAY,SAAS,EAAE,OAAO,OAAO,YAC1C,MAAM,KAAK,UAAU;GACnB,MAAM,KAAK;GACX,OAAO,KAAK;GACZ,aAAa,KAAK;GAClB;GACA,MAAM,KAAK;GACX,YAAY,KAAK;GACjB,WAAW,KAAK;GAChB,QAAQ,SAAS,IAAI;EACvB,EAAE,CACJ;CACF;AACF"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ALL_PERMISSIONS } from "@headless-lms/core/organizations";
|
|
2
2
|
//#region src/http/mcp/challenge.ts
|
|
3
3
|
function unauthorized(mcp) {
|
|
4
4
|
return new Response(JSON.stringify({
|
|
@@ -12,7 +12,7 @@ function unauthorized(mcp) {
|
|
|
12
12
|
status: 401,
|
|
13
13
|
headers: {
|
|
14
14
|
"content-type": "application/json",
|
|
15
|
-
"www-authenticate": `Bearer resource_metadata="${mcp.resourceMetadataUrl}", scope="${
|
|
15
|
+
"www-authenticate": `Bearer resource_metadata="${mcp.resourceMetadataUrl}", scope="${ALL_PERMISSIONS.join(" ")}"`
|
|
16
16
|
}
|
|
17
17
|
});
|
|
18
18
|
}
|
|
@@ -21,7 +21,7 @@ function forbidden() {
|
|
|
21
21
|
jsonrpc: "2.0",
|
|
22
22
|
error: {
|
|
23
23
|
code: -32e3,
|
|
24
|
-
message: "Only
|
|
24
|
+
message: "Only active members of this organization can use this server"
|
|
25
25
|
},
|
|
26
26
|
id: null
|
|
27
27
|
}), {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"challenge.js","names":[],"sources":["../../../src/http/mcp/challenge.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"file":"challenge.js","names":[],"sources":["../../../src/http/mcp/challenge.ts"],"sourcesContent":["import { ALL_PERMISSIONS } from '@headless-lms/core/organizations';\nimport type { McpEndpoints } from '../../app/mcp-endpoints.js';\n\nexport function unauthorized(mcp: McpEndpoints): Response {\n const body = { jsonrpc: '2.0', error: { code: -32000, message: 'Unauthorized' }, id: null };\n return new Response(JSON.stringify(body), {\n status: 401,\n headers: {\n 'content-type': 'application/json',\n 'www-authenticate': `Bearer resource_metadata=\"${mcp.resourceMetadataUrl}\", scope=\"${ALL_PERMISSIONS.join(' ')}\"`,\n },\n });\n}\n\nexport function forbidden(): Response {\n const body = {\n jsonrpc: '2.0',\n error: {\n code: -32000,\n message: 'Only active members of this organization can use this server',\n },\n id: null,\n };\n return new Response(JSON.stringify(body), {\n status: 403,\n headers: { 'content-type': 'application/json' },\n });\n}\n"],"mappings":";;AAGA,SAAgB,aAAa,KAA6B;CAExD,OAAO,IAAI,SAAS,KAAK,UAAU;EADpB,SAAS;EAAO,OAAO;GAAE,MAAM;GAAQ,SAAS;EAAe;EAAG,IAAI;CAC/C,CAAC,GAAG;EACxC,QAAQ;EACR,SAAS;GACP,gBAAgB;GAChB,oBAAoB,6BAA6B,IAAI,oBAAoB,YAAY,gBAAgB,KAAK,GAAG,EAAE;EACjH;CACF,CAAC;AACH;AAEA,SAAgB,YAAsB;CASpC,OAAO,IAAI,SAAS,KAAK,UAAU;EAPjC,SAAS;EACT,OAAO;GACL,MAAM;GACN,SAAS;EACX;EACA,IAAI;CAEgC,CAAC,GAAG;EACxC,QAAQ;EACR,SAAS,EAAE,gBAAgB,mBAAmB;CAChD,CAAC;AACH"}
|
package/dist/http/mcp/route.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { NoActiveOrgError,
|
|
1
|
+
import { NoActiveOrgError, resolveCallerFor } from "../scope.js";
|
|
2
2
|
import { forbidden, unauthorized } from "./challenge.js";
|
|
3
3
|
import { createToolServer } from "./server.js";
|
|
4
4
|
import { TOOLS } from "./tools/index.js";
|
|
5
|
-
import {
|
|
5
|
+
import { isPermission } from "@headless-lms/core/organizations";
|
|
6
6
|
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
7
7
|
import { toWebRequest } from "@modelcontextprotocol/node";
|
|
8
8
|
//#region src/http/mcp/route.ts
|
|
@@ -29,9 +29,9 @@ function registerMcp(app, container, tools = TOOLS) {
|
|
|
29
29
|
headers: request.headers
|
|
30
30
|
});
|
|
31
31
|
if (!grant) return reply.send(unauthorized(container.mcp));
|
|
32
|
-
let
|
|
32
|
+
let caller;
|
|
33
33
|
try {
|
|
34
|
-
|
|
34
|
+
caller = await resolveCallerFor(container, grant.userId, grant.orgId, "roleKey" in grant.limit ? grant.limit : { scopes: grant.limit.scopes.filter(isPermission) });
|
|
35
35
|
} catch (error) {
|
|
36
36
|
if (error instanceof NoActiveOrgError) return reply.send(forbidden());
|
|
37
37
|
throw error;
|
|
@@ -39,16 +39,16 @@ function registerMcp(app, container, tools = TOOLS) {
|
|
|
39
39
|
const connection = {
|
|
40
40
|
context: {
|
|
41
41
|
container,
|
|
42
|
-
|
|
42
|
+
caller,
|
|
43
43
|
requestId: request.id
|
|
44
44
|
},
|
|
45
|
-
tools
|
|
45
|
+
tools
|
|
46
46
|
};
|
|
47
47
|
const response = await handler.fetch(await webRequest(request, reply), {
|
|
48
48
|
authInfo: {
|
|
49
49
|
token: "",
|
|
50
50
|
clientId: grant.clientId,
|
|
51
|
-
scopes: grant.scopes,
|
|
51
|
+
scopes: "scopes" in grant.limit ? grant.limit.scopes : [],
|
|
52
52
|
extra: { connection }
|
|
53
53
|
},
|
|
54
54
|
parsedBody: request.body
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route.js","names":[],"sources":["../../../src/http/mcp/route.ts"],"sourcesContent":["import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';\nimport { createMcpHandler } from '@modelcontextprotocol/server';\nimport { toWebRequest } from '@modelcontextprotocol/node';\nimport {
|
|
1
|
+
{"version":3,"file":"route.js","names":[],"sources":["../../../src/http/mcp/route.ts"],"sourcesContent":["import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';\nimport { createMcpHandler } from '@modelcontextprotocol/server';\nimport { toWebRequest } from '@modelcontextprotocol/node';\nimport { isPermission } from '@headless-lms/core/organizations';\nimport type { Container } from '../../app/container.js';\nimport { NoActiveOrgError, resolveCallerFor, type Caller } from '../scope.js';\nimport { forbidden, unauthorized } from './challenge.js';\nimport { createToolServer } from './server.js';\nimport type { Tool, ToolContext } from './tool.js';\nimport { TOOLS } from './tools/index.js';\n\ntype Connection = { context: ToolContext; tools: readonly Tool[] };\n\nexport function registerMcp(\n app: FastifyInstance,\n container: Container,\n tools: readonly Tool[] = TOOLS,\n): void {\n const handler = createMcpHandler(\n (ctx) => {\n const connection = ctx.authInfo?.extra?.connection as Connection | undefined;\n if (!connection) {\n throw new Error('mcp connection missing');\n }\n return createToolServer(connection.context, connection.tools);\n },\n {\n legacy: 'stateless',\n onerror: (err) => container.logger.error('mcp handler error', { err }),\n },\n );\n\n const webRequest = async (request: FastifyRequest, reply: FastifyReply) => {\n const abort = new AbortController();\n reply.raw.on('close', () => {\n if (!reply.raw.writableFinished) {\n abort.abort();\n }\n });\n return toWebRequest(request.raw, request.body, { signal: abort.signal });\n };\n\n app.post('/mcp', { schema: { hide: true } }, async (request, reply) => {\n const grant = await container.auth.verifyAccess({\n method: request.method,\n url: container.mcp.resource,\n headers: request.headers,\n });\n if (!grant) {\n return reply.send(unauthorized(container.mcp));\n }\n let caller: Caller;\n try {\n caller = await resolveCallerFor(\n container,\n grant.userId,\n grant.orgId,\n 'roleKey' in grant.limit\n ? grant.limit\n : { scopes: grant.limit.scopes.filter(isPermission) },\n );\n } catch (error) {\n if (error instanceof NoActiveOrgError) {\n return reply.send(forbidden());\n }\n throw error;\n }\n const connection: Connection = {\n context: { container, caller, requestId: request.id },\n tools,\n };\n const response = await handler.fetch(await webRequest(request, reply), {\n authInfo: {\n token: '',\n clientId: grant.clientId,\n scopes: 'scopes' in grant.limit ? grant.limit.scopes : [],\n extra: { connection },\n },\n parsedBody: request.body,\n });\n return reply.send(response);\n });\n\n app.route({\n method: ['GET', 'DELETE'],\n url: '/mcp',\n schema: { hide: true },\n handler: async (_request, reply) =>\n reply\n .code(405)\n .header('allow', 'POST')\n .header('content-type', 'application/json')\n .send({\n jsonrpc: '2.0',\n error: { code: -32000, message: 'Method not allowed.' },\n id: null,\n }),\n });\n}\n"],"mappings":";;;;;;;;AAaA,SAAgB,YACd,KACA,WACA,QAAyB,OACnB;CACN,MAAM,UAAU,kBACb,QAAQ;EACP,MAAM,aAAa,IAAI,UAAU,OAAO;EACxC,IAAI,CAAC,YACH,MAAM,IAAI,MAAM,wBAAwB;EAE1C,OAAO,iBAAiB,WAAW,SAAS,WAAW,KAAK;CAC9D,GACA;EACE,QAAQ;EACR,UAAU,QAAQ,UAAU,OAAO,MAAM,qBAAqB,EAAE,IAAI,CAAC;CACvE,CACF;CAEA,MAAM,aAAa,OAAO,SAAyB,UAAwB;EACzE,MAAM,QAAQ,IAAI,gBAAgB;EAClC,MAAM,IAAI,GAAG,eAAe;GAC1B,IAAI,CAAC,MAAM,IAAI,kBACb,MAAM,MAAM;EAEhB,CAAC;EACD,OAAO,aAAa,QAAQ,KAAK,QAAQ,MAAM,EAAE,QAAQ,MAAM,OAAO,CAAC;CACzE;CAEA,IAAI,KAAK,QAAQ,EAAE,QAAQ,EAAE,MAAM,KAAK,EAAE,GAAG,OAAO,SAAS,UAAU;EACrE,MAAM,QAAQ,MAAM,UAAU,KAAK,aAAa;GAC9C,QAAQ,QAAQ;GAChB,KAAK,UAAU,IAAI;GACnB,SAAS,QAAQ;EACnB,CAAC;EACD,IAAI,CAAC,OACH,OAAO,MAAM,KAAK,aAAa,UAAU,GAAG,CAAC;EAE/C,IAAI;EACJ,IAAI;GACF,SAAS,MAAM,iBACb,WACA,MAAM,QACN,MAAM,OACN,aAAa,MAAM,QACf,MAAM,QACN,EAAE,QAAQ,MAAM,MAAM,OAAO,OAAO,YAAY,EAAE,CACxD;EACF,SAAS,OAAO;GACd,IAAI,iBAAiB,kBACnB,OAAO,MAAM,KAAK,UAAU,CAAC;GAE/B,MAAM;EACR;EACA,MAAM,aAAyB;GAC7B,SAAS;IAAE;IAAW;IAAQ,WAAW,QAAQ;GAAG;GACpD;EACF;EACA,MAAM,WAAW,MAAM,QAAQ,MAAM,MAAM,WAAW,SAAS,KAAK,GAAG;GACrE,UAAU;IACR,OAAO;IACP,UAAU,MAAM;IAChB,QAAQ,YAAY,MAAM,QAAQ,MAAM,MAAM,SAAS,CAAC;IACxD,OAAO,EAAE,WAAW;GACtB;GACA,YAAY,QAAQ;EACtB,CAAC;EACD,OAAO,MAAM,KAAK,QAAQ;CAC5B,CAAC;CAED,IAAI,MAAM;EACR,QAAQ,CAAC,OAAO,QAAQ;EACxB,KAAK;EACL,QAAQ,EAAE,MAAM,KAAK;EACrB,SAAS,OAAO,UAAU,UACxB,MACG,KAAK,GAAG,CAAC,CACT,OAAO,SAAS,MAAM,CAAC,CACvB,OAAO,gBAAgB,kBAAkB,CAAC,CAC1C,KAAK;GACJ,SAAS;GACT,OAAO;IAAE,MAAM;IAAQ,SAAS;GAAsB;GACtD,IAAI;EACN,CAAC;CACP,CAAC;AACH"}
|