@headless-lms/server 0.1.0 → 0.2.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.
Files changed (101) hide show
  1. package/README.md +1 -1
  2. package/dist/app/container.d.ts +7 -3
  3. package/dist/app/container.js +57 -26
  4. package/dist/app/container.js.map +1 -1
  5. package/dist/app/notifications.js +4 -1
  6. package/dist/app/notifications.js.map +1 -1
  7. package/dist/http/plugins/error-handler.d.ts +1 -1
  8. package/dist/http/plugins/error-handler.js +15 -4
  9. package/dist/http/plugins/error-handler.js.map +1 -1
  10. package/dist/http/plugins/openapi.js +25 -22
  11. package/dist/http/plugins/openapi.js.map +1 -1
  12. package/dist/http/routes/activities.js +34 -2
  13. package/dist/http/routes/activities.js.map +1 -1
  14. package/dist/http/routes/assets.js +4 -0
  15. package/dist/http/routes/assets.js.map +1 -1
  16. package/dist/http/routes/automations.js +32 -8
  17. package/dist/http/routes/automations.js.map +1 -1
  18. package/dist/http/routes/bundles.d.ts +7 -0
  19. package/dist/http/routes/bundles.js +170 -0
  20. package/dist/http/routes/bundles.js.map +1 -0
  21. package/dist/http/routes/course-analytics.d.ts +7 -0
  22. package/dist/http/routes/course-analytics.js +58 -0
  23. package/dist/http/routes/course-analytics.js.map +1 -0
  24. package/dist/http/routes/courses.js +8 -8
  25. package/dist/http/routes/courses.js.map +1 -1
  26. package/dist/http/routes/dashboard.js +2 -2
  27. package/dist/http/routes/dashboard.js.map +1 -1
  28. package/dist/http/routes/discussion.js +1 -1
  29. package/dist/http/routes/discussion.js.map +1 -1
  30. package/dist/http/routes/downloads.js +10 -10
  31. package/dist/http/routes/downloads.js.map +1 -1
  32. package/dist/http/routes/integrations.js.map +1 -1
  33. package/dist/http/routes/learn/assets.js +1 -1
  34. package/dist/http/routes/learn/assets.js.map +1 -1
  35. package/dist/http/routes/learn/comments.js +7 -7
  36. package/dist/http/routes/learn/comments.js.map +1 -1
  37. package/dist/http/routes/learn/courses.js +26 -3
  38. package/dist/http/routes/learn/courses.js.map +1 -1
  39. package/dist/http/routes/learn/downloads.js +3 -3
  40. package/dist/http/routes/learn/downloads.js.map +1 -1
  41. package/dist/http/routes/learn/org.js +2 -2
  42. package/dist/http/routes/learn/org.js.map +1 -1
  43. package/dist/http/routes/learn/progress.js +2 -2
  44. package/dist/http/routes/learn/progress.js.map +1 -1
  45. package/dist/http/routes/organizations.js.map +1 -1
  46. package/dist/http/routes/students.js +27 -6
  47. package/dist/http/routes/students.js.map +1 -1
  48. package/dist/http/routes/webhooks.d.ts +7 -0
  49. package/dist/http/routes/webhooks.js +159 -0
  50. package/dist/http/routes/webhooks.js.map +1 -0
  51. package/dist/http/routes.js +6 -0
  52. package/dist/http/routes.js.map +1 -1
  53. package/dist/http/schemas/activities.d.ts +34 -1
  54. package/dist/http/schemas/activities.js +7 -2
  55. package/dist/http/schemas/activities.js.map +1 -1
  56. package/dist/http/schemas/assets.d.ts +11 -7
  57. package/dist/http/schemas/assets.js.map +1 -1
  58. package/dist/http/schemas/automations.d.ts +75 -8
  59. package/dist/http/schemas/automations.js +13 -2
  60. package/dist/http/schemas/automations.js.map +1 -1
  61. package/dist/http/schemas/bundles.d.ts +63 -0
  62. package/dist/http/schemas/bundles.js +23 -0
  63. package/dist/http/schemas/bundles.js.map +1 -0
  64. package/dist/http/schemas/content.d.ts +18 -11
  65. package/dist/http/schemas/content.js +1 -2
  66. package/dist/http/schemas/content.js.map +1 -1
  67. package/dist/http/schemas/course-analytics.d.ts +29 -0
  68. package/dist/http/schemas/course-analytics.js +21 -0
  69. package/dist/http/schemas/course-analytics.js.map +1 -0
  70. package/dist/http/schemas/dashboard.js.map +1 -1
  71. package/dist/http/schemas/discussion.d.ts +9 -9
  72. package/dist/http/schemas/discussion.js.map +1 -1
  73. package/dist/http/schemas/downloads.d.ts +2 -0
  74. package/dist/http/schemas/downloads.js.map +1 -1
  75. package/dist/http/schemas/entitlements.d.ts +14 -6
  76. package/dist/http/schemas/entitlements.js +4 -2
  77. package/dist/http/schemas/entitlements.js.map +1 -1
  78. package/dist/http/schemas/index.d.ts +6 -4
  79. package/dist/http/schemas/index.js +6 -4
  80. package/dist/http/schemas/integrations.d.ts +4 -4
  81. package/dist/http/schemas/integrations.js.map +1 -1
  82. package/dist/http/schemas/invites.js +2 -1
  83. package/dist/http/schemas/invites.js.map +1 -1
  84. package/dist/http/schemas/learn.d.ts +11 -1
  85. package/dist/http/schemas/learn.js.map +1 -1
  86. package/dist/http/schemas/members.js.map +1 -1
  87. package/dist/http/schemas/organizations.js.map +1 -1
  88. package/dist/http/schemas/settings.js.map +1 -1
  89. package/dist/http/schemas/shared.d.ts +1 -0
  90. package/dist/http/schemas/shared.js +3 -1
  91. package/dist/http/schemas/shared.js.map +1 -1
  92. package/dist/http/schemas/students.d.ts +30 -2
  93. package/dist/http/schemas/students.js +21 -2
  94. package/dist/http/schemas/students.js.map +1 -1
  95. package/dist/http/server.js +4 -2
  96. package/dist/http/server.js.map +1 -1
  97. package/dist/index.d.ts +2 -2
  98. package/dist/index.js.map +1 -1
  99. package/dist/package.js +6 -0
  100. package/dist/package.js.map +1 -0
  101. package/package.json +5 -5
package/README.md CHANGED
@@ -23,7 +23,7 @@ Public surface (`src/index.ts`): `createContainer`, `buildServer`,
23
23
  `@headless-lms/adapter-db`, which ships the `drizzle/` migration assets;
24
24
  `@headless-lms/cli` wraps it as `headless-lms migrate`), and the types
25
25
  installations need (`ServerConfig`, `Container`, `AdapterOverrides`, shared ports
26
- like `EmailSender` / `ObjectStorage`). Everything else is internal.
26
+ like `EmailSender` / `ObjectStorage` / `WebhookSender`). Everything else is internal.
27
27
 
28
28
  ## Layout
29
29
 
@@ -1,7 +1,7 @@
1
1
  import { PollingOutboxRelayConfig } from "@headless-lms/adapter-defaults/events/outbox-relay";
2
2
  import { LogLevel, PinoInstance, RequestLogContext } from "@headless-lms/adapter-defaults/logging";
3
3
  import { BetterAuth } from "@headless-lms/adapter-auth";
4
- import { ContentServiceImpl } from "@headless-lms/core/content";
4
+ import { ContentService } from "@headless-lms/core/content";
5
5
  import { EntitlementsServiceImpl } from "@headless-lms/core/entitlements";
6
6
  import { ProgressServiceImpl } from "@headless-lms/core/progress";
7
7
  import { DiscussionServiceImpl } from "@headless-lms/core/discussion";
@@ -12,10 +12,11 @@ import { IntegrationsServiceImpl } from "@headless-lms/core/integrations";
12
12
  import { AutomationsServiceImpl } from "@headless-lms/core/automations";
13
13
  import { StudentsReportServiceImpl } from "@headless-lms/core/reporting/students";
14
14
  import { DashboardReportServiceImpl } from "@headless-lms/core/reporting/dashboard";
15
+ import { CoursesReportServiceImpl } from "@headless-lms/core/reporting/courses";
15
16
  import { LearnReportServiceImpl } from "@headless-lms/core/reporting/learn";
16
17
  import { Mailer } from "@headless-lms/core/shared/mailer";
17
18
  import { SettingsService } from "@headless-lms/core/shared/settings";
18
- import { AutomationEngine } from "@headless-lms/core/types";
19
+ import { AutomationEngine, WebhookSender } from "@headless-lms/core/types";
19
20
  import { CredentialStore, EmailSender, Logger, ObjectStorage, OutboxRelay, TemplateContext, TemplateRenderer } from "@headless-lms/core/shared/ports";
20
21
  //#region src/app/container.d.ts
21
22
  /** Installation-supplied ports; an absent slot falls back to a fail-loudly stub. */
@@ -26,6 +27,8 @@ interface AdapterOverrides {
26
27
  templates?: TemplateRenderer;
27
28
  /** Durable automation engine. Absent → InlineAutomationEngine (in-process, one attempt per action). */
28
29
  workflows?: AutomationEngine;
30
+ /** Outbound HTTP for webhook deliveries. Absent → FetchWebhookSender (fetch, 10s timeout). */
31
+ webhooks?: WebhookSender;
29
32
  }
