feat(gatekeeper): add per workspace feature flags (#5303)

* feat(gatekeeper): add per workspace feature flags

* feat(workspaces): add admin api for granting and removing access to
workspace features

* fix(workspaces): use the correct constant name

* fix(workspaces): more test type fixes

* fix(shared): fix tests and types

* fix(workspaces): properly use exhaustive switch statement

* fix(workspaces): add new workspace plan feature to switch

* fix(workspaces): use regular integer, its fine for now...

* fix(workspaces): feature flag retention post checkout

* fix(gatekeeper): fix upsert plan tests
This commit is contained in:
Gergő Jedlicska
2025-08-26 10:23:02 +01:00
committed by GitHub
parent ae3086f681
commit 6982023dca
31 changed files with 616 additions and 386 deletions
+1 -1
View File
@@ -34,7 +34,7 @@
"console": "integratedTerminal",
"program": "test",
"runtimeExecutable": "yarn",
"args": ["-t", "forbids creation for users eligible"],
"args": [],
"type": "pwa-node",
"cwd": "$ZED_WORKTREE_ROOT/packages/shared",
"skipFiles": ["<node_internals>/**"],
@@ -135,6 +135,11 @@ export type AddDomainToWorkspaceInput = {
workspaceId: Scalars['ID']['input'];
};
export type AdminAccessToWorkspaceFeatureInput = {
featureFlagName: WorkspaceFeatureFlagName;
workspaceId: Scalars['ID']['input'];
};
export type AdminInviteList = {
__typename?: 'AdminInviteList';
cursor?: Maybe<Scalars['String']['output']>;
@@ -144,10 +149,22 @@ export type AdminInviteList = {
export type AdminMutations = {
__typename?: 'AdminMutations';
giveAccessToWorkspaceFeature: Scalars['Boolean']['output'];
removeAccessToWorkspaceFeature: Scalars['Boolean']['output'];
updateWorkspacePlan: Scalars['Boolean']['output'];
};
export type AdminMutationsGiveAccessToWorkspaceFeatureArgs = {
input: AdminAccessToWorkspaceFeatureInput;
};
export type AdminMutationsRemoveAccessToWorkspaceFeatureArgs = {
input: AdminAccessToWorkspaceFeatureInput;
};
export type AdminMutationsUpdateWorkspacePlanArgs = {
input: AdminUpdateWorkspacePlanInput;
};
@@ -5273,8 +5290,15 @@ export type WorkspaceEmbedOptions = {
hideSpeckleBranding: Scalars['Boolean']['output'];
};
export const WorkspaceFeatureFlagName = {
AccIntegration: 'accIntegration',
Dashboards: 'dashboards'
} as const;
export type WorkspaceFeatureFlagName = typeof WorkspaceFeatureFlagName[keyof typeof WorkspaceFeatureFlagName];
export const WorkspaceFeatureName = {
AccIntegration: 'accIntegration',
Dashboards: 'dashboards',
DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies',
ExclusiveMembership: 'exclusiveMembership',
HideSpeckleBranding: 'hideSpeckleBranding',
@@ -8872,6 +8896,8 @@ export type AdminInviteListFieldArgs = {
totalCount: {},
}
export type AdminMutationsFieldArgs = {
giveAccessToWorkspaceFeature: AdminMutationsGiveAccessToWorkspaceFeatureArgs,
removeAccessToWorkspaceFeature: AdminMutationsRemoveAccessToWorkspaceFeatureArgs,
updateWorkspacePlan: AdminMutationsUpdateWorkspacePlanArgs,
}
export type AdminQueriesFieldArgs = {
@@ -154,7 +154,8 @@ extend type Project {
}
enum WorkspaceFeatureName {
accIntegration
accIntegration # will be moved to a per workspace feature
dashboards
domainBasedSecurityPolicies
oidcSso
hideSpeckleBranding
@@ -684,8 +684,20 @@ input AdminUpdateWorkspacePlanInput {
status: WorkspacePlanStatuses!
}
enum WorkspaceFeatureFlagName {
dashboards
accIntegration
}
input AdminAccessToWorkspaceFeatureInput {
workspaceId: ID!
featureFlagName: WorkspaceFeatureFlagName!
}
type AdminMutations {
updateWorkspacePlan(input: AdminUpdateWorkspacePlanInput!): Boolean!
giveAccessToWorkspaceFeature(input: AdminAccessToWorkspaceFeatureInput!): Boolean!
removeAccessToWorkspaceFeature(input: AdminAccessToWorkspaceFeatureInput!): Boolean!
}
extend type ActiveUserMutations {
@@ -156,6 +156,11 @@ export type AddDomainToWorkspaceInput = {
workspaceId: Scalars['ID']['input'];
};
export type AdminAccessToWorkspaceFeatureInput = {
featureFlagName: WorkspaceFeatureFlagName;
workspaceId: Scalars['ID']['input'];
};
export type AdminInviteList = {
__typename?: 'AdminInviteList';
cursor?: Maybe<Scalars['String']['output']>;
@@ -165,10 +170,22 @@ export type AdminInviteList = {
export type AdminMutations = {
__typename?: 'AdminMutations';
giveAccessToWorkspaceFeature: Scalars['Boolean']['output'];
removeAccessToWorkspaceFeature: Scalars['Boolean']['output'];
updateWorkspacePlan: Scalars['Boolean']['output'];
};
export type AdminMutationsGiveAccessToWorkspaceFeatureArgs = {
input: AdminAccessToWorkspaceFeatureInput;
};
export type AdminMutationsRemoveAccessToWorkspaceFeatureArgs = {
input: AdminAccessToWorkspaceFeatureInput;
};
export type AdminMutationsUpdateWorkspacePlanArgs = {
input: AdminUpdateWorkspacePlanInput;
};
@@ -5299,8 +5316,15 @@ export type WorkspaceEmbedOptions = {
hideSpeckleBranding: Scalars['Boolean']['output'];
};
export const WorkspaceFeatureFlagName = {
AccIntegration: 'accIntegration',
Dashboards: 'dashboards'
} as const;
export type WorkspaceFeatureFlagName = typeof WorkspaceFeatureFlagName[keyof typeof WorkspaceFeatureFlagName];
export const WorkspaceFeatureName = {
AccIntegration: 'accIntegration',
Dashboards: 'dashboards',
DomainBasedSecurityPolicies: 'domainBasedSecurityPolicies',
ExclusiveMembership: 'exclusiveMembership',
HideSpeckleBranding: 'hideSpeckleBranding',
@@ -5896,6 +5920,7 @@ export type ResolversTypes = {
Activity: ResolverTypeWrapper<Activity>;
ActivityCollection: ResolverTypeWrapper<ActivityCollectionGraphQLReturn>;
AddDomainToWorkspaceInput: AddDomainToWorkspaceInput;
AdminAccessToWorkspaceFeatureInput: AdminAccessToWorkspaceFeatureInput;
AdminInviteList: ResolverTypeWrapper<Omit<AdminInviteList, 'items'> & { items: Array<ResolversTypes['ServerInvite']> }>;
AdminMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
AdminQueries: ResolverTypeWrapper<GraphQLEmptyReturn>;
@@ -6223,6 +6248,7 @@ export type ResolversTypes = {
WorkspaceDomain: ResolverTypeWrapper<WorkspaceDomain>;
WorkspaceDomainDeleteInput: WorkspaceDomainDeleteInput;
WorkspaceEmbedOptions: ResolverTypeWrapper<WorkspaceEmbedOptions>;
WorkspaceFeatureFlagName: WorkspaceFeatureFlagName;
WorkspaceFeatureName: WorkspaceFeatureName;
WorkspaceInviteCreateInput: WorkspaceInviteCreateInput;
WorkspaceInviteLookupOptions: WorkspaceInviteLookupOptions;
@@ -6280,6 +6306,7 @@ export type ResolversParentTypes = {
Activity: Activity;
ActivityCollection: ActivityCollectionGraphQLReturn;
AddDomainToWorkspaceInput: AddDomainToWorkspaceInput;
AdminAccessToWorkspaceFeatureInput: AdminAccessToWorkspaceFeatureInput;
AdminInviteList: Omit<AdminInviteList, 'items'> & { items: Array<ResolversParentTypes['ServerInvite']> };
AdminMutations: MutationsObjectGraphQLReturn;
AdminQueries: GraphQLEmptyReturn;
@@ -6726,6 +6753,8 @@ export type AdminInviteListResolvers<ContextType = GraphQLContext, ParentType ex
};
export type AdminMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['AdminMutations'] = ResolversParentTypes['AdminMutations']> = {
giveAccessToWorkspaceFeature?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AdminMutationsGiveAccessToWorkspaceFeatureArgs, 'input'>>;
removeAccessToWorkspaceFeature?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AdminMutationsRemoveAccessToWorkspaceFeatureArgs, 'input'>>;
updateWorkspacePlan?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<AdminMutationsUpdateWorkspacePlanArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -6,7 +6,7 @@ import type {
import type {
Optional,
WorkspacePlan,
WorkspacePlanFeatures,
WorkspaceFeatures,
WorkspacePlans,
WorkspacePlanStatuses,
WorkspaceRoles
@@ -14,7 +14,7 @@ import type {
export type CanWorkspaceAccessFeature = (args: {
workspaceId: string
workspaceFeature: WorkspacePlanFeatures
workspaceFeature: WorkspaceFeatures
}) => Promise<boolean>
export type WorkspaceFeatureAccessFunction = (args: {
@@ -4,6 +4,7 @@ import { authorizeResolver } from '@/modules/shared'
import {
Roles,
throwUncoveredError,
WorkspaceFeatureFlags,
WorkspacePlanFeatures,
WorkspacePlans
} from '@speckle/shared'
@@ -124,11 +125,28 @@ export default FF_GATEKEEPER_MODULE_ENABLED
})
},
hasAccessToFeature: async (parent, args) => {
const workspaceFeature = (() => {
switch (args.featureName) {
case 'dashboards':
return WorkspaceFeatureFlags.dashboards
case 'accIntegration':
// TODO: move this to be a feature flag, once the feature flags have rolled out.
case WorkspacePlanFeatures.DomainSecurity:
case WorkspacePlanFeatures.ExclusiveMembership:
case WorkspacePlanFeatures.HideSpeckleBranding:
case WorkspacePlanFeatures.SSO:
case WorkspacePlanFeatures.CustomDataRegion:
case WorkspacePlanFeatures.SavedViews:
return args.featureName
default:
throwUncoveredError(args.featureName)
}
})()
const hasAccess = await canWorkspaceAccessFeatureFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db })
})({
workspaceId: parent.id,
workspaceFeature: args.featureName
workspaceFeature
})
return hasAccess
},
@@ -35,7 +35,8 @@ const WorkspacePlans = buildTableHelper('workspace_plans', [
'name',
'status',
'createdAt',
'updatedAt'
'updatedAt',
'featureFlags'
])
const WorkspaceSubscriptions = buildTableHelper('workspace_subscriptions', [
'workspaceId',
@@ -107,7 +108,7 @@ export const upsertWorkspacePlanFactory =
.workspacePlans(db)
.insert(workspacePlan)
.onConflict('workspaceId')
.merge(['name', 'status', 'updatedAt'])
.merge(['name', 'status', 'updatedAt', 'featureFlags'])
}
// this is a typed rebrand of the generic workspace plan upsert
@@ -79,7 +79,8 @@ export const completeCheckoutSessionFactory =
updatedAt: new Date(),
workspaceId: checkoutSession.workspaceId,
name: checkoutSession.workspacePlan,
status: 'valid'
status: 'valid',
featureFlags: previousWorkspacePlan.featureFlags
} as const
await upsertPaidWorkspacePlan({
workspacePlan
@@ -4,7 +4,12 @@ import type {
WorkspaceFeatureAccessFunction
} from '@/modules/gatekeeper/domain/operations'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { throwUncoveredError, workspacePlanHasAccessToFeature } from '@speckle/shared'
import {
throwUncoveredError,
workspacePlanHasAccessToFeature,
isPlanFeature,
isWorkspaceFeatureFlagOn
} from '@speckle/shared'
export const canWorkspaceAccessFeatureFactory =
({
@@ -25,12 +30,17 @@ export const canWorkspaceAccessFeatureFactory =
default:
throwUncoveredError(workspacePlan)
}
return workspacePlanHasAccessToFeature({
plan: workspacePlan.name,
feature: workspaceFeature,
featureFlags: getFeatureFlags()
if (isPlanFeature(workspaceFeature))
return workspacePlanHasAccessToFeature({
plan: workspacePlan.name,
feature: workspaceFeature,
featureFlags: getFeatureFlags()
})
const isFlagOn = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags: workspacePlan.featureFlags,
feature: workspaceFeature
})
return isFlagOn
}
export const canWorkspaceUseOidcSsoFactory =
@@ -59,7 +59,14 @@ export const updateWorkspacePlanFactory =
case 'cancelationScheduled':
case 'canceled':
case 'paymentFailed':
workspacePlan = { workspaceId, status, name, createdAt, updatedAt }
workspacePlan = {
workspaceId,
status,
name,
createdAt,
updatedAt,
featureFlags: previousWorkspacePlan.featureFlags
}
await upsertWorkspacePlan({ workspacePlan })
break
default:
@@ -77,7 +84,14 @@ export const updateWorkspacePlanFactory =
case 'valid':
if (workspaceSubscription) throw new InvalidWorkspacePlanStatus()
workspacePlan = { workspaceId, status, name, createdAt, updatedAt }
workspacePlan = {
workspaceId,
status,
name,
createdAt,
updatedAt,
featureFlags: previousWorkspacePlan.featureFlags
}
await upsertWorkspacePlan({ workspacePlan })
break
case 'cancelationScheduled':
@@ -1,4 +1,4 @@
import type { WorkspacePlan } from '@speckle/shared'
import { WorkspaceFeatureFlags, type WorkspacePlan } from '@speckle/shared'
import cryptoRandomString from 'crypto-random-string'
import { assign } from 'lodash-es'
import type {
@@ -16,7 +16,8 @@ export const buildTestWorkspacePlan = (
createdAt: new Date(),
updatedAt: new Date(),
name: 'free',
status: 'valid'
status: 'valid',
featureFlags: WorkspaceFeatureFlags.none
},
overrides
)
@@ -21,7 +21,7 @@ import {
import { upsertWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces'
import { truncateTables } from '@/test/hooks'
import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces'
import { PaidWorkspacePlans } from '@speckle/shared'
import { PaidWorkspacePlans, WorkspaceFeatureFlags } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
@@ -61,7 +61,8 @@ describe('billing repositories @gatekeeper', () => {
status: 'paymentFailed',
workspaceId,
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
featureFlags: WorkspaceFeatureFlags.none
} as const
await upsertPaidWorkspacePlan({
workspacePlan
@@ -78,6 +79,7 @@ describe('billing repositories @gatekeeper', () => {
status: 'paymentFailed',
createdAt: new Date(),
updatedAt: new Date(),
featureFlags: WorkspaceFeatureFlags.none,
workspaceId
} as const
await upsertPaidWorkspacePlan({
@@ -105,6 +107,7 @@ describe('billing repositories @gatekeeper', () => {
status: 'paymentFailed',
createdAt: createdAt1,
updatedAt: createdAt1,
featureFlags: WorkspaceFeatureFlags.none,
workspaceId: workspace1.id
} as const
await upsertPaidWorkspacePlan({
@@ -118,6 +121,7 @@ describe('billing repositories @gatekeeper', () => {
status: 'paymentFailed',
createdAt: createdAt2,
updatedAt: createdAt2,
featureFlags: WorkspaceFeatureFlags.none,
workspaceId: workspace2.id
} as const
await upsertPaidWorkspacePlan({
@@ -144,6 +148,7 @@ describe('billing repositories @gatekeeper', () => {
status: 'paymentFailed',
createdAt: createdAt1,
updatedAt: createdAt1,
featureFlags: WorkspaceFeatureFlags.none,
workspaceId: workspace1.id
} as const
await upsertPaidWorkspacePlan({
@@ -157,6 +162,7 @@ describe('billing repositories @gatekeeper', () => {
status: 'paymentFailed',
createdAt: createdAt2,
updatedAt: createdAt2,
featureFlags: WorkspaceFeatureFlags.none,
workspaceId: workspace2.id
} as const
await upsertPaidWorkspacePlan({
@@ -180,6 +186,7 @@ describe('billing repositories @gatekeeper', () => {
status: 'paymentFailed',
createdAt: createdAt1,
updatedAt: createdAt1,
featureFlags: WorkspaceFeatureFlags.none,
workspaceId: workspace1.id
} as const
await upsertPaidWorkspacePlan({
@@ -193,6 +200,7 @@ describe('billing repositories @gatekeeper', () => {
status: 'valid',
createdAt: createdAt2,
updatedAt: createdAt2,
featureFlags: WorkspaceFeatureFlags.none,
workspaceId: workspace2.id
} as const
await upsertPaidWorkspacePlan({
@@ -216,6 +224,7 @@ describe('billing repositories @gatekeeper', () => {
status: 'valid',
createdAt: createdAt1,
updatedAt: createdAt1,
featureFlags: WorkspaceFeatureFlags.none,
workspaceId: workspace1.id
} as const
await upsertPaidWorkspacePlan({
@@ -229,6 +238,7 @@ describe('billing repositories @gatekeeper', () => {
status: 'valid',
createdAt: createdAt2,
updatedAt: createdAt2,
featureFlags: WorkspaceFeatureFlags.none,
workspaceId: workspace2.id
} as const
await upsertPaidWorkspacePlan({
@@ -462,7 +472,8 @@ describe('billing repositories @gatekeeper', () => {
name: PaidWorkspacePlans.Team,
status: 'valid',
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
featureFlags: WorkspaceFeatureFlags.none
}
})
await upsertWorkspaceSubscription({
@@ -14,6 +14,7 @@ import { createTestUser } from '@/test/authHelper'
import type { WorkspacePlan } from '@speckle/shared'
import { PaidWorkspacePlans, PaidWorkspacePlanStatuses } from '@speckle/shared'
import { expect } from 'chai'
import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan'
const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
@@ -25,7 +26,6 @@ describe('Module @gatekeeper', () => {
'Repositories WorkspacePlan',
() => {
let user: BasicTestUser
let now: Date
let in5days: Date
let workspace1: BasicTestWorkspace
let workspace2: BasicTestWorkspace
@@ -34,7 +34,6 @@ describe('Module @gatekeeper', () => {
let plan2: WorkspacePlan
before(async () => {
now = new Date()
user = await createTestUser()
workspace1 = buildBasicTestWorkspace({ ownerId: user.id })
workspace2 = buildBasicTestWorkspace({ ownerId: user.id })
@@ -43,21 +42,8 @@ describe('Module @gatekeeper', () => {
await createTestWorkspace(workspace2, user)
await createTestWorkspace(workspaceWithoutPlan, user)
plan1 = {
workspaceId: workspace1.id,
name: PaidWorkspacePlans.Team,
createdAt: now,
updatedAt: now,
status: PaidWorkspacePlanStatuses.Valid
}
plan2 = {
workspaceId: workspace2.id,
name: PaidWorkspacePlans.Team,
createdAt: now,
updatedAt: now,
status: PaidWorkspacePlanStatuses.Valid
}
plan1 = buildTestWorkspacePlan({ workspaceId: workspace1.id })
plan2 = buildTestWorkspacePlan({ workspaceId: workspace2.id })
await upsertWorkspacePlan({
workspacePlan: plan1
@@ -86,13 +72,10 @@ describe('Module @gatekeeper', () => {
describe('upsertWorkspacePlan should return a function, that', () => {
it('inserts a workspace plan if it does not exist', async () => {
await upsertWorkspacePlan({
workspacePlan: {
workspacePlan: buildTestWorkspacePlan({
workspaceId: workspaceWithoutPlan.id,
name: PaidWorkspacePlans.Team,
createdAt: now,
updatedAt: now,
status: PaidWorkspacePlanStatuses.Valid
}
name: PaidWorkspacePlans.Team
})
})
const result = (
@@ -109,17 +92,38 @@ describe('Module @gatekeeper', () => {
in5days = new Date()
in5days.setDate(in5days.getDate() + 7)
const now = new Date()
const workspacePlan = buildTestWorkspacePlan({
workspaceId: workspace2.id,
name: PaidWorkspacePlans.Pro,
updatedAt: now
})
await upsertWorkspacePlan({
workspacePlan
})
let result = (
await getWorkspacePlansByWorkspaceId({
workspaceIds: [workspace2.id]
})
)[workspace2.id]
expect(result.workspaceId).to.equal(workspace2.id)
expect(result.name).to.equal(PaidWorkspacePlans.Pro)
expect(result.updatedAt).to.be.deep.eq(now)
await upsertWorkspacePlan({
workspacePlan: {
workspaceId: workspace2.id,
...workspacePlan,
name: PaidWorkspacePlans.ProUnlimited,
createdAt: in5days,
updatedAt: in5days,
status: PaidWorkspacePlanStatuses.Canceled
}
})
const result = (
result = (
await getWorkspacePlansByWorkspaceId({
workspaceIds: [workspace2.id]
})
@@ -158,7 +158,8 @@ describe('checkout @gatekeeper', () => {
expect(omit(storedWorkspacePlan, 'createdAt', 'updatedAt')).to.deep.equal({
workspaceId,
name: storedCheckoutSession.workspacePlan,
status: 'valid'
status: 'valid',
featureFlags: 0
})
expect(emittedEventName).to.equal('gatekeeper.workspace-subscription-updated')
expect(emittedEventPayload).to.nested.include({
@@ -236,13 +237,12 @@ describe('checkout @gatekeeper', () => {
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'team',
status: 'valid',
createdAt: new Date(),
updatedAt: new Date(),
workspaceId
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'team',
status: 'valid',
workspaceId
}),
getWorkspaceCheckoutSession: () => {
expect.fail()
},
@@ -275,13 +275,12 @@ describe('checkout @gatekeeper', () => {
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'team',
status: 'paymentFailed',
createdAt: new Date(),
updatedAt: new Date(),
workspaceId
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'team',
status: 'paymentFailed',
workspaceId
}),
getWorkspaceCheckoutSession: () => {
expect.fail()
},
@@ -314,13 +313,12 @@ describe('checkout @gatekeeper', () => {
const userId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'free',
status: 'valid',
createdAt: new Date(),
updatedAt: new Date(),
workspaceId
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'free',
status: 'valid',
workspaceId
}),
getWorkspaceCheckoutSession: async () => ({
billingInterval: 'monthly',
id: cryptoRandomString({ length: 10 }),
@@ -380,13 +378,12 @@ describe('checkout @gatekeeper', () => {
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
workspaceId,
name: 'free',
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'free',
status: 'valid'
}),
getWorkspaceCheckoutSession: async () => null,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: () => {
@@ -440,13 +437,12 @@ describe('checkout @gatekeeper', () => {
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
workspaceId,
name: 'free',
status: 'valid',
createdAt: new Date(),
updatedAt: new Date()
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'free',
status: 'valid'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: async () => {
@@ -489,13 +485,12 @@ describe('checkout @gatekeeper', () => {
}
const err = await expectToThrow(async () => {
await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
workspaceId,
name: 'free',
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'free',
status: 'valid'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: async () => {
@@ -549,13 +544,12 @@ describe('checkout @gatekeeper', () => {
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'team',
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'canceled'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'team',
workspaceId,
status: 'canceled'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countSeatsByTypeInWorkspace: async () => 1,
deleteCheckoutSession: async () => {
@@ -2,6 +2,7 @@ import { canWorkspaceAccessFeatureFactory } from '@/modules/gatekeeper/services/
import { PaidWorkspacePlans, WorkspacePlanFeatures } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan'
describe('featureAuthorization @gatekeeper', () => {
describe('canWorkspaceAccessFeatureFactory creates a function, that', () => {
@@ -34,13 +35,12 @@ describe('featureAuthorization @gatekeeper', () => {
it(`returns ${expectedResult} for ${plan} @ ${status} for ${workspaceFeature}`, async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const canWorkspaceAccessFeature = canWorkspaceAccessFeatureFactory({
getWorkspacePlan: async () => ({
name: plan,
status,
workspaceId,
createdAt: new Date(),
updatedAt: new Date()
})
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: plan,
status,
workspaceId
})
})
const result = await canWorkspaceAccessFeature({
workspaceId,
@@ -32,6 +32,7 @@ import { upgradeWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/servic
import type { EventBusEmit } from '@/modules/shared/services/eventBus'
import { testLogger } from '@/observability/logging'
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
import { buildTestWorkspacePlan } from '@/modules/gatekeeper/tests/helpers/workspacePlan'
describe('subscriptions @gatekeeper', () => {
describe('handleSubscriptionUpdateFactory creates a function, that', () => {
@@ -106,13 +107,12 @@ describe('subscriptions @gatekeeper', () => {
subscriptionData,
workspaceId
}),
getWorkspacePlan: async () => ({
name,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name,
workspaceId,
status: 'valid'
}),
upsertWorkspaceSubscription: async () => {
expect.fail()
},
@@ -149,13 +149,12 @@ describe('subscriptions @gatekeeper', () => {
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({
name: PaidWorkspacePlans.Team,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: PaidWorkspacePlans.Team,
workspaceId,
status: 'valid'
}),
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedSubscription = workspaceSubscription
},
@@ -210,13 +209,12 @@ describe('subscriptions @gatekeeper', () => {
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({
name: PaidWorkspacePlans.Team,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'paymentFailed'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: PaidWorkspacePlans.Team,
workspaceId,
status: 'paymentFailed'
}),
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedSubscription = workspaceSubscription
},
@@ -290,13 +288,12 @@ describe('subscriptions @gatekeeper', () => {
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({
name: PaidWorkspacePlans.Team,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'paymentFailed'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: PaidWorkspacePlans.Team,
workspaceId,
status: 'paymentFailed'
}),
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedSubscription = workspaceSubscription
},
@@ -350,13 +347,12 @@ describe('subscriptions @gatekeeper', () => {
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({
name: PaidWorkspacePlans.Team,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: PaidWorkspacePlans.Team,
workspaceId,
status: 'valid'
}),
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedSubscription = workspaceSubscription
},
@@ -410,13 +406,12 @@ describe('subscriptions @gatekeeper', () => {
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({
name: PaidWorkspacePlans.Team,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: PaidWorkspacePlans.Team,
workspaceId,
status: 'valid'
}),
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedSubscription = workspaceSubscription
},
@@ -459,13 +454,12 @@ describe('subscriptions @gatekeeper', () => {
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({
name: PaidWorkspacePlans.Team,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: PaidWorkspacePlans.Team,
workspaceId,
status: 'valid'
}),
upsertWorkspaceSubscription: async () => {
expect.fail()
},
@@ -514,13 +508,12 @@ describe('subscriptions @gatekeeper', () => {
const updatedByUserId = cryptoRandomString({ length: 10 })
const addWorkspaceSubscriptionSeatIfNeeded =
addWorkspaceSubscriptionSeatIfNeededFactory({
getWorkspacePlan: async () => ({
name: 'free',
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'free',
workspaceId,
status: 'valid'
}),
getWorkspaceSubscription: async () => null,
getWorkspacePlanPriceId: () => {
expect.fail()
@@ -551,13 +544,12 @@ describe('subscriptions @gatekeeper', () => {
})
const addWorkspaceSubscriptionSeatIfNeeded =
addWorkspaceSubscriptionSeatIfNeededFactory({
getWorkspacePlan: async () => ({
name: 'free',
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'free',
workspaceId,
status: 'valid'
}),
getWorkspaceSubscription: async () => workspaceSubscription,
getWorkspacePlanPriceId: () => {
expect.fail()
@@ -590,13 +582,12 @@ describe('subscriptions @gatekeeper', () => {
})
const addWorkspaceSubscriptionSeatIfNeeded =
addWorkspaceSubscriptionSeatIfNeededFactory({
getWorkspacePlan: async () => ({
name: 'team',
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'canceled'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'team',
workspaceId,
status: 'canceled'
}),
getWorkspaceSubscription: async () => workspaceSubscription,
getWorkspacePlanPriceId: () => {
expect.fail()
@@ -624,13 +615,11 @@ describe('subscriptions @gatekeeper', () => {
workspaceId,
subscriptionData
})
const workspacePlan: WorkspacePlan = {
const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({
name: 'team',
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}
})
const priceId = cryptoRandomString({ length: 10 })
const productId = cryptoRandomString({ length: 10 })
const roleCount = 10
@@ -699,13 +688,11 @@ describe('subscriptions @gatekeeper', () => {
workspaceId,
subscriptionData
})
const workspacePlan: WorkspacePlan = {
const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({
name: 'team',
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}
})
const roleCount = 10
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
@@ -769,13 +756,11 @@ describe('subscriptions @gatekeeper', () => {
workspaceId,
subscriptionData
})
const workspacePlan: WorkspacePlan = {
const workspacePlan: WorkspacePlan = buildTestWorkspacePlan({
name: 'team',
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}
})
const count = 1
const addWorkspaceSubscriptionSeatIfNeeded =
@@ -931,13 +916,12 @@ describe('subscriptions @gatekeeper', () => {
workspaceId
})
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
name: 'unlimited',
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'unlimited',
workspaceId,
status: 'valid'
}),
countSeatsByTypeInWorkspace: async () => {
expect.fail()
},
@@ -961,13 +945,12 @@ describe('subscriptions @gatekeeper', () => {
workspaceId
})
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
name: PaidWorkspacePlans.Team,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'canceled'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: PaidWorkspacePlans.Team,
workspaceId,
status: 'canceled'
}),
countSeatsByTypeInWorkspace: async () => {
expect.fail()
},
@@ -998,13 +981,12 @@ describe('subscriptions @gatekeeper', () => {
})
const workspacePlanName = PaidWorkspacePlans.Team
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
name: workspacePlanName,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: workspacePlanName,
workspaceId,
status: 'valid'
}),
countSeatsByTypeInWorkspace: async ({ type }) => {
return type === WorkspaceSeatType.Viewer ? 0 : quantity
},
@@ -1043,13 +1025,12 @@ describe('subscriptions @gatekeeper', () => {
let reconciledSub: SubscriptionDataInput | undefined = undefined
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
name: workspacePlanName,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: workspacePlanName,
workspaceId,
status: 'valid'
}),
countSeatsByTypeInWorkspace: async ({ type }) => {
return type === WorkspaceSeatType.Viewer ? 0 : editorQty / 2
},
@@ -1098,13 +1079,12 @@ describe('subscriptions @gatekeeper', () => {
workspaceId
})
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
name: 'unlimited',
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'unlimited',
workspaceId,
status: 'valid'
}),
countSeatsByTypeInWorkspace: async () => {
expect.fail()
},
@@ -1128,13 +1108,12 @@ describe('subscriptions @gatekeeper', () => {
workspaceId
})
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
name: 'pro',
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'canceled'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: 'pro',
workspaceId,
status: 'canceled'
}),
countSeatsByTypeInWorkspace: async () => {
expect.fail()
},
@@ -1165,13 +1144,12 @@ describe('subscriptions @gatekeeper', () => {
})
const workspacePlanName = 'pro'
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
name: workspacePlanName,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: workspacePlanName,
workspaceId,
status: 'valid'
}),
countSeatsByTypeInWorkspace: async () => {
return 10
},
@@ -1211,13 +1189,12 @@ describe('subscriptions @gatekeeper', () => {
let reconciledSub: SubscriptionDataInput | undefined = undefined
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
name: workspacePlanName,
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: workspacePlanName,
workspaceId,
status: 'valid'
}),
countSeatsByTypeInWorkspace: async () => {
return 5
},
@@ -1280,13 +1257,12 @@ describe('subscriptions @gatekeeper', () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
createdAt: new Date(),
updatedAt: new Date(),
name: plan,
status: 'valid',
workspaceId
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
name: plan,
status: 'valid',
workspaceId
}),
getWorkspacePlanProductId: () => {
expect.fail()
},
@@ -1325,13 +1301,12 @@ describe('subscriptions @gatekeeper', () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
name: plan,
status
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: plan,
status
}),
getWorkspacePlanProductId: () => {
expect.fail()
},
@@ -1369,13 +1344,12 @@ describe('subscriptions @gatekeeper', () => {
const workspaceId = cryptoRandomString({ length: 10 })
const userId = cryptoRandomString({ length: 10 })
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
name: 'team',
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'team',
status: 'valid'
}),
getWorkspacePlanProductId: () => {
expect.fail()
},
@@ -1412,13 +1386,12 @@ describe('subscriptions @gatekeeper', () => {
const userId = cryptoRandomString({ length: 10 })
const workspaceSubscription = createTestWorkspaceSubscription()
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
name: 'pro',
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'pro',
status: 'valid'
}),
getWorkspacePlanProductId: () => {
expect.fail()
},
@@ -1457,13 +1430,12 @@ describe('subscriptions @gatekeeper', () => {
billingInterval: 'yearly'
})
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
name: 'team',
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'team',
status: 'valid'
}),
getWorkspacePlanProductId: () => {
expect.fail()
},
@@ -1501,13 +1473,12 @@ describe('subscriptions @gatekeeper', () => {
billingInterval: 'monthly'
})
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
name: 'team',
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'team',
status: 'valid'
}),
getWorkspacePlanProductId: () => {
expect.fail()
},
@@ -1553,13 +1524,12 @@ describe('subscriptions @gatekeeper', () => {
subscriptionData
})
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
name: 'team',
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'team',
status: 'valid'
}),
getWorkspacePlanProductId: () => {
return cryptoRandomString({ length: 10 })
},
@@ -1616,13 +1586,12 @@ describe('subscriptions @gatekeeper', () => {
let reconciledSubscriptionData: SubscriptionDataInput | undefined = undefined
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
const upgradeWorkspaceSubscription = upgradeWorkspaceSubscriptionFactory({
getWorkspacePlan: async () => ({
workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
name: 'team',
status: 'valid'
}),
getWorkspacePlan: async () =>
buildTestWorkspacePlan({
workspaceId,
name: 'team',
status: 'valid'
}),
getWorkspacePlanProductId: ({ workspacePlan }) => {
switch (workspacePlan) {
case 'team':
@@ -9,6 +9,7 @@ import {
PaidWorkspacePlans,
PaidWorkspacePlanStatuses,
UnpaidWorkspacePlans,
WorkspaceFeatureFlags,
WorkspacePlans
} from '@speckle/shared'
import { expect } from 'chai'
@@ -215,7 +216,12 @@ describe('workspacePlan services @gatekeeper', () => {
//@ts-expect-error we need to test the runtime error checks too
status
})
const expectedPlan = { workspaceId, name: planName, status }
const expectedPlan = {
workspaceId,
name: planName,
status,
featureFlags: WorkspaceFeatureFlags.none
}
expect(omit(storedWorkspacePlan, 'createdAt', 'updatedAt')).to.deep.equal(
expectedPlan
)
@@ -33,7 +33,12 @@ import type { EventBusEmit, EventPayload } from '@/modules/shared/services/event
import { getEventBus } from '@/modules/shared/services/eventBus'
import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants'
import type { MaybeNullOrUndefined, StreamRoles, WorkspaceRoles } from '@speckle/shared'
import { isPaidPlan, Roles, throwUncoveredError } from '@speckle/shared'
import {
isPaidPlan,
Roles,
throwUncoveredError,
WorkspaceFeatureFlags
} from '@speckle/shared'
import type {
QueryAllProjects,
UpsertProjectRole
@@ -965,7 +970,8 @@ export const initializeEventListenersFactory =
status: WorkspacePlanStatuses.Valid,
workspaceId: payload.workspace.id,
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
featureFlags: WorkspaceFeatureFlags.none
}
await upsertUnpaidWorkspacePlanFactory({ db })({ workspacePlan })
await eventBus.emit({
@@ -114,6 +114,7 @@ import {
import type { WorkspaceRoles } from '@speckle/shared'
import {
Roles,
WorkspaceFeatureFlags,
WorkspacePlanFeatures,
WorkspacePlans,
removeNullOrUndefinedKeys,
@@ -229,6 +230,7 @@ import {
import { WorkspaceInvitesLimit } from '@/modules/workspaces/domain/constants'
import { copyWorkspaceFactory } from '@/modules/workspaces/repositories/projectRegions'
import { queryAllProjectsFactory } from '@/modules/core/services/projects'
import { WorkspacePlanNotFoundError } from '@/modules/gatekeeper/errors/billing'
const eventBus = getEventBus()
const getServerInfo = getServerInfoFactory({ db })
@@ -607,6 +609,42 @@ export default FF_WORKSPACES_MODULE_ENABLED
operationDescription: 'Update workspace plan'
}
)
return true
},
giveAccessToWorkspaceFeature: async (_parent, { input }, ctx) => {
const { workspaceId, featureFlagName } = input
const userId = ctx.userId
if (!userId) throw new UnauthorizedError()
const featureFlag = WorkspaceFeatureFlags[featureFlagName]
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
workspacePlan.featureFlags |= featureFlag
// not updating updatedAt here deliberately. Feature flags are internal for now
await upsertWorkspacePlanFactory({ db })({
workspacePlan
})
return true
},
removeAccessToWorkspaceFeature: async (_parent, { input }, ctx) => {
const { workspaceId, featureFlagName } = input
const userId = ctx.userId
if (!userId) throw new UnauthorizedError()
const featureFlag = WorkspaceFeatureFlags[featureFlagName]
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
workspacePlan.featureFlags ^= featureFlag
// not updating updatedAt here deliberately. Feature flags are internal for now
await upsertWorkspacePlanFactory({ db })({
workspacePlan
})
return true
}
},
@@ -0,0 +1,13 @@
import { type Knex } from 'knex'
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('workspace_plans', (table) => {
table.integer('featureFlags').notNullable().defaultTo(0)
})
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('workspace_plans', (table) => {
table.dropColumn('featureFlags')
})
}
@@ -233,15 +233,7 @@ describe('ensureUserIsWorkspaceAdminFragment', () => {
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getWorkspacePlan: async () => {
return {
workspaceId,
name: 'unlimited' as const,
status: 'valid' as const,
createdAt: new Date(),
updatedAt: new Date()
}
},
getWorkspacePlan: getWorkspacePlanFake({ workspaceId }),
...overrides
})
}
@@ -12,7 +12,11 @@ import {
WorkspaceProjectMoveInvalidError,
WorkspacesNotEnabledError
} from '../../domain/authErrors.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import {
getProjectFake,
getWorkspaceFake,
getWorkspacePlanFake
} from '../../../tests/fakes.js'
const buildCanMoveToWorkspace = (
overrides?: Partial<Parameters<typeof canMoveToWorkspacePolicy>[0]>
@@ -42,15 +46,7 @@ const buildCanMoveToWorkspace = (
getWorkspaceSsoSession: async () => {
assert.fail()
},
getWorkspacePlan: async () => {
return {
status: 'valid',
workspaceId: 'workspace-id',
createdAt: new Date(),
updatedAt: new Date(),
name: 'team'
}
},
getWorkspacePlan: getWorkspacePlanFake({ workspaceId: 'workspace-id' }),
getWorkspaceLimits: async () => {
return {
modelCount: 5,
@@ -3,13 +3,18 @@ import { Roles } from '../../../core/constants.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { OverridesOf } from '../../../tests/helpers/types.js'
import { canReadAccIntegrationSettingsPolicy } from './canReadAccIntegrationSettings.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import {
getProjectFake,
getWorkspaceFake,
getWorkspacePlanFake
} from '../../../tests/fakes.js'
import { assert, describe, expect, it } from 'vitest'
import {
AccIntegrationNotEnabledError,
ProjectNoAccessError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { WorkspaceFeatureFlags } from '../../../workspaces/index.js'
const buildSUT = (
overrides?: OverridesOf<typeof canReadAccIntegrationSettingsPolicy>
@@ -43,15 +48,7 @@ const buildSUT = (
getWorkspaceSsoSession: async () => {
assert.fail()
},
getWorkspacePlan: async () => {
return {
status: 'valid',
workspaceId,
name: 'enterprise',
createdAt: new Date(),
updatedAt: new Date()
}
},
getWorkspacePlan: getWorkspacePlanFake({ workspaceId, name: 'enterprise' }),
...overrides
})
}
@@ -108,7 +105,8 @@ describe('canReadAccIntegrationSettings returns a function, that', () => {
workspaceId: cryptoRandomString({ length: 9 }),
name: 'free',
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
featureFlags: WorkspaceFeatureFlags.none
}
}
})(buildArgs())
@@ -1,7 +1,11 @@
import cryptoRandomString from 'crypto-random-string'
import { Roles } from '../../../core/constants.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { getProjectFake, getWorkspaceFake } from '../../../tests/fakes.js'
import {
getProjectFake,
getWorkspaceFake,
getWorkspacePlanFake
} from '../../../tests/fakes.js'
import { canUpdateEmbedTokensPolicy } from './canUpdateEmbedTokens.js'
import { assert, describe, expect, it } from 'vitest'
import {
@@ -10,6 +14,7 @@ import {
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { OverridesOf } from '../../../tests/helpers/types.js'
import { WorkspaceFeatureFlags } from '../../../workspaces/index.js'
const buildCanUpdateEmbedTokens = (
overrides?: OverridesOf<typeof canUpdateEmbedTokensPolicy>
@@ -40,15 +45,7 @@ const buildCanUpdateEmbedTokens = (
getWorkspaceSsoSession: async () => {
assert.fail()
},
getWorkspacePlan: async () => {
return {
status: 'valid',
workspaceId,
name: 'unlimited',
createdAt: new Date(),
updatedAt: new Date()
}
},
getWorkspacePlan: getWorkspacePlanFake({ workspaceId }),
...overrides
})
}
@@ -118,7 +115,8 @@ describe('canUpdateEmbedTokensArgs returns a function, that', () => {
workspaceId: 'foo',
name: 'free',
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
featureFlags: WorkspaceFeatureFlags.none
}
}
})(canUpdateEmbedTokensArgs())
@@ -11,7 +11,7 @@ import {
WorkspacesNotEnabledError
} from '../../domain/authErrors.js'
import { canReadMemberEmailPolicy } from './canReadMemberEmail.js'
import { getWorkspaceFake } from '../../../tests/fakes.js'
import { getWorkspaceFake, getWorkspacePlanFake } from '../../../tests/fakes.js'
describe('canReadMemberEmailPolicy', () => {
const workspaceId = cryptoRandomString({ length: 9 })
@@ -32,15 +32,7 @@ describe('canReadMemberEmailPolicy', () => {
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getWorkspacePlan: async () => {
return {
workspaceId,
name: 'unlimited' as const,
status: 'valid' as const,
createdAt: new Date(),
updatedAt: new Date()
}
},
getWorkspacePlan: getWorkspacePlanFake({ workspaceId }),
...overrides
})
}
@@ -3,7 +3,10 @@ import { Roles } from '../../../core/constants.js'
import { parseFeatureFlags } from '../../../environment/index.js'
import { Workspace } from '../../domain/workspaces/types.js'
import { canUseWorkspacePlanFeature } from './canUseWorkspacePlanFeature.js'
import { WorkspacePlanFeatures } from '../../../workspaces/index.js'
import {
WorkspaceFeatureFlags,
WorkspacePlanFeatures
} from '../../../workspaces/index.js'
import { describe, expect, it } from 'vitest'
import {
ServerNoAccessError,
@@ -14,6 +17,7 @@ import {
WorkspaceNotEnoughPermissionsError,
WorkspaceReadOnlyError
} from '../../domain/authErrors.js'
import { getWorkspacePlanFake } from '../../../tests/fakes.js'
const buildSUT = (
overrides?: Partial<Parameters<typeof canUseWorkspacePlanFeature>[0]>
@@ -35,15 +39,7 @@ const buildSUT = (
getWorkspaceRole: async () => Roles.Workspace.Admin,
getWorkspaceSsoProvider: async () => null,
getWorkspaceSsoSession: async () => null,
getWorkspacePlan: async () => {
return {
workspaceId,
name: 'unlimited',
status: 'valid',
createdAt: new Date(),
updatedAt: new Date()
}
},
getWorkspacePlan: getWorkspacePlanFake({ workspaceId, name: 'unlimited' }),
...overrides
})
}
@@ -123,7 +119,8 @@ describe('canUseFeature', () => {
name: 'proUnlimited',
status: 'canceled',
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
featureFlags: WorkspaceFeatureFlags.none
})
})
@@ -141,7 +138,8 @@ describe('canUseFeature', () => {
name: 'free',
status: 'valid',
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
featureFlags: WorkspaceFeatureFlags.none
})
})
+3 -2
View File
@@ -11,7 +11,7 @@ import {
} from '../authz/domain/workspaces/types.js'
import { parseFeatureFlags } from '../environment/index.js'
import { mapValues } from 'lodash'
import { WorkspacePlan } from '../workspaces/index.js'
import { WorkspaceFeatureFlags, WorkspacePlan } from '../workspaces/index.js'
import { TIME_MS } from '../core/index.js'
import {
SavedView,
@@ -49,7 +49,8 @@ export const getWorkspacePlanFake = fakeGetFactory<WorkspacePlan>(() => ({
status: 'valid',
workspaceId: nanoid(10),
createdAt: new Date(Date.now() - TIME_MS.day),
updatedAt: new Date(Date.now() - TIME_MS.day)
updatedAt: new Date(Date.now() - TIME_MS.day),
featureFlags: WorkspaceFeatureFlags.none
}))
export const getWorkspaceSsoProviderFake = fakeGetFactory<WorkspaceSsoProvider>(() => ({
@@ -2,31 +2,98 @@ import { describe, expect, it } from 'vitest'
import {
workspacePlanHasAccessToFeature,
WorkspacePlanFeatures,
WorkspacePlanConfigs
WorkspacePlanConfigs,
isWorkspaceFeatureFlagOn,
WorkspaceFeatureFlags
} from './features.js'
import { WorkspacePlans } from './plans.js'
describe('workspacePlanHasAccessToFeature', () => {
describe('Comprehensive feature coverage', () => {
const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[]
const allFeatures = Object.values(WorkspacePlanFeatures) as WorkspacePlanFeatures[]
describe('workspace features', () => {
describe('workspacePlanHasAccessToFeature', () => {
describe('Comprehensive feature coverage', () => {
const allPlans = Object.values(WorkspacePlans) as WorkspacePlans[]
const allFeatures = Object.values(
WorkspacePlanFeatures
) as WorkspacePlanFeatures[]
describe.each(allPlans)('should work for %s plan', (plan) => {
it.each(allFeatures)('%s feature combination', (feature) => {
const expectedResult = WorkspacePlanConfigs({ featureFlags: undefined })[
plan
].features.includes(feature)
const actualResult = workspacePlanHasAccessToFeature({
plan,
feature,
featureFlags: undefined
describe.each(allPlans)('should work for %s plan', (plan) => {
it.each(allFeatures)('%s feature combination', (feature) => {
const expectedResult = WorkspacePlanConfigs({ featureFlags: undefined })[
plan
].features.includes(feature)
const actualResult = workspacePlanHasAccessToFeature({
plan,
feature,
featureFlags: undefined
})
expect(
actualResult,
`Plan ${plan} feature ${feature} access should be ${expectedResult}`
).toBe(expectedResult)
})
expect(
actualResult,
`Plan ${plan} feature ${feature} access should be ${expectedResult}`
).toBe(expectedResult)
})
})
})
describe('isWorkspaceFeatureFlagOn', () => {
it('returns false if no flags are on', () => {
const workspaceFeatureFlags = WorkspaceFeatureFlags.none
const feature = WorkspaceFeatureFlags.dashboards
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
expect(result).toBe(false)
})
it('returns false if the currently tested flag is off', () => {
const workspaceFeatureFlags = WorkspaceFeatureFlags.accIntegration
const feature = WorkspaceFeatureFlags.dashboards
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
expect(result).toBe(false)
})
it('returns true if the currently tested flag is on', () => {
const workspaceFeatureFlags = WorkspaceFeatureFlags.dashboards
const feature = WorkspaceFeatureFlags.dashboards
const result = isWorkspaceFeatureFlagOn({ workspaceFeatureFlags, feature })
expect(result).toBe(true)
})
it('returns true if the currently tested flag is on in a combo flag', () => {
const workspaceFeatureFlags =
WorkspaceFeatureFlags.dashboards | WorkspaceFeatureFlags.accIntegration
let result = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags,
feature: WorkspaceFeatureFlags.dashboards
})
expect(result).toBe(true)
result = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags,
feature: WorkspaceFeatureFlags.accIntegration
})
expect(result).toBe(true)
})
it('feature flag can be turned on and off', () => {
let workspaceFeatureFlags = WorkspaceFeatureFlags.none
let result = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags,
feature: WorkspaceFeatureFlags.dashboards
})
expect(result).toBe(false)
workspaceFeatureFlags |= WorkspaceFeatureFlags.dashboards
result = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags,
feature: WorkspaceFeatureFlags.dashboards
})
expect(result).toBe(true)
workspaceFeatureFlags ^= WorkspaceFeatureFlags.dashboards
result = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags,
feature: WorkspaceFeatureFlags.dashboards
})
expect(result).toBe(false)
})
})
})
@@ -24,13 +24,37 @@ export const WorkspacePlanFeatures = <const>{
HideSpeckleBranding: 'hideSpeckleBranding',
ExclusiveMembership: 'exclusiveMembership',
EmbedPrivateProjects: 'embedPrivateProjects',
AccIntegration: 'accIntegration',
AccIntegration: 'accIntegration', // TODO: this should be moved to a workspace addon
SavedViews: 'savedViews'
}
export type WorkspacePlanFeatures =
(typeof WorkspacePlanFeatures)[keyof typeof WorkspacePlanFeatures]
// this const will be used as a bitwise flag for a per workspace feature access controller
// IMPORTANT: always use powers of 2 as the value of the object
// read more https://www.hendrik-erz.de/post/bitwise-flags-are-beautiful-and-heres-why
// this will make its way to the pricing plan and info setup at some point
// but for now its an internal only control
export const WorkspaceFeatureFlags = <const>{
none: 0,
dashboards: 1,
accIntegration: 2
}
export type WorkspaceFeatureFlags =
(typeof WorkspaceFeatureFlags)[keyof typeof WorkspaceFeatureFlags]
export const isWorkspaceFeatureFlagOn = ({
workspaceFeatureFlags,
feature
}: {
workspaceFeatureFlags: number
feature: WorkspaceFeatureFlags
}): boolean => (workspaceFeatureFlags & feature) === feature
export type WorkspaceFeatures = WorkspacePlanFeatures | WorkspaceFeatureFlags
export const WorkspacePlanFeaturesMetadata = (<const>{
[WorkspacePlanFeatures.AutomateBeta]: {
displayName: 'Automate beta access',
@@ -314,3 +338,12 @@ export const workspacePlanHasAccessToFeature = ({
const hasAccess = planConfig.features.includes(feature)
return hasAccess
}
export const isPlanFeature = (
feature: WorkspaceFeatures
): feature is WorkspacePlanFeatures => {
if (typeof feature === 'number') {
return false
}
return Object.values(WorkspacePlanFeatures).includes(feature)
}
@@ -136,6 +136,7 @@ type BaseWorkspacePlan = {
workspaceId: string
createdAt: Date
updatedAt: Date
featureFlags: number // this will be a bitwise flag number
}
export type PaidWorkspacePlan = BaseWorkspacePlan & {