@headless-lms/server 0.4.0 → 0.6.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 (222) hide show
  1. package/README.md +14 -0
  2. package/dist/app/community-mail.d.ts +5 -3
  3. package/dist/app/community-mail.js +60 -12
  4. package/dist/app/community-mail.js.map +1 -1
  5. package/dist/app/container.d.ts +24 -3
  6. package/dist/app/container.js +102 -38
  7. package/dist/app/container.js.map +1 -1
  8. package/dist/app/notification-channels.js +1 -0
  9. package/dist/app/notification-channels.js.map +1 -1
  10. package/dist/app/notification-reads.d.ts +3 -3
  11. package/dist/app/notification-reads.js +7 -28
  12. package/dist/app/notification-reads.js.map +1 -1
  13. package/dist/app/platform.d.ts +14 -0
  14. package/dist/app/platform.js +14 -0
  15. package/dist/app/platform.js.map +1 -0
  16. package/dist/http/config.d.ts +4 -0
  17. package/dist/http/errors.js +9 -4
  18. package/dist/http/errors.js.map +1 -1
  19. package/dist/http/fastify.d.ts +8 -0
  20. package/dist/http/mcp/catalogue.d.ts +11 -9
  21. package/dist/http/mcp/catalogue.js +15 -21
  22. package/dist/http/mcp/catalogue.js.map +1 -1
  23. package/dist/http/mcp/challenge.js +3 -3
  24. package/dist/http/mcp/challenge.js.map +1 -1
  25. package/dist/http/mcp/route.js +7 -7
  26. package/dist/http/mcp/route.js.map +1 -1
  27. package/dist/http/mcp/test-support.d.ts +6 -5
  28. package/dist/http/mcp/test-support.js +32 -11
  29. package/dist/http/mcp/test-support.js.map +1 -1
  30. package/dist/http/mcp/tool.d.ts +3 -9
  31. package/dist/http/mcp/tool.js +14 -6
  32. package/dist/http/mcp/tool.js.map +1 -1
  33. package/dist/http/mcp/tools/automations.js +10 -20
  34. package/dist/http/mcp/tools/automations.js.map +1 -1
  35. package/dist/http/mcp/tools/catalog.js +25 -42
  36. package/dist/http/mcp/tools/catalog.js.map +1 -1
  37. package/dist/http/mcp/tools/comments.js +9 -19
  38. package/dist/http/mcp/tools/comments.js.map +1 -1
  39. package/dist/http/mcp/tools/communities.js +17 -32
  40. package/dist/http/mcp/tools/communities.js.map +1 -1
  41. package/dist/http/mcp/tools/community-members.js +9 -16
  42. package/dist/http/mcp/tools/community-members.js.map +1 -1
  43. package/dist/http/mcp/tools/community-moderation.js +13 -25
  44. package/dist/http/mcp/tools/community-moderation.js.map +1 -1
  45. package/dist/http/mcp/tools/courses.js +11 -21
  46. package/dist/http/mcp/tools/courses.js.map +1 -1
  47. package/dist/http/mcp/tools/events.d.ts +6 -0
  48. package/dist/http/mcp/tools/events.js +170 -0
  49. package/dist/http/mcp/tools/events.js.map +1 -0
  50. package/dist/http/mcp/tools/index.d.ts +5 -1
  51. package/dist/http/mcp/tools/index.js +45 -10
  52. package/dist/http/mcp/tools/index.js.map +1 -1
  53. package/dist/http/mcp/tools/organization.d.ts +6 -0
  54. package/dist/http/mcp/tools/organization.js +214 -0
  55. package/dist/http/mcp/tools/organization.js.map +1 -0
  56. package/dist/http/mcp/tools/people.js +30 -49
  57. package/dist/http/mcp/tools/people.js.map +1 -1
  58. package/dist/http/plugins/auth.js +37 -8
  59. package/dist/http/plugins/auth.js.map +1 -1
  60. package/dist/http/plugins/error-handler.d.ts +5 -1
  61. package/dist/http/plugins/error-handler.js +10 -2
  62. package/dist/http/plugins/error-handler.js.map +1 -1
  63. package/dist/http/plugins/openapi.js +18 -2
  64. package/dist/http/plugins/openapi.js.map +1 -1
  65. package/dist/http/plugins/platform.d.ts +7 -0
  66. package/dist/http/plugins/platform.js +13 -0
  67. package/dist/http/plugins/platform.js.map +1 -0
  68. package/dist/http/routes/access.js +54 -36
  69. package/dist/http/routes/access.js.map +1 -1
  70. package/dist/http/routes/activities.js +45 -45
  71. package/dist/http/routes/activities.js.map +1 -1
  72. package/dist/http/routes/assets.js +30 -65
  73. package/dist/http/routes/assets.js.map +1 -1
  74. package/dist/http/routes/automations.js +37 -42
  75. package/dist/http/routes/automations.js.map +1 -1
  76. package/dist/http/routes/bundles.js +33 -36
  77. package/dist/http/routes/bundles.js.map +1 -1
  78. package/dist/http/routes/communities.js +93 -145
  79. package/dist/http/routes/communities.js.map +1 -1
  80. package/dist/http/routes/community-moderation.js +83 -137
  81. package/dist/http/routes/community-moderation.js.map +1 -1
  82. package/dist/http/routes/course-analytics.js +32 -15
  83. package/dist/http/routes/course-analytics.js.map +1 -1
  84. package/dist/http/routes/courses.js +25 -32
  85. package/dist/http/routes/courses.js.map +1 -1
  86. package/dist/http/routes/dashboard.js +9 -9
  87. package/dist/http/routes/dashboard.js.map +1 -1
  88. package/dist/http/routes/discussion.js +21 -39
  89. package/dist/http/routes/discussion.js.map +1 -1
  90. package/dist/http/routes/downloads.js +41 -47
  91. package/dist/http/routes/downloads.js.map +1 -1
  92. package/dist/http/routes/entitlements.js +19 -23
  93. package/dist/http/routes/entitlements.js.map +1 -1
  94. package/dist/http/routes/events.d.ts +7 -0
  95. package/dist/http/routes/events.js +333 -0
  96. package/dist/http/routes/events.js.map +1 -0
  97. package/dist/http/routes/integrations.js +40 -73
  98. package/dist/http/routes/integrations.js.map +1 -1
  99. package/dist/http/routes/member/assets.js +10 -13
  100. package/dist/http/routes/member/assets.js.map +1 -1
  101. package/dist/http/routes/member/chats.js +170 -47
  102. package/dist/http/routes/member/chats.js.map +1 -1
  103. package/dist/http/routes/member/comments.js +65 -26
  104. package/dist/http/routes/member/comments.js.map +1 -1
  105. package/dist/http/routes/member/community.js +45 -52
  106. package/dist/http/routes/member/community.js.map +1 -1
  107. package/dist/http/routes/member/courses.js +12 -19
  108. package/dist/http/routes/member/courses.js.map +1 -1
  109. package/dist/http/routes/member/devices.js +9 -10
  110. package/dist/http/routes/member/devices.js.map +1 -1
  111. package/dist/http/routes/member/downloads.js +6 -9
  112. package/dist/http/routes/member/downloads.js.map +1 -1
  113. package/dist/http/routes/member/events.d.ts +7 -0
  114. package/dist/http/routes/member/events.js +386 -0
  115. package/dist/http/routes/member/events.js.map +1 -0
  116. package/dist/http/routes/member/files.js +88 -46
  117. package/dist/http/routes/member/files.js.map +1 -1
  118. package/dist/http/routes/member/index.js +2 -0
  119. package/dist/http/routes/member/index.js.map +1 -1
  120. package/dist/http/routes/member/notification-settings.js +15 -23
  121. package/dist/http/routes/member/notification-settings.js.map +1 -1
  122. package/dist/http/routes/member/notifications.js +16 -16
  123. package/dist/http/routes/member/notifications.js.map +1 -1
  124. package/dist/http/routes/member/org.js +5 -15
  125. package/dist/http/routes/member/org.js.map +1 -1
  126. package/dist/http/routes/member/posts.js +67 -68
  127. package/dist/http/routes/member/posts.js.map +1 -1
  128. package/dist/http/routes/member/profile.js +15 -6
  129. package/dist/http/routes/member/profile.js.map +1 -1
  130. package/dist/http/routes/member/progress.js +7 -11
  131. package/dist/http/routes/member/progress.js.map +1 -1
  132. package/dist/http/routes/organizations.js +319 -135
  133. package/dist/http/routes/organizations.js.map +1 -1
  134. package/dist/http/routes/platform.d.ts +7 -0
  135. package/dist/http/routes/platform.js +61 -0
  136. package/dist/http/routes/platform.js.map +1 -0
  137. package/dist/http/routes/students.js +12 -75
  138. package/dist/http/routes/students.js.map +1 -1
  139. package/dist/http/routes/webhooks.js +30 -39
  140. package/dist/http/routes/webhooks.js.map +1 -1
  141. package/dist/http/routes.js +9 -0
  142. package/dist/http/routes.js.map +1 -1
  143. package/dist/http/schemas/access.d.ts +346 -64
  144. package/dist/http/schemas/access.js +39 -12
  145. package/dist/http/schemas/access.js.map +1 -1
  146. package/dist/http/schemas/assets.d.ts +8 -3
  147. package/dist/http/schemas/assets.js +6 -5
  148. package/dist/http/schemas/assets.js.map +1 -1
  149. package/dist/http/schemas/automations.d.ts +4 -4
  150. package/dist/http/schemas/chats.d.ts +216 -37
  151. package/dist/http/schemas/chats.js +13 -4
  152. package/dist/http/schemas/chats.js.map +1 -1
  153. package/dist/http/schemas/community.d.ts +105 -31
  154. package/dist/http/schemas/community.js +25 -9
  155. package/dist/http/schemas/community.js.map +1 -1
  156. package/dist/http/schemas/course-analytics.d.ts +51 -1
  157. package/dist/http/schemas/course-analytics.js +19 -1
  158. package/dist/http/schemas/course-analytics.js.map +1 -1
  159. package/dist/http/schemas/dashboard.d.ts +1 -0
  160. package/dist/http/schemas/dashboard.js +2 -1
  161. package/dist/http/schemas/dashboard.js.map +1 -1
  162. package/dist/http/schemas/discussion.d.ts +22 -72
  163. package/dist/http/schemas/discussion.js +2 -2
  164. package/dist/http/schemas/discussion.js.map +1 -1
  165. package/dist/http/schemas/entitlements.d.ts +9 -12
  166. package/dist/http/schemas/entitlements.js +11 -5
  167. package/dist/http/schemas/entitlements.js.map +1 -1
  168. package/dist/http/schemas/events.d.ts +527 -0
  169. package/dist/http/schemas/events.js +64 -0
  170. package/dist/http/schemas/events.js.map +1 -0
  171. package/dist/http/schemas/files.d.ts +24 -5
  172. package/dist/http/schemas/files.js +15 -2
  173. package/dist/http/schemas/files.js.map +1 -1
  174. package/dist/http/schemas/index.d.ts +11 -9
  175. package/dist/http/schemas/index.js +11 -9
  176. package/dist/http/schemas/invites.d.ts +3 -47
  177. package/dist/http/schemas/invites.js +4 -25
  178. package/dist/http/schemas/invites.js.map +1 -1
  179. package/dist/http/schemas/member.d.ts +94 -7
  180. package/dist/http/schemas/member.js +7 -3
  181. package/dist/http/schemas/member.js.map +1 -1
  182. package/dist/http/schemas/members.d.ts +329 -65
  183. package/dist/http/schemas/members.js +67 -25
  184. package/dist/http/schemas/members.js.map +1 -1
  185. package/dist/http/schemas/notifications.d.ts +58 -54
  186. package/dist/http/schemas/platform.d.ts +38 -0
  187. package/dist/http/schemas/platform.js +19 -0
  188. package/dist/http/schemas/platform.js.map +1 -0
  189. package/dist/http/schemas/posts.d.ts +55 -35
  190. package/dist/http/schemas/posts.js +4 -4
  191. package/dist/http/schemas/posts.js.map +1 -1
  192. package/dist/http/schemas/profile.d.ts +1 -1
  193. package/dist/http/schemas/shared.d.ts +1 -11
  194. package/dist/http/schemas/shared.js +1 -10
  195. package/dist/http/schemas/shared.js.map +1 -1
  196. package/dist/http/schemas/students.d.ts +22 -0
  197. package/dist/http/schemas/students.js +3 -1
  198. package/dist/http/schemas/students.js.map +1 -1
  199. package/dist/http/scope.d.ts +9 -13
  200. package/dist/http/scope.js +29 -40
  201. package/dist/http/scope.js.map +1 -1
  202. package/dist/http/server.js +9 -0
  203. package/dist/http/server.js.map +1 -1
  204. package/dist/http/shutdown.d.ts +11 -0
  205. package/dist/http/shutdown.js +40 -0
  206. package/dist/http/shutdown.js.map +1 -0
  207. package/dist/http/sse.d.ts +19 -0
  208. package/dist/http/sse.js +104 -0
  209. package/dist/http/sse.js.map +1 -0
  210. package/dist/http/test-caller.d.ts +13 -0
  211. package/dist/http/test-caller.js +14 -0
  212. package/dist/http/test-caller.js.map +1 -0
  213. package/dist/http/well-known.js +2 -2
  214. package/dist/http/well-known.js.map +1 -1
  215. package/dist/index.d.ts +4 -2
  216. package/dist/index.js +4 -2
  217. package/dist/index.js.map +1 -1
  218. package/dist/packages/server/package.js +1 -1
  219. package/package.json +7 -6
  220. package/dist/http/routes/member/actor.d.ts +0 -14
  221. package/dist/http/routes/member/actor.js +0 -11
  222. package/dist/http/routes/member/actor.js.map +0 -1
