diff --git a/.github/workflows/cli-win.yml b/.github/workflows/cli-win.yml new file mode 100644 index 0000000000..52ec1b8b27 --- /dev/null +++ b/.github/workflows/cli-win.yml @@ -0,0 +1,65 @@ +name: CLI Test Windows + +# Building on windows is really slow, so this workflow is separate from cli.yml and only builds on changes +# to the cli itself. They're more likely to introduce issues on windows, compared to changes to core and yarn.lock. +on: + pull_request: + paths: + - '.github/workflows/cli-win.yml' + - 'packages/cli/**' + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [windows-latest] + node-version: [12.x] + + env: + CI: true + NODE_OPTIONS: --max-old-space-size=4096 + + name: Node ${{ matrix.node-version }} on ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + - name: find location of global yarn cache + id: yarn-cache + run: echo "::set-output name=dir::$(yarn cache dir)" + - name: cache global yarn cache + uses: actions/cache@v2 + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: use node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: yarn install + run: yarn install --frozen-lockfile + - run: yarn tsc + - run: yarn build + - name: verify app and plugin creation + working-directory: ${{ runner.temp }} + run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js + env: + BACKSTAGE_E2E_CLI_TEST: true + - name: lint newly created app and plugin + run: yarn lint:all + working-directory: ${{ runner.temp }}/test-app + env: + BACKSTAGE_E2E_CLI_TEST: true + - name: test newly created app and plugin + run: yarn test:all + working-directory: ${{ runner.temp }}/test-app + env: + BACKSTAGE_E2E_CLI_TEST: true + - name: e2e test newly created app + run: yarn test:e2e:ci + working-directory: ${{ runner.temp }}/test-app/packages/app + env: + PORT: 3001 + BACKSTAGE_E2E_CLI_TEST: true diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 24ff2a734c..62f91e3259 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/cli.yml' - 'packages/cli/**' - 'packages/core/**' + - 'packages/core-api/**' - 'yarn.lock' jobs: @@ -14,7 +15,7 @@ jobs: strategy: matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest] node-version: [12.x] env: @@ -28,7 +29,7 @@ jobs: id: yarn-cache run: echo "::set-output name=dir::$(yarn cache dir)" - name: cache global yarn cache - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ${{ steps.yarn-cache.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} @@ -42,15 +43,8 @@ jobs: run: yarn install --frozen-lockfile - run: yarn tsc - run: yarn build - - name: verify app and plugin creation on Windows + - name: verify app and plugin creation working-directory: ${{ runner.temp }} - if: runner.os == 'Windows' - run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js - env: - BACKSTAGE_E2E_CLI_TEST: true - - name: verify app and plugin creation on Linux - working-directory: ${{ runner.temp }} - if: runner.os == 'Linux' run: | sudo sysctl fs.inotify.max_user_watches=524288 node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 55a594d55e..c3fa6e4e23 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -25,19 +25,19 @@ jobs: id: yarn-cache run: echo "::set-output name=dir::$(yarn cache dir)" - name: cache global yarn cache - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ${{ steps.yarn-cache.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - name: cache node_modules - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: node_modules key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} - name: cache build cache - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: .backstage-build-cache key: build-cache-${{ github.sha }} diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 14619305cd..52f2760469 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -24,19 +24,19 @@ jobs: id: yarn-cache run: echo "::set-output name=dir::$(yarn cache dir)" - name: cache global yarn cache - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ${{ steps.yarn-cache.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - name: cache node_modules - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: node_modules key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} - name: cache build cache - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: .backstage-build-cache key: build-cache-${{ github.sha }} diff --git a/.github/workflows/storybook-deploy.yml b/.github/workflows/storybook-deploy.yml index d6eef29744..33ffba5ccd 100644 --- a/.github/workflows/storybook-deploy.yml +++ b/.github/workflows/storybook-deploy.yml @@ -27,14 +27,14 @@ jobs: id: yarn-cache run: echo "::set-output name=dir::$(yarn cache dir)" - name: cache global yarn cache - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: ${{ steps.yarn-cache.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - name: cache node_modules - uses: actions/cache@v1 + uses: actions/cache@v2 with: path: node_modules key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }} diff --git a/app-config.yaml b/app-config.yaml new file mode 100644 index 0000000000..6ff336c727 --- /dev/null +++ b/app-config.yaml @@ -0,0 +1,9 @@ +app: + title: Backstage Example App + baseUrl: http://localhost:3000 + +backend: + baseUrl: http://localhost:7000 + +organization: + name: Spotify diff --git a/docs/architecture-decisions/adr005-catalog-core-entities.md b/docs/architecture-decisions/adr005-catalog-core-entities.md new file mode 100644 index 0000000000..ca2a66f6b9 --- /dev/null +++ b/docs/architecture-decisions/adr005-catalog-core-entities.md @@ -0,0 +1,83 @@ +# ADR005: Catalog Core Entities + +| Created | Status | +| ---------- | ------ | +| 2020-05-29 | Open | + +## Context + +We want to standardize on a few core entities that we are tracking in the Backstage catalog. This allows us to build specific plugins around them. + +## Decision + +Backstage should eventually support the following core entities: + +- **Components** are individual pieces of software +- **APIs** are the boundaries between different components +- **Resources** are physical or virtual infrastructure needed to operate a component + +![Catalog Core Entities](catalog-core-entities.png) + +For now, we'll start by only implementing support for the Component entity in the Backstage catalog. This can later be extended to APIs, Resources and other potentially useful entities. + +### Component + +A component is a piece of software, for example a mobile application feature, web site, backend service or data pipeline (list not exhaustive). A component can be tracked in source control, or use some existing open source or commercial software. It can implement APIs for other components to consume. In turn it might depend on APIs implemented by other components, or resources that are attached to it at runtime. + +Component entities are typically defined in YAML descriptor files next to the code of the component, and could look like this (actual schema will evolve): + +```yaml +apiVersion: backstage.io/v1beta1 +kind: Component +metadata: + name: my-component-name +spec: + type: service +``` + +### API + +APIs form an abstraction that allows large software ecosystems to scale. Thus, APIs are a first class citizen in the Backstage model and the primary way to discover existing functionality in the ecosystem. + +APIs are implemented by components and make their boundaries explicit. They might be defined using an RPC IDL (e.g. in Protobuf, GraphQL or similar), a data schema (e.g. in Avro, TFRecord or similar), or as code interfaces (e.g. framework APIs in Swift, Kotlin, Java, C++, Typescript etc). In any case, APIs exposed by components need to be in a known machine-readable format so we can build further tooling and analysis on top. + +APIs are typically indexed from existing definitions in source control and thus wouldn't need their own descriptor files, but would be stored in the catalog somewhat like this (actual schema will evolve): + +```yaml +apiVersion: backstage.io/v1beta1 +kind: API +metadata: + name: my-component-api +spec: + type: grpc + definition: > + service HelloService { + rpc SayHello (HelloRequest) returns (HelloResponse); + } + message HelloRequest { + string greeting = 1; + } + message HelloResponse { + string reply = 1; + } +``` + +### Resource + +Resources are the infrastructure your software needs to operate at runtime like Bigtable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and APIs will allow us to visualize and create tooling around them in Backstage. + +Resources are typically indexed from declarative definitions (e.g. Terraform, GCP Config Connector, AWS Cloud Formation) and/or inventories from cloud providers (e.g. GCP Asset Inventory) and thus wouldn't need their own descriptor files, but would be stored in the catalog somewhat like this (actual schema will evolve): + +```yaml +apiVersion: backstage.io/v1beta1 +kind: Resource +metadata: + name: my-component-db +spec: + type: gcp-spanner + url: spanner.googleapis.com/projects/prj/instances/my-component-db/databases/my-db +``` + +## Consequences + +We will continue fleshing out support for the Component entity in the Backstage catalog. diff --git a/docs/architecture-decisions/catalog-core-entities.png b/docs/architecture-decisions/catalog-core-entities.png new file mode 100644 index 0000000000..b0c7cb4575 Binary files /dev/null and b/docs/architecture-decisions/catalog-core-entities.png differ diff --git a/docs/getting-started/Plugin development.md b/docs/getting-started/Plugin development.md index 774a02434e..0f578151a2 100644 --- a/docs/getting-started/Plugin development.md +++ b/docs/getting-started/Plugin development.md @@ -39,14 +39,19 @@ Each plugin is responsible for registering its components to corresponding route The app will call the `createPlugin` method on each plugin, passing in a `router` object with a set of methods on it. -```typescript -import { createPlugin } from '@backstage/core'; +```jsx +import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; -export default createPlugin({ - id: 'my-plugin', +export const rootRouteRef = createRouteRef({ + path: '/new-plugin', + title: 'New plugin', +}); + +export const plugin = createPlugin({ + id: 'new-plugin', register({ router }) { - router.registerRoute('/my-plugin', ExampleComponent); + router.addRoute(rootRouteRef, ExampleComponent); }, }); ``` @@ -54,17 +59,18 @@ export default createPlugin({ #### `router` API ```typescript -type RouterHooks = { - registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, - ): void; +addRoute( + target: RouteRef, + Component: ComponentType, + options?: RouteOptions, +): void; - registerRedirect( - path: RoutePath, - target: RoutePath, - options?: RouteOptions, - ): void; -}; +/** + * @deprecated See the `addRoute` method + */ +registerRoute( + path: RoutePath, + Component: ComponentType, + options?: RouteOptions, +): void; ``` diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index 489116fcc8..26b432570f 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -1,16 +1,28 @@ # Development Environment +This section describes how to get set up for doing development on the Backstage repository. + +## Cloning the Repository + +After you have cloned the Backstage repository, you should run the following commands +once to set things up for development: + +```bash +$ yarn install # fetch dependency packages - may take a while + +$ yarn tsc # does a first run of type generation and checks +``` + ## Serving the Example App -Open a terminal window and start the web app using the following commands from the project root: +Open a terminal window and start the web app by using the following command from the project root. +Make sure you have run the above mentioned commands first. ```bash -$ yarn install # may take a while - $ yarn start ``` -The final `yarn start` command should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. +This should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. By default, backstage will start on port 3000, however you can override this by setting an environment variable `PORT` on your local machine. e.g. `export PORT=8080` then running `yarn start`. Or `PORT=8080 yarn start`. diff --git a/docs/getting-started/structure-of-a-plugin.md b/docs/getting-started/structure-of-a-plugin.md index 361546a1b6..462b2eb5f4 100644 --- a/docs/getting-started/structure-of-a-plugin.md +++ b/docs/getting-started/structure-of-a-plugin.md @@ -43,13 +43,18 @@ In the root folder you have some configuration for typescript and jest, the test In the `src` folder we get to the interesting bits. Check out the `plugin.ts`: ```jsx -import { createPlugin } from '@backstage/core'; +import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; -export default createPlugin({ +export const rootRouteRef = createRouteRef({ + path: '/new-plugin', + title: 'New plugin', +}); + +export const plugin = createPlugin({ id: 'new-plugin', register({ router }) { - router.registerRoute('/new-plugin', ExampleComponent); + router.addRoute(rootRouteRef, ExampleComponent); }, }); ``` diff --git a/docs/reference/createPlugin-router.md b/docs/reference/createPlugin-router.md index fa1a9cfa5b..88958a5093 100644 --- a/docs/reference/createPlugin-router.md +++ b/docs/reference/createPlugin-router.md @@ -1,21 +1,36 @@ # createPlugin - router -The router that is passed to the `register` function includes makes it possible for plugins to hook into routing of the Backstage app and provide the end users with new views to navigate to. +The router that is passed to the `register` function makes it possible for plugins to hook into routing of the Backstage app and provide the end users with new views to navigate to. +This is done by utilising the following methods on the `router`: ```typescript -type RouterHooks = { - registerRoute( - path: RoutePath, - Component: ComponentType, - options?: RouteOptions, - ): void; +addRoute( + target: RouteRef, + Component: ComponentType, + options?: RouteOptions, +): void; - registerRedirect( - path: RoutePath, - target: RoutePath, - options?: RouteOptions, - ): void; -}; +/** + * @deprecated See the `addRoute` method + */ +registerRoute( + path: RoutePath, + Component: ComponentType, + options?: RouteOptions, +): void; +``` + +## RouteRef + +`addRoute` method is using mutable RouteRefs, which can be created as following: + +```ts +import { createRouteRef } from '@backstage/core'; + +const myPluginRouteRef = createRouteRef({ + path: '/my-plugin', + title: 'My Plugin', +}); ``` [Back to References](README.md) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 31b68a9da5..39ff0432ea 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -25,18 +25,6 @@ import { hot } from 'react-hot-loader/root'; const app = createApp({ apis, plugins: Object.values(plugins), - configLoader: async () => ({ - app: { - title: 'Backstage Example App', - baseUrl: 'http://localhost:3000', - }, - backend: { - baseUrl: 'http://localhost:7000', - }, - organization: { - name: 'Spotify', - }, - }), }); const AppProvider = app.getProvider(); diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 6a23de1edd..ec0088e219 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -30,6 +30,8 @@ import { OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + storageApiRef, + WebStorage, } from '@backstage/core'; import { @@ -45,8 +47,12 @@ import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; const builder = ApiRegistry.builder(); const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); +const errorApi = builder.add( + errorApiRef, + new ErrorAlerter(alertApi, new ErrorApiForwarder()), +); -builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); +builder.add(storageApiRef, WebStorage.create({ errorApi })); builder.add(circleCIApiRef, new CircleCIApi()); builder.add(featureFlagsApiRef, new FeatureFlags()); diff --git a/packages/cli/package.json b/packages/cli/package.json index 20d9893a37..97e3aaa972 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -32,7 +32,7 @@ "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", - "@rollup/plugin-commonjs": "^11.0.2", + "@rollup/plugin-commonjs": "^12.0.0", "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^7.1.1", "@spotify/eslint-config": "^7.0.1", @@ -79,6 +79,7 @@ "url-loader": "^4.1.0", "webpack": "^4.41.6", "webpack-dev-server": "^3.10.3", + "yaml": "^1.10.0", "yml-loader": "^2.1.0", "yn": "^4.0.0" }, diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts index c654baa439..fad3e6db13 100644 --- a/packages/cli/src/commands/app/build.ts +++ b/packages/cli/src/commands/app/build.ts @@ -16,10 +16,12 @@ import { buildBundle } from '../../lib/bundler'; import { Command } from 'commander'; +import { loadConfig } from '../../lib/app-config'; export default async (cmd: Command) => { await buildBundle({ entry: 'src/index', statsJsonEnabled: cmd.stats, + appConfig: await loadConfig(), }); }; diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts index 416f8f0151..19dfd9a7be 100644 --- a/packages/cli/src/commands/app/serve.ts +++ b/packages/cli/src/commands/app/serve.ts @@ -16,11 +16,13 @@ import { Command } from 'commander'; import { serveBundle } from '../../lib/bundler'; +import { loadConfig } from '../../lib/app-config'; export default async (cmd: Command) => { const waitForExit = await serveBundle({ entry: 'src/index', checksEnabled: cmd.check, + appConfig: await loadConfig(), }); await waitForExit(); diff --git a/packages/cli/src/commands/clean/clean.ts b/packages/cli/src/commands/clean/clean.ts index 2fa49fcc4a..91531a53ed 100644 --- a/packages/cli/src/commands/clean/clean.ts +++ b/packages/cli/src/commands/clean/clean.ts @@ -24,6 +24,7 @@ export default async function clean() { const packagePath = getPackagePath(cacheOptions.cacheDir); await fs.remove(cacheOptions.output); await fs.remove(packagePath); + await fs.remove(paths.resolveTarget('coverage')); } function getPackagePath(cacheDir: string) { diff --git a/packages/cli/src/commands/plugin/serve.ts b/packages/cli/src/commands/plugin/serve.ts index 8abbd92440..174d1fe4af 100644 --- a/packages/cli/src/commands/plugin/serve.ts +++ b/packages/cli/src/commands/plugin/serve.ts @@ -16,11 +16,13 @@ import { Command } from 'commander'; import { serveBundle } from '../../lib/bundler'; +import { loadConfig } from '../../lib/app-config'; export default async (cmd: Command) => { const waitForExit = await serveBundle({ entry: 'dev/index', checksEnabled: cmd.check, + appConfig: await loadConfig(), }); await waitForExit(); diff --git a/plugins/auth-backend/src/providers/index.test.ts b/packages/cli/src/lib/app-config/index.ts similarity index 79% rename from plugins/auth-backend/src/providers/index.test.ts rename to packages/cli/src/lib/app-config/index.ts index 7f39d9de57..e2c80f89e1 100644 --- a/plugins/auth-backend/src/providers/index.test.ts +++ b/packages/cli/src/lib/app-config/index.ts @@ -14,10 +14,5 @@ * limitations under the License. */ -import { defaultRouter } from '.'; - -describe('test', () => { - it('unbreaks the test runner', () => { - expect(defaultRouter).toBeDefined(); - }); -}); +export type { AppConfig } from './types'; +export { loadConfig } from './loaders'; diff --git a/packages/cli/src/lib/app-config/loaders.ts b/packages/cli/src/lib/app-config/loaders.ts new file mode 100644 index 0000000000..6e6a56e6c5 --- /dev/null +++ b/packages/cli/src/lib/app-config/loaders.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AppConfig } from './types'; +import fs from 'fs-extra'; +import yaml from 'yaml'; +import { paths } from '../paths'; + +type LoadConfigOptions = { + // Config path, defaults to app-config.yaml in project root + configPath?: string; +}; + +export async function loadConfig( + options: LoadConfigOptions = {}, +): Promise { + // TODO: We'll want this to be a bit more elaborate, probably adding configs for + // specific env, and maybe local config for plugins. + const { configPath = paths.resolveTargetRoot('app-config.yaml') } = options; + + try { + const configYaml = await fs.readFile(configPath, 'utf8'); + const config = yaml.parse(configYaml); + return [config]; + } catch (error) { + throw new Error(`Failed to read static configuration file, ${error}`); + } +} diff --git a/packages/cli/src/lib/app-config/types.ts b/packages/cli/src/lib/app-config/types.ts new file mode 100644 index 0000000000..d15cbe3787 --- /dev/null +++ b/packages/cli/src/lib/app-config/types.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type AppConfig = any; diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index a35c22e3bd..deb02a38c6 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -51,6 +51,12 @@ export function createConfig( ); } + plugins.push( + new webpack.EnvironmentPlugin({ + APP_CONFIG: options.appConfig, + }), + ); + return { mode: isDev ? 'development' : 'production', profile: false, diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 4182226d69..1a9f49701c 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -15,16 +15,20 @@ */ import { BundlingPathsOptions } from './paths'; +import { AppConfig } from '../app-config'; export type BundlingOptions = { checksEnabled: boolean; isDev: boolean; + appConfig: AppConfig[]; }; export type ServeOptions = BundlingPathsOptions & { checksEnabled: boolean; + appConfig: AppConfig[]; }; export type BuildOptions = BundlingPathsOptions & { statsJsonEnabled: boolean; + appConfig: AppConfig[]; }; diff --git a/packages/cli/templates/default-app/app-config.yaml b/packages/cli/templates/default-app/app-config.yaml new file mode 100644 index 0000000000..b4c53905de --- /dev/null +++ b/packages/cli/templates/default-app/app-config.yaml @@ -0,0 +1,5 @@ +app: + title: Scaffolded Backstage App + +organization: + name: Acme Corporation diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs index 61d82fca53..01df20a06a 100644 --- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs @@ -1,25 +1,30 @@ /* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +* Copyright 2020 Spotify AB +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ -import { createPlugin } from '@backstage/core'; +import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; -export const plugin = createPlugin({ - id: '{{ id }}', - register({ router }) { - router.registerRoute('/{{ id }}', ExampleComponent); - }, +export const rootRouteRef = createRouteRef({ +path: '/{{ id }}', +title: '{{ id }}', +}); + +export const plugin = createPlugin({ +id: '{{ id }}', +register({ router }) { +router.addRoute(rootRouteRef, ExampleComponent); +}, }); diff --git a/packages/core-api/src/apis/definitions/StorageApi.ts b/packages/core-api/src/apis/definitions/StorageApi.ts new file mode 100644 index 0000000000..920ee56811 --- /dev/null +++ b/packages/core-api/src/apis/definitions/StorageApi.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '../ApiRef'; +import { Observable } from '../../types'; +import { ErrorApi } from './ErrorApi'; + +export type StorageValueChange = { + key: string; + newValue?: T; +}; + +export type CreateStorageApiOptions = { + errorApi: ErrorApi; + namespace?: string; +}; + +export interface StorageApi { + /** + * Create a bucket to store data in. + * @param {String} name Namespace for the storage to be stored under, + * will inherit previous namespaces too + */ + forBucket(name: string): StorageApi; + + /** + * Get the current value for persistent data, use observe$ to be notified of updates. + * + * @param {String} key Unique key associated with the data. + * @return {Object} data The data that should is stored. + */ + get(key: string): T | undefined; + + /** + * Remove persistent data. + * + * @param {String} key Unique key associated with the data. + */ + remove(key: string): Promise; + + /** + * Save persistant data, and emit messages to anyone that is using observe$ for this key + * + * @param {String} key Unique key associated with the data. + */ + set(key: string, data: any): Promise; + + /** + * Observe changes on a particular key in the bucket + * @param {String} key Unique key associated with the data + */ + observe$(key: string): Observable>; +} + +export const storageApiRef = createApiRef({ + id: 'core.storage', + description: 'Provides the ability to store data which is unique to the user', +}); diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index 2e9db325dc..41fbc839f9 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -28,3 +28,4 @@ export * from './ConfigApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; export * from './OAuthRequestApi'; +export * from './StorageApi'; diff --git a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts index 7a5cf3b185..f6a8bce17b 100644 --- a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts +++ b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts @@ -15,6 +15,7 @@ */ import { ConfigApi, Config } from '../../definitions/ConfigApi'; +import { AppConfig } from '../../../app'; const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; @@ -62,6 +63,18 @@ function validateString( export class ConfigReader implements ConfigApi { static nullReader = new ConfigReader({}); + static fromConfigs(configs: AppConfig[]): ConfigReader { + if (configs.length === 0) { + return new ConfigReader({}); + } + + // Merge together all configs info a single config with recursive fallback + // readers, giving the first config object in the array the highest priority. + return configs.reduceRight((previousReader, nextConfig) => { + return new ConfigReader(nextConfig, previousReader); + }, undefined); + } + constructor( private readonly data: JsonObject, private readonly fallback?: ConfigApi, diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts new file mode 100644 index 0000000000..a96813347e --- /dev/null +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { WebStorage } from './WebStorage'; +import { CreateStorageApiOptions, StorageApi } from '../../definitions'; +describe('WebStorage Storage API', () => { + const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; + const createWebStorage = ( + args?: Partial, + ): StorageApi => { + return WebStorage.create({ + errorApi: mockErrorApi, + ...args, + }); + }; + it('should return undefined for values which are unset', async () => { + const storage = createWebStorage(); + + expect(storage.get('myfakekey')).toBeUndefined(); + }); + + it('should allow the setting and getting of the simple data structures', async () => { + const storage = createWebStorage(); + + await storage.set('myfakekey', 'helloimastring'); + await storage.set('mysecondfakekey', 1234); + await storage.set('mythirdfakekey', true); + expect(storage.get('myfakekey')).toBe('helloimastring'); + expect(storage.get('mysecondfakekey')).toBe(1234); + expect(storage.get('mythirdfakekey')).toBe(true); + }); + + it('should allow setting of complex datastructures', async () => { + const storage = createWebStorage(); + + const mockData = { + something: 'here', + is: [{ super: { complex: [{ but: 'something', why: true }] } }], + }; + + await storage.set('myfakekey', mockData); + + expect(storage.get('myfakekey')).toEqual(mockData); + }); + + it('should subscribe to key changes when setting a new value', async () => { + const storage = createWebStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.set('correctKey', mockData); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: mockData, + }); + }); + + it('should subscribe to key changes when deleting a value', async () => { + const storage = createWebStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + storage.set('correctKey', mockData); + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.remove('correctKey'); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: undefined, + }); + }); + + it('should be able to create different buckets for different uses', async () => { + const rootStorage = createWebStorage(); + + const firstStorage = rootStorage.forBucket('userSettings'); + const secondStorage = rootStorage.forBucket('profileSettings'); + const keyName = 'blobby'; + + await firstStorage.set(keyName, 'boop'); + await secondStorage.set(keyName, 'deerp'); + + expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName)); + expect(firstStorage.get(keyName)).toBe('boop'); + expect(secondStorage.get(keyName)).toBe('deerp'); + }); + + it('should not clash with other namesapces when creating buckets', async () => { + const rootStorage = createWebStorage(); + + // when getting key test2 it will translate to /profile/something/deep/test2 + const firstStorage = rootStorage + .forBucket('profile') + .forBucket('something') + .forBucket('deep'); + // when getting key deep/test2 it will translate to /profile/something/deep/test2 + const secondStorage = rootStorage.forBucket('profile/something'); + + await firstStorage.set('test2', { error: true }); + + expect(secondStorage.get('deep/test2')).toBe(undefined); + }); + + it('should call the error api when the json can not be parsed in local storage', async () => { + const rootStorage = createWebStorage({ + namespace: '/Test/Mock/Thing', + }); + + localStorage.setItem('/Test/Mock/Thing/key', '{smd: asdouindA}'); + + const value = rootStorage.get('key'); + + expect(value).toBe(undefined); + expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error)); + expect(mockErrorApi.post).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Error when parsing JSON config from storage for: key', + }), + ); + }); +}); diff --git a/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts new file mode 100644 index 0000000000..8af8f760cf --- /dev/null +++ b/packages/core-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + StorageApi, + StorageValueChange, + ErrorApi, + CreateStorageApiOptions, +} from '../../definitions'; +import { Observable } from '../../../types'; +import ObservableImpl from 'zen-observable'; + +export class WebStorage implements StorageApi { + constructor( + private readonly namespace: string, + private readonly errorApi: ErrorApi, + ) {} + + static create(options: CreateStorageApiOptions): WebStorage { + return new WebStorage(options.namespace ?? '', options.errorApi); + } + + get(key: string): T | undefined { + try { + const storage = JSON.parse(localStorage.getItem(this.getKeyName(key))!); + return storage ?? undefined; + } catch (e) { + this.errorApi.post( + new Error(`Error when parsing JSON config from storage for: ${key}`), + ); + } + + return undefined; + } + + forBucket(name: string): WebStorage { + return new WebStorage(`${this.namespace}/${name}`, this.errorApi); + } + + async set(key: string, data: T): Promise { + localStorage.setItem(this.getKeyName(key), JSON.stringify(data, null, 2)); + this.notifyChanges({ key, newValue: data }); + } + + async remove(key: string): Promise { + localStorage.removeItem(this.getKeyName(key)); + this.notifyChanges({ key, newValue: undefined }); + } + + observe$(key: string): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + private getKeyName(key: string) { + return `${this.namespace}/${encodeURIComponent(key)}`; + } + + private notifyChanges(message: StorageValueChange) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = new ObservableImpl( + subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }, + ); +} diff --git a/packages/core-api/src/apis/implementations/StorageApi/index.ts b/packages/core-api/src/apis/implementations/StorageApi/index.ts new file mode 100644 index 0000000000..33b0094551 --- /dev/null +++ b/packages/core-api/src/apis/implementations/StorageApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { WebStorage } from './WebStorage'; diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts index b5cc250ae4..e6d23fee21 100644 --- a/packages/core-api/src/apis/implementations/index.ts +++ b/packages/core-api/src/apis/implementations/index.ts @@ -25,3 +25,4 @@ export * from './AppThemeApi'; export * from './ConfigApi'; export * from './ErrorApi'; export * from './OAuthRequestApi'; +export * from './StorageApi'; diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 4207a267af..575dcfc59d 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -110,7 +110,7 @@ export class PrivateAppImpl implements BackstageApp { ); break; } - case 'redirect-route': { + case 'legacy-redirect-route': { const { path, target, options = {} } = output; const { exact = true } = options; routes.push( @@ -118,6 +118,19 @@ export class PrivateAppImpl implements BackstageApp { ); break; } + case 'redirect-route': { + const { from, to, options = {} } = output; + const { exact = true } = options; + routes.push( + , + ); + break; + } case 'feature-flag': { registeredFeatureFlags.push({ pluginId: plugin.getId(), @@ -150,7 +163,7 @@ export class PrivateAppImpl implements BackstageApp { const Provider: FC<{}> = ({ children }) => { // Keeping this synchronous when a config loader isn't set simplifies tests a lot const hasConfig = Boolean(this.configLoader); - const config = useAsync(this.configLoader || (() => Promise.resolve({}))); + const config = useAsync(this.configLoader || (() => Promise.resolve([]))); let childNode = children; @@ -164,7 +177,7 @@ export class PrivateAppImpl implements BackstageApp { const appApis = ApiRegistry.from([ [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], - [configApiRef, new ConfigReader(config.value ?? {})], + [configApiRef, ConfigReader.fromConfigs(config.value ?? [])], ]); const apis = new ApiAggregator(this.apis, appApis); diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index defc155a82..953a10cbb2 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -38,8 +38,11 @@ export type AppConfig = any; /** * A function that loads in the App config that will be accessible via the ConfigApi. + * + * If multiple config objects are returned in the array, values in the earlier configs + * will override later ones. */ -export type AppConfigLoader = () => Promise; +export type AppConfigLoader = () => Promise; export type AppOptions = { /** diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index 3d8f7abeb9..bfbb2dbc0b 100644 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -76,7 +76,7 @@ describe('DefaultAuthConnector', () => { const helper = new DefaultAuthConnector(defaultOptions); await expect(helper.refreshSession()).rejects.toThrow( - 'Auth refresh request failed with status NOPE', + 'Auth refresh request failed, NOPE', ); }); diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts index 21d2530c5a..f0f57c1c12 100644 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -115,7 +115,7 @@ export class DefaultAuthConnector if (!res.ok) { const error: any = new Error( - `Auth refresh request failed with status ${res.statusText}`, + `Auth refresh request failed, ${res.statusText}`, ); error.status = res.status; throw error; @@ -140,10 +140,14 @@ export class DefaultAuthConnector 'x-requested-with': 'XMLHttpRequest', }, credentials: 'include', + }).catch(error => { + throw new Error(`Logout request failed, ${error}`); }); if (!res.ok) { - throw new Error(`Logout request failed with status ${res.status}`); + const error: any = new Error(`Logout request failed, ${res.statusText}`); + error.status = res.status; + throw error; } } diff --git a/packages/core-api/src/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx index ed3179296f..bf98919c2b 100644 --- a/packages/core-api/src/plugin/Plugin.tsx +++ b/packages/core-api/src/plugin/Plugin.tsx @@ -42,17 +42,14 @@ export type RouterHooks = { options?: RouteOptions, ): void; + /** + * @deprecated See the `addRoute` method + */ registerRoute( path: RoutePath, Component: ComponentType, options?: RouteOptions, ): void; - - registerRedirect( - path: RoutePath, - target: RoutePath, - options?: RouteOptions, - ): void; }; export type FeatureFlagsHooks = { @@ -91,9 +88,6 @@ export class PluginImpl { registerRoute(path, component, options) { outputs.push({ type: 'legacy-route', path, component, options }); }, - registerRedirect(path, target, options) { - outputs.push({ type: 'redirect-route', path, target, options }); - }, }, featureFlags: { register(name) { diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index 8dcbcda0a3..fabe97434e 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -41,6 +41,13 @@ export type RouteOutput = { export type RedirectRouteOutput = { type: 'redirect-route'; + from: RouteRef; + to: RouteRef; + options?: RouteOptions; +}; + +export type LegacyRedirectRouteOutput = { + type: 'legacy-redirect-route'; path: RoutePath; target: RoutePath; options?: RouteOptions; @@ -56,6 +63,7 @@ export type FeatureFlagOutput = { export type PluginOutput = | LegacyRouteOutput | RouteOutput + | LegacyRedirectRouteOutput | RedirectRouteOutput | FeatureFlagOutput; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 491665bbcf..515dc31de6 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -18,13 +18,13 @@ import { IconComponent } from '../icons'; export type RouteRef = { path: string; - icon: IconComponent; + icon?: IconComponent; title: string; }; export type RouteRefConfig = { path: string; - icon: IconComponent; + icon?: IconComponent; title: string; }; diff --git a/packages/core/package.json b/packages/core/package.json index 452633a4e5..05986a6b1e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", "@types/react": "^16.9", + "@types/react-router-dom": "^5.1.5", "@types/react-sparklines": "^1.7.0", "classnames": "^2.2.6", "clsx": "^1.1.0", diff --git a/packages/core/src/api-wrappers/createApp.test.tsx b/packages/core/src/api-wrappers/createApp.test.tsx new file mode 100644 index 0000000000..30553d84a7 --- /dev/null +++ b/packages/core/src/api-wrappers/createApp.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defaultConfigLoader } from './createApp'; + +describe('defaultConfigLoader', () => { + afterEach(() => { + delete process.env.APP_CONFIG; + }); + + it('loads static config', async () => { + Object.defineProperty(process.env, 'APP_CONFIG', { + configurable: true, + value: [{ my: 'config' }, { my: 'override-config' }] as any, + }); + const configs = await defaultConfigLoader(); + expect(configs).toEqual([{ my: 'config' }, { my: 'override-config' }]); + }); + + it('loads runtime config', async () => { + Object.defineProperty(process.env, 'APP_CONFIG', { + configurable: true, + value: [{ my: 'override-config' }, { my: 'config' }] as any, + }); + const configs = await (defaultConfigLoader as any)( + '{"my":"runtime-config"}', + ); + expect(configs).toEqual([ + { my: 'runtime-config' }, + { my: 'override-config' }, + { my: 'config' }, + ]); + }); + + it('fails to load invalid missing config', async () => { + await expect(defaultConfigLoader()).rejects.toThrow( + 'No static configuration provided', + ); + }); + + it('fails to load invalid static config', async () => { + Object.defineProperty(process.env, 'APP_CONFIG', { + configurable: true, + value: { my: 'invalid-config' } as any, + }); + await expect(defaultConfigLoader()).rejects.toThrow( + 'Static configuration has invalid format', + ); + }); + + it('fails to load bad runtime config', async () => { + Object.defineProperty(process.env, 'APP_CONFIG', { + configurable: true, + value: [{ my: 'config' }] as any, + }); + + await expect((defaultConfigLoader as any)('}')).rejects.toThrow( + 'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0', + ); + }); +}); diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index 7c605c4e18..c53a4765b7 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -20,6 +20,8 @@ import privateExports, { ApiRegistry, defaultSystemIcons, BootErrorPageProps, + AppConfigLoader, + AppConfig, } from '@backstage/core-api'; import { BrowserRouter as Router } from 'react-router-dom'; @@ -29,6 +31,43 @@ import { lightTheme, darkTheme } from '@backstage/theme'; const { PrivateAppImpl } = privateExports; +/** + * The default config loader, which expects that config is available at compile-time + * in `process.env.APP_CONFIG`. APP_CONFIG should be an array of config objects as + * returned by the config loader. + * + * It will also load runtime config from the __APP_INJECTED_RUNTIME_CONFIG__ string, + * which can be rewritten at runtime to contain an additional JSON config object. + * If runtime config is present, it will be placed first in the config array, overriding + * other config values. + */ +export const defaultConfigLoader: AppConfigLoader = async ( + // This string may be replaced at runtime to provide additional config. + // It should be replaced by a JSON-serialized config object. + // It's a param so we can test it, but at runtime this will always fall back to default. + runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__', +) => { + const appConfig = process.env.APP_CONFIG; + if (!appConfig) { + throw new Error('No static configuration provided'); + } + if (!Array.isArray(appConfig)) { + throw new Error('Static configuration has invalid format'); + } + const configs = (appConfig.slice() as unknown) as AppConfig[]; + + // Avoiding this string also being replaced at runtime + if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) { + try { + configs.unshift(JSON.parse(runtimeConfigJson)); + } catch (error) { + throw new Error(`Failed to load runtime configuration, ${error}`); + } + } + + return configs; +}; + // createApp is defined in core, and not core-api, since we need access // to the components inside core to provide defaults. // The actual implementation of the app class still lives in core-api, @@ -77,7 +116,7 @@ export function createApp(options?: AppOptions) { theme: darkTheme, }, ]; - const configLoader = options?.configLoader ?? (async () => ({})); + const configLoader = options?.configLoader ?? defaultConfigLoader; const app = new PrivateAppImpl({ apis, diff --git a/packages/core/src/components/Button/Button.stories.tsx b/packages/core/src/components/Button/Button.stories.tsx new file mode 100644 index 0000000000..f7bbe45abd --- /dev/null +++ b/packages/core/src/components/Button/Button.stories.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { FunctionComponentFactory } from 'react'; +import { Button } from './Button'; +import { + MemoryRouter, + Route, + useLocation, + Link as RouterLink, +} from 'react-router-dom'; +import { createRouteRef } from '@backstage/core-api'; + +const Location = () => { + const location = useLocation(); + return
Current location: {location.pathname}
; +}; + +export default { + title: 'Button', + component: Button, + decorators: [ + (storyFn: FunctionComponentFactory<{}>) => ( + +
+
+ +
+ {storyFn()} +
+
+ ), + ], +}; + +export const Default = () => { + const routeRef = createRouteRef({ + path: '/hello', + title: 'Hi there!', + }); + + return ( + <> +  will utilise the + react-router MemoryRouter's navigation + +

{routeRef.title}

+
+ + ); +}; + +export const PassProps = () => { + const routeRef = createRouteRef({ + path: '/hello', + title: 'Hi there!', + }); + + return ( + <> + +  has props for both material-ui's component as well as for + react-router-dom's + +

{routeRef.title}

+
+ + ); +}; +PassProps.story = { + name: `Accepts material-ui Button's and react-router-dom Link's props`, +}; diff --git a/packages/core/src/components/Button/Button.test.jsx b/packages/core/src/components/Button/Button.test.jsx new file mode 100644 index 0000000000..2563d367c2 --- /dev/null +++ b/packages/core/src/components/Button/Button.test.jsx @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { Button } from './Button'; +import { MemoryRouter, Route } from 'react-router'; +import { act } from 'react-dom/test-utils'; + +describe(' + {testString}{' '} + , + ), + ); + expect(() => getByText(testString)).toThrow(); + await act(async () => fireEvent.click(getByText(buttonLabel))); + expect(getByText(testString)).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/Button/Button.tsx b/packages/core/src/components/Button/Button.tsx new file mode 100644 index 0000000000..ca45b3da7f --- /dev/null +++ b/packages/core/src/components/Button/Button.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps } from 'react'; +import { Button as MaterialButton } from '@material-ui/core'; +import { Link as RouterLink } from 'react-router-dom'; + +type Props = ComponentProps & + ComponentProps; + +/** + * Thin wrapper on top of material-ui's Button component + * Makes the Button to utilise react-router + */ +export const Button = React.forwardRef((props, ref) => ( + +)); diff --git a/packages/core/src/components/Button/index.ts b/packages/core/src/components/Button/index.ts new file mode 100644 index 0000000000..7b584ed799 --- /dev/null +++ b/packages/core/src/components/Button/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Button } from './Button'; diff --git a/packages/core/src/components/Link/Link.stories.tsx b/packages/core/src/components/Link/Link.stories.tsx new file mode 100644 index 0000000000..2bd499fa33 --- /dev/null +++ b/packages/core/src/components/Link/Link.stories.tsx @@ -0,0 +1,92 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { FunctionComponentFactory } from 'react'; +import { Link } from './Link'; +import { + MemoryRouter, + Route, + useLocation, + NavLink as RouterNavLink, +} from 'react-router-dom'; +import { createRouteRef } from '@backstage/core-api'; + +const Location = () => { + const location = useLocation(); + return
Current location: {location.pathname}
; +}; + +export default { + title: 'Link', + component: Link, + decorators: [ + (storyFn: FunctionComponentFactory<{}>) => ( + +
+
+ +
+ {storyFn()} +
+
+ ), + ], +}; + +export const Default = () => { + const routeRef = createRouteRef({ + path: '/hello', + title: 'Hi there!', + }); + + return ( + <> + This link will utilise the + react-router MemoryRouter's navigation + +

{routeRef.title}

+
+ + ); +}; + +export const PassProps = () => { + const routeRef = createRouteRef({ + path: '/hello', + title: 'Hi there!', + }); + + return ( + <> + + This link + +  has props for both material-ui's component as well as for + react-router-dom's + +

{routeRef.title}

+
+ + ); +}; +PassProps.story = { + name: `Accepts material-ui Link's and react-router-dom Link's props`, +}; diff --git a/packages/core/src/components/Link/Link.test.jsx b/packages/core/src/components/Link/Link.test.jsx new file mode 100644 index 0000000000..fcbc92a67a --- /dev/null +++ b/packages/core/src/components/Link/Link.test.jsx @@ -0,0 +1,40 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { Link } from './Link'; +import { MemoryRouter, Route } from 'react-router'; +import { act } from 'react-dom/test-utils'; + +describe('', () => { + it('navigates using react-router', async () => { + const testString = 'This is test string'; + const linkText = 'Navigate!'; + const { getByText } = render( + wrapInTestApp( + + {linkText} + {testString} + , + ), + ); + expect(() => getByText(testString)).toThrow(); + await act(async () => fireEvent.click(getByText(linkText))); + expect(getByText(testString)).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/Link/Link.tsx b/packages/core/src/components/Link/Link.tsx new file mode 100644 index 0000000000..859650e6b0 --- /dev/null +++ b/packages/core/src/components/Link/Link.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps } from 'react'; +import { Link as MaterialLink } from '@material-ui/core'; +import { Link as RouterLink } from 'react-router-dom'; + +type Props = ComponentProps & + ComponentProps; + +/** + * Thin wrapper on top of material-ui's Link component + * Makes the Link to utilise react-router + */ +export const Link = React.forwardRef((props, ref) => ( + +)); diff --git a/packages/core/src/components/Link/index.ts b/packages/core/src/components/Link/index.ts new file mode 100644 index 0000000000..18e990181c --- /dev/null +++ b/packages/core/src/components/Link/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Link } from './Link'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2ceea470f4..f3c5c5c778 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,4 +38,6 @@ export { default as StructuredMetadataTable } from './components/StructuredMetad export { default as TrendLine } from './components/TrendLine'; export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular'; export * from './components/Status'; +export * from './components/Button'; +export * from './components/Link'; export { default as WarningPanel } from './components/WarningPanel'; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index b174a0447f..aa952f1ca5 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -33,6 +33,7 @@ import { OAuthRequestDialog, } from '@backstage/core'; import * as defaultApiFactories from './apiFactories'; +import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied'; // TODO(rugvip): export proper plugin type from core that isn't the plugin class type BackstagePlugin = ReturnType; @@ -148,7 +149,7 @@ class DevAppBuilder { key={target.path} to={target.path} text={target.title} - icon={target.icon} + icon={target.icon ?? SentimentDissatisfiedIcon} />, ); break; diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index 46bfef8b3c..ae17a556c9 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -19,6 +19,14 @@ read -r AUTH_GOOGLE_CLIENT_SECRET export AUTH_GOOGLE_CLIENT_SECRET run `yarn start` in packages/backend folder +### SAML + +To try out SAML, you can use the mock identity provider: + +```bash +./scripts/start-saml-idp.sh +``` + ## Links - (The Backstage homepage)[https://backstage.io] diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 505240853d..3e902304db 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -20,6 +20,7 @@ "@types/passport": "^1.0.3", "@types/passport-github2": "^1.2.4", "@types/passport-google-oauth20": "^2.0.3", + "body-parser": "^1.19.0", "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", @@ -31,11 +32,14 @@ "passport": "^0.4.1", "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", + "passport-saml": "^1.3.3", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.6", + "@types/body-parser": "^1.19.0", + "@types/passport-saml": "^1.1.2", "jest-fetch-mock": "^3.0.3", "tsc-watch": "^4.2.3" }, diff --git a/plugins/auth-backend/scripts/.gitignore b/plugins/auth-backend/scripts/.gitignore new file mode 100644 index 0000000000..cfaad76118 --- /dev/null +++ b/plugins/auth-backend/scripts/.gitignore @@ -0,0 +1 @@ +*.pem diff --git a/plugins/auth-backend/scripts/start-saml-idp.sh b/plugins/auth-backend/scripts/start-saml-idp.sh new file mode 100755 index 0000000000..33217f7978 --- /dev/null +++ b/plugins/auth-backend/scripts/start-saml-idp.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +cd "$DIR" + +if [[ ! -f idp-public-cert.pem ]]; then + echo "Generating new SAML Certificates" + openssl req \ + -x509 \ + -newkey rsa:1024 \ + -days 3650 \ + -nodes \ + -subj '/CN=localhost' \ + -keyout "idp-private-key.pem" \ + -out "idp-public-cert.pem" +fi + +echo "Downloading and starting SAML-IdP" +export NPM_CONFIG_REGISTRY=https://registry.npmjs.org +exec npx saml-idp --acsUrl "http://localhost:7000/auth/saml/handler/frame" --audience "http://localhost:7000" --port 7001 diff --git a/plugins/auth-backend/src/providers/OAuthProvider.test.ts b/plugins/auth-backend/src/providers/OAuthProvider.test.ts new file mode 100644 index 0000000000..362653b5f7 --- /dev/null +++ b/plugins/auth-backend/src/providers/OAuthProvider.test.ts @@ -0,0 +1,364 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { + ensuresXRequestedWith, + postMessageResponse, + removeRefreshTokenCookie, + setRefreshTokenCookie, + THOUSAND_DAYS_MS, + setNonceCookie, + TEN_MINUTES_MS, + verifyNonce, + OAuthProvider, +} from './OAuthProvider'; +import { AuthResponse, OAuthProviderHandlers } from './types'; + +describe('OAuthProvider Utils', () => { + describe('verifyNonce', () => { + it('should throw error if cookie nonce missing', () => { + const mockRequest = ({ + cookies: {}, + query: { + state: 'NONCE', + }, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).toThrowError('Missing nonce'); + }); + it('should throw error if state nonce missing', () => { + const mockRequest = ({ + cookies: { + 'providera-nonce': 'NONCE', + }, + query: {}, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).toThrowError('Missing nonce'); + }); + it('should throw error if nonce mismatch', () => { + const mockRequest = ({ + cookies: { + 'providera-nonce': 'NONCEA', + }, + query: { + state: 'NONCEB', + }, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).toThrowError('Invalid nonce'); + }); + it('should not throw any error if nonce matches', () => { + const mockRequest = ({ + cookies: { + 'providera-nonce': 'NONCE', + }, + query: { + state: 'NONCE', + }, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).not.toThrow(); + }); + }); + + describe('setNonceCookie', () => { + it('should set nonce cookie', () => { + const mockResponse = ({ + cookie: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + setNonceCookie(mockResponse, 'providera'); + expect(mockResponse.cookie).toBeCalledTimes(1); + expect(mockResponse.cookie).toBeCalledWith( + 'providera-nonce', + expect.any(String), + expect.objectContaining({ maxAge: TEN_MINUTES_MS }), + ); + }); + }); + + describe('setRefreshTokenCookie', () => { + it('should set refresh token cookie', () => { + const mockResponse = ({ + cookie: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + setRefreshTokenCookie(mockResponse, 'providera', 'REFRESH_TOKEN'); + expect(mockResponse.cookie).toBeCalledTimes(1); + expect(mockResponse.cookie).toBeCalledWith( + 'providera-refresh-token', + 'REFRESH_TOKEN', + expect.objectContaining({ maxAge: THOUSAND_DAYS_MS }), + ); + }); + }); + + describe('removeRefreshTokenCookie', () => { + it('should remove refresh token cookie', () => { + const mockResponse = ({ + cookie: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + removeRefreshTokenCookie(mockResponse, 'providera'); + expect(mockResponse.cookie).toBeCalledTimes(1); + expect(mockResponse.cookie).toBeCalledWith( + 'providera-refresh-token', + '', + expect.objectContaining({ maxAge: 0 }), + ); + }); + }); + + describe('postMessageResponse', () => { + it('should post a message back with payload success', () => { + const mockResponse = ({ + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + const data: AuthResponse = { + type: 'auth-result', + payload: { + accessToken: 'ACCESS_TOKEN', + idToken: 'ID_TOKEN', + expiresInSeconds: 10, + scope: 'email', + }, + }; + const jsonData = JSON.stringify(data); + const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); + + postMessageResponse(mockResponse, data); + expect(mockResponse.setHeader).toBeCalledTimes(2); + expect(mockResponse.end).toBeCalledTimes(1); + expect(mockResponse.end).toBeCalledWith( + expect.stringContaining(base64Data), + ); + }); + + it('should post a message back with payload error', () => { + const mockResponse = ({ + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + const data: AuthResponse = { + type: 'auth-result', + error: new Error('Unknown error occured'), + }; + const jsonData = JSON.stringify(data); + const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); + + postMessageResponse(mockResponse, data); + expect(mockResponse.setHeader).toBeCalledTimes(2); + expect(mockResponse.end).toBeCalledTimes(1); + expect(mockResponse.end).toBeCalledWith( + expect.stringContaining(base64Data), + ); + }); + }); + + describe('ensuresXRequestedWith', () => { + it('should return false if no header present', () => { + const mockRequest = ({ + header: () => jest.fn(), + } as unknown) as express.Request; + expect(ensuresXRequestedWith(mockRequest)).toBe(false); + }); + + it('should return false if header present with incorrect value', () => { + const mockRequest = ({ + header: () => 'INVALID', + } as unknown) as express.Request; + expect(ensuresXRequestedWith(mockRequest)).toBe(false); + }); + + it('should return true if header present with correct value', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + } as unknown) as express.Request; + expect(ensuresXRequestedWith(mockRequest)).toBe(true); + }); + }); +}); + +describe('OAuthProvider', () => { + class MyAuthProvider implements OAuthProviderHandlers { + async start() { + return { + url: '/url', + status: 301, + }; + } + async handler() { + return { + user: {}, + info: { + refreshToken: 'token', + }, + }; + } + async refresh() { + return { + accessToken: 'token', + }; + } + } + const providerInstance = new MyAuthProvider(); + const providerId = 'test-provider'; + + it('sets the correct headers in start', async () => { + const oauthProvider = new OAuthProvider(providerInstance, providerId); + const mockRequest = ({ + query: { + scope: 'user', + }, + } as unknown) as express.Request; + + const mockResponse = ({ + cookie: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + statusCode: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + await oauthProvider.start(mockRequest, mockResponse); + expect(mockResponse.setHeader).toHaveBeenCalledTimes(2); + expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url'); + expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0'); + expect(mockResponse.statusCode).toEqual(301); + expect(mockResponse.end).toHaveBeenCalledTimes(1); + }); + + it('sets the refresh cookie if refresh is enabled', async () => { + const oauthProvider = new OAuthProvider(providerInstance, providerId); + + const mockRequest = ({ + cookies: { + 'test-provider-nonce': 'nonce', + }, + query: { + state: 'nonce', + }, + } as unknown) as express.Request; + + const mockResponse = ({ + cookie: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + await oauthProvider.frameHandler(mockRequest, mockResponse); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + expect.stringContaining('test-provider-refresh-token'), + expect.stringContaining('token'), + expect.objectContaining({ path: '/auth/test-provider' }), + ); + }); + + it('does no set the refresh cookie if refresh is disabled', async () => { + const oauthProvider = new OAuthProvider(providerInstance, providerId, true); + + const mockRequest = ({ + cookies: { + 'test-provider-nonce': 'nonce', + }, + query: { + state: 'nonce', + }, + } as unknown) as express.Request; + + const mockResponse = ({ + cookie: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + await oauthProvider.frameHandler(mockRequest, mockResponse); + expect(mockResponse.cookie).toHaveBeenCalledTimes(0); + }); + + it('removes refresh cookie when logging out', async () => { + const oauthProvider = new OAuthProvider(providerInstance, providerId); + + const mockRequest = ({ + header: () => 'XMLHttpRequest', + } as unknown) as express.Request; + + const mockResponse = ({ + cookie: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + await oauthProvider.logout(mockRequest, mockResponse); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + expect.stringContaining('test-provider-refresh-token'), + '', + expect.objectContaining({ path: '/auth/test-provider' }), + ); + }); + + it('gets new access-token when refreshing', async () => { + const oauthProvider = new OAuthProvider(providerInstance, providerId); + + const mockRequest = ({ + header: () => 'XMLHttpRequest', + cookies: { + 'test-provider-refresh-token': 'token', + }, + query: {}, + } as unknown) as express.Request; + + const mockResponse = ({ + send: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockResponse.send).toHaveBeenCalledTimes(1); + expect(mockResponse.send).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'token', + }), + ); + }); + + it('handles refresh without capabilities', async () => { + const oauthProvider = new OAuthProvider(providerInstance, providerId, true); + + const mockRequest = ({ + header: () => 'XMLHttpRequest', + cookies: { + 'test-provider-refresh-token': 'token', + }, + query: {}, + } as unknown) as express.Request; + + const mockResponse = ({ + send: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockResponse.send).toHaveBeenCalledTimes(1); + expect(mockResponse.send).toHaveBeenCalledWith( + 'Refresh token not supported for provider: test-provider', + ); + }); +}); diff --git a/plugins/auth-backend/src/providers/PassportStrategyHelper.ts b/plugins/auth-backend/src/providers/PassportStrategyHelper.ts index 3ec8539330..5c02930c2c 100644 --- a/plugins/auth-backend/src/providers/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/providers/PassportStrategyHelper.ts @@ -55,7 +55,7 @@ export const executeFrameHandlerStrategy = async ( reject(new Error('Unexpected redirect')); }; - strategy.authenticate(req); + strategy.authenticate(req, {}); }); }; diff --git a/plugins/auth-backend/src/providers/config.ts b/plugins/auth-backend/src/providers/config.ts index 8d04dc5a1f..5ec73b7827 100644 --- a/plugins/auth-backend/src/providers/config.ts +++ b/plugins/auth-backend/src/providers/config.ts @@ -32,4 +32,12 @@ export const providers = [ }, disableRefresh: true, }, + { + provider: 'saml', + options: { + path: '/auth/saml/handler/frame', + entryPoint: 'http://localhost:7001/', + issuer: 'passport-saml', + }, + }, ]; diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 0c9f99e878..8576e36096 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -14,37 +14,37 @@ * limitations under the License. */ -import { - AuthProviderFactories, - AuthProviderRouteHandlers, - AuthProviderConfig, -} from './types'; -import { GoogleAuthProvider } from './google'; -import { GithubAuthProvider } from './github'; -import { OAuthProvider } from './OAuthProvider'; +import Router from 'express-promise-router'; +import { createGithubProvider } from './github'; +import { createGoogleProvider } from './google'; +import { createSamlProvider } from './saml'; +import { AuthProviderFactory, AuthProviderConfig } from './types'; -export class ProviderFactories { - private static readonly providerFactories: AuthProviderFactories = { - google: GoogleAuthProvider, - github: GithubAuthProvider, - }; +const factories: { [providerId: string]: AuthProviderFactory } = { + google: createGoogleProvider, + github: createGithubProvider, + saml: createSamlProvider, +}; - public static getProviderFactory( - config: AuthProviderConfig, - ): AuthProviderRouteHandlers { - const providerId = config.provider; - const ProviderImpl = ProviderFactories.providerFactories[providerId]; - if (!ProviderImpl) { - throw Error( - `Provider Implementation missing for : ${providerId} auth provider`, - ); - } - const providerInstance = new ProviderImpl(config); - const oauthProvider = new OAuthProvider( - providerInstance, - providerId, - config.disableRefresh, - ); - return oauthProvider; +export function createAuthProvider(providerId: string, config: any) { + const factory = factories[providerId]; + if (!factory) { + throw Error(`No auth provider available for '${providerId}'`); } + return factory(config); } + +export const createAuthProviderRouter = (config: AuthProviderConfig) => { + const providerId = config.provider; + const provider = createAuthProvider(providerId, config); + + const router = Router(); + router.get('/start', provider.start.bind(provider)); + router.get('/handler/frame', provider.frameHandler.bind(provider)); + router.post('/handler/frame', provider.frameHandler.bind(provider)); + router.post('/logout', provider.logout.bind(provider)); + if (provider.refresh) { + router.get('/refresh', provider.refresh.bind(provider)); + } + return router; +}; diff --git a/plugins/auth-backend/src/providers/github/index.ts b/plugins/auth-backend/src/providers/github/index.ts index c3a48d35e0..60ad6998b7 100644 --- a/plugins/auth-backend/src/providers/github/index.ts +++ b/plugins/auth-backend/src/providers/github/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { GithubAuthProvider } from './provider'; +export { createGithubProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index 4445e98451..09622d9303 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -27,6 +27,7 @@ import { AuthInfoBase, AuthInfoPrivate, } from '../types'; +import { OAuthProvider } from '../OAuthProvider'; export class GithubAuthProvider implements OAuthProviderHandlers { private readonly providerConfig: AuthProviderConfig; @@ -57,3 +58,9 @@ export class GithubAuthProvider implements OAuthProviderHandlers { return await executeFrameHandlerStrategy(req, this._strategy); } } + +export function createGithubProvider(config: AuthProviderConfig) { + const provider = new GithubAuthProvider(config); + const oauthProvider = new OAuthProvider(provider, config.provider, true); + return oauthProvider; +} diff --git a/plugins/auth-backend/src/providers/google/index.ts b/plugins/auth-backend/src/providers/google/index.ts index 0ec98bef89..b2cd85e6de 100644 --- a/plugins/auth-backend/src/providers/google/index.ts +++ b/plugins/auth-backend/src/providers/google/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { GoogleAuthProvider } from './provider'; +export { createGoogleProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 90d33652a5..e3969b14c8 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -28,6 +28,7 @@ import { RedirectInfo, AuthProviderConfig, } from '../types'; +import { OAuthProvider } from '../OAuthProvider'; export class GoogleAuthProvider implements OAuthProviderHandlers { private readonly providerConfig: AuthProviderConfig; @@ -87,3 +88,9 @@ export class GoogleAuthProvider implements OAuthProviderHandlers { }; } } + +export function createGoogleProvider(config: AuthProviderConfig) { + const provider = new GoogleAuthProvider(config); + const oauthProvider = new OAuthProvider(provider, config.provider); + return oauthProvider; +} diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index cfbabbbad1..d210bfd1bb 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -14,24 +14,4 @@ * limitations under the License. */ -import Router from 'express-promise-router'; -import { AuthProviderRouteHandlers, AuthProviderConfig } from './types'; -import { ProviderFactories } from './factories'; - -export const defaultRouter = (provider: AuthProviderRouteHandlers) => { - const router = Router(); - router.get('/start', provider.start.bind(provider)); - router.get('/handler/frame', provider.frameHandler.bind(provider)); - router.get('/logout', provider.logout.bind(provider)); - if (provider.refresh) { - router.get('/refresh', provider.refresh.bind(provider)); - } - return router; -}; - -export const makeProvider = (config: AuthProviderConfig) => { - const providerId = config.provider; - const oauthProvider = ProviderFactories.getProviderFactory(config); - const providerRouter = defaultRouter(oauthProvider); - return { providerId, providerRouter }; -}; +export { createAuthProviderRouter } from './factories'; diff --git a/plugins/auth-backend/src/providers/saml/index.ts b/plugins/auth-backend/src/providers/saml/index.ts new file mode 100644 index 0000000000..582deb1608 --- /dev/null +++ b/plugins/auth-backend/src/providers/saml/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { createSamlProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts new file mode 100644 index 0000000000..50bea3495e --- /dev/null +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { Strategy as SamlStrategy } from 'passport-saml'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, +} from '../PassportStrategyHelper'; +import { AuthProviderConfig, AuthProviderRouteHandlers } from '../types'; +import { postMessageResponse } from '../OAuthProvider'; + +export class SamlAuthProvider implements AuthProviderRouteHandlers { + private readonly strategy: SamlStrategy; + + constructor(providerConfig: AuthProviderConfig) { + this.strategy = new SamlStrategy( + { ...providerConfig.options }, + (profile: any, done: any) => { + // TODO: There's plenty more validation and profile handling to do here, + // this provider is currently only intended to validate the provider pattern + // for non-oauth auth flows. + // TODO: This flow doesn't issue an identity token that can be used to validate + // the identity of the user in other backends, which we need in some form. + done(undefined, { + email: profile.email, + firstName: profile.firstName, + lastName: profile.lastName, + displayName: profile.displayName, + }); + }, + ); + } + + async start(req: express.Request, res: express.Response): Promise { + const { url } = await executeRedirectStrategy(req, this.strategy, {}); + res.redirect(url); + } + + async frameHandler( + req: express.Request, + res: express.Response, + ): Promise { + try { + const { user } = await executeFrameHandlerStrategy(req, this.strategy); + + return postMessageResponse(res, { + type: 'auth-result', + payload: user, + }); + } catch (error) { + return postMessageResponse(res, { + type: 'auth-result', + error: { + name: error.name, + message: error.message, + }, + }); + } + } + + async logout(_req: express.Request, res: express.Response): Promise { + res.send('noop'); + } +} + +export function createSamlProvider(config: AuthProviderConfig) { + return new SamlAuthProvider(config); +} diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index dc83c19dd0..661435e74b 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -37,13 +37,9 @@ export interface AuthProviderRouteHandlers { logout(req: express.Request, res: express.Response): Promise; } -export type AuthProviderFactories = { - [key: string]: AuthProviderFactory; -}; - -export type AuthProviderFactory = { - new (providerConfig: any): OAuthProviderHandlers; -}; +export type AuthProviderFactory = ( + config: AuthProviderConfig, +) => AuthProviderRouteHandlers; export type AuthInfoBase = { accessToken: string; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 49487d551a..9de5fb80a5 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -17,9 +17,10 @@ import express from 'express'; import Router from 'express-promise-router'; import cookieParser from 'cookie-parser'; +import bodyParser from 'body-parser'; import { Logger } from 'winston'; import { providers } from './../providers/config'; -import { makeProvider } from '../providers'; +import { createAuthProviderRouter } from '../providers'; export interface RouterOptions { logger: Logger; @@ -32,12 +33,15 @@ export async function createRouter( const logger = options.logger.child({ plugin: 'auth' }); router.use(cookieParser()); + router.use(bodyParser.urlencoded({ extended: false })); + router.use(bodyParser.json()); // configure all the providers for (const providerConfig of providers) { - const { providerId, providerRouter } = makeProvider(providerConfig); - logger.info(`Configuring provider, ${providerId}`); - router.use(`/${providerId}`, providerRouter); + const { provider } = providerConfig; + const providerRouter = createAuthProviderRouter(providerConfig); + logger.info(`Configuring provider, ${provider}`); + router.use(`/${provider}`, providerRouter); } return router; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 5ec37295ed..253959c61b 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -6,19 +6,21 @@ "license": "Apache-2.0", "private": true, "scripts": { - "start": "tsc-watch --onFirstSuccess \"cross-env NODE_ENV=development nodemon dist/run.js\"", + "start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"cross-env NODE_ENV=development nodemon -r esm dist/run.js\\\"", "build": "tsc", "lint": "backstage-cli lint", "test": "backstage-cli test", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" + "clean": "backstage-cli clean", + "mock-data": "./scripts/mock-data" }, "dependencies": { "@backstage/backend-common": "^0.1.1-alpha.6", "@backstage/catalog-model": "^0.1.1-alpha.6", "compression": "^1.7.4", "cors": "^2.8.5", + "esm": "^3.2.25", "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", diff --git a/plugins/catalog-backend/scripts/mock-data b/plugins/catalog-backend/scripts/mock-data new file mode 100755 index 0000000000..7edc8c422b --- /dev/null +++ b/plugins/catalog-backend/scripts/mock-data @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +curl \ +--location \ +--request POST 'localhost:3003/locations' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "type": "github", + "target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml" +}' diff --git a/plugins/catalog-backend/src/database/DatabaseManager.ts b/plugins/catalog-backend/src/database/DatabaseManager.ts index bc7c7b8dae..f0fbe92600 100644 --- a/plugins/catalog-backend/src/database/DatabaseManager.ts +++ b/plugins/catalog-backend/src/database/DatabaseManager.ts @@ -18,7 +18,7 @@ import Knex from 'knex'; import path from 'path'; import { Logger } from 'winston'; import { CommonDatabase } from './CommonDatabase'; -import type { Database } from './types'; +import { Database } from './types'; export class DatabaseManager { public static async createDatabase( @@ -31,4 +31,18 @@ export class DatabaseManager { }); return new CommonDatabase(knex, logger); } + + public static async createInMemoryDatabase( + logger: Logger, + ): Promise { + const knex = Knex({ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + return DatabaseManager.createDatabase(knex, logger); + } } diff --git a/plugins/catalog-backend/src/ingestion/index.ts b/plugins/catalog-backend/src/ingestion/index.ts index 93af856656..6c530c7234 100644 --- a/plugins/catalog-backend/src/ingestion/index.ts +++ b/plugins/catalog-backend/src/ingestion/index.ts @@ -18,4 +18,4 @@ export * from './descriptor'; export { HigherOrderOperations } from './HigherOrderOperations'; export { IngestionModels } from './IngestionModels'; export * from './source'; -export type { IngestionModel } from './types'; +export type { HigherOrderOperation, IngestionModel } from './types'; diff --git a/plugins/catalog-backend/src/ingestion/types.ts b/plugins/catalog-backend/src/ingestion/types.ts index 018784bd0f..8fb56367a6 100644 --- a/plugins/catalog-backend/src/ingestion/types.ts +++ b/plugins/catalog-backend/src/ingestion/types.ts @@ -28,4 +28,5 @@ export type IngestionModel = { export type HigherOrderOperation = { addLocation(spec: LocationSpec): Promise; + refreshAllLocations(): Promise; }; diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index 0efc0a3be4..29b3f7ce04 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -48,6 +48,7 @@ describe('createRouter', () => { }; higherOrderOperation = { addLocation: jest.fn(), + refreshAllLocations: jest.fn(), }; const router = await createRouter({ entitiesCatalog, @@ -80,6 +81,7 @@ describe('createRouter', () => { const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c='); expect(response.status).toEqual(200); + expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); expect(entitiesCatalog.entities).toHaveBeenCalledWith([ { key: 'a', values: ['1', null, '3'] }, { key: 'b', values: ['4'] }, @@ -101,14 +103,19 @@ describe('createRouter', () => { const response = await request(app).get('/entities/by-uid/zzz'); + expect(entitiesCatalog.entityByUid).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entityByUid).toHaveBeenCalledWith('zzz'); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); }); it('responds with a 404 for missing entities', async () => { entitiesCatalog.entityByUid.mockResolvedValue(undefined); + const response = await request(app).get('/entities/by-uid/zzz'); + expect(entitiesCatalog.entityByUid).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entityByUid).toHaveBeenCalledWith('zzz'); expect(response.status).toEqual(404); expect(response.text).toMatch(/uid/); }); @@ -118,16 +125,18 @@ describe('createRouter', () => { it('can fetch entity by name', async () => { const entity: Entity = { apiVersion: 'a', - kind: 'b', + kind: 'k', metadata: { - name: 'c', - namespace: 'd', + name: 'n', + namespace: 'ns', }, }; entitiesCatalog.entityByName.mockResolvedValue(entity); - const response = await request(app).get('/entities/by-name/b/d/c'); + const response = await request(app).get('/entities/by-name/k/ns/n'); + expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('k', 'ns', 'n'); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); }); @@ -135,8 +144,10 @@ describe('createRouter', () => { it('responds with a 404 for missing entities', async () => { entitiesCatalog.entityByName.mockResolvedValue(undefined); - const response = await request(app).get('/entities/by-name//b/d/c'); + const response = await request(app).get('/entities/by-name/b/d/c'); + expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('b', 'd', 'c'); expect(response.status).toEqual(404); expect(response.text).toMatch(/name/); }); @@ -149,9 +160,9 @@ describe('createRouter', () => { .set('Content-Type', 'application/json') .send(); + expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled(); expect(response.status).toEqual(400); expect(response.text).toMatch(/body/); - expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled(); }); it('passes the body down', async () => { @@ -171,13 +182,13 @@ describe('createRouter', () => { .send(entity) .set('Content-Type', 'application/json'); - expect(response.status).toEqual(200); - expect(response.body).toEqual(entity); expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1); expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith( 1, entity, ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(entity); }); }); @@ -187,8 +198,9 @@ describe('createRouter', () => { const response = await request(app).delete('/entities/by-uid/apa'); - expect(response.status).toEqual(204); expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa'); + expect(response.status).toEqual(204); }); it('responds with a 404 for missing entities', async () => { @@ -198,8 +210,9 @@ describe('createRouter', () => { const response = await request(app).delete('/entities/by-uid/apa'); - expect(response.status).toEqual(404); expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1); + expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa'); + expect(response.status).toEqual(404); }); }); @@ -229,8 +242,8 @@ describe('createRouter', () => { const response = await request(app).post('/locations').send(spec); - expect(response.status).toEqual(400); expect(higherOrderOperation.addLocation).not.toHaveBeenCalled(); + expect(response.status).toEqual(400); }); it('passes the body down', async () => { @@ -246,9 +259,14 @@ describe('createRouter', () => { const response = await request(app).post('/locations').send(spec); - expect(response.status).toEqual(201); expect(higherOrderOperation.addLocation).toHaveBeenCalledTimes(1); expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec); + expect(response.status).toEqual(201); + expect(response.body).toEqual( + expect.objectContaining({ + location: { id: 'a', ...spec }, + }), + ); }); }); }); diff --git a/plugins/catalog-backend/src/service/standaloneApplication.ts b/plugins/catalog-backend/src/service/standaloneApplication.ts index 126806c751..bdf296d06b 100644 --- a/plugins/catalog-backend/src/service/standaloneApplication.ts +++ b/plugins/catalog-backend/src/service/standaloneApplication.ts @@ -25,19 +25,27 @@ import express from 'express'; import helmet from 'helmet'; import { Logger } from 'winston'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; +import { HigherOrderOperation } from '../ingestion'; import { createRouter } from './router'; export interface ApplicationOptions { enableCors: boolean; entitiesCatalog: EntitiesCatalog; locationsCatalog?: LocationsCatalog; + higherOrderOperation?: HigherOrderOperation; logger: Logger; } export async function createStandaloneApplication( options: ApplicationOptions, ): Promise { - const { enableCors, entitiesCatalog, locationsCatalog, logger } = options; + const { + enableCors, + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + logger, + } = options; const app = express(); app.use(helmet()); @@ -49,7 +57,12 @@ export async function createStandaloneApplication( app.use(requestLoggingHandler()); app.use( '/', - await createRouter({ entitiesCatalog, locationsCatalog, logger }), + await createRouter({ + entitiesCatalog, + locationsCatalog, + higherOrderOperation, + logger, + }), ); app.use(notFoundHandler()); app.use(errorHandler()); diff --git a/plugins/catalog-backend/src/service/standaloneServer.ts b/plugins/catalog-backend/src/service/standaloneServer.ts index 2fd4afc7cd..79cf72df4d 100644 --- a/plugins/catalog-backend/src/service/standaloneServer.ts +++ b/plugins/catalog-backend/src/service/standaloneServer.ts @@ -16,8 +16,13 @@ import { Server } from 'http'; import { Logger } from 'winston'; -import { StaticEntitiesCatalog } from '../catalog'; import { createStandaloneApplication } from './standaloneApplication'; +import { DatabaseEntitiesCatalog } from '../catalog/DatabaseEntitiesCatalog'; +import { DatabaseManager } from '../database/DatabaseManager'; +import { DatabaseLocationsCatalog } from '../catalog/DatabaseLocationsCatalog'; +import { LocationReaders } from '../ingestion/source/LocationReaders'; +import { IngestionModels, DescriptorParsers, HigherOrderOperations } from '..'; +import { EntityPolicies } from '@backstage/catalog-model'; export interface ServerOptions { port: number; @@ -30,25 +35,28 @@ export async function startStandaloneServer( ): Promise { const logger = options.logger.child({ service: 'catalog-backend' }); - const entitiesCatalog = new StaticEntitiesCatalog([ - { - apiVersion: 'backstage.io/v1beta1', - kind: 'Component', - metadata: { name: 'c1' }, - spec: { type: 'service' }, - }, - { - apiVersion: 'backstage.io/v1beta1', - kind: 'Component', - metadata: { name: 'c2' }, - spec: { type: 'service' }, - }, - ]); + const db = await DatabaseManager.createInMemoryDatabase(logger); + + const entitiesCatalog = new DatabaseEntitiesCatalog(db); + const locationsCatalog = new DatabaseLocationsCatalog(db); + const ingestionModel = new IngestionModels( + new LocationReaders(), + new DescriptorParsers(), + new EntityPolicies(), + ); + const higherOrderOperation = new HigherOrderOperations( + entitiesCatalog, + locationsCatalog, + ingestionModel, + logger, + ); logger.debug('Creating application...'); const app = await createStandaloneApplication({ enableCors: options.enableCors, entitiesCatalog, + locationsCatalog, + higherOrderOperation, logger, }); diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 429ae5f0a2..4ae4676703 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -16,6 +16,7 @@ import { CatalogApi } from './types'; import { DescriptorEnvelope } from '../types'; +import { Entity, Location } from '@backstage/catalog-model'; export class CatalogClient implements CatalogApi { private apiOrigin: string; @@ -69,4 +70,22 @@ export class CatalogClient implements CatalogApi { entities, }; } + + async getLocationByEntity(entity: Entity): Promise { + const findLocationIdInEntity = (e: Entity): string | undefined => + e.metadata.annotations?.['backstage.io/managed-by-location']; + + const locationId = findLocationIdInEntity(entity); + if (!locationId) return undefined; + + const response = await fetch( + `${this.apiOrigin}${this.basePath}/locations/${locationId}`, + ); + if (response.ok) { + const location = await response.json(); + if (location) return location.data; + } + + return undefined; + } } diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index a01270e7d8..5bb6a6ca83 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -26,6 +26,7 @@ export interface CatalogApi { getEntities(): Promise; getEntityByName(name: string): Promise; addLocation(type: string, target: string): Promise; + getLocationByEntity(entity: Entity): Promise; } export type AddLocationResponse = { location: Location; entities: Entity[] }; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index c8ae0382bd..53cc4f6c44 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -35,6 +35,8 @@ const catalogApi: Partial = { kind: 'Component', }, ] as Entity[]), + getLocationByEntity: () => + Promise.resolve({ id: 'id', type: 'github', target: 'url' }), }; describe('CatalogPage', () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index d3b29bfa86..779bcc7693 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { FC, useCallback, useState, useEffect } from 'react'; import { Content, ContentHeader, @@ -26,7 +26,7 @@ import { pageTheme, useApi, } from '@backstage/core'; -import { useAsync } from 'react-use'; +import { useAsync, useMountedState } from 'react-use'; import CatalogTable from '../CatalogTable/CatalogTable'; import { CatalogFilter, @@ -36,6 +36,8 @@ import { Button, makeStyles, Typography, Link } from '@material-ui/core'; import { filterGroups, defaultFilter } from '../../data/filters'; import { Link as RouterLink } from 'react-router-dom'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; +import GitHub from '@material-ui/icons/GitHub'; +import { Entity, Location } from '@backstage/catalog-model'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -48,20 +50,64 @@ const useStyles = makeStyles(theme => ({ import { catalogApiRef } from '../..'; import { envelopeToComponent } from '../../data/utils'; +import { Component } from '../../data/component'; const CatalogPage: FC<{}> = () => { const catalogApi = useApi(catalogApiRef); const { value, error, loading } = useAsync(() => catalogApi.getEntities()); - const [selectedFilter, setSelectedFilter] = React.useState( + const [locations, setLocations] = useState([]); + const [selectedFilter, setSelectedFilter] = useState( defaultFilter, ); + const isMounted = useMountedState(); - const onFilterSelected = React.useCallback( + const onFilterSelected = useCallback( selected => setSelectedFilter(selected), [], ); const styles = useStyles(); + useEffect(() => { + const getLocationDataForEntities = async (entities: Entity[]) => { + return Promise.all( + entities.map(entity => catalogApi.getLocationByEntity(entity)), + ); + }; + + if (value) { + getLocationDataForEntities(value) + .then( + (location): Location[] => + location.filter(l => !!l) as Array, + ) + .then(location => { + if (isMounted()) setLocations(location); + }); + } + }, [value, catalogApi, isMounted]); + + const actions = [ + (rowData: Component) => ({ + icon: GitHub, + tooltip: 'View on GitHub', + onClick: () => { + if (!rowData || !rowData.location) return; + window.open(rowData.location.target, '_blank'); + }, + hidden: + rowData && rowData.location ? rowData.location.type !== 'github' : true, + }), + ]; + + const findLocationForEntity = ( + entity: Entity, + l: Location[], + ): Location | undefined => { + const entityLocationId = + entity.metadata.annotations?.['backstage.io/managed-by-location']; + return l.find(location => location.id === entityLocationId); + }; + return (
@@ -105,9 +151,19 @@ const CatalogPage: FC<{}> = () => { + envelopeToComponent( + val, + findLocationForEntity(val, locations), + ), + )) || + [] + } loading={loading} error={error} + actions={actions} /> diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 37c518cb6b..7ff521ee67 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -49,12 +49,14 @@ type CatalogTableProps = { titlePreamble: string; loading: boolean; error?: any; + actions?: any; }; const CatalogTable: FC = ({ components, loading, error, titlePreamble, + actions, }) => { if (loading) { return ; @@ -71,9 +73,10 @@ const CatalogTable: FC = ({ return ( ); }; diff --git a/plugins/catalog/src/data/component.ts b/plugins/catalog/src/data/component.ts index 57cba03a9a..1a7935a196 100644 --- a/plugins/catalog/src/data/component.ts +++ b/plugins/catalog/src/data/component.ts @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Location } from '@backstage/catalog-model'; + export type Component = { name: string; kind: string; description: string; + location?: Location; }; diff --git a/plugins/catalog/src/data/utils.ts b/plugins/catalog/src/data/utils.ts index 05fb2fee46..fe2ec62493 100644 --- a/plugins/catalog/src/data/utils.ts +++ b/plugins/catalog/src/data/utils.ts @@ -14,12 +14,16 @@ * limitations under the License. */ import { Component } from './component'; -import { Entity } from '@backstage/catalog-model'; +import { Entity, Location } from '@backstage/catalog-model'; -export function envelopeToComponent(envelope: Entity): Component { +export function envelopeToComponent( + envelope: Entity, + location?: Location, +): Component { return { name: envelope.metadata?.name ?? '', kind: envelope.kind ?? 'unknown', description: envelope.metadata?.annotations?.description ?? 'placeholder', + location, }; } diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index e67461c83d..a3fee6e217 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -48,7 +48,7 @@ "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", - "@types/codemirror": "^0.0.93", + "@types/codemirror": "^0.0.95", "@types/jest": "^25.2.2", "@types/node": "^12.0.0", "@types/testing-library__jest-dom": "^5.0.4", diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.github/workflows/build.yml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.github/workflows/build.yml index 9087876ce2..d1563ba3e6 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.github/workflows/build.yml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: get yarn cache id: yarn-cache run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v1 + - uses: actions/cache@v2 with: path: ${{ steps.yarn-cache.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} diff --git a/yarn.lock b/yarn.lock index 760f1421b7..4f6e9ab06e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1482,14 +1482,14 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@lerna/add@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/add/-/add-3.20.0.tgz#bea7edf36fc93fb72ec34cb9ba854c48d4abf309" - integrity sha512-AnH1oRIEEg/VDa3SjYq4x1/UglEAvrZuV0WssHUMN81RTZgQk3we+Mv3qZNddrZ/fBcZu2IAdN/EQ3+ie2JxKQ== +"@lerna/add@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/add/-/add-3.21.0.tgz#27007bde71cc7b0a2969ab3c2f0ae41578b4577b" + integrity sha512-vhUXXF6SpufBE1EkNEXwz1VLW03f177G9uMOFMQkp6OJ30/PWg4Ekifuz9/3YfgB2/GH8Tu4Lk3O51P2Hskg/A== dependencies: "@evocateur/pacote" "^9.6.3" - "@lerna/bootstrap" "3.20.0" - "@lerna/command" "3.18.5" + "@lerna/bootstrap" "3.21.0" + "@lerna/command" "3.21.0" "@lerna/filter-options" "3.20.0" "@lerna/npm-conf" "3.16.0" "@lerna/validation-error" "3.13.0" @@ -1498,12 +1498,12 @@ p-map "^2.1.0" semver "^6.2.0" -"@lerna/bootstrap@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/bootstrap/-/bootstrap-3.20.0.tgz#635d71046830f208e851ab429a63da1747589e37" - integrity sha512-Wylullx3uthKE7r4izo09qeRGL20Y5yONlQEjPCfnbxCC2Elu+QcPu4RC6kqKQ7b+g7pdC3OOgcHZjngrwr5XQ== +"@lerna/bootstrap@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/bootstrap/-/bootstrap-3.21.0.tgz#bcd1b651be5b0970b20d8fae04c864548123aed6" + integrity sha512-mtNHlXpmvJn6JTu0KcuTTPl2jLsDNud0QacV/h++qsaKbhAaJr/FElNZ5s7MwZFUM3XaDmvWzHKaszeBMHIbBw== dependencies: - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/filter-options" "3.20.0" "@lerna/has-npm-version" "3.16.5" "@lerna/npm-install" "3.16.5" @@ -1527,13 +1527,13 @@ read-package-tree "^5.1.6" semver "^6.2.0" -"@lerna/changed@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/changed/-/changed-3.20.0.tgz#66b97ebd6c8f8d207152ee524a0791846a9097ae" - integrity sha512-+hzMFSldbRPulZ0vbKk6RD9f36gaH3Osjx34wrrZ62VB4pKmjyuS/rxVYkCA3viPLHoiIw2F8zHM5BdYoDSbjw== +"@lerna/changed@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/changed/-/changed-3.21.0.tgz#108e15f679bfe077af500f58248c634f1044ea0b" + integrity sha512-hzqoyf8MSHVjZp0gfJ7G8jaz+++mgXYiNs9iViQGA8JlN/dnWLI5sWDptEH3/B30Izo+fdVz0S0s7ydVE3pWIw== dependencies: "@lerna/collect-updates" "3.20.0" - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/listable" "3.18.5" "@lerna/output" "3.13.0" @@ -1555,12 +1555,12 @@ execa "^1.0.0" strong-log-transformer "^2.0.0" -"@lerna/clean@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/clean/-/clean-3.20.0.tgz#ba777e373ddeae63e57860df75d47a9e5264c5b2" - integrity sha512-9ZdYrrjQvR5wNXmHfDsfjWjp0foOkCwKe3hrckTzkAeQA1ibyz5llGwz5e1AeFrV12e2/OLajVqYfe+qdkZUgg== +"@lerna/clean@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/clean/-/clean-3.21.0.tgz#c0b46b5300cc3dae2cda3bec14b803082da3856d" + integrity sha512-b/L9l+MDgE/7oGbrav6rG8RTQvRiZLO1zTcG17zgJAAuhlsPxJExMlh2DFwJEVi2les70vMhHfST3Ue1IMMjpg== dependencies: - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/filter-options" "3.20.0" "@lerna/prompt" "3.18.5" "@lerna/pulse-till-done" "3.13.0" @@ -1600,14 +1600,14 @@ npmlog "^4.1.2" slash "^2.0.0" -"@lerna/command@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/command/-/command-3.18.5.tgz#14c6d2454adbfd365f8027201523e6c289cd3cd9" - integrity sha512-36EnqR59yaTU4HrR1C9XDFti2jRx0BgpIUBeWn129LZZB8kAB3ov1/dJNa1KcNRKp91DncoKHLY99FZ6zTNpMQ== +"@lerna/command@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/command/-/command-3.21.0.tgz#9a2383759dc7b700dacfa8a22b2f3a6e190121f7" + integrity sha512-T2bu6R8R3KkH5YoCKdutKv123iUgUbW8efVjdGCDnCMthAQzoentOJfDeodBwn0P2OqCl3ohsiNVtSn9h78fyQ== dependencies: "@lerna/child-process" "3.16.5" "@lerna/package-graph" "3.18.5" - "@lerna/project" "3.18.0" + "@lerna/project" "3.21.0" "@lerna/validation-error" "3.13.0" "@lerna/write-log-file" "3.13.0" clone-deep "^4.0.1" @@ -1616,10 +1616,10 @@ is-ci "^2.0.0" npmlog "^4.1.2" -"@lerna/conventional-commits@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/conventional-commits/-/conventional-commits-3.18.5.tgz#08efd2e5b45acfaf3f151a53a3ec7ecade58a7bc" - integrity sha512-qcvXIEJ3qSgalxXnQ7Yxp5H9Ta5TVyai6vEor6AAEHc20WiO7UIdbLDCxBtiiHMdGdpH85dTYlsoYUwsCJu3HQ== +"@lerna/conventional-commits@3.22.0": + version "3.22.0" + resolved "https://registry.npmjs.org/@lerna/conventional-commits/-/conventional-commits-3.22.0.tgz#2798f4881ee2ef457bdae027ab7d0bf0af6f1e09" + integrity sha512-z4ZZk1e8Mhz7+IS8NxHr64wyklHctCJyWpJKEZZPJiLFJ8yKto/x38O80R10pIzC0rr8Sy/OsjSH4bl0TbbgqA== dependencies: "@lerna/validation-error" "3.13.0" conventional-changelog-angular "^5.0.3" @@ -1642,14 +1642,14 @@ fs-extra "^8.1.0" npmlog "^4.1.2" -"@lerna/create@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/create/-/create-3.18.5.tgz#11ac539f069248eaf7bc4c42e237784330f4fc47" - integrity sha512-cHpjocbpKmLopCuZFI7cKEM3E/QY8y+yC7VtZ4FQRSaLU8D8i2xXtXmYaP1GOlVNavji0iwoXjuNpnRMInIr2g== +"@lerna/create@3.22.0": + version "3.22.0" + resolved "https://registry.npmjs.org/@lerna/create/-/create-3.22.0.tgz#d6bbd037c3dc5b425fe5f6d1b817057c278f7619" + integrity sha512-MdiQQzCcB4E9fBF1TyMOaAEz9lUjIHp1Ju9H7f3lXze5JK6Fl5NYkouAvsLgY6YSIhXMY8AHW2zzXeBDY4yWkw== dependencies: "@evocateur/pacote" "^9.6.3" "@lerna/child-process" "3.16.5" - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/npm-conf" "3.16.0" "@lerna/validation-error" "3.13.0" camelcase "^5.0.0" @@ -1674,23 +1674,23 @@ "@lerna/child-process" "3.16.5" npmlog "^4.1.2" -"@lerna/diff@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/diff/-/diff-3.18.5.tgz#e9e2cb882f84d5b84f0487c612137305f07accbc" - integrity sha512-u90lGs+B8DRA9Z/2xX4YaS3h9X6GbypmGV6ITzx9+1Ga12UWGTVlKaCXBgONMBjzJDzAQOK8qPTwLA57SeBLgA== +"@lerna/diff@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/diff/-/diff-3.21.0.tgz#e6df0d8b9916167ff5a49fcb02ac06424280a68d" + integrity sha512-5viTR33QV3S7O+bjruo1SaR40m7F2aUHJaDAC7fL9Ca6xji+aw1KFkpCtVlISS0G8vikUREGMJh+c/VMSc8Usw== dependencies: "@lerna/child-process" "3.16.5" - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/validation-error" "3.13.0" npmlog "^4.1.2" -"@lerna/exec@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/exec/-/exec-3.20.0.tgz#29f0c01aee2340eb46f90706731fef2062a49639" - integrity sha512-pS1mmC7kzV668rHLWuv31ClngqeXjeHC8kJuM+W2D6IpUVMGQHLcCTYLudFgQsuKGVpl0DGNYG+sjLhAPiiu6A== +"@lerna/exec@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/exec/-/exec-3.21.0.tgz#17f07533893cb918a17b41bcc566dc437016db26" + integrity sha512-iLvDBrIE6rpdd4GIKTY9mkXyhwsJ2RvQdB9ZU+/NhR3okXfqKc6py/24tV111jqpXTtZUW6HNydT4dMao2hi1Q== dependencies: "@lerna/child-process" "3.16.5" - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/filter-options" "3.20.0" "@lerna/profiler" "3.20.0" "@lerna/run-topologically" "3.18.5" @@ -1733,13 +1733,13 @@ ssri "^6.0.1" tar "^4.4.8" -"@lerna/github-client@3.16.5": - version "3.16.5" - resolved "https://registry.npmjs.org/@lerna/github-client/-/github-client-3.16.5.tgz#2eb0235c3bf7a7e5d92d73e09b3761ab21f35c2e" - integrity sha512-rHQdn8Dv/CJrO3VouOP66zAcJzrHsm+wFuZ4uGAai2At2NkgKH+tpNhQy2H1PSC0Ezj9LxvdaHYrUzULqVK5Hw== +"@lerna/github-client@3.22.0": + version "3.22.0" + resolved "https://registry.npmjs.org/@lerna/github-client/-/github-client-3.22.0.tgz#5d816aa4f76747ed736ae64ff962b8f15c354d95" + integrity sha512-O/GwPW+Gzr3Eb5bk+nTzTJ3uv+jh5jGho9BOqKlajXaOkMYGBELEAqV5+uARNGWZFvYAiF4PgqHb6aCUu7XdXg== dependencies: "@lerna/child-process" "3.16.5" - "@octokit/plugin-enterprise-rest" "^3.6.1" + "@octokit/plugin-enterprise-rest" "^6.0.1" "@octokit/rest" "^16.28.4" git-url-parse "^11.1.2" npmlog "^4.1.2" @@ -1766,13 +1766,13 @@ "@lerna/child-process" "3.16.5" semver "^6.2.0" -"@lerna/import@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/import/-/import-3.18.5.tgz#a9c7d8601870729851293c10abd18b3707f7ba5e" - integrity sha512-PH0WVLEgp+ORyNKbGGwUcrueW89K3Iuk/DDCz8mFyG2IG09l/jOF0vzckEyGyz6PO5CMcz4TI1al/qnp3FrahQ== +"@lerna/import@3.22.0": + version "3.22.0" + resolved "https://registry.npmjs.org/@lerna/import/-/import-3.22.0.tgz#1a5f0394f38e23c4f642a123e5e1517e70d068d2" + integrity sha512-uWOlexasM5XR6tXi4YehODtH9Y3OZrFht3mGUFFT3OIl2s+V85xIGFfqFGMTipMPAGb2oF1UBLL48kR43hRsOg== dependencies: "@lerna/child-process" "3.16.5" - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/prompt" "3.18.5" "@lerna/pulse-till-done" "3.13.0" "@lerna/validation-error" "3.13.0" @@ -1780,43 +1780,43 @@ fs-extra "^8.1.0" p-map-series "^1.0.0" -"@lerna/info@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/info/-/info-3.20.0.tgz#3a5212f3029f2bc6255f9533bdf4bcb120ef329a" - integrity sha512-Rsz+KQF9mczbGUbPTrtOed1N0C+cA08Qz0eX/oI+NNjvsryZIju/o7uedG4I3P55MBiAioNrJI88fHH3eTgYug== +"@lerna/info@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/info/-/info-3.21.0.tgz#76696b676fdb0f35d48c83c63c1e32bb5e37814f" + integrity sha512-0XDqGYVBgWxUquFaIptW2bYSIu6jOs1BtkvRTWDDhw4zyEdp6q4eaMvqdSap1CG+7wM5jeLCi6z94wS0AuiuwA== dependencies: - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/output" "3.13.0" envinfo "^7.3.1" -"@lerna/init@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/init/-/init-3.18.5.tgz#86dd0b2b3290755a96975069b5cb007f775df9f5" - integrity sha512-oCwipWrha98EcJAHm8AGd2YFFLNI7AW9AWi0/LbClj1+XY9ah+uifXIgYGfTk63LbgophDd8936ZEpHMxBsbAg== +"@lerna/init@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/init/-/init-3.21.0.tgz#1e810934dc8bf4e5386c031041881d3b4096aa5c" + integrity sha512-6CM0z+EFUkFfurwdJCR+LQQF6MqHbYDCBPyhu/d086LRf58GtYZYj49J8mKG9ktayp/TOIxL/pKKjgLD8QBPOg== dependencies: "@lerna/child-process" "3.16.5" - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" fs-extra "^8.1.0" p-map "^2.1.0" write-json-file "^3.2.0" -"@lerna/link@3.18.5": - version "3.18.5" - resolved "https://registry.npmjs.org/@lerna/link/-/link-3.18.5.tgz#f24347e4f0b71d54575bd37cfa1794bc8ee91b18" - integrity sha512-xTN3vktJpkT7Nqc3QkZRtHO4bT5NvuLMtKNIBDkks0HpGxC9PRyyqwOoCoh1yOGbrWIuDezhfMg3Qow+6I69IQ== +"@lerna/link@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/link/-/link-3.21.0.tgz#8be68ff0ccee104b174b5bbd606302c2f06e9d9b" + integrity sha512-tGu9GxrX7Ivs+Wl3w1+jrLi1nQ36kNI32dcOssij6bg0oZ2M2MDEFI9UF2gmoypTaN9uO5TSsjCFS7aR79HbdQ== dependencies: - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/package-graph" "3.18.5" "@lerna/symlink-dependencies" "3.17.0" p-map "^2.1.0" slash "^2.0.0" -"@lerna/list@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/list/-/list-3.20.0.tgz#7e67cc29c5cf661cfd097e8a7c2d3dcce7a81029" - integrity sha512-fXTicPrfioVnRzknyPawmYIVkzDRBaQqk9spejS1S3O1DOidkihK0xxNkr8HCVC0L22w6f92g83qWDp2BYRUbg== +"@lerna/list@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/list/-/list-3.21.0.tgz#42f76fafa56dea13b691ec8cab13832691d61da2" + integrity sha512-KehRjE83B1VaAbRRkRy6jLX1Cin8ltsrQ7FHf2bhwhRHK0S54YuA6LOoBnY/NtA8bHDX/Z+G5sMY78X30NS9tg== dependencies: - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/filter-options" "3.20.0" "@lerna/listable" "3.18.5" "@lerna/output" "3.13.0" @@ -1962,25 +1962,7 @@ npmlog "^4.1.2" upath "^1.2.0" -"@lerna/project@3.18.0": - version "3.18.0" - resolved "https://registry.npmjs.org/@lerna/project/-/project-3.18.0.tgz#56feee01daeb42c03cbdf0ed8a2a10cbce32f670" - integrity sha512-+LDwvdAp0BurOAWmeHE3uuticsq9hNxBI0+FMHiIai8jrygpJGahaQrBYWpwbshbQyVLeQgx3+YJdW2TbEdFWA== - dependencies: - "@lerna/package" "3.16.0" - "@lerna/validation-error" "3.13.0" - cosmiconfig "^5.1.0" - dedent "^0.7.0" - dot-prop "^4.2.0" - glob-parent "^5.0.0" - globby "^9.2.0" - load-json-file "^5.3.0" - npmlog "^4.1.2" - p-map "^2.1.0" - resolve-from "^4.0.0" - write-json-file "^3.2.0" - -"@lerna/project@^3.18.0": +"@lerna/project@3.21.0", "@lerna/project@^3.18.0": version "3.21.0" resolved "https://registry.npmjs.org/@lerna/project/-/project-3.21.0.tgz#5d784d2d10c561a00f20320bcdb040997c10502d" integrity sha512-xT1mrpET2BF11CY32uypV2GPtPVm6Hgtha7D81GQP9iAitk9EccrdNjYGt5UBYASl4CIDXBRxwmTTVGfrCx82A== @@ -2006,10 +1988,10 @@ inquirer "^6.2.0" npmlog "^4.1.2" -"@lerna/publish@3.20.2": - version "3.20.2" - resolved "https://registry.npmjs.org/@lerna/publish/-/publish-3.20.2.tgz#a45d29813099b3249657ea913d0dc3f8ebc5cc2e" - integrity sha512-N7Y6PdhJ+tYQPdI1tZum8W25cDlTp4D6brvRacKZusweWexxaopbV8RprBaKexkEX/KIbncuADq7qjDBdQHzaA== +"@lerna/publish@3.22.0": + version "3.22.0" + resolved "https://registry.npmjs.org/@lerna/publish/-/publish-3.22.0.tgz#7a3fb61026d3b7425f3b9a1849421f67d795c55d" + integrity sha512-8LBeTLBN8NIrCrLGykRu+PKrfrCC16sGCVY0/bzq9TDioR7g6+cY0ZAw653Qt/0Kr7rg3J7XxVNdzj3fvevlwA== dependencies: "@evocateur/libnpmaccess" "^3.1.2" "@evocateur/npm-registry-fetch" "^4.0.0" @@ -2017,7 +1999,7 @@ "@lerna/check-working-tree" "3.16.5" "@lerna/child-process" "3.16.5" "@lerna/collect-updates" "3.20.0" - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/describe-ref" "3.16.5" "@lerna/log-packed" "3.16.0" "@lerna/npm-conf" "3.16.0" @@ -2032,7 +2014,7 @@ "@lerna/run-lifecycle" "3.16.2" "@lerna/run-topologically" "3.18.5" "@lerna/validation-error" "3.13.0" - "@lerna/version" "3.20.2" + "@lerna/version" "3.22.0" figgy-pudding "^3.5.1" fs-extra "^8.1.0" npm-package-arg "^6.1.0" @@ -2095,12 +2077,12 @@ figgy-pudding "^3.5.1" p-queue "^4.0.0" -"@lerna/run@3.20.0": - version "3.20.0" - resolved "https://registry.npmjs.org/@lerna/run/-/run-3.20.0.tgz#a479f7c42bdf9ebabb3a1e5a2bdebb7a8d201151" - integrity sha512-9U3AqeaCeB7KsGS9oyKNp62s9vYoULg/B4cqXTKZkc+OKL6QOEjYHYVSBcMK9lUXrMjCjDIuDSX3PnTCPxQ2Dw== +"@lerna/run@3.21.0": + version "3.21.0" + resolved "https://registry.npmjs.org/@lerna/run/-/run-3.21.0.tgz#2a35ec84979e4d6e42474fe148d32e5de1cac891" + integrity sha512-fJF68rT3veh+hkToFsBmUJ9MHc9yGXA7LSDvhziAojzOb0AI/jBDp6cEcDQyJ7dbnplba2Lj02IH61QUf9oW0Q== dependencies: - "@lerna/command" "3.18.5" + "@lerna/command" "3.21.0" "@lerna/filter-options" "3.20.0" "@lerna/npm-run-script" "3.16.5" "@lerna/output" "3.13.0" @@ -2145,17 +2127,17 @@ dependencies: npmlog "^4.1.2" -"@lerna/version@3.20.2": - version "3.20.2" - resolved "https://registry.npmjs.org/@lerna/version/-/version-3.20.2.tgz#3709141c0f537741d9bc10cb24f56897bcb30428" - integrity sha512-ckBJMaBWc+xJen0cMyCE7W67QXLLrc0ELvigPIn8p609qkfNM0L0CF803MKxjVOldJAjw84b8ucNWZLvJagP/Q== +"@lerna/version@3.22.0": + version "3.22.0" + resolved "https://registry.npmjs.org/@lerna/version/-/version-3.22.0.tgz#67e1340c1904e9b339becd66429f32dd8ad65a55" + integrity sha512-6uhL6RL7/FeW6u1INEgyKjd5dwO8+IsbLfkfC682QuoVLS7VG6OOB+JmTpCvnuyYWI6fqGh1bRk9ww8kPsj+EA== dependencies: "@lerna/check-working-tree" "3.16.5" "@lerna/child-process" "3.16.5" "@lerna/collect-updates" "3.20.0" - "@lerna/command" "3.18.5" - "@lerna/conventional-commits" "3.18.5" - "@lerna/github-client" "3.16.5" + "@lerna/command" "3.21.0" + "@lerna/conventional-commits" "3.22.0" + "@lerna/github-client" "3.22.0" "@lerna/gitlab-client" "3.15.0" "@lerna/output" "3.13.0" "@lerna/prerelease-id-from-version" "3.16.0" @@ -2340,10 +2322,10 @@ is-plain-object "^3.0.0" universal-user-agent "^5.0.0" -"@octokit/plugin-enterprise-rest@^3.6.1": - version "3.6.2" - resolved "https://registry.npmjs.org/@octokit/plugin-enterprise-rest/-/plugin-enterprise-rest-3.6.2.tgz#74de25bef21e0182b4fa03a8678cd00a4e67e561" - integrity sha512-3wF5eueS5OHQYuAEudkpN+xVeUsg8vYEMMenEzLphUZ7PRZ8OJtDcsreL3ad9zxXmBbaFWzLmFcdob5CLyZftA== +"@octokit/plugin-enterprise-rest@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/@octokit/plugin-enterprise-rest/-/plugin-enterprise-rest-6.0.1.tgz#e07896739618dab8da7d4077c658003775f95437" + integrity sha512-93uGjlhUD+iNg1iWhUENAtJata6w5nE+V4urXOAlIXdco6xNZtUSfYY8dzp3Udy74aqO/B5UZL80x/YMa5PKRw== "@octokit/plugin-paginate-rest@^1.1.1": version "1.1.2" @@ -2427,13 +2409,15 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" -"@rollup/plugin-commonjs@^11.0.2": - version "11.0.2" - resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-11.0.2.tgz#837cc6950752327cb90177b608f0928a4e60b582" - integrity sha512-MPYGZr0qdbV5zZj8/2AuomVpnRVXRU5XKXb3HVniwRoRCreGlf5kOE081isNWeiLIi6IYkwTX9zE0/c7V8g81g== +"@rollup/plugin-commonjs@^12.0.0": + version "12.0.0" + resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-12.0.0.tgz#e2f308ae6057499e0f413f878fff7c3a0fdc02a1" + integrity sha512-8+mDQt1QUmN+4Y9D3yCG8AJNewuTSLYPJVzKKUZ+lGeQrI+bV12Tc5HCyt2WdlnG6ihIL/DPbKRJlB40DX40mw== dependencies: - "@rollup/pluginutils" "^3.0.0" + "@rollup/pluginutils" "^3.0.8" + commondir "^1.0.1" estree-walker "^1.0.1" + glob "^7.1.2" is-reference "^1.1.2" magic-string "^0.25.2" resolve "^1.11.0" @@ -2456,7 +2440,7 @@ is-module "^1.0.0" resolve "^1.14.2" -"@rollup/pluginutils@^3.0.0", "@rollup/pluginutils@^3.0.10", "@rollup/pluginutils@^3.0.8": +"@rollup/pluginutils@^3.0.10", "@rollup/pluginutils@^3.0.8": version "3.0.10" resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.0.10.tgz#a659b9025920378494cd8f8c59fbf9b3a50d5f12" integrity sha512-d44M7t+PjmMrASHbhgpSbVgtL6EFyX7J4mYxwQ/c5eoaE6N2VgCgEcWVzNnwycIloti+/MpwFr8qfw+nRw00sw== @@ -2585,15 +2569,15 @@ ts-dedent "^1.1.0" "@storybook/addon-storysource@^5.3.18": - version "5.3.18" - resolved "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-5.3.18.tgz#003374d76c0898c3b74aa4cd008f444eb6b63c68" - integrity sha512-uMSu505rbcNErAYngV5raVRQ/eMiOtGNA88WxNzD/8h/0EVpEXlBAewiGzAmooNhCa/3O3iPuwka8Cky6aWPOA== + version "5.3.19" + resolved "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-5.3.19.tgz#ae693e88db5d220cb256a9ef4a2366c300e8d88c" + integrity sha512-W7mIAHuxYT+b1huaHCHLkBAh2MbeWmF8CxeBCFiOgZaYYQUTDEh018HJF8u2AqiWSouRhcfzhTnGxOo0hNRBgw== dependencies: - "@storybook/addons" "5.3.18" - "@storybook/components" "5.3.18" - "@storybook/router" "5.3.18" - "@storybook/source-loader" "5.3.18" - "@storybook/theming" "5.3.18" + "@storybook/addons" "5.3.19" + "@storybook/components" "5.3.19" + "@storybook/router" "5.3.19" + "@storybook/source-loader" "5.3.19" + "@storybook/theming" "5.3.19" core-js "^3.0.1" estraverse "^4.2.0" loader-utils "^1.2.3" @@ -2616,7 +2600,7 @@ global "^4.3.2" util-deprecate "^1.0.2" -"@storybook/addons@^5.3.17": +"@storybook/addons@5.3.19", "@storybook/addons@^5.3.17": version "5.3.19" resolved "https://registry.npmjs.org/@storybook/addons/-/addons-5.3.19.tgz#3a7010697afd6df9a41b8c8a7351d9a06ff490a4" integrity sha512-Ky/k22p6i6FVNvs1VhuFyGvYJdcp+FgXqFgnPyY/OXJW/vPDapdElpTpHJZLFI9I2FQBDcygBPU5RXkumQ+KUQ== @@ -2770,6 +2754,33 @@ simplebar-react "^1.0.0-alpha.6" ts-dedent "^1.1.0" +"@storybook/components@5.3.19": + version "5.3.19" + resolved "https://registry.npmjs.org/@storybook/components/-/components-5.3.19.tgz#aac1f9eea1247cc85bd93b10fca803876fb84a6b" + integrity sha512-3g23/+ktlocaHLJKISu9Neu3XKa6aYP2ctDYkRtGchSB0Q55hQsUVGO+BEVuT7Pk2D59mVCxboBjxcRoPUY4pw== + dependencies: + "@storybook/client-logger" "5.3.19" + "@storybook/theming" "5.3.19" + "@types/react-syntax-highlighter" "11.0.4" + "@types/react-textarea-autosize" "^4.3.3" + core-js "^3.0.1" + global "^4.3.2" + lodash "^4.17.15" + markdown-to-jsx "^6.11.4" + memoizerific "^1.11.3" + polished "^3.3.1" + popper.js "^1.14.7" + prop-types "^15.7.2" + react "^16.8.3" + react-dom "^16.8.3" + react-focus-lock "^2.1.0" + react-helmet-async "^1.0.2" + react-popper-tooltip "^2.8.3" + react-syntax-highlighter "^11.0.2" + react-textarea-autosize "^7.1.0" + simplebar-react "^1.0.0-alpha.6" + ts-dedent "^1.1.0" + "@storybook/core-events@5.3.18": version "5.3.18" resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-5.3.18.tgz#e5d335f8a2c7dd46502b8f505006f1e111b46d49" @@ -2939,13 +2950,13 @@ qs "^6.6.0" util-deprecate "^1.0.2" -"@storybook/source-loader@5.3.18": - version "5.3.18" - resolved "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-5.3.18.tgz#39ba28d9664ab8204d6b04ee757772369931e7e5" - integrity sha512-oljmLt3KMu17W9FAaEgocsI6wrloWczS26SVXadzbDbL+8Tu6vTeFJSd6HcY+e4JIIKjuze0kmaVhpi6wwPbZQ== +"@storybook/source-loader@5.3.19": + version "5.3.19" + resolved "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-5.3.19.tgz#ff0a00731c24c61721d8b9d84152f8542913a3b7" + integrity sha512-srSZRPgEOUse8nRVnlazweB2QGp63mPqM0uofg8zYARyaYSOzkC155ymdeiHsmiBTS3X3I0FQE4+KnwiH7iLtw== dependencies: - "@storybook/addons" "5.3.18" - "@storybook/client-logger" "5.3.18" + "@storybook/addons" "5.3.19" + "@storybook/client-logger" "5.3.19" "@storybook/csf" "0.0.1" core-js "^3.0.1" estraverse "^4.2.0" @@ -3402,7 +3413,7 @@ dependencies: "@babel/types" "^7.3.0" -"@types/body-parser@*": +"@types/body-parser@*", "@types/body-parser@^1.19.0": version "1.19.0" resolved "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ== @@ -3422,10 +3433,10 @@ dependencies: "@types/node" "*" -"@types/codemirror@^0.0.93": - version "0.0.93" - resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.93.tgz#74ce5e1132325cb2c88a9bb91fd819294fe0c8ed" - integrity sha512-7F2ksY4U5amV/bIkMTev0n22RWcFO2BBuFuFYmAJIT19gr3kDElr1phgjPsNKV/Pj301bujnMjSy5GlWla+cow== +"@types/codemirror@^0.0.95": + version "0.0.95" + resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.95.tgz#8fe424989cd5a6d7848f124774d42ef34d91adb8" + integrity sha512-E7w4HS8/rR7Rxkga6j68n3/Mi4BJ870/OJJKRqytyWiM659KnbviSng/NPfM/FOjg7YL+5ruFF69FqoLChnPBw== dependencies: "@types/tern" "*" @@ -3669,15 +3680,7 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" -"@types/jest@*": - version "25.2.1" - resolved "https://registry.npmjs.org/@types/jest/-/jest-25.2.1.tgz#9544cd438607955381c1bdbdb97767a249297db5" - integrity sha512-msra1bCaAeEdkSyA0CZ6gW1ukMIvZ5YoJkdXw/qhQdsuuDlFTcEUrUw8CLCPt2rVRUfXlClVvK2gvPs9IokZaA== - dependencies: - jest-diff "^25.2.1" - pretty-format "^25.2.1" - -"@types/jest@^25.2.2": +"@types/jest@*", "@types/jest@^25.2.2": version "25.2.3" resolved "https://registry.npmjs.org/@types/jest/-/jest-25.2.3.tgz#33d27e4c4716caae4eced355097a47ad363fdcaf" integrity sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw== @@ -3703,9 +3706,9 @@ integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA== "@types/lodash@^4.14.151": - version "4.14.151" - resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.151.tgz#7d58cac32bedb0ec37cb7f99094a167d6176c9d5" - integrity sha512-Zst90IcBX5wnwSu7CAS0vvJkTjTELY4ssKbHiTnGcJgi170uiS8yQDdc3v6S77bRqYQIN1App5a1Pc2lceE5/g== + version "4.14.155" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a" + integrity sha512-vEcX7S7aPhsBCivxMwAANQburHBtfN9RdyXFk84IJmu2Z4Hkg1tOFgaslRiEqqvoLtbCBi6ika1EMspE+NZ9Lg== "@types/mime@*": version "2.0.1" @@ -3812,6 +3815,14 @@ "@types/oauth" "*" "@types/passport" "*" +"@types/passport-saml@^1.1.2": + version "1.1.2" + resolved "https://registry.npmjs.org/@types/passport-saml/-/passport-saml-1.1.2.tgz#f32ac2321eb25ec7bdbb1f3a5313b596bb0887e6" + integrity sha512-vpSdcb7V/bFxrvZJwSqnBr0qEqIhtOnwRBxw+Dvq4UkVbEgcCOkxF4tERCCFfA+FP3lp63VCCAifZLQrF5JkXA== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport@*", "@types/passport@^1.0.3": version "1.0.3" resolved "https://registry.npmjs.org/@types/passport/-/passport-1.0.3.tgz#e459ed6c262bf0686684d1b05901be0d0b192a9c" @@ -3885,7 +3896,7 @@ "@types/react" "*" immutable ">=3.8.2" -"@types/react-router-dom@^5.1.3": +"@types/react-router-dom@^5.1.3", "@types/react-router-dom@^5.1.5": version "5.1.5" resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.5.tgz#7c334a2ea785dbad2b2dcdd83d2cf3d9973da090" integrity sha512-ArBM4B1g3BWLGbaGvwBGO75GNFbLDUthrDojV2vHLih/Tq8M+tgvY1DSwkuNrPSwdp/GUL93WSEpTZs8nVyJLw== @@ -4066,17 +4077,10 @@ dependencies: pretty-format "^25.1.0" -"@types/testing-library__jest-dom@^5.0.2": - version "5.7.0" - resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.7.0.tgz#078790bf4dc89152a74428591a228ec5f9433251" - integrity sha512-LoZ3uonlnAbJUz4bg6UoeFl+frfndXngmkCItSjJ8DD5WlRfVqPC5/LgJASsY/dy7AHH2YJ7PcsdASOydcVeFA== - dependencies: - "@types/jest" "*" - -"@types/testing-library__jest-dom@^5.0.4": - version "5.0.4" - resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.0.4.tgz#c7bfbafb920cd1ce40506474e70ee73637f33701" - integrity sha512-Ns69aaNvlxvXkPxIwsqeaWH5vJpwa/pdBIlf8LGkRnbV3tiqUgifs13moLXg1NQ2AM23qRR5CtHarNshvRyEdA== +"@types/testing-library__jest-dom@^5.0.2", "@types/testing-library__jest-dom@^5.0.4": + version "5.9.1" + resolved "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.1.tgz#aba5ee062b7880f69c212ef769389f30752806e5" + integrity sha512-yYn5EKHO3MPEMSOrcAb1dLWY+68CG29LiXKsWmmpVHqoP5+ZRiAVLyUHvPNrO2dABDdUGZvavMsaGpWNjM6N2g== dependencies: "@types/jest" "*" @@ -4134,9 +4138,9 @@ source-map "^0.6.1" "@types/webpack@*", "@types/webpack@^4.41.7", "@types/webpack@^4.41.8": - version "4.41.13" - resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.13.tgz#988d114c8913d039b8a0e0502a7fe4f1f84f3d5e" - integrity sha512-RYmIHOWSxnTTa765N6jJBVE45pd2SYNblEYshVDduLw6RhocazNmRzE5/ytvBD8IkDMH6DI+bcrqxh8NILimBA== + version "4.41.17" + resolved "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.17.tgz#0a69005e644d657c85b7d6ec1c826a71bebd1c93" + integrity sha512-6FfeCidTSHozwKI67gIVQQ5Mp0g4X96c2IXxX75hYEQJwST/i6NyZexP//zzMOBb+wG9jJ7oO8fk9yObP2HWAw== dependencies: "@types/anymatch" "*" "@types/node" "*" @@ -5453,7 +5457,7 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== -body-parser@1.19.0: +body-parser@1.19.0, body-parser@^1.19.0: version "1.19.0" resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -6771,7 +6775,7 @@ cross-spawn@6.0.5, cross-spawn@^6.0.0: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@7.0.1, cross-spawn@^7.0.0, cross-spawn@^7.0.1: +cross-spawn@7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14" integrity sha512-u7v4o84SwFpD32Z8IIcPZ6z1/ie24O6RU3RbtL5Y316l3KuHVPx9ItBgWQ6VlfAFnRnTtMUrsQ9MUUTuEZjogg== @@ -6789,7 +6793,7 @@ cross-spawn@^5.1.0: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.2: +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -8065,7 +8069,7 @@ escape-goat@^2.0.0: resolved "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== -escape-html@~1.0.3: +escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= @@ -9031,9 +9035,9 @@ fork-ts-checker-webpack-plugin@3.1.1: worker-rpc "^0.1.0" fork-ts-checker-webpack-plugin@^4.0.5: - version "4.1.5" - resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.5.tgz#780d52c65183742d8c885fff42a9ec9ea7006672" - integrity sha512-nuD4IDqoOfkEIlS6shhjLGaLBDSNyVJulAlr5lFbPe0saGqlsTo+/HmhtIrs/cNLFtmaudL10byivhxr+Qhh4w== + version "4.1.6" + resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz#5055c703febcf37fa06405d400c122b905167fc5" + integrity sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw== dependencies: "@babel/code-frame" "^7.5.5" chalk "^2.4.1" @@ -9512,7 +9516,7 @@ globalthis@^1.0.0: dependencies: define-properties "^1.1.3" -globby@11.0.0, globby@^11.0.0: +globby@11.0.0: version "11.0.0" resolved "https://registry.npmjs.org/globby/-/globby-11.0.0.tgz#56fd0e9f0d4f8fb0c456f1ab0dee96e1380bc154" integrity sha512-iuehFnR3xu5wBBtm4xi0dMe92Ob87ufyu/dHwpDYfbcpYpIbrO5OnS8M1vWvrBhSGEJ3/Ecj7gnX76P8YxpPEg== @@ -9551,6 +9555,18 @@ globby@^10.0.1: merge2 "^1.2.3" slash "^3.0.0" +globby@^11.0.0: + version "11.0.1" + resolved "https://registry.npmjs.org/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" + integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + globby@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" @@ -12006,26 +12022,26 @@ left-pad@^1.3.0: integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== lerna@^3.20.2: - version "3.20.2" - resolved "https://registry.npmjs.org/lerna/-/lerna-3.20.2.tgz#abf84e73055fe84ee21b46e64baf37b496c24864" - integrity sha512-bjdL7hPLpU3Y8CBnw/1ys3ynQMUjiK6l9iDWnEGwFtDy48Xh5JboR9ZJwmKGCz9A/sarVVIGwf1tlRNKUG9etA== + version "3.22.0" + resolved "https://registry.npmjs.org/lerna/-/lerna-3.22.0.tgz#da14d08f183ffe6eec566a4ef3f0e11afa621183" + integrity sha512-xWlHdAStcqK/IjKvjsSMHPZjPkBV1lS60PmsIeObU8rLljTepc4Sg/hncw4HWfQxPIewHAUTqhrxPIsqf9L2Eg== dependencies: - "@lerna/add" "3.20.0" - "@lerna/bootstrap" "3.20.0" - "@lerna/changed" "3.20.0" - "@lerna/clean" "3.20.0" + "@lerna/add" "3.21.0" + "@lerna/bootstrap" "3.21.0" + "@lerna/changed" "3.21.0" + "@lerna/clean" "3.21.0" "@lerna/cli" "3.18.5" - "@lerna/create" "3.18.5" - "@lerna/diff" "3.18.5" - "@lerna/exec" "3.20.0" - "@lerna/import" "3.18.5" - "@lerna/info" "3.20.0" - "@lerna/init" "3.18.5" - "@lerna/link" "3.18.5" - "@lerna/list" "3.20.0" - "@lerna/publish" "3.20.2" - "@lerna/run" "3.20.0" - "@lerna/version" "3.20.2" + "@lerna/create" "3.22.0" + "@lerna/diff" "3.21.0" + "@lerna/exec" "3.21.0" + "@lerna/import" "3.22.0" + "@lerna/info" "3.21.0" + "@lerna/init" "3.21.0" + "@lerna/link" "3.21.0" + "@lerna/list" "3.21.0" + "@lerna/publish" "3.22.0" + "@lerna/run" "3.21.0" + "@lerna/version" "3.22.0" import-local "^2.0.0" npmlog "^4.1.2" @@ -12553,6 +12569,14 @@ markdown-it@^10.0.0: mdurl "^1.0.1" uc.micro "^1.0.5" +markdown-to-jsx@^6.11.4: + version "6.11.4" + resolved "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-6.11.4.tgz#b4528b1ab668aef7fe61c1535c27e837819392c5" + integrity sha512-3lRCD5Sh+tfA52iGgfs/XZiw33f7fFX9Bn55aNnVNUd2GzLDkOWyKYYD8Yju2B1Vn+feiEdgJs8T6Tg0xNokPw== + dependencies: + prop-types "^15.6.2" + unquote "^1.1.0" + markdown-to-jsx@^6.9.1, markdown-to-jsx@^6.9.3: version "6.11.0" resolved "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-6.11.0.tgz#a2e3f2bc781c3402d8bb0f8e0a12a186474623b0" @@ -13210,6 +13234,11 @@ node-forge@0.9.0: resolved "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ== +node-forge@^0.7.0: + version "0.7.6" + resolved "https://registry.npmjs.org/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" + integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw== + node-gyp@^5.0.2: version "5.1.0" resolved "https://registry.npmjs.org/node-gyp/-/node-gyp-5.1.0.tgz#8e31260a7af4a2e2f994b0673d4e0b3866156332" @@ -14079,7 +14108,21 @@ passport-oauth2@1.x.x: uid2 "0.0.x" utils-merge "1.x.x" -passport-strategy@1.x.x: +passport-saml@^1.3.3: + version "1.3.3" + resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.3.tgz#cbea1a2b21ff32b3bc4bfd84dc39c3a370df9935" + integrity sha512-54ecY/A6UEsyCehJws6a+J6THvwtYnGl9cnAUxx5DjsuKgZrDs0tSy58K4hCk1XG/LOcdQSF1TR3xlRXgTULhA== + dependencies: + debug "^3.1.0" + passport-strategy "*" + q "^1.5.0" + xml-crypto "^1.4.0" + xml-encryption "^1.0.0" + xml2js "0.4.x" + xmlbuilder "^11.0.0" + xmldom "0.1.x" + +passport-strategy@*, passport-strategy@1.x.x: version "1.0.0" resolved "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4" integrity sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ= @@ -15067,7 +15110,7 @@ pupa@^2.0.1: dependencies: escape-goat "^2.0.0" -q@^1.1.2, q@^1.5.1: +q@^1.1.2, q@^1.5.0, q@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= @@ -16405,7 +16448,7 @@ sane@^4.0.3: minimist "^1.1.1" walker "~1.0.5" -sax@^1.2.4, sax@~1.2.4: +sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -18083,9 +18126,9 @@ ts-pnp@^1.1.2: integrity sha512-CrG5GqAAzMT7144Cl+UIFP7mz/iIhiy+xQ6GGcnjTezhALT02uPMRw7tgDSESgB5MsfKt55+GPWw4ir1kVtMIQ== tsc-watch@^4.2.3: - version "4.2.5" - resolved "https://registry.npmjs.org/tsc-watch/-/tsc-watch-4.2.5.tgz#3ee680cfd02087bb76cbecd2d5f290a0d6302454" - integrity sha512-scXpL5SFJevvtKOtQIRxJvyEwCKSIZS9bDrO/cy/dWlETTVwSlfRm2WmakdRPM0Rg90DbYyJjkUr2z74HXcxzQ== + version "4.2.8" + resolved "https://registry.npmjs.org/tsc-watch/-/tsc-watch-4.2.8.tgz#cb8639bf507738dfa64590504f539a2f525d3a7b" + integrity sha512-EtWdmVZYFxTxSHqtmpowtvC/son28Hgl3qxYnD6sy+uGQXsWYDQxwmL8EneWca2kzAIYNZq3CGrUdK1jjbL4Vw== dependencies: cross-spawn "^5.1.0" node-cleanup "^2.1.2" @@ -19171,16 +19214,62 @@ xdg-basedir@^4.0.0: resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== +xml-crypto@^1.4.0: + version "1.5.3" + resolved "https://registry.npmjs.org/xml-crypto/-/xml-crypto-1.5.3.tgz#a8f500b90f0dfaf0efa3331c345ecb0fff993c34" + integrity sha512-uHkmpUtX15xExe5iimPmakAZN+6CqIvjmaJTy4FwqGzaTjrKRBNeqMh8zGEzVNgW0dk6beFYpyQSgqV/J6C5xA== + dependencies: + xmldom "0.1.27" + xpath "0.0.27" + +xml-encryption@^1.0.0: + version "1.2.0" + resolved "https://registry.npmjs.org/xml-encryption/-/xml-encryption-1.2.0.tgz#37c8b470beae88b4625ea8cad82f108ea0f9c364" + integrity sha512-J3NjGMY8jf6bTo15jURTYBLtsisbnyCeM+MuxtfiAkZEZBnSZpNKjUUORhiOScKvSi6tMOAaZ3r7bZOXOni+Ew== + dependencies: + escape-html "^1.0.3" + node-forge "^0.7.0" + xmldom "~0.1.15" + xpath "0.0.27" + xml-name-validator@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== +xml2js@0.4.x: + version "0.4.23" + resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" + integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xmlbuilder@^11.0.0, xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xmldom@0.1.27: + version "0.1.27" + resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9" + integrity sha1-1QH5ezvbQDr4757MIFcxh6rawOk= + +xmldom@0.1.x, xmldom@~0.1.15: + version "0.1.31" + resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff" + integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ== + +xpath@0.0.27: + version "0.0.27" + resolved "https://registry.npmjs.org/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92" + integrity sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ== + xregexp@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/xregexp/-/xregexp-4.3.0.tgz#7e92e73d9174a99a59743f67a4ce879a04b5ae50" @@ -19220,6 +19309,11 @@ yaml@*, yaml@^1.9.2: dependencies: "@babel/runtime" "^7.9.2" +yaml@^1.10.0: + version "1.10.0" + resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" + integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== + yaml@^1.7.2: version "1.8.3" resolved "https://registry.npmjs.org/yaml/-/yaml-1.8.3.tgz#2f420fca58b68ce3a332d0ca64be1d191dd3f87a"