create-mithaq 0.0.0-stage → 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 (396) hide show
  1. package/LICENSE +22 -0
  2. package/LICENSES/openstatus-time-picker.txt +21 -0
  3. package/LICENSES/payload.txt +22 -0
  4. package/LICENSES/ponytail.txt +21 -0
  5. package/index.js +521 -0
  6. package/package.json +19 -4
  7. package/template/apps/asas-backoffice/components.json +25 -0
  8. package/template/apps/asas-backoffice/gitignore +3 -0
  9. package/template/apps/asas-backoffice/package.json +57 -0
  10. package/template/apps/asas-backoffice/public/favicon.svg +1 -0
  11. package/template/apps/asas-backoffice/src/api.ts +147 -0
  12. package/template/apps/asas-backoffice/src/auth-frame.tsx +150 -0
  13. package/template/apps/asas-backoffice/src/components/ui/accordion.tsx +74 -0
  14. package/template/apps/asas-backoffice/src/components/ui/alert-dialog.tsx +185 -0
  15. package/template/apps/asas-backoffice/src/components/ui/avatar.tsx +106 -0
  16. package/template/apps/asas-backoffice/src/components/ui/badge.tsx +48 -0
  17. package/template/apps/asas-backoffice/src/components/ui/breadcrumb.tsx +124 -0
  18. package/template/apps/asas-backoffice/src/components/ui/button-group.tsx +87 -0
  19. package/template/apps/asas-backoffice/src/components/ui/button.tsx +55 -0
  20. package/template/apps/asas-backoffice/src/components/ui/calendar.tsx +223 -0
  21. package/template/apps/asas-backoffice/src/components/ui/card.tsx +102 -0
  22. package/template/apps/asas-backoffice/src/components/ui/chart.tsx +371 -0
  23. package/template/apps/asas-backoffice/src/components/ui/checkbox.tsx +28 -0
  24. package/template/apps/asas-backoffice/src/components/ui/collapsible.tsx +19 -0
  25. package/template/apps/asas-backoffice/src/components/ui/combobox.tsx +308 -0
  26. package/template/apps/asas-backoffice/src/components/ui/dialog.tsx +158 -0
  27. package/template/apps/asas-backoffice/src/components/ui/direction.tsx +6 -0
  28. package/template/apps/asas-backoffice/src/components/ui/directional-icon.tsx +20 -0
  29. package/template/apps/asas-backoffice/src/components/ui/dropdown-menu.tsx +267 -0
  30. package/template/apps/asas-backoffice/src/components/ui/field.tsx +236 -0
  31. package/template/apps/asas-backoffice/src/components/ui/input-group.tsx +155 -0
  32. package/template/apps/asas-backoffice/src/components/ui/input-otp.tsx +84 -0
  33. package/template/apps/asas-backoffice/src/components/ui/input.tsx +19 -0
  34. package/template/apps/asas-backoffice/src/components/ui/label.tsx +19 -0
  35. package/template/apps/asas-backoffice/src/components/ui/popover.tsx +90 -0
  36. package/template/apps/asas-backoffice/src/components/ui/radio-group.tsx +37 -0
  37. package/template/apps/asas-backoffice/src/components/ui/resizable.tsx +47 -0
  38. package/template/apps/asas-backoffice/src/components/ui/scroll-area.tsx +54 -0
  39. package/template/apps/asas-backoffice/src/components/ui/select.tsx +204 -0
  40. package/template/apps/asas-backoffice/src/components/ui/separator.tsx +24 -0
  41. package/template/apps/asas-backoffice/src/components/ui/sheet.tsx +139 -0
  42. package/template/apps/asas-backoffice/src/components/ui/sidebar.tsx +714 -0
  43. package/template/apps/asas-backoffice/src/components/ui/skeleton.tsx +13 -0
  44. package/template/apps/asas-backoffice/src/components/ui/slider.tsx +55 -0
  45. package/template/apps/asas-backoffice/src/components/ui/spinner.tsx +10 -0
  46. package/template/apps/asas-backoffice/src/components/ui/switch.tsx +29 -0
  47. package/template/apps/asas-backoffice/src/components/ui/table.tsx +115 -0
  48. package/template/apps/asas-backoffice/src/components/ui/tabs.tsx +79 -0
  49. package/template/apps/asas-backoffice/src/components/ui/textarea.tsx +17 -0
  50. package/template/apps/asas-backoffice/src/components/ui/toggle-group.tsx +89 -0
  51. package/template/apps/asas-backoffice/src/components/ui/toggle.tsx +44 -0
  52. package/template/apps/asas-backoffice/src/components/ui/tooltip.tsx +63 -0
  53. package/template/apps/asas-backoffice/src/components.ts +35 -0
  54. package/template/apps/asas-backoffice/src/copy.ts +1 -0
  55. package/template/apps/asas-backoffice/src/first-user.ts +14 -0
  56. package/template/apps/asas-backoffice/src/hooks/use-mobile.ts +19 -0
  57. package/template/apps/asas-backoffice/src/lib/utils.ts +1 -0
  58. package/template/apps/asas-backoffice/src/links.ts +38 -0
  59. package/template/apps/asas-backoffice/src/locale-switch.tsx +28 -0
  60. package/template/apps/asas-backoffice/src/no-organization.tsx +5 -0
  61. package/template/apps/asas-backoffice/src/redirect.ts +1 -0
  62. package/template/apps/asas-backoffice/src/request-headers.ts +11 -0
  63. package/template/apps/asas-backoffice/src/routeTree.gen.ts +206 -0
  64. package/template/apps/asas-backoffice/src/router.tsx +18 -0
  65. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/files.ts +10 -0
  66. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/screen.json +3971 -0
  67. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice/types.ts +163 -0
  68. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.$.tsx +17 -0
  69. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.index.tsx +10 -0
  70. package/template/apps/asas-backoffice/src/routes/(screens)/backoffice.tsx +13 -0
  71. package/template/apps/asas-backoffice/src/routes/__root.tsx +95 -0
  72. package/template/apps/asas-backoffice/src/routes/api/$.ts +34 -0
  73. package/template/apps/asas-backoffice/src/routes/auth.create-first-user.tsx +113 -0
  74. package/template/apps/asas-backoffice/src/routes/auth.sign-in.tsx +77 -0
  75. package/template/apps/asas-backoffice/src/routes/unauthorized.tsx +134 -0
  76. package/template/apps/asas-backoffice/src/screen-form.tsx +36 -0
  77. package/template/apps/asas-backoffice/src/screen-list.tsx +140 -0
  78. package/template/apps/asas-backoffice/src/screen-names.ts +10 -0
  79. package/template/apps/asas-backoffice/src/screens.tsx +888 -0
  80. package/template/apps/asas-backoffice/src/server.ts +30 -0
  81. package/template/apps/asas-backoffice/src/slide.ts +22 -0
  82. package/template/apps/asas-backoffice/src/start.ts +42 -0
  83. package/template/apps/asas-backoffice/src/styles.css +202 -0
  84. package/template/apps/asas-backoffice/src/theme.ts +2 -0
  85. package/template/apps/asas-backoffice/tsconfig.json +19 -0
  86. package/template/apps/asas-backoffice/vite.config.ts +32 -0
  87. package/template/apps/asas-tanstack/components.json +25 -0
  88. package/template/apps/asas-tanstack/gitignore +3 -0
  89. package/template/apps/asas-tanstack/package.json +58 -0
  90. package/template/apps/asas-tanstack/public/favicon.svg +1 -0
  91. package/template/apps/asas-tanstack/src/api.ts +147 -0
  92. package/template/apps/asas-tanstack/src/auth-frame.tsx +150 -0
  93. package/template/apps/asas-tanstack/src/choose-password.tsx +150 -0
  94. package/template/apps/asas-tanstack/src/components/ui/accordion.tsx +10 -0
  95. package/template/apps/asas-tanstack/src/components/ui/alert-dialog.tsx +173 -0
  96. package/template/apps/asas-tanstack/src/components/ui/avatar.tsx +104 -0
  97. package/template/apps/asas-tanstack/src/components/ui/badge.tsx +48 -0
  98. package/template/apps/asas-tanstack/src/components/ui/breadcrumb.tsx +125 -0
  99. package/template/apps/asas-tanstack/src/components/ui/button-group.tsx +87 -0
  100. package/template/apps/asas-tanstack/src/components/ui/button.tsx +56 -0
  101. package/template/apps/asas-tanstack/src/components/ui/calendar.tsx +223 -0
  102. package/template/apps/asas-tanstack/src/components/ui/card.tsx +94 -0
  103. package/template/apps/asas-tanstack/src/components/ui/chart.tsx +369 -0
  104. package/template/apps/asas-tanstack/src/components/ui/checkbox.tsx +27 -0
  105. package/template/apps/asas-tanstack/src/components/ui/collapsible.tsx +19 -0
  106. package/template/apps/asas-tanstack/src/components/ui/combobox.tsx +300 -0
  107. package/template/apps/asas-tanstack/src/components/ui/dialog.tsx +149 -0
  108. package/template/apps/asas-tanstack/src/components/ui/direction.tsx +5 -0
  109. package/template/apps/asas-tanstack/src/components/ui/directional-icon.tsx +20 -0
  110. package/template/apps/asas-tanstack/src/components/ui/dropdown-menu.tsx +267 -0
  111. package/template/apps/asas-tanstack/src/components/ui/field.tsx +225 -0
  112. package/template/apps/asas-tanstack/src/components/ui/input-group.tsx +147 -0
  113. package/template/apps/asas-tanstack/src/components/ui/input-otp.tsx +20 -0
  114. package/template/apps/asas-tanstack/src/components/ui/input.tsx +20 -0
  115. package/template/apps/asas-tanstack/src/components/ui/label.tsx +18 -0
  116. package/template/apps/asas-tanstack/src/components/ui/popover.tsx +50 -0
  117. package/template/apps/asas-tanstack/src/components/ui/radio-group.tsx +10 -0
  118. package/template/apps/asas-tanstack/src/components/ui/resizable.tsx +47 -0
  119. package/template/apps/asas-tanstack/src/components/ui/scroll-area.tsx +52 -0
  120. package/template/apps/asas-tanstack/src/components/ui/select.tsx +190 -0
  121. package/template/apps/asas-tanstack/src/components/ui/separator.tsx +23 -0
  122. package/template/apps/asas-tanstack/src/components/ui/sheet.tsx +127 -0
  123. package/template/apps/asas-tanstack/src/components/ui/sidebar.tsx +716 -0
  124. package/template/apps/asas-tanstack/src/components/ui/skeleton.tsx +13 -0
  125. package/template/apps/asas-tanstack/src/components/ui/slider.tsx +13 -0
  126. package/template/apps/asas-tanstack/src/components/ui/spinner.tsx +11 -0
  127. package/template/apps/asas-tanstack/src/components/ui/switch.tsx +8 -0
  128. package/template/apps/asas-tanstack/src/components/ui/table.tsx +119 -0
  129. package/template/apps/asas-tanstack/src/components/ui/tabs.tsx +80 -0
  130. package/template/apps/asas-tanstack/src/components/ui/textarea.tsx +18 -0
  131. package/template/apps/asas-tanstack/src/components/ui/toggle-group.tsx +8 -0
  132. package/template/apps/asas-tanstack/src/components/ui/toggle.tsx +44 -0
  133. package/template/apps/asas-tanstack/src/components/ui/tooltip.tsx +68 -0
  134. package/template/apps/asas-tanstack/src/components.ts +35 -0
  135. package/template/apps/asas-tanstack/src/copy.ts +1207 -0
  136. package/template/apps/asas-tanstack/src/first-run.ts +16 -0
  137. package/template/apps/asas-tanstack/src/hooks/use-mobile.ts +19 -0
  138. package/template/apps/asas-tanstack/src/lib/utils.ts +6 -0
  139. package/template/apps/asas-tanstack/src/links.ts +38 -0
  140. package/template/apps/asas-tanstack/src/locale-switch.tsx +28 -0
  141. package/template/apps/asas-tanstack/src/no-organization.tsx +108 -0
  142. package/template/apps/asas-tanstack/src/quiet-abort.ts +25 -0
  143. package/template/apps/asas-tanstack/src/redirect.ts +39 -0
  144. package/template/apps/asas-tanstack/src/request-headers.ts +11 -0
  145. package/template/apps/asas-tanstack/src/routeTree.gen.ts +424 -0
  146. package/template/apps/asas-tanstack/src/router.tsx +18 -0
  147. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/files.ts +11 -0
  148. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/screen.json +4242 -0
  149. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard/types.ts +160 -0
  150. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.$.tsx +17 -0
  151. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.index.tsx +10 -0
  152. package/template/apps/asas-tanstack/src/routes/(screens)/dashboard.tsx +13 -0
  153. package/template/apps/asas-tanstack/src/routes/(screens)/portal/files.ts +9 -0
  154. package/template/apps/asas-tanstack/src/routes/(screens)/portal/screen.json +1564 -0
  155. package/template/apps/asas-tanstack/src/routes/(screens)/portal/types.ts +94 -0
  156. package/template/apps/asas-tanstack/src/routes/(screens)/portal.$.tsx +17 -0
  157. package/template/apps/asas-tanstack/src/routes/(screens)/portal.index.tsx +10 -0
  158. package/template/apps/asas-tanstack/src/routes/(screens)/portal.tsx +13 -0
  159. package/template/apps/asas-tanstack/src/routes/__root.tsx +99 -0
  160. package/template/apps/asas-tanstack/src/routes/api/$.ts +11 -0
  161. package/template/apps/asas-tanstack/src/routes/auth.accept-invitation.tsx +108 -0
  162. package/template/apps/asas-tanstack/src/routes/auth.first-run.tsx +114 -0
  163. package/template/apps/asas-tanstack/src/routes/auth.forgot-password.tsx +94 -0
  164. package/template/apps/asas-tanstack/src/routes/auth.handoff.ts +39 -0
  165. package/template/apps/asas-tanstack/src/routes/auth.reset-password.tsx +16 -0
  166. package/template/apps/asas-tanstack/src/routes/auth.set-password.tsx +16 -0
  167. package/template/apps/asas-tanstack/src/routes/auth.sign-in.tsx +98 -0
  168. package/template/apps/asas-tanstack/src/routes/auth.sign-up.tsx +143 -0
  169. package/template/apps/asas-tanstack/src/routes/index.tsx +146 -0
  170. package/template/apps/asas-tanstack/src/routes/unauthorized.tsx +134 -0
  171. package/template/apps/asas-tanstack/src/screen-form.tsx +36 -0
  172. package/template/apps/asas-tanstack/src/screen-list.tsx +140 -0
  173. package/template/apps/asas-tanstack/src/screen-names.ts +10 -0
  174. package/template/apps/asas-tanstack/src/screens.tsx +888 -0
  175. package/template/apps/asas-tanstack/src/server.ts +26 -0
  176. package/template/apps/asas-tanstack/src/slide.ts +22 -0
  177. package/template/apps/asas-tanstack/src/start.ts +57 -0
  178. package/template/apps/asas-tanstack/src/styles.css +163 -0
  179. package/template/apps/asas-tanstack/src/theme.ts +5 -0
  180. package/template/apps/asas-tanstack/tsconfig.json +19 -0
  181. package/template/apps/asas-tanstack/vite.config.ts +34 -0
  182. package/template/package.json +16 -0
  183. package/template/templates/asas/.claude/skills/add-field/SKILL.md +27 -0
  184. package/template/templates/asas/.claude/skills/add-kayan/SKILL.md +26 -0
  185. package/template/templates/asas/.claude/skills/eject-admin-piece/SKILL.md +28 -0
  186. package/template/templates/asas/.claude/skills/upgrade-mithaq/SKILL.md +35 -0
  187. package/template/templates/asas/.env.example +33 -0
  188. package/template/templates/asas/AGENTS.md +101 -0
  189. package/template/templates/asas/CLAUDE.md +1 -0
  190. package/template/templates/asas/README.md +1056 -0
  191. package/template/templates/asas/mithaq.hints.json +25 -0
  192. package/template/templates/asas/package.json +53 -0
  193. package/template/templates/asas/src/asas.ts +118 -0
  194. package/template/templates/asas/src/backoffice-http.ts +117 -0
  195. package/template/templates/asas/src/dev-setup.ts +112 -0
  196. package/template/templates/asas/src/local-database.ts +22 -0
  197. package/template/templates/asas/src/messages/account.json +132 -0
  198. package/template/templates/asas/src/messages/auditLog.json +304 -0
  199. package/template/templates/asas/src/messages/backofficeLog.json +132 -0
  200. package/template/templates/asas/src/messages/courses.json +312 -0
  201. package/template/templates/asas/src/messages/enrollments.json +60 -0
  202. package/template/templates/asas/src/messages/invitation.json +82 -0
  203. package/template/templates/asas/src/messages/localUsers.json +48 -0
  204. package/template/templates/asas/src/messages/media.json +76 -0
  205. package/template/templates/asas/src/messages/member.json +62 -0
  206. package/template/templates/asas/src/messages/memberRoles.json +48 -0
  207. package/template/templates/asas/src/messages/mithaq.json +214 -0
  208. package/template/templates/asas/src/messages/organization.json +72 -0
  209. package/template/templates/asas/src/messages/organizationRole.json +62 -0
  210. package/template/templates/asas/src/messages/roles.json +72 -0
  211. package/template/templates/asas/src/messages/session.json +102 -0
  212. package/template/templates/asas/src/messages/sessions.json +136 -0
  213. package/template/templates/asas/src/messages/settings.json +64 -0
  214. package/template/templates/asas/src/messages/tags.json +48 -0
  215. package/template/templates/asas/src/messages/user.json +112 -0
  216. package/template/templates/asas/src/messages/verification.json +62 -0
  217. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/migration.sql +177 -0
  218. package/template/templates/asas/src/migrations/20260920080701_parched_tombstone/snapshot.json +2169 -0
  219. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/migration.sql +1 -0
  220. package/template/templates/asas/src/migrations/20260920230505_zippy_ben_urich/snapshot.json +2182 -0
  221. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/migration.sql +20 -0
  222. package/template/templates/asas/src/migrations/20260920231219_slim_retro_girl/snapshot.json +2409 -0
  223. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/migration.sql +15 -0
  224. package/template/templates/asas/src/migrations/20260922082945_military_ironclad/snapshot.json +2627 -0
  225. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/migration.sql +5 -0
  226. package/template/templates/asas/src/migrations/20260922112630_bitter_chat/snapshot.json +2691 -0
  227. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/migration.sql +8 -0
  228. package/template/templates/asas/src/migrations/20260923235915_foamy_rachel_grey/snapshot.json +2795 -0
  229. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/migration.sql +3 -0
  230. package/template/templates/asas/src/migrations/20260926145709_clean_the_initiative/snapshot.json +2834 -0
  231. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/migration.sql +9 -0
  232. package/template/templates/asas/src/migrations/20260927212525_bent_prowler/snapshot.json +2870 -0
  233. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/migration.sql +1 -0
  234. package/template/templates/asas/src/migrations/20260927223441_tranquil_bromley/snapshot.json +2881 -0
  235. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/migration.sql +15 -0
  236. package/template/templates/asas/src/migrations/20260928030047_outgoing_wraith/snapshot.json +3073 -0
  237. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/migration.sql +21 -0
  238. package/template/templates/asas/src/migrations/20260929024407_grey_morlun/snapshot.json +3098 -0
  239. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/migration.sql +21 -0
  240. package/template/templates/asas/src/migrations/20260929130530_bored_jackal/snapshot.json +3124 -0
  241. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/migration.sql +32 -0
  242. package/template/templates/asas/src/migrations/20260930102924_material_newton_destine/snapshot.json +3324 -0
  243. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/migration.sql +2 -0
  244. package/template/templates/asas/src/migrations/20261003022353_clumsy_taskmaster/snapshot.json +3350 -0
  245. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/migration.sql +21 -0
  246. package/template/templates/asas/src/migrations/20261003143249_premium_red_shift/snapshot.json +3635 -0
  247. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/migration.sql +8 -0
  248. package/template/templates/asas/src/migrations/20261008215349_square_norrin_radd/snapshot.json +3635 -0
  249. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/migration.sql +18 -0
  250. package/template/templates/asas/src/migrations/20261008230204_many_scarecrow/snapshot.json +3635 -0
  251. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/migration.sql +18 -0
  252. package/template/templates/asas/src/migrations/20261010005521_elite_giant_girl/snapshot.json +3635 -0
  253. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/migration.sql +11 -0
  254. package/template/templates/asas/src/migrations/20261010120822_needy_thunderbolt/snapshot.json +3667 -0
  255. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/migration.sql +48 -0
  256. package/template/templates/asas/src/migrations/20261012000752_organization_ownership/snapshot.json +3891 -0
  257. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/migration.sql +1 -0
  258. package/template/templates/asas/src/migrations/20261014221355_tags_restrict/snapshot.json +3881 -0
  259. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/migration.sql +1 -0
  260. package/template/templates/asas/src/migrations/20261014222218_one_seat_per_session/snapshot.json +3894 -0
  261. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/migration.sql +1 -0
  262. package/template/templates/asas/src/migrations/20261014223522_session_archive_reason/snapshot.json +3907 -0
  263. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/migration.sql +1 -0
  264. package/template/templates/asas/src/migrations/20261014225728_course_archive_by_operation/snapshot.json +3907 -0
  265. package/template/templates/asas/src/migrations/20261015042444_organization_names/migration.sql +1 -0
  266. package/template/templates/asas/src/migrations/20261015042444_organization_names/snapshot.json +3920 -0
  267. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/migration.sql +1 -0
  268. package/template/templates/asas/src/migrations/20261015053212_member_last_seen/snapshot.json +3933 -0
  269. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/migration.sql +15 -0
  270. package/template/templates/asas/src/migrations/20261015093203_activity_and_staff_history/snapshot.json +4113 -0
  271. package/template/templates/asas/src/migrations/20261015095752_portal/migration.sql +2 -0
  272. package/template/templates/asas/src/migrations/20261015095752_portal/snapshot.json +4113 -0
  273. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/migration.sql +9 -0
  274. package/template/templates/asas/src/migrations/20261015135529_better_auth_indexes/snapshot.json +4302 -0
  275. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/migration.sql +27 -0
  276. package/template/templates/asas/src/migrations/20261015162438_audit_changes_json/snapshot.json +4289 -0
  277. package/template/templates/asas/src/migrations/drizzle.config.ts +11 -0
  278. package/template/templates/asas/src/migrations/schema.ts +30 -0
  279. package/template/templates/asas/src/mithaq.ts +4 -0
  280. package/template/templates/asas/src/organizations.ts +3 -0
  281. package/template/templates/asas/src/seed.ts +427 -0
  282. package/template/templates/asas/src/ui-backoffice/home.tsx +63 -0
  283. package/template/templates/asas/src/ui-backoffice/organization-actions.tsx +121 -0
  284. package/template/templates/asas/src/ui-backoffice/record-page.tsx +30 -0
  285. package/template/templates/asas/src/ui-backoffice/role-cells.tsx +59 -0
  286. package/template/templates/asas/src/ui-backoffice/user-actions.tsx +249 -0
  287. package/template/templates/asas/src/ui-dashboard/dashboard.tsx +300 -0
  288. package/template/templates/asas/src/ui-dashboard/home.tsx +73 -0
  289. package/template/templates/asas/src/ui-dashboard/members-card.tsx +147 -0
  290. package/template/templates/asas/src/ui-dashboard/members.tsx +829 -0
  291. package/template/templates/asas/src/ui-dashboard/theme-provider.tsx +10 -0
  292. package/template/templates/asas/src/ui-dashboard/versions.tsx +446 -0
  293. package/template/templates/asas/src/ui-portal/courses-home.tsx +96 -0
  294. package/template/templates/asas/src/ui-portal/home.tsx +73 -0
  295. package/template/templates/asas/src/ui-portal/page.tsx +11 -0
  296. package/template/templates/features/audit-log/audit-log/audit-log.ts +284 -0
  297. package/template/templates/features/audit-log/backoffice-log/backoffice-log.ts +67 -0
  298. package/template/templates/features/audit-log/index.ts +4 -0
  299. package/template/templates/features/audit-log/package.json +13 -0
  300. package/template/templates/features/auth/index.ts +11 -0
  301. package/template/templates/features/auth/local-users/local-users.ts +143 -0
  302. package/template/templates/features/auth/package.json +13 -0
  303. package/template/templates/features/auth/user/account.access.ts +8 -0
  304. package/template/templates/features/auth/user/account.ts +31 -0
  305. package/template/templates/features/auth/user/index.ts +18 -0
  306. package/template/templates/features/auth/user/invitation.access.ts +4 -0
  307. package/template/templates/features/auth/user/invitation.ts +27 -0
  308. package/template/templates/features/auth/user/member.access.ts +10 -0
  309. package/template/templates/features/auth/user/member.screens.ts +37 -0
  310. package/template/templates/features/auth/user/member.ts +26 -0
  311. package/template/templates/features/auth/user/organization.access.ts +20 -0
  312. package/template/templates/features/auth/user/organization.screens.ts +37 -0
  313. package/template/templates/features/auth/user/organization.ts +25 -0
  314. package/template/templates/features/auth/user/organizationRole.access.ts +8 -0
  315. package/template/templates/features/auth/user/organizationRole.ts +25 -0
  316. package/template/templates/features/auth/user/session.access.ts +8 -0
  317. package/template/templates/features/auth/user/session.ts +28 -0
  318. package/template/templates/features/auth/user/user.access.ts +30 -0
  319. package/template/templates/features/auth/user/user.screens.ts +45 -0
  320. package/template/templates/features/auth/user/user.ts +31 -0
  321. package/template/templates/features/auth/user/verification.access.ts +8 -0
  322. package/template/templates/features/auth/user/verification.ts +23 -0
  323. package/template/templates/features/courses/courses/courses.ts +601 -0
  324. package/template/templates/features/courses/courses/cover-reads.ts +33 -0
  325. package/template/templates/features/courses/courses/ui/archived-badge.tsx +9 -0
  326. package/template/templates/features/courses/courses/ui/fee-cell.tsx +19 -0
  327. package/template/templates/features/courses/courses/ui/no-courses.tsx +13 -0
  328. package/template/templates/features/courses/courses/ui/status-cell.tsx +33 -0
  329. package/template/templates/features/courses/courses/ui/unpublish-button.tsx +15 -0
  330. package/template/templates/features/courses/domain/archive.ts +39 -0
  331. package/template/templates/features/courses/domain/on-offer.ts +6 -0
  332. package/template/templates/features/courses/enrollments/enrollments.ts +309 -0
  333. package/template/templates/features/courses/enrollments/ui/session-cell.tsx +75 -0
  334. package/template/templates/features/courses/enrollments/ui/session-labels.tsx +36 -0
  335. package/template/templates/features/courses/index.ts +8 -0
  336. package/template/templates/features/courses/package.json +24 -0
  337. package/template/templates/features/courses/portal/ui/catalogue.tsx +86 -0
  338. package/template/templates/features/courses/portal/ui/course.tsx +176 -0
  339. package/template/templates/features/courses/portal/ui/enrollments.tsx +170 -0
  340. package/template/templates/features/courses/portal/ui/parts.tsx +95 -0
  341. package/template/templates/features/courses/portal/ui/reads.ts +76 -0
  342. package/template/templates/features/courses/portal/ui/rules.ts +87 -0
  343. package/template/templates/features/courses/portal/ui/shell.tsx +194 -0
  344. package/template/templates/features/courses/sessions/seats.ts +42 -0
  345. package/template/templates/features/courses/sessions/sessions.ts +186 -0
  346. package/template/templates/features/media/index.ts +10 -0
  347. package/template/templates/features/media/media/media.ts +131 -0
  348. package/template/templates/features/media/media/ui/preview.tsx +143 -0
  349. package/template/templates/features/media/package.json +19 -0
  350. package/template/templates/features/roles/index.ts +4 -0
  351. package/template/templates/features/roles/member-roles/member-roles.ts +207 -0
  352. package/template/templates/features/roles/package.json +21 -0
  353. package/template/templates/features/roles/roles/roles.ts +184 -0
  354. package/template/templates/features/roles/roles/ui/role-form.tsx +267 -0
  355. package/template/templates/features/roles/roles/ui/roles.tsx +190 -0
  356. package/template/templates/features/settings/index.ts +3 -0
  357. package/template/templates/features/settings/package.json +13 -0
  358. package/template/templates/features/settings/settings/settings.ts +64 -0
  359. package/template/templates/features/tags/index.ts +3 -0
  360. package/template/templates/features/tags/package.json +14 -0
  361. package/template/templates/features/tags/tags/tags.ts +80 -0
  362. package/template/templates/mithaq/components.json +22 -0
  363. package/template/templates/mithaq/package.json +33 -0
  364. package/template/templates/mithaq/src/mail/index.ts +32 -0
  365. package/template/templates/mithaq/src/mail/mailers.ts +36 -0
  366. package/template/templates/mithaq/src/mithaq/audit-sink.ts +111 -0
  367. package/template/templates/mithaq/src/mithaq/auth/backoffice-roles.ts +108 -0
  368. package/template/templates/mithaq/src/mithaq/auth/index.ts +1859 -0
  369. package/template/templates/mithaq/src/mithaq/auth/organizations.ts +80 -0
  370. package/template/templates/mithaq/src/mithaq/pages.ts +83 -0
  371. package/template/templates/mithaq/src/mithaq/rich-text-nodes.ts +19 -0
  372. package/template/templates/mithaq/src/mithaq.ts +593 -0
  373. package/template/templates/mithaq/src/roles/default-member-roles.ts +29 -0
  374. package/template/templates/mithaq/src/roles/grants.ts +47 -0
  375. package/template/templates/mithaq/src/roles/member-access.ts +11 -0
  376. package/template/templates/mithaq/src/roles/statements.ts +13 -0
  377. package/template/templates/mithaq/src/roles/system-roles.ts +16 -0
  378. package/template/templates/mithaq/src/roles/tenant-access.ts +260 -0
  379. package/template/templates/mithaq/src/roles/tenant-roles.ts +321 -0
  380. package/template/templates/mithaq/src/steps/expected-updated-at.ts +41 -0
  381. package/template/templates/mithaq/src/storage/defaults.ts +156 -0
  382. package/template/templates/mithaq/src/storage/index.ts +97 -0
  383. package/template/templates/mithaq/src/storage/local.ts +143 -0
  384. package/template/templates/mithaq/src/storage/r2.ts +5 -0
  385. package/template/templates/mithaq/src/storage/s3.ts +91 -0
  386. package/template/templates/mithaq/src/storage/uploadthing.ts +59 -0
  387. package/template/templates/mithaq/src/ui/activity-page.tsx +33 -0
  388. package/template/templates/mithaq/src/ui/activity.tsx +527 -0
  389. package/template/templates/mithaq/src/ui/app-shell.tsx +268 -0
  390. package/template/templates/mithaq/src/ui/kpi-card.tsx +25 -0
  391. package/template/templates/mithaq/src/ui/record-page.tsx +50 -0
  392. package/template/templates/mithaq/src/ui/rich-text-nodes.tsx +33 -0
  393. package/template/templates/mithaq/src/ui/screen-nav.tsx +28 -0
  394. package/template/templates/mithaq/src/ui/table-page.tsx +9 -0
  395. package/template/templates/mithaq/src/ui/top-shell.tsx +60 -0
  396. package/README.md +0 -3