@@ -1,142 +1,123 @@
1
1
  import { CourseIdPathParam } from "../../schemas/activities.js";
2
2
  import { StudentIdParam, StudentsQuery } from "../../schemas/students.js";
3
- import { EntitlementIdParam, EntitlementsQuery, GrantEntitlement, SetEntitlementStatus } from "../../schemas/entitlements.js";
3
+ import { EntitlementsQuery, GrantEntitlement, RevokeEntitlement } from "../../schemas/entitlements.js";
4
4
  import { CreateInvite, Invite } from "../../schemas/invites.js";
5
5
  import { EnrollmentSeriesQuery } from "../../schemas/dashboard.js";
6
+ import { CourseLearnersQuery } from "../../schemas/course-analytics.js";
6
7
  import "../../schemas/index.js";
7
- import { actor, defineTool, noInput } from "../tool.js";
8
+ import { defineTool, noInput } from "../tool.js";
8
9
  //#region src/http/mcp/tools/people.ts
9
10
  const peopleTools = [
10
11
  defineTool({
11
12
  name: "list_students",
12
13
  title: "List students",
13
14
  description: "Search and page through the students with their access count, average progress, status and last activity.",
14
- scope: "students:read",
15
15
  kind: "read",
16
16
  idempotent: true,
17
17
  openWorld: false,
18
18
  input: StudentsQuery,
19
- call: (input, { container, scope }) => container.reporting.admin.listStudents(scope.orgId, input)
19
+ call: (input, { container, caller }) => container.reporting.admin.listStudents(input, caller.actor)
20
20
  }),
21
21
  defineTool({
22
22
  name: "invite_student",
23
23
  title: "Invite student",
24
24
  description: "Invite someone by email as a student. They exist as an invited student at once. sendEmail false skips the email.",
25
- scope: "students:invite",
26
25
  kind: "write",
27
26
  idempotent: false,
28
27
  openWorld: true,
29
- input: CreateInvite.omit({ role: true }),
28
+ input: CreateInvite.omit({ roleKey: true }),
30
29
  output: Invite,
31
- call: (input, { container, scope }) => container.organizations.createInvite(actor(scope), {
32
- orgId: scope.orgId,
33
- inviterUserId: scope.userId,
34
- role: "student",
35
- ...input
36
- })
37
- }),
38
- defineTool({
39
- name: "resend_student_invite",
40
- title: "Resend student invite",
41
- description: "Send a pending student's invite email again.",
42
- scope: "students:invite",
43
- kind: "write",
44
- idempotent: false,
45
- openWorld: true,
46
- input: StudentIdParam,
47
- call: ({ id }, { container, scope }) => container.organizations.resendStudentInvite(actor(scope), {
48
- orgId: scope.orgId,
49
- orgUserId: id,
50
- inviterUserId: scope.userId
51
- })
30
+ call: (input, { container, caller }) => container.organizations.createInvite(input, caller.actor, caller.userId)
52
31
  }),
53
32
  defineTool({
54
33
  name: "list_access",
55
34
  title: "List access",
56
35
  description: "Who has access to what. Filter by student (orgUserId), content, type or status.",
57
- scope: "access:read",
58
36
  kind: "read",
59
37
  idempotent: true,
60
38
  openWorld: false,
61
39
  input: EntitlementsQuery,
62
- call: (input, { container, scope }) => container.entitlements.list(scope.orgId, input)
40
+ call: (input, { container, caller }) => container.entitlements.list(input, caller.actor)
63
41
  }),
64
42
  defineTool({
65
43
  name: "grant_access",
66
44
  title: "Grant access",
67
- description: "Give a student access to one course, download or community (contentId) or to a bundle (bundleId), never both. expiresAt is a date-time, or null for no expiry.",
68
- scope: "access:write",
45
+ description: "Give a student access to one course, download, community or event (contentId) or to a bundle (bundleId), never both. expiresAt is a date-time, or null for no expiry.",
69
46
  kind: "write",
70
47
  idempotent: false,
71
48
  openWorld: false,
72
49
  input: GrantEntitlement,
73
- call: (input, { container, scope }) => container.entitlements.grant(scope.orgId, input)
50
+ call: (input, { container, caller }) => container.entitlements.grant(input, caller.actor)
74
51
  }),
75
52
  defineTool({
76
- name: "set_access_status",
77
- title: "Revoke or reinstate access",
78
- description: "Revoke an access grant, or reinstate a revoked one.",
79
- scope: "access:write",
53
+ name: "revoke_access",
54
+ title: "Revoke access",
55
+ description: "Revoke a student's direct grant to one course, download, community or event (contentId) or to a bundle (bundleId), never both. Access through a bundle stays. grant_access makes it active again.",
80
56
  kind: "destructive",
81
- idempotent: true,
57
+ idempotent: false,
82
58
  openWorld: false,
83
- input: SetEntitlementStatus.extend(EntitlementIdParam.shape),
84
- call: ({ id, status }, { container, scope }) => container.entitlements.setStatus(scope.orgId, id, status)
59
+ input: RevokeEntitlement,
60
+ call: (input, { container, caller }) => container.entitlements.revoke(input, caller.actor)
85
61
  }),
86
62
  defineTool({
87
63
  name: "get_dashboard",
88
64
  title: "Get dashboard",
89
65
  description: "Published and draft course counts, active students, and access grants expiring soon.",
90
- scope: "analytics:read",
91
66
  kind: "read",
92
67
  idempotent: true,
93
68
  openWorld: false,
94
69
  input: noInput,
95
- call: (_input, { container, scope }) => container.reporting.admin.overview(scope.orgId)
70
+ call: (_input, { container, caller }) => container.reporting.admin.overview(caller.actor)
96
71
  }),
97
72
  defineTool({
98
73
  name: "get_enrollment_trend",
99
74
  title: "Get enrollment trend",
100
75
  description: "Daily enrollments across the organization for the last days (default 30, at most 365).",
101
- scope: "analytics:read",
102
76
  kind: "read",
103
77
  idempotent: true,
104
78
  openWorld: false,
105
79
  input: EnrollmentSeriesQuery,
106
- call: ({ days }, { container, scope }) => container.reporting.admin.enrollments(scope.orgId, days)
80
+ call: ({ days }, { container, caller }) => container.reporting.admin.enrollments(days, caller.actor)
107
81
  }),
108
82
  defineTool({
109
83
  name: "get_course_analytics",
110
84
  title: "Get course analytics",
111
85
  description: "Enrolled, started and completed counts, average progress and per-activity engagement for one course.",
112
- scope: "analytics:read",
113
86
  kind: "read",
114
87
  idempotent: true,
115
88
  openWorld: false,
116
89
  input: CourseIdPathParam,
117
- call: ({ courseId }, { container, scope }) => container.reporting.admin.courseAnalytics(scope.orgId, courseId)
90
+ call: ({ courseId }, { container, caller }) => container.reporting.admin.courseAnalytics(courseId, caller.actor)
91
+ }),
92
+ defineTool({
93
+ name: "list_course_learners",
94
+ title: "List course learners",
95
+ description: "Page through a course's learners: everyone with a grant that opens it, directly or through a bundle, active or not, with that grant and their progress. Search by name or email.",
96
+ kind: "read",
97
+ idempotent: true,
98
+ openWorld: false,
99
+ input: CourseLearnersQuery.extend(CourseIdPathParam.shape),
100
+ call: ({ courseId, ...query }, { container, caller }) => container.reporting.admin.courseLearners(courseId, query, caller.actor)
118
101
  }),
119
102
  defineTool({
120
103
  name: "get_course_enrollment_trend",
121
104
  title: "Get course enrollment trend",
122
105
  description: "Daily enrollments in one course for the last days (default 30, at most 365).",
123
- scope: "analytics:read",
124
106
  kind: "read",
125
107
  idempotent: true,
126
108
  openWorld: false,
127
109
  input: EnrollmentSeriesQuery.extend(CourseIdPathParam.shape),
128
- call: ({ courseId, days }, { container, scope }) => container.reporting.admin.courseEnrollments(scope.orgId, courseId, days)
110
+ call: ({ courseId, days }, { container, caller }) => container.reporting.admin.courseEnrollments(courseId, days, caller.actor)
129
111
  }),
130
112
  defineTool({
131
113
  name: "get_student_analytics",
132
114
  title: "Get student analytics",
133
115
  description: "One student's enrolled, started and completed counts, average progress and progress in each course.",
134
- scope: "analytics:read",
135
116
  kind: "read",
136
117
  idempotent: true,
137
118
  openWorld: false,
138
119
  input: StudentIdParam,
139
- call: ({ id }, { container, scope }) => container.reporting.admin.studentAnalytics(scope.orgId, id)
120
+ call: ({ id }, { container, caller }) => container.reporting.admin.studentAnalytics(id, caller.actor)
140
121
  })
141
122
  ];
