From a0f9e4efce5db5d5fb9fe3d1fbfa09704b7cc35a Mon Sep 17 00:00:00 2001 From: Cory Steers Date: Thu, 29 May 2025 15:21:37 -0500 Subject: [PATCH 01/46] first cut at addressing bug with adding backstage dependencies to a package maintained by the backstage plugin Signed-off-by: Cory Steers --- .changeset/tiny-crews-shop.md | 5 ++ packages/yarn-plugin/package.json | 3 +- .../afterWorkspaceDependencyAddition.test.ts | 67 ++++++++++++++++ .../afterWorkspaceDependencyAddition.ts | 33 ++++++++ ...fterWorkspaceDependencyReplacement.test.ts | 77 +++++++++++++++++++ .../afterWorkspaceDependencyReplacement.ts | 35 +++++++++ packages/yarn-plugin/src/handlers/index.ts | 2 + packages/yarn-plugin/src/index.ts | 12 ++- yarn.lock | 5 +- 9 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 .changeset/tiny-crews-shop.md create mode 100644 packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.test.ts create mode 100644 packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts create mode 100644 packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.test.ts create mode 100644 packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts diff --git a/.changeset/tiny-crews-shop.md b/.changeset/tiny-crews-shop.md new file mode 100644 index 0000000000..18f205c5fc --- /dev/null +++ b/.changeset/tiny-crews-shop.md @@ -0,0 +1,5 @@ +--- +'yarn-plugin-backstage': patch +--- + +added functionality so that adding or updating a backstage dependency to a package would maintain the "backstage:^" placeholder for the version. diff --git a/packages/yarn-plugin/package.json b/packages/yarn-plugin/package.json index 7de8d140bb..c764da4fa6 100644 --- a/packages/yarn-plugin/package.json +++ b/packages/yarn-plugin/package.json @@ -32,8 +32,9 @@ "dependencies": { "@backstage/cli-common": "workspace:^", "@backstage/release-manifests": "workspace:^", - "@yarnpkg/core": "^4.4.0", + "@yarnpkg/core": "^4.4.1", "@yarnpkg/fslib": "^3.1.2", + "@yarnpkg/plugin-essentials": "^4.4.0", "@yarnpkg/plugin-npm": "patch:@yarnpkg/plugin-npm@npm%3A3.1.0#~/.yarn/patches/@yarnpkg-plugin-npm-npm-3.1.0-6533d0f5a1.patch", "@yarnpkg/plugin-pack": "^4.0.1", "semver": "^7.6.0" diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.test.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.test.ts new file mode 100644 index 0000000000..fa89080889 --- /dev/null +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Descriptor, + DescriptorHash, + IdentHash, + Workspace, +} from '@yarnpkg/core'; +import { suggestUtils } from '@yarnpkg/plugin-essentials'; +import { afterWorkspaceDependencyAddition } from './afterWorkspaceDependencyAddition'; + +describe('afterWorkspaceDependencyAddition', () => { + const workspace = {} as Workspace; + const target = {} as suggestUtils.Target; + const strategies: Array = []; + + it('should replace the range for a backstage scoped dependency', async () => { + const input: Descriptor = { + scope: 'backstage', + name: 'test-package', + range: '^1.0.0', + descriptorHash: {} as DescriptorHash, + identHash: {} as IdentHash, + }; + + await afterWorkspaceDependencyAddition( + workspace, + target, + input, + strategies, + ); + + expect(input.range).toBe('backstage:^'); + }); + + it('should not replace the range for a non-backstage scoped dependency', async () => { + const input: Descriptor = { + scope: 'backstage-community', + name: 'test-package', + range: '^1.0.0', + descriptorHash: {} as DescriptorHash, + identHash: {} as IdentHash, + }; + + await afterWorkspaceDependencyAddition( + workspace, + target, + input, + strategies, + ); + + expect(input.range).toBe('^1.0.0'); + }); +}); diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts new file mode 100644 index 0000000000..8e39b1c759 --- /dev/null +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Descriptor, Workspace } from '@yarnpkg/core'; +import { suggestUtils } from '@yarnpkg/plugin-essentials'; + +export const afterWorkspaceDependencyAddition = async ( + _workspace: Workspace, + _target: suggestUtils.Target, + descriptor: Descriptor, + _strategies: Array, +) => { + if (descriptor.scope === 'backstage' && descriptor.range !== 'backstage:^') { + // is there a better way to log than console.log? + console.log( + `afterWorkspaceDependencyAddition hook: Setting descriptor range from ${descriptor.range} to 'backstage:^' for ${descriptor.scope}/${descriptor.name}`, + ); + descriptor.range = 'backstage:^'; + } +}; diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.test.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.test.ts new file mode 100644 index 0000000000..eef3f25f97 --- /dev/null +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Descriptor, + DescriptorHash, + IdentHash, + Workspace, +} from '@yarnpkg/core'; +import { suggestUtils } from '@yarnpkg/plugin-essentials'; +import { afterWorkspaceDependencyReplacement } from './afterWorkspaceDependencyReplacement'; + +describe('afterWorkspaceDependencyReplacement.test', () => { + const workspace = {} as Workspace; + const target = {} as suggestUtils.Target; + + it('should warn that the range is being changed for a backstage scoped dependency', async () => { + const fromDescriptor: Descriptor = { + scope: 'backstage', + name: 'test-package', + range: 'backstage:^', + descriptorHash: {} as DescriptorHash, + identHash: {} as IdentHash, + }; + const toDescriptor: Descriptor = { + scope: 'backstage', + name: 'test-package', + range: '^1.0.0', + descriptorHash: {} as DescriptorHash, + identHash: {} as IdentHash, + }; + await afterWorkspaceDependencyReplacement( + workspace, + target, + fromDescriptor, + toDescriptor, + ); + + expect(toDescriptor.range).toBe('^1.0.0'); + }); + it('should ignore that the range is being changed for a non-backstage scoped dependency', async () => { + const fromDescriptor: Descriptor = { + scope: 'backstage-community', + name: 'test-package', + range: 'backstage:^', + descriptorHash: {} as DescriptorHash, + identHash: {} as IdentHash, + }; + const toDescriptor: Descriptor = { + scope: 'backstage-community', + name: 'test-package', + range: '^1.0.0', + descriptorHash: {} as DescriptorHash, + identHash: {} as IdentHash, + }; + await afterWorkspaceDependencyReplacement( + workspace, + target, + fromDescriptor, + toDescriptor, + ); + + expect(toDescriptor.range).toBe('^1.0.0'); + }); +}); diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts new file mode 100644 index 0000000000..b8141c8bb3 --- /dev/null +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Descriptor, Workspace } from '@yarnpkg/core'; +import { suggestUtils } from '@yarnpkg/plugin-essentials'; + +export const afterWorkspaceDependencyReplacement = async ( + _workspace: Workspace, + _target: suggestUtils.Target, + fromDescriptor: Descriptor, + toDescriptor: Descriptor, +) => { + if ( + toDescriptor.scope === 'backstage' && + toDescriptor.range !== 'backstage:^' + ) { + // is there a better way to log than console.log? + console.log( + `afterWorkspaceDependencyReplacement hook: Setting descriptor range from '${fromDescriptor.range}' to '${toDescriptor.range}' for ${fromDescriptor.scope}/${fromDescriptor.name}. Are you sure you want to be doing that?`, + ); + } +}; diff --git a/packages/yarn-plugin/src/handlers/index.ts b/packages/yarn-plugin/src/handlers/index.ts index baa144b36f..fe8f05c935 100644 --- a/packages/yarn-plugin/src/handlers/index.ts +++ b/packages/yarn-plugin/src/handlers/index.ts @@ -16,3 +16,5 @@ export { beforeWorkspacePacking } from './beforeWorkspacePacking'; export { reduceDependency } from './reduceDependency'; +export { afterWorkspaceDependencyAddition } from './afterWorkspaceDependencyAddition'; +export { afterWorkspaceDependencyReplacement } from './afterWorkspaceDependencyReplacement'; diff --git a/packages/yarn-plugin/src/index.ts b/packages/yarn-plugin/src/index.ts index ad18deb0ff..b08d98eae7 100644 --- a/packages/yarn-plugin/src/index.ts +++ b/packages/yarn-plugin/src/index.ts @@ -23,7 +23,13 @@ import { Plugin, Hooks, semverUtils, YarnVersion } from '@yarnpkg/core'; import { Hooks as PackHooks } from '@yarnpkg/plugin-pack'; -import { beforeWorkspacePacking, reduceDependency } from './handlers'; +import { Hooks as EssentialHooks } from '@yarnpkg/plugin-essentials'; +import { + afterWorkspaceDependencyAddition, + afterWorkspaceDependencyReplacement, + beforeWorkspacePacking, + reduceDependency, +} from './handlers'; import { BackstageNpmResolver } from './resolvers'; // All dependencies of the yarn plugin are bundled during the build. Chalk @@ -44,8 +50,10 @@ if (!semverUtils.satisfiesWithPrereleases(YarnVersion, '^4.1.1')) { /** * @public */ -const plugin: Plugin = { +const plugin: Plugin = { hooks: { + afterWorkspaceDependencyAddition, + afterWorkspaceDependencyReplacement, reduceDependency, beforeWorkspacePacking, }, diff --git a/yarn.lock b/yarn.lock index 14e234f527..539b89f70c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22705,7 +22705,7 @@ __metadata: languageName: node linkType: hard -"@yarnpkg/core@npm:^4.2.1, @yarnpkg/core@npm:^4.4.0": +"@yarnpkg/core@npm:^4.2.1, @yarnpkg/core@npm:^4.4.0, @yarnpkg/core@npm:^4.4.1": version: 4.4.1 resolution: "@yarnpkg/core@npm:4.4.1" dependencies: @@ -49221,8 +49221,9 @@ __metadata: "@backstage/cli-common": "workspace:^" "@backstage/release-manifests": "workspace:^" "@yarnpkg/builder": "npm:^4.2.1" - "@yarnpkg/core": "npm:^4.4.0" + "@yarnpkg/core": "npm:^4.4.1" "@yarnpkg/fslib": "npm:^3.1.2" + "@yarnpkg/plugin-essentials": "npm:^4.4.0" "@yarnpkg/plugin-npm": "patch:@yarnpkg/plugin-npm@npm%3A3.1.0#~/.yarn/patches/@yarnpkg-plugin-npm-npm-3.1.0-6533d0f5a1.patch" "@yarnpkg/plugin-pack": "npm:^4.0.1" fs-extra: "npm:^11.2.0" From 797688a944d2854fcea1be90903f7048b605a0ef Mon Sep 17 00:00:00 2001 From: Cory Steers Date: Fri, 30 May 2025 10:23:28 -0500 Subject: [PATCH 02/46] enhance logic to make sure backstage can manage the version of the dependency before changing the range Signed-off-by: Cory Steers --- .../afterWorkspaceDependencyAddition.test.ts | 54 +++++++++++++++- .../afterWorkspaceDependencyAddition.ts | 30 ++++++--- ...fterWorkspaceDependencyReplacement.test.ts | 63 ++++++++++++++++++- .../afterWorkspaceDependencyReplacement.ts | 25 +++++--- 4 files changed, 155 insertions(+), 17 deletions(-) diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.test.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.test.ts index fa89080889..ae575a8b02 100644 --- a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.test.ts +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.test.ts @@ -20,12 +20,28 @@ import { Workspace, } from '@yarnpkg/core'; import { suggestUtils } from '@yarnpkg/plugin-essentials'; +import { getPackageVersion } from '../util'; import { afterWorkspaceDependencyAddition } from './afterWorkspaceDependencyAddition'; +jest.mock('../util', () => ({ + getPackageVersion: jest.fn(), +})); + describe('afterWorkspaceDependencyAddition', () => { - const workspace = {} as Workspace; + const workspace = { + project: { + configuration: {}, + }, + } as Workspace; const target = {} as suggestUtils.Target; const strategies: Array = []; + const mockGetPackageVersion = getPackageVersion as jest.MockedFunction< + typeof getPackageVersion + >; + + beforeEach(() => { + mockGetPackageVersion.mockReset(); + }); it('should replace the range for a backstage scoped dependency', async () => { const input: Descriptor = { @@ -36,6 +52,8 @@ describe('afterWorkspaceDependencyAddition', () => { identHash: {} as IdentHash, }; + mockGetPackageVersion.mockImplementation(() => Promise.resolve('success')); + await afterWorkspaceDependencyAddition( workspace, target, @@ -44,6 +62,39 @@ describe('afterWorkspaceDependencyAddition', () => { ); expect(input.range).toBe('backstage:^'); + expect(mockGetPackageVersion).toHaveBeenCalledTimes(1); + expect(mockGetPackageVersion).toHaveBeenCalledWith( + input, + workspace.project.configuration, + ); + }); + + it('should not replace the range for a backstage scoped dependency where it cant find a version from remote', async () => { + const input: Descriptor = { + scope: 'backstage', + name: 'test-package', + range: '^1.0.0', + descriptorHash: {} as DescriptorHash, + identHash: {} as IdentHash, + }; + + mockGetPackageVersion.mockImplementation(() => + Promise.reject(new Error('test error')), + ); + + await afterWorkspaceDependencyAddition( + workspace, + target, + input, + strategies, + ); + + expect(input.range).toBe('^1.0.0'); + expect(mockGetPackageVersion).toHaveBeenCalledTimes(1); + expect(mockGetPackageVersion).toHaveBeenCalledWith( + input, + workspace.project.configuration, + ); }); it('should not replace the range for a non-backstage scoped dependency', async () => { @@ -63,5 +114,6 @@ describe('afterWorkspaceDependencyAddition', () => { ); expect(input.range).toBe('^1.0.0'); + expect(mockGetPackageVersion).not.toHaveBeenCalled(); }); }); diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts index 8e39b1c759..9307790930 100644 --- a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts @@ -14,20 +14,34 @@ * limitations under the License. */ -import { Descriptor, Workspace } from '@yarnpkg/core'; +import { Descriptor, structUtils, Workspace } from '@yarnpkg/core'; import { suggestUtils } from '@yarnpkg/plugin-essentials'; +import { getPackageVersion } from '../util'; +import { PROTOCOL } from '../constants'; export const afterWorkspaceDependencyAddition = async ( - _workspace: Workspace, + workspace: Workspace, _target: suggestUtils.Target, descriptor: Descriptor, _strategies: Array, ) => { - if (descriptor.scope === 'backstage' && descriptor.range !== 'backstage:^') { - // is there a better way to log than console.log? - console.log( - `afterWorkspaceDependencyAddition hook: Setting descriptor range from ${descriptor.range} to 'backstage:^' for ${descriptor.scope}/${descriptor.name}`, - ); - descriptor.range = 'backstage:^'; + const descriptorRange = structUtils.parseRange(descriptor.range); + + if ( + descriptor.scope === 'backstage' && + descriptorRange.protocol !== PROTOCOL + ) { + try { + await getPackageVersion(descriptor, workspace.project.configuration); + // is there a better way to log than console.log? + console.log( + `afterWorkspaceDependencyAddition hook: Setting descriptor range from ${descriptor.range} to 'backstage:^' for ${descriptor.scope}/${descriptor.name}`, + ); + descriptor.range = `${PROTOCOL}^`; + } catch (_error: any) { + // if there's no found version then this is likely a deprecated package + // or otherwise the plugin won't be able to resolve the real version + // and we should leave the desired range as is + } } }; diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.test.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.test.ts index eef3f25f97..7bf9c77554 100644 --- a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.test.ts +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.test.ts @@ -20,11 +20,27 @@ import { Workspace, } from '@yarnpkg/core'; import { suggestUtils } from '@yarnpkg/plugin-essentials'; +import { getPackageVersion } from '../util'; import { afterWorkspaceDependencyReplacement } from './afterWorkspaceDependencyReplacement'; +jest.mock('../util', () => ({ + getPackageVersion: jest.fn(), +})); + describe('afterWorkspaceDependencyReplacement.test', () => { - const workspace = {} as Workspace; + const workspace = { + project: { + configuration: {}, + }, + } as Workspace; const target = {} as suggestUtils.Target; + const mockGetPackageVersion = getPackageVersion as jest.MockedFunction< + typeof getPackageVersion + >; + + beforeEach(() => { + mockGetPackageVersion.mockReset(); + }); it('should warn that the range is being changed for a backstage scoped dependency', async () => { const fromDescriptor: Descriptor = { @@ -41,6 +57,9 @@ describe('afterWorkspaceDependencyReplacement.test', () => { descriptorHash: {} as DescriptorHash, identHash: {} as IdentHash, }; + + mockGetPackageVersion.mockImplementation(() => Promise.resolve('success')); + await afterWorkspaceDependencyReplacement( workspace, target, @@ -49,7 +68,48 @@ describe('afterWorkspaceDependencyReplacement.test', () => { ); expect(toDescriptor.range).toBe('^1.0.0'); + expect(mockGetPackageVersion).toHaveBeenCalledTimes(1); + expect(mockGetPackageVersion).toHaveBeenCalledWith( + toDescriptor, + workspace.project.configuration, + ); }); + + it('should not warn that the range is being changed for a backstage scoped dependency where it cant find a version from remote', async () => { + const fromDescriptor: Descriptor = { + scope: 'backstage', + name: 'test-package', + range: 'backstage:^', + descriptorHash: {} as DescriptorHash, + identHash: {} as IdentHash, + }; + const toDescriptor: Descriptor = { + scope: 'backstage', + name: 'test-package', + range: '^1.0.0', + descriptorHash: {} as DescriptorHash, + identHash: {} as IdentHash, + }; + + mockGetPackageVersion.mockImplementation(() => + Promise.reject(new Error('test error')), + ); + + await afterWorkspaceDependencyReplacement( + workspace, + target, + fromDescriptor, + toDescriptor, + ); + + expect(toDescriptor.range).toBe('^1.0.0'); + expect(mockGetPackageVersion).toHaveBeenCalledTimes(1); + expect(mockGetPackageVersion).toHaveBeenCalledWith( + toDescriptor, + workspace.project.configuration, + ); + }); + it('should ignore that the range is being changed for a non-backstage scoped dependency', async () => { const fromDescriptor: Descriptor = { scope: 'backstage-community', @@ -73,5 +133,6 @@ describe('afterWorkspaceDependencyReplacement.test', () => { ); expect(toDescriptor.range).toBe('^1.0.0'); + expect(mockGetPackageVersion).not.toHaveBeenCalled(); }); }); diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts index b8141c8bb3..a955191244 100644 --- a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts @@ -14,22 +14,33 @@ * limitations under the License. */ -import { Descriptor, Workspace } from '@yarnpkg/core'; +import { Descriptor, structUtils, Workspace } from '@yarnpkg/core'; import { suggestUtils } from '@yarnpkg/plugin-essentials'; +import { getPackageVersion } from '../util'; +import { PROTOCOL } from '../constants'; export const afterWorkspaceDependencyReplacement = async ( - _workspace: Workspace, + workspace: Workspace, _target: suggestUtils.Target, fromDescriptor: Descriptor, toDescriptor: Descriptor, ) => { + const toDescriptorRange = structUtils.parseRange(toDescriptor.range); + if ( toDescriptor.scope === 'backstage' && - toDescriptor.range !== 'backstage:^' + toDescriptorRange.protocol !== PROTOCOL ) { - // is there a better way to log than console.log? - console.log( - `afterWorkspaceDependencyReplacement hook: Setting descriptor range from '${fromDescriptor.range}' to '${toDescriptor.range}' for ${fromDescriptor.scope}/${fromDescriptor.name}. Are you sure you want to be doing that?`, - ); + try { + await getPackageVersion(toDescriptor, workspace.project.configuration); + // is there a better way to log than console.log? + console.log( + `afterWorkspaceDependencyReplacement hook: Setting descriptor range from '${fromDescriptor.range}' to '${toDescriptor.range}' for ${fromDescriptor.scope}/${fromDescriptor.name}. Are you sure you want to be doing that?`, + ); + } catch (_error: any) { + // if there's no found version then this is likely a deprecated package + // or otherwise the plugin won't be able to resolve the real version + // and we should not warn them. + } } }; From 95955c21f28ac07c05bae543f9a810fc0a6bb660 Mon Sep 17 00:00:00 2001 From: Cory Steers Date: Fri, 30 May 2025 10:47:15 -0500 Subject: [PATCH 03/46] update with doc on new hooks added Signed-off-by: Cory Steers --- packages/yarn-plugin/README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/yarn-plugin/README.md b/packages/yarn-plugin/README.md index 073669b9a5..49b57fe0ef 100644 --- a/packages/yarn-plugin/README.md +++ b/packages/yarn-plugin/README.md @@ -82,3 +82,12 @@ specific Backstage repository. As such, when publishing packages, all `backstage:^` versions should be removed from the package.json and replaced with the appropriate npm version ranges. This is handled by the `beforeWorkspacePacking` hook. + +### `afterWorkspaceDependencyAddition` hook + +\_Replaces npm version ranges with `backstage:^` ranges for `@backstage/*` dependencies added after +the plugin has converted existing dependencies to `backstage:^` range + +### `afterWorkspaceDependencyReplacement` hook + +\_warns user with console message when running `yarn add` for a `@backstage/*` scoped dependency that is already a dependency in the target package. Doing so will remove the `backstage:^` scope and replace it with the actual npm version range, which may not be desired. From 1115791e35d7fe824e5701c69aeadfabd541837b Mon Sep 17 00:00:00 2001 From: Cory Steers Date: Fri, 30 May 2025 12:37:22 -0500 Subject: [PATCH 04/46] formatting fixes Signed-off-by: Cory Steers --- packages/yarn-plugin/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/yarn-plugin/README.md b/packages/yarn-plugin/README.md index 49b57fe0ef..5c112506e3 100644 --- a/packages/yarn-plugin/README.md +++ b/packages/yarn-plugin/README.md @@ -85,9 +85,9 @@ the appropriate npm version ranges. This is handled by the ### `afterWorkspaceDependencyAddition` hook -\_Replaces npm version ranges with `backstage:^` ranges for `@backstage/*` dependencies added after -the plugin has converted existing dependencies to `backstage:^` range +_Replaces npm version ranges with `backstage:^` ranges for `@backstage/*` dependencies added after +the plugin has converted existing dependencies to `backstage:^` range_ ### `afterWorkspaceDependencyReplacement` hook -\_warns user with console message when running `yarn add` for a `@backstage/*` scoped dependency that is already a dependency in the target package. Doing so will remove the `backstage:^` scope and replace it with the actual npm version range, which may not be desired. +_warns user with console message when running `yarn add` for a `@backstage/*` scoped dependency that is already a dependency in the target package. Doing so will remove the `backstage:^` scope and replace it with the actual npm version range, which may not be desired._ From 52f5f431d018a7022741ce79cfcca0fa9c7bc0ee Mon Sep 17 00:00:00 2001 From: Cory Steers Date: Fri, 30 May 2025 12:41:39 -0500 Subject: [PATCH 05/46] update to include 3rd hooks import Signed-off-by: Cory Steers --- packages/yarn-plugin/report.api.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/yarn-plugin/report.api.md b/packages/yarn-plugin/report.api.md index 620bbbb567..b9425282b4 100644 --- a/packages/yarn-plugin/report.api.md +++ b/packages/yarn-plugin/report.api.md @@ -4,10 +4,11 @@ ```ts import { Hooks } from '@yarnpkg/core'; -import { Hooks as Hooks_2 } from '@yarnpkg/plugin-pack'; +import { Hooks as Hooks_2 } from '@yarnpkg/plugin-essentials'; +import { Hooks as Hooks_3 } from '@yarnpkg/plugin-pack'; import { Plugin as Plugin_2 } from '@yarnpkg/core'; // @public (undocumented) -const plugin: Plugin_2; +const plugin: Plugin_2; export default plugin; ``` From 1220cf84d0771dbf985c86aba736007a23598a11 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 17 Sep 2024 08:56:38 +0300 Subject: [PATCH 06/46] feat(backend): allow rate limiting requests to the backend uses redis for storing the data if it has been configured; otherwise falls back to memory configuration allows controlling almost all possible configuration available in the `express-rate-limit` library. Signed-off-by: Heikki Hellgren --- .changeset/famous-terms-rescue.md | 15 ++ packages/backend-defaults/config.d.ts | 68 ++++- packages/backend-defaults/package.json | 2 + .../report-rootHttpRouter.api.md | 11 + .../rootHttpRouter/http/MiddlewareFactory.ts | 86 ++++++- .../http/RateLimitStoreFactory.test.ts | 84 +++++++ .../http/RateLimitStoreFactory.ts | 69 ++++++ .../entrypoints/rootHttpRouter/http/index.ts | 1 + .../rootHttpRouterServiceFactory.ts | 5 +- yarn.lock | 234 +++++++++++++++++- 10 files changed, 558 insertions(+), 17 deletions(-) create mode 100644 .changeset/famous-terms-rescue.md create mode 100644 packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts create mode 100644 packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts diff --git a/.changeset/famous-terms-rescue.md b/.changeset/famous-terms-rescue.md new file mode 100644 index 0000000000..073f3a02ba --- /dev/null +++ b/.changeset/famous-terms-rescue.md @@ -0,0 +1,15 @@ +--- +'@backstage/backend-defaults': patch +--- + +Added new rate limit middleware to allow rate limiting requests to the backend + +Rate limiting can be turned on by adding the following configuration to `app-config.yaml`: + +```yaml +backend: + rateLimit: + enabled: true + windowMs: 60000 + limit: 100 +``` diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 1ef054a537..7866b17c29 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { HumanDuration } from '@backstage/types'; +import { HumanDuration, JsonValue } from '@backstage/types'; export interface Config { app: { @@ -790,6 +790,72 @@ export interface Config { headers?: { [name: string]: string }; }; + /** + * Rate limiting options + */ + rateLimit?: { + /** + * Store to use for rate limiting. If not defined, the store will be automatically + * decided based on `backend.cache.store` value. If + * redis is not available, the store will be memory. + */ + store?: 'memory' | 'redis'; + /** + * Rate limiting enabled. Defaults to false. + */ + enabled?: boolean; + /** + * Time frame in milliseconds or as human duration for which requests are checked/remembered. + * Defaults to 6000ms. + */ + window?: number | HumanDuration; + /** + * The maximum number of connections to allow during the `window` before rate limiting the client. + * Defaults to 5. + */ + limit?: number; + /** + * The response body to send back when a client is rate limited. + * Defaults to 'Too many requests, please try again later.'. + */ + message?: JsonValue; + /** + * The HTTP status code to send back when a client is rate limited. + * Defaults to 429. + */ + statusCode?: number; + /** + * Whether to send the legacy rate limit headers for the limit. + * Defaults to true. + */ + legacyHeaders?: boolean; + /** + * Whether to enable support for headers conforming the RateLimit header fields for HTTP + * standardization. Defaults to undefined. + */ + standardHeaders?: 'draft-6' | 'draft-7'; + /** + * Whether to pass requests in case of store failure. + * Defaults to false. + */ + passOnStoreError?: boolean; + /** + * List of allowed IP addresses that are not rate limited. + * Defaults to [127.0.0.1]. + */ + ipAllowList?: string[]; + /** + * Skip rate limiting for requests that have been successful. + * Defaults to false. + */ + skipSuccessfulRequests?: boolean; + /** + * Skip rate limiting for requests that have failed. + * Defaults to false. + */ + skipFailedRequests?: boolean; + }; + /** * Configuration related to URL reading, used for example for reading catalog info * files, scaffolder templates, and techdocs content. diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 3c74bad2d5..c10cb9f11f 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -168,6 +168,7 @@ "cron": "^3.0.0", "express": "^4.17.1", "express-promise-router": "^4.1.0", + "express-rate-limit": "^7.4.0", "fs-extra": "^11.2.0", "git-url-parse": "^15.0.0", "helmet": "^6.0.0", @@ -187,6 +188,7 @@ "pg": "^8.11.3", "pg-connection-string": "^2.3.0", "pg-format": "^1.0.4", + "rate-limit-redis": "^4.2.0", "raw-body": "^2.4.1", "selfsigned": "^2.0.0", "tar": "^6.1.12", diff --git a/packages/backend-defaults/report-rootHttpRouter.api.md b/packages/backend-defaults/report-rootHttpRouter.api.md index 826d592e8b..91e760b7ea 100644 --- a/packages/backend-defaults/report-rootHttpRouter.api.md +++ b/packages/backend-defaults/report-rootHttpRouter.api.md @@ -20,6 +20,7 @@ import { RootHttpRouterService } from '@backstage/backend-plugin-api'; import { Router } from 'express'; import type { Server } from 'node:http'; import { ServiceFactory } from '@backstage/backend-plugin-api'; +import type { Store } from 'express-rate-limit'; // @public (undocumented) export function createHealthRouter(options: { @@ -93,6 +94,7 @@ export class MiddlewareFactory { helmet(): RequestHandler; logging(): RequestHandler; notFound(): RequestHandler; + rateLimit(): RequestHandler; } // @public @@ -109,6 +111,15 @@ export interface MiddlewareFactoryOptions { logger: LoggerService; } +// @public +export class RateLimitStoreFactory { + constructor(config: Config); + // (undocumented) + create(): Store | undefined; + // (undocumented) + redis(): Store; +} + // @public export function readCorsOptions(config?: Config): CorsOptions; diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts index 1ff9bd15d1..561a1999c9 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts @@ -15,15 +15,15 @@ */ import { - RootConfigService, LoggerService, + RootConfigService, } from '@backstage/backend-plugin-api'; import { - Request, - Response, ErrorRequestHandler, NextFunction, + Request, RequestHandler, + Response, } from 'express'; import cors from 'cors'; import helmet from 'helmet'; @@ -37,12 +37,19 @@ import { InputError, NotAllowedError, NotFoundError, + NotImplementedError, NotModifiedError, - ServiceUnavailableError, serializeError, + ServiceUnavailableError, } from '@backstage/errors'; -import { NotImplementedError } from '@backstage/errors'; import { applyInternalErrorFilter } from './applyInternalErrorFilter'; +import { DraftHeadersVersion, rateLimit } from 'express-rate-limit'; +import { + durationToMilliseconds, + HumanDuration, + JsonValue, +} from '@backstage/types'; +import { RateLimitStoreFactory } from './RateLimitStoreFactory'; type LogMeta = { date: string; @@ -227,6 +234,75 @@ export class MiddlewareFactory { return cors(readCorsOptions(this.#config.getOptionalConfig('backend'))); } + /** + * Returns a middleware that implements rate limiting. + * + * @remarks + * + * Rate limiting is a common technique to prevent abuse of APIs. This middleware is + * configured using the config key `backend.rateLimit`. + * + * @returns An Express request handler + */ + rateLimit(): RequestHandler { + const rateLimitOptions = + this.#config.getOptionalConfig('backend.rateLimit'); + const enabled = rateLimitOptions?.getOptionalBoolean('enabled') ?? false; + if (!rateLimitOptions || !enabled) { + return (_req: Request, _res: Response, next: NextFunction) => { + next(); + }; + } + + const window = rateLimitOptions.getOptional( + 'window', + ); + let windowMs: number | undefined; + if (window !== undefined) { + if (typeof window === 'number') { + windowMs = window; + } else if (typeof window === 'object' && !Array.isArray(window)) { + windowMs = durationToMilliseconds(window); + } else { + throw new Error( + `Invalid configuration backend.rateLimit.window: ${window}, expected milliseconds number or HumanDuration object`, + ); + } + } + + const ipAllowList = rateLimitOptions.getOptionalStringArray( + 'ipAllowList', + ) ?? ['127.0.0.1']; + + return rateLimit({ + windowMs, + limit: rateLimitOptions.getOptionalNumber('limit'), + message: rateLimitOptions.getOptional('message'), + statusCode: rateLimitOptions.getOptionalNumber('statusCode'), + skipSuccessfulRequests: rateLimitOptions.getOptionalBoolean( + 'skipSuccessfulRequests', + ), + skipFailedRequests: + rateLimitOptions.getOptionalBoolean('skipFailedRequests'), + legacyHeaders: rateLimitOptions.getOptionalBoolean('legacyHeaders'), + standardHeaders: rateLimitOptions.getOptionalString( + 'standardHeaders', + ) as DraftHeadersVersion, + passOnStoreError: rateLimitOptions.getOptionalBoolean('passOnStoreError'), + keyGenerator(req, _res): string { + if (!req.ip) { + return req.socket.remoteAddress!; + } + + return req.ip.replace(/:\d+[^:]*$/, ''); + }, + skip: (req, _res) => { + return Boolean(req.ip && ipAllowList.includes(req.ip)); + }, + store: new RateLimitStoreFactory(this.#config).create(), + }); + } + /** * Express middleware to handle errors during request processing. * diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts new file mode 100644 index 0000000000..105cf0cac6 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; +import { RateLimitStoreFactory } from './RateLimitStoreFactory'; +import { RedisStore } from 'rate-limit-redis'; +import KeyvRedis from '@keyv/redis'; + +jest.mock('@keyv/redis'); +(KeyvRedis as jest.Mocked).mockImplementation(() => { + return { + redis: { + call: jest.fn().mockResolvedValue('OK'), + }, + }; +}); + +describe('CacheRateLimitStoreFactory', () => { + it('should return redis store with auto configuration', () => { + const config = mockServices.rootConfig({ + data: { + backend: { + cache: { + store: 'redis', + }, + rateLimit: { + store: undefined, + }, + }, + }, + }); + const factory = new RateLimitStoreFactory(config); + const store = factory.create(); + expect(store).toBeInstanceOf(RedisStore); + }); + + it('should return undefined store with auto configuration if redis is not available', () => { + const config = mockServices.rootConfig({ + data: { + backend: { + cache: { + store: 'memory', + }, + database: { + client: 'sqlite3', + }, + rateLimit: { + store: undefined, + }, + }, + }, + }); + const factory = new RateLimitStoreFactory(config); + const store = factory.create(); + expect(store).toBeUndefined(); + }); + + it('should return redis store if configured explicitly', () => { + const config = mockServices.rootConfig({ + data: { + backend: { + rateLimit: { + store: 'redis', + }, + }, + }, + }); + const factory = new RateLimitStoreFactory(config); + const store = factory.create(); + expect(store).toBeInstanceOf(RedisStore); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts new file mode 100644 index 0000000000..00e5440ebe --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Config } from '@backstage/config'; +import type { Store } from 'express-rate-limit'; +import KeyvRedis from '@keyv/redis'; +import { RedisStore } from 'rate-limit-redis'; + +/** + * Creates a store for `express-rate-limit` based on the configuration. + * + * @public + */ +export class RateLimitStoreFactory { + constructor(private readonly config: Config) {} + + create(): Store | undefined { + const storeType = this.config.getOptionalString('backend.rateLimit.store'); + if (!storeType) { + return this.auto(); + } + switch (storeType) { + case 'redis': + return this.redis(); + default: + throw new Error( + `Invalid 'backend.rateLimit.store' provided: ${storeType}`, + ); + } + } + + private auto(): Store | undefined { + const cacheStore = + this.config.getOptionalString('backend.cache.store') || 'memory'; + // Use redis as primary if available + if (cacheStore === 'redis') { + return this.redis(); + } + + // Fallback to undefined (memory) + return undefined; + } + + redis(): Store { + const connectionString = + this.config.getOptionalString('backend.cache.connection') || ''; + const useRedisSets = + this.config.getOptionalBoolean('backend.cache.useRedisSets') ?? true; + const keyv = new KeyvRedis(connectionString, { + useRedisSets, + }); + return new RedisStore({ + // Keyv uses ioredis under the hood + sendCommand: (...args: string[]) => keyv.redis.call(...args), + }); + } +} diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts index 4a9ec14cf8..5c63d887bb 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts @@ -28,3 +28,4 @@ export type { HttpServerCertificateOptions, HttpServerOptions, } from './types'; +export { RateLimitStoreFactory } from './RateLimitStoreFactory'; diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts index da7fcfe3f9..7d0d778b0c 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts @@ -15,13 +15,13 @@ */ import { - RootConfigService, coreServices, createServiceFactory, LifecycleService, LoggerService, + RootConfigService, } from '@backstage/backend-plugin-api'; -import express, { RequestHandler, Express } from 'express'; +import express, { Express, RequestHandler } from 'express'; import type { Server } from 'node:http'; import { createHttpServer, @@ -117,6 +117,7 @@ const rootHttpRouterServiceFactoryWithOptions = ( if (trustProxy !== undefined) { app.set('trust proxy', trustProxy); } + app.use(middleware.rateLimit()); app.use(middleware.helmet()); app.use(middleware.cors()); app.use(middleware.compression()); diff --git a/yarn.lock b/yarn.lock index d8736591ea..0a73d77844 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3335,7 +3335,16 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": + version: 7.26.7 + resolution: "@babel/runtime@npm:7.26.7" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 10/c7a661a6836b332d9d2e047cba77ba1862c1e4f78cec7146db45808182ef7636d8a7170be9797e5d8fd513180bffb9fa16f6ca1c69341891efec56113cf22bfc + languageName: node + linkType: hard + +"@babel/runtime@npm:^7.26.10": version: 7.27.0 resolution: "@babel/runtime@npm:7.27.0" dependencies: @@ -3344,7 +3353,18 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.27.0, @babel/template@npm:^7.3.3": +"@babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.3.3": + version: 7.25.9 + resolution: "@babel/template@npm:7.25.9" + dependencies: + "@babel/code-frame": "npm:^7.25.9" + "@babel/parser": "npm:^7.25.9" + "@babel/types": "npm:^7.25.9" + checksum: 10/e861180881507210150c1335ad94aff80fd9e9be6202e1efa752059c93224e2d5310186ddcdd4c0f0b0fc658ce48cb47823f15142b5c00c8456dde54f5de80b2 + languageName: node + linkType: hard + +"@babel/template@npm:^7.27.0": version: 7.27.0 resolution: "@babel/template@npm:7.27.0" dependencies: @@ -3370,7 +3390,17 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.10, @babel/types@npm:^7.22.5, @babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.0, @babel/types@npm:^7.27.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.10, @babel/types@npm:^7.22.5, @babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": + version: 7.26.0 + resolution: "@babel/types@npm:7.26.0" + dependencies: + "@babel/helper-string-parser": "npm:^7.25.9" + "@babel/helper-validator-identifier": "npm:^7.25.9" + checksum: 10/40780741ecec886ed9edae234b5eb4976968cc70d72b4e5a40d55f83ff2cc457de20f9b0f4fe9d858350e43dab0ea496e7ef62e2b2f08df699481a76df02cd6e + languageName: node + linkType: hard + +"@babel/types@npm:^7.27.0": version: 7.27.0 resolution: "@babel/types@npm:7.27.0" dependencies: @@ -3602,6 +3632,7 @@ __metadata: cron: "npm:^3.0.0" express: "npm:^4.17.1" express-promise-router: "npm:^4.1.0" + express-rate-limit: "npm:^7.4.0" fs-extra: "npm:^11.2.0" git-url-parse: "npm:^15.0.0" helmet: "npm:^6.0.0" @@ -3624,6 +3655,7 @@ __metadata: pg: "npm:^8.11.3" pg-connection-string: "npm:^2.3.0" pg-format: "npm:^1.0.4" + rate-limit-redis: "npm:^4.2.0" raw-body: "npm:^2.4.1" selfsigned: "npm:^2.0.0" supertest: "npm:^7.0.0" @@ -19193,6 +19225,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-darwin-arm64@npm:1.10.6": + version: 1.10.6 + resolution: "@swc/core-darwin-arm64@npm:1.10.6" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@swc/core-darwin-arm64@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-darwin-arm64@npm:1.11.24" @@ -19200,6 +19239,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-darwin-x64@npm:1.10.6": + version: 1.10.6 + resolution: "@swc/core-darwin-x64@npm:1.10.6" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@swc/core-darwin-x64@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-darwin-x64@npm:1.11.24" @@ -19207,6 +19253,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-arm-gnueabihf@npm:1.10.6": + version: 1.10.6 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.10.6" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@swc/core-linux-arm-gnueabihf@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-linux-arm-gnueabihf@npm:1.11.24" @@ -19214,6 +19267,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-arm64-gnu@npm:1.10.6": + version: 1.10.6 + resolution: "@swc/core-linux-arm64-gnu@npm:1.10.6" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@swc/core-linux-arm64-gnu@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-linux-arm64-gnu@npm:1.11.24" @@ -19221,6 +19281,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-arm64-musl@npm:1.10.6": + version: 1.10.6 + resolution: "@swc/core-linux-arm64-musl@npm:1.10.6" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@swc/core-linux-arm64-musl@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-linux-arm64-musl@npm:1.11.24" @@ -19228,6 +19295,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-x64-gnu@npm:1.10.6": + version: 1.10.6 + resolution: "@swc/core-linux-x64-gnu@npm:1.10.6" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@swc/core-linux-x64-gnu@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-linux-x64-gnu@npm:1.11.24" @@ -19235,6 +19309,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-linux-x64-musl@npm:1.10.6": + version: 1.10.6 + resolution: "@swc/core-linux-x64-musl@npm:1.10.6" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@swc/core-linux-x64-musl@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-linux-x64-musl@npm:1.11.24" @@ -19242,6 +19323,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-win32-arm64-msvc@npm:1.10.6": + version: 1.10.6 + resolution: "@swc/core-win32-arm64-msvc@npm:1.10.6" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@swc/core-win32-arm64-msvc@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-win32-arm64-msvc@npm:1.11.24" @@ -19249,6 +19337,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-win32-ia32-msvc@npm:1.10.6": + version: 1.10.6 + resolution: "@swc/core-win32-ia32-msvc@npm:1.10.6" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@swc/core-win32-ia32-msvc@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-win32-ia32-msvc@npm:1.11.24" @@ -19256,6 +19351,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-win32-x64-msvc@npm:1.10.6": + version: 1.10.6 + resolution: "@swc/core-win32-x64-msvc@npm:1.10.6" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@swc/core-win32-x64-msvc@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-win32-x64-msvc@npm:1.11.24" @@ -19263,7 +19365,7 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:^1.10.8, @swc/core@npm:^1.3.46": +"@swc/core@npm:^1.10.8": version: 1.11.24 resolution: "@swc/core@npm:1.11.24" dependencies: @@ -19309,6 +19411,52 @@ __metadata: languageName: node linkType: hard +"@swc/core@npm:^1.3.46": + version: 1.10.6 + resolution: "@swc/core@npm:1.10.6" + dependencies: + "@swc/core-darwin-arm64": "npm:1.10.6" + "@swc/core-darwin-x64": "npm:1.10.6" + "@swc/core-linux-arm-gnueabihf": "npm:1.10.6" + "@swc/core-linux-arm64-gnu": "npm:1.10.6" + "@swc/core-linux-arm64-musl": "npm:1.10.6" + "@swc/core-linux-x64-gnu": "npm:1.10.6" + "@swc/core-linux-x64-musl": "npm:1.10.6" + "@swc/core-win32-arm64-msvc": "npm:1.10.6" + "@swc/core-win32-ia32-msvc": "npm:1.10.6" + "@swc/core-win32-x64-msvc": "npm:1.10.6" + "@swc/counter": "npm:^0.1.3" + "@swc/types": "npm:^0.1.17" + peerDependencies: + "@swc/helpers": "*" + dependenciesMeta: + "@swc/core-darwin-arm64": + optional: true + "@swc/core-darwin-x64": + optional: true + "@swc/core-linux-arm-gnueabihf": + optional: true + "@swc/core-linux-arm64-gnu": + optional: true + "@swc/core-linux-arm64-musl": + optional: true + "@swc/core-linux-x64-gnu": + optional: true + "@swc/core-linux-x64-musl": + optional: true + "@swc/core-win32-arm64-msvc": + optional: true + "@swc/core-win32-ia32-msvc": + optional: true + "@swc/core-win32-x64-msvc": + optional: true + peerDependenciesMeta: + "@swc/helpers": + optional: true + checksum: 10/51eccbba6ee8a41f57a6ba4213ec05859434f52fd6698f508c92a8bb467afda56a1e95b07985faf059b27cb28e77d479340de7210a8616976ea82f774a6d86a3 + languageName: node + linkType: hard + "@swc/counter@npm:^0.1.3": version: 0.1.3 resolution: "@swc/counter@npm:0.1.3" @@ -19338,6 +19486,15 @@ __metadata: languageName: node linkType: hard +"@swc/types@npm:^0.1.17": + version: 0.1.17 + resolution: "@swc/types@npm:0.1.17" + dependencies: + "@swc/counter": "npm:^0.1.3" + checksum: 10/ddef1ad5bfead3acdfc41f14e79ba43a99200eb325afbad5716058dbe36358b0513400e9f22aff32432be84a98ae93df95a20b94192f69b8687144270e4eaa18 + languageName: node + linkType: hard + "@swc/types@npm:^0.1.21": version: 0.1.21 resolution: "@swc/types@npm:0.1.21" @@ -20659,7 +20816,16 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=18.0.0, @types/node@npm:^22.0.0": +"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0": + version: 22.10.5 + resolution: "@types/node@npm:22.10.5" + dependencies: + undici-types: "npm:~6.20.0" + checksum: 10/a5366961ffa9921e8f15435bc18ea9f8b7a7bb6b3d92dd5e93ebcd25e8af65708872bd8e6fee274b4655bab9ca80fbff9f0e42b5b53857790f13cf68cf4cbbfc + languageName: node + linkType: hard + +"@types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=18.0.0": version: 22.13.10 resolution: "@types/node@npm:22.13.10" dependencies: @@ -24011,13 +24177,20 @@ __metadata: languageName: node linkType: hard -"async@npm:^3.2.2, async@npm:^3.2.3, async@npm:^3.2.4, async@npm:^3.2.6": +"async@npm:^3.2.2, async@npm:^3.2.6": version: 3.2.6 resolution: "async@npm:3.2.6" checksum: 10/cb6e0561a3c01c4b56a799cc8bab6ea5fef45f069ab32500b6e19508db270ef2dffa55e5aed5865c5526e9907b1f8be61b27530823b411ffafb5e1538c86c368 languageName: node linkType: hard +"async@npm:^3.2.3, async@npm:^3.2.4": + version: 3.2.4 + resolution: "async@npm:3.2.4" + checksum: 10/bebb5dc2258c45b83fa1d3be179ae0eb468e1646a62d443c8d60a45e84041b28fccebe1e2d1f234bfc3dcad44e73dcdbf4ba63d98327c9f6556e3dbd47c2ae8b + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -24509,13 +24682,20 @@ __metadata: languageName: node linkType: hard -"before-after-hook@npm:^2.1.0, before-after-hook@npm:^2.2.0": +"before-after-hook@npm:^2.1.0": version: 2.2.3 resolution: "before-after-hook@npm:2.2.3" checksum: 10/e676f769dbc4abcf4b3317db2fd2badb4a92c0710e0a7da12cf14b59c3482d4febf835ad7de7874499060fd4e13adf0191628e504728b3c5bb4ec7a878c09940 languageName: node linkType: hard +"before-after-hook@npm:^2.2.0": + version: 2.2.2 + resolution: "before-after-hook@npm:2.2.2" + checksum: 10/34c190def503f771f8811db0bd0c62b35301fe6059c8d847664633ce0548e8253e2661104ba66c71a85548746ba87d5ff2ebf5278c1f3ad367d111ffc9a26bb4 + languageName: node + linkType: hard + "better-opn@npm:^3.0.2": version: 3.0.2 resolution: "better-opn@npm:3.0.2" @@ -29865,6 +30045,15 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^7.4.0": + version: 7.4.0 + resolution: "express-rate-limit@npm:7.4.0" + peerDependencies: + express: 4 || 5 || ^5.0.0-beta.1 + checksum: 10/33178c652bb1472aad2022194b5cd7963bd3e74d3eaf5e49eb1491a968fdce54551cc76b097ac10d3a1646d62cec2e6f2405ccef5ef5b60152a0c4a148749a4d + languageName: node + linkType: hard + "express-session@npm:^1.17.1, express-session@npm:^1.17.3": version: 1.18.1 resolution: "express-session@npm:1.18.1" @@ -31040,7 +31229,25 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": + version: 1.2.6 + resolution: "get-intrinsic@npm:1.2.6" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + dunder-proto: "npm:^1.0.0" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.0.0" + function-bind: "npm:^1.1.2" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.0.0" + checksum: 10/a1ffae6d7893a6fa0f4d1472adbc85095edd6b3b0943ead97c3738539cecb19d422ff4d48009eed8c3c27ad678c2b1e38a83b1a1e96b691d13ed8ecefca1068d + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" dependencies: @@ -36734,7 +36941,7 @@ __metadata: languageName: node linkType: hard -"math-intrinsics@npm:^1.1.0": +"math-intrinsics@npm:^1.0.0, math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" checksum: 10/11df2eda46d092a6035479632e1ec865b8134bdfc4bd9e571a656f4191525404f13a283a515938c3a8de934dbfd9c09674d9da9fa831e6eb7e22b50b197d2edd @@ -41835,6 +42042,15 @@ __metadata: languageName: node linkType: hard +"rate-limit-redis@npm:^4.2.0": + version: 4.2.0 + resolution: "rate-limit-redis@npm:4.2.0" + peerDependencies: + express-rate-limit: ">= 6" + checksum: 10/22adc67918ca906f613b45f9dcfd039f543d363921979d21ba56be5f3288c6e9973c9e4bb4ec59810fc6b3abb20defd572c102f607a8c3b4d273d5e09b63839f + languageName: node + linkType: hard + "rate-limiter-flexible@npm:^4.0.1": version: 4.0.1 resolution: "rate-limiter-flexible@npm:4.0.1" From 7bd8af72ad11eafda24b8c5ac5397ac6c825c170 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 17 Sep 2024 13:38:41 +0300 Subject: [PATCH 07/46] feat(backend): add support for trustProxy in backend config this is required for the rate limiting to work behind proxy. closes #24169 Signed-off-by: Heikki Hellgren --- .changeset/sour-comics-attend.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sour-comics-attend.md diff --git a/.changeset/sour-comics-attend.md b/.changeset/sour-comics-attend.md new file mode 100644 index 0000000000..729fc59ba7 --- /dev/null +++ b/.changeset/sour-comics-attend.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Add configuration variable for `express` trust proxy setting From 279c15cb5dc3d6151fc829e60cd3b0e471dee8f8 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 17 Sep 2024 17:19:45 +0300 Subject: [PATCH 08/46] fix: skip trust proxy validation in rate limiting Signed-off-by: Heikki Hellgren --- .changeset/famous-terms-rescue.md | 5 +- packages/backend-defaults/config.d.ts | 106 +++++++----------- .../report-rootHttpRouter.api.md | 10 -- .../rootHttpRouter/http/MiddlewareFactory.ts | 43 ++++--- .../http/RateLimitStoreFactory.test.ts | 50 +++------ .../http/RateLimitStoreFactory.ts | 40 ++----- .../entrypoints/rootHttpRouter/http/index.ts | 1 - 7 files changed, 91 insertions(+), 164 deletions(-) diff --git a/.changeset/famous-terms-rescue.md b/.changeset/famous-terms-rescue.md index 073f3a02ba..78630eb532 100644 --- a/.changeset/famous-terms-rescue.md +++ b/.changeset/famous-terms-rescue.md @@ -9,7 +9,6 @@ Rate limiting can be turned on by adding the following configuration to `app-con ```yaml backend: rateLimit: - enabled: true - windowMs: 60000 - limit: 100 + window: 60000 + incomingRequestLimit: 100 ``` diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 7866b17c29..49a51ec2f5 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { HumanDuration, JsonValue } from '@backstage/types'; +import { HumanDuration } from '@backstage/types'; export interface Config { app: { @@ -793,68 +793,48 @@ export interface Config { /** * Rate limiting options */ - rateLimit?: { - /** - * Store to use for rate limiting. If not defined, the store will be automatically - * decided based on `backend.cache.store` value. If - * redis is not available, the store will be memory. - */ - store?: 'memory' | 'redis'; - /** - * Rate limiting enabled. Defaults to false. - */ - enabled?: boolean; - /** - * Time frame in milliseconds or as human duration for which requests are checked/remembered. - * Defaults to 6000ms. - */ - window?: number | HumanDuration; - /** - * The maximum number of connections to allow during the `window` before rate limiting the client. - * Defaults to 5. - */ - limit?: number; - /** - * The response body to send back when a client is rate limited. - * Defaults to 'Too many requests, please try again later.'. - */ - message?: JsonValue; - /** - * The HTTP status code to send back when a client is rate limited. - * Defaults to 429. - */ - statusCode?: number; - /** - * Whether to send the legacy rate limit headers for the limit. - * Defaults to true. - */ - legacyHeaders?: boolean; - /** - * Whether to enable support for headers conforming the RateLimit header fields for HTTP - * standardization. Defaults to undefined. - */ - standardHeaders?: 'draft-6' | 'draft-7'; - /** - * Whether to pass requests in case of store failure. - * Defaults to false. - */ - passOnStoreError?: boolean; - /** - * List of allowed IP addresses that are not rate limited. - * Defaults to [127.0.0.1]. - */ - ipAllowList?: string[]; - /** - * Skip rate limiting for requests that have been successful. - * Defaults to false. - */ - skipSuccessfulRequests?: boolean; - /** - * Skip rate limiting for requests that have failed. - * Defaults to false. - */ - skipFailedRequests?: boolean; - }; + rateLimit?: + | false + | { + store?: + | { + client: 'redis'; + connection: string; + } + | { + client: 'memory'; + }; + /** + * Time frame in milliseconds or as human duration for which requests are checked/remembered. + * Defaults to 6000ms. + */ + window?: number | HumanDuration; + /** + * The maximum number of connections to allow during the `window` before rate limiting the client. + * Defaults to 5. + */ + incomingRequestLimit?: number; + /** + * Whether to pass requests in case of store failure. + * Defaults to false. + */ + passOnStoreError?: boolean; + /** + * List of allowed IP addresses that are not rate limited. + * Defaults to [127.0.0.1, 0:0:0:0:0:0:0:1, ::1]. + */ + ipAllowList?: string[]; + /** + * Skip rate limiting for requests that have been successful. + * Defaults to false. + */ + skipSuccessfulRequests?: boolean; + /** + * Skip rate limiting for requests that have failed. + * Defaults to false. + */ + skipFailedRequests?: boolean; + }; /** * Configuration related to URL reading, used for example for reading catalog info diff --git a/packages/backend-defaults/report-rootHttpRouter.api.md b/packages/backend-defaults/report-rootHttpRouter.api.md index 91e760b7ea..84424be39d 100644 --- a/packages/backend-defaults/report-rootHttpRouter.api.md +++ b/packages/backend-defaults/report-rootHttpRouter.api.md @@ -20,7 +20,6 @@ import { RootHttpRouterService } from '@backstage/backend-plugin-api'; import { Router } from 'express'; import type { Server } from 'node:http'; import { ServiceFactory } from '@backstage/backend-plugin-api'; -import type { Store } from 'express-rate-limit'; // @public (undocumented) export function createHealthRouter(options: { @@ -111,15 +110,6 @@ export interface MiddlewareFactoryOptions { logger: LoggerService; } -// @public -export class RateLimitStoreFactory { - constructor(config: Config); - // (undocumented) - create(): Store | undefined; - // (undocumented) - redis(): Store; -} - // @public export function readCorsOptions(config?: Config): CorsOptions; diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts index 561a1999c9..853e5aa8f6 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts @@ -43,12 +43,9 @@ import { ServiceUnavailableError, } from '@backstage/errors'; import { applyInternalErrorFilter } from './applyInternalErrorFilter'; -import { DraftHeadersVersion, rateLimit } from 'express-rate-limit'; -import { - durationToMilliseconds, - HumanDuration, - JsonValue, -} from '@backstage/types'; +import { rateLimit } from 'express-rate-limit'; +import { Config } from '@backstage/config'; +import { durationToMilliseconds, HumanDuration } from '@backstage/types'; import { RateLimitStoreFactory } from './RateLimitStoreFactory'; type LogMeta = { @@ -245,15 +242,15 @@ export class MiddlewareFactory { * @returns An Express request handler */ rateLimit(): RequestHandler { - const rateLimitOptions = - this.#config.getOptionalConfig('backend.rateLimit'); - const enabled = rateLimitOptions?.getOptionalBoolean('enabled') ?? false; - if (!rateLimitOptions || !enabled) { + const conf = this.#config.getOptional( + 'backend.rateLimit', + ); + if (!conf || typeof conf !== 'object') { return (_req: Request, _res: Response, next: NextFunction) => { next(); }; } - + const rateLimitOptions = conf as Config; const window = rateLimitOptions.getOptional( 'window', ); @@ -272,32 +269,34 @@ export class MiddlewareFactory { const ipAllowList = rateLimitOptions.getOptionalStringArray( 'ipAllowList', - ) ?? ['127.0.0.1']; + ) ?? ['127.0.0.1', '0:0:0:0:0:0:0:1', '::1']; return rateLimit({ windowMs, - limit: rateLimitOptions.getOptionalNumber('limit'), - message: rateLimitOptions.getOptional('message'), - statusCode: rateLimitOptions.getOptionalNumber('statusCode'), + limit: rateLimitOptions.getOptionalNumber('incomingRequestLimit'), skipSuccessfulRequests: rateLimitOptions.getOptionalBoolean( 'skipSuccessfulRequests', ), skipFailedRequests: rateLimitOptions.getOptionalBoolean('skipFailedRequests'), - legacyHeaders: rateLimitOptions.getOptionalBoolean('legacyHeaders'), - standardHeaders: rateLimitOptions.getOptionalString( - 'standardHeaders', - ) as DraftHeadersVersion, passOnStoreError: rateLimitOptions.getOptionalBoolean('passOnStoreError'), keyGenerator(req, _res): string { if (!req.ip) { return req.socket.remoteAddress!; } - - return req.ip.replace(/:\d+[^:]*$/, ''); + return req.ip; }, skip: (req, _res) => { - return Boolean(req.ip && ipAllowList.includes(req.ip)); + return ( + Boolean(req.ip && ipAllowList.includes(req.ip)) || + Boolean( + req.socket.remoteAddress && + ipAllowList.includes(req.socket.remoteAddress), + ) + ); + }, + validate: { + trustProxy: false, }, store: new RateLimitStoreFactory(this.#config).create(), }); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts index 105cf0cac6..4fdc513c81 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts @@ -13,49 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { mockServices } from '@backstage/backend-test-utils'; +import { mockServices, TestCaches } from '@backstage/backend-test-utils'; import { RateLimitStoreFactory } from './RateLimitStoreFactory'; import { RedisStore } from 'rate-limit-redis'; -import KeyvRedis from '@keyv/redis'; - -jest.mock('@keyv/redis'); -(KeyvRedis as jest.Mocked).mockImplementation(() => { - return { - redis: { - call: jest.fn().mockResolvedValue('OK'), - }, - }; -}); describe('CacheRateLimitStoreFactory', () => { - it('should return redis store with auto configuration', () => { - const config = mockServices.rootConfig({ - data: { - backend: { - cache: { - store: 'redis', - }, - rateLimit: { - store: undefined, - }, - }, - }, - }); - const factory = new RateLimitStoreFactory(config); - const store = factory.create(); - expect(store).toBeInstanceOf(RedisStore); - }); + const caches = TestCaches.create(); + + afterEach(jest.clearAllMocks); it('should return undefined store with auto configuration if redis is not available', () => { const config = mockServices.rootConfig({ data: { backend: { - cache: { - store: 'memory', - }, - database: { - client: 'sqlite3', - }, rateLimit: { store: undefined, }, @@ -67,12 +37,20 @@ describe('CacheRateLimitStoreFactory', () => { expect(store).toBeUndefined(); }); - it('should return redis store if configured explicitly', () => { + it('should return redis store if configured explicitly', async () => { + if (!caches.supports('REDIS_7')) { + return; + } + const conf = await caches.init('REDIS_7'); + const config = mockServices.rootConfig({ data: { backend: { rateLimit: { - store: 'redis', + store: { + client: 'redis', + connection: conf.connection, + }, }, }, }, diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts index 00e5440ebe..85eb3c4d86 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts @@ -21,46 +21,28 @@ import { RedisStore } from 'rate-limit-redis'; /** * Creates a store for `express-rate-limit` based on the configuration. * - * @public + * @internal */ export class RateLimitStoreFactory { constructor(private readonly config: Config) {} create(): Store | undefined { - const storeType = this.config.getOptionalString('backend.rateLimit.store'); - if (!storeType) { - return this.auto(); + const store = this.config.getOptionalConfig('backend.rateLimit.store'); + if (!store) { + return undefined; } - switch (storeType) { + const client = store.getString('client'); + switch (client) { case 'redis': - return this.redis(); + return this.redis(store); default: - throw new Error( - `Invalid 'backend.rateLimit.store' provided: ${storeType}`, - ); + return undefined; } } - private auto(): Store | undefined { - const cacheStore = - this.config.getOptionalString('backend.cache.store') || 'memory'; - // Use redis as primary if available - if (cacheStore === 'redis') { - return this.redis(); - } - - // Fallback to undefined (memory) - return undefined; - } - - redis(): Store { - const connectionString = - this.config.getOptionalString('backend.cache.connection') || ''; - const useRedisSets = - this.config.getOptionalBoolean('backend.cache.useRedisSets') ?? true; - const keyv = new KeyvRedis(connectionString, { - useRedisSets, - }); + redis(storeConfig: Config): Store { + const connectionString = storeConfig.getString('connection'); + const keyv = new KeyvRedis(connectionString); return new RedisStore({ // Keyv uses ioredis under the hood sendCommand: (...args: string[]) => keyv.redis.call(...args), diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts index 5c63d887bb..4a9ec14cf8 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts @@ -28,4 +28,3 @@ export type { HttpServerCertificateOptions, HttpServerOptions, } from './types'; -export { RateLimitStoreFactory } from './RateLimitStoreFactory'; From d6bd7a540d8ab2b3eee0bcce0bcb426514cf9226 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 26 Nov 2024 14:33:30 +0200 Subject: [PATCH 09/46] fix: review findings Signed-off-by: Heikki Hellgren --- .changeset/famous-terms-rescue.md | 3 +- app-config.yaml | 6 ++ packages/backend-defaults/config.d.ts | 8 +-- packages/backend-defaults/package.json | 2 +- .../rootHttpRouter/http/MiddlewareFactory.ts | 62 +++++++++++-------- .../http/RateLimitStoreFactory.test.ts | 35 ++++++----- .../http/RateLimitStoreFactory.ts | 16 ++--- .../rootHttpRouterServiceFactory.ts | 2 +- yarn.lock | 12 ++-- 9 files changed, 83 insertions(+), 63 deletions(-) diff --git a/.changeset/famous-terms-rescue.md b/.changeset/famous-terms-rescue.md index 78630eb532..9f38986934 100644 --- a/.changeset/famous-terms-rescue.md +++ b/.changeset/famous-terms-rescue.md @@ -4,11 +4,12 @@ Added new rate limit middleware to allow rate limiting requests to the backend +If you are using the `configure` callback of the root HTTP router service and do NOT call `applyDefaults()` inside it, please see [the relevant changes](https://github.com/backstage/backstage/pull/26725/files#diff-86ad1b6a694dd250823aee39d410428dd837c9d9a04ca8c33bd1081fbe3f22af) that were made, to see if you want to apply them as well to your custom configuration. Rate limiting can be turned on by adding the following configuration to `app-config.yaml`: ```yaml backend: rateLimit: - window: 60000 + window: 6000ms incomingRequestLimit: 100 ``` diff --git a/app-config.yaml b/app-config.yaml index eaee401d09..df80c7add0 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -36,6 +36,12 @@ backend: # keys: # - secret: ${BACKEND_SECRET} + # Used for testing rate limiting locally + # rateLimit: + # windowMs: 60000 + # incomingRequestLimit: 1 + # ipAllowList: [] + auth: # TODO: once plugins have been migrated we can remove this, but right now it # is require for the backend-next to work in this repo diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 49a51ec2f5..6cd1aa183a 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -791,10 +791,10 @@ export interface Config { }; /** - * Rate limiting options + * Rate limiting options. Defining this as `true` will enable rate limiting with default values. */ rateLimit?: - | false + | true | { store?: | { @@ -806,9 +806,9 @@ export interface Config { }; /** * Time frame in milliseconds or as human duration for which requests are checked/remembered. - * Defaults to 6000ms. + * Defaults to one minute. */ - window?: number | HumanDuration; + window?: string | HumanDuration; /** * The maximum number of connections to allow during the `window` before rate limiting the client. * Defaults to 5. diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index c10cb9f11f..b1f9513f2c 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -168,7 +168,7 @@ "cron": "^3.0.0", "express": "^4.17.1", "express-promise-router": "^4.1.0", - "express-rate-limit": "^7.4.0", + "express-rate-limit": "^7.5.0", "fs-extra": "^11.2.0", "git-url-parse": "^15.0.0", "helmet": "^6.0.0", diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts index 853e5aa8f6..3dddc99974 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts @@ -44,8 +44,8 @@ import { } from '@backstage/errors'; import { applyInternalErrorFilter } from './applyInternalErrorFilter'; import { rateLimit } from 'express-rate-limit'; -import { Config } from '@backstage/config'; -import { durationToMilliseconds, HumanDuration } from '@backstage/types'; +import { readDurationFromConfig } from '@backstage/config'; +import { durationToMilliseconds } from '@backstage/types'; import { RateLimitStoreFactory } from './RateLimitStoreFactory'; type LogMeta = { @@ -242,44 +242,50 @@ export class MiddlewareFactory { * @returns An Express request handler */ rateLimit(): RequestHandler { - const conf = this.#config.getOptional( - 'backend.rateLimit', - ); - if (!conf || typeof conf !== 'object') { + const enabled = this.#config.has('backend.rateLimit'); + if (!enabled) { return (_req: Request, _res: Response, next: NextFunction) => { next(); }; } - const rateLimitOptions = conf as Config; - const window = rateLimitOptions.getOptional( - 'window', - ); - let windowMs: number | undefined; - if (window !== undefined) { - if (typeof window === 'number') { - windowMs = window; - } else if (typeof window === 'object' && !Array.isArray(window)) { - windowMs = durationToMilliseconds(window); - } else { - throw new Error( - `Invalid configuration backend.rateLimit.window: ${window}, expected milliseconds number or HumanDuration object`, - ); - } + + const useDefaults = this.#config.getOptional('backend.rateLimit') === true; + const rateLimitOptions = useDefaults + ? undefined + : this.#config.getOptionalConfig('backend.rateLimit'); + + let windowMs: number = 60000; + if (rateLimitOptions && rateLimitOptions.has('window')) { + const windowDuration = readDurationFromConfig(rateLimitOptions, { + key: 'window', + }); + windowMs = durationToMilliseconds(windowDuration); } - const ipAllowList = rateLimitOptions.getOptionalStringArray( + const ipAllowList = rateLimitOptions?.getOptionalStringArray( 'ipAllowList', ) ?? ['127.0.0.1', '0:0:0:0:0:0:0:1', '::1']; return rateLimit({ windowMs, - limit: rateLimitOptions.getOptionalNumber('incomingRequestLimit'), - skipSuccessfulRequests: rateLimitOptions.getOptionalBoolean( + limit: rateLimitOptions?.getOptionalNumber('incomingRequestLimit'), + skipSuccessfulRequests: rateLimitOptions?.getOptionalBoolean( 'skipSuccessfulRequests', ), + message: { + error: { + name: 'Error', + message: `Too many requests, please try again later`, + }, + response: { + statusCode: 429, + }, + }, + statusCode: 429, skipFailedRequests: - rateLimitOptions.getOptionalBoolean('skipFailedRequests'), - passOnStoreError: rateLimitOptions.getOptionalBoolean('passOnStoreError'), + rateLimitOptions?.getOptionalBoolean('skipFailedRequests'), + passOnStoreError: + rateLimitOptions?.getOptionalBoolean('passOnStoreError'), keyGenerator(req, _res): string { if (!req.ip) { return req.socket.remoteAddress!; @@ -298,7 +304,9 @@ export class MiddlewareFactory { validate: { trustProxy: false, }, - store: new RateLimitStoreFactory(this.#config).create(), + store: useDefaults + ? undefined + : RateLimitStoreFactory.create(this.#config), }); } diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts index 4fdc513c81..c1f690474e 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts @@ -13,13 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { mockServices, TestCaches } from '@backstage/backend-test-utils'; +import { mockServices } from '@backstage/backend-test-utils'; import { RateLimitStoreFactory } from './RateLimitStoreFactory'; -import { RedisStore } from 'rate-limit-redis'; + +jest.mock('@keyv/redis', () => { + const Actual = jest.requireActual('@keyv/redis'); + return { + ...Actual, + __esModule: true, + default: jest.fn(() => { + return { + getClient: jest.fn(() => ({ + sendCommand: jest.fn().mockReturnValue('mock'), + })), + }; + }), + }; +}); describe('CacheRateLimitStoreFactory', () => { - const caches = TestCaches.create(); - afterEach(jest.clearAllMocks); it('should return undefined store with auto configuration if redis is not available', () => { @@ -32,31 +44,24 @@ describe('CacheRateLimitStoreFactory', () => { }, }, }); - const factory = new RateLimitStoreFactory(config); - const store = factory.create(); + const store = RateLimitStoreFactory.create(config); expect(store).toBeUndefined(); }); it('should return redis store if configured explicitly', async () => { - if (!caches.supports('REDIS_7')) { - return; - } - const conf = await caches.init('REDIS_7'); - const config = mockServices.rootConfig({ data: { backend: { rateLimit: { store: { client: 'redis', - connection: conf.connection, + connection: 'redis://localhost:6379', }, }, }, }, }); - const factory = new RateLimitStoreFactory(config); - const store = factory.create(); - expect(store).toBeInstanceOf(RedisStore); + const store = RateLimitStoreFactory.create(config); + expect(store).not.toBeUndefined(); }); }); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts index 85eb3c4d86..1275095aca 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts @@ -15,7 +15,6 @@ */ import { Config } from '@backstage/config'; import type { Store } from 'express-rate-limit'; -import KeyvRedis from '@keyv/redis'; import { RedisStore } from 'rate-limit-redis'; /** @@ -24,10 +23,8 @@ import { RedisStore } from 'rate-limit-redis'; * @internal */ export class RateLimitStoreFactory { - constructor(private readonly config: Config) {} - - create(): Store | undefined { - const store = this.config.getOptionalConfig('backend.rateLimit.store'); + static create(config: Config): Store | undefined { + const store = config.getOptionalConfig('backend.rateLimit.store'); if (!store) { return undefined; } @@ -40,12 +37,15 @@ export class RateLimitStoreFactory { } } - redis(storeConfig: Config): Store { + private static redis(storeConfig: Config): Store { const connectionString = storeConfig.getString('connection'); + const KeyvRedis = require('@keyv/redis').default; const keyv = new KeyvRedis(connectionString); return new RedisStore({ - // Keyv uses ioredis under the hood - sendCommand: (...args: string[]) => keyv.redis.call(...args), + sendCommand: async (...args: string[]) => { + const client = await keyv.getClient(); + return client.sendCommand(args); + }, }); } } diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts index 7d0d778b0c..3b50515742 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts @@ -117,11 +117,11 @@ const rootHttpRouterServiceFactoryWithOptions = ( if (trustProxy !== undefined) { app.set('trust proxy', trustProxy); } - app.use(middleware.rateLimit()); app.use(middleware.helmet()); app.use(middleware.cors()); app.use(middleware.compression()); app.use(middleware.logging()); + app.use(middleware.rateLimit()); app.use(healthRouter); app.use(routes); app.use(middleware.notFound()); diff --git a/yarn.lock b/yarn.lock index 0a73d77844..aebb875a10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3632,7 +3632,7 @@ __metadata: cron: "npm:^3.0.0" express: "npm:^4.17.1" express-promise-router: "npm:^4.1.0" - express-rate-limit: "npm:^7.4.0" + express-rate-limit: "npm:^7.5.0" fs-extra: "npm:^11.2.0" git-url-parse: "npm:^15.0.0" helmet: "npm:^6.0.0" @@ -30045,12 +30045,12 @@ __metadata: languageName: node linkType: hard -"express-rate-limit@npm:^7.4.0": - version: 7.4.0 - resolution: "express-rate-limit@npm:7.4.0" +"express-rate-limit@npm:^7.5.0": + version: 7.5.0 + resolution: "express-rate-limit@npm:7.5.0" peerDependencies: - express: 4 || 5 || ^5.0.0-beta.1 - checksum: 10/33178c652bb1472aad2022194b5cd7963bd3e74d3eaf5e49eb1491a968fdce54551cc76b097ac10d3a1646d62cec2e6f2405ccef5ef5b60152a0c4a148749a4d + express: ^4.11 || 5 || ^5.0.0-beta.1 + checksum: 10/eff34c83bf586789933a332a339b66649e2cca95c8e977d193aa8bead577d3182ac9f0e9c26f39389287539b8038890ff023f910b54ebb506a26a2ce135b92ca languageName: node linkType: hard From c05a6982eabffaddc1915faa720bffe65ac1777e Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Tue, 4 Feb 2025 08:06:37 +0200 Subject: [PATCH 10/46] feat: support postgres store for rate limiting Signed-off-by: Hellgren Heikki --- .changeset/famous-terms-rescue.md | 4 +- app-config.yaml | 2 +- packages/backend-defaults/config.d.ts | 16 +++ packages/backend-defaults/package.json | 1 + .../http/RateLimitStoreFactory.test.ts | 21 ++- .../http/RateLimitStoreFactory.ts | 15 ++ yarn.lock | 130 +++++++++++++++++- 7 files changed, 184 insertions(+), 5 deletions(-) diff --git a/.changeset/famous-terms-rescue.md b/.changeset/famous-terms-rescue.md index 9f38986934..68fd1f3591 100644 --- a/.changeset/famous-terms-rescue.md +++ b/.changeset/famous-terms-rescue.md @@ -4,12 +4,12 @@ Added new rate limit middleware to allow rate limiting requests to the backend -If you are using the `configure` callback of the root HTTP router service and do NOT call `applyDefaults()` inside it, please see [the relevant changes](https://github.com/backstage/backstage/pull/26725/files#diff-86ad1b6a694dd250823aee39d410428dd837c9d9a04ca8c33bd1081fbe3f22af) that were made, to see if you want to apply them as well to your custom configuration. +If you are using the `configure` callback of the root HTTP router service and do NOT call `applyDefaults()` inside it, please see [the relevant changes](https://github.com/backstage/backstage/pull/28708/files#diff-86ad1b6a694dd250823aee39d410428dd837c9d9a04ca8c33bd1081fbe3f22af) that were made, to see if you want to apply them as well to your custom configuration. Rate limiting can be turned on by adding the following configuration to `app-config.yaml`: ```yaml backend: rateLimit: - window: 6000ms + window: 6s incomingRequestLimit: 100 ``` diff --git a/app-config.yaml b/app-config.yaml index df80c7add0..89926357c2 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -38,7 +38,7 @@ backend: # Used for testing rate limiting locally # rateLimit: - # windowMs: 60000 + # windowMs: 1m # incomingRequestLimit: 1 # ipAllowList: [] diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 6cd1aa183a..67183896bb 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -801,6 +801,22 @@ export interface Config { client: 'redis'; connection: string; } + | { + client: 'postgres'; + connection: + | string + | { + /** + * @visibility secret + */ + password?: string; + /** + * Other connection settings + * @see https://node-postgres.com/apis/client + */ + [key: string]: unknown; + }; + } | { client: 'memory'; }; diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index b1f9513f2c..bca883c44e 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -130,6 +130,7 @@ "test": "backstage-cli package test" }, "dependencies": { + "@acpr/rate-limit-postgresql": "^1.4.1", "@aws-sdk/abort-controller": "^3.347.0", "@aws-sdk/client-codecommit": "^3.350.0", "@aws-sdk/client-s3": "^3.350.0", diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts index c1f690474e..6fe4a2d327 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts @@ -15,6 +15,8 @@ */ import { mockServices } from '@backstage/backend-test-utils'; import { RateLimitStoreFactory } from './RateLimitStoreFactory'; +import { RedisStore } from 'rate-limit-redis'; +import { PostgresStore } from '@acpr/rate-limit-postgresql'; jest.mock('@keyv/redis', () => { const Actual = jest.requireActual('@keyv/redis'); @@ -62,6 +64,23 @@ describe('CacheRateLimitStoreFactory', () => { }, }); const store = RateLimitStoreFactory.create(config); - expect(store).not.toBeUndefined(); + expect(store).toBeInstanceOf(RedisStore); + }); + + it('should return postgres store if configured explicitly', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + rateLimit: { + store: { + client: 'postgres', + connection: 'postgres://localhost:5432', + }, + }, + }, + }, + }); + const store = RateLimitStoreFactory.create(config); + expect(store).toBeInstanceOf(PostgresStore); }); }); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts index 1275095aca..3905655587 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts @@ -16,6 +16,8 @@ import { Config } from '@backstage/config'; import type { Store } from 'express-rate-limit'; import { RedisStore } from 'rate-limit-redis'; +import { parsePgConnectionString } from '../../database/connectors/postgres.ts'; +import { PostgresStore } from '@acpr/rate-limit-postgresql'; /** * Creates a store for `express-rate-limit` based on the configuration. @@ -32,6 +34,9 @@ export class RateLimitStoreFactory { switch (client) { case 'redis': return this.redis(store); + case 'postgres': + return this.postgres(store); + case 'memory': default: return undefined; } @@ -48,4 +53,14 @@ export class RateLimitStoreFactory { }, }); } + + private static postgres(storeConfig: Config): Store { + const connection = storeConfig.get('connection') as any; + const isConnectionString = + typeof connection === 'string' || connection instanceof String; + const connectionOptions = isConnectionString + ? parsePgConnectionString(connection as string) + : connection; + return new PostgresStore(connectionOptions, 'rl'); + } } diff --git a/yarn.lock b/yarn.lock index aebb875a10..66f819db26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,20 @@ __metadata: languageName: node linkType: hard +"@acpr/rate-limit-postgresql@npm:^1.4.1": + version: 1.4.1 + resolution: "@acpr/rate-limit-postgresql@npm:1.4.1" + dependencies: + "@types/pg-pool": "npm:2.0.3" + pg: "npm:8.11.3" + pg-pool: "npm:3.6.1" + postgres-migrations: "npm:5.3.0" + peerDependencies: + express-rate-limit: ">=6.0.0" + checksum: 10/9295f86890ea10f0be24a211f100cfe9dde40df20d8328be36a66736e36ee7043dc6fcae785e39bea19de3f43ad344f3e0fa3f9d40bc8d89d38bf6ce457bcc28 + languageName: node + linkType: hard + "@adobe/css-tools@npm:^4.4.0": version: 4.4.0 resolution: "@adobe/css-tools@npm:4.4.0" @@ -3580,6 +3594,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/backend-defaults@workspace:packages/backend-defaults" dependencies: + "@acpr/rate-limit-postgresql": "npm:^1.4.1" "@aws-sdk/abort-controller": "npm:^3.347.0" "@aws-sdk/client-codecommit": "npm:^3.350.0" "@aws-sdk/client-s3": "npm:^3.350.0" @@ -20987,6 +21002,15 @@ __metadata: languageName: node linkType: hard +"@types/pg-pool@npm:2.0.3": + version: 2.0.3 + resolution: "@types/pg-pool@npm:2.0.3" + dependencies: + "@types/pg": "npm:*" + checksum: 10/9ea0bcdbdd09c9de6f774e59465189e552ee094901724278082c41ba6287e7fddffb9ba4b4107c242bba4e8f8a1f0016e6a1eb0c6ca306d43c08b5ddd7f34549 + languageName: node + linkType: hard + "@types/pg-pool@npm:2.0.6": version: 2.0.6 resolution: "@types/pg-pool@npm:2.0.6" @@ -25149,6 +25173,13 @@ __metadata: languageName: node linkType: hard +"buffer-writer@npm:2.0.0": + version: 2.0.0 + resolution: "buffer-writer@npm:2.0.0" + checksum: 10/fdca8e28c55704de7af2f41c8f875293de69ad22005d5041d54aa916d125cead00afa969bc09e4702ae6b66e098409958c06bebfc97fcf8fa4ea5afcae088cd9 + languageName: node + linkType: hard + "buffer-xor@npm:^1.0.3": version: 1.0.3 resolution: "buffer-xor@npm:1.0.3" @@ -39963,6 +39994,13 @@ __metadata: languageName: node linkType: hard +"packet-reader@npm:1.0.0": + version: 1.0.0 + resolution: "packet-reader@npm:1.0.0" + checksum: 10/8504cc8c32672380867e933516a029b1d4dd784c139213c85c9042ffc1162de48ec914f8c71260a9311518694cf5d0be11c67357f4b536129d2ea42aa7257ec0 + languageName: node + linkType: hard + "pacote@npm:^12.0.0, pacote@npm:^12.0.2": version: 12.0.3 resolution: "pacote@npm:12.0.3" @@ -40492,7 +40530,7 @@ __metadata: languageName: node linkType: hard -"pg-connection-string@npm:^2.3.0, pg-connection-string@npm:^2.5.0, pg-connection-string@npm:^2.7.0": +"pg-connection-string@npm:^2.3.0, pg-connection-string@npm:^2.5.0, pg-connection-string@npm:^2.6.2, pg-connection-string@npm:^2.7.0": version: 2.7.0 resolution: "pg-connection-string@npm:2.7.0" checksum: 10/68015a8874b7ca5dad456445e4114af3d2602bac2fdb8069315ecad0ff9660ec93259b9af7186606529ac4f6f72a06831e6f20897a689b16cc7fda7ca0e247fd @@ -40520,6 +40558,24 @@ __metadata: languageName: node linkType: hard +"pg-pool@npm:3.6.1": + version: 3.6.1 + resolution: "pg-pool@npm:3.6.1" + peerDependencies: + pg: ">=8.0" + checksum: 10/5d1b02b959e6c849004d8f3d2222c48d3b3b67b7b1eb5f2e5819ed9412129ea6b0f0376bc74ddf197973c99575d325cbb3f64a8017ab520535c011329b12fffb + languageName: node + linkType: hard + +"pg-pool@npm:^3.6.1, pg-pool@npm:^3.7.0": + version: 3.7.0 + resolution: "pg-pool@npm:3.7.0" + peerDependencies: + pg: ">=8.0" + checksum: 10/a07a4f9e26eec9d7ac3597dc7b3469c62983edff9a321dbb7acbe1bbc7f5e9b2d33438e277d4cf8145071f3d63c7ebdc287a539fd69dfb8cdddb15b33eefe1a2 + languageName: node + linkType: hard + "pg-pool@npm:^3.8.0": version: 3.8.0 resolution: "pg-pool@npm:3.8.0" @@ -40536,6 +40592,13 @@ __metadata: languageName: node linkType: hard +"pg-protocol@npm:^1.6.0, pg-protocol@npm:^1.7.0": + version: 1.7.0 + resolution: "pg-protocol@npm:1.7.0" + checksum: 10/ffffdf74426c9357b57050f1c191e84447c0e8b2a701b3ab302ac7dd0eb27b862d92e5e3b2d38876a1051de83547eb9165d6a58b3a8e90bb050dae97f9993d54 + languageName: node + linkType: hard + "pg-types@npm:^2.1.0, pg-types@npm:^2.2.0": version: 2.2.0 resolution: "pg-types@npm:2.2.0" @@ -40564,6 +40627,30 @@ __metadata: languageName: node linkType: hard +"pg@npm:8.11.3": + version: 8.11.3 + resolution: "pg@npm:8.11.3" + dependencies: + buffer-writer: "npm:2.0.0" + packet-reader: "npm:1.0.0" + pg-cloudflare: "npm:^1.1.1" + pg-connection-string: "npm:^2.6.2" + pg-pool: "npm:^3.6.1" + pg-protocol: "npm:^1.6.0" + pg-types: "npm:^2.1.0" + pgpass: "npm:1.x" + peerDependencies: + pg-native: ">=3.0.1" + dependenciesMeta: + pg-cloudflare: + optional: true + peerDependenciesMeta: + pg-native: + optional: true + checksum: 10/f15f29c8e17723ee1da72abdf400cbed2c04602c58c93687f3f0068e71df2a6fb62b9a3543e13da21b10a0494f4c5b4cfc8d6cd8396617b76c4cbfd6ddab17e7 + languageName: node + linkType: hard + "pg@npm:^8.11.3, pg@npm:^8.9.0": version: 8.14.1 resolution: "pg@npm:8.14.1" @@ -40586,6 +40673,28 @@ __metadata: languageName: node linkType: hard +"pg@npm:^8.6.0": + version: 8.13.1 + resolution: "pg@npm:8.13.1" + dependencies: + pg-cloudflare: "npm:^1.1.1" + pg-connection-string: "npm:^2.7.0" + pg-pool: "npm:^3.7.0" + pg-protocol: "npm:^1.7.0" + pg-types: "npm:^2.1.0" + pgpass: "npm:1.x" + peerDependencies: + pg-native: ">=3.0.1" + dependenciesMeta: + pg-cloudflare: + optional: true + peerDependenciesMeta: + pg-native: + optional: true + checksum: 10/542aa49fcb37657cf5f779b4a31fe6eb336e683445ecca38e267eeb0ca85d873ffe51f04794f9f9e184187e9f74bf7895e932a0fa9507132ac0dfc76c7c73451 + languageName: node + linkType: hard + "pgpass@npm:1.x": version: 1.0.2 resolution: "pgpass@npm:1.0.2" @@ -41353,6 +41462,18 @@ __metadata: languageName: node linkType: hard +"postgres-migrations@npm:5.3.0": + version: 5.3.0 + resolution: "postgres-migrations@npm:5.3.0" + dependencies: + pg: "npm:^8.6.0" + sql-template-strings: "npm:^2.2.2" + bin: + pg-validate-migrations: dist/bin/validate.js + checksum: 10/520d95f01144f88689d5c0a7575743c4f99536935deb1ffff7b3765883a688c4f001d98e8b493ca9b342cd2609593970c3d2198b41fade648f102008e3607226 + languageName: node + linkType: hard + "postgres-range@npm:^1.1.1": version: 1.1.3 resolution: "postgres-range@npm:1.1.3" @@ -45172,6 +45293,13 @@ __metadata: languageName: node linkType: hard +"sql-template-strings@npm:^2.2.2": + version: 2.2.2 + resolution: "sql-template-strings@npm:2.2.2" + checksum: 10/594378a44acbaf3db8a4067137c0c315d0656fcc1b6b8fa76c760d032c1970bf6ede2b31690a3bdc6482d86cbff8b202bb14f6528aa1d9d6bf19d48b03ba2744 + languageName: node + linkType: hard + "sqlstring@npm:^2.3.2": version: 2.3.2 resolution: "sqlstring@npm:2.3.2" From 6633c138b583146db5574feac33f4b1712f43d36 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 27 Feb 2025 11:40:11 +0200 Subject: [PATCH 11/46] fix: review findings + docs update Signed-off-by: Hellgren Heikki --- .../core-services/root-http-router.md | 5 + packages/backend-defaults/config.d.ts | 20 +-- packages/backend-defaults/package.json | 1 - .../http/RateLimitStoreFactory.test.ts | 18 --- .../http/RateLimitStoreFactory.ts | 18 +-- yarn.lock | 130 +----------------- 6 files changed, 10 insertions(+), 182 deletions(-) diff --git a/docs/backend-system/core-services/root-http-router.md b/docs/backend-system/core-services/root-http-router.md index 7a268e27fc..bdaf7bba23 100644 --- a/docs/backend-system/core-services/root-http-router.md +++ b/docs/backend-system/core-services/root-http-router.md @@ -121,6 +121,11 @@ backend.add( app.use(middleware.cors()); app.use(middleware.compression()); + // Optional rate limiting middleware + app.use(middleware.rateLimit()); + // If you are using rate limiting behind a proxy, you should set the `trust proxy` setting to true + app.set('trust proxy', true); + app.use(healthRouter); // you can add you your own middleware in here diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 67183896bb..f67680d1e2 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -798,27 +798,11 @@ export interface Config { | { store?: | { - client: 'redis'; + type: 'redis'; connection: string; } | { - client: 'postgres'; - connection: - | string - | { - /** - * @visibility secret - */ - password?: string; - /** - * Other connection settings - * @see https://node-postgres.com/apis/client - */ - [key: string]: unknown; - }; - } - | { - client: 'memory'; + type: 'memory'; }; /** * Time frame in milliseconds or as human duration for which requests are checked/remembered. diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index bca883c44e..b1f9513f2c 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -130,7 +130,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@acpr/rate-limit-postgresql": "^1.4.1", "@aws-sdk/abort-controller": "^3.347.0", "@aws-sdk/client-codecommit": "^3.350.0", "@aws-sdk/client-s3": "^3.350.0", diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts index 6fe4a2d327..f1044979e8 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts @@ -16,7 +16,6 @@ import { mockServices } from '@backstage/backend-test-utils'; import { RateLimitStoreFactory } from './RateLimitStoreFactory'; import { RedisStore } from 'rate-limit-redis'; -import { PostgresStore } from '@acpr/rate-limit-postgresql'; jest.mock('@keyv/redis', () => { const Actual = jest.requireActual('@keyv/redis'); @@ -66,21 +65,4 @@ describe('CacheRateLimitStoreFactory', () => { const store = RateLimitStoreFactory.create(config); expect(store).toBeInstanceOf(RedisStore); }); - - it('should return postgres store if configured explicitly', async () => { - const config = mockServices.rootConfig({ - data: { - backend: { - rateLimit: { - store: { - client: 'postgres', - connection: 'postgres://localhost:5432', - }, - }, - }, - }, - }); - const store = RateLimitStoreFactory.create(config); - expect(store).toBeInstanceOf(PostgresStore); - }); }); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts index 3905655587..0a24a8792c 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts @@ -16,8 +16,6 @@ import { Config } from '@backstage/config'; import type { Store } from 'express-rate-limit'; import { RedisStore } from 'rate-limit-redis'; -import { parsePgConnectionString } from '../../database/connectors/postgres.ts'; -import { PostgresStore } from '@acpr/rate-limit-postgresql'; /** * Creates a store for `express-rate-limit` based on the configuration. @@ -30,12 +28,10 @@ export class RateLimitStoreFactory { if (!store) { return undefined; } - const client = store.getString('client'); - switch (client) { + const type = store.getString('type'); + switch (type) { case 'redis': return this.redis(store); - case 'postgres': - return this.postgres(store); case 'memory': default: return undefined; @@ -53,14 +49,4 @@ export class RateLimitStoreFactory { }, }); } - - private static postgres(storeConfig: Config): Store { - const connection = storeConfig.get('connection') as any; - const isConnectionString = - typeof connection === 'string' || connection instanceof String; - const connectionOptions = isConnectionString - ? parsePgConnectionString(connection as string) - : connection; - return new PostgresStore(connectionOptions, 'rl'); - } } diff --git a/yarn.lock b/yarn.lock index 66f819db26..aebb875a10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,20 +12,6 @@ __metadata: languageName: node linkType: hard -"@acpr/rate-limit-postgresql@npm:^1.4.1": - version: 1.4.1 - resolution: "@acpr/rate-limit-postgresql@npm:1.4.1" - dependencies: - "@types/pg-pool": "npm:2.0.3" - pg: "npm:8.11.3" - pg-pool: "npm:3.6.1" - postgres-migrations: "npm:5.3.0" - peerDependencies: - express-rate-limit: ">=6.0.0" - checksum: 10/9295f86890ea10f0be24a211f100cfe9dde40df20d8328be36a66736e36ee7043dc6fcae785e39bea19de3f43ad344f3e0fa3f9d40bc8d89d38bf6ce457bcc28 - languageName: node - linkType: hard - "@adobe/css-tools@npm:^4.4.0": version: 4.4.0 resolution: "@adobe/css-tools@npm:4.4.0" @@ -3594,7 +3580,6 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/backend-defaults@workspace:packages/backend-defaults" dependencies: - "@acpr/rate-limit-postgresql": "npm:^1.4.1" "@aws-sdk/abort-controller": "npm:^3.347.0" "@aws-sdk/client-codecommit": "npm:^3.350.0" "@aws-sdk/client-s3": "npm:^3.350.0" @@ -21002,15 +20987,6 @@ __metadata: languageName: node linkType: hard -"@types/pg-pool@npm:2.0.3": - version: 2.0.3 - resolution: "@types/pg-pool@npm:2.0.3" - dependencies: - "@types/pg": "npm:*" - checksum: 10/9ea0bcdbdd09c9de6f774e59465189e552ee094901724278082c41ba6287e7fddffb9ba4b4107c242bba4e8f8a1f0016e6a1eb0c6ca306d43c08b5ddd7f34549 - languageName: node - linkType: hard - "@types/pg-pool@npm:2.0.6": version: 2.0.6 resolution: "@types/pg-pool@npm:2.0.6" @@ -25173,13 +25149,6 @@ __metadata: languageName: node linkType: hard -"buffer-writer@npm:2.0.0": - version: 2.0.0 - resolution: "buffer-writer@npm:2.0.0" - checksum: 10/fdca8e28c55704de7af2f41c8f875293de69ad22005d5041d54aa916d125cead00afa969bc09e4702ae6b66e098409958c06bebfc97fcf8fa4ea5afcae088cd9 - languageName: node - linkType: hard - "buffer-xor@npm:^1.0.3": version: 1.0.3 resolution: "buffer-xor@npm:1.0.3" @@ -39994,13 +39963,6 @@ __metadata: languageName: node linkType: hard -"packet-reader@npm:1.0.0": - version: 1.0.0 - resolution: "packet-reader@npm:1.0.0" - checksum: 10/8504cc8c32672380867e933516a029b1d4dd784c139213c85c9042ffc1162de48ec914f8c71260a9311518694cf5d0be11c67357f4b536129d2ea42aa7257ec0 - languageName: node - linkType: hard - "pacote@npm:^12.0.0, pacote@npm:^12.0.2": version: 12.0.3 resolution: "pacote@npm:12.0.3" @@ -40530,7 +40492,7 @@ __metadata: languageName: node linkType: hard -"pg-connection-string@npm:^2.3.0, pg-connection-string@npm:^2.5.0, pg-connection-string@npm:^2.6.2, pg-connection-string@npm:^2.7.0": +"pg-connection-string@npm:^2.3.0, pg-connection-string@npm:^2.5.0, pg-connection-string@npm:^2.7.0": version: 2.7.0 resolution: "pg-connection-string@npm:2.7.0" checksum: 10/68015a8874b7ca5dad456445e4114af3d2602bac2fdb8069315ecad0ff9660ec93259b9af7186606529ac4f6f72a06831e6f20897a689b16cc7fda7ca0e247fd @@ -40558,24 +40520,6 @@ __metadata: languageName: node linkType: hard -"pg-pool@npm:3.6.1": - version: 3.6.1 - resolution: "pg-pool@npm:3.6.1" - peerDependencies: - pg: ">=8.0" - checksum: 10/5d1b02b959e6c849004d8f3d2222c48d3b3b67b7b1eb5f2e5819ed9412129ea6b0f0376bc74ddf197973c99575d325cbb3f64a8017ab520535c011329b12fffb - languageName: node - linkType: hard - -"pg-pool@npm:^3.6.1, pg-pool@npm:^3.7.0": - version: 3.7.0 - resolution: "pg-pool@npm:3.7.0" - peerDependencies: - pg: ">=8.0" - checksum: 10/a07a4f9e26eec9d7ac3597dc7b3469c62983edff9a321dbb7acbe1bbc7f5e9b2d33438e277d4cf8145071f3d63c7ebdc287a539fd69dfb8cdddb15b33eefe1a2 - languageName: node - linkType: hard - "pg-pool@npm:^3.8.0": version: 3.8.0 resolution: "pg-pool@npm:3.8.0" @@ -40592,13 +40536,6 @@ __metadata: languageName: node linkType: hard -"pg-protocol@npm:^1.6.0, pg-protocol@npm:^1.7.0": - version: 1.7.0 - resolution: "pg-protocol@npm:1.7.0" - checksum: 10/ffffdf74426c9357b57050f1c191e84447c0e8b2a701b3ab302ac7dd0eb27b862d92e5e3b2d38876a1051de83547eb9165d6a58b3a8e90bb050dae97f9993d54 - languageName: node - linkType: hard - "pg-types@npm:^2.1.0, pg-types@npm:^2.2.0": version: 2.2.0 resolution: "pg-types@npm:2.2.0" @@ -40627,30 +40564,6 @@ __metadata: languageName: node linkType: hard -"pg@npm:8.11.3": - version: 8.11.3 - resolution: "pg@npm:8.11.3" - dependencies: - buffer-writer: "npm:2.0.0" - packet-reader: "npm:1.0.0" - pg-cloudflare: "npm:^1.1.1" - pg-connection-string: "npm:^2.6.2" - pg-pool: "npm:^3.6.1" - pg-protocol: "npm:^1.6.0" - pg-types: "npm:^2.1.0" - pgpass: "npm:1.x" - peerDependencies: - pg-native: ">=3.0.1" - dependenciesMeta: - pg-cloudflare: - optional: true - peerDependenciesMeta: - pg-native: - optional: true - checksum: 10/f15f29c8e17723ee1da72abdf400cbed2c04602c58c93687f3f0068e71df2a6fb62b9a3543e13da21b10a0494f4c5b4cfc8d6cd8396617b76c4cbfd6ddab17e7 - languageName: node - linkType: hard - "pg@npm:^8.11.3, pg@npm:^8.9.0": version: 8.14.1 resolution: "pg@npm:8.14.1" @@ -40673,28 +40586,6 @@ __metadata: languageName: node linkType: hard -"pg@npm:^8.6.0": - version: 8.13.1 - resolution: "pg@npm:8.13.1" - dependencies: - pg-cloudflare: "npm:^1.1.1" - pg-connection-string: "npm:^2.7.0" - pg-pool: "npm:^3.7.0" - pg-protocol: "npm:^1.7.0" - pg-types: "npm:^2.1.0" - pgpass: "npm:1.x" - peerDependencies: - pg-native: ">=3.0.1" - dependenciesMeta: - pg-cloudflare: - optional: true - peerDependenciesMeta: - pg-native: - optional: true - checksum: 10/542aa49fcb37657cf5f779b4a31fe6eb336e683445ecca38e267eeb0ca85d873ffe51f04794f9f9e184187e9f74bf7895e932a0fa9507132ac0dfc76c7c73451 - languageName: node - linkType: hard - "pgpass@npm:1.x": version: 1.0.2 resolution: "pgpass@npm:1.0.2" @@ -41462,18 +41353,6 @@ __metadata: languageName: node linkType: hard -"postgres-migrations@npm:5.3.0": - version: 5.3.0 - resolution: "postgres-migrations@npm:5.3.0" - dependencies: - pg: "npm:^8.6.0" - sql-template-strings: "npm:^2.2.2" - bin: - pg-validate-migrations: dist/bin/validate.js - checksum: 10/520d95f01144f88689d5c0a7575743c4f99536935deb1ffff7b3765883a688c4f001d98e8b493ca9b342cd2609593970c3d2198b41fade648f102008e3607226 - languageName: node - linkType: hard - "postgres-range@npm:^1.1.1": version: 1.1.3 resolution: "postgres-range@npm:1.1.3" @@ -45293,13 +45172,6 @@ __metadata: languageName: node linkType: hard -"sql-template-strings@npm:^2.2.2": - version: 2.2.2 - resolution: "sql-template-strings@npm:2.2.2" - checksum: 10/594378a44acbaf3db8a4067137c0c315d0656fcc1b6b8fa76c760d032c1970bf6ede2b31690a3bdc6482d86cbff8b202bb14f6528aa1d9d6bf19d48b03ba2744 - languageName: node - linkType: hard - "sqlstring@npm:^2.3.2": version: 2.3.2 resolution: "sqlstring@npm:2.3.2" From a68bbc54c02524bf679247802601c376127c6b88 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 27 Feb 2025 15:02:43 +0200 Subject: [PATCH 12/46] feat: allow plugin specific rate limiting Signed-off-by: Hellgren Heikki --- packages/backend-defaults/config.d.ts | 40 +++++++++ .../http/createRateLimitMiddleware.ts | 40 +++++++++ .../httpRouter/httpRouterServiceFactory.ts | 7 +- .../rootHttpRouter/http/MiddlewareFactory.ts | 64 +++------------ .../RateLimitStoreFactory.test.ts | 4 +- .../http => lib}/RateLimitStoreFactory.ts | 0 .../src/lib/rateLimitMiddleware.ts | 81 +++++++++++++++++++ 7 files changed, 180 insertions(+), 56 deletions(-) create mode 100644 packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts rename packages/backend-defaults/src/{entrypoints/rootHttpRouter/http => lib}/RateLimitStoreFactory.test.ts (95%) rename packages/backend-defaults/src/{entrypoints/rootHttpRouter/http => lib}/RateLimitStoreFactory.ts (100%) create mode 100644 packages/backend-defaults/src/lib/rateLimitMiddleware.ts diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index f67680d1e2..167a8935f9 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -804,6 +804,11 @@ export interface Config { | { type: 'memory'; }; + /** + * Enable/disable global rate limiting. If this is disabled, plugin specific rate limiting must be + * used. + */ + global?: boolean; /** * Time frame in milliseconds or as human duration for which requests are checked/remembered. * Defaults to one minute. @@ -834,6 +839,41 @@ export interface Config { * Defaults to false. */ skipFailedRequests?: boolean; + /** Plugin specific rate limiting configuration */ + plugin?: { + [pluginId: string]: { + /** + * Time frame in milliseconds or as human duration for which requests are checked/remembered. + * Defaults to one minute. + */ + window?: string | HumanDuration; + /** + * The maximum number of connections to allow during the `window` before rate limiting the client. + * Defaults to 5. + */ + incomingRequestLimit?: number; + /** + * Whether to pass requests in case of store failure. + * Defaults to false. + */ + passOnStoreError?: boolean; + /** + * List of allowed IP addresses that are not rate limited. + * Defaults to [127.0.0.1, 0:0:0:0:0:0:0:1, ::1]. + */ + ipAllowList?: string[]; + /** + * Skip rate limiting for requests that have been successful. + * Defaults to false. + */ + skipSuccessfulRequests?: boolean; + /** + * Skip rate limiting for requests that have failed. + * Defaults to false. + */ + skipFailedRequests?: boolean; + }; + }; }; /** diff --git a/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts b/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts new file mode 100644 index 0000000000..0e9239650b --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NextFunction, Request, Response } from 'express'; +import { RateLimitStoreFactory } from '../../../lib/RateLimitStoreFactory.ts'; +import { Config } from '@backstage/config'; +import { rateLimitMiddleware } from '../../../lib/rateLimitMiddleware.ts'; + +export const createRateLimitMiddleware = (options: { + pluginId: string; + config: Config; +}) => { + const { pluginId, config } = options; + const configKey = `backend.rateLimit.${pluginId}`; + const enabled = config.has(configKey); + if (!enabled) { + return (_req: Request, _res: Response, next: NextFunction) => { + next(); + }; + } + + const rateLimitOptions = config.getConfig(configKey); + + return rateLimitMiddleware({ + store: RateLimitStoreFactory.create(config), + config: rateLimitOptions, + }); +}; diff --git a/packages/backend-defaults/src/entrypoints/httpRouter/httpRouterServiceFactory.ts b/packages/backend-defaults/src/entrypoints/httpRouter/httpRouterServiceFactory.ts index 1f2b989018..0fc70a07f0 100644 --- a/packages/backend-defaults/src/entrypoints/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/httpRouter/httpRouterServiceFactory.ts @@ -22,12 +22,13 @@ import { HttpRouterServiceAuthPolicy, } from '@backstage/backend-plugin-api'; import { - createLifecycleMiddleware, + createAuthIntegrationRouter, createCookieAuthRefreshMiddleware, createCredentialsBarrier, - createAuthIntegrationRouter, + createLifecycleMiddleware, } from './http'; import { MiddlewareFactory } from '../rootHttpRouter'; +import { createRateLimitMiddleware } from './http/createRateLimitMiddleware.ts'; /** * HTTP route registration for plugins. @@ -61,6 +62,8 @@ export const httpRouterServiceFactory = createServiceFactory({ }) { const router = PromiseRouter(); + router.use(createRateLimitMiddleware({ pluginId: plugin.getId(), config })); + rootHttpRouter.use(`/api/${plugin.getId()}`, router); const credentialsBarrier = createCredentialsBarrier({ diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts index 3dddc99974..9a76ec5a59 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts @@ -43,10 +43,8 @@ import { ServiceUnavailableError, } from '@backstage/errors'; import { applyInternalErrorFilter } from './applyInternalErrorFilter'; -import { rateLimit } from 'express-rate-limit'; -import { readDurationFromConfig } from '@backstage/config'; -import { durationToMilliseconds } from '@backstage/types'; -import { RateLimitStoreFactory } from './RateLimitStoreFactory'; +import { RateLimitStoreFactory } from '../../../lib/RateLimitStoreFactory.ts'; +import { rateLimitMiddleware } from '../../../lib/rateLimitMiddleware.ts'; type LogMeta = { date: string; @@ -254,59 +252,21 @@ export class MiddlewareFactory { ? undefined : this.#config.getOptionalConfig('backend.rateLimit'); - let windowMs: number = 60000; - if (rateLimitOptions && rateLimitOptions.has('window')) { - const windowDuration = readDurationFromConfig(rateLimitOptions, { - key: 'window', - }); - windowMs = durationToMilliseconds(windowDuration); + // Global rate limiting disabled + if ( + rateLimitOptions && + rateLimitOptions.getOptionalBoolean('global') === false + ) { + return (_req: Request, _res: Response, next: NextFunction) => { + next(); + }; } - const ipAllowList = rateLimitOptions?.getOptionalStringArray( - 'ipAllowList', - ) ?? ['127.0.0.1', '0:0:0:0:0:0:0:1', '::1']; - - return rateLimit({ - windowMs, - limit: rateLimitOptions?.getOptionalNumber('incomingRequestLimit'), - skipSuccessfulRequests: rateLimitOptions?.getOptionalBoolean( - 'skipSuccessfulRequests', - ), - message: { - error: { - name: 'Error', - message: `Too many requests, please try again later`, - }, - response: { - statusCode: 429, - }, - }, - statusCode: 429, - skipFailedRequests: - rateLimitOptions?.getOptionalBoolean('skipFailedRequests'), - passOnStoreError: - rateLimitOptions?.getOptionalBoolean('passOnStoreError'), - keyGenerator(req, _res): string { - if (!req.ip) { - return req.socket.remoteAddress!; - } - return req.ip; - }, - skip: (req, _res) => { - return ( - Boolean(req.ip && ipAllowList.includes(req.ip)) || - Boolean( - req.socket.remoteAddress && - ipAllowList.includes(req.socket.remoteAddress), - ) - ); - }, - validate: { - trustProxy: false, - }, + return rateLimitMiddleware({ store: useDefaults ? undefined : RateLimitStoreFactory.create(this.#config), + config: rateLimitOptions, }); } diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts b/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts similarity index 95% rename from packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts rename to packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts index f1044979e8..02a7f30417 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts +++ b/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { mockServices } from '@backstage/backend-test-utils'; -import { RateLimitStoreFactory } from './RateLimitStoreFactory'; +import { RateLimitStoreFactory } from './RateLimitStoreFactory.ts'; import { RedisStore } from 'rate-limit-redis'; jest.mock('@keyv/redis', () => { @@ -55,7 +55,7 @@ describe('CacheRateLimitStoreFactory', () => { backend: { rateLimit: { store: { - client: 'redis', + type: 'redis', connection: 'redis://localhost:6379', }, }, diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts b/packages/backend-defaults/src/lib/RateLimitStoreFactory.ts similarity index 100% rename from packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts rename to packages/backend-defaults/src/lib/RateLimitStoreFactory.ts diff --git a/packages/backend-defaults/src/lib/rateLimitMiddleware.ts b/packages/backend-defaults/src/lib/rateLimitMiddleware.ts new file mode 100644 index 0000000000..57163b5908 --- /dev/null +++ b/packages/backend-defaults/src/lib/rateLimitMiddleware.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { RequestHandler } from 'express'; +import { rateLimit, Store } from 'express-rate-limit'; +import { Config, readDurationFromConfig } from '@backstage/config'; +import { durationToMilliseconds } from '@backstage/types'; + +export const rateLimitMiddleware = (options: { + store?: Store; + config?: Config; +}): RequestHandler => { + const { store, config } = options; + let windowMs: number = 60000; + if (config && config.has('window')) { + const windowDuration = readDurationFromConfig(config, { + key: 'window', + }); + windowMs = durationToMilliseconds(windowDuration); + } + const limit = config?.getOptionalNumber('incomingRequestLimit'); + const ipAllowList = config?.getOptionalStringArray('ipAllowList') ?? [ + '127.0.0.1', + '0:0:0:0:0:0:0:1', + '::1', + ]; + const skipSuccessfulRequests = config?.getOptionalBoolean( + 'skipSuccessfulRequests', + ); + const skipFailedRequests = config?.getOptionalBoolean('skipFailedRequests'); + const passOnStoreError = config?.getOptionalBoolean('passOnStoreError'); + + return rateLimit({ + windowMs, + limit, + skipSuccessfulRequests, + message: { + error: { + name: 'Error', + message: `Too many requests, please try again later`, + }, + response: { + statusCode: 429, + }, + }, + statusCode: 429, + skipFailedRequests, + passOnStoreError: passOnStoreError, + keyGenerator(req, _res): string { + if (!req.ip) { + return req.socket.remoteAddress!; + } + return req.ip; + }, + skip: (req, _res) => { + return ( + Boolean(req.ip && ipAllowList.includes(req.ip)) || + Boolean( + req.socket.remoteAddress && + ipAllowList.includes(req.socket.remoteAddress), + ) + ); + }, + validate: { + trustProxy: false, + }, + store, + }); +}; From eddd61a600044cab080ebae0ef85265f7d27149d Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 27 Feb 2025 15:13:41 +0200 Subject: [PATCH 13/46] fix: use prefix for plugin rate limit redis store Signed-off-by: Hellgren Heikki --- .../httpRouter/http/createRateLimitMiddleware.ts | 2 +- .../rootHttpRouter/http/MiddlewareFactory.ts | 2 +- .../src/lib/RateLimitStoreFactory.test.ts | 4 ++-- .../src/lib/RateLimitStoreFactory.ts | 14 ++++++++++---- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts b/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts index 0e9239650b..377d7fa301 100644 --- a/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts +++ b/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts @@ -34,7 +34,7 @@ export const createRateLimitMiddleware = (options: { const rateLimitOptions = config.getConfig(configKey); return rateLimitMiddleware({ - store: RateLimitStoreFactory.create(config), + store: RateLimitStoreFactory.create({ config, prefix: pluginId }), config: rateLimitOptions, }); }; diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts index 9a76ec5a59..80a10fe0eb 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts @@ -265,7 +265,7 @@ export class MiddlewareFactory { return rateLimitMiddleware({ store: useDefaults ? undefined - : RateLimitStoreFactory.create(this.#config), + : RateLimitStoreFactory.create({ config: this.#config }), config: rateLimitOptions, }); } diff --git a/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts b/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts index 02a7f30417..684c204cb8 100644 --- a/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts +++ b/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts @@ -45,7 +45,7 @@ describe('CacheRateLimitStoreFactory', () => { }, }, }); - const store = RateLimitStoreFactory.create(config); + const store = RateLimitStoreFactory.create({ config }); expect(store).toBeUndefined(); }); @@ -62,7 +62,7 @@ describe('CacheRateLimitStoreFactory', () => { }, }, }); - const store = RateLimitStoreFactory.create(config); + const store = RateLimitStoreFactory.create({ config }); expect(store).toBeInstanceOf(RedisStore); }); }); diff --git a/packages/backend-defaults/src/lib/RateLimitStoreFactory.ts b/packages/backend-defaults/src/lib/RateLimitStoreFactory.ts index 0a24a8792c..9df6f03225 100644 --- a/packages/backend-defaults/src/lib/RateLimitStoreFactory.ts +++ b/packages/backend-defaults/src/lib/RateLimitStoreFactory.ts @@ -23,7 +23,11 @@ import { RedisStore } from 'rate-limit-redis'; * @internal */ export class RateLimitStoreFactory { - static create(config: Config): Store | undefined { + static create(options: { + config: Config; + prefix?: string; + }): Store | undefined { + const { config, prefix } = options; const store = config.getOptionalConfig('backend.rateLimit.store'); if (!store) { return undefined; @@ -31,18 +35,20 @@ export class RateLimitStoreFactory { const type = store.getString('type'); switch (type) { case 'redis': - return this.redis(store); + return this.redis({ store, prefix }); case 'memory': default: return undefined; } } - private static redis(storeConfig: Config): Store { - const connectionString = storeConfig.getString('connection'); + private static redis(options: { store: Config; prefix?: string }): Store { + const { store, prefix } = options; + const connectionString = store.getString('connection'); const KeyvRedis = require('@keyv/redis').default; const keyv = new KeyvRedis(connectionString); return new RedisStore({ + prefix, sendCommand: async (...args: string[]) => { const client = await keyv.getClient(); return client.sendCommand(args); From 25ffb8684cf1c2d9dbd6abeda169cfacd308d6a0 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 27 Feb 2025 15:15:13 +0200 Subject: [PATCH 14/46] chore: add mention about plugin rate limit to changeset Signed-off-by: Hellgren Heikki --- .changeset/famous-terms-rescue.md | 12 +++++ .changeset/sour-comics-attend.md | 5 --- .../core-services/http-router.md | 44 +++++++++++++++++++ .../http/createRateLimitMiddleware.ts | 2 +- 4 files changed, 57 insertions(+), 6 deletions(-) delete mode 100644 .changeset/sour-comics-attend.md diff --git a/.changeset/famous-terms-rescue.md b/.changeset/famous-terms-rescue.md index 68fd1f3591..d323bf0867 100644 --- a/.changeset/famous-terms-rescue.md +++ b/.changeset/famous-terms-rescue.md @@ -13,3 +13,15 @@ backend: window: 6s incomingRequestLimit: 100 ``` + +Plugin specific rate limiting can be configured by adding the following configuration to `app-config.yaml`: + +```yaml +backend: + rateLimit: + global: false # This will disable the global rate limiting + plugin: + catalog: + window: 6s + incomingRequestLimit: 100 +``` diff --git a/.changeset/sour-comics-attend.md b/.changeset/sour-comics-attend.md deleted file mode 100644 index 729fc59ba7..0000000000 --- a/.changeset/sour-comics-attend.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-defaults': patch ---- - -Add configuration variable for `express` trust proxy setting diff --git a/docs/backend-system/core-services/http-router.md b/docs/backend-system/core-services/http-router.md index d7658d9a67..87462eac5a 100644 --- a/docs/backend-system/core-services/http-router.md +++ b/docs/backend-system/core-services/http-router.md @@ -68,6 +68,50 @@ For those routes you will also have to specify `allowLimitedAccess: true` when using the [`auth`](./auth.md) and [`httpAuth`](./http-auth.md) services to access the incoming credentials. +## Rate limiting + +Rate limiting allows you to limit the amount of requests users can send to you backend. +This is useful for blocking various network attacks, such as DDOS, if your instance does not +have additional firewall configured to handle this. + +To configure the default rate limiting, add the following to your config: + +```yaml +backend: + rateLimit: true +``` + +You can additionally configure the rate limiting parameters also by plugin: + +```yaml +backend: + rateLimit: + global: true # Enables or disables rate limit for all plugins + window: 6s # Time window for rate limiting for single client + incomingRequestLimit: 100 # Number of requests to accept from one client during time window + ipAllowList: ['127.0.0.1'] # IPs to bypass rate limiting + skipSuccesfulRequests: false # Rate limit successful requests + skipFailedRequests: false # Rate limit failed requests + plugin: + # Plugin specific rate limiting + catalog: + window: 3s + incomingRequestLimit: 50 +``` + +By default, the rate limiting is per instance and the request counts are stored into memory. +If you want to share this information across all your backstage instances, you have to configure +the rate limiting store: + +```yaml +backend: + rateLimit: + global: true + store: + type: redis + connection: redis://127.0.0.1:16379 +``` + ## Configuring the service For more advanced customization, there are several APIs from the `@backstage/backend-defaults/httpRouter` package that allow you to customize the implementation of the config service. The default implementation uses all of the middleware exported from `@backstage/backend-defaults/httpRouter`, including `createLifecycleMiddleware`, `createAuthIntegrationRouter`, `createCredentialsBarrier` and `createCookieAuthRefreshMiddleware`. You can use these to create your own `httpRouter` service implementation, for example - here's how you would add a custom health check route to all plugins: diff --git a/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts b/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts index 377d7fa301..b139b212d2 100644 --- a/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts +++ b/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts @@ -23,7 +23,7 @@ export const createRateLimitMiddleware = (options: { config: Config; }) => { const { pluginId, config } = options; - const configKey = `backend.rateLimit.${pluginId}`; + const configKey = `backend.rateLimit.plugin.${pluginId}`; const enabled = config.has(configKey); if (!enabled) { return (_req: Request, _res: Response, next: NextFunction) => { From b994af48e0dd38dc101264d910137f3074043de4 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Wed, 14 May 2025 21:39:34 +0300 Subject: [PATCH 15/46] chore: yarn.lock fix Signed-off-by: Hellgren Heikki --- yarn.lock | 214 +++--------------------------------------------------- 1 file changed, 9 insertions(+), 205 deletions(-) diff --git a/yarn.lock b/yarn.lock index aebb875a10..6cf0fb2716 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3335,16 +3335,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": - version: 7.26.7 - resolution: "@babel/runtime@npm:7.26.7" - dependencies: - regenerator-runtime: "npm:^0.14.0" - checksum: 10/c7a661a6836b332d9d2e047cba77ba1862c1e4f78cec7146db45808182ef7636d8a7170be9797e5d8fd513180bffb9fa16f6ca1c69341891efec56113cf22bfc - languageName: node - linkType: hard - -"@babel/runtime@npm:^7.26.10": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.6, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.26.10, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.27.0 resolution: "@babel/runtime@npm:7.27.0" dependencies: @@ -3353,18 +3344,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.3.3": - version: 7.25.9 - resolution: "@babel/template@npm:7.25.9" - dependencies: - "@babel/code-frame": "npm:^7.25.9" - "@babel/parser": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" - checksum: 10/e861180881507210150c1335ad94aff80fd9e9be6202e1efa752059c93224e2d5310186ddcdd4c0f0b0fc658ce48cb47823f15142b5c00c8456dde54f5de80b2 - languageName: node - linkType: hard - -"@babel/template@npm:^7.27.0": +"@babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.27.0, @babel/template@npm:^7.3.3": version: 7.27.0 resolution: "@babel/template@npm:7.27.0" dependencies: @@ -3390,17 +3370,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.10, @babel/types@npm:^7.22.5, @babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": - version: 7.26.0 - resolution: "@babel/types@npm:7.26.0" - dependencies: - "@babel/helper-string-parser": "npm:^7.25.9" - "@babel/helper-validator-identifier": "npm:^7.25.9" - checksum: 10/40780741ecec886ed9edae234b5eb4976968cc70d72b4e5a40d55f83ff2cc457de20f9b0f4fe9d858350e43dab0ea496e7ef62e2b2f08df699481a76df02cd6e - languageName: node - linkType: hard - -"@babel/types@npm:^7.27.0": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.10, @babel/types@npm:^7.22.5, @babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.0, @babel/types@npm:^7.27.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.27.0 resolution: "@babel/types@npm:7.27.0" dependencies: @@ -19225,13 +19195,6 @@ __metadata: languageName: node linkType: hard -"@swc/core-darwin-arm64@npm:1.10.6": - version: 1.10.6 - resolution: "@swc/core-darwin-arm64@npm:1.10.6" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@swc/core-darwin-arm64@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-darwin-arm64@npm:1.11.24" @@ -19239,13 +19202,6 @@ __metadata: languageName: node linkType: hard -"@swc/core-darwin-x64@npm:1.10.6": - version: 1.10.6 - resolution: "@swc/core-darwin-x64@npm:1.10.6" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@swc/core-darwin-x64@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-darwin-x64@npm:1.11.24" @@ -19253,13 +19209,6 @@ __metadata: languageName: node linkType: hard -"@swc/core-linux-arm-gnueabihf@npm:1.10.6": - version: 1.10.6 - resolution: "@swc/core-linux-arm-gnueabihf@npm:1.10.6" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "@swc/core-linux-arm-gnueabihf@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-linux-arm-gnueabihf@npm:1.11.24" @@ -19267,13 +19216,6 @@ __metadata: languageName: node linkType: hard -"@swc/core-linux-arm64-gnu@npm:1.10.6": - version: 1.10.6 - resolution: "@swc/core-linux-arm64-gnu@npm:1.10.6" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@swc/core-linux-arm64-gnu@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-linux-arm64-gnu@npm:1.11.24" @@ -19281,13 +19223,6 @@ __metadata: languageName: node linkType: hard -"@swc/core-linux-arm64-musl@npm:1.10.6": - version: 1.10.6 - resolution: "@swc/core-linux-arm64-musl@npm:1.10.6" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "@swc/core-linux-arm64-musl@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-linux-arm64-musl@npm:1.11.24" @@ -19295,13 +19230,6 @@ __metadata: languageName: node linkType: hard -"@swc/core-linux-x64-gnu@npm:1.10.6": - version: 1.10.6 - resolution: "@swc/core-linux-x64-gnu@npm:1.10.6" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@swc/core-linux-x64-gnu@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-linux-x64-gnu@npm:1.11.24" @@ -19309,13 +19237,6 @@ __metadata: languageName: node linkType: hard -"@swc/core-linux-x64-musl@npm:1.10.6": - version: 1.10.6 - resolution: "@swc/core-linux-x64-musl@npm:1.10.6" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@swc/core-linux-x64-musl@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-linux-x64-musl@npm:1.11.24" @@ -19323,13 +19244,6 @@ __metadata: languageName: node linkType: hard -"@swc/core-win32-arm64-msvc@npm:1.10.6": - version: 1.10.6 - resolution: "@swc/core-win32-arm64-msvc@npm:1.10.6" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@swc/core-win32-arm64-msvc@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-win32-arm64-msvc@npm:1.11.24" @@ -19337,13 +19251,6 @@ __metadata: languageName: node linkType: hard -"@swc/core-win32-ia32-msvc@npm:1.10.6": - version: 1.10.6 - resolution: "@swc/core-win32-ia32-msvc@npm:1.10.6" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@swc/core-win32-ia32-msvc@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-win32-ia32-msvc@npm:1.11.24" @@ -19351,13 +19258,6 @@ __metadata: languageName: node linkType: hard -"@swc/core-win32-x64-msvc@npm:1.10.6": - version: 1.10.6 - resolution: "@swc/core-win32-x64-msvc@npm:1.10.6" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@swc/core-win32-x64-msvc@npm:1.11.24": version: 1.11.24 resolution: "@swc/core-win32-x64-msvc@npm:1.11.24" @@ -19365,7 +19265,7 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:^1.10.8": +"@swc/core@npm:^1.10.8, @swc/core@npm:^1.3.46": version: 1.11.24 resolution: "@swc/core@npm:1.11.24" dependencies: @@ -19411,52 +19311,6 @@ __metadata: languageName: node linkType: hard -"@swc/core@npm:^1.3.46": - version: 1.10.6 - resolution: "@swc/core@npm:1.10.6" - dependencies: - "@swc/core-darwin-arm64": "npm:1.10.6" - "@swc/core-darwin-x64": "npm:1.10.6" - "@swc/core-linux-arm-gnueabihf": "npm:1.10.6" - "@swc/core-linux-arm64-gnu": "npm:1.10.6" - "@swc/core-linux-arm64-musl": "npm:1.10.6" - "@swc/core-linux-x64-gnu": "npm:1.10.6" - "@swc/core-linux-x64-musl": "npm:1.10.6" - "@swc/core-win32-arm64-msvc": "npm:1.10.6" - "@swc/core-win32-ia32-msvc": "npm:1.10.6" - "@swc/core-win32-x64-msvc": "npm:1.10.6" - "@swc/counter": "npm:^0.1.3" - "@swc/types": "npm:^0.1.17" - peerDependencies: - "@swc/helpers": "*" - dependenciesMeta: - "@swc/core-darwin-arm64": - optional: true - "@swc/core-darwin-x64": - optional: true - "@swc/core-linux-arm-gnueabihf": - optional: true - "@swc/core-linux-arm64-gnu": - optional: true - "@swc/core-linux-arm64-musl": - optional: true - "@swc/core-linux-x64-gnu": - optional: true - "@swc/core-linux-x64-musl": - optional: true - "@swc/core-win32-arm64-msvc": - optional: true - "@swc/core-win32-ia32-msvc": - optional: true - "@swc/core-win32-x64-msvc": - optional: true - peerDependenciesMeta: - "@swc/helpers": - optional: true - checksum: 10/51eccbba6ee8a41f57a6ba4213ec05859434f52fd6698f508c92a8bb467afda56a1e95b07985faf059b27cb28e77d479340de7210a8616976ea82f774a6d86a3 - languageName: node - linkType: hard - "@swc/counter@npm:^0.1.3": version: 0.1.3 resolution: "@swc/counter@npm:0.1.3" @@ -19486,15 +19340,6 @@ __metadata: languageName: node linkType: hard -"@swc/types@npm:^0.1.17": - version: 0.1.17 - resolution: "@swc/types@npm:0.1.17" - dependencies: - "@swc/counter": "npm:^0.1.3" - checksum: 10/ddef1ad5bfead3acdfc41f14e79ba43a99200eb325afbad5716058dbe36358b0513400e9f22aff32432be84a98ae93df95a20b94192f69b8687144270e4eaa18 - languageName: node - linkType: hard - "@swc/types@npm:^0.1.21": version: 0.1.21 resolution: "@swc/types@npm:0.1.21" @@ -20816,16 +20661,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0": - version: 22.10.5 - resolution: "@types/node@npm:22.10.5" - dependencies: - undici-types: "npm:~6.20.0" - checksum: 10/a5366961ffa9921e8f15435bc18ea9f8b7a7bb6b3d92dd5e93ebcd25e8af65708872bd8e6fee274b4655bab9ca80fbff9f0e42b5b53857790f13cf68cf4cbbfc - languageName: node - linkType: hard - -"@types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=18.0.0": +"@types/node@npm:*, @types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=18.0.0, @types/node@npm:^22.0.0": version: 22.13.10 resolution: "@types/node@npm:22.13.10" dependencies: @@ -24177,20 +24013,13 @@ __metadata: languageName: node linkType: hard -"async@npm:^3.2.2, async@npm:^3.2.6": +"async@npm:^3.2.2, async@npm:^3.2.3, async@npm:^3.2.4, async@npm:^3.2.6": version: 3.2.6 resolution: "async@npm:3.2.6" checksum: 10/cb6e0561a3c01c4b56a799cc8bab6ea5fef45f069ab32500b6e19508db270ef2dffa55e5aed5865c5526e9907b1f8be61b27530823b411ffafb5e1538c86c368 languageName: node linkType: hard -"async@npm:^3.2.3, async@npm:^3.2.4": - version: 3.2.4 - resolution: "async@npm:3.2.4" - checksum: 10/bebb5dc2258c45b83fa1d3be179ae0eb468e1646a62d443c8d60a45e84041b28fccebe1e2d1f234bfc3dcad44e73dcdbf4ba63d98327c9f6556e3dbd47c2ae8b - languageName: node - linkType: hard - "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -24682,20 +24511,13 @@ __metadata: languageName: node linkType: hard -"before-after-hook@npm:^2.1.0": +"before-after-hook@npm:^2.1.0, before-after-hook@npm:^2.2.0": version: 2.2.3 resolution: "before-after-hook@npm:2.2.3" checksum: 10/e676f769dbc4abcf4b3317db2fd2badb4a92c0710e0a7da12cf14b59c3482d4febf835ad7de7874499060fd4e13adf0191628e504728b3c5bb4ec7a878c09940 languageName: node linkType: hard -"before-after-hook@npm:^2.2.0": - version: 2.2.2 - resolution: "before-after-hook@npm:2.2.2" - checksum: 10/34c190def503f771f8811db0bd0c62b35301fe6059c8d847664633ce0548e8253e2661104ba66c71a85548746ba87d5ff2ebf5278c1f3ad367d111ffc9a26bb4 - languageName: node - linkType: hard - "better-opn@npm:^3.0.2": version: 3.0.2 resolution: "better-opn@npm:3.0.2" @@ -31229,25 +31051,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": - version: 1.2.6 - resolution: "get-intrinsic@npm:1.2.6" - dependencies: - call-bind-apply-helpers: "npm:^1.0.1" - dunder-proto: "npm:^1.0.0" - es-define-property: "npm:^1.0.1" - es-errors: "npm:^1.3.0" - es-object-atoms: "npm:^1.0.0" - function-bind: "npm:^1.1.2" - gopd: "npm:^1.2.0" - has-symbols: "npm:^1.1.0" - hasown: "npm:^2.0.2" - math-intrinsics: "npm:^1.0.0" - checksum: 10/a1ffae6d7893a6fa0f4d1472adbc85095edd6b3b0943ead97c3738539cecb19d422ff4d48009eed8c3c27ad678c2b1e38a83b1a1e96b691d13ed8ecefca1068d - languageName: node - linkType: hard - -"get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" dependencies: @@ -36941,7 +36745,7 @@ __metadata: languageName: node linkType: hard -"math-intrinsics@npm:^1.0.0, math-intrinsics@npm:^1.1.0": +"math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" checksum: 10/11df2eda46d092a6035479632e1ec865b8134bdfc4bd9e571a656f4191525404f13a283a515938c3a8de934dbfd9c09674d9da9fa831e6eb7e22b50b197d2edd From 4990bc2063ff0eacd9391e12425e7b44cc0d21e7 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Wed, 14 May 2025 22:05:47 +0300 Subject: [PATCH 16/46] docs: improve documentation a bit Signed-off-by: Hellgren Heikki --- .../core-services/http-router.md | 19 ++++++++++++++++++- .../core-services/root-http-router.md | 4 ++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/backend-system/core-services/http-router.md b/docs/backend-system/core-services/http-router.md index 87462eac5a..f53f7d9cd8 100644 --- a/docs/backend-system/core-services/http-router.md +++ b/docs/backend-system/core-services/http-router.md @@ -81,7 +81,7 @@ backend: rateLimit: true ``` -You can additionally configure the rate limiting parameters also by plugin: +You can additionally configure the rate limiting parameters, also by plugin: ```yaml backend: @@ -112,6 +112,17 @@ backend: connection: redis://127.0.0.1:16379 ``` +If your instance is working behind a proxy, you have to configure the backend to trust the proxy +for the rate limiting being able to distinguish clients. + +```yaml +backend: + trustProxy: true +``` + +For more information about the trust proxy configuration and available options, +please refer to [express documentation](https://expressjs.com/en/guide/behind-proxies.html). + ## Configuring the service For more advanced customization, there are several APIs from the `@backstage/backend-defaults/httpRouter` package that allow you to customize the implementation of the config service. The default implementation uses all of the middleware exported from `@backstage/backend-defaults/httpRouter`, including `createLifecycleMiddleware`, `createAuthIntegrationRouter`, `createCredentialsBarrier` and `createCookieAuthRefreshMiddleware`. You can use these to create your own `httpRouter` service implementation, for example - here's how you would add a custom health check route to all plugins: @@ -122,6 +133,7 @@ import { createCookieAuthRefreshMiddleware, createCredentialsBarrier, createAuthIntegrationRouter, + createRateLimitMiddleware, } from '@backstage/backend-defaults/httpRouter'; import { createServiceFactory } from '@backstage/backend-plugin-api'; @@ -149,6 +161,11 @@ backend.add( }) { const router = PromiseRouter(); + // Optional rate limiting middleware + router.use( + createRateLimitMiddleware({ pluginId: plugin.getId(), config }), + ); + rootHttpRouter.use(`/api/${plugin.getId()}`, router); const credentialsBarrier = createCredentialsBarrier({ diff --git a/docs/backend-system/core-services/root-http-router.md b/docs/backend-system/core-services/root-http-router.md index bdaf7bba23..8a6f86fab8 100644 --- a/docs/backend-system/core-services/root-http-router.md +++ b/docs/backend-system/core-services/root-http-router.md @@ -40,6 +40,10 @@ createBackendPlugin({ }); ``` +## Rate limiting + +Please refer to the [HTTP Router documentation](./http-router.md#rate-limiting). + ## Configuring the service ### Via `app-config.yaml` From 9a093c82e48d8f94e2117e619cd247097195fe0c Mon Sep 17 00:00:00 2001 From: Ishdril Date: Wed, 11 Jun 2025 16:51:27 +0200 Subject: [PATCH 17/46] feat: create spacelift-io plugins entry Signed-off-by: Ishdril --- microsite/data/plugins/spacelift-io.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 microsite/data/plugins/spacelift-io.yaml diff --git a/microsite/data/plugins/spacelift-io.yaml b/microsite/data/plugins/spacelift-io.yaml new file mode 100644 index 0000000000..fe9b48139f --- /dev/null +++ b/microsite/data/plugins/spacelift-io.yaml @@ -0,0 +1,10 @@ +--- +title: Spacelift.io +author: Spacelift.io +authorUrl: https://spacelift.io/ +category: Infrastructure +description: The Spacelift plugin allows you to manage your infrastructure directly from Backstage. Visualize your IaC stacks and trigger runs with ease. +documentation: https://docs.spacelift.io/integrations/external-integrations/backstage +iconUrl: https://avatars.githubusercontent.com/u/53318513?s=200&v=4 +npmPackageName: '@spacelift-io/backstage-integration-frontend' +addedDate: '2025-06-10' From 1011968ae676ed891584e01f79554dc94c8e1002 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 12 Jun 2025 08:39:02 +0300 Subject: [PATCH 18/46] docs: fix doc proposals Signed-off-by: Hellgren Heikki --- docs/backend-system/core-services/http-router.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/backend-system/core-services/http-router.md b/docs/backend-system/core-services/http-router.md index f53f7d9cd8..5e065152a0 100644 --- a/docs/backend-system/core-services/http-router.md +++ b/docs/backend-system/core-services/http-router.md @@ -71,10 +71,9 @@ access the incoming credentials. ## Rate limiting Rate limiting allows you to limit the amount of requests users can send to you backend. -This is useful for blocking various network attacks, such as DDOS, if your instance does not -have additional firewall configured to handle this. +This is useful for blocking various network attacks, such as DDOS. -To configure the default rate limiting, add the following to your config: +To enable rate limiting, add the following to your config: ```yaml backend: From 2da89d12895d39a44002051d45f2abfb1c757ff2 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 13 Jun 2025 10:00:42 +0200 Subject: [PATCH 19/46] scaffolder: refine analytics events data Signed-off-by: Vincenzo Scamporlino --- .../TemplateGroup/TemplateGroup.tsx | 18 ++++--- .../src/next/components/Workflow/Workflow.tsx | 6 +-- .../components/OngoingTask/ContextMenu.tsx | 28 ++-------- .../components/OngoingTask/OngoingTask.tsx | 51 ++++++++++++++++--- 4 files changed, 63 insertions(+), 40 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.tsx b/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.tsx index a70b822d13..74ade33344 100644 --- a/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.tsx +++ b/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.tsx @@ -22,7 +22,7 @@ import { } from '@backstage/core-components'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { TemplateCardProps, TemplateCard } from '../TemplateCard'; -import { IconComponent } from '@backstage/core-plugin-api'; +import { AnalyticsContext, IconComponent } from '@backstage/core-plugin-api'; /** * The props for the {@link TemplateGroup} component. @@ -69,12 +69,18 @@ export const TemplateGroup = (props: TemplateGroupProps) => { {titleComponent} {templates.map(({ template, additionalLinks }) => ( - + > + + ))} diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 2aa4ee9b2d..95e2647b5a 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -97,13 +97,11 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { async (formState: Record) => { await onCreate(formState); - const name = - typeof formState.name === 'string' ? formState.name : undefined; - analytics.captureEvent('create', name ?? templateName ?? 'unknown', { + analytics.captureEvent('create', 'Task has been created', { value: minutesSaved, }); }, - [onCreate, analytics, templateName, minutesSaved], + [onCreate, analytics, minutesSaved], ); useEffect(() => { diff --git a/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx b/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx index 704d39a8fd..e4ca230c36 100644 --- a/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx @@ -29,11 +29,8 @@ import Toc from '@material-ui/icons/Toc'; import ControlPointIcon from '@material-ui/icons/ControlPoint'; import MoreVert from '@material-ui/icons/MoreVert'; import { SyntheticEvent, useState } from 'react'; -import { useAnalytics, useApi } from '@backstage/core-plugin-api'; -import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { usePermission } from '@backstage/plugin-permission-react'; import { - taskCancelPermission, taskReadPermission, taskCreatePermission, } from '@backstage/plugin-scaffolder-common/alpha'; @@ -50,7 +47,8 @@ type ContextMenuProps = { onStartOver?: () => void; onToggleLogs?: (state: boolean) => void; onToggleButtonBar?: (state: boolean) => void; - taskId?: string; + isCancelButtonDisabled: boolean; + onCancel: () => void; }; const useStyles = makeStyles(() => ({ @@ -70,27 +68,13 @@ export const ContextMenu = (props: ContextMenuProps) => { onStartOver, onToggleLogs, onToggleButtonBar, - taskId, } = props; const { getPageTheme } = useTheme(); const pageTheme = getPageTheme({ themeId: 'website' }); const classes = useStyles({ fontColor: pageTheme.fontColor }); - const scaffolderApi = useApi(scaffolderApiRef); - const analytics = useAnalytics(); const [anchorEl, setAnchorEl] = useState(); const { t } = useTranslationRef(scaffolderTranslationRef); - const [{ status: cancelStatus }, { execute: cancel }] = useAsync(async () => { - if (taskId) { - analytics.captureEvent('cancelled', 'Template has been cancelled'); - await scaffolderApi.cancelTask(taskId); - } - }); - - const { allowed: canCancelTask } = usePermission({ - permission: taskCancelPermission, - }); - const { allowed: canReadTask } = usePermission({ permission: taskReadPermission, }); @@ -171,12 +155,8 @@ export const ContextMenu = (props: ContextMenuProps) => { )} diff --git a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx index b718a257b6..c9a119e4a1 100644 --- a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx @@ -32,7 +32,12 @@ import { useTaskEventStream, } from '@backstage/plugin-scaffolder-react'; import { selectedTemplateRouteRef } from '../../routes'; -import { useAnalytics, useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { + AnalyticsContext, + useAnalytics, + useApi, + useRouteRef, +} from '@backstage/core-plugin-api'; import qs from 'qs'; import { ContextMenu } from './ContextMenu'; import { @@ -51,6 +56,7 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../translation'; import { entityPresentationApiRef } from '@backstage/plugin-catalog-react'; import { default as reactUseAsync } from 'react-use/esm/useAsync'; +import { stringifyEntityRef } from '@backstage/catalog-model'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -82,6 +88,36 @@ export const OngoingTask = (props: { }>; }) => { // todo(blam): check that task Id actually exists, and that it's valid. otherwise redirect to something more useful. + const { taskId } = useParams(); + const taskStream = useTaskEventStream(taskId!); + const { namespace, name } = + taskStream.task?.spec.templateInfo?.entity?.metadata ?? {}; + + return ( + + + + + + ); +}; + +function OngoingTaskContent(props: { + TemplateOutputsComponent?: ComponentType<{ + output?: ScaffolderTaskOutput; + }>; +}) { const { taskId } = useParams(); const templateRouteRef = useRouteRef(selectedTemplateRouteRef); const navigate = useNavigate(); @@ -183,7 +219,7 @@ export const OngoingTask = (props: { templateRouteRef, ]); - const [{ status: _ }, { execute: triggerRetry }] = useAsync(async () => { + const [, { execute: triggerRetry }] = useAsync(async () => { if (taskId) { analytics.captureEvent('retried', 'Template has been retried'); await scaffolderApi.retry?.(taskId); @@ -202,9 +238,11 @@ export const OngoingTask = (props: { const Outputs = props.TemplateOutputsComponent ?? DefaultTemplateOutputs; const cancelEnabled = !(taskStream.cancelled || taskStream.completed); + const isCancelButtonDisabled = + !cancelEnabled || cancelStatus !== 'not-executed' || !canCancelTask; return ( - + <>
@@ -316,6 +355,6 @@ export const OngoingTask = (props: { ) : null} -
+ ); -}; +} From bea4f7f368f26992a7cd3b536900881c652906ad Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 13 Jun 2025 10:01:33 +0200 Subject: [PATCH 20/46] scaffolder: do not error on render method Signed-off-by: Vincenzo Scamporlino --- .../next/components/TemplateGroups/TemplateGroups.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/TemplateGroups/TemplateGroups.tsx b/plugins/scaffolder-react/src/next/components/TemplateGroups/TemplateGroups.tsx index a0070765fe..58b74dff9d 100644 --- a/plugins/scaffolder-react/src/next/components/TemplateGroups/TemplateGroups.tsx +++ b/plugins/scaffolder-react/src/next/components/TemplateGroups/TemplateGroups.tsx @@ -22,7 +22,7 @@ import { } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react'; import Typography from '@material-ui/core/Typography'; -import { ComponentType, useCallback } from 'react'; +import { ComponentType, useCallback, useEffect } from 'react'; import { TemplateGroup } from '../TemplateGroup/TemplateGroup'; @@ -58,12 +58,17 @@ export const TemplateGroups = (props: TemplateGroupsProps) => { [onTemplateSelected], ); + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error, errorApi]); + if (loading) { return ; } if (error) { - errorApi.post(error); return null; } From 6c972fe17f8dfd5de10a2b32c862071f6c1ccd93 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 13 Jun 2025 10:12:43 +0200 Subject: [PATCH 21/46] scaffolder: analytics changeset Signed-off-by: Vincenzo Scamporlino --- .changeset/dull-cloths-act.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dull-cloths-act.md diff --git a/.changeset/dull-cloths-act.md b/.changeset/dull-cloths-act.md new file mode 100644 index 0000000000..82ba469c7d --- /dev/null +++ b/.changeset/dull-cloths-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Added information about the `entityRef` and `taskId` to the analytics events whenever is possible. From 2092bd5bb2699045c59035f0bbbe025419916f5b Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 13 Jun 2025 10:25:38 +0200 Subject: [PATCH 22/46] chore: added ability to define attributes Signed-off-by: benjdlambert --- .../actions/actionsServiceFactory.test.ts | 10 ++ .../DefaultActionsRegistryService.ts | 10 +- .../actionsRegistryServiceFactory.test.ts | 103 ++++++++++++++++++ .../definitions/ActionsRegistryService.ts | 5 + .../services/definitions/ActionsService.ts | 5 + packages/backend-test-utils/package.json | 2 + yarn.lock | 2 + 7 files changed, 135 insertions(+), 2 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts index 7b7285c89a..58a79c6a2a 100644 --- a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts @@ -70,6 +70,11 @@ describe('actionsServiceFactory', () => { input: {}, output: {}, }, + attributes: { + destructive: false, + idempotent: false, + readOnly: false, + }, }; beforeEach(() => { @@ -306,6 +311,11 @@ describe('actionsServiceFactory', () => { type: 'object', }, }, + attributes: { + destructive: false, + idempotent: false, + readOnly: false, + }, title: 'Test', }, ], diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index 2ecf4d5006..cddb04c817 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -67,13 +67,19 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { actions: Array.from(this.actions.entries()).map(([id, action]) => ({ id, ...action, + attributes: { + // todo(blam): what's safe defaults? + destructive: action.attributes?.destructive ?? false, + idempotent: action.attributes?.idempotent ?? false, + readOnly: action.attributes?.readOnly ?? false, + }, schema: { input: action.schema?.input ? zodToJsonSchema(action.schema.input(z)) - : zodToJsonSchema(z.any()), + : zodToJsonSchema(z.object({})), output: action.schema?.output ? zodToJsonSchema(action.schema.output(z)) - : zodToJsonSchema(z.any()), + : zodToJsonSchema(z.object({})), }, })), }); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 9d6674c0cf..e885cb10a5 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -181,6 +181,109 @@ describe('actionsRegistryServiceFactory', () => { }); }); + it('should set default attributes', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => z.object({}), + output: z => z.object({}), + }, + action: async () => ({ output: { ok: true } }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server).get( + '/api/my-plugin/.backstage/actions/v1/actions', + ); + + expect(status).toBe(200); + + expect(body).toMatchObject({ + actions: [ + { + name: 'test', + attributes: { + destructive: false, + idempotent: false, + readOnly: false, + }, + }, + ], + }); + }); + + it('should allow setting attributes', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + attributes: { + destructive: true, + idempotent: true, + readOnly: true, + }, + schema: { + input: z => z.object({}), + output: z => z.object({}), + }, + action: async () => ({ output: { ok: true } }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server).get( + '/api/my-plugin/.backstage/actions/v1/actions', + ); + + expect(status).toBe(200); + + expect(body).toMatchObject({ + actions: [ + { + name: 'test', + title: 'Test', + description: 'Test', + attributes: { + destructive: true, + idempotent: true, + readOnly: true, + }, + }, + ], + }); + }); + it('should forces registration of input and output schema as objects', async () => { const pluginSubject = createBackendPlugin({ pluginId: 'my-plugin', diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index 44df71f41b..6c0936f85c 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -40,6 +40,11 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; + attributes?: { + destructive?: boolean; + idempotent?: boolean; + readOnly?: boolean; + }; action: ( context: ActionsRegistryActionContext, ) => Promise< diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts index 69b13afaaa..d595008a42 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -29,6 +29,11 @@ export type ActionsServiceAction = { input: JSONSchema7; output: JSONSchema7; }; + attributes: { + readOnly: boolean; + destructive: boolean; + idempotent: boolean; + }; }; /** diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index d58720723d..1d36f77026 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -59,12 +59,14 @@ "@keyv/valkey": "^1.0.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", + "@types/json-schema": "^7.0.6", "@types/keyv": "^4.2.0", "@types/qs": "^6.9.6", "better-sqlite3": "^11.0.0", "cookie": "^0.7.0", "express": "^4.17.1", "fs-extra": "^11.0.0", + "json-schema": "^0.4.0", "keyv": "^5.2.1", "knex": "^3.0.0", "mysql2": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index b09bfdd787..8fde916ae8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3784,6 +3784,7 @@ __metadata: "@types/express": "npm:^4.17.6" "@types/express-serve-static-core": "npm:^4.17.5" "@types/jest": "npm:*" + "@types/json-schema": "npm:^7.0.6" "@types/keyv": "npm:^4.2.0" "@types/qs": "npm:^6.9.6" "@types/supertest": "npm:^2.0.8" @@ -3791,6 +3792,7 @@ __metadata: cookie: "npm:^0.7.0" express: "npm:^4.17.1" fs-extra: "npm:^11.0.0" + json-schema: "npm:^0.4.0" keyv: "npm:^5.2.1" knex: "npm:^3.0.0" mysql2: "npm:^3.0.0" From 52e0626d94331c5e3621d7ddd118178733bcb577 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 13 Jun 2025 10:27:44 +0200 Subject: [PATCH 23/46] chore: updated API reports Signed-off-by: benjdlambert --- packages/backend-plugin-api/report.api.md | 10 ++++++++++ packages/backend-test-utils/package.json | 2 -- yarn.lock | 2 -- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/backend-plugin-api/report.api.md b/packages/backend-plugin-api/report.api.md index 3b24fcb012..d5030adb9c 100644 --- a/packages/backend-plugin-api/report.api.md +++ b/packages/backend-plugin-api/report.api.md @@ -48,6 +48,11 @@ export type ActionsRegistryActionOptions< input: (zod: typeof z) => TInputSchema; output: (zod: typeof z) => TOutputSchema; }; + attributes?: { + destructive?: boolean; + idempotent?: boolean; + readOnly?: boolean; + }; action: (context: ActionsRegistryActionContext) => Promise< z.infer extends void ? void @@ -94,6 +99,11 @@ export type ActionsServiceAction = { input: JSONSchema7; output: JSONSchema7; }; + attributes: { + readOnly: boolean; + destructive: boolean; + idempotent: boolean; + }; }; // @public diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 1d36f77026..d58720723d 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -59,14 +59,12 @@ "@keyv/valkey": "^1.0.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", - "@types/json-schema": "^7.0.6", "@types/keyv": "^4.2.0", "@types/qs": "^6.9.6", "better-sqlite3": "^11.0.0", "cookie": "^0.7.0", "express": "^4.17.1", "fs-extra": "^11.0.0", - "json-schema": "^0.4.0", "keyv": "^5.2.1", "knex": "^3.0.0", "mysql2": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 8fde916ae8..b09bfdd787 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3784,7 +3784,6 @@ __metadata: "@types/express": "npm:^4.17.6" "@types/express-serve-static-core": "npm:^4.17.5" "@types/jest": "npm:*" - "@types/json-schema": "npm:^7.0.6" "@types/keyv": "npm:^4.2.0" "@types/qs": "npm:^6.9.6" "@types/supertest": "npm:^2.0.8" @@ -3792,7 +3791,6 @@ __metadata: cookie: "npm:^0.7.0" express: "npm:^4.17.1" fs-extra: "npm:^11.0.0" - json-schema: "npm:^0.4.0" keyv: "npm:^5.2.1" knex: "npm:^3.0.0" mysql2: "npm:^3.0.0" From 2c77bd52d7fb254e9aa00f1bc59414c4406f1f63 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 13 Jun 2025 10:30:49 +0200 Subject: [PATCH 24/46] scaffolder: track steps Signed-off-by: Vincenzo Scamporlino --- .../src/next/components/Workflow/Workflow.tsx | 5 +- .../next/hooks/useFilteredSchemaProperties.ts | 85 ++++++++++--------- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 95e2647b5a..527009037d 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -99,9 +99,12 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { analytics.captureEvent('create', 'Task has been created', { value: minutesSaved, + attributes: { + templateSteps: sortedManifest?.steps?.length ?? 0, + }, }); }, - [onCreate, analytics, minutesSaved], + [onCreate, analytics, minutesSaved, sortedManifest], ); useEffect(() => { diff --git a/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.ts b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.ts index be6f581f87..cac696a745 100644 --- a/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.ts +++ b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.ts @@ -16,6 +16,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { useApi, featureFlagsApiRef } from '@backstage/core-plugin-api'; import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; +import { useMemo } from 'react'; /** * Returns manifest of software templates with steps without a featureFlag tag. @@ -28,49 +29,51 @@ export const useFilteredSchemaProperties = ( const featureFlagKey = 'backstage:featureFlag'; const featureFlagApi = useApi(featureFlagsApiRef); - if (!manifest) { - return undefined; - } - - const filteredSteps = manifest?.steps - .filter(step => { - const featureFlag = step.schema[featureFlagKey]; - return ( - typeof featureFlag !== 'string' || featureFlagApi.isActive(featureFlag) - ); - }) - .map(step => { - const filteredStep = cloneDeep(step); - const removedPropertyKeys: Array = []; - if (filteredStep.schema.properties) { - filteredStep.schema.properties = Object.fromEntries( - Object.entries(filteredStep.schema.properties).filter( - ([key, value]) => { - if (value[featureFlagKey]) { - if (featureFlagApi.isActive(value[featureFlagKey])) { - return true; - } - - removedPropertyKeys.push(key); - return false; - } - return true; - }, - ), + return useMemo(() => { + if (!manifest) { + return undefined; + } + const filteredSteps = manifest?.steps + .filter(step => { + const featureFlag = step.schema[featureFlagKey]; + return ( + typeof featureFlag !== 'string' || + featureFlagApi.isActive(featureFlag) ); + }) + .map(step => { + const filteredStep = cloneDeep(step); + const removedPropertyKeys: Array = []; + if (filteredStep.schema.properties) { + filteredStep.schema.properties = Object.fromEntries( + Object.entries(filteredStep.schema.properties).filter( + ([key, value]) => { + if (value[featureFlagKey]) { + if (featureFlagApi.isActive(value[featureFlagKey])) { + return true; + } - // remove the feature flag property key from required if they are not active - filteredStep.schema.required = Array.isArray( - filteredStep.schema.required, - ) - ? filteredStep.schema.required?.filter( - r => !removedPropertyKeys.includes(r as string), - ) - : filteredStep.schema.required; - } + removedPropertyKeys.push(key); + return false; + } + return true; + }, + ), + ); - return filteredStep; - }); + // remove the feature flag property key from required if they are not active + filteredStep.schema.required = Array.isArray( + filteredStep.schema.required, + ) + ? filteredStep.schema.required?.filter( + r => !removedPropertyKeys.includes(r as string), + ) + : filteredStep.schema.required; + } - return { ...manifest, steps: filteredSteps }; + return filteredStep; + }); + + return { ...manifest, steps: filteredSteps }; + }, [manifest, featureFlagApi]); }; From 2977da8602e5b384e85a93b9819fb88c082b59ee Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 13 Jun 2025 10:47:13 +0200 Subject: [PATCH 25/46] scaffolder: fix test Signed-off-by: Vincenzo Scamporlino --- .../TemplateWizardPage.test.tsx | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx index d3a02931de..6836259dd8 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPage.test.tsx @@ -21,7 +21,7 @@ import { renderInTestApp, TestApiRegistry, } from '@backstage/test-utils'; -import { act, fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import { ScaffolderApi, scaffolderApiRef, @@ -127,14 +127,10 @@ describe('TemplateWizardPage', () => { }); // Go to the final page - await act(async () => { - fireEvent.click(await findByRole('button', { name: 'Review' })); - }); + fireEvent.click(await findByRole('button', { name: 'Review' })); // Create the software - await act(async () => { - fireEvent.click(await findByRole('button', { name: 'Create' })); - }); + fireEvent.click(await findByRole('button', { name: 'Create' })); // The "Next Step" button should have fired an event expect(analyticsApi.captureEvent).toHaveBeenCalledWith( @@ -148,15 +144,20 @@ describe('TemplateWizardPage', () => { ); // And the "Create" button should have fired an event - expect(analyticsApi.captureEvent).toHaveBeenCalledWith( - expect.objectContaining({ - action: 'create', - subject: 'expected-name', - context: expect.objectContaining({ - entityRef: 'template:default/test', + await waitFor(() => + expect(analyticsApi.captureEvent).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'create', + subject: 'Task has been created', + attributes: { + templateSteps: 1, + }, + context: expect.objectContaining({ + entityRef: 'template:default/test', + }), + value: 120, }), - value: 120, - }), + ), ); }); From 5f74715c81b9a92447e737cb10d73ae6b0930239 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 13 Jun 2025 12:18:38 +0200 Subject: [PATCH 26/46] scaffolder: remove unused import Signed-off-by: Vincenzo Scamporlino --- plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx b/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx index e4ca230c36..e1a5f37ddc 100644 --- a/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx @@ -21,7 +21,6 @@ import MenuItem from '@material-ui/core/MenuItem'; import MenuList from '@material-ui/core/MenuList'; import Popover from '@material-ui/core/Popover'; import { makeStyles, Theme, useTheme } from '@material-ui/core/styles'; -import { useAsync } from '@react-hookz/web'; import Cancel from '@material-ui/icons/Cancel'; import Repeat from '@material-ui/icons/Repeat'; import Replay from '@material-ui/icons/Replay'; From 015af26f82a3be19271621bab95c67e65df51801 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 13 Jun 2025 12:25:57 +0200 Subject: [PATCH 27/46] scaffolder: scaffolder-react changeset Signed-off-by: Vincenzo Scamporlino --- .changeset/dull-cloths-act.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/dull-cloths-act.md b/.changeset/dull-cloths-act.md index 82ba469c7d..e49ee5a289 100644 --- a/.changeset/dull-cloths-act.md +++ b/.changeset/dull-cloths-act.md @@ -1,4 +1,5 @@ --- +'@backstage/plugin-scaffolder-react': minor '@backstage/plugin-scaffolder': minor --- From 90dc85d9d49c7dcc93f5987f58190623732fae5c Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 13 Jun 2025 13:05:13 +0200 Subject: [PATCH 28/46] scaffolder: track edit link Signed-off-by: Vincenzo Scamporlino --- .../TemplateWizardPageContextMenu.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPageContextMenu.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPageContextMenu.tsx index 9a912f265f..72fa397a9e 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPageContextMenu.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPageContextMenu.tsx @@ -26,6 +26,7 @@ import MoreVert from '@material-ui/icons/MoreVert'; import { SyntheticEvent, useState } from 'react'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../../translation'; +import { Link } from '@backstage/core-components'; const useStyles = makeStyles(theme => ({ button: { @@ -82,16 +83,18 @@ export function TemplateWizardPageContextMenu( transformOrigin={{ vertical: 'top', horizontal: 'right' }} > - window.open(editUrl, '_blank')}> - - - - - + + + + + + + + From 5b2a34ab5881d8330f0df0efaa10f3c3a801a374 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Fri, 13 Jun 2025 15:38:44 +0200 Subject: [PATCH 29/46] scaffolder: adjust link component Signed-off-by: Vincenzo Scamporlino --- .../TemplateWizardPageContextMenu.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPageContextMenu.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPageContextMenu.tsx index 72fa397a9e..c15795de2c 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPageContextMenu.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPageContextMenu.tsx @@ -83,18 +83,16 @@ export function TemplateWizardPageContextMenu( transformOrigin={{ vertical: 'top', horizontal: 'right' }} > - - - - - - - - + + + + + + From 41d4d6e7afc68d1dca29823574348e8d0b27aaf9 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Wed, 11 Jun 2025 14:10:35 +0300 Subject: [PATCH 30/46] feat: clean up old notifications a new scheduled task that will delete old notifications. the default is that over 1 year old notifications will be deleted. the scheduled task is run every 24 hours. this can be disabled by setting the retention period to false in the notifications config. Signed-off-by: Hellgren Heikki --- .changeset/common-goats-exist.md | 15 +++ docs/notifications/index.md | 15 +++ plugins/notifications-backend/config.d.ts | 5 + .../DatabaseNotificationsStore.test.ts | 19 +++ .../database/DatabaseNotificationsStore.ts | 21 ++++ .../src/database/NotificationsStore.ts | 9 ++ plugins/notifications-backend/src/plugin.ts | 16 ++- .../src/service/NotificationCleaner.test.ts | 118 ++++++++++++++++++ .../src/service/NotificationCleaner.ts | 91 ++++++++++++++ .../src/service/router.test.ts | 13 +- .../src/service/router.ts | 8 +- 11 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 .changeset/common-goats-exist.md create mode 100644 plugins/notifications-backend/src/service/NotificationCleaner.test.ts create mode 100644 plugins/notifications-backend/src/service/NotificationCleaner.ts diff --git a/.changeset/common-goats-exist.md b/.changeset/common-goats-exist.md new file mode 100644 index 0000000000..1992b69223 --- /dev/null +++ b/.changeset/common-goats-exist.md @@ -0,0 +1,15 @@ +--- +'@backstage/plugin-notifications-backend': patch +--- + +Notifications are now automatically deleted after 1 year by default. + +There is a new scheduled task that runs every 24 hours to delete notifications older than 1 year. +This can be configured by setting the `notifications.retention` in the `app-config.yaml` file. + +```yaml +notifications: + retention: 1y +``` + +If the retention is set to false, notifications will not be automatically deleted. diff --git a/docs/notifications/index.md b/docs/notifications/index.md index 2af8879ac0..c31d2716e4 100644 --- a/docs/notifications/index.md +++ b/docs/notifications/index.md @@ -158,6 +158,21 @@ You can customize the origin names shown in the UI by passing an object where th Each notification processor will receive its own row in the settings page, where the user can enable or disable notifications from that processor. +### Automatic notification cleanup + +Notifications are deleted automatically after a certain period of time to prevent the database from growing indefinitely +and to keep the user interface clean. The default retention period is set to 1 year, meaning that notifications older +than that will be deleted automatically. + +The retention period can be configured by setting the `notifications.retention` in the `app-config.yaml` file. + +```yaml +notifications: + retention: 1y +``` + +If the retention is set to false, notifications will not be automatically deleted. + ## Additional info An example of a backend plugin sending notifications can be found in the [`@backstage/plugin-scaffolder-backend-module-notifications` package](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend-module-notifications). diff --git a/plugins/notifications-backend/config.d.ts b/plugins/notifications-backend/config.d.ts index 5dd795f8d3..61ae411df8 100644 --- a/plugins/notifications-backend/config.d.ts +++ b/plugins/notifications-backend/config.d.ts @@ -28,5 +28,10 @@ export interface Config { * Throttle duration between notification sending, defaults to 50ms */ throttleInterval?: HumanDuration | string; + /** + * Time to keep the notifications in the database, defaults to 365 days. + * Can be disabled by setting to false. + */ + retention?: HumanDuration | string | false; }; } diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts index 4a69c9fd0b..d8ae3faf57 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.test.ts @@ -805,5 +805,24 @@ describe.each(databases.eachSupportedId())( }); }); }); + + describe('clearNotifications', () => { + it('should clear notifications older than specified days', async () => { + const oldDate = new Date(); + oldDate.setDate(oldDate.getDate() - 10); // 10 days ago + await storage.saveNotification({ + ...testNotification1, + created: oldDate, + }); + await storage.saveNotification(testNotification2); + + const result = await storage.clearNotifications({ + maxAge: { days: 5 }, + }); // Clear notifications older than 5 days + expect(result.deletedCount).toBe(1); // Only the first notification should be cleared + const remainingNotifications = await storage.getNotifications({ user }); + expect(remainingNotifications.map(idOnly)).toEqual([id2]); + }); + }); }, ); diff --git a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts index f761081438..94131ca257 100644 --- a/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts +++ b/plugins/notifications-backend/src/database/DatabaseNotificationsStore.ts @@ -31,6 +31,7 @@ import { } from '@backstage/plugin-notifications-common'; import { Knex } from 'knex'; import crypto from 'crypto'; +import { durationToMilliseconds, HumanDuration } from '@backstage/types'; const migrationsDir = resolvePackagePath( '@backstage/plugin-notifications-backend', @@ -656,4 +657,24 @@ export class DatabaseNotificationsStore implements NotificationsStore { .distinct(['topic']); return { topics: topics.map(row => row.topic) }; } + + async clearNotifications(options: { + maxAge: HumanDuration; + }): Promise<{ deletedCount: number }> { + const ms = durationToMilliseconds(options.maxAge); + const now = new Date(new Date().getTime() - ms); + const notificationsCount = await this.db('notification') + .where(builder => { + builder.where('created', '<=', now).whereNull('updated'); + }) + .orWhere('updated', '<=', now) + .delete(); + const broadcastsCount = await this.db('broadcast') + .where(builder => { + builder.where('created', '<=', now).whereNull('updated'); + }) + .orWhere('updated', '<=', now) + .delete(); + return { deletedCount: notificationsCount + broadcastsCount }; + } } diff --git a/plugins/notifications-backend/src/database/NotificationsStore.ts b/plugins/notifications-backend/src/database/NotificationsStore.ts index a1f4eb6999..1f25b28bbe 100644 --- a/plugins/notifications-backend/src/database/NotificationsStore.ts +++ b/plugins/notifications-backend/src/database/NotificationsStore.ts @@ -20,6 +20,7 @@ import { NotificationSeverity, NotificationStatus, } from '@backstage/plugin-notifications-common'; +import { HumanDuration } from '@backstage/types'; /** @internal */ export type EntityOrder = { @@ -99,6 +100,10 @@ export interface NotificationsStore { user: string; }): Promise<{ origins: string[] }>; + getUserNotificationTopics(options: { + user: string; + }): Promise<{ topics: { origin: string; topic: string }[] }>; + getNotificationSettings(options: { user: string; }): Promise; @@ -109,4 +114,8 @@ export interface NotificationsStore { }): Promise; getTopics(options: TopicGetOptions): Promise<{ topics: string[] }>; + + clearNotifications(options: { + maxAge: HumanDuration; + }): Promise<{ deletedCount: number }>; } diff --git a/plugins/notifications-backend/src/plugin.ts b/plugins/notifications-backend/src/plugin.ts index a5a31d92ed..5f430318b5 100644 --- a/plugins/notifications-backend/src/plugin.ts +++ b/plugins/notifications-backend/src/plugin.ts @@ -26,6 +26,8 @@ import { NotificationsProcessingExtensionPoint, } from '@backstage/plugin-notifications-node'; import { catalogServiceRef } from '@backstage/plugin-catalog-node'; +import { DatabaseNotificationsStore } from './database'; +import { NotificationCleaner } from './service/NotificationCleaner.ts'; class NotificationsProcessingExtensionPointImpl implements NotificationsProcessingExtensionPoint @@ -69,6 +71,7 @@ export const notificationsPlugin = createBackendPlugin({ signals: signalsServiceRef, config: coreServices.rootConfig, catalog: catalogServiceRef, + scheduler: coreServices.scheduler, }, async init({ auth, @@ -80,7 +83,10 @@ export const notificationsPlugin = createBackendPlugin({ signals, config, catalog, + scheduler, }) { + const store = await DatabaseNotificationsStore.create({ database }); + httpRouter.use( await createRouter({ auth, @@ -88,7 +94,7 @@ export const notificationsPlugin = createBackendPlugin({ userInfo, logger, config, - database, + store, catalog, signals, processors: processingExtensions.processors, @@ -98,6 +104,14 @@ export const notificationsPlugin = createBackendPlugin({ path: '/health', allow: 'unauthenticated', }); + + const cleaner = new NotificationCleaner( + config, + scheduler, + logger, + store, + ); + await cleaner.initTaskRunner(); }, }); }, diff --git a/plugins/notifications-backend/src/service/NotificationCleaner.test.ts b/plugins/notifications-backend/src/service/NotificationCleaner.test.ts new file mode 100644 index 0000000000..bfd3cc62b9 --- /dev/null +++ b/plugins/notifications-backend/src/service/NotificationCleaner.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { LoggerService, SchedulerService } from '@backstage/backend-plugin-api'; +import { Config } from '@backstage/config'; +import { mockServices } from '@backstage/backend-test-utils'; +import { NotificationsStore } from '../database'; +import { NotificationCleaner } from './NotificationCleaner.ts'; + +describe('NotificationCleaner', () => { + let mockConfig: Config; + let mockScheduler: SchedulerService; + let mockLogger: LoggerService; + let mockDatabase: NotificationsStore; + + beforeEach(() => { + mockConfig = mockServices.rootConfig(); + mockScheduler = mockServices.scheduler.mock(); + mockLogger = mockServices.logger.mock(); + mockDatabase = { + clearNotifications: jest.fn(), + } as unknown as NotificationsStore; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initNotificationCleaner', () => { + it('should initialize the notification cleaner with the correct schedule', async () => { + const mockTaskRunner = { + run: jest.fn(), + }; + mockScheduler.createScheduledTaskRunner = jest + .fn() + .mockReturnValue(mockTaskRunner); + + const cleaner = new NotificationCleaner( + mockConfig, + mockScheduler, + mockLogger, + mockDatabase, + ); + expect(cleaner).toBeInstanceOf(NotificationCleaner); + await cleaner.initTaskRunner(); + + expect(mockScheduler.createScheduledTaskRunner).toHaveBeenCalled(); + expect(mockTaskRunner.run).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'notification-cleaner', + fn: expect.any(Function), + }), + ); + }); + + it('should not create a task runner if retention is disabled', async () => { + mockConfig = mockServices.rootConfig({ + data: { notifications: { retention: false } }, + }); + const cleaner = new NotificationCleaner( + mockConfig, + mockScheduler, + mockLogger, + mockDatabase, + ); + await cleaner.initTaskRunner(); + + expect(mockScheduler.createScheduledTaskRunner).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Notification retention is disabled, skipping notification cleaner task', + ); + }); + }); + + describe('clearNotifications', () => { + it('should clear notifications', async () => { + mockDatabase.clearNotifications = jest + .fn() + .mockResolvedValue({ deletedCount: 1 }); + const mockTaskRunner = { + run: jest.fn().mockImplementation(({ fn }) => fn()), + }; + mockScheduler.createScheduledTaskRunner = jest + .fn() + .mockReturnValue(mockTaskRunner); + + const cleaner = new NotificationCleaner( + mockConfig, + mockScheduler, + mockLogger, + mockDatabase, + ); + await cleaner.initTaskRunner(); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Starting notification cleaner task', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Notification cleaner task completed successfully, deleted 1 notifications', + ); + expect(mockDatabase.clearNotifications).toHaveBeenCalledWith({ + maxAge: { years: 1 }, + }); + }); + }); +}); diff --git a/plugins/notifications-backend/src/service/NotificationCleaner.ts b/plugins/notifications-backend/src/service/NotificationCleaner.ts new file mode 100644 index 0000000000..6733a843b5 --- /dev/null +++ b/plugins/notifications-backend/src/service/NotificationCleaner.ts @@ -0,0 +1,91 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + LoggerService, + SchedulerService, + SchedulerServiceTaskScheduleDefinition, +} from '@backstage/backend-plugin-api'; +import { Config, readDurationFromConfig } from '@backstage/config'; +import { NotificationsStore } from '../database'; +import { HumanDuration } from '@backstage/types'; +import { ForwardedError } from '@backstage/errors'; + +export class NotificationCleaner { + private readonly retention: HumanDuration = { years: 1 }; + private readonly enabled: boolean = true; + + constructor( + config: Config, + private readonly scheduler: SchedulerService, + private readonly logger: LoggerService, + private readonly database: NotificationsStore, + ) { + if (config.has('notifications.retention')) { + const retentionConfig = config.get('notifications.retention'); + if (typeof retentionConfig === 'boolean' && !retentionConfig) { + logger.info( + 'Notification retention is disabled, skipping notification cleaner task', + ); + this.enabled = false; + return; + } + this.retention = readDurationFromConfig(config, { + key: 'notifications.retention', + }); + } + } + + async initTaskRunner() { + if (!this.enabled) { + return; + } + + const schedule: SchedulerServiceTaskScheduleDefinition = { + frequency: { cron: '0 0 * * *' }, + timeout: { hours: 1 }, + initialDelay: { hours: 1 }, + scope: 'global', + }; + + const taskRunner = this.scheduler.createScheduledTaskRunner(schedule); + await taskRunner.run({ + id: 'notification-cleaner', + fn: async () => { + await this.clearNotifications( + this.logger, + this.database, + this.retention, + ); + }, + }); + } + + private async clearNotifications( + logger: LoggerService, + database: NotificationsStore, + retention: HumanDuration, + ) { + logger.info('Starting notification cleaner task'); + try { + const result = await database.clearNotifications({ maxAge: retention }); + logger.info( + `Notification cleaner task completed successfully, deleted ${result.deletedCount} notifications`, + ); + } catch (error) { + throw new ForwardedError('Notification cleaner task failed', error); + } + } +} diff --git a/plugins/notifications-backend/src/service/router.test.ts b/plugins/notifications-backend/src/service/router.test.ts index 0538e90697..1b3f2e8fd6 100644 --- a/plugins/notifications-backend/src/service/router.test.ts +++ b/plugins/notifications-backend/src/service/router.test.ts @@ -29,8 +29,10 @@ import { NotificationSendOptions } from '@backstage/plugin-notifications-node'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { DatabaseService } from '@backstage/backend-plugin-api'; import { v4 as uuid } from 'uuid'; +import { DatabaseNotificationsStore } from '../database'; const databases = TestDatabases.create(); +let store: DatabaseNotificationsStore; async function createDatabase( databaseId: TestDatabaseId, @@ -83,6 +85,9 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { beforeAll(async () => { database = await createDatabase(databaseId); + store = await DatabaseNotificationsStore.create({ + database, + }); }); describe('POST /notifications', () => { @@ -93,7 +98,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { beforeAll(async () => { const router = await createRouter({ logger: mockServices.logger.mock(), - database, + store, signals: signalService, userInfo, config, @@ -460,7 +465,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { beforeAll(async () => { const router = await createRouter({ logger: mockServices.logger.mock(), - database, + store, signals: signalService, userInfo, config, @@ -550,7 +555,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { beforeAll(async () => { const router = await createRouter({ logger: mockServices.logger.mock(), - database, + store, signals: signalService, userInfo, config, @@ -600,7 +605,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => { beforeAll(async () => { const router = await createRouter({ logger: mockServices.logger.mock(), - database, + store, signals: signalService, userInfo, config, diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index f502d49cc4..9563d7965b 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -17,9 +17,9 @@ import express, { Request, Response } from 'express'; import Router from 'express-promise-router'; import { - DatabaseNotificationsStore, normalizeSeverity, NotificationGetOptions, + NotificationsStore, TopicGetOptions, } from '../database'; import { v4 as uuid } from 'uuid'; @@ -31,7 +31,6 @@ import { import { InputError, NotFoundError } from '@backstage/errors'; import { AuthService, - DatabaseService, HttpAuthService, LoggerService, UserInfoService, @@ -58,7 +57,7 @@ import pThrottle from 'p-throttle'; export interface RouterOptions { logger: LoggerService; config: Config; - database: DatabaseService; + store: NotificationsStore; auth: AuthService; httpAuth: HttpAuthService; userInfo: UserInfoService; @@ -74,7 +73,7 @@ export async function createRouter( const { config, logger, - database, + store, auth, httpAuth, userInfo, @@ -84,7 +83,6 @@ export async function createRouter( } = options; const WEB_NOTIFICATION_CHANNEL = 'Web'; - const store = await DatabaseNotificationsStore.create({ database }); const frontendBaseUrl = config.getString('app.baseUrl'); const concurrencyLimit = config.getOptionalNumber('notifications.concurrencyLimit') ?? 10; From f77c481d4ab4a7d80bf182849b66ad21b5721bea Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 1 May 2025 14:40:05 +0200 Subject: [PATCH 31/46] feat: allow the ability to disable relations from one side of the relation graph Signed-off-by: benjdlambert --- .../catalog-backend-module-ldap/config.d.ts | 112 +++++++++++++++++- .../src/ldap/config.ts | 12 ++ .../src/ldap/read.test.ts | 97 +++++++++++++++ .../src/ldap/read.ts | 18 ++- 4 files changed, 232 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-backend-module-ldap/config.d.ts b/plugins/catalog-backend-module-ldap/config.d.ts index 8bd8623891..5474108700 100644 --- a/plugins/catalog-backend-module-ldap/config.d.ts +++ b/plugins/catalog-backend-module-ldap/config.d.ts @@ -93,6 +93,17 @@ export interface Config { pagePause?: boolean; }; }; + /** + * Additional parsing config + */ + parsing?: { + /** + * Whether to skip the memberOf attribute on the users to power the relations of users and groups + * + * @default false + */ + skipMemberOf?: boolean; + }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -173,6 +184,17 @@ export interface Config { pagePause?: boolean; }; }; + /** + * Additional parsing config + */ + parsing?: { + /** + * Whether to skip the memberOf attribute on the users to power the relations of users and groups + * + * @default false + */ + skipMemberOf?: boolean; + }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -258,6 +280,17 @@ export interface Config { pagePause?: boolean; }; }; + /** + * Additional parsing config + */ + parsing?: { + /** + * Whether to skip the member attributes on the groups to power the relations of users and groups + * + * @default false + */ + skipMembers?: boolean; + }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -348,6 +381,17 @@ export interface Config { pagePause?: boolean; }; }; + /** + * Additional parsing config + */ + parsing?: { + /** + * Whether to skip the member attributes on the groups to power the relations of users and groups + * + * @default false + */ + skipMembers?: boolean; + }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -508,6 +552,17 @@ export interface Config { pagePause?: boolean; }; }; + /** + * Additional parsing config + */ + parsing?: { + /** + * Whether to skip the memberOf attribute on the users to power the relations of users and groups + * + * @default false + */ + skipMemberOf?: boolean; + }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -588,6 +643,17 @@ export interface Config { pagePause?: boolean; }; }; + /** + * Additional parsing config + */ + parsing?: { + /** + * Whether to skip the memberOf attribute on the users to power the relations of users and groups + * + * @default false + */ + skipMemberOf?: boolean; + }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -673,6 +739,17 @@ export interface Config { pagePause?: boolean; }; }; + /** + * Additional parsing config + */ + parsing?: { + /** + * Whether to skip the member attributes on the groups to power the relations of users and groups + * + * @default false + */ + skipMembers?: boolean; + }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -763,6 +840,17 @@ export interface Config { pagePause?: boolean; }; }; + /** + * Additional parsing config + */ + parsing?: { + /** + * Whether to skip the member attributes on the groups to power the relations of users and groups + * + * @default false + */ + skipMembers?: boolean; + }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -921,12 +1009,22 @@ export interface Config { pagePause?: boolean; }; }; + /** + * Additional parsing config + */ + parsing?: { + /** + * Whether to skip the memberOf attribute on the users to power the relations of users and groups + * + * @default false + */ + skipMemberOf?: boolean; + }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. * * This can be useful for example if you want to hard code a - * namespace or similar on the generated entities. */ set?: { [key: string]: JsonValue }; /** @@ -1006,6 +1104,18 @@ export interface Config { }; }; /** + * Additional parsing config + */ + parsing?: { + /** + * Whether to skip the member attributes on the groups to power the relations of users and groups + * + * @default false + */ + skipMembers?: boolean; + }; + /** + * @default false * JSON paths (on a.b.c form) and hard coded values to set on those * paths. * diff --git a/plugins/catalog-backend-module-ldap/src/ldap/config.ts b/plugins/catalog-backend-module-ldap/src/ldap/config.ts index 6fdf682b42..87951a9801 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/config.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/config.ts @@ -90,6 +90,12 @@ export type UserConfig = { // Only the scope, filter, attributes, and paged fields are supported. The // default is scope "one" and attributes "*" and "+". options: SearchOptions; + + // Additional parsing config + parsing?: { + // Whether to skip the memberOf attribute on the users to power the relations of users and groups + skipMemberOf?: boolean; + }; // JSON paths (on a.b.c form) and hard coded values to set on those paths set?: { [path: string]: JsonValue }; // Mappings from well known entity fields, to LDAP attribute names @@ -129,6 +135,12 @@ export type GroupConfig = { // The search options to use. // Only the scope, filter, attributes, and paged fields are supported. options: SearchOptions; + + // Additional parsing config + parsing?: { + // Whether to skip the members attribute on the groups to power the relations of users and groups + skipMembers?: boolean; + }; // JSON paths (on a.b.c form) and hard coded values to set on those paths set?: { [path: string]: JsonValue }; // Mappings from well known entity fields, to LDAP attribute names diff --git a/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts b/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts index 4e4f4f7cfd..f1070ad1f0 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts @@ -210,6 +210,55 @@ describe('readLdapUsers', () => { ); }); + it('should allow skipping memberOf', async () => { + client.getVendor.mockResolvedValue(DefaultLdapVendor); + client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => { + await fn(searchEntry({ memberOf: ['x', 'y', 'z'] })); + }); + + client.getVendor.mockResolvedValue(DefaultLdapVendor); + client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => { + await fn( + searchEntry({ + uid: ['uid-value'], + description: ['description-value'], + cn: ['cn-value'], + mail: ['mail-value'], + avatarUrl: ['avatarUrl-value'], + memberOf: ['x', 'y', 'z'], + customDN: ['dn-value'], + customUUID: ['uuid-value'], + }), + ); + }); + const config: UserConfig[] = [ + { + dn: 'ddd', + options: {}, + parsing: { + skipMemberOf: true, + }, + map: { + rdn: 'uid', + name: 'uid', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + memberOf: 'memberOf', + }, + }, + ]; + + const vendorConfig: VendorConfig = { + dnAttributeName: 'customDN', + uuidAttributeName: 'customUUID', + }; + + const { userMemberOf } = await readLdapUsers(client, config, vendorConfig); + expect(userMemberOf.size).toBe(0); + }); + it('transfers all attributes from Microsoft Active Directory', async () => { client.getVendor.mockResolvedValue(ActiveDirectoryVendor); client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => { @@ -729,6 +778,54 @@ describe('readLdapGroups', () => { ); }); + it('should allow skipping members', async () => { + client.getVendor.mockResolvedValue(DefaultLdapVendor); + client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => { + await fn( + searchEntry({ + cn: ['cn-value'], + description: ['description-value'], + tt: ['type-value'], + mail: ['mail-value'], + avatarUrl: ['avatarUrl-value'], + memberOf: ['x', 'y', 'z'], + member: ['e', 'f', 'g'], + customDN: ['dn-value'], + customUUID: ['uuid-value'], + }), + ); + }); + const config: GroupConfig[] = [ + { + dn: 'ddd', + options: {}, + parsing: { + skipMembers: true, + }, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + type: 'tt', + memberOf: 'memberOf', + members: 'member', + }, + }, + ]; + + const vendorConfig: VendorConfig = { + dnAttributeName: 'customDN', + uuidAttributeName: 'customUUID', + }; + + const { groupMember } = await readLdapGroups(client, config, vendorConfig); + + expect(groupMember.size).toBe(0); + }); + it('can process a list of GroupConfigs', async () => { client.getVendor.mockResolvedValue(DefaultLdapVendor); client.searchStreaming.mockImplementation(async (_dn, _opts, fn) => { diff --git a/plugins/catalog-backend-module-ldap/src/ldap/read.ts b/plugins/catalog-backend-module-ldap/src/ldap/read.ts index 9b9041913d..aadaaae7cb 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.ts @@ -143,9 +143,12 @@ export async function readLdapUsers( return; } - mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => { - ensureItems(userMemberOf, myDn, vs); - }); + if (!cfg.parsing?.skipMemberOf) { + mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => { + ensureItems(userMemberOf, myDn, vs); + }); + } + entities.push(entity); }); } @@ -277,9 +280,12 @@ export async function readLdapGroups( mapReferencesAttr(entry, vendor, map.memberOf, (myDn, vs) => { ensureItems(groupMemberOf, myDn, vs); }); - mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => { - ensureItems(groupMember, myDn, vs); - }); + + if (!cfg.parsing?.skipMembers) { + mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => { + ensureItems(groupMember, myDn, vs); + }); + } groups.push(entity); }); From f07b0adafa4b22e3a3f828008ea334ad08f68564 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 1 May 2025 14:46:07 +0200 Subject: [PATCH 32/46] chore: add changeset Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .changeset/yellow-rats-argue.md | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .changeset/yellow-rats-argue.md diff --git a/.changeset/yellow-rats-argue.md b/.changeset/yellow-rats-argue.md new file mode 100644 index 0000000000..16f5468cb8 --- /dev/null +++ b/.changeset/yellow-rats-argue.md @@ -0,0 +1,38 @@ +--- +'@backstage/plugin-catalog-backend-module-ldap': patch +--- + +Added the ability to configure disabling one side of the relations tree with LDAP. + +Groups have a `member` attribute and users have a `memberOf` attribute, however these often can drift out of sync, leaving weird states in the Catalog as we collate these results together and deduplicate them. + +You can chose to optionally disable one side of these relationships, or even both by providing config in `app-config.yaml` under either the `GroupConfig` or `UserConfig`: + +```yaml +catalog: + providers: + ldapOrg: + default: + target: ldaps://ds.example.net + bind: + dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net + secret: ${LDAP_SECRET} + users: + - dn: ou=people,ou=example,dc=example,dc=net + options: + filter: (uid=*) + parsing: + # this defaults to false, to disable use the following: + skipMemberOf: true + groups: + - dn: ou=access,ou=groups,ou=example,dc=example,dc=net + options: + filter: (&(objectClass=some-group-class)(!(groupType=email))) + map: + description: l + set: + metadata.customField: 'hello' + parsing: + # this defaults to false, to disable use the following: + skipMember: true +``` From 2c0dd741e9d7bbec3fd462642af9ba9411a2d2a6 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 2 May 2025 09:35:00 +0200 Subject: [PATCH 33/46] chore: fixing api-report Signed-off-by: benjdlambert --- plugins/catalog-backend-module-ldap/report.api.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/catalog-backend-module-ldap/report.api.md b/plugins/catalog-backend-module-ldap/report.api.md index 16546cb773..9554f0ff19 100644 --- a/plugins/catalog-backend-module-ldap/report.api.md +++ b/plugins/catalog-backend-module-ldap/report.api.md @@ -52,6 +52,9 @@ export function defaultUserTransformer( export type GroupConfig = { dn: string; options: SearchOptions; + parsing?: { + skipMembers?: boolean; + }; set?: { [path: string]: JsonValue; }; @@ -249,6 +252,9 @@ export type TLSConfig = { export type UserConfig = { dn: string; options: SearchOptions; + parsing?: { + skipMemberOf?: boolean; + }; set?: { [path: string]: JsonValue; }; From e527d469989ee3bf48cac57e9b59b138bb5e3d99 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 6 May 2025 11:46:53 +0200 Subject: [PATCH 34/46] chore: refactor to set memberOf or member as null Signed-off-by: benjdlambert --- .../catalog-backend-module-ldap/config.d.ts | 141 ++---------------- .../src/ldap/config.ts | 6 +- .../src/ldap/read.test.ts | 11 +- 3 files changed, 22 insertions(+), 136 deletions(-) diff --git a/plugins/catalog-backend-module-ldap/config.d.ts b/plugins/catalog-backend-module-ldap/config.d.ts index 5474108700..f9b49eabee 100644 --- a/plugins/catalog-backend-module-ldap/config.d.ts +++ b/plugins/catalog-backend-module-ldap/config.d.ts @@ -93,17 +93,6 @@ export interface Config { pagePause?: boolean; }; }; - /** - * Additional parsing config - */ - parsing?: { - /** - * Whether to skip the memberOf attribute on the users to power the relations of users and groups - * - * @default false - */ - skipMemberOf?: boolean; - }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -152,7 +141,7 @@ export interface Config { * The name of the attribute that shall be used for the values of * the spec.memberOf field of the entity. Defaults to "memberOf". */ - memberOf?: string; + memberOf?: string | null; }; } | Array<{ @@ -184,17 +173,6 @@ export interface Config { pagePause?: boolean; }; }; - /** - * Additional parsing config - */ - parsing?: { - /** - * Whether to skip the memberOf attribute on the users to power the relations of users and groups - * - * @default false - */ - skipMemberOf?: boolean; - }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -243,7 +221,7 @@ export interface Config { * The name of the attribute that shall be used for the values of * the spec.memberOf field of the entity. Defaults to "memberOf". */ - memberOf?: string; + memberOf?: string | null; }; }>; @@ -280,17 +258,6 @@ export interface Config { pagePause?: boolean; }; }; - /** - * Additional parsing config - */ - parsing?: { - /** - * Whether to skip the member attributes on the groups to power the relations of users and groups - * - * @default false - */ - skipMembers?: boolean; - }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -344,12 +311,12 @@ export interface Config { * The name of the attribute that shall be used for the values of * the spec.parent field of the entity. Defaults to "memberOf". */ - memberOf?: string; + memberOf?: string | null; /** * The name of the attribute that shall be used for the values of * the spec.children field of the entity. Defaults to "member". */ - members?: string; + members?: string | null; }; } | Array<{ @@ -381,17 +348,6 @@ export interface Config { pagePause?: boolean; }; }; - /** - * Additional parsing config - */ - parsing?: { - /** - * Whether to skip the member attributes on the groups to power the relations of users and groups - * - * @default false - */ - skipMembers?: boolean; - }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -445,12 +401,12 @@ export interface Config { * The name of the attribute that shall be used for the values of * the spec.parent field of the entity. Defaults to "memberOf". */ - memberOf?: string; + memberOf?: string | null; /** * The name of the attribute that shall be used for the values of * the spec.children field of the entity. Defaults to "member". */ - members?: string; + members?: string | null; }; }>; /** @@ -552,17 +508,6 @@ export interface Config { pagePause?: boolean; }; }; - /** - * Additional parsing config - */ - parsing?: { - /** - * Whether to skip the memberOf attribute on the users to power the relations of users and groups - * - * @default false - */ - skipMemberOf?: boolean; - }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -611,7 +556,7 @@ export interface Config { * The name of the attribute that shall be used for the values of * the spec.memberOf field of the entity. Defaults to "memberOf". */ - memberOf?: string; + memberOf?: string | null; }; } | Array<{ @@ -643,17 +588,7 @@ export interface Config { pagePause?: boolean; }; }; - /** - * Additional parsing config - */ - parsing?: { - /** - * Whether to skip the memberOf attribute on the users to power the relations of users and groups - * - * @default false - */ - skipMemberOf?: boolean; - }; + /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -702,7 +637,7 @@ export interface Config { * The name of the attribute that shall be used for the values of * the spec.memberOf field of the entity. Defaults to "memberOf". */ - memberOf?: string; + memberOf?: string | null; }; }>; @@ -739,17 +674,6 @@ export interface Config { pagePause?: boolean; }; }; - /** - * Additional parsing config - */ - parsing?: { - /** - * Whether to skip the member attributes on the groups to power the relations of users and groups - * - * @default false - */ - skipMembers?: boolean; - }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -803,12 +727,12 @@ export interface Config { * The name of the attribute that shall be used for the values of * the spec.parent field of the entity. Defaults to "memberOf". */ - memberOf?: string; + memberOf?: string | null; /** * The name of the attribute that shall be used for the values of * the spec.children field of the entity. Defaults to "member". */ - members?: string; + members?: string | null; }; } | Array<{ @@ -840,17 +764,6 @@ export interface Config { pagePause?: boolean; }; }; - /** - * Additional parsing config - */ - parsing?: { - /** - * Whether to skip the member attributes on the groups to power the relations of users and groups - * - * @default false - */ - skipMembers?: boolean; - }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -904,12 +817,12 @@ export interface Config { * The name of the attribute that shall be used for the values of * the spec.parent field of the entity. Defaults to "memberOf". */ - memberOf?: string; + memberOf?: string | null; /** * The name of the attribute that shall be used for the values of * the spec.children field of the entity. Defaults to "member". */ - members?: string; + members?: string | null; }; }>; @@ -1009,17 +922,6 @@ export interface Config { pagePause?: boolean; }; }; - /** - * Additional parsing config - */ - parsing?: { - /** - * Whether to skip the memberOf attribute on the users to power the relations of users and groups - * - * @default false - */ - skipMemberOf?: boolean; - }; /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -1067,7 +969,7 @@ export interface Config { * The name of the attribute that shall be used for the values of * the spec.memberOf field of the entity. Defaults to "memberOf". */ - memberOf?: string; + memberOf?: string | null; }; }; @@ -1103,17 +1005,6 @@ export interface Config { pagePause?: boolean; }; }; - /** - * Additional parsing config - */ - parsing?: { - /** - * Whether to skip the member attributes on the groups to power the relations of users and groups - * - * @default false - */ - skipMembers?: boolean; - }; /** * @default false * JSON paths (on a.b.c form) and hard coded values to set on those @@ -1168,12 +1059,12 @@ export interface Config { * The name of the attribute that shall be used for the values of * the spec.parent field of the entity. Defaults to "memberOf". */ - memberOf?: string; + memberOf?: string | null; /** * The name of the attribute that shall be used for the values of * the spec.children field of the entity. Defaults to "member". */ - members?: string; + members?: string | null; }; }; /** diff --git a/plugins/catalog-backend-module-ldap/src/ldap/config.ts b/plugins/catalog-backend-module-ldap/src/ldap/config.ts index 87951a9801..95e45f3d5f 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/config.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/config.ts @@ -120,7 +120,7 @@ export type UserConfig = { picture?: string; // The name of the attribute that shall be used for the values of the // spec.memberOf field of the entity. Defaults to "memberOf". - memberOf: string; + memberOf: string | null; }; }; @@ -168,10 +168,10 @@ export type GroupConfig = { picture?: string; // The name of the attribute that shall be used for the values of the // spec.parent field of the entity. Defaults to "memberOf". - memberOf: string; + memberOf: string | null; // The name of the attribute that shall be used for the values of the // spec.children field of the entity. Defaults to "member". - members: string; + members: string | null; }; }; diff --git a/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts b/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts index f1070ad1f0..374cc2afd0 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.test.ts @@ -235,9 +235,7 @@ describe('readLdapUsers', () => { { dn: 'ddd', options: {}, - parsing: { - skipMemberOf: true, - }, + map: { rdn: 'uid', name: 'uid', @@ -245,7 +243,7 @@ describe('readLdapUsers', () => { displayName: 'cn', email: 'mail', picture: 'avatarUrl', - memberOf: 'memberOf', + memberOf: null, }, }, ]; @@ -799,9 +797,6 @@ describe('readLdapGroups', () => { { dn: 'ddd', options: {}, - parsing: { - skipMembers: true, - }, map: { rdn: 'cn', name: 'cn', @@ -811,7 +806,7 @@ describe('readLdapGroups', () => { picture: 'avatarUrl', type: 'tt', memberOf: 'memberOf', - members: 'member', + members: null, }, }, ]; From 0b41d0dd2cff3c7454667e1eb6d62bcce3315e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 16 Jun 2025 14:43:37 +0200 Subject: [PATCH 35/46] allow nulls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/yellow-rats-argue.md | 16 ++++++++-------- .../catalog-backend-module-ldap/report.api.md | 12 +++--------- .../src/ldap/config.ts | 10 ---------- .../src/ldap/read.ts | 18 +++++++----------- 4 files changed, 18 insertions(+), 38 deletions(-) diff --git a/.changeset/yellow-rats-argue.md b/.changeset/yellow-rats-argue.md index 16f5468cb8..321498fd37 100644 --- a/.changeset/yellow-rats-argue.md +++ b/.changeset/yellow-rats-argue.md @@ -4,9 +4,9 @@ Added the ability to configure disabling one side of the relations tree with LDAP. -Groups have a `member` attribute and users have a `memberOf` attribute, however these often can drift out of sync, leaving weird states in the Catalog as we collate these results together and deduplicate them. +Groups have a `member` attribute and users have a `memberOf` attribute, however these can drift out of sync in some LDAP installations, leaving weird states in the Catalog as we collate these results together and deduplicate them. -You can chose to optionally disable one side of these relationships, or even both by providing config in `app-config.yaml` under either the `GroupConfig` or `UserConfig`: +You can chose to optionally disable one side of these relationships, or even both by setting the respective mapping to `null` in your `app-config.yaml` for your groups and/or users: ```yaml catalog: @@ -21,9 +21,9 @@ catalog: - dn: ou=people,ou=example,dc=example,dc=net options: filter: (uid=*) - parsing: - # this defaults to false, to disable use the following: - skipMemberOf: true + map: + # this ensures that outgoing memberships from users is ignored + memberOf: null groups: - dn: ou=access,ou=groups,ou=example,dc=example,dc=net options: @@ -32,7 +32,7 @@ catalog: description: l set: metadata.customField: 'hello' - parsing: - # this defaults to false, to disable use the following: - skipMember: true + map: + # this ensures that outgoing memberships from groups is ignored + members: null ``` diff --git a/plugins/catalog-backend-module-ldap/report.api.md b/plugins/catalog-backend-module-ldap/report.api.md index 9554f0ff19..8357a9ae38 100644 --- a/plugins/catalog-backend-module-ldap/report.api.md +++ b/plugins/catalog-backend-module-ldap/report.api.md @@ -52,9 +52,6 @@ export function defaultUserTransformer( export type GroupConfig = { dn: string; options: SearchOptions; - parsing?: { - skipMembers?: boolean; - }; set?: { [path: string]: JsonValue; }; @@ -66,8 +63,8 @@ export type GroupConfig = { displayName: string; email?: string; picture?: string; - memberOf: string; - members: string; + memberOf: string | null; + members: string | null; }; }; @@ -252,9 +249,6 @@ export type TLSConfig = { export type UserConfig = { dn: string; options: SearchOptions; - parsing?: { - skipMemberOf?: boolean; - }; set?: { [path: string]: JsonValue; }; @@ -265,7 +259,7 @@ export type UserConfig = { displayName: string; email: string; picture?: string; - memberOf: string; + memberOf: string | null; }; }; diff --git a/plugins/catalog-backend-module-ldap/src/ldap/config.ts b/plugins/catalog-backend-module-ldap/src/ldap/config.ts index 95e45f3d5f..8c7fd0dda5 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/config.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/config.ts @@ -91,11 +91,6 @@ export type UserConfig = { // default is scope "one" and attributes "*" and "+". options: SearchOptions; - // Additional parsing config - parsing?: { - // Whether to skip the memberOf attribute on the users to power the relations of users and groups - skipMemberOf?: boolean; - }; // JSON paths (on a.b.c form) and hard coded values to set on those paths set?: { [path: string]: JsonValue }; // Mappings from well known entity fields, to LDAP attribute names @@ -136,11 +131,6 @@ export type GroupConfig = { // Only the scope, filter, attributes, and paged fields are supported. options: SearchOptions; - // Additional parsing config - parsing?: { - // Whether to skip the members attribute on the groups to power the relations of users and groups - skipMembers?: boolean; - }; // JSON paths (on a.b.c form) and hard coded values to set on those paths set?: { [path: string]: JsonValue }; // Mappings from well known entity fields, to LDAP attribute names diff --git a/plugins/catalog-backend-module-ldap/src/ldap/read.ts b/plugins/catalog-backend-module-ldap/src/ldap/read.ts index aadaaae7cb..a3f3ce1e75 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.ts @@ -143,11 +143,9 @@ export async function readLdapUsers( return; } - if (!cfg.parsing?.skipMemberOf) { - mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => { - ensureItems(userMemberOf, myDn, vs); - }); - } + mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => { + ensureItems(userMemberOf, myDn, vs); + }); entities.push(entity); }); @@ -281,11 +279,9 @@ export async function readLdapGroups( ensureItems(groupMemberOf, myDn, vs); }); - if (!cfg.parsing?.skipMembers) { - mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => { - ensureItems(groupMember, myDn, vs); - }); - } + mapReferencesAttr(entry, vendor, map.members, (myDn, vs) => { + ensureItems(groupMember, myDn, vs); + }); groups.push(entity); }); @@ -355,7 +351,7 @@ export async function readLdapOrg( function mapReferencesAttr( entry: SearchEntry, vendor: LdapVendor, - attributeName: string | undefined, + attributeName: string | undefined | null, setter: (sourceDn: string, targets: string[]) => void, ) { if (attributeName) { From 31dcaf72ae0826258e13820556157030f7fc12be Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 16 Jun 2025 14:58:49 +0200 Subject: [PATCH 36/46] chore: destructive by default Signed-off-by: benjdlambert --- .../src/entrypoints/actions/actionsServiceFactory.test.ts | 2 +- .../actionsRegistry/DefaultActionsRegistryService.ts | 5 +++-- .../actionsRegistry/actionsRegistryServiceFactory.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts index 58a79c6a2a..c43945f1d5 100644 --- a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts @@ -312,7 +312,7 @@ describe('actionsServiceFactory', () => { }, }, attributes: { - destructive: false, + destructive: true, idempotent: false, readOnly: false, }, diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index cddb04c817..bee3f7d565 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -68,8 +68,9 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { id, ...action, attributes: { - // todo(blam): what's safe defaults? - destructive: action.attributes?.destructive ?? false, + // Inspired by the @modelcontextprotocol/sdk defaults for the hints. + // https://github.com/modelcontextprotocol/typescript-sdk/blob/dd69efa1de8646bb6b195ff8d5f52e13739f4550/src/types.ts#L777-L812 + destructive: action.attributes?.destructive ?? true, idempotent: action.attributes?.idempotent ?? false, readOnly: action.attributes?.readOnly ?? false, }, diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index e885cb10a5..2de6272365 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -220,7 +220,7 @@ describe('actionsRegistryServiceFactory', () => { { name: 'test', attributes: { - destructive: false, + destructive: true, idempotent: false, readOnly: false, }, @@ -243,7 +243,7 @@ describe('actionsRegistryServiceFactory', () => { title: 'Test', description: 'Test', attributes: { - destructive: true, + destructive: false, idempotent: true, readOnly: true, }, @@ -275,7 +275,7 @@ describe('actionsRegistryServiceFactory', () => { title: 'Test', description: 'Test', attributes: { - destructive: true, + destructive: false, idempotent: true, readOnly: true, }, From abcc776f1d1288d4b50b8f7b5bd214f2c6f30380 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:18:32 +0000 Subject: [PATCH 37/46] fix(deps): update dependency @types/prop-types to v15.7.15 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3bc412b836..ec065e8ba9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22439,9 +22439,9 @@ __metadata: linkType: hard "@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.3": - version: 15.7.14 - resolution: "@types/prop-types@npm:15.7.14" - checksum: 10/d0c5407b9ccc3dd5fae0ccf9b1007e7622ba5e6f1c18399b4f24dff33619d469da4b9fa918a374f19dc0d9fe6a013362aab0b844b606cfc10676efba3f5f736d + version: 15.7.15 + resolution: "@types/prop-types@npm:15.7.15" + checksum: 10/31aa2f59b28f24da6fb4f1d70807dae2aedfce090ec63eaf9ea01727a9533ef6eaf017de5bff99fbccad7d1c9e644f52c6c2ba30869465dd22b1a7221c29f356 languageName: node linkType: hard From 9b8af68f69f79a28eaae06d00caa3061d999a044 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:18:56 +0000 Subject: [PATCH 38/46] fix(deps): update dependency dockerode to v4.0.7 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3bc412b836..01c11f0221 100644 --- a/yarn.lock +++ b/yarn.lock @@ -29468,8 +29468,8 @@ __metadata: linkType: hard "dockerode@npm:^4.0.0": - version: 4.0.6 - resolution: "dockerode@npm:4.0.6" + version: 4.0.7 + resolution: "dockerode@npm:4.0.7" dependencies: "@balena/dockerignore": "npm:^1.0.2" "@grpc/grpc-js": "npm:^1.11.1" @@ -29478,7 +29478,7 @@ __metadata: protobufjs: "npm:^7.3.2" tar-fs: "npm:~2.1.2" uuid: "npm:^10.0.0" - checksum: 10/75bd706f20f01742d22913b72e2a5215a4d9f79772c29079772f84fc41a2b1890a704a1aa3d1d764405367090e93c33197d6add19fde3546bac919d98eebc8e3 + checksum: 10/d7cd174cf4489f41335ec8aaaa7c98c164a624f9a793544aa5280d85254ce276e7797de896042ce47d87aca6f8d2653acc37a0d18807d4ce8ea31892faef40a8 languageName: node linkType: hard From 24f454a510d0a99ad290e5e3416d52076a3a5e4e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 14:03:56 +0000 Subject: [PATCH 39/46] fix(deps): update dependency esbuild to v0.25.5 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 206 +++++++++++++++++++++++++++--------------------------- 1 file changed, 103 insertions(+), 103 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3bc412b836..f004f965b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9667,9 +9667,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/aix-ppc64@npm:0.25.4" +"@esbuild/aix-ppc64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/aix-ppc64@npm:0.25.5" conditions: os=aix & cpu=ppc64 languageName: node linkType: hard @@ -9681,9 +9681,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/android-arm64@npm:0.25.4" +"@esbuild/android-arm64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/android-arm64@npm:0.25.5" conditions: os=android & cpu=arm64 languageName: node linkType: hard @@ -9695,9 +9695,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/android-arm@npm:0.25.4" +"@esbuild/android-arm@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/android-arm@npm:0.25.5" conditions: os=android & cpu=arm languageName: node linkType: hard @@ -9709,9 +9709,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-x64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/android-x64@npm:0.25.4" +"@esbuild/android-x64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/android-x64@npm:0.25.5" conditions: os=android & cpu=x64 languageName: node linkType: hard @@ -9723,9 +9723,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/darwin-arm64@npm:0.25.4" +"@esbuild/darwin-arm64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/darwin-arm64@npm:0.25.5" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -9737,9 +9737,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/darwin-x64@npm:0.25.4" +"@esbuild/darwin-x64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/darwin-x64@npm:0.25.5" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -9751,9 +9751,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/freebsd-arm64@npm:0.25.4" +"@esbuild/freebsd-arm64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/freebsd-arm64@npm:0.25.5" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard @@ -9765,9 +9765,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/freebsd-x64@npm:0.25.4" +"@esbuild/freebsd-x64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/freebsd-x64@npm:0.25.5" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -9779,9 +9779,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/linux-arm64@npm:0.25.4" +"@esbuild/linux-arm64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/linux-arm64@npm:0.25.5" conditions: os=linux & cpu=arm64 languageName: node linkType: hard @@ -9793,9 +9793,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/linux-arm@npm:0.25.4" +"@esbuild/linux-arm@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/linux-arm@npm:0.25.5" conditions: os=linux & cpu=arm languageName: node linkType: hard @@ -9807,9 +9807,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/linux-ia32@npm:0.25.4" +"@esbuild/linux-ia32@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/linux-ia32@npm:0.25.5" conditions: os=linux & cpu=ia32 languageName: node linkType: hard @@ -9821,9 +9821,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/linux-loong64@npm:0.25.4" +"@esbuild/linux-loong64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/linux-loong64@npm:0.25.5" conditions: os=linux & cpu=loong64 languageName: node linkType: hard @@ -9835,9 +9835,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/linux-mips64el@npm:0.25.4" +"@esbuild/linux-mips64el@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/linux-mips64el@npm:0.25.5" conditions: os=linux & cpu=mips64el languageName: node linkType: hard @@ -9849,9 +9849,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/linux-ppc64@npm:0.25.4" +"@esbuild/linux-ppc64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/linux-ppc64@npm:0.25.5" conditions: os=linux & cpu=ppc64 languageName: node linkType: hard @@ -9863,9 +9863,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/linux-riscv64@npm:0.25.4" +"@esbuild/linux-riscv64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/linux-riscv64@npm:0.25.5" conditions: os=linux & cpu=riscv64 languageName: node linkType: hard @@ -9877,9 +9877,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/linux-s390x@npm:0.25.4" +"@esbuild/linux-s390x@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/linux-s390x@npm:0.25.5" conditions: os=linux & cpu=s390x languageName: node linkType: hard @@ -9891,16 +9891,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/linux-x64@npm:0.25.4" +"@esbuild/linux-x64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/linux-x64@npm:0.25.5" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@esbuild/netbsd-arm64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/netbsd-arm64@npm:0.25.4" +"@esbuild/netbsd-arm64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/netbsd-arm64@npm:0.25.5" conditions: os=netbsd & cpu=arm64 languageName: node linkType: hard @@ -9912,16 +9912,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/netbsd-x64@npm:0.25.4" +"@esbuild/netbsd-x64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/netbsd-x64@npm:0.25.5" conditions: os=netbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/openbsd-arm64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/openbsd-arm64@npm:0.25.4" +"@esbuild/openbsd-arm64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/openbsd-arm64@npm:0.25.5" conditions: os=openbsd & cpu=arm64 languageName: node linkType: hard @@ -9933,9 +9933,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/openbsd-x64@npm:0.25.4" +"@esbuild/openbsd-x64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/openbsd-x64@npm:0.25.5" conditions: os=openbsd & cpu=x64 languageName: node linkType: hard @@ -9947,9 +9947,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/sunos-x64@npm:0.25.4" +"@esbuild/sunos-x64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/sunos-x64@npm:0.25.5" conditions: os=sunos & cpu=x64 languageName: node linkType: hard @@ -9961,9 +9961,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/win32-arm64@npm:0.25.4" +"@esbuild/win32-arm64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/win32-arm64@npm:0.25.5" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -9975,9 +9975,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/win32-ia32@npm:0.25.4" +"@esbuild/win32-ia32@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/win32-ia32@npm:0.25.5" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard @@ -9989,9 +9989,9 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.25.4": - version: 0.25.4 - resolution: "@esbuild/win32-x64@npm:0.25.4" +"@esbuild/win32-x64@npm:0.25.5": + version: 0.25.5 + resolution: "@esbuild/win32-x64@npm:0.25.5" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -30327,34 +30327,34 @@ __metadata: linkType: hard "esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0, esbuild@npm:^0.25.0": - version: 0.25.4 - resolution: "esbuild@npm:0.25.4" + version: 0.25.5 + resolution: "esbuild@npm:0.25.5" dependencies: - "@esbuild/aix-ppc64": "npm:0.25.4" - "@esbuild/android-arm": "npm:0.25.4" - "@esbuild/android-arm64": "npm:0.25.4" - "@esbuild/android-x64": "npm:0.25.4" - "@esbuild/darwin-arm64": "npm:0.25.4" - "@esbuild/darwin-x64": "npm:0.25.4" - "@esbuild/freebsd-arm64": "npm:0.25.4" - "@esbuild/freebsd-x64": "npm:0.25.4" - "@esbuild/linux-arm": "npm:0.25.4" - "@esbuild/linux-arm64": "npm:0.25.4" - "@esbuild/linux-ia32": "npm:0.25.4" - "@esbuild/linux-loong64": "npm:0.25.4" - "@esbuild/linux-mips64el": "npm:0.25.4" - "@esbuild/linux-ppc64": "npm:0.25.4" - "@esbuild/linux-riscv64": "npm:0.25.4" - "@esbuild/linux-s390x": "npm:0.25.4" - "@esbuild/linux-x64": "npm:0.25.4" - "@esbuild/netbsd-arm64": "npm:0.25.4" - "@esbuild/netbsd-x64": "npm:0.25.4" - "@esbuild/openbsd-arm64": "npm:0.25.4" - "@esbuild/openbsd-x64": "npm:0.25.4" - "@esbuild/sunos-x64": "npm:0.25.4" - "@esbuild/win32-arm64": "npm:0.25.4" - "@esbuild/win32-ia32": "npm:0.25.4" - "@esbuild/win32-x64": "npm:0.25.4" + "@esbuild/aix-ppc64": "npm:0.25.5" + "@esbuild/android-arm": "npm:0.25.5" + "@esbuild/android-arm64": "npm:0.25.5" + "@esbuild/android-x64": "npm:0.25.5" + "@esbuild/darwin-arm64": "npm:0.25.5" + "@esbuild/darwin-x64": "npm:0.25.5" + "@esbuild/freebsd-arm64": "npm:0.25.5" + "@esbuild/freebsd-x64": "npm:0.25.5" + "@esbuild/linux-arm": "npm:0.25.5" + "@esbuild/linux-arm64": "npm:0.25.5" + "@esbuild/linux-ia32": "npm:0.25.5" + "@esbuild/linux-loong64": "npm:0.25.5" + "@esbuild/linux-mips64el": "npm:0.25.5" + "@esbuild/linux-ppc64": "npm:0.25.5" + "@esbuild/linux-riscv64": "npm:0.25.5" + "@esbuild/linux-s390x": "npm:0.25.5" + "@esbuild/linux-x64": "npm:0.25.5" + "@esbuild/netbsd-arm64": "npm:0.25.5" + "@esbuild/netbsd-x64": "npm:0.25.5" + "@esbuild/openbsd-arm64": "npm:0.25.5" + "@esbuild/openbsd-x64": "npm:0.25.5" + "@esbuild/sunos-x64": "npm:0.25.5" + "@esbuild/win32-arm64": "npm:0.25.5" + "@esbuild/win32-ia32": "npm:0.25.5" + "@esbuild/win32-x64": "npm:0.25.5" dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -30408,7 +30408,7 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 10/227ffe9b31f0b184a0b0a0210bb9d32b2b115b8c5c9b09f08db2c3928cb470fc55a22dbba3c2894365d3abcc62c2089b85638be96a20691d1234d31990ea01b2 + checksum: 10/0fa4c3b42c6ddf1a008e75a4bb3dcab08ce22ac0b31dd59dc01f7fe8e21380bfaec07a2fe3730a7cf430da5a30142d016714b358666325a4733547afa42be405 languageName: node linkType: hard From c94f8e089cc4276c091c2d025d08581f56cdd435 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Mon, 2 Jun 2025 11:38:25 +0200 Subject: [PATCH 40/46] (canon)fix: use full menu width for combobox menu filter input. Signed-off-by: Johan Persson --- .changeset/tired-readers-share.md | 5 +++++ packages/canon/css/components.css | 1 + packages/canon/css/menu.css | 1 + packages/canon/css/styles.css | 1 + packages/canon/src/components/Menu/Menu.styles.css | 1 + 5 files changed, 9 insertions(+) create mode 100644 .changeset/tired-readers-share.md diff --git a/.changeset/tired-readers-share.md b/.changeset/tired-readers-share.md new file mode 100644 index 0000000000..148ebd635d --- /dev/null +++ b/.changeset/tired-readers-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/canon': patch +--- + +The filter input in menu comboboxes should now always use the full width of the menu it's in. diff --git a/packages/canon/css/components.css b/packages/canon/css/components.css index 1b60bf74ed..4ea92e4d86 100644 --- a/packages/canon/css/components.css +++ b/packages/canon/css/components.css @@ -903,6 +903,7 @@ border: none; border-bottom: 1px solid var(--canon-border); background-color: var(--canon-bg-surface-1); + width: 100%; height: 32px; color: var(--canon-fg-primary); line-height: 140%; diff --git a/packages/canon/css/menu.css b/packages/canon/css/menu.css index 4c9433275c..7035fab12b 100644 --- a/packages/canon/css/menu.css +++ b/packages/canon/css/menu.css @@ -94,6 +94,7 @@ border: none; border-bottom: 1px solid var(--canon-border); background-color: var(--canon-bg-surface-1); + width: 100%; height: 32px; color: var(--canon-fg-primary); line-height: 140%; diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 83329e817c..0bb27fbd56 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -10127,6 +10127,7 @@ border: none; border-bottom: 1px solid var(--canon-border); background-color: var(--canon-bg-surface-1); + width: 100%; height: 32px; color: var(--canon-fg-primary); line-height: 140%; diff --git a/packages/canon/src/components/Menu/Menu.styles.css b/packages/canon/src/components/Menu/Menu.styles.css index 07d257f57f..7ccaa7a634 100644 --- a/packages/canon/src/components/Menu/Menu.styles.css +++ b/packages/canon/src/components/Menu/Menu.styles.css @@ -93,6 +93,7 @@ .canon-SubmenuComboboxSearch { padding-inline: var(--canon-space-3); + width: 100%; height: 32px; border: none; border-bottom: 1px solid var(--canon-border); From 1269a131388ca92f85238f759a86f7d0c58b38fd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:02:41 +0000 Subject: [PATCH 41/46] fix(deps): update dependency octokit to v3.2.2 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 77 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1a955071d1..a3e391955c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13381,10 +13381,10 @@ __metadata: languageName: node linkType: hard -"@octokit/openapi-types@npm:^22.2.0": - version: 22.2.0 - resolution: "@octokit/openapi-types@npm:22.2.0" - checksum: 10/0471b0c789fada5aa2390e6f82ba477738228ef7d2d986dda9aab0cb625d1562bd178ba0ba4d2655ce841079cd5efff9e58ece2077c27e569ea22109ea301830 +"@octokit/openapi-types@npm:^24.2.0": + version: 24.2.0 + resolution: "@octokit/openapi-types@npm:24.2.0" + checksum: 10/000897ebc6e247c2591049d6081e95eb5636f73798dadd695ee6048496772b58065df88823e74a760201828545a7ac601dd3c1bcd2e00079a62a9ee9d389409c languageName: node linkType: hard @@ -13397,14 +13397,14 @@ __metadata: languageName: node linkType: hard -"@octokit/plugin-paginate-rest@npm:11.3.1": - version: 11.3.1 - resolution: "@octokit/plugin-paginate-rest@npm:11.3.1" +"@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2": + version: 11.4.4-cjs.2 + resolution: "@octokit/plugin-paginate-rest@npm:11.4.4-cjs.2" dependencies: - "@octokit/types": "npm:^13.5.0" + "@octokit/types": "npm:^13.7.0" peerDependencies: "@octokit/core": 5 - checksum: 10/82f5bcc3a536a44bed0a205c8301176c0d210b7a1c6d035a79b31a102e2e02f46234a38629cc984a21be544194ac69151814e9a909416aa7389cdffd1297bcd9 + checksum: 10/e0f696b3b69febe4e7c736d909065871f38bb8346a07f19a9c83246a02972568ac672667db472f846baef20a9611adf26ce8f0f189a11004c4b6618765078e19 languageName: node linkType: hard @@ -13440,14 +13440,14 @@ __metadata: languageName: node linkType: hard -"@octokit/plugin-rest-endpoint-methods@npm:13.2.2": - version: 13.2.2 - resolution: "@octokit/plugin-rest-endpoint-methods@npm:13.2.2" +"@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1": + version: 13.3.2-cjs.1 + resolution: "@octokit/plugin-rest-endpoint-methods@npm:13.3.2-cjs.1" dependencies: - "@octokit/types": "npm:^13.5.0" + "@octokit/types": "npm:^13.8.0" peerDependencies: "@octokit/core": ^5 - checksum: 10/9eccc1a22aa0b65f3f9378f26a74c386683db420c33202998918df1eef492e93212e1849e1d85530f425602663cfc2bfbf385a30991b8a04470334c74ba2386b + checksum: 10/479827e62466e55bc1a50129d51597807bddc6c909e56be9e8dd9c1a91efa0f466a2f56b7d80438649e21ab0a3a195f840b3fccf2ae7f11fb0a919db8e62bc62 languageName: node linkType: hard @@ -13589,12 +13589,12 @@ __metadata: languageName: node linkType: hard -"@octokit/types@npm:^13.0.0, @octokit/types@npm:^13.1.0, @octokit/types@npm:^13.5.0": - version: 13.6.2 - resolution: "@octokit/types@npm:13.6.2" +"@octokit/types@npm:^13.0.0, @octokit/types@npm:^13.1.0, @octokit/types@npm:^13.7.0, @octokit/types@npm:^13.8.0": + version: 13.10.0 + resolution: "@octokit/types@npm:13.10.0" dependencies: - "@octokit/openapi-types": "npm:^22.2.0" - checksum: 10/8e614796f3554d28dfb77c570e80ef52d68ef311bdd4614ec263f8ea2266b9c06d4f7963fe2989f32cbfe4ea0c05e13eba9a64a6e0f64afb997cd975af154d52 + "@octokit/openapi-types": "npm:^24.2.0" + checksum: 10/32f8f5010d7faae128b0cdd0c221f0ca8c3781fe44483ecd87162b3da507db667f7369acda81340f6e2c9c374d9a938803409c6085c2c01d98210b6c58efb99a languageName: node linkType: hard @@ -13641,10 +13641,10 @@ __metadata: languageName: node linkType: hard -"@octokit/webhooks-methods@npm:^4.0.0": - version: 4.0.0 - resolution: "@octokit/webhooks-methods@npm:4.0.0" - checksum: 10/f26892ed868488bf08d5be1fdacbc51f5b6ba84cef21067e0b1ff969d087202989e74303049691a77c75bb1940e7835ad6522b0f4f151ceef015e6d083789c80 +"@octokit/webhooks-methods@npm:^4.1.0": + version: 4.1.0 + resolution: "@octokit/webhooks-methods@npm:4.1.0" + checksum: 10/a95ad68600c43798b09ea29d5a356fb69de25b45d38fbddf0ade00aadb0492b1d59985031e072a66d12e91034999a536653e5d2d4d01350d38cccf735d9ca270 languageName: node linkType: hard @@ -13655,10 +13655,10 @@ __metadata: languageName: node linkType: hard -"@octokit/webhooks-types@npm:7.1.0": - version: 7.1.0 - resolution: "@octokit/webhooks-types@npm:7.1.0" - checksum: 10/80b41945586243df9178a24dce9a5c4b2784eb963c5f6a1c76bcf5600a56e9cb51d8dc4a0da2108abfab3323cde577474a6365615fa1dfaac84c14e9295b54f9 +"@octokit/webhooks-types@npm:7.6.1": + version: 7.6.1 + resolution: "@octokit/webhooks-types@npm:7.6.1" + checksum: 10/0b11bd7e8d13b5a9cf14214421298a423d0180a5e1aaaea876ee4db6f97b5cca536f48d89af63105db75419d777a2402733eb0e110002d4dd59581ef36037bdc languageName: node linkType: hard @@ -13674,15 +13674,15 @@ __metadata: languageName: node linkType: hard -"@octokit/webhooks@npm:^12.0.4": - version: 12.0.10 - resolution: "@octokit/webhooks@npm:12.0.10" +"@octokit/webhooks@npm:^12.0.4, @octokit/webhooks@npm:^12.3.1": + version: 12.3.1 + resolution: "@octokit/webhooks@npm:12.3.1" dependencies: "@octokit/request-error": "npm:^5.0.0" - "@octokit/webhooks-methods": "npm:^4.0.0" - "@octokit/webhooks-types": "npm:7.1.0" + "@octokit/webhooks-methods": "npm:^4.1.0" + "@octokit/webhooks-types": "npm:7.6.1" aggregate-error: "npm:^3.1.0" - checksum: 10/ab7d216d1a1fae91bc3f75057c093707c1f6dbd5ff2106656154a7f471b1a1b5b78c947474e21362590e5510516f9537c4ab79ad6780cc91bfaaaa685aa366e0 + checksum: 10/373266807eb8dcf8d8c6f4685594106f1a257798a1b391cbe36e5b5faf60aca38a0467acd1356427ea7375edc111c03be3d443bc04efb16019a84e853c8a1bc3 languageName: node linkType: hard @@ -40763,20 +40763,21 @@ __metadata: linkType: hard "octokit@npm:^3.0.0": - version: 3.2.1 - resolution: "octokit@npm:3.2.1" + version: 3.2.2 + resolution: "octokit@npm:3.2.2" dependencies: "@octokit/app": "npm:^14.0.2" "@octokit/core": "npm:^5.0.0" "@octokit/oauth-app": "npm:^6.0.0" "@octokit/plugin-paginate-graphql": "npm:^4.0.0" - "@octokit/plugin-paginate-rest": "npm:11.3.1" - "@octokit/plugin-rest-endpoint-methods": "npm:13.2.2" + "@octokit/plugin-paginate-rest": "npm:11.4.4-cjs.2" + "@octokit/plugin-rest-endpoint-methods": "npm:13.3.2-cjs.1" "@octokit/plugin-retry": "npm:^6.0.0" "@octokit/plugin-throttling": "npm:^8.0.0" "@octokit/request-error": "npm:^5.0.0" "@octokit/types": "npm:^13.0.0" - checksum: 10/a3539831c9c0828b1e37dc94a3668f5fb9aa873fc396c0464275d152e4065b6b20300cfba766871cb52ed2e6d537febda7d4be872894c4c62024393a3578fde0 + "@octokit/webhooks": "npm:^12.3.1" + checksum: 10/a258cc62767552fcf9d9a2dfb2aac44b326a2a235fb6444c1f37bc018bda4ac4bf0203704d8b7b3955d3caee6f9256c200287f7e99843c8be01febe4d9fe86d8 languageName: node linkType: hard From a353f9364c780ebf4c4a600adb8b0abdf133c1d4 Mon Sep 17 00:00:00 2001 From: Cory Steers Date: Mon, 16 Jun 2025 10:42:52 -0500 Subject: [PATCH 42/46] implement sugested changes Signed-off-by: Cory Steers --- .../src/handlers/afterWorkspaceDependencyAddition.ts | 5 ++--- .../src/handlers/afterWorkspaceDependencyReplacement.ts | 7 +++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts index 9307790930..219830a8c9 100644 --- a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts @@ -33,9 +33,8 @@ export const afterWorkspaceDependencyAddition = async ( ) { try { await getPackageVersion(descriptor, workspace.project.configuration); - // is there a better way to log than console.log? - console.log( - `afterWorkspaceDependencyAddition hook: Setting descriptor range from ${descriptor.range} to 'backstage:^' for ${descriptor.scope}/${descriptor.name}`, + console.info( + `Setting ${descriptor.scope}/${descriptor.name} to ${PROTOCOL}^`, ); descriptor.range = `${PROTOCOL}^`; } catch (_error: any) { diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts index a955191244..55a11086fd 100644 --- a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts @@ -22,7 +22,7 @@ import { PROTOCOL } from '../constants'; export const afterWorkspaceDependencyReplacement = async ( workspace: Workspace, _target: suggestUtils.Target, - fromDescriptor: Descriptor, + _fromDescriptor: Descriptor, toDescriptor: Descriptor, ) => { const toDescriptorRange = structUtils.parseRange(toDescriptor.range); @@ -33,9 +33,8 @@ export const afterWorkspaceDependencyReplacement = async ( ) { try { await getPackageVersion(toDescriptor, workspace.project.configuration); - // is there a better way to log than console.log? - console.log( - `afterWorkspaceDependencyReplacement hook: Setting descriptor range from '${fromDescriptor.range}' to '${toDescriptor.range}' for ${fromDescriptor.scope}/${fromDescriptor.name}. Are you sure you want to be doing that?`, + console.warn( + `${toDescriptor.name} should be set to "${PROTOCOL}^" instead of "${toDescriptor.range}". Make sure this change is intentional and not a mistake.`, ); } catch (_error: any) { // if there's no found version then this is likely a deprecated package From 1a3325496276c93cf178f67c39ffe2f2dd6e80c3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 17:48:39 +0000 Subject: [PATCH 43/46] chore(deps): update dependency @playwright/test to v1.53.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/yarn.lock b/yarn.lock index a3e391955c..bdadb7d20c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15136,13 +15136,13 @@ __metadata: linkType: hard "@playwright/test@npm:^1.32.3": - version: 1.52.0 - resolution: "@playwright/test@npm:1.52.0" + version: 1.53.0 + resolution: "@playwright/test@npm:1.53.0" dependencies: - playwright: "npm:1.52.0" + playwright: "npm:1.53.0" bin: playwright: cli.js - checksum: 10/e18a4eb626c7bc6cba212ff2e197cf9ae2e4da1c91bfdf08a744d62e27222751173e4b220fa27da72286a89a3b4dea7c09daf384d23708f284b64f98e9a63a88 + checksum: 10/968df4fba133dd18b8c65504c3cc5a3a6071e49f0706c6524711cdfab321a51debfeb506b9ff0a8f7dd8ce3015921d82fa51429d8f11d392cc68de1938703c33 languageName: node linkType: hard @@ -42153,27 +42153,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.52.0": - version: 1.52.0 - resolution: "playwright-core@npm:1.52.0" +"playwright-core@npm:1.53.0": + version: 1.53.0 + resolution: "playwright-core@npm:1.53.0" bin: playwright-core: cli.js - checksum: 10/42e13f5f98dc25ebc95525fb338a215b9097b2ba39d41e99972a190bf75d79979f163f5bc07b1ca06847ee07acb2c9b487d070fab67e9cd55e33310fc05aca3c + checksum: 10/881f27a9b7edd9954700489a5a4212cb91bcada226fd1d79a239b2eab0f333df1e2e41e275e6fa846d7f57c6a92afe14dca33ca7a2ce303dfb687d02511b7c69 languageName: node linkType: hard -"playwright@npm:1.52.0": - version: 1.52.0 - resolution: "playwright@npm:1.52.0" +"playwright@npm:1.53.0": + version: 1.53.0 + resolution: "playwright@npm:1.53.0" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.52.0" + playwright-core: "npm:1.53.0" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10/214175446089000c2ac997b925063b95f7d86d129c5d7c74caa5ddcb05bcad598dfd569d2133a10dc82d288bf67e7858877dcd099274b0b928b9c63db7d6ecec + checksum: 10/0b0258630f39b4d6ff1555d008ee4d591fe45cbe1e0f643a612397e3e6b1f7a99a2037a957eaa7351edd907ba10966ba105b2d244eafd1b247378910b660f086 languageName: node linkType: hard From a19e622d723f998fd65cd6b8fe23511d34ba2c12 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 18:40:07 +0000 Subject: [PATCH 44/46] chore(deps): update dependency axios to v1.10.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 94aa50e750..13e11813af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25748,7 +25748,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.9.0, axios@npm:^1.0.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7, axios@npm:^1.7.8": +"axios@npm:1.9.0": version: 1.9.0 resolution: "axios@npm:1.9.0" dependencies: @@ -25759,6 +25759,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.0.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7, axios@npm:^1.7.8": + version: 1.10.0 + resolution: "axios@npm:1.10.0" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10/d43c80316a45611fd395743e15d16ea69a95f2b7f7095f2bb12cb78f9ca0a905194a02e52a3bf4e0db9f85fd1186d6c690410644c10ecd8bb0a468e57c2040e4 + languageName: node + linkType: hard + "axobject-query@npm:^4.1.0": version: 4.1.0 resolution: "axobject-query@npm:4.1.0" From 4d2438e19a65bfe5a0b7cc5136a957a3be7daf7c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 19:12:39 +0000 Subject: [PATCH 45/46] fix(deps): update dependency rate-limit-redis to v4.2.1 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 7734f8a4ed..fbb716af0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -43427,11 +43427,11 @@ __metadata: linkType: hard "rate-limit-redis@npm:^4.2.0": - version: 4.2.0 - resolution: "rate-limit-redis@npm:4.2.0" + version: 4.2.1 + resolution: "rate-limit-redis@npm:4.2.1" peerDependencies: express-rate-limit: ">= 6" - checksum: 10/22adc67918ca906f613b45f9dcfd039f543d363921979d21ba56be5f3288c6e9973c9e4bb4ec59810fc6b3abb20defd572c102f607a8c3b4d273d5e09b63839f + checksum: 10/c36c50cfca992cbd14c97c08bb01c7d1d340d27b5fa1a2c1154275fec0b59c187eb6fb207da3d035c7c87e7629c92324cd53074f854dbb16548faf94f5a41351 languageName: node linkType: hard From cab1ad8b1b221055504b4a4e5a2ac6a14e1b39e3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 20:03:11 +0000 Subject: [PATCH 46/46] chore(deps): update dependency msw to v2.10.2 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index fb52106772..b35d626c22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12416,9 +12416,9 @@ __metadata: languageName: node linkType: hard -"@mswjs/interceptors@npm:^0.37.0": - version: 0.37.1 - resolution: "@mswjs/interceptors@npm:0.37.1" +"@mswjs/interceptors@npm:^0.39.1": + version: 0.39.2 + resolution: "@mswjs/interceptors@npm:0.39.2" dependencies: "@open-draft/deferred-promise": "npm:^2.2.0" "@open-draft/logger": "npm:^0.3.0" @@ -12426,7 +12426,7 @@ __metadata: is-node-process: "npm:^1.2.0" outvariant: "npm:^1.4.3" strict-event-emitter: "npm:^0.5.1" - checksum: 10/332d8aa50beb4834ccbda6a800ca00b1204adc0eba23e1c1f7bb9f4e564a92707e563f7a2424d4a8607404ec91424e5d8c34a87c250b191ca7b24dff12eba2c5 + checksum: 10/faaa95d636363a197f125c32066457fa74d5063d8ccae4c9c0e0510179060d92b1faf8640df45a0623e0bf42a30d610c83364a58e0eb0ca412c87b2e835936c1 languageName: node linkType: hard @@ -39692,14 +39692,14 @@ __metadata: linkType: hard "msw@npm:^2.0.0, msw@npm:^2.0.8": - version: 2.8.2 - resolution: "msw@npm:2.8.2" + version: 2.10.2 + resolution: "msw@npm:2.10.2" dependencies: "@bundled-es-modules/cookie": "npm:^2.0.1" "@bundled-es-modules/statuses": "npm:^1.0.1" "@bundled-es-modules/tough-cookie": "npm:^0.1.6" "@inquirer/confirm": "npm:^5.0.0" - "@mswjs/interceptors": "npm:^0.37.0" + "@mswjs/interceptors": "npm:^0.39.1" "@open-draft/deferred-promise": "npm:^2.2.0" "@open-draft/until": "npm:^2.1.0" "@types/cookie": "npm:^0.6.0" @@ -39720,7 +39720,7 @@ __metadata: optional: true bin: msw: cli/index.js - checksum: 10/7579a8dccb8cc8eb0f13d0bf3a232a3d50a478511d95bc2a4b70778e78ffa28fd0949a855fbceb6ec381bbf76f6331a6d36fcb4a3197ff5bf55d0f14a7f3b35c + checksum: 10/bc90bc34a0b9e978e662f33fa630a0de66c4b6eff3a92b41efa08bf67d79b43e4a961a17b0d274580393c912f4ac6226c76a8bc33148799b1ee43477df712c26 languageName: node linkType: hard