@headless-lms/server 0.1.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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/dist/app/container.d.ts +124 -0
  4. package/dist/app/container.js +312 -0
  5. package/dist/app/container.js.map +1 -0
  6. package/dist/app/db.d.ts +2 -0
  7. package/dist/app/db.js +2 -0
  8. package/dist/app/integrations.d.ts +7 -0
  9. package/dist/app/integrations.js +44 -0
  10. package/dist/app/integrations.js.map +1 -0
  11. package/dist/app/notifications.d.ts +7 -0
  12. package/dist/app/notifications.js +23 -0
  13. package/dist/app/notifications.js.map +1 -0
  14. package/dist/http/config.d.ts +25 -0
  15. package/dist/http/config.js +1 -0
  16. package/dist/http/fastify.d.ts +35 -0
  17. package/dist/http/plugins/auth.d.ts +11 -0
  18. package/dist/http/plugins/auth.js +47 -0
  19. package/dist/http/plugins/auth.js.map +1 -0
  20. package/dist/http/plugins/cors.d.ts +7 -0
  21. package/dist/http/plugins/cors.js +22 -0
  22. package/dist/http/plugins/cors.js.map +1 -0
  23. package/dist/http/plugins/error-handler.d.ts +6 -0
  24. package/dist/http/plugins/error-handler.js +64 -0
  25. package/dist/http/plugins/error-handler.js.map +1 -0
  26. package/dist/http/plugins/openapi.d.ts +7 -0
  27. package/dist/http/plugins/openapi.js +63 -0
  28. package/dist/http/plugins/openapi.js.map +1 -0
  29. package/dist/http/routes/activities.d.ts +7 -0
  30. package/dist/http/routes/activities.js +163 -0
  31. package/dist/http/routes/activities.js.map +1 -0
  32. package/dist/http/routes/assets.d.ts +7 -0
  33. package/dist/http/routes/assets.js +168 -0
  34. package/dist/http/routes/assets.js.map +1 -0
  35. package/dist/http/routes/automations.d.ts +7 -0
  36. package/dist/http/routes/automations.js +158 -0
  37. package/dist/http/routes/automations.js.map +1 -0
  38. package/dist/http/routes/courses.d.ts +7 -0
  39. package/dist/http/routes/courses.js +130 -0
  40. package/dist/http/routes/courses.js.map +1 -0
  41. package/dist/http/routes/dashboard.d.ts +7 -0
  42. package/dist/http/routes/dashboard.js +42 -0
  43. package/dist/http/routes/dashboard.js.map +1 -0
  44. package/dist/http/routes/discussion.d.ts +7 -0
  45. package/dist/http/routes/discussion.js +122 -0
  46. package/dist/http/routes/discussion.js.map +1 -0
  47. package/dist/http/routes/downloads.d.ts +7 -0
  48. package/dist/http/routes/downloads.js +208 -0
  49. package/dist/http/routes/downloads.js.map +1 -0
  50. package/dist/http/routes/entitlements.d.ts +7 -0
  51. package/dist/http/routes/entitlements.js +69 -0
  52. package/dist/http/routes/entitlements.js.map +1 -0
  53. package/dist/http/routes/identity.d.ts +6 -0
  54. package/dist/http/routes/identity.js +6 -0
  55. package/dist/http/routes/identity.js.map +1 -0
  56. package/dist/http/routes/integrations.d.ts +7 -0
  57. package/dist/http/routes/integrations.js +210 -0
  58. package/dist/http/routes/integrations.js.map +1 -0
  59. package/dist/http/routes/learn/assets.d.ts +8 -0
  60. package/dist/http/routes/learn/assets.js +32 -0
  61. package/dist/http/routes/learn/assets.js.map +1 -0
  62. package/dist/http/routes/learn/comments.d.ts +7 -0
  63. package/dist/http/routes/learn/comments.js +175 -0
  64. package/dist/http/routes/learn/comments.js.map +1 -0
  65. package/dist/http/routes/learn/courses.d.ts +7 -0
  66. package/dist/http/routes/learn/courses.js +75 -0
  67. package/dist/http/routes/learn/courses.js.map +1 -0
  68. package/dist/http/routes/learn/downloads.d.ts +7 -0
  69. package/dist/http/routes/learn/downloads.js +76 -0
  70. package/dist/http/routes/learn/downloads.js.map +1 -0
  71. package/dist/http/routes/learn/index.d.ts +7 -0
  72. package/dist/http/routes/learn/index.js +19 -0
  73. package/dist/http/routes/learn/index.js.map +1 -0
  74. package/dist/http/routes/learn/org.d.ts +7 -0
  75. package/dist/http/routes/learn/org.js +50 -0
  76. package/dist/http/routes/learn/org.js.map +1 -0
  77. package/dist/http/routes/learn/progress.d.ts +7 -0
  78. package/dist/http/routes/learn/progress.js +63 -0
  79. package/dist/http/routes/learn/progress.js.map +1 -0
  80. package/dist/http/routes/organizations.d.ts +7 -0
  81. package/dist/http/routes/organizations.js +219 -0
  82. package/dist/http/routes/organizations.js.map +1 -0
  83. package/dist/http/routes/progress.d.ts +6 -0
  84. package/dist/http/routes/progress.js +6 -0
  85. package/dist/http/routes/progress.js.map +1 -0
  86. package/dist/http/routes/students.d.ts +7 -0
  87. package/dist/http/routes/students.js +125 -0
  88. package/dist/http/routes/students.js.map +1 -0
  89. package/dist/http/routes.d.ts +7 -0
  90. package/dist/http/routes.js +37 -0
  91. package/dist/http/routes.js.map +1 -0
  92. package/dist/http/schemas/activities.d.ts +69 -0
  93. package/dist/http/schemas/activities.js +25 -0
  94. package/dist/http/schemas/activities.js.map +1 -0
  95. package/dist/http/schemas/assets.d.ts +142 -0
  96. package/dist/http/schemas/assets.js +24 -0
  97. package/dist/http/schemas/assets.js.map +1 -0
  98. package/dist/http/schemas/automations.d.ts +154 -0
  99. package/dist/http/schemas/automations.js +26 -0
  100. package/dist/http/schemas/automations.js.map +1 -0
  101. package/dist/http/schemas/content.d.ts +113 -0
  102. package/dist/http/schemas/content.js +29 -0
  103. package/dist/http/schemas/content.js.map +1 -0
  104. package/dist/http/schemas/dashboard.d.ts +26 -0
  105. package/dist/http/schemas/dashboard.js +18 -0
  106. package/dist/http/schemas/dashboard.js.map +1 -0
  107. package/dist/http/schemas/discussion.d.ts +340 -0
  108. package/dist/http/schemas/discussion.js +78 -0
  109. package/dist/http/schemas/discussion.js.map +1 -0
  110. package/dist/http/schemas/downloads.d.ts +110 -0
  111. package/dist/http/schemas/downloads.js +34 -0
  112. package/dist/http/schemas/downloads.js.map +1 -0
  113. package/dist/http/schemas/entitlements.d.ts +97 -0
  114. package/dist/http/schemas/entitlements.js +41 -0
  115. package/dist/http/schemas/entitlements.js.map +1 -0
  116. package/dist/http/schemas/index.d.ts +17 -0
  117. package/dist/http/schemas/index.js +17 -0
  118. package/dist/http/schemas/integrations.d.ts +93 -0
  119. package/dist/http/schemas/integrations.js +47 -0
  120. package/dist/http/schemas/integrations.js.map +1 -0
  121. package/dist/http/schemas/invites.d.ts +95 -0
  122. package/dist/http/schemas/invites.js +62 -0
  123. package/dist/http/schemas/invites.js.map +1 -0
  124. package/dist/http/schemas/learn.d.ts +142 -0
  125. package/dist/http/schemas/learn.js +58 -0
  126. package/dist/http/schemas/learn.js.map +1 -0
  127. package/dist/http/schemas/members.d.ts +88 -0
  128. package/dist/http/schemas/members.js +27 -0
  129. package/dist/http/schemas/members.js.map +1 -0
  130. package/dist/http/schemas/organizations.d.ts +22 -0
  131. package/dist/http/schemas/organizations.js +20 -0
  132. package/dist/http/schemas/organizations.js.map +1 -0
  133. package/dist/http/schemas/settings.d.ts +23 -0
  134. package/dist/http/schemas/settings.js +20 -0
  135. package/dist/http/schemas/settings.js.map +1 -0
  136. package/dist/http/schemas/shared.d.ts +57 -0
  137. package/dist/http/schemas/shared.js +54 -0
  138. package/dist/http/schemas/shared.js.map +1 -0
  139. package/dist/http/schemas/students.d.ts +64 -0
  140. package/dist/http/schemas/students.js +25 -0
  141. package/dist/http/schemas/students.js.map +1 -0
  142. package/dist/http/scope.d.ts +22 -0
  143. package/dist/http/scope.js +28 -0
  144. package/dist/http/scope.js.map +1 -0
  145. package/dist/http/server.d.ts +8 -0
  146. package/dist/http/server.js +33 -0
  147. package/dist/http/server.js.map +1 -0
  148. package/dist/http/web-bridge.d.ts +9 -0
  149. package/dist/http/web-bridge.js +27 -0
  150. package/dist/http/web-bridge.js.map +1 -0
  151. package/dist/index.d.ts +15 -0
  152. package/dist/index.js +16 -0
  153. package/dist/index.js.map +1 -0
  154. package/package.json +56 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mdwt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @headless-lms/server
