Merge branch 'main' into andrew/web-2865-implement-upgrade-confirmation-modal
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"build": "tsc -p ./tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@godaddy/terminus": "^4.12.1",
|
||||
"@speckle/shared": "workspace:^",
|
||||
"bull": "^4.16.4",
|
||||
"dotenv": "^16.4.7",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { jobProcessor } from '@/jobProcessor.js'
|
||||
import { Redis, RedisOptions } from 'ioredis'
|
||||
import { jobPayload } from '@speckle/shared/dist/esm/previews/job.js'
|
||||
import { initMetrics, initPrometheusRegistry } from '@/metrics.js'
|
||||
import { createTerminus } from '@godaddy/terminus'
|
||||
|
||||
const app = express()
|
||||
const host = HOST
|
||||
@@ -122,25 +123,32 @@ const server = app.listen(port, host, async () => {
|
||||
})
|
||||
})
|
||||
|
||||
const shutdown = async () => {
|
||||
// stop accepting new jobs
|
||||
const beforeShutdown = async () => {
|
||||
logger.info('🛑 Beginning shut down, pausing all jobs')
|
||||
// stop accepting new jobs and kill any running jobs
|
||||
await jobQueue.pause(
|
||||
true, // just pausing this local worker of the queue
|
||||
true // do not wait for active jobs to finish
|
||||
)
|
||||
|
||||
// if there is a job currently running, cancell it with an error
|
||||
if (jobDoneCallback) {
|
||||
jobDoneCallback(new Error('Job cancelled due to perview-service shutdown'))
|
||||
logger.warn('Cancelling job due to preview-service shutdown')
|
||||
jobDoneCallback(new Error('Job cancelled due to preview-service shutdown'))
|
||||
}
|
||||
|
||||
logger.info('Received signal to shut down')
|
||||
server.close(() => {
|
||||
logger.debug('Exiting the express server')
|
||||
process.exit()
|
||||
})
|
||||
}
|
||||
|
||||
process.on('SIGINT', async () => await shutdown())
|
||||
process.on('SIGQUIT', async () => await shutdown())
|
||||
process.on('SIGABRT', async () => await shutdown())
|
||||
const onShutdown = async () => {
|
||||
logger.info('👋 Completed shut down, now exiting')
|
||||
}
|
||||
|
||||
createTerminus(server, {
|
||||
beforeShutdown,
|
||||
onShutdown,
|
||||
logger: (msg, err) => {
|
||||
if (err) {
|
||||
logger.error({ err }, msg)
|
||||
return
|
||||
}
|
||||
logger.info(msg)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -196,11 +196,7 @@ export function buildApolloSubscriptionServer(params: {
|
||||
// for subscriptions)
|
||||
try {
|
||||
const headers = getHeaders({ connContext, connectionParams })
|
||||
const buildCtx = await buildContext({
|
||||
req: null,
|
||||
token,
|
||||
cleanLoadersEarly: false
|
||||
})
|
||||
const buildCtx = await buildContext({ token })
|
||||
buildCtx.log.info(
|
||||
{
|
||||
userId: buildCtx.userId,
|
||||
|
||||
+14
-10
@@ -1,27 +1,31 @@
|
||||
import { defineModuleLoaders } from '@/modules/loaders'
|
||||
import { getStreamFactory } from '@/modules/core/repositories/streams'
|
||||
import { defineLoaders } from '@/modules/loaders'
|
||||
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
|
||||
import { db } from '@/db/knex'
|
||||
import { getUserServerRoleFactory } from '@/modules/shared/repositories/acl'
|
||||
import { err, ok } from 'true-myth/result'
|
||||
import { Authz } from '@speckle/shared'
|
||||
|
||||
export const defineModuleLoaders = () => {
|
||||
export default defineModuleLoaders(async () => {
|
||||
const getStream = getStreamFactory({ db })
|
||||
const getUserServerRole = getUserServerRoleFactory({ db })
|
||||
|
||||
defineLoaders({
|
||||
getEnv: getFeatureFlags,
|
||||
return {
|
||||
getEnv: async () => ok(getFeatureFlags()),
|
||||
getProject: async ({ projectId }) => {
|
||||
const project = await getStream({ streamId: projectId })
|
||||
if (!project) return null
|
||||
return { ...project, projectId: project.id }
|
||||
if (!project) return err(Authz.ProjectNotFoundError)
|
||||
return ok({ ...project, projectId: project.id })
|
||||
},
|
||||
getProjectRole: async ({ userId, projectId }) => {
|
||||
const project = await getStream({ streamId: projectId, userId })
|
||||
return project?.role ?? null
|
||||
if (!project?.role) return err(Authz.ProjectRoleNotFoundError)
|
||||
return ok(project.role)
|
||||
},
|
||||
getServerRole: async ({ userId }) => {
|
||||
const role = await getUserServerRole({ userId })
|
||||
return role ?? null
|
||||
if (!role) return err(Authz.ServerRoleNotFoundError)
|
||||
return ok(role)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -184,7 +184,7 @@ export = {
|
||||
userId: context.userId
|
||||
})
|
||||
|
||||
if (!canQuery.authorized) {
|
||||
if (!canQuery.isOk) {
|
||||
switch (canQuery.error.code) {
|
||||
case Authz.ProjectNotFoundError.code:
|
||||
throw new StreamNotFoundError()
|
||||
@@ -199,6 +199,7 @@ export = {
|
||||
|
||||
const project = await getStream({ streamId: args.id })
|
||||
|
||||
// TODO: Should scopes & token resource access rules be checked in authz policy?
|
||||
if (!project?.isPublic && !project?.isDiscoverable) {
|
||||
await validateScopes(context.scopes, Scopes.Streams.Read)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { reportSubscriptionEventsFactory } from '@/modules/core/events/subscript
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { publish } from '@/modules/shared/utils/subscriptions'
|
||||
import { getStreamCollaboratorsFactory } from '@/modules/core/repositories/streams'
|
||||
import { defineModuleLoaders } from '@/modules/core/authz'
|
||||
|
||||
let stopTestSubs: (() => void) | undefined = undefined
|
||||
|
||||
@@ -88,8 +87,6 @@ const coreModule: SpeckleModule<{
|
||||
getStreamCollaborators: getStreamCollaboratorsFactory({ db })
|
||||
})()
|
||||
}
|
||||
|
||||
defineModuleLoaders()
|
||||
},
|
||||
async shutdown() {
|
||||
await shutdownResultListener()
|
||||
|
||||
@@ -66,6 +66,7 @@ export const parseSubscriptionData = (
|
||||
cancelAt: stripeSubscription.cancel_at
|
||||
? new Date(stripeSubscription.cancel_at * 1000)
|
||||
: null,
|
||||
currentPeriodEnd: stripeSubscription.current_period_end * 1000, // this value arrives as a UNIX timestamp
|
||||
products: stripeSubscription.items.data.map((subscriptionItem) => {
|
||||
const productId =
|
||||
typeof subscriptionItem.price.product === 'string'
|
||||
@@ -84,7 +85,7 @@ export const parseSubscriptionData = (
|
||||
}
|
||||
})
|
||||
}
|
||||
return subscriptionData
|
||||
return SubscriptionData.parse(subscriptionData)
|
||||
}
|
||||
|
||||
// this should be a reconcile subscriptions, we keep an accurate state in the DB
|
||||
|
||||
@@ -113,7 +113,7 @@ const subscriptionProduct = z.object({
|
||||
|
||||
export type SubscriptionProduct = z.infer<typeof subscriptionProduct>
|
||||
|
||||
export const subscriptionData = z.object({
|
||||
export const SubscriptionData = z.object({
|
||||
subscriptionId: z.string().min(1),
|
||||
customerId: z.string().min(1),
|
||||
cancelAt: z.date().nullable(),
|
||||
@@ -127,8 +127,11 @@ export const subscriptionData = z.object({
|
||||
z.literal('unpaid'),
|
||||
z.literal('paused')
|
||||
]),
|
||||
products: subscriptionProduct.array()
|
||||
products: subscriptionProduct.array(),
|
||||
currentPeriodEnd: z.coerce.date()
|
||||
})
|
||||
// this abstracts the stripe sub data
|
||||
export type SubscriptionData = z.infer<typeof SubscriptionData>
|
||||
|
||||
export const calculateSubscriptionSeats = ({
|
||||
subscriptionData,
|
||||
@@ -147,9 +150,6 @@ export const calculateSubscriptionSeats = ({
|
||||
return { guest: guestProduct?.quantity || 0, plan: planProduct?.quantity || 0 }
|
||||
}
|
||||
|
||||
// this abstracts the stripe sub data
|
||||
export type SubscriptionData = z.infer<typeof subscriptionData>
|
||||
|
||||
export type UpsertWorkspaceSubscription = (args: {
|
||||
workspaceSubscription: WorkspaceSubscription
|
||||
}) => Promise<void>
|
||||
|
||||
@@ -14,23 +14,23 @@ import {
|
||||
acquireTaskLockFactory,
|
||||
releaseTaskLockFactory
|
||||
} from '@/modules/core/repositories/scheduledTasks'
|
||||
import {
|
||||
downscaleWorkspaceSubscriptionFactory,
|
||||
manageSubscriptionDownscaleFactory
|
||||
} from '@/modules/gatekeeper/services/subscriptions'
|
||||
import {
|
||||
changeExpiredTrialWorkspacePlanStatusesFactory,
|
||||
getWorkspacePlanByProjectIdFactory,
|
||||
getWorkspacePlanFactory,
|
||||
getWorkspacesByPlanAgeFactory,
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactory,
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans,
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans,
|
||||
upsertWorkspaceSubscriptionFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
import {
|
||||
countWorkspaceRoleWithOptionalProjectRoleFactory,
|
||||
getWorkspaceCollaboratorsFactory
|
||||
} from '@/modules/workspaces/repositories/workspaces'
|
||||
import { reconcileWorkspaceSubscriptionFactory } from '@/modules/gatekeeper/clients/stripe'
|
||||
import {
|
||||
getSubscriptionDataFactory,
|
||||
reconcileWorkspaceSubscriptionFactory
|
||||
} from '@/modules/gatekeeper/clients/stripe'
|
||||
import { ScheduleExecution } from '@/modules/core/domain/scheduledTasks/operations'
|
||||
import { EventBusEmit, getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { sendWorkspaceTrialExpiresEmailFactory } from '@/modules/gatekeeper/services/trialEmails'
|
||||
@@ -42,6 +42,13 @@ import coreModule from '@/modules/core/index'
|
||||
import { isProjectReadOnlyFactory } from '@/modules/gatekeeper/services/readOnly'
|
||||
import { WorkspaceReadOnlyError } from '@/modules/gatekeeper/errors/billing'
|
||||
import { InvalidLicenseError } from '@/modules/gatekeeper/errors/license'
|
||||
import {
|
||||
downscaleWorkspaceSubscriptionFactoryNew,
|
||||
downscaleWorkspaceSubscriptionFactoryOld,
|
||||
manageSubscriptionDownscaleFactoryNew,
|
||||
manageSubscriptionDownscaleFactoryOld
|
||||
} from '@/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale'
|
||||
import { countSeatsByTypeInWorkspaceFactory } from '@/modules/gatekeeper/repositories/workspaceSeat'
|
||||
|
||||
const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
|
||||
getFeatureFlags()
|
||||
@@ -58,16 +65,31 @@ const scheduleWorkspaceSubscriptionDownscale = ({
|
||||
}) => {
|
||||
const stripe = getStripeClient()
|
||||
|
||||
const manageSubscriptionDownscale = manageSubscriptionDownscaleFactory({
|
||||
downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactory({
|
||||
const manageSubscriptionDownscaleOld = manageSubscriptionDownscaleFactoryOld({
|
||||
downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactoryOld({
|
||||
countWorkspaceRole: countWorkspaceRoleWithOptionalProjectRoleFactory({ db }),
|
||||
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
||||
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe }),
|
||||
getWorkspacePlanProductId
|
||||
}),
|
||||
getWorkspaceSubscriptions: getWorkspaceSubscriptionsPastBillingCycleEndFactory({
|
||||
db
|
||||
getWorkspaceSubscriptions:
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans({
|
||||
db
|
||||
}),
|
||||
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
|
||||
})
|
||||
const manageSubscriptionDownscaleNew = manageSubscriptionDownscaleFactoryNew({
|
||||
downscaleWorkspaceSubscription: downscaleWorkspaceSubscriptionFactoryNew({
|
||||
countSeatsByTypeInWorkspace: countSeatsByTypeInWorkspaceFactory({ db }),
|
||||
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
||||
reconcileSubscriptionData: reconcileWorkspaceSubscriptionFactory({ stripe }),
|
||||
getWorkspacePlanProductId
|
||||
}),
|
||||
getWorkspaceSubscriptions:
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans({
|
||||
db
|
||||
}),
|
||||
getSubscriptionData: getSubscriptionDataFactory({ stripe }),
|
||||
updateWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
|
||||
})
|
||||
|
||||
@@ -76,7 +98,10 @@ const scheduleWorkspaceSubscriptionDownscale = ({
|
||||
cronExpression,
|
||||
'WorkspaceSubscriptionDownscale',
|
||||
async (_scheduledTime, { logger }) => {
|
||||
await manageSubscriptionDownscale({ logger })
|
||||
await Promise.all([
|
||||
manageSubscriptionDownscaleOld({ logger }), // Only takes old plans subscriptions
|
||||
manageSubscriptionDownscaleNew({ logger }) // Only takes new plans subscriptions
|
||||
])
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { WorkspacePlan } from '@/modules/gatekeeperCore/domain/billing'
|
||||
import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper'
|
||||
import { Workspace } from '@/modules/workspacesCore/domain/types'
|
||||
import { Workspaces } from '@/modules/workspacesCore/helpers/db'
|
||||
import { PaidWorkspacePlansNew, PaidWorkspacePlansOld } from '@speckle/shared'
|
||||
import { Knex } from 'knex'
|
||||
import { omit } from 'lodash'
|
||||
|
||||
@@ -37,6 +38,14 @@ const WorkspacePlans = buildTableHelper('workspace_plans', [
|
||||
'createdAt',
|
||||
'updatedAt'
|
||||
])
|
||||
const WorkspaceSubscriptions = buildTableHelper('workspace_subscriptions', [
|
||||
'workspaceId',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'currentBillingCycleEnd',
|
||||
'billingInterval',
|
||||
'subscriptionData'
|
||||
])
|
||||
|
||||
const tables = {
|
||||
workspaces: (db: Knex) => db<Workspace>('workspaces'),
|
||||
@@ -212,15 +221,41 @@ export const getWorkspaceSubscriptionBySubscriptionIdFactory =
|
||||
return subscription ?? null
|
||||
}
|
||||
|
||||
export const getWorkspaceSubscriptionsPastBillingCycleEndFactory =
|
||||
const newPlans = Object.values(PaidWorkspacePlansNew)
|
||||
const oldPlans = Object.values(PaidWorkspacePlansOld)
|
||||
|
||||
export const getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans =
|
||||
({ db }: { db: Knex }): GetWorkspaceSubscriptions =>
|
||||
async () => {
|
||||
const cycleEnd = new Date()
|
||||
cycleEnd.setMinutes(cycleEnd.getMinutes() + 5)
|
||||
return await tables
|
||||
.workspaceSubscriptions(db)
|
||||
.select()
|
||||
.join(
|
||||
WorkspacePlans.name,
|
||||
WorkspacePlans.col.workspaceId,
|
||||
'workspace_subscriptions.workspaceId'
|
||||
)
|
||||
.whereIn(WorkspacePlans.col.name, oldPlans)
|
||||
.where('currentBillingCycleEnd', '<', cycleEnd)
|
||||
.select(WorkspaceSubscriptions.cols)
|
||||
}
|
||||
|
||||
export const getWorkspaceSubscriptionsPastBillingCycleEndFactoryNewPlans =
|
||||
({ db }: { db: Knex }): GetWorkspaceSubscriptions =>
|
||||
async () => {
|
||||
const cycleEnd = new Date()
|
||||
cycleEnd.setMinutes(cycleEnd.getMinutes() + 5)
|
||||
return await tables
|
||||
.workspaceSubscriptions(db)
|
||||
.join(
|
||||
WorkspacePlans.name,
|
||||
WorkspacePlans.col.workspaceId,
|
||||
'workspace_subscriptions.workspaceId'
|
||||
)
|
||||
.whereIn(WorkspacePlans.col.name, newPlans)
|
||||
.where('currentBillingCycleEnd', '<', cycleEnd)
|
||||
.select(WorkspaceSubscriptions.cols)
|
||||
}
|
||||
|
||||
export const getWorkspacePlanByProjectIdFactory =
|
||||
|
||||
@@ -22,6 +22,7 @@ import { withTransaction } from '@/modules/shared/helpers/dbHelper'
|
||||
import { getStripeClient } from '@/modules/gatekeeper/stripe'
|
||||
import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions'
|
||||
import { getEventBus } from '@/modules/shared/services/eventBus'
|
||||
import { SubscriptionData } from '@/modules/gatekeeper/domain/billing'
|
||||
|
||||
export const getBillingRouter = (): Router => {
|
||||
const router = Router()
|
||||
@@ -144,6 +145,19 @@ export const getBillingRouter = (): Router => {
|
||||
})({ subscriptionData: parseSubscriptionData(event.data.object) })
|
||||
|
||||
break
|
||||
case 'invoice.created':
|
||||
const subscriptionData = await getSubscriptionFromEventFactory({ stripe })(
|
||||
event
|
||||
)
|
||||
if (!subscriptionData) break
|
||||
await handleSubscriptionUpdateFactory({
|
||||
getWorkspacePlan: getWorkspacePlanFactory({ db }),
|
||||
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
|
||||
getWorkspaceSubscriptionBySubscriptionId:
|
||||
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
|
||||
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
|
||||
})({ subscriptionData })
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
@@ -154,3 +168,18 @@ export const getBillingRouter = (): Router => {
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
const getSubscriptionFromEventFactory =
|
||||
({ stripe }: { stripe: Stripe }) =>
|
||||
async (event: Stripe.InvoiceCreatedEvent): Promise<SubscriptionData | null> => {
|
||||
const subscription = event.data.object.subscription
|
||||
if (!subscription) {
|
||||
return null
|
||||
}
|
||||
if (typeof subscription === 'string') {
|
||||
return await getSubscriptionDataFactory({ stripe })({
|
||||
subscriptionId: subscription
|
||||
})
|
||||
}
|
||||
return parseSubscriptionData(subscription)
|
||||
}
|
||||
|
||||
@@ -66,18 +66,7 @@ export const completeCheckoutSessionFactory =
|
||||
const subscriptionData = await getSubscriptionData({
|
||||
subscriptionId
|
||||
})
|
||||
const currentBillingCycleEnd = new Date()
|
||||
switch (checkoutSession.billingInterval) {
|
||||
case 'monthly':
|
||||
currentBillingCycleEnd.setMonth(currentBillingCycleEnd.getMonth() + 1)
|
||||
break
|
||||
case 'yearly':
|
||||
currentBillingCycleEnd.setMonth(currentBillingCycleEnd.getMonth() + 12)
|
||||
break
|
||||
|
||||
default:
|
||||
throwUncoveredError(checkoutSession.billingInterval)
|
||||
}
|
||||
const currentBillingCycleEnd = subscriptionData.currentPeriodEnd
|
||||
|
||||
const workspaceSubscription = {
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { Logger } from '@/observability/logging'
|
||||
import {
|
||||
GetWorkspacePlan,
|
||||
GetWorkspacePlanPriceId,
|
||||
GetWorkspacePlanProductId,
|
||||
GetWorkspaceSubscription,
|
||||
GetWorkspaceSubscriptionBySubscriptionId,
|
||||
GetWorkspaceSubscriptions,
|
||||
ReconcileSubscriptionData,
|
||||
SubscriptionData,
|
||||
SubscriptionDataInput,
|
||||
UpsertPaidWorkspacePlan,
|
||||
UpsertWorkspaceSubscription,
|
||||
WorkspaceSeatType,
|
||||
WorkspaceSubscription
|
||||
WorkspaceSeatType
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import {
|
||||
WorkspacePlanMismatchError,
|
||||
@@ -27,9 +24,7 @@ import {
|
||||
throwUncoveredError,
|
||||
WorkspaceRoles
|
||||
} from '@speckle/shared'
|
||||
import { cloneDeep, isEqual, sum } from 'lodash'
|
||||
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
|
||||
import { calculateNewBillingCycleEnd } from '@/modules/gatekeeper/services/subscriptions/calculateNewBillingCycleEnd'
|
||||
import { cloneDeep, sum } from 'lodash'
|
||||
import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations'
|
||||
|
||||
export const handleSubscriptionUpdateFactory =
|
||||
@@ -297,117 +292,3 @@ export const addWorkspaceSubscriptionSeatIfNeededFactoryOld =
|
||||
prorationBehavior: 'create_prorations'
|
||||
})
|
||||
}
|
||||
|
||||
type DownscaleWorkspaceSubscription = (args: {
|
||||
workspaceSubscription: WorkspaceSubscription
|
||||
}) => Promise<boolean>
|
||||
|
||||
export const downscaleWorkspaceSubscriptionFactory =
|
||||
({
|
||||
getWorkspacePlan,
|
||||
countWorkspaceRole,
|
||||
getWorkspacePlanProductId,
|
||||
reconcileSubscriptionData
|
||||
}: {
|
||||
getWorkspacePlan: GetWorkspacePlan
|
||||
countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole
|
||||
getWorkspacePlanProductId: GetWorkspacePlanProductId
|
||||
reconcileSubscriptionData: ReconcileSubscriptionData
|
||||
}): DownscaleWorkspaceSubscription =>
|
||||
async ({ workspaceSubscription }) => {
|
||||
const workspaceId = workspaceSubscription.workspaceId
|
||||
|
||||
const workspacePlan = await getWorkspacePlan({ workspaceId })
|
||||
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
|
||||
|
||||
switch (workspacePlan.name) {
|
||||
case 'team':
|
||||
case 'pro':
|
||||
// Cause seat types matter, a future issue
|
||||
throw new NotImplementedError()
|
||||
case 'starter':
|
||||
case 'plus':
|
||||
case 'business':
|
||||
break
|
||||
case 'unlimited':
|
||||
case 'academia':
|
||||
case 'starterInvoiced':
|
||||
case 'plusInvoiced':
|
||||
case 'businessInvoiced':
|
||||
case 'free':
|
||||
throw new WorkspacePlanMismatchError()
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
|
||||
if (workspacePlan.status === 'canceled') return false
|
||||
|
||||
// TODO: Guests will be able to have a paid seat
|
||||
const [guestCount, memberCount, adminCount] = await Promise.all([
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }),
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }),
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' })
|
||||
])
|
||||
|
||||
const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData)
|
||||
|
||||
mutateSubscriptionDataWithNewValidSeatNumbers({
|
||||
seatCount: guestCount,
|
||||
workspacePlan: 'guest',
|
||||
getWorkspacePlanProductId,
|
||||
subscriptionData
|
||||
})
|
||||
mutateSubscriptionDataWithNewValidSeatNumbers({
|
||||
seatCount: memberCount + adminCount,
|
||||
workspacePlan: workspacePlan.name,
|
||||
getWorkspacePlanProductId,
|
||||
subscriptionData
|
||||
})
|
||||
|
||||
if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) {
|
||||
await reconcileSubscriptionData({ subscriptionData, prorationBehavior: 'none' })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const manageSubscriptionDownscaleFactory =
|
||||
({
|
||||
getWorkspaceSubscriptions,
|
||||
downscaleWorkspaceSubscription,
|
||||
updateWorkspaceSubscription
|
||||
}: {
|
||||
getWorkspaceSubscriptions: GetWorkspaceSubscriptions
|
||||
downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription
|
||||
updateWorkspaceSubscription: UpsertWorkspaceSubscription
|
||||
}) =>
|
||||
async (context: { logger: Logger }) => {
|
||||
const { logger } = context
|
||||
const subscriptions = await getWorkspaceSubscriptions()
|
||||
for (const workspaceSubscription of subscriptions) {
|
||||
const log = logger.child({ workspaceId: workspaceSubscription.workspaceId })
|
||||
try {
|
||||
const subDownscaled = await downscaleWorkspaceSubscription({
|
||||
workspaceSubscription
|
||||
})
|
||||
if (subDownscaled) {
|
||||
log.info(
|
||||
'Downscaled workspace subscription to match the current workspace team'
|
||||
)
|
||||
} else {
|
||||
log.info('Did not need to downscale the workspace subscription')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, 'Failed to downscale workspace subscription')
|
||||
}
|
||||
const newBillingCycleEnd = calculateNewBillingCycleEnd({ workspaceSubscription })
|
||||
const updatedWorkspaceSubscription = {
|
||||
...workspaceSubscription,
|
||||
currentBillingCycleEnd: newBillingCycleEnd
|
||||
}
|
||||
await updateWorkspaceSubscription({
|
||||
workspaceSubscription: updatedWorkspaceSubscription
|
||||
})
|
||||
log.info({ updatedWorkspaceSubscription }, 'Updated workspace billing cycle end')
|
||||
}
|
||||
}
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
GetSubscriptionData,
|
||||
GetWorkspacePlan,
|
||||
GetWorkspacePlanProductId,
|
||||
GetWorkspaceSubscriptions,
|
||||
ReconcileSubscriptionData,
|
||||
UpsertWorkspaceSubscription,
|
||||
WorkspaceSubscription
|
||||
} from '@/modules/gatekeeper/domain/billing'
|
||||
import { CountSeatsByTypeInWorkspace } from '@/modules/gatekeeper/domain/operations'
|
||||
import {
|
||||
WorkspacePlanMismatchError,
|
||||
WorkspacePlanNotFoundError
|
||||
} from '@/modules/gatekeeper/errors/billing'
|
||||
import { calculateNewBillingCycleEnd } from '@/modules/gatekeeper/services/subscriptions/calculateNewBillingCycleEnd'
|
||||
import { mutateSubscriptionDataWithNewValidSeatNumbers } from '@/modules/gatekeeper/services/subscriptions/mutateSubscriptionDataWithNewValidSeatNumbers'
|
||||
import { NotImplementedError } from '@/modules/shared/errors'
|
||||
import { CountWorkspaceRoleWithOptionalProjectRole } from '@/modules/workspaces/domain/operations'
|
||||
import { Logger } from '@/observability/logging'
|
||||
import { throwUncoveredError } from '@speckle/shared'
|
||||
import { cloneDeep, isEqual } from 'lodash'
|
||||
|
||||
type DownscaleWorkspaceSubscription = (args: {
|
||||
workspaceSubscription: WorkspaceSubscription
|
||||
}) => Promise<boolean>
|
||||
|
||||
export const downscaleWorkspaceSubscriptionFactoryOld =
|
||||
({
|
||||
getWorkspacePlan,
|
||||
countWorkspaceRole,
|
||||
getWorkspacePlanProductId,
|
||||
reconcileSubscriptionData
|
||||
}: {
|
||||
getWorkspacePlan: GetWorkspacePlan
|
||||
countWorkspaceRole: CountWorkspaceRoleWithOptionalProjectRole
|
||||
getWorkspacePlanProductId: GetWorkspacePlanProductId
|
||||
reconcileSubscriptionData: ReconcileSubscriptionData
|
||||
}): DownscaleWorkspaceSubscription =>
|
||||
async ({ workspaceSubscription }) => {
|
||||
const workspaceId = workspaceSubscription.workspaceId
|
||||
|
||||
const workspacePlan = await getWorkspacePlan({ workspaceId })
|
||||
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
|
||||
|
||||
switch (workspacePlan.name) {
|
||||
case 'team':
|
||||
case 'pro':
|
||||
// Cause seat types matter, a future issue
|
||||
throw new NotImplementedError()
|
||||
case 'starter':
|
||||
case 'plus':
|
||||
case 'business':
|
||||
break
|
||||
case 'unlimited':
|
||||
case 'academia':
|
||||
case 'starterInvoiced':
|
||||
case 'plusInvoiced':
|
||||
case 'businessInvoiced':
|
||||
case 'free':
|
||||
throw new WorkspacePlanMismatchError()
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
|
||||
if (workspacePlan.status === 'canceled') return false
|
||||
|
||||
// TODO: Guests will be able to have a paid seat
|
||||
const [guestCount, memberCount, adminCount] = await Promise.all([
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:guest' }),
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:member' }),
|
||||
countWorkspaceRole({ workspaceId, workspaceRole: 'workspace:admin' })
|
||||
])
|
||||
|
||||
const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData)
|
||||
|
||||
mutateSubscriptionDataWithNewValidSeatNumbers({
|
||||
seatCount: guestCount,
|
||||
workspacePlan: 'guest',
|
||||
getWorkspacePlanProductId,
|
||||
subscriptionData
|
||||
})
|
||||
mutateSubscriptionDataWithNewValidSeatNumbers({
|
||||
seatCount: memberCount + adminCount,
|
||||
workspacePlan: workspacePlan.name,
|
||||
getWorkspacePlanProductId,
|
||||
subscriptionData
|
||||
})
|
||||
|
||||
if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) {
|
||||
await reconcileSubscriptionData({ subscriptionData, prorationBehavior: 'none' })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const downscaleWorkspaceSubscriptionFactoryNew =
|
||||
({
|
||||
getWorkspacePlan,
|
||||
countSeatsByTypeInWorkspace,
|
||||
getWorkspacePlanProductId,
|
||||
reconcileSubscriptionData
|
||||
}: {
|
||||
getWorkspacePlan: GetWorkspacePlan
|
||||
countSeatsByTypeInWorkspace: CountSeatsByTypeInWorkspace
|
||||
getWorkspacePlanProductId: GetWorkspacePlanProductId
|
||||
reconcileSubscriptionData: ReconcileSubscriptionData
|
||||
}): DownscaleWorkspaceSubscription =>
|
||||
async ({ workspaceSubscription }) => {
|
||||
const workspaceId = workspaceSubscription.workspaceId
|
||||
|
||||
const workspacePlan = await getWorkspacePlan({ workspaceId })
|
||||
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
|
||||
|
||||
switch (workspacePlan.name) {
|
||||
case 'team':
|
||||
case 'pro':
|
||||
break
|
||||
case 'starter':
|
||||
case 'plus':
|
||||
case 'business':
|
||||
case 'unlimited':
|
||||
case 'academia':
|
||||
case 'starterInvoiced':
|
||||
case 'plusInvoiced':
|
||||
case 'businessInvoiced':
|
||||
case 'free':
|
||||
throw new WorkspacePlanMismatchError()
|
||||
default:
|
||||
throwUncoveredError(workspacePlan)
|
||||
}
|
||||
|
||||
if (workspacePlan.status === 'canceled') return false
|
||||
|
||||
const editorsCount = await countSeatsByTypeInWorkspace({
|
||||
workspaceId,
|
||||
type: 'editor'
|
||||
})
|
||||
|
||||
const subscriptionData = cloneDeep(workspaceSubscription.subscriptionData)
|
||||
|
||||
mutateSubscriptionDataWithNewValidSeatNumbers({
|
||||
seatCount: editorsCount,
|
||||
workspacePlan: workspacePlan.name,
|
||||
getWorkspacePlanProductId,
|
||||
subscriptionData
|
||||
})
|
||||
|
||||
if (!isEqual(subscriptionData, workspaceSubscription.subscriptionData)) {
|
||||
await reconcileSubscriptionData({ subscriptionData, prorationBehavior: 'none' })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const manageSubscriptionDownscaleFactoryOld =
|
||||
({
|
||||
getWorkspaceSubscriptions,
|
||||
downscaleWorkspaceSubscription,
|
||||
updateWorkspaceSubscription
|
||||
}: {
|
||||
getWorkspaceSubscriptions: GetWorkspaceSubscriptions
|
||||
downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription
|
||||
updateWorkspaceSubscription: UpsertWorkspaceSubscription
|
||||
}) =>
|
||||
async (context: { logger: Logger }) => {
|
||||
const { logger } = context
|
||||
const subscriptions = await getWorkspaceSubscriptions()
|
||||
for (const workspaceSubscription of subscriptions) {
|
||||
const log = logger.child({ workspaceId: workspaceSubscription.workspaceId })
|
||||
try {
|
||||
const subDownscaled = await downscaleWorkspaceSubscription({
|
||||
workspaceSubscription
|
||||
})
|
||||
if (subDownscaled) {
|
||||
log.info(
|
||||
'Downscaled workspace subscription to match the current workspace team'
|
||||
)
|
||||
} else {
|
||||
log.info('Did not need to downscale the workspace subscription')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, 'Failed to downscale workspace subscription')
|
||||
}
|
||||
const newBillingCycleEnd = calculateNewBillingCycleEnd({ workspaceSubscription })
|
||||
const updatedWorkspaceSubscription = {
|
||||
...workspaceSubscription,
|
||||
currentBillingCycleEnd: newBillingCycleEnd
|
||||
}
|
||||
await updateWorkspaceSubscription({
|
||||
workspaceSubscription: updatedWorkspaceSubscription
|
||||
})
|
||||
log.info({ updatedWorkspaceSubscription }, 'Updated workspace billing cycle end')
|
||||
}
|
||||
}
|
||||
|
||||
export const manageSubscriptionDownscaleFactoryNew =
|
||||
({
|
||||
getWorkspaceSubscriptions,
|
||||
downscaleWorkspaceSubscription,
|
||||
updateWorkspaceSubscription,
|
||||
getSubscriptionData
|
||||
}: {
|
||||
getWorkspaceSubscriptions: GetWorkspaceSubscriptions
|
||||
downscaleWorkspaceSubscription: DownscaleWorkspaceSubscription
|
||||
updateWorkspaceSubscription: UpsertWorkspaceSubscription
|
||||
getSubscriptionData: GetSubscriptionData
|
||||
}) =>
|
||||
async (context: { logger: Logger }) => {
|
||||
const { logger } = context
|
||||
const subscriptions = await getWorkspaceSubscriptions()
|
||||
for (const workspaceSubscription of subscriptions) {
|
||||
const log = logger.child({ workspaceId: workspaceSubscription.workspaceId })
|
||||
try {
|
||||
//TODO:
|
||||
const subDownscaled = await downscaleWorkspaceSubscription({
|
||||
workspaceSubscription
|
||||
})
|
||||
if (subDownscaled) {
|
||||
log.info(
|
||||
'Downscaled workspace subscription to match the current workspace team'
|
||||
)
|
||||
} else {
|
||||
log.info('Did not need to downscale the workspace subscription')
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, 'Failed to downscale workspace subscription')
|
||||
}
|
||||
const subscriptionData = await getSubscriptionData(
|
||||
workspaceSubscription.subscriptionData
|
||||
)
|
||||
const updatedWorkspaceSubscription = {
|
||||
...workspaceSubscription,
|
||||
currentBillingCycleEnd: subscriptionData.currentPeriodEnd
|
||||
}
|
||||
await updateWorkspaceSubscription({
|
||||
workspaceSubscription: updatedWorkspaceSubscription
|
||||
})
|
||||
log.info({ updatedWorkspaceSubscription }, 'Updated workspace billing cycle end')
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import { assign } from 'lodash'
|
||||
export const createTestSubscriptionData = (
|
||||
overrides: Partial<SubscriptionData> = {}
|
||||
): SubscriptionData => {
|
||||
const defaultValues: SubscriptionData = {
|
||||
const aMonthFromNow = new Date()
|
||||
aMonthFromNow.setMonth(new Date().getMonth() + 1)
|
||||
const defaultValues = {
|
||||
cancelAt: null,
|
||||
customerId: cryptoRandomString({ length: 10 }),
|
||||
products: [
|
||||
@@ -20,7 +22,8 @@ export const createTestSubscriptionData = (
|
||||
}
|
||||
],
|
||||
status: 'active',
|
||||
subscriptionId: cryptoRandomString({ length: 10 })
|
||||
subscriptionId: cryptoRandomString({ length: 10 }),
|
||||
currentPeriodEnd: aMonthFromNow.toISOString()
|
||||
}
|
||||
return assign(defaultValues, overrides)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ import {
|
||||
upsertPaidWorkspacePlanFactory,
|
||||
getWorkspaceSubscriptionFactory,
|
||||
getWorkspaceSubscriptionBySubscriptionIdFactory,
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactory,
|
||||
changeExpiredTrialWorkspacePlanStatusesFactory,
|
||||
upsertTrialWorkspacePlanFactory,
|
||||
getWorkspacesByPlanAgeFactory
|
||||
getWorkspacesByPlanAgeFactory,
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans,
|
||||
upsertWorkspacePlanFactory
|
||||
} from '@/modules/gatekeeper/repositories/billing'
|
||||
import {
|
||||
createTestSubscriptionData,
|
||||
@@ -43,8 +44,8 @@ const getWorkspaceSubscription = getWorkspaceSubscriptionFactory({ db })
|
||||
const getWorkspaceSubscriptionBySubscriptionId =
|
||||
getWorkspaceSubscriptionBySubscriptionIdFactory({ db })
|
||||
|
||||
const getSubscriptionsAboutToEndBillingCycle =
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactory({ db })
|
||||
const getSubscriptionsAboutToEndBillingCycleOld =
|
||||
getWorkspaceSubscriptionsPastBillingCycleEndFactoryOldPlans({ db })
|
||||
|
||||
const changeExpiredTrialWorkspacePlanStatuses =
|
||||
changeExpiredTrialWorkspacePlanStatusesFactory({ db })
|
||||
@@ -526,10 +527,18 @@ describe('billing repositories @gatekeeper', () => {
|
||||
const workspace2Subscription = createTestWorkspaceSubscription({
|
||||
workspaceId: workspace2Id
|
||||
})
|
||||
await upsertWorkspacePlanFactory({ db })({
|
||||
workspacePlan: {
|
||||
workspaceId: workspace2Subscription.workspaceId,
|
||||
name: 'plus',
|
||||
status: 'valid',
|
||||
createdAt: new Date()
|
||||
}
|
||||
})
|
||||
await upsertWorkspaceSubscription({
|
||||
workspaceSubscription: workspace2Subscription
|
||||
})
|
||||
const subscriptions = await getSubscriptionsAboutToEndBillingCycle()
|
||||
const subscriptions = await getSubscriptionsAboutToEndBillingCycleOld()
|
||||
expect(subscriptions).deep.equalInAnyOrder([workspace2Subscription])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -586,7 +586,8 @@ describe('checkout @gatekeeper', () => {
|
||||
}
|
||||
],
|
||||
status: 'active',
|
||||
cancelAt: null
|
||||
cancelAt: null,
|
||||
currentPeriodEnd: new Date()
|
||||
}
|
||||
|
||||
let storedWorkspaceSubscriptionData: WorkspaceSubscription | undefined =
|
||||
|
||||
@@ -15,10 +15,14 @@ import {
|
||||
import {
|
||||
addWorkspaceSubscriptionSeatIfNeededFactoryNew,
|
||||
addWorkspaceSubscriptionSeatIfNeededFactoryOld,
|
||||
downscaleWorkspaceSubscriptionFactory,
|
||||
handleSubscriptionUpdateFactory,
|
||||
manageSubscriptionDownscaleFactory
|
||||
handleSubscriptionUpdateFactory
|
||||
} from '@/modules/gatekeeper/services/subscriptions'
|
||||
import {
|
||||
downscaleWorkspaceSubscriptionFactoryNew,
|
||||
downscaleWorkspaceSubscriptionFactoryOld,
|
||||
manageSubscriptionDownscaleFactoryOld
|
||||
} from '@/modules/gatekeeper/services/subscriptions/manageSubscriptionDownscale'
|
||||
|
||||
import {
|
||||
createTestSubscriptionData,
|
||||
createTestWorkspaceSubscription
|
||||
@@ -1014,13 +1018,13 @@ describe('subscriptions @gatekeeper', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('downscaleWorkspaceSubscriptionFactory creates a function, that', () => {
|
||||
describe('downscaleWorkspaceSubscriptionFactoryOld creates a function, that', () => {
|
||||
it('throws an error if the workspace has no plan attached to it', async () => {
|
||||
const subscriptionData = createTestSubscriptionData()
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData
|
||||
})
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryOld({
|
||||
getWorkspacePlan: async () => null,
|
||||
countWorkspaceRole: async () => {
|
||||
expect.fail()
|
||||
@@ -1044,7 +1048,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
subscriptionData,
|
||||
workspaceId
|
||||
})
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryOld({
|
||||
getWorkspacePlan: async () => ({
|
||||
name: 'unlimited',
|
||||
workspaceId,
|
||||
@@ -1073,7 +1077,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
subscriptionData,
|
||||
workspaceId
|
||||
})
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryOld({
|
||||
getWorkspacePlan: async () => ({
|
||||
name: 'plus',
|
||||
workspaceId,
|
||||
@@ -1109,7 +1113,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
workspaceId
|
||||
})
|
||||
const workspacePlanName = 'plus'
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryOld({
|
||||
getWorkspacePlan: async () => ({
|
||||
name: workspacePlanName,
|
||||
workspaceId,
|
||||
@@ -1164,7 +1168,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
const workspacePlanName = 'plus'
|
||||
|
||||
let reconciledSub: SubscriptionDataInput | undefined = undefined
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactory({
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryOld({
|
||||
getWorkspacePlan: async () => ({
|
||||
name: workspacePlanName,
|
||||
workspaceId,
|
||||
@@ -1193,14 +1197,178 @@ describe('subscriptions @gatekeeper', () => {
|
||||
).to.be.equal(guestQuantity / 2)
|
||||
})
|
||||
})
|
||||
describe('manageSubscriptionDownscaleFactory, creates a function, that', () => {
|
||||
describe('downscaleWorkspaceSubscriptionFactoryNew creates a function, that', () => {
|
||||
it('throws an error if the workspace has no plan attached to it', async () => {
|
||||
const subscriptionData = createTestSubscriptionData()
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData
|
||||
})
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryNew({
|
||||
getWorkspacePlan: async () => null,
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspacePlanProductId: () => {
|
||||
expect.fail()
|
||||
},
|
||||
reconcileSubscriptionData: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await downscaleSubscription({ workspaceSubscription })
|
||||
})
|
||||
expect(err.message).to.equal(new WorkspacePlanNotFoundError().message)
|
||||
})
|
||||
it('throws an error if workspacePlan is not a paid plan', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const subscriptionData = createTestSubscriptionData()
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData,
|
||||
workspaceId
|
||||
})
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryNew({
|
||||
getWorkspacePlan: async () => ({
|
||||
name: 'unlimited',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspacePlanProductId: () => {
|
||||
expect.fail()
|
||||
},
|
||||
reconcileSubscriptionData: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const err = await expectToThrow(async () => {
|
||||
await downscaleSubscription({ workspaceSubscription })
|
||||
})
|
||||
expect(err.message).to.equal(new WorkspacePlanMismatchError().message)
|
||||
})
|
||||
it('returns if the subscription is canceled', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const subscriptionData = createTestSubscriptionData()
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData,
|
||||
workspaceId
|
||||
})
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryNew({
|
||||
getWorkspacePlan: async () => ({
|
||||
name: 'pro',
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
status: 'canceled'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
expect.fail()
|
||||
},
|
||||
getWorkspacePlanProductId: () => {
|
||||
expect.fail()
|
||||
},
|
||||
reconcileSubscriptionData: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
const hasDownscaled = await downscaleSubscription({ workspaceSubscription })
|
||||
expect(hasDownscaled).to.be.false
|
||||
})
|
||||
it('does not reconcile the subscription seats did not change', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const priceId = cryptoRandomString({ length: 10 })
|
||||
const productId = cryptoRandomString({ length: 10 })
|
||||
const quantity = 10
|
||||
const subscriptionItemId = cryptoRandomString({ length: 10 })
|
||||
const subscriptionData = createTestSubscriptionData({
|
||||
products: [{ priceId, productId, quantity, subscriptionItemId }]
|
||||
})
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData,
|
||||
billingInterval: 'monthly',
|
||||
currentBillingCycleEnd: new Date(2034, 11, 5),
|
||||
workspaceId
|
||||
})
|
||||
const workspacePlanName = 'pro'
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryNew({
|
||||
getWorkspacePlan: async () => ({
|
||||
name: workspacePlanName,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
return 10
|
||||
},
|
||||
getWorkspacePlanProductId: ({ workspacePlan }) => {
|
||||
return workspacePlan === workspacePlanName
|
||||
? productId
|
||||
: cryptoRandomString({ length: 10 })
|
||||
},
|
||||
reconcileSubscriptionData: async () => {
|
||||
expect.fail()
|
||||
}
|
||||
})
|
||||
await downscaleSubscription({ workspaceSubscription })
|
||||
})
|
||||
it('reconciles the subscription to the new seat values', async () => {
|
||||
const workspaceId = cryptoRandomString({ length: 10 })
|
||||
const proPriceId = cryptoRandomString({ length: 10 })
|
||||
const proProductId = cryptoRandomString({ length: 10 })
|
||||
const proQuantity = 10
|
||||
const proSubscriptionItemId = cryptoRandomString({ length: 10 })
|
||||
|
||||
const subscriptionData = createTestSubscriptionData({
|
||||
products: [
|
||||
{
|
||||
priceId: proPriceId,
|
||||
productId: proProductId,
|
||||
quantity: proQuantity,
|
||||
subscriptionItemId: proSubscriptionItemId
|
||||
}
|
||||
]
|
||||
})
|
||||
const testWorkspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData,
|
||||
workspaceId
|
||||
})
|
||||
const workspacePlanName = 'pro'
|
||||
|
||||
let reconciledSub: SubscriptionDataInput | undefined = undefined
|
||||
const downscaleSubscription = downscaleWorkspaceSubscriptionFactoryNew({
|
||||
getWorkspacePlan: async () => ({
|
||||
name: workspacePlanName,
|
||||
workspaceId,
|
||||
createdAt: new Date(),
|
||||
status: 'valid'
|
||||
}),
|
||||
countSeatsByTypeInWorkspace: async () => {
|
||||
return 5
|
||||
},
|
||||
getWorkspacePlanProductId: () => {
|
||||
return proProductId
|
||||
},
|
||||
reconcileSubscriptionData: async ({ subscriptionData }) => {
|
||||
reconciledSub = subscriptionData
|
||||
}
|
||||
})
|
||||
await downscaleSubscription({ workspaceSubscription: testWorkspaceSubscription })
|
||||
|
||||
expect(
|
||||
reconciledSub!.products.find((p) => p.productId === proProductId)?.quantity
|
||||
).to.be.equal(5)
|
||||
})
|
||||
})
|
||||
describe('manageSubscriptionDownscaleFactoryOld, creates a function, that', () => {
|
||||
it('still updates the monthly billing cycle end, even if subscription reconciliation fails', async () => {
|
||||
const testWorkspaceSubscription = createTestWorkspaceSubscription({
|
||||
billingInterval: 'monthly',
|
||||
currentBillingCycleEnd: new Date(2034, 11, 5)
|
||||
})
|
||||
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
|
||||
await manageSubscriptionDownscaleFactory({
|
||||
await manageSubscriptionDownscaleFactoryOld({
|
||||
getWorkspaceSubscriptions: async () => [testWorkspaceSubscription],
|
||||
downscaleWorkspaceSubscription: async () => {
|
||||
throw new Error('kabumm')
|
||||
@@ -1222,7 +1390,7 @@ describe('subscriptions @gatekeeper', () => {
|
||||
currentBillingCycleEnd: new Date(2034, 11, 5)
|
||||
})
|
||||
let updatedWorkspaceSubscription: WorkspaceSubscription | undefined = undefined
|
||||
await manageSubscriptionDownscaleFactory({
|
||||
await manageSubscriptionDownscaleFactoryOld({
|
||||
getWorkspaceSubscriptions: async () => [testWorkspaceSubscription],
|
||||
downscaleWorkspaceSubscription: async () => {
|
||||
throw new Error('kabumm')
|
||||
@@ -1593,7 +1761,8 @@ describe('subscriptions @gatekeeper', () => {
|
||||
customerId: cryptoRandomString({ length: 10 }),
|
||||
subscriptionId: cryptoRandomString({ length: 10 }),
|
||||
status: 'active',
|
||||
products: []
|
||||
products: [],
|
||||
currentPeriodEnd: new Date()
|
||||
}
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData
|
||||
@@ -1657,7 +1826,8 @@ describe('subscriptions @gatekeeper', () => {
|
||||
quantity: 20,
|
||||
subscriptionItemId: cryptoRandomString({ length: 10 })
|
||||
}
|
||||
]
|
||||
],
|
||||
currentPeriodEnd: new Date()
|
||||
}
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData,
|
||||
@@ -2044,7 +2214,8 @@ describe('subscriptions @gatekeeper', () => {
|
||||
customerId: cryptoRandomString({ length: 10 }),
|
||||
subscriptionId: cryptoRandomString({ length: 10 }),
|
||||
status: 'active',
|
||||
products: []
|
||||
products: [],
|
||||
currentPeriodEnd: new Date()
|
||||
}
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData
|
||||
@@ -2102,7 +2273,8 @@ describe('subscriptions @gatekeeper', () => {
|
||||
quantity: 10,
|
||||
subscriptionItemId: cryptoRandomString({ length: 10 })
|
||||
}
|
||||
]
|
||||
],
|
||||
currentPeriodEnd: new Date()
|
||||
}
|
||||
const workspaceSubscription = createTestWorkspaceSubscription({
|
||||
subscriptionData,
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { appRoot, packageRoot } from '@/bootstrap'
|
||||
import { values, merge, camelCase, reduce, intersection } from 'lodash'
|
||||
import { values, merge, camelCase, reduce, intersection, difference } from 'lodash'
|
||||
import baseTypeDefs from '@/modules/core/graph/schema/baseTypeDefs'
|
||||
import { scalarResolvers } from '@/modules/core/graph/scalars'
|
||||
import { makeExecutableSchema } from '@graphql-tools/schema'
|
||||
import { moduleLogger } from '@/observability/logging'
|
||||
import { addMocksToSchema } from '@graphql-tools/mock'
|
||||
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
|
||||
import { isNonNullable } from '@speckle/shared'
|
||||
import { isNonNullable, Optional, Authz } from '@speckle/shared'
|
||||
import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
|
||||
import type { Express } from 'express'
|
||||
import { RequestDataLoadersBuilder } from '@/modules/shared/helpers/graphqlHelper'
|
||||
@@ -22,9 +22,14 @@ import {
|
||||
} from '@/modules/core/graph/helpers/directiveHelper'
|
||||
import { AppMocksConfig } from '@/modules/mocks'
|
||||
import { SpeckleModuleMocksConfig } from '@/modules/shared/helpers/mocks'
|
||||
import { LogicError } from '@/modules/shared/errors'
|
||||
import { LoaderConfigurationError, LogicError } from '@/modules/shared/errors'
|
||||
import type { Registry } from 'prom-client'
|
||||
import { validateLoaders } from '@/modules/loaders'
|
||||
import type { defineModuleLoaders } from '@/modules/loaders'
|
||||
import {
|
||||
inMemoryCacheProviderFactory,
|
||||
wrapWithCache
|
||||
} from '@/modules/shared/utils/caching'
|
||||
import TTLCache from '@isaacs/ttlcache'
|
||||
|
||||
/**
|
||||
* Cached speckle module requires
|
||||
@@ -128,7 +133,8 @@ export const init = async (params: { app: Express; metricsRegister: Registry })
|
||||
await module.finalize?.({ app, isInitial, metricsRegister })
|
||||
}
|
||||
|
||||
validateLoaders()
|
||||
// Validate & cache authz loaders
|
||||
await moduleAuthLoaders()
|
||||
|
||||
hasInitializationOccurred = true
|
||||
}
|
||||
@@ -148,10 +154,12 @@ export const shutdown = async () => {
|
||||
*/
|
||||
export const graphDataloadersBuilders = (): RequestDataLoadersBuilder<any>[] => {
|
||||
let dataLoaders: RequestDataLoadersBuilder<any>[] = []
|
||||
const enabledModuleNames = getEnabledModuleNames()
|
||||
|
||||
// load code modules from /modules
|
||||
const codeModuleDirs = fs.readdirSync(`${appRoot}/modules`)
|
||||
codeModuleDirs.forEach((file) => {
|
||||
if (!enabledModuleNames.includes(file)) return
|
||||
const fullPath = path.join(`${appRoot}/modules`, file)
|
||||
|
||||
// load dataloaders
|
||||
@@ -169,13 +177,15 @@ export const graphDataloadersBuilders = (): RequestDataLoadersBuilder<any>[] =>
|
||||
}
|
||||
|
||||
/**
|
||||
* GQL components will be loaded even from disabled modules to avoid schema complexity, so ensure
|
||||
* that resolvers return valid values even if the module is disabled
|
||||
* GQL components - typedefs, resolvers, directives
|
||||
* (assets & directives will be loaded from even disabled components cause the schema must be static)
|
||||
*/
|
||||
const graphComponents = (): Pick<ApolloServerOptions<any>, 'resolvers'> & {
|
||||
directiveBuilders: Record<string, GraphqlDirectiveBuilder>
|
||||
typeDefs: string[]
|
||||
} => {
|
||||
const enabledModuleNames = getEnabledModuleNames()
|
||||
|
||||
// Base query and mutation to allow for type extension by modules.
|
||||
const typeDefs = [baseTypeDefs]
|
||||
|
||||
@@ -197,11 +207,12 @@ const graphComponents = (): Pick<ApolloServerOptions<any>, 'resolvers'> & {
|
||||
// load code modules from /modules
|
||||
const codeModuleDirs = fs.readdirSync(`${appRoot}/modules`)
|
||||
codeModuleDirs.forEach((file) => {
|
||||
const isEnabledModule = enabledModuleNames.includes(file)
|
||||
const fullPath = path.join(`${appRoot}/modules`, file)
|
||||
|
||||
// first pass load of resolvers
|
||||
const resolversPath = path.join(fullPath, 'graph', 'resolvers')
|
||||
if (fs.existsSync(resolversPath)) {
|
||||
if (isEnabledModule && fs.existsSync(resolversPath)) {
|
||||
const newResolverObjs = values(autoloadFromDirectory(resolversPath)).map((o) =>
|
||||
'default' in o ? o.default : o
|
||||
)
|
||||
@@ -304,3 +315,72 @@ export const moduleMockConfigs = (
|
||||
|
||||
return mockConfigs
|
||||
}
|
||||
|
||||
export const moduleAuthLoaders = async () => {
|
||||
const enabledModuleNames = getEnabledModuleNames()
|
||||
|
||||
let loaders: Partial<Authz.AuthCheckContextLoaders> = {}
|
||||
|
||||
// load auth loaders from /modules and in same order as the whitelist
|
||||
const codeModuleDirs = fs.readdirSync(`${appRoot}/modules`)
|
||||
const coreModuleDirsOrdered = intersection(enabledModuleNames, codeModuleDirs)
|
||||
for (const moduleName of coreModuleDirsOrdered) {
|
||||
const fullModulePath = path.join(`${appRoot}/modules`, moduleName)
|
||||
const loadersFolderPath = path.join(fullModulePath, 'authz', 'loaders')
|
||||
if (!fs.existsSync(loadersFolderPath)) continue
|
||||
|
||||
// We only take the first loaders.ts file we find (for now)
|
||||
const moduleLoadersBuilderFn = values(autoloadFromDirectory(loadersFolderPath))
|
||||
.map((l) => l.default)
|
||||
.filter(isNonNullable)[0] as Optional<ReturnType<typeof defineModuleLoaders>>
|
||||
|
||||
loaders = {
|
||||
...loaders,
|
||||
...(await moduleLoadersBuilderFn?.())
|
||||
}
|
||||
}
|
||||
|
||||
// validate that all were loaded
|
||||
const notFoundKeys = difference(
|
||||
Object.values(Authz.AuthCheckContextLoaderKeys),
|
||||
Object.keys(loaders)
|
||||
)
|
||||
if (notFoundKeys.length) {
|
||||
throw new LoaderConfigurationError(
|
||||
`Missing authz loaders found: ${notFoundKeys.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const allLoaders = loaders as Authz.AuthCheckContextLoaders
|
||||
|
||||
/**
|
||||
* Add inmemory caching to all loaders. Since the loaders & their caches are scoped to each request and these checks
|
||||
* occur before any mutations, we can safely cache them in memory with a long ttl.
|
||||
*
|
||||
* In edge cases - the caches can be cleared
|
||||
*/
|
||||
const cache = new TTLCache<string, unknown>()
|
||||
const loadersWithCache: Authz.AuthCheckContextLoaders = Object.entries(
|
||||
allLoaders
|
||||
).reduce((acc, entry) => {
|
||||
const key = entry[0] as Authz.AuthCheckContextLoaderKeys
|
||||
const loader = entry[1] as Authz.AllAuthCheckContextLoaders[typeof key]
|
||||
|
||||
const newLoader = wrapWithCache<any, any>({
|
||||
resolver: loader,
|
||||
name: `authzLoader:${key}`,
|
||||
// since its the inmemory cache, we dont have to worry about true-myth results being
|
||||
// serialized and deserialized as they would be with redis
|
||||
cacheProvider: inMemoryCacheProviderFactory({ cache }),
|
||||
ttlMs: 1000 * 60 * 60 // 1 hour (longer than any req will be)
|
||||
})
|
||||
acc[key] = newLoader
|
||||
|
||||
return acc
|
||||
}, {} as Authz.AuthCheckContextLoaders)
|
||||
|
||||
return {
|
||||
loaders: loadersWithCache,
|
||||
clearCache: () => cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,8 @@
|
||||
import { LoaderConfigurationError } from '@/modules/shared/errors'
|
||||
import { Authz } from '@speckle/shared'
|
||||
import { Authz, MaybeAsync } from '@speckle/shared'
|
||||
|
||||
let cachedLoaders: Partial<Authz.AuthCheckContextLoaders> = {}
|
||||
|
||||
const loaderKeys: (keyof Authz.AuthCheckContextLoaders)[] = [
|
||||
'getEnv',
|
||||
'getProject',
|
||||
'getProjectRole',
|
||||
'getServerRole',
|
||||
'getWorkspace',
|
||||
'getWorkspaceRole',
|
||||
'getWorkspaceSsoProvider',
|
||||
'getWorkspaceSsoSession'
|
||||
]
|
||||
|
||||
export const defineLoaders = (
|
||||
loaders: Partial<Authz.AuthCheckContextLoaders>
|
||||
): void => {
|
||||
for (const key of Object.keys(loaders)) {
|
||||
if (!loaderKeys.includes(key as keyof Authz.AuthCheckContextLoaders)) {
|
||||
throw new LoaderConfigurationError(
|
||||
`Attempted to define loader with unknown key: ${key}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
cachedLoaders = {
|
||||
...cachedLoaders,
|
||||
...loaders
|
||||
}
|
||||
}
|
||||
|
||||
const isValidLoaders = (
|
||||
loaders: Partial<Authz.AuthCheckContextLoaders>
|
||||
): loaders is Authz.AuthCheckContextLoaders => {
|
||||
return loaderKeys.every((key) => !!loaders[key])
|
||||
}
|
||||
|
||||
export const validateLoaders = () => {
|
||||
if (!isValidLoaders(cachedLoaders)) {
|
||||
throw new LoaderConfigurationError()
|
||||
}
|
||||
}
|
||||
|
||||
export const getLoaders = (): Authz.AuthCheckContextLoaders => {
|
||||
if (!isValidLoaders(cachedLoaders)) {
|
||||
throw new LoaderConfigurationError('Attempted to reference invalid loaders.')
|
||||
}
|
||||
return cachedLoaders
|
||||
// define being an arg simplifes usage in export default calls
|
||||
export const defineModuleLoaders = (
|
||||
define: () => MaybeAsync<Partial<Authz.AuthCheckContextLoaders>>
|
||||
) => {
|
||||
return async () => await define()
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export const consumePreviewResultFactory =
|
||||
|
||||
switch (previewResult.status) {
|
||||
case 'error':
|
||||
log.error(previewMessage)
|
||||
log.error({ reason: previewResult.reason }, previewMessage)
|
||||
await upsertObjectPreview({
|
||||
objectPreview: {
|
||||
objectId,
|
||||
|
||||
@@ -53,7 +53,7 @@ export type SpeckleModule<T extends Record<string, unknown> = Record<string, unk
|
||||
|
||||
export type GraphQLContext = BaseContext &
|
||||
AuthContext & {
|
||||
authPolicies: Authz.AuthPolices
|
||||
authPolicies: Authz.AuthPolicies & { clearCache: () => void }
|
||||
/**
|
||||
* Request-scoped GraphQL dataloaders
|
||||
* @see https://github.com/graphql/dataloader
|
||||
|
||||
@@ -24,13 +24,11 @@ import {
|
||||
MaybeNullOrUndefined,
|
||||
Nullable
|
||||
} from '@/modules/shared/helpers/typeHelper'
|
||||
import { Authz, Optional, wait } from '@speckle/shared'
|
||||
import { Authz, wait } from '@speckle/shared'
|
||||
import { mixpanel } from '@/modules/shared/utils/mixpanel'
|
||||
import * as Observability from '@speckle/shared/dist/commonjs/observability/index.js'
|
||||
import { pino } from 'pino'
|
||||
import { getIpFromRequest } from '@/modules/shared/utils/ip'
|
||||
import { Netmask } from 'netmask'
|
||||
import { Merge } from 'type-fest'
|
||||
import { resourceAccessRuleToIdentifier } from '@/modules/core/helpers/token'
|
||||
import { delayGraphqlResponsesBy } from '@/modules/shared/helpers/envHelper'
|
||||
import { subscriptionLogger } from '@/observability/logging'
|
||||
@@ -48,7 +46,7 @@ import { getTokenAppInfoFactory } from '@/modules/auth/repositories/apps'
|
||||
import { getUserRoleFactory } from '@/modules/core/repositories/users'
|
||||
import { UserInputError } from '@/modules/core/errors/userinput'
|
||||
import compression from 'compression'
|
||||
import { getLoaders } from '@/modules/loaders'
|
||||
import { moduleAuthLoaders } from '@/modules'
|
||||
|
||||
export const authMiddlewareCreator = (
|
||||
steps: AuthPipelineFunction[]
|
||||
@@ -170,28 +168,17 @@ export const authContextMiddleware: RequestHandler = async (req, res, next) => {
|
||||
next()
|
||||
}
|
||||
|
||||
export async function addLoadersToCtx(
|
||||
ctx: Merge<Omit<GraphQLContext, 'loaders'>, { log?: Optional<pino.Logger> }>,
|
||||
options?: Partial<{ cleanLoadersEarly: boolean }>
|
||||
): Promise<GraphQLContext> {
|
||||
const log =
|
||||
ctx.log || Observability.extendLoggerComponent(Observability.getLogger(), 'graphql')
|
||||
const loaders = await buildRequestLoaders(ctx, options)
|
||||
return { ...ctx, loaders, log }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context for GQL operations
|
||||
*/
|
||||
export async function buildContext({
|
||||
req,
|
||||
token,
|
||||
cleanLoadersEarly
|
||||
}: {
|
||||
req: MaybeNullOrUndefined<Request>
|
||||
export async function buildContext(params?: {
|
||||
req?: MaybeNullOrUndefined<Request>
|
||||
token?: Nullable<string>
|
||||
authContext?: AuthContext
|
||||
cleanLoadersEarly?: boolean
|
||||
}): Promise<GraphQLContext> {
|
||||
const { req, token, authContext, cleanLoadersEarly } = params || {}
|
||||
|
||||
const validateToken = validateTokenFactory({
|
||||
revokeUserTokenById: revokeUserTokenByIdFactory({ db }),
|
||||
getApiTokenById: getApiTokenByIdFactory({ db }),
|
||||
@@ -207,6 +194,7 @@ export async function buildContext({
|
||||
})
|
||||
|
||||
const ctx =
|
||||
authContext ||
|
||||
req?.context ||
|
||||
(await createAuthContextFromToken(token ?? getTokenFromRequest(req), validateToken))
|
||||
|
||||
@@ -221,17 +209,23 @@ export async function buildContext({
|
||||
await wait(delay)
|
||||
}
|
||||
|
||||
const authPolicies = Authz.authPoliciesFactory(getLoaders())
|
||||
const [authLoaders, dataLoaders] = await Promise.all([
|
||||
moduleAuthLoaders(),
|
||||
buildRequestLoaders(ctx, { cleanLoadersEarly })
|
||||
])
|
||||
const authPolicies = Authz.authPoliciesFactory(authLoaders.loaders)
|
||||
|
||||
// Adding request data loaders
|
||||
return await addLoadersToCtx(
|
||||
{
|
||||
...ctx,
|
||||
log,
|
||||
authPolicies
|
||||
},
|
||||
{ cleanLoadersEarly }
|
||||
)
|
||||
return {
|
||||
...ctx,
|
||||
loaders: dataLoaders,
|
||||
log,
|
||||
authPolicies: {
|
||||
...authPolicies,
|
||||
clearCache: () => {
|
||||
authLoaders.clearCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+19
-9
@@ -1,5 +1,5 @@
|
||||
import { db } from '@/db/knex'
|
||||
import { defineLoaders } from '@/modules/loaders'
|
||||
import { defineModuleLoaders } from '@/modules/loaders'
|
||||
import {
|
||||
getUserSsoSessionFactory,
|
||||
getWorkspaceSsoProviderRecordFactory
|
||||
@@ -8,29 +8,39 @@ import {
|
||||
getWorkspaceFactory,
|
||||
getWorkspaceRoleForUserFactory
|
||||
} from '@/modules/workspaces/repositories/workspaces'
|
||||
import { Authz } from '@speckle/shared'
|
||||
import { err, ok } from 'true-myth/result'
|
||||
|
||||
export const defineModuleLoaders = () => {
|
||||
defineLoaders({
|
||||
getWorkspace: getWorkspaceFactory({ db }),
|
||||
export default defineModuleLoaders(async () => {
|
||||
const getWorkspace = getWorkspaceFactory({ db })
|
||||
return {
|
||||
getWorkspace: async ({ workspaceId }) => {
|
||||
const workspace = await getWorkspace({ workspaceId })
|
||||
if (!workspace) return err(Authz.WorkspaceNotFoundError)
|
||||
return ok(workspace)
|
||||
},
|
||||
getWorkspaceRole: async ({ userId, workspaceId }) => {
|
||||
const role = await getWorkspaceRoleForUserFactory({ db })({
|
||||
userId,
|
||||
workspaceId
|
||||
})
|
||||
return role?.role ?? null
|
||||
if (!role) return err(Authz.WorkspaceRoleNotFoundError)
|
||||
return ok(role.role)
|
||||
},
|
||||
getWorkspaceSsoSession: async ({ userId, workspaceId }) => {
|
||||
const ssoSession = await getUserSsoSessionFactory({ db })({
|
||||
userId,
|
||||
workspaceId
|
||||
})
|
||||
return ssoSession ?? null
|
||||
if (!ssoSession) return err(Authz.WorkspaceSsoSessionNotFoundError)
|
||||
return ok(ssoSession)
|
||||
},
|
||||
getWorkspaceSsoProvider: async ({ workspaceId }) => {
|
||||
const ssoProvider = await getWorkspaceSsoProviderRecordFactory({ db })({
|
||||
workspaceId
|
||||
})
|
||||
return ssoProvider ?? null
|
||||
if (!ssoProvider) return err(Authz.WorkspaceSsoProviderNotFoundError)
|
||||
return ok(ssoProvider)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -10,7 +10,6 @@ import { initializeEventListenersFactory } from '@/modules/workspaces/events/eve
|
||||
import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense'
|
||||
import { getSsoRouter } from '@/modules/workspaces/rest/sso'
|
||||
import { InvalidLicenseError } from '@/modules/gatekeeper/errors/license'
|
||||
import { defineModuleLoaders } from '@/modules/workspaces/authz'
|
||||
|
||||
const { FF_WORKSPACES_MODULE_ENABLED, FF_WORKSPACES_SSO_ENABLED } = getFeatureFlags()
|
||||
|
||||
@@ -45,8 +44,6 @@ const workspacesModule: SpeckleModule = {
|
||||
quitListeners = initializeEventListenersFactory({ db })()
|
||||
}
|
||||
await Promise.all([initScopes(), initRoles()])
|
||||
|
||||
defineModuleLoaders()
|
||||
},
|
||||
shutdown() {
|
||||
if (!FF_WORKSPACES_MODULE_ENABLED) return
|
||||
|
||||
@@ -195,6 +195,8 @@ export const createTestWorkspace = async (
|
||||
}
|
||||
|
||||
if (addSubscription) {
|
||||
const aMonthFromNow = new Date()
|
||||
aMonthFromNow.setMonth(new Date().getMonth() + 1)
|
||||
await upsertSubscription({
|
||||
workspaceSubscription: {
|
||||
workspaceId: newWorkspace.id,
|
||||
@@ -207,7 +209,8 @@ export const createTestWorkspace = async (
|
||||
customerId: cryptoRandomString({ length: 10 }),
|
||||
cancelAt: null,
|
||||
status: 'active',
|
||||
products: []
|
||||
products: [],
|
||||
currentPeriodEnd: aMonthFromNow
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineLoaders } from '@/modules/loaders'
|
||||
import { LoaderUnsupportedError } from '@/modules/shared/errors'
|
||||
|
||||
export const defineModuleLoaders = () => {
|
||||
defineLoaders({
|
||||
getWorkspace: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
},
|
||||
getWorkspaceRole: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
},
|
||||
getWorkspaceSsoSession: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
},
|
||||
getWorkspaceSsoProvider: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineModuleLoaders } from '@/modules/loaders'
|
||||
import { LoaderUnsupportedError } from '@/modules/shared/errors'
|
||||
|
||||
export default defineModuleLoaders(() => ({
|
||||
getWorkspace: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
},
|
||||
getWorkspaceRole: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
},
|
||||
getWorkspaceSsoSession: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
},
|
||||
getWorkspaceSsoProvider: async () => {
|
||||
throw new LoaderUnsupportedError()
|
||||
}
|
||||
}))
|
||||
@@ -1,8 +1,6 @@
|
||||
import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
|
||||
import { defineModuleLoaders } from '@/modules/workspacesCore/authz'
|
||||
import { moduleLogger } from '@/observability/logging'
|
||||
|
||||
export const init: SpeckleModule['init'] = () => {
|
||||
moduleLogger.info('⚒️ Init workspaces core module')
|
||||
defineModuleLoaders()
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
"string-pixel-width": "^1.10.0",
|
||||
"stripe": "^17.1.0",
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"true-myth": "^8.5.0",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"undici": "^5.28.4",
|
||||
"verror": "^1.10.1",
|
||||
|
||||
@@ -3,11 +3,10 @@ import { DocumentNode, FormattedExecutionResult } from 'graphql'
|
||||
import { GraphQLContext } from '@/modules/shared/helpers/typeHelper'
|
||||
import { TypedDocumentNode } from '@graphql-typed-document-node/core'
|
||||
import { buildApolloServer, buildApolloSubscriptionServer } from '@/app'
|
||||
import { addLoadersToCtx } from '@/modules/shared/middleware'
|
||||
import { buildContext } from '@/modules/shared/middleware'
|
||||
import { Roles } from '@/modules/core/helpers/mainConstants'
|
||||
import {
|
||||
AllScopes,
|
||||
Authz,
|
||||
buildManualPromise,
|
||||
ensureError,
|
||||
MaybeAsync,
|
||||
@@ -34,7 +33,6 @@ import { PingPongDocument } from '@/test/graphql/generated/graphql'
|
||||
import { BaseError } from '@/modules/shared/errors'
|
||||
import EventEmitter from 'eventemitter2'
|
||||
import { expectToThrow } from '@/test/assertionHelper'
|
||||
import { getLoaders } from '@/modules/loaders'
|
||||
|
||||
type TypedGraphqlResponse<R = Record<string, any>> = GraphQLResponse<R>
|
||||
|
||||
@@ -110,30 +108,32 @@ export async function executeOperation<
|
||||
export const createTestContext = async (
|
||||
ctx?: Partial<GraphQLContext>
|
||||
): Promise<GraphQLContext> =>
|
||||
addLoadersToCtx({
|
||||
auth: false,
|
||||
userId: undefined,
|
||||
role: undefined,
|
||||
token: undefined,
|
||||
scopes: [],
|
||||
stream: undefined,
|
||||
err: undefined,
|
||||
authPolicies: Authz.authPoliciesFactory(getLoaders()),
|
||||
...(ctx || {})
|
||||
await buildContext({
|
||||
authContext: {
|
||||
auth: false,
|
||||
userId: undefined,
|
||||
role: undefined,
|
||||
token: undefined,
|
||||
scopes: [],
|
||||
stream: undefined,
|
||||
err: undefined,
|
||||
...(ctx || {})
|
||||
}
|
||||
})
|
||||
|
||||
export const createAuthedTestContext = async (
|
||||
userId: string,
|
||||
ctxOverrides?: Partial<GraphQLContext>
|
||||
): Promise<GraphQLContext> =>
|
||||
addLoadersToCtx({
|
||||
auth: true,
|
||||
userId,
|
||||
role: Roles.Server.User,
|
||||
token: 'asd',
|
||||
scopes: AllScopes,
|
||||
authPolicies: Authz.authPoliciesFactory(getLoaders()),
|
||||
...(ctxOverrides || {})
|
||||
await buildContext({
|
||||
authContext: {
|
||||
auth: true,
|
||||
userId,
|
||||
role: Roles.Server.User,
|
||||
token: 'asd',
|
||||
scopes: AllScopes,
|
||||
...(ctxOverrides || {})
|
||||
}
|
||||
})
|
||||
|
||||
const buildMergedContext = async (params: {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
/* Modules */
|
||||
"module": "commonjs" /* Specify what module code is generated. */,
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||
"baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */,
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Only need to do this because our CommonJS app does not support true-myth's export maps
|
||||
declare module 'true-myth/result' {
|
||||
import { Result } from 'true-myth/dist/cjs/result.cjs'
|
||||
export * from 'true-myth/dist/cjs/result.cjs'
|
||||
export default Result
|
||||
}
|
||||
|
||||
declare module 'true-myth' {
|
||||
export * from 'true-myth/dist/cjs/index.cjs'
|
||||
}
|
||||
@@ -36,6 +36,12 @@ const configs = [
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/require-await': 'off' // so we can easily make sync mocked loaders -> async
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"true-myth": "^8.5.0",
|
||||
"type-fest": "^3.11.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -6,13 +6,15 @@ import {
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { Project } from '../domain/projects/types.js'
|
||||
import { Roles, UncoveredError } from '../../core/index.js'
|
||||
import { err, ok } from 'true-myth/result'
|
||||
import { ProjectNotFoundError, ProjectRoleNotFoundError } from '../domain/authErrors.js'
|
||||
|
||||
describe('project checks', () => {
|
||||
describe('requireExactProjectVisibilityFactory returns a function, that', () => {
|
||||
it('throws if project does not exist', async () => {
|
||||
const requireExactProjectVisibility = requireExactProjectVisibilityFactory({
|
||||
loaders: {
|
||||
getProject: () => Promise.resolve(null)
|
||||
getProject: () => Promise.resolve(err(ProjectNotFoundError))
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
@@ -26,9 +28,11 @@ describe('project checks', () => {
|
||||
const result = await requireExactProjectVisibilityFactory({
|
||||
loaders: {
|
||||
getProject: () =>
|
||||
Promise.resolve({
|
||||
isDiscoverable: true
|
||||
} as Project)
|
||||
Promise.resolve(
|
||||
ok({
|
||||
isDiscoverable: true
|
||||
} as Project)
|
||||
)
|
||||
}
|
||||
})({
|
||||
projectVisibility: 'linkShareable',
|
||||
@@ -40,9 +44,11 @@ describe('project checks', () => {
|
||||
const result = await requireExactProjectVisibilityFactory({
|
||||
loaders: {
|
||||
getProject: () =>
|
||||
Promise.resolve({
|
||||
isPublic: true
|
||||
} as Project)
|
||||
Promise.resolve(
|
||||
ok({
|
||||
isPublic: true
|
||||
} as Project)
|
||||
)
|
||||
}
|
||||
})({
|
||||
projectVisibility: 'public',
|
||||
@@ -54,10 +60,12 @@ describe('project checks', () => {
|
||||
const result = await requireExactProjectVisibilityFactory({
|
||||
loaders: {
|
||||
getProject: () =>
|
||||
Promise.resolve({
|
||||
isDiscoverable: false,
|
||||
isPublic: false
|
||||
} as Project)
|
||||
Promise.resolve(
|
||||
ok({
|
||||
isDiscoverable: false,
|
||||
isPublic: false
|
||||
} as Project)
|
||||
)
|
||||
}
|
||||
})({
|
||||
projectVisibility: 'private',
|
||||
@@ -70,10 +78,12 @@ describe('project checks', () => {
|
||||
requireExactProjectVisibilityFactory({
|
||||
loaders: {
|
||||
getProject: () =>
|
||||
Promise.resolve({
|
||||
isDiscoverable: false,
|
||||
isPublic: false
|
||||
} as Project)
|
||||
Promise.resolve(
|
||||
ok({
|
||||
isDiscoverable: false,
|
||||
isPublic: false
|
||||
} as Project)
|
||||
)
|
||||
}
|
||||
})({
|
||||
// @ts-expect-error this is what im testing here
|
||||
@@ -87,7 +97,7 @@ describe('project checks', () => {
|
||||
it('returns false, if there is no role for the user', async () => {
|
||||
const result = await requireMinimumProjectRoleFactory({
|
||||
loaders: {
|
||||
getProjectRole: () => Promise.resolve(null)
|
||||
getProjectRole: () => Promise.resolve(err(ProjectRoleNotFoundError))
|
||||
}
|
||||
})({
|
||||
projectId: cryptoRandomString({ length: 10 }),
|
||||
@@ -99,7 +109,7 @@ describe('project checks', () => {
|
||||
it('returns false, if the role is not sufficient', async () => {
|
||||
const result = await requireMinimumProjectRoleFactory({
|
||||
loaders: {
|
||||
getProjectRole: () => Promise.resolve(Roles.Stream.Reviewer)
|
||||
getProjectRole: () => Promise.resolve(ok(Roles.Stream.Reviewer))
|
||||
}
|
||||
})({
|
||||
projectId: cryptoRandomString({ length: 10 }),
|
||||
@@ -111,7 +121,7 @@ describe('project checks', () => {
|
||||
it('returns true, if the role is sufficient', async () => {
|
||||
const result = await requireMinimumProjectRoleFactory({
|
||||
loaders: {
|
||||
getProjectRole: () => Promise.resolve(Roles.Stream.Contributor)
|
||||
getProjectRole: () => Promise.resolve(ok(Roles.Stream.Contributor))
|
||||
}
|
||||
})({
|
||||
projectId: cryptoRandomString({ length: 10 }),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { StreamRoles, throwUncoveredError } from '../../core/index.js'
|
||||
import { ProjectNotFoundError } from '../domain/errors.js'
|
||||
import { AuthCheckContext } from '../domain/loaders.js'
|
||||
import { AuthCheckContext, AuthCheckContextLoaderKeys } from '../domain/loaders.js'
|
||||
import { isMinimumProjectRole } from '../domain/projects/logic.js'
|
||||
import { ProjectVisibility } from '../domain/projects/types.js'
|
||||
|
||||
export const requireExactProjectVisibilityFactory =
|
||||
({ loaders }: AuthCheckContext<'getProject'>) =>
|
||||
({ loaders }: AuthCheckContext<typeof AuthCheckContextLoaderKeys.getProject>) =>
|
||||
async (args: {
|
||||
projectVisibility: ProjectVisibility
|
||||
projectId: string
|
||||
@@ -13,22 +13,22 @@ export const requireExactProjectVisibilityFactory =
|
||||
const { projectId, projectVisibility } = args
|
||||
|
||||
const project = await loaders.getProject({ projectId })
|
||||
if (!project) throw new ProjectNotFoundError({ projectId })
|
||||
if (!project.isOk) throw new ProjectNotFoundError({ projectId })
|
||||
|
||||
switch (projectVisibility) {
|
||||
case 'linkShareable':
|
||||
return project.isDiscoverable === true
|
||||
return project.value.isDiscoverable === true
|
||||
case 'public':
|
||||
return project.isPublic === true
|
||||
return project.value.isPublic === true
|
||||
case 'private':
|
||||
return project.isPublic !== true && project.isDiscoverable !== true
|
||||
return project.value.isPublic !== true && project.value.isDiscoverable !== true
|
||||
default:
|
||||
throwUncoveredError(projectVisibility)
|
||||
}
|
||||
}
|
||||
|
||||
export const requireMinimumProjectRoleFactory =
|
||||
({ loaders }: AuthCheckContext<'getProjectRole'>) =>
|
||||
({ loaders }: AuthCheckContext<typeof AuthCheckContextLoaderKeys.getProjectRole>) =>
|
||||
async (args: {
|
||||
userId: string
|
||||
projectId: string
|
||||
@@ -37,7 +37,7 @@ export const requireMinimumProjectRoleFactory =
|
||||
const { userId, projectId, role: requiredProjectRole } = args
|
||||
|
||||
const userProjectRole = await loaders.getProjectRole({ userId, projectId })
|
||||
return userProjectRole
|
||||
? isMinimumProjectRole(userProjectRole, requiredProjectRole)
|
||||
return userProjectRole.isOk
|
||||
? isMinimumProjectRole(userProjectRole.value, requiredProjectRole)
|
||||
: false
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { requireExactServerRole } from './serverRole.js'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { err, ok } from 'true-myth/result'
|
||||
import { ServerRoleNotFoundError } from '../domain/authErrors.js'
|
||||
|
||||
describe('requireExactServerRole returns a function, that', () => {
|
||||
it('returns false for mismatch roles', async () => {
|
||||
const result = await requireExactServerRole({
|
||||
loaders: {
|
||||
getServerRole: () => Promise.resolve('server:user')
|
||||
getServerRole: () => Promise.resolve(ok('server:user'))
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
@@ -17,7 +19,7 @@ describe('requireExactServerRole returns a function, that', () => {
|
||||
it('returns false for users without roles', async () => {
|
||||
const result = await requireExactServerRole({
|
||||
loaders: {
|
||||
getServerRole: () => Promise.resolve(null)
|
||||
getServerRole: () => Promise.resolve(err(ServerRoleNotFoundError))
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
@@ -28,7 +30,7 @@ describe('requireExactServerRole returns a function, that', () => {
|
||||
it('returns true for matching roles', async () => {
|
||||
const result = await requireExactServerRole({
|
||||
loaders: {
|
||||
getServerRole: () => Promise.resolve('server:admin')
|
||||
getServerRole: () => Promise.resolve(ok('server:admin'))
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ServerRoles } from '../../core/constants.js'
|
||||
import { AuthCheckContext } from '../domain/loaders.js'
|
||||
import { AuthCheckContext, AuthCheckContextLoaderKeys } from '../domain/loaders.js'
|
||||
|
||||
export const requireExactServerRole =
|
||||
({ loaders }: AuthCheckContext<'getServerRole'>) =>
|
||||
({ loaders }: AuthCheckContext<typeof AuthCheckContextLoaderKeys.getServerRole>) =>
|
||||
async (args: { userId: string; role: ServerRoles }): Promise<boolean> => {
|
||||
const { userId, role: requiredServerRole } = args
|
||||
|
||||
const userServerRole = await loaders.getServerRole({ userId })
|
||||
if (!userServerRole.isOk) return false
|
||||
|
||||
return userServerRole === requiredServerRole
|
||||
return userServerRole.value === requiredServerRole
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
requireMinimumWorkspaceRole
|
||||
} from './workspaceRole.js'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { err, ok } from 'true-myth/result'
|
||||
import { WorkspaceRoleNotFoundError } from '../domain/authErrors.js'
|
||||
|
||||
describe('requireAnyWorkspaceRole returns a function, that', () => {
|
||||
it('returns false if the user has no role', async () => {
|
||||
const result = await requireAnyWorkspaceRole({
|
||||
loaders: {
|
||||
getWorkspaceRole: () => Promise.resolve(null)
|
||||
getWorkspaceRole: () => Promise.resolve(err(WorkspaceRoleNotFoundError))
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
@@ -20,7 +22,7 @@ describe('requireAnyWorkspaceRole returns a function, that', () => {
|
||||
it('returns true if the user has a role', async () => {
|
||||
const result = await requireAnyWorkspaceRole({
|
||||
loaders: {
|
||||
getWorkspaceRole: () => Promise.resolve('workspace:member')
|
||||
getWorkspaceRole: () => Promise.resolve(ok('workspace:member'))
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
@@ -34,7 +36,7 @@ describe('requireMinimumWorkspaceRole returns a function, that', () => {
|
||||
it('returns false if user does not have a role', async () => {
|
||||
const result = await requireMinimumWorkspaceRole({
|
||||
loaders: {
|
||||
getWorkspaceRole: () => Promise.resolve(null)
|
||||
getWorkspaceRole: () => Promise.resolve(err(WorkspaceRoleNotFoundError))
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
@@ -46,7 +48,7 @@ describe('requireMinimumWorkspaceRole returns a function, that', () => {
|
||||
it('returns false if user is below target role', async () => {
|
||||
const result = await requireMinimumWorkspaceRole({
|
||||
loaders: {
|
||||
getWorkspaceRole: () => Promise.resolve('workspace:member')
|
||||
getWorkspaceRole: () => Promise.resolve(ok('workspace:member'))
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
@@ -58,7 +60,7 @@ describe('requireMinimumWorkspaceRole returns a function, that', () => {
|
||||
it('returns true if user matches target role', async () => {
|
||||
const result = await requireMinimumWorkspaceRole({
|
||||
loaders: {
|
||||
getWorkspaceRole: () => Promise.resolve('workspace:member')
|
||||
getWorkspaceRole: () => Promise.resolve(ok('workspace:member'))
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
@@ -70,7 +72,7 @@ describe('requireMinimumWorkspaceRole returns a function, that', () => {
|
||||
it('returns true if user exceeds target role', async () => {
|
||||
const result = await requireMinimumWorkspaceRole({
|
||||
loaders: {
|
||||
getWorkspaceRole: () => Promise.resolve('workspace:admin')
|
||||
getWorkspaceRole: () => Promise.resolve(ok('workspace:admin'))
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { WorkspaceRoles } from '../../core/constants.js'
|
||||
import { AuthCheckContext } from '../domain/loaders.js'
|
||||
import { AuthCheckContext, AuthCheckContextLoaderKeys } from '../domain/loaders.js'
|
||||
import { isMinimumWorkspaceRole } from '../domain/workspaces/logic.js'
|
||||
|
||||
export const requireAnyWorkspaceRole =
|
||||
({ loaders }: AuthCheckContext<'getWorkspaceRole'>) =>
|
||||
({ loaders }: AuthCheckContext<typeof AuthCheckContextLoaderKeys.getWorkspaceRole>) =>
|
||||
async (args: { userId: string; workspaceId: string }): Promise<boolean> => {
|
||||
const { userId, workspaceId } = args
|
||||
|
||||
const userWorkspaceRole = await loaders.getWorkspaceRole({ userId, workspaceId })
|
||||
|
||||
return userWorkspaceRole !== null
|
||||
return userWorkspaceRole.isOk
|
||||
}
|
||||
|
||||
export const requireMinimumWorkspaceRole =
|
||||
({ loaders }: AuthCheckContext<'getWorkspaceRole'>) =>
|
||||
({ loaders }: AuthCheckContext<typeof AuthCheckContextLoaderKeys.getWorkspaceRole>) =>
|
||||
async (args: {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
@@ -23,7 +22,7 @@ export const requireMinimumWorkspaceRole =
|
||||
|
||||
const userWorkspaceRole = await loaders.getWorkspaceRole({ userId, workspaceId })
|
||||
|
||||
return userWorkspaceRole
|
||||
? isMinimumWorkspaceRole(userWorkspaceRole, requiredWorkspaceRole)
|
||||
return userWorkspaceRole.isOk
|
||||
? isMinimumWorkspaceRole(userWorkspaceRole.value, requiredWorkspaceRole)
|
||||
: false
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { requireValidWorkspaceSsoSession } from './workspaceSso.js'
|
||||
import cryptoRandomString from 'crypto-random-string'
|
||||
import { err, ok } from 'true-myth/result'
|
||||
import { WorkspaceSsoSessionNotFoundError } from '../domain/authErrors.js'
|
||||
|
||||
describe('requireValidWorkspaceSsoSession returns a function, that', () => {
|
||||
it('returns false if user does not have an SSO session', async () => {
|
||||
const result = await requireValidWorkspaceSsoSession({
|
||||
loaders: {
|
||||
getWorkspaceSsoSession: () => Promise.resolve(null)
|
||||
getWorkspaceSsoSession: () =>
|
||||
Promise.resolve(err(WorkspaceSsoSessionNotFoundError))
|
||||
}
|
||||
})({
|
||||
userId: cryptoRandomString({ length: 9 }),
|
||||
@@ -25,11 +28,13 @@ describe('requireValidWorkspaceSsoSession returns a function, that', () => {
|
||||
const result = await requireValidWorkspaceSsoSession({
|
||||
loaders: {
|
||||
getWorkspaceSsoSession: () =>
|
||||
Promise.resolve({
|
||||
userId,
|
||||
providerId,
|
||||
validUntil
|
||||
})
|
||||
Promise.resolve(
|
||||
ok({
|
||||
userId,
|
||||
providerId,
|
||||
validUntil
|
||||
})
|
||||
)
|
||||
}
|
||||
})({
|
||||
userId,
|
||||
@@ -48,11 +53,13 @@ describe('requireValidWorkspaceSsoSession returns a function, that', () => {
|
||||
const result = await requireValidWorkspaceSsoSession({
|
||||
loaders: {
|
||||
getWorkspaceSsoSession: () =>
|
||||
Promise.resolve({
|
||||
userId,
|
||||
providerId,
|
||||
validUntil
|
||||
})
|
||||
Promise.resolve(
|
||||
ok({
|
||||
userId,
|
||||
providerId,
|
||||
validUntil
|
||||
})
|
||||
)
|
||||
}
|
||||
})({
|
||||
userId,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { AuthCheckContext } from '../domain/loaders.js'
|
||||
import { AuthCheckContext, AuthCheckContextLoaderKeys } from '../domain/loaders.js'
|
||||
|
||||
export const requireValidWorkspaceSsoSession =
|
||||
({ loaders }: AuthCheckContext<'getWorkspaceSsoSession'>) =>
|
||||
({
|
||||
loaders
|
||||
}: AuthCheckContext<typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession>) =>
|
||||
async (args: { userId: string; workspaceId: string }): Promise<boolean> => {
|
||||
const { userId, workspaceId } = args
|
||||
|
||||
@@ -9,9 +11,10 @@ export const requireValidWorkspaceSsoSession =
|
||||
userId,
|
||||
workspaceId
|
||||
})
|
||||
if (!workspaceSsoSession.isOk) return false
|
||||
|
||||
const isExpiredSession =
|
||||
new Date().getTime() > (workspaceSsoSession?.validUntil?.getTime() ?? 0)
|
||||
new Date().getTime() > workspaceSsoSession.value.validUntil.getTime()
|
||||
|
||||
return !!workspaceSsoSession && !isExpiredSession
|
||||
return !isExpiredSession
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type AuthError<ErrorCode extends string> = {
|
||||
export type AuthError<ErrorCode extends string = string> = {
|
||||
code: ErrorCode
|
||||
message: string
|
||||
}
|
||||
@@ -25,12 +25,42 @@ export const ProjectNoAccessError = defineAuthError({
|
||||
message: 'You do not have access to the project'
|
||||
})
|
||||
|
||||
export const ProjectRoleNotFoundError = defineAuthError({
|
||||
code: 'ProjectRoleNotFound',
|
||||
message: 'Could not resolve your project role'
|
||||
})
|
||||
|
||||
export const WorkspaceNotFoundError = defineAuthError({
|
||||
code: 'WorkspaceNotFound',
|
||||
message: 'Workspace not found'
|
||||
})
|
||||
|
||||
export const WorkspaceNoAccessError = defineAuthError({
|
||||
code: 'WorkspaceNoAccess',
|
||||
message: 'You do not have access to the workspace'
|
||||
})
|
||||
|
||||
export const WorkspaceSsoProviderNotFoundError = defineAuthError({
|
||||
code: 'WorkspaceSsoProviderNotFound',
|
||||
message: 'The workspace SSO provider was not found'
|
||||
})
|
||||
|
||||
export const WorkspaceSsoSessionInvalidError = defineAuthError({
|
||||
code: 'WorkspaceSsoSessionInvalid',
|
||||
message: 'Your workspace SSO session is invalid'
|
||||
})
|
||||
|
||||
export const WorkspaceSsoSessionNotFoundError = defineAuthError({
|
||||
code: 'WorkspaceSsoSessionNotFound',
|
||||
message: 'Your workspace SSO session was not found'
|
||||
})
|
||||
|
||||
export const WorkspaceRoleNotFoundError = defineAuthError({
|
||||
code: 'WorkspaceRoleNotFound',
|
||||
message: 'The user does not have a role in the workspace'
|
||||
})
|
||||
|
||||
export const ServerRoleNotFoundError = defineAuthError({
|
||||
code: 'ServerRoleNotFound',
|
||||
message: 'Could not resolve your server role'
|
||||
})
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
type AuthSuccess = {
|
||||
authorized: true
|
||||
}
|
||||
|
||||
export type AuthFailure<T> = {
|
||||
authorized: false
|
||||
error: T
|
||||
}
|
||||
|
||||
export type AuthResult<T> = AuthSuccess | AuthFailure<T>
|
||||
|
||||
export const authorized = (): AuthSuccess => ({
|
||||
authorized: true
|
||||
})
|
||||
|
||||
export const unauthorized = <T>(error: T): AuthFailure<T> => ({
|
||||
authorized: false,
|
||||
error
|
||||
})
|
||||
@@ -1,3 +1,7 @@
|
||||
import Result from 'true-myth/result'
|
||||
import { ServerRoles } from '../../../core/constants.js'
|
||||
import { ServerRoleNotFoundError } from '../authErrors.js'
|
||||
|
||||
export type GetServerRole = (args: { userId: string }) => Promise<ServerRoles | null>
|
||||
export type GetServerRole = (args: {
|
||||
userId: string
|
||||
}) => Promise<Result<ServerRoles, typeof ServerRoleNotFoundError>>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export class LogicError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectNotFoundError extends Error {
|
||||
constructor({ projectId }: { projectId: string }) {
|
||||
super(`Project with id ${projectId} not found`)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { OverrideProperties } from 'type-fest'
|
||||
import { MaybeAsync } from '../../core/index.js'
|
||||
import type { GetServerRole } from './core/operations.js'
|
||||
import type { GetProject, GetProjectRole } from './projects/operations.js'
|
||||
import type {
|
||||
@@ -8,11 +10,47 @@ import type {
|
||||
GetWorkspaceSsoSession
|
||||
} from './workspaces/operations.js'
|
||||
|
||||
export type AuthCheckContext<LoaderKeys extends keyof AuthCheckContextLoaders> = {
|
||||
loaders: Pick<AuthCheckContextLoaders, LoaderKeys>
|
||||
// utility type that ensures all properties functions that return promises
|
||||
type PromiseAll<T> = {
|
||||
[K in keyof T]: T[K] extends (...args: infer Args) => MaybeAsync<infer Return>
|
||||
? (...args: Args) => Promise<Return>
|
||||
: never
|
||||
}
|
||||
|
||||
export type AuthCheckContextLoaders = {
|
||||
// wrapper type for AllAuthCheckContextLoaders that ensures loaders follow the expected schema
|
||||
type AuthContextLoaderMappingDefinition<
|
||||
Mapping extends {
|
||||
[Key in keyof Mapping]: Key extends AuthCheckContextLoaderKeys
|
||||
? Mapping[Key]
|
||||
: never
|
||||
}
|
||||
> = PromiseAll<
|
||||
OverrideProperties<
|
||||
{
|
||||
[key in AuthCheckContextLoaderKeys]: unknown
|
||||
},
|
||||
Mapping
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* All loaders must be listed here for app startup validation to work properly
|
||||
*/
|
||||
export const AuthCheckContextLoaderKeys = <const>{
|
||||
getEnv: 'getEnv',
|
||||
getProject: 'getProject',
|
||||
getProjectRole: 'getProjectRole',
|
||||
getServerRole: 'getServerRole',
|
||||
getWorkspace: 'getWorkspace',
|
||||
getWorkspaceRole: 'getWorkspaceRole',
|
||||
getWorkspaceSsoProvider: 'getWorkspaceSsoProvider',
|
||||
getWorkspaceSsoSession: 'getWorkspaceSsoSession'
|
||||
}
|
||||
|
||||
export type AuthCheckContextLoaderKeys =
|
||||
(typeof AuthCheckContextLoaderKeys)[keyof typeof AuthCheckContextLoaderKeys]
|
||||
|
||||
export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
|
||||
getEnv: GetEnv
|
||||
getProject: GetProject
|
||||
getProjectRole: GetProjectRole
|
||||
@@ -21,4 +59,12 @@ export type AuthCheckContextLoaders = {
|
||||
getWorkspaceRole: GetWorkspaceRole
|
||||
getWorkspaceSsoProvider: GetWorkspaceSsoProvider
|
||||
getWorkspaceSsoSession: GetWorkspaceSsoSession
|
||||
}>
|
||||
|
||||
export type AuthCheckContextLoaders<
|
||||
LoaderKeys extends AuthCheckContextLoaderKeys = AuthCheckContextLoaderKeys
|
||||
> = Pick<AllAuthCheckContextLoaders, LoaderKeys>
|
||||
|
||||
export type AuthCheckContext<LoaderKeys extends AuthCheckContextLoaderKeys> = {
|
||||
loaders: AuthCheckContextLoaders<LoaderKeys>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import Result from 'true-myth/result'
|
||||
import { AuthError } from './authErrors.js'
|
||||
import { AuthCheckContextLoaderKeys, AuthCheckContextLoaders } from './loaders.js'
|
||||
|
||||
export type ProjectContext = { projectId: string }
|
||||
|
||||
export type UserContext = { userId?: string }
|
||||
|
||||
export type AuthPolicyFactory<
|
||||
LoaderKeys extends AuthCheckContextLoaderKeys,
|
||||
Args extends object,
|
||||
ExpectedAuthErrors extends AuthError
|
||||
> = (
|
||||
loaders: AuthCheckContextLoaders<LoaderKeys>
|
||||
) => (args: Args) => Promise<Result<true, ExpectedAuthErrors>>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { Result } from 'true-myth/result'
|
||||
import { StreamRoles } from '../../../core/constants.js'
|
||||
import { Project } from './types.js'
|
||||
import {
|
||||
ProjectNoAccessError,
|
||||
ProjectNotFoundError,
|
||||
ProjectRoleNotFoundError
|
||||
} from '../authErrors.js'
|
||||
|
||||
// TODO: this should probably just throw an error if the project doesn't exist
|
||||
export type GetProject = (args: { projectId: string }) => Promise<Project | null>
|
||||
export type GetProject = (args: {
|
||||
projectId: string
|
||||
}) => Promise<
|
||||
Result<Project, typeof ProjectNotFoundError | typeof ProjectNoAccessError>
|
||||
>
|
||||
|
||||
export type GetProjectRole = (args: {
|
||||
userId: string
|
||||
projectId: string
|
||||
}) => Promise<StreamRoles | null>
|
||||
}) => Promise<Result<StreamRoles, typeof ProjectRoleNotFoundError>>
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import Result from 'true-myth/result'
|
||||
import { WorkspaceRoles } from '../../../core/constants.js'
|
||||
import { FeatureFlags } from '../../../environment/index.js'
|
||||
import { Workspace, WorkspaceSsoProvider, WorkspaceSsoSession } from './types.js'
|
||||
import {
|
||||
WorkspaceNotFoundError,
|
||||
WorkspaceRoleNotFoundError,
|
||||
WorkspaceSsoProviderNotFoundError,
|
||||
WorkspaceSsoSessionNotFoundError
|
||||
} from '../authErrors.js'
|
||||
|
||||
export type GetWorkspace = (args: { workspaceId: string }) => Promise<Workspace | null>
|
||||
export type GetWorkspace = (args: {
|
||||
workspaceId: string
|
||||
}) => Promise<Result<Workspace, typeof WorkspaceNotFoundError>>
|
||||
|
||||
export type GetWorkspaceRole = (args: {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
}) => Promise<WorkspaceRoles | null>
|
||||
}) => Promise<Result<WorkspaceRoles, typeof WorkspaceRoleNotFoundError>>
|
||||
|
||||
export type GetWorkspaceSsoProvider = (args: {
|
||||
workspaceId: string
|
||||
}) => Promise<WorkspaceSsoProvider | null>
|
||||
}) => Promise<Result<WorkspaceSsoProvider, typeof WorkspaceSsoProviderNotFoundError>>
|
||||
|
||||
export type GetWorkspaceSsoSession = (args: {
|
||||
userId: string
|
||||
workspaceId: string
|
||||
}) => Promise<WorkspaceSsoSession | null>
|
||||
}) => Promise<Result<WorkspaceSsoSession, typeof WorkspaceSsoSessionNotFoundError>>
|
||||
|
||||
export type GetEnv = () => FeatureFlags
|
||||
export type GetEnv = () => Result<FeatureFlags, never>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export { authPoliciesFactory, AuthPolices } from './policies/index.js'
|
||||
export { AuthCheckContextLoaders } from './domain/loaders.js'
|
||||
export { authPoliciesFactory, AuthPolicies } from './policies/index.js'
|
||||
export {
|
||||
AllAuthCheckContextLoaders,
|
||||
AuthCheckContextLoaders,
|
||||
AuthCheckContextLoaderKeys
|
||||
} from './domain/loaders.js'
|
||||
|
||||
export * from './domain/authErrors.js'
|
||||
|
||||
@@ -3,8 +3,16 @@ import { canQueryProjectPolicyFactory } from './canQueryProject.js'
|
||||
import { parseFeatureFlags } from '../../environment/index.js'
|
||||
import crs from 'crypto-random-string'
|
||||
import { Roles } from '../../core/constants.js'
|
||||
import { ProjectNoAccessError, ProjectNotFoundError } from '../domain/authErrors.js'
|
||||
import {
|
||||
ProjectNoAccessError,
|
||||
ProjectNotFoundError,
|
||||
ProjectRoleNotFoundError,
|
||||
WorkspaceRoleNotFoundError,
|
||||
WorkspaceSsoProviderNotFoundError,
|
||||
WorkspaceSsoSessionNotFoundError
|
||||
} from '../domain/authErrors.js'
|
||||
import { getProjectFake } from '../../tests/fakes.js'
|
||||
import { err, ok } from 'true-myth/result'
|
||||
|
||||
const canQueryProjectArgs = () => {
|
||||
const projectId = crs({ length: 10 })
|
||||
@@ -16,8 +24,8 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
describe('project not found', () => {
|
||||
it('by returning project no access', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () => parseFeatureFlags({}),
|
||||
getProject: () => Promise.resolve(null),
|
||||
getEnv: async () => ok(parseFeatureFlags({})),
|
||||
getProject: () => Promise.resolve(err(ProjectNotFoundError)),
|
||||
getProjectRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
@@ -36,8 +44,8 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
|
||||
expect(canQuery.authorized).toBe(false)
|
||||
if (!canQuery.authorized) {
|
||||
expect(canQuery.isOk).toBe(false)
|
||||
if (!canQuery.isOk) {
|
||||
expect(canQuery.error.code).toBe(ProjectNotFoundError.code)
|
||||
}
|
||||
})
|
||||
@@ -45,7 +53,7 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
describe('project visibility', () => {
|
||||
it('allows anyone on a public project', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () => parseFeatureFlags({}),
|
||||
getEnv: async () => ok(parseFeatureFlags({})),
|
||||
getProject: getProjectFake({ isPublic: true }),
|
||||
getProjectRole: () => {
|
||||
assert.fail()
|
||||
@@ -64,11 +72,11 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
}
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(true)
|
||||
expect(canQuery.isOk).toBe(true)
|
||||
})
|
||||
it('allows anyone on a linkShareable project', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () => parseFeatureFlags({}),
|
||||
getEnv: async () => ok(parseFeatureFlags({})),
|
||||
getProject: getProjectFake({ isDiscoverable: true }),
|
||||
getProjectRole: () => {
|
||||
assert.fail()
|
||||
@@ -87,7 +95,7 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
}
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(true)
|
||||
expect(canQuery.isOk).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -96,12 +104,14 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
'allows access to private projects with role %',
|
||||
async (role) => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'false'
|
||||
}),
|
||||
getEnv: async () =>
|
||||
ok(
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'false'
|
||||
})
|
||||
),
|
||||
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
|
||||
getProjectRole: () => Promise.resolve(role),
|
||||
getProjectRole: () => Promise.resolve(ok(role)),
|
||||
getServerRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
@@ -116,17 +126,19 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
}
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(true)
|
||||
expect(canQuery.isOk).toBe(true)
|
||||
}
|
||||
)
|
||||
it('does not allow access to private projects without a project role', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'false'
|
||||
}),
|
||||
getEnv: async () =>
|
||||
ok(
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'false'
|
||||
})
|
||||
),
|
||||
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
|
||||
getProjectRole: () => Promise.resolve(null),
|
||||
getProjectRole: () => Promise.resolve(err(ProjectRoleNotFoundError)),
|
||||
getServerRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
@@ -141,8 +153,8 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
}
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(false)
|
||||
if (!canQuery.authorized) {
|
||||
expect(canQuery.isOk).toBe(false)
|
||||
if (!canQuery.isOk) {
|
||||
expect(canQuery.error.code).toBe(ProjectNoAccessError.code)
|
||||
}
|
||||
})
|
||||
@@ -150,9 +162,10 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
describe('admin override', () => {
|
||||
it('allows server admins without project roles on private projects if admin override is enabled', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () => parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'true' }),
|
||||
getEnv: async () =>
|
||||
ok(parseFeatureFlags({ FF_ADMIN_OVERRIDE_ENABLED: 'true' })),
|
||||
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
|
||||
getServerRole: () => Promise.resolve(Roles.Server.Admin),
|
||||
getServerRole: () => Promise.resolve(ok(Roles.Server.Admin)),
|
||||
getProjectRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
@@ -167,20 +180,22 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
}
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(true)
|
||||
expect(canQuery.isOk).toBe(true)
|
||||
})
|
||||
|
||||
it('does not allow server admins without project roles on private projects if admin override is disabled', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () =>
|
||||
parseFeatureFlags({
|
||||
FF_ADMIN_OVERRIDE_ENABLED: 'false',
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'false'
|
||||
}),
|
||||
getEnv: async () =>
|
||||
ok(
|
||||
parseFeatureFlags({
|
||||
FF_ADMIN_OVERRIDE_ENABLED: 'false',
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'false'
|
||||
})
|
||||
),
|
||||
getProject: getProjectFake({ isDiscoverable: false, isPublic: false }),
|
||||
getServerRole: () => Promise.resolve(Roles.Server.Admin),
|
||||
getServerRole: () => Promise.resolve(ok(Roles.Server.Admin)),
|
||||
getProjectRole: () => {
|
||||
return Promise.resolve(null)
|
||||
return Promise.resolve(err(ProjectRoleNotFoundError))
|
||||
},
|
||||
getWorkspaceRole: () => {
|
||||
assert.fail()
|
||||
@@ -193,8 +208,8 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
}
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(false)
|
||||
if (!canQuery.authorized) {
|
||||
expect(canQuery.isOk).toBe(false)
|
||||
if (!canQuery.isOk) {
|
||||
expect(canQuery.error.code).toBe(ProjectNoAccessError.code)
|
||||
}
|
||||
})
|
||||
@@ -202,13 +217,14 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
describe('the workspace world', () => {
|
||||
it('does not check workspace rules if the workspaces module is not enabled', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () => parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' }),
|
||||
getEnv: async () =>
|
||||
ok(parseFeatureFlags({ FF_WORKSPACES_MODULE_ENABLED: 'false' })),
|
||||
getProject: getProjectFake({
|
||||
isDiscoverable: false,
|
||||
isPublic: false,
|
||||
workspaceId: crs({ length: 10 })
|
||||
}),
|
||||
getProjectRole: () => Promise.resolve('stream:contributor'),
|
||||
getProjectRole: () => Promise.resolve(ok('stream:contributor')),
|
||||
getServerRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
@@ -223,24 +239,26 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
}
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(true)
|
||||
expect(canQuery.isOk).toBe(true)
|
||||
})
|
||||
it('does not allow project access without a workspace role', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getEnv: async () =>
|
||||
ok(
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
})
|
||||
),
|
||||
getProject: getProjectFake({
|
||||
isDiscoverable: false,
|
||||
isPublic: false,
|
||||
workspaceId: crs({ length: 10 })
|
||||
}),
|
||||
getProjectRole: () => Promise.resolve('stream:contributor'),
|
||||
getProjectRole: () => Promise.resolve(ok('stream:contributor')),
|
||||
getServerRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspaceRole: () => Promise.resolve(null),
|
||||
getWorkspaceRole: () => Promise.resolve(err(WorkspaceRoleNotFoundError)),
|
||||
getWorkspaceSsoSession: () => {
|
||||
assert.fail()
|
||||
},
|
||||
@@ -249,48 +267,53 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
}
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(false)
|
||||
expect(canQuery.isOk).toBe(false)
|
||||
})
|
||||
it('allows project access via workspace role if user does not have project role', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getEnv: async () =>
|
||||
ok(
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
})
|
||||
),
|
||||
getProject: getProjectFake({
|
||||
isDiscoverable: false,
|
||||
isPublic: false,
|
||||
workspaceId: crs({ length: 10 })
|
||||
}),
|
||||
getProjectRole: () => Promise.resolve(null),
|
||||
getProjectRole: () => Promise.resolve(err(ProjectRoleNotFoundError)),
|
||||
getServerRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspaceRole: () => Promise.resolve('workspace:admin'),
|
||||
getWorkspaceRole: () => Promise.resolve(ok('workspace:admin')),
|
||||
getWorkspaceSsoSession: () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspaceSsoProvider: () => Promise.resolve(null)
|
||||
getWorkspaceSsoProvider: () =>
|
||||
Promise.resolve(err(WorkspaceSsoProviderNotFoundError))
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(true)
|
||||
expect(canQuery.isOk).toBe(true)
|
||||
})
|
||||
it('does not check SSO sessions if user is workspace guest', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getEnv: async () =>
|
||||
ok(
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
})
|
||||
),
|
||||
getProject: getProjectFake({
|
||||
isDiscoverable: false,
|
||||
isPublic: false,
|
||||
workspaceId: crs({ length: 10 })
|
||||
}),
|
||||
getProjectRole: () => Promise.resolve('stream:contributor'),
|
||||
getProjectRole: () => Promise.resolve(ok('stream:contributor')),
|
||||
getServerRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspaceRole: () => Promise.resolve('workspace:guest'),
|
||||
getWorkspaceRole: () => Promise.resolve(ok('workspace:guest')),
|
||||
getWorkspaceSsoSession: () => {
|
||||
assert.fail()
|
||||
},
|
||||
@@ -299,105 +322,115 @@ describe('canQueryProjectPolicyFactory creates a function, that handles ', () =>
|
||||
}
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(true)
|
||||
expect(canQuery.isOk).toBe(true)
|
||||
})
|
||||
it('does not check SSO sessions if workspace does not have it enabled', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getEnv: async () =>
|
||||
ok(
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
})
|
||||
),
|
||||
getProject: getProjectFake({
|
||||
isDiscoverable: false,
|
||||
isPublic: false,
|
||||
workspaceId: crs({ length: 10 })
|
||||
}),
|
||||
getProjectRole: () => Promise.resolve('stream:contributor'),
|
||||
getProjectRole: () => Promise.resolve(ok('stream:contributor')),
|
||||
getServerRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspaceRole: () => Promise.resolve('workspace:member'),
|
||||
getWorkspaceRole: () => Promise.resolve(ok('workspace:member')),
|
||||
getWorkspaceSsoSession: () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspaceSsoProvider: () => Promise.resolve(null)
|
||||
getWorkspaceSsoProvider: () =>
|
||||
Promise.resolve(err(WorkspaceSsoProviderNotFoundError))
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(true)
|
||||
expect(canQuery.isOk).toBe(true)
|
||||
})
|
||||
it('does not allow project access if SSO session is missing', async () => {
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getEnv: async () =>
|
||||
ok(
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
})
|
||||
),
|
||||
getProject: getProjectFake({
|
||||
isDiscoverable: false,
|
||||
isPublic: false,
|
||||
workspaceId: crs({ length: 10 })
|
||||
}),
|
||||
getProjectRole: () => Promise.resolve('stream:contributor'),
|
||||
getProjectRole: () => Promise.resolve(ok('stream:contributor')),
|
||||
getServerRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspaceRole: () => Promise.resolve('workspace:member'),
|
||||
getWorkspaceSsoSession: () => Promise.resolve(null),
|
||||
getWorkspaceSsoProvider: () => Promise.resolve({ providerId: 'foo' })
|
||||
getWorkspaceRole: () => Promise.resolve(ok('workspace:member')),
|
||||
getWorkspaceSsoSession: () =>
|
||||
Promise.resolve(err(WorkspaceSsoSessionNotFoundError)),
|
||||
getWorkspaceSsoProvider: () => Promise.resolve(ok({ providerId: 'foo' }))
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(false)
|
||||
expect(canQuery.isOk).toBe(false)
|
||||
})
|
||||
it('does not allow project access if SSO session is expired or invalid', async () => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - 1)
|
||||
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getEnv: async () =>
|
||||
ok(
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
})
|
||||
),
|
||||
getProject: getProjectFake({
|
||||
isDiscoverable: false,
|
||||
isPublic: false,
|
||||
workspaceId: crs({ length: 10 })
|
||||
}),
|
||||
getProjectRole: () => Promise.resolve('stream:contributor'),
|
||||
getProjectRole: () => Promise.resolve(ok('stream:contributor')),
|
||||
getServerRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspaceRole: () => Promise.resolve('workspace:member'),
|
||||
getWorkspaceRole: () => Promise.resolve(ok('workspace:member')),
|
||||
getWorkspaceSsoSession: () =>
|
||||
Promise.resolve({ validUntil: date, userId: 'foo', providerId: 'foo' }),
|
||||
getWorkspaceSsoProvider: () => Promise.resolve({ providerId: 'foo' })
|
||||
Promise.resolve(ok({ validUntil: date, userId: 'foo', providerId: 'foo' })),
|
||||
getWorkspaceSsoProvider: () => Promise.resolve(ok({ providerId: 'foo' }))
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(false)
|
||||
expect(canQuery.isOk).toBe(false)
|
||||
})
|
||||
it('allows project access if SSO session is valid', async () => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + 1)
|
||||
|
||||
const canQueryProject = canQueryProjectPolicyFactory({
|
||||
getEnv: () =>
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
}),
|
||||
getEnv: async () =>
|
||||
ok(
|
||||
parseFeatureFlags({
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'true'
|
||||
})
|
||||
),
|
||||
getProject: getProjectFake({
|
||||
isDiscoverable: false,
|
||||
isPublic: false,
|
||||
workspaceId: crs({ length: 10 })
|
||||
}),
|
||||
getProjectRole: () => Promise.resolve('stream:contributor'),
|
||||
getProjectRole: () => Promise.resolve(ok('stream:contributor')),
|
||||
getServerRole: () => {
|
||||
assert.fail()
|
||||
},
|
||||
getWorkspaceRole: () => Promise.resolve('workspace:member'),
|
||||
getWorkspaceRole: () => Promise.resolve(ok('workspace:member')),
|
||||
getWorkspaceSsoSession: () =>
|
||||
Promise.resolve({ validUntil: date, userId: 'foo', providerId: 'foo' }),
|
||||
getWorkspaceSsoProvider: () => Promise.resolve({ providerId: 'foo' })
|
||||
Promise.resolve(ok({ validUntil: date, userId: 'foo', providerId: 'foo' })),
|
||||
getWorkspaceSsoProvider: () => Promise.resolve(ok({ providerId: 'foo' }))
|
||||
})
|
||||
const canQuery = await canQueryProject(canQueryProjectArgs())
|
||||
expect(canQuery.authorized).toBe(true)
|
||||
expect(canQuery.isOk).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,11 @@ import {
|
||||
requireAnyWorkspaceRole,
|
||||
requireMinimumWorkspaceRole
|
||||
} from '../checks/workspaceRole.js'
|
||||
import { AuthResult, authorized, unauthorized } from '../domain/authResult.js'
|
||||
import {
|
||||
requireExactProjectVisibilityFactory,
|
||||
requireMinimumProjectRoleFactory
|
||||
} from '../checks/projects.js'
|
||||
import { AuthCheckContextLoaders } from '../domain/loaders.js'
|
||||
import { ProjectContext, UserContext } from '../domain/policies.js'
|
||||
import { AuthPolicyFactory, ProjectContext, UserContext } from '../domain/policies.js'
|
||||
import { requireExactServerRole } from '../checks/serverRole.js'
|
||||
import { requireValidWorkspaceSsoSession } from '../checks/workspaceSso.js'
|
||||
import { Roles } from '../../core/constants.js'
|
||||
@@ -18,36 +16,37 @@ import {
|
||||
WorkspaceNoAccessError,
|
||||
WorkspaceSsoSessionInvalidError
|
||||
} from '../domain/authErrors.js'
|
||||
import { err, isOk, ok } from 'true-myth/result'
|
||||
import { AuthCheckContextLoaderKeys } from '../domain/loaders.js'
|
||||
import { LogicError } from '../domain/errors.js'
|
||||
|
||||
export const canQueryProjectPolicyFactory =
|
||||
(
|
||||
loaders: Pick<
|
||||
AuthCheckContextLoaders,
|
||||
| 'getEnv'
|
||||
| 'getProject'
|
||||
| 'getProjectRole'
|
||||
| 'getServerRole'
|
||||
| 'getWorkspaceRole'
|
||||
| 'getWorkspaceSsoProvider'
|
||||
| 'getWorkspaceSsoSession'
|
||||
>
|
||||
) =>
|
||||
async ({
|
||||
userId,
|
||||
projectId
|
||||
}: UserContext & ProjectContext): Promise<
|
||||
AuthResult<
|
||||
| typeof ProjectNotFoundError
|
||||
| typeof ProjectNoAccessError
|
||||
| typeof WorkspaceNoAccessError
|
||||
| typeof WorkspaceSsoSessionInvalidError
|
||||
>
|
||||
> => {
|
||||
const { FF_ADMIN_OVERRIDE_ENABLED, FF_WORKSPACES_MODULE_ENABLED } = loaders.getEnv()
|
||||
export const canQueryProjectPolicyFactory: AuthPolicyFactory<
|
||||
| typeof AuthCheckContextLoaderKeys.getEnv
|
||||
| typeof AuthCheckContextLoaderKeys.getProject
|
||||
| typeof AuthCheckContextLoaderKeys.getProjectRole
|
||||
| typeof AuthCheckContextLoaderKeys.getServerRole
|
||||
| typeof AuthCheckContextLoaderKeys.getWorkspaceRole
|
||||
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoProvider
|
||||
| typeof AuthCheckContextLoaderKeys.getWorkspaceSsoSession,
|
||||
UserContext & ProjectContext,
|
||||
| typeof ProjectNotFoundError
|
||||
| typeof ProjectNoAccessError
|
||||
| typeof WorkspaceNoAccessError
|
||||
| typeof WorkspaceSsoSessionInvalidError
|
||||
> =
|
||||
(loaders) =>
|
||||
async ({ userId, projectId }) => {
|
||||
const env = await loaders.getEnv()
|
||||
if (!isOk(env)) {
|
||||
throw new LogicError('Failed to load environment variables')
|
||||
}
|
||||
|
||||
const { FF_ADMIN_OVERRIDE_ENABLED, FF_WORKSPACES_MODULE_ENABLED } = env.value
|
||||
|
||||
const project = await loaders.getProject({ projectId })
|
||||
// hiding the project not found, to stop id brute force lookups
|
||||
if (!project) return unauthorized(ProjectNotFoundError)
|
||||
if (!isOk(project)) {
|
||||
return err(project.error)
|
||||
}
|
||||
|
||||
// All users may read public projects
|
||||
const isPublicResult = await requireExactProjectVisibilityFactory({ loaders })({
|
||||
@@ -55,7 +54,7 @@ export const canQueryProjectPolicyFactory =
|
||||
projectVisibility: 'public'
|
||||
})
|
||||
if (isPublicResult) {
|
||||
return authorized()
|
||||
return ok(true)
|
||||
}
|
||||
|
||||
// All users may read link-shareable projects
|
||||
@@ -66,11 +65,11 @@ export const canQueryProjectPolicyFactory =
|
||||
projectVisibility: 'linkShareable'
|
||||
})
|
||||
if (isLinkShareableResult) {
|
||||
return authorized()
|
||||
return ok(true)
|
||||
}
|
||||
// From this point on, you cannot pass as an unknown user
|
||||
if (!userId) {
|
||||
return unauthorized(ProjectNoAccessError)
|
||||
return err(ProjectNoAccessError)
|
||||
}
|
||||
|
||||
// When G O D M O D E is enabled
|
||||
@@ -81,11 +80,11 @@ export const canQueryProjectPolicyFactory =
|
||||
role: Roles.Server.Admin
|
||||
})
|
||||
if (isServerAdminResult) {
|
||||
return authorized()
|
||||
return ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
const { workspaceId } = project
|
||||
const { workspaceId } = project.value
|
||||
|
||||
// When a project belongs to a workspace
|
||||
if (FF_WORKSPACES_MODULE_ENABLED && !!workspaceId) {
|
||||
@@ -96,7 +95,7 @@ export const canQueryProjectPolicyFactory =
|
||||
})
|
||||
if (!hasWorkspaceRoleResult) {
|
||||
// Should we hide the fact, the project is in a workspace?
|
||||
return unauthorized(WorkspaceNoAccessError)
|
||||
return err(WorkspaceNoAccessError)
|
||||
}
|
||||
|
||||
const hasMinimumMemberRole = await requireMinimumWorkspaceRole({
|
||||
@@ -111,7 +110,7 @@ export const canQueryProjectPolicyFactory =
|
||||
const workspaceSsoProvider = await loaders.getWorkspaceSsoProvider({
|
||||
workspaceId
|
||||
})
|
||||
if (!!workspaceSsoProvider) {
|
||||
if (workspaceSsoProvider.isOk) {
|
||||
// Member and admin user must have a valid SSO session to read project data
|
||||
const hasValidSsoSessionResult = await requireValidWorkspaceSsoSession({
|
||||
loaders
|
||||
@@ -120,12 +119,12 @@ export const canQueryProjectPolicyFactory =
|
||||
workspaceId
|
||||
})
|
||||
if (!hasValidSsoSessionResult) {
|
||||
return unauthorized(WorkspaceSsoSessionInvalidError)
|
||||
return err(WorkspaceSsoSessionInvalidError)
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace members get to go through without an explicit project role
|
||||
return authorized()
|
||||
return ok(true)
|
||||
} else {
|
||||
// just fall through to the generic project role check for workspace:guest-s
|
||||
}
|
||||
@@ -140,7 +139,7 @@ export const canQueryProjectPolicyFactory =
|
||||
role: 'stream:reviewer'
|
||||
})
|
||||
if (hasMinimumProjectRoleResult) {
|
||||
return authorized()
|
||||
return ok(true)
|
||||
}
|
||||
return unauthorized(ProjectNoAccessError)
|
||||
return err(ProjectNoAccessError)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AuthCheckContextLoaders } from '../domain/loaders.js'
|
||||
import { AllAuthCheckContextLoaders } from '../domain/loaders.js'
|
||||
import { canQueryProjectPolicyFactory } from './canQueryProject.js'
|
||||
|
||||
export const authPoliciesFactory = (loaders: AuthCheckContextLoaders) => ({
|
||||
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
|
||||
project: {
|
||||
canQuery: canQueryProjectPolicyFactory(loaders)
|
||||
}
|
||||
})
|
||||
|
||||
export type AuthPolices = ReturnType<typeof authPoliciesFactory>
|
||||
export type AuthPolicies = ReturnType<typeof authPoliciesFactory>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { merge } from 'lodash'
|
||||
import { Project } from '../authz/domain/projects/types.js'
|
||||
import { ok, Result } from 'true-myth/result'
|
||||
|
||||
export const fakeGetFactory =
|
||||
<T extends Record<string, unknown>>(defaults: T) =>
|
||||
(overrides?: Partial<T>) =>
|
||||
(): Promise<T> => {
|
||||
(): Promise<Result<T, never>> => {
|
||||
if (overrides) {
|
||||
return Promise.resolve(merge(defaults, overrides))
|
||||
return Promise.resolve(ok(merge(defaults, overrides)))
|
||||
}
|
||||
return Promise.resolve(defaults)
|
||||
return Promise.resolve(ok(defaults))
|
||||
}
|
||||
|
||||
export const getProjectFake = fakeGetFactory<Project>({
|
||||
|
||||
@@ -114,15 +114,26 @@ import {
|
||||
} from './modules/pipeline/Passes/GPass.js'
|
||||
import { Pipeline } from './modules/pipeline/Pipelines/Pipeline.js'
|
||||
import { ProgressivePipeline } from './modules/pipeline/Pipelines/ProgressivePipeline.js'
|
||||
import { DepthPass } from './modules/pipeline/Passes/DepthPass.js'
|
||||
import { DepthPass, DepthPassOptions } from './modules/pipeline/Passes/DepthPass.js'
|
||||
import { GeometryPass } from './modules/pipeline/Passes/GeometryPass.js'
|
||||
import { NormalsPass } from './modules/pipeline/Passes/NormalsPass.js'
|
||||
import { InputType, OutputPass } from './modules/pipeline/Passes/OutputPass.js'
|
||||
import { ViewportPass } from './modules/pipeline/Passes/ViewportPass.js'
|
||||
import { BlendPass } from './modules/pipeline/Passes/BlendPass.js'
|
||||
import {
|
||||
InputType,
|
||||
OutputPass,
|
||||
OutputPassOptions
|
||||
} from './modules/pipeline/Passes/OutputPass.js'
|
||||
import {
|
||||
ViewportPass,
|
||||
ViewportPassOptions
|
||||
} from './modules/pipeline/Passes/ViewportPass.js'
|
||||
import { BlendPass, BlendPassOptions } from './modules/pipeline/Passes/BlendPass.js'
|
||||
import { DepthNormalPass } from './modules/pipeline/Passes/DepthNormalPass.js'
|
||||
import { BasitPass } from './modules/pipeline/Passes/BasitPass.js'
|
||||
import { ProgressiveAOPass } from './modules/pipeline/Passes/ProgressiveAOPass.js'
|
||||
import { ShadedPass } from './modules/pipeline/Passes/ShadedPass.js'
|
||||
import {
|
||||
DefaultProgressiveAOPassOptions,
|
||||
ProgressiveAOPass,
|
||||
ProgressiveAOPassOptions
|
||||
} from './modules/pipeline/Passes/ProgressiveAOPass.js'
|
||||
import { TAAPass } from './modules/pipeline/Passes/TAAPass.js'
|
||||
import {
|
||||
FilterMaterial,
|
||||
@@ -133,13 +144,18 @@ import { SpeckleOfflineLoader } from './modules/loaders/Speckle/SpeckleOfflineLo
|
||||
import { AccelerationStructure } from './modules/objects/AccelerationStructure.js'
|
||||
import { TopLevelAccelerationStructure } from './modules/objects/TopLevelAccelerationStructure.js'
|
||||
import { StencilPass } from './modules/pipeline/Passes/StencilPass.js'
|
||||
import { StencilMaskPass } from './modules/pipeline/Passes/StencilMaskPass.js'
|
||||
import { SpeckleWebGLRenderer } from './modules/objects/SpeckleWebGLRenderer.js'
|
||||
import { InstancedMeshBatch } from './modules/batching/InstancedMeshBatch.js'
|
||||
import { ViewModeEvent, ViewModeEventPayload } from './modules/extensions/ViewModes.js'
|
||||
import { BasitPipeline } from './modules/pipeline/Pipelines/BasitViewPipeline.js'
|
||||
import SpeckleMesh from './modules/objects/SpeckleMesh.js'
|
||||
import SpeckleInstancedMesh from './modules/objects/SpeckleInstancedMesh.js'
|
||||
import { StencilMaskPass } from './modules/pipeline/Passes/StencilMaskPass.js'
|
||||
import {
|
||||
DefaultEdgesPassOptions,
|
||||
EdgesPass,
|
||||
EdgesPassOptions
|
||||
} from './modules/pipeline/Passes/EdgesPass.js'
|
||||
|
||||
export {
|
||||
Viewer,
|
||||
@@ -207,12 +223,21 @@ export {
|
||||
ViewportPass,
|
||||
BlendPass,
|
||||
DepthNormalPass,
|
||||
BasitPass,
|
||||
ShadedPass as BasitPass,
|
||||
ProgressiveAOPass,
|
||||
TAAPass,
|
||||
StencilPass,
|
||||
StencilMaskPass,
|
||||
EdgesPass,
|
||||
PassOptions,
|
||||
EdgesPassOptions as EdgePassOptions,
|
||||
BlendPassOptions,
|
||||
DepthPassOptions,
|
||||
OutputPassOptions,
|
||||
ProgressiveAOPassOptions,
|
||||
ViewportPassOptions,
|
||||
DefaultEdgesPassOptions,
|
||||
DefaultProgressiveAOPassOptions,
|
||||
ClearFlags,
|
||||
ObjectVisibility,
|
||||
InputType,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IViewer, UpdateFlags, ViewerEvent } from '../../IViewer.js'
|
||||
import { BasitPass } from '../pipeline/Passes/BasitPass.js'
|
||||
import { ShadedPass } from '../pipeline/Passes/ShadedPass.js'
|
||||
import { GPass } from '../pipeline/Passes/GPass.js'
|
||||
import { ArcticViewPipeline } from '../pipeline/Pipelines/ArcticViewPipeline.js'
|
||||
import { BasitPipeline } from '../pipeline/Pipelines/BasitViewPipeline.js'
|
||||
@@ -53,7 +53,7 @@ export class ViewModes extends Extension {
|
||||
.getRenderer()
|
||||
.pipeline.getPass('BASIT')
|
||||
.forEach((pass: GPass) => {
|
||||
;(pass as BasitPass).applyColorIndices()
|
||||
;(pass as ShadedPass).applyColorIndices()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { speckleEdgesGeneratorFrag } from '../../materials/shaders/speckle-edges
|
||||
import { speckleEdgesGeneratorVert } from '../../materials/shaders/speckle-edges-generator-vert.js'
|
||||
import { Pipeline } from '../Pipelines/Pipeline.js'
|
||||
|
||||
export interface EdgePassOptions extends PassOptions {
|
||||
export interface EdgesPassOptions extends PassOptions {
|
||||
depthMultiplier?: number
|
||||
depthBias?: number
|
||||
normalMultiplier?: number
|
||||
@@ -26,7 +26,7 @@ export interface EdgePassOptions extends PassOptions {
|
||||
backgroundTextureIntensity: number
|
||||
}
|
||||
|
||||
export const DefaultEdgePassOptions: Required<EdgePassOptions> = {
|
||||
export const DefaultEdgesPassOptions: Required<EdgesPassOptions> = {
|
||||
depthMultiplier: 1,
|
||||
depthBias: 0.001,
|
||||
normalMultiplier: 1,
|
||||
@@ -37,13 +37,16 @@ export const DefaultEdgePassOptions: Required<EdgePassOptions> = {
|
||||
backgroundTextureIntensity: 0
|
||||
}
|
||||
|
||||
export class EdgePass extends BaseGPass {
|
||||
export class EdgesPass extends BaseGPass {
|
||||
public edgesMaterial: ShaderMaterial
|
||||
private fsQuad: FullScreenQuad
|
||||
|
||||
public _options: Required<EdgePassOptions> = Object.assign({}, DefaultEdgePassOptions)
|
||||
public _options: Required<EdgesPassOptions> = Object.assign(
|
||||
{},
|
||||
DefaultEdgesPassOptions
|
||||
)
|
||||
|
||||
public set options(value: EdgePassOptions) {
|
||||
public set options(value: EdgesPassOptions) {
|
||||
super.options = value
|
||||
this.setBackground(
|
||||
this._options.backgroundTexture,
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import SpeckleStandardColoredMaterial from '../../materials/SpeckleStandardColor
|
||||
import { Assets } from '../../../index.js'
|
||||
import SpeckleMesh from '../../objects/SpeckleMesh.js'
|
||||
|
||||
export class BasitPass extends BaseGPass {
|
||||
export class ShadedPass extends BaseGPass {
|
||||
protected tree: WorldTree
|
||||
protected speckleRenderer: SpeckleRenderer
|
||||
protected materialMap: {
|
||||
@@ -2,7 +2,7 @@ import { ObjectLayers, WorldTree } from '../../../index.js'
|
||||
import SpeckleRenderer from '../../SpeckleRenderer.js'
|
||||
import { GeometryPass } from '../Passes/GeometryPass.js'
|
||||
import { Pipeline } from './Pipeline.js'
|
||||
import { BasitPass } from '../Passes/BasitPass.js'
|
||||
import { ShadedPass } from '../Passes/ShadedPass.js'
|
||||
import { ClearFlags, ObjectVisibility } from '../Passes/GPass.js'
|
||||
import { StencilPass } from '../Passes/StencilPass.js'
|
||||
import { StencilMaskPass } from '../Passes/StencilMaskPass.js'
|
||||
@@ -11,7 +11,7 @@ export class BasitPipeline extends Pipeline {
|
||||
constructor(speckleRenderer: SpeckleRenderer, tree: WorldTree) {
|
||||
super(speckleRenderer)
|
||||
|
||||
const basitPass = new BasitPass(tree, speckleRenderer)
|
||||
const basitPass = new ShadedPass(tree, speckleRenderer)
|
||||
basitPass.setLayers([ObjectLayers.STREAM_CONTENT_MESH, ObjectLayers.PROPS])
|
||||
basitPass.setClearColor(0x000000, 0)
|
||||
basitPass.setClearFlags(ClearFlags.COLOR)
|
||||
|
||||
@@ -2,7 +2,7 @@ import SpeckleRenderer from '../../SpeckleRenderer.js'
|
||||
import { BlendPass } from '../Passes/BlendPass.js'
|
||||
import { GeometryPass } from '../Passes/GeometryPass.js'
|
||||
import { DepthPass } from '../Passes/DepthPass.js'
|
||||
import { EdgePass } from '../Passes/EdgesPass.js'
|
||||
import { EdgesPass } from '../Passes/EdgesPass.js'
|
||||
import { NormalsPass } from '../Passes/NormalsPass.js'
|
||||
import { ClearFlags, ObjectVisibility } from '../Passes/GPass.js'
|
||||
import { ProgressiveAOPass } from '../Passes/ProgressiveAOPass.js'
|
||||
@@ -71,11 +71,11 @@ export class EdgesPipeline extends ProgressivePipeline {
|
||||
progressiveAOPass.setClearColor(0xffffff, 1)
|
||||
progressiveAOPass.accumulationFrames = this.accumulationFrameCount
|
||||
|
||||
const edgesPass = new EdgePass()
|
||||
const edgesPass = new EdgesPass()
|
||||
edgesPass.setTexture('tDepth', depthPass.outputTarget?.texture)
|
||||
edgesPass.setTexture('tNormal', normalPass.outputTarget?.texture)
|
||||
|
||||
const edgesPassDynamic = new EdgePass()
|
||||
const edgesPassDynamic = new EdgesPass()
|
||||
edgesPassDynamic.setTexture('tDepth', depthPassDynamic.outputTarget?.texture)
|
||||
edgesPassDynamic.setTexture('tNormal', normalPassDynamic.outputTarget?.texture)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SpeckleRenderer from '../../../SpeckleRenderer.js'
|
||||
import { BlendPass } from '../../Passes/BlendPass.js'
|
||||
import { GeometryPass } from '../../Passes/GeometryPass.js'
|
||||
import { EdgePass } from '../../Passes/EdgesPass.js'
|
||||
import { EdgesPass } from '../../Passes/EdgesPass.js'
|
||||
import { ClearFlags, ObjectVisibility } from '../../Passes/GPass.js'
|
||||
import { ProgressiveAOPass } from '../../Passes/ProgressiveAOPass.js'
|
||||
import { TAAPass } from '../../Passes/TAAPass.js'
|
||||
@@ -56,12 +56,12 @@ export class MRTEdgesPipeline extends ProgressivePipeline {
|
||||
progressiveAOPass.setClearColor(0xffffff, 1)
|
||||
progressiveAOPass.accumulationFrames = this.accumulationFrameCount
|
||||
|
||||
const edgesPass = new EdgePass()
|
||||
const edgesPass = new EdgesPass()
|
||||
edgesPass.setTexture('tDepth', depthNormalIdPass.depthTexture)
|
||||
edgesPass.setTexture('tNormal', depthNormalIdPass.normalTexture)
|
||||
edgesPass.setTexture('tId', depthNormalIdPass.idTexture)
|
||||
|
||||
const edgesPassDynamic = new EdgePass()
|
||||
const edgesPassDynamic = new EdgesPass()
|
||||
edgesPassDynamic.setTexture('tDepth', depthPassNormalIdDynamic.depthTexture)
|
||||
edgesPassDynamic.setTexture('tNormal', depthPassNormalIdDynamic.normalTexture)
|
||||
edgesPassDynamic.setTexture('tId', depthPassNormalIdDynamic.idTexture)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ObjectLayers } from '../../../../index.js'
|
||||
import SpeckleRenderer from '../../../SpeckleRenderer.js'
|
||||
import { GeometryPass } from '../../Passes/GeometryPass.js'
|
||||
import { EdgePass } from '../../Passes/EdgesPass.js'
|
||||
import { EdgesPass } from '../../Passes/EdgesPass.js'
|
||||
import { OutputPass } from '../../Passes/OutputPass.js'
|
||||
import { ObjectVisibility, ClearFlags } from '../../Passes/GPass.js'
|
||||
import { StencilMaskPass } from '../../Passes/StencilMaskPass.js'
|
||||
@@ -36,12 +36,12 @@ export class MRTPenViewPipeline extends ProgressivePipeline {
|
||||
depthNormalIdPassDynamic.setClearColor(0x000000, 1)
|
||||
depthNormalIdPassDynamic.setClearFlags(ClearFlags.COLOR | ClearFlags.DEPTH)
|
||||
|
||||
const edgesPass = new EdgePass()
|
||||
const edgesPass = new EdgesPass()
|
||||
edgesPass.setTexture('tDepth', depthNormalIdPass.depthTexture)
|
||||
edgesPass.setTexture('tNormal', depthNormalIdPass.normalTexture)
|
||||
edgesPass.setTexture('tId', depthNormalIdPass.idTexture)
|
||||
|
||||
const edgesPassDynamic = new EdgePass()
|
||||
const edgesPassDynamic = new EdgesPass()
|
||||
edgesPassDynamic.setTexture('tDepth', depthNormalIdPassDynamic.depthTexture)
|
||||
edgesPassDynamic.setTexture('tNormal', depthNormalIdPassDynamic.normalTexture)
|
||||
edgesPassDynamic.setTexture('tId', depthNormalIdPassDynamic.idTexture)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ObjectLayers, AssetType } from '../../../../index.js'
|
||||
import SpeckleRenderer from '../../../SpeckleRenderer.js'
|
||||
import { BlendPass } from '../../Passes/BlendPass.js'
|
||||
import { GeometryPass } from '../../Passes/GeometryPass.js'
|
||||
import { EdgePass } from '../../Passes/EdgesPass.js'
|
||||
import { EdgesPass } from '../../Passes/EdgesPass.js'
|
||||
import { ClearFlags, ObjectVisibility } from '../../Passes/GPass.js'
|
||||
import { StencilMaskPass } from '../../Passes/StencilMaskPass.js'
|
||||
import { StencilPass } from '../../Passes/StencilPass.js'
|
||||
@@ -50,12 +50,12 @@ export class MRTShadedViewPipeline extends ProgressivePipeline {
|
||||
const shadowcatcherPass = new GeometryPass()
|
||||
shadowcatcherPass.setLayers([ObjectLayers.SHADOWCATCHER])
|
||||
|
||||
const edgesPass = new EdgePass()
|
||||
const edgesPass = new EdgesPass()
|
||||
edgesPass.setTexture('tDepth', depthNormalIdPass.depthTexture)
|
||||
edgesPass.setTexture('tNormal', depthNormalIdPass.normalTexture)
|
||||
edgesPass.setTexture('tId', depthNormalIdPass.idTexture)
|
||||
|
||||
const edgesPassDynamic = new EdgePass()
|
||||
const edgesPassDynamic = new EdgesPass()
|
||||
edgesPassDynamic.setTexture('tDepth', depthPassNormalIdDynamic.depthTexture)
|
||||
edgesPassDynamic.setTexture('tNormal', depthPassNormalIdDynamic.normalTexture)
|
||||
edgesPassDynamic.setTexture('tId', depthPassNormalIdDynamic.idTexture)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SpeckleRenderer from '../../SpeckleRenderer.js'
|
||||
import { DepthPass } from '../Passes/DepthPass.js'
|
||||
import { EdgePass } from '../Passes/EdgesPass.js'
|
||||
import { EdgesPass } from '../Passes/EdgesPass.js'
|
||||
import { NormalsPass } from '../Passes/NormalsPass.js'
|
||||
import { ClearFlags, ObjectVisibility } from '../Passes/GPass.js'
|
||||
import { TAAPass } from '../Passes/TAAPass.js'
|
||||
@@ -50,11 +50,11 @@ export class PenViewPipeline extends ProgressivePipeline {
|
||||
normalPassDynamic.setClearColor(0x000000, 1)
|
||||
normalPassDynamic.setClearFlags(ClearFlags.COLOR | ClearFlags.DEPTH)
|
||||
|
||||
const edgesPass = new EdgePass()
|
||||
const edgesPass = new EdgesPass()
|
||||
edgesPass.setTexture('tDepth', depthPass.outputTarget?.texture)
|
||||
edgesPass.setTexture('tNormal', normalPass.outputTarget?.texture)
|
||||
|
||||
const edgesPassDynamic = new EdgePass()
|
||||
const edgesPassDynamic = new EdgesPass()
|
||||
edgesPassDynamic.setTexture('tDepth', depthPassDynamic.outputTarget?.texture)
|
||||
edgesPassDynamic.setTexture('tNormal', normalPassDynamic.outputTarget?.texture)
|
||||
edgesPassDynamic.outputTarget = null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SpeckleRenderer from '../../SpeckleRenderer.js'
|
||||
import { BlendPass } from '../Passes/BlendPass.js'
|
||||
import { DepthPass } from '../Passes/DepthPass.js'
|
||||
import { EdgePass } from '../Passes/EdgesPass.js'
|
||||
import { EdgesPass } from '../Passes/EdgesPass.js'
|
||||
import { NormalsPass } from '../Passes/NormalsPass.js'
|
||||
import { TAAPass } from '../Passes/TAAPass.js'
|
||||
import { AssetType, ObjectLayers } from '../../../IViewer.js'
|
||||
@@ -60,11 +60,11 @@ export class ShadedViewPipeline extends ProgressivePipeline {
|
||||
const shadowcatcherPass = new GeometryPass()
|
||||
shadowcatcherPass.setLayers([ObjectLayers.SHADOWCATCHER])
|
||||
|
||||
const edgesPass = new EdgePass()
|
||||
const edgesPass = new EdgesPass()
|
||||
edgesPass.setTexture('tDepth', depthPass.outputTarget?.texture)
|
||||
edgesPass.setTexture('tNormal', normalPass.outputTarget?.texture)
|
||||
|
||||
const edgesPassDynamic = new EdgePass()
|
||||
const edgesPassDynamic = new EdgesPass()
|
||||
edgesPassDynamic.setTexture('tDepth', depthPassDynamic.outputTarget?.texture)
|
||||
edgesPassDynamic.setTexture('tNormal', normalPassDynamic.outputTarget?.texture)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BackSide, NoBlending, WebGLRenderTarget } from 'three'
|
||||
import SpeckleRenderer from '../../SpeckleRenderer.js'
|
||||
import { DepthPass } from '../Passes/DepthPass.js'
|
||||
import { EdgePass } from '../Passes/EdgesPass.js'
|
||||
import { EdgesPass } from '../Passes/EdgesPass.js'
|
||||
import { NormalsPass } from '../Passes/NormalsPass.js'
|
||||
import { ObjectVisibility } from '../Passes/GPass.js'
|
||||
import { TAAPass } from '../Passes/TAAPass.js'
|
||||
@@ -56,17 +56,17 @@ export class TechnicalViewPipeline extends ProgressivePipeline {
|
||||
normalPassBackDynamic.overrideMaterial.side = BackSide
|
||||
// normalPassBackDynamic.overrideMaterial.depthTest = false
|
||||
|
||||
const edgesPassFront = new EdgePass()
|
||||
const edgesPassFront = new EdgesPass()
|
||||
edgesPassFront.setTexture('tDepth', depthPassFront.outputTarget?.texture)
|
||||
edgesPassFront.setTexture('tNormal', normalPassFront.outputTarget?.texture)
|
||||
|
||||
const edgesPassBack = new EdgePass()
|
||||
const edgesPassBack = new EdgesPass()
|
||||
edgesPassBack.setTexture('tDepth', depthPassBack.outputTarget?.texture)
|
||||
edgesPassBack.setTexture('tNormal', normalPassBack.outputTarget?.texture)
|
||||
edgesPassBack.edgesMaterial.uniforms.uOutlineDensity.value = 0.25
|
||||
edgesPassBack.edgesMaterial.needsUpdate = true
|
||||
|
||||
const edgesPassFrontDynamic = new EdgePass()
|
||||
const edgesPassFrontDynamic = new EdgesPass()
|
||||
edgesPassFrontDynamic.setTexture(
|
||||
'tDepth',
|
||||
depthPassFrontDynamic.outputTarget?.texture
|
||||
@@ -76,7 +76,7 @@ export class TechnicalViewPipeline extends ProgressivePipeline {
|
||||
normalPassFrontDynamic.outputTarget?.texture
|
||||
)
|
||||
|
||||
const edgesPassBackDynamic = new EdgePass()
|
||||
const edgesPassBackDynamic = new EdgesPass()
|
||||
edgesPassBackDynamic.setTexture(
|
||||
'tDepth',
|
||||
depthPassBackDynamic.outputTarget?.texture
|
||||
|
||||
@@ -10526,6 +10526,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@godaddy/terminus@npm:^4.12.1":
|
||||
version: 4.12.1
|
||||
resolution: "@godaddy/terminus@npm:4.12.1"
|
||||
dependencies:
|
||||
stoppable: "npm:^1.1.0"
|
||||
checksum: 10/e1b6e0a079db5748c71211e22d2fc1ab76ee20763bb552e9e2b6003330c0b19fee10b20b1dd961218a402c6aadd3a8ecf7f03ecdfaa2c65ed072b2479eb9b517
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@godaddy/terminus@npm:^4.9.0":
|
||||
version: 4.10.2
|
||||
resolution: "@godaddy/terminus@npm:4.10.2"
|
||||
@@ -16764,6 +16773,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@speckle/preview-service@workspace:packages/preview-service"
|
||||
dependencies:
|
||||
"@godaddy/terminus": "npm:^4.12.1"
|
||||
"@speckle/shared": "workspace:^"
|
||||
"@swc/cli": "npm:^0.5.1"
|
||||
"@swc/core": "npm:^1.9.3"
|
||||
@@ -16953,6 +16963,7 @@ __metadata:
|
||||
stripe: "npm:^17.1.0"
|
||||
subscriptions-transport-ws: "npm:^0.11.0"
|
||||
supertest: "npm:^4.0.2"
|
||||
true-myth: "npm:^8.5.0"
|
||||
ts-node: "npm:^10.9.2"
|
||||
tsconfig-paths: "npm:^4.0.0"
|
||||
type-fest: "npm:^4.26.1"
|
||||
@@ -16993,6 +17004,7 @@ __metadata:
|
||||
mixpanel: "npm:^0.17.0"
|
||||
pino: "npm:^8.7.0"
|
||||
pino-http: "npm:^8.0.0"
|
||||
true-myth: "npm:^8.5.0"
|
||||
tshy: "npm:^1.14.0"
|
||||
type-fest: "npm:^3.11.1"
|
||||
typescript: "npm:^4.5.4"
|
||||
@@ -50223,6 +50235,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"true-myth@npm:^8.5.0":
|
||||
version: 8.5.0
|
||||
resolution: "true-myth@npm:8.5.0"
|
||||
checksum: 10/f3f96d96df8f0bfb5d26c379bbe029b947349b9dabdd00afa939ff75e979864c951c647ba35b21a13d676a9b34d721bffd2a1e9f0750d5d2a588988f16de5bd0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ts-api-utils@npm:^1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "ts-api-utils@npm:1.3.0"
|
||||
|
||||
Reference in New Issue
Block a user