@headless-lms/server 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +14 -0
  2. package/dist/app/community-mail.d.ts +10 -10
  3. package/dist/app/community-mail.js +72 -28
  4. package/dist/app/community-mail.js.map +1 -1
  5. package/dist/app/container.d.ts +48 -10
  6. package/dist/app/container.js +268 -48
  7. package/dist/app/container.js.map +1 -1
  8. package/dist/app/notification-channels.d.ts +47 -0
  9. package/dist/app/notification-channels.js +175 -0
  10. package/dist/app/notification-channels.js.map +1 -0
  11. package/dist/app/notification-reads.d.ts +23 -0
  12. package/dist/app/notification-reads.js +74 -0
  13. package/dist/app/notification-reads.js.map +1 -0
  14. package/dist/app/platform.d.ts +14 -0
  15. package/dist/app/platform.js +14 -0
  16. package/dist/app/platform.js.map +1 -0
  17. package/dist/http/config.d.ts +4 -0
  18. package/dist/http/errors.d.ts +1 -0
  19. package/dist/http/errors.js +24 -4
  20. package/dist/http/errors.js.map +1 -1
  21. package/dist/http/fastify.d.ts +8 -0
  22. package/dist/http/mcp/catalogue.d.ts +11 -9
  23. package/dist/http/mcp/catalogue.js +15 -21
  24. package/dist/http/mcp/catalogue.js.map +1 -1
  25. package/dist/http/mcp/challenge.js +3 -3
  26. package/dist/http/mcp/challenge.js.map +1 -1
  27. package/dist/http/mcp/route.js +7 -7
  28. package/dist/http/mcp/route.js.map +1 -1
  29. package/dist/http/mcp/test-support.d.ts +6 -5
  30. package/dist/http/mcp/test-support.js +32 -11
  31. package/dist/http/mcp/test-support.js.map +1 -1
  32. package/dist/http/mcp/tool.d.ts +3 -9
  33. package/dist/http/mcp/tool.js +14 -6
  34. package/dist/http/mcp/tool.js.map +1 -1
  35. package/dist/http/mcp/tools/automations.js +10 -20
  36. package/dist/http/mcp/tools/automations.js.map +1 -1
  37. package/dist/http/mcp/tools/catalog.js +25 -42
  38. package/dist/http/mcp/tools/catalog.js.map +1 -1
  39. package/dist/http/mcp/tools/comments.js +9 -19
  40. package/dist/http/mcp/tools/comments.js.map +1 -1
  41. package/dist/http/mcp/tools/communities.js +16 -31
  42. package/dist/http/mcp/tools/communities.js.map +1 -1
  43. package/dist/http/mcp/tools/community-members.js +9 -16
  44. package/dist/http/mcp/tools/community-members.js.map +1 -1
  45. package/dist/http/mcp/tools/community-moderation.js +13 -25
  46. package/dist/http/mcp/tools/community-moderation.js.map +1 -1
  47. package/dist/http/mcp/tools/courses.js +11 -21
  48. package/dist/http/mcp/tools/courses.js.map +1 -1
  49. package/dist/http/mcp/tools/events.d.ts +6 -0
  50. package/dist/http/mcp/tools/events.js +170 -0
  51. package/dist/http/mcp/tools/events.js.map +1 -0
  52. package/dist/http/mcp/tools/index.d.ts +5 -1
  53. package/dist/http/mcp/tools/index.js +45 -10
  54. package/dist/http/mcp/tools/index.js.map +1 -1
  55. package/dist/http/mcp/tools/organization.d.ts +6 -0
  56. package/dist/http/mcp/tools/organization.js +214 -0
  57. package/dist/http/mcp/tools/organization.js.map +1 -0
  58. package/dist/http/mcp/tools/people.js +30 -49
  59. package/dist/http/mcp/tools/people.js.map +1 -1
  60. package/dist/http/plugins/auth.js +37 -8
  61. package/dist/http/plugins/auth.js.map +1 -1
  62. package/dist/http/plugins/error-handler.d.ts +5 -1
  63. package/dist/http/plugins/error-handler.js +13 -3
  64. package/dist/http/plugins/error-handler.js.map +1 -1
  65. package/dist/http/plugins/openapi.js +18 -2
  66. package/dist/http/plugins/openapi.js.map +1 -1
  67. package/dist/http/plugins/platform.d.ts +7 -0
  68. package/dist/http/plugins/platform.js +13 -0
  69. package/dist/http/plugins/platform.js.map +1 -0
  70. package/dist/http/routes/access.js +54 -36
  71. package/dist/http/routes/access.js.map +1 -1
  72. package/dist/http/routes/activities.js +45 -45
  73. package/dist/http/routes/activities.js.map +1 -1
  74. package/dist/http/routes/assets.js +30 -65
  75. package/dist/http/routes/assets.js.map +1 -1
  76. package/dist/http/routes/automations.js +37 -42
  77. package/dist/http/routes/automations.js.map +1 -1
  78. package/dist/http/routes/bundles.js +33 -36
  79. package/dist/http/routes/bundles.js.map +1 -1
  80. package/dist/http/routes/communities.js +144 -136
  81. package/dist/http/routes/communities.js.map +1 -1
  82. package/dist/http/routes/community-moderation.js +83 -137
  83. package/dist/http/routes/community-moderation.js.map +1 -1
  84. package/dist/http/routes/course-analytics.js +32 -15
  85. package/dist/http/routes/course-analytics.js.map +1 -1
  86. package/dist/http/routes/courses.js +25 -32
  87. package/dist/http/routes/courses.js.map +1 -1
  88. package/dist/http/routes/dashboard.js +9 -9
  89. package/dist/http/routes/dashboard.js.map +1 -1
  90. package/dist/http/routes/discussion.js +21 -39
  91. package/dist/http/routes/discussion.js.map +1 -1
  92. package/dist/http/routes/downloads.js +41 -47
  93. package/dist/http/routes/downloads.js.map +1 -1
  94. package/dist/http/routes/entitlements.js +19 -23
  95. package/dist/http/routes/entitlements.js.map +1 -1
  96. package/dist/http/routes/events.d.ts +7 -0
  97. package/dist/http/routes/events.js +333 -0
  98. package/dist/http/routes/events.js.map +1 -0
  99. package/dist/http/routes/integrations.js +40 -73
  100. package/dist/http/routes/integrations.js.map +1 -1
  101. package/dist/http/routes/member/assets.js +10 -13
  102. package/dist/http/routes/member/assets.js.map +1 -1
  103. package/dist/http/routes/member/chats.js +177 -75
  104. package/dist/http/routes/member/chats.js.map +1 -1
  105. package/dist/http/routes/member/comments.js +65 -26
  106. package/dist/http/routes/member/comments.js.map +1 -1
  107. package/dist/http/routes/member/community.js +68 -51
  108. package/dist/http/routes/member/community.js.map +1 -1
  109. package/dist/http/routes/member/courses.js +12 -19
  110. package/dist/http/routes/member/courses.js.map +1 -1
  111. package/dist/http/routes/member/devices.d.ts +7 -0
  112. package/dist/http/routes/member/devices.js +97 -0
  113. package/dist/http/routes/member/devices.js.map +1 -0
  114. package/dist/http/routes/member/downloads.js +6 -9
  115. package/dist/http/routes/member/downloads.js.map +1 -1
  116. package/dist/http/routes/member/events.d.ts +7 -0
  117. package/dist/http/routes/member/events.js +386 -0
  118. package/dist/http/routes/member/events.js.map +1 -0
  119. package/dist/http/routes/member/files.js +71 -46
  120. package/dist/http/routes/member/files.js.map +1 -1
  121. package/dist/http/routes/member/index.js +6 -0
  122. package/dist/http/routes/member/index.js.map +1 -1
  123. package/dist/http/routes/member/notification-settings.d.ts +7 -0
  124. package/dist/http/routes/member/notification-settings.js +100 -0
  125. package/dist/http/routes/member/notification-settings.js.map +1 -0
  126. package/dist/http/routes/member/notifications.js +69 -45
  127. package/dist/http/routes/member/notifications.js.map +1 -1
  128. package/dist/http/routes/member/org.js +5 -15
  129. package/dist/http/routes/member/org.js.map +1 -1
  130. package/dist/http/routes/member/posts.js +94 -95
  131. package/dist/http/routes/member/posts.js.map +1 -1
  132. package/dist/http/routes/member/profile.js +15 -6
  133. package/dist/http/routes/member/profile.js.map +1 -1
  134. package/dist/http/routes/member/progress.js +7 -11
  135. package/dist/http/routes/member/progress.js.map +1 -1
  136. package/dist/http/routes/organizations.js +415 -110
  137. package/dist/http/routes/organizations.js.map +1 -1
  138. package/dist/http/routes/platform.d.ts +7 -0
  139. package/dist/http/routes/platform.js +61 -0
  140. package/dist/http/routes/platform.js.map +1 -0
  141. package/dist/http/routes/students.js +12 -75
  142. package/dist/http/routes/students.js.map +1 -1
  143. package/dist/http/routes/webhooks.js +30 -39
  144. package/dist/http/routes/webhooks.js.map +1 -1
  145. package/dist/http/routes.js +9 -0
  146. package/dist/http/routes.js.map +1 -1
  147. package/dist/http/schemas/access.d.ts +346 -64
  148. package/dist/http/schemas/access.js +39 -12
  149. package/dist/http/schemas/access.js.map +1 -1
  150. package/dist/http/schemas/assets.d.ts +13 -8
  151. package/dist/http/schemas/assets.js +6 -5
  152. package/dist/http/schemas/assets.js.map +1 -1
  153. package/dist/http/schemas/automations.d.ts +4 -8
  154. package/dist/http/schemas/chats.d.ts +238 -36
  155. package/dist/http/schemas/chats.js +14 -7
  156. package/dist/http/schemas/chats.js.map +1 -1
  157. package/dist/http/schemas/community.d.ts +140 -33
  158. package/dist/http/schemas/community.js +25 -9
  159. package/dist/http/schemas/community.js.map +1 -1
  160. package/dist/http/schemas/course-analytics.d.ts +51 -1
  161. package/dist/http/schemas/course-analytics.js +19 -1
  162. package/dist/http/schemas/course-analytics.js.map +1 -1
  163. package/dist/http/schemas/dashboard.d.ts +1 -0
  164. package/dist/http/schemas/dashboard.js +2 -1
  165. package/dist/http/schemas/dashboard.js.map +1 -1
  166. package/dist/http/schemas/discussion.d.ts +45 -73
  167. package/dist/http/schemas/discussion.js +16 -4
  168. package/dist/http/schemas/discussion.js.map +1 -1
  169. package/dist/http/schemas/entitlements.d.ts +9 -12
  170. package/dist/http/schemas/entitlements.js +11 -5
  171. package/dist/http/schemas/entitlements.js.map +1 -1
  172. package/dist/http/schemas/events.d.ts +527 -0
  173. package/dist/http/schemas/events.js +64 -0
  174. package/dist/http/schemas/events.js.map +1 -0
  175. package/dist/http/schemas/files.d.ts +15 -5
  176. package/dist/http/schemas/files.js +10 -2
  177. package/dist/http/schemas/files.js.map +1 -1
  178. package/dist/http/schemas/index.d.ts +14 -12
  179. package/dist/http/schemas/index.js +14 -12
  180. package/dist/http/schemas/invites.d.ts +3 -47
  181. package/dist/http/schemas/invites.js +4 -25
  182. package/dist/http/schemas/invites.js.map +1 -1
  183. package/dist/http/schemas/member.d.ts +94 -7
  184. package/dist/http/schemas/member.js +7 -3
  185. package/dist/http/schemas/member.js.map +1 -1
  186. package/dist/http/schemas/members.d.ts +329 -65
  187. package/dist/http/schemas/members.js +67 -25
  188. package/dist/http/schemas/members.js.map +1 -1
  189. package/dist/http/schemas/notifications.d.ts +502 -175
  190. package/dist/http/schemas/notifications.js +148 -28
  191. package/dist/http/schemas/notifications.js.map +1 -1
  192. package/dist/http/schemas/platform.d.ts +37 -0
  193. package/dist/http/schemas/platform.js +19 -0
  194. package/dist/http/schemas/platform.js.map +1 -0
  195. package/dist/http/schemas/posts.d.ts +92 -38
  196. package/dist/http/schemas/posts.js +4 -4
  197. package/dist/http/schemas/posts.js.map +1 -1
  198. package/dist/http/schemas/profile.d.ts +1 -1
  199. package/dist/http/schemas/shared.d.ts +10 -13
  200. package/dist/http/schemas/shared.js +3 -11
  201. package/dist/http/schemas/shared.js.map +1 -1
  202. package/dist/http/schemas/students.d.ts +22 -0
  203. package/dist/http/schemas/students.js +3 -1
  204. package/dist/http/schemas/students.js.map +1 -1
  205. package/dist/http/scope.d.ts +9 -12
  206. package/dist/http/scope.js +30 -35
  207. package/dist/http/scope.js.map +1 -1
  208. package/dist/http/server.js +9 -0
  209. package/dist/http/server.js.map +1 -1
  210. package/dist/http/shutdown.d.ts +11 -0
  211. package/dist/http/shutdown.js +40 -0
  212. package/dist/http/shutdown.js.map +1 -0
  213. package/dist/http/sse.d.ts +19 -0
  214. package/dist/http/sse.js +104 -0
  215. package/dist/http/sse.js.map +1 -0
  216. package/dist/http/test-caller.d.ts +13 -0
  217. package/dist/http/test-caller.js +14 -0
  218. package/dist/http/test-caller.js.map +1 -0
  219. package/dist/http/well-known.js +2 -2
  220. package/dist/http/well-known.js.map +1 -1
  221. package/dist/index.d.ts +5 -2
  222. package/dist/index.js +4 -2
  223. package/dist/index.js.map +1 -1
  224. package/dist/packages/server/package.js +1 -1
  225. package/package.json +7 -6
  226. package/dist/http/routes/member/actor.d.ts +0 -14
  227. package/dist/http/routes/member/actor.js +0 -11
  228. package/dist/http/routes/member/actor.js.map +0 -1
