@cedarjs/cli 9.0.0-canary.1784 → 9.0.0-canary.3124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/README.md +7 -10
  2. package/dist/cfw.js +10 -6
  3. package/dist/commands/build/buildHandler.js +220 -45
  4. package/dist/commands/build/buildPackagesTask.js +21 -20
  5. package/dist/commands/build.js +13 -2
  6. package/dist/commands/check.js +4 -6
  7. package/dist/commands/console.js +6 -4
  8. package/dist/commands/deploy/baremetal/SshExecutor.js +13 -4
  9. package/dist/commands/deploy/baremetal/baremetalHandler.js +116 -50
  10. package/dist/commands/deploy/baremetal.js +11 -4
  11. package/dist/commands/deploy/flightcontrolHandler.js +28 -15
  12. package/dist/commands/deploy/helpers/deployHandler.js +11 -6
  13. package/dist/commands/deploy/netlify.js +1 -1
  14. package/dist/commands/deploy/packing/nft.js +1 -2
  15. package/dist/commands/deploy/render.js +1 -1
  16. package/dist/commands/deploy/renderHandler.js +24 -13
  17. package/dist/commands/deploy/serverlessHandler.js +69 -27
  18. package/dist/commands/deploy/vercel.js +1 -1
  19. package/dist/commands/destroy/component/component.js +2 -3
  20. package/dist/commands/destroy/directive/directive.js +1 -3
  21. package/dist/commands/destroy/directive/directiveHandler.js +2 -4
  22. package/dist/commands/destroy/function/function.js +1 -3
  23. package/dist/commands/destroy/handlerHelpers.js +17 -17
  24. package/dist/commands/destroy/helpers.js +8 -2
  25. package/dist/commands/destroy/layout/layout.js +1 -2
  26. package/dist/commands/destroy/page/pageHandler.js +3 -3
  27. package/dist/commands/destroy/scaffold/scaffoldHandler.js +31 -12
  28. package/dist/commands/destroy/sdl/sdlHandler.js +3 -3
  29. package/dist/commands/dev/apiDebugFlag.js +5 -4
  30. package/dist/commands/dev/devHandler.js +219 -68
  31. package/dist/commands/dev/packageWatchCommands.js +8 -3
  32. package/dist/commands/dev.js +16 -6
  33. package/dist/commands/execHandler.js +49 -29
  34. package/dist/commands/experimental/live-queries/liveQueries.js +32 -0
  35. package/dist/commands/experimental/live-queries/liveQueriesHandler.js +448 -0
  36. package/dist/commands/experimental/live-queries/templates/liveQueriesListener.ts.template +153 -0
  37. package/dist/commands/experimental/live-queries/templates/migration.sql.template +100 -0
  38. package/dist/commands/experimental/setupInngest.js +2 -2
  39. package/dist/commands/experimental/setupInngestHandler.js +13 -12
  40. package/dist/commands/experimental/setupOpentelemetry.js +2 -2
  41. package/dist/commands/experimental/setupOpentelemetryHandler.js +14 -10
  42. package/dist/commands/experimental/setupReactCompiler.js +2 -2
  43. package/dist/commands/experimental/setupReactCompilerHandler.js +48 -33
  44. package/dist/commands/experimental/setupRsc.js +1 -1
  45. package/dist/commands/experimental/setupRscHandler.js +20 -16
  46. package/dist/commands/experimental/setupStreamingSsr.js +2 -2
  47. package/dist/commands/experimental/setupStreamingSsrHandler.js +49 -34
  48. package/dist/commands/experimental/templates/opentelemetry.ts.template +2 -1
  49. package/dist/commands/experimental/templates/rsc/Document.tsx.template +1 -1
  50. package/dist/commands/experimental/templates/rsc/entry.client.tsx.template +8 -8
  51. package/dist/commands/experimental/templates/streamingSsr/Document.tsx.template +1 -1
  52. package/dist/commands/experimental/templates/streamingSsr/entry.client.tsx.template +6 -6
  53. package/dist/commands/experimental/templates/streamingSsr/tsconfig.json.template +6 -6
  54. package/dist/commands/experimental/util.js +27 -25
  55. package/dist/commands/experimental.js +3 -2
  56. package/dist/commands/generate/cell/cell.js +20 -0
  57. package/dist/commands/generate/cell/cellHandler.js +89 -40
  58. package/dist/commands/generate/cell/templates/cell.tsx.template +21 -2
  59. package/dist/commands/generate/cell/templates/cellFragment.tsx.template +43 -0
  60. package/dist/commands/generate/cell/templates/cellList.tsx.template +21 -2
  61. package/dist/commands/generate/cell/templates/storiesFragment.tsx.template +23 -0
  62. package/dist/commands/generate/cell/templates/test.js.template +1 -1
  63. package/dist/commands/generate/cell/templates/testFragment.js.template +30 -0
  64. package/dist/commands/generate/component/componentHandler.js +14 -16
  65. package/dist/commands/generate/dataMigration/dataMigration.js +36 -18
  66. package/dist/commands/generate/dataMigration/templates/dataMigration.js.template +1 -1
  67. package/dist/commands/generate/dataMigration/templates/dataMigration.ts.template +1 -1
  68. package/dist/commands/generate/dbAuth/dbAuth.js +1 -0
  69. package/dist/commands/generate/dbAuth/dbAuthHandler.js +63 -34
  70. package/dist/commands/generate/dbAuth/templates/login.oauth.tsx.template +164 -0
  71. package/dist/commands/generate/dbAuth/templates/signup.oauth.tsx.template +147 -0
  72. package/dist/commands/generate/directive/directiveHandler.js +14 -16
  73. package/dist/commands/generate/function/function.js +1 -0
  74. package/dist/commands/generate/function/functionHandler.js +14 -19
  75. package/dist/commands/generate/helpers.js +34 -18
  76. package/dist/commands/generate/job/job.js +1 -0
  77. package/dist/commands/generate/job/jobHandler.js +20 -24
  78. package/dist/commands/generate/layout/layoutHandler.js +13 -16
  79. package/dist/commands/generate/model/model.js +1 -0
  80. package/dist/commands/generate/model/modelHandler.js +11 -5
  81. package/dist/commands/generate/ogImage/ogImage.js +1 -0
  82. package/dist/commands/generate/ogImage/ogImageHandler.js +18 -11
  83. package/dist/commands/generate/package/filesTask.js +9 -0
  84. package/dist/commands/generate/package/packageHandler.js +50 -61
  85. package/dist/commands/generate/package/templates/test.ts.template +2 -2
  86. package/dist/commands/generate/package/templates/vitest.config.ts.template +9 -0
  87. package/dist/commands/generate/page/pageHandler.js +27 -24
  88. package/dist/commands/generate/realtime/realtime.js +2 -2
  89. package/dist/commands/generate/realtime/realtimeHandler.js +51 -32
  90. package/dist/commands/generate/realtime/templates/subscriptions/blank/blank.ts.template +1 -1
  91. package/dist/commands/generate/scaffold/scaffold.js +1 -0
  92. package/dist/commands/generate/scaffold/scaffoldHandler.js +176 -49
  93. package/dist/commands/generate/scaffold/templates/lib/formatters.tsx.template +1 -1
  94. package/dist/commands/generate/script/script.js +1 -0
  95. package/dist/commands/generate/script/scriptHandler.js +10 -6
  96. package/dist/commands/generate/script/templates/script.ts.template +9 -4
  97. package/dist/commands/generate/script/templates/tsconfig.json.template +16 -2
  98. package/dist/commands/generate/sdl/sdl.js +4 -3
  99. package/dist/commands/generate/sdl/sdlHandler.js +135 -46
  100. package/dist/commands/generate/sdl/stubFiles.js +132 -0
  101. package/dist/commands/generate/sdl/templates/sdl.js.template +2 -1
  102. package/dist/commands/generate/sdl/templates/sdl.ts.template +2 -1
  103. package/dist/commands/generate/secret/secret.js +20 -20
  104. package/dist/commands/generate/service/service.js +5 -5
  105. package/dist/commands/generate/service/serviceHandler.js +126 -61
  106. package/dist/commands/generate/service/templates/scenarios.ts.template +4 -2
  107. package/dist/commands/generate/service/templates/service.ts.template +22 -8
  108. package/dist/commands/generate/service/templates/test.ts.template +2 -2
  109. package/dist/commands/generate/yargsCommandHelpers.js +7 -2
  110. package/dist/commands/generate/yargsHandlerHelpers.js +20 -18
  111. package/dist/commands/generate.js +4 -4
  112. package/dist/commands/info.js +2 -2
  113. package/dist/commands/jobsHandler.js +7 -4
  114. package/dist/commands/lint.js +26 -69
  115. package/dist/commands/prerenderHandler.js +95 -58
  116. package/dist/commands/prismaHandler.js +51 -26
  117. package/dist/commands/record/init.js +2 -2
  118. package/dist/commands/record.js +4 -3
  119. package/dist/commands/serve.js +245 -31
  120. package/dist/commands/serveApiHandler.js +7 -6
  121. package/dist/commands/serveBothHandler.js +22 -13
  122. package/dist/commands/serveWebHandler.js +3 -2
  123. package/dist/commands/setup/auth/auth.js +15 -11
  124. package/dist/commands/setup/cache/cache.js +1 -1
  125. package/dist/commands/setup/cache/cacheHandler.js +15 -9
  126. package/dist/commands/setup/database/database.js +15 -0
  127. package/dist/commands/setup/database/postgres.js +19 -0
  128. package/dist/commands/setup/database/postgresHandler.js +196 -0
  129. package/dist/commands/setup/database/templates/db.ts.template +32 -0
  130. package/dist/commands/setup/deploy/deploy.js +4 -3
  131. package/dist/commands/setup/deploy/helpers/helpers.js +5 -1
  132. package/dist/commands/setup/deploy/helpers/index.js +132 -6
  133. package/dist/commands/setup/deploy/providers/baremetalHandler.js +28 -6
  134. package/dist/commands/setup/deploy/providers/coherence.js +1 -1
  135. package/dist/commands/setup/deploy/providers/coherenceHandler.js +60 -31
  136. package/dist/commands/setup/deploy/providers/flightcontrolHandler.js +69 -51
  137. package/dist/commands/setup/deploy/providers/netlify.js +6 -0
  138. package/dist/commands/setup/deploy/providers/netlifyHandler.js +114 -13
  139. package/dist/commands/setup/deploy/providers/renderHandler.js +44 -41
  140. package/dist/commands/setup/deploy/providers/serverlessHandler.js +7 -5
  141. package/dist/commands/setup/deploy/providers/universal-deploy.js +11 -0
  142. package/dist/commands/setup/deploy/providers/universalDeployHandler.js +186 -0
  143. package/dist/commands/setup/deploy/providers/vercel.js +6 -0
  144. package/dist/commands/setup/deploy/providers/vercelHandler.js +115 -14
  145. package/dist/commands/setup/deploy/templates/baremetal.js +3 -2
  146. package/dist/commands/setup/deploy/templates/flightcontrol.js +60 -49
  147. package/dist/commands/setup/deploy/templates/netlify.js +2 -1
  148. package/dist/commands/setup/deploy/templates/netlifyUD.js +31 -0
  149. package/dist/commands/setup/deploy/templates/render.js +32 -17
  150. package/dist/commands/setup/docker/docker.js +2 -2
  151. package/dist/commands/setup/docker/dockerHandler.js +74 -39
  152. package/dist/commands/setup/docker/templates/Dockerfile.npm +119 -0
  153. package/dist/commands/setup/docker/templates/Dockerfile.pnpm +112 -0
  154. package/dist/commands/setup/docker/templates/{Dockerfile → Dockerfile.yarn} +4 -7
  155. package/dist/commands/setup/docker/templates/docker-compose.dev.yml +3 -3
  156. package/dist/commands/setup/docker/templates/docker-compose.prod.yml +2 -5
  157. package/dist/commands/setup/docker/templates/dockerignore +1 -0
  158. package/dist/commands/setup/generator/generator.js +1 -1
  159. package/dist/commands/setup/generator/generatorHandler.js +5 -4
  160. package/dist/commands/setup/graphql/features/fragments/appGqlConfigTransform.js +6 -3
  161. package/dist/commands/setup/graphql/features/fragments/fragments.js +1 -1
  162. package/dist/commands/setup/graphql/features/fragments/fragmentsHandler.js +5 -7
  163. package/dist/commands/setup/graphql/features/trustedDocuments/trustedDocuments.js +1 -1
  164. package/dist/commands/setup/graphql/features/trustedDocuments/trustedDocumentsHandler.js +4 -3
  165. package/dist/commands/setup/i18n/i18n.js +1 -1
  166. package/dist/commands/setup/i18n/i18nHandler.js +21 -17
  167. package/dist/commands/setup/jobs/jobs.js +1 -1
  168. package/dist/commands/setup/jobs/jobsHandler.js +17 -12
  169. package/dist/commands/setup/mailer/mailer.js +1 -1
  170. package/dist/commands/setup/mailer/mailerHandler.js +13 -11
  171. package/dist/commands/setup/middleware/ogImage/ogImage.js +1 -1
  172. package/dist/commands/setup/middleware/ogImage/ogImageHandler.js +2 -1
  173. package/dist/commands/setup/monitoring/sentry/sentry.js +1 -1
  174. package/dist/commands/setup/monitoring/sentry/sentryHandler.js +18 -12
  175. package/dist/commands/setup/neon/neon.js +35 -0
  176. package/dist/commands/setup/neon/neonHandler.js +264 -0
  177. package/dist/commands/setup/package/package.js +1 -1
  178. package/dist/commands/setup/package/packageHandler.js +18 -14
  179. package/dist/commands/setup/realtime/addRealtimeToGraphql.js +1 -1
  180. package/dist/commands/setup/realtime/realtime.js +1 -1
  181. package/dist/commands/setup/realtime/realtimeHandler.js +26 -37
  182. package/dist/commands/setup/realtime/templates/realtime.ts.template +1 -1
  183. package/dist/commands/setup/realtime/templates/subscriptions/countdown/countdown.ts.template +1 -1
  184. package/dist/commands/setup/realtime/templates/subscriptions/newMessage/newMessage.ts.template +1 -1
  185. package/dist/commands/setup/server-file/serverFile.js +1 -1
  186. package/dist/commands/setup/server-file/serverFileHandler.js +18 -10
  187. package/dist/commands/setup/tenancy/authCodemod.js +66 -0
  188. package/dist/commands/setup/tenancy/dbCodemod.js +64 -0
  189. package/dist/commands/setup/tenancy/graphqlCodemod.js +88 -0
  190. package/dist/commands/setup/tenancy/preflight.js +49 -0
  191. package/dist/commands/setup/tenancy/schemaPrisma.js +81 -0
  192. package/dist/commands/setup/tenancy/signupCodemod.js +153 -0
  193. package/dist/commands/setup/tenancy/templates/InvitePage.tsx.template +63 -0
  194. package/dist/commands/setup/tenancy/templates/OrgScope.tsx.template +40 -0
  195. package/dist/commands/setup/tenancy/templates/ensureDefaultOrganizations.dataMigration.ts.template +39 -0
  196. package/dist/commands/setup/tenancy/templates/organizations.scenarios.ts.template +59 -0
  197. package/dist/commands/setup/tenancy/templates/organizations.sdl.ts.template +89 -0
  198. package/dist/commands/setup/tenancy/templates/organizations.test.ts.template +389 -0
  199. package/dist/commands/setup/tenancy/templates/organizations.ts.template +394 -0
  200. package/dist/commands/setup/tenancy/templates/requireMembership.test.ts.template +35 -0
  201. package/dist/commands/setup/tenancy/templates/requireMembership.ts.template +25 -0
  202. package/dist/commands/setup/tenancy/tenancy.js +30 -0
  203. package/dist/commands/setup/tenancy/tenancyHandler.js +425 -0
  204. package/dist/commands/setup/ui/helpers/helpers.js +5 -1
  205. package/dist/commands/setup/ui/libraries/chakra-uiHandler.js +16 -8
  206. package/dist/commands/setup/ui/libraries/mantineHandler.js +26 -16
  207. package/dist/commands/setup/ui/libraries/tailwindcssHandler.js +40 -29
  208. package/dist/commands/setup/uploads/schemaPrisma.js +41 -0
  209. package/dist/commands/setup/uploads/serverFile.js +151 -0
  210. package/dist/commands/setup/uploads/templates/requireUploadToken.ts.template +15 -0
  211. package/dist/commands/setup/uploads/templates/srcLibUploads.ts.template +23 -24
  212. package/dist/commands/setup/uploads/templates/uploads.sdl.ts.template +79 -0
  213. package/dist/commands/setup/uploads/templates/uploads.ts.template +89 -0
  214. package/dist/commands/setup/uploads/templates/withDataUri.ts.template +14 -0
  215. package/dist/commands/setup/uploads/templates/withSignedUrl.ts.template +16 -0
  216. package/dist/commands/setup/uploads/uploads.js +49 -7
  217. package/dist/commands/setup/uploads/uploadsHandler.js +266 -72
  218. package/dist/commands/setup.js +5 -4
  219. package/dist/commands/studio.js +1 -1
  220. package/dist/commands/studioHandler.js +2 -2
  221. package/dist/commands/test/datasourceWarning.js +42 -0
  222. package/dist/commands/test/testHandler.js +104 -0
  223. package/dist/commands/test/viteVersionCheck.js +75 -0
  224. package/dist/commands/test.js +16 -19
  225. package/dist/commands/type-check.js +2 -1
  226. package/dist/commands/type-checkHandler.js +25 -18
  227. package/dist/commands/upgrade/preUpgradeScripts.js +11 -11
  228. package/dist/commands/upgrade/upgrade.js +4 -2
  229. package/dist/commands/upgrade/upgradeHandler.js +97 -73
  230. package/dist/index.js +15 -11
  231. package/dist/lib/background.js +22 -1
  232. package/dist/lib/configureStorybook.js +2 -2
  233. package/dist/lib/exec.js +68 -63
  234. package/dist/lib/exit.js +20 -5
  235. package/dist/lib/extendFile.js +23 -13
  236. package/dist/lib/generatePrismaClient.js +48 -12
  237. package/dist/lib/index.js +137 -106
  238. package/dist/lib/loadEnvFiles.js +1 -1
  239. package/dist/lib/locking.js +8 -4
  240. package/dist/lib/merge/algorithms.js +4 -1
  241. package/dist/lib/merge/index.js +78 -41
  242. package/dist/lib/merge/semanticIdentity.js +18 -6
  243. package/dist/lib/merge/strategy.js +86 -36
  244. package/dist/lib/packages.js +23 -13
  245. package/dist/lib/plugin.js +13 -7
  246. package/dist/lib/pluralHelpers.js +6 -2
  247. package/dist/lib/project.js +3 -1
  248. package/dist/lib/rollback.js +7 -7
  249. package/dist/lib/schemaHelpers.js +16 -19
  250. package/dist/lib/test.js +20 -5
  251. package/dist/lib/updateCheck.js +47 -18
  252. package/dist/middleware/checkNodeVersion.js +9 -4
  253. package/dist/middleware/{detectProjectRxVersion.js → detectProjectCedarVersion.js} +2 -2
  254. package/dist/plugin.js +18 -12
  255. package/dist/telemetry/exporter.js +12 -5
  256. package/dist/telemetry/index.js +4 -3
  257. package/dist/telemetry/resource.js +9 -12
  258. package/dist/telemetry/send.js +14 -10
  259. package/dist/testLib/cells.js +5 -3
  260. package/package.json +44 -41
  261. package/LICENSE +0 -21
  262. package/dist/commands/consoleHandler.js +0 -68
  263. package/dist/commands/setup/tsconfig/tsconfig.js +0 -25
  264. package/dist/commands/setup/tsconfig/tsconfigHandler.js +0 -59
  265. package/dist/commands/setup/uploads/dbCodemod.js +0 -28
  266. package/dist/commands/setup/uploads/templates/signedUrl.ts.template +0 -21
  267. package/dist/commands/setup/vite/templates/vite.config.ts.template +0 -19
  268. package/dist/commands/setup/vite/vite.js +0 -38
  269. package/dist/commands/setup/vite/viteHandler.js +0 -83
  270. package/dist/commands/testEsm.js +0 -36
  271. package/dist/commands/testHandler.js +0 -137
  272. package/dist/commands/testHandlerEsm.js +0 -85
  273. package/dist/commands/ts-to-js.js +0 -20
  274. package/dist/lib/cedarPluralize.js +0 -56
  275. package/dist/lib/colors.js +0 -18
  276. package/dist/testLib/getFilesWithPattern.js +0 -21
  277. /package/dist/{index.d.js → global.d.js} +0 -0
