create-mithaq 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/index.js +457 -63
  3. package/package.json +1 -1
  4. package/template/apps/{asas-platform → asas-backoffice}/package.json +5 -3
  5. package/template/apps/{asas-platform → asas-backoffice}/src/api.ts +1 -1
  6. package/template/apps/{asas-platform → asas-backoffice}/src/auth-frame.tsx +12 -12
  7. package/template/apps/asas-backoffice/src/components.ts +35 -0
  8. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  9. package/template/apps/{asas-platform → asas-backoffice}/src/links.ts +1 -1
  10. package/template/apps/{asas-platform → asas-backoffice}/src/locale-switch.tsx +7 -5
  11. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  12. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  13. package/template/apps/{asas-platform → asas-backoffice}/src/request-headers.ts +1 -1
  14. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  15. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  16. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  17. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/screen.json +426 -64
  18. package/template/apps/{asas-platform/src/routes/(screens)/platform → asas-backoffice/src/routes/(screens)/backoffice}/types.ts +18 -0
  19. package/template/apps/{asas-platform/src/routes/(screens)/platform.$.tsx → asas-backoffice/src/routes/(screens)/backoffice.$.tsx} +4 -4
  20. package/template/apps/{asas-platform/src/routes/(screens)/platform.index.tsx → asas-backoffice/src/routes/(screens)/backoffice.index.tsx} +3 -3
  21. package/template/apps/{asas-platform/src/routes/(screens)/platform.tsx → asas-backoffice/src/routes/(screens)/backoffice.tsx} +3 -3
  22. package/template/apps/{asas-platform → asas-backoffice}/src/routes/__root.tsx +2 -2
  23. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  24. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  25. package/template/apps/{asas-platform → asas-backoffice}/src/routes/auth.sign-in.tsx +9 -4
  26. package/template/apps/{asas-platform → asas-backoffice}/src/routes/unauthorized.tsx +24 -12
  27. package/template/apps/{asas-platform → asas-backoffice}/src/screen-form.tsx +5 -3
  28. package/template/apps/{asas-platform → asas-backoffice}/src/screen-list.tsx +6 -5
  29. package/template/apps/{asas-platform → asas-backoffice}/src/screen-names.ts +1 -1
  30. package/template/apps/{asas-platform → asas-backoffice}/src/screens.tsx +46 -26
  31. package/template/apps/asas-backoffice/src/server.ts +30 -0
  32. package/template/apps/{asas-platform → asas-backoffice}/src/slide.ts +1 -1
  33. package/template/apps/{asas-platform → asas-backoffice}/src/start.ts +1 -1
  34. package/template/apps/{asas-platform → asas-backoffice}/src/styles.css +9 -7
  35. package/template/apps/{asas-platform → asas-backoffice}/tsconfig.json +2 -2
  36. package/template/apps/{asas-platform → asas-backoffice}/vite.config.ts +7 -1
  37. package/template/apps/asas-tanstack/package.json +4 -2
  38. package/template/apps/asas-tanstack/src/api.ts +1 -1
  39. package/template/apps/asas-tanstack/src/auth-frame.tsx +12 -12
  40. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  41. package/template/apps/asas-tanstack/src/components.ts +35 -0
  42. package/template/apps/asas-tanstack/src/copy.ts +416 -79
  43. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  44. package/template/apps/asas-tanstack/src/links.ts +1 -1
  45. package/template/apps/asas-tanstack/src/locale-switch.tsx +7 -5
  46. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  47. package/template/apps/asas-tanstack/src/redirect.ts +20 -1
  48. package/template/apps/asas-tanstack/src/request-headers.ts +1 -1
  49. package/template/apps/asas-tanstack/src/routeTree.gen.ts +42 -0
  50. package/template/apps/asas-tanstack/src/router.tsx +12 -1
  51. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +9 -2
  52. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +475 -116
  53. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +4 -2
  54. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +7 -2
  55. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +83 -319
  56. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +3 -1
  57. package/template/apps/asas-tanstack/src/routes/__root.tsx +1 -1
  58. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +14 -7
  59. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  60. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +2 -2
  61. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +5 -5
  62. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +7 -100
  63. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  64. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +27 -15
  65. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +59 -38
  66. package/template/apps/asas-tanstack/src/routes/index.tsx +18 -14
  67. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +24 -12
  68. package/template/apps/asas-tanstack/src/screen-form.tsx +5 -3
  69. package/template/apps/asas-tanstack/src/screen-list.tsx +6 -5
  70. package/template/apps/asas-tanstack/src/screen-names.ts +1 -1
  71. package/template/apps/asas-tanstack/src/screens.tsx +46 -26
  72. package/template/apps/asas-tanstack/src/server.ts +10 -0
  73. package/template/apps/asas-tanstack/src/slide.ts +1 -1
  74. package/template/apps/asas-tanstack/src/styles.css +7 -3
  75. package/template/apps/asas-tanstack/tsconfig.json +2 -2
  76. package/template/apps/asas-tanstack/vite.config.ts +7 -1
  77. package/template/package.json +3 -3
  78. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  79. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  80. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  81. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  82. package/template/templates/asas/.env.example +22 -5
  83. package/template/templates/asas/AGENTS.md +101 -0
  84. package/template/templates/asas/CLAUDE.md +1 -0
  85. package/template/templates/asas/README.md +1056 -0
  86. package/template/templates/asas/package.json +19 -4
  87. package/template/templates/asas/src/asas.ts +81 -56
  88. package/template/templates/asas/src/{platform-http.ts → backoffice-http.ts} +48 -27
  89. package/template/templates/asas/src/dev-setup.ts +112 -0
  90. package/template/templates/asas/src/local-database.ts +22 -0
  91. package/template/templates/asas/src/messages/account.json +129 -103
  92. package/template/templates/asas/src/messages/auditLog.json +223 -105
  93. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  94. package/template/templates/asas/src/messages/courses.json +256 -214
  95. package/template/templates/asas/src/messages/enrollments.json +45 -27
  96. package/template/templates/asas/src/messages/invitation.json +72 -56
  97. package/template/templates/asas/src/messages/localUsers.json +34 -28
  98. package/template/templates/asas/src/messages/media.json +31 -21
  99. package/template/templates/asas/src/messages/member.json +59 -39
  100. package/template/templates/asas/src/messages/memberRoles.json +37 -31
  101. package/template/templates/asas/src/messages/mithaq.json +206 -162
  102. package/template/templates/asas/src/messages/organization.json +63 -41
  103. package/template/templates/asas/src/messages/organizationRole.json +59 -47
  104. package/template/templates/asas/src/messages/roles.json +59 -45
  105. package/template/templates/asas/src/messages/session.json +99 -79
  106. package/template/templates/asas/src/messages/sessions.json +110 -80
  107. package/template/templates/asas/src/messages/settings.json +51 -53
  108. package/template/templates/asas/src/messages/tags.json +31 -21
  109. package/template/templates/asas/src/messages/user.json +109 -87
  110. package/template/templates/asas/src/messages/verification.json +59 -47
  111. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  112. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  113. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  114. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  115. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  116. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  117. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  118. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  119. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  120. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  121. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  122. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  123. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  124. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  125. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  126. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  127. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  128. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  129. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  130. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  131. package/template/templates/asas/src/migrations/schema.ts +1 -0
  132. package/template/templates/asas/src/mithaq.ts +4 -406
  133. package/template/templates/asas/src/organizations.ts +3 -0
  134. package/template/templates/asas/src/seed.ts +147 -45
  135. package/template/templates/asas/src/{ui-platform → ui-backoffice}/home.tsx +9 -9
  136. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  137. package/template/templates/asas/src/{ui-platform → ui-backoffice}/record-page.tsx +4 -4
  138. package/template/templates/asas/src/{ui-platform → ui-backoffice}/role-cells.tsx +13 -6
  139. package/template/templates/asas/src/{ui-platform → ui-backoffice}/user-actions.tsx +46 -48
  140. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +10 -4
  141. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  142. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  143. package/template/templates/asas/src/ui-dashboard/members.tsx +637 -438
  144. package/template/templates/asas/src/ui-dashboard/versions.tsx +22 -8
  145. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  146. package/template/templates/asas/src/ui-portal/home.tsx +9 -13
  147. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  148. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  149. package/template/templates/features/audit-log/index.ts +4 -0
  150. package/template/templates/features/audit-log/package.json +13 -0
  151. package/template/templates/features/auth/index.ts +11 -0
  152. package/template/templates/{asas/src → features/auth}/local-users/local-users.ts +7 -7
  153. package/template/templates/{auth → features/auth}/package.json +2 -3
  154. package/template/templates/{asas/src/kayan → features}/auth/user/account.access.ts +2 -2
  155. package/template/templates/{asas/src/kayan → features}/auth/user/account.ts +2 -2
  156. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.access.ts +1 -1
  157. package/template/templates/{asas/src/kayan → features}/auth/user/invitation.ts +3 -3
  158. package/template/templates/{asas/src/kayan → features}/auth/user/member.access.ts +4 -4
  159. package/template/templates/{asas/src/kayan → features}/auth/user/member.screens.ts +5 -5
  160. package/template/templates/{asas/src/kayan → features}/auth/user/member.ts +4 -3
  161. package/template/templates/{asas/src/kayan → features}/auth/user/organization.access.ts +6 -6
  162. package/template/templates/{asas/src/kayan → features}/auth/user/organization.screens.ts +6 -6
  163. package/template/templates/{asas/src/kayan → features}/auth/user/organization.ts +2 -1
  164. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.access.ts +2 -2
  165. package/template/templates/{asas/src/kayan → features}/auth/user/organizationRole.ts +3 -3
  166. package/template/templates/{asas/src/kayan → features}/auth/user/session.access.ts +2 -2
  167. package/template/templates/{asas/src/kayan → features}/auth/user/session.ts +2 -2
  168. package/template/templates/{asas/src/kayan → features}/auth/user/user.access.ts +8 -7
  169. package/template/templates/{asas/src/kayan → features}/auth/user/user.screens.ts +9 -9
  170. package/template/templates/{asas/src/kayan → features}/auth/user/user.ts +2 -2
  171. package/template/templates/{asas/src/kayan → features}/auth/user/verification.access.ts +2 -2
  172. package/template/templates/{asas/src/kayan → features}/auth/user/verification.ts +2 -2
  173. package/template/templates/{asas/src → features/courses}/courses/courses.ts +122 -133
  174. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  175. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  176. package/template/templates/{asas/src → features/courses}/courses/ui/fee-cell.tsx +1 -1
  177. package/template/templates/{asas/src → features/courses}/courses/ui/status-cell.tsx +5 -3
  178. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  179. package/template/templates/features/courses/domain/archive.ts +39 -0
  180. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  181. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  182. package/template/templates/{asas/src → features/courses}/enrollments/ui/session-cell.tsx +17 -7
  183. package/template/templates/{asas/src → features/courses/enrollments}/ui/session-labels.tsx +3 -3
  184. package/template/templates/features/courses/index.ts +8 -0
  185. package/template/templates/features/courses/package.json +24 -0
  186. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  187. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  188. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  189. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  190. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  191. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  192. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  193. package/template/templates/features/courses/sessions/seats.ts +42 -0
  194. package/template/templates/{asas/src → features/courses}/sessions/sessions.ts +45 -14
  195. package/template/templates/features/media/index.ts +10 -0
  196. package/template/templates/{asas/src → features/media}/media/media.ts +34 -40
  197. package/template/templates/{asas/src → features/media}/media/ui/preview.tsx +2 -2
  198. package/template/templates/features/media/package.json +19 -0
  199. package/template/templates/features/roles/index.ts +4 -0
  200. package/template/templates/{asas/src → features/roles}/member-roles/member-roles.ts +15 -15
  201. package/template/templates/features/roles/package.json +21 -0
  202. package/template/templates/{asas/src → features/roles}/roles/roles.ts +23 -23
  203. package/template/templates/{asas/src → features/roles}/roles/ui/role-form.tsx +65 -24
  204. package/template/templates/{asas/src → features/roles}/roles/ui/roles.tsx +49 -25
  205. package/template/templates/features/settings/index.ts +3 -0
  206. package/template/templates/features/settings/package.json +13 -0
  207. package/template/templates/{asas/src → features/settings}/settings/settings.ts +12 -17
  208. package/template/templates/features/tags/index.ts +3 -0
  209. package/template/templates/features/tags/package.json +14 -0
  210. package/template/templates/{asas/src → features/tags}/tags/tags.ts +15 -15
  211. package/template/templates/mithaq/package.json +33 -0
  212. package/template/templates/mithaq/src/mail/index.ts +32 -0
  213. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  214. package/template/templates/{asas/src/audit-log/sink.ts → mithaq/src/mithaq/audit-sink.ts} +31 -9
  215. package/template/templates/{auth/src/platform-roles.ts → mithaq/src/mithaq/auth/backoffice-roles.ts} +44 -23
  216. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  217. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  218. package/template/templates/{asas/src → mithaq/src/mithaq}/pages.ts +16 -7
  219. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  220. package/template/templates/mithaq/src/mithaq.ts +593 -0
  221. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  222. package/template/templates/{asas → mithaq}/src/roles/grants.ts +1 -1
  223. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  224. package/template/templates/{asas/src → mithaq/src/roles}/statements.ts +6 -4
  225. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  226. package/template/templates/{asas/src → mithaq/src/roles}/tenant-access.ts +96 -57
  227. package/template/templates/{asas/src → mithaq/src/roles}/tenant-roles.ts +84 -25
  228. package/template/templates/{asas → mithaq}/src/steps/expected-updated-at.ts +2 -2
  229. package/template/templates/{asas → mithaq}/src/storage/index.ts +3 -1
  230. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  231. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  232. package/template/templates/{asas → mithaq}/src/ui/app-shell.tsx +27 -19
  233. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  234. package/template/templates/{asas → mithaq}/src/ui/screen-nav.tsx +1 -1
  235. package/template/templates/{asas → mithaq}/src/ui/top-shell.tsx +8 -8
  236. package/template/apps/asas-platform/src/components/mithaq/cells.tsx +0 -112
  237. package/template/apps/asas-platform/src/components/mithaq/controls.tsx +0 -650
  238. package/template/apps/asas-platform/src/components/mithaq/field-shell.tsx +0 -53
  239. package/template/apps/asas-platform/src/components/mithaq/filter-operators.ts +0 -108
  240. package/template/apps/asas-platform/src/components/mithaq/form.tsx +0 -1467
  241. package/template/apps/asas-platform/src/components/mithaq/list-columns.tsx +0 -108
  242. package/template/apps/asas-platform/src/components/mithaq/list-filters.tsx +0 -368
  243. package/template/apps/asas-platform/src/components/mithaq/list-pagination.tsx +0 -153
  244. package/template/apps/asas-platform/src/components/mithaq/list-query.ts +0 -432
  245. package/template/apps/asas-platform/src/components/mithaq/list-selection.tsx +0 -74
  246. package/template/apps/asas-platform/src/components/mithaq/list.tsx +0 -865
  247. package/template/apps/asas-platform/src/components/mithaq/nav.tsx +0 -871
  248. package/template/apps/asas-platform/src/components/mithaq/nodes.tsx +0 -1024
  249. package/template/apps/asas-platform/src/components/mithaq/relationship-cell.tsx +0 -127
  250. package/template/apps/asas-platform/src/components/mithaq/relationship-control.tsx +0 -400
  251. package/template/apps/asas-platform/src/components/mithaq/rich-text-nodes.ts +0 -6
  252. package/template/apps/asas-platform/src/components/mithaq/rich-text.tsx +0 -1083
  253. package/template/apps/asas-platform/src/components/mithaq/upload.tsx +0 -686
  254. package/template/apps/asas-platform/src/components/ui/drawer.tsx +0 -216
  255. package/template/apps/asas-platform/src/redirect.ts +0 -1
  256. package/template/apps/asas-platform/src/routeTree.gen.ts +0 -181
  257. package/template/apps/asas-platform/src/router.tsx +0 -7
  258. package/template/apps/asas-platform/src/routes/(screens)/platform/files.ts +0 -4
  259. package/template/apps/asas-platform/src/routes/api/$.ts +0 -30
  260. package/template/apps/asas-platform/src/server.ts +0 -20
  261. package/template/apps/asas-tanstack/src/components/mithaq/cells.tsx +0 -112
  262. package/template/apps/asas-tanstack/src/components/mithaq/controls.tsx +0 -650
  263. package/template/apps/asas-tanstack/src/components/mithaq/field-shell.tsx +0 -53
  264. package/template/apps/asas-tanstack/src/components/mithaq/filter-operators.ts +0 -108
  265. package/template/apps/asas-tanstack/src/components/mithaq/form.tsx +0 -1467
  266. package/template/apps/asas-tanstack/src/components/mithaq/list-columns.tsx +0 -108
  267. package/template/apps/asas-tanstack/src/components/mithaq/list-filters.tsx +0 -368
  268. package/template/apps/asas-tanstack/src/components/mithaq/list-pagination.tsx +0 -153
  269. package/template/apps/asas-tanstack/src/components/mithaq/list-query.ts +0 -432
  270. package/template/apps/asas-tanstack/src/components/mithaq/list-selection.tsx +0 -74
  271. package/template/apps/asas-tanstack/src/components/mithaq/list.tsx +0 -865
  272. package/template/apps/asas-tanstack/src/components/mithaq/nav.tsx +0 -871
  273. package/template/apps/asas-tanstack/src/components/mithaq/nodes.tsx +0 -1024
  274. package/template/apps/asas-tanstack/src/components/mithaq/relationship-cell.tsx +0 -127
  275. package/template/apps/asas-tanstack/src/components/mithaq/relationship-control.tsx +0 -400
  276. package/template/apps/asas-tanstack/src/components/mithaq/rich-text-nodes.ts +0 -6
  277. package/template/apps/asas-tanstack/src/components/mithaq/rich-text.tsx +0 -1083
  278. package/template/apps/asas-tanstack/src/components/mithaq/upload.tsx +0 -686
  279. package/template/apps/asas-tanstack/src/components/ui/drawer.tsx +0 -215
  280. package/template/templates/asas/src/audit-log/audit-log.ts +0 -187
  281. package/template/templates/asas/src/audit-log/ui/activity.tsx +0 -243
  282. package/template/templates/asas/src/courses/ui/archive-button.tsx +0 -25
  283. package/template/templates/asas/src/courses/ui/no-courses-on-offer.tsx +0 -11
  284. package/template/templates/asas/src/courses/ui/on-offer.ts +0 -6
  285. package/template/templates/asas/src/enrollments/enrollments.ts +0 -137
  286. package/template/templates/asas/src/rich-text-nodes.ts +0 -11
  287. package/template/templates/asas/src/roles.ts +0 -16
  288. package/template/templates/asas/src/ui/rich-text-nodes.tsx +0 -15
  289. package/template/templates/asas/src/ui-platform/organization-actions.tsx +0 -114
  290. package/template/templates/auth/src/index.ts +0 -690
  291. /package/template/apps/{asas-platform → asas-backoffice}/components.json +0 -0
  292. /package/template/apps/{asas-platform → asas-backoffice}/gitignore +0 -0
  293. /package/template/apps/{asas-platform → asas-backoffice}/public/favicon.svg +0 -0
  294. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/accordion.tsx +0 -0
  295. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/alert-dialog.tsx +0 -0
  296. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/avatar.tsx +0 -0
  297. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/badge.tsx +0 -0
  298. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/breadcrumb.tsx +0 -0
  299. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button-group.tsx +0 -0
  300. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/button.tsx +0 -0
  301. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/calendar.tsx +0 -0
  302. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/card.tsx +0 -0
  303. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/chart.tsx +0 -0
  304. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/checkbox.tsx +0 -0
  305. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/collapsible.tsx +0 -0
  306. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/combobox.tsx +0 -0
  307. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dialog.tsx +0 -0
  308. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/direction.tsx +0 -0
  309. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/directional-icon.tsx +0 -0
  310. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/dropdown-menu.tsx +0 -0
  311. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/field.tsx +0 -0
  312. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-group.tsx +0 -0
  313. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input-otp.tsx +0 -0
  314. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/input.tsx +0 -0
  315. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/label.tsx +0 -0
  316. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/popover.tsx +0 -0
  317. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/radio-group.tsx +0 -0
  318. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/resizable.tsx +0 -0
  319. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/scroll-area.tsx +0 -0
  320. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/select.tsx +0 -0
  321. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/separator.tsx +0 -0
  322. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sheet.tsx +0 -0
  323. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/sidebar.tsx +0 -0
  324. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/skeleton.tsx +0 -0
  325. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/slider.tsx +0 -0
  326. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/spinner.tsx +0 -0
  327. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/switch.tsx +0 -0
  328. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/table.tsx +0 -0
  329. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tabs.tsx +0 -0
  330. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/textarea.tsx +0 -0
  331. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle-group.tsx +0 -0
  332. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/toggle.tsx +0 -0
  333. /package/template/apps/{asas-platform → asas-backoffice}/src/components/ui/tooltip.tsx +0 -0
  334. /package/template/apps/{asas-platform → asas-backoffice}/src/copy.ts +0 -0
  335. /package/template/apps/{asas-platform → asas-backoffice}/src/hooks/use-mobile.ts +0 -0
  336. /package/template/apps/{asas-platform → asas-backoffice}/src/lib/utils.ts +0 -0
  337. /package/template/apps/{asas-platform → asas-backoffice}/src/theme.ts +0 -0
  338. /package/template/templates/{asas/src/kayan → features}/auth/user/index.ts +0 -0
  339. /package/template/templates/{asas/src → features/courses}/courses/ui/no-courses.tsx +0 -0
  340. /package/template/templates/{asas → mithaq}/components.json +0 -0
  341. /package/template/templates/{asas → mithaq}/src/storage/defaults.ts +0 -0
  342. /package/template/templates/{asas → mithaq}/src/storage/local.ts +0 -0
  343. /package/template/templates/{asas → mithaq}/src/storage/r2.ts +0 -0
  344. /package/template/templates/{asas → mithaq}/src/storage/s3.ts +0 -0
  345. /package/template/templates/{asas → mithaq}/src/storage/uploadthing.ts +0 -0
  346. /package/template/templates/{asas → mithaq}/src/ui/kpi-card.tsx +0 -0
  347. /package/template/templates/{asas → mithaq}/src/ui/record-page.tsx +0 -0
  348. /package/template/templates/{asas → mithaq}/src/ui/table-page.tsx +0 -0