@@ -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.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 });\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,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;EACjD,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"}
@@ -1,31 +1,48 @@
1
1
  import { refusals } from "../schemas/shared.js";
2
- import { resolveScope } from "../scope.js";
3
- import { ApiKeyIdParam, ApiKeyList, ClientIdParam, ConnectedAppList, CreateApiKey, CreatedApiKey, ScopeOptionList } from "../schemas/access.js";
2
+ import { resolveActor } from "../scope.js";
3
+ import { ApiKeyIdParam, ApiKeyList, ApiKeyRoleList, ClientIdParam, ConnectedAppList, CreateApiKey, CreatedApiKey, PermissionDefinitionList, ViewerPermissions } from "../schemas/access.js";
4
4
  import "../schemas/index.js";
5
- import { assertRoleHoldsScopes, scopeOptions } from "@headless-lms/core/organizations";
6
5
  import { z } from "zod";
7
6
  //#region src/http/routes/access.ts
8
7
  async function accessRoutes(app, container) {
9
8
  const r = app.withTypeProvider();
10
9
  const tags = ["Access"];
10
+ const organizations = container.organizations;
11
11
  r.route({
12
12
  method: "GET",
13
- url: "/api/access/scopes",
13
+ url: "/api/access/permissions/list",
14
14
  preHandler: app.requireOrgSession,
15
15
  schema: {
16
- operationId: "listAccessScopes",
16
+ operationId: "listPermissions",
17
17
  tags,
18
- summary: "Every access scope and whether your role can grant it",
19
- description: "Staff only (owner, admin, instructor). Each scope an app connection or API key can carry, marked with whether your role holds it. Only allowed scopes can be granted.",
18
+ summary: "Every permission, with its label, description and level",
19
+ description: `Any active member of the organization. For role screens, the key-creation screen and the consent screen.`,
20
20
  response: {
21
- 200: ScopeOptionList,
21
+ 200: PermissionDefinitionList,
22
22
  ...refusals(403)
23
23
  }
24
24
  },
25
25
  handler: async (req) => {
26
- return scopeOptions((await resolveScope(container, req)).role);
26
+ await resolveActor(container, req);
27
+ return organizations.permissionList();
27
28
  }
28
29
  });
30
+ r.route({
31
+ method: "GET",
32
+ url: "/api/access/permissions",
33
+ preHandler: app.requireOrgSession,
34
+ schema: {
35
+ operationId: "getMyPermissions",
36
+ tags,
37
+ summary: "Your effective permissions in this organization",
38
+ description: `Any active member of the organization. Your org role, whether you own the organization, the permissions you hold on the organization, and the ones you hold on each content item.`,
39
+ response: {
40
+ 200: ViewerPermissions,
41
+ ...refusals(403)
42
+ }
43
+ },
44
+ handler: async (req) => organizations.viewerPermissions(await resolveActor(container, req))
45
+ });
29
46
  r.route({
30
47
  method: "GET",
31
48
  url: "/api/access/apps",
@@ -34,16 +51,13 @@ async function accessRoutes(app, container) {
34
51
  operationId: "listConnectedApps",
35
52
  tags,
36
53
  summary: "Apps connected to this organization as you",
37
- description: "Staff only (owner, admin, instructor). The apps you connected to this organization, each with the scopes you granted and when.",
54
+ description: `Any active member of the organization. The apps you connected to this organization, each with the scopes you granted and when.`,
38
55
  response: {
39
56
  200: ConnectedAppList,
40
57
  ...refusals(403)
41
58
  }
42
59
  },
43
- handler: async (req) => {
44
- const scope = await resolveScope(container, req);
45
- return container.auth.listConnectedApps(req.headers, scope.authOrgId);
46
- }
60
+ handler: async (req) => organizations.listConnectedApps(await resolveActor(container, req))
47
61
  });
48
62
  r.route({
49
63
  method: "DELETE",
@@ -53,7 +67,7 @@ async function accessRoutes(app, container) {
53
67
  operationId: "revokeConnectedApp",
54
68
  tags,
55
69
  summary: "Disconnect an app",
56
- description: "Staff only (owner, admin, instructor). Revokes your consent and refresh tokens for the app in this organization; its next call is refused.",
70
+ description: `Any active member of the organization. Revokes your consent and tokens for the app in this organization; its next call is refused.`,
57
71
  params: ClientIdParam,
58
72
  response: {
59
73
  204: z.void(),
@@ -61,8 +75,7 @@ async function accessRoutes(app, container) {
61
75
  }
62
76
  },
63
77
  handler: async (req, reply) => {
64
- const scope = await resolveScope(container, req);
65
- await container.auth.revokeConnectedApp(req.headers, scope.authOrgId, req.params.clientId);
78
+ await organizations.revokeConnectedApp(req.params.clientId, await resolveActor(container, req));
66
79
  return reply.code(204).send();
67
80
  }
68
81
  });
@@ -74,16 +87,29 @@ async function accessRoutes(app, container) {
74
87
  operationId: "listApiKeys",
75
88
  tags,
76
89
  summary: "Your API keys for this organization",
77
- description: "Staff only (owner, admin, instructor). Your keys for this organization with their scopes. The key itself is never returned again after it is created.",
90
+ description: `Requires \`api_keys:read\`. Your keys for this organization with their role and expiry. The key itself is never returned again after it is created.`,
78
91
  response: {
79
92
  200: ApiKeyList,
80
93
  ...refusals(403)
81
94
  }
82
95
  },
83
- handler: async (req) => {
84
- const scope = await resolveScope(container, req);
85
- return container.auth.listApiKeys(req.headers, scope.authOrgId);
86
- }
96
+ handler: async (req) => organizations.listApiKeys(await resolveActor(container, req))
97
+ });
98
+ r.route({
99
+ method: "GET",
100
+ url: "/api/access/api-keys/roles",
101
+ preHandler: app.requireOrgSession,
102
+ schema: {
103
+ operationId: "listApiKeyRoles",
104
+ tags,
105
+ summary: "The roles you can put on an API key",
106
+ description: `Requires \`api_keys:write\`. The organization's roles whose every permission you hold somewhere.`,
107
+ response: {
108
+ 200: ApiKeyRoleList,
109
+ ...refusals(403)
110
+ }
111
+ },
112
+ handler: async (req) => organizations.listApiKeyRoles(await resolveActor(container, req))
87
113
  });