142
123
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"people.js","names":[],"sources":["../../../../src/http/mcp/tools/people.ts"],"sourcesContent":["import {\n CourseIdPathParam,\n CreateInvite,\n EnrollmentSeriesQuery,\n EntitlementIdParam,\n EntitlementsQuery,\n GrantEntitlement,\n Invite,\n SetEntitlementStatus,\n StudentIdParam,\n StudentsQuery,\n} from '../../schemas/index.js';\nimport { actor, defineTool, noInput, type Tool } from '../tool.js';\n\nexport const peopleTools: Tool[] = [\n defineTool({\n name: 'list_students',\n title: 'List students',\n description:\n 'Search and page through the students with their access count, average progress, status and last activity.',\n scope: 'students:read',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: StudentsQuery,\n call: (input, { container, scope }) =>\n container.reporting.admin.listStudents(scope.orgId, input),\n }),\n defineTool({\n name: 'invite_student',\n title: 'Invite student',\n description:\n 'Invite someone by email as a student. They exist as an invited student at once. sendEmail false skips the email.',\n scope: 'students:invite',\n kind: 'write',\n idempotent: false,\n openWorld: true,\n input: CreateInvite.omit({ role: true }),\n output: Invite,\n call: (input, { container, scope }) =>\n container.organizations.createInvite(actor(scope), {\n orgId: scope.orgId,\n inviterUserId: scope.userId,\n role: 'student',\n ...input,\n }),\n }),\n defineTool({\n name: 'resend_student_invite',\n title: 'Resend student invite',\n description: \"Send a pending student's invite email again.\",\n scope: 'students:invite',\n kind: 'write',\n idempotent: false,\n openWorld: true,\n input: StudentIdParam,\n call: ({ id }, { container, scope }) =>\n container.organizations.resendStudentInvite(actor(scope), {\n orgId: scope.orgId,\n orgUserId: id,\n inviterUserId: scope.userId,\n }),\n }),\n defineTool({\n name: 'list_access',\n title: 'List access',\n description: 'Who has access to what. Filter by student (orgUserId), content, type or status.',\n scope: 'access:read',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: EntitlementsQuery,\n call: (input, { container, scope }) => container.entitlements.list(scope.orgId, input),\n }),\n defineTool({\n name: 'grant_access',\n title: 'Grant access',\n description:\n 'Give a student access to one course, download or community (contentId) or to a bundle (bundleId), never both. expiresAt is a date-time, or null for no expiry.',\n scope: 'access:write',\n kind: 'write',\n idempotent: false,\n openWorld: false,\n input: GrantEntitlement,\n call: (input, { container, scope }) => container.entitlements.grant(scope.orgId, input),\n }),\n defineTool({\n name: 'set_access_status',\n title: 'Revoke or reinstate access',\n description: 'Revoke an access grant, or reinstate a revoked one.',\n scope: 'access:write',\n kind: 'destructive',\n idempotent: true,\n openWorld: false,\n input: SetEntitlementStatus.extend(EntitlementIdParam.shape),\n call: ({ id, status }, { container, scope }) =>\n container.entitlements.setStatus(scope.orgId, id, status),\n }),\n defineTool({\n name: 'get_dashboard',\n title: 'Get dashboard',\n description:\n 'Published and draft course counts, active students, and access grants expiring soon.',\n scope: 'analytics:read',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: noInput,\n call: (_input, { container, scope }) => container.reporting.admin.overview(scope.orgId),\n }),\n defineTool({\n name: 'get_enrollment_trend',\n title: 'Get enrollment trend',\n description:\n 'Daily enrollments across the organization for the last days (default 30, at most 365).',\n scope: 'analytics:read',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: EnrollmentSeriesQuery,\n call: ({ days }, { container, scope }) =>\n container.reporting.admin.enrollments(scope.orgId, days),\n }),\n defineTool({\n name: 'get_course_analytics',\n title: 'Get course analytics',\n description:\n 'Enrolled, started and completed counts, average progress and per-activity engagement for one course.',\n scope: 'analytics:read',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: CourseIdPathParam,\n call: ({ courseId }, { container, scope }) =>\n container.reporting.admin.courseAnalytics(scope.orgId, courseId),\n }),\n defineTool({\n name: 'get_course_enrollment_trend',\n title: 'Get course enrollment trend',\n description: 'Daily enrollments in one course for the last days (default 30, at most 365).',\n scope: 'analytics:read',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: EnrollmentSeriesQuery.extend(CourseIdPathParam.shape),\n call: ({ courseId, days }, { container, scope }) =>\n container.reporting.admin.courseEnrollments(scope.orgId, courseId, days),\n }),\n defineTool({\n name: 'get_student_analytics',\n title: 'Get student analytics',\n description:\n \"One student's enrolled, started and completed counts, average progress and progress in each course.\",\n scope: 'analytics:read',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: StudentIdParam,\n call: ({ id }, { container, scope }) =>\n container.reporting.admin.studentAnalytics(scope.orgId, id),\n }),\n];\n"],"mappings":"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"}
1
+ {"version":3,"file":"people.js","names":[],"sources":["../../../../src/http/mcp/tools/people.ts"],"sourcesContent":["import {\n CourseIdPathParam,\n CourseLearnersQuery,\n CreateInvite,\n EnrollmentSeriesQuery,\n EntitlementsQuery,\n GrantEntitlement,\n Invite,\n RevokeEntitlement,\n StudentIdParam,\n StudentsQuery,\n} from '../../schemas/index.js';\nimport { defineTool, noInput, type Tool } from '../tool.js';\n\nexport const peopleTools: Tool[] = [\n defineTool({\n name: 'list_students',\n title: 'List students',\n description:\n 'Search and page through the students with their access count, average progress, status and last activity.',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: StudentsQuery,\n call: (input, { container, caller }) =>\n container.reporting.admin.listStudents(input, caller.actor),\n }),\n defineTool({\n name: 'invite_student',\n title: 'Invite student',\n description:\n 'Invite someone by email as a student. They exist as an invited student at once. sendEmail false skips the email.',\n kind: 'write',\n idempotent: false,\n openWorld: true,\n input: CreateInvite.omit({ roleKey: true }),\n output: Invite,\n call: (input, { container, caller }) =>\n container.organizations.createInvite(input, caller.actor, caller.userId),\n }),\n defineTool({\n name: 'list_access',\n title: 'List access',\n description: 'Who has access to what. Filter by student (orgUserId), content, type or status.',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: EntitlementsQuery,\n call: (input, { container, caller }) => container.entitlements.list(input, caller.actor),\n }),\n defineTool({\n name: 'grant_access',\n title: 'Grant access',\n description:\n 'Give a student access to one course, download, community or event (contentId) or to a bundle (bundleId), never both. expiresAt is a date-time, or null for no expiry.',\n kind: 'write',\n idempotent: false,\n openWorld: false,\n input: GrantEntitlement,\n call: (input, { container, caller }) => container.entitlements.grant(input, caller.actor),\n }),\n defineTool({\n name: 'revoke_access',\n title: 'Revoke access',\n description:\n \"Revoke a student's direct grant to one course, download, community or event (contentId) or to a bundle (bundleId), never both. Access through a bundle stays. grant_access makes it active again.\",\n kind: 'destructive',\n idempotent: false,\n openWorld: false,\n input: RevokeEntitlement,\n call: (input, { container, caller }) => container.entitlements.revoke(input, caller.actor),\n }),\n defineTool({\n name: 'get_dashboard',\n title: 'Get dashboard',\n description:\n 'Published and draft course counts, active students, and access grants expiring soon.',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: noInput,\n call: (_input, { container, caller }) => container.reporting.admin.overview(caller.actor),\n }),\n defineTool({\n name: 'get_enrollment_trend',\n title: 'Get enrollment trend',\n description:\n 'Daily enrollments across the organization for the last days (default 30, at most 365).',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: EnrollmentSeriesQuery,\n call: ({ days }, { container, caller }) =>\n container.reporting.admin.enrollments(days, caller.actor),\n }),\n defineTool({\n name: 'get_course_analytics',\n title: 'Get course analytics',\n description:\n 'Enrolled, started and completed counts, average progress and per-activity engagement for one course.',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: CourseIdPathParam,\n call: ({ courseId }, { container, caller }) =>\n container.reporting.admin.courseAnalytics(courseId, caller.actor),\n }),\n defineTool({\n name: 'list_course_learners',\n title: 'List course learners',\n description:\n \"Page through a course's learners: everyone with a grant that opens it, directly or through a bundle, active or not, with that grant and their progress. Search by name or email.\",\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: CourseLearnersQuery.extend(CourseIdPathParam.shape),\n call: ({ courseId, ...query }, { container, caller }) =>\n container.reporting.admin.courseLearners(courseId, query, caller.actor),\n }),\n defineTool({\n name: 'get_course_enrollment_trend',\n title: 'Get course enrollment trend',\n description: 'Daily enrollments in one course for the last days (default 30, at most 365).',\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: EnrollmentSeriesQuery.extend(CourseIdPathParam.shape),\n call: ({ courseId, days }, { container, caller }) =>\n container.reporting.admin.courseEnrollments(courseId, days, caller.actor),\n }),\n defineTool({\n name: 'get_student_analytics',\n title: 'Get student analytics',\n description:\n \"One student's enrolled, started and completed counts, average progress and progress in each course.\",\n kind: 'read',\n idempotent: true,\n openWorld: false,\n input: StudentIdParam,\n call: ({ id }, { container, caller }) =>\n container.reporting.admin.studentAnalytics(id, caller.actor),\n }),\n];\n"],"mappings":";;;;;;;;;AAcA,MAAa,cAAsB;CACjC,WAAW;EACT,MAAM;EACN,OAAO;EACP,aACE;EACF,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO;EACP,OAAO,OAAO,EAAE,WAAW,aACzB,UAAU,UAAU,MAAM,aAAa,OAAO,OAAO,KAAK;CAC9D,CAAC;CACD,WAAW;EACT,MAAM;EACN,OAAO;EACP,aACE;EACF,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO,aAAa,KAAK,EAAE,SAAS,KAAK,CAAC;EAC1C,QAAQ;EACR,OAAO,OAAO,EAAE,WAAW,aACzB,UAAU,cAAc,aAAa,OAAO,OAAO,OAAO,OAAO,MAAM;CAC3E,CAAC;CACD,WAAW;EACT,MAAM;EACN,OAAO;EACP,aAAa;EACb,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO;EACP,OAAO,OAAO,EAAE,WAAW,aAAa,UAAU,aAAa,KAAK,OAAO,OAAO,KAAK;CACzF,CAAC;CACD,WAAW;EACT,MAAM;EACN,OAAO;EACP,aACE;EACF,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO;EACP,OAAO,OAAO,EAAE,WAAW,aAAa,UAAU,aAAa,MAAM,OAAO,OAAO,KAAK;CAC1F,CAAC;CACD,WAAW;EACT,MAAM;EACN,OAAO;EACP,aACE;EACF,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO;EACP,OAAO,OAAO,EAAE,WAAW,aAAa,UAAU,aAAa,OAAO,OAAO,OAAO,KAAK;CAC3F,CAAC;CACD,WAAW;EACT,MAAM;EACN,OAAO;EACP,aACE;EACF,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO;EACP,OAAO,QAAQ,EAAE,WAAW,aAAa,UAAU,UAAU,MAAM,SAAS,OAAO,KAAK;CAC1F,CAAC;CACD,WAAW;EACT,MAAM;EACN,OAAO;EACP,aACE;EACF,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO;EACP,OAAO,EAAE,QAAQ,EAAE,WAAW,aAC5B,UAAU,UAAU,MAAM,YAAY,MAAM,OAAO,KAAK;CAC5D,CAAC;CACD,WAAW;EACT,MAAM;EACN,OAAO;EACP,aACE;EACF,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO;EACP,OAAO,EAAE,YAAY,EAAE,WAAW,aAChC,UAAU,UAAU,MAAM,gBAAgB,UAAU,OAAO,KAAK;CACpE,CAAC;CACD,WAAW;EACT,MAAM;EACN,OAAO;EACP,aACE;EACF,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO,oBAAoB,OAAO,kBAAkB,KAAK;EACzD,OAAO,EAAE,UAAU,GAAG,SAAS,EAAE,WAAW,aAC1C,UAAU,UAAU,MAAM,eAAe,UAAU,OAAO,OAAO,KAAK;CAC1E,CAAC;CACD,WAAW;EACT,MAAM;EACN,OAAO;EACP,aAAa;EACb,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO,sBAAsB,OAAO,kBAAkB,KAAK;EAC3D,OAAO,EAAE,UAAU,QAAQ,EAAE,WAAW,aACtC,UAAU,UAAU,MAAM,kBAAkB,UAAU,MAAM,OAAO,KAAK;CAC5E,CAAC;CACD,WAAW;EACT,MAAM;EACN,OAAO;EACP,aACE;EACF,MAAM;EACN,YAAY;EACZ,WAAW;EACX,OAAO;EACP,OAAO,EAAE,MAAM,EAAE,WAAW,aAC1B,UAAU,UAAU,MAAM,iBAAiB,IAAI,OAAO,KAAK;CAC/D,CAAC;AACH"}
@@ -1,4 +1,5 @@
1
1
  import { bridgeWebResponse, toWebRequest } from "../web-bridge.js";