30
33
  interface BuildContainerOptions {
31
34
  /** Installation's plugins folder, scanned by loadIntegrations. Absent → no integrations. */
@@ -82,7 +85,7 @@ interface Container {
82
85
  orgProvider: OrgAdmin;
83
86
  identity: IdentityServiceImpl;
84
87
  organizations: OrganizationServiceImpl;
85
- content: ContentServiceImpl;
88
+ content: ContentService;
86
89
  entitlements: EntitlementsServiceImpl;
87
90
  progress: ProgressServiceImpl;
88
91
  discussion: DiscussionServiceImpl;
@@ -95,6 +98,7 @@ interface Container {
95
98
  reporting: {
96
99
  students: StudentsReportServiceImpl;
97
100
  dashboard: DashboardReportServiceImpl;
101
+ courses: CoursesReportServiceImpl;
98
102
  learn: LearnReportServiceImpl;
99
103
  };
100
104
  storage: ObjectStorage;
@@ -1,14 +1,15 @@
1
1
  import { loadIntegrations } from "./integrations.js";
2
2
  import { registerNotificationSubscribers } from "./notifications.js";
3
- import { DrizzleAssetsRepository, DrizzleAutomationRunsRepository, DrizzleAutomationsRepository, DrizzleConnectionsRepository, DrizzleContentRepository, DrizzleCredentialStore, DrizzleDashboardRepository, DrizzleDiscussionRepository, DrizzleEntitlementsRepository, DrizzleIdentityRepository, DrizzleLearnRepository, DrizzleMembersRepository, DrizzleOrganizationsRepository, DrizzleOutboxAppender, DrizzleOutboxStore, DrizzleProgressRepository, DrizzleSettingsRepository, DrizzleStudentsRepository, DrizzleUnitOfWork, createDb } from "@headless-lms/adapter-db";
3
+ import { DrizzleAssetsRepository, DrizzleAutomationRunsRepository, DrizzleAutomationsRepository, DrizzleConnectionsRepository, DrizzleContentRepository, DrizzleCourseAnalyticsRepository, DrizzleCredentialStore, DrizzleDashboardRepository, DrizzleDiscussionRepository, DrizzleEntitlementsRepository, DrizzleIdentityRepository, DrizzleLearnRepository, DrizzleMembersRepository, DrizzleOrganizationsRepository, DrizzleOutboxAppender, DrizzleOutboxStore, DrizzleProgressRepository, DrizzleSettingsRepository, DrizzleStudentsRepository, DrizzleUnitOfWork, createDb } from "@headless-lms/adapter-db";
4
4
  import { InMemoryEventBus } from "@headless-lms/adapter-defaults/events";
5
5
  import { PollingOutboxRelay } from "@headless-lms/adapter-defaults/events/outbox-relay";
6
6
  import { InlineAutomationEngine } from "@headless-lms/adapter-defaults/workflows";
7
+ import { FetchWebhookSender } from "@headless-lms/adapter-defaults/webhooks";
7
8
  import { EmailAdapter, StubTemplateRenderer } from "@headless-lms/adapter-defaults/email";
8
9
  import { createRootLogger, requestLogContext } from "@headless-lms/adapter-defaults/logging";
9
10
  import { StorageAdapter } from "@headless-lms/adapter-defaults/storage";
10
11
  import { BetterAuth } from "@headless-lms/adapter-auth";
11
- import { ContentServiceImpl } from "@headless-lms/core/content";
12
+ import { ContentService } from "@headless-lms/core/content";
12
13
  import { EntitlementsServiceImpl } from "@headless-lms/core/entitlements";
13
14
  import { ProgressServiceImpl } from "@headless-lms/core/progress";
14
15
  import { DiscussionServiceImpl } from "@headless-lms/core/discussion";
@@ -19,6 +20,7 @@ import { IntegrationsServiceImpl } from "@headless-lms/core/integrations";
19
20
  import { AutomationsServiceImpl } from "@headless-lms/core/automations";
20
21
  import { StudentsReportServiceImpl } from "@headless-lms/core/reporting/students";
21
22
  import { DashboardReportServiceImpl } from "@headless-lms/core/reporting/dashboard";
23
+ import { CoursesReportServiceImpl } from "@headless-lms/core/reporting/courses";
22
24
  import { LearnReportServiceImpl } from "@headless-lms/core/reporting/learn";
23
25
  import { Mailer } from "@headless-lms/core/shared/mailer";
24
26
  import { SettingsService } from "@headless-lms/core/shared/settings";
@@ -76,11 +78,16 @@ async function buildContainer(config, options) {
76
78
  organizations: new DrizzleOrganizationsRepository(tx, logger.child({ name: "org" })),
77
79
  outbox: new DrizzleOutboxAppender(tx, logger.child({ name: "org" }))
78
80
  }));
81
+ const orgAdminRef = { current: void 0 };
79
82
  const organizations = new OrganizationServiceImpl({
80
83
  repo: new DrizzleOrganizationsRepository(db, logger.child({ name: "org" })),
81
84
  membersRepo: new DrizzleMembersRepository(db, logger.child({ name: "org" })),
82
85
  people: identity,
83
86
  uow: organizationsUow,
87
+ orgAdmin: () => {
88
+ if (!orgAdminRef.current) throw new Error("orgAdmin not initialised");
89
+ return orgAdminRef.current;
90
+ },
84
91
  logger: logger.child({ name: "org" }),
85
92
  mailer,
86
93
  inviteUrls: {
@@ -93,10 +100,9 @@ async function buildContainer(config, options) {
93
100
  content: new DrizzleContentRepository(tx, contentLogger),
94
101
  outbox: new DrizzleOutboxAppender(tx, outboxLogger)
95
102
  }));
96
- const content = new ContentServiceImpl({
103
+ const content = new ContentService({
97
104
  repo: new DrizzleContentRepository(db, contentLogger),
98
105
  uow: contentUow,
99
- settings,
100
106
  logger: contentLogger
101
107
  });
102
108
  const entitlementsUow = new DrizzleUnitOfWork(db, (tx) => ({
@@ -147,6 +153,10 @@ async function buildContainer(config, options) {
147
153
  repo: new DrizzleDashboardRepository(db, reportingLogger),
148
154
  logger: reportingLogger
149
155
  }),
156
+ courses: new CoursesReportServiceImpl({
157
+ repo: new DrizzleCourseAnalyticsRepository(db, reportingLogger),
158
+ logger: reportingLogger
159
+ }),
150
160
  learn: new LearnReportServiceImpl({
151
161
  reader: new DrizzleLearnRepository(db, reportingLogger),
152
162
  content,
@@ -173,6 +183,7 @@ async function buildContainer(config, options) {
173
183
  const automationsUow = new DrizzleUnitOfWork(db, (tx) => ({
174
184
  automations: new DrizzleAutomationsRepository(tx, automationsLogger),
175
185
  runs: new DrizzleAutomationRunsRepository(tx, automationsLogger),
186
+ credentials: new DrizzleCredentialStore(tx, config.credentialStoreKey, automationsLogger),
176
187
  outbox: new DrizzleOutboxAppender(tx, automationsLogger)
177
188
  }));
178
189
  const mailerLookups = {
@@ -181,12 +192,14 @@ async function buildContainer(config, options) {
181
192
  const course = await content.getCourse(orgId, contentId);
182
193
  if (course) return {
183
194
  id: course.id,
184
- title: course.title
195
+ title: course.title,
196
+ type: course.type
185
197
  };
186
198
  const download = await content.getDownload(orgId, contentId);
187
199
  return download ? {
188
200
  id: download.id,
189
- title: download.title
201
+ title: download.title,
202
+ type: download.type
190
203
  } : null;
191
204
  }
192
205
  };
@@ -198,6 +211,8 @@ async function buildContainer(config, options) {
198
211
  mailer,
199
212
  lookups: mailerLookups,
200
213
  integrations,
214
+ credentials: credentialStore,
215
+ webhooks: options?.adapters?.webhooks ?? new FetchWebhookSender(),
201
216
  logger: automationsLogger
202
217
  });
203
218
  automationEngine.register(automations);
@@ -206,25 +221,37 @@ async function buildContainer(config, options) {
206
221
  eventBus.subscribeAll((event) => automations.handle(event));
207
222
  const outboxConfig = resolveOutboxConfig(config.outbox);
208
223
  const outboxRelay = new PollingOutboxRelay(new DrizzleOutboxStore(db, outboxLogger), eventBus, outboxConfig, outboxLogger);
224
+ const authLogger = logger.child({ name: "auth" });
225
+ const authHook = (name, fn) => async (...args) => {
226
+ try {
227
+ return await fn(...args);
228
+ } catch (err) {
229
+ authLogger.error(`auth hook ${name} failed`, { err });
230
+ throw err;
231
+ }
232
+ };
209
233
  const auth = new BetterAuth({
210
234
  db,
211
235
  baseUrl: config.authBaseURL,
212
236
  secret: config.authSecret,
213
237
  trustedOrigins: config.trustedOrigins,
214
238
  hooks: {
215
- sendResetPassword: async (data) => {
239
+ sendResetPassword: async ({ user, url }) => {
216
240
  await identity.sendPasswordReset({
217
- email: data.user.email,
218
- url: ""
241
+ email: user.email,
242
+ url
219
243
  });
220
244
  },
221
- sendMagicLink: async ({ email, url }) => {
245
+ onPasswordReset: async ({ user }) => {
246
+ await identity.sendPasswordChanged({ email: user.email });
247
+ },
248
+ sendMagicLink: authHook("sendMagicLink", async ({ email, url }) => {
222
249
  await identity.sendMagicLink({
223
250
  email,
224
251
  url
225
252
  });
226
- },
227
- beforeUserCreate: async (user) => {
253
+ }),
254
+ beforeUserCreate: authHook("beforeUserCreate", async (user) => {
228
255
  const { email, name } = user;
229
256
  const [firstName, lastName] = name.split(" ");
230
257
  const domainUser = await identity.linkOrCreateUser({
@@ -236,8 +263,8 @@ async function buildContainer(config, options) {
236
263
  ...user,
237
264
  id: domainUser.id
238
265
  } };
239
- },
240
- beforeCreateSession: async (session) => {
266
+ }),
267
+ beforeCreateSession: authHook("beforeCreateSession", async (session) => {
241
268
  const person = await identity.getUserByExternalId(session.userId);
242
269
  if (!person) return;
243
270
  const orgs = await organizations.getOrgUsersForUser(person.id);
@@ -248,8 +275,8 @@ async function buildContainer(config, options) {
248
275
  ...session,
249
276
  activeOrganizationId: org.id
250
277
  } };
251
- },
252
- beforeCreateOrganization: async ({ organization: org, user: baUser }) => {
278
+ }),
279
+ beforeCreateOrganization: authHook("beforeCreateOrganization", async ({ organization: org, user: baUser }) => {
253
280
  const domainOrg = await organizations.createOrganization({
254
281
  ownerId: baUser.id,
255
282
  ...org,
@@ -259,28 +286,32 @@ async function buildContainer(config, options) {
259
286
  ...org,
260
287
  id: domainOrg.id
261
288
  } };
262
- },
263
- beforeUpdateOrganization: async ({ organization }) => {
289
+ }),
290
+ beforeUpdateOrganization: authHook("beforeUpdateOrganization", async ({ organization }) => {
264
291
  await organizations.updateOrganization(organization.id, organization);
265
- },
266
- beforeDeleteOrganization: async ({ organization }) => {
292
+ }),
293
+ beforeDeleteOrganization: authHook("beforeDeleteOrganization", async ({ organization }) => {
267
294
  await organizations.deleteOrganization(organization.id);
268
- },
269
- beforeAddMember: async ({ member }) => {
295
+ }),
296
+ beforeAddMember: authHook("beforeAddMember", async ({ member }) => {
270
297
  await organizations.addOrgUser({
271
298
  orgId: member.organizationId,
272
299
  userId: member.userId,
273
300
  role: parseRole(member.role)
274
301
  });
275
- },
276
- beforeRemoveMember: async ({ member }) => {
302
+ }),
303
+ beforeUpdateMemberRole: authHook("beforeUpdateMemberRole", async ({ member, newRole }) => {
304
+ await organizations.updateOrgUser(member.organizationId, member.userId, { role: parseRole(newRole) });
305
+ }),
306
+ beforeRemoveMember: authHook("beforeRemoveMember", async ({ member }) => {
277
307
  await organizations.removeOrgUser(member.organizationId, member.userId);
278
- }
308
+ })
279
309
  },
280
- logger: logger.child({ name: "auth" }),
310
+ logger: authLogger,
281
311
  cookieDomain: config.cookieDomain,
282
312
  secureCookies: config.secureCookies
283
313
  });
314
+ orgAdminRef.current = auth;
284
315
  return {
285
316
  auth,
286
317
  identity,
@@ -1 +1 @@
1
- {"version":3,"file":"container.js","names":[],"sources":["../../src/app/container.ts"],"sourcesContent":["// Wires adapters + services in dependency order. Starts nothing.\nimport {\n createDb,\n DrizzleAssetsRepository,\n DrizzleAutomationRunsRepository,\n DrizzleAutomationsRepository,\n DrizzleConnectionsRepository,\n DrizzleContentRepository,\n DrizzleCredentialStore,\n DrizzleDashboardRepository,\n DrizzleDiscussionRepository,\n DrizzleEntitlementsRepository,\n DrizzleIdentityRepository,\n DrizzleLearnRepository,\n DrizzleMembersRepository,\n DrizzleOrganizationsRepository,\n DrizzleOutboxAppender,\n DrizzleOutboxStore,\n DrizzleProgressRepository,\n DrizzleSettingsRepository,\n DrizzleStudentsRepository,\n DrizzleUnitOfWork,\n} from '@headless-lms/adapter-db';\nimport { InMemoryEventBus } from '@headless-lms/adapter-defaults/events';\nimport {\n PollingOutboxRelay,\n type PollingOutboxRelayConfig,\n} from '@headless-lms/adapter-defaults/events/outbox-relay';\nimport { InlineAutomationEngine } from '@headless-lms/adapter-defaults/workflows';\nimport { EmailAdapter, StubTemplateRenderer } from '@headless-lms/adapter-defaults/email';\nimport {\n createRootLogger,\n type LogLevel,\n type PinoInstance,\n requestLogContext,\n type RequestLogContext,\n} from '@headless-lms/adapter-defaults/logging';\nimport { StorageAdapter } from '@headless-lms/adapter-defaults/storage';\nimport { BetterAuth } from '@headless-lms/adapter-auth';\n\nimport { ContentServiceImpl } from '@headless-lms/core/content';\nimport { EntitlementsServiceImpl } from '@headless-lms/core/entitlements';\nimport { ProgressServiceImpl } from '@headless-lms/core/progress';\nimport { DiscussionServiceImpl } from '@headless-lms/core/discussion';\nimport { IdentityServiceImpl, type SessionAdmin } from '@headless-lms/core/identity';\nimport { type OrgAdmin, OrganizationServiceImpl, parseRole } from '@headless-lms/core/organizations';\nimport { AssetsServiceImpl } from '@headless-lms/core/assets';\nimport { IntegrationsServiceImpl } from '@headless-lms/core/integrations';\nimport { AutomationsServiceImpl } from '@headless-lms/core/automations';\nimport { loadIntegrations } from './integrations.js';\nimport { registerNotificationSubscribers } from './notifications.js';\nimport { StudentsReportServiceImpl } from '@headless-lms/core/reporting/students';\nimport { DashboardReportServiceImpl } from '@headless-lms/core/reporting/dashboard';\nimport { LearnReportServiceImpl } from '@headless-lms/core/reporting/learn';\nimport { Mailer, type MailerLookups } from '@headless-lms/core/shared/mailer';\nimport { SettingsService } from '@headless-lms/core/shared/settings';\n\nimport type {\n CredentialStore,\n EmailSender,\n Logger,\n ObjectStorage,\n OutboxRelay,\n TemplateContext,\n TemplateRenderer,\n} from '@headless-lms/core/shared/ports';\nimport type { AutomationEngine } from '@headless-lms/core/types';\n\n/** Installation-supplied ports; an absent slot falls back to a fail-loudly stub. */\nexport interface AdapterOverrides {\n email?: EmailSender;\n storage?: ObjectStorage;\n /** Resolves email templates to rendered content. Absent → fail-loudly stub. */\n templates?: TemplateRenderer;\n /** Durable automation engine. Absent → InlineAutomationEngine (in-process, one attempt per action). */\n workflows?: AutomationEngine;\n}\n\nexport interface BuildContainerOptions {\n /** Installation's plugins folder, scanned by loadIntegrations. Absent → no integrations. */\n pluginsDir?: string;\n adapters?: AdapterOverrides;\n}\n\nexport interface Config {\n databaseUrl: string;\n authBaseURL: string;\n authSecret: string;\n trustedOrigins: string[];\n /** Branding threaded into every email template. Default: brandName \"Headless LMS\", baseUrl = adminAppUrl.\n * (studentPortalUrl is composed in from the top-level config field.) */\n emailBranding?: Omit<TemplateContext, 'studentPortalUrl'>;\n /** base64-encoded 32-byte key for the credential store (CREDENTIAL_STORE_KEY). */\n credentialStoreKey: string;\n /** Parent domain for cross-subdomain session cookies (e.g. \".example.com\"); undefined → host-only cookie. */\n cookieDomain?: string;\n /** Mark session cookies Secure (set behind HTTPS / in production). */\n secureCookies?: boolean;\n /** Student portal origin — invite links for students, and the origin whose signups are invite-gated. */\n studentPortalUrl: string;\n /** Admin app origin — invite links for staff. */\n adminAppUrl: string;\n /** Transactional-outbox relay tuning. All optional — see OUTBOX_DEFAULTS. */\n outbox?: OutboxConfig;\n /** Log level for the process-wide logger (HTTP + domain + relay). Default \"info\". */\n logging?: LoggingConfig;\n /** Presigned URL lifetime for entitled download delivery, seconds. Mirrors\n * ServerConfig.deliveryExpirySeconds — createContainer threads it through. */\n deliveryExpirySeconds: number;\n}\n\n/** Tuning for the transactional-outbox relay. Every field is optional; the\n * container resolves against OUTBOX_DEFAULTS. */\nexport interface OutboxConfig {\n /** Master switch for the same-process relay. Default true. */\n enabled?: boolean;\n /** Idle delay between polls. Default 1000. */\n pollIntervalMs?: number;\n /** Max rows fetched/dispatched per tick. Default 100. */\n batchSize?: number;\n}\n\nexport const OUTBOX_DEFAULTS: PollingOutboxRelayConfig = {\n enabled: true,\n pollIntervalMs: 1000,\n batchSize: 100,\n};\n\nexport function resolveOutboxConfig(config: OutboxConfig = {}): PollingOutboxRelayConfig {\n const overrides = Object.fromEntries(\n Object.entries(config).filter(([, value]) => value !== undefined),\n );\n return { ...OUTBOX_DEFAULTS, ...overrides };\n}\n\n/** Logging tuning. Optional; resolved against LOGGING_DEFAULTS. */\nexport interface LoggingConfig {\n /** Minimum level emitted. Default \"info\". */\n level?: LogLevel;\n}\n\nexport const LOGGING_DEFAULTS: Required<LoggingConfig> = { level: 'info' };\n\nexport function resolveLoggingConfig(config: LoggingConfig = {}): Required<LoggingConfig> {\n return { level: config.level ?? LOGGING_DEFAULTS.level };\n}\n\nexport interface Container {\n auth: BetterAuth;\n // Org Provider\n orgProvider: OrgAdmin;\n\n // Domains\n identity: IdentityServiceImpl;\n organizations: OrganizationServiceImpl;\n content: ContentServiceImpl;\n entitlements: EntitlementsServiceImpl;\n progress: ProgressServiceImpl;\n discussion: DiscussionServiceImpl;\n assets: AssetsServiceImpl;\n integrations: IntegrationsServiceImpl;\n automations: AutomationsServiceImpl;\n /** Cross-cutting settings store. Not a context — a leaf every context may use;\n * inject `settings.for('<namespace>')` into a service, never the service itself. */\n settings: SettingsService;\n // Reporting read layer (composed cross-context reads; owns no domain rules).\n reporting: {\n students: StudentsReportServiceImpl;\n dashboard: DashboardReportServiceImpl;\n learn: LearnReportServiceImpl;\n };\n storage: ObjectStorage;\n mailer: Mailer;\n /** Shared secure credential store — encrypted at rest, org-scoped, decrypt at point of use. */\n credentials: CredentialStore;\n /** The outbox relay — constructed but NEVER started by the container; the\n * installation's entry point starts it after listen (gen-openapi must not\n * poll). buildServer stops it onClose. */\n outboxRelay: OutboxRelay;\n /** The automation engine — constructed but NEVER started by the container;\n * the installation's entry point starts it after listen (gen-openapi must\n * not run a worker). buildServer stops it onClose. */\n automationEngine: AutomationEngine;\n /** Root logger port — components receive children bound with { name }. */\n logger: Logger;\n /** The raw pino root; buildServer hands it to Fastify so HTTP shares the stream. */\n loggerInstance: PinoInstance;\n /** Request-scoped log correlation; the HTTP layer enters it per request. */\n requestContext: RequestLogContext;\n /** Identity's session-write port; the auth adapter fulfils it. */\n sessions: SessionAdmin;\n}\n\nexport async function buildContainer(\n config: Config,\n options?: BuildContainerOptions,\n): Promise<Container> {\n const { instance: loggerInstance, logger } = createRootLogger(\n resolveLoggingConfig(config.logging).level,\n );\n // One child per domain — a context's service and repositories share it.\n\n const contentLogger = logger.child({ name: 'content' });\n const entitlementsLogger = logger.child({ name: 'entitlements' });\n const progressLogger = logger.child({ name: 'progress' });\n const discussionLogger = logger.child({ name: 'discussion' });\n const assetsLogger = logger.child({ name: 'assets' });\n const integrationsLogger = logger.child({ name: 'integrations' });\n const automationsLogger = logger.child({ name: 'automations' });\n const reportingLogger = logger.child({ name: 'reporting' });\n const outboxLogger = logger.child({ name: 'outbox' });\n\n // Outbound adapters\n const db = createDb(config.databaseUrl);\n const email = options?.adapters?.email ?? new EmailAdapter(logger.child({ name: 'email' }));\n const storage: ObjectStorage =\n options?.adapters?.storage ?? new StorageAdapter(logger.child({ name: 'storage' }));\n const templates =\n options?.adapters?.templates ?? new StubTemplateRenderer(logger.child({ name: 'email' }));\n const automationEngine: AutomationEngine =\n options?.adapters?.workflows ?? new InlineAutomationEngine();\n const mailer = new Mailer(templates, email, {\n ...(config.emailBranding ?? { brandName: 'Headless LMS', baseUrl: config.adminAppUrl }),\n studentPortalUrl: config.studentPortalUrl,\n });\n\n /*\n * Identity\n */\n const identityUoW = new DrizzleUnitOfWork(db, (tx) => ({\n identity: new DrizzleIdentityRepository(tx, logger.child({ name: 'identity' })),\n outbox: new DrizzleOutboxAppender(tx, logger.child({ name: 'identity' })),\n }));\n const identity = new IdentityServiceImpl({\n repo: new DrizzleIdentityRepository(db, logger.child({ name: 'identity_repo' })),\n logger: logger.child({ name: 'identity' }),\n uow: identityUoW,\n mailer,\n });\n\n /*\n * Organization\n */\n const organizationsUow = new DrizzleUnitOfWork(db, (tx) => ({\n organizations: new DrizzleOrganizationsRepository(tx, logger.child({ name: 'org' })),\n outbox: new DrizzleOutboxAppender(tx, logger.child({ name: 'org' })),\n }));\n const organizations = new OrganizationServiceImpl({\n repo: new DrizzleOrganizationsRepository(db, logger.child({ name: 'org' })),\n membersRepo: new DrizzleMembersRepository(db, logger.child({ name: 'org' })),\n people: identity,\n uow: organizationsUow,\n logger: logger.child({ name: 'org' }),\n mailer,\n inviteUrls: { studentPortalUrl: config.studentPortalUrl, adminAppUrl: config.adminAppUrl },\n });\n\n /*\n * Settings\n */\n const settings = new SettingsService(\n new DrizzleSettingsRepository(db, logger.child({ name: 'settings' })),\n );\n // Content: reads on the root db; course writes + outbox append in one tx.\n const contentUow = new DrizzleUnitOfWork(db, (tx) => ({\n content: new DrizzleContentRepository(tx, contentLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n }));\n const content = new ContentServiceImpl({\n repo: new DrizzleContentRepository(db, contentLogger),\n uow: contentUow,\n settings,\n logger: contentLogger,\n });\n // Entitlements: reads on the root db; writes + outbox append in one tx.\n const entitlementsUow = new DrizzleUnitOfWork(db, (tx) => ({\n entitlements: new DrizzleEntitlementsRepository(tx, entitlementsLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n }));\n const entitlements = new EntitlementsServiceImpl({\n repo: new DrizzleEntitlementsRepository(db, entitlementsLogger),\n uow: entitlementsUow,\n logger: entitlementsLogger,\n });\n // Progress: report writes + outbox append in one tx; content supplies the\n // structure and completion rules the service evaluates against.\n const progressUow = new DrizzleUnitOfWork(db, (tx) => ({\n progress: new DrizzleProgressRepository(tx, progressLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n }));\n const progress = new ProgressServiceImpl({\n repo: new DrizzleProgressRepository(db, progressLogger),\n content,\n uow: progressUow,\n logger: progressLogger,\n });\n\n const discussion = new DiscussionServiceImpl({\n repo: new DrizzleDiscussionRepository(db, discussionLogger),\n access: entitlements,\n content: content,\n uow: new DrizzleUnitOfWork(db, (tx) => ({\n discussion: new DrizzleDiscussionRepository(tx, discussionLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n })),\n settings,\n logger: discussionLogger,\n });\n const assets = new AssetsServiceImpl({\n storage,\n repo: new DrizzleAssetsRepository(db, assetsLogger),\n uow: new DrizzleUnitOfWork(db, (tx) => ({\n assets: new DrizzleAssetsRepository(tx, assetsLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n })),\n logger: assetsLogger,\n });\n\n const reporting = {\n students: new StudentsReportServiceImpl({\n repo: new DrizzleStudentsRepository(db, reportingLogger),\n logger: reportingLogger,\n }),\n dashboard: new DashboardReportServiceImpl({\n repo: new DrizzleDashboardRepository(db, reportingLogger),\n logger: reportingLogger,\n }),\n learn: new LearnReportServiceImpl({\n reader: new DrizzleLearnRepository(db, reportingLogger),\n content,\n progress,\n assets,\n deliveryExpirySeconds: config.deliveryExpirySeconds,\n logger: reportingLogger,\n }),\n };\n\n const credentialStore = new DrizzleCredentialStore(\n db,\n config.credentialStoreKey,\n integrationsLogger,\n );\n // The integrations this deployment supports: the installation's declared\n // plugins folder (directory name = integration id), loaded at startup.\n // Connect/configure reject undeclared ids and validate config with the\n // integration's own schema. No folder → no integrations.\n const integrationsRegistry = await loadIntegrations(options?.pluginsDir, integrationsLogger);\n // Integrations: credential + connection writes + outbox append in one tx\n // (a tx-bound credential store instance shares the scope's transaction).\n const integrationsUow = new DrizzleUnitOfWork(db, (tx) => ({\n connections: new DrizzleConnectionsRepository(tx, integrationsLogger),\n credentials: new DrizzleCredentialStore(tx, config.credentialStoreKey, integrationsLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n }));\n const integrations = new IntegrationsServiceImpl({\n registry: integrationsRegistry,\n repo: new DrizzleConnectionsRepository(db, integrationsLogger),\n uow: integrationsUow,\n credentials: credentialStore,\n logger: integrationsLogger,\n });\n\n // Automations: run writes + outbox append in one tx; the engine drives\n // execution (register below), integrations supplies the loaded\n // integrations' actions for `available`.\n const automationsUow = new DrizzleUnitOfWork(db, (tx) => ({\n automations: new DrizzleAutomationsRepository(tx, automationsLogger),\n runs: new DrizzleAutomationRunsRepository(tx, automationsLogger),\n outbox: new DrizzleOutboxAppender(tx, automationsLogger),\n }));\n // Emails triggered by row-shaped events resolve their recipient and content\n // details at send time — the event carries ids only.\n const mailerLookups: MailerLookups = {\n orgUserEmail: async (orgId, orgUserId) =>\n (await reporting.students.get(orgId, orgUserId))?.email ?? null,\n contentInfo: async (orgId, contentId) => {\n const course = await content.getCourse(orgId, contentId);\n if (course) {\n return { id: course.id, title: course.title };\n }\n const download = await content.getDownload(orgId, contentId);\n return download ? { id: download.id, title: download.title } : null;\n },\n };\n\n const automations = new AutomationsServiceImpl({\n repo: new DrizzleAutomationsRepository(db, automationsLogger),\n runsRepo: new DrizzleAutomationRunsRepository(db, automationsLogger),\n uow: automationsUow,\n engine: automationEngine,\n mailer,\n lookups: mailerLookups,\n integrations,\n logger: automationsLogger,\n });\n automationEngine.register(automations);\n\n const eventBus = new InMemoryEventBus();\n registerNotificationSubscribers(eventBus, mailer, mailerLookups);\n // handle() never throws — the EventBus fans out to every subscriber\n // sequentially, and a rejection here would break sibling subscribers.\n eventBus.subscribeAll((event) => automations.handle(event));\n const outboxConfig = resolveOutboxConfig(config.outbox);\n const outboxRelay = new PollingOutboxRelay(\n new DrizzleOutboxStore(db, outboxLogger),\n eventBus,\n outboxConfig,\n outboxLogger,\n );\n\n /*\n * Auth Adapter\n */\n const auth = new BetterAuth({\n db,\n baseUrl: config.authBaseURL,\n secret: config.authSecret,\n trustedOrigins: config.trustedOrigins,\n hooks: {\n sendResetPassword: async (data) => {\n await identity.sendPasswordReset({\n email: data.user.email,\n url: '',\n });\n },\n sendMagicLink: async ({ email, url }) => {\n await identity.sendMagicLink({\n email,\n url,\n });\n },\n beforeUserCreate: async (user) => {\n const { email, name } = user;\n const [firstName, lastName] = name.split(' ');\n // Better Auth has not minted an id yet — the id returned below becomes\n // its account id. A person provisioned at invite time is linked instead of re-created.\n const domainUser = await identity.linkOrCreateUser({\n email,\n ...(firstName !== undefined && { firstName }),\n ...(lastName !== undefined && { lastName }),\n });\n\n return {\n data: {\n ...user,\n id: domainUser.id,\n },\n };\n },\n beforeCreateSession: async (session) => {\n const person = await identity.getUserByExternalId(session.userId);\n if (!person) {\n return;\n }\n const orgs = await organizations.getOrgUsersForUser(person.id);\n if (orgs.length !== 1) {\n return;\n }\n // TODO - fix, why default to the first?\n const org = await organizations.getById(orgs[0]!.orgId);\n if (!org) {\n return;\n }\n\n return { data: { ...session, activeOrganizationId: org.id } };\n },\n /*\n * We're making sure the domain org creates and using the same ID for better-auth org ID.\n */\n beforeCreateOrganization: async ({ organization: org, user: baUser }) => {\n const domainOrg = await organizations.createOrganization({\n ownerId: baUser.id,\n ...org,\n logo: org.logo ?? undefined,\n });\n return { data: { ...org, id: domainOrg.id } };\n },\n beforeUpdateOrganization: async ({ organization }) => {\n await organizations.updateOrganization(organization.id, organization);\n },\n beforeDeleteOrganization: async ({ organization }) => {\n await organizations.deleteOrganization(organization.id);\n },\n beforeAddMember: async ({ member }) => {\n await organizations.addOrgUser({\n orgId: member.organizationId,\n userId: member.userId,\n role: parseRole(member.role),\n });\n },\n beforeRemoveMember: async ({ member }) => {\n await organizations.removeOrgUser(member.organizationId, member.userId);\n },\n },\n logger: logger.child({ name: 'auth' }),\n cookieDomain: config.cookieDomain,\n secureCookies: config.secureCookies,\n });\n\n return {\n auth,\n identity,\n organizations,\n // Better Auth owns org writes; the same instance fulfils the OrgAdmin port.\n orgProvider: auth,\n content,\n entitlements,\n progress,\n discussion,\n assets,\n integrations,\n automations,\n settings,\n reporting,\n storage,\n mailer,\n credentials: credentialStore,\n outboxRelay,\n automationEngine,\n logger,\n loggerInstance,\n requestContext: requestLogContext,\n // Better Auth owns the session store; the same instance fulfils SessionAdmin.\n sessions: auth,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"container.js","names":[],"sources":["../../src/app/container.ts"],"sourcesContent":["// Wires adapters + services in dependency order. Starts nothing.\nimport {\n createDb,\n DrizzleAssetsRepository,\n DrizzleAutomationRunsRepository,\n DrizzleAutomationsRepository,\n DrizzleConnectionsRepository,\n DrizzleContentRepository,\n DrizzleCredentialStore,\n DrizzleCourseAnalyticsRepository,\n DrizzleDashboardRepository,\n DrizzleDiscussionRepository,\n DrizzleEntitlementsRepository,\n DrizzleIdentityRepository,\n DrizzleLearnRepository,\n DrizzleMembersRepository,\n DrizzleOrganizationsRepository,\n DrizzleOutboxAppender,\n DrizzleOutboxStore,\n DrizzleProgressRepository,\n DrizzleSettingsRepository,\n DrizzleStudentsRepository,\n DrizzleUnitOfWork,\n} from '@headless-lms/adapter-db';\nimport { InMemoryEventBus } from '@headless-lms/adapter-defaults/events';\nimport {\n PollingOutboxRelay,\n type PollingOutboxRelayConfig,\n} from '@headless-lms/adapter-defaults/events/outbox-relay';\nimport { InlineAutomationEngine } from '@headless-lms/adapter-defaults/workflows';\nimport { FetchWebhookSender } from '@headless-lms/adapter-defaults/webhooks';\nimport { EmailAdapter, StubTemplateRenderer } from '@headless-lms/adapter-defaults/email';\nimport {\n createRootLogger,\n type LogLevel,\n type PinoInstance,\n requestLogContext,\n type RequestLogContext,\n} from '@headless-lms/adapter-defaults/logging';\nimport { StorageAdapter } from '@headless-lms/adapter-defaults/storage';\nimport { BetterAuth } from '@headless-lms/adapter-auth';\n\nimport { ContentService } from '@headless-lms/core/content';\nimport { EntitlementsServiceImpl } from '@headless-lms/core/entitlements';\nimport { ProgressServiceImpl } from '@headless-lms/core/progress';\nimport { DiscussionServiceImpl } from '@headless-lms/core/discussion';\nimport { IdentityServiceImpl, type SessionAdmin } from '@headless-lms/core/identity';\nimport {\n type OrgAdmin,\n OrganizationServiceImpl,\n parseRole,\n} from '@headless-lms/core/organizations';\nimport { AssetsServiceImpl } from '@headless-lms/core/assets';\nimport { IntegrationsServiceImpl } from '@headless-lms/core/integrations';\nimport { AutomationsServiceImpl } from '@headless-lms/core/automations';\nimport { loadIntegrations } from './integrations.js';\nimport { registerNotificationSubscribers } from './notifications.js';\nimport { StudentsReportServiceImpl } from '@headless-lms/core/reporting/students';\nimport { DashboardReportServiceImpl } from '@headless-lms/core/reporting/dashboard';\nimport { CoursesReportServiceImpl } from '@headless-lms/core/reporting/courses';\nimport { LearnReportServiceImpl } from '@headless-lms/core/reporting/learn';\nimport { Mailer, type MailerLookups } from '@headless-lms/core/shared/mailer';\nimport { SettingsService } from '@headless-lms/core/shared/settings';\n\nimport type {\n CredentialStore,\n EmailSender,\n Logger,\n ObjectStorage,\n OutboxRelay,\n TemplateContext,\n TemplateRenderer,\n} from '@headless-lms/core/shared/ports';\nimport type { AutomationEngine, WebhookSender } from '@headless-lms/core/types';\n\n/** Installation-supplied ports; an absent slot falls back to a fail-loudly stub. */\nexport interface AdapterOverrides {\n email?: EmailSender;\n storage?: ObjectStorage;\n /** Resolves email templates to rendered content. Absent → fail-loudly stub. */\n templates?: TemplateRenderer;\n /** Durable automation engine. Absent → InlineAutomationEngine (in-process, one attempt per action). */\n workflows?: AutomationEngine;\n /** Outbound HTTP for webhook deliveries. Absent → FetchWebhookSender (fetch, 10s timeout). */\n webhooks?: WebhookSender;\n}\n\nexport interface BuildContainerOptions {\n /** Installation's plugins folder, scanned by loadIntegrations. Absent → no integrations. */\n pluginsDir?: string;\n adapters?: AdapterOverrides;\n}\n\nexport interface Config {\n databaseUrl: string;\n authBaseURL: string;\n authSecret: string;\n trustedOrigins: string[];\n /** Branding threaded into every email template. Default: brandName \"Headless LMS\", baseUrl = adminAppUrl.\n * (studentPortalUrl is composed in from the top-level config field.) */\n emailBranding?: Omit<TemplateContext, 'studentPortalUrl'>;\n /** base64-encoded 32-byte key for the credential store (CREDENTIAL_STORE_KEY). */\n credentialStoreKey: string;\n /** Parent domain for cross-subdomain session cookies (e.g. \".example.com\"); undefined → host-only cookie. */\n cookieDomain?: string;\n /** Mark session cookies Secure (set behind HTTPS / in production). */\n secureCookies?: boolean;\n /** Student portal origin — invite links for students, and the origin whose signups are invite-gated. */\n studentPortalUrl: string;\n /** Admin app origin — invite links for staff. */\n adminAppUrl: string;\n /** Transactional-outbox relay tuning. All optional — see OUTBOX_DEFAULTS. */\n outbox?: OutboxConfig;\n /** Log level for the process-wide logger (HTTP + domain + relay). Default \"info\". */\n logging?: LoggingConfig;\n /** Presigned URL lifetime for entitled download delivery, seconds. Mirrors\n * ServerConfig.deliveryExpirySeconds — createContainer threads it through. */\n deliveryExpirySeconds: number;\n}\n\n/** Tuning for the transactional-outbox relay. Every field is optional; the\n * container resolves against OUTBOX_DEFAULTS. */\nexport interface OutboxConfig {\n /** Master switch for the same-process relay. Default true. */\n enabled?: boolean;\n /** Idle delay between polls. Default 1000. */\n pollIntervalMs?: number;\n /** Max rows fetched/dispatched per tick. Default 100. */\n batchSize?: number;\n}\n\nexport const OUTBOX_DEFAULTS: PollingOutboxRelayConfig = {\n enabled: true,\n pollIntervalMs: 1000,\n batchSize: 100,\n};\n\nexport function resolveOutboxConfig(config: OutboxConfig = {}): PollingOutboxRelayConfig {\n const overrides = Object.fromEntries(\n Object.entries(config).filter(([, value]) => value !== undefined),\n );\n return { ...OUTBOX_DEFAULTS, ...overrides };\n}\n\n/** Logging tuning. Optional; resolved against LOGGING_DEFAULTS. */\nexport interface LoggingConfig {\n /** Minimum level emitted. Default \"info\". */\n level?: LogLevel;\n}\n\nexport const LOGGING_DEFAULTS: Required<LoggingConfig> = { level: 'info' };\n\nexport function resolveLoggingConfig(config: LoggingConfig = {}): Required<LoggingConfig> {\n return { level: config.level ?? LOGGING_DEFAULTS.level };\n}\n\nexport interface Container {\n auth: BetterAuth;\n // Org Provider\n orgProvider: OrgAdmin;\n\n // Domains\n identity: IdentityServiceImpl;\n organizations: OrganizationServiceImpl;\n content: ContentService;\n entitlements: EntitlementsServiceImpl;\n progress: ProgressServiceImpl;\n discussion: DiscussionServiceImpl;\n assets: AssetsServiceImpl;\n integrations: IntegrationsServiceImpl;\n automations: AutomationsServiceImpl;\n /** Cross-cutting settings store. Not a context — a leaf every context may use;\n * inject `settings.for('<namespace>')` into a service, never the service itself. */\n settings: SettingsService;\n // Reporting read layer (composed cross-context reads; owns no domain rules).\n reporting: {\n students: StudentsReportServiceImpl;\n dashboard: DashboardReportServiceImpl;\n courses: CoursesReportServiceImpl;\n learn: LearnReportServiceImpl;\n };\n storage: ObjectStorage;\n mailer: Mailer;\n /** Shared secure credential store — encrypted at rest, org-scoped, decrypt at point of use. */\n credentials: CredentialStore;\n /** The outbox relay — constructed but NEVER started by the container; the\n * installation's entry point starts it after listen (gen-openapi must not\n * poll). buildServer stops it onClose. */\n outboxRelay: OutboxRelay;\n /** The automation engine — constructed but NEVER started by the container;\n * the installation's entry point starts it after listen (gen-openapi must\n * not run a worker). buildServer stops it onClose. */\n automationEngine: AutomationEngine;\n /** Root logger port — components receive children bound with { name }. */\n logger: Logger;\n /** The raw pino root; buildServer hands it to Fastify so HTTP shares the stream. */\n loggerInstance: PinoInstance;\n /** Request-scoped log correlation; the HTTP layer enters it per request. */\n requestContext: RequestLogContext;\n /** Identity's session-write port; the auth adapter fulfils it. */\n sessions: SessionAdmin;\n}\n\nexport async function buildContainer(\n config: Config,\n options?: BuildContainerOptions,\n): Promise<Container> {\n const { instance: loggerInstance, logger } = createRootLogger(\n resolveLoggingConfig(config.logging).level,\n );\n // One child per domain — a context's service and repositories share it.\n\n const contentLogger = logger.child({ name: 'content' });\n const entitlementsLogger = logger.child({ name: 'entitlements' });\n const progressLogger = logger.child({ name: 'progress' });\n const discussionLogger = logger.child({ name: 'discussion' });\n const assetsLogger = logger.child({ name: 'assets' });\n const integrationsLogger = logger.child({ name: 'integrations' });\n const automationsLogger = logger.child({ name: 'automations' });\n const reportingLogger = logger.child({ name: 'reporting' });\n const outboxLogger = logger.child({ name: 'outbox' });\n\n // Outbound adapters\n const db = createDb(config.databaseUrl);\n const email = options?.adapters?.email ?? new EmailAdapter(logger.child({ name: 'email' }));\n const storage: ObjectStorage =\n options?.adapters?.storage ?? new StorageAdapter(logger.child({ name: 'storage' }));\n const templates =\n options?.adapters?.templates ?? new StubTemplateRenderer(logger.child({ name: 'email' }));\n const automationEngine: AutomationEngine =\n options?.adapters?.workflows ?? new InlineAutomationEngine();\n const mailer = new Mailer(templates, email, {\n ...(config.emailBranding ?? { brandName: 'Headless LMS', baseUrl: config.adminAppUrl }),\n studentPortalUrl: config.studentPortalUrl,\n });\n\n /*\n * Identity\n */\n const identityUoW = new DrizzleUnitOfWork(db, (tx) => ({\n identity: new DrizzleIdentityRepository(tx, logger.child({ name: 'identity' })),\n outbox: new DrizzleOutboxAppender(tx, logger.child({ name: 'identity' })),\n }));\n const identity = new IdentityServiceImpl({\n repo: new DrizzleIdentityRepository(db, logger.child({ name: 'identity_repo' })),\n logger: logger.child({ name: 'identity' }),\n uow: identityUoW,\n mailer,\n });\n\n /*\n * Organization\n */\n const organizationsUow = new DrizzleUnitOfWork(db, (tx) => ({\n organizations: new DrizzleOrganizationsRepository(tx, logger.child({ name: 'org' })),\n outbox: new DrizzleOutboxAppender(tx, logger.child({ name: 'org' })),\n }));\n const orgAdminRef: { current: OrgAdmin | undefined } = { current: undefined };\n const organizations = new OrganizationServiceImpl({\n repo: new DrizzleOrganizationsRepository(db, logger.child({ name: 'org' })),\n membersRepo: new DrizzleMembersRepository(db, logger.child({ name: 'org' })),\n people: identity,\n uow: organizationsUow,\n orgAdmin: () => {\n if (!orgAdminRef.current) {\n throw new Error('orgAdmin not initialised');\n }\n return orgAdminRef.current;\n },\n logger: logger.child({ name: 'org' }),\n mailer,\n inviteUrls: { studentPortalUrl: config.studentPortalUrl, adminAppUrl: config.adminAppUrl },\n });\n\n /*\n * Settings\n */\n const settings = new SettingsService(\n new DrizzleSettingsRepository(db, logger.child({ name: 'settings' })),\n );\n // Content: reads on the root db; course writes + outbox append in one tx.\n const contentUow = new DrizzleUnitOfWork(db, (tx) => ({\n content: new DrizzleContentRepository(tx, contentLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n }));\n const content = new ContentService({\n repo: new DrizzleContentRepository(db, contentLogger),\n uow: contentUow,\n logger: contentLogger,\n });\n // Entitlements: reads on the root db; writes + outbox append in one tx.\n const entitlementsUow = new DrizzleUnitOfWork(db, (tx) => ({\n entitlements: new DrizzleEntitlementsRepository(tx, entitlementsLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n }));\n const entitlements = new EntitlementsServiceImpl({\n repo: new DrizzleEntitlementsRepository(db, entitlementsLogger),\n uow: entitlementsUow,\n logger: entitlementsLogger,\n });\n // Progress: report writes + outbox append in one tx; content supplies the\n // structure and completion rules the service evaluates against.\n const progressUow = new DrizzleUnitOfWork(db, (tx) => ({\n progress: new DrizzleProgressRepository(tx, progressLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n }));\n const progress = new ProgressServiceImpl({\n repo: new DrizzleProgressRepository(db, progressLogger),\n content,\n uow: progressUow,\n logger: progressLogger,\n });\n\n const discussion = new DiscussionServiceImpl({\n repo: new DrizzleDiscussionRepository(db, discussionLogger),\n access: entitlements,\n content: content,\n uow: new DrizzleUnitOfWork(db, (tx) => ({\n discussion: new DrizzleDiscussionRepository(tx, discussionLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n })),\n settings,\n logger: discussionLogger,\n });\n const assets = new AssetsServiceImpl({\n storage,\n repo: new DrizzleAssetsRepository(db, assetsLogger),\n uow: new DrizzleUnitOfWork(db, (tx) => ({\n assets: new DrizzleAssetsRepository(tx, assetsLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n })),\n logger: assetsLogger,\n });\n\n const reporting = {\n students: new StudentsReportServiceImpl({\n repo: new DrizzleStudentsRepository(db, reportingLogger),\n logger: reportingLogger,\n }),\n dashboard: new DashboardReportServiceImpl({\n repo: new DrizzleDashboardRepository(db, reportingLogger),\n logger: reportingLogger,\n }),\n courses: new CoursesReportServiceImpl({\n repo: new DrizzleCourseAnalyticsRepository(db, reportingLogger),\n logger: reportingLogger,\n }),\n learn: new LearnReportServiceImpl({\n reader: new DrizzleLearnRepository(db, reportingLogger),\n content,\n progress,\n assets,\n deliveryExpirySeconds: config.deliveryExpirySeconds,\n logger: reportingLogger,\n }),\n };\n\n const credentialStore = new DrizzleCredentialStore(\n db,\n config.credentialStoreKey,\n integrationsLogger,\n );\n // The integrations this deployment supports: the installation's declared\n // plugins folder (directory name = integration id), loaded at startup.\n // Connect/configure reject undeclared ids and validate config with the\n // integration's own schema. No folder → no integrations.\n const integrationsRegistry = await loadIntegrations(options?.pluginsDir, integrationsLogger);\n // Integrations: credential + connection writes + outbox append in one tx\n // (a tx-bound credential store instance shares the scope's transaction).\n const integrationsUow = new DrizzleUnitOfWork(db, (tx) => ({\n connections: new DrizzleConnectionsRepository(tx, integrationsLogger),\n credentials: new DrizzleCredentialStore(tx, config.credentialStoreKey, integrationsLogger),\n outbox: new DrizzleOutboxAppender(tx, outboxLogger),\n }));\n const integrations = new IntegrationsServiceImpl({\n registry: integrationsRegistry,\n repo: new DrizzleConnectionsRepository(db, integrationsLogger),\n uow: integrationsUow,\n credentials: credentialStore,\n logger: integrationsLogger,\n });\n\n // Automations: run writes + outbox append in one tx; the engine drives\n // execution (register below), integrations supplies the loaded\n // integrations' actions for `available`.\n const automationsUow = new DrizzleUnitOfWork(db, (tx) => ({\n automations: new DrizzleAutomationsRepository(tx, automationsLogger),\n runs: new DrizzleAutomationRunsRepository(tx, automationsLogger),\n credentials: new DrizzleCredentialStore(tx, config.credentialStoreKey, automationsLogger),\n outbox: new DrizzleOutboxAppender(tx, automationsLogger),\n }));\n // Emails triggered by row-shaped events resolve their recipient and content\n // details at send time — the event carries ids only.\n const mailerLookups: MailerLookups = {\n orgUserEmail: async (orgId, orgUserId) =>\n (await reporting.students.get(orgId, orgUserId))?.email ?? null,\n contentInfo: async (orgId, contentId) => {\n const course = await content.getCourse(orgId, contentId);\n if (course) {\n return { id: course.id, title: course.title, type: course.type };\n }\n const download = await content.getDownload(orgId, contentId);\n return download ? { id: download.id, title: download.title, type: download.type } : null;\n },\n };\n\n const automations = new AutomationsServiceImpl({\n repo: new DrizzleAutomationsRepository(db, automationsLogger),\n runsRepo: new DrizzleAutomationRunsRepository(db, automationsLogger),\n uow: automationsUow,\n engine: automationEngine,\n mailer,\n lookups: mailerLookups,\n integrations,\n credentials: credentialStore,\n webhooks: options?.adapters?.webhooks ?? new FetchWebhookSender(),\n logger: automationsLogger,\n });\n automationEngine.register(automations);\n\n const eventBus = new InMemoryEventBus();\n registerNotificationSubscribers(eventBus, mailer, mailerLookups);\n // handle() never throws — the EventBus fans out to every subscriber\n // sequentially, and a rejection here would break sibling subscribers.\n eventBus.subscribeAll((event) => automations.handle(event));\n const outboxConfig = resolveOutboxConfig(config.outbox);\n const outboxRelay = new PollingOutboxRelay(\n new DrizzleOutboxStore(db, outboxLogger),\n eventBus,\n outboxConfig,\n outboxLogger,\n );\n\n /*\n * Auth Adapter\n */\n const authLogger = logger.child({ name: 'auth' });\n // Hook failures otherwise surface through Better Auth's route as an opaque\n // 500 — log which hook died before rethrowing.\n const authHook =\n <A extends unknown[], R>(name: string, fn: (...args: A) => Promise<R>) =>\n async (...args: A): Promise<R> => {\n try {\n return await fn(...args);\n } catch (err) {\n authLogger.error(`auth hook ${name} failed`, { err });\n throw err;\n }\n };\n const auth = new BetterAuth({\n db,\n baseUrl: config.authBaseURL,\n secret: config.authSecret,\n trustedOrigins: config.trustedOrigins,\n hooks: {\n sendResetPassword: async ({ user, url }) => {\n await identity.sendPasswordReset({ email: user.email, url });\n },\n onPasswordReset: async ({ user }) => {\n await identity.sendPasswordChanged({ email: user.email });\n },\n sendMagicLink: authHook('sendMagicLink', async ({ email, url }) => {\n await identity.sendMagicLink({\n email,\n url,\n });\n }),\n beforeUserCreate: authHook('beforeUserCreate', async (user) => {\n const { email, name } = user;\n const [firstName, lastName] = name.split(' ');\n // Better Auth has not minted an id yet — the id returned below becomes\n // its account id. A person provisioned at invite time is linked instead of re-created.\n const domainUser = await identity.linkOrCreateUser({\n email,\n ...(firstName !== undefined && { firstName }),\n ...(lastName !== undefined && { lastName }),\n });\n\n return {\n data: {\n ...user,\n id: domainUser.id,\n },\n };\n }),\n beforeCreateSession: authHook('beforeCreateSession', async (session) => {\n const person = await identity.getUserByExternalId(session.userId);\n if (!person) {\n return;\n }\n const orgs = await organizations.getOrgUsersForUser(person.id);\n if (orgs.length !== 1) {\n return;\n }\n // TODO - fix, why default to the first?\n const org = await organizations.getById(orgs[0]!.orgId);\n if (!org) {\n return;\n }\n\n return { data: { ...session, activeOrganizationId: org.id } };\n }),\n /*\n * We're making sure the domain org creates and using the same ID for better-auth org ID.\n */\n beforeCreateOrganization: authHook(\n 'beforeCreateOrganization',\n async ({ organization: org, user: baUser }) => {\n const domainOrg = await organizations.createOrganization({\n ownerId: baUser.id,\n ...org,\n logo: org.logo ?? undefined,\n });\n return { data: { ...org, id: domainOrg.id } };\n },\n ),\n beforeUpdateOrganization: authHook('beforeUpdateOrganization', async ({ organization }) => {\n await organizations.updateOrganization(organization.id, organization);\n }),\n beforeDeleteOrganization: authHook('beforeDeleteOrganization', async ({ organization }) => {\n await organizations.deleteOrganization(organization.id);\n }),\n beforeAddMember: authHook('beforeAddMember', async ({ member }) => {\n await organizations.addOrgUser({\n orgId: member.organizationId,\n userId: member.userId,\n role: parseRole(member.role),\n });\n }),\n beforeUpdateMemberRole: authHook('beforeUpdateMemberRole', async ({ member, newRole }) => {\n await organizations.updateOrgUser(member.organizationId, member.userId, {\n role: parseRole(newRole),\n });\n }),\n beforeRemoveMember: authHook('beforeRemoveMember', async ({ member }) => {\n await organizations.removeOrgUser(member.organizationId, member.userId);\n }),\n },\n logger: authLogger,\n cookieDomain: config.cookieDomain,\n secureCookies: config.secureCookies,\n });\n orgAdminRef.current = auth;\n\n return {\n auth,\n identity,\n organizations,\n // Better Auth owns org writes; the same instance fulfils the OrgAdmin port.\n orgProvider: auth,\n content,\n entitlements,\n progress,\n discussion,\n assets,\n integrations,\n automations,\n settings,\n reporting,\n storage,\n mailer,\n credentials: credentialStore,\n outboxRelay,\n automationEngine,\n logger,\n loggerInstance,\n requestContext: requestLogContext,\n // Better Auth owns the session store; the same instance fulfils SessionAdmin.\n sessions: auth,\n };\n}\n"],"mappings":"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"}
@@ -3,15 +3,18 @@ import { entitlementEvents } from "@headless-lms/core/entitlements";
3
3
  function registerNotificationSubscribers(bus, mailer, lookups) {
4
4
  bus.subscribe(entitlementEvents.entitlementCreated, async (event) => {
5
5
  const { orgUserId, contentId } = event.data;
6
+ if (!contentId) return;
6
7
  const [to, content] = await Promise.all([lookups.orgUserEmail(event.orgId, orgUserId), lookups.contentInfo(event.orgId, contentId)]);
7
8
  if (!to || !content) throw new Error(`entitlement notification lookups failed: ${event.data.id}`);
8
9
  await mailer.send(to, "accessGranted", {
9
10
  contentTitle: content.title,
10
- contentId: content.id
11
+ contentId: content.id,
12
+ contentType: content.type
11
13
  });
12
14
  });
13
15
  bus.subscribe(entitlementEvents.entitlementDeleted, async (event) => {
14
16
  const { orgUserId, contentId } = event.data;
17
+ if (!contentId) return;
15
18
  const [to, content] = await Promise.all([lookups.orgUserEmail(event.orgId, orgUserId), lookups.contentInfo(event.orgId, contentId)]);
16
19
  if (!to || !content) throw new Error(`entitlement notification lookups failed: ${event.data.id}`);
17
20
  await mailer.send(to, "accessRevoked", { contentTitle: content.title });
@@ -1 +1 @@
1
- {"version":3,"file":"notifications.js","names":[],"sources":["../../src/app/notifications.ts"],"sourcesContent":["// Email side-effects of domain events, subscribed on the EventBus. Events\n// reach the bus via the outbox relay at-least-once: a mailer failure throws\n// through publish, and the relay retries with backoff — no email is silently\n// dropped.\nimport type { EventBus } from '@headless-lms/core/shared/ports';\nimport type { Mailer, MailerLookups } from '@headless-lms/core/shared/mailer';\nimport { entitlementEvents } from '@headless-lms/core/entitlements';\n\nexport function registerNotificationSubscribers(\n bus: EventBus,\n mailer: Pick<Mailer, 'send'>,\n lookups: MailerLookups,\n): void {\n bus.subscribe(entitlementEvents.entitlementCreated, async (event) => {\n const { orgUserId, contentId } = event.data;\n const [to, content] = await Promise.all([\n lookups.orgUserEmail(event.orgId, orgUserId),\n lookups.contentInfo(event.orgId, contentId),\n ]);\n if (!to || !content) {\n throw new Error(`entitlement notification lookups failed: ${event.data.id}`);\n }\n await mailer.send(to, 'accessGranted', {\n contentTitle: content.title,\n contentId: content.id,\n });\n });\n\n bus.subscribe(entitlementEvents.entitlementDeleted, async (event) => {\n const { orgUserId, contentId } = event.data;\n const [to, content] = await Promise.all([\n lookups.orgUserEmail(event.orgId, orgUserId),\n lookups.contentInfo(event.orgId, contentId),\n ]);\n if (!to || !content) {\n throw new Error(`entitlement notification lookups failed: ${event.data.id}`);\n }\n await mailer.send(to, 'accessRevoked', {\n contentTitle: content.title,\n });\n });\n}\n"],"mappings":";;AAQA,SAAgB,gCACd,KACA,QACA,SACM;CACN,IAAI,UAAU,kBAAkB,oBAAoB,OAAO,UAAU;EACnE,MAAM,EAAE,WAAW,cAAc,MAAM;EACvC,MAAM,CAAC,IAAI,WAAW,MAAM,QAAQ,IAAI,CACtC,QAAQ,aAAa,MAAM,OAAO,SAAS,GAC3C,QAAQ,YAAY,MAAM,OAAO,SAAS,CAC5C,CAAC;EACD,IAAI,CAAC,MAAM,CAAC,SACV,MAAM,IAAI,MAAM,4CAA4C,MAAM,KAAK,IAAI;EAE7E,MAAM,OAAO,KAAK,IAAI,iBAAiB;GACrC,cAAc,QAAQ;GACtB,WAAW,QAAQ;EACrB,CAAC;CACH,CAAC;CAED,IAAI,UAAU,kBAAkB,oBAAoB,OAAO,UAAU;EACnE,MAAM,EAAE,WAAW,cAAc,MAAM;EACvC,MAAM,CAAC,IAAI,WAAW,MAAM,QAAQ,IAAI,CACtC,QAAQ,aAAa,MAAM,OAAO,SAAS,GAC3C,QAAQ,YAAY,MAAM,OAAO,SAAS,CAC5C,CAAC;EACD,IAAI,CAAC,MAAM,CAAC,SACV,MAAM,IAAI,MAAM,4CAA4C,MAAM,KAAK,IAAI;EAE7E,MAAM,OAAO,KAAK,IAAI,iBAAiB,EACrC,cAAc,QAAQ,MACxB,CAAC;CACH,CAAC;AACH"}
1
+ {"version":3,"file":"notifications.js","names":[],"sources":["../../src/app/notifications.ts"],"sourcesContent":["// Email side-effects of domain events, subscribed on the EventBus. Events\n// reach the bus via the outbox relay at-least-once: a mailer failure throws\n// through publish, and the relay retries with backoff — no email is silently\n// dropped.\nimport type { EventBus } from '@headless-lms/core/shared/ports';\nimport type { Mailer, MailerLookups } from '@headless-lms/core/shared/mailer';\nimport { entitlementEvents } from '@headless-lms/core/entitlements';\n\nexport function registerNotificationSubscribers(\n bus: EventBus,\n mailer: Pick<Mailer, 'send'>,\n lookups: MailerLookups,\n): void {\n bus.subscribe(entitlementEvents.entitlementCreated, async (event) => {\n const { orgUserId, contentId } = event.data;\n // Bundle grants carry no content id — these emails are per content item.\n if (!contentId) {\n return;\n }\n const [to, content] = await Promise.all([\n lookups.orgUserEmail(event.orgId, orgUserId),\n lookups.contentInfo(event.orgId, contentId),\n ]);\n if (!to || !content) {\n throw new Error(`entitlement notification lookups failed: ${event.data.id}`);\n }\n await mailer.send(to, 'accessGranted', {\n contentTitle: content.title,\n contentId: content.id,\n contentType: content.type,\n });\n });\n\n bus.subscribe(entitlementEvents.entitlementDeleted, async (event) => {\n const { orgUserId, contentId } = event.data;\n // Bundle grants carry no content id — these emails are per content item.\n if (!contentId) {\n return;\n }\n const [to, content] = await Promise.all([\n lookups.orgUserEmail(event.orgId, orgUserId),\n lookups.contentInfo(event.orgId, contentId),\n ]);\n if (!to || !content) {\n throw new Error(`entitlement notification lookups failed: ${event.data.id}`);\n }\n await mailer.send(to, 'accessRevoked', {\n contentTitle: content.title,\n });\n });\n}\n"],"mappings":";;AAQA,SAAgB,gCACd,KACA,QACA,SACM;CACN,IAAI,UAAU,kBAAkB,oBAAoB,OAAO,UAAU;EACnE,MAAM,EAAE,WAAW,cAAc,MAAM;EAEvC,IAAI,CAAC,WACH;EAEF,MAAM,CAAC,IAAI,WAAW,MAAM,QAAQ,IAAI,CACtC,QAAQ,aAAa,MAAM,OAAO,SAAS,GAC3C,QAAQ,YAAY,MAAM,OAAO,SAAS,CAC5C,CAAC;EACD,IAAI,CAAC,MAAM,CAAC,SACV,MAAM,IAAI,MAAM,4CAA4C,MAAM,KAAK,IAAI;EAE7E,MAAM,OAAO,KAAK,IAAI,iBAAiB;GACrC,cAAc,QAAQ;GACtB,WAAW,QAAQ;GACnB,aAAa,QAAQ;EACvB,CAAC;CACH,CAAC;CAED,IAAI,UAAU,kBAAkB,oBAAoB,OAAO,UAAU;EACnE,MAAM,EAAE,WAAW,cAAc,MAAM;EAEvC,IAAI,CAAC,WACH;EAEF,MAAM,CAAC,IAAI,WAAW,MAAM,QAAQ,IAAI,CACtC,QAAQ,aAAa,MAAM,OAAO,SAAS,GAC3C,QAAQ,YAAY,MAAM,OAAO,SAAS,CAC5C,CAAC;EACD,IAAI,CAAC,MAAM,CAAC,SACV,MAAM,IAAI,MAAM,4CAA4C,MAAM,KAAK,IAAI;EAE7E,MAAM,OAAO,KAAK,IAAI,iBAAiB,EACrC,cAAc,QAAQ,MACxB,CAAC;CACH,CAAC;AACH"}
@@ -1,6 +1,6 @@
1
1
  import { FastifyReply, FastifyRequest } from "fastify";
2
2
  //#region src/http/plugins/error-handler.d.ts
3
- declare function errorHandler(error: unknown, _request: FastifyRequest, reply: FastifyReply): FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
3
+ declare function errorHandler(error: unknown, request: FastifyRequest, reply: FastifyReply): FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
4
4
  //#endregion
5
5
  export { errorHandler };
6
6
  //# sourceMappingURL=error-handler.d.ts.map
@@ -2,10 +2,10 @@ import { UnauthorizedError } from "./auth.js";
2
2
  import { NoActiveOrgError } from "../scope.js";
3
3
  import { OrganizationRuleError } from "@headless-lms/core/organizations";
4
4
  import { ActionInvocationError, AlreadyConnectedError, InvalidConfigError, UnknownIntegrationError } from "@headless-lms/core/integrations";
5
- import { InvalidTriggerError } from "@headless-lms/core/automations";
5
+ import { AutomationKindMismatchError, InvalidTriggerError, ReservedActionError } from "@headless-lms/core/automations";
6
6
  import { ConflictError, ForbiddenError, NotFoundError } from "@headless-lms/core/shared/errors";
7
7
  //#region src/http/plugins/error-handler.ts
8
- function errorHandler(error, _request, reply) {
8
+ function errorHandler(error, request, reply) {
9
9
  if (error instanceof NotFoundError) return reply.status(404).send({
10
10
  error: "not_found",
11
11
  message: error.message
@@ -42,6 +42,14 @@ function errorHandler(error, _request, reply) {
42
42
  error: "invalid_trigger",
43
43
  message: error.message
44
44
  });
45
+ if (error instanceof ReservedActionError) return reply.status(400).send({
46
+ error: "reserved_action",
47
+ message: error.message
48
+ });
49
+ if (error instanceof AutomationKindMismatchError) return reply.status(409).send({
50
+ error: "kind_mismatch",
51
+ message: error.message
52
+ });
45
53
  if (error instanceof NoActiveOrgError) return reply.status(403).send({
46
54
  error: "forbidden",
47
55
  message: error.message
@@ -55,8 +63,11 @@ function errorHandler(error, _request, reply) {
55
63
  error: err.code ?? "bad_request",
56
64
  message: err.message ?? "Bad request"
57
65
  });
58
- _request.log.error(error);
59
- return reply.status(500).send({ error: "internal_error" });
66
+ request.log.error({ err: error }, `unhandled error in ${request.method} ${request.url}`);
67
+ return reply.status(500).send({
68
+ error: "internal_error",
69
+ requestId: request.id
70
+ });
60
71
  }
61
72
  //#endregion
62
73
  export { errorHandler };
@@ -1 +1 @@
1
- {"version":3,"file":"error-handler.js","names":[],"sources":["../../../src/http/plugins/error-handler.ts"],"sourcesContent":["import { ConflictError, ForbiddenError, NotFoundError } from '@headless-lms/core/shared/errors';\nimport { OrganizationRuleError } from '@headless-lms/core/organizations';\nimport {\n ActionInvocationError,\n AlreadyConnectedError,\n InvalidConfigError,\n UnknownIntegrationError,\n} from '@headless-lms/core/integrations';\nimport { InvalidTriggerError } from '@headless-lms/core/automations';\nimport { NoActiveOrgError } from '../scope.js';\nimport { UnauthorizedError } from './auth.js';\nimport type { FastifyReply, FastifyRequest } from 'fastify';\n\nexport function errorHandler(error: unknown, _request: FastifyRequest, reply: FastifyReply) {\n if (error instanceof NotFoundError) {\n return reply.status(404).send({ error: 'not_found', message: error.message });\n }\n if (error instanceof ConflictError) {\n return reply.status(409).send({ error: 'conflict', message: error.message });\n }\n if (error instanceof ForbiddenError) {\n return reply.status(403).send({ error: 'forbidden', message: error.message });\n }\n if (error instanceof OrganizationRuleError) {\n return reply.status(409).send({ error: 'conflict', message: error.message });\n }\n if (error instanceof AlreadyConnectedError) {\n return reply.status(409).send({ error: 'already_connected', message: error.message });\n }\n if (error instanceof UnknownIntegrationError) {\n return reply.status(400).send({ error: 'unknown_integration', message: error.message });\n }\n if (error instanceof InvalidConfigError) {\n return reply.status(400).send({ error: 'invalid_config', message: error.message });\n }\n if (error instanceof ActionInvocationError) {\n return reply.status(502).send({ error: 'action_failed', message: error.message });\n }\n if (error instanceof InvalidTriggerError) {\n return reply.status(400).send({ error: 'invalid_trigger', message: error.message });\n }\n if (error instanceof NoActiveOrgError) {\n return reply.status(403).send({ error: 'forbidden', message: error.message });\n }\n // No session at all — 401 so the client bounces to login, not a generic 403.\n if (error instanceof UnauthorizedError) {\n return reply.status(401).send({ error: 'unauthorized', message: error.message });\n }\n const err = error as { statusCode?: number; code?: string; message?: string };\n if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 500) {\n return reply\n .status(err.statusCode)\n .send({ error: err.code ?? 'bad_request', message: err.message ?? 'Bad request' });\n }\n _request.log.error(error);\n return reply.status(500).send({ error: 'internal_error' });\n}\n"],"mappings":";;;;;;;AAaA,SAAgB,aAAa,OAAgB,UAA0B,OAAqB;CAC1F,IAAI,iBAAiB,eACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAa,SAAS,MAAM;CAAQ,CAAC;CAE9E,IAAI,iBAAiB,eACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAY,SAAS,MAAM;CAAQ,CAAC;CAE7E,IAAI,iBAAiB,gBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAa,SAAS,MAAM;CAAQ,CAAC;CAE9E,IAAI,iBAAiB,uBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAY,SAAS,MAAM;CAAQ,CAAC;CAE7E,IAAI,iBAAiB,uBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAqB,SAAS,MAAM;CAAQ,CAAC;CAEtF,IAAI,iBAAiB,yBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAuB,SAAS,MAAM;CAAQ,CAAC;CAExF,IAAI,iBAAiB,oBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAkB,SAAS,MAAM;CAAQ,CAAC;CAEnF,IAAI,iBAAiB,uBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAiB,SAAS,MAAM;CAAQ,CAAC;CAElF,IAAI,iBAAiB,qBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAmB,SAAS,MAAM;CAAQ,CAAC;CAEpF,IAAI,iBAAiB,kBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAa,SAAS,MAAM;CAAQ,CAAC;CAG9E,IAAI,iBAAiB,mBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAgB,SAAS,MAAM;CAAQ,CAAC;CAEjF,MAAM,MAAM;CACZ,IAAI,OAAO,IAAI,eAAe,YAAY,IAAI,cAAc,OAAO,IAAI,aAAa,KAClF,OAAO,MACJ,OAAO,IAAI,UAAU,CAAC,CACtB,KAAK;EAAE,OAAO,IAAI,QAAQ;EAAe,SAAS,IAAI,WAAW;CAAc,CAAC;CAErF,SAAS,IAAI,MAAM,KAAK;CACxB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK,EAAE,OAAO,iBAAiB,CAAC;AAC3D"}
1
+ {"version":3,"file":"error-handler.js","names":[],"sources":["../../../src/http/plugins/error-handler.ts"],"sourcesContent":["import { ConflictError, ForbiddenError, NotFoundError } from '@headless-lms/core/shared/errors';\nimport { 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 { NoActiveOrgError } from '../scope.js';\nimport { UnauthorizedError } from './auth.js';\nimport type { FastifyReply, FastifyRequest } from 'fastify';\n\nexport function errorHandler(error: unknown, request: FastifyRequest, reply: FastifyReply) {\n if (error instanceof NotFoundError) {\n return reply.status(404).send({ error: 'not_found', message: error.message });\n }\n if (error instanceof ConflictError) {\n return reply.status(409).send({ error: 'conflict', message: error.message });\n }\n if (error instanceof ForbiddenError) {\n return reply.status(403).send({ error: 'forbidden', message: error.message });\n }\n if (error instanceof OrganizationRuleError) {\n return reply.status(409).send({ error: 'conflict', message: error.message });\n }\n if (error instanceof AlreadyConnectedError) {\n return reply.status(409).send({ error: 'already_connected', message: error.message });\n }\n if (error instanceof UnknownIntegrationError) {\n return reply.status(400).send({ error: 'unknown_integration', message: error.message });\n }\n if (error instanceof InvalidConfigError) {\n return reply.status(400).send({ error: 'invalid_config', message: error.message });\n }\n if (error instanceof ActionInvocationError) {\n return reply.status(502).send({ error: 'action_failed', message: error.message });\n }\n if (error instanceof InvalidTriggerError) {\n return reply.status(400).send({ error: 'invalid_trigger', message: error.message });\n }\n if (error instanceof ReservedActionError) {\n return reply.status(400).send({ error: 'reserved_action', message: error.message });\n }\n if (error instanceof AutomationKindMismatchError) {\n return reply.status(409).send({ error: 'kind_mismatch', message: error.message });\n }\n if (error instanceof NoActiveOrgError) {\n return reply.status(403).send({ error: 'forbidden', message: error.message });\n }\n // No session at all — 401 so the client bounces to login, not a generic 403.\n if (error instanceof UnauthorizedError) {\n return reply.status(401).send({ error: 'unauthorized', message: error.message });\n }\n const err = error as { statusCode?: number; code?: string; message?: string };\n if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 500) {\n return reply\n .status(err.statusCode)\n .send({ error: err.code ?? 'bad_request', message: err.message ?? 'Bad request' });\n }\n request.log.error({ err: error }, `unhandled error in ${request.method} ${request.url}`);\n return reply.status(500).send({ error: 'internal_error', requestId: request.id });\n}\n"],"mappings":";;;;;;;AAiBA,SAAgB,aAAa,OAAgB,SAAyB,OAAqB;CACzF,IAAI,iBAAiB,eACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAa,SAAS,MAAM;CAAQ,CAAC;CAE9E,IAAI,iBAAiB,eACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAY,SAAS,MAAM;CAAQ,CAAC;CAE7E,IAAI,iBAAiB,gBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAa,SAAS,MAAM;CAAQ,CAAC;CAE9E,IAAI,iBAAiB,uBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAY,SAAS,MAAM;CAAQ,CAAC;CAE7E,IAAI,iBAAiB,uBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAqB,SAAS,MAAM;CAAQ,CAAC;CAEtF,IAAI,iBAAiB,yBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAuB,SAAS,MAAM;CAAQ,CAAC;CAExF,IAAI,iBAAiB,oBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAkB,SAAS,MAAM;CAAQ,CAAC;CAEnF,IAAI,iBAAiB,uBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAiB,SAAS,MAAM;CAAQ,CAAC;CAElF,IAAI,iBAAiB,qBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAmB,SAAS,MAAM;CAAQ,CAAC;CAEpF,IAAI,iBAAiB,qBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAmB,SAAS,MAAM;CAAQ,CAAC;CAEpF,IAAI,iBAAiB,6BACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAiB,SAAS,MAAM;CAAQ,CAAC;CAElF,IAAI,iBAAiB,kBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAa,SAAS,MAAM;CAAQ,CAAC;CAG9E,IAAI,iBAAiB,mBACnB,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAgB,SAAS,MAAM;CAAQ,CAAC;CAEjF,MAAM,MAAM;CACZ,IAAI,OAAO,IAAI,eAAe,YAAY,IAAI,cAAc,OAAO,IAAI,aAAa,KAClF,OAAO,MACJ,OAAO,IAAI,UAAU,CAAC,CACtB,KAAK;EAAE,OAAO,IAAI,QAAQ;EAAe,SAAS,IAAI,WAAW;CAAc,CAAC;CAErF,QAAQ,IAAI,MAAM,EAAE,KAAK,MAAM,GAAG,sBAAsB,QAAQ,OAAO,GAAG,QAAQ,KAAK;CACvF,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAAE,OAAO;EAAkB,WAAW,QAAQ;CAAG,CAAC;AAClF"}
@@ -1,4 +1,5 @@
1
- import { jsonSchemaTransform } from "fastify-type-provider-zod";
1
+ import { version } from "../../package.js";
2
+ import { jsonSchemaTransform, jsonSchemaTransformObject } from "fastify-type-provider-zod";
2
3
  import swagger from "@fastify/swagger";
3
4
  import swaggerUi from "@fastify/swagger-ui";
4
5
  //#region src/http/plugins/openapi.ts
@@ -7,25 +8,22 @@ function registerOpenApi(app, config) {
7
8
  openapi: {
8
9
  info: {
9
10
  title: "Headless LMS API",
10
- version: "0.0.0"
11
+ version,
12
+ description: "API documentation for the Headless LMS platform",
13
+ license: {
14
+ name: "MIT",
15
+ url: "https://opensource.org/licenses/MIT"
16
+ }
11
17
  },
12
18
  servers: [{ url: config.publicUrl }],
13
19
  tags: [
14
20
  {
15
21
  name: "Organizations",
16
- description: "Organizations and member management"
17
- },
18
- {
19
- name: "Courses",
20
- description: "Course authoring: courses, modules, activities"
22
+ description: "The tenant: its members, roles, invites and students"
21
23
  },
22
24
  {
23
- name: "Learn",
24
- description: "Student-facing course delivery"
25
- },
26
- {
27
- name: "Students",
28
- description: "Students and their enrollments"
25
+ name: "Content",
26
+ description: "Authored content: courses and downloads, their structure and delivery"
29
27
  },
30
28
  {
31
29
  name: "Entitlements",
@@ -33,27 +31,32 @@ function registerOpenApi(app, config) {
33
31
  },
34
32
  {
35
33
  name: "Progress",
36
- description: "Completion tracking"
34
+ description: "Per-student progress and completion through course content"
37
35
  },
38
36
  {
39
- name: "Automations",
40
- description: "Trigger/action workflows"
37
+ name: "Discussion",
38
+ description: "Learner comments, replies, reactions and moderation"
41
39
  },
42
40
  {
43
- name: "Dashboard",
44
- description: "Back-office overview"
41
+ name: "Assets",
42
+ description: "The org media library: uploads, serving and lifecycle"
45
43
  },
46
44
  {
47
- name: "Assets",
48
- description: "Media library and uploads"
45
+ name: "Automations",
46
+ description: "Workflows and webhooks that react to system events"
49
47
  },
50
48
  {
51
49
  name: "Integrations",
52
- description: "Third-party integration connections"
50
+ description: "Connections to external services"
51
+ },
52
+ {
53
+ name: "Reporting",
54
+ description: "Reporting and analytics"
53
55
  }
54
56
  ]
55
57
  },
56
- transform: jsonSchemaTransform
58
+ transform: jsonSchemaTransform,
59
+ transformObject: jsonSchemaTransformObject
57
60
  });
58
61
  app.register(swaggerUi, { routePrefix: "/docs" });
59
62
  }
@@ -1 +1 @@
1
- {"version":3,"file":"openapi.js","names":[],"sources":["../../../src/http/plugins/openapi.ts"],"sourcesContent":["// OpenAPI documentation. @fastify/swagger reads the Zod route schemas (via the\n// transform) to build the spec the frontend SDK is generated from; it must be\n// registered before the routes so its onRoute hook captures them. UI + JSON are\n// served under /docs.\nimport type { FastifyInstance } from 'fastify';\nimport swagger from '@fastify/swagger';\nimport swaggerUi from '@fastify/swagger-ui';\nimport { jsonSchemaTransform } from 'fastify-type-provider-zod';\nimport type { ServerConfig } from '../config.js';\n\nexport function registerOpenApi(app: FastifyInstance, config: ServerConfig): void {\n app.register(swagger, {\n openapi: {\n info: { title: 'Headless LMS API', version: '0.0.0' },\n servers: [{ url: config.publicUrl }],\n tags: [\n { name: 'Organizations', description: 'Organizations and member management' },\n { name: 'Courses', description: 'Course authoring: courses, modules, activities' },\n { name: 'Learn', description: 'Student-facing course delivery' },\n { name: 'Students', description: 'Students and their enrollments' },\n { name: 'Entitlements', description: 'Access grants to content' },\n { name: 'Progress', description: 'Completion tracking' },\n { name: 'Automations', description: 'Trigger/action workflows' },\n { name: 'Dashboard', description: 'Back-office overview' },\n { name: 'Assets', description: 'Media library and uploads' },\n { name: 'Integrations', description: 'Third-party integration connections' },\n ],\n },\n transform: jsonSchemaTransform,\n });\n app.register(swaggerUi, { routePrefix: '/docs' });\n}\n"],"mappings":";;;;AAUA,SAAgB,gBAAgB,KAAsB,QAA4B;CAChF,IAAI,SAAS,SAAS;EACpB,SAAS;GACP,MAAM;IAAE,OAAO;IAAoB,SAAS;GAAQ;GACpD,SAAS,CAAC,EAAE,KAAK,OAAO,UAAU,CAAC;GACnC,MAAM;IACJ;KAAE,MAAM;KAAiB,aAAa;IAAsC;IAC5E;KAAE,MAAM;KAAW,aAAa;IAAiD;IACjF;KAAE,MAAM;KAAS,aAAa;IAAiC;IAC/D;KAAE,MAAM;KAAY,aAAa;IAAiC;IAClE;KAAE,MAAM;KAAgB,aAAa;IAA2B;IAChE;KAAE,MAAM;KAAY,aAAa;IAAsB;IACvD;KAAE,MAAM;KAAe,aAAa;IAA2B;IAC/D;KAAE,MAAM;KAAa,aAAa;IAAuB;IACzD;KAAE,MAAM;KAAU,aAAa;IAA4B;IAC3D;KAAE,MAAM;KAAgB,aAAa;IAAsC;GAC7E;EACF;EACA,WAAW;CACb,CAAC;CACD,IAAI,SAAS,WAAW,EAAE,aAAa,QAAQ,CAAC;AAClD"}
1
+ {"version":3,"file":"openapi.js","names":["pkg.version"],"sources":["../../../src/http/plugins/openapi.ts"],"sourcesContent":["// OpenAPI documentation. @fastify/swagger reads the Zod route schemas (via the\n// transform) to build the spec the frontend SDK is generated from; it must be\n// registered before the routes so its onRoute hook captures them. UI + JSON are\n// served under /docs.\nimport type { FastifyInstance } from 'fastify';\nimport swagger from '@fastify/swagger';\nimport swaggerUi from '@fastify/swagger-ui';\nimport { jsonSchemaTransform, jsonSchemaTransformObject } from 'fastify-type-provider-zod';\nimport type { ServerConfig } from '../config.js';\nimport pkg from '../../../package.json' with { type: 'json' };\n\nexport function registerOpenApi(app: FastifyInstance, config: ServerConfig): void {\n app.register(swagger, {\n openapi: {\n info: {\n title: 'Headless LMS API',\n version: pkg.version,\n description: 'API documentation for the Headless LMS platform',\n license: {\n name: 'MIT',\n url: 'https://opensource.org/licenses/MIT',\n },\n },\n servers: [{ url: config.publicUrl }],\n tags: [\n {\n name: 'Organizations',\n description: 'The tenant: its members, roles, invites and students',\n },\n {\n name: 'Content',\n description: 'Authored content: courses and downloads, their structure and delivery',\n },\n { name: 'Entitlements', description: 'Access grants to content' },\n {\n name: 'Progress',\n description: 'Per-student progress and completion through course content',\n },\n { name: 'Discussion', description: 'Learner comments, replies, reactions and moderation' },\n { name: 'Assets', description: 'The org media library: uploads, serving and lifecycle' },\n { name: 'Automations', description: 'Workflows and webhooks that react to system events' },\n { name: 'Integrations', description: 'Connections to external services' },\n { name: 'Reporting', description: 'Reporting and analytics' },\n ],\n },\n transform: jsonSchemaTransform,\n transformObject: jsonSchemaTransformObject,\n });\n app.register(swaggerUi, { routePrefix: '/docs' });\n}\n"],"mappings":";;;;;AAWA,SAAgB,gBAAgB,KAAsB,QAA4B;CAChF,IAAI,SAAS,SAAS;EACpB,SAAS;GACP,MAAM;IACJ,OAAO;IACEA;IACT,aAAa;IACb,SAAS;KACP,MAAM;KACN,KAAK;IACP;GACF;GACA,SAAS,CAAC,EAAE,KAAK,OAAO,UAAU,CAAC;GACnC,MAAM;IACJ;KACE,MAAM;KACN,aAAa;IACf;IACA;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAgB,aAAa;IAA2B;IAChE;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAc,aAAa;IAAsD;IACzF;KAAE,MAAM;KAAU,aAAa;IAAwD;IACvF;KAAE,MAAM;KAAe,aAAa;IAAqD;IACzF;KAAE,MAAM;KAAgB,aAAa;IAAmC;IACxE;KAAE,MAAM;KAAa,aAAa;IAA0B;GAC9D;EACF;EACA,WAAW;EACX,iBAAiB;CACnB,CAAC;CACD,IAAI,SAAS,WAAW,EAAE,aAAa,QAAQ,CAAC;AAClD"}