@@ -0,0 +1,1859 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
3
+ import { domainToUnicode } from 'node:url'
4
+ import { runWithAdapter, tryGetCurrentAuthEndpointContext } from '@better-auth/core/context'
5
+ import { BASE_ERROR_CODES, betterAuth as betterAuthServer, getCurrentAdapter } from 'better-auth'
6
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
7
+ import { APIError, createAuthEndpoint, createAuthMiddleware, formCsrfMiddleware, getSessionFromCtx, sessionMiddleware } from 'better-auth/api'
8
+ import { admin, oneTimeToken, organization } from 'better-auth/plugins'
9
+ import { createAccessControl } from 'better-auth/plugins/access'
10
+ import { adminAc, defaultStatements as adminStatements, userAc } from 'better-auth/plugins/admin/access'
11
+ import { defaultStatements } from 'better-auth/plugins/organization/access'
12
+ import { holdsPermission, requestOrganization, type Vocabulary, type Where } from 'mithaq'
13
+ import { z } from 'zod'
14
+ import { sql } from 'mithaq/drizzle'
15
+ import { configuredMailer, logMailer, type Mail, type Mailer } from '../../mail/index.ts'
16
+ // A module of its own so a screen can list the roles without loading the provider.
17
+ import { backofficeRoles } from './backoffice-roles.ts'
18
+ import { allowedDomains, defaultLocale, joinRole, nameIn, organizationName, organizations, showOrganization, signUp, verifyEmail } from './organizations.ts'
19
+ // add-on: roles
20
+ import { tenantRoles } from '../../roles/tenant-roles.ts'
21
+ // end add-on: roles
22
+
23
+ export { backofficeRoles }
24
+
25
+ // Set only by the backoffice app, which wraps every request it serves in `asBackoffice`. Nothing a
26
+ // client sends sets it.
27
+ const backofficeRequest = new AsyncLocalStorage<true>()
28
+ /** Runs `run` as a request the backoffice app serves. Only the backoffice app calls it. */
29
+ export const asBackoffice = <Result>(run: () => Result): Result => backofficeRequest.run(true, run)
30
+ /**
31
+ * Whether the current request is the backoffice app's, which picks the backoffice's Better Auth
32
+ * instance and its role set.
33
+ */
34
+ export const onBackoffice = () => backofficeRequest.getStore() === true
35
+
36
+ /**
37
+ * Whether this project serves the backoffice console, `apps/asas-backoffice`, as `generate.screens`
38
+ * in `src/mithaq.ts` says too. Without it no boot asks for BACKOFFICE_URL, and nobody is linked or
39
+ * sent to a backoffice app that does not exist.
40
+ */
41
+ // add-on: backoffice
42
+ export const backofficeApp: boolean = true
43
+ // end add-on: backoffice
44
+ // without add-on: backoffice
45
+ // export const backofficeApp: boolean = false
46
+ // end without add-on: backoffice
47
+
48
+ // A production boot needs all three: the fallbacks below are for development only, and Better
49
+ // Auth's refusal of its default secret never fires for the backoffice, whose secret is derived here.
50
+ const production = process.env.NODE_ENV === 'production'
51
+ const secret = process.env.BETTER_AUTH_SECRET ?? process.env.AUTH_SECRET
52
+ if (production && ((backofficeApp && !process.env.BACKOFFICE_URL) || !process.env.BETTER_AUTH_URL || !secret)) {
53
+ throw new Error(backofficeApp
54
+ ? 'A production boot needs BACKOFFICE_URL and BETTER_AUTH_URL, where the two apps are served, and BETTER_AUTH_SECRET, which both sign their sessions under. Set all three.'
55
+ : 'A production boot needs BETTER_AUTH_URL, where the app is served, and BETTER_AUTH_SECRET, which signs its sessions. Set both.')
56
+ }
57
+
58
+ // `NODE_ENV` is unset when a deploy runs plain `node` or `bun`, so the check above never runs and
59
+ // both apps would boot on a public secret. Refuse on a non-local host with no secret instead.
60
+ export const isLocalHost = (url: string): boolean => {
61
+ try {
62
+ return ['localhost', '127.0.0.1', '::1'].includes(new URL(url).hostname)
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+ const remoteWithNoSecret = process.env.BETTER_AUTH_SECRET
68
+ ? undefined
69
+ : [process.env.BACKOFFICE_URL, process.env.BETTER_AUTH_URL].filter((url): url is string => typeof url === 'string').find((url) => !isLocalHost(url))
70
+ if (remoteWithNoSecret) {
71
+ throw new Error(`BETTER_AUTH_SECRET must be set: ${remoteWithNoSecret} is not localhost, and this file's own secret falls back to a public default without it.`)
72
+ }
73
+
74
+ /**
75
+ * Where the backoffice app is served, and the backoffice instance's `baseURL`. Outside production, or
76
+ * where no backoffice app is served, it falls back to the `vite dev` port, and nothing links there.
77
+ */
78
+ export const backofficeURL = process.env.BACKOFFICE_URL ?? 'http://localhost:5174'
79
+
80
+ // The backoffice's first administrator is made by first-register, and the first owner of a project
81
+ // of one organization by first-run, and both ask for the `SETUP_CODE` the deploy set, so nobody who
82
+ // merely reaches a new deploy first takes it. Only `vite dev` on this machine skips it: a deploy
83
+ // run with plain `node` sets no `NODE_ENV`, and an unset URL falls back to localhost. The code is
84
+ // the one bar, so a value short enough to guess is no code at all.
85
+ const setupCode = (process.env.SETUP_CODE?.length ?? 0) >= 32 ? process.env.SETUP_CODE : undefined
86
+ const developing = process.env.NODE_ENV === 'development'
87
+ const setupCodeState = (url: string): 'none' | 'required' | 'unset' => (developing && isLocalHost(url) ? 'none' : setupCode ? 'required' : 'unset')
88
+ // Mirrors the tenant's `rateLimit.enabled`, read off the backoffice's own address.
89
+ const backofficeLimited = production || !isLocalHost(backofficeURL)
90
+ // Digests, so both sides are always 32 bytes and `timingSafeEqual` neither throws on nor answers
91
+ // sooner for a code of another length.
92
+ const digest = (text: string) => createHash('sha256').update(text).digest()
93
+ const isSetupCode = (given: unknown) => typeof given === 'string' && setupCode !== undefined && timingSafeEqual(digest(given), digest(setupCode))
94
+ /** Why a setup route served at `url` refuses `code`, or nothing; `app` names where SETUP_CODE is set. */
95
+ const refusedSetup = ({ url, code, app }: { url: string; code: unknown; app: string }) => {
96
+ const state = setupCodeState(url)
97
+ if (state === 'unset') return new APIError('FORBIDDEN', { code: 'SETUP_CODE_UNSET', message: `Set SETUP_CODE where the ${app} runs, to a random value of 32 characters or more: the first administrator is made only with it.` })
98
+ if (state === 'required' && !isSetupCode(code)) return new APIError('FORBIDDEN', { code: 'SETUP_CODE_WRONG', message: 'That is not the setup code.' })
99
+ return undefined
100
+ }
101
+ // Set only around the calls first-register and first-run make once they found nobody under their
102
+ // lock, and around the account add-person makes: the only calls that may write a role or make an
103
+ // account no sign-up would.
104
+ const firstRegistering = new AsyncLocalStorage<true>()
105
+ // Without TRUSTED_IP_HEADER, Better Auth keys its limit on an `x-forwarded-for` the caller writes,
106
+ // so each setup route also keeps one window that every caller shares.
107
+ // ponytail: process-local, and a flood holds setup off for everyone until it stops; set
108
+ // TRUSTED_IP_HEADER for per-address limits alone.
109
+ const sharedWindow = () => {
110
+ const tries: number[] = []
111
+ return () => {
112
+ const now = Date.now()
113
+ while (tries.length && now - tries[0]! >= 60_000) tries.shift()
114
+ return tries.length >= 10 || (tries.push(now), false)
115
+ }
116
+ }
117
+ const overSharedLimit = sharedWindow()
118
+ const overFirstRunLimit = sharedWindow()
119
+ // First-register and first-run take one lock, so neither reads "nobody yet" while the other is
120
+ // making the first administrator.
121
+ const setupLock = sql`select pg_advisory_xact_lock(hashtext('mithaq.setup'))`
122
+
123
+ /**
124
+ * One JSON line, in the shape of the backoffice's audit, for what the provider does where no kayan
125
+ * logs it: a sign-up, a join, a first-run and a role given. Operators keep this output.
126
+ */
127
+ const authAudit = (line: Record<string, unknown>) => console.log(JSON.stringify({ audit: 'auth', ...line, at: new Date().toISOString() }))
128
+
129
+ /**
130
+ * Where the tenant app is served, the tenant instance's `baseURL` and the target of an
131
+ * impersonation hand-off. Outside production it falls back to the `vite dev` port.
132
+ */
133
+ export const tenantURL = process.env.BETTER_AUTH_URL ?? 'http://localhost:5173'
134
+
135
+ // A browser isolates scripts by origin and sends cookies by host whatever the port, so the
136
+ // backoffice on a path or another port of the customer app's host would put staff sessions within
137
+ // reach of any script flaw in the customer app. Two subdomains are two hosts. Only this machine's
138
+ // development ports share one.
139
+ const [backofficeAt, tenantAt] = [URL.parse(backofficeURL), URL.parse(tenantURL)]
140
+ if (backofficeApp && backofficeAt && tenantAt && (backofficeAt.origin === tenantAt.origin || (backofficeAt.hostname === tenantAt.hostname && !isLocalHost(backofficeURL)))) {
141
+ throw new Error(`BACKOFFICE_URL (${backofficeURL}) and BETTER_AUTH_URL (${tenantURL}) are served on one host, ${backofficeAt.hostname}. Serve the backoffice on a host of its own, such as backoffice.example.com beside app.example.com: browsers isolate scripts by origin and send cookies by host, so on one host a script flaw (XSS) in the customer app would reach staff sessions in the backoffice.`)
142
+ }
143
+
144
+ /**
145
+ * The origins the backoffice instance trusts beside its own `baseURL`: the `vite dev` and `vite
146
+ * preview` ports, outside production only.
147
+ */
148
+ export const backofficeOrigins = production ? [] : ['http://localhost:4174', 'http://localhost:5174']
149
+
150
+ // Derived from the deployment secret and never equal to it, so a cookie one app signed fails
151
+ // verification in the other. The apps share one session table and never a session; a session
152
+ // crosses only through the one-time hand-off. The label keeps the console's old name, `platform`,
153
+ // so a deploy that renamed it derives the same secret and keeps what that secret signed.
154
+ const backofficeSecret = createHmac('sha256', secret ?? 'mithaq-development-only').update('mithaq-platform-session').digest('base64url')
155
+
156
+ /** The backoffice's session cookie name, `mithaq-backoffice.session_token`, beside the tenant's `better-auth.session_token`. */
157
+ export const backofficeCookiePrefix = 'mithaq-backoffice'
158
+
159
+ /**
160
+ * Better Auth's own actions, which no kayan declares; `buildAsas` adds them to the catalog.
161
+ *
162
+ * `member` and `user` are each a provider resource and a generated kayan, so one key opens both
163
+ * doors. They never meet while no auth kayan carries a tenant field. They cannot be renamed: the
164
+ * provider's routes ask under these exact names.
165
+ */
166
+ export const providerStatements = { ...defaultStatements, ...adminStatements }
167
+
168
+
169
+ // `APIError` makes the refusal the provider's own 403 rather than the 500 an unrecognised throw
170
+ // becomes.
171
+ const gate = (key: string) => async ({ organization }: { organization?: { [key: string]: unknown; id?: unknown } } = {}) => {
172
+ sameOrganization(organization)
173
+ if (holdsPermission(key)) return
174
+ throw new APIError('FORBIDDEN', { message: `You do not hold the permission "${key}".` })
175
+ }
176
+
177
+ // `holdsPermission` answers with the caller's keys in their active organization, but a provider
178
+ // route that takes an `organizationId` in its body acts on that one. Refuse any other, or keys held
179
+ // in one organization would open another. Outside `asas.handler` nothing is resolved and the
180
+ // provider's own roles decide alone.
181
+ const sameOrganization = (organization?: { [key: string]: unknown; id?: unknown }) => {
182
+ const active = requestOrganization()
183
+ if (active === undefined || typeof organization?.id !== 'string' || organization.id === active) return
184
+ throw new APIError('FORBIDDEN', { message: 'Your keys are your active organization\'s, and this action is in another. Make it the active organization first.' })
185
+ }
186
+
187
+ // The provider refuses only an `owner` invite from a non-owner, so without this an inviter could
188
+ // hand out `admin` with no `member.update`.
189
+ const invitedAs = (role: string) => {
190
+ if (role.split(',').every((name) => name === 'member') || holdsPermission('member.update')) return
191
+ throw new APIError('FORBIDDEN', { message: 'An invitation as anything but a member needs the permission "member.update".' })
192
+ }
193
+
194
+ // The provider checks an invitation's role names trimmed but stores the value as written, and its
195
+ // owner checks match untrimmed, so `member, owner` could slip past an inviter who may not invite an
196
+ // owner. Refuse whitespace and empty names.
197
+ const exactRoles = (role: unknown) => {
198
+ if (typeof role === 'string' && role.split(',').every((name) => name !== '' && name === name.trim())) return
199
+ throw new APIError('BAD_REQUEST', {
200
+ code: 'error.invalid.member_role',
201
+ message: `A role value holds exact role names, comma-joined, with no whitespace around a name and no empty one: ${JSON.stringify(role)}.`,
202
+ })
203
+ }
204
+
205
+ // Both conditions are asked because either alone fails open: a deploy run with plain `node` never
206
+ // sets `NODE_ENV`, and an unset `BETTER_AUTH_URL` falls back to `localhost`.
207
+ const logsLinks = process.env.NODE_ENV === 'development' && isLocalHost(tenantURL)
208
+
209
+ /**
210
+ * What the auth setup reads beyond the provider's own options: the organization mode and who may
211
+ * join (`organizations.ts`), and the mailer that sends the password reset, the invitation and the
212
+ * verification. The mailer is the deploy's configured one (`src/mail/index.ts`), else in
213
+ * development on this machine the log, else none, and then no reset is offered and the inviter
214
+ * hands the link over.
215
+ */
216
+ export type AuthSettings = {
217
+ mailer: Mailer | undefined
218
+ organizations: typeof organizations
219
+ showOrganization: boolean
220
+ signUp: typeof signUp
221
+ joinRole: string | null
222
+ verifyEmail: boolean
223
+ allowedDomains: readonly string[]
224
+ /** In `'one'`, the organization's name in each locale, which lives in code. */
225
+ organizationName: Readonly<Record<string, string>>
226
+ }
227
+ export const authSettings: AuthSettings = { mailer: configuredMailer() ?? (logsLinks ? logMailer : undefined), organizations, showOrganization, signUp, joinRole, verifyEmail, allowedDomains, organizationName }
228
+
229
+ /** An address as the provider's own sign-up takes one. */
230
+ const anAddress = z.email()
231
+
232
+ /** A lowercase ASCII host of two labels or more, which an `xn--` international one is. */
233
+ const host = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
234
+
235
+ /**
236
+ * Whether `email`'s domain, taken after its last `@`, lowercased and turned into punycode as a URL
237
+ * does, is one `allowed` names: equal to an entry, or under a `*.` entry's domain. Never a suffix
238
+ * match, which lets `notexample.com` pass for `example.com`.
239
+ */
240
+ export const domainAllowed = (email: string, allowed: readonly string[]) => {
241
+ const at = email.lastIndexOf('@')
242
+ if (at < 0 || at === email.length - 1) return false
243
+ const written = email.slice(at + 1).toLowerCase()
244
+ let domain: string
245
+ try {
246
+ domain = new URL(`http://${written}`).hostname
247
+ } catch {
248
+ return false
249
+ }
250
+ // A URL also decodes `%2e` and folds a fullwidth `e` or `.` into ASCII, which no mail server
251
+ // does, so the domain must read back as written: an international one does, a disguised one not.
252
+ // One written in its `xn--` form, which the provider's address check asks of an international
253
+ // domain, is its own reading.
254
+ if (domainToUnicode(domain) !== written && domain !== written) return false
255
+ return allowed.some((entry) => (entry.startsWith('*.') ? domain.endsWith(entry.slice(1)) : domain === entry))
256
+ }
257
+
258
+ /**
259
+ * An organization's name as it is kept: one line of 1 to 100 characters. Anyone founds one with many
260
+ * organizations, and its name goes into the plain text of every mail that names it, where a line
261
+ * break would be the founder's own line.
262
+ */
263
+ // Control characters (NEL among them), the line and paragraph separators, and the direction
264
+ // overrides and isolates, which would make a name read as other words than it holds.
265
+ export const oneLine = /[\p{Cc}\p{Zl}\p{Zp}\u202a-\u202e\u2066-\u2069]+/gu
266
+ const asLine = (name: unknown) => (typeof name === 'string' ? name.replace(oneLine, ' ').replace(/ {2,}/g, ' ').trim() : '')
267
+ const badName = (message: string) => new APIError('BAD_REQUEST', { code: 'ORGANIZATION_NAME', message })
268
+ const oneLineName = (name: unknown) => {
269
+ const line = asLine(name)
270
+ if (!line || line.length > 100) throw badName('An organization has a name of 1 to 100 characters on one line.')
271
+ return line
272
+ }
273
+ /** The locales an organization is named in: the project's own, as `organizationName` lists them. */
274
+ const nameLocales = Object.keys(organizationName)
275
+ /** Why `names` is no organization's names in every locale, the default's required, or nothing. */
276
+ const namesProblem = (names: Readonly<Record<string, unknown>>) => {
277
+ const stray = Object.keys(names).filter((locale) => !nameLocales.includes(locale))
278
+ if (stray.length) return `names ${stray.join(', ')}, which is none of the project's locales: ${nameLocales.join(', ')}.`
279
+ if (!asLine(names[defaultLocale])) return `holds no name in ${defaultLocale}, the locale every other falls back to.`
280
+ const long = Object.entries(names).find(([, one]) => asLine(one).length > 100)
281
+ return long ? `holds a name in ${long[0]} past 100 characters.` : undefined
282
+ }
283
+ /**
284
+ * An organization's names as they are kept: the JSON of one line of 1 to 100 characters per locale it is named in,
285
+ * the default locale's required and the others left out where empty, and `name`, the default locale's. `written` is
286
+ * the JSON a client sends, or the names themselves.
287
+ */
288
+ export const namesOf = (written: unknown) => {
289
+ let names: unknown = written
290
+ if (typeof written === 'string') {
291
+ try {
292
+ names = JSON.parse(written)
293
+ } catch {
294
+ throw badName('An organization\'s names are a JSON object, one name per locale.')
295
+ }
296
+ }
297
+ if (!names || typeof names !== 'object' || Array.isArray(names)) throw badName('An organization\'s names are a JSON object, one name per locale.')
298
+ const problem = namesProblem(names as Record<string, unknown>)
299
+ if (problem) throw badName(`An organization's names ${problem}`)
300
+ const kept = Object.fromEntries(Object.entries(names).map(([locale, one]) => [locale, asLine(one)]).filter(([, one]) => one))
301
+ return { name: kept[defaultLocale]!, names: JSON.stringify(kept) }
302
+ }
303
+ /** The names a create or a rename writes: from `names` where it carries them, else `name` as the default locale's. */
304
+ const named = (data: { name?: unknown; names?: unknown }) => {
305
+ if (data.names !== undefined && data.names !== null) return namesOf(data.names)
306
+ const name = oneLineName(data.name)
307
+ return { name, names: JSON.stringify({ [defaultLocale]: name }) }
308
+ }
309
+ /**
310
+ * Why `names`, the organization's name in code, does not name it in each of `locales`, the language's and no other,
311
+ * the first required, or nothing. Every mode holds a founder's names to its locales and falls back to its first, so
312
+ * the language checks it as it loads (`src/mithaq.ts`).
313
+ */
314
+ export const organizationNameProblem = ({ names, locales }: { names: Readonly<Record<string, string>>; locales: readonly string[] }) => {
315
+ const held = Object.keys(names)
316
+ if (held.length !== locales.length || locales.some((one) => !held.includes(one))) return `organizationName names ${held.join(', ') || 'no locale'}, and the language reads ${locales.join(', ')}: name the organization in each, the first the one every other falls back to.`
317
+ const problem = namesProblem(names)
318
+ return problem && `organizationName ${problem}`
319
+ }
320
+ const nameInCode = () => new APIError('FORBIDDEN', { code: 'ORGANIZATION_NAME_IN_CODE', message: 'With one organization its name lives in code: organizationName in src/mithaq/auth/organizations.ts.' })
321
+
322
+ /**
323
+ * A key no stranger who signs up is given: one that gives a role, writes a role, invites, or changes
324
+ * the people or the organization. Reading them is not one.
325
+ */
326
+ const grantsPower = (key: string) => /^(roles|member(Roles)?|invitation|ac|organization)\.(?!(find|findByID|count|read)$|fields\.)/.test(key)
327
+
328
+ /**
329
+ * Every way `settings` means nothing or opens a door, one sentence each naming the line of
330
+ * `organizations.ts` to change. `joinRoles` is the roles a joiner may be given, each with what it
331
+ * holds (`null` without the roles add-on). `deployed` is a start anywhere but development on this
332
+ * machine.
333
+ */
334
+ export const settingsProblems = ({ settings, deployed, joinRoles }: { settings: AuthSettings; deployed: boolean; joinRoles: Readonly<Record<string, '*' | readonly string[]>> | null }) => {
335
+ const { organizations, showOrganization, signUp, joinRole, verifyEmail, allowedDomains, mailer } = settings
336
+ const problems: string[] = []
337
+ if (organizations === 'many') {
338
+ if (!showOrganization) problems.push("organizations = 'many' takes showOrganization = true: a person who belongs to several switches between them by name.")
339
+ if (signUp !== 'open') problems.push("organizations = 'many' takes signUp = 'open': each sign-up founds its own organization, and nobody joins one but by invitation.")
340
+ if (joinRole !== null) problems.push("organizations = 'many' takes joinRole = null: nobody joins an organization there but by invitation.")
341
+ // Anyone founds an organization there, so a sign-up that answered an address with an account
342
+ // differently would tell anyone which addresses do, and an unproved account would keep one.
343
+ if (!verifyEmail) problems.push("organizations = 'many' takes verifyEmail = true: every sign-up proves its address, so a sign-up tells nobody which addresses have an account and nobody keeps another's.")
344
+ if (allowedDomains.length) problems.push("organizations = 'many' takes no allowedDomains: nobody joins an organization there but by invitation.")
345
+ } else {
346
+ problemsOfOne({ problems, signUp, verifyEmail, allowedDomains, joinRole, joinRoles })
347
+ }
348
+ // Without one no reset is offered, no invitation reaches anyone and no address is proved.
349
+ if (deployed && !mailer) problems.push('A start needs a mailer in production, which proves each address, sends each reset and mails each invitation: set RESEND_API_KEY and MAIL_FROM.')
350
+ return problems
351
+ }
352
+ /** `settingsProblems` of one organization. */
353
+ const problemsOfOne = ({ problems, signUp, verifyEmail, allowedDomains, joinRole, joinRoles }: Omit<AuthSettings, 'mailer' | 'organizations' | 'showOrganization' | 'organizationName'> & { problems: string[]; joinRoles: Readonly<Record<string, '*' | readonly string[]>> | null }) => {
354
+ if (allowedDomains.length && signUp !== 'open') problems.push("allowedDomains is read only with signUp = 'open': empty it, or open the sign-up.")
355
+ if (allowedDomains.length && !verifyEmail) problems.push('allowedDomains needs verifyEmail = true: an address nobody proved is any domain its writer wants.')
356
+ for (const entry of allowedDomains) {
357
+ if (!host.test(entry.startsWith('*.') ? entry.slice(2) : entry)) problems.push(`allowedDomains holds ${JSON.stringify(entry)}, which is no lowercase ASCII domain, or *. and one: write an international domain as its xn-- form.`)
358
+ }
359
+ const held = joinRole === null || joinRoles === null || !Object.hasOwn(joinRoles, joinRole) ? undefined : joinRoles[joinRole]!
360
+ const powers = held === '*' ? ['every key'] : (held ?? []).filter(grantsPower)
361
+ if (joinRole !== null && joinRoles === null) problems.push('joinRole is a role of the roles add-on, which this project does not hold: set joinRole = null, and a joiner holds the provider\'s member.')
362
+ else if (joinRole !== null && held === undefined) problems.push(`joinRole names ${JSON.stringify(joinRole)}, which is none of roles/tenant-roles.ts: ${Object.keys(joinRoles!).join(', ')}.`)
363
+ else if (powers.length) problems.push(`joinRole names ${JSON.stringify(joinRole)}, which holds ${powers.join(', ')}, and a stranger who signs up is never given a role that gives roles or invites.`)
364
+ }
365
+
366
+ // add-on: roles
367
+ const joinRoles: Record<string, '*' | readonly string[]> | null = Object.fromEntries(Object.entries(tenantRoles).map(([slug, role]) => [slug, (role as { permissions: '*' | readonly string[] }).permissions]))
368
+ // end add-on: roles
369
+ // without add-on: roles
370
+ // const joinRoles: Record<string, '*' | readonly string[]> | null = null
371
+ // end without add-on: roles
372
+ // Refused wherever the other start checks refuse: in production, or off this machine whatever
373
+ // `NODE_ENV` says.
374
+ const settingsRefused = settingsProblems({ settings: authSettings, deployed: production || !isLocalHost(tenantURL), joinRoles })
375
+ if (settingsRefused.length) throw new Error(`src/mithaq/auth/organizations.ts: ${settingsRefused.join(' ')}`)
376
+
377
+ const escaped = (text: string) => text.replace(/[&<>"']/g, (one) => `&#${one.charCodeAt(0)};`)
378
+ /** A subject on one line: a line break in it would start a header of the writer's own. */
379
+ export const subjectLine = (subject: string) => subject.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/ {2,}/g, ' ').trim()
380
+ /**
381
+ * One message in both of the project's locales, English then Arabic, since a mail is sent before
382
+ * anyone says which they read. The link sits on a line of its own in each.
383
+ */
384
+ const letter = ({ to, subject, en, ar, link }: { to: string; subject: string; en: string; ar: string; link: string }): Mail => ({
385
+ to,
386
+ subject: subjectLine(subject),
387
+ text: `${en}\n\n${link}\n\n${ar}\n\n${link}\n`,
388
+ html: `<p>${escaped(en)}</p><p><a href="${escaped(link)}">${escaped(link)}</a></p><div dir="rtl" lang="ar"><p>${escaped(ar)}</p><p><a href="${escaped(link)}">${escaped(link)}</a></p></div>`,
389
+ })
390
+
391
+ // The subject is fixed and only the body names the organization, escaped: its name is words an
392
+ // administrator wrote. With no organization shown, the invitation is to the app, as every page says.
393
+ // With many organizations anyone founds one and invites, so only an inviter whose own address is
394
+ // proved has an invitation mailed: nobody sends a stranger the project's mail from an address they
395
+ // do not hold. The inviter still copies the link from the Members page.
396
+ const invitationMail = ({ mailer, organizations, showOrganization }: { mailer: Mailer; organizations: AuthSettings['organizations']; showOrganization: boolean }) => async ({ id, email, organization, inviter }: { id: string; email: string; organization: { name: string; names?: unknown }; inviter: { user: { emailVerified?: boolean } } }) => {
397
+ if (organizations === 'many' && !inviter.user.emailVerified) return
398
+ const [en, ar] = showOrganization ? [nameIn(organization, 'en'), nameIn(organization, 'ar')] : ['this app', 'هذا التطبيق']
399
+ await mailer.send(letter({
400
+ to: email,
401
+ subject: 'You are invited · لديك دعوة',
402
+ en: `You are invited to join ${en}. Open the link below with this address to accept; it lasts 48 hours.`,
403
+ ar: `أنت مدعو للانضمام إلى ${ar}. افتح الرابط أدناه بهذا العنوان لقبول الدعوة؛ وهو صالح 48 ساعة.`,
404
+ link: new URL(`/auth/accept-invitation?id=${encodeURIComponent(id)}`, tenantURL).href,
405
+ }))
406
+ }
407
+
408
+
409
+ /**
410
+ * How many invitations one organization makes, whoever makes them, so no organization turns the
411
+ * project's mailer into a flood: `perMinute` in any minute and `perDay` in any day.
412
+ */
413
+ export const invitationLimits = { perMinute: 10, perDay: 100 }
414
+
415
+ /**
416
+ * The provider's organization limits, each set here so none is a silent default of the provider's:
417
+ * left unset, anyone signed in founds organizations without end, and an organization stops taking
418
+ * members and listing them at 100.
419
+ *
420
+ * - `allowUserToCreateOrganization`: in `'many'`, sign-up founds each person's own organization. In
421
+ * `'one'` nobody founds one over HTTP; first-run makes the one on the server.
422
+ * - `organizationLimit`: a person who belongs to this many organizations, however they joined, founds
423
+ * no more, so one account cannot fill the database with organizations. An invitation still adds
424
+ * them to more.
425
+ * - `membershipLimit`: the members one organization holds, and the most `list-members` answers
426
+ * unasked; the Members page pages past it.
427
+ * - `invitationLimit`: the pending invitations one organization holds.
428
+ */
429
+ export const organizationLimitsFor = (mode: AuthSettings['organizations']) => ({
430
+ allowUserToCreateOrganization: mode === 'many',
431
+ organizationLimit: 5,
432
+ // In `'one'` the one organization is every person of the app, so it takes members without end.
433
+ // A function, so the provider's unasked member lists keep their own 100 rather than this.
434
+ membershipLimit: mode === 'one' ? async () => Number.MAX_SAFE_INTEGER : 10_000,
435
+ invitationLimit: 1_000,
436
+ })
437
+
438
+ // These options do not depend on this project's vocabulary, so `mithaq generate auth` reads the
439
+ // same schema the runtime builds.
440
+ //
441
+ // `beforeAddMember` is deliberately not gated. `addMember` has no HTTP route, and its other firing
442
+ // is inside `createOrganization`, where the creator is in no organization yet and holds no key, so
443
+ // gating it would make creating an organization impossible. `member.create` is left to the
444
+ // provider's static roles.
445
+ const organizationOptionsFor = ({ mailer, organizations, showOrganization }: AuthSettings) => ({
446
+ ...organizationLimitsFor(organizations),
447
+ // An organization's name in each locale of the project, the JSON `namesOf` writes; `name` is the default
448
+ // locale's. `mithaq generate auth` writes it onto the organization kayan.
449
+ // When each member was last seen in the organization, which the tenant's session hooks stamp and no caller writes.
450
+ schema: { organization: { additionalFields: { names: { type: 'string', required: false } } }, member: { additionalFields: { lastSeenAt: { type: 'date', required: false, input: false } } } } as const,
451
+ dynamicAccessControl: { enabled: true as const },
452
+ // An organization that was ever used holds rows that cannot all be removed (an audit log,
453
+ // settings, its last administrator), and every tenant key is `restrict`, so deletion is off over
454
+ // HTTP.
455
+ disableOrganizationDeletion: true,
456
+ // Every invitation is mailed through the fixed letter, where a mailer exists.
457
+ ...(mailer ? { sendInvitationEmail: invitationMail({ mailer, organizations, showOrganization }) } : {}),
458
+ // The provider compares the invited address with the signed-in one on every invitation route.
459
+ // `requireEmailVerificationOnInvitation` stays unset rather than `false`, so the provider asks
460
+ // for a verified address only if invitation ids become guessable. An account made by invitation
461
+ // is not verified, since holding the id is its proof, so asking for one would leave every
462
+ // invitation unanswerable.
463
+ organizationHooks: {
464
+ beforeCreateOrganization: async ({ organization }: { organization: { name?: unknown; names?: unknown } & Record<string, unknown> }) => ({ data: { ...organization, ...named(organization) } }),
465
+ beforeUpdateOrganization: async ({ organization }: { organization: { name?: unknown; names?: unknown } & Record<string, unknown> }) => {
466
+ await gate('organization.update')({ organization })
467
+ if (organization.name === undefined && organization.names === undefined) return undefined
468
+ if (organizations === 'one') throw nameInCode()
469
+ // A name alone renames the default locale's, and each other locale keeps its own.
470
+ return { data: { ...organization, ...(organization.names === undefined ? { name: oneLineName(organization.name) } : namesOf(organization.names)) } }
471
+ },
472
+ beforeRemoveMember: gate('member.delete'),
473
+ beforeUpdateMemberRole: async ({ newRole, organization }: { newRole: string; organization: { id: string } }) => {
474
+ await gate('member.update')({ organization })
475
+ exactRoles(newRole)
476
+ },
477
+ beforeCreateInvitation: async ({ invitation, organization }: { invitation: { role: string }; organization: { id: string } }) => {
478
+ await gate('invitation.create')({ organization })
479
+ exactRoles(invitation.role)
480
+ invitedAs(invitation.role)
481
+ },
482
+ beforeAcceptInvitation: async ({ invitation }: { invitation: { role: string } }) => exactRoles(invitation.role),
483
+ beforeCancelInvitation: gate('invitation.cancel'),
484
+ },
485
+ })
486
+
487
+ // `'*'` is the plugin's `adminAc`, every action but `impersonate-admins`, so no backoffice
488
+ // administrator mimics another.
489
+ const adminAccess = createAccessControl(adminStatements)
490
+ const adminGrant = (permissions: '*' | readonly string[]) => {
491
+ if (permissions === '*') return adminAc
492
+ const grant: Record<string, string[]> = {}
493
+ for (const key of permissions) {
494
+ const [resource, action] = [key.slice(0, key.indexOf('.')), key.slice(key.indexOf('.') + 1)]
495
+ const actions: readonly string[] | undefined = Object.hasOwn(adminStatements, resource) ? adminStatements[resource as keyof typeof adminStatements] : undefined
496
+ if (actions?.includes(action)) (grant[resource] ??= []).push(action)
497
+ }
498
+ return adminAccess.newRole(grant as Parameters<typeof adminAccess.newRole>[0])
499
+ }
500
+
501
+ // `adminRoles`, not `adminUserIds`: an id list in code is a secret in the repository that no audit
502
+ // trail records a change to, while a column is written like any other row and can be taken back
503
+ // without a deploy.
504
+ //
505
+ // `roles` is the backoffice roles and `user` only. The plugin's gate reads `roles`, so spreading its
506
+ // `defaultRoles` would make `user.role = 'admin'` a backoffice administrator this project never
507
+ // declared. `user` is the plugin's `userAc`, which holds no action, and is here because `set-role`
508
+ // refuses a name `roles` does not hold.
509
+ //
510
+ // `adminRoles` is every backoffice role. None holds `impersonate-admins`, so no staff member can be
511
+ // impersonated. A backoffice role holds nothing inside a tenant.
512
+ const adminOptions = {
513
+ adminRoles: Object.keys(backofficeRoles),
514
+ roles: {
515
+ ...Object.fromEntries(Object.entries(backofficeRoles).map(([slug, role]) => [slug, adminGrant(role.permissions)])),
516
+ user: userAc,
517
+ },
518
+ }
519
+
520
+ // The backoffice screen's actions are the admin plugin's own routes, not this project's operations,
521
+ // so the audit step never sees them. Every `/admin/` route is audited here: one JSON line per
522
+ // successful call naming the actor, the real person behind an impersonated session, the route, the
523
+ // target and the role, never a password or other body field. With the audit-log add-on the
524
+ // impersonations, backoffice roles set and bans are rows of its staff history too
525
+ // (`recordBackoffice`); every other route is this output alone, so operators must retain it.
526
+ //
527
+ // The caller is read before the route runs because stopping an impersonation deletes the session
528
+ // that made the request.
529
+ const audited = (path: string) => path.startsWith('/admin/') || path === '/sign-out'
530
+ type Caller = { user: { id: string; email?: string; role?: unknown }; session: Record<string, unknown> } | null
531
+ const callers = new WeakMap<object, Caller>()
532
+ /** The person a route acts on, as they stood before it ran, which the history row names the role they held by. */
533
+ type Target = ({ email?: string; role?: string | null } & Record<string, unknown>) | null
534
+ const targets = new WeakMap<object, Target>()
535
+ /** The role a call writes, on `set-role` and `create-user` as `role` and on `update-user` and `create-user` as `data.role`. */
536
+ const roleOf = (body: { role?: unknown; data?: { role?: unknown } }) => body.role ?? body.data?.role
537
+ /** Whether a provider `member.role` names the owner, matched exactly as the provider matches it. */
538
+ const isOwnerRole = (role: unknown) => typeof role === 'string' && role.split(',').includes('owner')
539
+ /** Whether a `user.role` value names a role `test` admits, read as the plugin reads it, comma-separated. */
540
+ const holdsRole = (role: unknown, test: (one: string) => boolean) => typeof role === 'string' && role.split(',').some((one) => test(one.trim()))
541
+ /**
542
+ * The plugin's own permission for each admin route that acts on a person, as its routes ask it, so
543
+ * it is asked before this hook or any plugin's judges the input. `stop-impersonating` and
544
+ * `has-permission` ask none.
545
+ */
546
+ const routePermissions: Record<string, Record<string, string[]> | undefined> = {
547
+ '/admin/set-role': { user: ['set-role'] },
548
+ '/admin/get-user': { user: ['get'] },
549
+ '/admin/create-user': { user: ['create'] },
550
+ '/admin/update-user': { user: ['update'] },
551
+ '/admin/list-users': { user: ['list'] },
552
+ '/admin/list-user-sessions': { session: ['list'] },
553
+ '/admin/unban-user': { user: ['ban'] },
554
+ '/admin/ban-user': { user: ['ban'] },
555
+ '/admin/impersonate-user': { user: ['impersonate'] },
556
+ '/admin/revoke-user-session': { session: ['revoke'] },
557
+ '/admin/revoke-user-sessions': { session: ['revoke'] },
558
+ '/admin/remove-user': { user: ['delete'] },
559
+ '/admin/set-user-password': { user: ['set-password'] },
560
+ }
561
+ /** The key this project asks for each of the provider's role-writing routes. */
562
+ const roleWrites = new Map([
563
+ ['/organization/create-role', 'ac.create'],
564
+ ['/organization/update-role', 'ac.update'],
565
+ ['/organization/delete-role', 'ac.delete'],
566
+ ])
567
+ type HookContext = Parameters<Parameters<typeof createAuthMiddleware>[0]>[0]
568
+ type Members = { findMany<Row>(query: { model: string; where: { field: string; value: string | Date }[]; limit: number; sortBy: { field: string; direction: 'asc' | 'desc' } }): Promise<Row[]> }
569
+ /**
570
+ * The organization a session opens in when nothing names one: the person's oldest membership,
571
+ * usually the one they founded, so every sign-in lands in the same place. The adapter sorts by one
572
+ * column, so memberships made in the same instant are read again and the first by id is taken.
573
+ */
574
+ const oldestMembership = async ({ adapter, user }: { adapter: Members; user: string }) => {
575
+ const [oldest] = await adapter.findMany<{ createdAt: Date; organizationId: string }>({ model: 'member', where: [{ field: 'userId', value: user }], limit: 1, sortBy: { field: 'createdAt', direction: 'asc' } })
576
+ if (!oldest) return undefined
577
+ const [first] = await adapter.findMany<{ organizationId: string }>({ model: 'member', where: [{ field: 'userId', value: user }, { field: 'createdAt', value: oldest.createdAt }], limit: 1, sortBy: { field: 'id', direction: 'asc' } })
578
+ // A database timestamp finer than the adapter's date matches no row; the oldest stands then.
579
+ return first?.organizationId ?? oldest.organizationId
580
+ }
581
+ // The provider's member reads take the organization by `organizationId` or by `organizationSlug`,
582
+ // the slug winning, so both are compared with the active organization.
583
+ const sameOrganizationNamed = async (ctx: HookContext) => {
584
+ const { organizationId, organizationSlug } = (ctx.query ?? {}) as { organizationId?: unknown; organizationSlug?: unknown }
585
+ sameOrganization({ id: organizationId })
586
+ if (typeof organizationSlug !== 'string') return
587
+ const named = await ctx.context.adapter.findOne<{ id: string }>({ model: 'organization', where: [{ field: 'slug', value: organizationSlug }] })
588
+ sameOrganization({ id: named?.id })
589
+ }
590
+ /** The names of the organization `id`, one per locale, as a reader of an invitation sees them. */
591
+ const namesById = async (ctx: HookContext, id: unknown) => {
592
+ const found = typeof id === 'string' ? await ctx.context.adapter.findOne<{ name: string; names?: unknown }>({ model: 'organization', where: [{ field: 'id', value: id }] }) : null
593
+ return found ? Object.fromEntries(nameLocales.map((locale) => [locale, nameIn(found, locale)])) : undefined
594
+ }
595
+ // When a member was last seen is a column with a key of its own, so a caller who may list, change or remove members
596
+ // reads it only holding that key as well, as a managed read hides a column its key does not open. Every member row an
597
+ // answer carries, at any depth, loses it.
598
+ const unseen = <Answer,>(answer: Answer): Answer =>
599
+ holdsPermission('member.fields.lastSeenAt') ? answer : JSON.parse(JSON.stringify(answer), (key, value) => (key === 'lastSeenAt' ? undefined : value))
600
+ const auditBefore = async (ctx: HookContext) => {
601
+ // The provider hands any member every member's name, address, image, role and join date, and
602
+ // anyone's role by `userId`, and these routes have no hook to gate. Only a caller who may read
603
+ // members does, in the active organization alone. A caller's own role, asked with no `userId`,
604
+ // stays open: every request's actor is read through it.
605
+ //
606
+ // The key is asked first, so a caller without it learns nothing of whether a slug names an
607
+ // organization.
608
+ if (ctx.path === '/organization/list-members' || (ctx.path === '/organization/get-active-member-role' && (ctx.query as { userId?: unknown } | undefined)?.userId !== undefined)) {
609
+ await gate('member.find')()
610
+ await sameOrganizationNamed(ctx)
611
+ }
612
+ // The full read's members are emptied afterwards for a caller who may not list them, so the
613
+ // provider loads one rather than up to `membershipLimit`.
614
+ if (ctx.path === '/organization/get-full-organization') {
615
+ const mayList = await gate('member.find')().then(() => sameOrganizationNamed(ctx)).then(() => true, () => false)
616
+ if (!mayList) return { context: { query: { ...(ctx.query as object | undefined), membersLimit: 1 } } }
617
+ }
618
+ // The provider lets any member list invitations, invitee emails among them, and this route has no
619
+ // hook to gate. Only a caller who may invite reads them, in the active organization alone.
620
+ if (ctx.path === '/organization/list-invitations') {
621
+ sameOrganization({ id: (ctx.query as { organizationId?: unknown } | undefined)?.organizationId })
622
+ await gate('invitation.create')()
623
+ }
624
+ // A rename takes an `organizationId` in its body, but its hook is handed the new data, which
625
+ // carries no id, so the organization is compared here. A body naming none updates the active
626
+ // organization and passes.
627
+ if (ctx.path === '/organization/update') sameOrganization({ id: (ctx.body as { organizationId?: unknown } | undefined)?.organizationId })
628
+ // The provider's `organizationRole` writes take an `organizationId` in their body and ask the
629
+ // provider's own `ac` keys, which it answers from governance roles alone, so a governance admin
630
+ // of another organization could write roles there with no key. Ask this project's keys instead,
631
+ // in the active organization alone.
632
+ const roleWrite = roleWrites.get(ctx.path)
633
+ if (roleWrite) {
634
+ sameOrganization({ id: (ctx.body as { organizationId?: unknown } | undefined)?.organizationId })
635
+ await gate(roleWrite)()
636
+ }
637
+ if (!audited(ctx.path)) return
638
+ const caller: Caller = await getSessionFromCtx(ctx).catch(() => null)
639
+ callers.set(ctx.context, caller)
640
+ // `/admin/has-permission` is excepted: it carries `role` to ask what that role may do and writes
641
+ // nothing.
642
+ const body = (ctx.body ?? {}) as Parameters<typeof roleOf>[0] & { userId?: unknown; banReason?: unknown; data?: { banned?: unknown; banReason?: unknown } }
643
+ const acts = ctx.path.startsWith('/admin/') && ctx.path !== '/admin/has-permission'
644
+ // Every route that names the person it acts on names them as `userId` in its body. Nobody acts on
645
+ // their own row, and only a super admin acts on a staff member's.
646
+ //
647
+ // A caller with no session is left to the provider's own 401, so the answer never says whether an
648
+ // id names staff.
649
+ if (!acts || !caller) return
650
+ // The route's own permission, asked as the plugin asks it, before any input is judged: a caller who
651
+ // may not use the route learns nothing of what it takes.
652
+ const asks = routePermissions[ctx.path]
653
+ // The serving instance's own plugin roles, which a project's second instance may hold differently.
654
+ const roles = (ctx.context.options.plugins?.find((one) => one.id === 'admin') as { options?: { roles?: Record<string, { authorize: (asked: typeof asks) => { success: boolean } } | undefined> } } | undefined)?.options?.roles ?? {}
655
+ if (asks && !String(caller.user.role ?? 'user').split(',').some((one) => roles[one]?.authorize(asks).success)) {
656
+ throw new APIError('FORBIDDEN', { message: 'This role may not use this route.' })
657
+ }
658
+ // Only a `super_admin` writes a role, on any route that takes one. The plugin already asks
659
+ // `user.set-role` of these routes, which only `'*'` holds, but a project's own backoffice role
660
+ // granted `user.update` must still write no role through `update-user`.
661
+ const grants = holdsRole(caller.user.role, (one) => one === 'super_admin')
662
+ if (roleOf(body) !== undefined && !grants && !firstRegistering.getStore()) throw new APIError('FORBIDDEN', { message: 'Only a backoffice administrator sets a role.' })
663
+ // A ban needs a reason. The provider writes `data.banned` raw unless it is `=== true`, so a
664
+ // non-boolean such as `'true'` would store a ban with no reason and end no session.
665
+ const writes = ctx.path === '/admin/update-user' || ctx.path === '/admin/create-user'
666
+ if (writes && body.data && Object.hasOwn(body.data, 'banned') && typeof body.data.banned !== 'boolean') {
667
+ throw new APIError('BAD_REQUEST', { message: 'A ban is true or false.' })
668
+ }
669
+ const banning = ctx.path === '/admin/ban-user' ? body : writes && body.data?.banned === true ? body.data : undefined
670
+ if (banning && (typeof banning.banReason !== 'string' || !banning.banReason.trim())) {
671
+ throw new APIError('BAD_REQUEST', { message: 'A ban carries a reason.' })
672
+ }
673
+ if (body.userId === undefined) return
674
+ const userId = String(body.userId)
675
+ if (userId === caller.user.id) throw new APIError('FORBIDDEN', { message: 'Nobody acts on their own row on the backoffice.' })
676
+ // The admin plugin's own `role` column, which the core user type does not name.
677
+ const target = (await ctx.context.internalAdapter.findUserById(userId)) as Target
678
+ targets.set(ctx.context, target)
679
+ if (holdsRole(target?.role, (one) => Object.hasOwn(backofficeRoles, one)) && !grants) {
680
+ throw new APIError('FORBIDDEN', { message: 'Only a backoffice administrator acts on a staff member.' })
681
+ }
682
+ // A role written as it already stands is answered and written nowhere, so the audit holds no line
683
+ // for it.
684
+ if (ctx.path === '/admin/set-role' && target && [body.role].flat().join(',') === (target.role ?? 'user')) return ctx.json({ user: target })
685
+ }
686
+ /**
687
+ * One row of the staff history: an impersonation started or stopped, a backoffice role set and a
688
+ * ban set or lifted, each person by id and by the address they had then.
689
+ */
690
+ export type BackofficeEntry = {
691
+ action: 'impersonate' | 'stopImpersonating' | 'setRole' | 'ban' | 'unban'
692
+ actor: string | null
693
+ actorEmail: string | null
694
+ target: string | null
695
+ targetEmail: string | null
696
+ previousRole?: string | null
697
+ role?: string | null
698
+ banReason?: string | null
699
+ }
700
+
701
+ const textOf = (value: unknown) => (typeof value === 'string' ? value : null)
702
+
703
+ /** The history rows one successful admin route or impersonation-ending sign-out makes, none for any other. */
704
+ const historyOf = async (ctx: HookContext, caller: Caller): Promise<BackofficeEntry[]> => {
705
+ if (!caller) return []
706
+ const body = (ctx.body ?? {}) as Parameters<typeof roleOf>[0] & { userId?: unknown; banReason?: unknown; data?: { banned?: unknown; banReason?: unknown } }
707
+ const before = targets.get(ctx.context)
708
+ const made = (ctx.context.returned as { user?: { id?: unknown; email?: unknown } } | undefined)?.user
709
+ const staff = { actor: caller.user.id, actorEmail: caller.user.email ?? null }
710
+ const person = { target: textOf(body.userId) ?? textOf(made?.id), targetEmail: textOf(before?.email) ?? textOf(made?.email) }
711
+ if (ctx.path === '/admin/impersonate-user') return [{ action: 'impersonate', ...staff, ...person }]
712
+ // Ended from inside the impersonated session, whose person is the member: the staff member is the
713
+ // one the session names as behind it.
714
+ if (ctx.path === '/sign-out' || ctx.path === '/admin/stop-impersonating') {
715
+ const behind = textOf(caller.session.impersonatedBy)
716
+ if (!behind) return []
717
+ const found = (await ctx.context.internalAdapter.findUserById(behind)) as Target
718
+ return [{ action: 'stopImpersonating', actor: behind, actorEmail: textOf(found?.email), target: caller.user.id, targetEmail: caller.user.email ?? null }]
719
+ }
720
+ const entries: BackofficeEntry[] = []
721
+ const role = roleOf(body)
722
+ const previousRole = before?.role ?? null
723
+ if (role !== undefined && [role].flat().join(',') !== (previousRole ?? 'user')) entries.push({ action: 'setRole', ...staff, ...person, previousRole, role: [role].flat().join(',') })
724
+ const banned = ctx.path === '/admin/ban-user' ? true : ctx.path === '/admin/unban-user' ? false : body.data?.banned
725
+ if (banned === true) entries.push({ action: 'ban', ...staff, ...person, banReason: textOf(ctx.path === '/admin/ban-user' ? body.banReason : body.data?.banReason) })
726
+ if (banned === false && (ctx.path === '/admin/unban-user' || before?.banned === true)) entries.push({ action: 'unban', ...staff, ...person })
727
+ return entries
728
+ }
729
+
730
+ /**
731
+ * The hooks that audit the backoffice's routes. `record` writes each row of the staff history,
732
+ * where the project keeps one, and its failure answers the route an error. Only an impersonation
733
+ * fails closed: its row is written before the hand-off, so one no row records is one nobody can
734
+ * use. A stop, a role set and a ban or its lifting are already committed by the provider when the
735
+ * row fails; the route still answers an error and the line above stays the record of them.
736
+ */
737
+ export const backofficeAuditOf = (record?: (entry: BackofficeEntry) => Promise<unknown>) => ({
738
+ before: createAuthMiddleware(auditBefore),
739
+ after: createAuthMiddleware(async (ctx) => {
740
+ // `get-full-organization` also answers members and invitations, addresses among them, to any
741
+ // member, so each is emptied unless the caller holds the key its own list route asks. The
742
+ // organization itself stays readable to every member.
743
+ const full = ctx.context.returned as { id?: unknown; members?: unknown; invitations?: unknown } | null | undefined
744
+ if (ctx.path === '/organization/get-full-organization' && full && typeof full === 'object' && 'id' in full) {
745
+ const holds = (key: string) => gate(key)({ organization: full }).then(() => true, () => false)
746
+ const [mayInvite, mayList] = [await holds('invitation.create'), await holds('member.find')]
747
+ return ctx.json(unseen({ ...full, ...(mayInvite ? {} : { invitations: [] }), members: mayList ? full.members : [] }))
748
+ }
749
+ // The provider hands the invitee the inviter's address, which the accept page never shows, and
750
+ // an invitation is no reason to learn a member's address.
751
+ const invitation = ctx.context.returned as { inviterEmail?: unknown; organizationId?: unknown } | null | undefined
752
+ if (ctx.path === '/organization/get-invitation' && invitation && typeof invitation === 'object' && 'inviterEmail' in invitation) return ctx.json({ ...invitation, inviterEmail: '', organizationNames: await namesById(ctx, invitation.organizationId) })
753
+ // The provider names an invitation's organization in the default locale alone, so each locale's is added.
754
+ const invitations = ctx.context.returned as { organizationId?: unknown }[] | null | undefined
755
+ if (ctx.path === '/organization/list-user-invitations' && Array.isArray(invitations)) return ctx.json(await Promise.all(invitations.map(async (one) => ({ ...one, organizationNames: await namesById(ctx, one.organizationId) }))))
756
+ // Leaving the active organization leaves the session in none, where every read is refused,
757
+ // so it opens the person's oldest remaining membership, as a new session would.
758
+ const leaving = ctx.context.session
759
+ if (ctx.path === '/organization/leave' && leaving && !(ctx.context.returned instanceof Error) && leaving.session.activeOrganizationId === (ctx.body as { organizationId?: unknown } | undefined)?.organizationId) {
760
+ const next = await oldestMembership({ adapter: ctx.context.adapter as unknown as Members, user: leaving.user.id })
761
+ if (next) await ctx.context.internalAdapter.updateSession(leaving.session.token, { activeOrganizationId: next })
762
+ }
763
+ const answered = ctx.context.returned
764
+ if (ctx.path.startsWith('/organization/') && answered && typeof answered === 'object' && !(answered instanceof Error) && !(answered instanceof Response) && JSON.stringify(answered).includes('"lastSeenAt"')) return ctx.json(unseen(answered))
765
+ if (!audited(ctx.path) || ctx.context.returned instanceof Error) return
766
+ const caller = callers.get(ctx.context)
767
+ // A sign-out is audited only where it ends an impersonation, which is how the tenant app stops
768
+ // one: the provider's own stop route would put the staff member's session back under the tenant
769
+ // app's cookie, which the tenant app refuses.
770
+ if (ctx.path === '/sign-out' && !caller?.session.impersonatedBy) return
771
+ const body = (ctx.body ?? {}) as { userId?: unknown; role?: unknown; data?: { role?: unknown } }
772
+ const query = (ctx.query ?? {}) as { id?: unknown; userId?: unknown }
773
+ // The person acted on: named in the body or the query, or made by the call, `create-user`'s.
774
+ const target = body.userId ?? query.userId ?? query.id ?? (ctx.context.returned as { user?: { id?: unknown } } | undefined)?.user?.id
775
+ const role = roleOf(body)
776
+ console.log(JSON.stringify({
777
+ audit: 'backoffice',
778
+ action: ctx.path,
779
+ actor: caller?.user.id ?? null,
780
+ impersonatedBy: caller?.session.impersonatedBy ?? null,
781
+ ...(target === undefined ? {} : { target }),
782
+ ...(role === undefined ? {} : { role }),
783
+ at: new Date().toISOString(),
784
+ }))
785
+ if (record) for (const entry of await historyOf(ctx, caller ?? null)) await record(entry)
786
+ if (ctx.path === '/admin/impersonate-user' && ctx.context.newSession) return handOff(ctx)
787
+ // Ending an impersonation sends the staff member back to the backoffice, whose session is still
788
+ // theirs, not to the tenant's sign-in. The answer keeps the provider's `success` and the
789
+ // cookies it cleared.
790
+ if (ctx.path === '/sign-out') return ctx.json({ success: true, url: backofficeApp ? backofficeURL : tenantURL })
791
+ }),
792
+ })
793
+
794
+ // The backoffice never writes the tenant app's cookie (two origins, two cookies), so an impersonation
795
+ // answers a one-time link into the tenant app, which trades it for the session under its own
796
+ // cookie.
797
+ //
798
+ // The token is stored the way the one-time-token plugin stores its own, because that plugin's
799
+ // `verifyOneTimeToken` consumes it: once, setting the session cookie of whichever instance served
800
+ // the verify. Every cookie the impersonation set is dropped from the answer, so the backoffice's own
801
+ // session is untouched.
802
+ const handOff = async (ctx: HookContext) => {
803
+ const token = randomBytes(32).toString('base64url')
804
+ await ctx.context.internalAdapter.createVerificationValue({
805
+ value: ctx.context.newSession!.session.token,
806
+ identifier: `one-time-token:${token}`,
807
+ expiresAt: new Date(Date.now() + 3 * 60 * 1000),
808
+ })
809
+ ctx.context.responseHeaders?.delete('set-cookie')
810
+ return ctx.json({ url: new URL(`/auth/handoff?token=${token}`, tenantURL).href })
811
+ }
812
+
813
+ // A session is made only for staff or for the person an impersonation mimics, which the provider
814
+ // makes with `impersonatedBy` set. Anyone else gets the provider's wrong-password refusal, the one
815
+ // its sign-in throws for an unknown address and a wrong password alike. The hook runs after the
816
+ // password was checked, so nobody learns which accounts exist or hold a password.
817
+ //
818
+ // Without the endpoint context there is no adapter to read the person with, so the session is
819
+ // refused. The read is the transaction's where one is open.
820
+ type SessionBefore = typeof options.databaseHooks.session.create.before
821
+ const staffSession = (next: SessionBefore): SessionBefore => async (session, context) => {
822
+ const mimics = !!(session as { impersonatedBy?: unknown }).impersonatedBy
823
+ const user = mimics || !context ? null : await (await getCurrentAdapter(context.context.adapter as never)).findOne<{ role?: unknown }>({ model: 'user', where: [{ field: 'id', value: session.userId }] })
824
+ if (!mimics && !holdsRole(user?.role, (one) => Object.hasOwn(backofficeRoles, one))) {
825
+ throw APIError.from('UNAUTHORIZED', BASE_ERROR_CODES.INVALID_EMAIL_OR_PASSWORD)
826
+ }
827
+ return next(session, context)
828
+ }
829
+
830
+ /**
831
+ * Called once by the provider inside `createOrganization`, with the organization and the member row
832
+ * it made for the caller. The template uses it to give a new organization an administrator.
833
+ */
834
+ export type AssignFirstRole = (data: {
835
+ organization: { id: string } & Record<string, unknown>
836
+ member: { id: string } & Record<string, unknown>
837
+ user: { id: string } & Record<string, unknown>
838
+ }) => Promise<void>
839
+
840
+ /**
841
+ * The organization hooks that need this project's store, which only the factory below holds. A
842
+ * `beforeRemoveMember` or `beforeAcceptInvitation` here runs after the template's own, never in its
843
+ * place. An invitation the server accepts for a sign-up, and a person Add person adds, run
844
+ * `beforeAddMember` and no accept hook: the membership is made without the accept route.
845
+ */
846
+ type Joining = { organization: { id: string } & Record<string, unknown> }
847
+ export type StoreHooks = {
848
+ afterCreateOrganization?: AssignFirstRole
849
+ beforeRemoveMember?: (data: Joining & { member: { userId: string } & Record<string, unknown> }) => Promise<void>
850
+ beforeAcceptInvitation?: (data: Joining & { invitation: { role: string } & Record<string, unknown>; user: { id: string } & Record<string, unknown> }) => Promise<void>
851
+ beforeAddMember?: (data: Joining & { member: { userId: string } & Record<string, unknown> }) => Promise<void>
852
+ /** Writes one row of the staff history, which the audit-log add-on keeps. */
853
+ recordBackoffice?: (entry: BackofficeEntry) => Promise<unknown>
854
+ }
855
+
856
+ /**
857
+ * Parses `TRUSTED_IP_HEADER`, the header names this deployment's proxy overwrites with the real
858
+ * client address (`cf-connecting-ip` behind Cloudflare, `x-forwarded-for` behind a reverse proxy
859
+ * configured to overwrite it, comma-separated for several). It feeds Better Auth's
860
+ * `advanced.ipAddress.ipAddressHeaders`.
861
+ *
862
+ * `undefined` when unset, so Better Auth keeps its default of `x-forwarded-for` alone. A header is
863
+ * trustworthy only when the proxy overwrites it on every request; otherwise any caller picks its
864
+ * own rate-limit bucket.
865
+ */
866
+ export const trustedIpHeaders = (value: string | undefined): string[] | undefined => {
867
+ const headers = value
868
+ ?.split(',')
869
+ .map((header) => header.trim())
870
+ .filter(Boolean)
871
+ return headers?.length ? headers : undefined
872
+ }
873
+
874
+ const ipAddressHeaders = trustedIpHeaders(process.env.TRUSTED_IP_HEADER)
875
+
876
+ // Where a password reset link goes: through the mailer, which in development may be the server's
877
+ // log. With no mailer the provider refuses every address alike before it looks one up, so no token
878
+ // or address is written anywhere.
879
+ //
880
+ // The link is the tenant app's reset page, not the provider's `url`, which names
881
+ // `/api/auth/reset-password` while this project serves the provider under `/api/auth/user`.
882
+ const onLocalHost = !production && isLocalHost(tenantURL)
883
+ // An address is proved by choosing the account's password from a link mailed to it: the reset
884
+ // page's link, which ends every session and marks the address verified. Proving it never keeps a
885
+ // password somebody else typed at sign-up, so whoever signed up first with another's address never
886
+ // signs in once its owner proves it.
887
+ // A first password is chosen on a page of its own, which names the account it is for; a forgotten one on the reset
888
+ // page. Both send the same token to the same route.
889
+ const setPasswordLink = (token: string) => new URL(`/auth/set-password?token=${encodeURIComponent(token)}`, tenantURL).href
890
+ // A link that also accepts an invitation names the organization, so opening it is the word to join.
891
+ const confirmMail = (mailer: Mailer) => async ({ email, token, joining }: { email: string; token: string; joining?: { name: string; names?: unknown } }) =>
892
+ mailer.send(letter({
893
+ to: email,
894
+ subject: 'Confirm your address · تأكيد عنوانك',
895
+ en: joining === undefined
896
+ ? 'Someone made an account with this address. If it was you, open the link below within an hour to confirm it and choose your password. If not, ignore this message: an account nobody confirms never signs in.'
897
+ : `Someone made an account with this address to join ${nameIn(joining, 'en')}. If it was you, open the link below within an hour to confirm it, choose your password and join. If not, ignore this message: an account nobody confirms never signs in.`,
898
+ ar: joining === undefined
899
+ ? 'أنشأ أحدهم حسابا بهذا العنوان. إن كنت أنت، فافتح الرابط أدناه خلال ساعة لتأكيده واختيار كلمة المرور. وإلا فتجاهل هذه الرسالة: الحساب الذي لا يؤكده أحد لا يسجل الدخول أبدا.'
900
+ : `أنشأ أحدهم حسابا بهذا العنوان للانضمام إلى ${nameIn(joining, 'ar')}. إن كنت أنت، فافتح الرابط أدناه خلال ساعة لتأكيده واختيار كلمة المرور والانضمام. وإلا فتجاهل هذه الرسالة: الحساب الذي لا يؤكده أحد لا يسجل الدخول أبدا.`,
901
+ link: setPasswordLink(token),
902
+ }))
903
+
904
+ // The set-password link of a person an administrator added, which they open to choose their password.
905
+ const addedMail = ({ mailer, showOrganization }: { mailer: Mailer; showOrganization: boolean }) => async ({ email, organization, token }: { email: string; organization: { name: string; names?: unknown }; token: string }) => {
906
+ const [en, ar] = showOrganization ? [nameIn(organization, 'en'), nameIn(organization, 'ar')] : ['this app', 'هذا التطبيق']
907
+ await mailer.send(letter({
908
+ to: email,
909
+ subject: 'Your account · حسابك',
910
+ en: `An administrator added you to ${en}. Open the link below to choose your password; it lasts 48 hours.`,
911
+ ar: `أضافك أحد المسؤولين إلى ${ar}. افتح الرابط أدناه لاختيار كلمة المرور؛ وهو صالح 48 ساعة.`,
912
+ link: setPasswordLink(token),
913
+ }))
914
+ }
915
+
916
+ // A new set-password link an administrator sent a member, which changes nothing until it is opened.
917
+ const newLinkMail = ({ mailer, showOrganization }: { mailer: Mailer; showOrganization: boolean }) => async ({ email, organization, token }: { email: string; organization: { name: string; names?: unknown }; token: string }) => {
918
+ const [en, ar] = showOrganization ? [nameIn(organization, 'en'), nameIn(organization, 'ar')] : ['this app', 'هذا التطبيق']
919
+ await mailer.send(letter({
920
+ to: email,
921
+ subject: 'Choose your password · اختر كلمة المرور',
922
+ en: `An administrator of ${en} sent you a link to choose your password. Open it within 48 hours. Until you do, nothing changes.`,
923
+ ar: `أرسل إليك أحد مسؤولي ${ar} رابطا لاختيار كلمة المرور. افتحه خلال 48 ساعة. لن يتغير شيء حتى تفعل.`,
924
+ link: setPasswordLink(token),
925
+ }))
926
+ }
927
+
928
+ // The first administrator is made with no proof of the address, the setup code standing in for it, so the address
929
+ // is told once: whoever set the app up and typed it wrong notices the mail that never came.
930
+ const adminMadeMail = ({ mailer, email, at }: { mailer: Mailer; email: string; at: string }) =>
931
+ mailer.send(letter({
932
+ to: email,
933
+ subject: 'Your administrator account · حساب المسؤول',
934
+ en: 'An administrator account was made with this address. Sign in at the link below. If you did not expect this, ignore this message and tell whoever set up this app.',
935
+ ar: 'أُنشئ حساب مسؤول بهذا العنوان. سجّل الدخول من الرابط أدناه. إن لم تكن تتوقع هذا، فتجاهل هذه الرسالة وأخبر من أعدّ هذا التطبيق.',
936
+ link: new URL('/auth/sign-in', at).href,
937
+ }))
938
+
939
+ const resetMail = (mailer: Mailer) => async ({ user, token }: { user: { email: string }; token: string }) =>
940
+ mailer.send(letter({
941
+ to: user.email,
942
+ subject: 'Reset your password · إعادة تعيين كلمة المرور',
943
+ en: 'Someone asked to reset the password of the account at this address. If it was you, open the link below within an hour. If not, ignore this message: nothing changes.',
944
+ ar: 'طلب أحدهم إعادة تعيين كلمة مرور الحساب بهذا العنوان. إن كنت أنت، فافتح الرابط أدناه خلال ساعة. وإلا فتجاهل هذه الرسالة: لن يتغير شيء.',
945
+ link: new URL(`/auth/reset-password?token=${encodeURIComponent(token)}`, tenantURL).href,
946
+ }))
947
+
948
+ /**
949
+ * The Better Auth options, with no `database`: the strategy supplies the tables mithaq built.
950
+ * `mithaq generate auth` reads this module to write the auth kayan.
951
+ *
952
+ * The hooks that write rows of this project (`StoreHooks`) are a parameter of `authOptions`
953
+ * instead. A hook changes no table, so the schema read here is the schema the runtime builds.
954
+ */
955
+ // Mail goes out after the answer, so how long an answer takes never says whether an address has an
956
+ // account, and a slow mailer holds up no request. Each send is kept until it settles, so a test or a
957
+ // shutdown can wait for what is still going out. The development log is written at once.
958
+ const background = new Set<Promise<unknown>>()
959
+ const inBackground = (promise: Promise<unknown>) => {
960
+ background.add(promise)
961
+ void promise.finally(() => background.delete(promise))
962
+ }
963
+ /** Resolves once every mail sent in the background has gone out or failed. */
964
+ export const backgroundSettled = async () => {
965
+ while (background.size) await Promise.allSettled([...background])
966
+ }
967
+
968
+ export const optionsFor = (settings: AuthSettings) => ({
969
+ // A reset ends every session of the account, in both apps, since they share one session table.
970
+ emailAndPassword: {
971
+ enabled: true as const,
972
+ revokeSessionsOnPasswordReset: true,
973
+ // Where a sign-up proves its address, an address with an account is answered with a made-up
974
+ // user. The admin plugin gives a real one its role as it is written, not as a default the
975
+ // made-up one reads, so without this the answer's `role` would say which addresses exist.
976
+ customSyntheticUser: ({ coreFields, additionalFields, id }: { coreFields: Record<string, unknown>; additionalFields: Record<string, unknown>; id: string }) => ({ ...coreFields, ...additionalFields, role: 'user', id }),
977
+ ...(settings.mailer ? { sendResetPassword: resetMail(settings.mailer) } : {}),
978
+ },
979
+ // The provider rate limits only when `NODE_ENV` is `production`, so a deploy that sets none would
980
+ // answer every sign-in and reset request unlimited. Off a local host it limits whatever
981
+ // `NODE_ENV` says.
982
+ //
983
+ // Invitations are mailed, so they are limited apart: ten a minute from one address, beside each
984
+ // organization's own `invitationLimits`.
985
+ rateLimit: { enabled: !onLocalHost, customRules: { '/organization/invite-member': { window: 60, max: invitationLimits.perMinute }, '/organization/add-person': { window: 60, max: invitationLimits.perMinute }, '/organization/send-password-link': { window: 60, max: invitationLimits.perMinute } } },
986
+ // Better Auth reads it for the links it writes into mail and the origin it checks a request
987
+ // against; without it every boot prints `Base URL is not set`.
988
+ baseURL: tenantURL,
989
+ // The origins a browser may carry a cookie of this project to: the template's `vite dev`, `vite
990
+ // preview` and Next ports, outside production only. Better Auth adds `baseURL` itself. A project
991
+ // that serves a mobile client adds its scheme here.
992
+ trustedOrigins: production ? [] : ['http://localhost:3000', 'http://localhost:4173', 'http://localhost:5173'],
993
+ ...((ipAddressHeaders || (settings.mailer && settings.mailer !== logMailer)) && {
994
+ advanced: {
995
+ ...(ipAddressHeaders && { ipAddress: { ipAddressHeaders } }),
996
+ ...(settings.mailer && settings.mailer !== logMailer && { backgroundTasks: { handler: inBackground } }),
997
+ },
998
+ }),
999
+ // How an impersonation crosses from the backoffice app into the tenant app (`handOff`). The plugin
1000
+ // adds no table, and no client may mint a token over HTTP.
1001
+ plugins: [admin(adminOptions), organization(organizationOptionsFor(settings)), oneTimeToken({ disableClientRequest: true })],
1002
+ hooks: backofficeAuditOf(),
1003
+ // The active organization, set where the session is made. Every row this project reads is keyed
1004
+ // on it and the provider sets it only when an organization is created, so a user who signed in
1005
+ // held none and was refused every read. The user's oldest membership becomes active on a session
1006
+ // that names none; a session that names one is left alone.
1007
+ //
1008
+ // The read goes through `getCurrentAdapter`, the transaction's adapter where the provider opened
1009
+ // one: the base adapter would take a second pool client while the transaction holds the first,
1010
+ // and a full pool would wait on itself.
1011
+ databaseHooks: {
1012
+ session: {
1013
+ create: {
1014
+ before: async (
1015
+ session: { userId: string; activeOrganizationId?: string | null },
1016
+ context: { context: { adapter: { findOne: <Row>(query: { model: string; where: { field: string; value: string }[] }) => Promise<Row | null> } } } | null,
1017
+ ) => {
1018
+ if (session.activeOrganizationId || !context) return
1019
+ const organization = await oldestMembership({ adapter: await getCurrentAdapter(context.context.adapter as never), user: session.userId })
1020
+ return organization ? { data: { ...session, activeOrganizationId: organization } } : undefined
1021
+ },
1022
+ },
1023
+ },
1024
+ },
1025
+ })
1026
+ /** This project's options, which `mithaq generate auth` reads. */
1027
+ export const options = optionsFor(authSettings)
1028
+
1029
+ /**
1030
+ * The same options with the provider's own access control built, which is what the template builds
1031
+ * its server from. It is a function because the roles are made from an `ac` that has to exist
1032
+ * first.
1033
+ *
1034
+ * The three built-in roles are the provider's defaults over the provider's own resources and grade
1035
+ * nothing of this project: what a member may do is the access target's answer, which reads the
1036
+ * project's own keys. A project narrows these where its own governance differs.
1037
+ */
1038
+ export const authOptions = (hooks: StoreHooks = {}, settings: AuthSettings = authSettings) => {
1039
+ const options = optionsFor(settings)
1040
+ const organizationOptions = organizationOptionsFor(settings)
1041
+ // Only the provider's own vocabulary. Merging this project's catalog in would make the provider's
1042
+ // `owner` hold every project key before any role row was read, and a `roles` row could then widen
1043
+ // and never narrow. The provider gates its own actions; this project's keys are the access
1044
+ // target's.
1045
+ const ac = createAccessControl({ ...defaultStatements })
1046
+ // A role's type is a subset of the statements it was made from, and these are derived at runtime,
1047
+ // so what a role holds is only a string list here. The assertion is at the one line that crosses
1048
+ // that boundary.
1049
+ type Grant = Parameters<typeof ac.newRole>[0]
1050
+ const everything: Record<string, string[]> = Object.fromEntries(
1051
+ Object.entries(ac.statements).map(([resource, actions]) => [resource, [...(actions as readonly string[])]]),
1052
+ )
1053
+ const roles = {
1054
+ owner: ac.newRole(everything as Grant),
1055
+ admin: ac.newRole({
1056
+ ...everything,
1057
+ organization: (everything.organization ?? []).filter((one) => one !== 'delete'),
1058
+ } as Grant),
1059
+ member: ac.newRole({ ac: ['read'] }),
1060
+ }
1061
+ return {
1062
+ options: {
1063
+ ...options,
1064
+ // The same audit, writing each row of the staff history where the project keeps one.
1065
+ hooks: backofficeAuditOf(hooks.recordBackoffice),
1066
+ plugins: [
1067
+ // The same object as in `options`, so the schema `mithaq generate auth` reads is the schema
1068
+ // the runtime builds. Leaving it out of the rebuilt list would drop five columns from the
1069
+ // runtime that the generated kayan still describe.
1070
+ admin(adminOptions),
1071
+ oneTimeToken({ disableClientRequest: true }),
1072
+ organization({
1073
+ ...organizationOptions,
1074
+ organizationHooks: {
1075
+ ...organizationOptions.organizationHooks,
1076
+ // The project's own, written where the store is in hand: `providers.user.server`.
1077
+ ...hooks,
1078
+ beforeRemoveMember: async (data: Parameters<NonNullable<StoreHooks['beforeRemoveMember']>>[0]) => {
1079
+ await organizationOptions.organizationHooks.beforeRemoveMember(data)
1080
+ await hooks.beforeRemoveMember?.(data)
1081
+ },
1082
+ beforeAcceptInvitation: async (data: Parameters<NonNullable<StoreHooks['beforeAcceptInvitation']>>[0]) => {
1083
+ await organizationOptions.organizationHooks.beforeAcceptInvitation(data)
1084
+ await hooks.beforeAcceptInvitation?.(data)
1085
+ },
1086
+ },
1087
+ ac,
1088
+ roles,
1089
+ }),
1090
+ ],
1091
+ },
1092
+ roles,
1093
+ }
1094
+ }
1095
+
1096
+ /**
1097
+ * Every Better Auth instance this project runs, one key per kayan it authenticates. `mithaq
1098
+ * generate auth` reads `options` to write that kayan's directory, and the generated kayan reads
1099
+ * `server` back, so one record is both halves of one instance.
1100
+ *
1101
+ * A second population is a second key here: a second kayan, cookie name and `/api/auth/<kayan>/*`
1102
+ * prefix. Its tables carry their own `modelName`, because one database holds them all.
1103
+ */
1104
+ export const providers = {
1105
+ user: {
1106
+ options,
1107
+ /**
1108
+ * Built from the targets the language carries and the vocabulary the build derived. Neither
1109
+ * exists where a kayan is written, so this is a factory the kayan names.
1110
+ */
1111
+ server: (targets: ServerTargets, vocabulary: Pick<Vocabulary, 'referenced'>) => serverFor(authSettings)(targets, vocabulary),
1112
+ },
1113
+ }
1114
+
1115
+ type ServerTargets = {
1116
+ drizzle: {
1117
+ db: Parameters<typeof drizzleAdapter>[0]
1118
+ tables: Record<string, unknown>
1119
+ insert(args: { tx: unknown; kayan: string; data: Record<string, unknown> }): Promise<unknown>
1120
+ remove(args: { tx: unknown; kayan: string; id: unknown }): Promise<unknown>
1121
+ find(args: { tx?: unknown; kayan: string; args: { limit: number; page: number; sort: { field: string; direction: 'asc' | 'desc' }; where?: Where } }): Promise<{ docs: Record<string, unknown>[] }>
1122
+ transaction<Result>(run: (tx: unknown) => Promise<Result>): Promise<Result>
1123
+ count?(args: { kayan: string; args: { where?: Where } }): Promise<number>
1124
+ }
1125
+ }
1126
+
1127
+ /**
1128
+ * The provider for `settings`, which is this project's own in `providers.user.server`. A test builds
1129
+ * one for another organization mode over the same tables.
1130
+ */
1131
+ export const serverFor = (settings: AuthSettings) => (targets: ServerTargets, { referenced }: Pick<Vocabulary, 'referenced'>) => {
1132
+ const store = targets.drizzle
1133
+ // add-on: roles
1134
+ // The drizzle store always counts; `Store` types it optional for stores that cannot.
1135
+ const heldRoles = (organization: string, user: string) => store.count!({ kayan: 'memberRoles', args: { where: { organization: { equals: organization }, user: { equals: user } } } })
1136
+ const holdsRoles = () => new APIError('CONFLICT', { code: 'error.conflict.member_holds_roles', message: 'This member still holds a role in this organization. Take each role from them first.' })
1137
+ // The roles a person holds in an organization they are not a member of, taken as they come
1138
+ // back in by an accepted invitation or an added membership: nobody gave them anew, and the
1139
+ // assignment guards never ran for whoever invited them back. Taken rather than refused,
1140
+ // because no screen reaches the roles of a non-member and a refusal would shut the person out
1141
+ // for good.
1142
+ const clearsLeftRoles = async ({ organization, user }: { organization: string; user: string }) => {
1143
+ // A current member left no organization: the provider runs this hook before it notices that
1144
+ // an old invitation or an added membership names a member already, and their roles are
1145
+ // theirs.
1146
+ if (await store.count!({ kayan: 'member', args: { where: { organizationId: { equals: organization }, userId: { equals: user } } } })) return
1147
+ if (!(await heldRoles(organization, user))) return
1148
+ await store.transaction(async (tx) => {
1149
+ const left = await store.find({ tx, kayan: 'memberRoles', args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' }, where: { organization: { equals: organization }, user: { equals: user } } } })
1150
+ for (const row of left.docs) await store.remove({ tx, kayan: 'memberRoles', id: row.id })
1151
+ })
1152
+ }
1153
+ // end add-on: roles
1154
+ // add-on: roles
1155
+ // A role given by the auth setup itself, which nothing inside the project can give: straight
1156
+ // through the store rather than through `asas.kayan.memberRoles.operations.create`, which is
1157
+ // graded against the actor making it.
1158
+ const grantRole = async ({ tx, organization, user, role }: { tx: unknown; organization: string; user: string; role: string }) => {
1159
+ const written = new Date()
1160
+ await store.insert({ tx, kayan: 'memberRoles', data: { createdAt: written, role, organization, updatedAt: written, user } })
1161
+ authAudit({ action: 'role', user, organization, role })
1162
+ }
1163
+ // end add-on: roles
1164
+ // The hooks that write this project's rows, in their own transaction, or in `tx` where a route
1165
+ // that holds one makes the organization: a second client would wait on the rows it has not
1166
+ // committed.
1167
+ const storeHooks = (tx?: unknown): StoreHooks => ({
1168
+ // add-on: roles
1169
+ // The tenant's first assignment: a role holding `memberRoles.create` has to be assigned before
1170
+ // it can assign anything, and the caller of `createOrganization` is in no organization until
1171
+ // that route puts it in one. The row is `administrator`, which holds `'*'`, so the
1172
+ // organization has a root granter from its first moment and every later assignment is the
1173
+ // gated one.
1174
+ afterCreateOrganization: async ({ organization, user: creator }) => {
1175
+ const granted = { organization: organization.id, user: creator.id, role: 'administrator' }
1176
+ await (tx === undefined ? store.transaction((own) => grantRole({ tx: own, ...granted })) : grantRole({ tx, ...granted }))
1177
+ },
1178
+ // A member who still holds a role of this project in the organization is not removed. The
1179
+ // provider deletes the member row alone and leaves this project's rows behind, which are
1180
+ // read again the moment the person is a member again. Their roles are taken first, each
1181
+ // through the assignment kayan's own delete, whose guards decide whether this caller may
1182
+ // take that role. The Members page does that before removing, so a person sees this refusal
1183
+ // only where a role was given in between or the route was called by hand.
1184
+ //
1185
+ // Counted outside any transaction, so a role assigned between the count and the delete is
1186
+ // left behind; it is dormant while the person is no member and taken as they come back in
1187
+ // (`clearsLeftRoles`).
1188
+ beforeRemoveMember: async ({ member, organization }) => {
1189
+ if (await heldRoles(organization.id, member.userId)) throw holdsRoles()
1190
+ },
1191
+ beforeAcceptInvitation: ({ user, organization }) => clearsLeftRoles({ organization: organization.id, user: user.id }),
1192
+ beforeAddMember: ({ member, organization }) => clearsLeftRoles({ organization: organization.id, user: member.userId }),
1193
+ // end add-on: roles
1194
+ // add-on: audit-log
1195
+ // The staff history, straight through the store: the kayan declares no write a caller could run.
1196
+ recordBackoffice: (entry) => {
1197
+ const row = { kayan: 'backofficeLog', data: { createdAt: new Date(), ...entry } }
1198
+ return tx === undefined ? store.transaction((own) => store.insert({ tx: own, ...row })) : store.insert({ tx, ...row })
1199
+ },
1200
+ // end add-on: audit-log
1201
+ })
1202
+ const built = authOptions(storeHooks(), settings).options
1203
+ // `transaction: true` makes the provider's own transaction helper open a real transaction
1204
+ // wherever it asks for one: a sign-up writes its user, account and session in one, and an
1205
+ // accepted invitation its member and the session's organization. Without it each statement
1206
+ // runs on its own.
1207
+ const database = drizzleAdapter(store.db, { provider: 'pg', schema: store.tables, transaction: true })
1208
+ // Two instances over one database and one user table, one per app, so a person is one row and
1209
+ // one password in both. The tenant app's refuses every route of the admin plugin, and its
1210
+ // stop route, which would put a staff member's session back under the tenant app's cookie.
1211
+ // The plugin stays in it because it refuses a banned user a session and carries
1212
+ // `impersonatedBy` onto the session it reads.
1213
+ const tenantOnly = Object.values(admin(adminOptions).endpoints).map((endpoint) => endpoint.path)
1214
+ // add-on: roles
1215
+ // Leaving is refused while a role is held, as a removal is above. The provider's leave route
1216
+ // runs no hook, so it is matched by path, on both instances. A member who holds a
1217
+ // role-managing role takes it from nobody else, so leaves only once another member has taken
1218
+ // it from them. An owner who holds an `administrator` row never leaves and is never removed,
1219
+ // because that row cannot be taken (`error.forbidden.owner_role`); that is stricter than the
1220
+ // provider, which lets an owner go while another remains, and intended.
1221
+ const keepsRoles = {
1222
+ id: 'keeps-roles',
1223
+ hooks: { before: [{
1224
+ matcher: (ctx: { path?: string }) => ctx.path === '/organization/leave',
1225
+ handler: createAuthMiddleware(async (ctx) => {
1226
+ const organization = (ctx.body as { organizationId?: unknown } | undefined)?.organizationId
1227
+ const caller = await getSessionFromCtx(ctx).catch(() => null)
1228
+ if (typeof organization !== 'string' || !caller) return
1229
+ if (await heldRoles(organization, caller.user.id)) throw holdsRoles()
1230
+ }),
1231
+ }] },
1232
+ }
1233
+ // end add-on: roles
1234
+ // Whether anyone holds a backoffice role, read as `holdsRole` reads `user.role`: comma-separated,
1235
+ // each name trimmed of the whitespace `.trim()` takes. Every tenant user's `user` is passed
1236
+ // over before the split, and the scan stops at the first holder.
1237
+ type Executes = { execute(query: unknown): Promise<{ rows: unknown[] }> }
1238
+ const users = store.tables.user as { role: unknown }
1239
+ const administered = async (db: Executes) =>
1240
+ (await db.execute(sql`select 1 from ${users} where ${users.role} <> 'user' and exists (select 1 from unnest(string_to_array(${users.role}, ',')) as one where btrim(one, E' \\t\\n\\r\\f\\x0b') in ${Object.keys(backofficeRoles)}) limit 1`)).rows.length > 0
1241
+ const organizationTable = store.tables.organization as { id: unknown; createdAt: unknown; name: unknown; names: unknown }
1242
+ const organized = async (db: Executes) => (await db.execute(sql`select 1 from ${organizationTable} limit 1`)).rows.length > 0
1243
+ const oneOrganization = settings.organizations !== 'many'
1244
+ // With one organization its name lives in code, and the row takes it on the first request this process serves,
1245
+ // after the tables exist: a deploy that changed `organizationName` renames it, and nothing else does.
1246
+ let synced: Promise<void> | undefined
1247
+ const syncName = async () => {
1248
+ const { name, names } = namesOf(settings.organizationName)
1249
+ // The oldest row alone, the one `joinOpen` joins: a project that held many keeps every other's own name.
1250
+ const one = sql`(select ${organizationTable.id} from ${organizationTable} order by ${organizationTable.createdAt}, ${organizationTable.id} limit 1)`
1251
+ await (store.db as Executes).execute(sql`update ${organizationTable} set ${sql.identifier('name')} = ${name}, ${sql.identifier('names')} = ${names} where ${organizationTable.id} = ${one} and (${organizationTable.name} is distinct from ${name} or ${organizationTable.names} is distinct from ${names})`)
1252
+ }
1253
+ const namedInCode = {
1254
+ id: 'named-in-code',
1255
+ hooks: { before: [{
1256
+ matcher: () => oneOrganization,
1257
+ handler: createAuthMiddleware(async () => {
1258
+ await (synced ??= syncName().catch((error: unknown) => {
1259
+ // Asked again on the next request, so a database that was not ready yet is not left unnamed.
1260
+ synced = undefined
1261
+ console.error('The organization\'s name in code was not written onto its row:', error)
1262
+ }))
1263
+ }),
1264
+ }] },
1265
+ }
1266
+ // After the answer, and never failing it: the account is made and committed whatever the mailer says.
1267
+ const toldAdmin = async ({ ctx, email, at }: { ctx: HookContext; email: string; at: string }) => {
1268
+ if (!settings.mailer) return
1269
+ await ctx.context.runInBackgroundOrAwait(adminMadeMail({ mailer: settings.mailer, email, at }).catch((error: unknown) => console.error('The first administrator\'s mail was not sent:', error)))
1270
+ }
1271
+ // The tenant's first-run, in one organization: the first owner and the one organization,
1272
+ // which nothing else makes, under the lock first-register takes and the setup code. It makes
1273
+ // the owner backoffice staff too while the backoffice app is served and has none, so one person
1274
+ // sets up both. The account is verified: the setup code is the proof.
1275
+ const firstRunClosed = () => new APIError('FORBIDDEN', { code: 'FIRST_RUN_CLOSED', message: 'This app has its organization, so first-run is closed. Sign in instead.' })
1276
+ const firstRun = {
1277
+ id: 'first-run',
1278
+ endpoints: {
1279
+ firstRunState: createAuthEndpoint('/first-run/state', { method: 'GET' }, async (ctx) =>
1280
+ ctx.json((await organized(store.db as Executes)) ? { open: false } : { open: true, code: setupCodeState(tenantURL) })),
1281
+ // `formCsrfMiddleware`, as sign-up has: no session guards this route.
1282
+ firstRun: createAuthEndpoint('/first-run', { method: 'POST', use: [formCsrfMiddleware] }, async (ctx) => {
1283
+ if (!onLocalHost && !ipAddressHeaders && overFirstRunLimit()) throw new APIError('TOO_MANY_REQUESTS', { message: 'Too many tries. Wait a minute and try again.' })
1284
+ if (await organized(store.db as Executes)) throw firstRunClosed()
1285
+ const { name, email, password, code } = (ctx.body ?? {}) as Record<string, unknown>
1286
+ const refused = refusedSetup({ url: tenantURL, code, app: 'tenant app' })
1287
+ if (refused) throw refused
1288
+ const { minPasswordLength: min, maxPasswordLength: max } = ctx.context.password.config
1289
+ if (typeof name !== 'string' || !name.trim() || typeof email !== 'string' || typeof password !== 'string' || password.length < min || password.length > max) {
1290
+ throw new APIError('BAD_REQUEST', { message: `A name, an email and a password of ${min} to ${max} characters are required.` })
1291
+ }
1292
+ // The organization's name lives in code, so nobody who reaches a new deploy first names it.
1293
+ const inCode = namesOf(settings.organizationName)
1294
+ const made = await store.transaction(async (tx) => {
1295
+ await (tx as Executes).execute(sql`set transaction isolation level read committed`)
1296
+ await (tx as Executes).execute(setupLock)
1297
+ if (await organized(tx as Executes)) throw firstRunClosed()
1298
+ const staff = backofficeApp && !(await administered(tx as Executes))
1299
+ // ponytail: an instance per call over this transaction, as first-register's; it runs
1300
+ // once in a project's life.
1301
+ const bound = betterAuthServer({ ...authOptions(storeHooks(tx), settings).options, database: drizzleAdapter(tx as never, { provider: 'pg', schema: store.tables }) })
1302
+ const adapter = (await bound.$context).adapter
1303
+ return runWithAdapter(adapter, () => firstRegistering.run(true, async () => {
1304
+ const { user } = await bound.api.createUser({ body: { name, email, password, role: (staff ? 'super_admin' : 'user') as never, data: { emailVerified: true } } })
1305
+ const slug = !settings.showOrganization ? 'main' : `${inCode.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'organization'}-${randomBytes(3).toString('hex')}`
1306
+ const made = (await bound.api.createOrganization({ body: { name: inCode.name, names: inCode.names, slug, userId: user.id } as never }))!
1307
+ authAudit({ action: 'first-run', user: user.id, organization: made.id, backoffice: staff })
1308
+ return { user: { id: user.id }, organization: { id: made.id } }
1309
+ }))
1310
+ })
1311
+ await toldAdmin({ ctx, email, at: tenantURL })
1312
+ return ctx.json(made)
1313
+ }),
1314
+ },
1315
+ rateLimit: [{ pathMatcher: (path: string) => path === '/first-run', window: 60, max: 3 }],
1316
+ }
1317
+ // Whether `id` names a pending invitation, unexpired, sent to `email` as the provider compares
1318
+ // addresses, lowercased. The id is ~190 random bits, so holding it is the proof.
1319
+ type Finds = { findOne<Row>(query: { model: string; where: { field: string; value: string }[] }): Promise<Row | null> }
1320
+ const holdsInvitation = async ({ adapter, id, email }: { adapter: Finds; id: unknown; email: unknown }) => {
1321
+ if (typeof id !== 'string' || typeof email !== 'string') return false
1322
+ const invitation = await adapter.findOne<{ email: string; status: string; expiresAt: Date | string }>({ model: 'invitation', where: [{ field: 'id', value: id }] })
1323
+ return !!invitation && invitation.status === 'pending' && new Date(invitation.expiresAt) > new Date() && invitation.email.toLowerCase() === email.toLowerCase()
1324
+ }
1325
+ // One answer for every refused sign-up by invitation, so it tells nobody whether an address has
1326
+ // an account or an invitation.
1327
+ const inviteOnly = () => new APIError('FORBIDDEN', { code: 'SIGN_UP_INVITE_ONLY', message: 'Sign-up here is by invitation: open the link of the invitation sent to you.' })
1328
+ const outsideDomains = () => new APIError('FORBIDDEN', { code: 'SIGN_UP_DOMAIN', message: 'This address is not at a domain that may sign up here.' })
1329
+ // Who may make an account in one organization, asked before the provider's route, which
1330
+ // would answer an address that has an account differently from one that has none.
1331
+ const signUpGate = {
1332
+ id: 'sign-up-gate',
1333
+ hooks: { before: [{
1334
+ matcher: (ctx: { path?: string }) => ctx.path === '/sign-up/email',
1335
+ handler: createAuthMiddleware(async (ctx) => {
1336
+ if (!(await organized(store.db as Executes))) throw new APIError('FORBIDDEN', { code: 'FIRST_RUN_PENDING', message: 'This app is not set up yet: its administrator opens /auth/first-run first.' })
1337
+ const { email, invitationId } = (ctx.body ?? {}) as { email?: unknown; invitationId?: unknown }
1338
+ if (settings.signUp === 'invite' && !(await holdsInvitation({ adapter: ctx.context.adapter as unknown as Finds, id: invitationId, email }))) throw inviteOnly()
1339
+ if (settings.signUp === 'open' && settings.allowedDomains.length && !(typeof email === 'string' && domainAllowed(email, settings.allowedDomains))) throw outsideDomains()
1340
+ }),
1341
+ }] },
1342
+ }
1343
+ // The same rule wherever the tenant app makes an account, so a sign-in method added later
1344
+ // (social, a magic link, a one-time code) never opens a way past it. First-run holds
1345
+ // `firstRegistering`. The backoffice console's `/admin/create-user`, which only staff reach, makes
1346
+ // accounts on its own instance and is not asked: staff add whom they choose.
1347
+ const accountGate = async (user: { email: string }, context: { body?: unknown; context: { adapter: unknown } } | null) => {
1348
+ if (!oneOrganization || firstRegistering.getStore()) return
1349
+ if (settings.signUp === 'invite') {
1350
+ const invitationId = (context?.body as { invitationId?: unknown } | undefined)?.invitationId
1351
+ if (!context || !(await holdsInvitation({ adapter: (await getCurrentAdapter(context.context.adapter as never)) as unknown as Finds, id: invitationId, email: user.email }))) throw inviteOnly()
1352
+ } else if (settings.allowedDomains.length && !domainAllowed(user.email, settings.allowedDomains)) throw outsideDomains()
1353
+ }
1354
+ // Whether a new account proves its address before it signs in and joins: with many organizations
1355
+ // always, and with one where the sign-up is open and says so.
1356
+ const verifying = settings.signUp === 'open' && settings.verifyEmail
1357
+ // Who joins the one organization by signing up: in one organization with an open sign-up,
1358
+ // when the account is made or, where addresses are verified, when its address is proved. The
1359
+ // settings are read now, not at sign-up, so a project that closed its sign-up since joins nobody.
1360
+ // Backoffice staff join no tenant, and the domains are asked again. Someone who is a member, or holds
1361
+ // an invitation still to answer, is passed over: the invitation is how they join; and so is someone
1362
+ // who accepted one, who was a member, perhaps removed since. An account joins once in its life,
1363
+ // recorded beside it, so a removed member whose address is made unverified and proved again stays
1364
+ // out.
1365
+ const joinedOnce = (user: string) => `joined-open:${user}`
1366
+ const joinOpen = async (user: { id: string; email: string; role?: unknown }) => {
1367
+ // An account first-run or add-person makes is placed by them.
1368
+ if (!oneOrganization || settings.signUp !== 'open' || firstRegistering.getStore()) return
1369
+ if (holdsRole(user.role, (one) => Object.hasOwn(backofficeRoles, one))) return
1370
+ if (settings.allowedDomains.length && !domainAllowed(user.email, settings.allowedDomains)) return
1371
+ const [organization] = (await store.find({ kayan: 'organization', args: { limit: 1, page: 1, sort: { field: 'createdAt', direction: 'asc' } } })).docs
1372
+ if (!organization) return
1373
+ const { internalAdapter } = await tenant.$context
1374
+ if (await internalAdapter.findVerificationValue(joinedOnce(user.id))) return
1375
+ if (await store.count!({ kayan: 'member', args: { where: { userId: { equals: user.id } } } })) return
1376
+ // An invitation still to answer, or one accepted, is how this address joins; one cancelled,
1377
+ // declined or expired is none.
1378
+ const answering: Where = { or: [{ status: { equals: 'accepted' } }, { and: [{ status: { equals: 'pending' } }, { expiresAt: { greater_than: new Date() } }] }] }
1379
+ if (await store.count!({ kayan: 'invitation', args: { where: { and: [{ email: { equals: user.email.toLowerCase() } }, answering] } } })) return
1380
+ const joined = String(organization.id)
1381
+ // ponytail: the record is a verification row that never expires, the one table the provider
1382
+ // lets a project write a fact about an account to with no column of its own; a column on the
1383
+ // user, written by `generate auth`, if more such facts come. Never purge `joined-open:` rows:
1384
+ // without one, a removed member who resets their password joins again.
1385
+ await internalAdapter.createVerificationValue({ identifier: joinedOnce(user.id), value: joined, expiresAt: new Date('9999-12-31T00:00:00Z') })
1386
+ await tenant.api.addMember({ body: { userId: user.id, organizationId: joined, role: 'member' } })
1387
+ // add-on: roles
1388
+ if (settings.joinRole !== null) await store.transaction((tx) => grantRole({ tx, organization: joined, user: user.id, role: settings.joinRole! }))
1389
+ // end add-on: roles
1390
+ // A session made before the join names no organization, and every read would be refused in it.
1391
+ for (const one of await internalAdapter.listSessions(user.id)) {
1392
+ // The organization plugin's own column, which the core session type does not name.
1393
+ if (!(one as { activeOrganizationId?: string | null }).activeOrganizationId) await internalAdapter.updateSession(one.token, { activeOrganizationId: joined })
1394
+ }
1395
+ authAudit({ action: 'join', user: user.id, organization: joined, role: settings.joinRole })
1396
+ }
1397
+ // The invitation a sign-up carried is accepted for the account it made: at once where holding the id
1398
+ // is the proof, or, where the address is proved, by the one link mailed for that sign-up alone. The
1399
+ // binding is `invited:<that link's token>` and names the account, so anyone who signs up with an
1400
+ // address they invited, before its owner, never joins the owner to their organization: the owner's
1401
+ // own sign-up or reset takes every binding off, and their own link carries none. It is read again
1402
+ // when accepted, so one cancelled, expired or sent to another address joins nobody.
1403
+ const bound = (token: string) => `invited:${token}`
1404
+ const acceptCarried = async ({ user, invitationId }: { user: { id: string; email: string }; invitationId: unknown }) => {
1405
+ const { adapter, internalAdapter } = await tenant.$context
1406
+ if (!(await holdsInvitation({ adapter: adapter as unknown as Finds, id: invitationId, email: user.email }))) return
1407
+ const id = invitationId as string
1408
+ // The accept route's own rule on the role, which a row written past the invite route may break.
1409
+ const asked = await adapter.findOne<{ role: unknown }>({ model: 'invitation', where: [{ field: 'id', value: id }] })
1410
+ try {
1411
+ exactRoles(asked?.role)
1412
+ } catch {
1413
+ return
1414
+ }
1415
+ // Pending to accepted once, so a second proof never adds the person twice.
1416
+ const accepted = await adapter.update<{ organizationId: string; role: string }>({ model: 'invitation', where: [{ field: 'id', value: id }, { field: 'status', value: 'pending' }], update: { status: 'accepted' } })
1417
+ if (!accepted) return
1418
+ // Put back as the provider's own accept puts it back, so a refused membership leaves the
1419
+ // invitation to answer.
1420
+ await tenant.api.addMember({ body: { userId: user.id, organizationId: accepted.organizationId, role: accepted.role as 'member' } }).catch(async (error: unknown) => {
1421
+ await adapter.update({ model: 'invitation', where: [{ field: 'id', value: id }], update: { status: 'pending' } })
1422
+ throw error
1423
+ })
1424
+ // A session made before the acceptance names no organization, and every read would be refused in it.
1425
+ for (const one of await internalAdapter.listSessions(user.id)) {
1426
+ if (!(one as { activeOrganizationId?: string | null }).activeOrganizationId) await internalAdapter.updateSession(one.token, { activeOrganizationId: accepted.organizationId })
1427
+ }
1428
+ authAudit({ action: 'accept', user: user.id, organization: accepted.organizationId, invitation: id })
1429
+ }
1430
+ const bindings = (user: string) => [{ field: 'identifier', operator: 'starts_with' as const, value: 'invited:' }, { field: 'value', operator: 'starts_with' as const, value: `${user} ` }]
1431
+ const unbind = async (user: string) => (await tenant.$context).adapter.deleteMany({ model: 'verification', where: bindings(user) })
1432
+ // `token` is the link the address was proved with: only its own binding is accepted.
1433
+ const acceptBound = async ({ user, token }: { user: { id: string; email: string }; token: unknown }) => {
1434
+ const { internalAdapter } = await tenant.$context
1435
+ const held = typeof token === 'string' ? await internalAdapter.findVerificationValue(bound(token)) : null
1436
+ await unbind(user.id)
1437
+ const [owner, invitationId] = held?.value.split(' ') ?? []
1438
+ if (owner === user.id) await acceptCarried({ user, invitationId })
1439
+ }
1440
+ // Whether the account was mailed a link to choose its password in the last five minutes, other
1441
+ // than `except`: that one still works, so nobody fills a mailbox by asking again and again.
1442
+ const resetLinks = [{ field: 'identifier', operator: 'starts_with' as const, value: 'reset-password:' }]
1443
+ const mailedLately = async (user: string, except?: string) => {
1444
+ const rows = await (await tenant.$context).adapter.findMany<{ identifier: string; createdAt: Date | string }>({ model: 'verification', where: [{ field: 'value', value: user }, ...resetLinks] })
1445
+ return rows.some((row) => row.identifier !== `reset-password:${except}` && Date.now() - new Date(row.createdAt).getTime() < 5 * 60 * 1000)
1446
+ }
1447
+ // The set-password link that proves an address, stored as the provider stores a reset's, so the
1448
+ // reset page and route answer it. It lasts an hour, as a reset link does.
1449
+ const confirmLink = async (user: { id: string; email: string }) => {
1450
+ if (!settings.mailer || (await mailedLately(user.id))) return
1451
+ const { adapter, internalAdapter } = await tenant.$context
1452
+ const token = randomBytes(24).toString('base64url')
1453
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000)
1454
+ await internalAdapter.createVerificationValue({ identifier: `reset-password:${token}`, value: user.id, expiresAt })
1455
+ // The invitation this sign-up carried rides on this link alone.
1456
+ const asked = tryGetCurrentAuthEndpointContext()
1457
+ const invitationId = asked?.path === '/sign-up/email' ? (asked.body as { invitationId?: unknown } | undefined)?.invitationId : undefined
1458
+ let joining: { name: string; names?: unknown } | undefined
1459
+ if (await holdsInvitation({ adapter: adapter as unknown as Finds, id: invitationId, email: user.email })) {
1460
+ await internalAdapter.createVerificationValue({ identifier: bound(token), value: `${user.id} ${invitationId as string}`, expiresAt })
1461
+ const invitation = await adapter.findOne<{ organizationId: string }>({ model: 'invitation', where: [{ field: 'id', value: invitationId as string }] })
1462
+ joining = (await adapter.findOne<{ name: string; names?: unknown }>({ model: 'organization', where: [{ field: 'id', value: invitation!.organizationId }] })) ?? undefined
1463
+ }
1464
+ await confirmMail(settings.mailer)({ email: user.email, token, ...(joining !== undefined && { joining }) })
1465
+ }
1466
+ // Every invitation an organization mails, new or sent again, and every person it adds, is one
1467
+ // `mailed:<organization>` row, written once the request succeeds and counted against
1468
+ // `invitationLimits` before the next goes, so every process of the deploy keeps one count and a
1469
+ // refused request spends nothing.
1470
+ // ponytail: two at the same instant may both pass the last place; a lock per organization if that
1471
+ // is ever seen.
1472
+ const withinLimits = async (organization: string) => {
1473
+ const since = (ago: number) => store.count!({ kayan: 'verification', args: { where: { value: { equals: `mailed:${organization}` }, createdAt: { greater_than: new Date(Date.now() - ago) } } } })
1474
+ if ((await since(60_000)) >= invitationLimits.perMinute) throw new APIError('TOO_MANY_REQUESTS', { code: 'INVITATIONS_PER_MINUTE', message: `This organization has sent ${invitationLimits.perMinute} invitations in the last minute. Wait a minute and try again.` })
1475
+ if ((await since(86_400_000)) >= invitationLimits.perDay) throw new APIError('TOO_MANY_REQUESTS', { code: 'INVITATIONS_PER_DAY', message: `This organization has sent ${invitationLimits.perDay} invitations in the last day. Try again tomorrow.` })
1476
+ }
1477
+ const mailedFor = async (organization: string) =>
1478
+ (await tenant.$context).internalAdapter.createVerificationValue({ identifier: `mailed:${randomBytes(16).toString('base64url')}`, value: `mailed:${organization}`, expiresAt: new Date(Date.now() + 86_400_000) })
1479
+ // The organization an invitation is made in, where the caller may invite there: the active one, as
1480
+ // every key of theirs is.
1481
+ const invitingIn = async (ctx: HookContext) => {
1482
+ const caller = await getSessionFromCtx(ctx).catch(() => null)
1483
+ const named = (ctx.body as { organizationId?: unknown } | undefined)?.organizationId ?? (caller?.session as { activeOrganizationId?: unknown } | undefined)?.activeOrganizationId
1484
+ const active = requestOrganization()
1485
+ return caller && typeof named === 'string' && (active === undefined || active === named) && holdsPermission('invitation.create') ? named : undefined
1486
+ }
1487
+ // The provider's invite route sends an invitation again on `resend` with no hook of its own, which
1488
+ // its governance `admin` passes holding no key of this project's, so the route itself asks the
1489
+ // key and the limits.
1490
+ const invitationLimit = {
1491
+ id: 'invitation-limit',
1492
+ hooks: {
1493
+ before: [{
1494
+ matcher: (ctx: { path?: string }) => ctx.path === '/organization/invite-member',
1495
+ handler: createAuthMiddleware(async (ctx) => {
1496
+ const organization = await invitingIn(ctx)
1497
+ if (organization) return withinLimits(organization)
1498
+ if ((ctx.body as { resend?: unknown } | undefined)?.resend) throw new APIError('FORBIDDEN', { message: 'Sending an invitation again needs the permission "invitation.create" in your active organization.' })
1499
+ }),
1500
+ }],
1501
+ after: [{
1502
+ matcher: (ctx: { path?: string }) => ctx.path === '/organization/invite-member',
1503
+ handler: createAuthMiddleware(async (ctx) => {
1504
+ if (ctx.context.returned instanceof Error) return
1505
+ const organization = await invitingIn(ctx)
1506
+ if (organization) await mailedFor(organization)
1507
+ }),
1508
+ }],
1509
+ },
1510
+ }
1511
+ // The only owner of an organization is not deleted, by staff or by themselves: the provider would
1512
+ // take their member row with them and leave an organization nobody governs. Asked before the route,
1513
+ // which deletes sessions and accounts before the person, so nothing goes. Another owner first.
1514
+ const soleOwner = async (user: string) => {
1515
+ const owned = (await store.find({ kayan: 'member', args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' }, where: { userId: { equals: user }, role: { contains: 'owner' } } } })).docs.filter((one) => isOwnerRole(one.role))
1516
+ // ponytail: read with no lock, so two owners deleted at the same instant may both pass and leave
1517
+ // none; a lock per organization if staff ever do that.
1518
+ for (const one of owned) {
1519
+ const owners = (await store.find({ kayan: 'member', args: { limit: 0, page: 1, sort: { field: 'id', direction: 'asc' }, where: { organizationId: { equals: String(one.organizationId) }, role: { contains: 'owner' } } } })).docs.filter((other) => isOwnerRole(other.role))
1520
+ if (owners.length < 2) return true
1521
+ }
1522
+ return false
1523
+ }
1524
+ // A person a row still names through a relationship that restricts their delete, a withdrawn seat
1525
+ // among them, is not deleted either. The provider's delete runs in no transaction, so the key alone
1526
+ // would fail after the person's sessions and accounts were gone, leaving someone who can never sign
1527
+ // in again. The count reads every organization's rows, marked ones included.
1528
+ const keepsPerson = {
1529
+ id: 'keeps-person',
1530
+ hooks: { before: [{
1531
+ matcher: (ctx: { path?: string }) => ['/admin/remove-user', '/delete-user', '/delete-user/callback'].includes(ctx.path ?? ''),
1532
+ handler: createAuthMiddleware(async (ctx) => {
1533
+ const caller = await getSessionFromCtx(ctx).catch(() => null)
1534
+ if (!caller) return
1535
+ // The id read the way the route reads it, after its own `z.coerce.string()`, so an id sent
1536
+ // as a list or a number is the same person here as there.
1537
+ const raw = ctx.path === '/admin/remove-user' ? (ctx.body as { userId?: unknown } | undefined)?.userId : caller.user.id
1538
+ if (raw === undefined || raw === null) return
1539
+ if (await soleOwner(String(raw))) {
1540
+ throw new APIError('CONFLICT', { code: 'error.conflict.sole_owner', message: 'This person is the only owner of an organization, so they are not deleted. Make another member its owner first.' })
1541
+ }
1542
+ if (await referenced(String(raw))) {
1543
+ throw new APIError('CONFLICT', { code: 'error.conflict.referenced', message: 'Rows still name this person, a seat among them, so they are not deleted while those rows stand.' })
1544
+ }
1545
+ }),
1546
+ }] },
1547
+ }
1548
+ // add-on: roles
1549
+ // Whether the caller may hand over `role` in `organization`, measured as `assignableRole` measures
1550
+ // an assignment: a root granter any role, anyone else one whose every key they hold.
1551
+ const assignable = async ({ organization, role }: { organization: string; role: string }) => {
1552
+ const custom = async () => (await store.find({ kayan: 'roles', args: { limit: 1, page: 1, sort: { field: 'id', direction: 'asc' }, where: { organization: { equals: organization }, name: { equals: role } } } })).docs[0]?.permissions as readonly string[] | undefined
1553
+ const keys = Object.hasOwn(tenantRoles, role) ? (tenantRoles[role as keyof typeof tenantRoles] as { permissions: '*' | readonly string[] }).permissions : await custom()
1554
+ if (keys === undefined) throw new APIError('BAD_REQUEST', { code: 'error.invalid.role', message: `Role "${role}" is none of this organization's.` })
1555
+ if (holdsPermission('memberRoles.create') && (holdsPermission('*') || (keys !== '*' && keys.every((key) => key !== '*' && holdsPermission(key))))) return
1556
+ throw new APIError('FORBIDDEN', { code: 'error.forbidden.assign', message: `You may not give role "${role}": it holds keys you do not hold.` })
1557
+ }
1558
+ // end add-on: roles
1559
+ // without add-on: roles
1560
+ // const assignable = async (_: { organization: string; role: string }) => {
1561
+ // throw new APIError('BAD_REQUEST', { message: 'A role is given on adding a person with the roles add-on alone.' })
1562
+ // }
1563
+ // end without add-on: roles
1564
+ // Adds a person to the one organization in one request: their account, their membership, a role
1565
+ // the caller may hand over, and a link to choose their password, mailed, or with no mailer answered
1566
+ // for the caller to hand over. Never a password anyone knows. An account nobody proved, which may
1567
+ // be a squat, is handed to the mailbox: its password and sessions end before it is added. A proved
1568
+ // account is never added without its word: it is invited instead. The organization is the caller's
1569
+ // verified active one; a body naming another is not read.
1570
+ // The tenant instance's own routes, typed here so the route below, which the instance holds, does
1571
+ // not make the instance's type depend on itself.
1572
+ type Invites = { createInvitation(args: { body: Record<string, unknown>; headers: Headers }): Promise<unknown>; addMember(args: { body: { userId: string; organizationId: string; role: 'member' } }): Promise<unknown> }
1573
+ const tenantApi = (): Invites => tenant.api as unknown as Invites
1574
+ const addPerson = {
1575
+ id: 'add-person',
1576
+ endpoints: {
1577
+ addPerson: createAuthEndpoint('/organization/add-person', { method: 'POST', use: [sessionMiddleware] }, async (ctx) => {
1578
+ const organization = requestOrganization()
1579
+ // Outside `asas.handler` no key is resolved, so the route answers nothing there.
1580
+ if (typeof organization !== 'string' || !holdsPermission('member.create')) throw new APIError('FORBIDDEN', { message: 'Adding a person needs the permission "member.create" in your active organization.' })
1581
+ const { name, email: written, role } = (ctx.body ?? {}) as Record<string, unknown>
1582
+ if (typeof name !== 'string' || !name.trim() || name.length > 100 || typeof written !== 'string' || !anAddress.safeParse(written).success || (role !== undefined && (typeof role !== 'string' || !role))) {
1583
+ throw new APIError('BAD_REQUEST', { message: 'A name of 1 to 100 characters and an email are required, and a role, where given, is a role\'s name.' })
1584
+ }
1585
+ if (typeof role === 'string') await assignable({ organization, role })
1586
+ const email = written.toLowerCase()
1587
+ const { adapter, internalAdapter } = ctx.context
1588
+ const found = (await internalAdapter.findUserByEmail(email))?.user as ({ id: string; emailVerified: boolean; role?: unknown } | undefined)
1589
+ if (found && (await store.count!({ kayan: 'member', args: { where: { organizationId: { equals: organization }, userId: { equals: found.id } } } }))) {
1590
+ throw new APIError('CONFLICT', { code: 'USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION', message: 'This person is already a member.' })
1591
+ }
1592
+ // Staff hold the backoffice, so a tenant never touches their sign-in.
1593
+ if (found && (found.emailVerified || holdsRole(found.role, (one) => Object.hasOwn(backofficeRoles, one)))) {
1594
+ const invitation = await tenantApi().createInvitation({ body: { email, role: 'member', organizationId: organization }, headers: ctx.headers! })
1595
+ return ctx.json({ invited: true, invitation })
1596
+ }
1597
+ // With no mailer the link is answered to the caller, which only this machine's own app may ask.
1598
+ if (!settings.mailer && !isLocalHost(ctx.headers?.get('origin') ?? '')) throw new APIError('FORBIDDEN', { message: 'Adding a person mails them a link, and no mailer is configured.' })
1599
+ if (settings.mailer) await withinLimits(organization)
1600
+ // ponytail: the steps below are not one transaction. A failure part-way leaves an account with
1601
+ // no password nobody can sign in to, which the next Add of that address takes up as any
1602
+ // account nobody proved; one transaction over the provider's writes if that is ever seen.
1603
+ const user = found ?? (await firstRegistering.run(true, () => internalAdapter.createUser({ name: name.trim(), email, emailVerified: false }, { method: 'admin' })))
1604
+ if (found) {
1605
+ await internalAdapter.deleteUserSessions(found.id)
1606
+ await adapter.deleteMany({ model: 'account', where: [{ field: 'userId', value: found.id }, { field: 'providerId', value: 'credential' }] })
1607
+ }
1608
+ await tenantApi().addMember({ body: { userId: user.id, organizationId: organization, role: 'member' } })
1609
+ // Joined once, as an open sign-up records it, so removed they never join again by one.
1610
+ if (!(await internalAdapter.findVerificationValue(joinedOnce(user.id)))) await internalAdapter.createVerificationValue({ identifier: joinedOnce(user.id), value: organization, expiresAt: new Date('9999-12-31T00:00:00Z') })
1611
+ // add-on: roles
1612
+ if (typeof role === 'string') await store.transaction((tx) => grantRole({ tx, organization, user: user.id, role }))
1613
+ // end add-on: roles
1614
+ const token = randomBytes(24).toString('base64url')
1615
+ await internalAdapter.createVerificationValue({ identifier: `reset-password:${token}`, value: user.id, expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000) })
1616
+ authAudit({ action: 'add-person', actor: ctx.context.session?.user.id ?? null, user: user.id, organization, role: role ?? null })
1617
+ if (!settings.mailer) return ctx.json({ added: true, user: { id: user.id }, link: setPasswordLink(token) })
1618
+ const held = (await adapter.findOne<{ name: string; names?: unknown }>({ model: 'organization', where: [{ field: 'id', value: organization }] }))!
1619
+ await ctx.context.runInBackgroundOrAwait(addedMail({ mailer: settings.mailer, showOrganization: settings.showOrganization })({ email, organization: held, token }))
1620
+ await mailedFor(organization)
1621
+ return ctx.json({ added: true, user: { id: user.id } })
1622
+ }),
1623
+ },
1624
+ }
1625
+ // A new link to choose a password, for a member of the one organization who lost theirs or never opened Add person's,
1626
+ // asked by whoever may add a person. It is mailed to the member. With no mailer it is answered to the caller, on this
1627
+ // machine alone, and only for an account that holds no password yet: any other's link would hand it to whoever asked,
1628
+ // an administrator's to a delegate holding member.create, whether or not its address is proved, since an invited
1629
+ // account keeps an unproved address beside the password it chose. Anyone outside the organization answers
1630
+ // as nobody, and staff hold the backoffice, whose sign-in a tenant never touches. The member's password and sessions
1631
+ // stand until they choose.
1632
+ const sendPasswordLink = {
1633
+ id: 'send-password-link',
1634
+ endpoints: {
1635
+ sendPasswordLink: createAuthEndpoint('/organization/send-password-link', { method: 'POST', use: [sessionMiddleware] }, async (ctx) => {
1636
+ const organization = requestOrganization()
1637
+ if (typeof organization !== 'string' || !holdsPermission('member.create')) throw new APIError('FORBIDDEN', { message: 'Sending a password link needs the permission "member.create" in your active organization.' })
1638
+ const { userId } = (ctx.body ?? {}) as Record<string, unknown>
1639
+ const member = typeof userId === 'string' && (await store.count!({ kayan: 'member', args: { where: { organizationId: { equals: organization }, userId: { equals: userId } } } }))
1640
+ const user = member ? ((await ctx.context.internalAdapter.findUserById(userId as string)) as ({ id: string; email: string; role?: unknown } | null)) : null
1641
+ if (!user) throw new APIError('NOT_FOUND', { code: 'error.missing.member', message: 'No member of this organization has that id.' })
1642
+ if (holdsRole(user.role, (one) => Object.hasOwn(backofficeRoles, one))) throw new APIError('FORBIDDEN', { message: 'A staff member\'s sign-in is the backoffice\'s, never a tenant\'s.' })
1643
+ if (!settings.mailer && (!isLocalHost(ctx.headers?.get('origin') ?? '') || (await ctx.context.internalAdapter.findAccounts(user.id)).some((one) => one.providerId === 'credential'))) throw new APIError('FORBIDDEN', { message: 'A password link is mailed, and no mailer is configured.' })
1644
+ if (settings.mailer && (await mailedLately(user.id))) return ctx.json({ sent: true })
1645
+ if (settings.mailer) await withinLimits(organization)
1646
+ const token = randomBytes(24).toString('base64url')
1647
+ await ctx.context.internalAdapter.createVerificationValue({ identifier: `reset-password:${token}`, value: user.id, expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000) })
1648
+ authAudit({ action: 'send-password-link', actor: ctx.context.session?.user.id ?? null, user: user.id, organization })
1649
+ if (!settings.mailer) return ctx.json({ sent: true, link: setPasswordLink(token) })
1650
+ const held = (await ctx.context.adapter.findOne<{ name: string; names?: unknown }>({ model: 'organization', where: [{ field: 'id', value: organization }] }))!
1651
+ await ctx.context.runInBackgroundOrAwait(newLinkMail({ mailer: settings.mailer, showOrganization: settings.showOrganization })({ email: user.email, organization: held, token }))
1652
+ await mailedFor(organization)
1653
+ return ctx.json({ sent: true })
1654
+ }),
1655
+ },
1656
+ }
1657
+ // Where a sign-up proves its address, the password is chosen once, from the mailed link, so the sign-up asks none:
1658
+ // whatever one carries is replaced by one nobody knows, and whoever signs up with another's address never holds a
1659
+ // password to it.
1660
+ const chosenLater = {
1661
+ id: 'chosen-later',
1662
+ hooks: { before: [{
1663
+ matcher: (ctx: { path?: string }) => ctx.path === '/sign-up/email',
1664
+ handler: createAuthMiddleware(async (ctx) => ({ context: { body: { ...(ctx.body as object | undefined), password: randomBytes(32).toString('base64url') } } })),
1665
+ }] },
1666
+ }
1667
+ // The address a live set-password link is for, which the page shows as the account it chooses a password of. The
1668
+ // token is the proof, as it is for choosing the password itself; anything else is one refusal. The page reads it in
1669
+ // process alone: over HTTP the token would sit in a query string, which access logs keep.
1670
+ const setPasswordState = {
1671
+ id: 'set-password-state',
1672
+ endpoints: {
1673
+ setPasswordState: createAuthEndpoint('/set-password/state', { method: 'GET', metadata: { SERVER_ONLY: true } }, async (ctx) => {
1674
+ const { token } = (ctx.query ?? {}) as { token?: unknown }
1675
+ const { internalAdapter } = ctx.context
1676
+ const link = typeof token === 'string' && token ? await internalAdapter.findVerificationValue(`reset-password:${token}`) : null
1677
+ const user = link && new Date(link.expiresAt) > new Date() ? await internalAdapter.findUserById(link.value) : null
1678
+ if (!user) throw APIError.from('BAD_REQUEST', BASE_ERROR_CODES.INVALID_TOKEN)
1679
+ return ctx.json({ email: user.email })
1680
+ }),
1681
+ },
1682
+ }
1683
+ // A member is seen in the organization a session of theirs opens or refreshes in, and only there: their activity
1684
+ // in another organization is that organization's business. An impersonation is staff, not the person. The provider
1685
+ // refreshes a session once a day, so this is a day's resolution and one write a day per person. The stamp is
1686
+ // best-effort: the provider rethrows what an after hook throws, so a failed stamp would fail the sign-in or the
1687
+ // session read it rode on, and a refresh racing a sign-out hands it no session at all.
1688
+ const lastSeen = async (session: { userId: string; activeOrganizationId?: string | null; impersonatedBy?: string | null } | null) => {
1689
+ if (!session?.activeOrganizationId || session.impersonatedBy) return
1690
+ try {
1691
+ const adapter = await getCurrentAdapter((await tenant.$context).adapter as never)
1692
+ await adapter.updateMany({ model: 'member', where: [{ field: 'userId', value: session.userId }, { field: 'organizationId', value: session.activeOrganizationId }], update: { lastSeenAt: new Date() } })
1693
+ } catch (error) {
1694
+ console.error('A member\'s last seen was not written:', error)
1695
+ }
1696
+ }
1697
+ const tenant = betterAuthServer({
1698
+ ...built,
1699
+ emailAndPassword: {
1700
+ ...built.emailAndPassword,
1701
+ requireEmailVerification: verifying,
1702
+ // A reset proves the mailbox, and an account proved so for the first time joins as a verified
1703
+ // one would. The user is read before the reset, so `emailVerified` says whether it was proved.
1704
+ onPasswordReset: async ({ user }: { user: { id: string; email: string; emailVerified?: boolean; role?: unknown } }) => {
1705
+ const { adapter, internalAdapter } = await tenant.$context
1706
+ await internalAdapter.updateUser(user.id, { emailVerified: true })
1707
+ // The password is chosen, so every other link to choose it is spent.
1708
+ await adapter.deleteMany({ model: 'verification', where: [{ field: 'value', value: user.id }, ...resetLinks] })
1709
+ if (!user.emailVerified) {
1710
+ await acceptBound({ user, token: (tryGetCurrentAuthEndpointContext()?.body as { token?: unknown } | undefined)?.token })
1711
+ await joinOpen(user)
1712
+ }
1713
+ },
1714
+ // A second sign-up with the address of an account nobody proved, such as its owner's after
1715
+ // someone squatted it, is answered as any sign-up and mails the address the link that proves it.
1716
+ ...(settings.mailer && {
1717
+ sendResetPassword: async (data: { user: { id: string; email: string }; token: string }) => {
1718
+ // Whoever asks for a reset proves the address afresh, carrying no invitation.
1719
+ await unbind(data.user.id)
1720
+ if (!(await mailedLately(data.user.id, data.token))) await resetMail(settings.mailer!)(data)
1721
+ },
1722
+ }),
1723
+ // An owner who signs up after someone else did takes off whatever that first sign-up carried.
1724
+ ...(verifying && {
1725
+ onExistingUserSignUp: async ({ user }: { user: { id: string; email: string; emailVerified?: boolean } }) => {
1726
+ if (user.emailVerified) return
1727
+ await unbind(user.id)
1728
+ await confirmLink(user)
1729
+ },
1730
+ }),
1731
+ },
1732
+ // A sign-up mails the link once. A sign-in before the proof mails nothing, and the provider's own
1733
+ // resend and verify routes are off, so nobody pushes a link to an address on demand.
1734
+ ...(verifying && { emailVerification: { sendVerificationEmail: ({ user }: { user: { id: string; email: string } }) => confirmLink(user), sendOnSignUp: true } }),
1735
+ plugins: [
1736
+ ...built.plugins,
1737
+ // add-on: roles
1738
+ keepsRoles,
1739
+ // end add-on: roles
1740
+ keepsPerson,
1741
+ invitationLimit,
1742
+ setPasswordState,
1743
+ namedInCode,
1744
+ ...(verifying ? [chosenLater] : []),
1745
+ ...(oneOrganization ? [firstRun, signUpGate, addPerson, sendPasswordLink] : []),
1746
+ ],
1747
+ // In one organization, nobody founds another or leaves the one: leaving would leave the
1748
+ // person in no organization at all, where every read is refused.
1749
+ disabledPaths: [
1750
+ ...tenantOnly,
1751
+ ...(oneOrganization ? ['/organization/create', '/organization/leave'] : []),
1752
+ ...(verifying ? ['/send-verification-email', '/verify-email'] : []),
1753
+ ],
1754
+ databaseHooks: {
1755
+ ...built.databaseHooks,
1756
+ session: {
1757
+ ...built.databaseHooks.session,
1758
+ create: { ...built.databaseHooks.session.create, after: lastSeen },
1759
+ update: { after: lastSeen },
1760
+ },
1761
+ user: {
1762
+ create: {
1763
+ before: async (user: { email: string }, context: { body?: unknown; context: { adapter: unknown } } | null) => {
1764
+ await accountGate(user, context)
1765
+ },
1766
+ after: async (user: { id: string; email: string; role?: unknown }, context: { body?: unknown } | null) => {
1767
+ authAudit({ action: 'sign-up', user: user.id })
1768
+ const invitationId = (context?.body as { invitationId?: unknown } | undefined)?.invitationId
1769
+ // Where the address is proved, the invitation rides on the link `confirmLink` mails.
1770
+ if (typeof invitationId === 'string' && !firstRegistering.getStore() && !verifying) await acceptCarried({ user, invitationId })
1771
+ if (!verifying) await joinOpen(user)
1772
+ },
1773
+ },
1774
+ },
1775
+ },
1776
+ database,
1777
+ })
1778
+ // One answer however often and by whomever it is asked, naming nobody.
1779
+ const closed = () => new APIError('FORBIDDEN', { code: 'FIRST_REGISTER_CLOSED', message: 'This backoffice has its administrator, so first-register is closed. Sign in instead.' })
1780
+ // The backoffice's first `super_admin`, which nothing else can make: the provider's own
1781
+ // `create-user`, run on an instance bound to a transaction that holds an advisory lock and
1782
+ // read no administrator under it, so two concurrent calls never both make one.
1783
+ const firstRegister = {
1784
+ id: 'first-register',
1785
+ endpoints: {
1786
+ init: createAuthEndpoint('/init', { method: 'GET' }, async (ctx) =>
1787
+ ctx.json((await administered(store.db as Executes)) ? { initialized: true } : { initialized: false, code: setupCodeState(backofficeURL) })),
1788
+ // `formCsrfMiddleware`, as sign-up has: no session guards this route, so another site's
1789
+ // form must not post to it.
1790
+ firstRegister: createAuthEndpoint('/first-register', { method: 'POST', use: [formCsrfMiddleware] }, async (ctx) => {
1791
+ if (backofficeLimited && !ipAddressHeaders && overSharedLimit()) throw new APIError('TOO_MANY_REQUESTS', { message: 'Too many tries. Wait a minute and try again.' })
1792
+ if (await administered(store.db as Executes)) throw closed()
1793
+ const { name, email, password, code } = (ctx.body ?? {}) as Record<string, unknown>
1794
+ const refused = refusedSetup({ url: backofficeURL, code, app: 'backoffice app' })
1795
+ if (refused) throw refused
1796
+ // `create-user` checks the address but not the password's length, which sign-up does.
1797
+ const { minPasswordLength: min, maxPasswordLength: max } = ctx.context.password.config
1798
+ if (typeof name !== 'string' || !name.trim() || typeof email !== 'string' || typeof password !== 'string' || password.length < min || password.length > max) {
1799
+ throw new APIError('BAD_REQUEST', { message: `A name, an email and a password of ${min} to ${max} characters are required.` })
1800
+ }
1801
+ const made = await store.transaction(async (tx) => {
1802
+ // Read committed whatever the database's default, so the read below sees an
1803
+ // administrator another call committed while this one waited on the lock.
1804
+ await (tx as Executes).execute(sql`set transaction isolation level read committed`)
1805
+ await (tx as Executes).execute(setupLock)
1806
+ if (await administered(tx as Executes)) throw closed()
1807
+ // ponytail: an instance per call, built over this transaction; it runs once in a
1808
+ // project's life. `built` carries the admin plugin and the audit hooks, which is all
1809
+ // `create-user` reads.
1810
+ const bound = betterAuthServer({ ...built, secret: backofficeSecret, database: drizzleAdapter(tx as never, { provider: 'pg', schema: store.tables }) })
1811
+ // The provider writes through the adapter its request stored, which is the backoffice's
1812
+ // pool here, so the user and account rows are pointed at this transaction instead:
1813
+ // both commit with the check or neither does, and no second client is taken.
1814
+ const adapter = (await bound.$context).adapter
1815
+ // The plugin types a role from `adminOptions.roles`, whose backoffice roles are built at
1816
+ // runtime, so it names `user` alone.
1817
+ // Verified, as first-run's owner is: the setup code is the proof, and staff are never
1818
+ // asked to verify an address.
1819
+ return runWithAdapter(adapter, () => firstRegistering.run(true, () => bound.api.createUser({ body: { name, email, password, role: 'super_admin' as never, data: { emailVerified: true } } })))
1820
+ })
1821
+ await toldAdmin({ ctx, email, at: backofficeURL })
1822
+ return ctx.json(made)
1823
+ }),
1824
+ },
1825
+ // Production limits every route; this one to three tries a minute from one address, as the
1826
+ // provider limits a password reset.
1827
+ rateLimit: [{ pathMatcher: (path: string) => path === '/first-register', window: 60, max: 3 }],
1828
+ }
1829
+ // The backoffice app's own instance: its own cookie name, so on `localhost`, where ports do not
1830
+ // keep cookies apart, the two apps never read each other's session; its own secret, so
1831
+ // neither signs a cookie the other accepts; its own origin alone trusted, so a tenant page
1832
+ // cannot post to it with the backoffice's cookie; no verify of a one-time token, which would
1833
+ // turn an impersonation into a backoffice session; and no sign-up, since staff are people the
1834
+ // backoffice already has. A project with no backoffice console has no first-register either.
1835
+ const backoffice = betterAuthServer({
1836
+ ...built,
1837
+ plugins: [
1838
+ ...built.plugins,
1839
+ keepsPerson,
1840
+ // add-on: roles
1841
+ keepsRoles,
1842
+ // end add-on: roles
1843
+ namedInCode,
1844
+ ...(backofficeApp ? [firstRegister] : []),
1845
+ ],
1846
+ baseURL: backofficeURL,
1847
+ trustedOrigins: backofficeOrigins,
1848
+ rateLimit: { ...built.rateLimit, enabled: backofficeLimited },
1849
+ secret: backofficeSecret,
1850
+ advanced: { ...built.advanced, cookiePrefix: backofficeCookiePrefix },
1851
+ // Nor a password reset of its own: staff reset through the tenant app's page like anyone,
1852
+ // and the reset ends their sessions here too.
1853
+ disabledPaths: ['/one-time-token/verify', '/sign-up/email', '/request-password-reset', '/reset-password'],
1854
+ databaseHooks: { session: { create: { before: staffSession(built.databaseHooks.session.create.before) } } },
1855
+ database,
1856
+ })
1857
+ // One object for the kayan, which answers from whichever instance serves the request.
1858
+ return new Proxy(tenant, { get: (_target, key) => Reflect.get(onBackoffice() ? backoffice : tenant, key) })
1859
+ }