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/.changeset/dull-cloths-act.md b/.changeset/dull-cloths-act.md new file mode 100644 index 0000000000..e49ee5a289 --- /dev/null +++ b/.changeset/dull-cloths-act.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-react': minor +'@backstage/plugin-scaffolder': minor +--- + +Added information about the `entityRef` and `taskId` to the analytics events whenever is possible. diff --git a/.changeset/famous-terms-rescue.md b/.changeset/famous-terms-rescue.md new file mode 100644 index 0000000000..d323bf0867 --- /dev/null +++ b/.changeset/famous-terms-rescue.md @@ -0,0 +1,27 @@ +--- +'@backstage/backend-defaults': patch +--- + +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/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: 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/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/.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/.changeset/yellow-rats-argue.md b/.changeset/yellow-rats-argue.md new file mode 100644 index 0000000000..321498fd37 --- /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 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 setting the respective mapping to `null` in your `app-config.yaml` for your groups and/or users: + +```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=*) + 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: + filter: (&(objectClass=some-group-class)(!(groupType=email))) + map: + description: l + set: + metadata.customField: 'hello' + map: + # this ensures that outgoing memberships from groups is ignored + members: null +``` diff --git a/app-config.yaml b/app-config.yaml index eaee401d09..89926357c2 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: 1m + # 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/docs/backend-system/core-services/http-router.md b/docs/backend-system/core-services/http-router.md index d7658d9a67..5e065152a0 100644 --- a/docs/backend-system/core-services/http-router.md +++ b/docs/backend-system/core-services/http-router.md @@ -68,6 +68,60 @@ 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. + +To enable 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 +``` + +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: @@ -78,6 +132,7 @@ import { createCookieAuthRefreshMiddleware, createCredentialsBarrier, createAuthIntegrationRouter, + createRateLimitMiddleware, } from '@backstage/backend-defaults/httpRouter'; import { createServiceFactory } from '@backstage/backend-plugin-api'; @@ -105,6 +160,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 5f745966db..1e76fb1871 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` @@ -121,6 +125,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/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/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' diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 1ef054a537..167a8935f9 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -790,6 +790,92 @@ export interface Config { headers?: { [name: string]: string }; }; + /** + * Rate limiting options. Defining this as `true` will enable rate limiting with default values. + */ + rateLimit?: + | true + | { + store?: + | { + type: 'redis'; + connection: string; + } + | { + 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. + */ + 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; + /** 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; + }; + }; + }; + /** * 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 9ae78c119a..93336fd6f8 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.5.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..84424be39d 100644 --- a/packages/backend-defaults/report-rootHttpRouter.api.md +++ b/packages/backend-defaults/report-rootHttpRouter.api.md @@ -93,6 +93,7 @@ export class MiddlewareFactory { helmet(): RequestHandler; logging(): RequestHandler; notFound(): RequestHandler; + rateLimit(): RequestHandler; } // @public diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts index 7b7285c89a..c43945f1d5 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: true, + 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..bee3f7d565 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -67,13 +67,20 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { actions: Array.from(this.actions.entries()).map(([id, action]) => ({ id, ...action, + attributes: { + // 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, + }, 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..2de6272365 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: true, + 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: false, + 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: false, + 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-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts b/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts new file mode 100644 index 0000000000..b139b212d2 --- /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.plugin.${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, prefix: pluginId }), + 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 1ff9bd15d1..80a10fe0eb 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,14 @@ import { InputError, NotAllowedError, NotFoundError, + NotImplementedError, NotModifiedError, - ServiceUnavailableError, serializeError, + ServiceUnavailableError, } from '@backstage/errors'; -import { NotImplementedError } from '@backstage/errors'; import { applyInternalErrorFilter } from './applyInternalErrorFilter'; +import { RateLimitStoreFactory } from '../../../lib/RateLimitStoreFactory.ts'; +import { rateLimitMiddleware } from '../../../lib/rateLimitMiddleware.ts'; type LogMeta = { date: string; @@ -227,6 +229,47 @@ 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 enabled = this.#config.has('backend.rateLimit'); + if (!enabled) { + return (_req: Request, _res: Response, next: NextFunction) => { + next(); + }; + } + + const useDefaults = this.#config.getOptional('backend.rateLimit') === true; + const rateLimitOptions = useDefaults + ? undefined + : this.#config.getOptionalConfig('backend.rateLimit'); + + // Global rate limiting disabled + if ( + rateLimitOptions && + rateLimitOptions.getOptionalBoolean('global') === false + ) { + return (_req: Request, _res: Response, next: NextFunction) => { + next(); + }; + } + + return rateLimitMiddleware({ + store: useDefaults + ? undefined + : RateLimitStoreFactory.create({ config: this.#config }), + config: rateLimitOptions, + }); + } + /** * Express middleware to handle errors during request processing. * diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts index da7fcfe3f9..3b50515742 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, @@ -121,6 +121,7 @@ const rootHttpRouterServiceFactoryWithOptions = ( 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/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts b/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts new file mode 100644 index 0000000000..684c204cb8 --- /dev/null +++ b/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts @@ -0,0 +1,68 @@ +/* + * 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.ts'; +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', () => { + afterEach(jest.clearAllMocks); + + it('should return undefined store with auto configuration if redis is not available', () => { + const config = mockServices.rootConfig({ + data: { + backend: { + rateLimit: { + store: undefined, + }, + }, + }, + }); + const store = RateLimitStoreFactory.create({ config }); + expect(store).toBeUndefined(); + }); + + it('should return redis store if configured explicitly', async () => { + const config = mockServices.rootConfig({ + data: { + backend: { + rateLimit: { + store: { + type: 'redis', + connection: 'redis://localhost:6379', + }, + }, + }, + }, + }); + 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 new file mode 100644 index 0000000000..9df6f03225 --- /dev/null +++ b/packages/backend-defaults/src/lib/RateLimitStoreFactory.ts @@ -0,0 +1,58 @@ +/* + * 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 { RedisStore } from 'rate-limit-redis'; + +/** + * Creates a store for `express-rate-limit` based on the configuration. + * + * @internal + */ +export class RateLimitStoreFactory { + static create(options: { + config: Config; + prefix?: string; + }): Store | undefined { + const { config, prefix } = options; + const store = config.getOptionalConfig('backend.rateLimit.store'); + if (!store) { + return undefined; + } + const type = store.getString('type'); + switch (type) { + case 'redis': + return this.redis({ store, prefix }); + case 'memory': + default: + return undefined; + } + } + + 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); + }, + }); + } +} 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, + }); +}; 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-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/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/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); diff --git a/packages/yarn-plugin/README.md b/packages/yarn-plugin/README.md index 073669b9a5..5c112506e3 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._ diff --git a/packages/yarn-plugin/package.json b/packages/yarn-plugin/package.json index 978f62fc85..5195bf5fdc 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/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; ``` 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..ae575a8b02 --- /dev/null +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.test.ts @@ -0,0 +1,119 @@ +/* + * 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 { getPackageVersion } from '../util'; +import { afterWorkspaceDependencyAddition } from './afterWorkspaceDependencyAddition'; + +jest.mock('../util', () => ({ + getPackageVersion: jest.fn(), +})); + +describe('afterWorkspaceDependencyAddition', () => { + 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 = { + scope: 'backstage', + name: 'test-package', + range: '^1.0.0', + descriptorHash: {} as DescriptorHash, + identHash: {} as IdentHash, + }; + + mockGetPackageVersion.mockImplementation(() => Promise.resolve('success')); + + await afterWorkspaceDependencyAddition( + workspace, + target, + input, + strategies, + ); + + 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 () => { + 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'); + expect(mockGetPackageVersion).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts new file mode 100644 index 0000000000..219830a8c9 --- /dev/null +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyAddition.ts @@ -0,0 +1,46 @@ +/* + * 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, 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, + _target: suggestUtils.Target, + descriptor: Descriptor, + _strategies: Array, +) => { + const descriptorRange = structUtils.parseRange(descriptor.range); + + if ( + descriptor.scope === 'backstage' && + descriptorRange.protocol !== PROTOCOL + ) { + try { + await getPackageVersion(descriptor, workspace.project.configuration); + console.info( + `Setting ${descriptor.scope}/${descriptor.name} to ${PROTOCOL}^`, + ); + 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 new file mode 100644 index 0000000000..7bf9c77554 --- /dev/null +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { getPackageVersion } from '../util'; +import { afterWorkspaceDependencyReplacement } from './afterWorkspaceDependencyReplacement'; + +jest.mock('../util', () => ({ + getPackageVersion: jest.fn(), +})); + +describe('afterWorkspaceDependencyReplacement.test', () => { + 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 = { + 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.resolve('success')); + + 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 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', + 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'); + expect(mockGetPackageVersion).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts new file mode 100644 index 0000000000..55a11086fd --- /dev/null +++ b/packages/yarn-plugin/src/handlers/afterWorkspaceDependencyReplacement.ts @@ -0,0 +1,45 @@ +/* + * 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, 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, + _target: suggestUtils.Target, + _fromDescriptor: Descriptor, + toDescriptor: Descriptor, +) => { + const toDescriptorRange = structUtils.parseRange(toDescriptor.range); + + if ( + toDescriptor.scope === 'backstage' && + toDescriptorRange.protocol !== PROTOCOL + ) { + try { + await getPackageVersion(toDescriptor, workspace.project.configuration); + 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 + // or otherwise the plugin won't be able to resolve the real version + // and we should not warn them. + } + } +}; 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/plugins/catalog-backend-module-ldap/config.d.ts b/plugins/catalog-backend-module-ldap/config.d.ts index 8bd8623891..f9b49eabee 100644 --- a/plugins/catalog-backend-module-ldap/config.d.ts +++ b/plugins/catalog-backend-module-ldap/config.d.ts @@ -141,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<{ @@ -221,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; }; }>; @@ -311,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<{ @@ -401,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; }; }>; /** @@ -556,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<{ @@ -588,6 +588,7 @@ export interface Config { pagePause?: boolean; }; }; + /** * JSON paths (on a.b.c form) and hard coded values to set on those * paths. @@ -636,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; }; }>; @@ -726,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<{ @@ -816,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; }; }>; @@ -926,7 +927,6 @@ export interface Config { * 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 }; /** @@ -969,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; }; }; @@ -1006,6 +1006,7 @@ export interface Config { }; }; /** + * @default false * JSON paths (on a.b.c form) and hard coded values to set on those * paths. * @@ -1058,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/report.api.md b/plugins/catalog-backend-module-ldap/report.api.md index 16546cb773..8357a9ae38 100644 --- a/plugins/catalog-backend-module-ldap/report.api.md +++ b/plugins/catalog-backend-module-ldap/report.api.md @@ -63,8 +63,8 @@ export type GroupConfig = { displayName: string; email?: string; picture?: string; - memberOf: string; - members: string; + memberOf: string | null; + members: string | null; }; }; @@ -259,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 6fdf682b42..8c7fd0dda5 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/config.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/config.ts @@ -90,6 +90,7 @@ export type UserConfig = { // Only the scope, filter, attributes, and paged fields are supported. The // default is scope "one" and attributes "*" and "+". options: SearchOptions; + // 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 @@ -114,7 +115,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; }; }; @@ -129,6 +130,7 @@ export type GroupConfig = { // The search options to use. // Only the scope, filter, attributes, and paged fields are supported. options: SearchOptions; + // 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 @@ -156,10 +158,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 4e4f4f7cfd..374cc2afd0 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,53 @@ 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: {}, + + map: { + rdn: 'uid', + name: 'uid', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + memberOf: null, + }, + }, + ]; + + 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 +776,51 @@ 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: {}, + map: { + rdn: 'cn', + name: 'cn', + description: 'description', + displayName: 'cn', + email: 'mail', + picture: 'avatarUrl', + type: 'tt', + memberOf: 'memberOf', + members: null, + }, + }, + ]; + + 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..a3f3ce1e75 100644 --- a/plugins/catalog-backend-module-ldap/src/ldap/read.ts +++ b/plugins/catalog-backend-module-ldap/src/ldap/read.ts @@ -146,6 +146,7 @@ export async function readLdapUsers( mapReferencesAttr(user, vendor, map.memberOf, (myDn, vs) => { ensureItems(userMemberOf, myDn, vs); }); + entities.push(entity); }); } @@ -277,6 +278,7 @@ 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); }); @@ -349,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) { 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; 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/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; } diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index 2aa4ee9b2d..527009037d 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -97,13 +97,14 @@ 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, + attributes: { + templateSteps: sortedManifest?.steps?.length ?? 0, + }, }); }, - [onCreate, analytics, templateName, 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]); }; 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, - }), + ), ); }); diff --git a/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPageContextMenu.tsx b/plugins/scaffolder/src/alpha/components/TemplateWizardPage/TemplateWizardPageContextMenu.tsx index 9a912f265f..c15795de2c 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,7 +83,7 @@ export function TemplateWizardPageContextMenu( transformOrigin={{ vertical: 'top', horizontal: 'right' }} > - window.open(editUrl, '_blank')}> + diff --git a/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx b/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx index 704d39a8fd..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'; @@ -29,11 +28,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 +46,8 @@ type ContextMenuProps = { onStartOver?: () => void; onToggleLogs?: (state: boolean) => void; onToggleButtonBar?: (state: boolean) => void; - taskId?: string; + isCancelButtonDisabled: boolean; + onCancel: () => void; }; const useStyles = makeStyles(() => ({ @@ -70,27 +67,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 +154,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} -
+ ); -}; +} diff --git a/yarn.lock b/yarn.lock index 3bc412b836..b35d626c22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3602,6 +3602,7 @@ __metadata: cron: "npm:^3.0.0" express: "npm:^4.17.1" express-promise-router: "npm:^4.1.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" @@ -3624,6 +3625,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" @@ -9667,9 +9669,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 +9683,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 +9697,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 +9711,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 +9725,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 +9739,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 +9753,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 +9767,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 +9781,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 +9795,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 +9809,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 +9823,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 +9837,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 +9851,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 +9865,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 +9879,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 +9893,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 +9914,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 +9935,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 +9949,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 +9963,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 +9977,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 +9991,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 @@ -12414,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" @@ -12424,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 @@ -13381,10 +13383,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 +13399,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 +13442,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 +13591,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 +13643,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 +13657,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 +13676,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 @@ -15136,13 +15138,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 @@ -22439,9 +22441,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 @@ -24036,7 +24038,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: @@ -25748,7 +25750,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 +25761,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" @@ -29468,8 +29481,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 +29491,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 @@ -30327,34 +30340,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 +30421,7 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 10/227ffe9b31f0b184a0b0a0210bb9d32b2b115b8c5c9b09f08db2c3928cb470fc55a22dbba3c2894365d3abcc62c2089b85638be96a20691d1234d31990ea01b2 + checksum: 10/0fa4c3b42c6ddf1a008e75a4bb3dcab08ce22ac0b31dd59dc01f7fe8e21380bfaec07a2fe3730a7cf430da5a30142d016714b358666325a4733547afa42be405 languageName: node linkType: hard @@ -31437,6 +31450,15 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^7.5.0": + version: 7.5.0 + resolution: "express-rate-limit@npm:7.5.0" + peerDependencies: + express: ^4.11 || 5 || ^5.0.0-beta.1 + checksum: 10/eff34c83bf586789933a332a339b66649e2cca95c8e977d193aa8bead577d3182ac9f0e9c26f39389287539b8038890ff023f910b54ebb506a26a2ce135b92ca + 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" @@ -39670,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" @@ -39698,7 +39720,7 @@ __metadata: optional: true bin: msw: cli/index.js - checksum: 10/7579a8dccb8cc8eb0f13d0bf3a232a3d50a478511d95bc2a4b70778e78ffa28fd0949a855fbceb6ec381bbf76f6331a6d36fcb4a3197ff5bf55d0f14a7f3b35c + checksum: 10/bc90bc34a0b9e978e662f33fa630a0de66c4b6eff3a92b41efa08bf67d79b43e4a961a17b0d274580393c912f4ac6226c76a8bc33148799b1ee43477df712c26 languageName: node linkType: hard @@ -40763,20 +40785,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 @@ -42152,27 +42175,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 @@ -43414,6 +43437,15 @@ __metadata: languageName: node linkType: hard +"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/c36c50cfca992cbd14c97c08bb01c7d1d340d27b5fa1a2c1154275fec0b59c187eb6fb207da3d035c7c87e7629c92324cd53074f854dbb16548faf94f5a41351 + languageName: node + linkType: hard + "rate-limiter-flexible@npm:^4.0.1": version: 4.0.1 resolution: "rate-limiter-flexible@npm:4.0.1" @@ -50644,8 +50676,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"