@@ -0,0 +1,156 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { fileTypeFromBuffer } from 'file-type'
3
+ import { imageDimensionsFromData } from 'image-dimensions'
4
+ import {
5
+ adapterOf,
6
+ allowedType,
7
+ checkReference,
8
+ fileHeaders,
9
+ invalid,
10
+ readForFile,
11
+ sanitizeFilename,
12
+ signReference,
13
+ type StorageConfig,
14
+ type StorageKayan,
15
+ } from 'mithaq'
16
+
17
+ /**
18
+ * `<prefix>/<tenant>/<kayan>/<id>/<filename>`, under the adapter's prefix, the
19
+ * one folder its sweep lists. The id is the row's own, so two files of one name never meet.
20
+ */
21
+ export const defaultKey: StorageConfig['key'] = ({ prefix, tenant, kayan, id, filename }) =>
22
+ [...(prefix ? [prefix] : []), (tenant ?? '_').replaceAll('/', '_'), kayan, id, filename].join('/')
23
+
24
+ /** The bytes `file-type` reads to tell a type, its own `reasonableDetectionSizeInBytes`. */
25
+ const sniffed = 4100
26
+
27
+ /**
28
+ * Checks the stored object is there, of the declared size and no larger than the kayan takes,
29
+ * and of the declared type as its first bytes tell it, and reads an image's width and height.
30
+ * A type the bytes cannot tell, such as text/plain, CSV or JSON, is refused; SVG is the one text type taken.
31
+ */
32
+ export const defaultVerify: StorageConfig['verify'] = async ({ adapter, key, mimeType, size, mimeTypes, fileSize }) => {
33
+ const head = await adapter.head({ key })
34
+ if (!head) return { refused: 'No file was uploaded.' }
35
+ if (head.size !== size) return { refused: `The file holds ${head.size} bytes, and ${size} were declared.` }
36
+ if (head.size > fileSize) return { refused: `The file holds more than ${fileSize} bytes.` }
37
+ const bytes = await adapter.readHead({ key, bytes: sniffed })
38
+ const read = (await fileTypeFromBuffer(bytes))?.mime
39
+ const svg = (!read || read === 'application/xml') && /<svg[\s>]/i.test(new TextDecoder().decode(bytes))
40
+ const type = svg ? 'image/svg+xml' : read
41
+ if (type !== mimeType) return { refused: `The file is ${type ?? 'of no type a file can be told by'}, and ${mimeType} was declared.` }
42
+ if (!allowedType(type, mimeTypes)) return { refused: `The file is ${type}, which this kayan does not hold.` }
43
+ const measured = imageDimensionsFromData(bytes)
44
+ return { width: measured?.width ?? null, height: measured?.height ?? null }
45
+ }
46
+
47
+ /** The types a browser draws in place and runs nothing in. Everything else is downloaded. */
48
+ const drawn = /^(image\/(png|jpeg|gif|webp|avif)|application\/pdf)$/
49
+
50
+ /**
51
+ * `inline` for a raster image and a PDF, `attachment` for every other type, an SVG among them,
52
+ * with the uploaded name (RFC 6266: `filename` for old clients, `filename*` for the rest).
53
+ */
54
+ export const defaultDisposition: StorageConfig['disposition'] = ({ filename, mimeType }) => {
55
+ const plain = filename.replace(/[^\x20-\x7e]|["\\]/g, '_')
56
+ const encoded = encodeURIComponent(filename).replace(/['()*]/g, (one) => `%${one.charCodeAt(0).toString(16).toUpperCase()}`)
57
+ return `${drawn.test(mimeType) ? 'inline' : 'attachment'}; filename="${plain}"; filename*=UTF-8''${encoded}`
58
+ }
59
+
60
+ /** How long an object may sit with no row naming it before it is swept: an upload in flight has an hour. */
61
+ const day = 24 * 60 * 60 * 1000
62
+
63
+ /** Removes every object older than a day that no row names, or with `check` lists them and leaves them. */
64
+ export const defaultSweep: StorageConfig['sweep'] = async ({ adapter, named, check, now }) => {
65
+ const orphans: string[] = []
66
+ for await (const { key, lastModified } of adapter.list!()) {
67
+ if (named.has(key) || now.getTime() - lastModified.getTime() < day) continue
68
+ orphans.push(key)
69
+ if (!check) await adapter.handleDelete({ key })
70
+ }
71
+ return orphans
72
+ }
73
+
74
+ /**
75
+ * Answers `uploadInstructions` once core has checked the create permission: validates the request,
76
+ * then returns the row's id, the reference core signs for it and the adapter's signed request.
77
+ */
78
+ export const defaultUploadInstructions: StorageConfig['instructions'] = async ({ kayan, input, actor }) => {
79
+ const asked = input as Record<string, unknown> | null
80
+ if (
81
+ !asked || typeof asked !== 'object' || Array.isArray(asked)
82
+ || typeof asked.filename !== 'string' || asked.filename.length > 255
83
+ || typeof asked.mimeType !== 'string' || !/^[\w.+-]+\/[\w.+-]+$/.test(asked.mimeType)
84
+ || !Number.isSafeInteger(asked.filesize) || (asked.filesize as number) < 1
85
+ ) {
86
+ throw invalid({ message: 'An upload names its filename, 255 characters at most, its mimeType, a type such as image/png, and its filesize, a whole number of bytes above 0.', key: 'error.invalid.upload_request', params: { kayan: kayan.name } })
87
+ }
88
+ const filename = sanitizeFilename(asked.filename as string)
89
+ const mimeType = asked.mimeType as string
90
+ const filesize = asked.filesize as number
91
+ if (!filename) throw invalid({ message: 'The file has no name left once its folders and control characters are dropped.', key: 'error.invalid.upload_request', params: { kayan: kayan.name } })
92
+ if (!allowedType(mimeType, kayan.storage.mimeTypes)) {
93
+ throw invalid({ message: `A "${kayan.name}" file may not be of type "${mimeType}".`, key: 'error.invalid.upload_type', params: { kayan: kayan.name, type: mimeType } })
94
+ }
95
+ if (filesize > kayan.storage.fileSize) {
96
+ throw invalid({ message: `A "${kayan.name}" file holds ${kayan.storage.fileSize} bytes at most.`, key: 'error.invalid.upload_size', params: { kayan: kayan.name, max: kayan.storage.fileSize } })
97
+ }
98
+ const uploadReference = signReference({ kayan, actor, id: randomUUID(), filename, type: mimeType, size: filesize })
99
+ const request = await adapterOf({ kayan, name: uploadReference.storage }).uploadInstructions.generate({ filename, filesize, mimeType, key: uploadReference.key })
100
+ return { ...request, file: { filename, mimeType, size: filesize, uploadReference } }
101
+ }
102
+
103
+ /**
104
+ * The create's step: checks the reference, runs the project's `verify` and writes every file column
105
+ * from what the server measured. It runs before the transaction so no lock is held while the object is read.
106
+ */
107
+ export const defaultUploaded: StorageConfig['uploaded'] = async (ctx) => {
108
+ const reference = await checkReference({ kayan: ctx.kayan, value: ctx.data.uploadReference, actor: ctx.actor })
109
+ const { storage } = ctx.kayan as StorageKayan
110
+ const adapter = adapterOf({ kayan: ctx.kayan, name: reference.storage })
111
+ const verified = await storage.config.verify({ adapter, key: reference.key, mimeType: reference.type, size: reference.size, mimeTypes: storage.mimeTypes, fileSize: storage.fileSize })
112
+ if ('refused' in verified) {
113
+ throw invalid({ message: `The uploaded file was refused: ${verified.refused}`, key: 'error.invalid.upload_verify', params: { kayan: ctx.kayan.name } })
114
+ }
115
+ const { uploadReference: _used, ...data } = ctx.data
116
+ return {
117
+ ...ctx,
118
+ data: {
119
+ ...data,
120
+ id: reference.id,
121
+ key: reference.key,
122
+ storage: reference.storage,
123
+ filename: reference.filename,
124
+ mimeType: reference.type,
125
+ filesize: reference.size,
126
+ width: verified.width ?? null,
127
+ height: verified.height ?? null,
128
+ },
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Deletes the removed row's object after the commit, so a failure leaves an orphan for the sweep,
134
+ * never a row naming no object. A kayan that marks its rows deleted keeps the object for a restore.
135
+ */
136
+ export const defaultRemoveFile: StorageConfig['removed'] = async (ctx) => {
137
+ if (ctx.kayan.softDelete) return ctx
138
+ const row = ctx.dataBefore ?? ctx.data
139
+ if (typeof row.key === 'string') await adapterOf({ kayan: ctx.kayan, name: row.storage }).handleDelete({ key: row.key })
140
+ return ctx
141
+ }
142
+
143
+ /**
144
+ * `GET /api/<kayan>/file/<id>`: reads the row through core's read gate, where every miss is the same 404,
145
+ * then answers with the adapter's redirect or bytes under the project's `disposition`.
146
+ */
147
+ export const defaultServeFile: StorageConfig['serve'] = async ({ request, kayan, id, read }) => {
148
+ const doc = await readForFile({ kayan, id, read })
149
+ const adapter = adapterOf({ kayan, name: doc.storage })
150
+ return fileHeaders({
151
+ doc,
152
+ disposition: kayan.storage.config.disposition({ filename: doc.filename, mimeType: doc.mimeType }),
153
+ answer: ({ disposition }) => adapter.staticHandler(request, { doc, disposition }),
154
+ })
155
+ }
156
+ // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 af2cd4436012c35d6e1d868b3f5cc2efec50c61983d2559609ebec55d1b4acf5
@@ -0,0 +1,97 @@
1
+ // Where this project's files go, every piece named here and nothing taken
2
+ // from core by default: the language lays down `storage(storageConfig)`, whose
3
+ // target holds this config, and a kayan holding files names the `uploaded` and
4
+ // `removed` below in its own create and delete. It holds the secret every
5
+ // upload reference is signed with, so it is imported by the server's modules
6
+ // alone: the screens read their generated `screen.json` and never the language.
7
+ // The adapters and `defaults.ts` beside this file are copied from the
8
+ // `mithaq-storage` registry item and owned by the project: `mithaq upgrade
9
+ // --check` reports one that was edited, and an edit is merged by hand.
10
+ import { randomBytes } from 'node:crypto'
11
+ import { defaultFileSize, defaultMaxBodyBytes, type StorageConfig } from 'mithaq'
12
+ import {
13
+ defaultDisposition,
14
+ defaultKey,
15
+ defaultRemoveFile,
16
+ defaultServeFile,
17
+ defaultSweep,
18
+ defaultUploaded,
19
+ defaultUploadInstructions,
20
+ defaultVerify,
21
+ } from './defaults.ts'
22
+ import { local } from './local.ts'
23
+ import { r2 } from './r2.ts'
24
+
25
+ const production = process.env.NODE_ENV === 'production'
26
+ /** A production boot names every setting below, so none falls back where it would surprise anyone. */
27
+ const required = (name: string, fallback: string) => {
28
+ const value = process.env[name]
29
+ if (value) return value
30
+ if (production) throw new Error(`A production boot needs ${name}: ${explained[name]}`)
31
+ return fallback
32
+ }
33
+ const explained: Record<string, string> = {
34
+ STORAGE_SECRET: 'every upload reference is signed with it.',
35
+ STORAGE_PREFIX: 'the folder of the bucket this project owns, which every key starts with and the sweep lists alone.',
36
+ STORAGE_ADAPTER: 'which adapter new uploads go to, "r2" or "local", named so a missing setting never sends files to the server\'s own disk.',
37
+ }
38
+
39
+ /**
40
+ * What upload references and local upload tokens are signed with. In
41
+ * development a fresh random one per process does: nobody can forge a
42
+ * reference with it, and an upload in flight across a restart is asked for
43
+ * again, since a reference lasts an hour. Said once, so nobody runs several
44
+ * instances on it by accident.
45
+ */
46
+ const secret = required('STORAGE_SECRET', randomBytes(32).toString('base64url'))
47
+ if (!process.env.STORAGE_SECRET) console.warn('STORAGE_SECRET is not set, so this process signs uploads with a secret of its own, which another process refuses.')
48
+
49
+ const prefix = required('STORAGE_PREFIX', 'mithaq/asas')
50
+
51
+ /** R2's four settings, all or none. */
52
+ const r2Settings = ['R2_ACCOUNT_ID', 'R2_BUCKET', 'R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY'] as const
53
+ const r2Unset = r2Settings.filter((name) => !process.env[name])
54
+ if (r2Unset.length && r2Unset.length < r2Settings.length) {
55
+ throw new Error(`R2 is half set: ${r2Unset.join(', ')} ${r2Unset.length === 1 ? 'is' : 'are'} missing. Set all four, or none.`)
56
+ }
57
+
58
+ /** Every adapter by the name a row's `storage` column holds: R2 where its four settings are set, and a folder always. */
59
+ const adapters: StorageConfig['adapters'] = {
60
+ local: local({ dir: process.env.STORAGE_DIR ?? '.storage', secret, prefix }),
61
+ ...(!r2Unset.length && {
62
+ r2: r2({
63
+ accountId: process.env.R2_ACCOUNT_ID!,
64
+ bucket: process.env.R2_BUCKET!,
65
+ accessKeyId: process.env.R2_ACCESS_KEY_ID!,
66
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
67
+ prefix,
68
+ }),
69
+ }),
70
+ }
71
+
72
+ /** Where new uploads go, named: the folder in development unless told otherwise. */
73
+ const adapter = required('STORAGE_ADAPTER', 'local')
74
+ if (!Object.hasOwn(adapters, adapter)) {
75
+ throw new Error(`STORAGE_ADAPTER names "${adapter}", which is none of ${Object.keys(adapters).map((one) => `"${one}"`).join(', ')}. R2 needs its four settings.`)
76
+ }
77
+
78
+ export const storageConfig: StorageConfig = {
79
+ secret,
80
+ adapters,
81
+ adapter,
82
+ // The largest file a kayan holds when its targets.storage names no fileSize.
83
+ fileSize: defaultFileSize,
84
+ // The largest instructions request read; files themselves never come this way.
85
+ maxBodyBytes: defaultMaxBodyBytes,
86
+ key: defaultKey,
87
+ verify: defaultVerify,
88
+ disposition: defaultDisposition,
89
+ sweep: defaultSweep,
90
+ instructions: defaultUploadInstructions,
91
+ uploaded: defaultUploaded,
92
+ removed: defaultRemoveFile,
93
+ serve: defaultServeFile,
94
+ // What a file route threw that is no refusal, before it answers 500. The
95
+ // console is this project's log; a project with an error tracker names it here.
96
+ onError: console.error,
97
+ }
@@ -0,0 +1,143 @@
1
+ // A folder on this machine as a mithaq storage adapter, for development and CI. A folder has
2
+ // no signed URLs, so an upload is a PUT to `<route>/<token>`, the token signed for one key,
3
+ // type and size; the bytes become the object only when their count is exactly the size signed.
4
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
5
+ import { createReadStream } from 'node:fs'
6
+ import { link, mkdir, open, readdir, rm, stat } from 'node:fs/promises'
7
+ import { dirname, relative, resolve, sep } from 'node:path'
8
+ import { Readable } from 'node:stream'
9
+ import type { StorageAdapter } from 'mithaq'
10
+
11
+ export type LocalOptions = {
12
+ dir: string
13
+ /** What the upload tokens are signed with, 32 characters at least. */
14
+ secret: string
15
+ /** The folder under `dir` this project's keys start with, and the one the sweep lists. */
16
+ prefix: string
17
+ /** The path the upload route answers under, `/api/storage/local` unless named. */
18
+ route?: string
19
+ }
20
+
21
+ const tokenSeconds = 600
22
+
23
+ type Token = { key: string; type: string; size: number; exp: number }
24
+
25
+ const refused = (status: number, message: string) => Response.json({ message }, { status })
26
+
27
+ /**
28
+ * The local adapter. A key that leaves `dir`, or holds an empty, `.` or `..` part, a backslash
29
+ * or a NUL, is refused before any file is touched, whoever handed it in.
30
+ */
31
+ export const local = ({ dir, secret, prefix, route = '/api/storage/local' }: LocalOptions): StorageAdapter => {
32
+ if (secret.length < 32) throw new Error('The local storage adapter signs its uploads with a secret of 32 characters at least.')
33
+ if (!prefix || prefix.split('/').some((part) => !part || part === '.' || part === '..')) {
34
+ throw new Error(`The local storage adapter's prefix ${JSON.stringify(prefix)} names no folder. Name one, such as "mithaq/asas".`)
35
+ }
36
+ const root = resolve(dir)
37
+ const pathOf = (key: string) => {
38
+ if (typeof key !== 'string' || !key || /[\\\0]/.test(key) || key.split('/').some((part) => part === '' || part === '.' || part === '..')) {
39
+ throw new Error(`The key ${JSON.stringify(key)} names no file inside the storage folder.`)
40
+ }
41
+ const full = resolve(root, key)
42
+ if (!full.startsWith(root + sep)) throw new Error(`The key ${JSON.stringify(key)} names no file inside the storage folder.`)
43
+ return full
44
+ }
45
+ const sign = (body: string) => createHmac('sha256', secret).update(body).digest('base64url')
46
+ const tokenOf = (token: Token) => {
47
+ const body = Buffer.from(JSON.stringify(token)).toString('base64url')
48
+ return `${body}.${sign(body)}`
49
+ }
50
+ const held = (token: string): Token | undefined => {
51
+ const [body = '', signature = ''] = token.split('.')
52
+ const given = Buffer.from(signature)
53
+ const expected = Buffer.from(sign(body))
54
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) return undefined
55
+ const read = JSON.parse(Buffer.from(body, 'base64url').toString()) as Token
56
+ return read.exp * 1000 < Date.now() ? undefined : read
57
+ }
58
+ return {
59
+ prefix,
60
+ uploadInstructions: {
61
+ generate: async ({ filesize, mimeType, key }) => {
62
+ pathOf(key)
63
+ const token = tokenOf({ key, type: mimeType, size: filesize, exp: Math.floor(Date.now() / 1000) + tokenSeconds })
64
+ return { type: 'http', request: { method: 'PUT', url: `${route}/${token}`, headers: { 'Content-Type': mimeType } } }
65
+ },
66
+ },
67
+ handler: async (request) => {
68
+ const { pathname } = new URL(request.url)
69
+ if (request.method !== 'PUT' || !pathname.startsWith(`${route}/`)) return null
70
+ const token = held(pathname.slice(route.length + 1))
71
+ if (!token) return refused(403, 'The upload token is not one this server signed, or it has expired.')
72
+ if (request.headers.get('content-type') !== token.type) return refused(400, 'The upload is not of the type that was signed.')
73
+ let path: string
74
+ try {
75
+ path = pathOf(token.key)
76
+ } catch {
77
+ return refused(400, 'The upload names no file inside the storage folder.')
78
+ }
79
+ await mkdir(dirname(path), { recursive: true })
80
+ const partial = `${path}.${randomUUID()}.part`
81
+ const file = await open(partial, 'wx')
82
+ let written = 0
83
+ try {
84
+ try {
85
+ const reader = request.body?.getReader()
86
+ while (reader) {
87
+ const { done, value } = await reader.read()
88
+ if (done) break
89
+ written += value.byteLength
90
+ if (written > token.size) return refused(400, 'The upload is larger than the size that was signed.')
91
+ await file.write(value)
92
+ }
93
+ } finally {
94
+ await file.close()
95
+ }
96
+ if (written !== token.size) return refused(400, 'The upload is not the size that was signed.')
97
+ // A second PUT with the same token must not change an object its create already
98
+ // verified: a link fails where the object exists, where a rename would replace it.
99
+ try {
100
+ await link(partial, path)
101
+ } catch (error) {
102
+ if ((error as { code?: unknown }).code === 'EEXIST') return refused(409, 'This upload was already sent.')
103
+ throw error
104
+ }
105
+ return new Response(null, { status: 204 })
106
+ } finally {
107
+ await rm(partial, { force: true })
108
+ }
109
+ },
110
+ handleDelete: async ({ key }) => {
111
+ await rm(pathOf(key), { force: true })
112
+ },
113
+ staticHandler: async (_request, { doc }) => {
114
+ const path = pathOf(doc.key)
115
+ const { size } = await stat(path)
116
+ return new Response(Readable.toWeb(createReadStream(path)) as ReadableStream, { status: 200, headers: { 'Content-Length': String(size) } })
117
+ },
118
+ head: async ({ key }) => {
119
+ const found = await stat(pathOf(key)).catch(() => undefined)
120
+ return found?.isFile() ? { size: found.size } : null
121
+ },
122
+ readHead: async ({ key, bytes }) => {
123
+ const file = await open(pathOf(key))
124
+ try {
125
+ const buffer = new Uint8Array(bytes)
126
+ const { bytesRead } = await file.read(buffer, 0, bytes, 0)
127
+ return buffer.subarray(0, bytesRead)
128
+ } finally {
129
+ await file.close()
130
+ }
131
+ },
132
+ // The prefix's folder alone, so another project sharing `dir` keeps its files.
133
+ list: async function* () {
134
+ const entries = await readdir(resolve(root, prefix), { recursive: true, withFileTypes: true }).catch(() => [])
135
+ for (const entry of entries) {
136
+ if (!entry.isFile()) continue
137
+ const path = resolve(entry.parentPath, entry.name)
138
+ yield { key: relative(root, path).split(sep).join('/'), lastModified: (await stat(path)).mtime }
139
+ }
140
+ },
141
+ }
142
+ }
143
+ // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 86a4471247d69e5f9d5427bb88decdb6d05a0fe5b7252ea88b47e37484dd510c
@@ -0,0 +1,5 @@
1
+ import { s3, type S3Options } from './s3.ts'
2
+
3
+ export const r2 = ({ accountId, ...rest }: Omit<S3Options, 'endpoint' | 'region'> & { accountId: string }) =>
4
+ s3({ ...rest, endpoint: `https://${accountId}.r2.cloudflarestorage.com`, region: 'auto' })
5
+ // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 3f592d20f1b51223f76dba6cc10eb9903abcf5084fe8bec1424e5f929769acff
@@ -0,0 +1,91 @@
1
+ // An S3 bucket as a mithaq storage adapter, over aws4fetch, so it runs on Node, Bun and
2
+ // Workers alike and reaches AWS S3, Cloudflare R2, MinIO and Backblaze B2 the same way.
3
+ import { AwsClient } from 'aws4fetch'
4
+ import type { StorageAdapter } from 'mithaq'
5
+
6
+ export type S3Options = {
7
+ /** The service's address without the bucket, such as `https://s3.eu-west-1.amazonaws.com`. */
8
+ endpoint: string
9
+ bucket: string
10
+ region: string
11
+ accessKeyId: string
12
+ secretAccessKey: string
13
+ /**
14
+ * The bucket folder this project owns, such as `mithaq/asas`, with no slash at either end.
15
+ * The sweep lists nothing outside it, so a bucket other apps share keeps their objects.
16
+ */
17
+ prefix: string
18
+ /** Where the bucket is read publicly, for a kayan that writes `public: true`. */
19
+ publicURL?: string
20
+ }
21
+
22
+ const putSeconds = 600
23
+ const getSeconds = 300
24
+
25
+ const pathOf = (key: string) => key.split('/').map(encodeURIComponent).join('/')
26
+
27
+ const entities: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
28
+ const text = (xml: string, tag: string) => xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => entities[name]!)
29
+
30
+ /**
31
+ * The S3 adapter: uploads are signed PUTs bound to the declared type and size, and downloads
32
+ * are 302s to a signed GET that carries the project's disposition.
33
+ */
34
+ export const s3 = ({ endpoint, bucket, region, accessKeyId, secretAccessKey, prefix, publicURL }: S3Options): StorageAdapter => {
35
+ if (!prefix || prefix.startsWith('/') || prefix.endsWith('/') || prefix.split('/').some((part) => !part || part === '.' || part === '..')) {
36
+ throw new Error(`The S3 adapter's prefix ${JSON.stringify(prefix)} names no folder of the bucket. Name one, such as "mithaq/asas".`)
37
+ }
38
+ const client = new AwsClient({ accessKeyId, secretAccessKey, service: 's3', region })
39
+ const base = `${endpoint.replace(/\/+$/, '')}/${bucket}`
40
+ const urlOf = (key: string) => `${base}/${pathOf(key)}`
41
+ const signed = async (url: string, init: { method: string; headers?: Record<string, string> }) =>
42
+ (await client.sign(url, { ...init, aws: { signQuery: true, allHeaders: true } })).url
43
+ return {
44
+ prefix,
45
+ uploadInstructions: {
46
+ // Signing the type and size makes the bucket refuse any other body, and signing
47
+ // `If-None-Match: *` makes it refuse a second PUT rather than replace a verified object.
48
+ generate: async ({ filesize, mimeType, key }) => {
49
+ const headers = { 'Content-Type': mimeType, 'Content-Length': String(filesize), 'If-None-Match': '*' }
50
+ return { type: 'http', request: { method: 'PUT', url: await signed(`${urlOf(key)}?X-Amz-Expires=${putSeconds}`, { method: 'PUT', headers }), headers } }
51
+ },
52
+ },
53
+ handleDelete: async ({ key }) => {
54
+ const answered = await client.fetch(urlOf(key), { method: 'DELETE' })
55
+ if (!answered.ok && answered.status !== 404) throw new Error(`S3 refused to delete an object: ${answered.status}.`)
56
+ },
57
+ generateURL: ({ key }) => {
58
+ if (!publicURL) throw new Error('This S3 adapter was given no publicURL, so it has no public address for a file.')
59
+ return `${publicURL.replace(/\/+$/, '')}/${pathOf(key)}`
60
+ },
61
+ staticHandler: async (_request, { doc, disposition }) => {
62
+ const query = new URLSearchParams({ 'X-Amz-Expires': String(getSeconds), 'response-content-disposition': disposition, 'response-content-type': doc.mimeType })
63
+ return new Response(null, { status: 302, headers: { Location: await signed(`${urlOf(doc.key)}?${query}`, { method: 'GET' }) } })
64
+ },
65
+ head: async ({ key }) => {
66
+ const answered = await client.fetch(urlOf(key), { method: 'HEAD' })
67
+ if (answered.status === 404) return null
68
+ if (!answered.ok) throw new Error(`S3 answered a HEAD with ${answered.status}.`)
69
+ return { size: Number(answered.headers.get('content-length')) }
70
+ },
71
+ readHead: async ({ key, bytes }) => {
72
+ const answered = await client.fetch(urlOf(key), { headers: { Range: `bytes=0-${bytes - 1}` } })
73
+ if (!answered.ok) throw new Error(`S3 answered a ranged GET with ${answered.status}.`)
74
+ return new Uint8Array(await answered.arrayBuffer()).subarray(0, bytes)
75
+ },
76
+ list: async function* () {
77
+ let token: string | undefined
78
+ do {
79
+ const query = new URLSearchParams({ 'list-type': '2', prefix: `${prefix}/`, ...(token && { 'continuation-token': token }) })
80
+ const answered = await client.fetch(`${base}?${query}`)
81
+ if (!answered.ok) throw new Error(`S3 answered a list with ${answered.status}.`)
82
+ const xml = await answered.text()
83
+ for (const [, one] of xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)) {
84
+ yield { key: text(one!, 'Key')!, lastModified: new Date(text(one!, 'LastModified')!) }
85
+ }
86
+ token = text(xml, 'IsTruncated') === 'true' ? text(xml, 'NextContinuationToken') : undefined
87
+ } while (token)
88
+ },
89
+ }
90
+ }
91
+ // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 a6b1f7b3a13ef66fb811f35844279694bfb8f3d08657170fa98e882bc0f5e079
@@ -0,0 +1,59 @@
1
+ // UploadThing as a mithaq storage adapter for files already stored there: it reads, signs a
2
+ // short-lived address and deletes by key, and never uploads, so new files go to another adapter.
3
+ import type { StorageAdapter } from 'mithaq'
4
+
5
+ export type UploadThingOptions = {
6
+ /** `UPLOADTHING_TOKEN`, the base64 JSON that names the API key and the app. */
7
+ token: string
8
+ /** The fetch every request goes through, which a test replaces. */
9
+ fetch?: typeof fetch
10
+ }
11
+
12
+ const getSeconds = 300
13
+ const api = 'https://api.uploadthing.com/v6'
14
+
15
+ export const uploadthing = ({ token, fetch: send = fetch }: UploadThingOptions): StorageAdapter => {
16
+ const { apiKey, appId } = JSON.parse(Buffer.from(token, 'base64').toString()) as { apiKey: string; appId: string }
17
+ const fileURL = (key: string) => `https://${appId}.ufs.sh/f/${encodeURIComponent(key)}`
18
+ const call = async (path: string, body: unknown) => {
19
+ const answered = await send(`${api}/${path}`, {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json', 'x-uploadthing-api-key': apiKey },
22
+ body: JSON.stringify(body),
23
+ })
24
+ if (!answered.ok) throw new Error(`UploadThing answered ${path} with ${answered.status}.`)
25
+ return answered.json() as Promise<Record<string, unknown>>
26
+ }
27
+ return {
28
+ // UploadThing chose these keys and lists none of them, so no sweep ever reaches them.
29
+ prefix: '',
30
+ uploadInstructions: {
31
+ generate: async () => {
32
+ throw new Error('The UploadThing adapter reads and deletes the files already there, and uploads none. Send new uploads to another adapter.')
33
+ },
34
+ },
35
+ handleDelete: async ({ key }) => {
36
+ await call('deleteFiles', { fileKeys: [key] })
37
+ },
38
+ generateURL: ({ key }) => fileURL(key),
39
+ // UploadThing's signed address takes no disposition, so the stored type stands and an SVG
40
+ // stored there is served to draw, not to download.
41
+ staticHandler: async (_request, { doc }) => {
42
+ const { url } = await call('requestFileAccess', { fileKey: doc.key, expiresIn: getSeconds })
43
+ if (typeof url !== 'string') throw new Error('UploadThing answered requestFileAccess with no url.')
44
+ return new Response(null, { status: 302, headers: { Location: url } })
45
+ },
46
+ head: async ({ key }) => {
47
+ const answered = await send(fileURL(key), { method: 'HEAD' })
48
+ if (answered.status === 404) return null
49
+ if (!answered.ok) throw new Error(`UploadThing answered a HEAD with ${answered.status}.`)
50
+ return { size: Number(answered.headers.get('content-length')) }
51
+ },
52
+ readHead: async ({ key, bytes }) => {
53
+ const answered = await send(fileURL(key), { headers: { Range: `bytes=0-${bytes - 1}` } })
54
+ if (!answered.ok) throw new Error(`UploadThing answered a ranged GET with ${answered.status}.`)
55
+ return new Uint8Array(await answered.arrayBuffer()).subarray(0, bytes)
56
+ },
57
+ }
58
+ }
59
+ // mithaq-admin b06d8d335d306c07556f8f2b81fba20ba7ded198 edb513da5b7d5ee1a405e8943b00bd25fa0b1cbf0c6c3d1a2854deaea95b8564
@@ -0,0 +1,33 @@
1
+ // The dashboard's Activity page: every change in the organization, read as the
2
+ // viewer from the log's `find`, which answers this tenant's entries alone, of
3
+ // the kayan the viewer lists. A file of its own beside `activity.tsx`, since the
4
+ // shell it draws in holds the app's own pieces.
5
+ import { useEffect, useState } from 'react'
6
+ import { useAccess, useLocale } from 'mithaq/admin/react'
7
+ import { ActivityLog, filtersOf, type ActivityQuery } from './activity.tsx'
8
+ import { AppShell } from './app-shell.tsx'
9
+
10
+ /**
11
+ * The page, filtered at first by what its link names, such as one record's
12
+ * entries from that record's panel. The link is read once the page is in the
13
+ * browser, since the server renders no location.
14
+ */
15
+ export function ActivityPage() {
16
+ const { t } = useLocale()
17
+ const people = useAccess().collections?.user?.read === true
18
+ const [query, setQuery] = useState<ActivityQuery>()
19
+ useEffect(() => setQuery(filtersOf({ search: window.location.search, people })), [])
20
+ return (
21
+ <AppShell
22
+ regions={{
23
+ header: <h1>{t('auditLog.activity', 'Activity')}</h1>,
24
+ main: (
25
+ <section data-dashboard="activity" className="grid gap-4">
26
+ <p className="text-muted-foreground">{t('auditLog.about', 'Every change in this organization, newest first.')}</p>
27
+ {query ? <ActivityLog query={query} onQuery={setQuery} /> : <p className="text-muted-foreground text-sm">{t('auditLog.loading', 'Loading activity…')}</p>}
28
+ </section>
29
+ ),
30
+ }}
31
+ />
32
+ )
33
+ }