fix(fe2): cors OPTIONS reqs sometimes returning redirects (#4860)

* fix(fe2): cors OPTIONS reqs sometimes returning redirects

* path parsing fix

* test fix
This commit is contained in:
Kristaps Fabians Geikins
2025-06-02 10:22:40 +03:00
committed by GitHub
parent 23b61769b7
commit 4412e7a798
6 changed files with 108 additions and 58 deletions
+1 -15
View File
@@ -24,6 +24,7 @@ const buildSourceMaps = ['1', 'true', true, 1].includes(BUILD_SOURCEMAPS)
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
// ssr: false, // for debugging set to false (prod should always be true)
...(buildSourceMaps ? { sourcemap: true } : {}),
modulesDir: ['./node_modules'],
typescript: {
@@ -146,21 +147,6 @@ export default defineNuxtConfig({
},
routeRules: {
// Necessary because of redirects from backend in auth flows
'/': {
cors: true,
headers: {
'access-control-allow-methods': 'GET',
'Access-Control-Expose-Headers': '*'
}
},
'/authn/login': {
cors: true,
headers: {
'access-control-allow-methods': 'GET',
'Access-Control-Expose-Headers': '*'
}
},
'/functions': {
redirect: {
to: '/',
@@ -0,0 +1,32 @@
import { RelativeURL } from '@speckle/shared'
const corsRoutes = ['/', '/authn/login']
/**
* CORS settings in nuxt config routeRules suck - with those, OPTIONS requests can still trigger redirects as if the
* req is a normal GET request, which is not supported. So we're implementing CORS ourselves here.
*/
export default defineEventHandler((event) => {
const optionsResponse = (code: number) => new Response(null, { status: code })
// Get path w/o querystring
const path = new RelativeURL(event.path).pathname
// For CORS routes - allow all origins on GET (necessary for authentication fetch calls)
if (corsRoutes.includes(path)) {
setHeaders(event, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Expose-Headers': '*',
'Access-Control-Allow-Headers': '*',
'Access-Control-Max-Age': '0'
})
if (event.method === 'OPTIONS') return optionsResponse(204)
}
// For other routes CORS is disabled
if (event.method === 'OPTIONS') {
return optionsResponse(403)
}
})
@@ -5,6 +5,7 @@ import {
hasMinimumWorkspaceRole
} from '../checks/workspaceRole.js'
import {
PersonalProjectsLimitedError,
ProjectNotFoundError,
WorkspaceLimitsReachedError,
WorkspaceNoAccessError,
@@ -214,6 +215,7 @@ export const ensureWorkspaceProjectCanBeCreatedFragment: AuthPolicyEnsureFragmen
* If userId is specified, will also check for appropriate user role & seat
*/
export const ensureModelCanBeCreatedFragment: AuthPolicyEnsureFragment<
| typeof Loaders.getEnv
| typeof Loaders.getWorkspacePlan
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspaceLimits
@@ -232,55 +234,72 @@ export const ensureModelCanBeCreatedFragment: AuthPolicyEnsureFragment<
| typeof WorkspaceReadOnlyError
| typeof WorkspaceLimitsReachedError
| typeof ProjectNotFoundError
| typeof PersonalProjectsLimitedError
>
> =
(loaders) =>
async ({ projectId, userId, addedModelCount, workspaceId }) => {
addedModelCount = addedModelCount ?? 1
const { FF_WORKSPACES_MODULE_ENABLED, FF_PERSONAL_PROJECTS_LIMITS_ENABLED } =
await loaders.getEnv()
const project = await loaders.getProject({ projectId })
if (!project) return err(new ProjectNotFoundError())
// Project may not be attached to a workspace yet, then we use the specified workspaceId
workspaceId = workspaceId || project.workspaceId || undefined
if (!workspaceId) return ok()
if (userId) {
// Has workspace role
const isInWorkspace = await hasAnyWorkspaceRole(loaders)({
userId,
// If workspace
if (workspaceId && FF_WORKSPACES_MODULE_ENABLED) {
if (userId) {
// Has workspace role
const isInWorkspace = await hasAnyWorkspaceRole(loaders)({
userId,
workspaceId
})
if (!isInWorkspace) {
return err(new WorkspaceNoAccessError())
}
}
const ensuredNotReadOnly = await ensureWorkspaceNotReadOnlyFragment(loaders)({
workspaceId
})
if (!isInWorkspace) {
return err(new WorkspaceNoAccessError())
}
}
if (ensuredNotReadOnly.isErr) return err(ensuredNotReadOnly.error)
const ensuredNotReadOnly = await ensureWorkspaceNotReadOnlyFragment(loaders)({
workspaceId
})
if (ensuredNotReadOnly.isErr) return err(ensuredNotReadOnly.error)
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
if (!workspacePlan) return err(new WorkspaceNoAccessError())
const workspacePlan = await loaders.getWorkspacePlan({ workspaceId })
if (!workspacePlan) return err(new WorkspaceNoAccessError())
const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId })
if (!workspaceLimits) return err(new WorkspaceNoAccessError())
const workspaceLimits = await loaders.getWorkspaceLimits({ workspaceId })
if (!workspaceLimits) return err(new WorkspaceNoAccessError())
if (workspaceLimits.modelCount === null) return ok()
if (workspaceLimits.modelCount === null) return ok()
const currentModelCount = await loaders.getWorkspaceModelCount({ workspaceId })
const currentModelCount = await loaders.getWorkspaceModelCount({ workspaceId })
if (currentModelCount === null) return err(new WorkspaceNoAccessError())
if (currentModelCount === null) return err(new WorkspaceNoAccessError())
return currentModelCount + addedModelCount <= workspaceLimits.modelCount
? ok()
: err(
new WorkspaceLimitsReachedError({
message:
'You have reached the maximum number of models for your plan. Upgrade to increase it.',
payload: {
limit: 'modelCount'
}
})
return currentModelCount + addedModelCount <= workspaceLimits.modelCount
? ok()
: err(
new WorkspaceLimitsReachedError({
message:
'You have reached the maximum number of models for your plan. Upgrade to increase it.',
payload: {
limit: 'modelCount'
}
})
)
} else {
// If not - check personal project limits
if (FF_PERSONAL_PROJECTS_LIMITS_ENABLED) {
return err(
new PersonalProjectsLimitedError(
'No new models can be added to personal projects'
)
)
}
return ok()
}
}
@@ -1,5 +1,6 @@
import { err, ok } from 'true-myth/result'
import {
PersonalProjectsLimitedError,
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
@@ -65,6 +66,7 @@ type PolicyErrors =
| InstanceType<typeof WorkspaceNoEditorSeatError>
| InstanceType<typeof WorkspaceNotEnoughPermissionsError>
| InstanceType<typeof ProjectNotEnoughPermissionsError>
| InstanceType<typeof PersonalProjectsLimitedError>
export const canMoveToWorkspacePolicy: AuthPolicy<
PolicyLoaderKeys,
@@ -21,7 +21,11 @@ const buildCanCreateModelPolicy = (
overrides?: Partial<Parameters<typeof canCreateModelPolicy>[0]>
) =>
canCreateModelPolicy({
getEnv: async () => parseFeatureFlags({}),
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'true',
FF_PERSONAL_PROJECTS_LIMITS_ENABLED: 'false'
}),
getProject: getProjectFake({
id: cryptoRandomString({ length: 9 }),
workspaceId: cryptoRandomString({ length: 9 })
@@ -184,4 +188,22 @@ describe('canCreateModelPolicy returns a function, that', () => {
const result = await buildCanCreateModelPolicy({})(canCreateArgs())
expect(result).toBeAuthOKResult()
})
it('allows even if workspaceId is set, but workspace module is disabled', async () => {
const result = await buildCanCreateModelPolicy({
getWorkspace: async () => {
assert.fail()
},
getWorkspaceRole: async () => {
assert.fail()
},
getEnv: async () =>
parseFeatureFlags({
FF_WORKSPACES_MODULE_ENABLED: 'false',
FF_PERSONAL_PROJECTS_LIMITS_ENABLED: 'false'
})
})(canCreateArgs())
expect(result).toBeAuthOKResult()
})
})
@@ -77,16 +77,5 @@ export const canCreateModelPolicy: AuthPolicy<
return err(ensuredModelsAccepted.error)
}
// Prevent personal project models, if personal projects limited
const project = await loaders.getProject({ projectId })
const env = await loaders.getEnv()
if (project && !project.workspaceId && env.FF_PERSONAL_PROJECTS_LIMITS_ENABLED) {
return err(
new PersonalProjectsLimitedError(
'No new models can be added to personal projects'
)
)
}
return ok()
}