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