@@ -0,0 +1,394 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ import type {
4
+ QueryResolvers,
5
+ MutationResolvers,
6
+ MembershipRelationResolvers,
7
+ } from 'types/graphql'
8
+
9
+ import { ForbiddenError, ValidationError } from '@cedarjs/graphql-server'
10
+ import { requireCurrentOrg } from '@cedarjs/tenancy'
11
+
12
+ import { Prisma, db } from 'src/lib/db'
13
+
14
+ interface MembershipSummary {
15
+ id: string
16
+ organizationId: string
17
+ role: string
18
+ }
19
+
20
+ /** Lowercases, hyphenates and trims `value` into a URL-safe slug. */
21
+ const slugify = (value: string): string =>
22
+ value
23
+ .toLowerCase()
24
+ .trim()
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/(^-+|-+$)/g, '') || 'organization'
27
+
28
+ const membershipsForUser = (userId: string): Promise<MembershipSummary[]> =>
29
+ db.membership.findMany({
30
+ where: { userId },
31
+ select: { id: true, organizationId: true, role: true },
32
+ })
33
+
34
+ /**
35
+ * Roles this generated service knows how to check, and which of them a
36
+ * membership with that role may grant to someone else (`inviteMember`) or
37
+ * revoke (`removeMember`). `owner` may grant or revoke any known role;
38
+ * `admin` may grant or revoke `admin` and `member`, but never `owner`, so
39
+ * an admin can never create or remove an owner. `member` grants nothing;
40
+ * it's listed anyway so `KNOWN_ROLES` below covers every role a membership
41
+ * can actually hold, which is what makes the "unknown role" check on
42
+ * `inviteMember` meaningful.
43
+ *
44
+ * Add a role here when your app adds one. A role missing from this map can
45
+ * never be granted to, or removed from, anyone by `inviteMember` /
46
+ * `removeMember` -- including by an owner.
47
+ */
48
+ export const ROLE_GRANTS: Readonly<Record<string, readonly string[]>> = {
49
+ owner: ['owner', 'admin', 'member'],
50
+ admin: ['admin', 'member'],
51
+ member: [],
52
+ }
53
+
54
+ const KNOWN_ROLES = Object.keys(ROLE_GRANTS)
55
+
56
+ /** Whether a membership with `granterRole` may grant or revoke `targetRole`. */
57
+ const canGrantRole = (granterRole: string, targetRole: string): boolean =>
58
+ ROLE_GRANTS[granterRole]?.includes(targetRole) ?? false
59
+
60
+ /**
61
+ * Every membership belonging to the signed-in user, across all
62
+ * organizations. `Membership` is a global model, so this reads plain `db`.
63
+ */
64
+ export const myMemberships: QueryResolvers['myMemberships'] = () => {
65
+ // @requireAuth guarantees a signed-in user before this resolver runs.
66
+ return db.membership.findMany({
67
+ where: { userId: context.currentUser!.id },
68
+ orderBy: { createdAt: 'asc' },
69
+ })
70
+ }
71
+
72
+ /**
73
+ * The current organization (the one `resolveCurrentOrg` resolved for this
74
+ * request). `id` is required so callers state which organization they mean,
75
+ * but it must match the current organization: `@requireMembership` only
76
+ * checks that the caller belongs to *some* organization, not to `id`.
77
+ */
78
+ export const organization: QueryResolvers['organization'] = async ({ id }) => {
79
+ const currentOrg = requireCurrentOrg()
80
+
81
+ if (id !== currentOrg.id) {
82
+ throw new ForbiddenError("You don't have access to that organization.")
83
+ }
84
+
85
+ return db.organization.findUnique({ where: { id: currentOrg.id } })
86
+ }
87
+
88
+ /** Creates a new organization with the signed-in user as its owner. */
89
+ export const createOrganization: MutationResolvers['createOrganization'] =
90
+ async ({ input }) => {
91
+ const baseSlug = slugify(input.name)
92
+ // @requireAuth guarantees a signed-in user before this resolver runs.
93
+ const userId = context.currentUser!.id
94
+
95
+ let slug = baseSlug
96
+ let attempt = 0
97
+
98
+ // Retry with a random suffix on a slug collision; two different
99
+ // organizations can share a display name. Each attempt runs in its own
100
+ // transaction so a P2002 on one slug does not abort the retry loop.
101
+ // eslint-disable-next-line no-constant-condition
102
+ while (true) {
103
+ try {
104
+ return await db.$transaction(async (tx) => {
105
+ const newOrganization = await tx.organization.create({
106
+ data: { name: input.name, slug },
107
+ })
108
+
109
+ await tx.membership.create({
110
+ data: {
111
+ role: 'owner',
112
+ userId,
113
+ organizationId: newOrganization.id,
114
+ },
115
+ })
116
+
117
+ return newOrganization
118
+ })
119
+ } catch (e) {
120
+ const isUniqueSlugViolation =
121
+ e instanceof Prisma.PrismaClientKnownRequestError &&
122
+ e.code === 'P2002'
123
+
124
+ if (!isUniqueSlugViolation || attempt >= 5) {
125
+ throw e
126
+ }
127
+
128
+ attempt += 1
129
+ slug = `${baseSlug}-${crypto.randomBytes(3).toString('hex')}`
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Creates a pending membership (an invitation) in the current organization,
136
+ * for a role the caller's own role may grant per `ROLE_GRANTS` -- an
137
+ * `admin` can invite an `admin` or a `member`, but not an `owner`, even
138
+ * though `@requireMembership(roles: ["owner", "admin"])` lets an admin call
139
+ * this mutation at all. A role that isn't in `ROLE_GRANTS` is rejected
140
+ * outright, so a typo in `input.role` can never create a membership nobody
141
+ * can check against.
142
+ *
143
+ * `Membership` has no email column: the caller is responsible for emailing
144
+ * `input.email` a link built from the returned `invitationToken`, with its
145
+ * own mailer. The token is returned here and nowhere else -- see the SDL
146
+ * doc comment on `Invitation` for why.
147
+ */
148
+ export const inviteMember: MutationResolvers['inviteMember'] = async ({
149
+ input,
150
+ }) => {
151
+ const currentOrg = requireCurrentOrg()
152
+
153
+ if (!KNOWN_ROLES.includes(input.role)) {
154
+ throw new ValidationError(
155
+ `"${input.role}" is not a known role. Add it to ROLE_GRANTS in ` +
156
+ 'organizations.ts before inviting with it.',
157
+ )
158
+ }
159
+
160
+ if (!canGrantRole(currentOrg.role, input.role)) {
161
+ throw new ForbiddenError(
162
+ `A "${currentOrg.role}" cannot invite a "${input.role}".`,
163
+ )
164
+ }
165
+
166
+ const invitationToken = crypto.randomUUID()
167
+
168
+ const membership = await db.membership.create({
169
+ data: {
170
+ role: input.role,
171
+ organizationId: currentOrg.id,
172
+ invitedById: currentOrg.membershipId,
173
+ invitationToken,
174
+ },
175
+ })
176
+
177
+ return { membership, invitationToken }
178
+ }
179
+
180
+ /**
181
+ * Marks a claimed invitation's token as spent while keeping it queryable by
182
+ * the exact original token value, so a repeat claim from whoever holds that
183
+ * value resolves to the same row instead of "invalid token". The prefix can
184
+ * never collide with a freshly generated token (`crypto.randomUUID()`
185
+ * output), and a claimed row's `userId` is never `null` again, so this
186
+ * value can never satisfy a fresh claim's guard (`invitationToken: token,
187
+ * userId: null`) -- the original credential is spent no matter who ends up
188
+ * holding it.
189
+ */
190
+ const spentInvitationToken = (token: string): string => `claimed:${token}`
191
+
192
+ interface ClaimedInvitation {
193
+ organizationId: string
194
+ }
195
+
196
+ /**
197
+ * Atomically attaches the pending membership matching `token` to `userId`:
198
+ * the `where` guard (the token, still unclaimed) is checked by the same
199
+ * write that assigns `userId` and spends the token, so two concurrent
200
+ * claims of the same token can never both win. The row is then read back by
201
+ * its spent token, which is the same read a repeat call by the user who
202
+ * already holds it needs. The claim is an `updateMany` rather than
203
+ * `updateManyAndReturn` because Prisma only supports the latter on
204
+ * PostgreSQL, CockroachDB and SQLite.
205
+ *
206
+ * Returns the claimed membership's organization id when `userId` holds the
207
+ * membership, whether this call claimed it or an earlier one did, or `null`
208
+ * when the token is invalid or was claimed by a different user.
209
+ */
210
+ const claimInvitation = async (
211
+ token: string,
212
+ userId: string,
213
+ ): Promise<ClaimedInvitation | null> => {
214
+ await db.membership.updateMany({
215
+ where: { invitationToken: token, userId: null },
216
+ data: { userId, invitationToken: spentInvitationToken(token) },
217
+ })
218
+
219
+ const claimed = await db.membership.findFirst({
220
+ where: { invitationToken: spentInvitationToken(token), userId },
221
+ })
222
+
223
+ return claimed ? { organizationId: claimed.organizationId } : null
224
+ }
225
+
226
+ /**
227
+ * Attaches the pending membership matching `token` to the signed-in user.
228
+ * Idempotent when it is already attached to them; throws when the token
229
+ * does not match a pending (unclaimed) membership, including when someone
230
+ * else has already claimed it.
231
+ */
232
+ export const acceptInvitation: MutationResolvers['acceptInvitation'] = async ({
233
+ token,
234
+ }) => {
235
+ // @requireAuth guarantees a signed-in user before this resolver runs.
236
+ const userId = context.currentUser!.id
237
+
238
+ const claim = await claimInvitation(token, userId)
239
+
240
+ if (!claim) {
241
+ throw new ValidationError('This invitation is invalid or has expired.')
242
+ }
243
+
244
+ return db.organization.findUniqueOrThrow({
245
+ where: { id: claim.organizationId },
246
+ })
247
+ }
248
+
249
+ /**
250
+ * Removes a membership from the current organization. A caller may only
251
+ * remove a membership whose role they could grant via `inviteMember` (the
252
+ * same `ROLE_GRANTS` rule), so an admin can never remove an owner, and the
253
+ * organization's last owner can never be removed -- including by
254
+ * themselves -- so an organization always keeps at least one owner who has
255
+ * accepted their invitation.
256
+ */
257
+ export const removeMember: MutationResolvers['removeMember'] = async ({
258
+ membershipId,
259
+ }) => {
260
+ const currentOrg = requireCurrentOrg()
261
+
262
+ const membership = await db.membership.findUnique({
263
+ where: { id: membershipId },
264
+ })
265
+
266
+ if (!membership || membership.organizationId !== currentOrg.id) {
267
+ throw new ForbiddenError("You don't have access to that membership.")
268
+ }
269
+
270
+ if (!canGrantRole(currentOrg.role, membership.role)) {
271
+ throw new ForbiddenError(
272
+ `A "${currentOrg.role}" cannot remove a "${membership.role}" member.`,
273
+ )
274
+ }
275
+
276
+ // Removing an owner is guarded in the delete itself rather than by a
277
+ // count read first, so the same membership cannot pass a check that another
278
+ // request has already invalidated. A pending invitation is not an owner
279
+ // yet, so `userId` has to be set for one to count.
280
+ //
281
+ // The guard reads other memberships that a concurrent request may be
282
+ // deleting, so the delete runs in a serializable transaction: when two
283
+ // owners remove each other in the same instant the database rejects one of
284
+ // the two requests with a write conflict, and that caller can retry.
285
+ const requireAnotherOwner =
286
+ membership.role === 'owner'
287
+ ? {
288
+ organization: {
289
+ memberships: {
290
+ some: {
291
+ role: 'owner',
292
+ userId: { not: null },
293
+ id: { not: membershipId },
294
+ },
295
+ },
296
+ },
297
+ }
298
+ : {}
299
+
300
+ const { count } = await db.$transaction(
301
+ (tx) =>
302
+ tx.membership.deleteMany({
303
+ where: {
304
+ id: membershipId,
305
+ organizationId: currentOrg.id,
306
+ ...requireAnotherOwner,
307
+ },
308
+ }),
309
+ { isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
310
+ )
311
+
312
+ if (count === 0) {
313
+ throw new ValidationError(
314
+ 'An organization must have at least one owner who has accepted their ' +
315
+ 'invitation.',
316
+ )
317
+ }
318
+
319
+ return membership
320
+ }
321
+
322
+ export const Membership: MembershipRelationResolvers = {
323
+ organization: (_obj, { root }) =>
324
+ db.membership.findUnique({ where: { id: root?.id } }).organization(),
325
+ }
326
+
327
+ interface EnsureDefaultOrganizationArgs {
328
+ currentUser: { id: string; memberships: MembershipSummary[] }
329
+ invitationToken?: string | null
330
+ }
331
+
332
+ /**
333
+ * Ensures the signed-in user has at least one organization: idempotent
334
+ * (returns the existing memberships unchanged when there are any), attaches
335
+ * a pending invitation matching `invitationToken` when there is one, and
336
+ * otherwise creates a new organization with a deterministic slug
337
+ * (`user-<userId>`) and an `owner` membership in one transaction.
338
+ *
339
+ * The invitation branch shares `claimInvitation` with `acceptInvitation`, so
340
+ * it gets the same atomic, race-safe claim: two concurrent first requests
341
+ * carrying the same `invitationToken` can never both attach it, and a
342
+ * request that loses that race still gets the organization once the
343
+ * winner's write is visible.
344
+ *
345
+ * A unique-constraint failure on the deterministic slug means a concurrent
346
+ * first request already created it; that case is treated as success and the
347
+ * memberships are refetched rather than raised as an error.
348
+ *
349
+ * Not exposed in the SDL: called from the GraphQL `context` function on
350
+ * every request and, for dbAuth, from the signup handler.
351
+ */
352
+ export const ensureDefaultOrganization = async ({
353
+ currentUser,
354
+ invitationToken,
355
+ }: EnsureDefaultOrganizationArgs): Promise<MembershipSummary[]> => {
356
+ if (currentUser.memberships.length > 0) {
357
+ return currentUser.memberships
358
+ }
359
+
360
+ if (invitationToken) {
361
+ const claim = await claimInvitation(invitationToken, currentUser.id)
362
+
363
+ if (claim) {
364
+ return membershipsForUser(currentUser.id)
365
+ }
366
+ }
367
+
368
+ const slug = `user-${currentUser.id}`
369
+
370
+ try {
371
+ await db.$transaction(async (tx) => {
372
+ const newOrganization = await tx.organization.create({
373
+ data: { name: 'My Organization', slug },
374
+ })
375
+
376
+ await tx.membership.create({
377
+ data: {
378
+ role: 'owner',
379
+ userId: currentUser.id,
380
+ organizationId: newOrganization.id,
381
+ },
382
+ })
383
+ })
384
+ } catch (e) {
385
+ const isUniqueSlugViolation =
386
+ e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002'
387
+
388
+ if (!isUniqueSlugViolation) {
389
+ throw e
390
+ }
391
+ }
392
+
393
+ return membershipsForUser(currentUser.id)
394
+ }
@@ -0,0 +1,35 @@
1
+ import { mockRedwoodDirective, getDirectiveName } from '@cedarjs/testing/api'
2
+
3
+ import requireMembership from './requireMembership.js'
4
+
5
+ /**
6
+ * `context.currentUser` is typed from this app's `getCurrentUser`, which
7
+ * selects more fields than these tests need.
8
+ */
9
+ const asCurrentUser = (user: { id: string; memberships?: unknown[] }) =>
10
+ user as unknown as typeof context.currentUser
11
+
12
+ describe('requireMembership directive', () => {
13
+ it('declares the directive sdl as schema, with the correct name', () => {
14
+ expect(requireMembership.schema).toBeTruthy()
15
+ expect(getDirectiveName(requireMembership.schema)).toBe(
16
+ 'requireMembership',
17
+ )
18
+ })
19
+
20
+ it('throws when there is no current user', () => {
21
+ const mockExecution = mockRedwoodDirective(requireMembership, {
22
+ context: {},
23
+ })
24
+
25
+ expect(mockExecution).toThrowError()
26
+ })
27
+
28
+ it('throws when there is a current user but no current organization', () => {
29
+ const mockExecution = mockRedwoodDirective(requireMembership, {
30
+ context: { currentUser: asCurrentUser({ id: '1' }) },
31
+ })
32
+
33
+ expect(mockExecution).toThrowError()
34
+ })
35
+ })
@@ -0,0 +1,25 @@
1
+ import { gql } from 'graphql-tag'
2
+
3
+ import type { ValidatorDirectiveFunc } from '@cedarjs/graphql-server'
4
+ import { createValidatorDirective } from '@cedarjs/graphql-server'
5
+
6
+ import { requireMembership as applicationRequireMembership } from 'src/lib/auth'
7
+
8
+ export const schema = gql`
9
+ """
10
+ Use to check whether or not the current user is a member of the current
11
+ organization, optionally restricted to a set of roles.
12
+ """
13
+ directive @requireMembership(roles: [String]) on FIELD_DEFINITION
14
+ `
15
+
16
+ type RequireMembershipValidate = ValidatorDirectiveFunc<{ roles?: string[] }>
17
+
18
+ const validate: RequireMembershipValidate = ({ directiveArgs }) => {
19
+ const { roles } = directiveArgs
20
+ applicationRequireMembership({ roles })
21
+ }
22
+
23
+ const requireMembership = createValidatorDirective(schema, validate)
24
+
25
+ export default requireMembership
@@ -0,0 +1,30 @@
1
+ import { recordTelemetryAttributes } from "@cedarjs/cli-helpers/telemetry";
2
+ const command = "tenancy";
3
+ const description = "Set up multi-tenancy: organizations, memberships and tenant-scoped database access";
4
+ const builder = (yargs) => {
5
+ yargs.option("tenant-field", {
6
+ default: "organizationId",
7
+ description: "Column name used to scope tenant-owned models",
8
+ type: "string"
9
+ }).option("force", {
10
+ alias: "f",
11
+ default: false,
12
+ description: "Overwrite existing configuration",
13
+ type: "boolean"
14
+ });
15
+ };
16
+ const handler = async (options) => {
17
+ recordTelemetryAttributes({
18
+ command: "setup tenancy",
19
+ tenantField: options.tenantField,
20
+ force: options.force
21
+ });
22
+ const { handler: handler2 } = await import("./tenancyHandler.js");
23
+ return handler2(options);
24
+ };
25
+ export {
26
+ builder,
27
+ command,
28
+ description,
29
+ handler
30
+ };