@backstage/backend-app-api 0.8.1-next.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs.js CHANGED
@@ -1,3968 +1,895 @@
1
1
  'use strict';
2
2
 
3
- var configLoader = require('@backstage/config-loader');
4
- var getPackages = require('@manypkg/get-packages');
5
- var path = require('path');
6
- var parseArgs = require('minimist');
7
- var cliCommon = require('@backstage/cli-common');
8
- var config = require('@backstage/config');
9
- var http = require('http');
10
- var https = require('https');
11
- var stoppableServer = require('stoppable');
12
- var fs = require('fs-extra');
13
- var forge = require('node-forge');
14
- var cors = require('cors');
15
- var helmet = require('helmet');
16
- var morgan = require('morgan');
17
- var compression = require('compression');
18
- var kebabCase = require('lodash/kebabCase');
19
- var minimatch = require('minimatch');
20
- var errors = require('@backstage/errors');
21
- var crypto = require('crypto');
22
3
  var backendPluginApi = require('@backstage/backend-plugin-api');
23
- var winston = require('winston');
24
- var tripleBeam = require('triple-beam');
4
+ var errors = require('@backstage/errors');
25
5
  var alpha = require('@backstage/backend-plugin-api/alpha');
26
- var backendCommon = require('@backstage/backend-common');
27
6
  var pluginAuthNode = require('@backstage/plugin-auth-node');
28
- var pluginPermissionNode = require('@backstage/plugin-permission-node');
29
- var jose = require('jose');
30
- var types = require('@backstage/types');
31
- var uuid = require('uuid');
32
- var luxon = require('luxon');
33
- var fs$1 = require('fs');
34
- var cookie = require('cookie');
35
- var Router = require('express-promise-router');
36
- var pathToRegexp = require('path-to-regexp');
37
- var express = require('express');
38
- var trimEnd = require('lodash/trimEnd');
39
- var backendTasks = require('@backstage/backend-tasks');
40
- var fetch$1 = require('node-fetch');
41
-
42
- function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
7
+ var backendCommon = require('@backstage/backend-common');
43
8
 