88
114
  r.route({
89
115
  method: "POST",
@@ -93,22 +119,15 @@ async function accessRoutes(app, container) {
93
119
  operationId: "createApiKey",
94
120
  tags,
95
121
  summary: "Create an API key",
96
- description: "Staff only (owner, admin, instructor). The key acts as you in this organization with the scopes given. 403 when your role cannot hold one of them. The key is returned once.",
122
+ description: `Requires \`api_keys:write\`. The key acts as you in this organization with the permissions of the role given, never beyond what you hold at the time of each request. Send it in the \`x-api-key\` header or as \`Authorization: Bearer <key>\`; every route under /api and /mcp accepts it. 403 when the role holds a permission you hold nowhere; 404 when the organization has no such role. The key is returned once.`,
97
123
  body: CreateApiKey,
98
124
  response: {
99
125
  201: CreatedApiKey,
100
- ...refusals(400, 403)
126
+ ...refusals(400, 403, 404)
101
127
  }
102
128
  },
103
129
  handler: async (req, reply) => {
104
- const scope = await resolveScope(container, req);
105
- assertRoleHoldsScopes(scope.role, req.body.scopes);
106
- const created = await container.auth.createApiKey({
107
- authUserId: req.authUser.id,
108
- orgId: scope.authOrgId,
109
- name: req.body.name,
110
- scopes: req.body.scopes
111
- });
130
+ const created = await organizations.createApiKey(req.body, await resolveActor(container, req));
112
131
  return reply.code(201).send(created);
113
132
  }
114
133
  });
