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,1056 @@
1
+ # Working in a mithaq project
2
+
3
+ This is the guide to a project made by `bun create mithaq`: getting it running, where each file lives, adding to it, and running it in production.
4
+
5
+ ## Getting started
6
+
7
+ This walkthrough takes you from an empty folder to a running project with your own kayan in its admin. It takes about an hour.
8
+
9
+ You need Bun 1.3, Node.js 24 or later, and PostgreSQL 15 or later. Docker is the quickest way to get Postgres.
10
+
11
+ ### 1. Create the project
12
+
13
+ ```sh
14
+ bun create mithaq my-app
15
+ ```
16
+
17
+ This copies the template into `my-app`, pins `mithaq` to the published package, and installs with the package manager that ran it (`npm create mithaq my-app` works too). A project installs with npm, pnpm 10.6 or later, Yarn 1 or Bun 1.3; Yarn 2 and later install a copy of mithaq for each workspace of a monorepo, which the build refuses. Started by any other, it skips the install and tells you to run it yourself.
18
+
19
+ A project starts minimal: the language, the asas, the `auth` feature and the `tags` example. `--full` adds every add-on, the whole worked example this guide walks through, and `--with roles,settings` picks some, each with the add-ons it needs. Asked with neither, it offers minimal, full or pick.
20
+
21
+ | Add-on | What it brings |
22
+ | --- | --- |
23
+ | `roles` | Roles a tenant writes itself (`roles`), who holds which (`memberRoles`) and the Members page. Without it, a member holds what Better Auth's own `member.role` names: `owner` and `admin` every key, `member` what `packages/mithaq/src/roles/default-member-roles.ts` lists. |
24
+ | `audit-log` | The `auditLog` kayan, the `audit` step that writes one row per change, the activity panel on each edit page, the course's Activity tab, the dashboard's Activity page (searched by text, person and field, filtered by kayan, action and day, for a holder of `auditLog.find`: the page asks the same key as the panel, and the log's read, scoped to the active organization and to the kayan the reader lists, is the boundary), and the backoffice's staff history (`backofficeLog`: impersonations, backoffice roles set and bans, read by `super_admin` and `explorer`). Without it the backoffice writes those as server log lines alone. |
25
+ | `settings` | The `settings` global, one row per organization. |
26
+ | `rich-text` | The `richText` kind, its Lexical nodes (`packages/mithaq/src/mithaq/rich-text-nodes.ts`) and the editor. Without it, no Lexical is installed. |
27
+ | `uploads` | The `upload` kind, the storage target and its adapters (`packages/mithaq/src/storage/`). Without it, no storage setting is read. |
28
+ | `media` | The organization's images (`media`), which an upload field points at. A holder of `media.create` reads every one; anyone else only what the features pointing at images hand it, such as the courses add-on's covers of the courses they read. Needs `uploads`. |
29
+ | `courses` | The worked example: courses, sessions and enrollments, each course with a cover from the media, and the business roles that work them. Needs `media`, `roles`, `audit-log` and `rich-text`. |
30
+ | `backoffice` | The backoffice console, `apps/asas-backoffice`. A single package cannot hold it. |
31
+
32
+ It also asks how the project holds its people, or takes `--organizations`, `--show-organization`, `--sign-up` and `--organization-name` (with `--organization-name-ar`): one organization made by its owner on first run and joined by invitation (the default, and the worked example's: a school), named in code and shown by name or never (`--show-organization no`), or many, each sign-up founding its own. [Organization modes](#organization-modes) has the whole story.
33
+
34
+ `rich-text` and `uploads` bring words of the language, a kind or a target, so a project's vocabulary is the one its add-ons make; every other add-on brings kayan and pages alone. A project short of an add-on with tables ships no migration: write its first with `bunx mithaq migrate --name init` before it first deploys (see [Running in production](#running-in-production)). No command adds an add-on to a project yet; [Add an add-on](#add-an-add-on) says how to by hand.
35
+
36
+ What you get is a workspace:
37
+
38
+ | Folder | What it is |
39
+ | --- | --- |
40
+ | `packages/mithaq` | The language, `@my-app/mithaq`. `src/mithaq.ts` declares its locales, its arkan (singular rukn, a pillar: a pluggable part that brings kinds, rules or fields), its targets and its screens. Beside it sit the Better Auth setup (`src/mithaq/auth/`), the roles (`src/roles/`), storage, the shared screen pieces and everything else the language or more than one feature reads. |
41
+ | `packages/features/<feature>` | One package per feature, `@my-app/<feature>`, each holding one or more kayan (an entity, one data type), each kayan in a folder of its own, such as `packages/features/tags/tags/`. |
42
+ | `packages/asas` | The asas, the runtime: `src/asas.ts` builds it from every feature's kayan. It also holds `src/migrations/`, `src/messages/`, each screen's own pages and `src/seed.ts`, and every `mithaq` command runs here. |
43
+ | `apps/asas-tanstack` | The tenant app, built on TanStack Start. It serves the dashboard at `/dashboard` and the portal at `/portal`, on `http://localhost:5173`. |
44
+ | `apps/asas-backoffice` | With the backoffice add-on, the backoffice console, for staff who work across every organization, on `http://localhost:5174`. |
45
+
46
+ At the root it also writes `AGENTS.md`, `CLAUDE.md` and this guide, `README.md`, for you and a coding agent, the agent's workflow skills in `.claude/`, a `.gitignore`, a `tsconfig.json`, and the settings pnpm and Yarn read (`pnpm-workspace.yaml`, `.yarnrc.yml`).
47
+
48
+ It writes the same `.env` into `packages/asas`, `apps/asas-tanstack` and `apps/asas-backoffice`, readable only by you:
49
+
50
+ ```sh
51
+ DATABASE_URL=postgres://postgres:postgres@localhost:5432/mithaq
52
+ LOCAL_AUTH_SECRET=<64 random hex characters>
53
+ BETTER_AUTH_SECRET=<64 random hex characters>
54
+ BETTER_AUTH_URL=http://localhost:5173
55
+ BACKOFFICE_URL=http://localhost:5174
56
+ DEV_ADMIN_EMAIL=admin@example.com
57
+ DEV_ADMIN_PASSWORD=dev-admin-password
58
+ # STORAGE_SECRET=
59
+ # STORAGE_PREFIX=mithaq/asas
60
+ # STORAGE_ADAPTER=local
61
+ ```
62
+
63
+ The two apps sign their sessions under one `BETTER_AUTH_SECRET`, so keep the value the same in every copy. The storage settings can stay commented out in development. [Environment variables](#environment-variables) lists every variable.
64
+
65
+ ### 2. Start Postgres
66
+
67
+ Start a Postgres that matches the `DATABASE_URL` above:
68
+
69
+ ```sh
70
+ docker run -d --name mithaq-dev -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=mithaq -p 5432:5432 postgres:17
71
+ ```
72
+
73
+ To use a Postgres you already run, create a database for the project and change `DATABASE_URL` in all three `.env` files.
74
+
75
+ ### 3. Set up the database
76
+
77
+ ```sh
78
+ cd my-app/packages/asas
79
+ bun run dev:setup --seed
80
+ ```
81
+
82
+ `dev:setup` makes a fresh development database usable in one command:
83
+
84
+ - It pushes the schema: in development, `asas.connect()` creates or changes the tables to match your kayan, and refuses any change that would lose data.
85
+ - It makes the dev admin that `DEV_ADMIN_EMAIL` and `DEV_ADMIN_PASSWORD` in `.env` name, with its address proved: the owner of "Dev admin's workspace" (with one organization through first-run's own path) and, with the backoffice console, its `super_admin`. The output names the two variables, never their values.
86
+ - With `--seed`, it fills that organization with sample rows (step 5).
87
+
88
+ It refuses unless `NODE_ENV` is `development` or unset and `DATABASE_URL`, `BETTER_AUTH_URL` and, with the backoffice console, `BACKOFFICE_URL` are on this machine, since every project's `.env.example` holds the same development password and a deploy run with plain `node` sets no `NODE_ENV`. It refuses a database that already holds a user, saying how to sign in instead, and a `DEV_ADMIN_PASSWORD` of a length sign-up would refuse.
89
+
90
+ Whenever you add or change a kayan, push the schema again:
91
+
92
+ ```sh
93
+ # development only
94
+ bun -e "const { asas } = await import('./src/asas.ts'); await asas.connect(); await asas.destroy()"
95
+ ```
96
+
97
+ `bun` reads `.env` for you, so `DATABASE_URL` is already set.
98
+
99
+ A pushed database is for development only. Production applies the migration files in `src/migrations/` instead, and refuses a database that was pushed. [Running in production](#running-in-production) explains how.
100
+
101
+ ### 4. Run both apps
102
+
103
+ ```sh
104
+ cd ../..
105
+ bun run --cwd apps/asas-tanstack dev # the tenant app, http://localhost:5173
106
+ bun run --cwd apps/asas-backoffice dev # in a second terminal: the backoffice console, http://localhost:5174
107
+ ```
108
+
109
+ Each `dev` script first runs `mithaq lock --check`. The template has no `mithaq.lock`, so this prints a notice and carries on.
110
+
111
+ ### 5. Sign in and seed
112
+
113
+ Sign in to the tenant app at `http://localhost:5173`, and to the backoffice console at `http://localhost:5174`, with `DEV_ADMIN_EMAIL` and `DEV_ADMIN_PASSWORD`.
114
+
115
+ A deploy has no dev admin, and is set up by hand, as you can here on a database `dev:setup` did not set up. With one organization, open `http://localhost:5173/auth/first-run`: the account made there owns the one organization (you name it there, unless it is never shown) and, with the backoffice console, is its first `super_admin` too. Until then every sign-up is refused, and the sign-in page sends you there. `vite dev` on this machine asks no setup code; anywhere else first-run asks the `SETUP_CODE` the server was started with.
116
+
117
+ With many organizations, open `http://localhost:5173/auth/sign-up` and sign up, then open the link the server log shows to choose your password, which proves your address. Signed in and in no organization yet, you are offered your invitations and a box to create an organization: creating it makes you its owner. The backoffice console's first administrator is made on its own `/auth/create-first-user`.
118
+
119
+ Either way the owner holds every permission in the organization: through the `administrator` role the roles add-on gives an owner, or through `owner` itself without it.
120
+
121
+ `--seed` filled the organization with sample rows of what the project holds: tags, and with the add-ons courses, sessions, enrollments, roles and settings. To fill it again, or one you set up by hand:
122
+
123
+ ```sh
124
+ cd packages/asas
125
+ bun run seed
126
+ ```
127
+
128
+ The seed takes the first user and that user's first organization, or the ones `SEED_EMAIL` and `SEED_ORGANIZATION` name, and writes every row through the asas's own operations, as that owner. Running it twice adds nothing.
129
+
130
+ The backoffice console admits backoffice staff only: the dev admin, the owner first-run made, or, with many organizations, the administrator made on its first-administrator page.
131
+
132
+ ### 6. Add your first kayan
133
+
134
+ This section adds `notes`: a title and a done flag, owned by one organization, listed and edited in the dashboard, as a feature of its own. Run each command from `packages/asas`.
135
+
136
+ #### The feature
137
+
138
+ A feature is a package beside the others: `packages/features/notes/package.json`, the same as `packages/features/tags/package.json` with `"name": "@my-app/notes"`, and `packages/features/notes/index.ts`, which lists its kayan:
139
+
140
+ ```ts
141
+ import { notes } from './notes/notes.ts'
142
+
143
+ export const kayan = [notes] as const
144
+ ```
145
+
146
+ Name `mithaq` in every package by the same spec, with the same peers: two specs, even two paths to one tarball, install two copies, and `buildAsas` refuses a step the other copy sealed.
147
+
148
+ #### The kayan file
149
+
150
+ Create `packages/features/notes/notes/notes.ts`:
151
+
152
+ ```ts
153
+ import { defaultFields, defaultOperations } from 'mithaq'
154
+ import { defineKayan, organization } from '@my-app/mithaq'
155
+ import { appPage, danger } from '@my-app/mithaq/mithaq/pages.ts'
156
+
157
+ export const notes = defineKayan({
158
+ name: 'notes',
159
+ messages: {
160
+ singular: { en: 'Note', ar: 'ملاحظة' },
161
+ plural: { en: 'Notes', ar: 'الملاحظات' },
162
+ },
163
+ targets: {
164
+ // What the dashboard screen draws of this kayan.
165
+ dashboard: {
166
+ useAsTitle: 'title',
167
+ pages: {
168
+ list: { path: '/notes', page: appPage([{ layout: 'table', main: [{ list: { columns: ['title', 'createdAt'], search: ['title'] } }] }]) },
169
+ create: { path: '/notes/new', page: appPage([{ layout: 'record', main: [{ form: ['title', 'done'] }] }]) },
170
+ edit: { path: '/notes/:id', page: appPage([{ layout: 'record', main: [{ form: ['title', 'done'] }], danger: danger() }]) },
171
+ },
172
+ },
173
+ // The operations this kayan answers: create, update, delete, find, findByID, count, updateMany, deleteMany.
174
+ service: { operations: defaultOperations },
175
+ },
176
+ fields: {
177
+ // id, createdAt and updatedAt.
178
+ ...defaultFields,
179
+ // Makes each note belong to the active organization.
180
+ organization,
181
+ title: {
182
+ kind: 'text',
183
+ label: { en: 'Title', ar: 'العنوان' },
184
+ rules: { required: true, trim: true, maxLength: 120 },
185
+ },
186
+ done: {
187
+ kind: 'checkbox',
188
+ label: { en: 'Done', ar: 'منجزة' },
189
+ },
190
+ },
191
+ })
192
+ ```
193
+
194
+ A few things to notice:
195
+
196
+ - Every label carries one string per locale the language declares, here `en` and `ar`. Leaving one out is a type error.
197
+ - Nothing is implicit. The fields come from `defaultFields` and the operations from `defaultOperations`, which you spread yourself and can change. Leave `count` out of the operations, for example, and the kayan has no count.
198
+ - The `organization` field makes `notes` tenant-owned. On every operation, core limits reads, updates and deletes to the signed-in user's active organization, and fills the field from that organization on create. A caller cannot set or change it. A kayan without the field is shared by every organization. [Tenancy](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/runner/README.md) has the details.
199
+
200
+ #### Register it
201
+
202
+ Add `"@my-app/notes": "*"` to the dependencies in `packages/asas/package.json`, run `bun install`, and spread the feature's kayan into the build in `packages/asas/src/asas.ts`:
203
+
204
+ ```ts
205
+ import * as notes from '@my-app/notes'
206
+
207
+ export const asas = buildAsas({
208
+ kayan: [...auth.kayan, ...roles.kayan, ...tags.kayan, ...courses.kayan, ...auditLog.kayan, ...notes.kayan],
209
+ // ...
210
+ })
211
+ ```
212
+
213
+ Then add it to the dashboard screen's `kayan` list in `packages/mithaq/src/mithaq.ts`:
214
+
215
+ ```ts
216
+ export const dashboardScreen = screenTarget({
217
+ // ...
218
+ kayan: ['courses', 'sessions', 'enrollments', 'tags', 'media', 'roles', 'memberRoles', 'settings', 'user', 'organization', 'auditLog', 'notes'],
219
+ // ...
220
+ })
221
+ ```
222
+
223
+ A screen's `kayan` list is free to change. It is not part of the frozen vocabulary that `mithaq.lock` records (see [The `mithaq` command](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/cli/README.md#lock-and-lock---check)).
224
+
225
+ #### Update the database
226
+
227
+ Push the new table into the dev database:
228
+
229
+ ```sh
230
+ bun -e "const { asas } = await import('./src/asas.ts'); await asas.connect(); await asas.destroy()"
231
+ ```
232
+
233
+ #### Generate the screens and the copy
234
+
235
+ ```sh
236
+ bunx mithaq generate screens
237
+ bunx mithaq generate messages
238
+ ```
239
+
240
+ `generate screens` rewrites the dashboard's `screen.json` and `types.ts` in `apps/asas-tanstack`. `generate messages` writes `src/messages/notes.json`, the copy file for this kayan, which you can edit. To use it in the tenant app's translations, import it into `scopes` in `apps/asas-tanstack/src/copy.ts`, the way `tags` is.
241
+
242
+ #### See it in the admin
243
+
244
+ Restart the tenant app's dev server and open `http://localhost:5173/dashboard/notes`. "Notes" is in the nav. You can create, list, edit and delete notes, and each one belongs to "Admin org".
245
+
246
+ You see it because the `administrator` role holds every key. Any other role needs the note keys added to it in `packages/mithaq/src/roles/tenant-roles.ts`: `notes.find`, `notes.findByID`, `notes.create` and so on, plus a key per readable field, such as `notes.fields.title`. `mithaq screens` prints the key each nav entry needs.
247
+
248
+ #### Write the migration
249
+
250
+ The push only changes your dev database. Production needs a migration file, and `mithaq migrate` refuses to write one over a pushed database, because a push leaves no history. So write it against a second database built from the migrations:
251
+
252
+ ```sh
253
+ docker exec mithaq-dev createdb -U postgres mithaq_migrations
254
+ export DATABASE_URL=postgres://postgres:postgres@localhost:5432/mithaq_migrations
255
+ DRIZZLE_KIT=$(node -p "path.join(path.dirname(require('module').createRequire(fs.realpathSync('node_modules/mithaq/package.json')).resolve('drizzle-kit')), 'bin.cjs')")
256
+ node "$DRIZZLE_KIT" migrate --config src/migrations/drizzle.config.ts # apply the existing migrations
257
+ bunx mithaq migrate --name add_notes # write src/migrations/<timestamp>_add_notes/
258
+ node "$DRIZZLE_KIT" migrate --config src/migrations/drizzle.config.ts # apply it
259
+ bunx mithaq migrate --check # "The database schema is up to date."
260
+ ```
261
+
262
+ The `mithaq` command runs under Node and does not read `.env`, so `DATABASE_URL` must be exported in the shell. Commit the new folder along with the code.
263
+
264
+ #### Check everything
265
+
266
+ ```sh
267
+ bunx mithaq generate --check
268
+ ```
269
+
270
+ This runs the lock check, every generator in check mode, and the migration check, and stops at the first failure. Run it in CI. [The `mithaq` command](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/cli/README.md) describes every command.
271
+
272
+ ### Next
273
+
274
+ - [Project structure](#project-structure): where each file lives.
275
+ - [Adding to the project](#adding-to-the-project): a kayan, a feature on a kayan, a step and a screen component.
276
+ - [Running in production](#running-in-production): migrations, environment variables, storage and deploying.
277
+ - [The language reference](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/README.md): the language, kinds, rules, targets and arkan, and your own kinds and targets.
278
+ - [Tenancy](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/runner/README.md): organizations, scoping and indexes.
279
+
280
+ ## Project structure
281
+
282
+ `bun create mithaq my-app` copies the template at the version of `mithaq` it was published with, pins `"mithaq": "npm:@drhomet/mithaq@<version>"` and `"mithaq-admin": "npm:@drhomet/mithaq-admin@<version>"` at that one version, names the project's packages under its own scope (`@my-app/...`), writes fresh secrets into three `.env` files and runs `bun install`. The project is a workspace of three steps, each named after what it holds, and each imports only the steps before it:
283
+
284
+ 1. **The language** (`packages/mithaq`): `defineMithaq`, its targets and arkan, and what the language or more than one feature reads.
285
+ 2. **The features** (`packages/features/*`): one package per feature, each holding one or more kayan.
286
+ 3. **The asas** (`packages/asas`): `buildAsas` over every feature's kayan, with the migrations, the copy and the seed.
287
+
288
+ ```
289
+ my-app/
290
+ README.md this guide
291
+ AGENTS.md the rules for a coding agent working in the project
292
+ CLAUDE.md one line, @AGENTS.md, so Claude Code reads the same rules
293
+ .claude/skills/ workflow skills for a coding agent: add a kayan, add a field, eject a piece, upgrade mithaq
294
+ package.json workspaces packages/mithaq, packages/features/*, packages/asas and apps/*; no scripts
295
+ tsconfig.json one strict config over every package's .ts and apps/*/src
296
+ .gitignore node_modules, .env, dist, .storage/
297
+ packages/
298
+ mithaq/ package "@my-app/mithaq": the language
299
+ features/
300
+ auth/ package "@my-app/auth": the Better Auth kayan and the local accounts
301
+ roles/ package "@my-app/roles": custom roles and each member's roles
302
+ courses/ package "@my-app/courses": courses, their sessions and enrollments
303
+ media/ package "@my-app/media": the organization's images
304
+ tags/ audit-log/ settings/
305
+ asas/ package "asas": the build, the migrations, the copy, each screen's own pages
306
+ apps/
307
+ asas-tanstack/ the tenant app: the dashboard and portal screens, sign-in pages, /api
308
+ asas-backoffice/ the backoffice console: the backoffice screen, for staff
309
+ ```
310
+
311
+ The language never imports a feature or the asas, and a feature imports another only through its `index.ts`, or names its kayan by name (`relationTo: 'user'`). The apps import the project only through the `asas` package (`import { asas } from 'asas'`), never by a path into `packages/`; it keeps that name because the screen host imports it by it. `packages/asas`, `apps/asas-tanstack` and `apps/asas-backoffice` each hold a `.env` with the same secrets. The backoffice app derives its own session secret from `BETTER_AUTH_SECRET`, so the two apps share one session table and never a session.
312
+
313
+ `bun create mithaq my-app --layout single` makes the same tree one package instead: `packages/mithaq/src/` and `packages/asas/src/` are both `src/`, each feature is `src/features/<feature>/`, and the tenant app's `src/` joins them. Each import of the language or a feature is then a path, and every `generate` path in `src/mithaq.ts` is written from the package's root.
314
+
315
+ The template's own tests stay in the mithaq repository. A new project has none of its `*.test.ts` files or test helpers, and writes its own.
316
+
317
+ ### `packages/mithaq`
318
+
319
+ ```
320
+ packages/mithaq/
321
+ package.json exports ".", "./auth", "./auth/backoffice-roles" and "./*" for every file under src/
322
+ components.json where mithaq eject writes the storage and step files
323
+ src/
324
+ mithaq.ts defineMithaq: locales, arkan, targets, screens, generate paths
325
+ mithaq/
326
+ auth/ the Better Auth options and instances, the backoffice session and roles
327
+ pages.ts the page trees every kayan page starts from
328
+ audit-sink.ts where the audit step hands each write
329
+ rich-text-nodes.ts
330
+ roles/ tenant-roles.ts, system-roles.ts, tenant-access.ts, grants.ts, statements.ts
331
+ ui/ the shells, layouts and pieces several screens draw
332
+ storage/ the storage config, its adapters and defaults
333
+ steps/ steps ejected from mithaq-admin
334
+ ```
335
+
336
+ **`src/mithaq.ts`** declares the language with one `defineMithaq` call and exports what it returns: `defineKayan`, `defineGlobal`, `buildAsas`, `rows`, `user`, `generate`, `locales`, `targets` and the tenant target. It also exports `organization`, the tenant target's field with a label, which every per-organization kayan places. The arkan (`base`, `richText`, the storage rukn and `versions`), the server targets (`drizzle`, `zod`, `rbac`, `tenant`, `service`) and the three screens (`dashboard`, `portal`, `backoffice`) are laid down here. `generate` names, from `packages/asas` where every `mithaq` command runs, where each writes and reads: the language the generated kayan import, the features folder, the auth kayan, the providers module, the messages and migrations folders and each screen's routes folder in an app. The language names the asas's folder (its migrations and each screen's own pages) and, with the courses add-on, its session labels by path, and imports neither.
337
+
338
+ **`src/mithaq/auth/`** is the Better Auth setup: `organizations.ts` the organization mode and who may sign up ([Organization modes](#organization-modes)); `index.ts` the options and instances (`providers`), the backoffice session, `asBackoffice` and `onBackoffice`, and the two apps' URLs; `backoffice-roles.ts` the backoffice roles staff hold on `user.role`, the columns staff read, and `secret`, the columns no backoffice role reads. It is imported as `@my-app/mithaq/auth`.
339
+
340
+ Better Auth's organization routes let any member read every member. The setup gates them by this project's keys, in the active organization alone: `list-members`, and `get-active-member-role` with a `userId`, need `member.find`; `list-invitations` needs `invitation.create`; `get-full-organization` answers the organization to every member, with its `members` empty (and loaded one at most) without `member.find` and its `invitations` empty without `invitation.create`; `get-invitation` answers the invitee without the inviter's address. Only the `administrator` role (and, without the roles add-on, `owner` and `admin`) holds `member.find`, and the Members page is served on it. When a member was last seen, stamped on their membership when a session of theirs opens or refreshes in that organization (never by an impersonation, once a day at most), is answered by `list-members`, `get-full-organization` and every other organization route that returns a membership only to a caller who also holds `member.fields.lastSeenAt`.
341
+
342
+ `organizationLimitsFor` in the same file sets the provider's limits rather than leaving its defaults: with many organizations a person founds organizations only while they belong to fewer than five, and with one nobody founds one over HTTP; an organization holds up to 10,000 members (without end with one) and 1,000 pending invitations, and the Members page reads every member a page at a time. A new session opens in the person's oldest membership (the first by id among ties), and leaving the active organization opens the next oldest.
343
+
344
+ **Access and roles.**
345
+
346
+ - `roles/tenant-roles.ts` holds the roles every organization has, written in code and never stored as rows. A key is `<kayan>.<operation>`, `<kayan>.fields.<field>` or `screen.<screen>`. Without the roles add-on they are Better Auth's own `owner`, `admin` and `member`, from `roles/default-member-roles.ts`, and the access target is `memberRoleTarget`, which answers each member what its `member.role` names in that organization.
347
+ - `roles/system-roles.ts` joins them with the backoffice roles into `systemRoles`, which the access target takes.
348
+ - `roles/tenant-access.ts` holds the row table every per-organization kayan inherits, the reader that answers an actor's roles, and the backoffice's checks; `roles/grants.ts` what the role form and the server both read.
349
+ - `membersOf` in `roles/tenant-access.ts` is whom a `user` read reaches in a tenant: every member to a holder of `member.find`, and to anyone else itself and the members holding a role its own roles name in `peopleAudience` (the instructor reads the students). A role it leaves out, a custom role included, reads itself alone, whatever `user.*` keys it holds; so does a picker or a refusal that names people through that read. A seat's student picker narrows that read to the people holding the student role, through `enrollments.student`'s `filterOptions`, and a seat for anyone else is refused; its session picker offers open sessions of open courses alone, and a seat in an archived one is refused in words of its own.
350
+ - `roles/statements.ts` holds the keys the project adds beside the kayan's own operations.
351
+
352
+ **Configurable features**, each with its add-on.
353
+
354
+ - `storage/index.ts` is the storage config. It names every function in `storage/defaults.ts`, and the adapters `local.ts`, `r2.ts`, `s3.ts` and `uploadthing.ts`. Those five files are ejected from the `mithaq-storage` item of `mithaq-admin` and end with a marker line.
355
+ - `steps/expected-updated-at.ts` is ejected from the `mithaq-expected-updated-at` item.
356
+ - `mithaq/rich-text-nodes.ts` is the one list of rich-text nodes the server and every editor read. The `richText` rukn comes from `mithaq/rich-text` and the editor from `mithaq-admin/rich-text.tsx`, and only those two load Lexical, so its packages are listed in each `package.json` that uses them; `mithaq` itself installs none.
357
+
358
+ **Shared page pieces.** `src/ui/` holds what every screen draws with: the shells (`app-shell.tsx`, `top-shell.tsx`), the layouts (`record-page.tsx`, `table-page.tsx`), the shared providers and the activity panel. A piece is named with `ui('file.tsx#Export')`, where `ui` is a `folderOf` over its folder, and the build refuses a missing file or export.
359
+
360
+ ### `packages/features`
361
+
362
+ Each feature is a package that exports its `index.ts` alone, which lists the feature's kayan as `kayan` (and `global`, for a single-row kayan); the asas imports each feature as a namespace and spreads `courses.kayan`, so the list never collides with a kayan of the same name. The media feature's `kayan` is a function of the reads that open an image to someone without `media.create`, which the asas hands it from the features pointing at images, such as `courses.coverReads`, so media imports none of them. Each kayan has a folder of its own, named after it in kebab case, holding a file of the same name that exports it:
363
+
364
+ ```
365
+ packages/features/courses/
366
+ package.json "@my-app/courses", exports ".", "./domain/*" and "./portal/*"
367
+ index.ts export const kayan = [courses, sessions, enrollments] as const, and coverReads
368
+ courses/
369
+ courses.ts export const courses = defineKayan({ name: 'courses', ... })
370
+ ui/ page pieces only this kayan draws with
371
+ sessions/ a child kayan is a sibling folder, never nested
372
+ enrollments/
373
+ domain/ plain data the asas's pages read too, which imports nothing
374
+ portal/ui/ the portal's own look: its shell, catalogue, course page and enrollments, drawn whole
375
+ ```
376
+
377
+ A kayan's `name` is camelCase (`auditLog`, `memberRoles`), and a multi-word table sets `dbName` in snake case (`audit_log`, `member_roles`). `<kayan>.access.ts`, `<kayan>.steps.ts` or `<kayan>.screens.ts` sit beside the kayan file once they outgrow it.
378
+
379
+ **`auth/user/`** holds the kayan Better Auth's tables become: `user`, `session`, `account`, `verification`, `organization`, `member`, `invitation`, `organizationRole` and `index.ts`, which groups them by plugin. `mithaq generate auth` writes them from the options in `packages/mithaq/src/mithaq/auth/index.ts`, and each starts with `Written by` and is never edited. The project's own choices for one of them go in `<kayan>.access.ts`, `<kayan>.operations.ts` and `<kayan>.screens.ts` beside it, which the generator imports on its next run.
380
+
381
+ ### `packages/asas`
382
+
383
+ ```
384
+ packages/asas/
385
+ package.json "asas": exports ".", "./messages/*", "./ui-dashboard/*"; script "seed"
386
+ mithaq.hints.json the project's answers to "is this a rename?" questions mithaq migrate asks
387
+ mithaq.lock the signed vocabulary, when the project keeps one
388
+ .env.example
389
+ src/
390
+ asas.ts buildAsas: every feature's kayan, globals, HTTP plugs, statements, auth
391
+ mithaq.ts where the mithaq command reads the language from
392
+ messages/ one JSON file of copy per kayan, written by mithaq generate messages
393
+ migrations/ written by mithaq migrate, committed
394
+ ui-dashboard/ ui-portal/ ui-backoffice/ what one screen draws, such as its home page
395
+ backoffice-http.ts seed.ts
396
+ ```
397
+
398
+ **`src/asas.ts`** builds the asas: `export const asas = buildAsas({ kayan, global, http, statements, auth })`. A feature joins the project when it is a dependency here and its `kayan` is spread into `kayan`.
399
+
400
+ **`src/messages/`** holds `<kayanName>.json` for each kayan, keyed by the kayan's name (`courses.json`, `auditLog.json`), and `mithaq.json` for the copy every kayan shares, such as the default fields' labels. `mithaq generate messages` writes them from the labels and `messages` in the kayan. The apps import them as `asas/messages/<name>.json`.
401
+
402
+ Message files nest dotted key paths and use `$value` only when a key has both its own value and children; names starting with `$` are reserved.
403
+
404
+ **`src/migrations/`** holds one folder per migration, `<timestamp>_<slug>/` with `migration.sql` and `snapshot.json`, and two files `mithaq migrate` writes and the project commits: `schema.ts`, which names every table, and `drizzle.config.ts`, which `drizzle-kit migrate --config src/migrations/drizzle.config.ts` reads. `mithaq migrate --name <slug>` sets the slug.
405
+
406
+ **The rest.** `backoffice-http.ts` is the one hand-written route, renaming an organization, which writes a Better Auth table no managed operation may write. `seed.ts` fills a database for a first look, run with `bun run seed`.
407
+
408
+ ### The two apps
409
+
410
+ Both apps are TanStack Start apps on Vite. Each app's `dev`, `build` and `preview` script runs `mithaq lock --check` in `packages/asas` first, so a red lock stops it from starting.
411
+
412
+ **`apps/asas-tanstack`** is the tenant app, at `http://localhost:5173` in development. It serves the `dashboard` and `portal` screens, the sign-in, sign-up, password and invitation pages, and every API request through one route, `src/routes/api/$.ts`, which hands the request to `asas.handler`.
413
+
414
+ ```
415
+ apps/asas-tanstack/
416
+ components.json shadcn config: where shadcn add and mithaq eject write
417
+ vite.config.ts with the mithaq/vite plugin
418
+ src/
419
+ copy.ts every copy scope the app's translator answers: each kayan's
420
+ messages JSON plus the app's own keys
421
+ routes/
422
+ __root.tsx index.tsx unauthorized.tsx auth.*.tsx
423
+ api/$.ts
424
+ (screens)/ written by mithaq generate screens
425
+ dashboard.tsx dashboard.$.tsx dashboard.index.tsx
426
+ dashboard/ screen.json types.ts files.ts
427
+ portal.tsx portal.$.tsx portal.index.tsx
428
+ portal/ screen.json types.ts files.ts
429
+ screens.tsx screen-form.tsx screen-list.tsx screen-names.ts api.ts slide.ts
430
+ links.ts locale-switch.tsx auth-frame.tsx request-headers.ts
431
+ the screen host, ejected from the mithaq-screens-host item
432
+ components.ts the components map every screen draws its controls, cells and nodes from
433
+ components/mithaq/ the pieces the project ejected from mithaq-admin, none at first
434
+ components/ui/ the shadcn primitives the pieces draw with
435
+ lib/ hooks/ shadcn's utils and hooks
436
+ router.tsx start.ts server.ts styles.css theme.ts redirect.ts quiet-abort.ts
437
+ no-organization.tsx what /unauthorized offers someone in no organization
438
+ routeTree.gen.ts written by TanStack Router
439
+ ```
440
+
441
+ The app owns its copy, its routes, its providers and its styling. It says nothing about any kayan: what a kayan is and what a screen draws of it come from the template and the generated files.
442
+
443
+ **`apps/asas-backoffice`** is the backoffice console for staff, at `http://localhost:5174` in development. It has the same shape and serves the `backoffice` screen alone. Its `server.ts` runs every request inside `asBackoffice`, so the backoffice roles are answered in this app and never in the tenant app. Its `copy.ts` re-exports the tenant app's.
444
+
445
+ ### Naming and where files go
446
+
447
+ | What | Where and how it is named |
448
+ |---|---|
449
+ | A feature | `packages/features/<kebab-name>/`, a package `@my-app/<kebab-name>` whose `index.ts` exports its `kayan` |
450
+ | A kayan | `<feature>/<kebab-name>/<kebab-name>.ts`, exporting `const <camelName> = defineKayan({ name: '<camelName>' })`; a child kayan is a sibling folder |
451
+ | A kayan's own page pieces | `<kayan>/ui/<kebab-name>.tsx`, PascalCase exports, named as `ui('<file>.tsx#<Export>')` |
452
+ | Shared page pieces | `packages/mithaq/src/ui/`, or `packages/asas/src/ui-<screen>/` for one screen |
453
+ | A step used by one kayan | in the kayan's file, beside the operation that lists it |
454
+ | A step several features share | `packages/mithaq/src/steps/<kebab-name>.ts` |
455
+ | Copy | `packages/asas/src/messages/<camelName>.json`, written by `mithaq generate messages` |
456
+ | Migrations | `packages/asas/src/migrations/<timestamp>_<slug>/`, written by `mithaq migrate --name <slug>` |
457
+ | Screens | each app's `src/routes/(screens)/`, written by `mithaq generate screens` |
458
+ | Better Auth kayan | `packages/features/auth/user/`, written by `mithaq generate auth`; the project's choices in `<kayan>.access.ts`, `<kayan>.operations.ts` and `<kayan>.screens.ts` |
459
+ | Tests | beside the code they test, as `<name>.test.ts`; shared test helpers as `test-<name>.ts` |
460
+
461
+ A file a generator writes starts with a line saying which command wrote it, such as `// Written by \`mithaq generate screens\`. Do not edit.`, and changes only by rerunning that command. A file ejected from `mithaq-admin` ends with a marker line, `// mithaq-admin <version> <hash>`, and is the project's own to edit; `mithaq upgrade --check` reports which were edited or differ from the installed package.
462
+
463
+ ## Organization modes
464
+
465
+ How the project holds its people is seven values in `packages/mithaq/src/mithaq/auth/organizations.ts`, which `create-mithaq` writes and you change by hand. In every mode a per-organization kayan places `organization` and core scopes it, so changing the mode moves no row.
466
+
467
+ | Value | What it says |
468
+ | --- | --- |
469
+ | `organizations` | `'one'`: one organization, made on `/auth/first-run`. `'many'`: anyone who signed up and proved their address founds their own, or joins one by invitation. The tenant target reads it too: in one, a start over more than one organization is refused. |
470
+ | `showOrganization` | With one, whether a page names the organization. `false`: first-run asks no name, the Members page is Users, the sidebar shows the app's name and no page names an organization. `'many'` takes `true`. |
471
+ | `signUp` | With one, who makes an account: `'invite'`, only someone holding an invitation, or `'open'`, anyone. `'many'` takes `'open'` alone. |
472
+ | `joinRole` | The role an open joiner is given, from `roles/tenant-roles.ts`, or `null`: they join with none, read nothing, and an administrator gives them a role on the Members page. That is how a project approves each joiner. A role that holds every key is refused. Without the roles add-on it is `null`. |
473
+ | `verifyEmail` | With an open sign-up, whether an account proves its address by a mailed link before it signs in (and, with one, joins). `'many'` takes `true`: anyone founds an organization there, so a sign-up must tell nobody which addresses have an account. |
474
+ | `allowedDomains` | With an open sign-up and `verifyEmail`, the only address domains that may sign up: `example.com` is that domain exactly, `*.example.com` every domain under it. An international domain is written as its `xn--` form. |
475
+ | `organizationName` | The organization's name in each locale the language reads and no other, `{ en, ar }`, the first the one every other falls back to; the language refuses to load otherwise, in every mode, since every organization's names are held to these locales. With one, it is the organization's name (`create-mithaq --organization-name`, `--organization-name-ar`). It lives in code: first-run asks none, the first request a process serves writes it onto the row, and nothing renames it over HTTP, the backoffice included. Change it here and deploy. |
476
+
477
+ **Names in every locale.** In every mode an organization holds its name in each locale of the project: `names`, a column `mithaq generate auth` writes onto the organization kayan from the auth setup's `additionalFields`, holds the JSON of one name per locale, and `name` is the default locale's, which every other falls back to. With many organizations, founding one asks its name in each locale, the default's required and the others optional, and so does the backoffice's rename. Each page names an organization in the reader's language, and each mail in each of its languages. `nameIn` in `organizations.ts` reads it.
478
+
479
+ A combination that means nothing or opens a door refuses to load, naming the line to change: `'many'` with `'invite'`, a join role, domains, no verification or no organization shown; domains without verification or by invitation; a join role that is no role. A production start with no mailer is refused in every mode, since no reset, proof or invitation would reach anyone ([Environment variables](#environment-variables): `RESEND_API_KEY` and `MAIL_FROM`).
480
+
481
+ **First run.** In one organization, nothing signs up until the owner is made. `/auth/first-run` takes a name, an address, a password and the setup code, and makes the owner, the organization, named by `organizationName`, and the owner's `administrator` role in one transaction. While the backoffice console has no administrator it makes the owner one, under the lock its own first-register takes, so the two never both make one. The account is verified, since the code is the proof, and its address is mailed once, after the answer, that an administrator account was made with it, so an address typed wrong is noticed by the mail that never came; the backoffice's first-register mails its administrator the same. Once an organization exists it answers every caller the same refusal. Set `SETUP_CODE` to a random value of 32 characters or more where the app runs (`openssl rand -hex 32`), and remove it once the app is set up: it opens nothing after that, but it need not linger.
482
+
483
+ **By invitation.** A sign-up carries the id of a pending invitation sent to that address; the invitation link's sign-up page sends it. The server accepts that invitation for the account it makes, so the person is in without a second press: at once where holding the id is the proof (one organization by invitation), or, where the sign-up proves its address, when the one link mailed for that sign-up is opened; that mail names the organization it joins. The address's own later sign-up or a password reset takes the invitation off every earlier link, so whoever signs up first with an address they invited never joins its owner to their organization. An id sent to another address, cancelled or expired by then joins nobody. Anything else is refused with one answer, before the provider looks the address up, so the refusal tells nobody whether an address has an account. The same rule holds wherever the tenant app makes an account, so a sign-in method added later cannot open a way past it. Only the backoffice console's `/admin/create-user`, which staff alone reach, makes an account without it.
484
+
485
+ **Invitations.** Each invitation is mailed in one fixed letter, its subject naming no organization and its body the organization's name escaped. An organization's name is one line of 1 to 100 characters however it is made or renamed: a line break, a control character or a direction override in it is taken out, and the backoffice's rename refuses one. Sending an invitation again needs `invitation.create` in the active organization, as a new one does. With many organizations, where anyone founds one, it is mailed only for an inviter whose own address is proved. Each organization mails at most `invitationLimits` of them, ten a minute and a hundred a day, whoever invites, counting each invitation sent again and each person added once the request succeeds (`packages/mithaq/src/mithaq/auth/index.ts`), beside ten a minute from one address. The Members page lists each pending invitation, one run out greyed, under its Invitations tab to whoever may invite, with Copy link, Resend and Cancel in each row's menu in every environment, for an address the mail does not reach: the link lets whoever holds it join as the invited address, so hand it to that person alone.
486
+
487
+ **Add person.** With one organization the Members page's first action is Add person, for whoever holds `member.create`: a name, an address and a role they may hand over, sent in one request to `/organization/add-person`, served on the tenant instance with one organization alone. It makes the account with no password, the membership in the caller's own active organization (a body naming another is not read) and the role, under the same rule a role assignment keeps (a role whose every key the caller holds), and mails a link to choose the password that lasts 48 hours; with no mailer the link is answered for the administrator to hand over. Nobody ever knows another's password. An account whose address nobody proved, which may be someone else's squat, is handed to its mailbox: its password and sessions end before it is added. An account whose address is proved, or a staff member's, is never added without its word: it is sent an invitation instead, and an existing member is refused. A person added has joined once, as an open sign-up records it, so removed they never join again by one. Invite by link stays beside it. Each member's row offers Send a new password link to the same holder: `/organization/send-password-link` mails the member, never the caller, a new 48-hour link to choose their password (with no mailer it is answered on this machine alone, and only for an account that holds no password yet; any account with a password is refused, proved or invited and unproved alike, so the link never hands a delegate holding `member.create` an account someone already holds), at most once in five minutes and counted against the organization's mail, and refuses a staff member and answers anyone outside the organization as nobody. The member's password stands until they open it.
488
+
489
+ **Open.** With `verifyEmail`, and always with many organizations, the sign-up asks a name and an address alone, answers with no session and mails a link to choose the account's password, and it answers a new address, one nobody proved and one proved the same. The link opens `/auth/set-password`, "Choose your password", which shows the account's address and cannot change it; a forgotten password's link opens `/auth/reset-password`, the same form. Choosing the password proves the address, ends every session, signs the person in through the ordinary sign-in with the password just chosen and, with one organization, joins them to it as `joinRole`; a password reset proves it the same way. A password a sign-up carries anyway is replaced by one nobody knows, so whoever signs up first with another's address never holds a password to it and gains nothing: the link goes to the address, and its owner's own sign-up is answered alike and, once the first link is five minutes old, mails another. A sign-in before the proof is refused and mails nothing, so nobody pushes links at an address on demand. Without `verifyEmail`, the account joins at once. An account joins once in its life, recorded as a `joined-open:<user id>` row in the `verification` table that never expires: never purge those rows, or a removed member who resets their password joins again. One address is mailed at most one link in five minutes: a second sign-up or reset within five minutes mails nothing, since the first link still works, and a reset ends every other link to the account. Mail goes out after the answer, so how long a sign-up or a reset takes says nothing about whether an address has an account. The join reads the settings when it happens, never joins someone holding an invitation still to answer (that invitation is how they join) or who accepted one, while one cancelled, declined or expired counts for nothing, and never joins a member who was removed: removing someone keeps them out.
490
+
491
+ **In no organization.** Someone signed in who belongs to no organization (a new account with many organizations, or a member removed from their last) is shown, under the sentence saying so, the invitations sent to their address once it is proved, each to accept or decline; with many organizations a box to create one, named "{name}'s workspace" until they change it, which `organizationLimit` still caps; and the address an administrator would invite. The app draws this from `src/no-organization.tsx`, which the screen host's `/unauthorized` imports.
492
+
493
+ The only owner of an organization is never deleted, by the backoffice's remove-user or by a delete of their own should one be turned on: the organization would be left with nobody who governs it. Make another member an owner first.
494
+
495
+ In one organization, nobody founds another organization or leaves the one over HTTP. Each sign-up, join, invitation accepted on the server, person added, first-run and role the setup gives writes one JSON line to the server log (`"audit":"auth"`), beside the backoffice's own; keep that output.
496
+
497
+ **Changing the mode.**
498
+
499
+ - `showOrganization`: change the value and run `bunx mithaq generate screens`, which renames the Members page. No row moves.
500
+ - `'one'` to `'many'`: set `organizations = 'many'`, `showOrganization = true`, `signUp = 'open'`, `joinRole = null`, `verifyEmail = true` and `allowedDomains = []`. Organization creation comes back on the page of someone in no organization, and the switcher appears once someone belongs to two. Review every kayan without `organization`, which every organization will share.
501
+ - `'many'` to `'one'`: a start is refused while more than one organization exists. Merging them is a migration of your own: point every per-organization row at one organization, resolve what each held once (a `within` unique value, the settings row, roles of the same name) and keep one owner.
502
+ - `'open'` to `'invite'`: every account and membership stays, and nobody joins without an invitation from then on. `'invite'` to `'open'`: accounts that exist with no membership are not joined; new ones are.
503
+
504
+ ## Upgrading
505
+
506
+ In the project's own `copy.ts`, wrap each imported message file with `copyFile` as in the template, using `flattenMessageFile` from `mithaq/admin`; otherwise nested keys stop resolving. Then run `bunx mithaq generate messages` to rewrite existing message files as nested objects; this release still reads flat files.
507
+
508
+ Move page about text from `<kayan>.description` to `<kayan>.page.description` in the project's message copy.
509
+
510
+ A generated auth kayan can take extra operations from a sibling `<kayan>.operations.ts` exporting `operations`; run `bunx mithaq generate auth` after adding it.
511
+
512
+ What a project made before the store additions changes when it takes this version:
513
+
514
+ - **`drizzleTarget` is removed.** Write `kinds: drizzleKinds, rules: drizzleRules, idKinds: drizzleIdKinds` in `drizzleAdapter(...)`.
515
+ - **`zodTarget` is a function.** Write `zodTarget({ kinds: zodKinds, rules: zodRules, idKinds: zodIdKinds })`.
516
+ - **`betterAuthTenant` requires `newRows: 'active'`.** Name it in the tenant target's config.
517
+ - **A step's `tools.store` call takes the kayan declaration.** Write `kayan: products`, not its string name, which now throws.
518
+ - **A custom store implements `upsert`, `sum` and the `share` lock mode.** A build refuses a store without `upsert` or `sum`.
519
+
520
+ What a project made before the sign-up round changes when it takes this version:
521
+
522
+ - **A sign-up that proves its address asks no password.** The page asks a name and an address; the person chooses the password on `/auth/set-password`, which the sign-up's and Add person's links now open. Copy `src/choose-password.tsx` and `src/routes/auth.set-password.tsx` from the template into `apps/asas-tanstack`, replace `auth.reset-password.tsx` and `auth.sign-up.tsx` with the template's, and run the app once so the router writes `routeTree.gen.ts`. A link mailed before still opens the reset page, which takes the same token.
523
+ - **An organization holds its name in each locale.** Run `bunx mithaq generate auth`, which adds `names` to the organization kayan, then `bunx mithaq migrate --name organization_names`. With one organization add `organizationName` to `organizations.ts`, `{ en, ar }`, holding the name the row has: the next start writes it onto the row, and first-run asks no name. Add `organization.fields.names` beside `name` to each backoffice role that reads organizations (`backoffice-roles.ts`).
524
+ - **The screen host is ejected again** in both apps (`bunx mithaq eject mithaq-screens-host`, keeping the files each app held): its language switch redraws the page in place with `showLocale`, which the app's `src/copy.ts` now exports beside `switchLocale`, and `/unauthorized` sends someone whose role opens one screen straight there. Copy `showLocale` and the new `auth` keys from the template's `copy.ts`.
525
+
526
+ What a project made with the platform console changes when it takes this version, where the console is the backoffice:
527
+
528
+ - **The target and its app are renamed.** In `packages/mithaq/src/mithaq.ts` the screen target `platform` is `backoffice`, which changes the vocabulary: run `bunx mithaq lock` in `packages/asas` and approve it. Move `apps/asas-platform` to `apps/asas-backoffice` (its `package.json` name too), `packages/asas/src/ui-platform/` to `ui-backoffice/` and `packages/mithaq/src/mithaq/auth/platform-roles.ts` to `backoffice-roles.ts`, then run `bunx mithaq generate screens` and eject the screen host again in both apps.
529
+ - **Names that said platform say backoffice.** `PLATFORM_URL` is `BACKOFFICE_URL`, the permission key `screen.platform` is `screen.backoffice`, the route `/platform` is `/backoffice`, and `asPlatform`, `onPlatform`, `platformApp`, `platformRoles` and the rest are `asBackoffice`, `onBackoffice`, `backofficeApp`, `backofficeRoles`. The console's session cookie is `mithaq-backoffice.session_token`, so staff sign in once again.
530
+
531
+ What a project made before the organization lifecycle round changes when it takes this version:
532
+
533
+ - **'none' is gone.** Write `organizations = 'one'` and `showOrganization = false` in `packages/mithaq/src/mithaq/auth/organizations.ts`; nothing else moves.
534
+ - **Many organizations prove every address.** Set `verifyEmail = true`: `'many'` with `false` refuses to load anywhere, development included. Every account made before is unproved and is refused at sign-in until its owner chooses a password from a reset link (Forgot password), which proves the address; tell your people before you deploy.
535
+ - **Every production start needs a mailer.** `RESEND_API_KEY` and `MAIL_FROM`, with many organizations and with one by invitation as with an open sign-up; a deploy without them stops starting. The check runs as the auth setup loads, so a start missing both a mailer and `DATABASE_URL` names the mailer first.
536
+ - **The host's `/unauthorized` imports `src/no-organization.tsx`** from the app. Copy the tenant app's from the template into `apps/asas-tanstack/src/`, and the backoffice's, which draws nothing, into `apps/asas-backoffice/src/`, before you eject the screen host again.
537
+ - **Organization names are one line of at most 100 characters.** A name stored before with a line break, a control character or a direction override, or longer, is refused on its next rename until it is put right: rename it on the backoffice, or once in SQL, `update organization set name = btrim(regexp_replace(name, '[[:cntrl:]\u2028\u2029\u202a-\u202e\u2066-\u2069]+', ' ', 'g')) where name ~ '[[:cntrl:]\u2028\u2029\u202a-\u202e\u2066-\u2069]'`, and shorten any past 100 by hand.
538
+ - **`src/mail/`.** `configuredMailer` in `src/mail/index.ts` is a function the auth setup calls as it loads, and `resendMailer` takes `baseURL`; `bunx mithaq upgrade --check` names `src/mail/mailers.ts` as differing.
539
+
540
+ What a project made before the data round changes:
541
+
542
+ - **A relationship restricts unless it says otherwise.** A row another row points at, through a single relationship, a set or an upload, or that a row's latest draft names, is not deleted; the refusal counts them and names the ones the caller reads. An optional relationship that relied on being set null writes `rules: { onDelete: 'set null' }`, and a set that should let its rows go writes `onDelete: 'cascade'`. Run `bunx mithaq migrate --name restrict` for the foreign keys that change.
543
+ - **A student holds one seat in a session.** The migration that adds the key stops on a database where one student holds two standing seats in one session; withdraw one of them first. This query lists each pair that holds more than one: `select session_id, student_id, count(*) from enrollments where deleted_at is null group by session_id, student_id having count(*) > 1`.
544
+
545
+ What a project made before the portal round changes:
546
+
547
+ - **The site has no title of its own.** The settings global drops `siteTitle`: the organization's name, in each locale, is the site's name. Its migration drops the `site_title` column and what it holds; copy a title you want to keep into the organization's name first.
548
+ - **A course says whether its students enroll themselves**, in `selfEnroll`, off until staff turn it on. A student now holds `enrollments.create` and `enrollments.delete` for its own seats alone, and a session takes no seat past its capacity. Run `bunx mithaq migrate --name portal` for the column the course adds and the one settings drops.
549
+
550
+ What a project made before the json kind changes, in every project:
551
+
552
+ - **Every body limit is the project's.** `fetchHttp`, `orpcHttp`, the storage config and `auth` each require `maxBodyBytes`, the largest request body they read, and refuse to build without it by name; a body past it is answered 413 before it is read whole. Name `defaultMaxBodyBytes` from `mithaq` (4 MiB) on each, as the template's `packages/asas/src/asas.ts` and `packages/mithaq/src/storage/index.ts` do, or a number of your own.
553
+ - **Every plug names its `onError`.** `fetchHttp` no longer falls back to `console.error`: each plug requires the function it hands a fault it answers 500, as `auth.onError` and `storage.onError` already were. The template hands one `report` to both plugs and to `auth`.
554
+ - **A json field's `maxBytes` counts bytes.** It held a value's JSON to that many characters, and now to that many UTF-8 bytes, so a value written in a script past ASCII may be refused where it was taken. Raise a field's `maxBytes` where its values need the room.
555
+ - **A `like` holds at most 1000 words.** A caller's `like` with more words than an `in` lists values is refused as a 400.
556
+
557
+ What a project made before the json kind changes, with the audit-log add-on:
558
+
559
+ - **An audit entry's `changes` is json.** The log held each entry's changes as JSON in a text column, and the column of the names they changed, `changed`, beside it. Copy `audit-log.ts`, `audit-sink.ts` and `activity.tsx` from the template, drop `auditLog.fields.changed` from the roles in `tenant-roles.ts` and the `changed` keys from `src/messages/auditLog.json`, then run `bunx mithaq migrate --name audit_changes_json`. It asks you to answer the drop of `changed` in `mithaq.hints.json`: the names it held are the keys of `changes`, so nothing is lost. Before you commit the migration, put the guard of the template's `audit_changes_json` migration above its `ALTER`, which stops it and changes nothing while an entry holds text that is no JSON. A search by `changed` is now one by a path, `{ 'changes.<column>': { exists: true } }`.
560
+
561
+ ## Adding to the project
562
+
563
+ Paths are written from the root of a project made by `bun create mithaq`, as laid out in [Project structure](#project-structure). `packages/mithaq` holds the language, its Better Auth setup and roles, `packages/features/*` every kayan, `packages/asas` the build, and `apps/asas-tanstack` and `apps/asas-backoffice` are the two host apps. The worked examples in the template are `courses` (`packages/features/courses/courses/courses.ts`) and `tags` (`packages/features/tags/tags/tags.ts`). Read them for shape.
564
+
565
+ Every `mithaq` command runs from `packages/asas`. The bin runs under Node and does not read `.env`, so set `DATABASE_URL` in the shell for any command that reaches a database, and point it at a throwaway database while you work.
566
+
567
+ ### First, decide who may make the change
568
+
569
+ Most changes are free. Everything you write in `defineKayan`, `defineGlobal` and `buildAsas` is yours: fields, rules a kind already accepts, copy, access, operations, steps, indexes, pages. In `defineMithaq` you may change a screen's pages and `kayan` list, shells and components, roles, steps and the `generate` paths.
570
+
571
+ The vocabulary of `defineMithaq` is frozen. It is the locales, each kind's label, message and accepted rules, each rule's message and excludes, each target's name and capability, and the list of root keys. Only the project's owner signs a change to it (see [Add a kind or a rule](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/README.md#add-a-kind-or-a-rule)). If the change you need cannot be said without touching that vocabulary, stop, tell the owner what you would change and why, and wait.
572
+
573
+ ### Where a piece belongs
574
+
575
+ `buildAsas` refuses a misspelled key, a missing `ui()` file or export, a join drawn as a leaf, a list inside a form and a role key the catalog does not hold. The type checker refuses a missing locale. What follows is judgement that no check makes for you.
576
+
577
+ - **A field says what a value is, and a step says what a write does.** A kind, its rules, its label, its options and who may read it go on the field. Deriving a column or writing another kayan goes in a step, as `markArchived` and `createSessions` do in `courses.ts`.
578
+ - **A rule only a form enforces is not enforced.** Every operation is served over HTTP, so a caller can skip the form. Put the rule in the field's `rules` or in a step. A `when` on a page node only decides whether the node is drawn.
579
+ - **A page component draws and calls operations.** It holds no rule and no write of its own. It lives in the kayan's own `ui/` folder when only that kayan draws with it, in `packages/mithaq/src/ui/` when several share it, and in `packages/asas/src/ui-dashboard/`, `ui-portal/` or `ui-backoffice/` when it is one screen's own.
580
+ - **The host app wires things together and says nothing about a kayan.** It holds the copy, the generated routes and the providers. A fact about a kayan written in an app drifts away from the kayan.
581
+ - **A new action is a custom operation, not a hand-written endpoint.** An operation gets its permission key, the access check, the transaction and the audit step, and a page can draw it as a button. The template writes one route by hand, `backofficeHttp` in `packages/asas/src/backoffice-http.ts`, because it writes a Better Auth table no managed operation may write. Follow it only for a provider's own table.
582
+ - **Rows of another kayan on a record page are a related list.** Write a `list` node with `of` and `where` beside the form, as the Sessions tab of a course does. A `join` field holds the data, and a page that names it as a leaf is refused.
583
+ - **A list's states are tabs.** A list node's `tabs` names each one with its `where`, and the list opens on the first, as the courses list opens on Active, keeping archived courses out of the way. A tab narrows what the read answers and never widens it.
584
+ - **A row leaves and comes back through operations of its own.** A course is archived and unarchived, each with its reason, and unpublished back to a draft, as Payload's Unpublish does; a session is archived and unarchived the same way. Each is a line of the activity; `archived` is never set by a create, and on a course by no plain update either.
585
+ - **One row per organization is a global.** Write `defineGlobal` with the `organization` field required and unique, as `packages/features/settings/settings/settings.ts` does.
586
+ - **A column some reader may not see is never filterable, sortable or searchable.** A served `find` takes a `where` on any column the actor reads, so a filter on such a column answers yes or no about values the reader cannot see. Refuse it in a step before the store, or narrow it to the rows whose columns the reader may be told of, as `queriedByQueryable` does in `packages/features/audit-log/audit-log/audit-log.ts`: a search of a change's text reaches only the kayan the reader reads whole, and a search by a path into one column, `changes.<column>`, only the kayan whose column of that name the reader reads.
587
+
588
+ ### Add a kayan
589
+
590
+ The example adds `rooms`, a per-organization list of rooms with a name and a capacity.
591
+
592
+ 1. **Write the kayan file.** Create `packages/features/rooms/rooms/rooms.ts`, in a feature of its own as [Add your first kayan](#6-add-your-first-kayan) lays out, or in the folder of a feature it belongs to. Open `fields` with `...defaultFields` and `organization`, and `targets.service.operations` with `...defaultOperations`. Give every label one string per locale the language declares.
593
+
594
+ ```ts
595
+ import { defaultFields, defaultOperations } from 'mithaq'
596
+ import { defineKayan, organization } from '@my-app/mithaq'
597
+ import { appPage, danger } from '@my-app/mithaq/mithaq/pages.ts'
598
+
599
+ export const rooms = defineKayan({
600
+ name: 'rooms',
601
+ messages: {
602
+ singular: { en: 'Room', ar: 'قاعة' },
603
+ plural: { en: 'Rooms', ar: 'القاعات' },
604
+ },
605
+ fields: {
606
+ ...defaultFields,
607
+ organization,
608
+ name: {
609
+ kind: 'text',
610
+ label: { en: 'Name', ar: 'الاسم' },
611
+ // Unique within the organization, because the kayan places `organization`.
612
+ rules: { required: true, trim: true, maxLength: 80, unique: true },
613
+ targets: { dashboard: { searchable: true } },
614
+ },
615
+ capacity: {
616
+ kind: 'integer',
617
+ label: { en: 'Capacity', ar: 'السعة' },
618
+ rules: { min: 1 },
619
+ },
620
+ },
621
+ // Added beside the defaults: a plain index on (organization, capacity).
622
+ indexes: [{ within: 'organization', index: ['capacity'] }],
623
+ targets: {
624
+ service: { operations: { ...defaultOperations } },
625
+ dashboard: {
626
+ useAsTitle: 'name',
627
+ pages: {
628
+ list: { path: '/rooms', page: appPage([{ layout: 'table', main: [{ list: { columns: ['name', 'capacity', 'createdAt'], search: ['name'] } }] }]) },
629
+ create: { path: '/rooms/new', page: appPage([{ layout: 'record', main: [{ form: ['name', 'capacity'] }] }]) },
630
+ edit: { path: '/rooms/:id', page: appPage([{ layout: 'record', main: [{ form: ['name', 'capacity'] }], danger: danger() }]) },
631
+ },
632
+ },
633
+ },
634
+ })
635
+ ```
636
+
637
+ `defaultFields` is `defaultIdField` (the `id`) and `defaultTimestampFields` (`createdAt` and `updatedAt`). Keep the timestamps unless you have a reason: the `expectedUpdatedAt` step refuses a kayan without `updatedAt`. Leave out any default operation the kayan should not answer, such as `count`.
638
+
639
+ 2. **Decide whose rows these are.** A kayan that places `organization` belongs to one organization, and core scopes every managed operation on it to the session's active organization. A kayan without it is shared by every organization. [Tenancy](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/runner/README.md) has the full rules.
640
+
641
+ 3. **Decide the indexes.** On a per-organization kayan, `unique: true` means unique within the organization, and a `{ within: 'organization', ... }` group builds indexes led by the organization field. A plain `{ fields, unique }` entry is unique across every organization. [Tenancy](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/runner/README.md#unique-values-and-indexes) has the full rules.
642
+
643
+ 4. **Leave `access` out unless its rows differ from the tenancy rows.** A kayan that places `organization` and writes no `access` inherits the row table the access target was given (`tenantAccess` in `packages/mithaq/src/roles/tenant-access.ts`). Plain callbacks such as `access: { read: fn }` are ANDed with those rows, so they only narrow, as `courses` does for archived courses. A table built with `rows({ ... })` replaces the inherited rows, as `packages/features/roles/roles/roles.ts` does.
644
+
645
+ 5. **Register it.** Add `rooms` to its feature's `kayan` list; a new feature is a dependency of `packages/asas/package.json` whose `kayan` is spread into `buildAsas` in `packages/asas/src/asas.ts`. A kayan that writes `targets.dashboard` must also be listed in that screen's `kayan` array in `packages/mithaq/src/mithaq.ts`, or the build refuses it with "does not list it in kayan". That array is not part of the vocabulary, so listing a kayan needs no signature.
646
+
647
+ 6. **Give roles their keys.** A key is `<kayan>.<operation>` or `<kayan>.fields.<field>`. The `administrator` role holds `'*'`. Add to each other role in `packages/mithaq/src/roles/tenant-roles.ts` the operations and columns it should reach. If the backoffice screen lists the kayan, add its columns to the staff reads in `packages/mithaq/src/mithaq/auth/backoffice-roles.ts`, or to `secret` for a column no backoffice role may read.
648
+
649
+ 7. **Generate copy and screens.**
650
+
651
+ ```sh
652
+ cd packages/asas
653
+ bunx mithaq generate messages
654
+ bunx mithaq generate screens
655
+ ```
656
+
657
+ `generate messages` writes `src/messages/rooms.json`. Import it into `apps/asas-tanstack/src/copy.ts` as the kayan's scope, the way that file imports `courses.json`. The backoffice app re-exports the same file. `generate screens` rewrites each screen's `screen.json`, `types.ts`, `files.ts` and route files under the app folders `generate.screens` names.
658
+
659
+ 8. **Write the migration and read it.**
660
+
661
+ ```sh
662
+ DATABASE_URL=postgres://... bunx mithaq migrate --name add_rooms
663
+ ```
664
+
665
+ `--name` takes a slug of lowercase letters, digits and underscores, 1 to 64 characters, and names the folder `src/migrations/<timestamp>_add_rooms/`. Read its `migration.sql` before anything applies it. `mithaq migrate` runs against a database built from the migrations and refuses one that `connect()` pushed; [Write the migration](#write-the-migration) shows how to build one. See [The `mithaq` command](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/cli/README.md#migrate) for the other flags and [Running in production](#running-in-production) for applying migrations.
666
+
667
+ 9. **Seed it** in `src/seed.ts` if the project seeds its neighbours.
668
+
669
+ 10. **Test it.** See [Tests](#tests). Watch each test fail before it passes.
670
+
671
+ The kayan is done when the project's tests, its typecheck and `bunx mithaq generate --check` pass against a throwaway database.
672
+
673
+ ### Add a feature to an existing kayan
674
+
675
+ Change the kayan first, then everything that follows from it.
676
+
677
+ 1. **Check the language can say it.** Open `packages/mithaq/src/mithaq.ts` beside the kayan. If the change needs a kind, rule, locale or target the language does not have, it is a vocabulary change: stop and ask the owner.
678
+ 2. **Edit the declaration and nothing else**, then read it back. For a kayan Better Auth owns, the declaration is the provider options in `packages/mithaq/src/mithaq/auth/index.ts` and the project's `<kayan>.access.ts`, `<kayan>.operations.ts` and `<kayan>.screens.ts` files under `packages/features/auth/user/`. Never edit a file that starts with `Written by`.
679
+ 3. **Run the generators that follow**, in this order:
680
+ 1. `bunx mithaq generate auth` when the provider options or an `.access.ts`, `.operations.ts` or `.screens.ts` file changed;
681
+ 2. `bunx mithaq generate messages` when copy changed;
682
+ 3. `bunx mithaq generate screens` when anything a screen draws changed: a field, a page or an operation's button;
683
+ 4. `bunx mithaq migrate --name <slug>` when a column changed, then read the `migration.sql`.
684
+ 4. **Update what you write by hand.** These must agree with the kayan and nothing regenerates them:
685
+ - role keys in `tenant-roles.ts` and `backoffice-roles.ts`, since renaming a field or an operation renames its key;
686
+ - the staff reads or `secret` in `backoffice-roles.ts`, for a new column of a kayan the backoffice lists;
687
+ - an operation's `input` schema and a step's field names, which repeat the kayan's field names;
688
+ - field names inside a page component;
689
+ - copy keys that a step or an `input` schema raises, in every locale.
690
+ 5. **Verify** with the project's tests, its typecheck and `bunx mithaq generate --check`.
691
+
692
+ #### A field
693
+
694
+ Add the field to `fields`. A text field with a fixed list takes `options`, and each screen says how it draws it under `targets.<screen>`, as `currency` does in `courses.ts`. Add it to the pages that should draw it.
695
+
696
+ A new `required` column on a table that already has rows is refused by `mithaq migrate`, because Postgres would stop on the rows with no value. Give the field a literal `defaultValue`, or add it without `required`, fill each row, and make it required in a later migration. No check covers the data a migration meets: the checks run on an empty database, and a narrowing type change loses precision without a warning. Prove a hand-edited migration on rows in the old shape.
697
+
698
+ #### An operation
699
+
700
+ A custom operation is one more entry beside `...defaultOperations`. Name its `input`, list its steps around one store step, and say what each screen's button asks for.
701
+
702
+ ```ts
703
+ import { defaultFields, defaultOperations, type Ctx } from 'mithaq'
704
+ import { z } from 'zod'
705
+
706
+ /** What `close` parses. Each message is a copy key, so the refusal reads in the viewer's locale. */
707
+ const closeInput = z.object({
708
+ id: z.guid(),
709
+ reason: z.string({ error: 'rooms.operations.close.reason.required' }).max(200, { error: 'rooms.operations.close.reason.maxLength' }),
710
+ })
711
+
712
+ /** Turns the operation's input into the kayan's own columns; `reason` reaches no column. */
713
+ const markClosed = (ctx: Ctx<z.infer<typeof closeInput>>): Ctx => ({
714
+ ...ctx,
715
+ data: { ...ctx.data, closed: true, closedReason: ctx.data.reason },
716
+ })
717
+
718
+ // In defineKayan({ ... }):
719
+ messages: {
720
+ singular: { en: 'Room', ar: 'قاعة' },
721
+ plural: { en: 'Rooms', ar: 'القاعات' },
722
+ // The keys `closeInput` raises, which no field declares, so `generate messages` keeps them.
723
+ operations: {
724
+ close: {
725
+ reason: {
726
+ required: { en: 'Give a reason.', ar: 'اذكر السبب.' },
727
+ maxLength: { en: 'Keep the reason under 200 characters.', ar: 'اجعل السبب أقل من 200 حرف.' },
728
+ },
729
+ },
730
+ },
731
+ },
732
+ // In fields:
733
+ closed: { kind: 'checkbox', label: { en: 'Closed', ar: 'مغلقة' } },
734
+ closedReason: { kind: 'text', label: { en: 'Reason closed', ar: 'سبب الإغلاق' }, rules: { maxLength: 200 } },
735
+ // In targets.service.operations:
736
+ close: {
737
+ // The box the dashboard's button asks for, drawn with the control every text field uses.
738
+ targets: { dashboard: { fields: { reason: { kind: 'text', label: { en: 'Reason', ar: 'السبب' }, rules: { required: true, maxLength: 200 } } } } },
739
+ input: () => closeInput,
740
+ steps: ({ steps }) => [markClosed, steps.update],
741
+ },
742
+ // On the edit page:
743
+ danger: danger({ button: 'close', label: { en: 'Close', ar: 'إغلاق' }, fields: ['reason'], reload: true }),
744
+ ```
745
+
746
+ The operation's name is its permission key, `rooms.close`, so give it to the roles that may run it. A shipped operation is changed the same way: `update: { input: ({ update }) => update.extend({ ... }), steps: ({ steps }) => [myStep, steps.update] }`. A read operation can type extra output with `output`, as `findWithSessions` does in `courses.ts`. Nest the copy keys a schema raises under the kayan's `messages`, because `buildAsas` refuses a key written flat with dots.
747
+
748
+ #### A column on a screen
749
+
750
+ Add the field name to the list's `columns`, and to `filters` or `search` if a person should filter or search by it. The build refuses `searchable` on a field whose kind cannot take `like` (only `text` and `email` can), on a localized field, and on a field no one may read. A column with a component of its own names it with `cell`, as `fee` does with `ui('fee-cell.tsx#FeeCell')`. Then run `bunx mithaq generate screens`. If a role should see the column, give it `<kayan>.fields.<field>`. If the backoffice lists the kayan, decide whether staff read it.
751
+
752
+ ### Add a step
753
+
754
+ A step is a function `(ctx, tools)` that returns the context, changed or not. It never takes an object of its own: steps keep this shape so that every list composes the same way.
755
+
756
+ ```ts
757
+ import type { Ctx, WriteTools } from 'mithaq'
758
+ import { sessions } from '../sessions/sessions.ts'
759
+
760
+ /** After the insert: one managed create per session, each through the sessions kayan's own operation. */
761
+ export const createSessions = async (ctx: Ctx<{ id?: string; sessions?: readonly { startsAt: Date }[] }>, tools: WriteTools): Promise<Ctx> => {
762
+ const { id, sessions: made } = ctx.data
763
+ if (!id || !made?.length) return ctx
764
+ for (const session of made) await tools.of(sessions).create({ ...session, course: id })
765
+ return ctx
766
+ }
767
+ ```
768
+
769
+ A kayan lists it after the store step: `create: { input: ({ create }) => create.extend({ sessions: ... }), steps: ({ steps }) => [steps.insert, createSessions] }`. The full version, which also refuses a draft, is `createSessions` in `packages/features/courses/courses/courses.ts`.
770
+
771
+ `ctx` carries the kayan, `data` (the parsed input, and after the store step the written row), `actor` and `user`, `dataBefore` (the row loaded before an update or delete), `context` (what the HTTP plug put there, such as `requestId`) and `tx`. A write step's `tools` carry `of`, `store`, `operation`, `kind` and `where`; a read step's carry `of` and `read`.
772
+
773
+ Choose how a step reaches data, in this order:
774
+
775
+ 1. **`tools.of(kayan)`** for another kayan. It runs that kayan's own operation: its access for the same actor, its steps, its validation and the audit, inside the same transaction. A failure rolls the whole write back. Prefer it.
776
+ 2. **`tools.store`** for a raw find, hold, insert, update, upsert or remove that should skip the other kayan's operation list. A call names this kayan or another. It skips that kayan's access, steps, validation and audit, but it is still sealed to the active organization; see [Tenancy](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/runner/README.md#what-core-enforces).
777
+ 3. **`ctx.tx`** for raw SQL. It is the project's own privileged path: nothing scopes it, checks it or audits it. Use it only for what no kayan models, and scope it yourself.
778
+
779
+ Pass the kayan declaration, `kayan: variants`, as with `tools.of(variants)`, never its string name or `.name`. That declaration types field names, stored values, filters, ids, conflict keys and numeric fields; runtime also refuses undeclared fields. An adapter implements the same `Store` contract with string names behind this step boundary.
780
+
781
+ The step store's words have these meanings, on shared kayan as well as tenant-owned ones:
782
+
783
+ - `find({ tx, kayan, args: { limit, page, sort, where, lock } })` reads rows past access and field masks. Core ANDs the active organization onto a tenant-owned kayan's filter. These rows can contain fields the actor may not read; never copy them into output or messages. `lock` is optional; any lock requires a transaction and holds the returned rows until it ends. `update` excludes every competing lock. `noKeyUpdate` keeps their values unchanged by others while allowing `keyShare` readers. `share` keeps them as read while allowing other `share` and `keyShare` readers. `keyShare` keeps their identity and unique keys while allowing other values to change.
784
+ - `hold({ tx, kayan, ids, mode })` keeps the named rows held in that mode until the transaction ends, taking them in ascending id order and ignoring duplicates. It returns nothing. A missing id and another organization's id both answer the same 404. Order across different kayan remains the step's responsibility.
785
+ - `update({ tx, kayan, id, data: { quantity: { $inc: n }, ...plainValues } })` adds `n` in the database and returns the changed row. The field must be an integer and `n` a safe integer. An increment writes a stored integer field; arithmetic in a draft snapshot is refused. Store writes skip validation, including a field's minimum rule; a quantity that must never go negative needs a database `CHECK`.
786
+ - `sum({ tx, kayan, field, where })` returns the exact total of an integer, number or decimal field as a decimal string, including for integer totals, so no aggregate passes through a float. No matching values returns `'0'`. A non-numeric field is refused; core seals the selection to the active organization just as for `find`.
787
+ - `upsert({ tx, kayan, on: ['organization', 'code'], data: { code: 'MAIN', quantity: { $inc: n } } })` inserts a row or updates the row with the same unique key, and returns it. `on` names all fields of a unique index the kayan declares, including indexes from `unique` rules; `on: ['id']` is also accepted without a declared unique index, and another set of fields is refused. Key values are plain values in `data`. Core fills a tenant-owned kayan's tenant field from the active organization and refuses any other value, whether or not the field appears in `on`. A conflict can never change another organization's row, even through a globally unique index. An increment inserts `n` if the row is new or adds `n` if it exists; plain values replace their fields, and an existing row keeps its id. Null keys follow the declared index's null uniqueness. While saving a draft, an upsert of the operation's own versioned kayan is refused; use `update` for that snapshot. Like the other store writes, upsert skips access, steps, validation and audit.
788
+
789
+ `find`, `hold`, `sum` and `upsert` are available inside the operation's transaction, as the existing store writes are; outside steps use managed operations. A store implements these promises itself. The drizzle store maps lock modes to its database's row locks and binds increment amounts as parameters.
790
+
791
+ Add a new store word only when at least two real callers need it, every store could keep its promise, and it is documented by meaning with a test that breaks when the promise breaks.
792
+
793
+ A deadlock or serialization failure that rolls a transaction back answers 409 with `error.conflict.retry`; the caller may try the operation again. Core never retries it automatically.
794
+
795
+ Where a step goes:
796
+
797
+ - in an operation's `steps`, before or after its one store or read step, through the `steps` function: `steps: ({ steps }) => [myStep, steps.update]`;
798
+ - in an operation's `beforeTransaction`, which runs outside the transaction before it opens, with no lock and no `dataBefore`, or in its `afterSteps` or `afterCommit`;
799
+ - in the service target's `beforeSteps`, `afterSteps` or `afterCommit` in `packages/mithaq/src/mithaq.ts`, for every write of the project. `audit` sits in `afterSteps`. A webhook or an email belongs in `afterCommit`, where the row is durable.
800
+
801
+ A step that refuses throws `invalid`, `denied`, `missing` or `conflict` from `mithaq`, each taking one object with a `message`, a copy `key` and `params`. Add the key to the copy in every locale.
802
+
803
+ ### Add a screen component
804
+
805
+ A component of the project's own lives in a `ui` folder beside the kayan that draws it, or in `packages/mithaq/src/ui/` when several draw it, and is named by `ui('file.tsx#Export')`, where `ui` is a `folderOf(new URL('./ui/', import.meta.url))`. A cell takes `CellProps` from `mithaq/admin`, a button takes `ButtonProps`, and hooks such as `useLocale` and `useRecord` come from `mithaq/admin/react`. Name it where it is used: as a field's `cell` or `component`, as a page node `{ component: ... }`, or under a screen's `components` in `src/mithaq.ts` when several pages use it by name. The build checks that the file exists and exports the name; you keep the field names inside it correct.
806
+
807
+ The screens draw with the pieces of `mithaq-admin`, which each app imports as `mithaq-admin/<file>`. To change a control, a cell or a node, replace it in the components map in `src/components.ts`. To change a piece the map does not hold, run `bunx mithaq eject <item>` in the app: it writes the piece into `src/components/mithaq/`, and that file is drawn in place of the package's from then on. [The admin package](https://github.com/drhomet/mithaq/blob/main/packages/mithaq-admin/README.md) describes the pieces, ejecting one, and migrating a project made before the package.
808
+
809
+ An ejected file is the project's own. A project edits it in place and never patches it from outside. After an edit, `upgrade --check` reports it as edited. When the installed package changes the same file, diff the two and merge by hand, or delete an unedited one (`bunx mithaq eject --prune`) to draw the package's.
810
+
811
+ ### Add an add-on
812
+
813
+ No command adds an add-on yet. Make two throwaway projects at your mithaq version, one with the add-on and one without, with `bun create mithaq` and the same other add-ons as yours; `git diff --no-index` between them shows exactly what the add-on brings: its feature folder, its own files, the dependency in `packages/asas/package.json`, and the lines it adds to the language, the asas and the tenant app's `copy.ts`. Copy those in, then run `bunx mithaq generate screens`, `bunx mithaq generate messages` and `bunx mithaq migrate --name add_<add-on>` from `packages/asas`. Adding `roles` also takes `default-member-roles.ts` out of `tenant-roles.ts`, and leaves each organization's Better Auth admins and members with no keys until someone assigns them a role; owners keep every key through `administrator`. Adding `rich-text` or `uploads` adds words to the project's vocabulary, so a project with a `mithaq.lock` signs it again with `mithaq lock`, which the developer approves.
814
+
815
+ ### Tests
816
+
817
+ - Give tests a throwaway database, named by `DATABASE_URL`, and never the development database. Under `node --test` the template's `packages/mithaq/src/mithaq.ts` refuses to load without `DATABASE_URL`. The mithaq repository's own tests need more; see [CONTRIBUTING.md](https://github.com/drhomet/mithaq/blob/main/CONTRIBUTING.md#set-up).
818
+ - Put a test beside the code it tests as `<name>.test.ts`, and run it with `node --test`.
819
+ - Write security cases first: the read of another organization's row, the write without the key, the refused input.
820
+ - See each test fail before it passes. Break the property, watch the failure, then restore it.
821
+ - A screen change needs a test that boots the app and visits each new page in every locale.
822
+
823
+ ### Checks
824
+
825
+ | Command, from `packages/asas` | What it checks |
826
+ |---|---|
827
+ | `bunx mithaq generate --check` | Every check below and every generator in check mode, stopping at the first failure. [The `mithaq` command](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/cli/README.md#generate---check) lists the steps. |
828
+ | `bunx mithaq lock --check` | The vocabulary still matches `mithaq.lock` and its seal verifies. Passes with a notice when there is no lock. |
829
+ | `bunx mithaq migrate --check` | No kayan change waits for a migration. Writes nothing. |
830
+ | `bunx mithaq upgrade --check` | Every file the project holds from `mithaq-admin` against the installed package. Never writes. |
831
+
832
+ ## Running in production
833
+
834
+ This part covers what changes between `vite dev` and a deployed app: how the database schema gets there, the settings each app reads, where uploaded files go, and how to build and start both apps. Paths are relative to a project made by `bun create mithaq` (see [Getting started](#getting-started)).
835
+
836
+ ### Development pushes, production migrates
837
+
838
+ The drizzle store has two ways to bring the database in line with your kayan:
839
+
840
+ - **Push.** `asas.connect()` compares the kayan with the database and applies the difference directly. It refuses any change that would lose data. It is on by default whenever `NODE_ENV` is not `production`, and it writes a marker row named `mithaq_dev_push` into `drizzle.__mithaq_migrations`.
841
+ - **Migrations.** `mithaq migrate` writes SQL migration files into `src/migrations/`, which you commit (see [The `mithaq` command](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/cli/README.md#migrate)). Production applies those files and nothing else.
842
+
843
+ A pushed database has no migration history, so it is never a production database. A production start refuses a database that carries the push marker. To keep one, adopt it with `mithaq migrate --baseline` (only possible while the project has no migrations yet), or point `DATABASE_URL` at a database built from the migrations.
844
+
845
+ ### `connect()` at start
846
+
847
+ Each app connects once before it serves any request. Both `apps/asas-tanstack/src/server.ts` and `apps/asas-backoffice/src/server.ts` do this:
848
+
849
+ <!-- from: apps/asas-tanstack/src/server.ts -->
850
+ ```ts
851
+ const { env } = process
852
+ if (env.NODE_ENV === 'production') {
853
+ await (await import('asas')).asas.connect().catch((error: unknown) => {
854
+ console.error(error)
855
+ process.exit(1)
856
+ })
857
+ }
858
+ ```
859
+
860
+ In production, `connect()` does not push. It checks the migrations instead, as `prodMigrations` on `drizzleAdapter` says. Without `prodMigrations`, a production `connect()` refuses to start. The template sets it in `packages/mithaq/src/mithaq.ts`:
861
+
862
+ <!-- from: templates/mithaq/src/mithaq.ts -->
863
+ ```ts
864
+ const drizzleStore = drizzleAdapter({
865
+ connectionString,
866
+ kinds: drizzleKinds,
867
+ rules: drizzleRules,
868
+ idKinds: drizzleIdKinds,
869
+ prodMigrations: { migrationsFolder: fileURLToPath(new URL('migrations/', asasSource)), pending: 'refuse' },
870
+ })
871
+ ```
872
+
873
+ `asasSource` is the asas's `src/`, named by path from the language, so the folder is found wherever each app calls `connect()` from.
874
+
875
+ `migrationsFolder` must hold the `drizzle.config.ts` that `mithaq migrate` wrote, so ship `src/migrations/` with the deploy. `push: true` and `prodMigrations` cannot be used together.
876
+
877
+ What `pending: 'refuse'` and `'run'` each do, the lock every writer of the migration history takes, and applying migrations by hand are in [Migrations in production](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/drizzle/README.md).
878
+
879
+ ### Environment variables
880
+
881
+ `bun create mithaq` writes the same `.env` into `packages/asas`, `apps/asas-tanstack` and `apps/asas-backoffice`, with fresh values for `LOCAL_AUTH_SECRET` and `BETTER_AUTH_SECRET`. Bun (`bun <file>`, `bun run`, `bun -e`) and the apps' dev servers read it. The `mithaq` command runs under Node and does not read it. A variable already set in the environment always wins over `.env`.
882
+
883
+ In production, set these in the host's environment rather than in a file.
884
+
885
+ | Variable | Read by | Production | Development |
886
+ | --- | --- | --- | --- |
887
+ | `DATABASE_URL` | `packages/mithaq/src/mithaq.ts`, `seed.ts`, the CLI | Required. Start refuses without it. | Falls back to `postgres://postgres:postgres@localhost:5432/mithaq` for the dev server. `seed`, `migrate`, `rbac assign` and `storage sweep` still refuse without it. Under `node --test` it is required. |
888
+ | `BETTER_AUTH_SECRET` | `packages/mithaq/src/mithaq/auth/index.ts` | Required. Both apps must share the same value: the backoffice app derives its own session secret from it, so a cookie one app signed never verifies in the other. `AUTH_SECRET` is read when it is unset, but a URL below that is not `localhost` still requires `BETTER_AUTH_SECRET` itself. | A public development default. Even outside production, start refuses when either URL below is not `localhost` and this is unset. |
889
+ | `BETTER_AUTH_URL` | `packages/mithaq/src/mithaq/auth/index.ts` | Required. The tenant app's public URL. | `http://localhost:5173` |
890
+ | `BACKOFFICE_URL` | `packages/mithaq/src/mithaq/auth/index.ts` | Required. The backoffice console's public URL, on an origin of its own: a subdomain beside the tenant app's, never a path of it (see [Deploying both apps on one domain](#deploying-both-apps-on-one-domain)). Start refuses when it shares `BETTER_AUTH_URL`'s origin, or its host anywhere but on this machine. | `http://localhost:5174` |
891
+ | `LOCAL_AUTH_SECRET` | `packages/features/auth/local-users/local-users.ts` | 32 characters at least. If it is unset, local accounts are off: every `POST` under `/api/auth/local` answers 503 and the public sign-up answers 403. | Same. |
892
+ | `TRUSTED_IP_HEADER` | `packages/mithaq/src/mithaq/auth/index.ts`, `local-users.ts` | The header names your proxy overwrites with the client address, comma-separated, such as `cf-connecting-ip`. Both rate limiters key on it, the limit on invitations among them. Trust it only behind exactly one proxy that overwrites it on every request. Without it every limit keys on `x-forwarded-for`, which a caller writes, so a caller who changes it on each request passes the limits. | Defaults to `x-forwarded-for`. |
893
+ | `SETUP_CODE` | `packages/mithaq/src/mithaq/auth/index.ts` | 32 characters or more, random. The backoffice console's first administrator, and with one organization the first owner on `/auth/first-run`, are made only with it. Shorter counts as unset, and then neither is made. Remove it once the app is set up. | Not asked under `vite dev` on `localhost`. |
894
+ | `RESEND_API_KEY`, `MAIL_FROM` | `packages/mithaq/src/mail/index.ts` | Both or neither; one alone refuses to start, and so does a production start with neither. With both, password resets, invitations and the links that prove an address are mailed through Resend from `MAIL_FROM`, an address at a domain the Resend account verified. Another service is a mailer of your own beside `src/mail/mailers.ts`, named in `src/mail/index.ts`. `RESEND_BASE_URL`, Resend's own variable, sends to another address that speaks its API, such as a test's catcher: https, or http on this machine alone. | Unset: every message is written to the server log, when `NODE_ENV` is `development` and the tenant URL is `localhost`, each under a line saying emails print there instead of being sent. |
895
+ | `STORAGE_SECRET` | `packages/mithaq/src/storage/index.ts` | Required. Signs every upload reference and local upload token. Every instance must share it. | A fresh random secret per process, with a warning. |
896
+ | `STORAGE_PREFIX` | `storage/index.ts` | Required. The bucket folder this project owns, such as `mithaq/asas`. Every key starts with it, and the sweep lists only inside it. | `mithaq/asas` |
897
+ | `STORAGE_ADAPTER` | `storage/index.ts` | Required. Where new uploads go: `local`, or `r2` once R2 is set. | `local` |
898
+ | `STORAGE_DIR` | `storage/index.ts` | The folder of the `local` adapter. | `.storage`, relative to the process's working directory. |
899
+ | `R2_ACCOUNT_ID`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `storage/index.ts` | All four or none. When all four are set, the `r2` adapter exists. If only some are set, start refuses. | Same. |
900
+ | `DEV_ADMIN_EMAIL`, `DEV_ADMIN_PASSWORD` | `dev-setup.ts` | Never read. | The account `bun run dev:setup` makes. `.env.example` holds a development value, which is why `dev:setup` refuses a database not on this machine. |
901
+ | `NODE_ENV` | the apps, the drizzle store, auth, storage | Must be `production`. See below. | Unset or `development`. With no mailer configured, mail is written to the server log only when it is `development` and the tenant URL is `localhost`. |
902
+ | `MITHAQ_COUNT_TIMEOUT_MS` | `mithaq migrate` | The per-query timeout, in milliseconds, for counting rows the rules would refuse. The default is 5000. | Same. |
903
+
904
+ ### `NODE_ENV`
905
+
906
+ Start both built apps with `NODE_ENV=production`. That one setting decides several things:
907
+
908
+ - `server.ts` calls `connect()` only in production, so only then are migrations checked or applied before serving. The apps read it from `process.env` at run time, so the build cannot bake it in.
909
+ - The drizzle store pushes the schema on `connect()` whenever `NODE_ENV` is not `production`. A production database must never be reached by a process without it.
910
+ - The production checks for `DATABASE_URL`, the auth URLs and secret, and the storage settings run only in production. Without it, those settings quietly fall back to their development values.
911
+
912
+ ### Storage adapters
913
+
914
+ Uploaded files go through the storage rukn, the pluggable part that brings the `upload` kind and the storage target. Its config is `packages/mithaq/src/storage/index.ts`, and the adapters beside it are project files, ejected from `mithaq-admin`, that you own and edit:
915
+
916
+ | Adapter | File | Use |
917
+ | --- | --- | --- |
918
+ | `local` | `storage/local.ts` | A folder on this machine. Uploads are a `PUT` to `/api/storage/local/<token>`, signed for one key, type and size. Good for development and CI. In production it only suits a single instance with a persistent disk. |
919
+ | `r2` | `storage/r2.ts` | Cloudflare R2, through the S3 adapter. Wired in when the four `R2_*` variables are set. |
920
+ | `s3` | `storage/s3.ts` | Any S3-compatible service: AWS S3, R2, MinIO, Backblaze B2. Not wired by default: add it to `adapters` in `storage/index.ts` with its `endpoint`, `bucket`, `region`, keys and `prefix`. |
921
+ | `uploadthing` | `storage/uploadthing.ts` | Reads, serves and deletes files already stored on UploadThing. It never uploads, so new files must go to another adapter. Add it to `adapters` with its `token` to serve old files. |
922
+
923
+ Each upload row stores the name of the adapter that holds its file. `STORAGE_ADAPTER` only picks where new uploads go, so keep every adapter that old rows still name.
924
+
925
+ `mithaq storage sweep` deletes objects that no row names and that are more than a day old. Run it with `--check` first to see the list (see [The `mithaq` command](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/cli/README.md#storage-sweep)).
926
+
927
+ ### Building and starting both apps
928
+
929
+ Each app's `build` script runs `mithaq lock --check` in `packages/asas`, then `vite build`, which writes `dist/`:
930
+
931
+ ```sh
932
+ bun run --cwd apps/asas-tanstack build
933
+ bun run --cwd apps/asas-backoffice build
934
+ ```
935
+
936
+ The build keeps `asas` and `mithaq` external: the server bundle imports them from `node_modules` at run time. So deploy the whole workspace with its installed `node_modules` and the `packages/asas` source, including `src/migrations/`, not only `dist/`.
937
+
938
+ The project's own release check serves each build with `vite preview` under Node. Each app's `preview` script runs it after `mithaq lock --check`. The tenant app uses Vite's default port, 4173, and the backoffice 4174:
939
+
940
+ ```sh
941
+ NODE_ENV=production bun run --cwd apps/asas-tanstack preview
942
+ NODE_ENV=production bun run --cwd apps/asas-backoffice preview
943
+ ```
944
+
945
+ A typical deploy runs in this order:
946
+
947
+ 1. `bun install`.
948
+ 2. Build both apps.
949
+ 3. With `pending: 'refuse'`, apply migrations with drizzle-kit, as [Applying migrations by hand](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/drizzle/README.md#applying-migrations-by-hand) shows.
950
+ 4. Start both apps with `NODE_ENV=production` and the variables above.
951
+
952
+ In CI, after `bun install`, run `mithaq generate --check` in `packages/asas` against a database built from the migrations (see [The `mithaq` command](https://github.com/drhomet/mithaq/blob/main/packages/mithaq/src/asas/cli/README.md#generate---check)).
953
+
954
+ ### Deploying both apps on one domain
955
+
956
+ > **Security: the backoffice gets a subdomain of its own, never a path of the customer app.** Serve `app.example.com` and `backoffice.example.com`, never `example.com/` and `example.com/backoffice`, nor `example.com:8443`. A browser isolates scripts and storage by origin (scheme, host and port), and sends cookies by host whatever the port or scheme, so on one host a script flaw (XSS) in the customer app would reach staff sessions, which can impersonate anyone and read every organization. Start refuses when `BACKOFFICE_URL` and `BETTER_AUTH_URL` share an origin, or a host anywhere but on this machine. Keep each session cookie on its own host: never give a cookie the domain `.example.com`, as Better Auth's `crossSubDomainCookies` does. Two subdomains are still one site, so `SameSite` cookies pass between them: the `Origin` checks are the barrier, the backoffice's `/api` route checking every write against `BACKOFFICE_URL` and Better Auth checking its own, and every state-changing backoffice route must keep one.
957
+
958
+ Both apps can still run on one machine behind one certificate. Point `app.example.com` and `backoffice.example.com` at the machine, take a wildcard certificate for `*.example.com`, start both apps as above on `127.0.0.1:4173` and `127.0.0.1:4174`, and let a proxy route each request by its `Host`. Each app's `vite.config.ts` binds `vite preview` to `127.0.0.1`, the address the proxies below target, and lets it answer the name in its URL (`preview.allowedHosts`): Vite refuses any `Host` but localhost otherwise, so set both URLs where the apps start:
959
+
960
+ ```sh
961
+ BETTER_AUTH_URL=https://app.example.com
962
+ BACKOFFICE_URL=https://backoffice.example.com
963
+ TRUSTED_IP_HEADER=x-real-ip
964
+ ```
965
+
966
+ `TRUSTED_IP_HEADER` names the header each proxy below overwrites with the client's address on every request, so the rate limits key on that address and never on what a caller wrote.
967
+
968
+ Caddy, built with your DNS provider's plugin, which a wildcard certificate needs (Cloudflare here):
969
+
970
+ ```caddyfile
971
+ *.example.com {
972
+ tls {
973
+ dns cloudflare {env.CLOUDFLARE_API_TOKEN}
974
+ }
975
+
976
+ @app host app.example.com
977
+ handle @app {
978
+ reverse_proxy 127.0.0.1:4173 {
979
+ header_up X-Real-IP {remote_host}
980
+ }
981
+ }
982
+
983
+ @backoffice host backoffice.example.com
984
+ handle @backoffice {
985
+ reverse_proxy 127.0.0.1:4174 {
986
+ header_up X-Real-IP {remote_host}
987
+ }
988
+ }
989
+
990
+ # Any other name under the certificate reaches neither app.
991
+ handle {
992
+ abort
993
+ }
994
+ }
995
+ ```
996
+
997
+ nginx, with the wildcard certificate certbot's DNS challenge wrote (`certbot certonly --dns-cloudflare -d '*.example.com'`):
998
+
999
+ ```nginx
1000
+ # A name neither app serves is refused before any request is read.
1001
+ server {
1002
+ listen 443 ssl default_server;
1003
+ listen [::]:443 ssl default_server;
1004
+ ssl_reject_handshake on;
1005
+ }
1006
+
1007
+ server {
1008
+ listen 80 default_server;
1009
+ listen [::]:80 default_server;
1010
+ return 444;
1011
+ }
1012
+
1013
+ server {
1014
+ listen 443 ssl;
1015
+ listen [::]:443 ssl;
1016
+ server_name app.example.com;
1017
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
1018
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
1019
+ # With the local adapter an upload is a PUT through the app, up to 10 MB unless its field allows more.
1020
+ client_max_body_size 10m;
1021
+
1022
+ location / {
1023
+ proxy_pass http://127.0.0.1:4173;
1024
+ proxy_set_header Host $host;
1025
+ proxy_set_header X-Real-IP $remote_addr;
1026
+ proxy_set_header X-Forwarded-Proto $scheme;
1027
+ }
1028
+ }
1029
+
1030
+ server {
1031
+ listen 443 ssl;
1032
+ listen [::]:443 ssl;
1033
+ server_name backoffice.example.com;
1034
+ ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
1035
+ ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
1036
+ client_max_body_size 10m;
1037
+
1038
+ location / {
1039
+ proxy_pass http://127.0.0.1:4174;
1040
+ proxy_set_header Host $host;
1041
+ proxy_set_header X-Real-IP $remote_addr;
1042
+ proxy_set_header X-Forwarded-Proto $scheme;
1043
+ }
1044
+ }
1045
+
1046
+ server {
1047
+ listen 80;
1048
+ listen [::]:80;
1049
+ server_name app.example.com backoffice.example.com;
1050
+ return 301 https://$host$request_uri;
1051
+ }
1052
+ ```
1053
+
1054
+ ### The test database
1055
+
1056
+ Never point tests at a database you want to keep. A project made by `bun create mithaq` ships without the template's own tests, but its `packages/mithaq/src/mithaq.ts` keeps one guard: under `node --test`, the build refuses to load without `DATABASE_URL`, so a test you write never falls back to the dev database. The mithaq repository's own tests need a throwaway Postgres set up as [CONTRIBUTING.md](https://github.com/drhomet/mithaq/blob/main/CONTRIBUTING.md#set-up) describes.