feat: personal project limits (#4759)

* base limits cleanup

* history limit tests

* canCreatePersonal & tests

* canInvite block

* WIP model check

* fix tests

* shared tests fix

* lodash import fix

* lint fix

* help update

* hopefully fixing test

* CR comment
This commit is contained in:
Kristaps Fabians Geikins
2025-05-20 14:56:05 +03:00
committed by GitHub
parent d2f2d95bb5
commit 9998ed2586
65 changed files with 3329 additions and 2354 deletions
+6 -2
View File
@@ -715,8 +715,12 @@ jobs:
command: yarn lint:ci
working_directory: 'packages/shared'
- run:
name: Run tests
command: yarn test:ci
name: Run tests (all FFs)
command: ENABLE_ALL_FFS=1 yarn test:ci
working_directory: 'packages/shared'
- run:
name: Run tests (no FFs)
command: DISABLE_ALL_FFS=1 yarn test:ci
working_directory: 'packages/shared'
- codecov/upload:
files: packages/shared/coverage/coverage-final.json
+1
View File
@@ -120,6 +120,7 @@ generates:
- 'test/graphql/*.{js,ts}'
- 'modules/**/tests/helpers/graphql.ts'
- 'modules/**/tests/helpers/*Graphql.ts'
- 'modules/**/tests/helpers/graphql/*.ts'
config:
enumsAsConst: true
scalars:
@@ -1,61 +1,54 @@
/* istanbul ignore file */
const expect = require('chai').expect
import { expect } from 'chai'
const { beforeEachContext, initializeTestServer } = require('@/test/hooks')
const { noErrors } = require('@/test/helpers')
import { beforeEachContext, initializeTestServer } from '@/test/hooks'
import { noErrors } from '@/test/helpers'
const { Roles, Scopes } = require('@speckle/shared')
const { getUserActivityFactory } = require('@/modules/activitystream/repositories')
const { db } = require('@/db/knex')
const {
import { Roles, Scopes } from '@speckle/shared'
import { getUserActivityFactory } from '@/modules/activitystream/repositories'
import { db } from '@/db/knex'
import {
validateStreamAccessFactory,
addOrUpdateStreamCollaboratorFactory
} = require('@/modules/core/services/streams/access')
const { authorizeResolver } = require('@/modules/shared')
const { grantStreamPermissionsFactory } = require('@/modules/core/repositories/streams')
const {
} from '@/modules/core/services/streams/access'
import { authorizeResolver } from '@/modules/shared'
import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
import {
getUserFactory,
storeUserFactory,
countAdminUsersFactory,
storeUserAclFactory
} = require('@/modules/core/repositories/users')
const {
} from '@/modules/core/repositories/users'
import {
findEmailFactory,
createUserEmailFactory,
ensureNoPrimaryEmailForUserFactory
} = require('@/modules/core/repositories/userEmails')
const {
requestNewEmailVerificationFactory
} = require('@/modules/emails/services/verification/request')
const {
deleteOldAndInsertNewVerificationFactory
} = require('@/modules/emails/repositories')
const { renderEmail } = require('@/modules/emails/services/emailRendering')
const { sendEmail } = require('@/modules/emails/services/sending')
const { createUserFactory } = require('@/modules/core/services/users/management')
const {
validateAndCreateUserEmailFactory
} = require('@/modules/core/services/userEmails')
const {
finalizeInvitedServerRegistrationFactory
} = require('@/modules/serverinvites/services/processing')
const {
} from '@/modules/core/repositories/userEmails'
import { requestNewEmailVerificationFactory } from '@/modules/emails/services/verification/request'
import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repositories'
import { renderEmail } from '@/modules/emails/services/emailRendering'
import { sendEmail } from '@/modules/emails/services/sending'
import { createUserFactory } from '@/modules/core/services/users/management'
import { validateAndCreateUserEmailFactory } from '@/modules/core/services/userEmails'
import { finalizeInvitedServerRegistrationFactory } from '@/modules/serverinvites/services/processing'
import {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
const { createPersonalAccessTokenFactory } = require('@/modules/core/services/tokens')
const {
} from '@/modules/serverinvites/repositories/serverInvites'
import { createPersonalAccessTokenFactory } from '@/modules/core/services/tokens'
import {
storePersonalApiTokenFactory,
storeApiTokenFactory,
storeTokenScopesFactory,
storeTokenResourceAccessDefinitionsFactory
} = require('@/modules/core/repositories/tokens')
const { getServerInfoFactory } = require('@/modules/core/repositories/server')
const { createObjectFactory } = require('@/modules/core/services/objects/management')
const {
storeSingleObjectIfNotFoundFactory
} = require('@/modules/core/repositories/objects')
const { getEventBus } = require('@/modules/shared/services/eventBus')
} from '@/modules/core/repositories/tokens'
import { getServerInfoFactory } from '@/modules/core/repositories/server'
import { createObjectFactory } from '@/modules/core/services/objects/management'
import { storeSingleObjectIfNotFoundFactory } from '@/modules/core/repositories/objects'
import { getEventBus } from '@/modules/shared/services/eventBus'
import type http from 'node:http'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { BasicTestBranch, createTestBranch } from '@/test/speckle-helpers/branchHelper'
const getUser = getUserFactory({ db })
const getUserActivity = getUserActivityFactory({ db })
@@ -107,36 +100,40 @@ const createObject = createObjectFactory({
storeSingleObjectIfNotFoundFactory: storeSingleObjectIfNotFoundFactory({ db })
})
let server
let sendRequest
let server: http.Server
let sendRequest: Awaited<ReturnType<typeof initializeTestServer>>['sendRequest']
describe('Activity @activity', () => {
const userIz = {
name: 'Izzy Lyseggen',
email: 'izzybizzi@speckle.systems',
password: 'sp0ckle sucks 9001',
id: undefined
id: '',
token: ''
}
const userCr = {
name: 'Cristi Balas',
email: 'cristib@speckle.systems',
password: 'hack3r man 666',
id: undefined
id: '',
token: ''
}
const userX = {
name: 'Mystery User',
email: 'mysteriousDude@speckle.systems',
password: 'super $ecret pw0rd',
id: undefined
id: '',
token: ''
}
const streamPublic = {
const streamPublic: BasicTestStream = {
name: 'a fun stream for sharing',
description: 'for all to see!',
isPublic: true,
id: undefined
id: '',
ownerId: ''
}
// const collaboratorTestStream = {
@@ -146,24 +143,32 @@ describe('Activity @activity', () => {
// id: undefined
// }
const branchPublic = { name: '🍁maple branch' }
const branchPublic: BasicTestBranch = {
name: '🍁maple branch',
id: '',
streamId: '',
authorId: ''
}
const streamSecret = {
const streamSecret: BasicTestStream = {
name: 'a secret stream for me',
description: 'for no one to see!',
isPublic: false,
id: undefined
id: '',
ownerId: ''
}
const testObj = {
hello: 'hallo',
cool: 'kult',
bunny: 'kanin'
bunny: 'kanin',
id: ''
}
const testObj2 = {
goodbye: 'ha det bra',
warm: 'varmt',
bunny: 'kanin'
bunny: 'kanin',
id: ''
}
before(async () => {
@@ -211,15 +216,10 @@ describe('Activity @activity', () => {
// It's definitely not great that there's a full on test case in the before() hook, but that's because
// these tests were originally written incorrectly - they depend on each other. So this is a temporary fix that
// ensures tests can be ran in any order
// + Avoiding GQL to bypass personal project limits, if any
// create stream (cr1)
const resStream1 = await sendRequest(userCr.token, {
query:
'mutation createStream($myStream:StreamCreateInput!) { streamCreate(stream: $myStream) }',
variables: { myStream: streamSecret }
})
expect(noErrors(resStream1))
streamSecret.id = resStream1.body.data.streamCreate
await createTestStream(streamSecret, userCr)
// create commit (cr2)
testObj2.id = await createObject({ streamId: streamSecret.id, object: testObj2 })
@@ -229,20 +229,14 @@ describe('Activity @activity', () => {
expect(noErrors(resCommit1))
// create stream #2 (iz1)
const resStream2 = await sendRequest(userIz.token, {
query:
'mutation createStream($myStream:StreamCreateInput!) { streamCreate(stream: $myStream) }',
variables: { myStream: streamPublic }
})
expect(noErrors(resStream2))
streamPublic.id = resStream2.body.data.streamCreate
await createTestStream(streamPublic, userIz)
// create branch (iz2)
const resBranch = await sendRequest(userIz.token, {
query: `mutation { branchCreate(branch: { streamId: "${streamPublic.id}", name: "${branchPublic.name}" }) }`
await createTestBranch({
branch: branchPublic,
stream: streamPublic,
owner: userIz
})
expect(noErrors(resBranch))
branchPublic.id = resBranch.body.data.branchCreate
// create commit #2 (iz3)
testObj.id = await createObject({ streamId: streamPublic.id, object: testObj })
@@ -8,6 +8,7 @@ import {
import { AllScopes } from '@/modules/core/helpers/mainConstants'
import { updateServerInfoFactory } from '@/modules/core/repositories/server'
import { findInviteFactory } from '@/modules/serverinvites/repositories/serverInvites'
import { LogicError } from '@/modules/shared/errors'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { expectToThrow, itEach } from '@/test/assertionHelper'
import { BasicTestUser, createTestUsers } from '@/test/authHelper'
@@ -34,7 +35,8 @@ import {
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
const { FF_NO_PERSONAL_EMAILS_ENABLED } = getFeatureFlags()
const { FF_NO_PERSONAL_EMAILS_ENABLED, FF_PERSONAL_PROJECTS_LIMITS_ENABLED } =
getFeatureFlags()
const updateServerInfo = updateServerInfoFactory({ db })
@@ -47,6 +49,10 @@ describe('Server registration', () => {
) => {
return await captureCreatedInvite(async () => {
if ('projectId' in args) {
if (FF_PERSONAL_PROJECTS_LIMITS_ENABLED) {
throw new LogicError('Should not be invoked when personal limits are enabled')
}
await apollo.execute(CreateProjectInviteDocument, args, {
assertNoErrors: true
})
@@ -154,49 +160,51 @@ describe('Server registration', () => {
)
expect(e.message).to.contain('Code challenge mismatch')
})
;(FF_PERSONAL_PROJECTS_LIMITS_ENABLED ? it.skip : it)(
'works with stream invite and allows joining stream afterwards',
async () => {
const params = generateRegistrationParams()
it('works with stream invite and allows joining stream afterwards', async () => {
const params = generateRegistrationParams()
const invite = await createInviteAsAdmin({
input: {
email: params.user.email,
serverRole: Roles.Server.Admin
},
projectId: basicAdminStream.id
})
expect(invite.token).to.be.ok
const invite = await createInviteAsAdmin({
input: {
email: params.user.email,
serverRole: Roles.Server.Admin
},
projectId: basicAdminStream.id
})
expect(invite.token).to.be.ok
params.inviteToken = invite.token
params.inviteToken = invite.token
const newUser = await restApi.register(params)
expect(newUser.role).to.equal(Roles.Server.Admin)
const newUser = await restApi.register(params)
expect(newUser.role).to.equal(Roles.Server.Admin)
const res = await apollo.execute(
UseStreamInviteDocument,
{
accept: true,
token: invite.token,
streamId: basicAdminStream.id
},
{
context: await createTestContext({
userId: newUser.id,
auth: true,
role: Roles.Server.User,
token: 'asd',
scopes: AllScopes
})
}
)
const res = await apollo.execute(
UseStreamInviteDocument,
{
accept: true,
token: invite.token,
streamId: basicAdminStream.id
},
{
context: await createTestContext({
userId: newUser.id,
auth: true,
role: Roles.Server.User,
token: 'asd',
scopes: AllScopes
})
}
)
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.streamInviteUse).to.be.ok
expect(await findInviteFactory({ db })({ inviteId: invite.id })).to.be.not.ok
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.streamInviteUse).to.be.ok
expect(await findInviteFactory({ db })({ inviteId: invite.id })).to.be.not.ok
const userStreamRole = await getUserStreamRole(newUser.id, basicAdminStream.id)
expect(userStreamRole).to.be.ok
})
const userStreamRole = await getUserStreamRole(newUser.id, basicAdminStream.id)
expect(userStreamRole).to.be.ok
}
)
const inviteOnlyModeSettings = [{ inviteOnly: true }, { inviteOnly: false }]
@@ -220,7 +228,10 @@ describe('Server registration', () => {
}
itEach(
[{ stream: true }, { stream: false }],
[
...(FF_PERSONAL_PROJECTS_LIMITS_ENABLED ? [] : [{ stream: true }]),
{ stream: false }
],
({ stream }) =>
`works with valid ${
stream ? 'stream' : 'server'
@@ -1135,7 +1135,8 @@ const createAppToken = createAppTokenFactory({
id: '',
objectId: '',
streamId: testUserStream.id,
authorId: testUser.id
authorId: testUser.id,
branchId: ''
})
})
@@ -1223,7 +1224,8 @@ const createAppToken = createAppTokenFactory({
authorId: testUser.id,
streamId: testUserStream.id,
branchName: testUserStreamModel.name,
objectId: ''
objectId: '',
branchId: ''
}
await createTestCommit(testVersion)
@@ -63,6 +63,7 @@ const command: CommandModule<
(i): BasicTestCommit => ({
id: '',
objectId: '',
branchId: '',
streamId,
authorId,
message: `#${i} - ${date} - Fake commit batch`
@@ -69,6 +69,8 @@ export type InsertCommentPayload = MarkNullableOptional<
text: SmartTextEditorValueSchema
archived?: boolean
id?: string
createdAt?: Date
updatedAt?: Date
}
>
@@ -239,7 +241,13 @@ export type ConvertLegacyDataToState = (
export type CreateCommentThreadAndNotify = (
input: CreateCommentInput,
userId: string
userId: string,
options?: Partial<{
/**
* Used in tests: Override createdAt date
*/
createdAt: Date
}>
) => Promise<CommentRecord>
export type CreateCommentReplyAndNotify = (
@@ -81,7 +81,6 @@ import {
getCommitsAndTheirBranchIdsFactory,
getSpecificBranchCommitsFactory
} from '@/modules/core/repositories/commits'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import {
getBranchLatestCommitsFactory,
getStreamBranchesByNameFactory
@@ -92,15 +91,9 @@ import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { Knex } from 'knex'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import { isCreatedBeyondHistoryLimitCutoff, getProjectLimitDate } from '@speckle/shared'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import { Authz } from '@speckle/shared'
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
? () => Promise.resolve(Authz.PersonalProjectsLimits)
: () => Promise.resolve(null)
import { isCreatedBeyondHistoryLimitCutoffFactory } from '@/modules/gatekeeperCore/utils/limits'
// We can use the main DB for these
const getStream = getStreamFactory({ db })
@@ -222,18 +215,16 @@ export = {
})
}
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})
})({ entity: parent, limitType: 'commentHistory', project })
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoffFactory({ ctx })({
entity: parent,
limitType: 'commentHistory',
project
})
// null is for out of limits
if (isBeyondLimit) return null
// why is the text nullable in the DB record?
if (!parent.text) return ''
return {
...ensureCommentSchema(parent.text),
...ensureCommentSchema(parent.text || ''),
projectId: parent.streamId
}
},
@@ -247,15 +238,14 @@ export = {
})
}
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})
})({ entity: parent, limitType: 'commentHistory', project })
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoffFactory({ ctx })({
entity: parent,
limitType: 'commentHistory',
project
})
// null is for out of limits
if (isBeyondLimit) return null
// why us the text nullable in the DB?
const { doc } = ensureCommentSchema(parent.text || '')
return documentToBasicString(doc)
},
@@ -1,6 +1,5 @@
import { ensureError, SpeckleViewer } from '@speckle/shared'
import {
CreateCommentInput,
CreateCommentReplyInput,
EditCommentInput
} from '@/modules/core/graph/generated/graphql'
@@ -44,7 +43,7 @@ export const createCommentThreadAndNotifyFactory =
markCommentViewed: MarkCommentViewed
emitEvent: EventBusEmit
}): CreateCommentThreadAndNotify =>
async (input: CreateCommentInput, userId: string) => {
async (input, userId, options) => {
const [resources] = await Promise.all([
deps.getViewerResourceItemsUngrouped({ ...input, loadedVersionsOnly: true }),
deps.validateInputAttachments(input.projectId, input.content.blobIds || [])
@@ -68,7 +67,10 @@ export const createCommentThreadAndNotifyFactory =
blobIds: input.content.blobIds || undefined
}),
screenshot: input.screenshot,
data: dataStruct
data: dataStruct,
...(options?.createdAt
? { createdAt: options.createdAt, updatedAt: options.createdAt }
: {})
}
let comment: CommentRecord
@@ -51,6 +51,7 @@ describe('Project Comments', () => {
objectId: '',
streamId: '',
authorId: '',
branchId: '',
message: 'this is my nice commit :)))',
branchName: myBranch.name
}
@@ -70,6 +70,10 @@ export type CreateCommitByBranchId = (
sourceApplication: Nullable<string>
totalChildrenCount?: MaybeNullOrUndefined<number>
parents: Nullable<string[]>
/**
* Only used in tests: Allows to set the createdAt date
*/
createdAt?: Nullable<Date>
}>
) => Promise<CommitWithStreamBranchId>
@@ -83,8 +87,12 @@ export type CreateCommitByBranchName = (
sourceApplication: Nullable<string>
totalChildrenCount?: MaybeNullOrUndefined<number>
parents: Nullable<string[]>
/**
* Only used in tests: Allows to set the createdAt date
*/
createdAt?: Nullable<Date>
}>
) => Promise<Commit>
) => Promise<CommitWithStreamBranchId>
export type InsertBranchCommits = (
branchCommits: BranchCommitRecord[],
@@ -78,20 +78,9 @@ import { getEventBus } from '@/modules/shared/services/eventBus'
import { isRateLimiterEnabled } from '@/modules/shared/helpers/envHelper'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import {
getProjectLimitDate,
isNonNullable,
MaybeNullOrUndefined,
Roles
} from '@speckle/shared'
import { PersonalProjectsLimits } from '@speckle/shared/authz'
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
? () => Promise.resolve(PersonalProjectsLimits)
: () => Promise.resolve(null)
import { isNonNullable, MaybeNullOrUndefined, Roles } from '@speckle/shared'
import { getProjectLimitDateFactory } from '@/modules/gatekeeperCore/utils/limits'
const getStreams = getStreamsFactory({ db })
@@ -221,10 +210,10 @@ export = {
async commits(parent, args, ctx) {
const projectDB = await getProjectDbClient({ projectId: parent.id })
const limitsDate = await getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})({ limitType: 'versionsHistory', project: parent })
const limitsDate = await getProjectLimitDateFactory({ ctx })({
limitType: 'versionsHistory',
project: parent
})
const getCommitsByStreamId = legacyGetPaginatedStreamCommitsPageFactory({
db: projectDB,
@@ -347,10 +336,10 @@ export = {
})
}
const limitsDate = await getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})({ limitType: 'versionsHistory', project })
const limitsDate = await getProjectLimitDateFactory({ ctx })({
limitType: 'versionsHistory',
project
})
const getPaginatedBranchCommits = getPaginatedBranchCommitsFactory({
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDB }),
@@ -40,7 +40,8 @@ import { throwForNotHavingServerRole } from '@/modules/shared/authz'
import {
toProjectIdWhitelist,
isResourceAllowed,
throwIfResourceAccessNotAllowed
throwIfResourceAccessNotAllowed,
throwIfNewResourceNotAllowed
} from '@/modules/core/helpers/token'
import {
Resolvers,
@@ -101,6 +102,7 @@ import { deleteOldAndInsertNewVerificationFactory } from '@/modules/emails/repos
import { renderEmail } from '@/modules/emails/services/emailRendering'
import { sendEmail } from '@/modules/emails/services/sending'
import { ProjectRecordVisibility } from '@/modules/core/helpers/types'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
const getServerInfo = getServerInfoFactory({ db })
const getUsers = getUsersFactory({ db })
@@ -497,6 +499,15 @@ export = {
source: context.userId!
})
throwIfNewResourceNotAllowed({
resourceAccessRules: context.resourceAccessRules,
resourceType: TokenResourceIdentifierType.Project
})
const canCreate = await context.authPolicies.project.canCreatePersonal({
userId: context.userId!
})
throwIfAuthNotOk(canCreate)
const { id } = await withOperationLogging(
async () =>
await createStreamReturnRecord({
@@ -516,12 +527,18 @@ export = {
async streamUpdate(_, args, context) {
const projectId = args.stream.id
await authorizeResolver(
context.userId,
args.stream.id,
Roles.Stream.Owner,
context.resourceAccessRules
)
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: context.resourceAccessRules
})
const canUpdate = await context.authPolicies.project.canUpdate({
userId: context.userId!,
projectId
})
throwIfAuthNotOk(canUpdate)
const logger = context.log.child({
projectId,
streamId: projectId //legacy
@@ -540,12 +557,18 @@ export = {
async streamDelete(_, args, context) {
const projectId = args.id
await authorizeResolver(
context.userId,
args.id,
Roles.Stream.Owner,
context.resourceAccessRules
)
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: context.resourceAccessRules
})
const canDelete = await context.authPolicies.project.canDelete({
userId: context.userId!,
projectId
})
throwIfAuthNotOk(canDelete)
const logger = context.log.child({
projectId,
streamId: projectId //legacy
@@ -568,6 +591,17 @@ export = {
async () =>
await Promise.all(
(args.ids || []).map(async (id) => {
throwIfResourceAccessNotAllowed({
resourceId: id,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: context.resourceAccessRules
})
const canDelete = await context.authPolicies.project.canDelete({
userId: context.userId!,
projectId: id
})
throwIfAuthNotOk(canDelete)
return await deleteStreamAndNotify(id, context.userId!)
})
),
@@ -582,6 +616,18 @@ export = {
async streamUpdatePermission(_, args, context) {
const projectId = args.permissionParams.streamId
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: context.resourceAccessRules
})
const canUpdate = await context.authPolicies.project.canUpdate({
userId: context.userId!,
projectId
})
throwIfAuthNotOk(canUpdate)
const logger = context.log.child({
projectId,
streamId: projectId //legacy
@@ -604,6 +650,18 @@ export = {
async streamRevokePermission(_, args, context) {
const projectId = args.permissionParams.streamId
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: context.resourceAccessRules
})
const canUpdate = await context.authPolicies.project.canUpdate({
userId: context.userId!,
projectId
})
throwIfAuthNotOk(canUpdate)
const logger = context.log.child({
projectId,
streamId: projectId //legacy
@@ -654,6 +712,17 @@ export = {
const { streamId } = args
const { userId } = ctx
throwIfResourceAccessNotAllowed({
resourceId: streamId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canLeave = await ctx.authPolicies.project.canLeave({
userId: ctx.userId!,
projectId: streamId
})
throwIfAuthNotOk(canLeave)
const logger = ctx.log.child({
projectId: streamId,
streamId //legacy
@@ -719,6 +788,17 @@ export = {
subscribe: filteredSubscribe(
StreamSubscriptions.StreamUpdated,
async (payload, variables, context) => {
throwIfResourceAccessNotAllowed({
resourceId: payload.id,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: context.resourceAccessRules
})
const canRead = await context.authPolicies.project.canRead({
userId: context.userId!,
projectId: payload.id
})
throwIfAuthNotOk(canRead)
await authorizeResolver(
context.userId,
payload.id,
@@ -734,12 +814,17 @@ export = {
subscribe: filteredSubscribe(
StreamSubscriptions.StreamDeleted,
async (payload, variables, context) => {
await authorizeResolver(
context.userId,
payload.streamId,
Roles.Stream.Reviewer,
context.resourceAccessRules
)
throwIfResourceAccessNotAllowed({
resourceId: payload.streamId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: context.resourceAccessRules
})
const canRead = await context.authPolicies.project.canRead({
userId: context.userId!,
projectId: payload.streamId
})
throwIfAuthNotOk(canRead)
return payload.streamId === variables.streamId
}
)
@@ -4,7 +4,6 @@ import {
ProjectSubscriptions
} from '@/modules/shared/utils/subscriptions'
import {
getFeatureFlags,
getServerOrigin,
isRateLimiterEnabled
} from '@/modules/shared/helpers/envHelper'
@@ -54,16 +53,7 @@ import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { Version } from '@/modules/core/domain/commits/types'
import { GraphQLResolveInfo } from 'graphql'
import { withOperationLogging } from '@/observability/domain/businessLogging'
import {
Authz,
getProjectLimitDate,
isCreatedBeyondHistoryLimitCutoff
} from '@speckle/shared'
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
? () => Promise.resolve(Authz.PersonalProjectsLimits)
: () => Promise.resolve(null)
import { isCreatedBeyondHistoryLimitCutoffFactory } from '@/modules/gatekeeperCore/utils/limits'
/**
* Simple utility to check if version is inside a Model or a Project
@@ -138,12 +128,12 @@ export = {
})
}
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})
})({ entity: parent, limitType: 'versionsHistory', project })
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoffFactory({ ctx })({
entity: parent,
limitType: 'versionsHistory',
project
})
let lastVersion: Version | null
if (getTypeFromPath(info) === 'Model') {
lastVersion = await ctx.loaders
@@ -154,6 +144,7 @@ export = {
.forRegion({ db: projectDB })
.streams.getLastVersion.load(parent.streamId)
}
if (lastVersion?.id === parent.id) return parent.referencedObject
if (isBeyondLimit) return null
return parent.referencedObject
@@ -101,7 +101,8 @@ export const createCommitByBranchIdFactory =
authorId,
message,
sourceApplication,
parents
parents,
createdAt
} = params
// If no total children count is passed in, get it from the original object
@@ -129,7 +130,8 @@ export const createCommitByBranchIdFactory =
sourceApplication,
totalChildrenCount,
parents,
message
message,
...(createdAt ? { createdAt } : {})
})
const id = commit.id
@@ -176,7 +178,8 @@ export const createCommitByBranchNameFactory =
message,
sourceApplication,
parents,
totalChildrenCount
totalChildrenCount,
createdAt
} = params
const branchName = params.branchName.toLowerCase()
let myBranch = await deps.getStreamBranchByName(streamId, branchName)
@@ -200,7 +203,8 @@ export const createCommitByBranchNameFactory =
message,
sourceApplication,
totalChildrenCount,
parents
parents,
createdAt
})
return commit
@@ -119,6 +119,7 @@ describe('Batch commits', () => {
return {
id: '',
objectId: '',
branchId: '',
streamId,
authorId: me.id
}
@@ -128,6 +129,7 @@ describe('Batch commits', () => {
(): BasicTestCommit => ({
id: '',
objectId: '',
branchId: '',
streamId: otherStream.id,
authorId: otherGuy.id
})
@@ -208,6 +210,7 @@ describe('Batch commits', () => {
streamId = i % 2 === 0 ? myStream.id : otherStream.id
return {
id: '',
branchId: '',
streamId,
objectId: '',
authorId: me.id
@@ -245,6 +248,7 @@ describe('Batch commits', () => {
streamId = i % 2 === 0 ? myStream.id : otherStream.id
return {
id: '',
branchId: '',
objectId: '',
streamId,
authorId: me.id
@@ -97,7 +97,8 @@ describe('Commits (GraphQL)', () => {
id: '',
objectId: '',
streamId: usePrivateStream ? myStream.id : myPrivateStream.id,
authorId: me.id
authorId: me.id,
branchId: ''
})
// other guys commit
@@ -105,7 +106,8 @@ describe('Commits (GraphQL)', () => {
id: '',
objectId: '',
streamId: usePrivateStream ? myStream.id : myPrivateStream.id,
authorId: otherGuy.id
authorId: otherGuy.id,
branchId: ''
})
}
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
import { gql } from 'graphql-tag'
/**
* Commits/Versions
*/
export const limitedPersonalProjectCommentFragment = gql`
fragment LimitedPersonalProjectComment on Comment {
id
rawText
createdAt
text {
doc
type
}
}
`
export const limitedPersonalProjectVersionFragment = gql`
fragment LimitedPersonalProjectVersion on Version {
id
createdAt
message
referencedObject
commentThreads {
totalCount
items {
...LimitedPersonalProjectComment
}
}
}
${limitedPersonalProjectCommentFragment}
`
export const getLimitedPersonalProjectVersionsQuery = gql`
query GetLimitedPersonalProjectVersions($projectId: String!) {
project(id: $projectId) {
versions {
totalCount
items {
...LimitedPersonalProjectVersion
}
}
}
}
${limitedPersonalProjectVersionFragment}
`
export const getLimitedPersonalProjectVersionQuery = gql`
query GetLimitedPersonalProjectVersion($projectId: String!, $versionId: String!) {
project(id: $projectId) {
version(id: $versionId) {
...LimitedPersonalProjectVersion
}
}
}
${limitedPersonalProjectVersionFragment}
`
export const limitedPersonalStreamCommitFragment = gql`
fragment LimitedPersonalStreamCommit on Commit {
id
message
referencedObject
createdAt
}
`
export const getLimitedPersonalStreamCommitsQuery = gql`
query GetLimitedPersonalStreamCommits($streamId: String!) {
stream(id: $streamId) {
commits {
totalCount
items {
...LimitedPersonalStreamCommit
}
}
}
}
${limitedPersonalStreamCommitFragment}
`
/**
* Comments
*/
export const getLimitedPersonalProjectCommentsQuery = gql`
query GetLimitedPersonalProjectComments($projectId: String!) {
project(id: $projectId) {
commentThreads {
totalCount
items {
...LimitedPersonalProjectComment
}
}
}
}
${limitedPersonalProjectCommentFragment}
`
export const getLimitedPersonalProjectCommentQuery = gql`
query GetLimitedPersonalProjectComment($projectId: String!, $commentId: String!) {
project(id: $projectId) {
comment(id: $commentId) {
...LimitedPersonalProjectComment
}
}
}
${limitedPersonalProjectCommentFragment}
`
@@ -0,0 +1,295 @@
import { CommentRecord } from '@/modules/comments/helpers/types'
import { ProjectRecordVisibility } from '@/modules/core/helpers/types'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { expectToThrow, itEach } from '@/test/assertionHelper'
import { BasicTestUser, createTestUsers } from '@/test/authHelper'
import {
CreateProjectDocument,
CreateProjectInviteDocument,
CreateProjectModelDocument,
GetLimitedPersonalProjectCommentDocument,
GetLimitedPersonalProjectCommentsDocument,
GetLimitedPersonalProjectVersionDocument,
GetLimitedPersonalProjectVersionsDocument,
GetLimitedPersonalStreamCommitsDocument,
LimitedPersonalProjectCommentFragment,
LimitedPersonalProjectVersionFragment,
LimitedPersonalStreamCommitFragment
} from '@/test/graphql/generated/graphql'
import { testApolloServer, TestApolloServer } from '@/test/graphqlHelper'
import { beforeEachContext } from '@/test/hooks'
import { createTestComment } from '@/test/speckle-helpers/commentHelper'
import { BasicTestCommit, createTestCommits } from '@/test/speckle-helpers/commitHelper'
import { BasicTestStream, createTestStreams } from '@/test/speckle-helpers/streamHelper'
import { expect } from 'chai'
import dayjs from 'dayjs'
import { flatten } from 'lodash'
const { FF_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
;(FF_PERSONAL_PROJECTS_LIMITS_ENABLED ? describe : describe.skip)(
'Personal project limits @graphql',
() => {
const me: BasicTestUser = {
id: '',
email: '',
name: 'meeeeeee'
}
const preexistingProject: BasicTestStream = {
id: '',
ownerId: '',
name: 'preexisting project',
description: 'preexisting project',
visibility: ProjectRecordVisibility.Private
}
let apollo: TestApolloServer
before(async () => {
await beforeEachContext()
await createTestUsers([me])
await createTestStreams([[preexistingProject, me]])
apollo = await testApolloServer({ authUserId: me.id })
})
describe('history limits', () => {
const oldVersion: BasicTestCommit = {
id: '',
objectId: '',
streamId: '',
authorId: '',
branchId: '',
createdAt: dayjs().subtract(8, 'day').toDate() // limit and -1 day
}
const newVersion: BasicTestCommit = {
id: '',
objectId: '',
streamId: '',
authorId: '',
branchId: ''
}
let comments: CommentRecord[]
before(async () => {
// old & new commit
await createTestCommits([oldVersion, newVersion], {
owner: me,
stream: preexistingProject
})
// old & new comment for each commit
const commentCreateResults = await Promise.all(
[newVersion, oldVersion].map(async (commit) => {
const comments: CommentRecord[] = []
// Old
const oldComment = await createTestComment({
userId: me.id,
projectId: preexistingProject.id,
modelId: commit.branchId,
versionId: commit.id,
createdAt: dayjs().subtract(8, 'day').toDate() // limit and -1 day
})
comments.push(oldComment)
// New
const newComment = await createTestComment({
userId: me.id,
projectId: preexistingProject.id,
modelId: commit.branchId,
versionId: commit.id
})
comments.push(newComment)
return comments
})
)
comments = flatten(commentCreateResults)
})
const checkComment = (comment: LimitedPersonalProjectCommentFragment) => {
const isOldComment = dayjs(comment.createdAt).isBefore(
dayjs().subtract(7, 'day')
)
if (isOldComment) {
expect(comment.text).to.not.be.ok
expect(comment.rawText).to.not.be.ok
} else {
expect(comment.text).to.be.ok
expect(comment.rawText).to.be.ok
}
}
const checkVersion = (
version: BasicTestCommit,
resultVersions: LimitedPersonalProjectVersionFragment[]
) => {
const isOld = dayjs(version.createdAt).isBefore(dayjs().subtract(7, 'day'))
const retVersion = resultVersions.find((v) => v.id === version.id)
expect(retVersion).to.be.ok
if (isOld) {
expect(retVersion?.referencedObject).to.not.be.ok
} else {
expect(retVersion?.referencedObject).to.be.ok
}
const comments = retVersion?.commentThreads
expect(comments).to.be.ok
expect(comments?.totalCount).to.equal(2)
comments?.items.forEach(checkComment)
}
const checkCommit = (
commit: BasicTestCommit,
resultCommits: LimitedPersonalStreamCommitFragment[]
) => {
// const isOld = dayjs(commit.createdAt).isBefore(dayjs().subtract(7, 'day'))
const retCommit = resultCommits.find((v) => v.id === commit.id)
expect(retCommit).to.be.ok
// if (isOld) {
// expect(retCommit?.referencedObject).to.not.be.ok
// } else {
// expect(retCommit?.referencedObject).to.be.ok
// }
}
it('followed when querying for project versions', async () => {
const res = await apollo.execute(
GetLimitedPersonalProjectVersionsDocument,
{
projectId: preexistingProject.id
},
{ assertNoErrors: true }
)
const versions = res.data?.project.versions
expect(versions?.items).to.be.ok
expect(versions?.totalCount).to.equal(2)
checkVersion(oldVersion, versions!.items)
checkVersion(newVersion, versions!.items)
})
itEach(
[{ old: true }, { old: false }],
({ old }) =>
`followed when querying for ${old ? 'old' : 'new'} project version`,
async ({ old }) => {
const baseVersion = old ? oldVersion : newVersion
const res = await apollo.execute(
GetLimitedPersonalProjectVersionDocument,
{
projectId: preexistingProject.id,
versionId: baseVersion.id
},
{ assertNoErrors: true }
)
const version = res.data?.project.version
expect(version).to.be.ok
checkVersion(baseVersion, [version!])
}
)
it('followed when querying for stream commits', async () => {
const res = await apollo.execute(
GetLimitedPersonalStreamCommitsDocument,
{
streamId: preexistingProject.id
},
{ assertNoErrors: true }
)
const commits = res.data?.stream?.commits
expect(commits?.items).to.be.ok
checkCommit(newVersion, commits!.items!)
await expectToThrow(() => checkCommit(oldVersion, commits!.items!)) // old commit should be filtered out
})
it('followed when querying for project comments', async () => {
const res = await apollo.execute(
GetLimitedPersonalProjectCommentsDocument,
{
projectId: preexistingProject.id
},
{ assertNoErrors: true }
)
const comments = res.data?.project.commentThreads
expect(comments?.items).to.be.ok
expect(comments?.totalCount).to.equal(4) // 2 from each version
comments?.items.forEach(checkComment)
})
it('followed when quering for project comment', async () => {
const test = async (comment: CommentRecord) => {
const res = await apollo.execute(
GetLimitedPersonalProjectCommentDocument,
{
projectId: preexistingProject.id,
commentId: comment.id
},
{ assertNoErrors: true }
)
const resultComment = res.data?.project.comment
expect(resultComment).to.be.ok
checkComment(resultComment!)
}
await Promise.all(comments.map(test))
})
})
it('prevent new personal project creation', async () => {
const res = await apollo.execute(CreateProjectDocument, {
input: {
name: 'test personal project'
}
})
expect(res).to.haveGraphQLErrors(
"Projects can't be created outside of workspaces"
)
expect(res.data?.projectMutations.create.id).to.not.be.ok
})
it('prevent new invites to personal projects', async () => {
const res = await apollo.execute(CreateProjectInviteDocument, {
projectId: preexistingProject.id,
input: {
email: 'personalprojectlimitsinvite@example.com'
}
})
expect(res).to.haveGraphQLErrors(
'No new collaborators can be added to personal projects'
)
expect(res.data?.projectMutations.invites.create.id).to.not.be.ok
})
it('prevent new models in personal projects', async () => {
const res = await apollo.execute(CreateProjectModelDocument, {
input: {
projectId: preexistingProject.id,
name: 'test personal project model'
}
})
expect(res).to.haveGraphQLErrors(
'No new models can be added to personal projects'
)
expect(res.data?.modelMutations.create.id).to.not.be.ok
})
}
)
@@ -39,7 +39,8 @@ describe('Object repository functions', () => {
id: '',
objectId: '',
streamId: '',
authorId: ''
authorId: '',
branchId: ''
}
before(async () => {
@@ -54,6 +55,7 @@ describe('Object repository functions', () => {
owner: adminUser
})
testVersion.branchId = ''
testVersion.branchName = testModel.name
testVersion.objectId = await createTestObject({ projectId: testProject.id })
@@ -7,37 +7,43 @@ import { Roles } from '@/modules/core/helpers/mainConstants'
import { expect } from 'chai'
import { beforeEachContext } from '@/test/hooks'
import { TestApolloServer, testApolloServer } from '@/test/graphqlHelper'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
describe('Projects GraphQL @core (outside of workspaces)', () => {
let apollo: TestApolloServer
const me: BasicTestUser = {
id: '',
name: 'me',
email: '',
role: Roles.Server.Admin
}
const { FF_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
before(async () => {
await beforeEachContext()
await createTestUser(me)
apollo = await testApolloServer({ authUserId: me.id })
})
;(FF_PERSONAL_PROJECTS_LIMITS_ENABLED ? describe.skip : describe)(
'Projects GraphQL @core (outside of workspaces)',
() => {
let apollo: TestApolloServer
const me: BasicTestUser = {
id: '',
name: 'me',
email: '',
role: Roles.Server.Admin
}
describe('when being created', () => {
it('should use private visibility by default ', async () => {
const res = await apollo.execute(
CreateProjectDocument,
{
input: {
name: 'Test Default Visibility Project'
}
},
{ assertNoErrors: true }
)
const project = res.data?.projectMutations.create
expect(project).to.be.ok
expect(project?.visibility).to.eq(ProjectVisibility.Private)
before(async () => {
await beforeEachContext()
await createTestUser(me)
apollo = await testApolloServer({ authUserId: me.id })
})
})
})
describe('when being created', () => {
it('should use private visibility by default ', async () => {
const res = await apollo.execute(
CreateProjectDocument,
{
input: {
name: 'Test Default Visibility Project'
}
},
{ assertNoErrors: true }
)
const project = res.data?.projectMutations.create
expect(project).to.be.ok
expect(project?.visibility).to.eq(ProjectVisibility.Private)
})
})
}
)
@@ -688,6 +688,7 @@ describe('Core GraphQL Subscriptions (New)', () => {
objectId: '',
id: '',
authorId: '',
branchId: '',
message
}
@@ -708,6 +709,7 @@ describe('Core GraphQL Subscriptions (New)', () => {
objectId: '',
id: '',
authorId: '',
branchId: '',
message: 'Commit to Delete'
}
await createTestCommits([commitToDelete], {
@@ -761,6 +763,7 @@ describe('Core GraphQL Subscriptions (New)', () => {
objectId: '',
id: '',
authorId: '',
branchId: '',
message: 'Commit to Update'
}
await createTestCommits([commitToUpdate], {
@@ -846,6 +849,7 @@ describe('Core GraphQL Subscriptions (New)', () => {
objectId: '',
id: '',
authorId: '',
branchId: '',
message: 'Random Commit'
}
await createTestCommits([commit], {
@@ -83,7 +83,7 @@ const createUser = createUserFactory({
emitEvent: getEventBus().emit
})
const { FF_BILLING_INTEGRATION_ENABLED, FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } =
const { FF_BILLING_INTEGRATION_ENABLED, FF_PERSONAL_PROJECTS_LIMITS_ENABLED } =
getFeatureFlags()
describe('Versions graphql @core', () => {
@@ -136,7 +136,7 @@ describe('Versions graphql @core', () => {
}
)
})
;(FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED ? describe : describe.skip)(
;(FF_PERSONAL_PROJECTS_LIMITS_ENABLED ? describe : describe.skip)(
'Version.referencedObject',
() => {
const tenDaysAgo = dayjs().subtract(10, 'day').toDate()
@@ -1,3 +1,7 @@
import {
BasicTestWorkspace,
createTestWorkspace
} from '@/modules/workspaces/tests/helpers/creation'
import { BasicTestUser, createTestUsers } from '@/test/authHelper'
import {
CreateModelInput,
@@ -18,6 +22,13 @@ describe('Models', () => {
id: ''
}
const workspace: BasicTestWorkspace = {
id: '',
slug: '',
ownerId: '',
name: 'private workspace'
}
const myPrivateStream: BasicTestStream = {
name: 'this is my private stream #1',
isPublic: false,
@@ -28,6 +39,11 @@ describe('Models', () => {
before(async () => {
await beforeEachContext()
await createTestUsers([me])
// workspace, to avoid personal project limits
await createTestWorkspace(workspace, me)
myPrivateStream.workspaceId = workspace.id
await createTestStreams([[myPrivateStream, me]])
})
@@ -14,6 +14,9 @@ import {
import { createTestObject } from '@/test/speckle-helpers/commitHelper'
import { times } from 'lodash'
import { Roles } from '@speckle/shared'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
const { FF_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
describe('Projects', () => {
const me: BasicTestUser = {
@@ -41,24 +44,28 @@ describe('Projects', () => {
authUserId: me.id
})
})
;(FF_PERSONAL_PROJECTS_LIMITS_ENABLED ? it.skip : it)(
'can be created',
async () => {
const input: ProjectCreateInput = {
name: 'my first project',
description: 'ayyooo'
}
const res = await apollo.execute(CreateProjectDocument, {
input
})
it('can be created', async () => {
const input: ProjectCreateInput = {
name: 'my first project',
description: 'ayyooo'
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.projectMutations.create.id).to.be.ok
expect(res.data?.projectMutations.create.name).to.equal(input.name)
expect(res.data?.projectMutations.create.description).to.equal(
input.description
)
expect(res.data?.projectMutations.create.visibility).to.equal(
ProjectVisibility.Private // private by default
)
}
const res = await apollo.execute(CreateProjectDocument, {
input
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.projectMutations.create.id).to.be.ok
expect(res.data?.projectMutations.create.name).to.equal(input.name)
expect(res.data?.projectMutations.create.description).to.equal(input.description)
expect(res.data?.projectMutations.create.visibility).to.equal(
ProjectVisibility.Private // private by default
)
})
)
describe('after creation', () => {
const myStream: BasicTestStream = {
@@ -6,7 +6,12 @@ import {
} from '@/modules/gatekeeper/repositories/workspaceSeat'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { defineRequestDataloaders } from '@/modules/shared/helpers/graphqlHelper'
import { WorkspacePlan } from '@speckle/shared'
import {
WorkspaceLimits,
WorkspacePaidPlanConfigs,
WorkspacePlan,
WorkspaceUnpaidPlanConfigs
} from '@speckle/shared'
const { FF_GATEKEEPER_MODULE_ENABLED } = getFeatureFlags()
@@ -74,6 +79,24 @@ const dataLoadersDefinition = defineRequestDataloaders(
{
cacheKeyFn: ({ workspaceId }) => workspaceId
}
),
getWorkspaceLimits: createLoader<string, WorkspaceLimits | null>(
async (workspaceIds) => {
const workspacePlans = await getWorkspacePlansByWorkspaceId({
workspaceIds: workspaceIds.slice()
})
return workspaceIds.map((workspaceId) => {
const plan = workspacePlans[workspaceId]
if (!plan) return null
const config = {
...WorkspacePaidPlanConfigs,
...WorkspaceUnpaidPlanConfigs
}
return config[plan.name]?.limits || null
})
}
)
}
}
@@ -298,7 +298,8 @@ describe('Workspaces Billing', () => {
authorId: user.id,
objectId,
streamId: project.id,
branchName: modelWithVersions.name
branchName: modelWithVersions.name,
branchId: ''
})
const session = await login(user)
@@ -0,0 +1,38 @@
import { GraphQLContext } from '@/modules/shared/helpers/typeHelper'
import {
getProjectLimitDateFactory as getProjectLimitDateFactoryBase,
isCreatedBeyondHistoryLimitCutoffFactory as isCreatedBeyondHistoryLimitCutoffFactoryBase,
IsCreatedBeyondHistoryLimitCutoff,
GetProjectLimitDate
} from '@speckle/shared'
import { PersonalProjectsLimits } from '@speckle/shared/authz'
import { getFeatureFlags } from '@speckle/shared/environment'
const { FF_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const getPersonalProjectLimits = FF_PERSONAL_PROJECTS_LIMITS_ENABLED
? () => Promise.resolve(PersonalProjectsLimits)
: () => Promise.resolve(null)
export const isCreatedBeyondHistoryLimitCutoffFactory = (deps: {
ctx: GraphQLContext
}): IsCreatedBeyondHistoryLimitCutoff => {
const getProjectLimitDate = getProjectLimitDateFactory(deps)
const isCreatedBeyondHistoryLimitCutoffFactory =
isCreatedBeyondHistoryLimitCutoffFactoryBase({
getProjectLimitDate
})
return isCreatedBeyondHistoryLimitCutoffFactory
}
export const getProjectLimitDateFactory = (deps: {
ctx: GraphQLContext
}): GetProjectLimitDate => {
const getProjectLimitDate = getProjectLimitDateFactoryBase({
getWorkspaceLimits: async ({ workspaceId }) =>
(await deps.ctx.loaders.gatekeeper?.getWorkspaceLimits.load(workspaceId)) || null,
getPersonalProjectLimits
})
return getProjectLimitDate
}
@@ -119,7 +119,8 @@ isMultiRegionTestMode()
id: '',
objectId: '',
streamId: '',
authorId: ''
authorId: '',
branchId: ''
}
let testAutomation: AutomationRecord
@@ -162,6 +163,7 @@ isMultiRegionTestMode()
owner: adminUser
})
testVersion.branchId = testModel.id
testVersion.branchName = testModel.name
testVersion.objectId = await createTestObject({ projectId: testProject.id })
@@ -43,6 +43,9 @@ import {
import { ServerInviteRecord } from '@/modules/serverinvites/domain/types'
import { reduce } from 'lodash'
import { grantStreamPermissionsFactory } from '@/modules/core/repositories/streams'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
const { FF_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
async function cleanup() {
await truncateTables([ServerInvites.name, Streams.name, Users.name])
@@ -189,268 +192,270 @@ describe('[Stream & Server Invites]', () => {
)
})
})
;(FF_PERSONAL_PROJECTS_LIMITS_ENABLED ? describe.skip : describe)(
'and inviting to stream',
() => {
const otherGuyAlreadyInvitedStream: BasicTestStream = {
name: 'Other guy is already here stream',
isPublic: false,
id: '',
ownerId: ''
}
describe('and inviting to stream', () => {
const otherGuyAlreadyInvitedStream: BasicTestStream = {
name: 'Other guy is already here stream',
isPublic: false,
id: '',
ownerId: ''
}
const createInvite = (input: StreamInviteCreateInput) =>
apollo.execute(CreateStreamInviteDocument, { input })
const createInvite = (input: StreamInviteCreateInput) =>
apollo.execute(CreateStreamInviteDocument, { input })
const createProjectInvite = (args: CreateProjectInviteMutationVariables) =>
apollo.execute(CreateProjectInviteDocument, args)
const createProjectInvite = (args: CreateProjectInviteMutationVariables) =>
apollo.execute(CreateProjectInviteDocument, args)
before(async () => {
// Create a stream and make sure otherGuy is already a contributor there
await createTestStream(otherGuyAlreadyInvitedStream, me)
await grantStreamPermissions({
streamId: otherGuyAlreadyInvitedStream.id,
role: Roles.Stream.Contributor,
userId: otherGuy.id
})
})
const alreadyInvitedUserDataSet = [
{ display: 'by user id', userId: true },
{ display: 'by email', userId: false }
]
alreadyInvitedUserDataSet.forEach(({ display, userId }) => {
it(`can't invite an already added user ${display}`, async () => {
const { errors, data } = await createInvite({
email: userId ? null : otherGuy.email,
userId: userId ? otherGuy.id : null,
message: 'hey dude come to my stream',
streamId: otherGuyAlreadyInvitedStream.id
before(async () => {
// Create a stream and make sure otherGuy is already a contributor there
await createTestStream(otherGuyAlreadyInvitedStream, me)
await grantStreamPermissions({
streamId: otherGuyAlreadyInvitedStream.id,
role: Roles.Stream.Contributor,
userId: otherGuy.id
})
expect(data?.streamInviteCreate).to.be.not.ok
expect(errors).to.be.ok
expect(errors!.map((e) => e.message).join('|')).to.contain(
'user is already a collaborator'
)
})
})
it("can't invite with an invalid role", async () => {
const result = await createInvite({
email: 'badroleguy@speckle.com',
streamId: myPrivateStream.id,
role: 'aaa'
})
expect(result.data?.streamInviteCreate).to.be.not.ok
expect(result.errors).to.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'Unexpected project invite role'
)
})
const createInviteTypes = [{ projectInvite: false }, { projectInvite: true }]
createInviteTypes.forEach(({ projectInvite }) => {
const userTypesDataSet = [
{
display: 'registered user',
user: otherGuy,
stream: myPrivateStream,
email: null
},
{
display: 'registered user (with custom role)',
user: otherGuy,
stream: myPrivateStream,
email: null,
role: Roles.Stream.Owner
},
{
display: 'unregistered user',
user: null,
stream: myPrivateStream,
email: 'randomer22@lool.com'
},
{
display: 'unregistered user (with custom role)',
user: null,
stream: myPrivateStream,
email: 'randomer22@lool.com',
Role: Roles.Stream.Reviewer
}
const alreadyInvitedUserDataSet = [
{ display: 'by user id', userId: true },
{ display: 'by email', userId: false }
]
userTypesDataSet.forEach(({ display, user, stream, email, role }) => {
it(`can ${
projectInvite ? 'project' : 'stream'
} invite a ${display}`, async () => {
const messagePart1 = '1234hiiiiduuuuude'
const messagePart2 = 'yepppppp'
const unsanitaryMessage = `<a href="https://google.com">${messagePart1}</a> <script>${messagePart2}</script>`
const targetEmail = email || user?.email
alreadyInvitedUserDataSet.forEach(({ display, userId }) => {
it(`can't invite an already added user ${display}`, async () => {
const { errors, data } = await createInvite({
email: userId ? null : otherGuy.email,
userId: userId ? otherGuy.id : null,
message: 'hey dude come to my stream',
streamId: otherGuyAlreadyInvitedStream.id
})
const sendEmailInvocations = mailerMock.hijackFunction(
'sendEmail',
async () => true
expect(data?.streamInviteCreate).to.be.not.ok
expect(errors).to.be.ok
expect(errors!.map((e) => e.message).join('|')).to.contain(
'user is already a collaborator'
)
if (projectInvite) {
const result = await createProjectInvite({
projectId: stream.id,
input: {
email,
userId: user?.id || null,
role: role || null
}
})
// Check that operation was successful
expect(result.data?.projectMutations.invites.create).to.be.ok
expect(result.errors).to.be.not.ok
} else {
const result = await createInvite({
email,
message: unsanitaryMessage,
userId: user?.id || null,
streamId: stream.id,
role: role || null
})
// Check that operation was successful
expect(result.data?.streamInviteCreate).to.be.ok
expect(result.errors).to.be.not.ok
}
// Check that email was sent out
const emailParams = sendEmailInvocations.args[0][0]
expect(emailParams).to.be.ok
expect(emailParams.to).to.eq(targetEmail)
expect(emailParams.subject).to.be.ok
// Check that message was sanitized
if (!projectInvite) {
expect(emailParams.text).to.contain(messagePart1)
expect(emailParams.text).to.not.contain(messagePart2)
expect(emailParams.html).to.contain(messagePart1)
expect(emailParams.html).to.not.contain(messagePart2)
}
// Validate that invite exists
const invite = await validateInviteExistanceFromEmail(emailParams)
expect(invite).to.be.ok
expect(invite?.resource.role).to.eq(role || Roles.Stream.Contributor)
})
})
it(`can't ${
projectInvite ? 'project' : 'stream'
} invite user to a nonexistant stream`, async () => {
const params = {
email: 'whocares@really.com',
streamId: 'ayoooooooo'
}
it("can't invite with an invalid role", async () => {
const result = await createInvite({
email: 'badroleguy@speckle.com',
streamId: myPrivateStream.id,
role: 'aaa'
})
const result = projectInvite
? await createProjectInvite({
projectId: params.streamId,
input: {
email: params.email
}
})
: await createInvite({
email: params.email,
streamId: params.streamId
})
expect(result.data).to.not.be.ok
expect(result.data?.streamInviteCreate).to.be.not.ok
expect(result.errors).to.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'not found'
'Unexpected project invite role'
)
})
it(`can't ${
projectInvite ? 'project' : 'stream'
} invite user w/ broken stream identifier`, async () => {
const params = {
email: 'whocares@really.com',
streamId: ''
}
const createInviteTypes = [{ projectInvite: false }, { projectInvite: true }]
const result = projectInvite
? await createProjectInvite({
projectId: params.streamId,
input: {
email: params.email
}
})
: await createInvite({
email: params.email,
streamId: params.streamId
})
createInviteTypes.forEach(({ projectInvite }) => {
const userTypesDataSet = [
{
display: 'registered user',
user: otherGuy,
stream: myPrivateStream,
email: null
},
{
display: 'registered user (with custom role)',
user: otherGuy,
stream: myPrivateStream,
email: null,
role: Roles.Stream.Owner
},
{
display: 'unregistered user',
user: null,
stream: myPrivateStream,
email: 'randomer22@lool.com'
},
{
display: 'unregistered user (with custom role)',
user: null,
stream: myPrivateStream,
email: 'randomer22@lool.com',
Role: Roles.Stream.Reviewer
}
]
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
projectInvite ? 'Project not found' : 'Invalid project ID specified'
)
userTypesDataSet.forEach(({ display, user, stream, email, role }) => {
it(`can ${
projectInvite ? 'project' : 'stream'
} invite a ${display}`, async () => {
const messagePart1 = '1234hiiiiduuuuude'
const messagePart2 = 'yepppppp'
const unsanitaryMessage = `<a href="https://google.com">${messagePart1}</a> <script>${messagePart2}</script>`
const targetEmail = email || user?.email
const sendEmailInvocations = mailerMock.hijackFunction(
'sendEmail',
async () => true
)
if (projectInvite) {
const result = await createProjectInvite({
projectId: stream.id,
input: {
email,
userId: user?.id || null,
role: role || null
}
})
// Check that operation was successful
expect(result.data?.projectMutations.invites.create).to.be.ok
expect(result.errors).to.be.not.ok
} else {
const result = await createInvite({
email,
message: unsanitaryMessage,
userId: user?.id || null,
streamId: stream.id,
role: role || null
})
// Check that operation was successful
expect(result.data?.streamInviteCreate).to.be.ok
expect(result.errors).to.be.not.ok
}
// Check that email was sent out
const emailParams = sendEmailInvocations.args[0][0]
expect(emailParams).to.be.ok
expect(emailParams.to).to.eq(targetEmail)
expect(emailParams.subject).to.be.ok
// Check that message was sanitized
if (!projectInvite) {
expect(emailParams.text).to.contain(messagePart1)
expect(emailParams.text).to.not.contain(messagePart2)
expect(emailParams.html).to.contain(messagePart1)
expect(emailParams.html).to.not.contain(messagePart2)
}
// Validate that invite exists
const invite = await validateInviteExistanceFromEmail(emailParams)
expect(invite).to.be.ok
expect(invite?.resource.role).to.eq(role || Roles.Stream.Contributor)
})
})
it(`can't ${
projectInvite ? 'project' : 'stream'
} invite user to a nonexistant stream`, async () => {
const params = {
email: 'whocares@really.com',
streamId: 'ayoooooooo'
}
const result = projectInvite
? await createProjectInvite({
projectId: params.streamId,
input: {
email: params.email
}
})
: await createInvite({
email: params.email,
streamId: params.streamId
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'not found'
)
})
it(`can't ${
projectInvite ? 'project' : 'stream'
} invite user w/ broken stream identifier`, async () => {
const params = {
email: 'whocares@really.com',
streamId: ''
}
const result = projectInvite
? await createProjectInvite({
projectId: params.streamId,
input: {
email: params.email
}
})
: await createInvite({
email: params.email,
streamId: params.streamId
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
projectInvite ? 'Project not found' : 'Invalid project ID specified'
)
})
it(`can't ${
projectInvite ? 'project' : 'stream'
} invite user to a stream, if not its owner`, async () => {
const params = {
email: 'whocares@really.com',
streamId: otherGuysStream.id
}
const result = projectInvite
? await createProjectInvite({
projectId: params.streamId,
input: {
email: params.email
}
})
: await createInvite({
email: params.email,
streamId: params.streamId
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
projectInvite
? 'You do not have access to the project'
: "Inviter doesn't have owner access to"
)
})
it(`can't ${
projectInvite ? 'project' : 'stream'
} invite a nonexistant user ID to a stream`, async () => {
const params = {
userId: 'bababooey',
streamId: myPrivateStream.id
}
const result = projectInvite
? await createProjectInvite({
projectId: params.streamId,
input: {
userId: params.userId
}
})
: await createInvite({
userId: params.userId,
streamId: params.streamId
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'Attempting to invite an invalid user'
)
})
})
it(`can't ${
projectInvite ? 'project' : 'stream'
} invite user to a stream, if not its owner`, async () => {
const params = {
email: 'whocares@really.com',
streamId: otherGuysStream.id
}
const result = projectInvite
? await createProjectInvite({
projectId: params.streamId,
input: {
email: params.email
}
})
: await createInvite({
email: params.email,
streamId: params.streamId
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
projectInvite
? 'You do not have access to the project'
: "Inviter doesn't have owner access to"
)
})
it(`can't ${
projectInvite ? 'project' : 'stream'
} invite a nonexistant user ID to a stream`, async () => {
const params = {
userId: 'bababooey',
streamId: myPrivateStream.id
}
const result = projectInvite
? await createProjectInvite({
projectId: params.streamId,
input: {
userId: params.userId
}
})
: await createInvite({
userId: params.userId,
streamId: params.streamId
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'Attempting to invite an invalid user'
)
})
})
})
}
)
describe('and administrating invites', () => {
const serverInvite1 = {
@@ -62,6 +62,8 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
case Authz.ModelNotFoundError.code:
case Authz.VersionNotFoundError.code:
return new NotFoundError(e.message)
case Authz.PersonalProjectsLimitedError.code:
return new BadRequestError(e.message)
default:
throwUncoveredError(e)
}
@@ -12,7 +12,7 @@ import type { ConditionalKeys, SetRequired } from 'type-fest'
import type { Logger } from 'pino'
import type { BaseContext } from '@apollo/server'
import type { Registry } from 'prom-client'
import { AuthCheckContextLoaders, AuthPolicies } from '@speckle/shared/authz'
import { AuthPolicies } from '@speckle/shared/authz'
export type MarkNullableOptional<T> = SetRequired<
Partial<T>,
@@ -62,10 +62,6 @@ export type GraphQLContext = BaseContext &
*/
loaders: RequestDataLoaders
log: Logger
/**
* @deprecated Should be cleaned up soon, just use dataloaders
*/
authLoaders: AuthCheckContextLoaders
/**
* Clear dataloader, auth policy loader etc. caches. Usually necessary after mutations
* are done in resolvers
@@ -223,7 +223,6 @@ export async function buildContext(params?: {
authLoaders.clearCache()
}
},
authLoaders: authLoaders.loaders,
clearCache: async () => {
authLoaders.clearCache()
dataLoaders.clearAll()
@@ -11,7 +11,6 @@ import {
import { getWorkspaceRoleForUserFactory } from '@/modules/workspaces/repositories/workspaces'
import { queryAllWorkspaceProjectsFactory } from '@/modules/workspaces/services/projects'
import { getWorkspaceModelCountFactory } from '@/modules/workspaces/services/workspaceLimits'
import { WorkspacePaidPlanConfigs, WorkspaceUnpaidPlanConfigs } from '@speckle/shared'
// TODO: Move everything to use dataLoaders
export default defineModuleLoaders(async () => {
@@ -72,11 +71,8 @@ export default defineModuleLoaders(async () => {
getWorkspacePlan: async ({ workspaceId }) => {
return await getWorkspacePlan({ workspaceId })
},
getWorkspaceLimits: async ({ workspaceId }) => {
const plan = await getWorkspacePlan({ workspaceId })
if (!plan) return null
const config = { ...WorkspacePaidPlanConfigs, ...WorkspaceUnpaidPlanConfigs }
return config[plan.name]?.limits ?? null
getWorkspaceLimits: async ({ workspaceId }, { dataLoaders }) => {
return await dataLoaders.gatekeeper!.getWorkspaceLimits.load(workspaceId)
}
}
})
@@ -68,12 +68,15 @@ import {
import { getEventBus } from '@/modules/shared/services/eventBus'
import { WorkspaceSeatType } from '@/modules/workspacesCore/domain/types'
import { ProjectRecordVisibility } from '@/modules/core/helpers/types'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
enum InviteByTarget {
Email = 'email',
Id = 'id'
}
const { FF_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver })
const getUser = getUserFactory({ db })
@@ -598,22 +601,24 @@ describe('Workspaces Invites GQL', () => {
expect(res).to.haveGraphQLErrors('Target project belongs to a workspace')
expect(res.data?.projectMutations.invites.create.id).to.not.be.ok
})
;(FF_PERSONAL_PROJECTS_LIMITS_ENABLED ? it.skip : it)(
'can invite to non-workspace project through workspace project invite resolver',
async () => {
const res = await gqlHelpers.createWorkspaceProjectInvite({
projectId: myProjectInviteTargetBasicProject.id,
inputs: [
{
userId: otherGuy.id,
role: Roles.Stream.Owner,
workspaceRole: Roles.Workspace.Admin // should be ignored
}
]
})
it('can invite to non-workspace project through workspace project invite resolver', async () => {
const res = await gqlHelpers.createWorkspaceProjectInvite({
projectId: myProjectInviteTargetBasicProject.id,
inputs: [
{
userId: otherGuy.id,
role: Roles.Stream.Owner,
workspaceRole: Roles.Workspace.Admin // should be ignored
}
]
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.projectMutations.invites.createForWorkspace.id).to.be.ok
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.projectMutations.invites.createForWorkspace.id).to.be.ok
}
)
it("can't indirectly invite to workspace if not workspace admin", async () => {
const res = await gqlHelpers.createWorkspaceProjectInvite(
@@ -21,7 +21,6 @@ import {
import {
ActiveUserProjectsDocument,
ActiveUserProjectsWorkspaceDocument,
CreateProjectDocument,
CreateWorkspaceProjectDocument,
GetProjectDocument,
GetWorkspaceDocument,
@@ -1019,13 +1018,14 @@ describe('Workspace project GQL CRUD', () => {
)
expect(createProjectInWorkspaceRes).to.not.haveGraphQLErrors()
const createProjectNonInWorkspaceRes = await session.execute(
CreateProjectDocument,
{ input: { name: 'project' } }
)
expect(createProjectNonInWorkspaceRes).to.not.haveGraphQLErrors()
const projectNonInWorkspace =
createProjectNonInWorkspaceRes.data!.projectMutations.create
// create w/o GQL, to not mess w/ personal project limits
const projectNonInWorkspace: BasicTestStream = {
id: '',
name: 'project',
ownerId: '',
isPublic: false
}
await createTestStream(projectNonInWorkspace, testAdminUser)
const userProjectsRes = await session.execute(ActiveUserProjectsDocument, {
filter: { personalOnly: true }
@@ -1071,11 +1071,14 @@ describe('Workspace project GQL CRUD', () => {
const projectInWorkspace =
createProjectInWorkspaceRes.data!.workspaceMutations.projects.create
const createProjectNonInWorkspaceRes = await session.execute(
CreateProjectDocument,
{ input: { name: 'project' } }
)
expect(createProjectNonInWorkspaceRes).to.not.haveGraphQLErrors()
// create w/o GQL, to not mess w/ personal project limits
const projectNonInWorkspace: BasicTestStream = {
id: '',
name: 'project',
ownerId: '',
isPublic: false
}
await createTestStream(projectNonInWorkspace, testAdminUser)
const userProjectsRes = await session.execute(ActiveUserProjectsDocument, {
filter: { workspaceId }
@@ -1119,11 +1122,14 @@ describe('Workspace project GQL CRUD', () => {
)
expect(createProjectInWorkspaceRes).to.not.haveGraphQLErrors()
const createProjectNonInWorkspaceRes = await session.execute(
CreateProjectDocument,
{ input: { name: 'project' } }
)
expect(createProjectNonInWorkspaceRes).to.not.haveGraphQLErrors()
// create w/o GQL, to not mess w/ personal project limits
const projectNonInWorkspace: BasicTestStream = {
id: '',
name: 'project',
ownerId: '',
isPublic: false
}
await createTestStream(projectNonInWorkspace, testAdminUser)
const userProjectsRes = await session.execute(ActiveUserProjectsDocument, {
filter: {}
@@ -1084,7 +1084,8 @@ describe('Workspaces GQL CRUD', () => {
id: cryptoRandomString({ length: 10 }),
streamId: workspaceProject.id,
objectId: '',
authorId: ''
authorId: '',
branchId: ''
}
await createTestCommit(testVersion, {
@@ -5299,6 +5299,49 @@ export type SetLegacyProjectsExplainerCollapsedMutationVariables = Exact<{
export type SetLegacyProjectsExplainerCollapsedMutation = { __typename?: 'Mutation', activeUserMutations: { __typename?: 'ActiveUserMutations', meta: { __typename?: 'UserMetaMutations', setLegacyProjectsExplainerCollapsed: boolean } } };
export type LimitedPersonalProjectCommentFragment = { __typename?: 'Comment', id: string, rawText?: string | null, createdAt: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, type: string } | null };
export type LimitedPersonalProjectVersionFragment = { __typename?: 'Version', id: string, createdAt: string, message?: string | null, referencedObject?: string | null, commentThreads: { __typename?: 'CommentCollection', totalCount: number, items: Array<{ __typename?: 'Comment', id: string, rawText?: string | null, createdAt: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, type: string } | null }> } };
export type GetLimitedPersonalProjectVersionsQueryVariables = Exact<{
projectId: Scalars['String']['input'];
}>;
export type GetLimitedPersonalProjectVersionsQuery = { __typename?: 'Query', project: { __typename?: 'Project', versions: { __typename?: 'VersionCollection', totalCount: number, items: Array<{ __typename?: 'Version', id: string, createdAt: string, message?: string | null, referencedObject?: string | null, commentThreads: { __typename?: 'CommentCollection', totalCount: number, items: Array<{ __typename?: 'Comment', id: string, rawText?: string | null, createdAt: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, type: string } | null }> } }> } } };
export type GetLimitedPersonalProjectVersionQueryVariables = Exact<{
projectId: Scalars['String']['input'];
versionId: Scalars['String']['input'];
}>;
export type GetLimitedPersonalProjectVersionQuery = { __typename?: 'Query', project: { __typename?: 'Project', version: { __typename?: 'Version', id: string, createdAt: string, message?: string | null, referencedObject?: string | null, commentThreads: { __typename?: 'CommentCollection', totalCount: number, items: Array<{ __typename?: 'Comment', id: string, rawText?: string | null, createdAt: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, type: string } | null }> } } } };
export type LimitedPersonalStreamCommitFragment = { __typename?: 'Commit', id: string, message?: string | null, referencedObject: string, createdAt?: string | null };
export type GetLimitedPersonalStreamCommitsQueryVariables = Exact<{
streamId: Scalars['String']['input'];
}>;
export type GetLimitedPersonalStreamCommitsQuery = { __typename?: 'Query', stream?: { __typename?: 'Stream', commits?: { __typename?: 'CommitCollection', totalCount: number, items?: Array<{ __typename?: 'Commit', id: string, message?: string | null, referencedObject: string, createdAt?: string | null }> | null } | null } | null };
export type GetLimitedPersonalProjectCommentsQueryVariables = Exact<{
projectId: Scalars['String']['input'];
}>;
export type GetLimitedPersonalProjectCommentsQuery = { __typename?: 'Query', project: { __typename?: 'Project', commentThreads: { __typename?: 'ProjectCommentCollection', totalCount: number, items: Array<{ __typename?: 'Comment', id: string, rawText?: string | null, createdAt: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, type: string } | null }> } } };
export type GetLimitedPersonalProjectCommentQueryVariables = Exact<{
projectId: Scalars['String']['input'];
commentId: Scalars['String']['input'];
}>;
export type GetLimitedPersonalProjectCommentQuery = { __typename?: 'Query', project: { __typename?: 'Project', comment?: { __typename?: 'Comment', id: string, rawText?: string | null, createdAt: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, type: string } | null } | null } };
export type BasicWorkspaceFragment = { __typename?: 'Workspace', id: string, name: string, slug: string, updatedAt: string, createdAt: string, role?: string | null, readOnly: boolean };
export type BasicPendingWorkspaceCollaboratorFragment = { __typename?: 'PendingWorkspaceCollaborator', id: string, inviteId: string, workspaceId: string, workspaceName: string, title: string, role: string, token?: string | null, invitedBy: { __typename?: 'LimitedUser', id: string, name: string }, user?: { __typename?: 'LimitedUser', id: string, name: string } | null };
@@ -6327,6 +6370,9 @@ export type ProjectEmbedOptionsQueryVariables = Exact<{
export type ProjectEmbedOptionsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, embedOptions: { __typename?: 'ProjectEmbedOptions', hideSpeckleBranding: boolean } } };
export const LimitedPersonalProjectCommentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectComment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]} as unknown as DocumentNode<LimitedPersonalProjectCommentFragment, unknown>;
export const LimitedPersonalProjectVersionFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Version"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"commentThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectComment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectComment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]} as unknown as DocumentNode<LimitedPersonalProjectVersionFragment, unknown>;
export const LimitedPersonalStreamCommitFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalStreamCommit"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Commit"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode<LimitedPersonalStreamCommitFragment, unknown>;
export const BasicWorkspaceFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}}]} as unknown as DocumentNode<BasicWorkspaceFragment, unknown>;
export const BasicPendingWorkspaceCollaboratorFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode<BasicPendingWorkspaceCollaboratorFragment, unknown>;
export const WorkspaceProjectsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceProjects"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCollection"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]} as unknown as DocumentNode<WorkspaceProjectsFragment, unknown>;
@@ -6373,6 +6419,11 @@ export const GetNewWorkspaceExplainerDismissedDocument = {"kind":"Document","def
export const SetNewWorkspaceExplainerDismissedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetNewWorkspaceExplainerDismissed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setNewWorkspaceExplainerDismissed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode<SetNewWorkspaceExplainerDismissedMutation, SetNewWorkspaceExplainerDismissedMutationVariables>;
export const GetLegacyProjectsExplainerCollapsedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLegacyProjectsExplainerCollapsed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"legacyProjectsExplainerCollapsed"}}]}}]}}]}}]} as unknown as DocumentNode<GetLegacyProjectsExplainerCollapsedQuery, GetLegacyProjectsExplainerCollapsedQueryVariables>;
export const SetLegacyProjectsExplainerCollapsedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetLegacyProjectsExplainerCollapsed"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUserMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setLegacyProjectsExplainerCollapsed"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode<SetLegacyProjectsExplainerCollapsedMutation, SetLegacyProjectsExplainerCollapsedMutationVariables>;
export const GetLimitedPersonalProjectVersionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLimitedPersonalProjectVersions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectComment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Version"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"commentThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectComment"}}]}}]}}]}}]} as unknown as DocumentNode<GetLimitedPersonalProjectVersionsQuery, GetLimitedPersonalProjectVersionsQueryVariables>;
export const GetLimitedPersonalProjectVersionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLimitedPersonalProjectVersion"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"versionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectComment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectVersion"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Version"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"commentThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectComment"}}]}}]}}]}}]} as unknown as DocumentNode<GetLimitedPersonalProjectVersionQuery, GetLimitedPersonalProjectVersionQueryVariables>;
export const GetLimitedPersonalStreamCommitsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLimitedPersonalStreamCommits"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stream"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"streamId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commits"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalStreamCommit"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalStreamCommit"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Commit"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"referencedObject"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode<GetLimitedPersonalStreamCommitsQuery, GetLimitedPersonalStreamCommitsQueryVariables>;
export const GetLimitedPersonalProjectCommentsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLimitedPersonalProjectComments"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"commentThreads"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectComment"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectComment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]} as unknown as DocumentNode<GetLimitedPersonalProjectCommentsQuery, GetLimitedPersonalProjectCommentsQueryVariables>;
export const GetLimitedPersonalProjectCommentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLimitedPersonalProjectComment"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"commentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comment"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"commentId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"LimitedPersonalProjectComment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"LimitedPersonalProjectComment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comment"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rawText"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"text"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"doc"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}}]}}]} as unknown as DocumentNode<GetLimitedPersonalProjectCommentQuery, GetLimitedPersonalProjectCommentQueryVariables>;
export const CreateWorkspaceInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorkspaceInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode<CreateWorkspaceInviteMutation, CreateWorkspaceInviteMutationVariables>;
export const BatchCreateWorkspaceInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BatchCreateWorkspaceInvites"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceInviteCreateInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"batchCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode<BatchCreateWorkspaceInvitesMutation, BatchCreateWorkspaceInvitesMutationVariables>;
export const GetWorkspaceWithTeamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithTeam"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode<GetWorkspaceWithTeamQuery, GetWorkspaceWithTeamQueryVariables>;
@@ -21,14 +21,17 @@ import {
getViewerResourceItemsUngroupedFactory
} from '@/modules/core/services/commit/viewerResources'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { resourceBuilder } from '@speckle/shared/viewer/route'
import cryptoRandomString from 'crypto-random-string'
export const createTestComment = async (params: {
userId: string
projectId: string
objectId: string
}): Promise<CommentRecord> => {
const { userId, projectId, objectId } = params
export const createTestComment = async (
params: {
userId: string
projectId: string
createdAt?: Date
} & ({ objectId: string } | { modelId: string; versionId?: string })
): Promise<CommentRecord> => {
const { userId, projectId } = params
const projectDb = await getProjectDbClient({ projectId })
@@ -51,6 +54,13 @@ export const createTestComment = async (params: {
emitEvent: async () => {}
})
const resourceIdStringBuilder = resourceBuilder()
if ('objectId' in params) {
resourceIdStringBuilder.addObject(params.objectId)
} else {
resourceIdStringBuilder.addModel(params.modelId, params.versionId)
}
return await createComment(
{
content: {
@@ -70,8 +80,11 @@ export const createTestComment = async (params: {
}
},
projectId,
resourceIdString: objectId
resourceIdString: resourceIdStringBuilder.toString()
},
userId
userId,
{
createdAt: params.createdAt
}
)
}
@@ -22,6 +22,7 @@ import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { BasicTestUser } from '@/test/authHelper'
import { BasicTestStream } from '@/test/speckle-helpers/streamHelper'
import cryptoRandomString from 'crypto-random-string'
export type BasicTestCommit = {
/**
@@ -40,6 +41,10 @@ export type BasicTestCommit = {
* Can be left empty, will be filled on creation if owner passed in
*/
authorId: string
/**
* Can be left empty, will be filled on creation. Takes precedence over branchName
*/
branchId: string
/**
* Defaults to 'main'
*/
@@ -53,6 +58,11 @@ export type BasicTestCommit = {
* Empty array by default
*/
parents?: string[]
/**
* Optionally override the createdAt date
*/
createdAt?: Date
}
export async function createTestObject(params: { projectId: string }) {
@@ -85,7 +95,7 @@ async function ensureObjects(commits: BasicTestCommit[]) {
return createObject({
streamId: c.streamId,
object: { foo: 'bar' }
object: { foo: cryptoRandomString({ length: 256 }) }
}).then((oid) => (c.objectId = oid))
})
)
@@ -128,7 +138,7 @@ export async function createTestCommits(
getBranchById: getBranchByIdFactory({ db: projectDb })
})
return createCommitByBranchName({
const baseArgs = {
streamId: c.streamId,
branchName: c.branchName || 'main',
message: c.message || 'this message is auto generated',
@@ -136,8 +146,20 @@ export async function createTestCommits(
objectId: c.objectId,
authorId: c.authorId,
totalChildrenCount: 0,
parents: c.parents || []
}).then((newCommit) => (c.id = newCommit.id))
parents: c.parents || [],
createdAt: c.createdAt
}
const commit = await (c.branchId?.length
? createCommitByBranchId({
...baseArgs,
branchId: c.branchId
})
: createCommitByBranchName(baseArgs))
c.id = commit.id
c.branchId = commit.branchId
return c
})
)
}
+22
View File
@@ -97,6 +97,8 @@
"./workers": "./src/workers/index.ts",
"./workers/previews": "./src/workers/previews/index.ts",
"./workers/fileimport": "./src/workers/fileimport/index.ts",
"./viewer": "./src/viewer/index.ts",
"./viewer/route": "./src/viewer/helpers/route.ts",
"./dist/*": "./dist/*"
},
"exclude": [
@@ -233,6 +235,26 @@
"default": "./dist/commonjs/workers/fileimport/index.js"
}
},
"./viewer": {
"import": {
"types": "./dist/esm/viewer/index.d.ts",
"default": "./dist/esm/viewer/index.js"
},
"require": {
"types": "./dist/commonjs/viewer/index.d.ts",
"default": "./dist/commonjs/viewer/index.js"
}
},
"./viewer/route": {
"import": {
"types": "./dist/esm/viewer/helpers/route.d.ts",
"default": "./dist/esm/viewer/helpers/route.js"
},
"require": {
"types": "./dist/commonjs/viewer/helpers/route.d.ts",
"default": "./dist/commonjs/viewer/helpers/route.js"
}
},
"./dist/*": "./dist/*"
}
}
@@ -64,6 +64,11 @@ export const ProjectNoAccessError = defineAuthError({
message: 'You do not have access to the project'
})
export const PersonalProjectsLimitedError = defineAuthError({
code: 'PersonalProjectsLimited',
message: 'Non-workspaced/personal projects are limited'
})
export const ProjectNotEnoughPermissionsError = defineAuthError({
code: 'ProjectNotEnoughPermissions',
message: 'You do not have enough permissions in the project to perform this action'
@@ -36,7 +36,10 @@ describe('ensureMinimumProjectRoleFragment', () => {
getWorkspaceRole: async () => null,
getServerRole: async () => Roles.Server.User,
getProjectRole: async () => Roles.Stream.Contributor,
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
...overrides
})
@@ -205,7 +208,10 @@ describe('checkIfPubliclyReadableProjectFragment', () => {
id: 'projectId',
workspaceId: null
}),
getEnv: async () => parseFeatureFlags({}),
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
...overrides
})
@@ -423,7 +429,10 @@ describe('ensureImplicitProjectMemberWithReadAccessFragment', async () => {
getAdminOverrideEnabled: async () => false,
getServerRole: async () => Roles.Server.User,
getProjectRole: async () => Roles.Stream.Contributor,
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getWorkspace: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
+2 -2
View File
@@ -9,8 +9,8 @@ import { canReadProjectSettingsPolicy } from './project/canReadSettings.js'
import { canReadProjectWebhooksPolicy } from './project/canReadWebhooks.js'
import { canUpdateProjectAllowPublicCommentsPolicy } from './project/canUpdateAllowPublicComments.js'
import { canLeaveProjectPolicy } from './project/canLeave.js'
import { canInvitePolicy as canInviteToWorkspacePolicy } from './workspace/canInvite.js'
import { canInvitePolicy as canInviteToProjectPolicy } from './project/canInvite.js'
import { canInviteToWorkspacePolicy } from './workspace/canInvite.js'
import { canInviteToProjectPolicy } from './project/canInvite.js'
import { canBroadcastProjectActivityPolicy } from './project/canBroadcastActivity.js'
import { canCreateProjectCommentPolicy } from './project/comment/canCreate.js'
import { canArchiveProjectCommentPolicy } from './project/comment/canArchive.js'
@@ -20,7 +20,10 @@ describe('canBroadcastProjectActivityPolicy', () => {
overrides?: OverridesOf<typeof canBroadcastProjectActivityPolicy>
) =>
canBroadcastProjectActivityPolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null
@@ -2,25 +2,25 @@ import { describe, expect, it } from 'vitest'
import { canCreatePersonalProjectPolicy } from './canCreatePersonal.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import {
ProjectNoAccessError,
PersonalProjectsLimitedError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError
} from '../../domain/authErrors.js'
import { OverridesOf } from '../../../tests/helpers/types.js'
const buildSUT = (
overrides?: Partial<Parameters<typeof canCreatePersonalProjectPolicy>[0]>
) =>
const buildSUT = (overrides?: OverridesOf<typeof canCreatePersonalProjectPolicy>) =>
canCreatePersonalProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'false'
FF_PERSONAL_PROJECTS_LIMITS_ENABLED: 'false',
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getServerRole: async () => 'server:user',
...(overrides || {})
})
describe('canCreateProject', () => {
describe('canCreatePersonalProject', () => {
it('returns error if user is not logged in', async () => {
const canCreateProject = buildSUT()
@@ -30,15 +30,15 @@ describe('canCreateProject', () => {
})
})
// TODO: Re-enable when ready
it.skip('returns error if workspaces module is enabled', async () => {
it('returns error if personal project limits enabled', async () => {
const canCreateProject = buildSUT({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' })
getEnv: async () =>
parseFeatureFlags({ FF_PERSONAL_PROJECTS_LIMITS_ENABLED: 'true' })
})
const result = await canCreateProject({ userId: 'user-id' })
expect(result).toBeAuthErrorResult({
code: ProjectNoAccessError.code
code: PersonalProjectsLimitedError.code
})
})
@@ -5,6 +5,7 @@ import { AuthPolicy } from '../../domain/policies.js'
import { Roles } from '../../../core/constants.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import {
PersonalProjectsLimitedError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError
@@ -17,18 +18,18 @@ export const canCreatePersonalProjectPolicy: AuthPolicy<
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ServerNotEnoughPermissionsError
| typeof PersonalProjectsLimitedError
>
> =
(loaders) =>
async ({ userId }) => {
const env = await loaders.getEnv()
if (env.FF_WORKSPACES_MODULE_ENABLED) {
// TODO: We're not ready to enforce this yet, there's a bunch of tests that would break
// return err(
// new ProjectNoAccessError({
// message: "Projects can't be created outside of workspaces"
// })
// )
if (env.FF_PERSONAL_PROJECTS_LIMITS_ENABLED) {
return err(
new PersonalProjectsLimitedError({
message: "Projects can't be created outside of workspaces"
})
)
}
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../tests/helpers/types.js'
import { canInviteToProjectPolicy } from './canInvite.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { Roles } from '../../../core/constants.js'
import { getProjectFake } from '../../../tests/fakes.js'
import { PersonalProjectsLimitedError } from '../../domain/authErrors.js'
const buildSUT = (overrides?: OverridesOf<typeof canInviteToProjectPolicy>) =>
canInviteToProjectPolicy({
getEnv: async () =>
parseFeatureFlags({
FF_PERSONAL_PROJECTS_LIMITS_ENABLED: 'false',
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getServerRole: async () => Roles.Server.User,
getProject: getProjectFake({
id: 'project-id'
}),
getProjectRole: async () => Roles.Stream.Owner,
getWorkspace: async () => null,
getWorkspaceRole: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
...(overrides || {})
})
describe('canInviteToProjectPolicy', () => {
it('succeeds for project owner', async () => {
const canInvite = buildSUT()
const result = await canInvite({ userId: 'user-id', projectId: 'project-id' })
expect(result).toBeOKResult()
})
it('fails if personal projects are disabled', async () => {
const canInvite = buildSUT({
getEnv: async () =>
parseFeatureFlags({
FF_PERSONAL_PROJECTS_LIMITS_ENABLED: 'true'
})
})
const result = await canInvite({ userId: 'user-id', projectId: 'project-id' })
expect(result).toBeAuthErrorResult({
code: PersonalProjectsLimitedError.code
})
})
})
@@ -11,7 +11,8 @@ import {
ProjectNotFoundError,
ServerNotEnoughPermissionsError,
ProjectNotEnoughPermissionsError,
WorkspaceNotEnoughPermissionsError
WorkspaceNotEnoughPermissionsError,
PersonalProjectsLimitedError
} from '../../domain/authErrors.js'
import { ensureMinimumServerRoleFragment } from '../../fragments/server.js'
import { Roles } from '../../../core/constants.js'
@@ -39,8 +40,13 @@ type PolicyErrors =
| InstanceType<typeof WorkspaceSsoSessionNoAccessError>
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
| InstanceType<typeof PersonalProjectsLimitedError>
export const canInvitePolicy: AuthPolicy<PolicyLoaderKeys, PolicyArgs, PolicyErrors> =
export const canInviteToProjectPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, projectId }) => {
const ensuredServerRole = await ensureMinimumServerRoleFragment(loaders)({
@@ -59,5 +65,16 @@ export const canInvitePolicy: AuthPolicy<PolicyLoaderKeys, PolicyArgs, PolicyErr
if (ensuredProjectRole.isErr) return err(ensuredProjectRole.error)
const env = await loaders.getEnv()
const project = await loaders.getProject({ projectId })
if (project && !project.workspaceId && env.FF_PERSONAL_PROJECTS_LIMITS_ENABLED) {
// Prevent inviting collaborators to personal projects
return err(
new PersonalProjectsLimitedError({
message: 'No new collaborators can be added to personal projects'
})
)
}
return ok()
}
@@ -33,7 +33,10 @@ describe('canReadProjectPolicy creates a function, that handles ', () => {
const result = canReadProjectPolicy({
getWorkspace,
getAdminOverrideEnabled: async () => false,
getEnv: async () => parseFeatureFlags({}),
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: async () => null,
getProjectRole: () => {
assert.fail()
@@ -17,7 +17,10 @@ import { ProjectVisibility } from '../../domain/projects/types.js'
describe('canReadProjectSettingsPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canReadProjectSettingsPolicy>) =>
canReadProjectSettingsPolicy({
getEnv: async () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'true' }),
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null
@@ -12,7 +12,10 @@ describe('canUpdateProjectAllowPublicCommentsPolicy', () => {
overrides?: OverridesOf<typeof canUpdateProjectAllowPublicCommentsPolicy>
) =>
canUpdateProjectAllowPublicCommentsPolicy({
getEnv: async () => parseFeatureFlags({}),
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true'
}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null,
@@ -7,6 +7,7 @@ import { Workspace } from '../../../domain/workspaces/types.js'
import { WorkspacePlan } from '../../../../workspaces/index.js'
import { Project } from '../../../domain/projects/types.js'
import {
PersonalProjectsLimitedError,
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ServerNoAccessError,
@@ -115,15 +116,38 @@ describe('canCreateModelPolicy returns a function, that', () => {
})
})
it('forbids if personal project limits are enabled', async () => {
const sut = buildCanCreateModelPolicy({
getEnv: async () =>
parseFeatureFlags({ FF_PERSONAL_PROJECTS_LIMITS_ENABLED: 'true' }),
getProject: getProjectFake({
workspaceId: null
})
})
const result = await sut(canCreateArgs())
expect(result).toBeAuthErrorResult({
code: PersonalProjectsLimitedError.code
})
})
it('allows stream contributors to create personal projects when project is not in a workspace', async () => {
const result = await buildCanCreateModelPolicy({
getProject: async () => {
return {} as Project
}
},
getEnv: async () =>
parseFeatureFlags(
{
FF_PERSONAL_PROJECTS_LIMITS_ENABLED: 'false'
},
{ forceInputs: true }
)
})(canCreateArgs())
expect(result).toBeAuthOKResult()
})
// Hold the workspace to a higher standard than myself
it('requires the workspace to have a plan', async () => {
const result = await buildCanCreateModelPolicy({
@@ -10,7 +10,8 @@ import {
WorkspaceReadOnlyError,
WorkspaceNotEnoughPermissionsError,
ProjectNotEnoughPermissionsError,
ServerNotEnoughPermissionsError
ServerNotEnoughPermissionsError,
PersonalProjectsLimitedError
} from '../../../domain/authErrors.js'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../../../domain/loaders.js'
@@ -46,6 +47,7 @@ type PolicyErrors =
| typeof WorkspaceNotEnoughPermissionsError
| typeof ProjectNotEnoughPermissionsError
| typeof ServerNotEnoughPermissionsError
| typeof PersonalProjectsLimitedError
>
export const canCreateModelPolicy: AuthPolicy<
@@ -75,5 +77,16 @@ export const canCreateModelPolicy: AuthPolicy<
return err(ensuredModelsAccepted.error)
}
// Prevent personal project models, if personal projects limited
const project = await loaders.getProject({ projectId })
const env = await loaders.getEnv()
if (project && !project.workspaceId && env.FF_PERSONAL_PROJECTS_LIMITS_ENABLED) {
return err(
new PersonalProjectsLimitedError(
'No new models can be added to personal projects'
)
)
}
return ok()
}
@@ -37,7 +37,11 @@ type PolicyErrors =
| InstanceType<typeof WorkspaceNoAccessError>
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
export const canInvitePolicy: AuthPolicy<PolicyLoaderKeys, PolicyArgs, PolicyErrors> =
export const canInviteToWorkspacePolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, workspaceId }) => {
const ensuredWorkspacesEnabled = await ensureWorkspacesEnabledFragment(loaders)({})
+18 -5
View File
@@ -1,3 +1,4 @@
import { has } from '#lodash'
import { parseEnv } from 'znv'
import { z } from 'zod'
@@ -13,9 +14,17 @@ const isEnableAllFFsMode = () =>
['true', '1'].includes(process.env.ENABLE_ALL_FFS || '')
export const parseFeatureFlags = (
input: // | Record<string, string | undefined>
Partial<Record<keyof FeatureFlags, 'true' | 'false' | undefined>>
input: Partial<Record<keyof FeatureFlags, 'true' | 'false' | undefined>>,
options?: Partial<{
/**
* Whether to prevent inputs from being overriden by disable/enable all
* Default: true
*/
forceInputs: boolean
}>
): FeatureFlags => {
const { forceInputs = true } = options || {}
//INFO
// As a convention all feature flags should be prefixed with a FF_
const res = parseEnv(input, {
@@ -88,7 +97,7 @@ export const parseFeatureFlags = (
defaults: { _: false }
},
// Enable limits on personal projects
FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED: {
FF_PERSONAL_PROJECTS_LIMITS_ENABLED: {
schema: z.boolean(),
description:
'Enables limits on personal projects. Requires FF_GATEKEEPER_MODULE_ENABLED and FF_WORKSPACES_MODULE_ENABLED to be true. This requires a valid Speckle Enterprise Edition license in order to be enabled, see https://github.com/specklesystems/speckle-server?tab=License-1-ov-file#readme',
@@ -105,6 +114,10 @@ export const parseFeatureFlags = (
// Can be used to disable/enable all feature flags for testing purposes
if (isDisableAllFFsMode() || isEnableAllFFsMode()) {
for (const key of Object.keys(res)) {
if (forceInputs && has(input, key)) {
continue // skip if we are forcing inputs
}
;(res as Record<string, boolean>)[key] = !isDisableAllFFsMode() // disable takes precedence
}
}
@@ -125,12 +138,12 @@ export type FeatureFlags = {
FF_FORCE_ONBOARDING: boolean
FF_MOVE_PROJECT_REGION_ENABLED: boolean
FF_NO_PERSONAL_EMAILS_ENABLED: boolean
FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED: boolean
FF_PERSONAL_PROJECTS_LIMITS_ENABLED: boolean
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: boolean
}
export function getFeatureFlags(): FeatureFlags {
//@ts-expect-error this way, the parse function typing is a lot better
if (!parsedFlags) parsedFlags = parseFeatureFlags(process.env)
if (!parsedFlags) parsedFlags = parseFeatureFlags(process.env, { forceInputs: false })
return parsedFlags
}
+29 -26
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
calculateLimitCutoffDate,
getProjectLimitDate,
isCreatedBeyondHistoryLimitCutoff
getProjectLimitDateFactory,
isCreatedBeyondHistoryLimitCutoffFactory
} from './utils.js'
import dayjs from 'dayjs'
@@ -30,7 +30,7 @@ describe('Limits utils', () => {
})
describe('getProjectLimitDate', () => {
it('returns workspaceLimits for workspace projects', async () => {
const cutoffDate = await getProjectLimitDate({
const cutoffDate = await getProjectLimitDateFactory({
getPersonalProjectLimits: () => {
expect.fail()
},
@@ -39,7 +39,7 @@ describe('Limits utils', () => {
expect(cutoffDate).toBeNull()
})
it('returns projectLimits for non workspaceProjects', async () => {
const cutoffDate = await getProjectLimitDate({
const cutoffDate = await getProjectLimitDateFactory({
getPersonalProjectLimits: async () => null,
getWorkspaceLimits: async () => {
expect.fail()
@@ -50,40 +50,43 @@ describe('Limits utils', () => {
})
describe('isCreatedBeyondHistoryLimitCutoff', () => {
it('returns false if there are no limits', async () => {
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: async () => null
})({
entity: { createdAt: new Date() },
limitType: 'commentHistory',
project: { workspaceId: null }
})
const isCreatedBeyondHistoryLimit =
await isCreatedBeyondHistoryLimitCutoffFactory({
getProjectLimitDate: async () => null
})({
entity: { createdAt: new Date() },
limitType: 'commentHistory',
project: { workspaceId: null }
})
expect(isCreatedBeyondHistoryLimit).to.be.toBeFalsy()
})
it('returns false if entity is newer than the limit cutoff date', async () => {
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: async () => new Date(1999)
})({
entity: { createdAt: new Date() },
limitType: 'commentHistory',
project: { workspaceId: null }
})
const isCreatedBeyondHistoryLimit =
await isCreatedBeyondHistoryLimitCutoffFactory({
getProjectLimitDate: async () => new Date(1999)
})({
entity: { createdAt: new Date() },
limitType: 'commentHistory',
project: { workspaceId: null }
})
expect(isCreatedBeyondHistoryLimit).to.be.toBeFalsy()
})
it('returns false if entity is right on the limit cutoff date', async () => {
const date = new Date()
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: async () => date
})({
entity: { createdAt: date },
limitType: 'commentHistory',
project: { workspaceId: null }
})
const isCreatedBeyondHistoryLimit =
await isCreatedBeyondHistoryLimitCutoffFactory({
getProjectLimitDate: async () => date
})({
entity: { createdAt: date },
limitType: 'commentHistory',
project: { workspaceId: null }
})
expect(isCreatedBeyondHistoryLimit).to.be.toBeFalsy()
})
})
it('returns true of entity is older than the limit cutoff date', async () => {
const date = new Date()
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoffFactory({
getProjectLimitDate: async () => date
})({
entity: { createdAt: new Date(1999) },
+7 -3
View File
@@ -3,7 +3,7 @@ import { GetWorkspaceLimits } from '../authz/domain/workspaces/operations.js'
import { GetHistoryLimits, HistoryLimitTypes, HistoryLimits } from './domain.js'
import { Project } from '../authz/domain/projects/types.js'
export const isCreatedBeyondHistoryLimitCutoff =
export const isCreatedBeyondHistoryLimitCutoffFactory =
({ getProjectLimitDate }: { getProjectLimitDate: GetProjectLimitDate }) =>
async ({
entity,
@@ -21,6 +21,10 @@ export const isCreatedBeyondHistoryLimitCutoff =
return limitDate ? dayjs(limitDate).isAfter(entity.createdAt) : false
}
export type IsCreatedBeyondHistoryLimitCutoff = ReturnType<
typeof isCreatedBeyondHistoryLimitCutoffFactory
>
export const calculateLimitCutoffDate = (
historyLimits: HistoryLimits | null,
limitType: HistoryLimitTypes
@@ -32,12 +36,12 @@ export const calculateLimitCutoffDate = (
.toDate()
}
type GetProjectLimitDate = (args: {
export type GetProjectLimitDate = (args: {
project: Pick<Project, 'workspaceId'>
limitType: HistoryLimitTypes
}) => Promise<Date | null>
export const getProjectLimitDate =
export const getProjectLimitDateFactory =
({
getWorkspaceLimits,
getPersonalProjectLimits
+5
View File
@@ -5,6 +5,8 @@ import { nanoid } from 'nanoid'
import { Model } from '../authz/domain/models/types.js'
import { Version } from '../authz/domain/versions/types.js'
import { Workspace } from '../authz/domain/workspaces/types.js'
import { FeatureFlags, parseFeatureFlags } from '../environment/index.js'
import { mapValues } from 'lodash'
export const fakeGetFactory =
<T extends Record<string, unknown>>(defaults: () => T) =>
@@ -47,3 +49,6 @@ export const getVersionFake = fakeGetFactory<Version>(() => ({
projectId: nanoid(10),
authorId: nanoid(10)
}))
export const getEnvFake = (overrides?: Partial<FeatureFlags>) =>
parseFeatureFlags(mapValues(overrides || {}, (v) => `${v}` as 'true' | 'false'))
+1
View File
@@ -1,3 +1,4 @@
export * from './errors/index.js'
export * from './helpers/plans.js'
export * from './helpers/features.js'
export * from './helpers/limits.js'
@@ -566,6 +566,9 @@ Generate the environment variables for Speckle server and Speckle objects deploy
- name: FF_WORKSPACES_MODULE_ENABLED
value: {{ .Values.featureFlags.workspacesModuleEnabled | quote }}
- name: FF_PERSONAL_PROJECTS_LIMITS_ENABLED
value: {{ .Values.featureFlags.personalProjectLimitsEnabled | quote }}
- name: FF_WORKSPACES_SSO_ENABLED
value: {{ .Values.featureFlags.workspacesSSOEnabled | quote }}
@@ -50,6 +50,11 @@
"description": "High level flag fully toggles the workspaces module",
"default": false
},
"personalProjectLimitsEnabled": {
"type": "boolean",
"description": "High level flag toggles personal (non-workspace) project limits",
"default": false
},
"workspacesSSOEnabled": {
"type": "boolean",
"description": "High level flag fully toggles the workspaces dynamic sso",
+2
View File
@@ -41,6 +41,8 @@ featureFlags:
gendoAIModuleEnabled: false
## @param featureFlags.workspacesModuleEnabled High level flag fully toggles the workspaces module
workspacesModuleEnabled: false
## @param featureFlags.personalProjectLimitsEnabled High level flag toggles personal (non-workspace) project limits
personalProjectLimitsEnabled: false
## @param featureFlags.workspacesSSOEnabled High level flag fully toggles the workspaces dynamic sso
workspacesSSOEnabled: false
## @param featureFlags.multipleEmailsModuleEnabled High level flag fully toggles multiple emails
+4 -2
View File
@@ -133,8 +133,10 @@
},
"vue.complete.casing.props": "kebab",
"vue.inlayHints.missingProps": true,
"circleci.persistedProjectSelection": ["gh/specklesystems/speckle-server"],
"eslint.experimental.useFlatConfig": true
"circleci.persistedProjectSelection": ["gh/specklesystems/speckle-server"]
// "vitest.nodeEnv": {
// "DISABLE_ALL_FFS": "1"
// }
},
"extensions": {
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.