2
+ import { isPermission } from "@headless-lms/core/organizations";
2
3
  //#region src/http/plugins/auth.ts
3
4
  /** The request carries no session. The central error handler maps this to 401. */
4
5
  var UnauthorizedError = class extends Error {
@@ -7,6 +8,12 @@ var UnauthorizedError = class extends Error {
7
8
  this.name = "UnauthorizedError";
8
9
  }
9
10
  };
11
+ function carriesKey(request) {
12
+ return request.headers["x-api-key"] !== void 0 || request.headers.authorization !== void 0;
13
+ }
14
+ function callerLimit(limit) {
15
+ return "roleKey" in limit ? limit : { scopes: limit.scopes.filter(isPermission) };
16
+ }
10
17
  function registerAuth(app, appServer) {
11
18
  const { auth } = appServer;
12
19
  const origin = new URL(appServer.mcp.issuer).origin;
@@ -20,32 +27,54 @@ function registerAuth(app, appServer) {
20
27
  await bridgeWebResponse(await auth.handler(toWebRequest(request, origin)), reply);
21
28
  }
22
29
  });
30
+ async function requireKey(request) {
31
+ const grant = await appServer.auth.verifyApiKey(request.headers);
32
+ if (!grant) throw new UnauthorizedError("The API key is not valid");
33
+ const caller = await appServer.organizations.resolveCaller(grant.orgId, grant.userId, callerLimit(grant.limit));
34
+ if (!caller) throw new UnauthorizedError("The API key's organization no longer exists");
35
+ request.authUserId = grant.userId;
36
+ request.userId = caller.userId ?? grant.userId;
37
+ request.orgId = caller.orgId;
38
+ request.authOrgId = grant.orgId;
39
+ request.resolvedCaller = caller;
40
+ appServer.requestContext.set({ orgId: caller.orgId });
41
+ }
23
42
  app.decorate("requireSession", async (request) => {
43
+ if (carriesKey(request)) throw new UnauthorizedError("Sign in to continue; an API key is not accepted here");
24
44
  const session = await appServer.auth.verify(request.headers);
25
45
  if (!session) throw new UnauthorizedError();
26
46
  request.authUser = session.user;
47
+ request.authUserId = session.user.id;
27
48
  request.userId = session.user.id;
28
49
  const authOrgId = session.session.activeOrganizationId;
29
50
  if (authOrgId) {
30
51
  request.authOrgId = authOrgId;
31
- const org = await appServer.organizations.getByExternalId(authOrgId);
32
- if (org) {
33
- request.orgId = org.id;
34
- appServer.requestContext.set({ orgId: org.id });
52
+ const caller = await appServer.organizations.resolveCaller(authOrgId, session.user.id);
53
+ if (caller) {
54
+ request.resolvedCaller = caller;
55
+ request.orgId = caller.orgId;
56
+ appServer.requestContext.set({ orgId: caller.orgId });
35
57
  }
36
58
  }
37
59
  });
38
60
  app.decorate("requireOrgSession", async (request) => {
61
+ if (request.resolvedCaller && request.orgId) return;
62
+ if (carriesKey(request)) {
63
+ await requireKey(request);
64
+ return;
65
+ }
39
66
  const session = await appServer.auth.verify(request.headers);
40
67
  if (!session) throw new UnauthorizedError();
41
68
  const authOrgId = session.session.activeOrganizationId;
42
69
  if (!authOrgId) throw new UnauthorizedError("Choose an organization to continue");
43
- const org = await appServer.organizations.getByExternalId(authOrgId);
44
- if (!org) throw new UnauthorizedError("Your session points to an organization that no longer exists");
70
+ const caller = await appServer.organizations.resolveCaller(authOrgId, session.user.id);
71
+ if (!caller) throw new UnauthorizedError("Your session points to an organization that no longer exists");
45
72
  request.authUser = session.user;
73
+ request.authUserId = session.user.id;
46
74
  request.userId = session.user.id;
47
- request.orgId = org.id;
48
- appServer.requestContext.set({ orgId: org.id });
75
+ request.orgId = caller.orgId;
76
+ request.resolvedCaller = caller;
77
+ appServer.requestContext.set({ orgId: caller.orgId });
49
78
  request.authOrgId = authOrgId;
50
79
  });
51
80
  }