@@ -119,8 +138,8 @@ async function accessRoutes(app, container) {
119
138
  schema: {
120
139
  operationId: "deleteApiKey",
121
140
  tags,
122
- summary: "Delete an API key",
123
- description: "Staff only (owner, admin, instructor). 404 when the key is not one of yours in this organization.",
141
+ summary: "Revoke an API key",
142
+ description: `Requires \`api_keys:delete\`. 404 when the key is not one of yours in this organization.`,
124
143
  params: ApiKeyIdParam,
125
144
  response: {
126
145
  204: z.void(),
@@ -128,8 +147,7 @@ async function accessRoutes(app, container) {
128
147
  }
129
148
  },
130
149
  handler: async (req, reply) => {
131
- const scope = await resolveScope(container, req);
132
- await container.auth.deleteApiKey(req.headers, scope.authOrgId, req.params.id);
150
+ await organizations.deleteApiKey(req.params.id, await resolveActor(container, req));
133
151
  return reply.code(204).send();
134
152
  }
135
153
  });
@@ -1 +1 @@
1
- {"version":3,"file":"access.js","names":[],"sources":["../../../src/http/routes/access.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport type { ZodTypeProvider } from 'fastify-type-provider-zod';\nimport { z } from 'zod';\nimport { assertRoleHoldsScopes, scopeOptions } from '@headless-lms/core/organizations';\nimport type { Container } from '../../app/container.js';\nimport {\n ApiKeyIdParam,\n ApiKeyList,\n ClientIdParam,\n ConnectedAppList,\n CreateApiKey,\n CreatedApiKey,\n refusals,\n ScopeOptionList,\n} from '../schemas/index.js';\nimport { resolveScope } from '../scope.js';\n\nexport async function accessRoutes(app: FastifyInstance, container: Container): Promise<void> {\n const r = app.withTypeProvider<ZodTypeProvider>();\n const tags = ['Access'];\n\n r.route({\n method: 'GET',\n url: '/api/access/scopes',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'listAccessScopes',\n tags,\n summary: 'Every access scope and whether your role can grant it',\n description:\n 'Staff only (owner, admin, instructor). Each scope an app connection or API key can carry, marked with whether your role holds it. Only allowed scopes can be granted.',\n response: { 200: ScopeOptionList, ...refusals(403) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return scopeOptions(scope.role);\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/access/apps',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'listConnectedApps',\n tags,\n summary: 'Apps connected to this organization as you',\n description:\n 'Staff only (owner, admin, instructor). The apps you connected to this organization, each with the scopes you granted and when.',\n response: { 200: ConnectedAppList, ...refusals(403) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.auth.listConnectedApps(req.headers, scope.authOrgId);\n },\n });\n\n r.route({\n method: 'DELETE',\n url: '/api/access/apps/:clientId',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'revokeConnectedApp',\n tags,\n summary: 'Disconnect an app',\n description:\n 'Staff only (owner, admin, instructor). Revokes your consent and refresh tokens for the app in this organization; its next call is refused.',\n params: ClientIdParam,\n response: { 204: z.void(), ...refusals(403) },\n },\n handler: async (req, reply) => {\n const scope = await resolveScope(container, req);\n await container.auth.revokeConnectedApp(req.headers, scope.authOrgId, req.params.clientId);\n return reply.code(204).send();\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/access/api-keys',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'listApiKeys',\n tags,\n summary: 'Your API keys for this organization',\n description:\n 'Staff only (owner, admin, instructor). Your keys for this organization with their scopes. The key itself is never returned again after it is created.',\n response: { 200: ApiKeyList, ...refusals(403) },\n },\n handler: async (req) => {\n const scope = await resolveScope(container, req);\n return container.auth.listApiKeys(req.headers, scope.authOrgId);\n },\n });\n\n r.route({\n method: 'POST',\n url: '/api/access/api-keys',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'createApiKey',\n tags,\n summary: 'Create an API key',\n description:\n 'Staff only (owner, admin, instructor). The key acts as you in this organization with the scopes given. 403 when your role cannot hold one of them. The key is returned once.',\n body: CreateApiKey,\n response: { 201: CreatedApiKey, ...refusals(400, 403) },\n },\n handler: async (req, reply) => {\n const scope = await resolveScope(container, req);\n assertRoleHoldsScopes(scope.role, req.body.scopes);\n const created = await container.auth.createApiKey({\n authUserId: req.authUser.id,\n orgId: scope.authOrgId,\n name: req.body.name,\n scopes: req.body.scopes,\n });\n return reply.code(201).send(created);\n },\n });\n\n r.route({\n method: 'DELETE',\n url: '/api/access/api-keys/:id',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'deleteApiKey',\n tags,\n summary: 'Delete an API key',\n description:\n 'Staff only (owner, admin, instructor). 404 when the key is not one of yours in this organization.',\n params: ApiKeyIdParam,\n response: { 204: z.void(), ...refusals(403, 404) },\n },\n handler: async (req, reply) => {\n const scope = await resolveScope(container, req);\n await container.auth.deleteApiKey(req.headers, scope.authOrgId, req.params.id);\n return reply.code(204).send();\n },\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"access.js","names":[],"sources":["../../../src/http/routes/access.ts"],"sourcesContent":["import type { FastifyInstance } from 'fastify';\nimport type { ZodTypeProvider } from 'fastify-type-provider-zod';\nimport { z } from 'zod';\nimport type { Container } from '../../app/container.js';\nimport {\n ApiKeyIdParam,\n ApiKeyList,\n ApiKeyRoleList,\n ClientIdParam,\n ConnectedAppList,\n CreateApiKey,\n CreatedApiKey,\n PermissionDefinitionList,\n ViewerPermissions,\n refusals,\n} from '../schemas/index.js';\nimport { resolveActor } from '../scope.js';\n\nexport async function accessRoutes(app: FastifyInstance, container: Container): Promise<void> {\n const r = app.withTypeProvider<ZodTypeProvider>();\n const tags = ['Access'];\n const organizations = container.organizations;\n\n r.route({\n method: 'GET',\n url: '/api/access/permissions/list',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'listPermissions',\n tags,\n summary: 'Every permission, with its label, description and level',\n description: `Any active member of the organization. For role screens, the key-creation screen and the consent screen.`,\n response: { 200: PermissionDefinitionList, ...refusals(403) },\n },\n handler: async (req) => {\n await resolveActor(container, req);\n return organizations.permissionList();\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/access/permissions',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'getMyPermissions',\n tags,\n summary: 'Your effective permissions in this organization',\n description: `Any active member of the organization. Your org role, whether you own the organization, the permissions you hold on the organization, and the ones you hold on each content item.`,\n response: { 200: ViewerPermissions, ...refusals(403) },\n },\n handler: async (req) => organizations.viewerPermissions(await resolveActor(container, req)),\n });\n\n r.route({\n method: 'GET',\n url: '/api/access/apps',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'listConnectedApps',\n tags,\n summary: 'Apps connected to this organization as you',\n description: `Any active member of the organization. The apps you connected to this organization, each with the scopes you granted and when.`,\n response: { 200: ConnectedAppList, ...refusals(403) },\n },\n handler: async (req) => organizations.listConnectedApps(await resolveActor(container, req)),\n });\n\n r.route({\n method: 'DELETE',\n url: '/api/access/apps/:clientId',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'revokeConnectedApp',\n tags,\n summary: 'Disconnect an app',\n description: `Any active member of the organization. Revokes your consent and tokens for the app in this organization; its next call is refused.`,\n params: ClientIdParam,\n response: { 204: z.void(), ...refusals(403) },\n },\n handler: async (req, reply) => {\n await organizations.revokeConnectedApp(\n req.params.clientId,\n await resolveActor(container, req),\n );\n return reply.code(204).send();\n },\n });\n\n r.route({\n method: 'GET',\n url: '/api/access/api-keys',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'listApiKeys',\n tags,\n summary: 'Your API keys for this organization',\n description: `Requires \\`api_keys:read\\`. Your keys for this organization with their role and expiry. The key itself is never returned again after it is created.`,\n response: { 200: ApiKeyList, ...refusals(403) },\n },\n handler: async (req) => organizations.listApiKeys(await resolveActor(container, req)),\n });\n\n r.route({\n method: 'GET',\n url: '/api/access/api-keys/roles',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'listApiKeyRoles',\n tags,\n summary: 'The roles you can put on an API key',\n description: `Requires \\`api_keys:write\\`. The organization's roles whose every permission you hold somewhere.`,\n response: { 200: ApiKeyRoleList, ...refusals(403) },\n },\n handler: async (req) => organizations.listApiKeyRoles(await resolveActor(container, req)),\n });\n\n r.route({\n method: 'POST',\n url: '/api/access/api-keys',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'createApiKey',\n tags,\n summary: 'Create an API key',\n description: `Requires \\`api_keys:write\\`. The key acts as you in this organization with the permissions of the role given, never beyond what you hold at the time of each request. Send it in the \\`x-api-key\\` header or as \\`Authorization: Bearer <key>\\`; every route under /api and /mcp accepts it. 403 when the role holds a permission you hold nowhere; 404 when the organization has no such role. The key is returned once.`,\n body: CreateApiKey,\n response: { 201: CreatedApiKey, ...refusals(400, 403, 404) },\n },\n handler: async (req, reply) => {\n const created = await organizations.createApiKey(\n req.body,\n await resolveActor(container, req),\n );\n return reply.code(201).send(created);\n },\n });\n\n r.route({\n method: 'DELETE',\n url: '/api/access/api-keys/:id',\n preHandler: app.requireOrgSession,\n schema: {\n operationId: 'deleteApiKey',\n tags,\n summary: 'Revoke an API key',\n description: `Requires \\`api_keys:delete\\`. 404 when the key is not one of yours in this organization.`,\n params: ApiKeyIdParam,\n response: { 204: z.void(), ...refusals(403, 404) },\n },\n handler: async (req, reply) => {\n await organizations.deleteApiKey(req.params.id, await resolveActor(container, req));\n return reply.code(204).send();\n },\n });\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { refusals } from "../schemas/shared.js";
2
- import { resolveScope } from "../scope.js";
2
+ import { resolveActor } from "../scope.js";
3
3
  import { ActivityAssetList, ActivityList, ActivityPathParam, CourseIdPathParam, CreateModule, ModuleList, ModulePathParam, ReorderInput, SaveActivity, UpdateModule } from "../schemas/activities.js";
4
4
  import "../schemas/index.js";
5
5
  //#region src/http/routes/activities.ts
@@ -14,8 +14,8 @@ async function activitiesRoutes(app, container) {
14
14
  schema: {
15
15
  operationId: "listModules",
16
16
  tags,
17
- summary: "List a course's modules (staff)",
18
- description: "Staff only (owner, admin, instructor). Modules in order, each with its activities.",
17
+ summary: "List a course's modules",
18
+ description: `Requires \`courses:read\`. Modules in order, each with its activities.`,
19
19
  params: CourseIdPathParam,
20
20
  response: {
21
21
  200: ModuleList,
@@ -23,8 +23,8 @@ async function activitiesRoutes(app, container) {
23
23
  }
24
24
  },
25
25
  handler: async (req) => {
26
- const scope = await resolveScope(container, req);
27
- return content.listCourseModules(scope.orgId, req.params.courseId);
26
+ const actor = await resolveActor(container, req);
27
+ return content.listCourseModules(req.params.courseId, actor);
28
28
  }
29
29
  });
30
30
  r.route({
@@ -34,8 +34,8 @@ async function activitiesRoutes(app, container) {
34
34
  schema: {
35
35
  operationId: "listActivities",
36
36
  tags,
37
- summary: "List a course's activities (staff)",
38
- description: "Staff only (owner, admin, instructor). Every activity, published or not.",
37
+ summary: "List a course's activities",
38
+ description: `Requires \`courses:read\`. Every activity, published or not.`,
39
39
  params: CourseIdPathParam,
40
40
  response: {
41
41
  200: ActivityList,
@@ -43,8 +43,8 @@ async function activitiesRoutes(app, container) {
43
43
  }
44
44
  },
45
45
  handler: async (req) => {
46
- const scope = await resolveScope(container, req);
47
- return content.listCourseActivityViews(scope.orgId, req.params.courseId);
46
+ const actor = await resolveActor(container, req);
47
+ return content.listCourseActivityViews(req.params.courseId, actor);
48
48
  }
49
49
  });
50
50
  r.route({
@@ -54,8 +54,8 @@ async function activitiesRoutes(app, container) {
54
54
  schema: {
55
55
  operationId: "listActivityAssets",
56
56
  tags,
57
- summary: "List the library files each activity of a course uses (staff)",
58
- description: "Staff only (owner, admin, instructor). One row per activity and file, as recorded when the activity was saved.",
57
+ summary: "List the library files each activity of a course uses",
58
+ description: `Requires \`courses:read\`. One row per activity and file, as recorded when the activity was saved.`,
59
59
  params: CourseIdPathParam,
60
60
  response: {
61
61
  200: ActivityAssetList,
@@ -63,8 +63,8 @@ async function activitiesRoutes(app, container) {
63
63
  }
64
64
  },
65
65
  handler: async (req) => {
66
- const scope = await resolveScope(container, req);
67
- return content.listCourseActivityAssets(scope.orgId, req.params.courseId);
66
+ const actor = await resolveActor(container, req);
67
+ return content.listCourseActivityAssets(req.params.courseId, actor);
68
68
  }
69
69
  });
70
70
  r.route({
@@ -74,8 +74,8 @@ async function activitiesRoutes(app, container) {
74
74
  schema: {
75
75
  operationId: "createModule",
76
76
  tags,
77
- summary: "Add a module (staff)",
78
- description: "Staff only (owner, admin, instructor). Added last. Returns the course's modules.",
77
+ summary: "Add a module",
78
+ description: `Requires \`courses:write\`. Added last. Returns the course's modules.`,
79
79
  params: CourseIdPathParam,
80
80
  body: CreateModule,
81
81
  response: {
@@ -84,8 +84,8 @@ async function activitiesRoutes(app, container) {
84
84
  }
85
85
  },
86
86
  handler: async (req) => {
87
- const scope = await resolveScope(container, req);
88
- return content.createModule(scope.orgId, req.params.courseId, req.body.title);
87
+ const actor = await resolveActor(container, req);
88
+ return content.createModule(req.params.courseId, req.body.title, actor);
89
89
  }
90
90
  });
91
91
  r.route({
@@ -95,8 +95,8 @@ async function activitiesRoutes(app, container) {
95
95
  schema: {
96
96
  operationId: "reorderModules",
97
97
  tags,
98
- summary: "Reorder a course's modules (staff)",
99
- description: "Staff only (owner, admin, instructor). Ids that are not modules of the course are ignored. Returns the course's modules.",
98
+ summary: "Reorder a course's modules",
99
+ description: `Requires \`courses:write\`. Ids that are not modules of the course are ignored. Returns the course's modules.`,
100
100
  params: CourseIdPathParam,
101
101
  body: ReorderInput,
102
102
  response: {
@@ -105,8 +105,8 @@ async function activitiesRoutes(app, container) {
105
105
  }
106
106
  },
107
107
  handler: async (req) => {
108
- const scope = await resolveScope(container, req);
109
- return content.reorderModules(scope.orgId, req.params.courseId, req.body.orderedIds);
108
+ const actor = await resolveActor(container, req);
109
+ return content.reorderModules(req.params.courseId, req.body.orderedIds, actor);
110
110
  }
111
111
  });
112
112
  r.route({
@@ -116,8 +116,8 @@ async function activitiesRoutes(app, container) {
116
116
  schema: {
117
117
  operationId: "updateModule",
118
118
  tags,
119
- summary: "Rename a module (staff)",
120
- description: "Staff only (owner, admin, instructor). Returns the course's modules.",
119
+ summary: "Rename a module",
120
+ description: `Requires \`courses:write\`. Returns the course's modules.`,
121
121
  params: ModulePathParam,
122
122
  body: UpdateModule,
123
123
  response: {
@@ -126,8 +126,8 @@ async function activitiesRoutes(app, container) {
126
126
  }
127
127
  },
128
128
  handler: async (req) => {
129
- const scope = await resolveScope(container, req);
130
- return content.updateModule(scope.orgId, req.params.courseId, req.params.moduleId, req.body.title);
129
+ const actor = await resolveActor(container, req);
130
+ return content.updateModule(req.params.courseId, req.params.moduleId, req.body.title, actor);
131
131
  }
132
132
  });
133
133
  r.route({
@@ -137,8 +137,8 @@ async function activitiesRoutes(app, container) {
137
137
  schema: {
138
138
  operationId: "deleteModule",
139
139
  tags,
140
- summary: "Delete a module (staff)",
141
- description: "Staff only (owner, admin, instructor). Returns the course's modules.",
140
+ summary: "Delete a module",
141
+ description: `Requires \`courses:write\`. Returns the course's modules.`,
142
142
  params: ModulePathParam,
143
143
  response: {
144
144
  200: ModuleList,
@@ -146,8 +146,8 @@ async function activitiesRoutes(app, container) {
146
146
  }
147
147
  },
148
148
  handler: async (req) => {
149
- const scope = await resolveScope(container, req);
150
- return content.deleteModule(scope.orgId, req.params.courseId, req.params.moduleId);
149
+ const actor = await resolveActor(container, req);
150
+ return content.deleteModule(req.params.courseId, req.params.moduleId, actor);
151
151
  }
152
152
  });
153
153
  r.route({
@@ -157,8 +157,8 @@ async function activitiesRoutes(app, container) {
157
157
  schema: {
158
158
  operationId: "reorderActivities",
159
159
  tags,
160
- summary: "Reorder the activities in a module (staff)",
161
- description: "Staff only (owner, admin, instructor). Send every activity id of the module once (409 otherwise). Returns the course's modules.",
160
+ summary: "Reorder the activities in a module",
161
+ description: `Requires \`courses:write\`. Send every activity id of the module once (409 otherwise). Returns the course's modules.`,
162
162
  params: ModulePathParam,
163
163
  body: ReorderInput,
164
164
  response: {
@@ -167,8 +167,8 @@ async function activitiesRoutes(app, container) {
167
167
  }
168
168
  },
169
169
  handler: async (req) => {
170
- const scope = await resolveScope(container, req);
171
- return content.reorderActivities(scope.orgId, req.params.courseId, req.params.moduleId, req.body.orderedIds);
170
+ const actor = await resolveActor(container, req);
171
+ return content.reorderActivities(req.params.courseId, req.params.moduleId, req.body.orderedIds, actor);
172
172
  }
173
173
  });
174
174
  r.route({
@@ -178,8 +178,8 @@ async function activitiesRoutes(app, container) {
178
178
  schema: {
179
179
  operationId: "createActivity",
180
180
  tags,
181
- summary: "Add an activity (staff)",
182
- description: "Staff only (owner, admin, instructor). Records the library files the activity uses; each must be a ready library file (409 otherwise). Returns the course's modules.",
181
+ summary: "Add an activity",
182
+ description: `Requires \`courses:write\`. Records the library files the activity uses; each must be a ready library file (409 otherwise). A file the course does not already use must be uploaded for the course, or the caller needs \`media:read\` (403 otherwise). Returns the course's modules.`,
183
183
  params: ModulePathParam,
184
184
  body: SaveActivity,
185
185
  response: {
@@ -188,8 +188,8 @@ async function activitiesRoutes(app, container) {
188
188
  }
189
189
  },
190
190
  handler: async (req) => {
191
- const scope = await resolveScope(container, req);
192
- return content.saveActivity(scope.orgId, req.params.courseId, req.params.moduleId, req.body);
191
+ const actor = await resolveActor(container, req);
192
+ return content.saveActivity(req.params.courseId, req.params.moduleId, req.body, null, actor);
193
193
  }
194
194
  });
195
195
  r.route({
@@ -199,8 +199,8 @@ async function activitiesRoutes(app, container) {
199
199
  schema: {
200
200
  operationId: "updateActivity",
201
201
  tags,
202
- summary: "Update an activity (staff)",
203
- description: "Staff only (owner, admin, instructor). Replaces its content and records the library files it uses; each must be a ready library file (409 otherwise). Returns the course's modules.",
202
+ summary: "Update an activity",
203
+ description: `Requires \`courses:write\`. Replaces its content and records the library files it uses; each must be a ready library file (409 otherwise). A file the course does not already use must be uploaded for the course, or the caller needs \`media:read\` (403 otherwise). Returns the course's modules.`,
204
204
  params: ActivityPathParam,
205
205
  body: SaveActivity,
206
206
  response: {
@@ -209,8 +209,8 @@ async function activitiesRoutes(app, container) {
209
209
  }
210
210
  },
211
211
  handler: async (req) => {
212
- const scope = await resolveScope(container, req);
213
- return content.saveActivity(scope.orgId, req.params.courseId, req.params.moduleId, req.body, req.params.activityId);
212
+ const actor = await resolveActor(container, req);
213
+ return content.saveActivity(req.params.courseId, req.params.moduleId, req.body, req.params.activityId, actor);
214
214
  }
215
215
  });
216
216
  r.route({
@@ -220,8 +220,8 @@ async function activitiesRoutes(app, container) {
220
220
  schema: {
221
221
  operationId: "deleteActivity",
222
222
  tags,
223
- summary: "Delete an activity (staff)",
224
- description: "Staff only (owner, admin, instructor). Returns the course's modules.",
223
+ summary: "Delete an activity",
224
+ description: `Requires \`courses:write\`. Returns the course's modules.`,
225
225
  params: ActivityPathParam,
226
226
  response: {
227
227
  200: ModuleList,
@@ -229,8 +229,8 @@ async function activitiesRoutes(app, container) {
229
229
  }
230
230
  },
231
231
  handler: async (req) => {
232
- const scope = await resolveScope(container, req);
233
- return content.deleteActivity(scope.orgId, req.params.courseId, req.params.moduleId, req.params.activityId);
232
+ const actor = await resolveActor(container, req);
233
+ return content.deleteActivity(req.params.courseId, req.params.moduleId, req.params.activityId, actor);
234
234
  }
235
235
  });
236
236
  }