44
- function _interopNamespaceCompat(e) {
45
- if (e && typeof e === 'object' && 'default' in e) return e;
46
- var n = Object.create(null);
47
- if (e) {
48
- Object.keys(e).forEach(function (k) {
49
- if (k !== 'default') {
50
- var d = Object.getOwnPropertyDescriptor(e, k);
51
- Object.defineProperty(n, k, d.get ? d : {
52
- enumerable: true,
53
- get: function () { return e[k]; }
54
- });
55
- }
56
- });
9
+ class Node {
10
+ constructor(value, consumes, provides) {
11
+ this.value = value;
12
+ this.consumes = consumes;
13
+ this.provides = provides;
57
14
  }
58
- n.default = e;
59
- return Object.freeze(n);
60
- }
61
-
62
- var parseArgs__default = /*#__PURE__*/_interopDefaultCompat(parseArgs);
63
- var http__namespace = /*#__PURE__*/_interopNamespaceCompat(http);
64
- var https__namespace = /*#__PURE__*/_interopNamespaceCompat(https);
65
- var stoppableServer__default = /*#__PURE__*/_interopDefaultCompat(stoppableServer);
66
- var fs__default = /*#__PURE__*/_interopDefaultCompat(fs);
67
- var forge__default = /*#__PURE__*/_interopDefaultCompat(forge);
68
- var cors__default = /*#__PURE__*/_interopDefaultCompat(cors);
69
- var helmet__default = /*#__PURE__*/_interopDefaultCompat(helmet);
70
- var morgan__default = /*#__PURE__*/_interopDefaultCompat(morgan);
71
- var compression__default = /*#__PURE__*/_interopDefaultCompat(compression);
72
- var kebabCase__default = /*#__PURE__*/_interopDefaultCompat(kebabCase);
73
- var Router__default = /*#__PURE__*/_interopDefaultCompat(Router);
74
- var express__default = /*#__PURE__*/_interopDefaultCompat(express);
75
- var trimEnd__default = /*#__PURE__*/_interopDefaultCompat(trimEnd);
76
- var fetch__default = /*#__PURE__*/_interopDefaultCompat(fetch$1);
77
-
78
- async function createConfigSecretEnumerator$1(options) {
79
- const { logger, dir = process.cwd() } = options;
80
- const { packages } = await getPackages.getPackages(dir);
81
- const schema = options.schema ?? await configLoader.loadConfigSchema({
82
- dependencies: packages.map((p) => p.packageJson.name)
83
- });
84
- return (config) => {
85
- const [secretsData] = schema.process(
86
- [{ data: config.getOptional() ?? {}, context: "schema-enumerator" }],
87
- {
88
- visibility: ["secret"],
89
- ignoreSchemaErrors: true
90
- }
91
- );
92
- const secrets = /* @__PURE__ */ new Set();
93
- JSON.parse(
94
- JSON.stringify(secretsData.data),
95
- (_, v) => typeof v === "string" && secrets.add(v)
96
- );
97
- logger.info(
98
- `Found ${secrets.size} new secrets in config that will be redacted`
15
+ static from(input) {
16
+ return new Node(
17
+ input.value,
18
+ input.consumes ? new Set(input.consumes) : /* @__PURE__ */ new Set(),
19
+ input.provides ? new Set(input.provides) : /* @__PURE__ */ new Set()
99
20
  );
100
- return secrets;
101
- };
102
- }
103
-
104
- class ObservableConfigProxy {
105
- constructor(parent, parentKey) {
106
- this.parent = parent;
107
- this.parentKey = parentKey;
108
- if (parent && !parentKey) {
109
- throw new Error("parentKey is required if parent is set");
110
- }
111
- }
112
- config = new config.ConfigReader({});
113
- subscribers = [];
114
- setConfig(config) {
115
- if (this.parent) {
116
- throw new Error("immutable");
117
- }
118
- this.config = config;
119
- for (const subscriber of this.subscribers) {
120
- try {
121
- subscriber();
122
- } catch (error) {
123
- console.error(`Config subscriber threw error, ${error}`);
124
- }
125
- }
126
- }
127
- subscribe(onChange) {
128
- if (this.parent) {
129
- return this.parent.subscribe(onChange);
130
- }
131
- this.subscribers.push(onChange);
132
- return {
133
- unsubscribe: () => {
134
- const index = this.subscribers.indexOf(onChange);
135
- if (index >= 0) {
136
- this.subscribers.splice(index, 1);
137
- }
138
- }
139
- };
140
- }
141
- select(required) {
142
- if (this.parent && this.parentKey) {
143
- if (required) {
144
- return this.parent.select(true).getConfig(this.parentKey);
145
- }
146
- return this.parent.select(false)?.getOptionalConfig(this.parentKey);
147
- }
148
- return this.config;
149
- }
150
- has(key) {
151
- return this.select(false)?.has(key) ?? false;
152
- }
153
- keys() {
154
- return this.select(false)?.keys() ?? [];
155
21
  }
156
- get(key) {
157
- return this.select(true).get(key);
158
- }
159
- getOptional(key) {
160
- return this.select(false)?.getOptional(key);
22
+ }
23
+ class CycleKeySet {
24
+ static from(nodes) {
25
+ return new CycleKeySet(nodes);
161
26
  }
162
- getConfig(key) {
163
- return new ObservableConfigProxy(this, key);
27
+ #nodeIds;
28
+ #cycleKeys;
29
+ constructor(nodes) {
30
+ this.#nodeIds = new Map(nodes.map((n, i) => [n.value, i]));
31
+ this.#cycleKeys = /* @__PURE__ */ new Set();
164
32
  }
165
- getOptionalConfig(key) {
166
- if (this.select(false)?.has(key)) {
167
- return new ObservableConfigProxy(this, key);
33
+ tryAdd(path) {
34
+ const cycleKey = this.#getCycleKey(path);
35
+ if (this.#cycleKeys.has(cycleKey)) {
36
+ return false;
168
37
  }
169
- return void 0;
170
- }
171
- getConfigArray(key) {
172
- return this.select(true).getConfigArray(key);
173
- }
174
- getOptionalConfigArray(key) {
175
- return this.select(false)?.getOptionalConfigArray(key);
176
- }
177
- getNumber(key) {
178
- return this.select(true).getNumber(key);
179
- }
180
- getOptionalNumber(key) {
181
- return this.select(false)?.getOptionalNumber(key);
182
- }
183
- getBoolean(key) {
184
- return this.select(true).getBoolean(key);
185
- }
186
- getOptionalBoolean(key) {
187
- return this.select(false)?.getOptionalBoolean(key);
188
- }
189
- getString(key) {
190
- return this.select(true).getString(key);
191
- }
192
- getOptionalString(key) {
193
- return this.select(false)?.getOptionalString(key);
194
- }
195
- getStringArray(key) {
196
- return this.select(true).getStringArray(key);
197
- }
198
- getOptionalStringArray(key) {
199
- return this.select(false)?.getOptionalStringArray(key);
200
- }
201
- }
202
-
203
- function isValidUrl(url) {
204
- try {
205
- new URL(url);
38
+ this.#cycleKeys.add(cycleKey);
206
39
  return true;
207
- } catch {
208
- return false;
209
40
  }
210
- }
211
-
212
- const createConfigSecretEnumerator = createConfigSecretEnumerator$1;
213
- async function loadBackendConfig(options) {
214
- const args = parseArgs__default.default(options.argv);
215
- const configTargets = [args.config ?? []].flat().map((arg) => isValidUrl(arg) ? { url: arg } : { path: path.resolve(arg) });
216
- const paths = cliCommon.findPaths(__dirname);
217
- let currentCancelFunc = void 0;
218
- const config$1 = new ObservableConfigProxy();
219
- const { appConfigs } = await configLoader.loadConfig({
220
- configRoot: paths.targetRoot,
221
- configTargets,
222
- remote: options.remote,
223
- watch: options.watch ?? true ? {
224
- onChange(newConfigs) {
225
- console.info(
226
- `Reloaded config from ${newConfigs.map((c) => c.context).join(", ")}`
227
- );
228
- const configsToMerge = [...newConfigs];
229
- if (options.additionalConfigs) {
230
- configsToMerge.push(...options.additionalConfigs);
231
- }
232
- config$1.setConfig(config.ConfigReader.fromConfigs(configsToMerge));
233
- },
234
- stopSignal: new Promise((resolve) => {
235
- if (currentCancelFunc) {
236
- currentCancelFunc();
237
- }
238
- currentCancelFunc = resolve;
239
- if (module.hot) {
240
- module.hot.addDisposeHandler(resolve);
241
- }
242
- })
243
- } : void 0
244
- });
245
- console.info(
246
- `Loaded config from ${appConfigs.map((c) => c.context).join(", ")}`
247
- );
248
- const finalAppConfigs = [...appConfigs];
249
- if (options.additionalConfigs) {
250
- finalAppConfigs.push(...options.additionalConfigs);
41
+ #getCycleKey(path) {
42
+ return path.map((n) => this.#nodeIds.get(n)).sort().join(",");
251
43
  }
252
- config$1.setConfig(config.ConfigReader.fromConfigs(finalAppConfigs));
253
- return { config: config$1 };
254
- }
255
-
256
- const DEFAULT_PORT = 7007;
257
- const DEFAULT_HOST = "";
258
- function readHttpServerOptions$1(config) {
259
- return {
260
- listen: readHttpListenOptions(config),
261
- https: readHttpsOptions(config)
262
- };
263
44
  }
264
- function readHttpListenOptions(config) {
265
- const listen = config?.getOptional("listen");
266
- if (typeof listen === "string") {
267
- const parts = String(listen).split(":");
268
- const port = parseInt(parts[parts.length - 1], 10);
269
- if (!isNaN(port)) {
270
- if (parts.length === 1) {
271
- return { port, host: DEFAULT_HOST };
272
- }
273
- if (parts.length === 2) {
274
- return { host: parts[0], port };
275
- }
276
- }
277
- throw new Error(
278
- `Unable to parse listen address ${listen}, expected <port> or <host>:<port>`
45
+ class DependencyGraph {
46
+ static fromMap(nodes) {
47
+ return this.fromIterable(
48
+ Object.entries(nodes).map(([key, node]) => ({
49
+ value: String(key),
50
+ ...node
51
+ }))
279
52
  );
280
53
  }
281
- const host = config?.getOptional("listen.host") ?? DEFAULT_HOST;
282
- if (typeof host !== "string") {
283
- config?.getOptionalString("listen.host");
284
- throw new Error("unreachable");
285
- }
286
- return {
287
- port: config?.getOptionalNumber("listen.port") ?? DEFAULT_PORT,
288
- host
289
- };
290
- }
291
- function readHttpsOptions(config) {
292
- const https = config?.getOptional("https");
293
- if (https === true) {
294
- const baseUrl = config.getString("baseUrl");
295
- let hostname;
296
- try {
297
- hostname = new URL(baseUrl).hostname;
298
- } catch (error) {
299
- throw new Error(`Invalid baseUrl "${baseUrl}"`);
300
- }
301
- return { certificate: { type: "generated", hostname } };
302
- }
303
- const cc = config?.getOptionalConfig("https");
304
- if (!cc) {
305
- return void 0;
306
- }
307
- return {
308
- certificate: {
309
- type: "pem",
310
- cert: cc.getString("certificate.cert"),
311
- key: cc.getString("certificate.key")
54
+ static fromIterable(nodeInputs) {
55
+ const nodes = new Array();
56
+ for (const nodeInput of nodeInputs) {
57
+ nodes.push(Node.from(nodeInput));
312
58
  }
313
- };
314
- }
315
-
316
- const FIVE_DAYS_IN_MS = 5 * 24 * 60 * 60 * 1e3;
317
- const IP_HOSTNAME_REGEX = /:|^\d+\.\d+\.\d+\.\d+$/;
318
- async function getGeneratedCertificate(hostname, logger) {
319
- const hasModules = await fs__default.default.pathExists("node_modules");
320
- let certPath;
321
- if (hasModules) {
322
- certPath = path.resolve(
323
- "node_modules/.cache/backstage-backend/dev-cert.pem"
324
- );
325
- await fs__default.default.ensureDir(path.dirname(certPath));
326
- } else {
327
- certPath = path.resolve(".dev-cert.pem");
59
+ return new DependencyGraph(nodes);
328
60
  }
329
- if (await fs__default.default.pathExists(certPath)) {
330
- try {
331
- const cert = await fs__default.default.readFile(certPath);
332
- const crt = forge__default.default.pki.certificateFromPem(cert.toString());
333
- const remainingMs = crt.validity.notAfter.getTime() - Date.now();
334
- if (remainingMs > FIVE_DAYS_IN_MS) {
335
- logger.info("Using existing self-signed certificate");
336
- return {
337
- key: cert,
338
- cert
339
- };
61
+ #nodes;
62
+ #allProvided;
63
+ constructor(nodes) {
64
+ this.#nodes = nodes;
65
+ this.#allProvided = /* @__PURE__ */ new Set();
66
+ for (const node of this.#nodes.values()) {
67
+ for (const produced of node.provides) {
68
+ this.#allProvided.add(produced);
340
69
  }
341
- } catch (error) {
342
- logger.warn(`Unable to use existing self-signed certificate, ${error}`);
343
70
  }
344
71
  }
345
- logger.info("Generating new self-signed certificate");
346
- const newCert = await generateCertificate(hostname);
347
- await fs__default.default.writeFile(certPath, newCert.cert + newCert.key, "utf8");
348
- return newCert;
349
- }
350
- async function generateCertificate(hostname) {
351
- const attributes = [
352
- {
353
- name: "commonName",
354
- value: "dev-cert"
355
- }
356
- ];
357
- const sans = [
358
- {
359
- type: 2,
360
- // DNS
361
- value: "localhost"
362
- },
363
- {
364
- type: 2,
365
- value: "localhost.localdomain"
366
- },
367
- {
368
- type: 2,
369
- value: "[::1]"
370
- },
371
- {
372
- type: 7,
373
- // IP
374
- ip: "127.0.0.1"
375
- },
376
- {
377
- type: 7,
378
- ip: "fe80::1"
379
- }
380
- ];
381
- if (!sans.find(({ value, ip }) => value === hostname || ip === hostname)) {
382
- sans.push(
383
- IP_HOSTNAME_REGEX.test(hostname) ? {
384
- type: 7,
385
- ip: hostname
386
- } : {
387
- type: 2,
388
- value: hostname
389
- }
390
- );
391
- }
392
- const params = {
393
- algorithm: "sha256",
394
- keySize: 2048,
395
- days: 30,
396
- extensions: [
397
- {
398
- name: "keyUsage",
399
- keyCertSign: true,
400
- digitalSignature: true,
401
- nonRepudiation: true,
402
- keyEncipherment: true,
403
- dataEncipherment: true
404
- },
405
- {
406
- name: "extKeyUsage",
407
- serverAuth: true,
408
- clientAuth: true,
409
- codeSigning: true,
410
- timeStamping: true
411
- },
412
- {
413
- name: "subjectAltName",
414
- altNames: sans
415
- }
416
- ]
417
- };
418
- return new Promise(
419
- (resolve, reject) => require("selfsigned").generate(
420
- attributes,
421
- params,
422
- (err, bundle) => {
423
- if (err) {
424
- reject(err);
425
- } else {
426
- resolve({ key: bundle.private, cert: bundle.cert });
427
- }
428
- }
429
- )
430
- );
431
- }
432
-
433
- async function createHttpServer$1(listener, options, deps) {
434
- const server = await createServer(listener, options, deps);
435
- const stopper = stoppableServer__default.default(server, 0);
436
- const stopServer = stopper.stop.bind(stopper);
437
- return Object.assign(server, {
438
- start() {
439
- return new Promise((resolve, reject) => {
440
- const handleStartupError = (error) => {
441
- server.close();
442
- reject(error);
443
- };
444
- server.on("error", handleStartupError);
445
- const { host, port } = options.listen;
446
- server.listen(port, host, () => {
447
- server.off("error", handleStartupError);
448
- deps.logger.info(`Listening on ${host}:${port}`);
449
- resolve();
450
- });
451
- });
452
- },
453
- stop() {
454
- return new Promise((resolve, reject) => {
455
- stopServer((error) => {
456
- if (error) {
457
- reject(error);
458
- } else {
459
- resolve();
460
- }
461
- });
462
- });
463
- },
464
- port() {
465
- const address = server.address();
466
- if (typeof address === "string" || address === null) {
467
- throw new Error(`Unexpected server address '${address}'`);
468
- }
469
- return address.port;
470
- }
471
- });
472
- }
473
- async function createServer(listener, options, deps) {
474
- if (options.https) {
475
- const { certificate } = options.https;
476
- if (certificate.type === "generated") {
477
- const credentials = await getGeneratedCertificate(
478
- certificate.hostname,
479
- deps.logger
72
+ /**
73
+ * Find all nodes that consume dependencies that are not provided by any other node.
74
+ */
75
+ findUnsatisfiedDeps() {
76
+ const unsatisfiedDependencies = [];
77
+ for (const node of this.#nodes.values()) {
78
+ const unsatisfied = Array.from(node.consumes).filter(
79
+ (id) => !this.#allProvided.has(id)
480
80
  );
481
- return https__namespace.createServer(credentials, listener);
482
- }
483
- return https__namespace.createServer(certificate, listener);
484
- }
485
- return http__namespace.createServer(listener);
486
- }
487
-
488
- function readHelmetOptions$1(config) {
489
- const cspOptions = readCspDirectives(config);
490
- return {
491
- contentSecurityPolicy: {
492
- useDefaults: false,
493
- directives: applyCspDirectives(cspOptions)
494
- },
495
- // These are all disabled in order to maintain backwards compatibility
496
- // when bumping helmet v5. We can't enable these by default because
497
- // there is no way for users to configure them.
498
- // TODO(Rugvip): We should give control of this setup to consumers
499
- crossOriginEmbedderPolicy: false,
500
- crossOriginOpenerPolicy: false,
501
- crossOriginResourcePolicy: false,
502
- originAgentCluster: false
503
- };
504
- }
505
- function readCspDirectives(config) {
506
- const cc = config?.getOptionalConfig("csp");
507
- if (!cc) {
508
- return void 0;
509
- }
510
- const result = {};
511
- for (const key of cc.keys()) {
512
- if (cc.get(key) === false) {
513
- result[key] = false;
514
- } else {
515
- result[key] = cc.getStringArray(key);
516
- }
517
- }
518
- return result;
519
- }
520
- function applyCspDirectives(directives) {
521
- const result = helmet__default.default.contentSecurityPolicy.getDefaultDirectives();
522
- result["script-src"] = ["'self'", "'unsafe-eval'"];
523
- delete result["form-action"];
524
- if (directives) {
525
- for (const [key, value] of Object.entries(directives)) {
526
- const kebabCaseKey = kebabCase__default.default(key);
527
- if (value === false) {
528
- delete result[kebabCaseKey];
529
- } else {
530
- result[kebabCaseKey] = value;
81
+ if (unsatisfied.length > 0) {
82
+ unsatisfiedDependencies.push({ value: node.value, unsatisfied });
531
83
  }
532
84
  }
533
- }
534
- return result;
535
- }
536
-
537
- function readCorsOptions$1(config) {
538
- const cc = config?.getOptionalConfig("cors");
539
- if (!cc) {
540
- return { origin: false };
541
- }
542
- return removeUnknown({
543
- origin: createCorsOriginMatcher(readStringArray(cc, "origin")),
544
- methods: readStringArray(cc, "methods"),
545
- allowedHeaders: readStringArray(cc, "allowedHeaders"),
546
- exposedHeaders: readStringArray(cc, "exposedHeaders"),
547
- credentials: cc.getOptionalBoolean("credentials"),
548
- maxAge: cc.getOptionalNumber("maxAge"),
549
- preflightContinue: cc.getOptionalBoolean("preflightContinue"),
550
- optionsSuccessStatus: cc.getOptionalNumber("optionsSuccessStatus")
551
- });
552
- }
553
- function removeUnknown(obj) {
554
- return Object.fromEntries(
555
- Object.entries(obj).filter(([, v]) => v !== void 0)
556
- );
557
- }
558
- function readStringArray(config, key) {
559
- const value = config.getOptional(key);
560
- if (typeof value === "string") {
561
- return [value];
562
- } else if (!value) {
563
- return void 0;
564
- }
565
- return config.getStringArray(key);
566
- }
567
- function createCorsOriginMatcher(allowedOriginPatterns) {
568
- if (!allowedOriginPatterns) {
569
- return void 0;
570
- }
571
- const allowedOriginMatchers = allowedOriginPatterns.map(
572
- (pattern) => new minimatch.Minimatch(pattern, { nocase: true, noglobstar: true })
573
- );
574
- return (origin, callback) => {
575
- return callback(
576
- null,
577
- allowedOriginMatchers.some((pattern) => pattern.match(origin ?? ""))
578
- );
579
- };
580
- }
581
-
582
- function handleBadError(error, logger) {
583
- const logId = crypto.randomBytes(10).toString("hex");
584
- logger.child({ logId }).error(`Filtered internal error with logId=${logId} from response`, error);
585
- const newError = new Error(`An internal error occurred logId=${logId}`);
586
- delete newError.stack;
587
- return newError;
588
- }
589
- function applyInternalErrorFilter(error, logger) {
590
- try {
591
- errors.assertError(error);
592
- } catch (assertionError) {
593
- errors.assertError(assertionError);
594
- return handleBadError(assertionError, logger);
595
- }
596
- const constructorName = error.constructor.name;
597
- if (constructorName === "DatabaseError") {
598
- return handleBadError(error, logger);
599
- }
600
- return error;
601
- }
602
-
603
- let MiddlewareFactory$1 = class MiddlewareFactory {
604
- #config;
605
- #logger;
606
- /**
607
- * Creates a new {@link MiddlewareFactory}.
608
- */
609
- static create(options) {
610
- return new MiddlewareFactory(options);
611
- }
612
- constructor(options) {
613
- this.#config = options.config;
614
- this.#logger = options.logger;
85
+ return unsatisfiedDependencies;
615
86
  }
616
87
  /**
617
- * Returns a middleware that unconditionally produces a 404 error response.
618
- *
619
- * @remarks
620
- *
621
- * Typically you want to place this middleware at the end of the chain, such
622
- * that it's the last one attempted after no other routes matched.
623
- *
624
- * @returns An Express request handler
88
+ * Detect the first circular dependency within the graph, returning the path of nodes that
89
+ * form a cycle, with the same node as the first and last element of the array.
625
90
  */
626
- notFound() {
627
- return (_req, res) => {
628
- res.status(404).end();
629
- };
91
+ detectCircularDependency() {
92
+ return this.detectCircularDependencies().next().value;
630
93
  }
631
94
  /**
632
- * Returns the compression middleware.
633
- *
634
- * @remarks
635
- *
636
- * The middleware will attempt to compress response bodies for all requests
637
- * that traverse through the middleware.
95
+ * Detect circular dependencies within the graph, returning the path of nodes that
96
+ * form a cycle, with the same node as the first and last element of the array.
638
97
  */
639
- compression() {
640
- return compression__default.default();
98
+ *detectCircularDependencies() {
99
+ const cycleKeys = CycleKeySet.from(this.#nodes);
100
+ for (const startNode of this.#nodes) {
101
+ const visited = /* @__PURE__ */ new Set();
102
+ const stack = new Array([
103
+ startNode,
104
+ [startNode.value]
105
+ ]);
106
+ while (stack.length > 0) {
107
+ const [node, path] = stack.pop();
108
+ if (visited.has(node)) {
109
+ continue;
110
+ }
111
+ visited.add(node);
112
+ for (const consumed of node.consumes) {
113
+ const providerNodes = this.#nodes.filter(
114
+ (other) => other.provides.has(consumed)
115
+ );
116
+ for (const provider of providerNodes) {
117
+ if (provider === startNode) {
118
+ if (cycleKeys.tryAdd(path)) {
119
+ yield [...path, startNode.value];
120
+ }
121
+ break;
122
+ }
123
+ if (!visited.has(provider)) {
124
+ stack.push([provider, [...path, provider.value]]);
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return void 0;
641
131
  }
642
132
  /**
643
- * Returns a request logging middleware.
644
- *
645
- * @remarks
133
+ * Traverses the dependency graph in topological order, calling the provided
134
+ * function for each node and waiting for it to resolve.
646
135
  *
647
- * Typically you want to place this middleware at the start of the chain, such
648
- * that it always logs requests whether they are "caught" by handlers farther
649
- * down or not.
136
+ * The nodes are traversed in parallel, but in such a way that no node is
137
+ * visited before all of its dependencies.
650
138
  *
651
- * @returns An Express request handler
139
+ * Dependencies of nodes that are not produced by any other nodes will be ignored.
652
140
  */
653
- logging() {
654
- const logger = this.#logger.child({
655
- type: "incomingRequest"
656
- });
657
- return morgan__default.default("combined", {
658
- stream: {
659
- write(message) {
660
- logger.info(message.trimEnd());
661
- }
662
- }
663
- });
664
- }
665
- /**
666
- * Returns a middleware that implements the helmet library.
667
- *
668
- * @remarks
669
- *
670
- * This middleware applies security policies to incoming requests and outgoing
671
- * responses. It is configured using config keys such as `backend.csp`.
672
- *
673
- * @see {@link https://helmetjs.github.io/}
674
- *
675
- * @returns An Express request handler
676
- */
677
- helmet() {
678
- return helmet__default.default(readHelmetOptions$1(this.#config.getOptionalConfig("backend")));
679
- }
680
- /**
681
- * Returns a middleware that implements the cors library.
682
- *
683
- * @remarks
684
- *
685
- * This middleware handles CORS. It is configured using the config key
686
- * `backend.cors`.
687
- *
688
- * @see {@link https://github.com/expressjs/cors}
689
- *
690
- * @returns An Express request handler
691
- */
692
- cors() {
693
- return cors__default.default(readCorsOptions$1(this.#config.getOptionalConfig("backend")));
694
- }
695
- /**
696
- * Express middleware to handle errors during request processing.
697
- *
698
- * @remarks
699
- *
700
- * This is commonly the very last middleware in the chain.
701
- *
702
- * Its primary purpose is not to do translation of business logic exceptions,
703
- * but rather to be a global catch-all for uncaught "fatal" errors that are
704
- * expected to result in a 500 error. However, it also does handle some common
705
- * error types (such as http-error exceptions, and the well-known error types
706
- * in the `@backstage/errors` package) and returns the enclosed status code
707
- * accordingly.
708
- *
709
- * It will also produce a response body with a serialized form of the error,
710
- * unless a previous handler already did send a body. See
711
- * {@link @backstage/errors#ErrorResponseBody} for the response shape used.
712
- *
713
- * @returns An Express error request handler
714
- */
715
- error(options = {}) {
716
- const showStackTraces = options.showStackTraces ?? process.env.NODE_ENV === "development";
717
- const logger = this.#logger.child({
718
- type: "errorHandler"
719
- });
720
- return (rawError, req, res, next) => {
721
- const error = applyInternalErrorFilter(rawError, logger);
722
- const statusCode = getStatusCode(error);
723
- if (options.logAllErrors || statusCode >= 500) {
724
- logger.error(`Request failed with status ${statusCode}`, error);
725
- }
726
- if (res.headersSent) {
727
- next(error);
141
+ async parallelTopologicalTraversal(fn) {
142
+ const allProvided = this.#allProvided;
143
+ const producedSoFar = /* @__PURE__ */ new Set();
144
+ const waiting = new Set(this.#nodes.values());
145
+ const visited = /* @__PURE__ */ new Set();
146
+ const results = new Array();
147
+ let inFlight = 0;
148
+ async function processMoreNodes() {
149
+ if (waiting.size === 0) {
728
150
  return;
729
151
  }
730
- const body = {
731
- error: errors.serializeError(error, { includeStack: showStackTraces }),
732
- request: { method: req.method, url: req.url },
733
- response: { statusCode }
734
- };
735
- res.status(statusCode).json(body);
736
- };
737
- }
738
- };
739
- function getStatusCode(error) {
740
- const knownStatusCodeFields = ["statusCode", "status"];
741
- for (const field of knownStatusCodeFields) {
742
- const statusCode = error[field];
743
- if (typeof statusCode === "number" && (statusCode | 0) === statusCode && // is whole integer
744
- statusCode >= 100 && statusCode <= 599) {
745
- return statusCode;
746
- }
747
- }
748
- switch (error.name) {
749
- case errors.NotModifiedError.name:
750
- return 304;
751
- case errors.InputError.name:
752
- return 400;
753
- case errors.AuthenticationError.name:
754
- return 401;
755
- case errors.NotAllowedError.name:
756
- return 403;
757
- case errors.NotFoundError.name:
758
- return 404;
759
- case errors.ConflictError.name:
760
- return 409;
761
- case errors.NotImplementedError.name:
762
- return 501;
763
- case errors.ServiceUnavailableError.name:
764
- return 503;
765
- }
766
- return 500;
767
- }
768
-
769
- const readHttpServerOptions = readHttpServerOptions$1;
770
- const createHttpServer = createHttpServer$1;
771
- const readCorsOptions = readCorsOptions$1;
772
- const readHelmetOptions = readHelmetOptions$1;
773
- class MiddlewareFactory {
774
- constructor(impl) {
775
- this.impl = impl;
776
- }
777
- /**
778
- * Creates a new {@link MiddlewareFactory}.
779
- */
780
- static create(options) {
781
- return new MiddlewareFactory(MiddlewareFactory$1.create(options));
782
- }
783
- /**
784
- * Returns a middleware that unconditionally produces a 404 error response.
785
- *
786
- * @remarks
787
- *
788
- * Typically you want to place this middleware at the end of the chain, such
789
- * that it's the last one attempted after no other routes matched.
790
- *
791
- * @returns An Express request handler
792
- */
793
- notFound() {
794
- return this.impl.notFound();
795
- }
796
- /**
797
- * Returns the compression middleware.
798
- *
799
- * @remarks
800
- *
801
- * The middleware will attempt to compress response bodies for all requests
802
- * that traverse through the middleware.
803
- */
804
- compression() {
805
- return this.impl.compression();
806
- }
807
- /**
808
- * Returns a request logging middleware.
809
- *
810
- * @remarks
811
- *
812
- * Typically you want to place this middleware at the start of the chain, such
813
- * that it always logs requests whether they are "caught" by handlers farther
814
- * down or not.
815
- *
816
- * @returns An Express request handler
817
- */
818
- logging() {
819
- return this.impl.logging();
820
- }
821
- /**
822
- * Returns a middleware that implements the helmet library.
823
- *
824
- * @remarks
825
- *
826
- * This middleware applies security policies to incoming requests and outgoing
827
- * responses. It is configured using config keys such as `backend.csp`.
828
- *
829
- * @see {@link https://helmetjs.github.io/}
830
- *
831
- * @returns An Express request handler
832
- */
833
- helmet() {
834
- return this.impl.helmet();
835
- }
836
- /**
837
- * Returns a middleware that implements the cors library.
838
- *
839
- * @remarks
840
- *
841
- * This middleware handles CORS. It is configured using the config key
842
- * `backend.cors`.
843
- *
844
- * @see {@link https://github.com/expressjs/cors}
845
- *
846
- * @returns An Express request handler
847
- */
848
- cors() {
849
- return this.impl.cors();
850
- }
851
- /**
852
- * Express middleware to handle errors during request processing.
853
- *
854
- * @remarks
855
- *
856
- * This is commonly the very last middleware in the chain.
857
- *
858
- * Its primary purpose is not to do translation of business logic exceptions,
859
- * but rather to be a global catch-all for uncaught "fatal" errors that are
860
- * expected to result in a 500 error. However, it also does handle some common
861
- * error types (such as http-error exceptions, and the well-known error types
862
- * in the `@backstage/errors` package) and returns the enclosed status code
863
- * accordingly.
864
- *
865
- * It will also produce a response body with a serialized form of the error,
866
- * unless a previous handler already did send a body. See
867
- * {@link @backstage/errors#ErrorResponseBody} for the response shape used.
868
- *
869
- * @returns An Express error request handler
870
- */
871
- error(options = {}) {
872
- return this.impl.error(options);
873
- }
874
- }
875
-
876
- const escapeRegExp = (text) => {
877
- return text.replace(/[.*+?^${}(\)|[\]\\]/g, "\\$&");
878
- };
879
-
880
- let WinstonLogger$1 = class WinstonLogger {
881
- #winston;
882
- #addRedactions;
883
- /**
884
- * Creates a {@link WinstonLogger} instance.
885
- */
886
- static create(options) {
887
- const redacter = WinstonLogger.redacter();
888
- const defaultFormatter = process.env.NODE_ENV === "production" ? winston.format.json() : WinstonLogger.colorFormat();
889
- let logger = winston.createLogger({
890
- level: process.env.LOG_LEVEL || options.level || "info",
891
- format: winston.format.combine(
892
- options.format ?? defaultFormatter,
893
- redacter.format
894
- ),
895
- transports: options.transports ?? new winston.transports.Console()
896
- });
897
- if (options.meta) {
898
- logger = logger.child(options.meta);
899
- }
900
- return new WinstonLogger(logger, redacter.add);
901
- }
902
- /**
903
- * Creates a winston log formatter for redacting secrets.
904
- */
905
- static redacter() {
906
- const redactionSet = /* @__PURE__ */ new Set();
907
- let redactionPattern = void 0;
908
- return {
909
- format: winston.format((obj) => {
910
- if (!redactionPattern || !obj) {
911
- return obj;
912
- }
913
- obj[tripleBeam.MESSAGE] = obj[tripleBeam.MESSAGE]?.replace?.(redactionPattern, "***");
914
- return obj;
915
- })(),
916
- add(newRedactions) {
917
- let added = 0;
918
- for (const redactionToTrim of newRedactions) {
919
- const redaction = redactionToTrim.trim();
920
- if (redaction.length <= 1) {
921
- continue;
922
- }
923
- if (!redactionSet.has(redaction)) {
924
- redactionSet.add(redaction);
925
- added += 1;
926
- }
927
- }
928
- if (added > 0) {
929
- const redactions = Array.from(redactionSet).map((r) => escapeRegExp(r)).join("|");
930
- redactionPattern = new RegExp(`(${redactions})`, "g");
931
- }
932
- }
933
- };
934
- }
935
- /**
936
- * Creates a pretty printed winston log formatter.
937
- */
938
- static colorFormat() {
939
- const colorizer = winston.format.colorize();
940
- return winston.format.combine(
941
- winston.format.timestamp(),
942
- winston.format.colorize({
943
- colors: {
944
- timestamp: "dim",
945
- prefix: "blue",
946
- field: "cyan",
947
- debug: "grey"
948
- }
949
- }),
950
- winston.format.printf((info) => {
951
- const { timestamp, level, message, plugin, service, ...fields } = info;
952
- const prefix = plugin || service;
953
- const timestampColor = colorizer.colorize("timestamp", timestamp);
954
- const prefixColor = colorizer.colorize("prefix", prefix);
955
- const extraFields = Object.entries(fields).map(
956
- ([key, value]) => `${colorizer.colorize("field", `${key}`)}=${value}`
957
- ).join(" ");
958
- return `${timestampColor} ${prefixColor} ${level} ${message} ${extraFields}`;
959
- })
960
- );
961
- }
962
- constructor(winston, addRedactions) {
963
- this.#winston = winston;
964
- this.#addRedactions = addRedactions;
965
- }
966
- error(message, meta) {
967
- this.#winston.error(message, meta);
968
- }
969
- warn(message, meta) {
970
- this.#winston.warn(message, meta);
971
- }
972
- info(message, meta) {
973
- this.#winston.info(message, meta);
974
- }
975
- debug(message, meta) {
976
- this.#winston.debug(message, meta);
977
- }
978
- child(meta) {
979
- return new WinstonLogger(this.#winston.child(meta));
980
- }
981
- addRedactions(redactions) {
982
- this.#addRedactions?.(redactions);
983
- }
984
- };
985
-
986
- const rootLoggerServiceFactory$1 = backendPluginApi.createServiceFactory({
987
- service: backendPluginApi.coreServices.rootLogger,
988
- deps: {
989
- config: backendPluginApi.coreServices.rootConfig
990
- },
991
- async factory({ config }) {
992
- const logger = WinstonLogger$1.create({
993
- meta: {
994
- service: "backstage"
995
- },
996
- level: process.env.LOG_LEVEL || "info",
997
- format: process.env.NODE_ENV === "production" ? winston.format.json() : WinstonLogger$1.colorFormat(),
998
- transports: [new winston.transports.Console()]
999
- });
1000
- const secretEnumerator = await createConfigSecretEnumerator$1({ logger });
1001
- logger.addRedactions(secretEnumerator(config));
1002
- config.subscribe?.(() => logger.addRedactions(secretEnumerator(config)));
1003
- return logger;
1004
- }
1005
- });
1006
-
1007
- class WinstonLogger {
1008
- constructor(impl) {
1009
- this.impl = impl;
1010
- }
1011
- /**
1012
- * Creates a {@link WinstonLogger} instance.
1013
- */
1014
- static create(options) {
1015
- return new WinstonLogger(WinstonLogger$1.create(options));
1016
- }
1017
- /**
1018
- * Creates a winston log formatter for redacting secrets.
1019
- */
1020
- static redacter() {
1021
- return WinstonLogger$1.redacter();
1022
- }
1023
- /**
1024
- * Creates a pretty printed winston log formatter.
1025
- */
1026
- static colorFormat() {
1027
- return WinstonLogger$1.colorFormat();
1028
- }
1029
- error(message, meta) {
1030
- this.impl.error(message, meta);
1031
- }
1032
- warn(message, meta) {
1033
- this.impl.warn(message, meta);
1034
- }
1035
- info(message, meta) {
1036
- this.impl.info(message, meta);
1037
- }
1038
- debug(message, meta) {
1039
- this.impl.debug(message, meta);
1040
- }
1041
- child(meta) {
1042
- return this.impl.child(meta);
1043
- }
1044
- addRedactions(redactions) {
1045
- this.impl.addRedactions(redactions);
1046
- }
1047
- }
1048
-
1049
- class Node {
1050
- constructor(value, consumes, provides) {
1051
- this.value = value;
1052
- this.consumes = consumes;
1053
- this.provides = provides;
1054
- }
1055
- static from(input) {
1056
- return new Node(
1057
- input.value,
1058
- input.consumes ? new Set(input.consumes) : /* @__PURE__ */ new Set(),
1059
- input.provides ? new Set(input.provides) : /* @__PURE__ */ new Set()
1060
- );
1061
- }
1062
- }
1063
- class CycleKeySet {
1064
- static from(nodes) {
1065
- return new CycleKeySet(nodes);
1066
- }
1067
- #nodeIds;
1068
- #cycleKeys;
1069
- constructor(nodes) {
1070
- this.#nodeIds = new Map(nodes.map((n, i) => [n.value, i]));
1071
- this.#cycleKeys = /* @__PURE__ */ new Set();
1072
- }
1073
- tryAdd(path) {
1074
- const cycleKey = this.#getCycleKey(path);
1075
- if (this.#cycleKeys.has(cycleKey)) {
1076
- return false;
1077
- }
1078
- this.#cycleKeys.add(cycleKey);
1079
- return true;
1080
- }
1081
- #getCycleKey(path) {
1082
- return path.map((n) => this.#nodeIds.get(n)).sort().join(",");
1083
- }
1084
- }
1085
- class DependencyGraph {
1086
- static fromMap(nodes) {
1087
- return this.fromIterable(
1088
- Object.entries(nodes).map(([key, node]) => ({
1089
- value: String(key),
1090
- ...node
1091
- }))
1092
- );
1093
- }
1094
- static fromIterable(nodeInputs) {
1095
- const nodes = new Array();
1096
- for (const nodeInput of nodeInputs) {
1097
- nodes.push(Node.from(nodeInput));
1098
- }
1099
- return new DependencyGraph(nodes);
1100
- }
1101
- #nodes;
1102
- #allProvided;
1103
- constructor(nodes) {
1104
- this.#nodes = nodes;
1105
- this.#allProvided = /* @__PURE__ */ new Set();
1106
- for (const node of this.#nodes.values()) {
1107
- for (const produced of node.provides) {
1108
- this.#allProvided.add(produced);
1109
- }
1110
- }
1111
- }
1112
- /**
1113
- * Find all nodes that consume dependencies that are not provided by any other node.
1114
- */
1115
- findUnsatisfiedDeps() {
1116
- const unsatisfiedDependencies = [];
1117
- for (const node of this.#nodes.values()) {
1118
- const unsatisfied = Array.from(node.consumes).filter(
1119
- (id) => !this.#allProvided.has(id)
1120
- );
1121
- if (unsatisfied.length > 0) {
1122
- unsatisfiedDependencies.push({ value: node.value, unsatisfied });
1123
- }
1124
- }
1125
- return unsatisfiedDependencies;
1126
- }
1127
- /**
1128
- * Detect the first circular dependency within the graph, returning the path of nodes that
1129
- * form a cycle, with the same node as the first and last element of the array.
1130
- */
1131
- detectCircularDependency() {
1132
- return this.detectCircularDependencies().next().value;
1133
- }
1134
- /**
1135
- * Detect circular dependencies within the graph, returning the path of nodes that
1136
- * form a cycle, with the same node as the first and last element of the array.
1137
- */
1138
- *detectCircularDependencies() {
1139
- const cycleKeys = CycleKeySet.from(this.#nodes);
1140
- for (const startNode of this.#nodes) {
1141
- const visited = /* @__PURE__ */ new Set();
1142
- const stack = new Array([
1143
- startNode,
1144
- [startNode.value]
1145
- ]);
1146
- while (stack.length > 0) {
1147
- const [node, path] = stack.pop();
1148
- if (visited.has(node)) {
1149
- continue;
1150
- }
1151
- visited.add(node);
1152
- for (const consumed of node.consumes) {
1153
- const providerNodes = this.#nodes.filter(
1154
- (other) => other.provides.has(consumed)
1155
- );
1156
- for (const provider of providerNodes) {
1157
- if (provider === startNode) {
1158
- if (cycleKeys.tryAdd(path)) {
1159
- yield [...path, startNode.value];
1160
- }
1161
- break;
1162
- }
1163
- if (!visited.has(provider)) {
1164
- stack.push([provider, [...path, provider.value]]);
1165
- }
1166
- }
1167
- }
1168
- }
1169
- }
1170
- return void 0;
1171
- }
1172
- /**
1173
- * Traverses the dependency graph in topological order, calling the provided
1174
- * function for each node and waiting for it to resolve.
1175
- *
1176
- * The nodes are traversed in parallel, but in such a way that no node is
1177
- * visited before all of its dependencies.
1178
- *
1179
- * Dependencies of nodes that are not produced by any other nodes will be ignored.
1180
- */
1181
- async parallelTopologicalTraversal(fn) {
1182
- const allProvided = this.#allProvided;
1183
- const producedSoFar = /* @__PURE__ */ new Set();
1184
- const waiting = new Set(this.#nodes.values());
1185
- const visited = /* @__PURE__ */ new Set();
1186
- const results = new Array();
1187
- let inFlight = 0;
1188
- async function processMoreNodes() {
1189
- if (waiting.size === 0) {
1190
- return;
1191
- }
1192
- const nodesToProcess = [];
1193
- for (const node of waiting) {
1194
- let ready = true;
1195
- for (const consumed of node.consumes) {
1196
- if (allProvided.has(consumed) && !producedSoFar.has(consumed)) {
1197
- ready = false;
1198
- continue;
1199
- }
1200
- }
1201
- if (ready) {
1202
- nodesToProcess.push(node);
1203
- }
1204
- }
1205
- for (const node of nodesToProcess) {
1206
- waiting.delete(node);
1207
- }
1208
- if (nodesToProcess.length === 0 && inFlight === 0) {
1209
- throw new Error("Circular dependency detected");
1210
- }
1211
- await Promise.all(nodesToProcess.map(processNode));
1212
- }
1213
- async function processNode(node) {
1214
- visited.add(node);
1215
- inFlight += 1;
1216
- const result = await fn(node.value);
1217
- results.push(result);
1218
- node.provides.forEach((produced) => producedSoFar.add(produced));
1219
- inFlight -= 1;
1220
- await processMoreNodes();
1221
- }
1222
- await processMoreNodes();
1223
- return results;
1224
- }
1225
- }
1226
-
1227
- function toInternalServiceFactory(factory) {
1228
- const f = factory;
1229
- if (f.$$type !== "@backstage/BackendFeature") {
1230
- throw new Error(`Invalid service factory, bad type '${f.$$type}'`);
1231
- }
1232
- if (f.version !== "v1") {
1233
- throw new Error(`Invalid service factory, bad version '${f.version}'`);
1234
- }
1235
- return f;
1236
- }
1237
- function createPluginMetadataServiceFactory(pluginId) {
1238
- return backendPluginApi.createServiceFactory({
1239
- service: backendPluginApi.coreServices.pluginMetadata,
1240
- deps: {},
1241
- factory: async () => ({ getId: () => pluginId })
1242
- });
1243
- }
1244
- class ServiceRegistry {
1245
- static create(factories) {
1246
- const factoryMap = /* @__PURE__ */ new Map();
1247
- for (const factory of factories) {
1248
- if (factory.service.multiton) {
1249
- const existing = factoryMap.get(factory.service.id) ?? [];
1250
- factoryMap.set(
1251
- factory.service.id,
1252
- existing.concat(toInternalServiceFactory(factory))
1253
- );
1254
- } else {
1255
- factoryMap.set(factory.service.id, [toInternalServiceFactory(factory)]);
1256
- }
1257
- }
1258
- const registry = new ServiceRegistry(factoryMap);
1259
- registry.checkForCircularDeps();
1260
- return registry;
1261
- }
1262
- #providedFactories;
1263
- #loadedDefaultFactories;
1264
- #implementations;
1265
- #rootServiceImplementations = /* @__PURE__ */ new Map();
1266
- #addedFactoryIds = /* @__PURE__ */ new Set();
1267
- #instantiatedFactories = /* @__PURE__ */ new Set();
1268
- constructor(factories) {
1269
- this.#providedFactories = factories;
1270
- this.#loadedDefaultFactories = /* @__PURE__ */ new Map();
1271
- this.#implementations = /* @__PURE__ */ new Map();
1272
- }
1273
- #resolveFactory(ref, pluginId) {
1274
- if (ref.id === backendPluginApi.coreServices.pluginMetadata.id) {
1275
- return Promise.resolve([
1276
- toInternalServiceFactory(createPluginMetadataServiceFactory(pluginId))
1277
- ]);
1278
- }
1279
- let resolvedFactory = this.#providedFactories.get(ref.id);
1280
- const { __defaultFactory: defaultFactory } = ref;
1281
- if (!resolvedFactory && !defaultFactory) {
1282
- return void 0;
1283
- }
1284
- if (!resolvedFactory) {
1285
- let loadedFactory = this.#loadedDefaultFactories.get(defaultFactory);
1286
- if (!loadedFactory) {
1287
- loadedFactory = Promise.resolve().then(() => defaultFactory(ref)).then(
1288
- (f) => toInternalServiceFactory(typeof f === "function" ? f() : f)
1289
- );
1290
- this.#loadedDefaultFactories.set(defaultFactory, loadedFactory);
1291
- }
1292
- resolvedFactory = loadedFactory.then(
1293
- (factory) => [factory],
1294
- (error) => {
1295
- throw new Error(
1296
- `Failed to instantiate service '${ref.id}' because the default factory loader threw an error, ${errors.stringifyError(
1297
- error
1298
- )}`
1299
- );
1300
- }
1301
- );
1302
- }
1303
- return Promise.resolve(resolvedFactory);
1304
- }
1305
- #checkForMissingDeps(factory, pluginId) {
1306
- const missingDeps = Object.values(factory.deps).filter((ref) => {
1307
- if (ref.id === backendPluginApi.coreServices.pluginMetadata.id) {
1308
- return false;
1309
- }
1310
- if (this.#providedFactories.get(ref.id)) {
1311
- return false;
1312
- }
1313
- if (ref.multiton) {
1314
- return false;
1315
- }
1316
- return !ref.__defaultFactory;
1317
- });
1318
- if (missingDeps.length) {
1319
- const missing = missingDeps.map((r) => `'${r.id}'`).join(", ");
1320
- throw new Error(
1321
- `Failed to instantiate service '${factory.service.id}' for '${pluginId}' because the following dependent services are missing: ${missing}`
1322
- );
1323
- }
1324
- }
1325
- checkForCircularDeps() {
1326
- const graph = DependencyGraph.fromIterable(
1327
- Array.from(this.#providedFactories).map(([serviceId, factories]) => ({
1328
- value: serviceId,
1329
- provides: [serviceId],
1330
- consumes: factories.flatMap(
1331
- (factory) => Object.values(factory.deps).map((d) => d.id)
1332
- )
1333
- }))
1334
- );
1335
- const circularDependencies = Array.from(graph.detectCircularDependencies());
1336
- if (circularDependencies.length) {
1337
- const cycles = circularDependencies.map((c) => c.map((id) => `'${id}'`).join(" -> ")).join("\n ");
1338
- throw new errors.ConflictError(`Circular dependencies detected:
1339
- ${cycles}`);
1340
- }
1341
- }
1342
- add(factory) {
1343
- const factoryId = factory.service.id;
1344
- if (factoryId === backendPluginApi.coreServices.pluginMetadata.id) {
1345
- throw new Error(
1346
- `The ${backendPluginApi.coreServices.pluginMetadata.id} service cannot be overridden`
1347
- );
1348
- }
1349
- if (this.#instantiatedFactories.has(factoryId)) {
1350
- throw new Error(
1351
- `Unable to set service factory with id ${factoryId}, service has already been instantiated`
1352
- );
1353
- }
1354
- if (factory.service.multiton) {
1355
- const newFactories = (this.#providedFactories.get(factoryId) ?? []).concat(toInternalServiceFactory(factory));
1356
- this.#providedFactories.set(factoryId, newFactories);
1357
- } else {
1358
- if (this.#addedFactoryIds.has(factoryId)) {
1359
- throw new Error(
1360
- `Duplicate service implementations provided for ${factoryId}`
1361
- );
1362
- }
1363
- this.#addedFactoryIds.add(factoryId);
1364
- this.#providedFactories.set(factoryId, [
1365
- toInternalServiceFactory(factory)
1366
- ]);
1367
- }
1368
- }
1369
- async initializeEagerServicesWithScope(scope, pluginId = "root") {
1370
- for (const [factory] of this.#providedFactories.values()) {
1371
- if (factory.service.scope === scope) {
1372
- if (scope === "root" && factory.initialization !== "lazy") {
1373
- await this.get(factory.service, pluginId);
1374
- } else if (scope === "plugin" && factory.initialization === "always") {
1375
- await this.get(factory.service, pluginId);
1376
- }
1377
- }
1378
- }
1379
- }
1380
- get(ref, pluginId) {
1381
- this.#instantiatedFactories.add(ref.id);
1382
- const resolvedFactory = this.#resolveFactory(ref, pluginId);
1383
- if (!resolvedFactory) {
1384
- return ref.multiton ? Promise.resolve([]) : void 0;
1385
- }
1386
- return resolvedFactory.then((factories) => {
1387
- return Promise.all(
1388
- factories.map((factory) => {
1389
- if (factory.service.scope === "root") {
1390
- let existing = this.#rootServiceImplementations.get(factory);
1391
- if (!existing) {
1392
- this.#checkForMissingDeps(factory, pluginId);
1393
- const rootDeps = new Array();
1394
- for (const [name, serviceRef] of Object.entries(factory.deps)) {
1395
- if (serviceRef.scope !== "root") {
1396
- throw new Error(
1397
- `Failed to instantiate 'root' scoped service '${ref.id}' because it depends on '${serviceRef.scope}' scoped service '${serviceRef.id}'.`
1398
- );
1399
- }
1400
- const target = this.get(serviceRef, pluginId);
1401
- rootDeps.push(target.then((impl) => [name, impl]));
1402
- }
1403
- existing = Promise.all(rootDeps).then(
1404
- (entries) => factory.factory(Object.fromEntries(entries), void 0)
1405
- );
1406
- this.#rootServiceImplementations.set(factory, existing);
1407
- }
1408
- return existing;
1409
- }
1410
- let implementation = this.#implementations.get(factory);
1411
- if (!implementation) {
1412
- this.#checkForMissingDeps(factory, pluginId);
1413
- const rootDeps = new Array();
1414
- for (const [name, serviceRef] of Object.entries(factory.deps)) {
1415
- if (serviceRef.scope === "root") {
1416
- const target = this.get(serviceRef, pluginId);
1417
- rootDeps.push(target.then((impl) => [name, impl]));
1418
- }
1419
- }
1420
- implementation = {
1421
- context: Promise.all(rootDeps).then(
1422
- (entries) => factory.createRootContext?.(Object.fromEntries(entries))
1423
- ).catch((error) => {
1424
- const cause = errors.stringifyError(error);
1425
- throw new Error(
1426
- `Failed to instantiate service '${ref.id}' because createRootContext threw an error, ${cause}`
1427
- );
1428
- }),
1429
- byPlugin: /* @__PURE__ */ new Map()
1430
- };
1431
- this.#implementations.set(factory, implementation);
1432
- }
1433
- let result = implementation.byPlugin.get(pluginId);
1434
- if (!result) {
1435
- const allDeps = new Array();
1436
- for (const [name, serviceRef] of Object.entries(factory.deps)) {
1437
- const target = this.get(serviceRef, pluginId);
1438
- allDeps.push(target.then((impl) => [name, impl]));
1439
- }
1440
- result = implementation.context.then(
1441
- (context) => Promise.all(allDeps).then(
1442
- (entries) => factory.factory(Object.fromEntries(entries), context)
1443
- )
1444
- ).catch((error) => {
1445
- const cause = errors.stringifyError(error);
1446
- throw new Error(
1447
- `Failed to instantiate service '${ref.id}' for '${pluginId}' because the factory function threw an error, ${cause}`
1448
- );
1449
- });
1450
- implementation.byPlugin.set(pluginId, result);
1451
- }
1452
- return result;
1453
- })
1454
- );
1455
- }).then((results) => ref.multiton ? results : results[0]);
1456
- }
1457
- }
1458
-
1459
- const LOGGER_INTERVAL_MAX = 6e4;
1460
- function joinIds(ids) {
1461
- return [...ids].map((id) => `'${id}'`).join(", ");
1462
- }
1463
- function createInitializationLogger(pluginIds, rootLogger) {
1464
- const logger = rootLogger?.child({ type: "initialization" });
1465
- const starting = new Set(pluginIds);
1466
- const started = /* @__PURE__ */ new Set();
1467
- logger?.info(`Plugin initialization started: ${joinIds(pluginIds)}`);
1468
- const getInitStatus = () => {
1469
- let status = "";
1470
- if (started.size > 0) {
1471
- status = `, newly initialized: ${joinIds(started)}`;
1472
- started.clear();
1473
- }
1474
- if (starting.size > 0) {
1475
- status += `, still initializing: ${joinIds(starting)}`;
1476
- }
1477
- return status;
1478
- };
1479
- let interval = 1e3;
1480
- let prevInterval = 0;
1481
- let timeout;
1482
- const onTimeout = () => {
1483
- logger?.info(`Plugin initialization in progress${getInitStatus()}`);
1484
- const nextInterval = Math.min(interval + prevInterval, LOGGER_INTERVAL_MAX);
1485
- prevInterval = interval;
1486
- interval = nextInterval;
1487
- timeout = setTimeout(onTimeout, nextInterval);
1488
- };
1489
- timeout = setTimeout(onTimeout, interval);
1490
- return {
1491
- onPluginStarted(pluginId) {
1492
- starting.delete(pluginId);
1493
- started.add(pluginId);
1494
- },
1495
- onAllStarted() {
1496
- logger?.info(`Plugin initialization complete${getInitStatus()}`);
1497
- if (timeout) {
1498
- clearTimeout(timeout);
1499
- timeout = void 0;
1500
- }
1501
- }
1502
- };
1503
- }
1504
-
1505
- class BackendInitializer {
1506
- #startPromise;
1507
- #registrations = new Array();
1508
- #extensionPoints = /* @__PURE__ */ new Map();
1509
- #serviceRegistry;
1510
- #registeredFeatures = new Array();
1511
- #registeredFeatureLoaders = new Array();
1512
- constructor(defaultApiFactories) {
1513
- this.#serviceRegistry = ServiceRegistry.create([...defaultApiFactories]);
1514
- }
1515
- async #getInitDeps(deps, pluginId, moduleId) {
1516
- const result = /* @__PURE__ */ new Map();
1517
- const missingRefs = /* @__PURE__ */ new Set();
1518
- for (const [name, ref] of Object.entries(deps)) {
1519
- const ep = this.#extensionPoints.get(ref.id);
1520
- if (ep) {
1521
- if (ep.pluginId !== pluginId) {
1522
- throw new Error(
1523
- `Illegal dependency: Module '${moduleId}' for plugin '${pluginId}' attempted to depend on extension point '${ref.id}' for plugin '${ep.pluginId}'. Extension points can only be used within their plugin's scope.`
1524
- );
1525
- }
1526
- result.set(name, ep.impl);
1527
- } else {
1528
- const impl = await this.#serviceRegistry.get(
1529
- ref,
1530
- pluginId
1531
- );
1532
- if (impl) {
1533
- result.set(name, impl);
1534
- } else {
1535
- missingRefs.add(ref);
1536
- }
1537
- }
1538
- }
1539
- if (missingRefs.size > 0) {
1540
- const missing = Array.from(missingRefs).join(", ");
1541
- throw new Error(
1542
- `No extension point or service available for the following ref(s): ${missing}`
1543
- );
1544
- }
1545
- return Object.fromEntries(result);
1546
- }
1547
- add(feature) {
1548
- if (this.#startPromise) {
1549
- throw new Error("feature can not be added after the backend has started");
1550
- }
1551
- this.#registeredFeatures.push(Promise.resolve(feature));
1552
- }
1553
- #addFeature(feature) {
1554
- if (isServiceFactory(feature)) {
1555
- this.#serviceRegistry.add(feature);
1556
- } else if (isBackendFeatureLoader(feature)) {
1557
- this.#registeredFeatureLoaders.push(feature);
1558
- } else if (isBackendRegistrations(feature)) {
1559
- this.#registrations.push(feature);
1560
- } else {
1561
- throw new Error(
1562
- `Failed to add feature, invalid feature ${JSON.stringify(feature)}`
1563
- );
1564
- }
1565
- }
1566
- async start() {
1567
- if (this.#startPromise) {
1568
- throw new Error("Backend has already started");
1569
- }
1570
- const exitHandler = async () => {
1571
- process.removeListener("SIGTERM", exitHandler);
1572
- process.removeListener("SIGINT", exitHandler);
1573
- process.removeListener("beforeExit", exitHandler);
1574
- try {
1575
- await this.stop();
1576
- process.exit(0);
1577
- } catch (error) {
1578
- console.error(error);
1579
- process.exit(1);
1580
- }
1581
- };
1582
- process.addListener("SIGTERM", exitHandler);
1583
- process.addListener("SIGINT", exitHandler);
1584
- process.addListener("beforeExit", exitHandler);
1585
- this.#startPromise = this.#doStart();
1586
- await this.#startPromise;
1587
- }
1588
- async #doStart() {
1589
- this.#serviceRegistry.checkForCircularDeps();
1590
- for (const feature of this.#registeredFeatures) {
1591
- this.#addFeature(await feature);
1592
- }
1593
- const featureDiscovery = await this.#serviceRegistry.get(
1594
- alpha.featureDiscoveryServiceRef,
1595
- "root"
1596
- );
1597
- if (featureDiscovery) {
1598
- const { features } = await featureDiscovery.getBackendFeatures();
1599
- for (const feature of features) {
1600
- this.#addFeature(feature);
1601
- }
1602
- this.#serviceRegistry.checkForCircularDeps();
1603
- }
1604
- await this.#applyBackendFeatureLoaders(this.#registeredFeatureLoaders);
1605
- await this.#serviceRegistry.initializeEagerServicesWithScope("root");
1606
- const pluginInits = /* @__PURE__ */ new Map();
1607
- const moduleInits = /* @__PURE__ */ new Map();
1608
- for (const feature of this.#registrations) {
1609
- for (const r of feature.getRegistrations()) {
1610
- const provides = /* @__PURE__ */ new Set();
1611
- if (r.type === "plugin" || r.type === "module") {
1612
- for (const [extRef, extImpl] of r.extensionPoints) {
1613
- if (this.#extensionPoints.has(extRef.id)) {
1614
- throw new Error(
1615
- `ExtensionPoint with ID '${extRef.id}' is already registered`
1616
- );
1617
- }
1618
- this.#extensionPoints.set(extRef.id, {
1619
- impl: extImpl,
1620
- pluginId: r.pluginId
1621
- });
1622
- provides.add(extRef);
1623
- }
1624
- }
1625
- if (r.type === "plugin") {
1626
- if (pluginInits.has(r.pluginId)) {
1627
- throw new Error(`Plugin '${r.pluginId}' is already registered`);
1628
- }
1629
- pluginInits.set(r.pluginId, {
1630
- provides,
1631
- consumes: new Set(Object.values(r.init.deps)),
1632
- init: r.init
1633
- });
1634
- } else if (r.type === "module") {
1635
- let modules = moduleInits.get(r.pluginId);
1636
- if (!modules) {
1637
- modules = /* @__PURE__ */ new Map();
1638
- moduleInits.set(r.pluginId, modules);
1639
- }
1640
- if (modules.has(r.moduleId)) {
1641
- throw new Error(
1642
- `Module '${r.moduleId}' for plugin '${r.pluginId}' is already registered`
1643
- );
1644
- }
1645
- modules.set(r.moduleId, {
1646
- provides,
1647
- consumes: new Set(Object.values(r.init.deps)),
1648
- init: r.init
1649
- });
1650
- } else {
1651
- throw new Error(`Invalid registration type '${r.type}'`);
1652
- }
1653
- }
1654
- }
1655
- const allPluginIds = [...pluginInits.keys()];
1656
- const initLogger = createInitializationLogger(
1657
- allPluginIds,
1658
- await this.#serviceRegistry.get(backendPluginApi.coreServices.rootLogger, "root")
1659
- );
1660
- await Promise.all(
1661
- allPluginIds.map(async (pluginId) => {
1662
- await this.#serviceRegistry.initializeEagerServicesWithScope(
1663
- "plugin",
1664
- pluginId
1665
- );
1666
- const modules = moduleInits.get(pluginId);
1667
- if (modules) {
1668
- const tree = DependencyGraph.fromIterable(
1669
- Array.from(modules).map(([moduleId, moduleInit]) => ({
1670
- value: { moduleId, moduleInit },
1671
- // Relationships are reversed at this point since we're only interested in the extension points.
1672
- // If a modules provides extension point A we want it to be initialized AFTER all modules
1673
- // that depend on extension point A, so that they can provide their extensions.
1674
- consumes: Array.from(moduleInit.provides).map((p) => p.id),
1675
- provides: Array.from(moduleInit.consumes).map((c) => c.id)
1676
- }))
1677
- );
1678
- const circular = tree.detectCircularDependency();
1679
- if (circular) {
1680
- throw new errors.ConflictError(
1681
- `Circular dependency detected for modules of plugin '${pluginId}', ${circular.map(({ moduleId }) => `'${moduleId}'`).join(" -> ")}`
1682
- );
1683
- }
1684
- await tree.parallelTopologicalTraversal(
1685
- async ({ moduleId, moduleInit }) => {
1686
- const moduleDeps = await this.#getInitDeps(
1687
- moduleInit.init.deps,
1688
- pluginId,
1689
- moduleId
1690
- );
1691
- await moduleInit.init.func(moduleDeps).catch((error) => {
1692
- throw new errors.ForwardedError(
1693
- `Module '${moduleId}' for plugin '${pluginId}' startup failed`,
1694
- error
1695
- );
1696
- });
1697
- }
1698
- );
1699
- }
1700
- const pluginInit = pluginInits.get(pluginId);
1701
- if (pluginInit) {
1702
- const pluginDeps = await this.#getInitDeps(
1703
- pluginInit.init.deps,
1704
- pluginId
1705
- );
1706
- await pluginInit.init.func(pluginDeps).catch((error) => {
1707
- throw new errors.ForwardedError(
1708
- `Plugin '${pluginId}' startup failed`,
1709
- error
1710
- );
1711
- });
1712
- }
1713
- initLogger.onPluginStarted(pluginId);
1714
- const lifecycleService2 = await this.#getPluginLifecycleImpl(pluginId);
1715
- await lifecycleService2.startup();
1716
- })
1717
- );
1718
- const lifecycleService = await this.#getRootLifecycleImpl();
1719
- await lifecycleService.startup();
1720
- initLogger.onAllStarted();
1721
- if (process.env.NODE_ENV !== "test") {
1722
- const rootLogger = await this.#serviceRegistry.get(
1723
- backendPluginApi.coreServices.rootLogger,
1724
- "root"
1725
- );
1726
- process.on("unhandledRejection", (reason) => {
1727
- rootLogger?.child({ type: "unhandledRejection" })?.error("Unhandled rejection", reason);
1728
- });
1729
- process.on("uncaughtException", (error) => {
1730
- rootLogger?.child({ type: "uncaughtException" })?.error("Uncaught exception", error);
1731
- });
1732
- }
1733
- }
1734
- async stop() {
1735
- if (!this.#startPromise) {
1736
- return;
1737
- }
1738
- try {
1739
- await this.#startPromise;
1740
- } catch (error) {
1741
- }
1742
- const lifecycleService = await this.#getRootLifecycleImpl();
1743
- await lifecycleService.shutdown();
1744
- }
1745
- // Bit of a hacky way to grab the lifecycle services, potentially find a nicer way to do this
1746
- async #getRootLifecycleImpl() {
1747
- const lifecycleService = await this.#serviceRegistry.get(
1748
- backendPluginApi.coreServices.rootLifecycle,
1749
- "root"
1750
- );
1751
- const service = lifecycleService;
1752
- if (service && typeof service.startup === "function" && typeof service.shutdown === "function") {
1753
- return service;
1754
- }
1755
- throw new Error("Unexpected root lifecycle service implementation");
1756
- }
1757
- async #getPluginLifecycleImpl(pluginId) {
1758
- const lifecycleService = await this.#serviceRegistry.get(
1759
- backendPluginApi.coreServices.lifecycle,
1760
- pluginId
1761
- );
1762
- const service = lifecycleService;
1763
- if (service && typeof service.startup === "function") {
1764
- return service;
1765
- }
1766
- throw new Error("Unexpected plugin lifecycle service implementation");
1767
- }
1768
- async #applyBackendFeatureLoaders(loaders) {
1769
- for (const loader of loaders) {
1770
- const deps = /* @__PURE__ */ new Map();
1771
- const missingRefs = /* @__PURE__ */ new Set();
1772
- for (const [name, ref] of Object.entries(loader.deps ?? {})) {
1773
- if (ref.scope !== "root") {
1774
- throw new Error(
1775
- `Feature loaders can only depend on root scoped services, but '${name}' is scoped to '${ref.scope}'. Offending loader is ${loader.description}`
1776
- );
1777
- }
1778
- const impl = await this.#serviceRegistry.get(
1779
- ref,
1780
- "root"
1781
- );
1782
- if (impl) {
1783
- deps.set(name, impl);
1784
- } else {
1785
- missingRefs.add(ref);
1786
- }
1787
- }
1788
- if (missingRefs.size > 0) {
1789
- const missing = Array.from(missingRefs).join(", ");
1790
- throw new Error(
1791
- `No service available for the following ref(s): ${missing}, depended on by feature loader ${loader.description}`
1792
- );
1793
- }
1794
- const result = await loader.loader(Object.fromEntries(deps)).catch((error) => {
1795
- throw new errors.ForwardedError(
1796
- `Feature loader ${loader.description} failed`,
1797
- error
1798
- );
1799
- });
1800
- let didAddServiceFactory = false;
1801
- const newLoaders = new Array();
1802
- for await (const feature of result) {
1803
- if (isBackendFeatureLoader(feature)) {
1804
- newLoaders.push(feature);
1805
- } else {
1806
- didAddServiceFactory ||= isServiceFactory(feature);
1807
- this.#addFeature(feature);
1808
- }
1809
- }
1810
- if (didAddServiceFactory) {
1811
- this.#serviceRegistry.checkForCircularDeps();
1812
- }
1813
- if (newLoaders.length > 0) {
1814
- await this.#applyBackendFeatureLoaders(newLoaders);
1815
- }
1816
- }
1817
- }
1818
- }
1819
- function toInternalBackendFeature(feature) {
1820
- if (feature.$$type !== "@backstage/BackendFeature") {
1821
- throw new Error(`Invalid BackendFeature, bad type '${feature.$$type}'`);
1822
- }
1823
- const internal = feature;
1824
- if (internal.version !== "v1") {
1825
- throw new Error(
1826
- `Invalid BackendFeature, bad version '${internal.version}'`
1827
- );
1828
- }
1829
- return internal;
1830
- }
1831
- function isServiceFactory(feature) {
1832
- const internal = toInternalBackendFeature(feature);
1833
- if (internal.featureType === "service") {
1834
- return true;
1835
- }
1836
- return "service" in internal;
1837
- }
1838
- function isBackendRegistrations(feature) {
1839
- const internal = toInternalBackendFeature(feature);
1840
- if (internal.featureType === "registrations") {
1841
- return true;
1842
- }
1843
- return "getRegistrations" in internal;
1844
- }
1845
- function isBackendFeatureLoader(feature) {
1846
- return toInternalBackendFeature(feature).featureType === "loader";
1847
- }
1848
-
1849
- class BackstageBackend {
1850
- #initializer;
1851
- constructor(defaultServiceFactories) {
1852
- this.#initializer = new BackendInitializer(defaultServiceFactories);
1853
- }
1854
- add(feature) {
1855
- if (isPromise(feature)) {
1856
- this.#initializer.add(feature.then((f) => unwrapFeature(f.default)));
1857
- } else {
1858
- this.#initializer.add(unwrapFeature(feature));
1859
- }
1860
- }
1861
- async start() {
1862
- await this.#initializer.start();
1863
- }
1864
- async stop() {
1865
- await this.#initializer.stop();
1866
- }
1867
- }
1868
- function isPromise(value) {
1869
- return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
1870
- }
1871
- function unwrapFeature(feature) {
1872
- if (typeof feature === "function") {
1873
- return feature();
1874
- }
1875
- if ("$$type" in feature) {
1876
- return feature;
1877
- }
1878
- if ("default" in feature) {
1879
- const defaultFeature = feature.default;
1880
- return typeof defaultFeature === "function" ? defaultFeature() : defaultFeature;
1881
- }
1882
- return feature;
1883
- }
1884
-
1885
- function createSpecializedBackend(options) {
1886
- const exists = /* @__PURE__ */ new Set();
1887
- const duplicates = /* @__PURE__ */ new Set();
1888
- for (const { service } of options.defaultServiceFactories) {
1889
- if (exists.has(service.id)) {
1890
- duplicates.add(service.id);
1891
- } else {
1892
- exists.add(service.id);
1893
- }
1894
- }
1895
- if (duplicates.size > 0) {
1896
- const ids = Array.from(duplicates).join(", ");
1897
- throw new Error(`Duplicate service implementations provided for ${ids}`);
1898
- }
1899
- if (exists.has(backendPluginApi.coreServices.pluginMetadata.id)) {
1900
- throw new Error(
1901
- `The ${backendPluginApi.coreServices.pluginMetadata.id} service cannot be overridden`
1902
- );
1903
- }
1904
- return new BackstageBackend(options.defaultServiceFactories);
1905
- }
1906
-
1907
- const cacheServiceFactory = backendPluginApi.createServiceFactory({
1908
- service: backendPluginApi.coreServices.cache,
1909
- deps: {
1910
- config: backendPluginApi.coreServices.rootConfig,
1911
- logger: backendPluginApi.coreServices.rootLogger,
1912
- plugin: backendPluginApi.coreServices.pluginMetadata
1913
- },
1914
- async createRootContext({ config, logger }) {
1915
- return backendCommon.CacheManager.fromConfig(config, { logger });
1916
- },
1917
- async factory({ plugin }, manager) {
1918
- return manager.forPlugin(plugin.getId()).getClient();
1919
- }
1920
- });
1921
-
1922
- const rootConfigServiceFactory = backendPluginApi.createServiceFactory(
1923
- (options) => ({
1924
- service: backendPluginApi.coreServices.rootConfig,
1925
- deps: {},
1926
- async factory() {
1927
- const source = configLoader.ConfigSources.default({
1928
- argv: options?.argv,
1929
- remote: options?.remote,
1930
- watch: options?.watch
1931
- });
1932
- console.log(`Loading config from ${source}`);
1933
- return await configLoader.ConfigSources.toConfig(source);
1934
- }
1935
- })
1936
- );
1937
-
1938
- const databaseServiceFactory = backendPluginApi.createServiceFactory({
1939
- service: backendPluginApi.coreServices.database,
1940
- deps: {
1941
- config: backendPluginApi.coreServices.rootConfig,
1942
- lifecycle: backendPluginApi.coreServices.lifecycle,
1943
- pluginMetadata: backendPluginApi.coreServices.pluginMetadata
1944
- },
1945
- async createRootContext({ config: config$1 }) {
1946
- return config$1.getOptional("backend.database") ? backendCommon.DatabaseManager.fromConfig(config$1) : backendCommon.DatabaseManager.fromConfig(
1947
- new config.ConfigReader({
1948
- backend: {
1949
- database: { client: "better-sqlite3", connection: ":memory:" }
1950
- }
1951
- })
1952
- );
1953
- },
1954
- async factory({ pluginMetadata, lifecycle }, databaseManager) {
1955
- return databaseManager.forPlugin(pluginMetadata.getId(), {
1956
- pluginMetadata,
1957
- lifecycle
1958
- });
1959
- }
1960
- });
1961
-
1962
- let HostDiscovery$1 = class HostDiscovery {
1963
- constructor(internalBaseUrl, externalBaseUrl, discoveryConfig) {
1964
- this.internalBaseUrl = internalBaseUrl;
1965
- this.externalBaseUrl = externalBaseUrl;
1966
- this.discoveryConfig = discoveryConfig;
1967
- }
1968
- /**
1969
- * Creates a new HostDiscovery discovery instance by reading
1970
- * from the `backend` config section, specifically the `.baseUrl` for
1971
- * discovering the external URL, and the `.listen` and `.https` config
1972
- * for the internal one.
1973
- *
1974
- * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`.
1975
- * eg.
1976
- * ```yaml
1977
- * discovery:
1978
- * endpoints:
1979
- * - target: https://internal.example.com/internal-catalog
1980
- * plugins: [catalog]
1981
- * - target: https://internal.example.com/secure/api/{{pluginId}}
1982
- * plugins: [auth, permission]
1983
- * - target:
1984
- * internal: https://internal.example.com/search
1985
- * external: https://example.com/search
1986
- * plugins: [search]
1987
- * ```
1988
- *
1989
- * The basePath defaults to `/api`, meaning the default full internal
1990
- * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
1991
- */
1992
- static fromConfig(config, options) {
1993
- const basePath = options?.basePath ?? "/api";
1994
- const externalBaseUrl = config.getString("backend.baseUrl").replace(/\/+$/, "");
1995
- const {
1996
- listen: { host: listenHost = "::", port: listenPort }
1997
- } = readHttpServerOptions$1(config.getConfig("backend"));
1998
- const protocol = config.has("backend.https") ? "https" : "http";
1999
- let host = listenHost;
2000
- if (host === "::" || host === "") {
2001
- host = "localhost";
2002
- } else if (host === "0.0.0.0") {
2003
- host = "127.0.0.1";
2004
- }
2005
- if (host.includes(":")) {
2006
- host = `[${host}]`;
2007
- }
2008
- const internalBaseUrl = `${protocol}://${host}:${listenPort}`;
2009
- return new HostDiscovery(
2010
- internalBaseUrl + basePath,
2011
- externalBaseUrl + basePath,
2012
- config.getOptionalConfig("discovery")
2013
- );
2014
- }
2015
- getTargetFromConfig(pluginId, type) {
2016
- const endpoints = this.discoveryConfig?.getOptionalConfigArray("endpoints");
2017
- const target = endpoints?.find((endpoint) => endpoint.getStringArray("plugins").includes(pluginId))?.get("target");
2018
- if (!target) {
2019
- const baseUrl = type === "external" ? this.externalBaseUrl : this.internalBaseUrl;
2020
- return `${baseUrl}/${encodeURIComponent(pluginId)}`;
2021
- }
2022
- if (typeof target === "string") {
2023
- return target.replace(
2024
- /\{\{\s*pluginId\s*\}\}/g,
2025
- encodeURIComponent(pluginId)
2026
- );
2027
- }
2028
- return target[type].replace(
2029
- /\{\{\s*pluginId\s*\}\}/g,
2030
- encodeURIComponent(pluginId)
2031
- );
2032
- }
2033
- async getBaseUrl(pluginId) {
2034
- return this.getTargetFromConfig(pluginId, "internal");
2035
- }
2036
- async getExternalBaseUrl(pluginId) {
2037
- return this.getTargetFromConfig(pluginId, "external");
2038
- }
2039
- };
2040
-
2041
- backendPluginApi.createServiceFactory({
2042
- service: backendPluginApi.coreServices.discovery,
2043
- deps: {
2044
- config: backendPluginApi.coreServices.rootConfig
2045
- },
2046
- async factory({ config }) {
2047
- return HostDiscovery$1.fromConfig(config);
2048
- }
2049
- });
2050
-
2051
- class HostDiscovery {
2052
- constructor(impl) {
2053
- this.impl = impl;
2054
- }
2055
- /**
2056
- * Creates a new HostDiscovery discovery instance by reading
2057
- * from the `backend` config section, specifically the `.baseUrl` for
2058
- * discovering the external URL, and the `.listen` and `.https` config
2059
- * for the internal one.
2060
- *
2061
- * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`.
2062
- * eg.
2063
- * ```yaml
2064
- * discovery:
2065
- * endpoints:
2066
- * - target: https://internal.example.com/internal-catalog
2067
- * plugins: [catalog]
2068
- * - target: https://internal.example.com/secure/api/{{pluginId}}
2069
- * plugins: [auth, permission]
2070
- * - target:
2071
- * internal: https://internal.example.com/search
2072
- * external: https://example.com/search
2073
- * plugins: [search]
2074
- * ```
2075
- *
2076
- * The basePath defaults to `/api`, meaning the default full internal
2077
- * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`.
2078
- */
2079
- static fromConfig(config, options) {
2080
- return new HostDiscovery(HostDiscovery$1.fromConfig(config, options));
2081
- }
2082
- async getBaseUrl(pluginId) {
2083
- return this.impl.getBaseUrl(pluginId);
2084
- }
2085
- async getExternalBaseUrl(pluginId) {
2086
- return this.impl.getExternalBaseUrl(pluginId);
2087
- }
2088
- }
2089
-
2090
- const discoveryServiceFactory = backendPluginApi.createServiceFactory({
2091
- service: backendPluginApi.coreServices.discovery,
2092
- deps: {
2093
- config: backendPluginApi.coreServices.rootConfig
2094
- },
2095
- async factory({ config }) {
2096
- return HostDiscovery.fromConfig(config);
2097
- }
2098
- });
2099
-
2100
- const identityServiceFactory = backendPluginApi.createServiceFactory(
2101
- (options) => ({
2102
- service: backendPluginApi.coreServices.identity,
2103
- deps: {
2104
- discovery: backendPluginApi.coreServices.discovery
2105
- },
2106
- async factory({ discovery }) {
2107
- return pluginAuthNode.DefaultIdentityClient.create({ discovery, ...options });
2108
- }
2109
- })
2110
- );
2111
-
2112
- class BackendPluginLifecycleImpl {
2113
- constructor(logger, rootLifecycle, pluginMetadata) {
2114
- this.logger = logger;
2115
- this.rootLifecycle = rootLifecycle;
2116
- this.pluginMetadata = pluginMetadata;
2117
- }
2118
- #hasStarted = false;
2119
- #startupTasks = [];
2120
- addStartupHook(hook, options) {
2121
- if (this.#hasStarted) {
2122
- throw new Error("Attempted to add startup hook after startup");
2123
- }
2124
- this.#startupTasks.push({ hook, options });
2125
- }
2126
- async startup() {
2127
- if (this.#hasStarted) {
2128
- return;
2129
- }
2130
- this.#hasStarted = true;
2131
- this.logger.debug(
2132
- `Running ${this.#startupTasks.length} plugin startup tasks...`
2133
- );
2134
- await Promise.all(
2135
- this.#startupTasks.map(async ({ hook, options }) => {
2136
- const logger = options?.logger ?? this.logger;
2137
- try {
2138
- await hook();
2139
- logger.debug(`Plugin startup hook succeeded`);
2140
- } catch (error) {
2141
- logger.error(`Plugin startup hook failed, ${error}`);
2142
- }
2143
- })
2144
- );
2145
- }
2146
- addShutdownHook(hook, options) {
2147
- const plugin = this.pluginMetadata.getId();
2148
- this.rootLifecycle.addShutdownHook(hook, {
2149
- logger: options?.logger?.child({ plugin }) ?? this.logger
2150
- });
2151
- }
2152
- }
2153
- const lifecycleServiceFactory = backendPluginApi.createServiceFactory({
2154
- service: backendPluginApi.coreServices.lifecycle,
2155
- deps: {
2156
- logger: backendPluginApi.coreServices.logger,
2157
- rootLifecycle: backendPluginApi.coreServices.rootLifecycle,
2158
- pluginMetadata: backendPluginApi.coreServices.pluginMetadata
2159
- },
2160
- async factory({ rootLifecycle, logger, pluginMetadata }) {
2161
- return new BackendPluginLifecycleImpl(
2162
- logger,
2163
- rootLifecycle,
2164
- pluginMetadata
2165
- );
2166
- }
2167
- });
2168
-
2169
- const permissionsServiceFactory = backendPluginApi.createServiceFactory({
2170
- service: backendPluginApi.coreServices.permissions,
2171
- deps: {
2172
- auth: backendPluginApi.coreServices.auth,
2173
- config: backendPluginApi.coreServices.rootConfig,
2174
- discovery: backendPluginApi.coreServices.discovery,
2175
- tokenManager: backendPluginApi.coreServices.tokenManager
2176
- },
2177
- async factory({ auth, config, discovery, tokenManager }) {
2178
- return pluginPermissionNode.ServerPermissionClient.fromConfig(config, {
2179
- auth,
2180
- discovery,
2181
- tokenManager
2182
- });
2183
- }
2184
- });
2185
-
2186
- class BackendLifecycleImpl {
2187
- constructor(logger) {
2188
- this.logger = logger;
2189
- }
2190
- #hasStarted = false;
2191
- #startupTasks = [];
2192
- addStartupHook(hook, options) {
2193
- if (this.#hasStarted) {
2194
- throw new Error("Attempted to add startup hook after startup");
2195
- }
2196
- this.#startupTasks.push({ hook, options });
2197
- }
2198
- async startup() {
2199
- if (this.#hasStarted) {
2200
- return;
2201
- }
2202
- this.#hasStarted = true;
2203
- this.logger.debug(`Running ${this.#startupTasks.length} startup tasks...`);
2204
- await Promise.all(
2205
- this.#startupTasks.map(async ({ hook, options }) => {
2206
- const logger = options?.logger ?? this.logger;
2207
- try {
2208
- await hook();
2209
- logger.debug(`Startup hook succeeded`);
2210
- } catch (error) {
2211
- logger.error(`Startup hook failed, ${error}`);
152
+ const nodesToProcess = [];
153
+ for (const node of waiting) {
154
+ let ready = true;
155
+ for (const consumed of node.consumes) {
156
+ if (allProvided.has(consumed) && !producedSoFar.has(consumed)) {
157
+ ready = false;
158
+ continue;
159
+ }
2212
160
  }
2213
- })
2214
- );
2215
- }
2216
- #hasShutdown = false;
2217
- #shutdownTasks = [];
2218
- addShutdownHook(hook, options) {
2219
- if (this.#hasShutdown) {
2220
- throw new Error("Attempted to add shutdown hook after shutdown");
161
+ if (ready) {
162
+ nodesToProcess.push(node);
163
+ }
164
+ }
165
+ for (const node of nodesToProcess) {
166
+ waiting.delete(node);
167
+ }
168
+ if (nodesToProcess.length === 0 && inFlight === 0) {
169
+ throw new Error("Circular dependency detected");
170
+ }
171
+ await Promise.all(nodesToProcess.map(processNode));
2221
172
  }
2222
- this.#shutdownTasks.push({ hook, options });
2223
- }
2224
- async shutdown() {
2225
- if (this.#hasShutdown) {
2226
- return;
173
+ async function processNode(node) {
174
+ visited.add(node);
175
+ inFlight += 1;
176
+ const result = await fn(node.value);
177
+ results.push(result);
178
+ node.provides.forEach((produced) => producedSoFar.add(produced));
179
+ inFlight -= 1;
180
+ await processMoreNodes();
2227
181
  }
2228
- this.#hasShutdown = true;
2229
- this.logger.debug(
2230
- `Running ${this.#shutdownTasks.length} shutdown tasks...`
2231
- );
2232
- await Promise.all(
2233
- this.#shutdownTasks.map(async ({ hook, options }) => {
2234
- const logger = options?.logger ?? this.logger;
2235
- try {
2236
- await hook();
2237
- logger.debug(`Shutdown hook succeeded`);
2238
- } catch (error) {
2239
- logger.error(`Shutdown hook failed, ${error}`);
2240
- }
2241
- })
2242
- );
182
+ await processMoreNodes();
183
+ return results;
2243
184
  }
2244
185
  }
2245
- const rootLifecycleServiceFactory = backendPluginApi.createServiceFactory({
2246
- service: backendPluginApi.coreServices.rootLifecycle,
2247
- deps: {
2248
- logger: backendPluginApi.coreServices.rootLogger
2249
- },
2250
- async factory({ logger }) {
2251
- return new BackendLifecycleImpl(logger);
2252
- }
2253
- });
2254
186
 
2255
- const tokenManagerServiceFactory = backendPluginApi.createServiceFactory({
2256
- service: backendPluginApi.coreServices.tokenManager,
2257
- deps: {
2258
- config: backendPluginApi.coreServices.rootConfig,
2259
- logger: backendPluginApi.coreServices.rootLogger
2260
- },
2261
- createRootContext({ config, logger }) {
2262
- return backendCommon.ServerTokenManager.fromConfig(config, {
2263
- logger,
2264
- allowDisabledTokenManager: true
2265
- });
2266
- },
2267
- async factory(_deps, tokenManager) {
2268
- return tokenManager;
187
+ function toInternalServiceFactory(factory) {
188
+ const f = factory;
189
+ if (f.$$type !== "@backstage/BackendFeature") {
190
+ throw new Error(`Invalid service factory, bad type '${f.$$type}'`);
2269
191
  }
2270
- });
2271
-
2272
- const urlReaderServiceFactory = backendPluginApi.createServiceFactory({
2273
- service: backendPluginApi.coreServices.urlReader,
2274
- deps: {
2275
- config: backendPluginApi.coreServices.rootConfig,
2276
- logger: backendPluginApi.coreServices.logger
2277
- },
2278
- async factory({ config, logger }) {
2279
- return backendCommon.UrlReaders.default({
2280
- config,
2281
- logger
2282
- });
192
+ if (f.version !== "v1") {
193
+ throw new Error(`Invalid service factory, bad version '${f.version}'`);
2283
194
  }
2284
- });
2285
-
2286
- function createCredentialsWithServicePrincipal(sub, token, accessRestrictions) {
2287
- return {
2288
- $$type: "@backstage/BackstageCredentials",
2289
- version: "v1",
2290
- token,
2291
- principal: {
2292
- type: "service",
2293
- subject: sub,
2294
- accessRestrictions
2295
- }
2296
- };
2297
- }
2298
- function createCredentialsWithUserPrincipal(sub, token, expiresAt) {
2299
- return {
2300
- $$type: "@backstage/BackstageCredentials",
2301
- version: "v1",
2302
- token,
2303
- expiresAt,
2304
- principal: {
2305
- type: "user",
2306
- userEntityRef: sub
2307
- }
2308
- };
2309
- }
2310
- function createCredentialsWithNonePrincipal() {
2311
- return {
2312
- $$type: "@backstage/BackstageCredentials",
2313
- version: "v1",
2314
- principal: {
2315
- type: "none"
2316
- }
2317
- };
195
+ return f;
2318
196
  }
2319
- function toInternalBackstageCredentials(credentials) {
2320
- if (credentials.$$type !== "@backstage/BackstageCredentials") {
2321
- throw new Error("Invalid credential type");
2322
- }
2323
- const internalCredentials = credentials;
2324
- if (internalCredentials.version !== "v1") {
2325
- throw new Error(
2326
- `Invalid credential version ${internalCredentials.version}`
2327
- );
2328
- }
2329
- return internalCredentials;
197
+ function createPluginMetadataServiceFactory(pluginId) {
198
+ return backendPluginApi.createServiceFactory({
199
+ service: backendPluginApi.coreServices.pluginMetadata,
200
+ deps: {},
201
+ factory: async () => ({ getId: () => pluginId })
202
+ });
2330
203
  }
2331
-
2332
- class DefaultAuthService {
2333
- constructor(userTokenHandler, pluginTokenHandler, externalTokenHandler, tokenManager, pluginId, disableDefaultAuthPolicy, pluginKeySource) {
2334
- this.userTokenHandler = userTokenHandler;
2335
- this.pluginTokenHandler = pluginTokenHandler;
2336
- this.externalTokenHandler = externalTokenHandler;
2337
- this.tokenManager = tokenManager;
2338
- this.pluginId = pluginId;
2339
- this.disableDefaultAuthPolicy = disableDefaultAuthPolicy;
2340
- this.pluginKeySource = pluginKeySource;
2341
- }
2342
- async authenticate(token, options) {
2343
- const pluginResult = await this.pluginTokenHandler.verifyToken(token);
2344
- if (pluginResult) {
2345
- if (pluginResult.limitedUserToken) {
2346
- const userResult2 = await this.userTokenHandler.verifyToken(
2347
- pluginResult.limitedUserToken
2348
- );
2349
- if (!userResult2) {
2350
- throw new errors.AuthenticationError(
2351
- "Invalid user token in plugin token obo claim"
2352
- );
2353
- }
2354
- return createCredentialsWithUserPrincipal(
2355
- userResult2.userEntityRef,
2356
- pluginResult.limitedUserToken,
2357
- this.#getJwtExpiration(pluginResult.limitedUserToken)
204
+ class ServiceRegistry {
205
+ static create(factories) {
206
+ const factoryMap = /* @__PURE__ */ new Map();
207
+ for (const factory of factories) {
208
+ if (factory.service.multiton) {
209
+ const existing = factoryMap.get(factory.service.id) ?? [];
210
+ factoryMap.set(
211
+ factory.service.id,
212
+ existing.concat(toInternalServiceFactory(factory))
2358
213
  );
214
+ } else {
215
+ factoryMap.set(factory.service.id, [toInternalServiceFactory(factory)]);
2359
216
  }
2360
- return createCredentialsWithServicePrincipal(pluginResult.subject);
2361
- }
2362
- const userResult = await this.userTokenHandler.verifyToken(token);
2363
- if (userResult) {
2364
- if (!options?.allowLimitedAccess && this.userTokenHandler.isLimitedUserToken(token)) {
2365
- throw new errors.AuthenticationError("Illegal limited user token");
2366
- }
2367
- return createCredentialsWithUserPrincipal(
2368
- userResult.userEntityRef,
2369
- token,
2370
- this.#getJwtExpiration(token)
2371
- );
2372
- }
2373
- const externalResult = await this.externalTokenHandler.verifyToken(token);
2374
- if (externalResult) {
2375
- return createCredentialsWithServicePrincipal(
2376
- externalResult.subject,
2377
- void 0,
2378
- externalResult.accessRestrictions
2379
- );
2380
- }
2381
- throw new errors.AuthenticationError("Illegal token");
2382
- }
2383
- isPrincipal(credentials, type) {
2384
- const principal = credentials.principal;
2385
- if (type === "unknown") {
2386
- return true;
2387
- }
2388
- if (principal.type !== type) {
2389
- return false;
2390
217
  }
2391
- return true;
2392
- }
2393
- async getNoneCredentials() {
2394
- return createCredentialsWithNonePrincipal();
2395
- }
2396
- async getOwnServiceCredentials() {
2397
- return createCredentialsWithServicePrincipal(`plugin:${this.pluginId}`);
218
+ const registry = new ServiceRegistry(factoryMap);
219
+ registry.checkForCircularDeps();
220
+ return registry;
2398
221
  }
2399
- async getPluginRequestToken(options) {
2400
- const { targetPluginId } = options;
2401
- const internalForward = toInternalBackstageCredentials(options.onBehalfOf);
2402
- const { type } = internalForward.principal;
2403
- if (type === "none" && this.disableDefaultAuthPolicy) {
2404
- return { token: "" };
2405
- }
2406
- const targetSupportsNewAuth = await this.pluginTokenHandler.isTargetPluginSupported(targetPluginId);
2407
- switch (type) {
2408
- case "service":
2409
- if (targetSupportsNewAuth) {
2410
- return this.pluginTokenHandler.issueToken({
2411
- pluginId: this.pluginId,
2412
- targetPluginId
2413
- });
2414
- }
2415
- return this.tokenManager.getToken().catch((error) => {
2416
- throw new errors.ForwardedError(
2417
- `Unable to generate legacy token for communication with the '${targetPluginId}' plugin. You will typically encounter this error when attempting to call a plugin that does not exist, or is deployed with an old version of Backstage`,
2418
- error
2419
- );
2420
- });
2421
- case "user": {
2422
- const { token } = internalForward;
2423
- if (!token) {
2424
- throw new Error("User credentials is unexpectedly missing token");
2425
- }
2426
- if (targetSupportsNewAuth) {
2427
- const onBehalfOf = await this.userTokenHandler.createLimitedUserToken(
2428
- token
2429
- );
2430
- return this.pluginTokenHandler.issueToken({
2431
- pluginId: this.pluginId,
2432
- targetPluginId,
2433
- onBehalfOf
2434
- });
2435
- }
2436
- if (this.userTokenHandler.isLimitedUserToken(token)) {
2437
- throw new errors.AuthenticationError(
2438
- `Unable to call '${targetPluginId}' plugin on behalf of user, because the target plugin does not support on-behalf-of tokens or the plugin doesn't exist`
2439
- );
2440
- }
2441
- return { token };
2442
- }
2443
- default:
2444
- throw new errors.AuthenticationError(
2445
- `Refused to issue service token for credential type '${type}'`
2446
- );
2447
- }
222
+ #providedFactories;
223
+ #loadedDefaultFactories;
224
+ #implementations;
225
+ #rootServiceImplementations = /* @__PURE__ */ new Map();
226
+ #addedFactoryIds = /* @__PURE__ */ new Set();
227
+ #instantiatedFactories = /* @__PURE__ */ new Set();
228
+ constructor(factories) {
229
+ this.#providedFactories = factories;
230
+ this.#loadedDefaultFactories = /* @__PURE__ */ new Map();
231
+ this.#implementations = /* @__PURE__ */ new Map();
2448
232
  }
2449
- async getLimitedUserToken(credentials) {
2450
- const { token: backstageToken } = toInternalBackstageCredentials(credentials);
2451
- if (!backstageToken) {
2452
- throw new errors.AuthenticationError(
2453
- "User credentials is unexpectedly missing token"
2454
- );
233
+ #resolveFactory(ref, pluginId) {
234
+ if (ref.id === backendPluginApi.coreServices.pluginMetadata.id) {
235
+ return Promise.resolve([
236
+ toInternalServiceFactory(createPluginMetadataServiceFactory(pluginId))
237
+ ]);
2455
238
  }
2456
- return this.userTokenHandler.createLimitedUserToken(backstageToken);
2457
- }
2458
- async listPublicServiceKeys() {
2459
- const { keys } = await this.pluginKeySource.listKeys();
2460
- return { keys: keys.map(({ key }) => key) };
2461
- }
2462
- #getJwtExpiration(token) {
2463
- const { exp } = jose.decodeJwt(token);
2464
- if (!exp) {
2465
- throw new errors.AuthenticationError("User token is missing expiration");
239
+ let resolvedFactory = this.#providedFactories.get(ref.id);
240
+ const { __defaultFactory: defaultFactory } = ref;
241
+ if (!resolvedFactory && !defaultFactory) {
242
+ return void 0;
2466
243
  }
2467
- return new Date(exp * 1e3);
2468
- }
2469
- }
2470
-
2471
- function readAccessRestrictionsFromConfig(externalAccessEntryConfig) {
2472
- const configs = externalAccessEntryConfig.getOptionalConfigArray("accessRestrictions") ?? [];
2473
- const result = /* @__PURE__ */ new Map();
2474
- for (const config of configs) {
2475
- const validKeys = ["plugin", "permission", "permissionAttribute"];
2476
- for (const key of config.keys()) {
2477
- if (!validKeys.includes(key)) {
2478
- const valid = validKeys.map((k) => `'${k}'`).join(", ");
2479
- throw new Error(
2480
- `Invalid key '${key}' in 'accessRestrictions' config, expected one of ${valid}`
244
+ if (!resolvedFactory) {
245
+ let loadedFactory = this.#loadedDefaultFactories.get(defaultFactory);
246
+ if (!loadedFactory) {
247
+ loadedFactory = Promise.resolve().then(() => defaultFactory(ref)).then(
248
+ (f) => toInternalServiceFactory(typeof f === "function" ? f() : f)
2481
249
  );
250
+ this.#loadedDefaultFactories.set(defaultFactory, loadedFactory);
2482
251
  }
2483
- }
2484
- const pluginId = config.getString("plugin");
2485
- const permissionNames = readPermissionNames(config);
2486
- const permissionAttributes = readPermissionAttributes(config);
2487
- if (result.has(pluginId)) {
2488
- throw new Error(
2489
- `Attempted to declare 'accessRestrictions' twice for plugin '${pluginId}', which is not permitted`
252
+ resolvedFactory = loadedFactory.then(
253
+ (factory) => [factory],
254
+ (error) => {
255
+ throw new Error(
256
+ `Failed to instantiate service '${ref.id}' because the default factory loader threw an error, ${errors.stringifyError(
257
+ error
258
+ )}`
259
+ );
260
+ }
2490
261
  );
2491
262
  }
2492
- result.set(pluginId, {
2493
- ...permissionNames ? { permissionNames } : {},
2494
- ...permissionAttributes ? { permissionAttributes } : {}
2495
- });
2496
- }
2497
- return result.size ? result : void 0;
2498
- }
2499
- function readStringOrStringArrayFromConfig(root, key, validValues) {
2500
- if (!root.has(key)) {
2501
- return void 0;
2502
- }
2503
- const rawValues = Array.isArray(root.get(key)) ? root.getStringArray(key) : [root.getString(key)];
2504
- const values = [
2505
- ...new Set(
2506
- rawValues.map((v) => v.split(/[ ,]/)).flat().filter(Boolean)
2507
- )
2508
- ];
2509
- if (!values.length) {
2510
- return void 0;
263
+ return Promise.resolve(resolvedFactory);
2511
264
  }
2512
- if (validValues?.length) {
2513
- for (const value of values) {
2514
- if (!validValues.includes(value)) {
2515
- const valid = validValues.map((k) => `'${k}'`).join(", ");
2516
- throw new Error(
2517
- `Invalid value '${value}' at '${key}' in 'permissionAttributes' config, valid values are ${valid}`
2518
- );
265
+ #checkForMissingDeps(factory, pluginId) {
266
+ const missingDeps = Object.values(factory.deps).filter((ref) => {
267
+ if (ref.id === backendPluginApi.coreServices.pluginMetadata.id) {
268
+ return false;
2519
269
  }
2520
- }
2521
- }
2522
- return values;
2523
- }
2524
- function readPermissionNames(externalAccessEntryConfig) {
2525
- return readStringOrStringArrayFromConfig(
2526
- externalAccessEntryConfig,
2527
- "permission"
2528
- );
2529
- }
2530
- function readPermissionAttributes(externalAccessEntryConfig) {
2531
- const config = externalAccessEntryConfig.getOptionalConfig(
2532
- "permissionAttribute"
2533
- );
2534
- if (!config) {
2535
- return void 0;
2536
- }
2537
- const validKeys = ["action"];
2538
- for (const key of config.keys()) {
2539
- if (!validKeys.includes(key)) {
2540
- const valid = validKeys.map((k) => `'${k}'`).join(", ");
270
+ if (this.#providedFactories.get(ref.id)) {
271
+ return false;
272
+ }
273
+ if (ref.multiton) {
274
+ return false;
275
+ }
276
+ return !ref.__defaultFactory;
277
+ });
278
+ if (missingDeps.length) {
279
+ const missing = missingDeps.map((r) => `'${r.id}'`).join(", ");
2541
280
  throw new Error(
2542
- `Invalid key '${key}' in 'permissionAttribute' config, expected ${valid}`
281
+ `Failed to instantiate service '${factory.service.id}' for '${pluginId}' because the following dependent services are missing: ${missing}`
2543
282
  );
2544
283
  }
2545
284
  }
2546
- const action = readStringOrStringArrayFromConfig(config, "action", [
2547
- "create",
2548
- "read",
2549
- "update",
2550
- "delete"
2551
- ]);
2552
- const result = {
2553
- ...action ? { action } : {}
2554
- };
2555
- return Object.keys(result).length ? result : void 0;
2556
- }
2557
-
2558
- class LegacyTokenHandler {
2559
- #entries = new Array();
2560
- add(config) {
2561
- const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2562
- this.#doAdd(
2563
- config.getString("options.secret"),
2564
- config.getString("options.subject"),
2565
- allAccessRestrictions
285
+ checkForCircularDeps() {
286
+ const graph = DependencyGraph.fromIterable(
287
+ Array.from(this.#providedFactories).map(([serviceId, factories]) => ({
288
+ value: serviceId,
289
+ provides: [serviceId],
290
+ consumes: factories.flatMap(
291
+ (factory) => Object.values(factory.deps).map((d) => d.id)
292
+ )
293
+ }))
2566
294
  );
2567
- }
2568
- // used only for the old backend.auth.keys array
2569
- addOld(config) {
2570
- this.#doAdd(config.getString("secret"), "external:backstage-plugin");
2571
- }
2572
- #doAdd(secret, subject, allAccessRestrictions) {
2573
- if (!secret.match(/^\S+$/)) {
2574
- throw new Error("Illegal secret, must be a valid base64 string");
2575
- } else if (!subject.match(/^\S+$/)) {
2576
- throw new Error("Illegal subject, must be a set of non-space characters");
2577
- }
2578
- let key;
2579
- try {
2580
- key = jose.base64url.decode(secret);
2581
- } catch {
2582
- throw new Error("Illegal secret, must be a valid base64 string");
2583
- }
2584
- if (this.#entries.some((e) => e.key === key)) {
2585
- throw new Error(
2586
- "Legacy externalAccess token was declared more than once"
2587
- );
2588
- }
2589
- this.#entries.push({
2590
- key,
2591
- result: {
2592
- subject,
2593
- allAccessRestrictions
2594
- }
2595
- });
2596
- }
2597
- async verifyToken(token) {
2598
- try {
2599
- const { alg } = jose.decodeProtectedHeader(token);
2600
- if (alg !== "HS256") {
2601
- return void 0;
2602
- }
2603
- const { sub, aud } = jose.decodeJwt(token);
2604
- if (sub !== "backstage-server" || aud) {
2605
- return void 0;
2606
- }
2607
- } catch (e) {
2608
- return void 0;
2609
- }
2610
- for (const { key, result } of this.#entries) {
2611
- try {
2612
- await jose.jwtVerify(token, key);
2613
- return result;
2614
- } catch (e) {
2615
- if (e.code !== "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") {
2616
- throw e;
2617
- }
2618
- }
295
+ const circularDependencies = Array.from(graph.detectCircularDependencies());
296
+ if (circularDependencies.length) {
297
+ const cycles = circularDependencies.map((c) => c.map((id) => `'${id}'`).join(" -> ")).join("\n ");
298
+ throw new errors.ConflictError(`Circular dependencies detected:
299
+ ${cycles}`);
2619
300
  }
2620
- return void 0;
2621
301
  }
2622
- }
2623
-
2624
- const MIN_TOKEN_LENGTH = 8;
2625
- class StaticTokenHandler {
2626
- #entries = /* @__PURE__ */ new Map();
2627
- add(config) {
2628
- const token = config.getString("options.token");
2629
- const subject = config.getString("options.subject");
2630
- const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2631
- if (!token.match(/^\S+$/)) {
2632
- throw new Error("Illegal token, must be a set of non-space characters");
2633
- } else if (token.length < MIN_TOKEN_LENGTH) {
2634
- throw new Error(
2635
- `Illegal token, must be at least ${MIN_TOKEN_LENGTH} characters length`
2636
- );
2637
- } else if (!subject.match(/^\S+$/)) {
2638
- throw new Error("Illegal subject, must be a set of non-space characters");
2639
- } else if (this.#entries.has(token)) {
302
+ add(factory) {
303
+ const factoryId = factory.service.id;
304
+ if (factoryId === backendPluginApi.coreServices.pluginMetadata.id) {
2640
305
  throw new Error(
2641
- "Static externalAccess token was declared more than once"
306
+ `The ${backendPluginApi.coreServices.pluginMetadata.id} service cannot be overridden`
2642
307
  );
2643
308
  }
2644
- this.#entries.set(token, { subject, allAccessRestrictions });
2645
- }
2646
- async verifyToken(token) {
2647
- return this.#entries.get(token);
2648
- }
2649
- }
2650
-
2651
- class JWKSHandler {
2652
- #entries = [];
2653
- add(config) {
2654
- if (!config.getString("options.url").match(/^\S+$/)) {
309
+ if (this.#instantiatedFactories.has(factoryId)) {
2655
310
  throw new Error(
2656
- "Illegal JWKS URL, must be a set of non-space characters"
311
+ `Unable to set service factory with id ${factoryId}, service has already been instantiated`
2657
312
  );
2658
313
  }
2659
- const algorithms = readStringOrStringArrayFromConfig(
2660
- config,
2661
- "options.algorithm"
2662
- );
2663
- const issuers = readStringOrStringArrayFromConfig(config, "options.issuer");
2664
- const audiences = readStringOrStringArrayFromConfig(
2665
- config,
2666
- "options.audience"
2667
- );
2668
- const subjectPrefix = config.getOptionalString("options.subjectPrefix");
2669
- const url = new URL(config.getString("options.url"));
2670
- const jwks = jose.createRemoteJWKSet(url);
2671
- const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2672
- this.#entries.push({
2673
- algorithms,
2674
- audiences,
2675
- issuers,
2676
- jwks,
2677
- subjectPrefix,
2678
- url,
2679
- allAccessRestrictions
2680
- });
2681
- }
2682
- async verifyToken(token) {
2683
- for (const entry of this.#entries) {
2684
- try {
2685
- const {
2686
- payload: { sub }
2687
- } = await jose.jwtVerify(token, entry.jwks, {
2688
- algorithms: entry.algorithms,
2689
- issuer: entry.issuers,
2690
- audience: entry.audiences
2691
- });
2692
- if (sub) {
2693
- const prefix = entry.subjectPrefix ? `external:${entry.subjectPrefix}:` : "external:";
2694
- return {
2695
- subject: `${prefix}${sub}`,
2696
- allAccessRestrictions: entry.allAccessRestrictions
2697
- };
2698
- }
2699
- } catch {
2700
- continue;
2701
- }
2702
- }
2703
- return void 0;
2704
- }
2705
- }
2706
-
2707
- const NEW_CONFIG_KEY = "backend.auth.externalAccess";
2708
- const OLD_CONFIG_KEY = "backend.auth.keys";
2709
- let loggedDeprecationWarning = false;
2710
- class ExternalTokenHandler {
2711
- constructor(ownPluginId, handlers) {
2712
- this.ownPluginId = ownPluginId;
2713
- this.handlers = handlers;
2714
- }
2715
- static create(options) {
2716
- const { ownPluginId, config, logger } = options;
2717
- const staticHandler = new StaticTokenHandler();
2718
- const legacyHandler = new LegacyTokenHandler();
2719
- const jwksHandler = new JWKSHandler();
2720
- const handlers = {
2721
- static: staticHandler,
2722
- legacy: legacyHandler,
2723
- jwks: jwksHandler
2724
- };
2725
- const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? [];
2726
- for (const handlerConfig of handlerConfigs) {
2727
- const type = handlerConfig.getString("type");
2728
- const handler = handlers[type];
2729
- if (!handler) {
2730
- const valid = Object.keys(handlers).map((k) => `'${k}'`).join(", ");
314
+ if (factory.service.multiton) {
315
+ const newFactories = (this.#providedFactories.get(factoryId) ?? []).concat(toInternalServiceFactory(factory));
316
+ this.#providedFactories.set(factoryId, newFactories);
317
+ } else {
318
+ if (this.#addedFactoryIds.has(factoryId)) {
2731
319
  throw new Error(
2732
- `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`
320
+ `Duplicate service implementations provided for ${factoryId}`
2733
321
  );
2734
322
  }
2735
- handler.add(handlerConfig);
2736
- }
2737
- const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
2738
- if (legacyConfigs.length && !loggedDeprecationWarning) {
2739
- loggedDeprecationWarning = true;
2740
- logger.warn(
2741
- `DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`
2742
- );
2743
- }
2744
- for (const handlerConfig of legacyConfigs) {
2745
- legacyHandler.addOld(handlerConfig);
323
+ this.#addedFactoryIds.add(factoryId);
324
+ this.#providedFactories.set(factoryId, [
325
+ toInternalServiceFactory(factory)
326
+ ]);
2746
327
  }
2747
- return new ExternalTokenHandler(ownPluginId, Object.values(handlers));
2748
328
  }
2749
- async verifyToken(token) {
2750
- for (const handler of this.handlers) {
2751
- const result = await handler.verifyToken(token);
2752
- if (result) {
2753
- const { allAccessRestrictions, ...rest } = result;
2754
- if (allAccessRestrictions) {
2755
- const accessRestrictions = allAccessRestrictions.get(
2756
- this.ownPluginId
2757
- );
2758
- if (!accessRestrictions) {
2759
- const valid = [...allAccessRestrictions.keys()].map((k) => `'${k}'`).join(", ");
2760
- throw new errors.NotAllowedError(
2761
- `This token's access is restricted to plugin(s) ${valid}`
2762
- );
2763
- }
2764
- return {
2765
- ...rest,
2766
- accessRestrictions
2767
- };
329
+ async initializeEagerServicesWithScope(scope, pluginId = "root") {
330
+ for (const [factory] of this.#providedFactories.values()) {
331
+ if (factory.service.scope === scope) {
332
+ if (scope === "root" && factory.initialization !== "lazy") {
333
+ await this.get(factory.service, pluginId);
334
+ } else if (scope === "plugin" && factory.initialization === "always") {
335
+ await this.get(factory.service, pluginId);
2768
336
  }
2769
- return rest;
2770
337
  }
2771
338
  }
2772
- return void 0;
2773
- }
2774
- }
2775
-
2776
- const CLOCK_MARGIN_S = 10;
2777
- class JwksClient {
2778
- constructor(getEndpoint) {
2779
- this.getEndpoint = getEndpoint;
2780
- }
2781
- #keyStore;
2782
- #keyStoreUpdated = 0;
2783
- get getKey() {
2784
- if (!this.#keyStore) {
2785
- throw new errors.AuthenticationError(
2786
- "refreshKeyStore must be called before jwksClient.getKey"
2787
- );
2788
- }
2789
- return this.#keyStore;
2790
339
  }
2791
- /**
2792
- * If the last keystore refresh is stale, update the keystore URL to the latest
2793
- */
2794
- async refreshKeyStore(rawJwtToken) {
2795
- const payload = await jose.decodeJwt(rawJwtToken);
2796
- const header = await jose.decodeProtectedHeader(rawJwtToken);
2797
- let keyStoreHasKey;
2798
- try {
2799
- if (this.#keyStore) {
2800
- const [_, rawPayload, rawSignature] = rawJwtToken.split(".");
2801
- keyStoreHasKey = await this.#keyStore(header, {
2802
- payload: rawPayload,
2803
- signature: rawSignature
2804
- });
2805
- }
2806
- } catch (error) {
2807
- keyStoreHasKey = false;
2808
- }
2809
- const issuedAfterLastRefresh = payload?.iat && payload.iat > this.#keyStoreUpdated - CLOCK_MARGIN_S;
2810
- if (!this.#keyStore || !keyStoreHasKey && issuedAfterLastRefresh) {
2811
- const endpoint = await this.getEndpoint();
2812
- this.#keyStore = jose.createRemoteJWKSet(endpoint);
2813
- this.#keyStoreUpdated = Date.now() / 1e3;
340
+ get(ref, pluginId) {
341
+ this.#instantiatedFactories.add(ref.id);
342
+ const resolvedFactory = this.#resolveFactory(ref, pluginId);
343
+ if (!resolvedFactory) {
344
+ return ref.multiton ? Promise.resolve([]) : void 0;
2814
345
  }
346
+ return resolvedFactory.then((factories) => {
347
+ return Promise.all(
348
+ factories.map((factory) => {
349
+ if (factory.service.scope === "root") {
350
+ let existing = this.#rootServiceImplementations.get(factory);
351
+ if (!existing) {
352
+ this.#checkForMissingDeps(factory, pluginId);
353
+ const rootDeps = new Array();
354
+ for (const [name, serviceRef] of Object.entries(factory.deps)) {
355
+ if (serviceRef.scope !== "root") {
356
+ throw new Error(
357
+ `Failed to instantiate 'root' scoped service '${ref.id}' because it depends on '${serviceRef.scope}' scoped service '${serviceRef.id}'.`
358
+ );
359
+ }
360
+ const target = this.get(serviceRef, pluginId);
361
+ rootDeps.push(target.then((impl) => [name, impl]));
362
+ }
363
+ existing = Promise.all(rootDeps).then(
364
+ (entries) => factory.factory(Object.fromEntries(entries), void 0)
365
+ );
366
+ this.#rootServiceImplementations.set(factory, existing);
367
+ }
368
+ return existing;
369
+ }
370
+ let implementation = this.#implementations.get(factory);
371
+ if (!implementation) {
372
+ this.#checkForMissingDeps(factory, pluginId);
373
+ const rootDeps = new Array();
374
+ for (const [name, serviceRef] of Object.entries(factory.deps)) {
375
+ if (serviceRef.scope === "root") {
376
+ const target = this.get(serviceRef, pluginId);
377
+ rootDeps.push(target.then((impl) => [name, impl]));
378
+ }
379
+ }
380
+ implementation = {
381
+ context: Promise.all(rootDeps).then(
382
+ (entries) => factory.createRootContext?.(Object.fromEntries(entries))
383
+ ).catch((error) => {
384
+ const cause = errors.stringifyError(error);
385
+ throw new Error(
386
+ `Failed to instantiate service '${ref.id}' because createRootContext threw an error, ${cause}`
387
+ );
388
+ }),
389
+ byPlugin: /* @__PURE__ */ new Map()
390
+ };
391
+ this.#implementations.set(factory, implementation);
392
+ }
393
+ let result = implementation.byPlugin.get(pluginId);
394
+ if (!result) {
395
+ const allDeps = new Array();
396
+ for (const [name, serviceRef] of Object.entries(factory.deps)) {
397
+ const target = this.get(serviceRef, pluginId);
398
+ allDeps.push(target.then((impl) => [name, impl]));
399
+ }
400
+ result = implementation.context.then(
401
+ (context) => Promise.all(allDeps).then(
402
+ (entries) => factory.factory(Object.fromEntries(entries), context)
403
+ )
404
+ ).catch((error) => {
405
+ const cause = errors.stringifyError(error);
406
+ throw new Error(
407
+ `Failed to instantiate service '${ref.id}' for '${pluginId}' because the factory function threw an error, ${cause}`
408
+ );
409
+ });
410
+ implementation.byPlugin.set(pluginId, result);
411
+ }
412
+ return result;
413
+ })
414
+ );
415
+ }).then((results) => ref.multiton ? results : results[0]);
2815
416
  }
2816
417
  }
2817
418
 
2818
- const SECONDS_IN_MS$2 = 1e3;
2819
- const ALLOWED_PLUGIN_ID_PATTERN = /^[a-z0-9_-]+$/i;
2820
- class PluginTokenHandler {
2821
- constructor(logger, ownPluginId, keySource, algorithm, keyDurationSeconds, discovery) {
2822
- this.logger = logger;
2823
- this.ownPluginId = ownPluginId;
2824
- this.keySource = keySource;
2825
- this.algorithm = algorithm;
2826
- this.keyDurationSeconds = keyDurationSeconds;
2827
- this.discovery = discovery;
2828
- }
2829
- jwksMap = /* @__PURE__ */ new Map();
2830
- // Tracking state for isTargetPluginSupported
2831
- supportedTargetPlugins = /* @__PURE__ */ new Set();
2832
- targetPluginInflightChecks = /* @__PURE__ */ new Map();
2833
- static create(options) {
2834
- return new PluginTokenHandler(
2835
- options.logger,
2836
- options.ownPluginId,
2837
- options.keySource,
2838
- options.algorithm ?? "ES256",
2839
- Math.round(types.durationToMilliseconds(options.keyDuration) / 1e3),
2840
- options.discovery
2841
- );
2842
- }
2843
- async verifyToken(token) {
2844
- try {
2845
- const { typ } = jose.decodeProtectedHeader(token);
2846
- if (typ !== pluginAuthNode.tokenTypes.plugin.typParam) {
2847
- return void 0;
2848
- }
2849
- } catch {
2850
- return void 0;
2851
- }
2852
- const pluginId = String(jose.decodeJwt(token).sub);
2853
- if (!pluginId) {
2854
- throw new errors.AuthenticationError("Invalid plugin token: missing subject");
2855
- }
2856
- if (!ALLOWED_PLUGIN_ID_PATTERN.test(pluginId)) {
2857
- throw new errors.AuthenticationError(
2858
- "Invalid plugin token: forbidden subject format"
2859
- );
2860
- }
2861
- const jwksClient = await this.getJwksClient(pluginId);
2862
- await jwksClient.refreshKeyStore(token);
2863
- const { payload } = await jose.jwtVerify(
2864
- token,
2865
- jwksClient.getKey,
2866
- {
2867
- typ: pluginAuthNode.tokenTypes.plugin.typParam,
2868
- audience: this.ownPluginId,
2869
- requiredClaims: ["iat", "exp", "sub", "aud"]
2870
- }
2871
- ).catch((e) => {
2872
- throw new errors.AuthenticationError("Invalid plugin token", e);
2873
- });
2874
- return { subject: `plugin:${payload.sub}`, limitedUserToken: payload.obo };
2875
- }
2876
- async issueToken(options) {
2877
- const { pluginId, targetPluginId, onBehalfOf } = options;
2878
- const key = await this.keySource.getPrivateSigningKey();
2879
- const sub = pluginId;
2880
- const aud = targetPluginId;
2881
- const iat = Math.floor(Date.now() / SECONDS_IN_MS$2);
2882
- const ourExp = iat + this.keyDurationSeconds;
2883
- const exp = onBehalfOf ? Math.min(
2884
- ourExp,
2885
- Math.floor(onBehalfOf.expiresAt.getTime() / SECONDS_IN_MS$2)
2886
- ) : ourExp;
2887
- const claims = { sub, aud, iat, exp, obo: onBehalfOf?.token };
2888
- const token = await new jose.SignJWT(claims).setProtectedHeader({
2889
- typ: pluginAuthNode.tokenTypes.plugin.typParam,
2890
- alg: this.algorithm,
2891
- kid: key.kid
2892
- }).setAudience(aud).setSubject(sub).setIssuedAt(iat).setExpirationTime(exp).sign(await jose.importJWK(key));
2893
- return { token };
2894
- }
2895
- async isTargetPluginSupported(targetPluginId) {
2896
- if (this.supportedTargetPlugins.has(targetPluginId)) {
2897
- return true;
419
+ const LOGGER_INTERVAL_MAX = 6e4;
420
+ function joinIds(ids) {
421
+ return [...ids].map((id) => `'${id}'`).join(", ");
422
+ }
423
+ function createInitializationLogger(pluginIds, rootLogger) {
424
+ const logger = rootLogger?.child({ type: "initialization" });
425
+ const starting = new Set(pluginIds);
426
+ const started = /* @__PURE__ */ new Set();
427
+ logger?.info(`Plugin initialization started: ${joinIds(pluginIds)}`);
428
+ const getInitStatus = () => {
429
+ let status = "";
430
+ if (started.size > 0) {
431
+ status = `, newly initialized: ${joinIds(started)}`;
432
+ started.clear();
2898
433
  }
2899
- const inFlight = this.targetPluginInflightChecks.get(targetPluginId);
2900
- if (inFlight) {
2901
- return inFlight;
434
+ if (starting.size > 0) {
435
+ status += `, still initializing: ${joinIds(starting)}`;
2902
436
  }
2903
- const doCheck = async () => {
2904
- try {
2905
- const res = await fetch(
2906
- `${await this.discovery.getBaseUrl(
2907
- targetPluginId
2908
- )}/.backstage/auth/v1/jwks.json`
2909
- );
2910
- if (res.status === 404) {
2911
- return false;
2912
- }
2913
- if (!res.ok) {
2914
- throw new Error(`Failed to fetch jwks.json, ${res.status}`);
2915
- }
2916
- const data = await res.json();
2917
- if (!data.keys) {
2918
- throw new Error(`Invalid jwks.json response, missing keys`);
2919
- }
2920
- this.supportedTargetPlugins.add(targetPluginId);
2921
- return true;
2922
- } catch (error) {
2923
- this.logger.error("Unexpected failure for target JWKS check", error);
2924
- return false;
2925
- } finally {
2926
- this.targetPluginInflightChecks.delete(targetPluginId);
437
+ return status;
438
+ };
439
+ let interval = 1e3;
440
+ let prevInterval = 0;
441
+ let timeout;
442
+ const onTimeout = () => {
443
+ logger?.info(`Plugin initialization in progress${getInitStatus()}`);
444
+ const nextInterval = Math.min(interval + prevInterval, LOGGER_INTERVAL_MAX);
445
+ prevInterval = interval;
446
+ interval = nextInterval;
447
+ timeout = setTimeout(onTimeout, nextInterval);
448
+ };
449
+ timeout = setTimeout(onTimeout, interval);
450
+ return {
451
+ onPluginStarted(pluginId) {
452
+ starting.delete(pluginId);
453
+ started.add(pluginId);
454
+ },
455
+ onAllStarted() {
456
+ logger?.info(`Plugin initialization complete${getInitStatus()}`);
457
+ if (timeout) {
458
+ clearTimeout(timeout);
459
+ timeout = void 0;
2927
460
  }
2928
- };
2929
- const check = doCheck();
2930
- this.targetPluginInflightChecks.set(targetPluginId, check);
2931
- return check;
2932
- }
2933
- async getJwksClient(pluginId) {
2934
- const client = this.jwksMap.get(pluginId);
2935
- if (client) {
2936
- return client;
2937
- }
2938
- if (!await this.isTargetPluginSupported(pluginId)) {
2939
- throw new errors.AuthenticationError(
2940
- `Received a plugin token where the source '${pluginId}' plugin unexpectedly does not have a JWKS endpoint`
2941
- );
2942
461
  }
2943
- const newClient = new JwksClient(async () => {
2944
- return new URL(
2945
- `${await this.discovery.getBaseUrl(
2946
- pluginId
2947
- )}/.backstage/auth/v1/jwks.json`
2948
- );
2949
- });
2950
- this.jwksMap.set(pluginId, newClient);
2951
- return newClient;
2952
- }
462
+ };
2953
463
  }
2954
464
 
2955
- const MIGRATIONS_TABLE = "backstage_backend_public_keys__knex_migrations";
2956
- const TABLE = "backstage_backend_public_keys__keys";
2957
- function applyDatabaseMigrations(knex) {
2958
- const migrationsDir = backendPluginApi.resolvePackagePath(
2959
- "@backstage/backend-defaults",
2960
- "migrations/auth"
2961
- );
2962
- return knex.migrate.latest({
2963
- directory: migrationsDir,
2964
- tableName: MIGRATIONS_TABLE
2965
- });
2966
- }
2967
- class DatabaseKeyStore {
2968
- constructor(client, logger) {
2969
- this.client = client;
2970
- this.logger = logger;
2971
- }
2972
- static async create(options) {
2973
- const { database, logger } = options;
2974
- const client = await database.getClient();
2975
- if (!database.migrations?.skip) {
2976
- await applyDatabaseMigrations(client);
2977
- }
2978
- return new DatabaseKeyStore(client, logger);
2979
- }
2980
- async addKey(options) {
2981
- await this.client(TABLE).insert({
2982
- id: options.key.kid,
2983
- key: JSON.stringify(options.key),
2984
- expires_at: options.expiresAt.toISOString()
2985
- });
465
+ class BackendInitializer {
466
+ #startPromise;
467
+ #registrations = new Array();
468
+ #extensionPoints = /* @__PURE__ */ new Map();
469
+ #serviceRegistry;
470
+ #registeredFeatures = new Array();
471
+ #registeredFeatureLoaders = new Array();
472
+ constructor(defaultApiFactories) {
473
+ this.#serviceRegistry = ServiceRegistry.create([...defaultApiFactories]);
2986
474
  }
2987
- async listKeys() {
2988
- const rows = await this.client(TABLE).select();
2989
- const keys = rows.map((row) => ({
2990
- id: row.id,
2991
- key: JSON.parse(row.key),
2992
- expiresAt: new Date(row.expires_at)
2993
- }));
2994
- const validKeys = [];
2995
- const expiredKeys = [];
2996
- for (const key of keys) {
2997
- if (luxon.DateTime.fromJSDate(key.expiresAt) < luxon.DateTime.local()) {
2998
- expiredKeys.push(key);
475
+ async #getInitDeps(deps, pluginId, moduleId) {
476
+ const result = /* @__PURE__ */ new Map();
477
+ const missingRefs = /* @__PURE__ */ new Set();
478
+ for (const [name, ref] of Object.entries(deps)) {
479
+ const ep = this.#extensionPoints.get(ref.id);
480
+ if (ep) {
481
+ if (ep.pluginId !== pluginId) {
482
+ throw new Error(
483
+ `Illegal dependency: Module '${moduleId}' for plugin '${pluginId}' attempted to depend on extension point '${ref.id}' for plugin '${ep.pluginId}'. Extension points can only be used within their plugin's scope.`
484
+ );
485
+ }
486
+ result.set(name, ep.impl);
2999
487
  } else {
3000
- validKeys.push(key);
3001
- }
3002
- }
3003
- if (expiredKeys.length > 0) {
3004
- const kids = expiredKeys.map(({ key }) => key.kid);
3005
- this.logger.info(
3006
- `Removing expired plugin service keys, '${kids.join("', '")}'`
3007
- );
3008
- this.client(TABLE).delete().whereIn("id", kids).catch((error) => {
3009
- this.logger.error(
3010
- "Failed to remove expired plugin service keys",
3011
- error
488
+ const impl = await this.#serviceRegistry.get(
489
+ ref,
490
+ pluginId
3012
491
  );
3013
- });
3014
- }
3015
- return { keys: validKeys };
3016
- }
3017
- }
3018
-
3019
- const SECONDS_IN_MS$1 = 1e3;
3020
- const KEY_EXPIRATION_MARGIN_FACTOR = 3;
3021
- class DatabasePluginKeySource {
3022
- constructor(keyStore, logger, keyDurationSeconds, algorithm) {
3023
- this.keyStore = keyStore;
3024
- this.logger = logger;
3025
- this.keyDurationSeconds = keyDurationSeconds;
3026
- this.algorithm = algorithm;
3027
- }
3028
- privateKeyPromise;
3029
- keyExpiry;
3030
- static async create(options) {
3031
- const keyStore = await DatabaseKeyStore.create({
3032
- database: options.database,
3033
- logger: options.logger
3034
- });
3035
- return new DatabasePluginKeySource(
3036
- keyStore,
3037
- options.logger,
3038
- Math.round(types.durationToMilliseconds(options.keyDuration) / 1e3),
3039
- options.algorithm ?? "ES256"
3040
- );
3041
- }
3042
- async getPrivateSigningKey() {
3043
- if (this.privateKeyPromise) {
3044
- if (this.keyExpiry && this.keyExpiry.getTime() > Date.now()) {
3045
- return this.privateKeyPromise;
492
+ if (impl) {
493
+ result.set(name, impl);
494
+ } else {
495
+ missingRefs.add(ref);
496
+ }
3046
497
  }
3047
- this.logger.info(`Signing key has expired, generating new key`);
3048
- delete this.privateKeyPromise;
3049
498
  }
3050
- this.keyExpiry = new Date(
3051
- Date.now() + this.keyDurationSeconds * SECONDS_IN_MS$1
3052
- );
3053
- const promise = (async () => {
3054
- const kid = uuid.v4();
3055
- const key = await jose.generateKeyPair(this.algorithm);
3056
- const publicKey = await jose.exportJWK(key.publicKey);
3057
- const privateKey = await jose.exportJWK(key.privateKey);
3058
- publicKey.kid = privateKey.kid = kid;
3059
- publicKey.alg = privateKey.alg = this.algorithm;
3060
- this.logger.info(`Created new signing key ${kid}`);
3061
- await this.keyStore.addKey({
3062
- id: kid,
3063
- key: publicKey,
3064
- expiresAt: new Date(
3065
- Date.now() + this.keyDurationSeconds * SECONDS_IN_MS$1 * KEY_EXPIRATION_MARGIN_FACTOR
3066
- )
3067
- });
3068
- return privateKey;
3069
- })();
3070
- this.privateKeyPromise = promise;
3071
- try {
3072
- await promise;
3073
- } catch (error) {
3074
- this.logger.error(`Failed to generate new signing key, ${error}`);
3075
- delete this.keyExpiry;
3076
- delete this.privateKeyPromise;
499
+ if (missingRefs.size > 0) {
500
+ const missing = Array.from(missingRefs).join(", ");
501
+ throw new Error(
502
+ `No extension point or service available for the following ref(s): ${missing}`
503
+ );
3077
504
  }
3078
- return promise;
3079
- }
3080
- listKeys() {
3081
- return this.keyStore.listKeys();
505
+ return Object.fromEntries(result);
3082
506
  }
3083
- }
3084
-
3085
- const DEFAULT_ALGORITHM = "ES256";
3086
- const SECONDS_IN_MS = 1e3;
3087
- class StaticConfigPluginKeySource {
3088
- constructor(keyPairs, keyDurationSeconds) {
3089
- this.keyPairs = keyPairs;
3090
- this.keyDurationSeconds = keyDurationSeconds;
507
+ add(feature) {
508
+ if (this.#startPromise) {
509
+ throw new Error("feature can not be added after the backend has started");
510
+ }
511
+ this.#registeredFeatures.push(Promise.resolve(feature));
3091
512
  }
3092
- static async create(options) {
3093
- const keyConfigs = options.sourceConfig.getConfigArray("static.keys").map((c) => {
3094
- const staticKeyConfig = {
3095
- publicKeyFile: c.getString("publicKeyFile"),
3096
- privateKeyFile: c.getOptionalString("privateKeyFile"),
3097
- keyId: c.getString("keyId"),
3098
- algorithm: c.getOptionalString("algorithm") ?? DEFAULT_ALGORITHM
3099
- };
3100
- return staticKeyConfig;
3101
- });
3102
- const keyPairs = await Promise.all(
3103
- keyConfigs.map(async (k) => await this.loadKeyPair(k))
3104
- );
3105
- if (keyPairs.length < 1) {
3106
- throw new Error(
3107
- "At least one key pair must be provided in static.keys, when the static key store type is used"
3108
- );
3109
- } else if (!keyPairs[0].privateKey) {
513
+ #addFeature(feature) {
514
+ if (isServiceFactory(feature)) {
515
+ this.#serviceRegistry.add(feature);
516
+ } else if (isBackendFeatureLoader(feature)) {
517
+ this.#registeredFeatureLoaders.push(feature);
518
+ } else if (isBackendRegistrations(feature)) {
519
+ this.#registrations.push(feature);
520
+ } else {
3110
521
  throw new Error(
3111
- "Private key for signing must be provided in the first key pair in static.keys, when the static key store type is used"
522
+ `Failed to add feature, invalid feature ${JSON.stringify(feature)}`
3112
523
  );
3113
524
  }
3114
- return new StaticConfigPluginKeySource(
3115
- keyPairs,
3116
- types.durationToMilliseconds(options.keyDuration) / SECONDS_IN_MS
3117
- );
3118
- }
3119
- async getPrivateSigningKey() {
3120
- return this.keyPairs[0].privateKey;
3121
- }
3122
- async listKeys() {
3123
- const keys = this.keyPairs.map((k) => this.keyPairToStoredKey(k));
3124
- return { keys };
3125
- }
3126
- static async loadKeyPair(options) {
3127
- const algorithm = options.algorithm;
3128
- const keyId = options.keyId;
3129
- const publicKey = await this.loadPublicKeyFromFile(
3130
- options.publicKeyFile,
3131
- keyId,
3132
- algorithm
3133
- );
3134
- const privateKey = options.privateKeyFile ? await this.loadPrivateKeyFromFile(
3135
- options.privateKeyFile,
3136
- keyId,
3137
- algorithm
3138
- ) : void 0;
3139
- return { publicKey, privateKey, keyId };
3140
- }
3141
- static async loadPublicKeyFromFile(path, keyId, algorithm) {
3142
- return this.loadKeyFromFile(path, keyId, algorithm, jose.importSPKI);
3143
- }
3144
- static async loadPrivateKeyFromFile(path, keyId, algorithm) {
3145
- return this.loadKeyFromFile(path, keyId, algorithm, jose.importPKCS8);
3146
- }
3147
- static async loadKeyFromFile(path, keyId, algorithm, importer) {
3148
- const content = await fs$1.promises.readFile(path, { encoding: "utf8", flag: "r" });
3149
- const key = await importer(content, algorithm);
3150
- const jwk = await jose.exportJWK(key);
3151
- jwk.kid = keyId;
3152
- jwk.alg = algorithm;
3153
- return jwk;
3154
525
  }
3155
- keyPairToStoredKey(keyPair) {
3156
- const publicKey = {
3157
- ...keyPair.publicKey,
3158
- kid: keyPair.keyId
3159
- };
3160
- return {
3161
- key: publicKey,
3162
- id: keyPair.keyId,
3163
- expiresAt: new Date(Date.now() + this.keyDurationSeconds * SECONDS_IN_MS)
526
+ async start() {
527
+ if (this.#startPromise) {
528
+ throw new Error("Backend has already started");
529
+ }
530
+ const exitHandler = async () => {
531
+ process.removeListener("SIGTERM", exitHandler);
532
+ process.removeListener("SIGINT", exitHandler);
533
+ process.removeListener("beforeExit", exitHandler);
534
+ try {
535
+ await this.stop();
536
+ process.exit(0);
537
+ } catch (error) {
538
+ console.error(error);
539
+ process.exit(1);
540
+ }
3164
541
  };
542
+ process.addListener("SIGTERM", exitHandler);
543
+ process.addListener("SIGINT", exitHandler);
544
+ process.addListener("beforeExit", exitHandler);
545
+ this.#startPromise = this.#doStart();
546
+ await this.#startPromise;
3165
547
  }
3166
- }
3167
-
3168
- const CONFIG_ROOT_KEY = "backend.auth.pluginKeyStore";
3169
- async function createPluginKeySource(options) {
3170
- const keyStoreConfig = options.config.getOptionalConfig(CONFIG_ROOT_KEY);
3171
- const type = keyStoreConfig?.getOptionalString("type") ?? "database";
3172
- if (!keyStoreConfig || type === "database") {
3173
- return DatabasePluginKeySource.create({
3174
- database: options.database,
3175
- logger: options.logger,
3176
- keyDuration: options.keyDuration,
3177
- algorithm: options.algorithm
3178
- });
3179
- } else if (type === "static") {
3180
- return StaticConfigPluginKeySource.create({
3181
- sourceConfig: keyStoreConfig,
3182
- keyDuration: options.keyDuration
3183
- });
3184
- }
3185
- throw new Error(
3186
- `Unsupported config value ${CONFIG_ROOT_KEY}.type '${type}'; expected one of 'database', 'static'`
3187
- );
3188
- }
3189
-
3190
- class UserTokenHandler {
3191
- constructor(jwksClient) {
3192
- this.jwksClient = jwksClient;
3193
- }
3194
- static create(options) {
3195
- const jwksClient = new JwksClient(async () => {
3196
- const url = await options.discovery.getBaseUrl("auth");
3197
- return new URL(`${url}/.well-known/jwks.json`);
3198
- });
3199
- return new UserTokenHandler(jwksClient);
3200
- }
3201
- async verifyToken(token) {
3202
- const verifyOpts = this.#getTokenVerificationOptions(token);
3203
- if (!verifyOpts) {
3204
- return void 0;
3205
- }
3206
- await this.jwksClient.refreshKeyStore(token);
3207
- const { payload } = await jose.jwtVerify(
3208
- token,
3209
- this.jwksClient.getKey,
3210
- verifyOpts
3211
- ).catch((e) => {
3212
- throw new errors.AuthenticationError("Invalid token", e);
3213
- });
3214
- const userEntityRef = payload.sub;
3215
- if (!userEntityRef) {
3216
- throw new errors.AuthenticationError("No user sub found in token");
548
+ async #doStart() {
549
+ this.#serviceRegistry.checkForCircularDeps();
550
+ for (const feature of this.#registeredFeatures) {
551
+ this.#addFeature(await feature);
3217
552
  }
3218
- return { userEntityRef };
3219
- }
3220
- #getTokenVerificationOptions(token) {
3221
- try {
3222
- const { typ } = jose.decodeProtectedHeader(token);
3223
- if (typ === pluginAuthNode.tokenTypes.user.typParam) {
3224
- return {
3225
- requiredClaims: ["iat", "exp", "sub"],
3226
- typ: pluginAuthNode.tokenTypes.user.typParam
3227
- };
3228
- }
3229
- if (typ === pluginAuthNode.tokenTypes.limitedUser.typParam) {
3230
- return {
3231
- requiredClaims: ["iat", "exp", "sub"],
3232
- typ: pluginAuthNode.tokenTypes.limitedUser.typParam
3233
- };
553
+ const featureDiscovery = await this.#serviceRegistry.get(
554
+ alpha.featureDiscoveryServiceRef,
555
+ "root"
556
+ );
557
+ if (featureDiscovery) {
558
+ const { features } = await featureDiscovery.getBackendFeatures();
559
+ for (const feature of features) {
560
+ this.#addFeature(feature);
3234
561
  }
3235
- const { aud } = jose.decodeJwt(token);
3236
- if (aud === pluginAuthNode.tokenTypes.user.audClaim) {
3237
- return {
3238
- audience: pluginAuthNode.tokenTypes.user.audClaim
3239
- };
562
+ this.#serviceRegistry.checkForCircularDeps();
563
+ }
564
+ await this.#applyBackendFeatureLoaders(this.#registeredFeatureLoaders);
565
+ await this.#serviceRegistry.initializeEagerServicesWithScope("root");
566
+ const pluginInits = /* @__PURE__ */ new Map();
567
+ const moduleInits = /* @__PURE__ */ new Map();
568
+ for (const feature of this.#registrations) {
569
+ for (const r of feature.getRegistrations()) {
570
+ const provides = /* @__PURE__ */ new Set();
571
+ if (r.type === "plugin" || r.type === "module") {
572
+ for (const [extRef, extImpl] of r.extensionPoints) {
573
+ if (this.#extensionPoints.has(extRef.id)) {
574
+ throw new Error(
575
+ `ExtensionPoint with ID '${extRef.id}' is already registered`
576
+ );
577
+ }
578
+ this.#extensionPoints.set(extRef.id, {
579
+ impl: extImpl,
580
+ pluginId: r.pluginId
581
+ });
582
+ provides.add(extRef);
583
+ }
584
+ }
585
+ if (r.type === "plugin") {
586
+ if (pluginInits.has(r.pluginId)) {
587
+ throw new Error(`Plugin '${r.pluginId}' is already registered`);
588
+ }
589
+ pluginInits.set(r.pluginId, {
590
+ provides,
591
+ consumes: new Set(Object.values(r.init.deps)),
592
+ init: r.init
593
+ });
594
+ } else if (r.type === "module") {
595
+ let modules = moduleInits.get(r.pluginId);
596
+ if (!modules) {
597
+ modules = /* @__PURE__ */ new Map();
598
+ moduleInits.set(r.pluginId, modules);
599
+ }
600
+ if (modules.has(r.moduleId)) {
601
+ throw new Error(
602
+ `Module '${r.moduleId}' for plugin '${r.pluginId}' is already registered`
603
+ );
604
+ }
605
+ modules.set(r.moduleId, {
606
+ provides,
607
+ consumes: new Set(Object.values(r.init.deps)),
608
+ init: r.init
609
+ });
610
+ } else {
611
+ throw new Error(`Invalid registration type '${r.type}'`);
612
+ }
3240
613
  }
3241
- } catch {
3242
614
  }
3243
- return void 0;
3244
- }
3245
- createLimitedUserToken(backstageToken) {
3246
- const [headerRaw, payloadRaw] = backstageToken.split(".");
3247
- const header = JSON.parse(
3248
- new TextDecoder().decode(jose.base64url.decode(headerRaw))
615
+ const allPluginIds = [...pluginInits.keys()];
616
+ const initLogger = createInitializationLogger(
617
+ allPluginIds,
618
+ await this.#serviceRegistry.get(backendPluginApi.coreServices.rootLogger, "root")
3249
619
  );
3250
- const payload = JSON.parse(
3251
- new TextDecoder().decode(jose.base64url.decode(payloadRaw))
620
+ await Promise.all(
621
+ allPluginIds.map(async (pluginId) => {
622
+ await this.#serviceRegistry.initializeEagerServicesWithScope(
623
+ "plugin",
624
+ pluginId
625
+ );
626
+ const modules = moduleInits.get(pluginId);
627
+ if (modules) {
628
+ const tree = DependencyGraph.fromIterable(
629
+ Array.from(modules).map(([moduleId, moduleInit]) => ({
630
+ value: { moduleId, moduleInit },
631
+ // Relationships are reversed at this point since we're only interested in the extension points.
632
+ // If a modules provides extension point A we want it to be initialized AFTER all modules
633
+ // that depend on extension point A, so that they can provide their extensions.
634
+ consumes: Array.from(moduleInit.provides).map((p) => p.id),
635
+ provides: Array.from(moduleInit.consumes).map((c) => c.id)
636
+ }))
637
+ );
638
+ const circular = tree.detectCircularDependency();
639
+ if (circular) {
640
+ throw new errors.ConflictError(
641
+ `Circular dependency detected for modules of plugin '${pluginId}', ${circular.map(({ moduleId }) => `'${moduleId}'`).join(" -> ")}`
642
+ );
643
+ }
644
+ await tree.parallelTopologicalTraversal(
645
+ async ({ moduleId, moduleInit }) => {
646
+ const moduleDeps = await this.#getInitDeps(
647
+ moduleInit.init.deps,
648
+ pluginId,
649
+ moduleId
650
+ );
651
+ await moduleInit.init.func(moduleDeps).catch((error) => {
652
+ throw new errors.ForwardedError(
653
+ `Module '${moduleId}' for plugin '${pluginId}' startup failed`,
654
+ error
655
+ );
656
+ });
657
+ }
658
+ );
659
+ }
660
+ const pluginInit = pluginInits.get(pluginId);
661
+ if (pluginInit) {
662
+ const pluginDeps = await this.#getInitDeps(
663
+ pluginInit.init.deps,
664
+ pluginId
665
+ );
666
+ await pluginInit.init.func(pluginDeps).catch((error) => {
667
+ throw new errors.ForwardedError(
668
+ `Plugin '${pluginId}' startup failed`,
669
+ error
670
+ );
671
+ });
672
+ }
673
+ initLogger.onPluginStarted(pluginId);
674
+ const lifecycleService2 = await this.#getPluginLifecycleImpl(pluginId);
675
+ await lifecycleService2.startup();
676
+ })
3252
677
  );
3253
- const tokenType = header.typ;
3254
- if (!tokenType || tokenType === pluginAuthNode.tokenTypes.limitedUser.typParam) {
3255
- return { token: backstageToken, expiresAt: new Date(payload.exp * 1e3) };
3256
- }
3257
- if (tokenType !== pluginAuthNode.tokenTypes.user.typParam) {
3258
- throw new errors.AuthenticationError(
3259
- "Failed to create limited user token, invalid token type"
678
+ const lifecycleService = await this.#getRootLifecycleImpl();
679
+ await lifecycleService.startup();
680
+ initLogger.onAllStarted();
681
+ if (process.env.NODE_ENV !== "test") {
682
+ const rootLogger = await this.#serviceRegistry.get(
683
+ backendPluginApi.coreServices.rootLogger,
684
+ "root"
3260
685
  );
686
+ process.on("unhandledRejection", (reason) => {
687
+ rootLogger?.child({ type: "unhandledRejection" })?.error("Unhandled rejection", reason);
688
+ });
689
+ process.on("uncaughtException", (error) => {
690
+ rootLogger?.child({ type: "uncaughtException" })?.error("Uncaught exception", error);
691
+ });
3261
692
  }
3262
- const limitedUserToken = [
3263
- jose.base64url.encode(
3264
- JSON.stringify({
3265
- typ: pluginAuthNode.tokenTypes.limitedUser.typParam,
3266
- alg: header.alg,
3267
- kid: header.kid
3268
- })
3269
- ),
3270
- jose.base64url.encode(
3271
- JSON.stringify({
3272
- sub: payload.sub,
3273
- iat: payload.iat,
3274
- exp: payload.exp
3275
- })
3276
- ),
3277
- payload.uip
3278
- ].join(".");
3279
- return { token: limitedUserToken, expiresAt: new Date(payload.exp * 1e3) };
3280
693
  }
3281
- isLimitedUserToken(token) {
694
+ async stop() {
695
+ if (!this.#startPromise) {
696
+ return;
697
+ }
3282
698
  try {
3283
- const { typ } = jose.decodeProtectedHeader(token);
3284
- return typ === pluginAuthNode.tokenTypes.limitedUser.typParam;
3285
- } catch {
3286
- return false;
699
+ await this.#startPromise;
700
+ } catch (error) {
3287
701
  }
702
+ const lifecycleService = await this.#getRootLifecycleImpl();
703
+ await lifecycleService.shutdown();
3288
704
  }
3289
- }
3290
-
3291
- const authServiceFactory$1 = backendPluginApi.createServiceFactory({
3292
- service: backendPluginApi.coreServices.auth,
3293
- deps: {
3294
- config: backendPluginApi.coreServices.rootConfig,
3295
- logger: backendPluginApi.coreServices.rootLogger,
3296
- discovery: backendPluginApi.coreServices.discovery,
3297
- plugin: backendPluginApi.coreServices.pluginMetadata,
3298
- database: backendPluginApi.coreServices.database,
3299
- // Re-using the token manager makes sure that we use the same generated keys for
3300
- // development as plugins that have not yet been migrated. It's important that this
3301
- // keeps working as long as there are plugins that have not been migrated to the
3302
- // new auth services in the new backend system.
3303
- tokenManager: backendPluginApi.coreServices.tokenManager
3304
- },
3305
- async factory({ config, discovery, plugin, tokenManager, logger, database }) {
3306
- const disableDefaultAuthPolicy = config.getOptionalBoolean(
3307
- "backend.auth.dangerouslyDisableDefaultAuthPolicy"
3308
- ) ?? false;
3309
- const keyDuration = { hours: 1 };
3310
- const keySource = await createPluginKeySource({
3311
- config,
3312
- database,
3313
- logger,
3314
- keyDuration
3315
- });
3316
- const userTokens = UserTokenHandler.create({
3317
- discovery
3318
- });
3319
- const pluginTokens = PluginTokenHandler.create({
3320
- ownPluginId: plugin.getId(),
3321
- logger,
3322
- keySource,
3323
- keyDuration,
3324
- discovery
3325
- });
3326
- const externalTokens = ExternalTokenHandler.create({
3327
- ownPluginId: plugin.getId(),
3328
- config,
3329
- logger
3330
- });
3331
- return new DefaultAuthService(
3332
- userTokens,
3333
- pluginTokens,
3334
- externalTokens,
3335
- tokenManager,
3336
- plugin.getId(),
3337
- disableDefaultAuthPolicy,
3338
- keySource
705
+ // Bit of a hacky way to grab the lifecycle services, potentially find a nicer way to do this
706
+ async #getRootLifecycleImpl() {
707
+ const lifecycleService = await this.#serviceRegistry.get(
708
+ backendPluginApi.coreServices.rootLifecycle,
709
+ "root"
3339
710
  );
3340
- }
3341
- });
3342
-
3343
- const authServiceFactory = authServiceFactory$1;
3344
-
3345
- const FIVE_MINUTES_MS = 5 * 60 * 1e3;
3346
- const BACKSTAGE_AUTH_COOKIE = "backstage-auth";
3347
- function getTokenFromRequest(req) {
3348
- const authHeader = req.headers.authorization;
3349
- if (typeof authHeader === "string") {
3350
- const matches = authHeader.match(/^Bearer[ ]+(\S+)$/i);
3351
- const token = matches?.[1];
3352
- if (token) {
3353
- return token;
3354
- }
3355
- }
3356
- return void 0;
3357
- }
3358
- function getCookieFromRequest(req) {
3359
- const cookieHeader = req.headers.cookie;
3360
- if (cookieHeader) {
3361
- const cookies = cookie.parse(cookieHeader);
3362
- const token = cookies[BACKSTAGE_AUTH_COOKIE];
3363
- if (token) {
3364
- return token;
3365
- }
3366
- }
3367
- return void 0;
3368
- }
3369
- function willExpireSoon(expiresAt) {
3370
- return Date.now() + FIVE_MINUTES_MS > expiresAt.getTime();
3371
- }
3372
- const credentialsSymbol = Symbol("backstage-credentials");
3373
- const limitedCredentialsSymbol = Symbol("backstage-limited-credentials");
3374
- class DefaultHttpAuthService {
3375
- #auth;
3376
- #discovery;
3377
- #pluginId;
3378
- constructor(auth, discovery, pluginId) {
3379
- this.#auth = auth;
3380
- this.#discovery = discovery;
3381
- this.#pluginId = pluginId;
3382
- }
3383
- async #extractCredentialsFromRequest(req) {
3384
- const token = getTokenFromRequest(req);
3385
- if (!token) {
3386
- return await this.#auth.getNoneCredentials();
3387
- }
3388
- return await this.#auth.authenticate(token);
3389
- }
3390
- async #extractLimitedCredentialsFromRequest(req) {
3391
- const token = getTokenFromRequest(req);
3392
- if (token) {
3393
- return await this.#auth.authenticate(token, {
3394
- allowLimitedAccess: true
3395
- });
3396
- }
3397
- const cookie = getCookieFromRequest(req);
3398
- if (cookie) {
3399
- return await this.#auth.authenticate(cookie, {
3400
- allowLimitedAccess: true
3401
- });
711
+ const service = lifecycleService;
712
+ if (service && typeof service.startup === "function" && typeof service.shutdown === "function") {
713
+ return service;
3402
714
  }
3403
- return await this.#auth.getNoneCredentials();
3404
- }
3405
- async #getCredentials(req) {
3406
- return req[credentialsSymbol] ??= this.#extractCredentialsFromRequest(req);
3407
- }
3408
- async #getLimitedCredentials(req) {
3409
- return req[limitedCredentialsSymbol] ??= this.#extractLimitedCredentialsFromRequest(req);
715
+ throw new Error("Unexpected root lifecycle service implementation");
3410
716
  }
3411
- async credentials(req, options) {
3412
- const credentials = options?.allowLimitedAccess ? await this.#getLimitedCredentials(req) : await this.#getCredentials(req);
3413
- const allowed = options?.allow;
3414
- if (!allowed) {
3415
- return credentials;
3416
- }
3417
- if (this.#auth.isPrincipal(credentials, "none")) {
3418
- if (allowed.includes("none")) {
3419
- return credentials;
3420
- }
3421
- throw new errors.AuthenticationError("Missing credentials");
3422
- } else if (this.#auth.isPrincipal(credentials, "user")) {
3423
- if (allowed.includes("user")) {
3424
- return credentials;
3425
- }
3426
- throw new errors.NotAllowedError(
3427
- `This endpoint does not allow 'user' credentials`
3428
- );
3429
- } else if (this.#auth.isPrincipal(credentials, "service")) {
3430
- if (allowed.includes("service")) {
3431
- return credentials;
3432
- }
3433
- throw new errors.NotAllowedError(
3434
- `This endpoint does not allow 'service' credentials`
3435
- );
3436
- }
3437
- throw new errors.NotAllowedError(
3438
- "Unknown principal type, this should never happen"
717
+ async #getPluginLifecycleImpl(pluginId) {
718
+ const lifecycleService = await this.#serviceRegistry.get(
719
+ backendPluginApi.coreServices.lifecycle,
720
+ pluginId
3439
721
  );
3440
- }
3441
- async issueUserCookie(res, options) {
3442
- if (res.headersSent) {
3443
- throw new Error("Failed to issue user cookie, headers were already sent");
722
+ const service = lifecycleService;
723
+ if (service && typeof service.startup === "function") {
724
+ return service;
3444
725
  }
3445
- let credentials;
3446
- if (options?.credentials) {
3447
- if (this.#auth.isPrincipal(options.credentials, "none")) {
3448
- res.clearCookie(
3449
- BACKSTAGE_AUTH_COOKIE,
3450
- await this.#getCookieOptions(res.req)
726
+ throw new Error("Unexpected plugin lifecycle service implementation");
727
+ }
728
+ async #applyBackendFeatureLoaders(loaders) {
729
+ for (const loader of loaders) {
730
+ const deps = /* @__PURE__ */ new Map();
731
+ const missingRefs = /* @__PURE__ */ new Set();
732
+ for (const [name, ref] of Object.entries(loader.deps ?? {})) {
733
+ if (ref.scope !== "root") {
734
+ throw new Error(
735
+ `Feature loaders can only depend on root scoped services, but '${name}' is scoped to '${ref.scope}'. Offending loader is ${loader.description}`
736
+ );
737
+ }
738
+ const impl = await this.#serviceRegistry.get(
739
+ ref,
740
+ "root"
3451
741
  );
3452
- return { expiresAt: /* @__PURE__ */ new Date() };
742
+ if (impl) {
743
+ deps.set(name, impl);
744
+ } else {
745
+ missingRefs.add(ref);
746
+ }
3453
747
  }
3454
- if (!this.#auth.isPrincipal(options.credentials, "user")) {
3455
- throw new errors.AuthenticationError(
3456
- "Refused to issue cookie for non-user principal"
748
+ if (missingRefs.size > 0) {
749
+ const missing = Array.from(missingRefs).join(", ");
750
+ throw new Error(
751
+ `No service available for the following ref(s): ${missing}, depended on by feature loader ${loader.description}`
3457
752
  );
3458
753
  }
3459
- credentials = options.credentials;
3460
- } else {
3461
- credentials = await this.credentials(res.req, { allow: ["user"] });
3462
- }
3463
- const existingExpiresAt = await this.#existingCookieExpiration(res.req);
3464
- if (existingExpiresAt && !willExpireSoon(existingExpiresAt)) {
3465
- return { expiresAt: existingExpiresAt };
3466
- }
3467
- const { token, expiresAt } = await this.#auth.getLimitedUserToken(
3468
- credentials
3469
- );
3470
- if (!token) {
3471
- throw new Error("User credentials is unexpectedly missing token");
3472
- }
3473
- res.cookie(BACKSTAGE_AUTH_COOKIE, token, {
3474
- ...await this.#getCookieOptions(res.req),
3475
- expires: expiresAt
3476
- });
3477
- return { expiresAt };
3478
- }
3479
- async #getCookieOptions(_req) {
3480
- const externalBaseUrlStr = await this.#discovery.getExternalBaseUrl(
3481
- this.#pluginId
3482
- );
3483
- const externalBaseUrl = new URL(externalBaseUrlStr);
3484
- const secure = externalBaseUrl.protocol === "https:" || externalBaseUrl.hostname === "localhost";
3485
- return {
3486
- domain: externalBaseUrl.hostname,
3487
- httpOnly: true,
3488
- secure,
3489
- priority: "high",
3490
- sameSite: secure ? "none" : "lax"
3491
- };
3492
- }
3493
- async #existingCookieExpiration(req) {
3494
- const existingCookie = getCookieFromRequest(req);
3495
- if (!existingCookie) {
3496
- return void 0;
3497
- }
3498
- try {
3499
- const existingCredentials = await this.#auth.authenticate(
3500
- existingCookie,
3501
- {
3502
- allowLimitedAccess: true
754
+ const result = await loader.loader(Object.fromEntries(deps)).catch((error) => {
755
+ throw new errors.ForwardedError(
756
+ `Feature loader ${loader.description} failed`,
757
+ error
758
+ );
759
+ });
760
+ let didAddServiceFactory = false;
761
+ const newLoaders = new Array();
762
+ for await (const feature of result) {
763
+ if (isBackendFeatureLoader(feature)) {
764
+ newLoaders.push(feature);
765
+ } else {
766
+ didAddServiceFactory ||= isServiceFactory(feature);
767
+ this.#addFeature(feature);
3503
768
  }
3504
- );
3505
- if (!this.#auth.isPrincipal(existingCredentials, "user")) {
3506
- return void 0;
3507
769
  }
3508
- return existingCredentials.expiresAt;
3509
- } catch (error) {
3510
- if (error.name === "AuthenticationError") {
3511
- return void 0;
770
+ if (didAddServiceFactory) {
771
+ this.#serviceRegistry.checkForCircularDeps();
772
+ }
773
+ if (newLoaders.length > 0) {
774
+ await this.#applyBackendFeatureLoaders(newLoaders);
3512
775
  }
3513
- throw error;
3514
776
  }
3515
777
  }
3516
778
  }
3517
- const httpAuthServiceFactory$1 = backendPluginApi.createServiceFactory({
3518
- service: backendPluginApi.coreServices.httpAuth,
3519
- deps: {
3520
- auth: backendPluginApi.coreServices.auth,
3521
- discovery: backendPluginApi.coreServices.discovery,
3522
- plugin: backendPluginApi.coreServices.pluginMetadata
3523
- },
3524
- async factory({ auth, discovery, plugin }) {
3525
- return new DefaultHttpAuthService(auth, discovery, plugin.getId());
779
+ function toInternalBackendFeature(feature) {
780
+ if (feature.$$type !== "@backstage/BackendFeature") {
781
+ throw new Error(`Invalid BackendFeature, bad type '${feature.$$type}'`);
3526
782
  }
3527
- });
3528
-
3529
- const httpAuthServiceFactory = httpAuthServiceFactory$1;
3530
-
3531
- const DEFAULT_TIMEOUT = { seconds: 5 };
3532
- function createLifecycleMiddleware$1(options) {
3533
- const { lifecycle, startupRequestPauseTimeout = DEFAULT_TIMEOUT } = options;
3534
- let state = "init";
3535
- const waiting = /* @__PURE__ */ new Set();
3536
- lifecycle.addStartupHook(async () => {
3537
- if (state === "init") {
3538
- state = "up";
3539
- for (const item of waiting) {
3540
- clearTimeout(item.timeout);
3541
- item.next();
3542
- }
3543
- waiting.clear();
3544
- }
3545
- });
3546
- lifecycle.addShutdownHook(async () => {
3547
- state = "down";
3548
- for (const item of waiting) {
3549
- clearTimeout(item.timeout);
3550
- item.next(new errors.ServiceUnavailableError("Service is shutting down"));
3551
- }
3552
- waiting.clear();
3553
- });
3554
- const timeoutMs = types.durationToMilliseconds(startupRequestPauseTimeout);
3555
- return (_req, _res, next) => {
3556
- if (state === "up") {
3557
- next();
3558
- return;
3559
- } else if (state === "down") {
3560
- next(new errors.ServiceUnavailableError("Service is shutting down"));
3561
- return;
3562
- }
3563
- const item = {
3564
- next,
3565
- timeout: setTimeout(() => {
3566
- if (waiting.delete(item)) {
3567
- next(new errors.ServiceUnavailableError("Service has not started up yet"));
3568
- }
3569
- }, timeoutMs)
3570
- };
3571
- waiting.add(item);
3572
- };
3573
- }
3574
-
3575
- function createPathPolicyPredicate(policyPath) {
3576
- if (policyPath === "/" || policyPath === "*") {
3577
- return () => true;
783
+ const internal = feature;
784
+ if (internal.version !== "v1") {
785
+ throw new Error(
786
+ `Invalid BackendFeature, bad version '${internal.version}'`
787
+ );
3578
788
  }
3579
- const pathRegex = pathToRegexp.pathToRegexp(policyPath, void 0, {
3580
- end: false
3581
- });
3582
- return (path) => {
3583
- return pathRegex.test(path);
3584
- };
789
+ return internal;
3585
790
  }
3586
- function createCredentialsBarrier(options) {
3587
- const { httpAuth, config } = options;
3588
- const disableDefaultAuthPolicy = config.getOptionalBoolean(
3589
- "backend.auth.dangerouslyDisableDefaultAuthPolicy"
3590
- );
3591
- if (disableDefaultAuthPolicy) {
3592
- return {
3593
- middleware: (_req, _res, next) => next(),
3594
- addAuthPolicy: () => {
3595
- }
3596
- };
791
+ function isServiceFactory(feature) {
792
+ const internal = toInternalBackendFeature(feature);
793
+ if (internal.featureType === "service") {
794
+ return true;
3597
795
  }
3598
- const unauthenticatedPredicates = new Array();
3599
- const cookiePredicates = new Array();
3600
- const middleware = (req, _, next) => {
3601
- const allowsUnauthenticated = unauthenticatedPredicates.some(
3602
- (predicate) => predicate(req.path)
3603
- );
3604
- if (allowsUnauthenticated) {
3605
- next();
3606
- return;
3607
- }
3608
- const allowsCookie = cookiePredicates.some(
3609
- (predicate) => predicate(req.path)
3610
- );
3611
- httpAuth.credentials(req, {
3612
- allow: ["user", "service"],
3613
- allowLimitedAccess: allowsCookie
3614
- }).then(
3615
- () => next(),
3616
- (err) => next(err)
3617
- );
3618
- };
3619
- const addAuthPolicy = (policy) => {
3620
- if (policy.allow === "unauthenticated") {
3621
- unauthenticatedPredicates.push(createPathPolicyPredicate(policy.path));
3622
- } else if (policy.allow === "user-cookie") {
3623
- cookiePredicates.push(createPathPolicyPredicate(policy.path));
3624
- } else {
3625
- throw new Error("Invalid auth policy");
3626
- }
3627
- };
3628
- return { middleware, addAuthPolicy };
796
+ return "service" in internal;
3629
797
  }
3630
-
3631
- function createAuthIntegrationRouter(options) {
3632
- const router = Router__default.default();
3633
- router.get("/.backstage/auth/v1/jwks.json", async (_req, res) => {
3634
- const { keys } = await options.auth.listPublicServiceKeys();
3635
- res.json({ keys });
3636
- });
3637
- return router;
798
+ function isBackendRegistrations(feature) {
799
+ const internal = toInternalBackendFeature(feature);
800
+ if (internal.featureType === "registrations") {
801
+ return true;
802
+ }
803
+ return "getRegistrations" in internal;
3638
804
  }
3639
-
3640
- const WELL_KNOWN_COOKIE_PATH_V1 = "/.backstage/auth/v1/cookie";
3641
- function createCookieAuthRefreshMiddleware(options) {
3642
- const { auth, httpAuth } = options;
3643
- const router = Router__default.default();
3644
- router.get(WELL_KNOWN_COOKIE_PATH_V1, async (_, res) => {
3645
- const { expiresAt } = await httpAuth.issueUserCookie(res);
3646
- res.json({ expiresAt: expiresAt.toISOString() });
3647
- });
3648
- router.delete(WELL_KNOWN_COOKIE_PATH_V1, async (_, res) => {
3649
- const credentials = await auth.getNoneCredentials();
3650
- await httpAuth.issueUserCookie(res, { credentials });
3651
- res.status(204).end();
3652
- });
3653
- return router;
805
+ function isBackendFeatureLoader(feature) {
806
+ return toInternalBackendFeature(feature).featureType === "loader";
3654
807
  }
3655
808
 
3656
- const httpRouterServiceFactory$1 = backendPluginApi.createServiceFactory({
3657
- service: backendPluginApi.coreServices.httpRouter,
3658
- initialization: "always",
3659
- deps: {
3660
- plugin: backendPluginApi.coreServices.pluginMetadata,
3661
- config: backendPluginApi.coreServices.rootConfig,
3662
- lifecycle: backendPluginApi.coreServices.lifecycle,
3663
- rootHttpRouter: backendPluginApi.coreServices.rootHttpRouter,
3664
- auth: backendPluginApi.coreServices.auth,
3665
- httpAuth: backendPluginApi.coreServices.httpAuth
3666
- },
3667
- async factory({ auth, httpAuth, config, plugin, rootHttpRouter, lifecycle }) {
3668
- const router = Router__default.default();
3669
- rootHttpRouter.use(`/api/${plugin.getId()}`, router);
3670
- const credentialsBarrier = createCredentialsBarrier({
3671
- httpAuth,
3672
- config
3673
- });
3674
- router.use(createAuthIntegrationRouter({ auth }));
3675
- router.use(createLifecycleMiddleware$1({ lifecycle }));
3676
- router.use(credentialsBarrier.middleware);
3677
- router.use(createCookieAuthRefreshMiddleware({ auth, httpAuth }));
3678
- return {
3679
- use(handler) {
3680
- router.use(handler);
3681
- },
3682
- addAuthPolicy(policy) {
3683
- credentialsBarrier.addAuthPolicy(policy);
3684
- }
3685
- };
3686
- }
3687
- });
3688
-
3689
- const httpRouterServiceFactory = httpRouterServiceFactory$1;
3690
-
3691
- const createLifecycleMiddleware = createLifecycleMiddleware$1;
3692
-
3693
- const loggerServiceFactory$1 = backendPluginApi.createServiceFactory({
3694
- service: backendPluginApi.coreServices.logger,
3695
- deps: {
3696
- rootLogger: backendPluginApi.coreServices.rootLogger,
3697
- plugin: backendPluginApi.coreServices.pluginMetadata
3698
- },
3699
- factory({ rootLogger, plugin }) {
3700
- return rootLogger.child({ plugin: plugin.getId() });
809
+ class BackstageBackend {
810
+ #initializer;
811
+ constructor(defaultServiceFactories) {
812
+ this.#initializer = new BackendInitializer(defaultServiceFactories);
3701
813
  }
3702
- });
3703
-
3704
- const loggerServiceFactory = loggerServiceFactory$1;
3705
-
3706
- function normalizePath(path) {
3707
- return `${trimEnd__default.default(path, "/")}/`;
3708
- }
3709
- let DefaultRootHttpRouter$1 = class DefaultRootHttpRouter {
3710
- #indexPath;
3711
- #router = express.Router();
3712
- #namedRoutes = express.Router();
3713
- #indexRouter = express.Router();
3714
- #existingPaths = new Array();
3715
- static create(options) {
3716
- let indexPath;
3717
- if (options?.indexPath === false) {
3718
- indexPath = void 0;
3719
- } else if (options?.indexPath === void 0) {
3720
- indexPath = "/api/app";
3721
- } else if (options?.indexPath === "") {
3722
- throw new Error("indexPath option may not be an empty string");
814
+ add(feature) {
815
+ if (isPromise(feature)) {
816
+ this.#initializer.add(feature.then((f) => unwrapFeature(f.default)));
3723
817
  } else {
3724
- indexPath = options.indexPath;
3725
- }
3726
- return new DefaultRootHttpRouter(indexPath);
3727
- }
3728
- constructor(indexPath) {
3729
- this.#indexPath = indexPath;
3730
- this.#router.use(this.#namedRoutes);
3731
- this.#router.use("/api/", (_req, _res, next) => {
3732
- next("router");
3733
- });
3734
- if (this.#indexPath) {
3735
- this.#router.use(this.#indexRouter);
3736
- }
3737
- }
3738
- use(path, handler) {
3739
- if (path.match(/^[/\s]*$/)) {
3740
- throw new Error(`Root router path may not be empty`);
3741
- }
3742
- const conflictingPath = this.#findConflictingPath(path);
3743
- if (conflictingPath) {
3744
- throw new Error(
3745
- `Path ${path} conflicts with the existing path ${conflictingPath}`
3746
- );
3747
- }
3748
- this.#existingPaths.push(path);
3749
- this.#namedRoutes.use(path, handler);
3750
- if (this.#indexPath === path) {
3751
- this.#indexRouter.use(handler);
818
+ this.#initializer.add(unwrapFeature(feature));
3752
819
  }
3753
820
  }
3754
- handler() {
3755
- return this.#router;
821
+ async start() {
822
+ await this.#initializer.start();
3756
823
  }
3757
- #findConflictingPath(newPath) {
3758
- const normalizedNewPath = normalizePath(newPath);
3759
- for (const path of this.#existingPaths) {
3760
- const normalizedPath = normalizePath(path);
3761
- if (normalizedPath.startsWith(normalizedNewPath)) {
3762
- return path;
3763
- }
3764
- if (normalizedNewPath.startsWith(normalizedPath)) {
3765
- return path;
3766
- }
3767
- }
3768
- return void 0;
824
+ async stop() {
825
+ await this.#initializer.stop();
3769
826
  }
3770
- };
3771
-
3772
- function createHealthRouter(options) {
3773
- const router = Router__default.default();
3774
- router.get(
3775
- "/.backstage/health/v1/readiness",
3776
- async (_request, response) => {
3777
- const { status, payload } = await options.health.getReadiness();
3778
- response.status(status).json(payload);
3779
- }
3780
- );
3781
- router.get(
3782
- "/.backstage/health/v1/liveness",
3783
- async (_request, response) => {
3784
- const { status, payload } = await options.health.getLiveness();
3785
- response.status(status).json(payload);
3786
- }
3787
- );
3788
- return router;
3789
827
  }
3790
-
3791
- function defaultConfigure({ applyDefaults }) {
3792
- applyDefaults();
828
+ function isPromise(value) {
829
+ return typeof value === "object" && value !== null && "then" in value && typeof value.then === "function";
3793
830
  }
3794
- const rootHttpRouterServiceFactoryWithOptions = (options) => backendPluginApi.createServiceFactory({
3795
- service: backendPluginApi.coreServices.rootHttpRouter,
3796
- deps: {
3797
- config: backendPluginApi.coreServices.rootConfig,
3798
- rootLogger: backendPluginApi.coreServices.rootLogger,
3799
- lifecycle: backendPluginApi.coreServices.rootLifecycle,
3800
- health: backendPluginApi.coreServices.rootHealth
3801
- },
3802
- async factory({ config, rootLogger, lifecycle, health }) {
3803
- const { indexPath, configure = defaultConfigure } = options ?? {};
3804
- const logger = rootLogger.child({ service: "rootHttpRouter" });
3805
- const app = express__default.default();
3806
- const router = DefaultRootHttpRouter$1.create({ indexPath });
3807
- const middleware = MiddlewareFactory$1.create({ config, logger });
3808
- const routes = router.handler();
3809
- const healthRouter = createHealthRouter({ health });
3810
- const server = await createHttpServer$1(
3811
- app,
3812
- readHttpServerOptions$1(config.getOptionalConfig("backend")),
3813
- { logger }
3814
- );
3815
- configure({
3816
- app,
3817
- server,
3818
- routes,
3819
- middleware,
3820
- config,
3821
- logger,
3822
- lifecycle,
3823
- healthRouter,
3824
- applyDefaults() {
3825
- app.use(middleware.helmet());
3826
- app.use(middleware.cors());
3827
- app.use(middleware.compression());
3828
- app.use(middleware.logging());
3829
- app.use(healthRouter);
3830
- app.use(routes);
3831
- app.use(middleware.notFound());
3832
- app.use(middleware.error());
3833
- }
3834
- });
3835
- lifecycle.addShutdownHook(() => server.stop());
3836
- await server.start();
3837
- return router;
3838
- }
3839
- })();
3840
- const rootHttpRouterServiceFactory$1 = Object.assign(
3841
- rootHttpRouterServiceFactoryWithOptions,
3842
- rootHttpRouterServiceFactoryWithOptions()
3843
- );
3844
-
3845
- const rootHttpRouterServiceFactory = rootHttpRouterServiceFactory$1;
3846
-
3847
- class DefaultRootHttpRouter {
3848
- constructor(impl) {
3849
- this.impl = impl;
3850
- }
3851
- static create(options) {
3852
- return new DefaultRootHttpRouter(DefaultRootHttpRouter$1.create(options));
3853
- }
3854
- use(path, handler) {
3855
- this.impl.use(path, handler);
831
+ function unwrapFeature(feature) {
832
+ if ("$$type" in feature) {
833
+ return feature;
3856
834
  }
3857
- handler() {
3858
- return this.impl.handler();
835
+ if ("default" in feature) {
836
+ return feature.default;
3859
837
  }
838
+ return feature;
3860
839
  }
3861
840
 
3862
- const rootLoggerServiceFactory = rootLoggerServiceFactory$1;
3863
-
3864
- const schedulerServiceFactory = backendPluginApi.createServiceFactory({
3865
- service: backendPluginApi.coreServices.scheduler,
3866
- deps: {
3867
- plugin: backendPluginApi.coreServices.pluginMetadata,
3868
- databaseManager: backendPluginApi.coreServices.database,
3869
- logger: backendPluginApi.coreServices.logger
3870
- },
3871
- async factory({ plugin, databaseManager, logger }) {
3872
- return backendTasks.TaskScheduler.forPlugin({
3873
- pluginId: plugin.getId(),
3874
- databaseManager,
3875
- logger
3876
- });
841
+ function createSpecializedBackend(options) {
842
+ const exists = /* @__PURE__ */ new Set();
843
+ const duplicates = /* @__PURE__ */ new Set();
844
+ for (const { service } of options.defaultServiceFactories) {
845
+ if (exists.has(service.id)) {
846
+ duplicates.add(service.id);
847
+ } else {
848
+ exists.add(service.id);
849
+ }
3877
850
  }
3878
- });
3879
-
3880
- class DefaultUserInfoService {
3881
- discovery;
3882
- constructor(options) {
3883
- this.discovery = options.discovery;
851
+ if (duplicates.size > 0) {
852
+ const ids = Array.from(duplicates).join(", ");
853
+ throw new Error(`Duplicate service implementations provided for ${ids}`);
3884
854
  }
3885
- async getUserInfo(credentials) {
3886
- const internalCredentials = toInternalBackstageCredentials(credentials);
3887
- if (internalCredentials.principal.type !== "user") {
3888
- throw new Error("Only user credentials are supported");
3889
- }
3890
- if (!internalCredentials.token) {
3891
- throw new Error("User credentials is unexpectedly missing token");
3892
- }
3893
- const { sub: userEntityRef, ent: tokenEnt } = jose.decodeJwt(
3894
- internalCredentials.token
855
+ if (exists.has(backendPluginApi.coreServices.pluginMetadata.id)) {
856
+ throw new Error(
857
+ `The ${backendPluginApi.coreServices.pluginMetadata.id} service cannot be overridden`
3895
858
  );
3896
- if (typeof userEntityRef !== "string") {
3897
- throw new Error("User entity ref must be a string");
3898
- }
3899
- let ownershipEntityRefs = tokenEnt;
3900
- if (!ownershipEntityRefs) {
3901
- const userInfoResp = await fetch__default.default(
3902
- `${await this.discovery.getBaseUrl("auth")}/v1/userinfo`,
3903
- {
3904
- headers: {
3905
- Authorization: `Bearer ${internalCredentials.token}`
3906
- }
3907
- }
3908
- );
3909
- if (!userInfoResp.ok) {
3910
- throw await errors.ResponseError.fromResponse(userInfoResp);
3911
- }
3912
- const {
3913
- claims: { ent }
3914
- } = await userInfoResp.json();
3915
- ownershipEntityRefs = ent;
3916
- }
3917
- if (!ownershipEntityRefs) {
3918
- throw new Error("Ownership entity refs can not be determined");
3919
- } else if (!Array.isArray(ownershipEntityRefs) || ownershipEntityRefs.some((ref) => typeof ref !== "string")) {
3920
- throw new Error("Ownership entity refs must be an array of strings");
3921
- }
3922
- return { userEntityRef, ownershipEntityRefs };
3923
859
  }
860
+ return new BackstageBackend(options.defaultServiceFactories);
3924
861
  }
3925
862
 
3926
- const userInfoServiceFactory$1 = backendPluginApi.createServiceFactory({
3927
- service: backendPluginApi.coreServices.userInfo,
863
+ const identityServiceFactory = backendPluginApi.createServiceFactory(
864
+ (options) => ({
865
+ service: backendPluginApi.coreServices.identity,
866
+ deps: {
867
+ discovery: backendPluginApi.coreServices.discovery
868
+ },
869
+ async factory({ discovery }) {
870
+ return pluginAuthNode.DefaultIdentityClient.create({ discovery, ...options });
871
+ }
872
+ })
873
+ );
874
+
875
+ const tokenManagerServiceFactory = backendPluginApi.createServiceFactory({
876
+ service: backendPluginApi.coreServices.tokenManager,
3928
877
  deps: {
3929
- discovery: backendPluginApi.coreServices.discovery
878
+ config: backendPluginApi.coreServices.rootConfig,
879
+ logger: backendPluginApi.coreServices.rootLogger
880
+ },
881
+ createRootContext({ config, logger }) {
882
+ return backendCommon.ServerTokenManager.fromConfig(config, {
883
+ logger,
884
+ allowDisabledTokenManager: true
885
+ });
3930
886
  },
3931
- async factory({ discovery }) {
3932
- return new DefaultUserInfoService({ discovery });
887
+ async factory(_deps, tokenManager) {
888
+ return tokenManager;
3933
889
  }
3934
890
  });
3935
891
 
3936
- const userInfoServiceFactory = userInfoServiceFactory$1;
3937
-
3938
- exports.DefaultRootHttpRouter = DefaultRootHttpRouter;
3939
- exports.HostDiscovery = HostDiscovery;
3940
- exports.MiddlewareFactory = MiddlewareFactory;
3941
- exports.WinstonLogger = WinstonLogger;
3942
- exports.authServiceFactory = authServiceFactory;
3943
- exports.cacheServiceFactory = cacheServiceFactory;
3944
- exports.createConfigSecretEnumerator = createConfigSecretEnumerator;
3945
- exports.createHttpServer = createHttpServer;
3946
- exports.createLifecycleMiddleware = createLifecycleMiddleware;
3947
892
  exports.createSpecializedBackend = createSpecializedBackend;
3948
- exports.databaseServiceFactory = databaseServiceFactory;
3949
- exports.discoveryServiceFactory = discoveryServiceFactory;
3950
- exports.httpAuthServiceFactory = httpAuthServiceFactory;
3951
- exports.httpRouterServiceFactory = httpRouterServiceFactory;
3952
893
  exports.identityServiceFactory = identityServiceFactory;
3953
- exports.lifecycleServiceFactory = lifecycleServiceFactory;
3954
- exports.loadBackendConfig = loadBackendConfig;
3955
- exports.loggerServiceFactory = loggerServiceFactory;
3956
- exports.permissionsServiceFactory = permissionsServiceFactory;
3957
- exports.readCorsOptions = readCorsOptions;
3958
- exports.readHelmetOptions = readHelmetOptions;
3959
- exports.readHttpServerOptions = readHttpServerOptions;
3960
- exports.rootConfigServiceFactory = rootConfigServiceFactory;
3961
- exports.rootHttpRouterServiceFactory = rootHttpRouterServiceFactory;
3962
- exports.rootLifecycleServiceFactory = rootLifecycleServiceFactory;
3963
- exports.rootLoggerServiceFactory = rootLoggerServiceFactory;
3964
- exports.schedulerServiceFactory = schedulerServiceFactory;
3965
894
  exports.tokenManagerServiceFactory = tokenManagerServiceFactory;
3966
- exports.urlReaderServiceFactory = urlReaderServiceFactory;
3967
- exports.userInfoServiceFactory = userInfoServiceFactory;
3968
895
  //# sourceMappingURL=index.cjs.js.map