@@ -1 +1 @@
1
- {"version":3,"file":"auth.js","names":[],"sources":["../../../src/http/plugins/auth.ts"],"sourcesContent":["// Everything better-auth touches on the HTTP surface:\n// - the /api/auth/* catch-all bridged to better-auth's Web handler\n// - the `requireSession` / `requireOrgSession` decorators routes guard with\nimport type { FastifyInstance, FastifyRequest } from 'fastify';\nimport type { Container } from '../../app/container.js';\nimport { bridgeWebResponse, toWebRequest } from '../web-bridge.js';\n\n/** The request carries no session. The central error handler maps this to 401. */\nexport class UnauthorizedError extends Error {\n constructor(message = 'Sign in to continue') {\n super(message);\n this.name = 'UnauthorizedError';\n }\n}\n\nexport function registerAuth(app: FastifyInstance, appServer: Container): void {\n const { auth } = appServer;\n const origin = new URL(appServer.mcp.issuer).origin;\n\n app.addContentTypeParser(\n 'application/x-www-form-urlencoded',\n { parseAs: 'string' },\n (_request, body, done) => {\n done(null, body);\n },\n );\n\n // Mount better-auth: a catch-all that bridges Fastify <-> Web Request/Response.\n app.route({\n method: ['GET', 'POST'],\n url: '/api/auth/*',\n async handler(request, reply) {\n await bridgeWebResponse(await auth.handler(toWebRequest(request, origin)), reply);\n },\n });\n\n // Session only, no active org required. For the routes a caller reaches\n // *before* they have an org: creating their first organization, and accepting\n // an invitation (which is what stamps the active org onto the session).\n app.decorate('requireSession', async (request: FastifyRequest) => {\n const session = await appServer.auth.verify(request.headers);\n if (!session) {\n throw new UnauthorizedError();\n }\n request.authUser = session.user;\n request.userId = session.user.id;\n\n const authOrgId = session.session.activeOrganizationId;\n if (authOrgId) {\n request.authOrgId = authOrgId;\n const org = await appServer.organizations.getByExternalId(authOrgId);\n if (org) {\n request.orgId = org.id;\n appServer.requestContext.set({ orgId: org.id });\n }\n }\n });\n\n // Requires a valid session with an active org set — every org-scoped route.\n app.decorate('requireOrgSession', async (request: FastifyRequest) => {\n const session = await appServer.auth.verify(request.headers);\n if (!session) {\n throw new UnauthorizedError();\n }\n const authOrgId = session.session.activeOrganizationId;\n if (!authOrgId) {\n throw new UnauthorizedError('Choose an organization to continue');\n }\n\n const org = await appServer.organizations.getByExternalId(authOrgId);\n if (!org) {\n throw new UnauthorizedError('Your session points to an organization that no longer exists');\n }\n request.authUser = session.user;\n request.userId = session.user.id;\n request.orgId = org.id;\n appServer.requestContext.set({ orgId: org.id });\n // resolveScope requires the auth-provider id too — routes that write back\n // through better-auth key on it.\n request.authOrgId = authOrgId;\n });\n}\n"],"mappings":";;;AAQA,IAAa,oBAAb,cAAuC,MAAM;CAC3C,YAAY,UAAU,uBAAuB;EAC3C,MAAM,OAAO;EACb,KAAK,OAAO;CACd;AACF;AAEA,SAAgB,aAAa,KAAsB,WAA4B;CAC7E,MAAM,EAAE,SAAS;CACjB,MAAM,SAAS,IAAI,IAAI,UAAU,IAAI,MAAM,CAAC,CAAC;CAE7C,IAAI,qBACF,qCACA,EAAE,SAAS,SAAS,IACnB,UAAU,MAAM,SAAS;EACxB,KAAK,MAAM,IAAI;CACjB,CACF;CAGA,IAAI,MAAM;EACR,QAAQ,CAAC,OAAO,MAAM;EACtB,KAAK;EACL,MAAM,QAAQ,SAAS,OAAO;GAC5B,MAAM,kBAAkB,MAAM,KAAK,QAAQ,aAAa,SAAS,MAAM,CAAC,GAAG,KAAK;EAClF;CACF,CAAC;CAKD,IAAI,SAAS,kBAAkB,OAAO,YAA4B;EAChE,MAAM,UAAU,MAAM,UAAU,KAAK,OAAO,QAAQ,OAAO;EAC3D,IAAI,CAAC,SACH,MAAM,IAAI,kBAAkB;EAE9B,QAAQ,WAAW,QAAQ;EAC3B,QAAQ,SAAS,QAAQ,KAAK;EAE9B,MAAM,YAAY,QAAQ,QAAQ;EAClC,IAAI,WAAW;GACb,QAAQ,YAAY;GACpB,MAAM,MAAM,MAAM,UAAU,cAAc,gBAAgB,SAAS;GACnE,IAAI,KAAK;IACP,QAAQ,QAAQ,IAAI;IACpB,UAAU,eAAe,IAAI,EAAE,OAAO,IAAI,GAAG,CAAC;GAChD;EACF;CACF,CAAC;CAGD,IAAI,SAAS,qBAAqB,OAAO,YAA4B;EACnE,MAAM,UAAU,MAAM,UAAU,KAAK,OAAO,QAAQ,OAAO;EAC3D,IAAI,CAAC,SACH,MAAM,IAAI,kBAAkB;EAE9B,MAAM,YAAY,QAAQ,QAAQ;EAClC,IAAI,CAAC,WACH,MAAM,IAAI,kBAAkB,oCAAoC;EAGlE,MAAM,MAAM,MAAM,UAAU,cAAc,gBAAgB,SAAS;EACnE,IAAI,CAAC,KACH,MAAM,IAAI,kBAAkB,8DAA8D;EAE5F,QAAQ,WAAW,QAAQ;EAC3B,QAAQ,SAAS,QAAQ,KAAK;EAC9B,QAAQ,QAAQ,IAAI;EACpB,UAAU,eAAe,IAAI,EAAE,OAAO,IAAI,GAAG,CAAC;EAG9C,QAAQ,YAAY;CACtB,CAAC;AACH"}
1
+ {"version":3,"file":"auth.js","names":[],"sources":["../../../src/http/plugins/auth.ts"],"sourcesContent":["// Everything better-auth touches on the HTTP surface:\n// - the /api/auth/* catch-all bridged to better-auth's Web handler\n// - the `requireSession` / `requireOrgSession` decorators routes guard with\nimport type { FastifyInstance, FastifyRequest } from 'fastify';\nimport type { Container } from '../../app/container.js';\nimport { isPermission } from '@headless-lms/core/organizations';\nimport type { CallerLimit } from '@headless-lms/core/organizations';\nimport type { CredentialLimit } from '@headless-lms/core/types';\nimport { bridgeWebResponse, toWebRequest } from '../web-bridge.js';\n\n/** The request carries no session. The central error handler maps this to 401. */\nexport class UnauthorizedError extends Error {\n constructor(message = 'Sign in to continue') {\n super(message);\n this.name = 'UnauthorizedError';\n }\n}\n\nfunction carriesKey(request: FastifyRequest): boolean {\n return request.headers['x-api-key'] !== undefined || request.headers.authorization !== undefined;\n}\n\nfunction callerLimit(limit: CredentialLimit): CallerLimit {\n return 'roleKey' in limit ? limit : { scopes: limit.scopes.filter(isPermission) };\n}\n\nexport function registerAuth(app: FastifyInstance, appServer: Container): void {\n const { auth } = appServer;\n const origin = new URL(appServer.mcp.issuer).origin;\n\n app.addContentTypeParser(\n 'application/x-www-form-urlencoded',\n { parseAs: 'string' },\n (_request, body, done) => {\n done(null, body);\n },\n );\n\n // Mount better-auth: a catch-all that bridges Fastify <-> Web Request/Response.\n app.route({\n method: ['GET', 'POST'],\n url: '/api/auth/*',\n async handler(request, reply) {\n await bridgeWebResponse(await auth.handler(toWebRequest(request, origin)), reply);\n },\n });\n\n async function requireKey(request: FastifyRequest): Promise<void> {\n const grant = await appServer.auth.verifyApiKey(request.headers);\n if (!grant) {\n throw new UnauthorizedError('The API key is not valid');\n }\n const caller = await appServer.organizations.resolveCaller(\n grant.orgId,\n grant.userId,\n callerLimit(grant.limit),\n );\n if (!caller) {\n throw new UnauthorizedError(\"The API key's organization no longer exists\");\n }\n request.authUserId = grant.userId;\n request.userId = caller.userId ?? grant.userId;\n request.orgId = caller.orgId;\n request.authOrgId = grant.orgId;\n request.resolvedCaller = caller;\n appServer.requestContext.set({ orgId: caller.orgId });\n }\n\n // Session only, no active org required. For the routes a caller reaches\n // *before* they have an org: creating their first organization, and accepting\n // an invitation (which is what stamps the active org onto the session).\n app.decorate('requireSession', async (request: FastifyRequest) => {\n if (carriesKey(request)) {\n throw new UnauthorizedError('Sign in to continue; an API key is not accepted here');\n }\n const session = await appServer.auth.verify(request.headers);\n if (!session) {\n throw new UnauthorizedError();\n }\n request.authUser = session.user;\n request.authUserId = session.user.id;\n request.userId = session.user.id;\n\n const authOrgId = session.session.activeOrganizationId;\n if (authOrgId) {\n request.authOrgId = authOrgId;\n const caller = await appServer.organizations.resolveCaller(authOrgId, session.user.id);\n if (caller) {\n request.resolvedCaller = caller;\n request.orgId = caller.orgId;\n appServer.requestContext.set({ orgId: caller.orgId });\n }\n }\n });\n\n // Requires a valid API key, or a valid session with an active org set — every org-scoped route.\n app.decorate('requireOrgSession', async (request: FastifyRequest) => {\n if (request.resolvedCaller && request.orgId) {\n return;\n }\n if (carriesKey(request)) {\n await requireKey(request);\n return;\n }\n const session = await appServer.auth.verify(request.headers);\n if (!session) {\n throw new UnauthorizedError();\n }\n const authOrgId = session.session.activeOrganizationId;\n if (!authOrgId) {\n throw new UnauthorizedError('Choose an organization to continue');\n }\n\n const caller = await appServer.organizations.resolveCaller(authOrgId, session.user.id);\n if (!caller) {\n throw new UnauthorizedError('Your session points to an organization that no longer exists');\n }\n request.authUser = session.user;\n request.authUserId = session.user.id;\n request.userId = session.user.id;\n request.orgId = caller.orgId;\n request.resolvedCaller = caller;\n appServer.requestContext.set({ orgId: caller.orgId });\n // resolveScope requires the auth-provider id too — routes that write back\n // through better-auth key on it.\n request.authOrgId = authOrgId;\n });\n}\n"],"mappings":"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"}
@@ -1,7 +1,11 @@
1
1
  import { FastifyReply, FastifyRequest } from "fastify";