2
+
3
+ The Fastify HTTP layer plus the composition root that wires `@headless-lms/core`
4
+ (the hexagonal domain) to its adapters (`@headless-lms/adapter-db`,
5
+ `-auth`, `-defaults`, and whatever an installation passes in). An installation
6
+ (e.g. `apps/api`) composes it and owns only its config, entry point, and
7
+ integration plugins. The `headless-lms` bin lives in `@headless-lms/cli`.
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { createContainer, buildServer } from '@headless-lms/server';
13
+
14
+ const container = await createContainer(config, {
15
+ pluginsDir, // one folder per integration (directory name = integration id)
16
+ });
17
+ const app = await buildServer(config, container);
18
+ await app.listen({ port: config.port, host: config.host });
19
+ ```
20
+
21
+ Public surface (`src/index.ts`): `createContainer`, `buildServer`,
22
+ `loadIntegrations`, the operational function `runMigrations` (re-exported from
23
+ `@headless-lms/adapter-db`, which ships the `drizzle/` migration assets;
24
+ `@headless-lms/cli` wraps it as `headless-lms migrate`), and the types
25
+ installations need (`ServerConfig`, `Container`, `AdapterOverrides`, shared ports
26
+ like `EmailSender` / `ObjectStorage`). Everything else is internal.
27
+
28
+ ## Layout
29
+
30
+ ```
31
+ src/
32
+ app/ container.ts — wires adapters into core's services; integration
33
+ loader
34
+ http/ Fastify server; routes validated against the Zod schemas in
35
+ http/schemas — the source of truth for the OpenAPI spec and SDK
36
+ ```
37
+
38
+ The domain lives in `@headless-lms/core` and the infrastructure in
39
+ `adapters/*`; this package holds neither. Import boundaries between the packages
40
+ are enforced by ESLint (`pnpm lint`).
41
+
42
+ ## Develop
43
+
44
+ ```bash
45
+ pnpm --filter @headless-lms/server test # vitest
46
+ pnpm --filter @headless-lms/server typecheck # tsc --noEmit (tsdown owns the build)
47
+ pnpm db:generate # drizzle-kit generate in @headless-lms/adapter-db
48
+ ```
@@ -0,0 +1,124 @@
1
+ import { PollingOutboxRelayConfig } from "@headless-lms/adapter-defaults/events/outbox-relay";
2
+ import { LogLevel, PinoInstance, RequestLogContext } from "@headless-lms/adapter-defaults/logging";
3
+ import { BetterAuth } from "@headless-lms/adapter-auth";
4
+ import { ContentServiceImpl } from "@headless-lms/core/content";
5
+ import { EntitlementsServiceImpl } from "@headless-lms/core/entitlements";
6
+ import { ProgressServiceImpl } from "@headless-lms/core/progress";
7
+ import { DiscussionServiceImpl } from "@headless-lms/core/discussion";
8
+ import { IdentityServiceImpl, SessionAdmin } from "@headless-lms/core/identity";
9
+ import { OrgAdmin, OrganizationServiceImpl } from "@headless-lms/core/organizations";
10
+ import { AssetsServiceImpl } from "@headless-lms/core/assets";
11
+ import { IntegrationsServiceImpl } from "@headless-lms/core/integrations";
12
+ import { AutomationsServiceImpl } from "@headless-lms/core/automations";
13
+ import { StudentsReportServiceImpl } from "@headless-lms/core/reporting/students";
14
+ import { DashboardReportServiceImpl } from "@headless-lms/core/reporting/dashboard";
15
+ import { LearnReportServiceImpl } from "@headless-lms/core/reporting/learn";
16
+ import { Mailer } from "@headless-lms/core/shared/mailer";
17
+ import { SettingsService } from "@headless-lms/core/shared/settings";
18
+ import { AutomationEngine } from "@headless-lms/core/types";
19
+ import { CredentialStore, EmailSender, Logger, ObjectStorage, OutboxRelay, TemplateContext, TemplateRenderer } from "@headless-lms/core/shared/ports";
20
+ //#region src/app/container.d.ts
21
+ /** Installation-supplied ports; an absent slot falls back to a fail-loudly stub. */
22
+ interface AdapterOverrides {
23
+ email?: EmailSender;
24
+ storage?: ObjectStorage;
25
+ /** Resolves email templates to rendered content. Absent → fail-loudly stub. */
26
+ templates?: TemplateRenderer;
27
+ /** Durable automation engine. Absent → InlineAutomationEngine (in-process, one attempt per action). */
28
+ workflows?: AutomationEngine;
29
+ }
30
+ interface BuildContainerOptions {
31
+ /** Installation's plugins folder, scanned by loadIntegrations. Absent → no integrations. */
32
+ pluginsDir?: string;
33
+ adapters?: AdapterOverrides;
34
+ }
35
+ interface Config {
36
+ databaseUrl: string;
37
+ authBaseURL: string;
38
+ authSecret: string;
39
+ trustedOrigins: string[];
40
+ /** Branding threaded into every email template. Default: brandName "Headless LMS", baseUrl = adminAppUrl.
41
+ * (studentPortalUrl is composed in from the top-level config field.) */
42
+ emailBranding?: Omit<TemplateContext, 'studentPortalUrl'>;
43
+ /** base64-encoded 32-byte key for the credential store (CREDENTIAL_STORE_KEY). */
44
+ credentialStoreKey: string;
45
+ /** Parent domain for cross-subdomain session cookies (e.g. ".example.com"); undefined → host-only cookie. */
46
+ cookieDomain?: string;
47
+ /** Mark session cookies Secure (set behind HTTPS / in production). */
48
+ secureCookies?: boolean;
49
+ /** Student portal origin — invite links for students, and the origin whose signups are invite-gated. */
50
+ studentPortalUrl: string;
51
+ /** Admin app origin — invite links for staff. */
52
+ adminAppUrl: string;
53
+ /** Transactional-outbox relay tuning. All optional — see OUTBOX_DEFAULTS. */
54
+ outbox?: OutboxConfig;
55
+ /** Log level for the process-wide logger (HTTP + domain + relay). Default "info". */
56
+ logging?: LoggingConfig;
57
+ /** Presigned URL lifetime for entitled download delivery, seconds. Mirrors
58
+ * ServerConfig.deliveryExpirySeconds — createContainer threads it through. */
59
+ deliveryExpirySeconds: number;
60
+ }
61
+ /** Tuning for the transactional-outbox relay. Every field is optional; the
62
+ * container resolves against OUTBOX_DEFAULTS. */
63
+ interface OutboxConfig {
64
+ /** Master switch for the same-process relay. Default true. */
65
+ enabled?: boolean;
66
+ /** Idle delay between polls. Default 1000. */
67
+ pollIntervalMs?: number;
68
+ /** Max rows fetched/dispatched per tick. Default 100. */
69
+ batchSize?: number;
70
+ }
71
+ declare const OUTBOX_DEFAULTS: PollingOutboxRelayConfig;
72
+ declare function resolveOutboxConfig(config?: OutboxConfig): PollingOutboxRelayConfig;
73
+ /** Logging tuning. Optional; resolved against LOGGING_DEFAULTS. */
74
+ interface LoggingConfig {
75
+ /** Minimum level emitted. Default "info". */
76
+ level?: LogLevel;
77
+ }
78
+ declare const LOGGING_DEFAULTS: Required<LoggingConfig>;
79
+ declare function resolveLoggingConfig(config?: LoggingConfig): Required<LoggingConfig>;
80
+ interface Container {
81
+ auth: BetterAuth;
82
+ orgProvider: OrgAdmin;
83
+ identity: IdentityServiceImpl;
84
+ organizations: OrganizationServiceImpl;
85
+ content: ContentServiceImpl;
86
+ entitlements: EntitlementsServiceImpl;
87
+ progress: ProgressServiceImpl;
88
+ discussion: DiscussionServiceImpl;
89
+ assets: AssetsServiceImpl;
90
+ integrations: IntegrationsServiceImpl;
91
+ automations: AutomationsServiceImpl;
92
+ /** Cross-cutting settings store. Not a context — a leaf every context may use;
93
+ * inject `settings.for('<namespace>')` into a service, never the service itself. */
94
+ settings: SettingsService;
95
+ reporting: {
96
+ students: StudentsReportServiceImpl;
97
+ dashboard: DashboardReportServiceImpl;
98
+ learn: LearnReportServiceImpl;
99
+ };
100
+ storage: ObjectStorage;
101
+ mailer: Mailer;
102
+ /** Shared secure credential store — encrypted at rest, org-scoped, decrypt at point of use. */
103
+ credentials: CredentialStore;
104
+ /** The outbox relay — constructed but NEVER started by the container; the
105
+ * installation's entry point starts it after listen (gen-openapi must not
106
+ * poll). buildServer stops it onClose. */
107
+ outboxRelay: OutboxRelay;
108
+ /** The automation engine — constructed but NEVER started by the container;
109
+ * the installation's entry point starts it after listen (gen-openapi must
110
+ * not run a worker). buildServer stops it onClose. */
111
+ automationEngine: AutomationEngine;
112
+ /** Root logger port — components receive children bound with { name }. */
113
+ logger: Logger;
114
+ /** The raw pino root; buildServer hands it to Fastify so HTTP shares the stream. */
115
+ loggerInstance: PinoInstance;
116
+ /** Request-scoped log correlation; the HTTP layer enters it per request. */
117
+ requestContext: RequestLogContext;
118
+ /** Identity's session-write port; the auth adapter fulfils it. */
119
+ sessions: SessionAdmin;
120
+ }
121
+ declare function buildContainer(config: Config, options?: BuildContainerOptions): Promise<Container>;
122
+ //#endregion
123
+ export { AdapterOverrides, BuildContainerOptions, Config, Container, LOGGING_DEFAULTS, LoggingConfig, OUTBOX_DEFAULTS, OutboxConfig, buildContainer, resolveLoggingConfig, resolveOutboxConfig };
124
+ //# sourceMappingURL=container.d.ts.map
@@ -0,0 +1,312 @@
1
+ import { loadIntegrations } from "./integrations.js";
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";
4
+ import { InMemoryEventBus } from "@headless-lms/adapter-defaults/events";
5
+ import { PollingOutboxRelay } from "@headless-lms/adapter-defaults/events/outbox-relay";
6
+ import { InlineAutomationEngine } from "@headless-lms/adapter-defaults/workflows";
7
+ import { EmailAdapter, StubTemplateRenderer } from "@headless-lms/adapter-defaults/email";
8
+ import { createRootLogger, requestLogContext } from "@headless-lms/adapter-defaults/logging";
9
+ import { StorageAdapter } from "@headless-lms/adapter-defaults/storage";
10
+ import { BetterAuth } from "@headless-lms/adapter-auth";
11
+ import { ContentServiceImpl } from "@headless-lms/core/content";
12
+ import { EntitlementsServiceImpl } from "@headless-lms/core/entitlements";
13
+ import { ProgressServiceImpl } from "@headless-lms/core/progress";
14
+ import { DiscussionServiceImpl } from "@headless-lms/core/discussion";
15
+ import { IdentityServiceImpl } from "@headless-lms/core/identity";
16
+ import { OrganizationServiceImpl, parseRole } from "@headless-lms/core/organizations";
17
+ import { AssetsServiceImpl } from "@headless-lms/core/assets";
18
+ import { IntegrationsServiceImpl } from "@headless-lms/core/integrations";
19
+ import { AutomationsServiceImpl } from "@headless-lms/core/automations";
20
+ import { StudentsReportServiceImpl } from "@headless-lms/core/reporting/students";
21
+ import { DashboardReportServiceImpl } from "@headless-lms/core/reporting/dashboard";
22
+ import { LearnReportServiceImpl } from "@headless-lms/core/reporting/learn";
23
+ import { Mailer } from "@headless-lms/core/shared/mailer";
24
+ import { SettingsService } from "@headless-lms/core/shared/settings";
25
+ //#region src/app/container.ts
26
+ const OUTBOX_DEFAULTS = {
27
+ enabled: true,
28
+ pollIntervalMs: 1e3,
29
+ batchSize: 100
30
+ };
31
+ function resolveOutboxConfig(config = {}) {
32
+ const overrides = Object.fromEntries(Object.entries(config).filter(([, value]) => value !== void 0));
33
+ return {
34
+ ...OUTBOX_DEFAULTS,
35
+ ...overrides
36
+ };
37
+ }
38
+ const LOGGING_DEFAULTS = { level: "info" };
39
+ function resolveLoggingConfig(config = {}) {
40
+ return { level: config.level ?? LOGGING_DEFAULTS.level };
41
+ }
42
+ async function buildContainer(config, options) {
43
+ const { instance: loggerInstance, logger } = createRootLogger(resolveLoggingConfig(config.logging).level);
44
+ const contentLogger = logger.child({ name: "content" });
45
+ const entitlementsLogger = logger.child({ name: "entitlements" });
46
+ const progressLogger = logger.child({ name: "progress" });
47
+ const discussionLogger = logger.child({ name: "discussion" });
48
+ const assetsLogger = logger.child({ name: "assets" });
49
+ const integrationsLogger = logger.child({ name: "integrations" });
50
+ const automationsLogger = logger.child({ name: "automations" });
51
+ const reportingLogger = logger.child({ name: "reporting" });
52
+ const outboxLogger = logger.child({ name: "outbox" });
53
+ const db = createDb(config.databaseUrl);
54
+ const email = options?.adapters?.email ?? new EmailAdapter(logger.child({ name: "email" }));
55
+ const storage = options?.adapters?.storage ?? new StorageAdapter(logger.child({ name: "storage" }));
56
+ const templates = options?.adapters?.templates ?? new StubTemplateRenderer(logger.child({ name: "email" }));
57
+ const automationEngine = options?.adapters?.workflows ?? new InlineAutomationEngine();
58
+ const mailer = new Mailer(templates, email, {
59
+ ...config.emailBranding ?? {
60
+ brandName: "Headless LMS",
61
+ baseUrl: config.adminAppUrl
62
+ },
63
+ studentPortalUrl: config.studentPortalUrl
64
+ });
65
+ const identityUoW = new DrizzleUnitOfWork(db, (tx) => ({
66
+ identity: new DrizzleIdentityRepository(tx, logger.child({ name: "identity" })),
67
+ outbox: new DrizzleOutboxAppender(tx, logger.child({ name: "identity" }))
68
+ }));
69
+ const identity = new IdentityServiceImpl({
70
+ repo: new DrizzleIdentityRepository(db, logger.child({ name: "identity_repo" })),
71
+ logger: logger.child({ name: "identity" }),
72
+ uow: identityUoW,
73
+ mailer
74
+ });
75
+ const organizationsUow = new DrizzleUnitOfWork(db, (tx) => ({
76
+ organizations: new DrizzleOrganizationsRepository(tx, logger.child({ name: "org" })),
77
+ outbox: new DrizzleOutboxAppender(tx, logger.child({ name: "org" }))
78
+ }));
79
+ const organizations = new OrganizationServiceImpl({
80
+ repo: new DrizzleOrganizationsRepository(db, logger.child({ name: "org" })),
81
+ membersRepo: new DrizzleMembersRepository(db, logger.child({ name: "org" })),
82
+ people: identity,
83
+ uow: organizationsUow,
84
+ logger: logger.child({ name: "org" }),
85
+ mailer,
86
+ inviteUrls: {
87
+ studentPortalUrl: config.studentPortalUrl,
88
+ adminAppUrl: config.adminAppUrl
89
+ }
90
+ });
91
+ const settings = new SettingsService(new DrizzleSettingsRepository(db, logger.child({ name: "settings" })));
92
+ const contentUow = new DrizzleUnitOfWork(db, (tx) => ({
93
+ content: new DrizzleContentRepository(tx, contentLogger),
94
+ outbox: new DrizzleOutboxAppender(tx, outboxLogger)
95
+ }));
96
+ const content = new ContentServiceImpl({
97
+ repo: new DrizzleContentRepository(db, contentLogger),
98
+ uow: contentUow,
99
+ settings,
100
+ logger: contentLogger
101
+ });
102
+ const entitlementsUow = new DrizzleUnitOfWork(db, (tx) => ({
103
+ entitlements: new DrizzleEntitlementsRepository(tx, entitlementsLogger),
104
+ outbox: new DrizzleOutboxAppender(tx, outboxLogger)
105
+ }));
106
+ const entitlements = new EntitlementsServiceImpl({
107
+ repo: new DrizzleEntitlementsRepository(db, entitlementsLogger),
108
+ uow: entitlementsUow,
109
+ logger: entitlementsLogger
110
+ });
111
+ const progressUow = new DrizzleUnitOfWork(db, (tx) => ({
112
+ progress: new DrizzleProgressRepository(tx, progressLogger),
113
+ outbox: new DrizzleOutboxAppender(tx, outboxLogger)
114
+ }));
115
+ const progress = new ProgressServiceImpl({
116
+ repo: new DrizzleProgressRepository(db, progressLogger),
117
+ content,
118
+ uow: progressUow,
119
+ logger: progressLogger
120
+ });
121
+ const discussion = new DiscussionServiceImpl({
122
+ repo: new DrizzleDiscussionRepository(db, discussionLogger),
123
+ access: entitlements,
124
+ content,
125
+ uow: new DrizzleUnitOfWork(db, (tx) => ({
126
+ discussion: new DrizzleDiscussionRepository(tx, discussionLogger),
127
+ outbox: new DrizzleOutboxAppender(tx, outboxLogger)
128
+ })),
129
+ settings,
130
+ logger: discussionLogger
131
+ });
132
+ const assets = new AssetsServiceImpl({
133
+ storage,
134
+ repo: new DrizzleAssetsRepository(db, assetsLogger),
135
+ uow: new DrizzleUnitOfWork(db, (tx) => ({
136
+ assets: new DrizzleAssetsRepository(tx, assetsLogger),
137
+ outbox: new DrizzleOutboxAppender(tx, outboxLogger)
138
+ })),
139
+ logger: assetsLogger
140
+ });
141
+ const reporting = {
142
+ students: new StudentsReportServiceImpl({
143
+ repo: new DrizzleStudentsRepository(db, reportingLogger),
144
+ logger: reportingLogger
145
+ }),
146
+ dashboard: new DashboardReportServiceImpl({
147
+ repo: new DrizzleDashboardRepository(db, reportingLogger),
148
+ logger: reportingLogger
149
+ }),
150
+ learn: new LearnReportServiceImpl({
151
+ reader: new DrizzleLearnRepository(db, reportingLogger),
152
+ content,
153
+ progress,
154
+ assets,
155
+ deliveryExpirySeconds: config.deliveryExpirySeconds,
156
+ logger: reportingLogger
157
+ })
158
+ };
159
+ const credentialStore = new DrizzleCredentialStore(db, config.credentialStoreKey, integrationsLogger);
160
+ const integrationsRegistry = await loadIntegrations(options?.pluginsDir, integrationsLogger);
161
+ const integrationsUow = new DrizzleUnitOfWork(db, (tx) => ({
162
+ connections: new DrizzleConnectionsRepository(tx, integrationsLogger),
163
+ credentials: new DrizzleCredentialStore(tx, config.credentialStoreKey, integrationsLogger),
164
+ outbox: new DrizzleOutboxAppender(tx, outboxLogger)
165
+ }));
166
+ const integrations = new IntegrationsServiceImpl({
167
+ registry: integrationsRegistry,
168
+ repo: new DrizzleConnectionsRepository(db, integrationsLogger),
169
+ uow: integrationsUow,
170
+ credentials: credentialStore,
171
+ logger: integrationsLogger
172
+ });
173
+ const automationsUow = new DrizzleUnitOfWork(db, (tx) => ({
174
+ automations: new DrizzleAutomationsRepository(tx, automationsLogger),
175
+ runs: new DrizzleAutomationRunsRepository(tx, automationsLogger),
176
+ outbox: new DrizzleOutboxAppender(tx, automationsLogger)
177
+ }));
178
+ const mailerLookups = {
179
+ orgUserEmail: async (orgId, orgUserId) => (await reporting.students.get(orgId, orgUserId))?.email ?? null,
180
+ contentInfo: async (orgId, contentId) => {
181
+ const course = await content.getCourse(orgId, contentId);
182
+ if (course) return {
183
+ id: course.id,
184
+ title: course.title
185
+ };
186
+ const download = await content.getDownload(orgId, contentId);
187
+ return download ? {
188
+ id: download.id,
189
+ title: download.title
190
+ } : null;
191
+ }
192
+ };
193
+ const automations = new AutomationsServiceImpl({
194
+ repo: new DrizzleAutomationsRepository(db, automationsLogger),
195
+ runsRepo: new DrizzleAutomationRunsRepository(db, automationsLogger),
196
+ uow: automationsUow,
197
+ engine: automationEngine,
198
+ mailer,
199
+ lookups: mailerLookups,
200
+ integrations,
201
+ logger: automationsLogger
202
+ });
203
+ automationEngine.register(automations);
204
+ const eventBus = new InMemoryEventBus();
205
+ registerNotificationSubscribers(eventBus, mailer, mailerLookups);
206
+ eventBus.subscribeAll((event) => automations.handle(event));
207
+ const outboxConfig = resolveOutboxConfig(config.outbox);
208
+ const outboxRelay = new PollingOutboxRelay(new DrizzleOutboxStore(db, outboxLogger), eventBus, outboxConfig, outboxLogger);
209
+ const auth = new BetterAuth({
210
+ db,
211
+ baseUrl: config.authBaseURL,
212
+ secret: config.authSecret,
213
+ trustedOrigins: config.trustedOrigins,
214
+ hooks: {
215
+ sendResetPassword: async (data) => {
216
+ await identity.sendPasswordReset({
217
+ email: data.user.email,
218
+ url: ""
219
+ });
220
+ },
221
+ sendMagicLink: async ({ email, url }) => {
222
+ await identity.sendMagicLink({
223
+ email,
224
+ url
225
+ });
226
+ },
227
+ beforeUserCreate: async (user) => {
228
+ const { email, name } = user;
229
+ const [firstName, lastName] = name.split(" ");
230
+ const domainUser = await identity.linkOrCreateUser({
231
+ email,
232
+ ...firstName !== void 0 && { firstName },
233
+ ...lastName !== void 0 && { lastName }
234
+ });
235
+ return { data: {
236
+ ...user,
237
+ id: domainUser.id
238
+ } };
239
+ },
240
+ beforeCreateSession: async (session) => {
241
+ const person = await identity.getUserByExternalId(session.userId);
242
+ if (!person) return;
243
+ const orgs = await organizations.getOrgUsersForUser(person.id);
244
+ if (orgs.length !== 1) return;
245
+ const org = await organizations.getById(orgs[0].orgId);
246
+ if (!org) return;
247
+ return { data: {
248
+ ...session,
249
+ activeOrganizationId: org.id
250
+ } };
251
+ },
252
+ beforeCreateOrganization: async ({ organization: org, user: baUser }) => {
253
+ const domainOrg = await organizations.createOrganization({
254
+ ownerId: baUser.id,
255
+ ...org,
256
+ logo: org.logo ?? void 0
257
+ });
258
+ return { data: {
259
+ ...org,
260
+ id: domainOrg.id
261
+ } };
262
+ },
263
+ beforeUpdateOrganization: async ({ organization }) => {
264
+ await organizations.updateOrganization(organization.id, organization);
265
+ },
266
+ beforeDeleteOrganization: async ({ organization }) => {
267
+ await organizations.deleteOrganization(organization.id);
268
+ },
269
+ beforeAddMember: async ({ member }) => {
270
+ await organizations.addOrgUser({
271
+ orgId: member.organizationId,
272
+ userId: member.userId,
273
+ role: parseRole(member.role)
274
+ });
275
+ },
276
+ beforeRemoveMember: async ({ member }) => {
277
+ await organizations.removeOrgUser(member.organizationId, member.userId);
278
+ }
279
+ },
280
+ logger: logger.child({ name: "auth" }),
281
+ cookieDomain: config.cookieDomain,
282
+ secureCookies: config.secureCookies
283
+ });
284
+ return {
285
+ auth,
286
+ identity,
287
+ organizations,
288
+ orgProvider: auth,
289
+ content,
290
+ entitlements,
291
+ progress,
292
+ discussion,
293
+ assets,
294
+ integrations,
295
+ automations,
296
+ settings,
297
+ reporting,
298
+ storage,
299
+ mailer,
300
+ credentials: credentialStore,
301
+ outboxRelay,
302
+ automationEngine,
303
+ logger,
304
+ loggerInstance,
305
+ requestContext: requestLogContext,
306
+ sessions: auth
307
+ };
308
+ }
309
+ //#endregion
310
+ export { LOGGING_DEFAULTS, OUTBOX_DEFAULTS, buildContainer, resolveLoggingConfig, resolveOutboxConfig };
311
+
312
+ //# sourceMappingURL=container.js.map
@@ -0,0 +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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"}
@@ -0,0 +1,2 @@
1
+ import { Db, createDb, schema } from "@headless-lms/adapter-db";
2
+ export { type Db, createDb, schema };
package/dist/app/db.js ADDED
@@ -0,0 +1,2 @@
1
+ import { createDb, schema } from "@headless-lms/adapter-db";
2
+ export { createDb, schema };
@@ -0,0 +1,7 @@
1
+ import { IntegrationsRegistry } from "@headless-lms/core/integrations";
2
+ import { Logger } from "@headless-lms/core/shared/ports";
3
+ //#region src/app/integrations.d.ts
4
+ declare function loadIntegrations(dir?: string, logger?: Logger): Promise<IntegrationsRegistry>;
5
+ //#endregion
6
+ export { loadIntegrations };
7
+ //# sourceMappingURL=integrations.d.ts.map
@@ -0,0 +1,44 @@
1
+ import { createIntegrationsRegistry } from "@headless-lms/core/integrations";
2
+ import { readdir } from "node:fs/promises";
3
+ import { pathToFileURL } from "node:url";
4
+ import { join } from "node:path";
5
+ import { noopLogger } from "@headless-lms/core/shared/logger";
6
+ //#region src/app/integrations.ts
7
+ function isIntegration(value) {
8
+ const it = value;
9
+ return typeof it?.id === "string" && typeof it?.configSchema === "function" && typeof it?.secretsSchema === "function" && typeof it?.validateConfig === "function" && Array.isArray(it?.actions) && it.actions.every((action) => typeof action?.id === "string" && typeof action?.description === "string" && typeof action?.inputSchema === "function" && typeof action?.outputSchema === "function" && typeof action?.invoke === "function") && new Set(it.actions.map((action) => action.id)).size === it.actions.length;
10
+ }
11
+ async function loadIntegrations(dir, logger = noopLogger) {
12
+ if (!dir) {
13
+ logger.debug("no plugins directory configured — zero integrations");
14
+ return createIntegrationsRegistry([]);
15
+ }
16
+ let entries;
17
+ try {
18
+ entries = await readdir(dir, { withFileTypes: true });
19
+ } catch (err) {
20
+ if (err.code === "ENOENT") {
21
+ logger.debug("plugins directory missing — zero integrations", { dir });
22
+ return createIntegrationsRegistry([]);
23
+ }
24
+ throw err;
25
+ }
26
+ const integrations = [];
27
+ for (const entry of entries.filter((e) => e.isDirectory())) {
28
+ const index = (await readdir(join(dir, entry.name))).find((f) => f === "index.ts" || f === "index.js");
29
+ if (!index) throw new Error(`integration "${entry.name}" has no index module`);
30
+ const mod = await import(pathToFileURL(join(dir, entry.name, index)).href);
31
+ if (!isIntegration(mod.default)) throw new Error(`integration "${entry.name}" must default-export an object satisfying the Integration port (id, configSchema, validateConfig, actions[] with unique ids and input/output schemas)`);
32
+ if (mod.default.id !== entry.name) throw new Error(`integration id "${mod.default.id}" must match its directory name "${entry.name}"`);
33
+ integrations.push(mod.default);
34
+ }
35
+ logger.info("integrations loaded", {
36
+ count: integrations.length,
37
+ ids: integrations.map((integration) => integration.id)
38
+ });
39
+ return createIntegrationsRegistry(integrations);
40
+ }
41
+ //#endregion
42
+ export { loadIntegrations };
43
+
44
+ //# sourceMappingURL=integrations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"integrations.js","names":[],"sources":["../../src/app/integrations.ts"],"sourcesContent":["// Builds the IntegrationsRegistry by scanning an installation-owned plugins\n// directory — the home of third-party integrations, deliberately outside core\n// (not the domain) and outside adapters. Each subdirectory is one integration:\n// its name IS the integration id, and its index module's default export must\n// satisfy the core Integration port. The directory itself is passed in by\n// composition options (this package ships no plugins of its own); loaded once\n// at startup, anything malformed fails the boot, not a request.\nimport { readdir } from 'node:fs/promises';\nimport { pathToFileURL } from 'node:url';\nimport { join } from 'node:path';\nimport {\n createIntegrationsRegistry,\n type Integration,\n type IntegrationsRegistry,\n} from '@headless-lms/core/integrations';\nimport type { Logger } from '@headless-lms/core/shared/ports';\nimport { noopLogger } from '@headless-lms/core/shared/logger';\n\nfunction isIntegration(value: unknown): value is Integration {\n const it = value as Integration | undefined;\n return (\n typeof it?.id === 'string' &&\n typeof it?.configSchema === 'function' &&\n typeof it?.secretsSchema === 'function' &&\n typeof it?.validateConfig === 'function' &&\n Array.isArray(it?.actions) &&\n it.actions.every(\n (action) =>\n typeof action?.id === 'string' &&\n typeof action?.description === 'string' &&\n typeof action?.inputSchema === 'function' &&\n typeof action?.outputSchema === 'function' &&\n typeof action?.invoke === 'function',\n ) &&\n new Set(it.actions.map((action) => action.id)).size === it.actions.length\n );\n}\n\nexport async function loadIntegrations(\n dir?: string,\n logger: Logger = noopLogger,\n): Promise<IntegrationsRegistry> {\n if (!dir) {\n logger.debug('no plugins directory configured — zero integrations');\n return createIntegrationsRegistry([]);\n }\n let entries;\n try {\n entries = await readdir(dir, { withFileTypes: true });\n } catch (err) {\n // A fresh installation's plugins/ dir has no .ts files yet (just a\n // README), so the build never creates dist/plugins/ — that's zero\n // installed integrations, not a boot failure.\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n logger.debug('plugins directory missing — zero integrations', { dir });\n return createIntegrationsRegistry([]);\n }\n throw err;\n }\n const integrations: Integration[] = [];\n for (const entry of entries.filter((e) => e.isDirectory())) {\n // index.ts when running from source (tsx/vitest), index.js from dist.\n const files = await readdir(join(dir, entry.name));\n const index = files.find((f) => f === 'index.ts' || f === 'index.js');\n if (!index) {\n throw new Error(`integration \"${entry.name}\" has no index module`);\n }\n const mod = (await import(pathToFileURL(join(dir, entry.name, index)).href)) as {\n default?: unknown;\n };\n if (!isIntegration(mod.default)) {\n throw new Error(\n `integration \"${entry.name}\" must default-export an object satisfying the Integration port ` +\n `(id, configSchema, validateConfig, actions[] with unique ids and input/output schemas)`,\n );\n }\n if (mod.default.id !== entry.name) {\n throw new Error(\n `integration id \"${mod.default.id}\" must match its directory name \"${entry.name}\"`,\n );\n }\n integrations.push(mod.default);\n }\n logger.info('integrations loaded', {\n count: integrations.length,\n ids: integrations.map((integration) => integration.id),\n });\n return createIntegrationsRegistry(integrations);\n}\n"],"mappings":";;;;;;AAkBA,SAAS,cAAc,OAAsC;CAC3D,MAAM,KAAK;CACX,OACE,OAAO,IAAI,OAAO,YAClB,OAAO,IAAI,iBAAiB,cAC5B,OAAO,IAAI,kBAAkB,cAC7B,OAAO,IAAI,mBAAmB,cAC9B,MAAM,QAAQ,IAAI,OAAO,KACzB,GAAG,QAAQ,OACR,WACC,OAAO,QAAQ,OAAO,YACtB,OAAO,QAAQ,gBAAgB,YAC/B,OAAO,QAAQ,gBAAgB,cAC/B,OAAO,QAAQ,iBAAiB,cAChC,OAAO,QAAQ,WAAW,UAC9B,KACA,IAAI,IAAI,GAAG,QAAQ,KAAK,WAAW,OAAO,EAAE,CAAC,CAAC,CAAC,SAAS,GAAG,QAAQ;AAEvE;AAEA,eAAsB,iBACpB,KACA,SAAiB,YACc;CAC/B,IAAI,CAAC,KAAK;EACR,OAAO,MAAM,qDAAqD;EAClE,OAAO,2BAA2B,CAAC,CAAC;CACtC;CACA,IAAI;CACJ,IAAI;EACF,UAAU,MAAM,QAAQ,KAAK,EAAE,eAAe,KAAK,CAAC;CACtD,SAAS,KAAK;EAIZ,IAAK,IAA8B,SAAS,UAAU;GACpD,OAAO,MAAM,iDAAiD,EAAE,IAAI,CAAC;GACrE,OAAO,2BAA2B,CAAC,CAAC;EACtC;EACA,MAAM;CACR;CACA,MAAM,eAA8B,CAAC;CACrC,KAAK,MAAM,SAAS,QAAQ,QAAQ,MAAM,EAAE,YAAY,CAAC,GAAG;EAG1D,MAAM,SAAQ,MADM,QAAQ,KAAK,KAAK,MAAM,IAAI,CAAC,EAAA,CAC7B,MAAM,MAAM,MAAM,cAAc,MAAM,UAAU;EACpE,IAAI,CAAC,OACH,MAAM,IAAI,MAAM,gBAAgB,MAAM,KAAK,sBAAsB;EAEnE,MAAM,MAAO,MAAM,OAAO,cAAc,KAAK,KAAK,MAAM,MAAM,KAAK,CAAC,CAAC,CAAC;EAGtE,IAAI,CAAC,cAAc,IAAI,OAAO,GAC5B,MAAM,IAAI,MACR,gBAAgB,MAAM,KAAK,uJAE7B;EAEF,IAAI,IAAI,QAAQ,OAAO,MAAM,MAC3B,MAAM,IAAI,MACR,mBAAmB,IAAI,QAAQ,GAAG,mCAAmC,MAAM,KAAK,EAClF;EAEF,aAAa,KAAK,IAAI,OAAO;CAC/B;CACA,OAAO,KAAK,uBAAuB;EACjC,OAAO,aAAa;EACpB,KAAK,aAAa,KAAK,gBAAgB,YAAY,EAAE;CACvD,CAAC;CACD,OAAO,2BAA2B,YAAY;AAChD"}
@@ -0,0 +1,7 @@
1
+ import { Mailer, MailerLookups } from "@headless-lms/core/shared/mailer";
2
+ import { EventBus } from "@headless-lms/core/shared/ports";
3
+ //#region src/app/notifications.d.ts
4
+ declare function registerNotificationSubscribers(bus: EventBus, mailer: Pick<Mailer, 'send'>, lookups: MailerLookups): void;
5
+ //#endregion
6
+ export { registerNotificationSubscribers };
7
+ //# sourceMappingURL=notifications.d.ts.map