@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.
- package/README.md +7 -10
- package/dist/cfw.js +10 -6
- package/dist/commands/build/buildHandler.js +220 -45
- package/dist/commands/build/buildPackagesTask.js +21 -20
- package/dist/commands/build.js +13 -2
- package/dist/commands/check.js +4 -6
- package/dist/commands/console.js +6 -4
- package/dist/commands/deploy/baremetal/SshExecutor.js +13 -4
- package/dist/commands/deploy/baremetal/baremetalHandler.js +116 -50
- package/dist/commands/deploy/baremetal.js +11 -4
- package/dist/commands/deploy/flightcontrolHandler.js +28 -15
- package/dist/commands/deploy/helpers/deployHandler.js +11 -6
- package/dist/commands/deploy/netlify.js +1 -1
- package/dist/commands/deploy/packing/nft.js +1 -2
- package/dist/commands/deploy/render.js +1 -1
- package/dist/commands/deploy/renderHandler.js +24 -13
- package/dist/commands/deploy/serverlessHandler.js +69 -27
- package/dist/commands/deploy/vercel.js +1 -1
- package/dist/commands/destroy/component/component.js +2 -3
- package/dist/commands/destroy/directive/directive.js +1 -3
- package/dist/commands/destroy/directive/directiveHandler.js +2 -4
- package/dist/commands/destroy/function/function.js +1 -3
- package/dist/commands/destroy/handlerHelpers.js +17 -17
- package/dist/commands/destroy/helpers.js +8 -2
- package/dist/commands/destroy/layout/layout.js +1 -2
- package/dist/commands/destroy/page/pageHandler.js +3 -3
- package/dist/commands/destroy/scaffold/scaffoldHandler.js +31 -12
- package/dist/commands/destroy/sdl/sdlHandler.js +3 -3
- package/dist/commands/dev/apiDebugFlag.js +5 -4
- package/dist/commands/dev/devHandler.js +219 -68
- package/dist/commands/dev/packageWatchCommands.js +8 -3
- package/dist/commands/dev.js +16 -6
- package/dist/commands/execHandler.js +49 -29
- package/dist/commands/experimental/live-queries/liveQueries.js +32 -0
- package/dist/commands/experimental/live-queries/liveQueriesHandler.js +448 -0
- package/dist/commands/experimental/live-queries/templates/liveQueriesListener.ts.template +153 -0
- package/dist/commands/experimental/live-queries/templates/migration.sql.template +100 -0
- package/dist/commands/experimental/setupInngest.js +2 -2
- package/dist/commands/experimental/setupInngestHandler.js +13 -12
- package/dist/commands/experimental/setupOpentelemetry.js +2 -2
- package/dist/commands/experimental/setupOpentelemetryHandler.js +14 -10
- package/dist/commands/experimental/setupReactCompiler.js +2 -2
- package/dist/commands/experimental/setupReactCompilerHandler.js +48 -33
- package/dist/commands/experimental/setupRsc.js +1 -1
- package/dist/commands/experimental/setupRscHandler.js +20 -16
- package/dist/commands/experimental/setupStreamingSsr.js +2 -2
- package/dist/commands/experimental/setupStreamingSsrHandler.js +49 -34
- package/dist/commands/experimental/templates/opentelemetry.ts.template +2 -1
- package/dist/commands/experimental/templates/rsc/Document.tsx.template +1 -1
- package/dist/commands/experimental/templates/rsc/entry.client.tsx.template +8 -8
- package/dist/commands/experimental/templates/streamingSsr/Document.tsx.template +1 -1
- package/dist/commands/experimental/templates/streamingSsr/entry.client.tsx.template +6 -6
- package/dist/commands/experimental/templates/streamingSsr/tsconfig.json.template +6 -6
- package/dist/commands/experimental/util.js +27 -25
- package/dist/commands/experimental.js +3 -2
- package/dist/commands/generate/cell/cell.js +20 -0
- package/dist/commands/generate/cell/cellHandler.js +89 -40
- package/dist/commands/generate/cell/templates/cell.tsx.template +21 -2
- package/dist/commands/generate/cell/templates/cellFragment.tsx.template +43 -0
- package/dist/commands/generate/cell/templates/cellList.tsx.template +21 -2
- package/dist/commands/generate/cell/templates/storiesFragment.tsx.template +23 -0
- package/dist/commands/generate/cell/templates/test.js.template +1 -1
- package/dist/commands/generate/cell/templates/testFragment.js.template +30 -0
- package/dist/commands/generate/component/componentHandler.js +14 -16
- package/dist/commands/generate/dataMigration/dataMigration.js +36 -18
- package/dist/commands/generate/dataMigration/templates/dataMigration.js.template +1 -1
- package/dist/commands/generate/dataMigration/templates/dataMigration.ts.template +1 -1
- package/dist/commands/generate/dbAuth/dbAuth.js +1 -0
- package/dist/commands/generate/dbAuth/dbAuthHandler.js +63 -34
- package/dist/commands/generate/dbAuth/templates/login.oauth.tsx.template +164 -0
- package/dist/commands/generate/dbAuth/templates/signup.oauth.tsx.template +147 -0
- package/dist/commands/generate/directive/directiveHandler.js +14 -16
- package/dist/commands/generate/function/function.js +1 -0
- package/dist/commands/generate/function/functionHandler.js +14 -19
- package/dist/commands/generate/helpers.js +34 -18
- package/dist/commands/generate/job/job.js +1 -0
- package/dist/commands/generate/job/jobHandler.js +20 -24
- package/dist/commands/generate/layout/layoutHandler.js +13 -16
- package/dist/commands/generate/model/model.js +1 -0
- package/dist/commands/generate/model/modelHandler.js +11 -5
- package/dist/commands/generate/ogImage/ogImage.js +1 -0
- package/dist/commands/generate/ogImage/ogImageHandler.js +18 -11
- package/dist/commands/generate/package/filesTask.js +9 -0
- package/dist/commands/generate/package/packageHandler.js +50 -61
- package/dist/commands/generate/package/templates/test.ts.template +2 -2
- package/dist/commands/generate/package/templates/vitest.config.ts.template +9 -0
- package/dist/commands/generate/page/pageHandler.js +27 -24
- package/dist/commands/generate/realtime/realtime.js +2 -2
- package/dist/commands/generate/realtime/realtimeHandler.js +51 -32
- package/dist/commands/generate/realtime/templates/subscriptions/blank/blank.ts.template +1 -1
- package/dist/commands/generate/scaffold/scaffold.js +1 -0
- package/dist/commands/generate/scaffold/scaffoldHandler.js +176 -49
- package/dist/commands/generate/scaffold/templates/lib/formatters.tsx.template +1 -1
- package/dist/commands/generate/script/script.js +1 -0
- package/dist/commands/generate/script/scriptHandler.js +10 -6
- package/dist/commands/generate/script/templates/script.ts.template +9 -4
- package/dist/commands/generate/script/templates/tsconfig.json.template +16 -2
- package/dist/commands/generate/sdl/sdl.js +4 -3
- package/dist/commands/generate/sdl/sdlHandler.js +135 -46
- package/dist/commands/generate/sdl/stubFiles.js +132 -0
- package/dist/commands/generate/sdl/templates/sdl.js.template +2 -1
- package/dist/commands/generate/sdl/templates/sdl.ts.template +2 -1
- package/dist/commands/generate/secret/secret.js +20 -20
- package/dist/commands/generate/service/service.js +5 -5
- package/dist/commands/generate/service/serviceHandler.js +126 -61
- package/dist/commands/generate/service/templates/scenarios.ts.template +4 -2
- package/dist/commands/generate/service/templates/service.ts.template +22 -8
- package/dist/commands/generate/service/templates/test.ts.template +2 -2
- package/dist/commands/generate/yargsCommandHelpers.js +7 -2
- package/dist/commands/generate/yargsHandlerHelpers.js +20 -18
- package/dist/commands/generate.js +4 -4
- package/dist/commands/info.js +2 -2
- package/dist/commands/jobsHandler.js +7 -4
- package/dist/commands/lint.js +26 -69
- package/dist/commands/prerenderHandler.js +95 -58
- package/dist/commands/prismaHandler.js +51 -26
- package/dist/commands/record/init.js +2 -2
- package/dist/commands/record.js +4 -3
- package/dist/commands/serve.js +245 -31
- package/dist/commands/serveApiHandler.js +7 -6
- package/dist/commands/serveBothHandler.js +22 -13
- package/dist/commands/serveWebHandler.js +3 -2
- package/dist/commands/setup/auth/auth.js +15 -11
- package/dist/commands/setup/cache/cache.js +1 -1
- package/dist/commands/setup/cache/cacheHandler.js +15 -9
- package/dist/commands/setup/database/database.js +15 -0
- package/dist/commands/setup/database/postgres.js +19 -0
- package/dist/commands/setup/database/postgresHandler.js +196 -0
- package/dist/commands/setup/database/templates/db.ts.template +32 -0
- package/dist/commands/setup/deploy/deploy.js +4 -3
- package/dist/commands/setup/deploy/helpers/helpers.js +5 -1
- package/dist/commands/setup/deploy/helpers/index.js +132 -6
- package/dist/commands/setup/deploy/providers/baremetalHandler.js +28 -6
- package/dist/commands/setup/deploy/providers/coherence.js +1 -1
- package/dist/commands/setup/deploy/providers/coherenceHandler.js +60 -31
- package/dist/commands/setup/deploy/providers/flightcontrolHandler.js +69 -51
- package/dist/commands/setup/deploy/providers/netlify.js +6 -0
- package/dist/commands/setup/deploy/providers/netlifyHandler.js +114 -13
- package/dist/commands/setup/deploy/providers/renderHandler.js +44 -41
- package/dist/commands/setup/deploy/providers/serverlessHandler.js +7 -5
- package/dist/commands/setup/deploy/providers/universal-deploy.js +11 -0
- package/dist/commands/setup/deploy/providers/universalDeployHandler.js +186 -0
- package/dist/commands/setup/deploy/providers/vercel.js +6 -0
- package/dist/commands/setup/deploy/providers/vercelHandler.js +115 -14
- package/dist/commands/setup/deploy/templates/baremetal.js +3 -2
- package/dist/commands/setup/deploy/templates/flightcontrol.js +60 -49
- package/dist/commands/setup/deploy/templates/netlify.js +2 -1
- package/dist/commands/setup/deploy/templates/netlifyUD.js +31 -0
- package/dist/commands/setup/deploy/templates/render.js +32 -17
- package/dist/commands/setup/docker/docker.js +2 -2
- package/dist/commands/setup/docker/dockerHandler.js +74 -39
- package/dist/commands/setup/docker/templates/Dockerfile.npm +119 -0
- package/dist/commands/setup/docker/templates/Dockerfile.pnpm +112 -0
- package/dist/commands/setup/docker/templates/{Dockerfile → Dockerfile.yarn} +4 -7
- package/dist/commands/setup/docker/templates/docker-compose.dev.yml +3 -3
- package/dist/commands/setup/docker/templates/docker-compose.prod.yml +2 -5
- package/dist/commands/setup/docker/templates/dockerignore +1 -0
- package/dist/commands/setup/generator/generator.js +1 -1
- package/dist/commands/setup/generator/generatorHandler.js +5 -4
- package/dist/commands/setup/graphql/features/fragments/appGqlConfigTransform.js +6 -3
- package/dist/commands/setup/graphql/features/fragments/fragments.js +1 -1
- package/dist/commands/setup/graphql/features/fragments/fragmentsHandler.js +5 -7
- package/dist/commands/setup/graphql/features/trustedDocuments/trustedDocuments.js +1 -1
- package/dist/commands/setup/graphql/features/trustedDocuments/trustedDocumentsHandler.js +4 -3
- package/dist/commands/setup/i18n/i18n.js +1 -1
- package/dist/commands/setup/i18n/i18nHandler.js +21 -17
- package/dist/commands/setup/jobs/jobs.js +1 -1
- package/dist/commands/setup/jobs/jobsHandler.js +17 -12
- package/dist/commands/setup/mailer/mailer.js +1 -1
- package/dist/commands/setup/mailer/mailerHandler.js +13 -11
- package/dist/commands/setup/middleware/ogImage/ogImage.js +1 -1
- package/dist/commands/setup/middleware/ogImage/ogImageHandler.js +2 -1
- package/dist/commands/setup/monitoring/sentry/sentry.js +1 -1
- package/dist/commands/setup/monitoring/sentry/sentryHandler.js +18 -12
- package/dist/commands/setup/neon/neon.js +35 -0
- package/dist/commands/setup/neon/neonHandler.js +264 -0
- package/dist/commands/setup/package/package.js +1 -1
- package/dist/commands/setup/package/packageHandler.js +18 -14
- package/dist/commands/setup/realtime/addRealtimeToGraphql.js +1 -1
- package/dist/commands/setup/realtime/realtime.js +1 -1
- package/dist/commands/setup/realtime/realtimeHandler.js +26 -37
- package/dist/commands/setup/realtime/templates/realtime.ts.template +1 -1
- package/dist/commands/setup/realtime/templates/subscriptions/countdown/countdown.ts.template +1 -1
- package/dist/commands/setup/realtime/templates/subscriptions/newMessage/newMessage.ts.template +1 -1
- package/dist/commands/setup/server-file/serverFile.js +1 -1
- package/dist/commands/setup/server-file/serverFileHandler.js +18 -10
- package/dist/commands/setup/tenancy/authCodemod.js +66 -0
- package/dist/commands/setup/tenancy/dbCodemod.js +64 -0
- package/dist/commands/setup/tenancy/graphqlCodemod.js +88 -0
- package/dist/commands/setup/tenancy/preflight.js +49 -0
- package/dist/commands/setup/tenancy/schemaPrisma.js +81 -0
- package/dist/commands/setup/tenancy/signupCodemod.js +153 -0
- package/dist/commands/setup/tenancy/templates/InvitePage.tsx.template +63 -0
- package/dist/commands/setup/tenancy/templates/OrgScope.tsx.template +40 -0
- package/dist/commands/setup/tenancy/templates/ensureDefaultOrganizations.dataMigration.ts.template +39 -0
- package/dist/commands/setup/tenancy/templates/organizations.scenarios.ts.template +59 -0
- package/dist/commands/setup/tenancy/templates/organizations.sdl.ts.template +89 -0
- package/dist/commands/setup/tenancy/templates/organizations.test.ts.template +389 -0
- package/dist/commands/setup/tenancy/templates/organizations.ts.template +394 -0
- package/dist/commands/setup/tenancy/templates/requireMembership.test.ts.template +35 -0
- package/dist/commands/setup/tenancy/templates/requireMembership.ts.template +25 -0
- package/dist/commands/setup/tenancy/tenancy.js +30 -0
- package/dist/commands/setup/tenancy/tenancyHandler.js +425 -0
- package/dist/commands/setup/ui/helpers/helpers.js +5 -1
- package/dist/commands/setup/ui/libraries/chakra-uiHandler.js +16 -8
- package/dist/commands/setup/ui/libraries/mantineHandler.js +26 -16
- package/dist/commands/setup/ui/libraries/tailwindcssHandler.js +40 -29
- package/dist/commands/setup/uploads/schemaPrisma.js +41 -0
- package/dist/commands/setup/uploads/serverFile.js +151 -0
- package/dist/commands/setup/uploads/templates/requireUploadToken.ts.template +15 -0
- package/dist/commands/setup/uploads/templates/srcLibUploads.ts.template +23 -24
- package/dist/commands/setup/uploads/templates/uploads.sdl.ts.template +79 -0
- package/dist/commands/setup/uploads/templates/uploads.ts.template +89 -0
- package/dist/commands/setup/uploads/templates/withDataUri.ts.template +14 -0
- package/dist/commands/setup/uploads/templates/withSignedUrl.ts.template +16 -0
- package/dist/commands/setup/uploads/uploads.js +49 -7
- package/dist/commands/setup/uploads/uploadsHandler.js +266 -72
- package/dist/commands/setup.js +5 -4
- package/dist/commands/studio.js +1 -1
- package/dist/commands/studioHandler.js +2 -2
- package/dist/commands/test/datasourceWarning.js +42 -0
- package/dist/commands/test/testHandler.js +104 -0
- package/dist/commands/test/viteVersionCheck.js +75 -0
- package/dist/commands/test.js +16 -19
- package/dist/commands/type-check.js +2 -1
- package/dist/commands/type-checkHandler.js +25 -18
- package/dist/commands/upgrade/preUpgradeScripts.js +11 -11
- package/dist/commands/upgrade/upgrade.js +4 -2
- package/dist/commands/upgrade/upgradeHandler.js +97 -73
- package/dist/index.js +15 -11
- package/dist/lib/background.js +22 -1
- package/dist/lib/configureStorybook.js +2 -2
- package/dist/lib/exec.js +68 -63
- package/dist/lib/exit.js +20 -5
- package/dist/lib/extendFile.js +23 -13
- package/dist/lib/generatePrismaClient.js +48 -12
- package/dist/lib/index.js +137 -106
- package/dist/lib/loadEnvFiles.js +1 -1
- package/dist/lib/locking.js +8 -4
- package/dist/lib/merge/algorithms.js +4 -1
- package/dist/lib/merge/index.js +78 -41
- package/dist/lib/merge/semanticIdentity.js +18 -6
- package/dist/lib/merge/strategy.js +86 -36
- package/dist/lib/packages.js +23 -13
- package/dist/lib/plugin.js +13 -7
- package/dist/lib/pluralHelpers.js +6 -2
- package/dist/lib/project.js +3 -1
- package/dist/lib/rollback.js +7 -7
- package/dist/lib/schemaHelpers.js +16 -19
- package/dist/lib/test.js +20 -5
- package/dist/lib/updateCheck.js +47 -18
- package/dist/middleware/checkNodeVersion.js +9 -4
- package/dist/middleware/{detectProjectRxVersion.js → detectProjectCedarVersion.js} +2 -2
- package/dist/plugin.js +18 -12
- package/dist/telemetry/exporter.js +12 -5
- package/dist/telemetry/index.js +4 -3
- package/dist/telemetry/resource.js +9 -12
- package/dist/telemetry/send.js +14 -10
- package/dist/testLib/cells.js +5 -3
- package/package.json +44 -41
- package/LICENSE +0 -21
- package/dist/commands/consoleHandler.js +0 -68
- package/dist/commands/setup/tsconfig/tsconfig.js +0 -25
- package/dist/commands/setup/tsconfig/tsconfigHandler.js +0 -59
- package/dist/commands/setup/uploads/dbCodemod.js +0 -28
- package/dist/commands/setup/uploads/templates/signedUrl.ts.template +0 -21
- package/dist/commands/setup/vite/templates/vite.config.ts.template +0 -19
- package/dist/commands/setup/vite/vite.js +0 -38
- package/dist/commands/setup/vite/viteHandler.js +0 -83
- package/dist/commands/testEsm.js +0 -36
- package/dist/commands/testHandler.js +0 -137
- package/dist/commands/testHandlerEsm.js +0 -85
- package/dist/commands/ts-to-js.js +0 -20
- package/dist/lib/cedarPluralize.js +0 -56
- package/dist/lib/colors.js +0 -18
- package/dist/testLib/getFilesWithPattern.js +0 -21
- /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
|
+
};
|