2
2
  //#region src/http/plugins/error-handler.d.ts
3
+ declare function refusalSubject(error: unknown): {
4
+ permission?: string;
5
+ contentId?: string;
6
+ };
3
7
  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
8
  declare function notFoundHandler(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>;
5
9
  //#endregion
6
- export { errorHandler, notFoundHandler };
10
+ export { errorHandler, notFoundHandler, refusalSubject };
7
11
  //# sourceMappingURL=error-handler.d.ts.map
@@ -5,6 +5,13 @@ import { isResponseSerializationError } from "fastify-type-provider-zod";
5
5
  function routeOf(request) {
6
6
  return `${request.method} ${request.routeOptions.url ?? "unmatched route"}`;
7
7
  }
8
+ function refusalSubject(error) {
9
+ const subject = error;
10
+ return {
11
+ ...typeof subject?.permission === "string" && { permission: subject.permission },
12
+ ...typeof subject?.contentId === "string" && { contentId: subject.contentId }
13
+ };
14
+ }
8
15
  function errorHandler(error, request, reply) {
9
16
  if (error instanceof ActionInvocationError) {
10
17
  request.log.error({
@@ -25,7 +32,8 @@ function errorHandler(error, request, reply) {
25
32
  message: refusal.message,
26
33
  ...refusal.issues && { issues: refusal.issues },
27
34
  ...refusal.details && { details: refusal.details },
28
- ...refusal.error === "bad_request" && { cause: error.message }
35
+ ...refusal.error === "bad_request" && { cause: error.message },
36
+ ...refusalSubject(error)
29
37
  }, `${routeOf(request)} refused`);
30
38
  return reply.status(refusal.status).send({
31
39
  error: refusal.error,
@@ -56,6 +64,6 @@ function notFoundHandler(request, reply) {
56
64
  });
57
65
  }
58
66
  //#endregion
59
- export { errorHandler, notFoundHandler };
67
+ export { errorHandler, notFoundHandler, refusalSubject };
60
68
 
61
69
  //# sourceMappingURL=error-handler.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"error-handler.js","names":[],"sources":["../../../src/http/plugins/error-handler.ts"],"sourcesContent":["import type { FastifyReply, FastifyRequest } from 'fastify';\nimport { ActionInvocationError } from '@headless-lms/core/integrations';\nimport { isResponseSerializationError } from 'fastify-type-provider-zod';\nimport { classifyError } from '../errors.js';\n\nfunction routeOf(request: FastifyRequest): string {\n return `${request.method} ${request.routeOptions.url ?? 'unmatched route'}`;\n}\n\nexport function errorHandler(error: unknown, request: FastifyRequest, reply: FastifyReply) {\n if (error instanceof ActionInvocationError) {\n request.log.error(\n { err: error, integrationId: error.integrationId, actionId: error.actionId },\n `integration action failed in ${routeOf(request)}`,\n );\n return reply.status(502).send({\n error: 'action_failed',\n message: 'The integration did not respond as expected',\n });\n }\n const refusal = classifyError(error);\n if (refusal) {\n request.log.info(\n {\n status: refusal.status,\n error: refusal.error,\n message: refusal.message,\n ...(refusal.issues && { issues: refusal.issues }),\n ...(refusal.details && { details: refusal.details }),\n ...(refusal.error === 'bad_request' && { cause: (error as Error).message }),\n },\n `${routeOf(request)} refused`,\n );\n return reply.status(refusal.status).send({\n error: refusal.error,\n message: refusal.message,\n ...(refusal.issues && { issues: refusal.issues }),\n ...(refusal.details && { details: refusal.details }),\n });\n }\n if (isResponseSerializationError(error)) {\n request.log.error(\n { err: error, issues: error.cause.issues },\n `response of ${routeOf(request)} does not match its schema`,\n );\n } else {\n request.log.error({ err: error }, `unhandled error in ${routeOf(request)}`);\n }\n return reply.status(500).send({\n error: 'internal_error',\n message: 'Something went wrong on our side. Quote the request id when reporting it.',\n requestId: request.id,\n });\n}\n\nexport function notFoundHandler(request: FastifyRequest, reply: FastifyReply) {\n request.log.info(\n { status: 404, error: 'not_found' },\n `${request.method} unmatched route refused`,\n );\n return reply\n .status(404)\n .send({ error: 'not_found', message: `No route for ${request.method} ${request.url}` });\n}\n"],"mappings":";;;;AAKA,SAAS,QAAQ,SAAiC;CAChD,OAAO,GAAG,QAAQ,OAAO,GAAG,QAAQ,aAAa,OAAO;AAC1D;AAEA,SAAgB,aAAa,OAAgB,SAAyB,OAAqB;CACzF,IAAI,iBAAiB,uBAAuB;EAC1C,QAAQ,IAAI,MACV;GAAE,KAAK;GAAO,eAAe,MAAM;GAAe,UAAU,MAAM;EAAS,GAC3E,gCAAgC,QAAQ,OAAO,GACjD;EACA,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;GAC5B,OAAO;GACP,SAAS;EACX,CAAC;CACH;CACA,MAAM,UAAU,cAAc,KAAK;CACnC,IAAI,SAAS;EACX,QAAQ,IAAI,KACV;GACE,QAAQ,QAAQ;GAChB,OAAO,QAAQ;GACf,SAAS,QAAQ;GACjB,GAAI,QAAQ,UAAU,EAAE,QAAQ,QAAQ,OAAO;GAC/C,GAAI,QAAQ,WAAW,EAAE,SAAS,QAAQ,QAAQ;GAClD,GAAI,QAAQ,UAAU,iBAAiB,EAAE,OAAQ,MAAgB,QAAQ;EAC3E,GACA,GAAG,QAAQ,OAAO,EAAE,SACtB;EACA,OAAO,MAAM,OAAO,QAAQ,MAAM,CAAC,CAAC,KAAK;GACvC,OAAO,QAAQ;GACf,SAAS,QAAQ;GACjB,GAAI,QAAQ,UAAU,EAAE,QAAQ,QAAQ,OAAO;GAC/C,GAAI,QAAQ,WAAW,EAAE,SAAS,QAAQ,QAAQ;EACpD,CAAC;CACH;CACA,IAAI,6BAA6B,KAAK,GACpC,QAAQ,IAAI,MACV;EAAE,KAAK;EAAO,QAAQ,MAAM,MAAM;CAAO,GACzC,eAAe,QAAQ,OAAO,EAAE,2BAClC;MAEA,QAAQ,IAAI,MAAM,EAAE,KAAK,MAAM,GAAG,sBAAsB,QAAQ,OAAO,GAAG;CAE5E,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAC5B,OAAO;EACP,SAAS;EACT,WAAW,QAAQ;CACrB,CAAC;AACH;AAEA,SAAgB,gBAAgB,SAAyB,OAAqB;CAC5E,QAAQ,IAAI,KACV;EAAE,QAAQ;EAAK,OAAO;CAAY,GAClC,GAAG,QAAQ,OAAO,yBACpB;CACA,OAAO,MACJ,OAAO,GAAG,CAAC,CACX,KAAK;EAAE,OAAO;EAAa,SAAS,gBAAgB,QAAQ,OAAO,GAAG,QAAQ;CAAM,CAAC;AAC1F"}
1
+ {"version":3,"file":"error-handler.js","names":[],"sources":["../../../src/http/plugins/error-handler.ts"],"sourcesContent":["import type { FastifyReply, FastifyRequest } from 'fastify';\nimport { ActionInvocationError } from '@headless-lms/core/integrations';\nimport { isResponseSerializationError } from 'fastify-type-provider-zod';\nimport { classifyError } from '../errors.js';\n\nfunction routeOf(request: FastifyRequest): string {\n return `${request.method} ${request.routeOptions.url ?? 'unmatched route'}`;\n}\n\nexport function refusalSubject(error: unknown): { permission?: string; contentId?: string } {\n const subject = error as { permission?: unknown; contentId?: unknown };\n return {\n ...(typeof subject?.permission === 'string' && { permission: subject.permission }),\n ...(typeof subject?.contentId === 'string' && { contentId: subject.contentId }),\n };\n}\n\nexport function errorHandler(error: unknown, request: FastifyRequest, reply: FastifyReply) {\n if (error instanceof ActionInvocationError) {\n request.log.error(\n { err: error, integrationId: error.integrationId, actionId: error.actionId },\n `integration action failed in ${routeOf(request)}`,\n );\n return reply.status(502).send({\n error: 'action_failed',\n message: 'The integration did not respond as expected',\n });\n }\n const refusal = classifyError(error);\n if (refusal) {\n request.log.info(\n {\n status: refusal.status,\n error: refusal.error,\n message: refusal.message,\n ...(refusal.issues && { issues: refusal.issues }),\n ...(refusal.details && { details: refusal.details }),\n ...(refusal.error === 'bad_request' && { cause: (error as Error).message }),\n ...refusalSubject(error),\n },\n `${routeOf(request)} refused`,\n );\n return reply.status(refusal.status).send({\n error: refusal.error,\n message: refusal.message,\n ...(refusal.issues && { issues: refusal.issues }),\n ...(refusal.details && { details: refusal.details }),\n });\n }\n if (isResponseSerializationError(error)) {\n request.log.error(\n { err: error, issues: error.cause.issues },\n `response of ${routeOf(request)} does not match its schema`,\n );\n } else {\n request.log.error({ err: error }, `unhandled error in ${routeOf(request)}`);\n }\n return reply.status(500).send({\n error: 'internal_error',\n message: 'Something went wrong on our side. Quote the request id when reporting it.',\n requestId: request.id,\n });\n}\n\nexport function notFoundHandler(request: FastifyRequest, reply: FastifyReply) {\n request.log.info(\n { status: 404, error: 'not_found' },\n `${request.method} unmatched route refused`,\n );\n return reply\n .status(404)\n .send({ error: 'not_found', message: `No route for ${request.method} ${request.url}` });\n}\n"],"mappings":";;;;AAKA,SAAS,QAAQ,SAAiC;CAChD,OAAO,GAAG,QAAQ,OAAO,GAAG,QAAQ,aAAa,OAAO;AAC1D;AAEA,SAAgB,eAAe,OAA6D;CAC1F,MAAM,UAAU;CAChB,OAAO;EACL,GAAI,OAAO,SAAS,eAAe,YAAY,EAAE,YAAY,QAAQ,WAAW;EAChF,GAAI,OAAO,SAAS,cAAc,YAAY,EAAE,WAAW,QAAQ,UAAU;CAC/E;AACF;AAEA,SAAgB,aAAa,OAAgB,SAAyB,OAAqB;CACzF,IAAI,iBAAiB,uBAAuB;EAC1C,QAAQ,IAAI,MACV;GAAE,KAAK;GAAO,eAAe,MAAM;GAAe,UAAU,MAAM;EAAS,GAC3E,gCAAgC,QAAQ,OAAO,GACjD;EACA,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;GAC5B,OAAO;GACP,SAAS;EACX,CAAC;CACH;CACA,MAAM,UAAU,cAAc,KAAK;CACnC,IAAI,SAAS;EACX,QAAQ,IAAI,KACV;GACE,QAAQ,QAAQ;GAChB,OAAO,QAAQ;GACf,SAAS,QAAQ;GACjB,GAAI,QAAQ,UAAU,EAAE,QAAQ,QAAQ,OAAO;GAC/C,GAAI,QAAQ,WAAW,EAAE,SAAS,QAAQ,QAAQ;GAClD,GAAI,QAAQ,UAAU,iBAAiB,EAAE,OAAQ,MAAgB,QAAQ;GACzE,GAAG,eAAe,KAAK;EACzB,GACA,GAAG,QAAQ,OAAO,EAAE,SACtB;EACA,OAAO,MAAM,OAAO,QAAQ,MAAM,CAAC,CAAC,KAAK;GACvC,OAAO,QAAQ;GACf,SAAS,QAAQ;GACjB,GAAI,QAAQ,UAAU,EAAE,QAAQ,QAAQ,OAAO;GAC/C,GAAI,QAAQ,WAAW,EAAE,SAAS,QAAQ,QAAQ;EACpD,CAAC;CACH;CACA,IAAI,6BAA6B,KAAK,GACpC,QAAQ,IAAI,MACV;EAAE,KAAK;EAAO,QAAQ,MAAM,MAAM;CAAO,GACzC,eAAe,QAAQ,OAAO,EAAE,2BAClC;MAEA,QAAQ,IAAI,MAAM,EAAE,KAAK,MAAM,GAAG,sBAAsB,QAAQ,OAAO,GAAG;CAE5E,OAAO,MAAM,OAAO,GAAG,CAAC,CAAC,KAAK;EAC5B,OAAO;EACP,SAAS;EACT,WAAW,QAAQ;CACrB,CAAC;AACH;AAEA,SAAgB,gBAAgB,SAAyB,OAAqB;CAC5E,QAAQ,IAAI,KACV;EAAE,QAAQ;EAAK,OAAO;CAAY,GAClC,GAAG,QAAQ,OAAO,yBACpB;CACA,OAAO,MACJ,OAAO,GAAG,CAAC,CACX,KAAK;EAAE,OAAO;EAAa,SAAS,gBAAgB,QAAQ,OAAO,GAAG,QAAQ;CAAM,CAAC;AAC1F"}
@@ -22,7 +22,7 @@ function registerOpenApi(app, config) {
22
22
  info: {
23
23
  title: "Headless LMS API",
24
24
  version,
25
- description: "API documentation for the Headless LMS platform",
25
+ description: "API documentation for the Headless LMS platform. Every route under /api takes a signed-in session or an API key, sent in the `x-api-key` header or as `Authorization: Bearer <key>`. The platform routes take the platform key as `Authorization: Bearer <platform key>`.",
26
26
  license: {
27
27
  name: "MIT",
28
28
  url: "https://opensource.org/licenses/MIT"
@@ -38,6 +38,10 @@ function registerOpenApi(app, config) {
38
38
  name: "Content",
39
39
  description: "Authored content: courses and downloads, their structure and delivery"
40
40
  },
41
+ {
42
+ name: "Events",
43
+ description: "Live sessions at a set time: their details, location, recording and files"
44
+ },
41
45
  {
42
46
  name: "Entitlements",
43
47
  description: "Access grants to content"
@@ -90,9 +94,21 @@ function registerOpenApi(app, config) {
90
94
  name: "Reporting",
91
95
  description: "Reporting and analytics"
92
96
  },
97
+ {
98
+ name: "People",
99
+ description: "The organization's people, invites and the roles they hold"
100
+ },
101
+ {
102
+ name: "Roles",
103
+ description: "The organization's roles and their permissions"
104
+ },
93
105
  {
94
106
  name: "Access",
95
- description: "Access scopes, connected apps and API keys"
107
+ description: "Your permissions, connected apps and API keys"
108
+ },
109
+ {
110
+ name: "Platform",
111
+ description: "Organizations and their API keys, created with the platform key"
96
112
  }
97
113
  ]
98
114
  },
@@ -1 +1 @@
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 { ErrorBody, ValidationErrorBody } from '../schemas/shared.js';\nimport pkg from '../../../package.json' with { type: 'json' };\n\nconst PUBLIC_ROUTES = new Set(['GET /api/organizations/invites/:token']);\n\ntype RouteSchema = {\n body?: unknown;\n querystring?: unknown;\n params?: unknown;\n response?: Record<string, unknown>;\n};\n\nfunction withDefaultResponses(\n schema: RouteSchema | undefined,\n method: string,\n url: string,\n): RouteSchema | undefined {\n if (!schema || !url.startsWith('/api/') || url.startsWith('/api/auth/')) {\n return schema;\n }\n const response: Record<string, unknown> = { ...(schema.response ?? {}) };\n if ((schema.body || schema.querystring || schema.params) && !response['400']) {\n response['400'] = ValidationErrorBody;\n }\n if (!PUBLIC_ROUTES.has(`${method} ${url}`) && !response['401']) {\n response['401'] = ErrorBody;\n }\n if (!response['500']) {\n response['500'] = ErrorBody;\n }\n return { ...schema, response };\n}\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 {\n name: 'Communities',\n description: 'Communities, their spaces, topics, and direct-message settings',\n },\n { name: 'Posts', description: 'Posts in a space, reactions, and the moderation queue' },\n { name: 'Chats', description: 'Direct and group chats inside one community' },\n { name: 'Profile', description: 'The signed-in member profile' },\n {\n name: 'Notifications',\n description: 'What happened to the signed-in member, and how they want to be told',\n },\n { name: 'Assets', description: 'The org media library: uploads, serving and lifecycle' },\n {\n name: 'Files',\n description: 'Stable file URLs that check access and redirect to a signed storage URL',\n },\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 { name: 'Access', description: 'Access scopes, connected apps and API keys' },\n ],\n },\n transform: (input) => {\n const method = String(\n Array.isArray(input.route.method) ? input.route.method[0] : input.route.method,\n );\n return jsonSchemaTransform({\n ...input,\n schema: withDefaultResponses(input.schema as RouteSchema | undefined, method, input.url),\n } as typeof input);\n },\n transformObject: jsonSchemaTransformObject,\n });\n app.register(swaggerUi, { routePrefix: '/docs' });\n}\n"],"mappings":";;;;;;AAYA,MAAM,gCAAgB,IAAI,IAAI,CAAC,uCAAuC,CAAC;AASvE,SAAS,qBACP,QACA,QACA,KACyB;CACzB,IAAI,CAAC,UAAU,CAAC,IAAI,WAAW,OAAO,KAAK,IAAI,WAAW,YAAY,GACpE,OAAO;CAET,MAAM,WAAoC,EAAE,GAAI,OAAO,YAAY,CAAC,EAAG;CACvE,KAAK,OAAO,QAAQ,OAAO,eAAe,OAAO,WAAW,CAAC,SAAS,QACpE,SAAS,SAAS;CAEpB,IAAI,CAAC,cAAc,IAAI,GAAG,OAAO,GAAG,KAAK,KAAK,CAAC,SAAS,QACtD,SAAS,SAAS;CAEpB,IAAI,CAAC,SAAS,QACZ,SAAS,SAAS;CAEpB,OAAO;EAAE,GAAG;EAAQ;CAAS;AAC/B;AAEA,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;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAS,aAAa;IAAwD;IACtF;KAAE,MAAM;KAAS,aAAa;IAA8C;IAC5E;KAAE,MAAM;KAAW,aAAa;IAA+B;IAC/D;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAU,aAAa;IAAwD;IACvF;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAe,aAAa;IAAqD;IACzF;KAAE,MAAM;KAAgB,aAAa;IAAmC;IACxE;KAAE,MAAM;KAAa,aAAa;IAA0B;IAC5D;KAAE,MAAM;KAAU,aAAa;IAA6C;GAC9E;EACF;EACA,YAAY,UAAU;GACpB,MAAM,SAAS,OACb,MAAM,QAAQ,MAAM,MAAM,MAAM,IAAI,MAAM,MAAM,OAAO,KAAK,MAAM,MAAM,MAC1E;GACA,OAAO,oBAAoB;IACzB,GAAG;IACH,QAAQ,qBAAqB,MAAM,QAAmC,QAAQ,MAAM,GAAG;GACzF,CAAiB;EACnB;EACA,iBAAiB;CACnB,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 { ErrorBody, ValidationErrorBody } from '../schemas/shared.js';\nimport pkg from '../../../package.json' with { type: 'json' };\n\nconst PUBLIC_ROUTES = new Set(['GET /api/organizations/invites/:token']);\n\ntype RouteSchema = {\n body?: unknown;\n querystring?: unknown;\n params?: unknown;\n response?: Record<string, unknown>;\n};\n\nfunction withDefaultResponses(\n schema: RouteSchema | undefined,\n method: string,\n url: string,\n): RouteSchema | undefined {\n if (!schema || !url.startsWith('/api/') || url.startsWith('/api/auth/')) {\n return schema;\n }\n const response: Record<string, unknown> = { ...(schema.response ?? {}) };\n if ((schema.body || schema.querystring || schema.params) && !response['400']) {\n response['400'] = ValidationErrorBody;\n }\n if (!PUBLIC_ROUTES.has(`${method} ${url}`) && !response['401']) {\n response['401'] = ErrorBody;\n }\n if (!response['500']) {\n response['500'] = ErrorBody;\n }\n return { ...schema, response };\n}\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:\n 'API documentation for the Headless LMS platform. Every route under /api takes a signed-in session or an API key, sent in the `x-api-key` header or as `Authorization: Bearer <key>`. The platform routes take the platform key as `Authorization: Bearer <platform key>`.',\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 {\n name: 'Events',\n description: 'Live sessions at a set time: their details, location, recording and files',\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 {\n name: 'Communities',\n description: 'Communities, their spaces, topics, and direct-message settings',\n },\n { name: 'Posts', description: 'Posts in a space, reactions, and the moderation queue' },\n { name: 'Chats', description: 'Direct and group chats inside one community' },\n { name: 'Profile', description: 'The signed-in member profile' },\n {\n name: 'Notifications',\n description: 'What happened to the signed-in member, and how they want to be told',\n },\n { name: 'Assets', description: 'The org media library: uploads, serving and lifecycle' },\n {\n name: 'Files',\n description: 'Stable file URLs that check access and redirect to a signed storage URL',\n },\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 name: 'People',\n description: \"The organization's people, invites and the roles they hold\",\n },\n { name: 'Roles', description: \"The organization's roles and their permissions\" },\n { name: 'Access', description: 'Your permissions, connected apps and API keys' },\n {\n name: 'Platform',\n description: 'Organizations and their API keys, created with the platform key',\n },\n ],\n },\n transform: (input) => {\n const method = String(\n Array.isArray(input.route.method) ? input.route.method[0] : input.route.method,\n );\n return jsonSchemaTransform({\n ...input,\n schema: withDefaultResponses(input.schema as RouteSchema | undefined, method, input.url),\n } as typeof input);\n },\n transformObject: jsonSchemaTransformObject,\n });\n app.register(swaggerUi, { routePrefix: '/docs' });\n}\n"],"mappings":";;;;;;AAYA,MAAM,gCAAgB,IAAI,IAAI,CAAC,uCAAuC,CAAC;AASvE,SAAS,qBACP,QACA,QACA,KACyB;CACzB,IAAI,CAAC,UAAU,CAAC,IAAI,WAAW,OAAO,KAAK,IAAI,WAAW,YAAY,GACpE,OAAO;CAET,MAAM,WAAoC,EAAE,GAAI,OAAO,YAAY,CAAC,EAAG;CACvE,KAAK,OAAO,QAAQ,OAAO,eAAe,OAAO,WAAW,CAAC,SAAS,QACpE,SAAS,SAAS;CAEpB,IAAI,CAAC,cAAc,IAAI,GAAG,OAAO,GAAG,KAAK,KAAK,CAAC,SAAS,QACtD,SAAS,SAAS;CAEpB,IAAI,CAAC,SAAS,QACZ,SAAS,SAAS;CAEpB,OAAO;EAAE,GAAG;EAAQ;CAAS;AAC/B;AAEA,SAAgB,gBAAgB,KAAsB,QAA4B;CAChF,IAAI,SAAS,SAAS;EACpB,SAAS;GACP,MAAM;IACJ,OAAO;IACEA;IACT,aACE;IACF,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;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAgB,aAAa;IAA2B;IAChE;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAc,aAAa;IAAsD;IACzF;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAS,aAAa;IAAwD;IACtF;KAAE,MAAM;KAAS,aAAa;IAA8C;IAC5E;KAAE,MAAM;KAAW,aAAa;IAA+B;IAC/D;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAU,aAAa;IAAwD;IACvF;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAe,aAAa;IAAqD;IACzF;KAAE,MAAM;KAAgB,aAAa;IAAmC;IACxE;KAAE,MAAM;KAAa,aAAa;IAA0B;IAC5D;KACE,MAAM;KACN,aAAa;IACf;IACA;KAAE,MAAM;KAAS,aAAa;IAAiD;IAC/E;KAAE,MAAM;KAAU,aAAa;IAAgD;IAC/E;KACE,MAAM;KACN,aAAa;IACf;GACF;EACF;EACA,YAAY,UAAU;GACpB,MAAM,SAAS,OACb,MAAM,QAAQ,MAAM,MAAM,MAAM,IAAI,MAAM,MAAM,OAAO,KAAK,MAAM,MAAM,MAC1E;GACA,OAAO,oBAAoB;IACzB,GAAG;IACH,QAAQ,qBAAqB,MAAM,QAAmC,QAAQ,MAAM,GAAG;GACzF,CAAiB;EACnB;EACA,iBAAiB;CACnB,CAAC;CACD,IAAI,SAAS,WAAW,EAAE,aAAa,QAAQ,CAAC;AAClD"}
@@ -0,0 +1,7 @@
1
+ import { PlatformKeyCheck } from "../../app/platform.js";
2
+ import { FastifyInstance } from "fastify";
3
+ //#region src/http/plugins/platform.d.ts
4
+ declare function registerPlatformAuth(app: FastifyInstance, platform: PlatformKeyCheck): void;
5
+ //#endregion
6
+ export { registerPlatformAuth };
7
+ //# sourceMappingURL=platform.d.ts.map
@@ -0,0 +1,13 @@
1
+ import { UnauthorizedError } from "./auth.js";
2
+ //#region src/http/plugins/platform.ts
3
+ const BEARER = /^Bearer\s+(\S+)$/i;
4
+ function registerPlatformAuth(app, platform) {
5
+ app.decorate("requirePlatform", async (request) => {
6
+ const match = BEARER.exec(request.headers.authorization ?? "");
7
+ if (!match || !platform.matches(match[1])) throw new UnauthorizedError("The platform key is not valid");
8
+ });
9
+ }
10
+ //#endregion
11
+ export { registerPlatformAuth };
12
+
13
+ //# sourceMappingURL=platform.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform.js","names":[],"sources":["../../../src/http/plugins/platform.ts"],"sourcesContent":["import type { FastifyInstance, FastifyRequest } from 'fastify';\nimport type { PlatformKeyCheck } from '../../app/platform.js';\nimport { UnauthorizedError } from './auth.js';\n\nconst BEARER = /^Bearer\\s+(\\S+)$/i;\n\nexport function registerPlatformAuth(app: FastifyInstance, platform: PlatformKeyCheck): void {\n app.decorate('requirePlatform', async (request: FastifyRequest) => {\n const match = BEARER.exec(request.headers.authorization ?? '');\n if (!match || !platform.matches(match[1]!)) {\n throw new UnauthorizedError('The platform key is not valid');\n }\n });\n}\n"],"mappings":";;AAIA,MAAM,SAAS;AAEf,SAAgB,qBAAqB,KAAsB,UAAkC;CAC3F,IAAI,SAAS,mBAAmB,OAAO,YAA4B;EACjE,MAAM,QAAQ,OAAO,KAAK,QAAQ,QAAQ,iBAAiB,EAAE;EAC7D,IAAI,CAAC,SAAS,CAAC,SAAS,QAAQ,MAAM,EAAG,GACvC,MAAM,IAAI,kBAAkB,+BAA+B;CAE/D,CAAC;AACH"}