Merge main

This commit is contained in:
andrewwallacespeckle
2025-09-01 17:00:16 +01:00
227 changed files with 7401 additions and 5665 deletions
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { isDashboardOwner } from './dashboards.js'
import cryptoRandomString from 'crypto-random-string'
describe('dashboard checks', () => {
describe('isDashboardOwner returns a function, that', () => {
it('returns false for dashboard not found', async () => {
const result = await isDashboardOwner({
getDashboard: async () => null
})({
userId: cryptoRandomString({ length: 9 }),
dashboardId: cryptoRandomString({ length: 9 })
})
expect(result).to.equal(false)
})
it('returns false if user not owner', async () => {
const result = await isDashboardOwner({
getDashboard: async () => ({
id: cryptoRandomString({ length: 9 }),
ownerId: cryptoRandomString({ length: 9 }),
workspaceId: '',
projectIds: []
})
})({
userId: cryptoRandomString({ length: 9 }),
dashboardId: cryptoRandomString({ length: 9 })
})
expect(result).to.equal(false)
})
it('returns true if user is owner', async () => {
const userId = cryptoRandomString({ length: 9 })
const result = await isDashboardOwner({
getDashboard: async () => ({
id: cryptoRandomString({ length: 9 }),
ownerId: userId,
workspaceId: '',
projectIds: []
})
})({
userId,
dashboardId: cryptoRandomString({ length: 9 })
})
expect(result).to.equal(true)
})
})
})
@@ -0,0 +1,14 @@
import { DashboardContext, UserContext } from '../domain/context.js'
import { AuthCheckContextLoaderKeys } from '../domain/loaders.js'
import { AuthPolicyCheck } from '../domain/policies.js'
export const isDashboardOwner: AuthPolicyCheck<
typeof AuthCheckContextLoaderKeys.getDashboard,
UserContext & DashboardContext
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return false
return dashboard.ownerId === userId
}
@@ -221,6 +221,31 @@ export const UngroupedSavedViewGroupLockError = defineAuthError({
message: 'The default/ungrouped group cannot be modified.'
})
export const DashboardsNotEnabledError = defineAuthError({
code: 'DashboardsNotEnabled',
message: 'Dashboards are not enabled for this server or workspaces.'
})
export const DashboardNotFoundError = defineAuthError({
code: 'DashboardNotFound',
message: 'Dashboard not found'
})
export const DashboardProjectsNotEnoughPermissionsError = defineAuthError<
'DashboardProjectsNotEnoughPermissions',
{
projectIds: string[]
}
>({
code: 'DashboardProjectsNotEnoughPermissions',
message: 'You do not have sufficient access to some projects in this workspace.'
})
export const DashboardNotOwnerError = defineAuthError({
code: 'DashboardNotOwner',
message: 'You must be a dashboard owner to perform this action'
})
// Resolve all exported error types
export type AllAuthErrors = ValueOf<{
[key in keyof typeof import('./authErrors.js')]: typeof import('./authErrors.js')[key] extends new (
@@ -7,6 +7,8 @@ export type MaybeUserContext = { userId?: string }
export type WorkspaceContext = { workspaceId: string }
export type MaybeWorkspaceContext = { workspaceId?: string }
export type DashboardContext = { dashboardId: string }
export type CommentContext = { commentId: string }
export type ModelContext = { modelId: string }
@@ -0,0 +1,3 @@
import { Dashboard } from './types.js'
export type GetDashboard = (args: { dashboardId: string }) => Promise<Dashboard | null>
@@ -0,0 +1,6 @@
export type Dashboard = {
id: string
ownerId: string
workspaceId: string
projectIds: string[]
}
@@ -26,6 +26,7 @@ import { GetModel } from './models/operations.js'
import { GetVersion } from './versions/operations.js'
import { GetAutomateFunction } from './automate/operations.js'
import { GetSavedView, GetSavedViewGroup } from './savedViews/operations.js'
import { GetDashboard } from './dashboards/operations.js'
// utility type that ensures all properties functions that return promises
type PromiseAll<T> = {
@@ -58,6 +59,7 @@ type AuthContextLoaderMappingDefinition<
export const AuthCheckContextLoaderKeys = StringEnum([
'getEnv',
'getAutomateFunction',
'getDashboard',
'getProject',
'getProjectRoleCounts',
'getProjectRole',
@@ -92,6 +94,7 @@ export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
getEnv: GetEnv
getAdminOverrideEnabled: GetAdminOverrideEnabled
getAutomateFunction: GetAutomateFunction
getDashboard: GetDashboard
getProject: GetProject
getProjectRole: GetProjectRole
getProjectRoleCounts: GetProjectRoleCounts
@@ -0,0 +1,90 @@
import { err, ok } from 'true-myth/result'
import {
DashboardNotFoundError,
DashboardProjectsNotEnoughPermissionsError,
DashboardsNotEnabledError,
WorkspacePlanNoFeatureAccessError
} from '../domain/authErrors.js'
import { Loaders } from '../domain/loaders.js'
import { AuthPolicyEnsureFragment } from '../domain/policies.js'
import { DashboardContext, UserContext, WorkspaceContext } from '../domain/context.js'
import {
isWorkspaceFeatureFlagOn,
WorkspaceFeatureFlags
} from '../../workspaces/index.js'
import { ensureMinimumProjectRoleFragment } from './projects.js'
export const ensureDashboardsEnabledFragment: AuthPolicyEnsureFragment<
typeof Loaders.getEnv,
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
{},
InstanceType<typeof DashboardsNotEnabledError>
> = (loaders) => async () => {
const env = await loaders.getEnv()
if (!env.FF_DASHBOARDS_MODULE_ENABLED) return err(new DashboardsNotEnabledError())
return ok()
}
export const ensureWorkspaceDashboardsFeatureAccessFragment: AuthPolicyEnsureFragment<
typeof Loaders.getWorkspacePlan,
WorkspaceContext,
InstanceType<typeof WorkspacePlanNoFeatureAccessError>
> =
(loaders) =>
async ({ workspaceId }) => {
const plan = await loaders.getWorkspacePlan({ workspaceId })
if (!plan) return err(new WorkspacePlanNoFeatureAccessError())
const isFlagOn = isWorkspaceFeatureFlagOn({
workspaceFeatureFlags: plan.featureFlags,
feature: WorkspaceFeatureFlags.dashboards
})
if (!isFlagOn) return err(new WorkspacePlanNoFeatureAccessError())
return ok()
}
export const ensureDashboardProjectsReadAccess: AuthPolicyEnsureFragment<
| typeof Loaders.getDashboard
| typeof Loaders.getProjectRole
| typeof Loaders.getProject
| typeof Loaders.getServerRole
| typeof Loaders.getEnv
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspaceSsoSession,
DashboardContext & UserContext,
InstanceType<
typeof DashboardNotFoundError | typeof DashboardProjectsNotEnoughPermissionsError
>
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return err(new DashboardNotFoundError())
const allProjectResults: [
string,
Awaited<ReturnType<ReturnType<typeof ensureMinimumProjectRoleFragment>>>
][] = await Promise.all(
dashboard.projectIds.map(async (projectId) => {
return [
projectId,
await ensureMinimumProjectRoleFragment(loaders)({ projectId, userId })
]
})
)
const projectAccessErrors = allProjectResults.filter(([, e]) => e.isErr)
return projectAccessErrors.length
? err(
new DashboardProjectsNotEnoughPermissionsError({
payload: {
projectIds: projectAccessErrors.map(([projectId]) => projectId)
}
})
)
: ok()
}
@@ -977,7 +977,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => {
const result = await sut({
projectId: 'project-id',
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthOKResult()
@@ -990,7 +990,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => {
const result = await sut({
projectId: 'project-id',
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -1008,7 +1008,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => {
const result = await sut({
projectId: 'project-id',
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -1026,7 +1026,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => {
const result = await sut({
projectId: 'project-id',
feature: WorkspacePlanFeatures.SavedViews,
feature: WorkspacePlanFeatures.HideSpeckleBranding,
allowUnworkspaced: true
})
@@ -1044,7 +1044,7 @@ describe('ensureCanUseProjectWorkspacePlanFeatureFragment', () => {
const result = await sut({
projectId: 'project-id',
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -20,8 +20,7 @@ import {
SavedViewNoAccessError,
SavedViewNotFoundError,
UngroupedSavedViewGroupLockError,
WorkspaceNoAccessError,
WorkspacePlanNoFeatureAccessError
WorkspaceNoAccessError
} from '../domain/authErrors.js'
import { nanoid } from 'nanoid'
@@ -191,11 +190,17 @@ describe('ensureCanAccessSavedViewFragment', () => {
)
it.each(<const>['read', 'write'])(
'fails when workspace plan is too cheap (%s)',
'succeeds to %s even on free plan',
async (access) => {
const sut = buildWorkspaceSUT({
getWorkspacePlan: getWorkspacePlanFake({
name: 'team'
name: 'free'
}),
getSavedView: getSavedViewFake({
id: savedViewId,
projectId,
visibility: SavedViewVisibility.public,
authorId: userId
})
})
@@ -205,9 +210,7 @@ describe('ensureCanAccessSavedViewFragment', () => {
savedViewId,
access
})
expect(result).toBeAuthErrorResult({
code: WorkspacePlanNoFeatureAccessError.code
})
expect(result).toBeAuthOKResult()
}
)
@@ -413,27 +416,6 @@ describe('ensureCanAccessSavedViewGroupFragment', () => {
})
})
it.each(<const>['read', 'write'])(
'fails when workspace plan is too cheap (%s)',
async (access) => {
const sut = buildWorkspaceSUT({
getWorkspacePlan: getWorkspacePlanFake({
name: 'team'
})
})
const result = await sut({
userId,
projectId,
savedViewGroupId,
access
})
expect(result).toBeAuthErrorResult({
code: WorkspacePlanNoFeatureAccessError.code
})
}
)
it.each(<const>['read', 'write'])(
'fails if view doesnt exist (%s)',
async (access) => {
@@ -109,7 +109,7 @@ export const ensureCanAccessSavedViewFragment: AuthPolicyEnsureFragment<
return err(
new ProjectNotEnoughPermissionsError({
message:
"Your role on this project doesn't give you permission to update saved views."
"Your role on this project doesn't give you permission to update views."
})
)
return err(ensuredWriteAccess.error)
@@ -122,8 +122,8 @@ export const ensureCanAccessSavedViewFragment: AuthPolicyEnsureFragment<
new SavedViewNoAccessError({
message:
access === 'write'
? 'You do not have write access for this saved view'
: 'You do not have read access for this saved view'
? 'You do not have permission to edit this view'
: 'You do not have read access for this view'
})
)
}
@@ -205,7 +205,7 @@ export const ensureCanAccessSavedViewGroupFragment: AuthPolicyEnsureFragment<
return err(
new ProjectNotEnoughPermissionsError({
message:
"Your role on this project doesn't give you permission to update saved view groups."
"Your role on this project doesn't give you permission to update view groups."
})
)
return err(ensuredWriteAccess.error)
@@ -347,7 +347,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => {
const result = await sut({
workspaceId: cryptoRandomString({ length: 10 }),
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeOKResult()
@@ -362,7 +362,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => {
const result = await sut({
workspaceId: cryptoRandomString({ length: 10 }),
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -380,7 +380,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => {
const result = await sut({
workspaceId: cryptoRandomString({ length: 10 }),
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -395,7 +395,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => {
const result = await sut({
workspaceId: cryptoRandomString({ length: 10 }),
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -413,7 +413,7 @@ describe('ensureCanUseWorkspacePlanFeatureFragment', () => {
const result = await sut({
workspaceId: cryptoRandomString({ length: 10 }),
feature: WorkspacePlanFeatures.SavedViews
feature: WorkspacePlanFeatures.HideSpeckleBranding
})
expect(result).toBeAuthErrorResult({
@@ -0,0 +1,86 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { DashboardContext, MaybeUserContext } from '../../domain/context.js'
import {
DashboardNotFoundError,
DashboardProjectsNotEnoughPermissionsError,
DashboardsNotEnabledError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardProjectsReadAccess,
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
import { hasEditorSeat } from '../../checks/workspaceSeat.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getDashboard
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSeat
| typeof AuthCheckContextLoaderKeys.getProjectRole
| typeof AuthCheckContextLoaderKeys.getProject
| typeof AuthCheckContextLoaderKeys.getServerRole
| typeof AuthCheckContextLoaderKeys.getWorkspace
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession
type PolicyArgs = MaybeUserContext & DashboardContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
| typeof WorkspaceNoEditorSeatError
| typeof DashboardNotFoundError
| typeof DashboardProjectsNotEnoughPermissionsError
>
export const canCreateDashboardTokenPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return err(new DashboardNotFoundError())
const { workspaceId } = dashboard
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceMember = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Member
})
if (!isWorkspaceMember) return err(new WorkspaceNotEnoughPermissionsError())
const isWorkspaceEditorSeat = await hasEditorSeat(loaders)({
userId: userId!,
workspaceId
})
if (!isWorkspaceEditorSeat) return err(new WorkspaceNoEditorSeatError())
const ensuredProjectAccess = await ensureDashboardProjectsReadAccess(loaders)({
userId: userId!,
dashboardId
})
if (ensuredProjectAccess.isErr) return err(ensuredProjectAccess.error)
return ok()
}
@@ -0,0 +1,76 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { DashboardContext, MaybeUserContext } from '../../domain/context.js'
import {
DashboardNotFoundError,
DashboardNotOwnerError,
DashboardsNotEnabledError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
import { hasEditorSeat } from '../../checks/workspaceSeat.js'
import { isDashboardOwner } from '../../checks/dashboards.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getDashboard
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSeat
type PolicyArgs = MaybeUserContext & DashboardContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof DashboardNotOwnerError
| typeof DashboardNotFoundError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
| typeof WorkspaceNoEditorSeatError
>
export const canDeleteDashboardPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return err(new DashboardNotFoundError())
const { workspaceId } = dashboard
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceEditorSeat = await hasEditorSeat(loaders)({
userId: userId!,
workspaceId
})
if (!isWorkspaceEditorSeat) return err(new WorkspaceNoEditorSeatError())
const isWorkspaceAdmin = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Admin
})
const isOwner = await isDashboardOwner(loaders)({ userId: userId!, dashboardId })
if (!isWorkspaceAdmin && !isOwner) return err(new DashboardNotOwnerError())
return ok()
}
@@ -0,0 +1,71 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { MaybeUserContext, DashboardContext } from '../../domain/context.js'
import {
DashboardNotFoundError,
DashboardsNotEnabledError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
import { hasEditorSeat } from '../../checks/workspaceSeat.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getDashboard
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSeat
type PolicyArgs = MaybeUserContext & DashboardContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof DashboardNotFoundError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
| typeof WorkspaceNoEditorSeatError
>
export const canEditDashboardPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return err(new DashboardNotFoundError())
const { workspaceId } = dashboard
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceMember = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Member
})
if (!isWorkspaceMember) return err(new WorkspaceNotEnoughPermissionsError())
const isWorkspaceEditorSeat = await hasEditorSeat(loaders)({
userId: userId!,
workspaceId
})
if (!isWorkspaceEditorSeat) return err(new WorkspaceNoEditorSeatError())
return ok()
}
@@ -0,0 +1,61 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { DashboardContext, MaybeUserContext } from '../../domain/context.js'
import {
DashboardNotFoundError,
DashboardsNotEnabledError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getDashboard
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
type PolicyArgs = MaybeUserContext & DashboardContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof DashboardNotFoundError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
>
export const canReadDashboardPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, dashboardId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const dashboard = await loaders.getDashboard({ dashboardId })
if (!dashboard) return err(new DashboardNotFoundError())
const { workspaceId } = dashboard
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceMember = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Member
})
if (!isWorkspaceMember) return err(new WorkspaceNotEnoughPermissionsError())
return ok()
}
+15 -1
View File
@@ -38,6 +38,12 @@ import { canCreateSavedViewPolicy } from './project/savedViews/canCreate.js'
import { canUpdateSavedViewPolicy } from './project/savedViews/canUpdate.js'
import { canUpdateSavedViewGroupPolicy } from './project/savedViews/canUpdateGroup.js'
import { canReadSavedViewPolicy } from './project/savedViews/canRead.js'
import { canListDashboardsPolicy } from './workspace/canListDashboards.js'
import { canDeleteDashboardPolicy } from './dashboard/canDelete.js'
import { canCreateDashboardsPolicy } from './workspace/canCreateDashboards.js'
import { canCreateDashboardTokenPolicy } from './dashboard/canCreateToken.js'
import { canEditDashboardPolicy } from './dashboard/canEdit.js'
import { canReadDashboardPolicy } from './dashboard/canRead.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
automate: {
@@ -45,6 +51,12 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
canRegenerateToken: canEditFunctionPolicy(loaders)
}
},
dashboard: {
canCreateToken: canCreateDashboardTokenPolicy(loaders),
canDelete: canDeleteDashboardPolicy(loaders),
canEdit: canEditDashboardPolicy(loaders),
canRead: canReadDashboardPolicy(loaders)
},
project: {
automation: {
canCreate: canCreateAutomationPolicy(loaders),
@@ -99,7 +111,9 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
canReceiveWorkspaceProjectsUpdatedMessagePolicy(loaders),
canUseWorkspacePlanFeature: canUseWorkspacePlanFeature(loaders),
canReadMemberEmail: canReadMemberEmailPolicy(loaders),
canCreateWorkspace: canCreateWorkspacePolicy(loaders)
canCreateWorkspace: canCreateWorkspacePolicy(loaders),
canCreateDashboards: canCreateDashboardsPolicy(loaders),
canListDashboards: canListDashboardsPolicy(loaders)
}
})
@@ -14,10 +14,9 @@ import {
ProjectNotEnoughPermissionsError,
ServerNoAccessError,
WorkspaceNoAccessError,
WorkspacePlanNoFeatureAccessError,
WorkspaceReadOnlyError
} from '../../../domain/authErrors.js'
import { PaidWorkspacePlans } from '../../../../workspaces/index.js'
import { WorkspacePlans } from '../../../../workspaces/index.js'
const buildSUT = (overrides?: OverridesOf<typeof canCreateSavedViewPolicy>) =>
canCreateSavedViewPolicy({
@@ -71,7 +70,7 @@ describe('canCreateSavedViewPolicy', () => {
id: 'workspace-id'
}),
getWorkspacePlan: getWorkspacePlanFake({
name: PaidWorkspacePlans.Pro
name: WorkspacePlans.Pro
}),
getWorkspaceSsoProvider: async () => ({
providerId: 'provider-id'
@@ -153,10 +152,10 @@ describe('canCreateSavedViewPolicy', () => {
})
})
it('fails if not on pro/business plan', async () => {
it('succeeds even on free plan', async () => {
const canCreate = buildWorkspaceSUT({
getWorkspacePlan: getWorkspacePlanFake({
name: PaidWorkspacePlans.Team
name: WorkspacePlans.Free
})
})
@@ -164,15 +163,13 @@ describe('canCreateSavedViewPolicy', () => {
userId: 'user-id',
projectId: 'project-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspacePlanNoFeatureAccessError.code
})
expect(result).toBeAuthOKResult()
})
it('fails if workspace readonly', async () => {
const canCreate = buildWorkspaceSUT({
getWorkspacePlan: getWorkspacePlanFake({
name: PaidWorkspacePlans.Pro,
name: WorkspacePlans.Pro,
status: 'canceled'
})
})
@@ -71,7 +71,7 @@ export const canCreateSavedViewPolicy: AuthPolicy<
return err(
new ProjectNotEnoughPermissionsError({
message:
"Your role on this project doesn't give you permission to create saved views. You need the Can edit or Project owner role."
"Your role on this project doesn't give you permission to save views. You need the Can edit or Project owner role."
})
)
return err(ensuredWriteAccess.error)
@@ -0,0 +1,63 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js'
import {
DashboardsNotEnabledError,
WorkspaceNoEditorSeatError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
import { hasEditorSeat } from '../../checks/workspaceSeat.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspaceSeat
type PolicyArgs = MaybeUserContext & WorkspaceContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
| typeof WorkspaceNoEditorSeatError
>
export const canCreateDashboardsPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, workspaceId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceMember = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Member
})
if (!isWorkspaceMember) return err(new WorkspaceNotEnoughPermissionsError())
const isWorkspaceEditorSeat = await hasEditorSeat(loaders)({
userId: userId!,
workspaceId
})
if (!isWorkspaceEditorSeat) return err(new WorkspaceNoEditorSeatError())
return ok()
}
@@ -0,0 +1,53 @@
import { err, ok } from 'true-myth/result'
import { AuthCheckContextLoaderKeys } from '../../domain/loaders.js'
import { MaybeUserContext, WorkspaceContext } from '../../domain/context.js'
import {
DashboardsNotEnabledError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '../../domain/authErrors.js'
import { AuthPolicy } from '../../domain/policies.js'
import {
ensureDashboardsEnabledFragment,
ensureWorkspaceDashboardsFeatureAccessFragment
} from '../../fragments/dashboards.js'
import { hasMinimumWorkspaceRole } from '../../checks/workspaceRole.js'
import { Roles } from '../../../core/constants.js'
type PolicyLoaderKeys =
| typeof AuthCheckContextLoaderKeys.getEnv
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
| typeof AuthCheckContextLoaderKeys.getWorkspacePlan
type PolicyArgs = MaybeUserContext & WorkspaceContext
type PolicyErrors = InstanceType<
| typeof DashboardsNotEnabledError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
>
export const canListDashboardsPolicy: AuthPolicy<
PolicyLoaderKeys,
PolicyArgs,
PolicyErrors
> =
(loaders) =>
async ({ userId, workspaceId }) => {
const isDashboardsEnabled = await ensureDashboardsEnabledFragment(loaders)({})
if (isDashboardsEnabled.isErr) return err(isDashboardsEnabled.error)
const ensuredFeatureAccess = await ensureWorkspaceDashboardsFeatureAccessFragment(
loaders
)({ workspaceId })
if (ensuredFeatureAccess.isErr) return err(ensuredFeatureAccess.error)
const isWorkspaceMember = await hasMinimumWorkspaceRole(loaders)({
userId: userId!,
workspaceId,
role: Roles.Workspace.Member
})
if (!isWorkspaceMember) return err(new WorkspaceNotEnoughPermissionsError())
return ok()
}
@@ -186,3 +186,15 @@ export const StringEnum = <T extends string>(args: T[]) => {
export type StringEnumValues<T extends Record<string, string>> = {
[K in keyof T]: T[K] extends string ? T[K] : never
}[keyof T]
/**
* Get first non-undefined/null value, or undefined if none found
*/
export const firstDefinedValue = <T>(
...args: (T | undefined | null)[]
): T | undefined => {
for (const arg of args) {
if (!isNullOrUndefined(arg)) return arg
}
return undefined
}
@@ -19,6 +19,7 @@ export type FeatureFlags = {
FF_RHINO_FILE_IMPORTER_ENABLED: boolean
FF_LEGACY_FILE_IMPORTS_ENABLED: boolean
FF_ACC_INTEGRATION_ENABLED: boolean
FF_DASHBOARDS_MODULE_ENABLED: boolean
FF_SAVED_VIEWS_ENABLED: boolean
FF_USERS_INVITE_SCOPE_IS_PUBLIC: boolean
}
+5
View File
@@ -135,6 +135,11 @@ export const parseFeatureFlags = (
'Enables the integration with ACC. This synchronizes models with specified ACC assets.',
defaults: { _: false }
},
FF_DASHBOARDS_MODULE_ENABLED: {
schema: z.boolean(),
description: 'Enables the dashboards module.',
defaults: { _: false }
},
FF_SAVED_VIEWS_ENABLED: {
schema: z.boolean(),
description: 'Enables the saved views feature for project models',
@@ -43,7 +43,13 @@ describe('Viewer State helpers', () => {
isOrthoProjection: false,
zoom: 1
},
viewMode: 0,
viewMode: {
mode: 0,
edgesColor: 0,
edgesEnabled: true,
outlineOpacity: 0,
edgesWeight: 0
},
sectionBox: null,
lightConfig: {},
explodeFactor: 0,
@@ -149,7 +155,13 @@ describe('Viewer State helpers', () => {
isOrthoProjection: false,
zoom: 1
},
viewMode: 0,
viewMode: {
mode: 0,
edgesColor: 0,
edgesEnabled: true,
outlineOpacity: 0,
edgesWeight: 0
},
sectionBox: null,
lightConfig: {},
explodeFactor: 0,
@@ -202,7 +214,13 @@ describe('Viewer State helpers', () => {
isOrthoProjection: false,
zoom: 1
},
viewMode: 0,
viewMode: {
mode: 0,
edgesColor: 0,
edgesEnabled: true,
outlineOpacity: 0,
edgesWeight: 0
},
sectionBox: null,
lightConfig: {},
explodeFactor: 0,
+26 -3
View File
@@ -1,9 +1,11 @@
import { has, intersection, isObjectLike } from '#lodash'
import { has, intersection, isNumber, isObjectLike } from '#lodash'
import type { MaybeNullOrUndefined, Nullable } from '../../core/helpers/utilityTypes.js'
import type { PartialDeep } from 'type-fest'
import { UnformattableSerializedViewerStateError } from '../errors/index.js'
import { coerceUndefinedValuesToNull } from '../../core/index.js'
export const defaultViewModeEdgeColorValue = 'DEFAULT_EDGE_COLOR'
/** Redefining these is unfortunate. Especially since they are not part of viewer-core */
enum MeasurementType {
PERPENDICULAR = 0,
@@ -35,6 +37,9 @@ export interface SectionBoxData {
* - ui.diff added
* v1.2 -> v1.3
* - ui.filters.selectedObjectIds removed in favor of ui.filters.selectedObjectApplicationIds
* v1.3 -> 1.4
* - ui.viewMode -> ui.viewMode.mode
* - ui.viewMode has new keys: edgesEnabled, edgesWeight, outlineOpacity, edgesColor
*/
export const SERIALIZED_VIEWER_STATE_VERSION = 1.3
@@ -96,7 +101,13 @@ export type SerializedViewerState = {
isOrthoProjection: boolean
zoom: number
}
viewMode: number
viewMode: {
mode: number
edgesEnabled: boolean
edgesWeight: number
outlineOpacity: number
edgesColor: typeof defaultViewModeEdgeColorValue | number
}
sectionBox: Nullable<SectionBoxData>
lightConfig: {
intensity?: number
@@ -182,6 +193,12 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState =
)
}
const viewModeType = isNumber(state.ui?.viewMode)
? state.ui.viewMode
: state.ui?.viewMode?.mode
const viewModeSettings = isNumber(state.ui?.viewMode) ? {} : state.ui?.viewMode
return {
projectId: state.projectId || throwInvalidError('projectId'),
sessionId: state.sessionId || `nullSessionId-${Math.random() * 1000}`,
@@ -248,7 +265,13 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState =
isOrthoProjection: state.ui?.camera?.isOrthoProjection || false,
zoom: state.ui?.camera?.zoom || 1
},
viewMode: state.ui?.viewMode || 0,
viewMode: {
mode: viewModeType ?? 0,
edgesEnabled: viewModeSettings?.edgesEnabled ?? true,
edgesWeight: viewModeSettings?.edgesWeight ?? 1,
outlineOpacity: viewModeSettings?.outlineOpacity ?? 0.75,
edgesColor: viewModeSettings?.edgesColor ?? defaultViewModeEdgeColorValue
},
sectionBox:
state.ui?.sectionBox?.min?.length && state.ui?.sectionBox.max?.length
? // Complains otherwise
@@ -68,3 +68,8 @@ export const fileImportResultPayload = z.discriminatedUnion('status', [
])
export type FileImportResultPayload = z.infer<typeof fileImportResultPayload>
export type FileImportJobPayloadV1 = JobPayload & {
jobType: 'fileImport'
payloadVersion: 1
}
+122 -120
View File
@@ -134,135 +134,137 @@ export const WorkspacePaidPlanConfigs: (params: {
featureFlags: Partial<FeatureFlags> | undefined
}) => {
[plan in PaidWorkspacePlans]: WorkspacePlanConfig<plan>
} = (params) => ({
[PaidWorkspacePlans.Team]: {
plan: PaidWorkspacePlans.Team,
features: [...baseFeatures],
limits: {
projectCount: 5,
modelCount: 25,
versionsHistory: { value: 30, unit: 'day' },
commentHistory: { value: 30, unit: 'day' }
}
},
[PaidWorkspacePlans.TeamUnlimited]: {
plan: PaidWorkspacePlans.TeamUnlimited,
features: [...baseFeatures],
limits: {
projectCount: null,
modelCount: null,
versionsHistory: { value: 30, unit: 'day' },
commentHistory: { value: 30, unit: 'day' }
}
},
[PaidWorkspacePlans.Pro]: {
plan: PaidWorkspacePlans.Pro,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
],
limits: {
projectCount: 10,
modelCount: 50,
versionsHistory: null,
commentHistory: null
}
},
[PaidWorkspacePlans.ProUnlimited]: {
plan: PaidWorkspacePlans.ProUnlimited,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
],
limits: {
projectCount: null,
modelCount: null,
versionsHistory: null,
commentHistory: null
} = (params) => {
const finalBaseFeatures = [
...baseFeatures,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
]
return {
[PaidWorkspacePlans.Team]: {
plan: PaidWorkspacePlans.Team,
features: [...finalBaseFeatures],
limits: {
projectCount: 5,
modelCount: 25,
versionsHistory: { value: 30, unit: 'day' },
commentHistory: { value: 30, unit: 'day' }
}
},
[PaidWorkspacePlans.TeamUnlimited]: {
plan: PaidWorkspacePlans.TeamUnlimited,
features: [...finalBaseFeatures],
limits: {
projectCount: null,
modelCount: null,
versionsHistory: { value: 30, unit: 'day' },
commentHistory: { value: 30, unit: 'day' }
}
},
[PaidWorkspacePlans.Pro]: {
plan: PaidWorkspacePlans.Pro,
features: [
...finalBaseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding
],
limits: {
projectCount: 10,
modelCount: 50,
versionsHistory: null,
commentHistory: null
}
},
[PaidWorkspacePlans.ProUnlimited]: {
plan: PaidWorkspacePlans.ProUnlimited,
features: [
...finalBaseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding
],
limits: {
projectCount: null,
modelCount: null,
versionsHistory: null,
commentHistory: null
}
}
}
})
}
export const WorkspaceUnpaidPlanConfigs: (params: {
featureFlags: Partial<FeatureFlags> | undefined
}) => {
[plan in UnpaidWorkspacePlans]: WorkspacePlanConfig<plan>
} = (params) => ({
[UnpaidWorkspacePlans.Enterprise]: {
plan: UnpaidWorkspacePlans.Enterprise,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
WorkspacePlanFeatures.ExclusiveMembership,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
],
limits: unlimited
},
[UnpaidWorkspacePlans.Unlimited]: {
plan: UnpaidWorkspacePlans.Unlimited,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
WorkspacePlanFeatures.ExclusiveMembership,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
],
limits: unlimited
},
[UnpaidWorkspacePlans.Academia]: {
plan: UnpaidWorkspacePlans.Academia,
features: [
...baseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
],
limits: unlimited
},
[UnpaidWorkspacePlans.TeamUnlimitedInvoiced]: {
...WorkspacePaidPlanConfigs(params).teamUnlimited,
plan: UnpaidWorkspacePlans.TeamUnlimitedInvoiced
},
[UnpaidWorkspacePlans.ProUnlimitedInvoiced]: {
...WorkspacePaidPlanConfigs(params).proUnlimited,
plan: UnpaidWorkspacePlans.ProUnlimitedInvoiced
},
[UnpaidWorkspacePlans.Free]: {
plan: UnpaidWorkspacePlans.Free,
features: baseFeatures,
limits: {
projectCount: 1,
modelCount: 5,
versionsHistory: { value: 7, unit: 'day' },
commentHistory: { value: 7, unit: 'day' }
} = (params) => {
const finalBaseFeatures = [
...baseFeatures,
...(params.featureFlags?.FF_SAVED_VIEWS_ENABLED
? [WorkspacePlanFeatures.SavedViews]
: [])
]
return {
[UnpaidWorkspacePlans.Enterprise]: {
plan: UnpaidWorkspacePlans.Enterprise,
features: [
...finalBaseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
WorkspacePlanFeatures.ExclusiveMembership
],
limits: unlimited
},
[UnpaidWorkspacePlans.Unlimited]: {
plan: UnpaidWorkspacePlans.Unlimited,
features: [
...finalBaseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding,
WorkspacePlanFeatures.ExclusiveMembership
],
limits: unlimited
},
[UnpaidWorkspacePlans.Academia]: {
plan: UnpaidWorkspacePlans.Academia,
features: [
...finalBaseFeatures,
WorkspacePlanFeatures.DomainSecurity,
WorkspacePlanFeatures.SSO,
WorkspacePlanFeatures.CustomDataRegion,
WorkspacePlanFeatures.HideSpeckleBranding
],
limits: unlimited
},
[UnpaidWorkspacePlans.TeamUnlimitedInvoiced]: {
...WorkspacePaidPlanConfigs(params).teamUnlimited,
plan: UnpaidWorkspacePlans.TeamUnlimitedInvoiced
},
[UnpaidWorkspacePlans.ProUnlimitedInvoiced]: {
...WorkspacePaidPlanConfigs(params).proUnlimited,
plan: UnpaidWorkspacePlans.ProUnlimitedInvoiced
},
[UnpaidWorkspacePlans.Free]: {
plan: UnpaidWorkspacePlans.Free,
features: finalBaseFeatures,
limits: {
projectCount: 1,
modelCount: 5,
versionsHistory: { value: 7, unit: 'day' },
commentHistory: { value: 7, unit: 'day' }
}
}
}
})
}
export const WorkspacePlanConfigs = (params: {
featureFlags: Partial<FeatureFlags> | undefined