diff --git a/.changeset/fair-seals-fry.md b/.changeset/fair-seals-fry.md
new file mode 100644
index 0000000000..09b8970357
--- /dev/null
+++ b/.changeset/fair-seals-fry.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-app-api': patch
+---
+
+Moved over implementation of the root HTTP service from `@backstage/backend-common`, and replaced the `middleware` option with a `configure` callback option.
diff --git a/.changeset/kind-badgers-know.md b/.changeset/kind-badgers-know.md
new file mode 100644
index 0000000000..e01351cb5e
--- /dev/null
+++ b/.changeset/kind-badgers-know.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-tasks': patch
+---
+
+Minor internal refactor to avoid import cycle issue.
diff --git a/.changeset/orange-spoons-approve.md b/.changeset/orange-spoons-approve.md
new file mode 100644
index 0000000000..96b1eda6fc
--- /dev/null
+++ b/.changeset/orange-spoons-approve.md
@@ -0,0 +1,5 @@
+---
+'@backstage/backend-common': patch
+---
+
+Refactor to rely on `@backstage/backend-app-api` for the implementation of `createServiceBuilder`.
diff --git a/packages/backend-app-api/api-report.md b/packages/backend-app-api/api-report.md
index a402150755..cc84309a2f 100644
--- a/packages/backend-app-api/api-report.md
+++ b/packages/backend-app-api/api-report.md
@@ -3,10 +3,17 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
+///
+
import { BackendFeature } from '@backstage/backend-plugin-api';
+import { Config } from '@backstage/config';
import { ConfigService } from '@backstage/backend-plugin-api';
+import { CorsOptions } from 'cors';
+import { ErrorRequestHandler } from 'express';
+import { Express as Express_2 } from 'express';
import { ExtensionPoint } from '@backstage/backend-plugin-api';
-import { Handler } from 'express';
+import { HelmetOptions } from 'helmet';
+import * as http from 'http';
import { HttpRouterService } from '@backstage/backend-plugin-api';
import { LifecycleService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
@@ -14,6 +21,8 @@ import { PermissionsService } from '@backstage/backend-plugin-api';
import { PluginCacheManager } from '@backstage/backend-common';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
+import { RequestHandler } from 'express';
+import { RequestListener } from 'http';
import { RootHttpRouterService } from '@backstage/backend-plugin-api';
import { RootLifecycleService } from '@backstage/backend-plugin-api';
import { RootLoggerService } from '@backstage/backend-plugin-api';
@@ -44,6 +53,15 @@ export const configFactory: (
options?: undefined,
) => ServiceFactory;
+// @public
+export function createHttpServer(
+ listener: RequestListener,
+ options: HttpServerOptions,
+ deps: {
+ logger: LoggerService;
+ },
+): Promise;
+
// @public (undocumented)
export function createSpecializedBackend(
options: CreateSpecializedBackendOptions,
@@ -65,6 +83,16 @@ export const discoveryFactory: (
options?: undefined,
) => ServiceFactory;
+// @public
+export interface ExtendedHttpServer extends http.Server {
+ // (undocumented)
+ port(): number;
+ // (undocumented)
+ start(): Promise;
+ // (undocumented)
+ stop(): Promise;
+}
+
// @public (undocumented)
export const httpRouterFactory: (
options?: HttpRouterFactoryOptions | undefined,
@@ -75,6 +103,29 @@ export type HttpRouterFactoryOptions = {
getPath(pluginId: string): string;
};
+// @public
+export type HttpServerCertificateOptions =
+ | {
+ type: 'plain';
+ key: string;
+ cert: string;
+ }
+ | {
+ type: 'generated';
+ hostname: string;
+ };
+
+// @public
+export type HttpServerOptions = {
+ listen: {
+ port: number;
+ host: string;
+ };
+ https?: {
+ certificate: HttpServerCertificateOptions;
+ };
+};
+
// @public
export const lifecycleFactory: (
options?: undefined,
@@ -85,11 +136,61 @@ export const loggerFactory: (
options?: undefined,
) => ServiceFactory;
+// @public
+export class MiddlewareFactory {
+ compression(): RequestHandler;
+ cors(): RequestHandler;
+ static create(options: MiddlewareFactoryOptions): MiddlewareFactory;
+ error(options?: MiddlewareFactoryErrorOptions): ErrorRequestHandler;
+ helmet(): RequestHandler;
+ logging(): RequestHandler;
+ notFound(): RequestHandler;
+}
+
+// @public
+export interface MiddlewareFactoryErrorOptions {
+ logAllErrors?: boolean;
+ showStackTraces?: boolean;
+}
+
+// @public
+export interface MiddlewareFactoryOptions {
+ // (undocumented)
+ config: ConfigService;
+ // (undocumented)
+ logger: LoggerService;
+}
+
// @public (undocumented)
export const permissionsFactory: (
options?: undefined,
) => ServiceFactory;
+// @public
+export function readCorsOptions(config?: Config): CorsOptions;
+
+// @public
+export function readHelmetOptions(config?: Config): HelmetOptions;
+
+// @public
+export function readHttpServerOptions(config?: Config): HttpServerOptions;
+
+// @public (undocumented)
+export interface RootHttpRouterConfigureOptions {
+ // (undocumented)
+ app: Express_2;
+ // (undocumented)
+ config: ConfigService;
+ // (undocumented)
+ lifecycle: LifecycleService;
+ // (undocumented)
+ logger: LoggerService;
+ // (undocumented)
+ middleware: MiddlewareFactory;
+ // (undocumented)
+ routes: RequestHandler;
+}
+
// @public (undocumented)
export const rootHttpRouterFactory: (
options?: RootHttpRouterFactoryOptions | undefined,
@@ -98,7 +199,7 @@ export const rootHttpRouterFactory: (
// @public (undocumented)
export type RootHttpRouterFactoryOptions = {
indexPath?: string | false;
- middleware?: Handler[];
+ configure?(options: RootHttpRouterConfigureOptions): void;
};
// @public
diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json
index 80598ec93d..ed1d2bca40 100644
--- a/packages/backend-app-api/package.json
+++ b/packages/backend-app-api/package.json
@@ -36,15 +36,34 @@
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
+ "@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
+ "@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
+ "compression": "^1.7.4",
+ "cors": "^2.8.5",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
+ "fs-extra": "10.1.0",
+ "helmet": "^6.0.0",
+ "minimatch": "^5.0.0",
+ "morgan": "^1.10.0",
+ "node-forge": "^1.3.1",
+ "selfsigned": "^2.0.0",
+ "stoppable": "^1.1.0",
"winston": "^3.2.1"
},
"devDependencies": {
- "@backstage/cli": "workspace:^"
+ "@backstage/cli": "workspace:^",
+ "@types/compression": "^1.7.0",
+ "@types/fs-extra": "^9.0.3",
+ "@types/http-errors": "^2.0.0",
+ "@types/morgan": "^1.9.0",
+ "@types/node-forge": "^1.3.0",
+ "@types/stoppable": "^1.1.0",
+ "http-errors": "^2.0.0",
+ "supertest": "^6.1.3"
},
"files": [
"dist",
diff --git a/packages/backend-app-api/src/http/MiddlewareFactory.test.ts b/packages/backend-app-api/src/http/MiddlewareFactory.test.ts
new file mode 100644
index 0000000000..64812564ca
--- /dev/null
+++ b/packages/backend-app-api/src/http/MiddlewareFactory.test.ts
@@ -0,0 +1,213 @@
+/*
+ * Copyright 2020 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 {
+ AuthenticationError,
+ ConflictError,
+ InputError,
+ NotAllowedError,
+ NotFoundError,
+ NotModifiedError,
+} from '@backstage/errors';
+import express from 'express';
+import createError from 'http-errors';
+import request from 'supertest';
+import { MiddlewareFactory } from './MiddlewareFactory';
+import { ConfigReader } from '@backstage/config';
+
+describe('MiddlewareFactory', () => {
+ describe('middleware.error', () => {
+ const childLogger = {
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ child: jest.fn(),
+ };
+
+ const logger = {
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ child: () => childLogger,
+ };
+
+ const middleware = MiddlewareFactory.create({
+ logger,
+ config: new ConfigReader({}),
+ });
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('gives default code and message', async () => {
+ const app = express();
+ app.use('/breaks', () => {
+ throw new Error('some message');
+ });
+ app.use(middleware.error());
+
+ const response = await request(app).get('/breaks');
+
+ expect(response.status).toBe(500);
+ expect(response.body).toEqual({
+ error: expect.objectContaining({
+ name: 'Error',
+ message: 'some message',
+ }),
+ request: { method: 'GET', url: '/breaks' },
+ response: { statusCode: 500 },
+ });
+ });
+
+ it('does not try to send the response again if its already been sent', async () => {
+ const app = express();
+ const mockSend = jest.fn();
+
+ app.use('/works_with_async_fail', (_, res) => {
+ res.status(200).send('hello');
+
+ // mutate the response object to test the middleware.
+ // it's hard to catch errors inside middleware from the outside.
+ res.send = mockSend;
+ throw new Error('some message');
+ });
+
+ app.use(middleware.error());
+ const response = await request(app).get('/works_with_async_fail');
+
+ expect(response.status).toBe(200);
+ expect(response.text).toBe('hello');
+
+ expect(mockSend).not.toHaveBeenCalled();
+ });
+
+ it('takes code from http-errors library errors', async () => {
+ const app = express();
+ app.use('/breaks', () => {
+ throw createError(432, 'Some Message');
+ });
+ app.use(middleware.error());
+
+ const response = await request(app).get('/breaks');
+
+ expect(response.status).toBe(432);
+ expect(response.body).toEqual({
+ error: {
+ expose: true,
+ name: 'BadRequestError',
+ message: 'Some Message',
+ status: 432,
+ statusCode: 432,
+ },
+ request: {
+ method: 'GET',
+ url: '/breaks',
+ },
+ response: { statusCode: 432 },
+ });
+ });
+
+ it('handles well-known error classes', async () => {
+ const app = express();
+ app.use('/NotModifiedError', () => {
+ throw new NotModifiedError();
+ });
+ app.use('/InputError', () => {
+ throw new InputError();
+ });
+ app.use('/AuthenticationError', () => {
+ throw new AuthenticationError();
+ });
+ app.use('/NotAllowedError', () => {
+ throw new NotAllowedError();
+ });
+ app.use('/NotFoundError', () => {
+ throw new NotFoundError();
+ });
+ app.use('/ConflictError', () => {
+ throw new ConflictError();
+ });
+ app.use(middleware.error());
+
+ const r = request(app);
+ expect((await r.get('/NotModifiedError')).status).toBe(304);
+ expect((await r.get('/InputError')).status).toBe(400);
+ expect((await r.get('/InputError')).body.error.name).toBe('InputError');
+ expect((await r.get('/AuthenticationError')).status).toBe(401);
+ expect((await r.get('/AuthenticationError')).body.error.name).toBe(
+ 'AuthenticationError',
+ );
+ expect((await r.get('/NotAllowedError')).status).toBe(403);
+ expect((await r.get('/NotAllowedError')).body.error.name).toBe(
+ 'NotAllowedError',
+ );
+ expect((await r.get('/NotFoundError')).status).toBe(404);
+ expect((await r.get('/NotFoundError')).body.error.name).toBe(
+ 'NotFoundError',
+ );
+ expect((await r.get('/ConflictError')).status).toBe(409);
+ expect((await r.get('/ConflictError')).body.error.name).toBe(
+ 'ConflictError',
+ );
+ });
+
+ it('logs all 500 errors', async () => {
+ const app = express();
+ const thrownError = new Error('some error');
+
+ app.use('/breaks', () => {
+ throw thrownError;
+ });
+ app.use(middleware.error());
+
+ await request(app).get('/breaks');
+
+ expect(childLogger.error).toHaveBeenCalledWith(
+ 'Request failed with status 500',
+ thrownError,
+ );
+ });
+
+ it('does not log 400 errors', async () => {
+ const app = express();
+
+ app.use('/NotFound', () => {
+ throw new NotFoundError();
+ });
+ app.use(middleware.error());
+
+ await request(app).get('/NotFound');
+
+ expect(childLogger.error).not.toHaveBeenCalled();
+ });
+
+ it('log 400 errors when logAllErrors is true', async () => {
+ const app = express();
+
+ app.use('/NotFound', () => {
+ throw new NotFoundError();
+ });
+ app.use(middleware.error({ logAllErrors: true }));
+
+ await request(app).get('/NotFound');
+
+ expect(childLogger.error).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/backend-app-api/src/http/MiddlewareFactory.ts b/packages/backend-app-api/src/http/MiddlewareFactory.ts
new file mode 100644
index 0000000000..dc9fe9aa5f
--- /dev/null
+++ b/packages/backend-app-api/src/http/MiddlewareFactory.ts
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2023 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 { ConfigService, LoggerService } from '@backstage/backend-plugin-api';
+import {
+ Request,
+ Response,
+ ErrorRequestHandler,
+ NextFunction,
+ RequestHandler,
+} from 'express';
+import cors from 'cors';
+import helmet from 'helmet';
+import morgan from 'morgan';
+import compression from 'compression';
+import { readHelmetOptions } from './readHelmetOptions';
+import { readCorsOptions } from './readCorsOptions';
+import {
+ AuthenticationError,
+ ConflictError,
+ ErrorResponseBody,
+ InputError,
+ NotAllowedError,
+ NotFoundError,
+ NotModifiedError,
+ serializeError,
+} from '@backstage/errors';
+
+/**
+ * Options used to create a {@link MiddlewareFactory}.
+ *
+ * @public
+ */
+export interface MiddlewareFactoryOptions {
+ config: ConfigService;
+ logger: LoggerService;
+}
+
+/**
+ * Options passed to the {@link MiddlewareFactory.error} middleware.
+ *
+ * @public
+ */
+export interface MiddlewareFactoryErrorOptions {
+ /**
+ * Whether error response bodies should show error stack traces or not.
+ *
+ * If not specified, by default shows stack traces only in development mode.
+ */
+ showStackTraces?: boolean;
+
+ /**
+ * Whether any 4xx errors should be logged or not.
+ *
+ * If not specified, default to only logging 5xx errors.
+ */
+ logAllErrors?: boolean;
+}
+
+/**
+ * A utility to configure common middleware.
+ *
+ * @public
+ */
+export class MiddlewareFactory {
+ #config: ConfigService;
+ #logger: LoggerService;
+
+ /**
+ * Creates a new {@link MiddlewareFactory}.
+ */
+ static create(options: MiddlewareFactoryOptions) {
+ return new MiddlewareFactory(options);
+ }
+
+ private constructor(options: MiddlewareFactoryOptions) {
+ this.#config = options.config;
+ this.#logger = options.logger;
+ }
+
+ /**
+ * Returns a middleware that unconditionally produces a 404 error response.
+ *
+ * @remarks
+ *
+ * Typically you want to place this middleware at the end of the chain, such
+ * that it's the last one attempted after no other routes matched.
+ *
+ * @returns An Express request handler
+ */
+ notFound(): RequestHandler {
+ return (_req: Request, res: Response) => {
+ res.status(404).end();
+ };
+ }
+
+ /**
+ * Returns the compression middleware.
+ *
+ * @remarks
+ *
+ * The middleware will attempt to compress response bodies for all requests
+ * that traverse through the middleware.
+ */
+ compression(): RequestHandler {
+ return compression();
+ }
+
+ /**
+ * Returns a request logging middleware.
+ *
+ * @remarks
+ *
+ * Typically you want to place this middleware at the start of the chain, such
+ * that it always logs requests whether they are "caught" by handlers farther
+ * down or not.
+ *
+ * @returns An Express request handler
+ */
+ logging(): RequestHandler {
+ const logger = this.#logger.child({
+ type: 'incomingRequest',
+ });
+
+ return morgan('combined', {
+ stream: {
+ write(message: string) {
+ logger.info(message.trimEnd());
+ },
+ },
+ });
+ }
+
+ /**
+ * Returns a middleware that implements the helmet library.
+ *
+ * @remarks
+ *
+ * This middleware applies security policies to incoming requests and outgoing
+ * responses. It is configured using config keys such as `backend.csp`.
+ *
+ * @see {@link https://helmetjs.github.io/}
+ *
+ * @returns An Express request handler
+ */
+ helmet(): RequestHandler {
+ return helmet(readHelmetOptions(this.#config.getOptionalConfig('backend')));
+ }
+
+ /**
+ * Returns a middleware that implements the cors library.
+ *
+ * @remarks
+ *
+ * This middleware handles CORS. It is configured using the config key
+ * `backend.cors`.
+ *
+ * @see {@link https://github.com/expressjs/cors}
+ *
+ * @returns An Express request handler
+ */
+ cors(): RequestHandler {
+ return cors(readCorsOptions(this.#config.getOptionalConfig('backend')));
+ }
+
+ /**
+ * Express middleware to handle errors during request processing.
+ *
+ * @remarks
+ *
+ * This is commonly the very last middleware in the chain.
+ *
+ * Its primary purpose is not to do translation of business logic exceptions,
+ * but rather to be a global catch-all for uncaught "fatal" errors that are
+ * expected to result in a 500 error. However, it also does handle some common
+ * error types (such as http-error exceptions, and the well-known error types
+ * in the `@backstage/errors` package) and returns the enclosed status code
+ * accordingly.
+ *
+ * It will also produce a response body with a serialized form of the error,
+ * unless a previous handler already did send a body. See
+ * {@link @backstage/errors#ErrorResponseBody} for the response shape used.
+ *
+ * @returns An Express error request handler
+ */
+ error(options: MiddlewareFactoryErrorOptions = {}): ErrorRequestHandler {
+ const showStackTraces =
+ options.showStackTraces ?? process.env.NODE_ENV === 'development';
+
+ const logger = this.#logger.child({
+ type: 'errorHandler',
+ });
+
+ return (error: Error, req: Request, res: Response, next: NextFunction) => {
+ const statusCode = getStatusCode(error);
+ if (options.logAllErrors || statusCode >= 500) {
+ logger.error(`Request failed with status ${statusCode}`, error);
+ }
+
+ if (res.headersSent) {
+ // If the headers have already been sent, do not send the response again
+ // as this will throw an error in the backend.
+ next(error);
+ return;
+ }
+
+ const body: ErrorResponseBody = {
+ error: serializeError(error, { includeStack: showStackTraces }),
+ request: { method: req.method, url: req.url },
+ response: { statusCode },
+ };
+
+ res.status(statusCode).json(body);
+ };
+ }
+}
+
+function getStatusCode(error: Error): number {
+ // Look for common http library status codes
+ const knownStatusCodeFields = ['statusCode', 'status'];
+ for (const field of knownStatusCodeFields) {
+ const statusCode = (error as any)[field];
+ if (
+ typeof statusCode === 'number' &&
+ (statusCode | 0) === statusCode && // is whole integer
+ statusCode >= 100 &&
+ statusCode <= 599
+ ) {
+ return statusCode;
+ }
+ }
+
+ // Handle well-known error types
+ switch (error.name) {
+ case NotModifiedError.name:
+ return 304;
+ case InputError.name:
+ return 400;
+ case AuthenticationError.name:
+ return 401;
+ case NotAllowedError.name:
+ return 403;
+ case NotFoundError.name:
+ return 404;
+ case ConflictError.name:
+ return 409;
+ default:
+ break;
+ }
+
+ // Fall back to internal server error
+ return 500;
+}
diff --git a/packages/backend-app-api/src/http/config.test.ts b/packages/backend-app-api/src/http/config.test.ts
new file mode 100644
index 0000000000..f54ba927ca
--- /dev/null
+++ b/packages/backend-app-api/src/http/config.test.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020 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 { ConfigReader } from '@backstage/config';
+import { readHttpServerOptions } from './config';
+
+describe('readHttpServerOptions', () => {
+ it('should return defaults', () => {
+ expect(readHttpServerOptions()).toEqual({
+ listen: { host: '', port: 7007 },
+ });
+ });
+
+ it.each([
+ [{}, { listen: { host: '', port: 7007 } }],
+ [{ listen: ':80' }, { listen: { host: '', port: 80 } }],
+ [{ listen: '80' }, { listen: { host: '', port: 80 } }],
+ [{ listen: '1.2.3.4:80' }, { listen: { host: '1.2.3.4', port: 80 } }],
+ [{ listen: { host: '' } }, { listen: { host: '', port: 7007 } }],
+ [
+ { listen: { host: '0.0.0.0' } },
+ { listen: { host: '0.0.0.0', port: 7007 } },
+ ],
+ [
+ { listen: { host: '0.0.0.0', port: '80' } },
+ { listen: { host: '0.0.0.0', port: 80 } },
+ ],
+ [{ listen: { port: '80' } }, { listen: { host: '', port: 80 } }],
+ [{ listen: { port: 80 } }, { listen: { host: '', port: 80 } }],
+ [{ listen: { port: 80 } }, { listen: { host: '', port: 80 } }],
+ [
+ { baseUrl: 'http://example.com:8080', https: true },
+ {
+ listen: { host: '', port: 7007 },
+ https: { certificate: { type: 'generated', hostname: 'example.com' } },
+ },
+ ],
+ [
+ { https: { certificate: { cert: 'my-cert', key: 'my-key' } } },
+ {
+ listen: { host: '', port: 7007 },
+ https: {
+ certificate: { type: 'plain', cert: 'my-cert', key: 'my-key' },
+ },
+ },
+ ],
+ ])('should read http server options %#', (input, output) => {
+ expect(readHttpServerOptions(new ConfigReader(input))).toEqual(output);
+ });
+
+ it.each([
+ [
+ { listen: { port: 'not-a-number' } },
+ "Unable to convert config value for key 'listen.port' in 'mock-config' to a number",
+ ],
+ [
+ { listen: { port: {} } },
+ "Invalid type in config for key 'listen.port' in 'mock-config', got object, wanted number",
+ ],
+ [
+ { listen: { host: false } },
+ "Invalid type in config for key 'listen.host' in 'mock-config', got boolean, wanted string",
+ ],
+ [{ https: {} }, "Missing required config value at 'https.certificate.cert"],
+ [
+ { https: { certificate: { cert: 'x' } } },
+ "Missing required config value at 'https.certificate.key",
+ ],
+ ])('should throw on bad options %#', (input, message) => {
+ expect(() => readHttpServerOptions(new ConfigReader(input))).toThrow(
+ message,
+ );
+ });
+});
diff --git a/packages/backend-app-api/src/http/config.ts b/packages/backend-app-api/src/http/config.ts
new file mode 100644
index 0000000000..e3b41c63de
--- /dev/null
+++ b/packages/backend-app-api/src/http/config.ts
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2023 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 { HttpServerOptions } from './types';
+
+const DEFAULT_PORT = 7007;
+const DEFAULT_HOST = '';
+
+/**
+ * Reads {@link HttpServerOptions} from a {@link @backstage/config#Config} object.
+ *
+ * @public
+ * @remarks
+ *
+ * The provided configuration object should contain the `listen` and
+ * additional keys directly.
+ *
+ * @example
+ * ```ts
+ * const opts = readHttpServerOptions(config.getConfig('backend'));
+ * ```
+ */
+export function readHttpServerOptions(config?: Config): HttpServerOptions {
+ return {
+ listen: readHttpListenOptions(config),
+ https: readHttpsOptions(config),
+ };
+}
+
+function readHttpListenOptions(config?: Config): HttpServerOptions['listen'] {
+ const listen = config?.getOptional('listen');
+ if (typeof listen === 'string') {
+ const parts = String(listen).split(':');
+ const port = parseInt(parts[parts.length - 1], 10);
+ if (!isNaN(port)) {
+ if (parts.length === 1) {
+ return { port, host: DEFAULT_HOST };
+ }
+ if (parts.length === 2) {
+ return { host: parts[0], port };
+ }
+ }
+ throw new Error(
+ `Unable to parse listen address ${listen}, expected or :`,
+ );
+ }
+
+ // Workaround to allow empty string
+ const host = config?.getOptional('listen.host') ?? DEFAULT_HOST;
+ if (typeof host !== 'string') {
+ config?.getOptionalString('listen.host'); // will throw
+ throw new Error('unreachable');
+ }
+
+ return {
+ port: config?.getOptionalNumber('listen.port') ?? DEFAULT_PORT,
+ host,
+ };
+}
+
+function readHttpsOptions(config?: Config): HttpServerOptions['https'] {
+ const https = config?.getOptional('https');
+ if (https === true) {
+ const baseUrl = config!.getString('baseUrl');
+ let hostname;
+ try {
+ hostname = new URL(baseUrl).hostname;
+ } catch (error) {
+ throw new Error(`Invalid baseUrl "${baseUrl}"`);
+ }
+
+ return { certificate: { type: 'generated', hostname } };
+ }
+
+ const cc = config?.getOptionalConfig('https');
+ if (!cc) {
+ return undefined;
+ }
+
+ return {
+ certificate: {
+ type: 'plain',
+ cert: cc.getString('certificate.cert'),
+ key: cc.getString('certificate.key'),
+ },
+ };
+}
diff --git a/packages/backend-app-api/src/http/createHttpServer.ts b/packages/backend-app-api/src/http/createHttpServer.ts
new file mode 100644
index 0000000000..5c526ba1e2
--- /dev/null
+++ b/packages/backend-app-api/src/http/createHttpServer.ts
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2020 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 * as http from 'http';
+import * as https from 'https';
+import stoppableServer from 'stoppable';
+import { RequestListener } from 'http';
+import { LoggerService } from '@backstage/backend-plugin-api';
+import { HttpServerOptions, ExtendedHttpServer } from './types';
+import { getGeneratedCertificate } from './getGeneratedCertificate';
+
+/**
+ * Creates a Node.js HTTP or HTTPS server instance.
+ *
+ * @public
+ */
+export async function createHttpServer(
+ listener: RequestListener,
+ options: HttpServerOptions,
+ deps: { logger: LoggerService },
+): Promise {
+ const server = await createServer(listener, options, deps);
+
+ const stopper = stoppableServer(server, 0);
+ // The stopper here is actually the server itself, so if we try
+ // to call stopper.stop() down in the stop implementation, we'll
+ // be calling ourselves.
+ const stopServer = stopper.stop.bind(stopper);
+
+ return Object.assign(server, {
+ start() {
+ return new Promise((resolve, reject) => {
+ const handleStartupError = (error: Error) => {
+ server.close();
+ reject(error);
+ };
+
+ server.on('error', handleStartupError);
+
+ const { host, port } = options.listen;
+ server.listen(port, host, () => {
+ server.off('error', handleStartupError);
+ deps.logger.info(`Listening on ${host}:${port}`);
+ resolve();
+ });
+ });
+ },
+
+ stop() {
+ return new Promise((resolve, reject) => {
+ stopServer((error?: Error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve();
+ }
+ });
+ });
+ },
+
+ port() {
+ const address = server.address();
+ if (typeof address === 'string' || address === null) {
+ throw new Error(`Unexpected server address '${address}'`);
+ }
+ return address.port;
+ },
+ });
+}
+
+async function createServer(
+ listener: RequestListener,
+ options: HttpServerOptions,
+ deps: { logger: LoggerService },
+): Promise {
+ if (options.https) {
+ const { certificate } = options.https;
+ if (certificate.type === 'generated') {
+ const credentials = await getGeneratedCertificate(
+ certificate.hostname,
+ deps.logger,
+ );
+ return https.createServer(credentials, listener);
+ }
+ return https.createServer(certificate, listener);
+ }
+
+ return http.createServer(listener);
+}
diff --git a/packages/backend-common/src/service/lib/hostFactory.ts b/packages/backend-app-api/src/http/getGeneratedCertificate.ts
similarity index 55%
rename from packages/backend-common/src/service/lib/hostFactory.ts
rename to packages/backend-app-api/src/http/getGeneratedCertificate.ts
index db4343cae5..b5cd420aff 100644
--- a/packages/backend-common/src/service/lib/hostFactory.ts
+++ b/packages/backend-app-api/src/http/getGeneratedCertificate.ts
@@ -16,86 +16,16 @@
import fs from 'fs-extra';
import { resolve as resolvePath, dirname } from 'path';
-import express from 'express';
-import * as http from 'http';
-import * as https from 'https';
import { LoggerService } from '@backstage/backend-plugin-api';
-import { HttpsSettings } from './config';
import forge from 'node-forge';
const FIVE_DAYS_IN_MS = 5 * 24 * 60 * 60 * 1000;
const IP_HOSTNAME_REGEX = /:|^\d+\.\d+\.\d+\.\d+$/;
-/**
- * Creates a Http server instance based on an Express application.
- *
- * @param app - The Express application object
- * @param logger - Optional Winston logger object
- * @returns A Http server instance
- *
- */
-export function createHttpServer(
- app: express.Express,
- logger?: LoggerService,
-): http.Server {
- logger?.info('Initializing http server');
-
- return http.createServer(app);
-}
-
-/**
- * Creates a Https server instance based on an Express application.
- *
- * @param app - The Express application object
- * @param httpsSettings - HttpsSettings for self-signed certificate generation
- * @param logger - Optional Winston logger object
- * @returns A Https server instance
- *
- */
-export async function createHttpsServer(
- app: express.Express,
- httpsSettings: HttpsSettings,
- logger?: LoggerService,
-): Promise {
- logger?.info('Initializing https server');
-
- let credentials: { key: string | Buffer; cert: string | Buffer };
-
- if ('hostname' in httpsSettings?.certificate) {
- credentials = await getGeneratedCertificate(
- httpsSettings.certificate.hostname,
- logger,
- );
- } else {
- logger?.info('Loading certificate from config');
-
- credentials = {
- key: httpsSettings?.certificate?.key,
- cert: httpsSettings?.certificate?.cert,
- };
- }
-
- if (!credentials.key || !credentials.cert) {
- throw new Error('Invalid HTTPS credentials');
- }
-
- return https.createServer(credentials, app) as http.Server;
-}
-
-function getCertificateExpiration(cert: string, logger?: LoggerService) {
- try {
- const crt = forge.pki.certificateFromPem(cert);
- return crt.validity.notAfter.getTime() - Date.now();
- } catch (error) {
- logger?.warn(`Unable to parse self-signed certificate. ${error}`);
- return 0;
- }
-}
-
-async function getGeneratedCertificate(
+export async function getGeneratedCertificate(
hostname: string,
- logger?: LoggerService,
+ logger: LoggerService,
) {
const hasModules = await fs.pathExists('node_modules');
let certPath;
@@ -109,24 +39,30 @@ async function getGeneratedCertificate(
}
if (await fs.pathExists(certPath)) {
- const cert = await fs.readFile(certPath);
- const remainingMs = getCertificateExpiration(cert.toString(), logger);
- if (remainingMs > FIVE_DAYS_IN_MS) {
- logger?.info('Using existing self-signed certificate');
- return {
- key: cert,
- cert,
- };
+ try {
+ const cert = await fs.readFile(certPath);
+
+ const crt = forge.pki.certificateFromPem(cert.toString());
+ const remainingMs = crt.validity.notAfter.getTime() - Date.now();
+ if (remainingMs > FIVE_DAYS_IN_MS) {
+ logger.info('Using existing self-signed certificate');
+ return {
+ key: cert,
+ cert,
+ };
+ }
+ } catch (error) {
+ logger.warn(`Unable to use existing self-signed certificate, ${error}`);
}
}
- logger?.info('Generating new self-signed certificate');
- const newCert = await createCertificate(hostname);
+ logger.info('Generating new self-signed certificate');
+ const newCert = await generateCertificate(hostname);
await fs.writeFile(certPath, newCert.cert + newCert.key, 'utf8');
return newCert;
}
-async function createCertificate(hostname: string) {
+async function generateCertificate(hostname: string) {
const attributes = [
{
name: 'commonName',
diff --git a/packages/backend-app-api/src/http/index.ts b/packages/backend-app-api/src/http/index.ts
new file mode 100644
index 0000000000..4a9ec14cf8
--- /dev/null
+++ b/packages/backend-app-api/src/http/index.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2023 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.
+ */
+
+export { readHttpServerOptions } from './config';
+export { createHttpServer } from './createHttpServer';
+export { MiddlewareFactory } from './MiddlewareFactory';
+export type {
+ MiddlewareFactoryErrorOptions,
+ MiddlewareFactoryOptions,
+} from './MiddlewareFactory';
+export { readCorsOptions } from './readCorsOptions';
+export { readHelmetOptions } from './readHelmetOptions';
+export type {
+ ExtendedHttpServer,
+ HttpServerCertificateOptions,
+ HttpServerOptions,
+} from './types';
diff --git a/packages/backend-app-api/src/http/readCorsOptions.test.ts b/packages/backend-app-api/src/http/readCorsOptions.test.ts
new file mode 100644
index 0000000000..af490a26f9
--- /dev/null
+++ b/packages/backend-app-api/src/http/readCorsOptions.test.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020 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 { ConfigReader } from '@backstage/config';
+import { readCorsOptions } from './readCorsOptions';
+
+describe('readCorsOptions', () => {
+ it('should be disabled by default', () => {
+ expect(readCorsOptions()).toEqual({
+ origin: false,
+ });
+ });
+
+ it('reads single string', () => {
+ const mockCallback = jest.fn();
+ const config = new ConfigReader({ cors: { origin: 'https://*.value*' } });
+ const cors = readCorsOptions(config);
+ expect(cors).toEqual(
+ expect.objectContaining({
+ origin: expect.any(Function),
+ }),
+ );
+ const origin = cors?.origin as Function;
+ origin('https://a.value', mockCallback); // valid origin
+ origin('http://a.value', mockCallback); // invalid origin
+ origin(undefined, mockCallback); // when not origin needs to reject the call
+
+ expect(mockCallback.mock.calls[0][0]).toBe(null);
+ expect(mockCallback.mock.calls[1][0]).toBe(null);
+
+ expect(mockCallback.mock.calls[0][1]).toBe(true);
+ expect(mockCallback.mock.calls[1][1]).toBe(false);
+ expect(mockCallback.mock.calls[2][1]).toBe(false);
+ });
+
+ it('reads string array', () => {
+ const mockCallback = jest.fn();
+ const config = new ConfigReader({
+ cors: {
+ origin: ['http?(s)://*.value?(-+([0-9])).com', 'http://*.value'],
+ },
+ });
+ const cors = readCorsOptions(config);
+ expect(cors).toEqual(
+ expect.objectContaining({
+ origin: expect.any(Function),
+ }),
+ );
+ const origin = cors?.origin as Function;
+ origin('https://a.b.c.value-9.com', mockCallback);
+ origin('http://a.value-999.com', mockCallback);
+ origin('http://a.value', mockCallback);
+ origin('http://a.valuex', mockCallback);
+
+ expect(mockCallback.mock.calls[0][0]).toBe(null);
+ expect(mockCallback.mock.calls[1][0]).toBe(null);
+ expect(mockCallback.mock.calls[2][0]).toBe(null);
+ expect(mockCallback.mock.calls[3][0]).toBe(null);
+
+ expect(mockCallback.mock.calls[0][1]).toBe(true);
+ expect(mockCallback.mock.calls[1][1]).toBe(true);
+ expect(mockCallback.mock.calls[2][1]).toBe(true);
+ expect(mockCallback.mock.calls[3][1]).toBe(false);
+ });
+
+ it('reads undefined origin', () => {
+ const config = new ConfigReader({
+ cors: {},
+ });
+ const cors = readCorsOptions(config);
+ expect(cors).toEqual(expect.objectContaining({}));
+ expect(cors?.origin).toBeUndefined();
+ });
+});
diff --git a/packages/backend-app-api/src/http/readCorsOptions.ts b/packages/backend-app-api/src/http/readCorsOptions.ts
new file mode 100644
index 0000000000..b58cae5ded
--- /dev/null
+++ b/packages/backend-app-api/src/http/readCorsOptions.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2023 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 { CorsOptions } from 'cors';
+import { Minimatch } from 'minimatch';
+
+/**
+ * Attempts to read a CORS options object from the backend configuration object.
+ *
+ * @public
+ * @param config - The backend configuration object.
+ * @returns A CORS options object, or undefined if no cors configuration is present.
+ *
+ * @example
+ * ```ts
+ * const corsOptions = readCorsOptions(config.getConfig('backend'));
+ * ```
+ */
+export function readCorsOptions(config?: Config): CorsOptions {
+ const cc = config?.getOptionalConfig('cors');
+ if (!cc) {
+ return { origin: false }; // Disable CORS
+ }
+
+ return {
+ origin: createCorsOriginMatcher(readStringArray(cc, 'origin')),
+ methods: readStringArray(cc, 'methods'),
+ allowedHeaders: readStringArray(cc, 'allowedHeaders'),
+ exposedHeaders: readStringArray(cc, 'exposedHeaders'),
+ credentials: cc.getOptionalBoolean('credentials'),
+ maxAge: cc.getOptionalNumber('maxAge'),
+ preflightContinue: cc.getOptionalBoolean('preflightContinue'),
+ optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'),
+ };
+}
+
+function readStringArray(config: Config, key: string): string[] | undefined {
+ const value = config.getOptional(key);
+ if (typeof value === 'string') {
+ return [value];
+ } else if (!value) {
+ return undefined;
+ }
+ return config.getStringArray(key);
+}
+
+function createCorsOriginMatcher(allowedOriginPatterns: string[] | undefined) {
+ if (!allowedOriginPatterns) {
+ return undefined;
+ }
+
+ const allowedOriginMatchers = allowedOriginPatterns.map(
+ pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
+ );
+
+ return (
+ origin: string | undefined,
+ callback: (
+ err: Error | null,
+ origin: boolean | string | RegExp | (boolean | string | RegExp)[],
+ ) => void,
+ ) => {
+ return callback(
+ null,
+ allowedOriginMatchers.some(pattern => pattern.match(origin ?? '')),
+ );
+ };
+}
diff --git a/packages/backend-app-api/src/http/readHelmetOptions.test.ts b/packages/backend-app-api/src/http/readHelmetOptions.test.ts
new file mode 100644
index 0000000000..bc31404634
--- /dev/null
+++ b/packages/backend-app-api/src/http/readHelmetOptions.test.ts
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2020 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 { ConfigReader } from '@backstage/config';
+import { readHelmetOptions } from './readHelmetOptions';
+
+describe('readHelmetOptions', () => {
+ it('should return defaults', () => {
+ expect(readHelmetOptions()).toEqual({
+ contentSecurityPolicy: {
+ useDefaults: false,
+ directives: {
+ 'default-src': ["'self'"],
+ 'base-uri': ["'self'"],
+ 'font-src': ["'self'", 'https:', 'data:'],
+ 'frame-ancestors': ["'self'"],
+ 'img-src': ["'self'", 'data:'],
+ 'object-src': ["'none'"],
+ 'script-src': ["'self'", "'unsafe-eval'"],
+ 'style-src': ["'self'", 'https:', "'unsafe-inline'"],
+ 'script-src-attr': ["'none'"],
+ 'upgrade-insecure-requests': [],
+ },
+ },
+ crossOriginEmbedderPolicy: false,
+ crossOriginOpenerPolicy: false,
+ crossOriginResourcePolicy: false,
+ originAgentCluster: false,
+ });
+ });
+
+ it('should add additional directives', () => {
+ const config = new ConfigReader({
+ csp: {
+ key: ['value'],
+ 'img-src': false,
+ 'script-src-attr': ['custom'],
+ },
+ });
+ expect(readHelmetOptions(config)).toEqual({
+ contentSecurityPolicy: {
+ useDefaults: false,
+ directives: {
+ 'default-src': ["'self'"],
+ 'base-uri': ["'self'"],
+ 'font-src': ["'self'", 'https:', 'data:'],
+ 'frame-ancestors': ["'self'"],
+ 'object-src': ["'none'"],
+ 'script-src': ["'self'", "'unsafe-eval'"],
+ 'style-src': ["'self'", 'https:', "'unsafe-inline'"],
+ 'script-src-attr': ['custom'],
+ 'upgrade-insecure-requests': [],
+ key: ['value'],
+ },
+ },
+ crossOriginEmbedderPolicy: false,
+ crossOriginOpenerPolicy: false,
+ crossOriginResourcePolicy: false,
+ originAgentCluster: false,
+ });
+ });
+
+ it('rejects invalid value types', () => {
+ const config = new ConfigReader({ csp: { key: [4] } });
+ expect(() => readHelmetOptions(config)).toThrow(/wanted string-array/);
+ });
+});
diff --git a/packages/backend-app-api/src/http/readHelmetOptions.ts b/packages/backend-app-api/src/http/readHelmetOptions.ts
new file mode 100644
index 0000000000..0555bdef00
--- /dev/null
+++ b/packages/backend-app-api/src/http/readHelmetOptions.ts
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2020 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 helmet from 'helmet';
+import { HelmetOptions } from 'helmet';
+import { ContentSecurityPolicyOptions } from 'helmet/dist/types/middlewares/content-security-policy';
+
+/**
+ * Attempts to read Helmet options from the backend configuration object.
+ *
+ * @public
+ * @param config - The backend configuration object.
+ * @returns A Helmet options object, or undefined if no Helmet configuration is present.
+ *
+ * @example
+ * ```ts
+ * const helmetOptions = readHelmetOptions(config.getConfig('backend'));
+ * ```
+ */
+export function readHelmetOptions(config?: Config): HelmetOptions {
+ const cspOptions = readCspDirectives(config);
+ return {
+ contentSecurityPolicy: {
+ useDefaults: false,
+ directives: applyCspDirectives(cspOptions),
+ },
+ // These are all disabled in order to maintain backwards compatibility
+ // when bumping helmet v5. We can't enable these by default because
+ // there is no way for users to configure them.
+ // TODO(Rugvip): We should give control of this setup to consumers
+ crossOriginEmbedderPolicy: false,
+ crossOriginOpenerPolicy: false,
+ crossOriginResourcePolicy: false,
+ originAgentCluster: false,
+ };
+}
+
+type CspDirectives = Record | undefined;
+
+/**
+ * Attempts to read a CSP directives from the backend configuration object.
+ *
+ * @example
+ * ```yaml
+ * backend:
+ * csp:
+ * connect-src: ["'self'", 'http:', 'https:']
+ * upgrade-insecure-requests: false
+ * ```
+ */
+function readCspDirectives(config?: Config): CspDirectives {
+ const cc = config?.getOptionalConfig('csp');
+ if (!cc) {
+ return undefined;
+ }
+
+ const result: Record = {};
+ for (const key of cc.keys()) {
+ if (cc.get(key) === false) {
+ result[key] = false;
+ } else {
+ result[key] = cc.getStringArray(key);
+ }
+ }
+
+ return result;
+}
+
+export function applyCspDirectives(
+ directives: CspDirectives,
+): ContentSecurityPolicyOptions['directives'] {
+ const result: ContentSecurityPolicyOptions['directives'] =
+ helmet.contentSecurityPolicy.getDefaultDirectives();
+
+ // TODO(Rugvip): We currently use non-precompiled AJV for validation in the frontend, which uses eval.
+ // It should be replaced by any other solution that doesn't require unsafe-eval.
+ result['script-src'] = ["'self'", "'unsafe-eval'"];
+
+ // TODO(Rugvip): This is removed so that we maintained backwards compatibility
+ // when bumping to helmet v5, we could remove this as well as
+ // skip setting `useDefaults: false` in the future.
+ delete result['form-action'];
+
+ if (directives) {
+ for (const [key, value] of Object.entries(directives)) {
+ if (value === false) {
+ delete result[key];
+ } else {
+ result[key] = value;
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/packages/backend-app-api/src/http/types.ts b/packages/backend-app-api/src/http/types.ts
new file mode 100644
index 0000000000..ff96f69bff
--- /dev/null
+++ b/packages/backend-app-api/src/http/types.ts
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2023 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 * as http from 'http';
+
+/**
+ * An HTTP server extended with utility methods.
+ *
+ * @public
+ */
+export interface ExtendedHttpServer extends http.Server {
+ start(): Promise;
+
+ stop(): Promise;
+
+ port(): number;
+}
+
+/**
+ * Options for starting up an HTTP server.
+ *
+ * @public
+ */
+export type HttpServerOptions = {
+ listen: {
+ port: number;
+ host: string;
+ };
+ https?: {
+ certificate: HttpServerCertificateOptions;
+ };
+};
+
+/**
+ * Options for configuring HTTPS for an HTTP server.
+ *
+ * @public
+ */
+export type HttpServerCertificateOptions =
+ | {
+ type: 'plain';
+ key: string;
+ cert: string;
+ }
+ | {
+ type: 'generated';
+ hostname: string;
+ };
diff --git a/packages/backend-app-api/src/index.ts b/packages/backend-app-api/src/index.ts
index 02633f3732..9527e35919 100644
--- a/packages/backend-app-api/src/index.ts
+++ b/packages/backend-app-api/src/index.ts
@@ -20,5 +20,6 @@
* @packageDocumentation
*/
+export * from './http';
export * from './wiring';
export * from './services/implementations';
diff --git a/packages/backend-app-api/src/services/implementations/httpRouterService.test.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterFactory.test.ts
similarity index 97%
rename from packages/backend-app-api/src/services/implementations/httpRouterService.test.ts
rename to packages/backend-app-api/src/services/implementations/httpRouter/httpRouterFactory.test.ts
index 89f6db2ac8..7717f4116e 100644
--- a/packages/backend-app-api/src/services/implementations/httpRouterService.test.ts
+++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterFactory.test.ts
@@ -18,7 +18,7 @@ import {
HttpRouterService,
ServiceFactory,
} from '@backstage/backend-plugin-api';
-import { httpRouterFactory } from './httpRouterService';
+import { httpRouterFactory } from './httpRouterFactory';
describe('httpRouterFactory', () => {
it('should register plugin paths', async () => {
diff --git a/packages/backend-app-api/src/services/implementations/httpRouterService.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterFactory.ts
similarity index 100%
rename from packages/backend-app-api/src/services/implementations/httpRouterService.ts
rename to packages/backend-app-api/src/services/implementations/httpRouter/httpRouterFactory.ts
diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/index.ts b/packages/backend-app-api/src/services/implementations/httpRouter/index.ts
new file mode 100644
index 0000000000..f2db660414
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/httpRouter/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2023 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.
+ */
+
+export { httpRouterFactory } from './httpRouterFactory';
+export type { HttpRouterFactoryOptions } from './httpRouterFactory';
diff --git a/packages/backend-app-api/src/services/implementations/index.ts b/packages/backend-app-api/src/services/implementations/index.ts
index 2365ff36f4..73c3418090 100644
--- a/packages/backend-app-api/src/services/implementations/index.ts
+++ b/packages/backend-app-api/src/services/implementations/index.ts
@@ -14,6 +14,9 @@
* limitations under the License.
*/
+export * from './httpRouter';
+export * from './rootHttpRouter';
+
export { cacheFactory } from './cacheService';
export { configFactory } from './configService';
export { databaseFactory } from './databaseService';
@@ -24,9 +27,5 @@ export { permissionsFactory } from './permissionsService';
export { schedulerFactory } from './schedulerService';
export { tokenManagerFactory } from './tokenManagerService';
export { urlReaderFactory } from './urlReaderService';
-export { httpRouterFactory } from './httpRouterService';
-export { rootHttpRouterFactory } from './rootHttpRouterService';
export { lifecycleFactory } from './lifecycleService';
export { rootLifecycleFactory } from './rootLifecycleService';
-export type { HttpRouterFactoryOptions } from './httpRouterService';
-export type { RootHttpRouterFactoryOptions } from './rootHttpRouterService';
diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.test.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.test.ts
new file mode 100644
index 0000000000..b72e87f77b
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.test.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2022 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 { RestrictedIndexedRouter } from './RestrictedIndexedRouter';
+
+describe('RestrictedIndexedRouter', () => {
+ it.each([
+ [['/b'], '/a'],
+ [['/a'], '/aa/b'],
+ [['/aa'], '/a/b'],
+ [['/a/b'], '/aa'],
+ [['/b/a'], '/a'],
+ [['/a'], '/aa'],
+ ])(`with existing paths %s, adds %s without conflict`, (existing, added) => {
+ const router = new RestrictedIndexedRouter(false);
+ for (const path of existing) {
+ router.use(path, () => {});
+ }
+ expect(() => router.use(added, () => {})).not.toThrow();
+ });
+
+ it.each([
+ [['/a'], '/a', '/a'],
+ [['/a'], '/a/b', '/a'],
+ [['/a/b'], '/a', '/a/b'],
+ ])(
+ `find conflict when existing paths %s, adds %s`,
+ (existing, added, conflict) => {
+ const router = new RestrictedIndexedRouter(false);
+ for (const path of existing) {
+ router.use(path, () => {});
+ }
+ expect(() => router.use(added, () => {})).toThrow(
+ `Path ${added} conflicts with the existing path ${conflict}`,
+ );
+ },
+ );
+});
diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.ts
new file mode 100644
index 0000000000..961277f34d
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/RestrictedIndexedRouter.ts
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2023 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 { RootHttpRouterService } from '@backstage/backend-plugin-api';
+import { Handler, Router } from 'express';
+
+function normalizePath(path: string): string {
+ return path.replace(/\/*$/, '/');
+}
+
+export class RestrictedIndexedRouter implements RootHttpRouterService {
+ #indexPath?: false | string;
+
+ #router = Router();
+ #namedRoutes = Router();
+ #indexRouter = Router();
+ #existingPaths = new Array();
+
+ constructor(indexPath?: false | string) {
+ this.#indexPath = indexPath;
+ this.#router.use(this.#namedRoutes);
+ this.#router.use(this.#indexRouter);
+ }
+
+ use(path: string, handler: Handler) {
+ if (path.match(/^[/\s]*$/)) {
+ throw new Error(`Root router path may not be empty`);
+ }
+ const conflictingPath = this.#findConflictingPath(path);
+ if (conflictingPath) {
+ throw new Error(
+ `Path ${path} conflicts with the existing path ${conflictingPath}`,
+ );
+ }
+ this.#existingPaths.push(path);
+ this.#namedRoutes.use(path, handler);
+
+ if (this.#indexPath === path) {
+ this.#indexRouter.use(handler);
+ }
+ }
+
+ handler(): Handler {
+ return this.#router;
+ }
+
+ #findConflictingPath(newPath: string): string | undefined {
+ const normalizedNewPath = normalizePath(newPath);
+ for (const path of this.#existingPaths) {
+ const normalizedPath = normalizePath(path);
+ if (normalizedPath.startsWith(normalizedNewPath)) {
+ return path;
+ }
+ if (normalizedNewPath.startsWith(normalizedPath)) {
+ return path;
+ }
+ }
+ return undefined;
+ }
+}
diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/index.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/index.ts
new file mode 100644
index 0000000000..1dfd72273d
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/index.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2023 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.
+ */
+
+export { rootHttpRouterFactory } from './rootHttpRouterFactory';
+export type {
+ RootHttpRouterFactoryOptions,
+ RootHttpRouterConfigureOptions,
+} from './rootHttpRouterFactory';
diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouter/rootHttpRouterFactory.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouter/rootHttpRouterFactory.ts
new file mode 100644
index 0000000000..d8cedda0d9
--- /dev/null
+++ b/packages/backend-app-api/src/services/implementations/rootHttpRouter/rootHttpRouterFactory.ts
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2022 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 {
+ ConfigService,
+ coreServices,
+ createServiceFactory,
+ LifecycleService,
+ LoggerService,
+} from '@backstage/backend-plugin-api';
+import express, { RequestHandler, Express } from 'express';
+import {
+ createHttpServer,
+ MiddlewareFactory,
+ readHttpServerOptions,
+} from '../../../http';
+import { RestrictedIndexedRouter } from './RestrictedIndexedRouter';
+
+/**
+ * @public
+ */
+export interface RootHttpRouterConfigureOptions {
+ app: Express;
+ middleware: MiddlewareFactory;
+ routes: RequestHandler;
+ config: ConfigService;
+ logger: LoggerService;
+ lifecycle: LifecycleService;
+}
+
+/**
+ * @public
+ */
+export type RootHttpRouterFactoryOptions = {
+ /**
+ * The path to forward all unmatched requests to. Defaults to '/api/app'
+ */
+ indexPath?: string | false;
+
+ configure?(options: RootHttpRouterConfigureOptions): void;
+};
+
+function defaultConfigure({
+ app,
+ routes,
+ middleware,
+}: RootHttpRouterConfigureOptions) {
+ app.use(middleware.helmet());
+ app.use(middleware.cors());
+ app.use(middleware.compression());
+ app.use(middleware.logging());
+ app.use(routes);
+ app.use(middleware.notFound());
+ app.use(middleware.error());
+}
+
+/** @public */
+export const rootHttpRouterFactory = createServiceFactory({
+ service: coreServices.rootHttpRouter,
+ deps: {
+ config: coreServices.config,
+ rootLogger: coreServices.rootLogger,
+ lifecycle: coreServices.rootLifecycle,
+ },
+ async factory(
+ { config, rootLogger, lifecycle },
+ {
+ indexPath,
+ configure = defaultConfigure,
+ }: RootHttpRouterFactoryOptions = {},
+ ) {
+ const router = new RestrictedIndexedRouter(indexPath ?? '/api/app');
+ const logger = rootLogger.child({ service: 'rootHttpRouter' });
+
+ const app = express();
+
+ const middleware = MiddlewareFactory.create({ config, logger });
+
+ configure({
+ app,
+ routes: router.handler(),
+ middleware,
+ config,
+ logger,
+ lifecycle,
+ });
+
+ const server = await createHttpServer(
+ app,
+ readHttpServerOptions(config.getOptionalConfig('backend')),
+ { logger },
+ );
+
+ lifecycle.addShutdownHook({
+ async fn() {
+ await server.stop();
+ },
+ labels: { service: 'rootHttpRouter' },
+ });
+
+ await server.start();
+
+ return router;
+ },
+});
diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouterService.test.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouterService.test.ts
deleted file mode 100644
index fea99b0df9..0000000000
--- a/packages/backend-app-api/src/services/implementations/rootHttpRouterService.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright 2022 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 { findConflictingPath } from './rootHttpRouterService';
-
-describe('findConflictingPath', () => {
- it('finds conflicts when present', () => {
- expect(findConflictingPath(['/a'], '/a')).toBe('/a');
- expect(findConflictingPath(['/b'], '/a')).toBe(undefined);
- expect(findConflictingPath(['/a'], '/a/b')).toBe('/a');
- expect(findConflictingPath(['/a'], '/aa/b')).toBe(undefined);
- expect(findConflictingPath(['/aa'], '/a/b')).toBe(undefined);
- expect(findConflictingPath(['/a/b'], '/a')).toBe('/a/b');
- expect(findConflictingPath(['/a/b'], '/aa')).toBe(undefined);
- expect(findConflictingPath(['/b/a'], '/a')).toBe(undefined);
- expect(findConflictingPath(['/a'], '/aa')).toBe(undefined);
- });
-});
diff --git a/packages/backend-app-api/src/services/implementations/rootHttpRouterService.ts b/packages/backend-app-api/src/services/implementations/rootHttpRouterService.ts
deleted file mode 100644
index dae7474793..0000000000
--- a/packages/backend-app-api/src/services/implementations/rootHttpRouterService.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * Copyright 2022 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 {
- createServiceFactory,
- coreServices,
-} from '@backstage/backend-plugin-api';
-import Router from 'express-promise-router';
-import { Handler } from 'express';
-import { createServiceBuilder } from '@backstage/backend-common';
-
-/**
- * @public
- */
-export type RootHttpRouterFactoryOptions = {
- /**
- * The path to forward all unmatched requests to. Defaults to '/api/app'
- */
- indexPath?: string | false;
-
- /**
- * Middlewares that are added before all other routes.
- */
- middleware?: Handler[];
-};
-
-/** @public */
-export const rootHttpRouterFactory = createServiceFactory({
- service: coreServices.rootHttpRouter,
- deps: {
- config: coreServices.config,
- lifecycle: coreServices.rootLifecycle,
- },
- async factory({ config, lifecycle }, options?: RootHttpRouterFactoryOptions) {
- const indexPath = options?.indexPath ?? '/api/app';
-
- const namedRouter = Router();
- const indexRouter = Router();
-
- const service = createServiceBuilder(module).loadConfig(config);
-
- for (const middleware of options?.middleware ?? []) {
- service.addRouter('', middleware);
- }
-
- service.addRouter('', namedRouter).addRouter('', indexRouter);
-
- const server = await service.start();
- // Stop method isn't part of the public API, let's fix that once we move the implementation here.
- const stoppableServer = server as typeof server & {
- stop: (cb: (error?: Error) => void) => void;
- };
-
- lifecycle.addShutdownHook({
- async fn() {
- await new Promise((resolve, reject) => {
- stoppableServer.stop((error?: Error) => {
- if (error) {
- reject(error);
- } else {
- resolve();
- }
- });
- });
- },
- labels: { service: 'rootHttpRouter' },
- });
-
- const existingPaths = new Array();
-
- return {
- use: (path: string, handler: Handler) => {
- if (path.match(/^[/\s]*$/)) {
- throw new Error(`Root router path may not be empty`);
- }
- const conflictingPath = findConflictingPath(existingPaths, path);
- if (conflictingPath) {
- throw new Error(
- `Path ${path} conflicts with the existing path ${conflictingPath}`,
- );
- }
- existingPaths.push(path);
- namedRouter.use(path, handler);
-
- if (indexPath === path) {
- indexRouter.use(handler);
- }
- },
- };
- },
-});
-
-function normalizePath(path: string): string {
- return path.replace(/\/*$/, '/');
-}
-
-export function findConflictingPath(
- paths: string[],
- newPath: string,
-): string | undefined {
- const normalizedNewPath = normalizePath(newPath);
- for (const path of paths) {
- const normalizedPath = normalizePath(path);
- if (normalizedPath.startsWith(normalizedNewPath)) {
- return path;
- }
- if (normalizedNewPath.startsWith(normalizedPath)) {
- return path;
- }
- }
- return undefined;
-}
diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json
index aff5e56abb..9c16472263 100644
--- a/packages/backend-common/package.json
+++ b/packages/backend-common/package.json
@@ -34,6 +34,7 @@
"test:kubernetes": "backstage-cli package test -t KubernetesContainerRunner --no-watch"
},
"dependencies": {
+ "@backstage/backend-app-api": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/cli-common": "workspace:^",
"@backstage/config": "workspace:^",
diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts
index cc23a2f744..ded23b3374 100644
--- a/packages/backend-common/src/discovery/SingleHostDiscovery.ts
+++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts
@@ -16,8 +16,7 @@
import { Config } from '@backstage/config';
import { PluginEndpointDiscovery } from './types';
-import { readBaseOptions } from '../service/lib/config';
-import { DEFAULT_PORT } from '../service/lib/ServiceBuilderImpl';
+import { readHttpServerOptions } from '@backstage/backend-app-api';
/**
* SingleHostDiscovery is a basic PluginEndpointDiscovery implementation
@@ -43,9 +42,9 @@ export class SingleHostDiscovery implements PluginEndpointDiscovery {
const basePath = options?.basePath ?? '/api';
const externalBaseUrl = config.getString('backend.baseUrl');
- const { listenHost = '::', listenPort = DEFAULT_PORT } = readBaseOptions(
- config.getConfig('backend'),
- );
+ const {
+ listen: { host: listenHost = '::', port: listenPort },
+ } = readHttpServerOptions(config.getConfig('backend'));
const protocol = config.has('backend.https') ? 'https' : 'http';
// Translate bind-all to localhost, and support IPv6
diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts
index 04b3f92e26..99ede75ab0 100644
--- a/packages/backend-common/src/middleware/errorHandler.ts
+++ b/packages/backend-common/src/middleware/errorHandler.ts
@@ -14,19 +14,11 @@
* limitations under the License.
*/
-import {
- AuthenticationError,
- ConflictError,
- ErrorResponseBody,
- InputError,
- NotAllowedError,
- NotFoundError,
- NotModifiedError,
- serializeError,
-} from '@backstage/errors';
-import { ErrorRequestHandler, NextFunction, Request, Response } from 'express';
+import { ErrorRequestHandler } from 'express';
import { LoggerService } from '@backstage/backend-plugin-api';
import { getRootLogger } from '../logging';
+import { ConfigReader } from '@backstage/config';
+import { MiddlewareFactory } from '@backstage/backend-app-api';
/**
* Options passed to the {@link errorHandler} middleware.
@@ -73,69 +65,11 @@ export type ErrorHandlerOptions = {
export function errorHandler(
options: ErrorHandlerOptions = {},
): ErrorRequestHandler {
- const showStackTraces =
- options.showStackTraces ?? process.env.NODE_ENV === 'development';
-
- const logger = (options.logger || getRootLogger()).child({
- type: 'errorHandler',
+ return MiddlewareFactory.create({
+ config: new ConfigReader({}),
+ logger: options.logger ?? getRootLogger(),
+ }).error({
+ logAllErrors: options.logClientErrors,
+ showStackTraces: options.showStackTraces,
});
-
- return (error: Error, req: Request, res: Response, next: NextFunction) => {
- const statusCode = getStatusCode(error);
- if (options.logClientErrors || statusCode >= 500) {
- logger.error(`Request failed with status ${statusCode}`, error);
- }
-
- if (res.headersSent) {
- // If the headers have already been sent, do not send the response again
- // as this will throw an error in the backend.
- next(error);
- return;
- }
-
- const body: ErrorResponseBody = {
- error: serializeError(error, { includeStack: showStackTraces }),
- request: { method: req.method, url: req.url },
- response: { statusCode },
- };
-
- res.status(statusCode).json(body);
- };
-}
-
-function getStatusCode(error: Error): number {
- // Look for common http library status codes
- const knownStatusCodeFields = ['statusCode', 'status'];
- for (const field of knownStatusCodeFields) {
- const statusCode = (error as any)[field];
- if (
- typeof statusCode === 'number' &&
- (statusCode | 0) === statusCode && // is whole integer
- statusCode >= 100 &&
- statusCode <= 599
- ) {
- return statusCode;
- }
- }
-
- // Handle well-known error types
- switch (error.name) {
- case NotModifiedError.name:
- return 304;
- case InputError.name:
- return 400;
- case AuthenticationError.name:
- return 401;
- case NotAllowedError.name:
- return 403;
- case NotFoundError.name:
- return 404;
- case ConflictError.name:
- return 409;
- default:
- break;
- }
-
- // Fall back to internal server error
- return 500;
}
diff --git a/packages/backend-common/src/middleware/notFoundHandler.ts b/packages/backend-common/src/middleware/notFoundHandler.ts
index 2d8b0ef3d0..4f29b4f824 100644
--- a/packages/backend-common/src/middleware/notFoundHandler.ts
+++ b/packages/backend-common/src/middleware/notFoundHandler.ts
@@ -14,7 +14,10 @@
* limitations under the License.
*/
-import { NextFunction, Request, RequestHandler, Response } from 'express';
+import { MiddlewareFactory } from '@backstage/backend-app-api';
+import { ConfigReader } from '@backstage/config';
+import { RequestHandler } from 'express';
+import { getRootLogger } from '../logging';
/**
* Express middleware to handle requests for missing routes.
@@ -26,8 +29,8 @@ import { NextFunction, Request, RequestHandler, Response } from 'express';
* @returns An Express request handler
*/
export function notFoundHandler(): RequestHandler {
- /* eslint-disable @typescript-eslint/no-unused-vars */
- return (_request: Request, response: Response, _next: NextFunction) => {
- response.status(404).end();
- };
+ return MiddlewareFactory.create({
+ config: new ConfigReader({}),
+ logger: getRootLogger(),
+ }).notFound();
}
diff --git a/packages/backend-common/src/middleware/requestLoggingHandler.ts b/packages/backend-common/src/middleware/requestLoggingHandler.ts
index 1618f43ced..a03e3c31f6 100644
--- a/packages/backend-common/src/middleware/requestLoggingHandler.ts
+++ b/packages/backend-common/src/middleware/requestLoggingHandler.ts
@@ -14,10 +14,11 @@
* limitations under the License.
*/
+import { MiddlewareFactory } from '@backstage/backend-app-api';
import { RequestHandler } from 'express';
import { LoggerService } from '@backstage/backend-plugin-api';
-import morgan from 'morgan';
import { getRootLogger } from '../logging';
+import { ConfigReader } from '@backstage/config';
/**
* Logs incoming requests.
@@ -27,15 +28,8 @@ import { getRootLogger } from '../logging';
* @returns An Express request handler
*/
export function requestLoggingHandler(logger?: LoggerService): RequestHandler {
- const actualLogger = (logger || getRootLogger()).child({
- type: 'incomingRequest',
- });
-
- return morgan('combined', {
- stream: {
- write(message: string) {
- actualLogger.info(message.trimEnd());
- },
- },
- });
+ return MiddlewareFactory.create({
+ config: new ConfigReader({}),
+ logger: logger ?? getRootLogger(),
+ }).logging();
}
diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
index a3551416dd..f74dcd9b64 100644
--- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
+++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts
@@ -18,10 +18,9 @@ import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express, { Router, ErrorRequestHandler } from 'express';
-import helmet from 'helmet';
+import helmet, { HelmetOptions } from 'helmet';
import { ContentSecurityPolicyOptions } from 'helmet/dist/types/middlewares/content-security-policy';
import * as http from 'http';
-import stoppable from 'stoppable';
import { LoggerService } from '@backstage/backend-plugin-api';
import { useHotCleanup } from '../../hot';
import { getRootLogger } from '../../logging';
@@ -32,26 +31,20 @@ import {
} from '../../middleware';
import { RequestLoggingHandlerFactory, ServiceBuilder } from '../types';
import {
- CspOptions,
- HttpsSettings,
- readBaseOptions,
readCorsOptions,
- readCspOptions,
- readHttpsSettings,
-} from './config';
-import { createHttpServer, createHttpsServer } from './hostFactory';
+ readHelmetOptions,
+ readHttpServerOptions,
+ HttpServerOptions,
+ createHttpServer,
+} from '@backstage/backend-app-api';
-export const DEFAULT_PORT = 7007;
-// '' is express default, which listens to all interfaces
-const DEFAULT_HOST = '';
+export type CspOptions = Record;
export class ServiceBuilderImpl implements ServiceBuilder {
- private port: number | undefined;
- private host: string | undefined;
private logger: LoggerService | undefined;
- private corsOptions: cors.CorsOptions | undefined;
- private cspOptions: Record | undefined;
- private httpsSettings: HttpsSettings | undefined;
+ private serverOptions: HttpServerOptions;
+ private helmetOptions: HelmetOptions;
+ private corsOptions: cors.CorsOptions;
private routers: [string, Router][];
private requestLoggingHandler: RequestLoggingHandlerFactory | undefined;
private errorHandler: ErrorRequestHandler | undefined;
@@ -64,50 +57,29 @@ export class ServiceBuilderImpl implements ServiceBuilder {
this.routers = [];
this.module = moduleRef;
this.useDefaultErrorHandler = true;
+
+ this.serverOptions = readHttpServerOptions();
+ this.corsOptions = readCorsOptions();
+ this.helmetOptions = readHelmetOptions();
}
loadConfig(config: Config): ServiceBuilder {
const backendConfig = config.getOptionalConfig('backend');
- if (!backendConfig) {
- return this;
- }
- const baseOptions = readBaseOptions(backendConfig);
- if (baseOptions.listenPort) {
- this.port =
- typeof baseOptions.listenPort === 'string'
- ? parseInt(baseOptions.listenPort, 10)
- : baseOptions.listenPort;
- }
- if (baseOptions.listenHost) {
- this.host = baseOptions.listenHost;
- }
-
- const corsOptions = readCorsOptions(backendConfig);
- if (corsOptions) {
- this.corsOptions = corsOptions;
- }
-
- const cspOptions = readCspOptions(backendConfig);
- if (cspOptions) {
- this.cspOptions = cspOptions;
- }
-
- const httpsSettings = readHttpsSettings(backendConfig);
- if (httpsSettings) {
- this.httpsSettings = httpsSettings;
- }
+ this.serverOptions = readHttpServerOptions(backendConfig);
+ this.corsOptions = readCorsOptions(backendConfig);
+ this.helmetOptions = readHelmetOptions(backendConfig);
return this;
}
setPort(port: number): ServiceBuilder {
- this.port = port;
+ this.serverOptions.listen.port = port;
return this;
}
setHost(host: string): ServiceBuilder {
- this.host = host;
+ this.serverOptions.listen.host = host;
return this;
}
@@ -116,8 +88,24 @@ export class ServiceBuilderImpl implements ServiceBuilder {
return this;
}
- setHttpsSettings(settings: HttpsSettings): ServiceBuilder {
- this.httpsSettings = settings;
+ setHttpsSettings(settings: {
+ certificate: { key: string; cert: string } | { hostname: string };
+ }): ServiceBuilder {
+ if ('hostname' in settings.certificate) {
+ this.serverOptions.https = {
+ certificate: {
+ ...settings.certificate,
+ type: 'generated',
+ },
+ };
+ } else {
+ this.serverOptions.https = {
+ certificate: {
+ ...settings.certificate,
+ type: 'plain',
+ },
+ };
+ }
return this;
}
@@ -127,7 +115,11 @@ export class ServiceBuilderImpl implements ServiceBuilder {
}
setCsp(options: CspOptions): ServiceBuilder {
- this.cspOptions = options;
+ const csp = this.helmetOptions.contentSecurityPolicy;
+ this.helmetOptions.contentSecurityPolicy = {
+ ...(typeof csp === 'object' ? csp : {}),
+ directives: applyCspDirectives(options),
+ };
return this;
}
@@ -155,13 +147,10 @@ export class ServiceBuilderImpl implements ServiceBuilder {
async start(): Promise {
const app = express();
- const { port, host, logger, corsOptions, httpsSettings, helmetOptions } =
- this.getOptions();
+ const logger = this.logger ?? getRootLogger();
- app.use(helmet(helmetOptions));
- if (corsOptions) {
- app.use(cors(corsOptions));
- }
+ app.use(helmet(this.helmetOptions));
+ app.use(cors(this.corsOptions));
app.use(compression());
app.use(
(this.requestLoggingHandler ?? defaultRequestLoggingHandler)(logger),
@@ -179,58 +168,23 @@ export class ServiceBuilderImpl implements ServiceBuilder {
app.use(defaultErrorHandler());
}
- const server: http.Server = httpsSettings
- ? await createHttpsServer(app, httpsSettings, logger)
- : createHttpServer(app, logger);
- const stoppableServer = stoppable(server, 0);
+ const server = await createHttpServer(app, this.serverOptions, { logger });
useHotCleanup(this.module, () =>
- stoppableServer.stop((e: any) => {
- if (e) console.error(e);
+ server.stop().catch(error => {
+ console.error(error);
}),
);
- return new Promise((resolve, reject) => {
- function handleStartupError(e: unknown) {
- server.close();
- reject(e);
- }
+ await server.start();
- server.on('error', handleStartupError);
-
- server.listen(port, host, () => {
- server.off('error', handleStartupError);
- logger.info(`Listening on ${host}:${port}`);
- resolve(stoppableServer);
- });
- });
- }
-
- private getOptions() {
- return {
- port: this.port ?? DEFAULT_PORT,
- host: this.host ?? DEFAULT_HOST,
- logger: this.logger ?? getRootLogger(),
- corsOptions: this.corsOptions,
- httpsSettings: this.httpsSettings,
- helmetOptions: {
- contentSecurityPolicy: {
- useDefaults: false,
- directives: applyCspDirectives(this.cspOptions),
- },
- // These are all disabled in order to maintain backwards compatibility
- // when bumping helmet v5. We can't enable these by default because
- // there is no way for users to configure them.
- // TODO(Rugvip): We should give control of this setup to consumers
- crossOriginEmbedderPolicy: false,
- crossOriginOpenerPolicy: false,
- crossOriginResourcePolicy: false,
- originAgentCluster: false,
- },
- };
+ return server;
}
}
+// TODO(Rugvip): This is a duplicate of the same logic over in backend-app-api.
+// It's needed as we don't want to export this helper from there, but need
+// It to implement the setCsp method here.
export function applyCspDirectives(
directives: Record | undefined,
): ContentSecurityPolicyOptions['directives'] {
diff --git a/packages/backend-common/src/service/lib/config.test.ts b/packages/backend-common/src/service/lib/config.test.ts
deleted file mode 100644
index b80d475517..0000000000
--- a/packages/backend-common/src/service/lib/config.test.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-/*
- * Copyright 2020 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 { ConfigReader } from '@backstage/config';
-import { readCorsOptions, readCspOptions } from './config';
-
-describe('config', () => {
- describe('readCspOptions', () => {
- it('reads valid values', () => {
- const config = new ConfigReader({ csp: { key: ['value'] } });
- expect(readCspOptions(config)).toEqual(
- expect.objectContaining({
- key: ['value'],
- }),
- );
- });
-
- it('accepts false', () => {
- const config = new ConfigReader({ csp: { key: false } });
- expect(readCspOptions(config)).toEqual(
- expect.objectContaining({
- key: false,
- }),
- );
- });
-
- it('rejects invalid value types', () => {
- const config = new ConfigReader({ csp: { key: [4] } });
- expect(() => readCspOptions(config)).toThrow(/wanted string-array/);
- });
- });
-
- describe('readCorsOptions', () => {
- it('reads single string', () => {
- const mockCallback = jest.fn();
- const config = new ConfigReader({ cors: { origin: 'https://*.value*' } });
- const cors = readCorsOptions(config);
- expect(cors).toEqual(
- expect.objectContaining({
- origin: expect.any(Function),
- }),
- );
- const origin = cors?.origin as Function;
- origin('https://a.value', mockCallback); // valid origin
- origin('http://a.value', mockCallback); // invalid origin
- origin(undefined, mockCallback); // when not origin needs to reject the call
-
- expect(mockCallback.mock.calls[0][0]).toBe(null);
- expect(mockCallback.mock.calls[1][0]).toBe(null);
-
- expect(mockCallback.mock.calls[0][1]).toBe(true);
- expect(mockCallback.mock.calls[1][1]).toBe(false);
- expect(mockCallback.mock.calls[2][1]).toBe(false);
- });
-
- it('reads string array', () => {
- const mockCallback = jest.fn();
- const config = new ConfigReader({
- cors: {
- origin: ['http?(s)://*.value?(-+([0-9])).com', 'http://*.value'],
- },
- });
- const cors = readCorsOptions(config);
- expect(cors).toEqual(
- expect.objectContaining({
- origin: expect.any(Function),
- }),
- );
- const origin = cors?.origin as Function;
- origin('https://a.b.c.value-9.com', mockCallback);
- origin('http://a.value-999.com', mockCallback);
- origin('http://a.value', mockCallback);
- origin('http://a.valuex', mockCallback);
-
- expect(mockCallback.mock.calls[0][0]).toBe(null);
- expect(mockCallback.mock.calls[1][0]).toBe(null);
- expect(mockCallback.mock.calls[2][0]).toBe(null);
- expect(mockCallback.mock.calls[3][0]).toBe(null);
-
- expect(mockCallback.mock.calls[0][1]).toBe(true);
- expect(mockCallback.mock.calls[1][1]).toBe(true);
- expect(mockCallback.mock.calls[2][1]).toBe(true);
- expect(mockCallback.mock.calls[3][1]).toBe(false);
- });
-
- it('reads undefined origin', () => {
- const config = new ConfigReader({
- cors: {},
- });
- const cors = readCorsOptions(config);
- expect(cors).toEqual(expect.objectContaining({}));
- expect(cors?.origin).toBeUndefined();
- });
- });
-});
diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts
deleted file mode 100644
index 93cc12b7d4..0000000000
--- a/packages/backend-common/src/service/lib/config.ts
+++ /dev/null
@@ -1,284 +0,0 @@
-/*
- * Copyright 2020 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 { CorsOptions } from 'cors';
-import { Minimatch } from 'minimatch';
-
-export type BaseOptions = {
- listenPort?: string | number;
- listenHost?: string;
-};
-
-export type HttpsSettings = {
- certificate: CertificateGenerationOptions | CertificateReferenceOptions;
-};
-
-export type CertificateReferenceOptions = {
- key: string;
- cert: string;
-};
-
-export type CertificateGenerationOptions = {
- hostname: string;
-};
-
-export type CertificateAttributes = {
- commonName: string;
-};
-
-/**
- * A map from CSP directive names to their values.
- */
-export type CspOptions = Record;
-
-type StaticOrigin = boolean | string | RegExp | (boolean | string | RegExp)[];
-
-type CustomOrigin = (
- requestOrigin: string | undefined,
- callback: (err: Error | null, origin?: StaticOrigin) => void,
-) => void;
-
-/**
- * Reads some base options out of a config object.
- *
- * @param config - The root of a backend config object
- * @returns A base options object
- *
- * @example
- * ```json
- * {
- * baseUrl: "http://localhost:7007",
- * listen: "0.0.0.0:7007"
- * }
- * ```
- */
-export function readBaseOptions(config: Config): BaseOptions {
- if (typeof config.get('listen') === 'string') {
- // TODO(freben): Expand this to support more addresses and perhaps optional
- const { host, port } = parseListenAddress(config.getString('listen'));
-
- return removeUnknown({
- listenPort: port,
- listenHost: host,
- });
- }
-
- const port = config.getOptional('listen.port');
- if (
- typeof port !== 'undefined' &&
- typeof port !== 'number' &&
- typeof port !== 'string'
- ) {
- throw new Error(
- `Invalid type in config for key 'backend.listen.port', got ${typeof port}, wanted string or number`,
- );
- }
-
- return removeUnknown({
- listenPort: port,
- listenHost: config.getOptionalString('listen.host'),
- baseUrl: config.getOptionalString('baseUrl'),
- });
-}
-
-/**
- * Attempts to read a CORS options object from the root of a config object.
- *
- * @param config - The root of a backend config object
- * @returns A CORS options object, or undefined if not specified
- *
- * @example
- * ```json
- * {
- * cors: {
- * origin: "http://localhost:3000",
- * credentials: true
- * }
- * }
- * ```
- */
-export function readCorsOptions(config: Config): CorsOptions | undefined {
- const cc = config.getOptionalConfig('cors');
- if (!cc) {
- return undefined;
- }
-
- return removeUnknown({
- origin: createCorsOriginMatcher(getOptionalStringOrStrings(cc, 'origin')),
- methods: getOptionalStringOrStrings(cc, 'methods'),
- allowedHeaders: getOptionalStringOrStrings(cc, 'allowedHeaders'),
- exposedHeaders: getOptionalStringOrStrings(cc, 'exposedHeaders'),
- credentials: cc.getOptionalBoolean('credentials'),
- maxAge: cc.getOptionalNumber('maxAge'),
- preflightContinue: cc.getOptionalBoolean('preflightContinue'),
- optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'),
- });
-}
-
-/**
- * Attempts to read a CSP options object from the root of a config object.
- *
- * @param config - The root of a backend config object
- * @returns A CSP options object, or undefined if not specified. Values can be
- * false as well, which means to remove the default behavior for that
- * key.
- *
- * @example
- * ```yaml
- * backend:
- * csp:
- * connect-src: ["'self'", 'http:', 'https:']
- * upgrade-insecure-requests: false
- * ```
- */
-export function readCspOptions(
- config: Config,
-): Record | undefined {
- const cc = config.getOptionalConfig('csp');
- if (!cc) {
- return undefined;
- }
-
- const result: Record = {};
- for (const key of cc.keys()) {
- if (cc.get(key) === false) {
- result[key] = false;
- } else {
- result[key] = cc.getStringArray(key);
- }
- }
-
- return result;
-}
-
-/**
- * Attempts to read a https settings object from the root of a config object.
- *
- * @param config - The root of a backend config object
- * @returns A https settings object, or undefined if not specified
- *
- * @example
- * ```json
- * {
- * https: {
- * certificate: ...
- * }
- * }
- * ```
- */
-export function readHttpsSettings(config: Config): HttpsSettings | undefined {
- const https = config.getOptional('https');
- if (https === true) {
- const baseUrl = config.getString('baseUrl');
- let hostname;
- try {
- hostname = new URL(baseUrl).hostname;
- } catch (error) {
- throw new Error(`Invalid backend.baseUrl "${baseUrl}"`);
- }
-
- return { certificate: { hostname } };
- }
-
- const cc = config.getOptionalConfig('https');
- if (!cc) {
- return undefined;
- }
-
- const certificateConfig = cc.get('certificate');
-
- const cfg = {
- certificate: certificateConfig,
- };
-
- return removeUnknown(cfg as HttpsSettings);
-}
-
-function getOptionalStringOrStrings(
- config: Config,
- key: string,
-): string | string[] | undefined {
- const value = config.getOptional(key);
- if (value === undefined || isStringOrStrings(value)) {
- return value;
- }
- throw new Error(`Expected string or array of strings, got ${typeof value}`);
-}
-
-function createCorsOriginMatcher(
- originValue: string | string[] | undefined,
-): CustomOrigin | undefined {
- if (originValue === undefined) {
- return originValue;
- }
-
- if (!isStringOrStrings(originValue)) {
- throw new Error(
- `Expected string or array of strings, got ${typeof originValue}`,
- );
- }
-
- const allowedOrigin =
- typeof originValue === 'string' ? [originValue] : originValue;
-
- const allowedOriginPatterns =
- allowedOrigin?.map(
- pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
- ) ?? [];
-
- return (origin, callback) => {
- return callback(
- null,
- allowedOriginPatterns.some(pattern => pattern.match(origin ?? '')),
- );
- };
-}
-
-function isStringOrStrings(value: any): value is string | string[] {
- return typeof value === 'string' || isStringArray(value);
-}
-
-function isStringArray(value: any): value is string[] {
- if (!Array.isArray(value)) {
- return false;
- }
- for (const v of value) {
- if (typeof v !== 'string') {
- return false;
- }
- }
- return true;
-}
-
-function removeUnknown(obj: T): T {
- return Object.fromEntries(
- Object.entries(obj).filter(([, v]) => v !== undefined),
- ) as T;
-}
-
-function parseListenAddress(value: string): { host?: string; port?: number } {
- const parts = value.split(':');
- if (parts.length === 1) {
- return { port: parseInt(parts[0], 10) };
- }
- if (parts.length === 2) {
- return { host: parts[0], port: parseInt(parts[1], 10) };
- }
- throw new Error(
- `Unable to parse listen address ${value}, expected or :`,
- );
-}
diff --git a/packages/backend-tasks/src/database/migrateBackendTasks.ts b/packages/backend-tasks/src/database/migrateBackendTasks.ts
index cc0f4349ac..9530668e3e 100644
--- a/packages/backend-tasks/src/database/migrateBackendTasks.ts
+++ b/packages/backend-tasks/src/database/migrateBackendTasks.ts
@@ -18,12 +18,12 @@ import { resolvePackagePath } from '@backstage/backend-common';
import { Knex } from 'knex';
import { DB_MIGRATIONS_TABLE } from './tables';
-const migrationsDir = resolvePackagePath(
- '@backstage/backend-tasks',
- 'migrations',
-);
-
export async function migrateBackendTasks(knex: Knex): Promise {
+ const migrationsDir = resolvePackagePath(
+ '@backstage/backend-tasks',
+ 'migrations',
+ );
+
await knex.migrate.latest({
directory: migrationsDir,
tableName: DB_MIGRATIONS_TABLE,
diff --git a/yarn.lock b/yarn.lock
index 0834d7d079..052d0cbec3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3381,11 +3381,30 @@ __metadata:
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-tasks": "workspace:^"
"@backstage/cli": "workspace:^"
+ "@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
+ "@types/compression": ^1.7.0
+ "@types/cors": ^2.8.6
"@types/express": ^4.17.6
+ "@types/fs-extra": ^9.0.3
+ "@types/http-errors": ^2.0.0
+ "@types/morgan": ^1.9.0
+ "@types/node-forge": ^1.3.0
+ "@types/stoppable": ^1.1.0
+ compression: ^1.7.4
+ cors: ^2.8.5
express: ^4.17.1
express-promise-router: ^4.1.0
+ fs-extra: 10.1.0
+ helmet: ^6.0.0
+ http-errors: ^2.0.0
+ minimatch: ^5.0.0
+ morgan: ^1.10.0
+ node-forge: ^1.3.1
+ selfsigned: ^2.0.0
+ stoppable: ^1.1.0
+ supertest: ^6.1.3
winston: ^3.2.1
languageName: unknown
linkType: soft
@@ -3394,6 +3413,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@backstage/backend-common@workspace:packages/backend-common"
dependencies:
+ "@backstage/backend-app-api": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"