initial commit v1.0.0
authorJustin Wind <justin.wind+git@gmail.com>
Mon, 26 Dec 2022 23:17:45 +0000 (15:17 -0800)
committerJustin Wind <justin.wind+git@gmail.com>
Fri, 30 Dec 2022 20:45:43 +0000 (12:45 -0800)
143 files changed:
.eslintrc.json [new file with mode: 0644]
.gitignore [new file with mode: 0644]
.nycrc.json [new file with mode: 0644]
CHANGELOG.md [new file with mode: 0644]
README.md [new file with mode: 0644]
bin/addProfile.js [new file with mode: 0644]
bin/addProfileScope.js [new file with mode: 0644]
bin/addScope.js [new file with mode: 0644]
bin/authUserAdd.js [new file with mode: 0644]
bin/cli-helper.js [new file with mode: 0644]
bin/dumpProfiles.js [new file with mode: 0644]
bin/generate-engine-entity-relation-diagram.sh [new file with mode: 0755]
bin/resourceCreate.js [new file with mode: 0644]
config/default.js [new file with mode: 0644]
config/development.js [new file with mode: 0644]
config/index.js [new file with mode: 0644]
config/test.js [new file with mode: 0644]
documentation/media/consent-page.png [new file with mode: 0644]
documentation/media/postgres-er.svg [new file with mode: 0644]
documentation/media/sqlite-er.svg [new file with mode: 0644]
documentation/media/ticket-page.png [new file with mode: 0644]
package-lock.json [new file with mode: 0644]
package.json [new file with mode: 0644]
server.js [new file with mode: 0644]
src/chores.js [new file with mode: 0644]
src/common.js [new file with mode: 0644]
src/db/abstract.js [new file with mode: 0644]
src/db/errors.js [new file with mode: 0644]
src/db/index.js [new file with mode: 0644]
src/db/postgres/index.js [new file with mode: 0644]
src/db/postgres/sql/almanac-get-all.sql [new file with mode: 0644]
src/db/postgres/sql/almanac-get.sql [new file with mode: 0644]
src/db/postgres/sql/almanac-upsert.sql [new file with mode: 0644]
src/db/postgres/sql/authentication-get.sql [new file with mode: 0644]
src/db/postgres/sql/authentication-success.sql [new file with mode: 0644]
src/db/postgres/sql/authentication-upsert.sql [new file with mode: 0644]
src/db/postgres/sql/profile-get.sql [new file with mode: 0644]
src/db/postgres/sql/profile-identifier-insert.sql [new file with mode: 0644]
src/db/postgres/sql/profile-scope-insert.sql [new file with mode: 0644]
src/db/postgres/sql/profile-scopes-clear.sql [new file with mode: 0644]
src/db/postgres/sql/profile-scopes-set-all.sql [new file with mode: 0644]
src/db/postgres/sql/profiles-scopes-by-identifier.sql [new file with mode: 0644]
src/db/postgres/sql/redeem-code.sql [new file with mode: 0644]
src/db/postgres/sql/refresh-code.sql [new file with mode: 0644]
src/db/postgres/sql/resource-get.sql [new file with mode: 0644]
src/db/postgres/sql/resource-upsert.sql [new file with mode: 0644]
src/db/postgres/sql/schema/1.0.0/apply.sql [new file with mode: 0644]
src/db/postgres/sql/schema/1.0.0/er.dot [new file with mode: 0644]
src/db/postgres/sql/schema/1.0.0/revert.sql [new file with mode: 0644]
src/db/postgres/sql/schema/init.sql [new file with mode: 0644]
src/db/postgres/sql/scope-cleanup.sql [new file with mode: 0644]
src/db/postgres/sql/scope-delete.sql [new file with mode: 0644]
src/db/postgres/sql/scope-in-use.sql [new file with mode: 0644]
src/db/postgres/sql/scope-upsert.sql [new file with mode: 0644]
src/db/postgres/sql/scopes-insert.sql [new file with mode: 0644]
src/db/postgres/sql/token-cleanup.sql [new file with mode: 0644]
src/db/postgres/sql/token-get-by-code-id.sql [new file with mode: 0644]
src/db/postgres/sql/token-refresh-revoke-by-code-id.sql [new file with mode: 0644]
src/db/postgres/sql/token-revoke-by-code-id.sql [new file with mode: 0644]
src/db/postgres/sql/token-scopes-remove.sql [new file with mode: 0644]
src/db/postgres/sql/token-scopes-set.sql [new file with mode: 0644]
src/db/postgres/sql/tokens-get-by-identifier.sql [new file with mode: 0644]
src/db/schema-version-helper.js [new file with mode: 0644]
src/db/sqlite/index.js [new file with mode: 0644]
src/db/sqlite/sql/almanac-get-all.sql [new file with mode: 0644]
src/db/sqlite/sql/almanac-get.sql [new file with mode: 0644]
src/db/sqlite/sql/almanac-upsert.sql [new file with mode: 0644]
src/db/sqlite/sql/authentication-get.sql [new file with mode: 0644]
src/db/sqlite/sql/authentication-success.sql [new file with mode: 0644]
src/db/sqlite/sql/authentication-upsert.sql [new file with mode: 0644]
src/db/sqlite/sql/profile-get.sql [new file with mode: 0644]
src/db/sqlite/sql/profile-identifier-insert.sql [new file with mode: 0644]
src/db/sqlite/sql/profile-scope-insert.sql [new file with mode: 0644]
src/db/sqlite/sql/profile-scopes-clear.sql [new file with mode: 0644]
src/db/sqlite/sql/profiles-scopes-by-identifier.sql [new file with mode: 0644]
src/db/sqlite/sql/redeem-code.sql [new file with mode: 0644]
src/db/sqlite/sql/refresh-code.sql [new file with mode: 0644]
src/db/sqlite/sql/resource-get.sql [new file with mode: 0644]
src/db/sqlite/sql/resource-upsert.sql [new file with mode: 0644]
src/db/sqlite/sql/schema/1.0.0/apply.sql [new file with mode: 0644]
src/db/sqlite/sql/schema/1.0.0/er.dot [new file with mode: 0644]
src/db/sqlite/sql/schema/1.0.0/revert.sql [new file with mode: 0644]
src/db/sqlite/sql/schema/init.sql [new file with mode: 0644]
src/db/sqlite/sql/scope-cleanup.sql [new file with mode: 0644]
src/db/sqlite/sql/scope-delete.sql [new file with mode: 0644]
src/db/sqlite/sql/scope-in-use.sql [new file with mode: 0644]
src/db/sqlite/sql/scope-insert.sql [new file with mode: 0644]
src/db/sqlite/sql/scope-upsert.sql [new file with mode: 0644]
src/db/sqlite/sql/token-cleanup.sql [new file with mode: 0644]
src/db/sqlite/sql/token-get-by-code-id.sql [new file with mode: 0644]
src/db/sqlite/sql/token-refresh-revoke-by-code-id.sql [new file with mode: 0644]
src/db/sqlite/sql/token-revoke-by-code-id.sql [new file with mode: 0644]
src/db/sqlite/sql/token-scope-remove.sql [new file with mode: 0644]
src/db/sqlite/sql/token-scope-set.sql [new file with mode: 0644]
src/db/sqlite/sql/token-scopes-get-by-code-id.sql [new file with mode: 0644]
src/db/sqlite/sql/tokens-get-by-identifier.sql [new file with mode: 0644]
src/enum.js [new file with mode: 0644]
src/errors.js [new file with mode: 0644]
src/logger/data-sanitizers.js [new file with mode: 0644]
src/logger/index.js [new file with mode: 0644]
src/manager.js [new file with mode: 0644]
src/service.js [new file with mode: 0644]
src/template/admin-html.js [new file with mode: 0644]
src/template/admin-maintenance-html.js [new file with mode: 0644]
src/template/admin-ticket-html.js [new file with mode: 0644]
src/template/authorization-error-html.js [new file with mode: 0644]
src/template/authorization-request-html.js [new file with mode: 0644]
src/template/index.js [new file with mode: 0644]
src/template/root-html.js [new file with mode: 0644]
src/template/template-helper.js [new file with mode: 0644]
static/.external-link.svg.meta [new file with mode: 0644]
static/.favicon.ico.meta [new file with mode: 0644]
static/.logo.svg.meta [new file with mode: 0644]
static/Makefile [new file with mode: 0644]
static/custom.css [new file with mode: 0644]
static/external-link.svg [new file with mode: 0644]
static/favicon.ico [new file with mode: 0644]
static/index.html [new file with mode: 0644]
static/logo.svg [new file with mode: 0644]
static/robots.txt [new file with mode: 0644]
static/theme.css [new file with mode: 0644]
test/config.js [new file with mode: 0644]
test/src/chores.js [new file with mode: 0644]
test/src/common.js [new file with mode: 0644]
test/src/db/abstract.js [new file with mode: 0644]
test/src/db/factory.js [new file with mode: 0644]
test/src/db/integration.js [new file with mode: 0644]
test/src/db/postgres.js [new file with mode: 0644]
test/src/db/schema-version-helper.js [new file with mode: 0644]
test/src/db/sqlite.js [new file with mode: 0644]
test/src/errors.js [new file with mode: 0644]
test/src/logger.js [new file with mode: 0644]
test/src/manager.js [new file with mode: 0644]
test/src/service.js [new file with mode: 0644]
test/src/template/admin-html.js [new file with mode: 0644]
test/src/template/admin-maintenance-html.js [new file with mode: 0644]
test/src/template/admin-ticket-html.js [new file with mode: 0644]
test/src/template/authorization-error-html.js [new file with mode: 0644]
test/src/template/authorization-request-html.js [new file with mode: 0644]
test/src/template/root-html.js [new file with mode: 0644]
test/src/template/template-helper.js [new file with mode: 0644]
test/stub-db.js [new file with mode: 0644]
test/stub-logger.js [new file with mode: 0644]

diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644 (file)
index 0000000..b3ffbe2
--- /dev/null
@@ -0,0 +1,89 @@
+{
+  "env": {
+    "browser": false,
+    "es6": true,
+    "node": true
+  },
+  "extends": [
+    "eslint:recommended",
+    "plugin:node/recommended",
+    "plugin:security/recommended",
+    "plugin:sonarjs/recommended"
+  ],
+  "parserOptions": {
+    "ecmaVersion": "latest"
+  },
+  "plugins": [
+    "node",
+    "security",
+    "sonarjs"
+  ],
+  "rules": {
+    "array-element-newline": [
+      "error",
+      "consistent"
+    ],
+    "arrow-parens": [
+      "error",
+      "always"
+    ],
+    "arrow-spacing": [
+      "error",
+      {
+        "after": true,
+        "before": true
+      }
+    ],
+    "block-scoped-var": "error",
+    "block-spacing": "error",
+    "brace-style": "error",
+    "callback-return": "error",
+    "camelcase": "error",
+    "class-methods-use-this": "error",
+    "comma-dangle": [
+      "error",
+      "always-multiline"
+    ],
+    "comma-spacing": [
+      "error",
+      {
+        "after": true,
+        "before": false
+      }
+    ],
+    "comma-style": [
+      "error",
+      "last"
+    ],
+    "indent": [
+      "warn",
+      2,
+      {
+        "SwitchCase": 1
+      }
+    ],
+    "sonarjs/cognitive-complexity": "warn",
+    "sonarjs/no-duplicate-string": "warn",
+    "keyword-spacing": "error",
+    "linebreak-style": [
+      "error",
+      "unix"
+    ],
+    "no-unused-vars": [
+      "error", {
+        "varsIgnorePattern": "^_"
+      }
+    ],
+    "object-curly-spacing": [
+      "error",
+      "always"
+    ],
+    "prefer-const": "error",
+    "quotes": [
+      "error",
+      "single"
+    ],
+    "strict": "error",
+    "vars-on-top": "error"
+  }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..88f74fe
--- /dev/null
@@ -0,0 +1,7 @@
+node_modules
+.nyc_output
+coverage
+.vscode
+*.sqlite*
+static/*.gz
+static/*.br
diff --git a/.nycrc.json b/.nycrc.json
new file mode 100644 (file)
index 0000000..497d8af
--- /dev/null
@@ -0,0 +1,6 @@
+{
+  "reporter": [
+    "lcov",
+    "text"
+  ]
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644 (file)
index 0000000..80cf52d
--- /dev/null
@@ -0,0 +1,14 @@
+# Changelog
+
+Releases and notable changes to this project are documented here.
+
+## [v1.0.0] - TBD
+
+### Added
+
+- Everything.  MVP first stable release.
+
+---
+
+[Unreleased]: https://git.squeep.com/?p=squeep-indie-auther;a=commitdiff;h=HEAD;hp=v1.0.0
+[v1.0.0]: https://git.squeep.com/?p=squeep-indie-auther;a=commitdiff;h=v1.0.0;hp=v0.0.0
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..e37aea1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,76 @@
+# Welcome to my IndieAuth-er
+
+## What
+
+[IndieAuth](https://indieweb.org/IndieAuth) is a [protocol](https://indieauth.spec.indieweb.org/) which facilitates identifying users uniquely by the URLs they control to third-party applications.  It is an extension of [Oauth 2](https://indieauth.spec.indieweb.org).
+
+This service implements the functionality required to negotiate that identity authentication and validation.
+
+## Let's Do Some Auth
+
+A ```user``` is an entity known to this service, with a credential (currently a password) used to authenticate and login to this service.  
+Authentication of a ```user``` is handled by either a [hashed password](https://en.wikipedia.org/wiki/Argon2) stored securely in one of the available database engines, or by optionally delegating to the host machine's [<abbr title="Pluggable Authentication Module">PAM</abbr> subsystem](https://en.wikipedia.org/wiki/Pluggable_Authentication_Modules).
+PAM can be used to leverage, exempli gratia, LDAP integration for user authentication.
+
+A ```profile``` is a URL (under control of a ```user```) which contents includes the necessary meta-data informing an application to contact this server for identification validation.  Each ```user``` may have one or more ```profile```s.
+
+Each ```profile``` may also be associated with a customizable list of additional [scopes](https://www.oauth.com/oauth2-servers/scope/) which may be added to any application client grant for convenience.
+
+An example of the user-interface when granting consent to a client application:
+![Consent page](./documentation/media/consent-page.png)
+
+A rudimentary ticket-sending UI is also available:
+![Ticket Offer page](./documentation/media/ticket-page.png)
+
+## Resource Service Integration
+
+Other services (resources) may make calls to validate token grants by configuring a pre-shared secret, and authenticating to this server using [an HMAC-style bearer token scheme](https://git.squeep.com/?p=squeep-resource-authentication-module;a=blob_plain;f=README.md;hb=HEAD).
+
+## Ticket Auth
+
+This service can accept proffered [authentication tickets](https://indieweb.org/IndieAuth_Ticket_Auth).  It will simply publish any proffered tickets for valid profiles to a configured AMQP/RabbitMQ queue for some other service to redeem and make use of.
+
+## Architecture
+
+A granted token is an encrypted identifier (specifically a UUID assigned to the initial authentication request) which references the user/client relationship stored in the database.  Details such as granted scopes, token expiration, refreshability, and revocation status are stored there.
+
+Uh, more later.
+
+![Entity relationship diagram for Postgres engine](./documentation/media/postgres-er.svg)
+
+### Quirks
+
+This implementation is built atop an in-house API framework, for Reasons.  Limiting the footprint of external dependencies as much as is reasonable is a design goal.
+
+### File Tour
+
+- bin/ - utility scripts
+- config/
+  - default.js - defines all configuration parameters' default values
+  - index.js - merges an environment's values over defaults
+  - *.js - environment specific values, edit these as needed
+- server.js - launches the application server
+- src/
+  - chores.js - recurring maintenance tasks
+  - common.js - utility functions
+  - db/
+    - abstract.js - base database class that any engine will implement
+    - errors.js - database Error types
+    - index.js - database factory
+    - schema-version-helper.js - schema migrations aide
+    - postgres/
+      - index.js - PostgreSQL engine implementation
+      - sql/ - statements and schemas
+    - sqlite/
+      - index.js - SQLite engine implementation
+      - sql - statements and schemas
+  - enum.js - invariants
+  - errors.js - local Error types
+  - logger/
+    - data-sanitizers.js - logger helpers to scrub sensitive and verbose data
+    - index.js - a very simple logging class
+  - manager.js - process incoming requests, most of application logic resides here
+  - service.js - defines incoming endpoints, linking the API server framework to the manager methods
+  - template/ - HTML content
+- static/ - static web assets, CSS, images, et cetera
+- test/ - unit and coverage tests
diff --git a/bin/addProfile.js b/bin/addProfile.js
new file mode 100644 (file)
index 0000000..6a47b3c
--- /dev/null
@@ -0,0 +1,38 @@
+'use strict';
+
+const DB = require('../src/db');
+const Logger = require('../src/logger');
+const Config = require('../config');
+const config = new Config(process.env.NODE_ENV);
+
+const logger = new Logger(config);
+const db = new DB(logger, config);
+
+
+const identifier = process.argv[2];
+const profile = process.argv[3];
+
+if (!identifier) {
+  console.log('missing user');
+  throw new Error('missing argument');
+}
+if (!profile) {
+  console.log('missing profile');
+  throw new Error('missing argument');
+}
+
+(async () => {
+  await db.initialize();
+  await db.context(async (dbCtx) => {
+    const user =  await db.authenticationGet(dbCtx, identifier);
+    if (!user) {
+      console.log('user does not exist');
+      throw new Error('invalid identifier');
+    }
+    const profileURL = new URL(profile); // Validate and normalize
+    const result = await db.profileIdentifierInsert(dbCtx, profileURL.href, identifier);
+    console.log(result);
+  });
+  console.log('done');
+  await db._closeConnection();
+})();
diff --git a/bin/addProfileScope.js b/bin/addProfileScope.js
new file mode 100644 (file)
index 0000000..dfeaca0
--- /dev/null
@@ -0,0 +1,32 @@
+'use strict';
+
+const DB = require('../src/db');
+const Logger = require('../src/logger');
+const Config = require('../config');
+const config = new Config(process.env.NODE_ENV);
+
+const logger = new Logger(config);
+const db = new DB(logger, config);
+
+
+const profile = process.argv[2];
+const scope = process.argv[3];
+
+if (!scope) {
+  console.log('missing scope');
+  throw new Error('missing argument');
+}
+if (!profile) {
+  console.log('missing profile');
+  throw new Error('missing argument');
+}
+
+(async () => {
+  await db.initialize();
+  await db.context(async (dbCtx) => {
+    const result = await db.profileScopeInsert(dbCtx, profile, scope);
+    console.log(result);
+  });
+  console.log('done');
+  await db._closeConnection();
+})();
diff --git a/bin/addScope.js b/bin/addScope.js
new file mode 100644 (file)
index 0000000..809ee5f
--- /dev/null
@@ -0,0 +1,32 @@
+'use strict';
+
+const DB = require('../src/db');
+const Logger = require('../src/logger');
+const Config = require('../config');
+const config = new Config(process.env.NODE_ENV);
+
+const logger = new Logger(config);
+const db = new DB(logger, config);
+
+
+const scope = process.argv[2];
+const description = process.argv[3];
+
+if (!scope) {
+  console.log('missing scope');
+  throw new Error('missing argument');
+}
+if (!description) {
+  console.log('missing description');
+  throw new Error('missing argument');
+}
+
+(async () => {
+  await db.initialize();
+  await db.context(async (dbCtx) => {
+    const result = await db.scopeUpsert(dbCtx, scope, description);
+    console.log(result);
+  });
+  console.log('done');
+  await db._closeConnection();
+})();
diff --git a/bin/authUserAdd.js b/bin/authUserAdd.js
new file mode 100644 (file)
index 0000000..cc71552
--- /dev/null
@@ -0,0 +1,70 @@
+'use strict';
+
+const argon2 = require('argon2');
+const readline = require('readline');
+const stream = require('stream');
+const DB = require('../src/db');
+const Logger = require('../src/logger');
+const Config = require('../config');
+const config = new Config(process.env.NODE_ENV);
+
+const logger = new Logger(config);
+const db = new DB(logger, config);
+
+const flags = {
+  isPAM: false,
+};
+if (process.argv.includes('-P')) {
+  flags.isPAM = true;
+  process.argv.splice(process.argv.indexOf('-P'), 1);
+}
+
+const identifier = process.argv[2];
+
+if (!identifier) {
+  console.log('missing user to add');
+  throw new Error('missing argument');
+}
+
+async function readPassword(prompt) {
+  const input = process.stdin;
+  const output = new stream.Writable({
+    write: function (chunk, encoding, callback) {
+      if (!this.muted) {
+        process.stdout.write(chunk, encoding);
+      }
+      callback();
+    },
+  });
+  const rl = readline.createInterface({ input, output, terminal: !!process.stdin.isTTY });
+  rl.setPrompt(prompt);
+  rl.prompt();
+  output.muted = true;
+
+  return new Promise((resolve) => {
+    rl.question('', (answer) => {
+      output.muted = false;
+      rl.close();
+      output.write('\n');
+      resolve(answer);
+    });
+  });
+}
+
+(async () => {
+  await db.initialize();
+  let credential;
+  if (flags.isPAM) {
+    credential = '$PAM$';
+  } else {
+    const password = await readPassword('password: ');
+    credential = await argon2.hash(password, { type: argon2.argon2id });
+  }
+  console.log(`\t${identifier}:${credential}`);
+  await db.context(async (dbCtx) => {
+    const result = await db.authenticationUpsert(dbCtx, identifier, credential);
+    console.log(result);
+  });
+  console.log('done');
+  await db._closeConnection();
+})();
diff --git a/bin/cli-helper.js b/bin/cli-helper.js
new file mode 100644 (file)
index 0000000..5f1a1a3
--- /dev/null
@@ -0,0 +1,38 @@
+'use strict';
+
+
+/**
+ * 
+ * @param {String} option
+ * @returns {*}
+ */
+function getOption(option) {
+  let value;
+  while (process.argv.includes(option)) {
+    const optionIndex = process.argv.indexOf(option);
+    value = process.argv.splice(optionIndex, 2)[1];
+  }
+  return value;
+}
+
+
+/**
+ * 
+ * @param {String} flag
+ * @returns {Number}
+ */
+function getFlag(flag) {
+  let value = 0;
+  while (process.argv.includes(flag)) {
+    const flagIndex = process.argv.indexOf(flag);
+    process.argv.splice(flagIndex, 1);
+    value += 1;
+  }
+  return value;
+}
+
+
+module.exports = {
+  getFlag,
+  getOption,
+};
\ No newline at end of file
diff --git a/bin/dumpProfiles.js b/bin/dumpProfiles.js
new file mode 100644 (file)
index 0000000..5b249fd
--- /dev/null
@@ -0,0 +1,32 @@
+'use strict';
+
+const DB = require('../src/db');
+const Logger = require('../src/logger');
+const Config = require('../config');
+const config = new Config(process.env.NODE_ENV);
+
+const logger = new Logger(config);
+const db = new DB(logger, config);
+
+
+const identifier = process.argv[2];
+
+if (!identifier) {
+  console.log('missing user');
+  throw new Error('missing argument');
+}
+
+(async () => {
+  await db.initialize();
+  await db.context(async (dbCtx) => {
+    const user =  await db.authenticationGet(dbCtx, identifier);
+
+    const profiles = await db.profilesByIdentifier(dbCtx, identifier);
+    console.log(profiles);
+    if (!user) {
+      console.log('(user does not exist)');
+    }
+  }); // dbCtx
+  console.log('done');
+  await db._closeConnection();
+})();
diff --git a/bin/generate-engine-entity-relation-diagram.sh b/bin/generate-engine-entity-relation-diagram.sh
new file mode 100755 (executable)
index 0000000..cd54251
--- /dev/null
@@ -0,0 +1,18 @@
+#!/bin/bash
+if [[ $# -ne 2 ]]
+then
+       echo "Usage: $(basename "$0") engine schema-version"
+       exit 64 # EX_USAGE
+fi
+engine="$1"
+schema="$2"
+base="$(dirname "$0")/.."
+pwd="$(pwd)"
+src=$(realpath --relative-to "${pwd}" "${base}/src/db/${engine}/sql/schema/${schema}/er.dot")
+dst=$(realpath --relative-to "${pwd}" "${base}/documentation/media/${engine}-er.svg")
+if [[ ! -e "${src}" ]]
+then
+       echo "Missing: ${src}" 1>&2
+       exit 65 # EX_DATAERR
+fi
+dot -Tsvg -o"${dst}" "${src}"
diff --git a/bin/resourceCreate.js b/bin/resourceCreate.js
new file mode 100644 (file)
index 0000000..ae7390a
--- /dev/null
@@ -0,0 +1,46 @@
+'use strict';
+
+const cli = require('./cli-helper');
+const DB = require('../src/db');
+const Logger = require('../src/logger');
+const Config = require('../config');
+const { newSecret } = require('../src/common');
+const config = new Config(process.env.NODE_ENV, false);
+const verbose = cli.getFlag('-v');
+if (!verbose) {
+  config.logger.ignoreBelowLevel = 'info';
+}
+const logger = new Logger(config);
+const db = new DB(logger, config);
+
+
+const resourceId = cli.getOption('-i');
+let secret = cli.getOption('-s');
+const rest = process.argv.slice(2);
+const description = rest.length ? rest.join(' ') : undefined;
+
+(async () => {
+  await db.initialize();
+  try {
+    if (!resourceId) {
+      if (!description || !description.length) {
+        console.log('ERROR: description is required when creating a new resource.');
+        throw new Error('Invalid parameters');
+      }
+      if (!secret) {
+        secret = await newSecret();
+      }
+    }
+
+    await db.context(async (dbCtx) => {
+      const result = await db.resourceUpsert(dbCtx, resourceId, secret, description);
+      console.log(result);
+    });
+  } catch (e) {
+    console.log(e);
+  } finally {
+    await db._closeConnection();
+  }
+})().then(() => {
+  console.log('done');
+});
diff --git a/config/default.js b/config/default.js
new file mode 100644 (file)
index 0000000..55945fd
--- /dev/null
@@ -0,0 +1,99 @@
+'use strict';
+
+// Provide default values for all configuration.
+
+const { name: packageName, version: packageVersion } = require('../package.json');
+const common = require('../src/common');
+const Enum = require('../src/enum');
+const roman = require('@squeep/roman');
+
+const currentYear = (new Date()).getFullYear();
+const romanYearHTML = roman.toRoman(currentYear, true);
+
+const defaultOptions = {
+  // Uniquely identify this instance.
+  nodeId: common.requestId(), // Default to ephemeral ID: easiest for clustered deployments.
+
+  encryptionSecret: '', // No default; set this to a long passphrase or randomness.
+  // This may also be set to an array, if secret needs to be rolled. This needs more documentation.
+
+  // Dingus API Server Framework options.
+  dingus: {
+    // This needs to be the full externally accessible root URL, including any proxyPrefix component.
+    selfBaseUrl: '',
+
+    // trustProxy: true, // If true, trust values of some headers regarding client IP address and protocol.
+    proxyPrefix: '', // Leading path parts to ignore when parsing routes, and include when constructing links, e.g. /indieauth
+  },
+
+  // The terminal portions of API route path endpoints.
+  route: {
+    authorization: 'auth',
+    consent: 'consent',
+    healthcheck: 'healthcheck',
+    introspection: 'introspect',
+    metadata: 'meta',
+    revocation: 'revoke',
+    ticket: 'ticket',
+    token: 'token',
+    userinfo: 'userinfo',
+  },
+
+  // Database options
+  db: {
+    connectionString: '', // e.g. sqlite://path/to/dbfile.sqlite
+    queryLogLevel: undefined, // Set to log queries
+
+    // SQLite specific options
+    sqliteOptimizeAfterChanges: 0, // Number of changes before running pragma optimize, 0 for never
+  },
+
+  // Queue options, currently only for handing off ticket offers
+  queues: {
+    amqp: {
+      url: undefined, // AMQP endpoint, e.g. 'amqp://user:pass@rmq.host:5672'  If not specified, ticket endpoint will be disabled
+      prefix: undefined,
+    },
+    ticketPublishName: 'indieauth.ticket.proffered', // exchange to publish proffered tickets to
+  },
+
+  // Logging options
+  logger: {
+    ignoreBelowLevel: 'info',
+  },
+
+  manager: {
+    codeValidityTimeoutMs: 10 * 60 * 1000,
+    ticketLifespanSeconds: 300,
+    pageTitle: packageName, // title on html pages
+    logoUrl: 'static/logo.svg', // image to go with title
+    footerEntries: [ // common footers on all html pages
+      '<a href="https://git.squeep.com/?p=squeep-indie-auther;a=tree">Development Repository</a>',
+      `<span class="copyright">&copy;<time datetime="${currentYear}">${romanYearHTML}</time></span>`,
+    ],
+    allowLegacyNonPKCE: false, // Whether to process auth requests lacking code challenges
+  },
+
+  chores: {
+    scopeCleanupMs: 0, // how often to clean up unreferenced scopes, 0 for never
+    tokenCleanupMs: 0, // how often to clean up no-longer-valid scopes, 0 for never
+  },
+
+  // Outgoing request UA header. Setting these here to override helper defaults.
+  userAgent: {
+    product: packageName,
+    version: packageVersion,
+    implementation: Enum.Specification,
+  },
+
+  authenticator: {
+    authnEnabled: ['argon2', 'pam'], // Types of authentication to attempt.
+    secureAuthOnly: true, // Require secure transport for authentication.
+    forbiddenPAMIdentifiers: [
+      'root',
+    ],
+  },
+
+};
+
+module.exports = defaultOptions;
diff --git a/config/development.js b/config/development.js
new file mode 100644 (file)
index 0000000..2f69e39
--- /dev/null
@@ -0,0 +1,36 @@
+'use strict';
+module.exports = [
+  {
+    authenticator: {
+      authnEnabled: undefined, // remove all, then set one below
+    },
+  },
+  {
+    encryptionSecret: 'this is not a very good secret',
+    db: {
+      connectionString: `postgresql://${encodeURIComponent('/var/lib/postgresql/14/data')}/indieauther`, // local pg socket
+      queryLogLevel: 'debug',
+    },
+    dingus: {
+      selfBaseUrl: 'https://ia.squeep.com/',
+    },
+    queues: {
+      amqp: {
+        url: 'amqp://guest:guest@rmq.int:5672',
+      },
+    },
+    logger: {
+      ignoreBelowLevel: 'debug',
+    },
+    authenticator: {
+      authnEnabled: ['argon2'],
+    },
+    chores: {
+      scopeCleanupMs: 86400000,
+      tokenCleanupMs: 86400000,
+    },
+    manager: {
+      allowLegacyNonPKCE: true,
+    },
+  },
+];
diff --git a/config/index.js b/config/index.js
new file mode 100644 (file)
index 0000000..79410a6
--- /dev/null
@@ -0,0 +1,24 @@
+'use strict';
+
+const common = require('../src/common');
+
+const defaultEnvironment = 'development';
+const testEnvironment = 'test';
+
+function Config(environment, freeze = true) {
+  environment = environment || defaultEnvironment;
+  const defaultConfig = require('./default');
+  let envConfig = require(`./${environment}`); // eslint-disable-line security/detect-non-literal-require
+  if (!Array.isArray(envConfig)) {
+    envConfig = Array(envConfig);
+  }
+  // We support arrays of config options in env to allow e.g. resetting an existing array
+  const combinedConfig = common.mergeDeep(defaultConfig, ...envConfig, { environment });
+  if (freeze && !environment.includes(testEnvironment)) {
+    /* istanbul ignore next */
+    common.freezeDeep(combinedConfig);
+  }
+  return combinedConfig;
+}
+
+module.exports = Config;
\ No newline at end of file
diff --git a/config/test.js b/config/test.js
new file mode 100644 (file)
index 0000000..b92272f
--- /dev/null
@@ -0,0 +1,16 @@
+'use strict';
+// Configuration used by test suites.
+module.exports = {
+  encryptionSecret: 'not a great secret',
+  dingus: {
+    selfBaseUrl: 'https://example.com/indieauthie/',
+  },
+  db: {
+    queryLogLevel: 'debug',
+  },
+  queues: {
+    amqp: {
+      url: 'ampq://localhost:5432',
+    },
+  },
+};
diff --git a/documentation/media/consent-page.png b/documentation/media/consent-page.png
new file mode 100644 (file)
index 0000000..fab0b42
Binary files /dev/null and b/documentation/media/consent-page.png differ
diff --git a/documentation/media/postgres-er.svg b/documentation/media/postgres-er.svg
new file mode 100644 (file)
index 0000000..670582a
--- /dev/null
@@ -0,0 +1,184 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
+ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<!-- Generated by graphviz version 2.50.0 (20211204.2007)
+ -->
+<!-- Title: indieAutherERD Pages: 1 -->
+<svg width="656pt" height="668pt"
+ viewBox="0.00 0.00 656.00 668.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 664)">
+<title>indieAutherERD</title>
+<polygon fill="white" stroke="transparent" points="-4,4 -4,-664 652,-664 652,4 -4,4"/>
+<text text-anchor="middle" x="324" y="-635.2" font-family="Times,serif" font-size="26.00">IndieAuther Entity&#45;Relations</text>
+<text text-anchor="middle" x="324" y="-606.2" font-family="Times,serif" font-size="26.00">Postgres</text>
+<text text-anchor="middle" x="324" y="-577.2" font-family="Times,serif" font-size="26.00">Schema 1.0.0</text>
+<!-- token -->
+<g id="node1" class="node">
+<title>token</title>
+<polygon fill="lightblue" stroke="transparent" points="362.5,-501.5 362.5,-524.5 488.5,-524.5 488.5,-501.5 362.5,-501.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="363.5,-502.5 363.5,-523.5 487.5,-523.5 487.5,-502.5 363.5,-502.5"/>
+<text text-anchor="start" x="398.5" y="-509.3" font-family="Times,serif" font-size="14.00">TOKEN</text>
+<polygon fill="none" stroke="black" points="362.5,-480.5 362.5,-501.5 488.5,-501.5 488.5,-480.5 362.5,-480.5"/>
+<text text-anchor="start" x="398.5" y="-487.3" font-family="Times,serif" font-size="14.00">code_id</text>
+<polygon fill="none" stroke="black" points="362.5,-459.5 362.5,-480.5 488.5,-480.5 488.5,-459.5 362.5,-459.5"/>
+<text text-anchor="start" x="392" y="-466.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="362.5,-438.5 362.5,-459.5 488.5,-459.5 488.5,-438.5 362.5,-438.5"/>
+<text text-anchor="start" x="398" y="-445.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="362.5,-417.5 362.5,-438.5 488.5,-438.5 488.5,-417.5 362.5,-417.5"/>
+<text text-anchor="start" x="399" y="-424.3" font-family="Times,serif" font-size="14.00">expires</text>
+<polygon fill="none" stroke="black" points="362.5,-396.5 362.5,-417.5 488.5,-417.5 488.5,-396.5 362.5,-396.5"/>
+<text text-anchor="start" x="369.5" y="-403.3" font-family="Times,serif" font-size="14.00">refresh_expires</text>
+<polygon fill="none" stroke="black" points="362.5,-375.5 362.5,-396.5 488.5,-396.5 488.5,-375.5 362.5,-375.5"/>
+<text text-anchor="start" x="390.5" y="-382.3" font-family="Times,serif" font-size="14.00">refreshed</text>
+<polygon fill="none" stroke="black" points="362.5,-354.5 362.5,-375.5 488.5,-375.5 488.5,-354.5 362.5,-354.5"/>
+<text text-anchor="start" x="395" y="-361.3" font-family="Times,serif" font-size="14.00">duration</text>
+<polygon fill="none" stroke="black" points="362.5,-333.5 362.5,-354.5 488.5,-354.5 488.5,-333.5 362.5,-333.5"/>
+<text text-anchor="start" x="365.5" y="-340.3" font-family="Times,serif" font-size="14.00">refresh_duration</text>
+<polygon fill="none" stroke="black" points="362.5,-312.5 362.5,-333.5 488.5,-333.5 488.5,-312.5 362.5,-312.5"/>
+<text text-anchor="start" x="376" y="-319.3" font-family="Times,serif" font-size="14.00">refresh_count</text>
+<polygon fill="none" stroke="black" points="362.5,-291.5 362.5,-312.5 488.5,-312.5 488.5,-291.5 362.5,-291.5"/>
+<text text-anchor="start" x="387.5" y="-298.3" font-family="Times,serif" font-size="14.00">is_revoked</text>
+<polygon fill="none" stroke="black" points="362.5,-270.5 362.5,-291.5 488.5,-291.5 488.5,-270.5 362.5,-270.5"/>
+<text text-anchor="start" x="396" y="-277.3" font-family="Times,serif" font-size="14.00">is_token</text>
+<polygon fill="none" stroke="black" points="362.5,-249.5 362.5,-270.5 488.5,-270.5 488.5,-249.5 362.5,-249.5"/>
+<text text-anchor="start" x="395" y="-256.3" font-family="Times,serif" font-size="14.00">client_id</text>
+<polygon fill="none" stroke="black" points="362.5,-228.5 362.5,-249.5 488.5,-249.5 488.5,-228.5 362.5,-228.5"/>
+<text text-anchor="start" x="394" y="-235.3" font-family="Times,serif" font-size="14.00">resource</text>
+<polygon fill="none" stroke="black" points="362.5,-207.5 362.5,-228.5 488.5,-228.5 488.5,-207.5 362.5,-207.5"/>
+<text text-anchor="start" x="383" y="-214.3" font-family="Times,serif" font-size="14.00">profile_data</text>
+</g>
+<!-- token_scope -->
+<g id="node4" class="node">
+<title>token_scope</title>
+<polygon fill="lightblue" stroke="transparent" points="528,-315.5 528,-338.5 648,-338.5 648,-315.5 528,-315.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="529,-316.5 529,-337.5 647,-337.5 647,-316.5 529,-316.5"/>
+<text text-anchor="start" x="532" y="-323.3" font-family="Times,serif" font-size="14.00">TOKEN_SCOPE</text>
+<polygon fill="none" stroke="black" points="528,-294.5 528,-315.5 648,-315.5 648,-294.5 528,-294.5"/>
+<text text-anchor="start" x="561" y="-301.3" font-family="Times,serif" font-size="14.00">code_id</text>
+<polygon fill="none" stroke="black" points="528,-273.5 528,-294.5 648,-294.5 648,-273.5 528,-273.5"/>
+<text text-anchor="start" x="557.5" y="-280.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+</g>
+<!-- token&#45;&gt;token_scope -->
+<g id="edge2" class="edge">
+<title>token:pk_code_id&#45;&gt;token_scope:fk_code_id</title>
+<path fill="none" stroke="black" d="M489.5,-491.5C570.54,-491.5 454.5,-320.92 517.83,-305.6"/>
+<polygon fill="black" stroke="black" points="518.06,-305.57 528.48,-308.97 523.03,-305.04 528,-304.5 528,-304.5 528,-304.5 523.03,-305.04 527.52,-300.03 518.06,-305.57 518.06,-305.57"/>
+</g>
+<!-- profile -->
+<g id="node2" class="node">
+<title>profile</title>
+<polygon fill="lightblue" stroke="transparent" points="206.5,-355.5 206.5,-378.5 299.5,-378.5 299.5,-355.5 206.5,-355.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="207.5,-356.5 207.5,-377.5 298.5,-377.5 298.5,-356.5 207.5,-356.5"/>
+<text text-anchor="start" x="219.5" y="-363.3" font-family="Times,serif" font-size="14.00">PROFILE</text>
+<polygon fill="none" stroke="black" points="206.5,-334.5 206.5,-355.5 299.5,-355.5 299.5,-334.5 206.5,-334.5"/>
+<text text-anchor="start" x="219.5" y="-341.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="206.5,-313.5 206.5,-334.5 299.5,-334.5 299.5,-313.5 206.5,-313.5"/>
+<text text-anchor="start" x="209.5" y="-320.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
+<polygon fill="none" stroke="black" points="206.5,-292.5 206.5,-313.5 299.5,-313.5 299.5,-292.5 206.5,-292.5"/>
+<text text-anchor="start" x="229.5" y="-299.3" font-family="Times,serif" font-size="14.00">profile</text>
+</g>
+<!-- profile&#45;&gt;token -->
+<g id="edge1" class="edge">
+<title>profile:pk_profile_id&#45;&gt;token:fk_profile_id</title>
+<path fill="none" stroke="black" d="M300.5,-345.5C358.57,-345.5 306.81,-455.82 351.58,-469.18"/>
+<polygon fill="black" stroke="black" points="351.59,-469.18 360.91,-474.96 356.54,-469.84 361.5,-470.5 361.5,-470.5 361.5,-470.5 356.54,-469.84 362.09,-466.04 351.59,-469.18 351.59,-469.18"/>
+</g>
+<!-- profile_scope -->
+<g id="node6" class="node">
+<title>profile_scope</title>
+<polygon fill="lightblue" stroke="transparent" points="359.5,-166.5 359.5,-189.5 492.5,-189.5 492.5,-166.5 359.5,-166.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="360.5,-167.5 360.5,-188.5 491.5,-188.5 491.5,-167.5 360.5,-167.5"/>
+<text text-anchor="start" x="363.5" y="-174.3" font-family="Times,serif" font-size="14.00">PROFILE_SCOPE</text>
+<polygon fill="none" stroke="black" points="359.5,-145.5 359.5,-166.5 492.5,-166.5 492.5,-145.5 359.5,-145.5"/>
+<text text-anchor="start" x="392.5" y="-152.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="359.5,-124.5 359.5,-145.5 492.5,-145.5 492.5,-124.5 359.5,-124.5"/>
+<text text-anchor="start" x="395.5" y="-131.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+</g>
+<!-- profile&#45;&gt;profile_scope -->
+<g id="edge5" class="edge">
+<title>profile:pk_profile_id&#45;&gt;profile_scope:fk_profile_id</title>
+<path fill="none" stroke="black" d="M300.5,-345.5C385.06,-345.5 280.42,-171.48 348.72,-156.52"/>
+<polygon fill="black" stroke="black" points="349.05,-156.49 359.45,-159.98 354.02,-155.99 359,-155.5 359,-155.5 359,-155.5 354.02,-155.99 358.55,-151.02 349.05,-156.49 349.05,-156.49"/>
+</g>
+<!-- scope -->
+<g id="node3" class="node">
+<title>scope</title>
+<polygon fill="lightblue" stroke="transparent" points="182.5,-125.5 182.5,-148.5 323.5,-148.5 323.5,-125.5 182.5,-125.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="183.5,-126.5 183.5,-147.5 322.5,-147.5 322.5,-126.5 183.5,-126.5"/>
+<text text-anchor="start" x="227" y="-133.3" font-family="Times,serif" font-size="14.00">SCOPE</text>
+<polygon fill="none" stroke="black" points="182.5,-104.5 182.5,-125.5 323.5,-125.5 323.5,-104.5 182.5,-104.5"/>
+<text text-anchor="start" x="222.5" y="-111.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+<polygon fill="none" stroke="black" points="182.5,-83.5 182.5,-104.5 323.5,-104.5 323.5,-83.5 182.5,-83.5"/>
+<text text-anchor="start" x="232.5" y="-90.3" font-family="Times,serif" font-size="14.00">scope</text>
+<polygon fill="none" stroke="black" points="182.5,-62.5 182.5,-83.5 323.5,-83.5 323.5,-62.5 182.5,-62.5"/>
+<text text-anchor="start" x="212.5" y="-69.3" font-family="Times,serif" font-size="14.00">description</text>
+<polygon fill="none" stroke="black" points="182.5,-41.5 182.5,-62.5 323.5,-62.5 323.5,-41.5 182.5,-41.5"/>
+<text text-anchor="start" x="213.5" y="-48.3" font-family="Times,serif" font-size="14.00">application</text>
+<polygon fill="none" stroke="black" points="182.5,-20.5 182.5,-41.5 323.5,-41.5 323.5,-20.5 182.5,-20.5"/>
+<text text-anchor="start" x="204.5" y="-27.3" font-family="Times,serif" font-size="14.00">is_permanent</text>
+<polygon fill="none" stroke="black" points="182.5,0.5 182.5,-20.5 323.5,-20.5 323.5,0.5 182.5,0.5"/>
+<text text-anchor="start" x="185.5" y="-6.3" font-family="Times,serif" font-size="14.00">is_manually_added</text>
+</g>
+<!-- scope&#45;&gt;token_scope -->
+<g id="edge3" class="edge">
+<title>scope:pk_scope_id&#45;&gt;token_scope:fk_scope_id</title>
+<path fill="none" stroke="black" d="M323,-115.5C398.11,-115.5 433.59,-68.28 492,-115.5 548.48,-161.16 461,-271.95 518.03,-282.66"/>
+<polygon fill="black" stroke="black" points="518.03,-282.66 527.62,-287.98 523.02,-283.08 528,-283.5 528,-283.5 528,-283.5 523.02,-283.08 528.38,-279.02 518.03,-282.66 518.03,-282.66"/>
+</g>
+<!-- scope&#45;&gt;profile_scope -->
+<g id="edge6" class="edge">
+<title>scope:pk_scope_id&#45;&gt;profile_scope:fk_scope_id</title>
+<path fill="none" stroke="black" d="M323,-115.5C336.99,-115.5 340.05,-126.87 349.09,-132.02"/>
+<polygon fill="black" stroke="black" points="349.3,-132.07 357.91,-138.86 354.15,-133.28 359,-134.5 359,-134.5 359,-134.5 354.15,-133.28 360.09,-130.14 349.3,-132.07 349.3,-132.07"/>
+</g>
+<!-- authentication -->
+<g id="node5" class="node">
+<title>authentication</title>
+<polygon fill="lightblue" stroke="transparent" points="0,-333.5 0,-356.5 146,-356.5 146,-333.5 0,-333.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="1,-334.5 1,-355.5 145,-355.5 145,-334.5 1,-334.5"/>
+<text text-anchor="start" x="4" y="-341.3" font-family="Times,serif" font-size="14.00">AUTHENTICATION</text>
+<polygon fill="none" stroke="black" points="0,-312.5 0,-333.5 146,-333.5 146,-312.5 0,-312.5"/>
+<text text-anchor="start" x="29.5" y="-319.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
+<polygon fill="none" stroke="black" points="0,-291.5 0,-312.5 146,-312.5 146,-291.5 0,-291.5"/>
+<text text-anchor="start" x="45.5" y="-298.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="0,-270.5 0,-291.5 146,-291.5 146,-270.5 0,-270.5"/>
+<text text-anchor="start" x="6.5" y="-277.3" font-family="Times,serif" font-size="14.00">last_authenticated</text>
+<polygon fill="none" stroke="black" points="0,-249.5 0,-270.5 146,-270.5 146,-249.5 0,-249.5"/>
+<text text-anchor="start" x="39.5" y="-256.3" font-family="Times,serif" font-size="14.00">identifier</text>
+<polygon fill="none" stroke="black" points="0,-228.5 0,-249.5 146,-249.5 146,-228.5 0,-228.5"/>
+<text text-anchor="start" x="36.5" y="-235.3" font-family="Times,serif" font-size="14.00">credential</text>
+</g>
+<!-- authentication&#45;&gt;profile -->
+<g id="edge4" class="edge">
+<title>authentication:pk_identifier_id&#45;&gt;profile:fk_identifier_id</title>
+<path fill="none" stroke="black" d="M146,-323.5C168.73,-323.5 176.8,-323.5 195.41,-323.5"/>
+<polygon fill="black" stroke="black" points="195.5,-323.5 205.5,-328 200.5,-323.5 205.5,-323.5 205.5,-323.5 205.5,-323.5 200.5,-323.5 205.5,-319 195.5,-323.5 195.5,-323.5"/>
+</g>
+<!-- resource -->
+<g id="node7" class="node">
+<title>resource</title>
+<polygon fill="lightblue" stroke="transparent" points="26,-458.5 26,-481.5 120,-481.5 120,-458.5 26,-458.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="27,-459.5 27,-480.5 119,-480.5 119,-459.5 27,-459.5"/>
+<text text-anchor="start" x="30" y="-466.3" font-family="Times,serif" font-size="14.00">RESOURCE</text>
+<polygon fill="none" stroke="black" points="26,-437.5 26,-458.5 120,-458.5 120,-437.5 26,-437.5"/>
+<text text-anchor="start" x="31.5" y="-444.3" font-family="Times,serif" font-size="14.00">resource_id</text>
+<polygon fill="none" stroke="black" points="26,-416.5 26,-437.5 120,-437.5 120,-416.5 26,-416.5"/>
+<text text-anchor="start" x="32.5" y="-423.3" font-family="Times,serif" font-size="14.00">description</text>
+<polygon fill="none" stroke="black" points="26,-395.5 26,-416.5 120,-416.5 120,-395.5 26,-395.5"/>
+<text text-anchor="start" x="45.5" y="-402.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="26,-374.5 26,-395.5 120,-395.5 120,-374.5 26,-374.5"/>
+<text text-anchor="start" x="50.5" y="-381.3" font-family="Times,serif" font-size="14.00">secret</text>
+</g>
+<!-- almanac -->
+<g id="node8" class="node">
+<title>almanac</title>
+<polygon fill="lightblue" stroke="transparent" points="31,-541.5 31,-564.5 115,-564.5 115,-541.5 31,-541.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="32,-542.5 32,-563.5 114,-563.5 114,-542.5 32,-542.5"/>
+<text text-anchor="start" x="35" y="-549.3" font-family="Times,serif" font-size="14.00">ALMANAC</text>
+<polygon fill="none" stroke="black" points="31,-520.5 31,-541.5 115,-541.5 115,-520.5 31,-520.5"/>
+<text text-anchor="start" x="53" y="-527.3" font-family="Times,serif" font-size="14.00">event</text>
+<polygon fill="none" stroke="black" points="31,-499.5 31,-520.5 115,-520.5 115,-499.5 31,-499.5"/>
+<text text-anchor="start" x="57" y="-506.3" font-family="Times,serif" font-size="14.00">date</text>
+</g>
+</g>
+</svg>
diff --git a/documentation/media/sqlite-er.svg b/documentation/media/sqlite-er.svg
new file mode 100644 (file)
index 0000000..8d036ed
--- /dev/null
@@ -0,0 +1,183 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
+ "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<!-- Generated by graphviz version 2.50.0 (20211204.2007)
+ -->
+<!-- Title: indieAutherERD Pages: 1 -->
+<svg width="656pt" height="639pt"
+ viewBox="0.00 0.00 656.00 639.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 635)">
+<title>indieAutherERD</title>
+<polygon fill="white" stroke="transparent" points="-4,4 -4,-635 652,-635 652,4 -4,4"/>
+<text text-anchor="middle" x="324" y="-606.2" font-family="Times,serif" font-size="26.00">IndieAuther Entity&#45;RelationsSQLite</text>
+<text text-anchor="middle" x="324" y="-577.2" font-family="Times,serif" font-size="26.00">Schema 1.0.0</text>
+<!-- token -->
+<g id="node1" class="node">
+<title>token</title>
+<polygon fill="lightblue" stroke="transparent" points="362.5,-501.5 362.5,-524.5 488.5,-524.5 488.5,-501.5 362.5,-501.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="363.5,-502.5 363.5,-523.5 487.5,-523.5 487.5,-502.5 363.5,-502.5"/>
+<text text-anchor="start" x="398.5" y="-509.3" font-family="Times,serif" font-size="14.00">TOKEN</text>
+<polygon fill="none" stroke="black" points="362.5,-480.5 362.5,-501.5 488.5,-501.5 488.5,-480.5 362.5,-480.5"/>
+<text text-anchor="start" x="398.5" y="-487.3" font-family="Times,serif" font-size="14.00">code_id</text>
+<polygon fill="none" stroke="black" points="362.5,-459.5 362.5,-480.5 488.5,-480.5 488.5,-459.5 362.5,-459.5"/>
+<text text-anchor="start" x="392" y="-466.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="362.5,-438.5 362.5,-459.5 488.5,-459.5 488.5,-438.5 362.5,-438.5"/>
+<text text-anchor="start" x="398" y="-445.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="362.5,-417.5 362.5,-438.5 488.5,-438.5 488.5,-417.5 362.5,-417.5"/>
+<text text-anchor="start" x="399" y="-424.3" font-family="Times,serif" font-size="14.00">expires</text>
+<polygon fill="none" stroke="black" points="362.5,-396.5 362.5,-417.5 488.5,-417.5 488.5,-396.5 362.5,-396.5"/>
+<text text-anchor="start" x="369.5" y="-403.3" font-family="Times,serif" font-size="14.00">refresh_expires</text>
+<polygon fill="none" stroke="black" points="362.5,-375.5 362.5,-396.5 488.5,-396.5 488.5,-375.5 362.5,-375.5"/>
+<text text-anchor="start" x="390.5" y="-382.3" font-family="Times,serif" font-size="14.00">refreshed</text>
+<polygon fill="none" stroke="black" points="362.5,-354.5 362.5,-375.5 488.5,-375.5 488.5,-354.5 362.5,-354.5"/>
+<text text-anchor="start" x="395" y="-361.3" font-family="Times,serif" font-size="14.00">duration</text>
+<polygon fill="none" stroke="black" points="362.5,-333.5 362.5,-354.5 488.5,-354.5 488.5,-333.5 362.5,-333.5"/>
+<text text-anchor="start" x="365.5" y="-340.3" font-family="Times,serif" font-size="14.00">refresh_duration</text>
+<polygon fill="none" stroke="black" points="362.5,-312.5 362.5,-333.5 488.5,-333.5 488.5,-312.5 362.5,-312.5"/>
+<text text-anchor="start" x="376" y="-319.3" font-family="Times,serif" font-size="14.00">refresh_count</text>
+<polygon fill="none" stroke="black" points="362.5,-291.5 362.5,-312.5 488.5,-312.5 488.5,-291.5 362.5,-291.5"/>
+<text text-anchor="start" x="387.5" y="-298.3" font-family="Times,serif" font-size="14.00">is_revoked</text>
+<polygon fill="none" stroke="black" points="362.5,-270.5 362.5,-291.5 488.5,-291.5 488.5,-270.5 362.5,-270.5"/>
+<text text-anchor="start" x="396" y="-277.3" font-family="Times,serif" font-size="14.00">is_token</text>
+<polygon fill="none" stroke="black" points="362.5,-249.5 362.5,-270.5 488.5,-270.5 488.5,-249.5 362.5,-249.5"/>
+<text text-anchor="start" x="395" y="-256.3" font-family="Times,serif" font-size="14.00">client_id</text>
+<polygon fill="none" stroke="black" points="362.5,-228.5 362.5,-249.5 488.5,-249.5 488.5,-228.5 362.5,-228.5"/>
+<text text-anchor="start" x="394" y="-235.3" font-family="Times,serif" font-size="14.00">resource</text>
+<polygon fill="none" stroke="black" points="362.5,-207.5 362.5,-228.5 488.5,-228.5 488.5,-207.5 362.5,-207.5"/>
+<text text-anchor="start" x="383" y="-214.3" font-family="Times,serif" font-size="14.00">profile_data</text>
+</g>
+<!-- token_scope -->
+<g id="node4" class="node">
+<title>token_scope</title>
+<polygon fill="lightblue" stroke="transparent" points="528,-315.5 528,-338.5 648,-338.5 648,-315.5 528,-315.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="529,-316.5 529,-337.5 647,-337.5 647,-316.5 529,-316.5"/>
+<text text-anchor="start" x="532" y="-323.3" font-family="Times,serif" font-size="14.00">TOKEN_SCOPE</text>
+<polygon fill="none" stroke="black" points="528,-294.5 528,-315.5 648,-315.5 648,-294.5 528,-294.5"/>
+<text text-anchor="start" x="561" y="-301.3" font-family="Times,serif" font-size="14.00">code_id</text>
+<polygon fill="none" stroke="black" points="528,-273.5 528,-294.5 648,-294.5 648,-273.5 528,-273.5"/>
+<text text-anchor="start" x="557.5" y="-280.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+</g>
+<!-- token&#45;&gt;token_scope -->
+<g id="edge2" class="edge">
+<title>token:pk_code_id&#45;&gt;token_scope:fk_code_id</title>
+<path fill="none" stroke="black" d="M489.5,-491.5C570.54,-491.5 454.5,-320.92 517.83,-305.6"/>
+<polygon fill="black" stroke="black" points="518.06,-305.57 528.48,-308.97 523.03,-305.04 528,-304.5 528,-304.5 528,-304.5 523.03,-305.04 527.52,-300.03 518.06,-305.57 518.06,-305.57"/>
+</g>
+<!-- profile -->
+<g id="node2" class="node">
+<title>profile</title>
+<polygon fill="lightblue" stroke="transparent" points="206.5,-355.5 206.5,-378.5 299.5,-378.5 299.5,-355.5 206.5,-355.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="207.5,-356.5 207.5,-377.5 298.5,-377.5 298.5,-356.5 207.5,-356.5"/>
+<text text-anchor="start" x="219.5" y="-363.3" font-family="Times,serif" font-size="14.00">PROFILE</text>
+<polygon fill="none" stroke="black" points="206.5,-334.5 206.5,-355.5 299.5,-355.5 299.5,-334.5 206.5,-334.5"/>
+<text text-anchor="start" x="219.5" y="-341.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="206.5,-313.5 206.5,-334.5 299.5,-334.5 299.5,-313.5 206.5,-313.5"/>
+<text text-anchor="start" x="209.5" y="-320.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
+<polygon fill="none" stroke="black" points="206.5,-292.5 206.5,-313.5 299.5,-313.5 299.5,-292.5 206.5,-292.5"/>
+<text text-anchor="start" x="229.5" y="-299.3" font-family="Times,serif" font-size="14.00">profile</text>
+</g>
+<!-- profile&#45;&gt;token -->
+<g id="edge1" class="edge">
+<title>profile:pk_profile_id&#45;&gt;token:fk_profile_id</title>
+<path fill="none" stroke="black" d="M300.5,-345.5C358.57,-345.5 306.81,-455.82 351.58,-469.18"/>
+<polygon fill="black" stroke="black" points="351.59,-469.18 360.91,-474.96 356.54,-469.84 361.5,-470.5 361.5,-470.5 361.5,-470.5 356.54,-469.84 362.09,-466.04 351.59,-469.18 351.59,-469.18"/>
+</g>
+<!-- profile_scope -->
+<g id="node6" class="node">
+<title>profile_scope</title>
+<polygon fill="lightblue" stroke="transparent" points="359.5,-166.5 359.5,-189.5 492.5,-189.5 492.5,-166.5 359.5,-166.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="360.5,-167.5 360.5,-188.5 491.5,-188.5 491.5,-167.5 360.5,-167.5"/>
+<text text-anchor="start" x="363.5" y="-174.3" font-family="Times,serif" font-size="14.00">PROFILE_SCOPE</text>
+<polygon fill="none" stroke="black" points="359.5,-145.5 359.5,-166.5 492.5,-166.5 492.5,-145.5 359.5,-145.5"/>
+<text text-anchor="start" x="392.5" y="-152.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="359.5,-124.5 359.5,-145.5 492.5,-145.5 492.5,-124.5 359.5,-124.5"/>
+<text text-anchor="start" x="395.5" y="-131.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+</g>
+<!-- profile&#45;&gt;profile_scope -->
+<g id="edge5" class="edge">
+<title>profile:pk_profile_id&#45;&gt;profile_scope:fk_profile_id</title>
+<path fill="none" stroke="black" d="M300.5,-345.5C385.06,-345.5 280.42,-171.48 348.72,-156.52"/>
+<polygon fill="black" stroke="black" points="349.05,-156.49 359.45,-159.98 354.02,-155.99 359,-155.5 359,-155.5 359,-155.5 354.02,-155.99 358.55,-151.02 349.05,-156.49 349.05,-156.49"/>
+</g>
+<!-- scope -->
+<g id="node3" class="node">
+<title>scope</title>
+<polygon fill="lightblue" stroke="transparent" points="182.5,-125.5 182.5,-148.5 323.5,-148.5 323.5,-125.5 182.5,-125.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="183.5,-126.5 183.5,-147.5 322.5,-147.5 322.5,-126.5 183.5,-126.5"/>
+<text text-anchor="start" x="227" y="-133.3" font-family="Times,serif" font-size="14.00">SCOPE</text>
+<polygon fill="none" stroke="black" points="182.5,-104.5 182.5,-125.5 323.5,-125.5 323.5,-104.5 182.5,-104.5"/>
+<text text-anchor="start" x="222.5" y="-111.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+<polygon fill="none" stroke="black" points="182.5,-83.5 182.5,-104.5 323.5,-104.5 323.5,-83.5 182.5,-83.5"/>
+<text text-anchor="start" x="232.5" y="-90.3" font-family="Times,serif" font-size="14.00">scope</text>
+<polygon fill="none" stroke="black" points="182.5,-62.5 182.5,-83.5 323.5,-83.5 323.5,-62.5 182.5,-62.5"/>
+<text text-anchor="start" x="212.5" y="-69.3" font-family="Times,serif" font-size="14.00">description</text>
+<polygon fill="none" stroke="black" points="182.5,-41.5 182.5,-62.5 323.5,-62.5 323.5,-41.5 182.5,-41.5"/>
+<text text-anchor="start" x="213.5" y="-48.3" font-family="Times,serif" font-size="14.00">application</text>
+<polygon fill="none" stroke="black" points="182.5,-20.5 182.5,-41.5 323.5,-41.5 323.5,-20.5 182.5,-20.5"/>
+<text text-anchor="start" x="204.5" y="-27.3" font-family="Times,serif" font-size="14.00">is_permanent</text>
+<polygon fill="none" stroke="black" points="182.5,0.5 182.5,-20.5 323.5,-20.5 323.5,0.5 182.5,0.5"/>
+<text text-anchor="start" x="185.5" y="-6.3" font-family="Times,serif" font-size="14.00">is_manually_added</text>
+</g>
+<!-- scope&#45;&gt;token_scope -->
+<g id="edge3" class="edge">
+<title>scope:pk_scope_id&#45;&gt;token_scope:fk_scope_id</title>
+<path fill="none" stroke="black" d="M323,-115.5C398.11,-115.5 433.59,-68.28 492,-115.5 548.48,-161.16 461,-271.95 518.03,-282.66"/>
+<polygon fill="black" stroke="black" points="518.03,-282.66 527.62,-287.98 523.02,-283.08 528,-283.5 528,-283.5 528,-283.5 523.02,-283.08 528.38,-279.02 518.03,-282.66 518.03,-282.66"/>
+</g>
+<!-- scope&#45;&gt;profile_scope -->
+<g id="edge6" class="edge">
+<title>scope:pk_scope_id&#45;&gt;profile_scope:fk_scope_id</title>
+<path fill="none" stroke="black" d="M323,-115.5C336.99,-115.5 340.05,-126.87 349.09,-132.02"/>
+<polygon fill="black" stroke="black" points="349.3,-132.07 357.91,-138.86 354.15,-133.28 359,-134.5 359,-134.5 359,-134.5 354.15,-133.28 360.09,-130.14 349.3,-132.07 349.3,-132.07"/>
+</g>
+<!-- authentication -->
+<g id="node5" class="node">
+<title>authentication</title>
+<polygon fill="lightblue" stroke="transparent" points="0,-333.5 0,-356.5 146,-356.5 146,-333.5 0,-333.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="1,-334.5 1,-355.5 145,-355.5 145,-334.5 1,-334.5"/>
+<text text-anchor="start" x="4" y="-341.3" font-family="Times,serif" font-size="14.00">AUTHENTICATION</text>
+<polygon fill="none" stroke="black" points="0,-312.5 0,-333.5 146,-333.5 146,-312.5 0,-312.5"/>
+<text text-anchor="start" x="29.5" y="-319.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
+<polygon fill="none" stroke="black" points="0,-291.5 0,-312.5 146,-312.5 146,-291.5 0,-291.5"/>
+<text text-anchor="start" x="45.5" y="-298.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="0,-270.5 0,-291.5 146,-291.5 146,-270.5 0,-270.5"/>
+<text text-anchor="start" x="6.5" y="-277.3" font-family="Times,serif" font-size="14.00">last_authenticated</text>
+<polygon fill="none" stroke="black" points="0,-249.5 0,-270.5 146,-270.5 146,-249.5 0,-249.5"/>
+<text text-anchor="start" x="39.5" y="-256.3" font-family="Times,serif" font-size="14.00">identifier</text>
+<polygon fill="none" stroke="black" points="0,-228.5 0,-249.5 146,-249.5 146,-228.5 0,-228.5"/>
+<text text-anchor="start" x="36.5" y="-235.3" font-family="Times,serif" font-size="14.00">credential</text>
+</g>
+<!-- authentication&#45;&gt;profile -->
+<g id="edge4" class="edge">
+<title>authentication:pk_identifier_id&#45;&gt;profile:fk_identifier_id</title>
+<path fill="none" stroke="black" d="M146,-323.5C168.73,-323.5 176.8,-323.5 195.41,-323.5"/>
+<polygon fill="black" stroke="black" points="195.5,-323.5 205.5,-328 200.5,-323.5 205.5,-323.5 205.5,-323.5 205.5,-323.5 200.5,-323.5 205.5,-319 195.5,-323.5 195.5,-323.5"/>
+</g>
+<!-- resource -->
+<g id="node7" class="node">
+<title>resource</title>
+<polygon fill="lightblue" stroke="transparent" points="26,-458.5 26,-481.5 120,-481.5 120,-458.5 26,-458.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="27,-459.5 27,-480.5 119,-480.5 119,-459.5 27,-459.5"/>
+<text text-anchor="start" x="30" y="-466.3" font-family="Times,serif" font-size="14.00">RESOURCE</text>
+<polygon fill="none" stroke="black" points="26,-437.5 26,-458.5 120,-458.5 120,-437.5 26,-437.5"/>
+<text text-anchor="start" x="31.5" y="-444.3" font-family="Times,serif" font-size="14.00">resource_id</text>
+<polygon fill="none" stroke="black" points="26,-416.5 26,-437.5 120,-437.5 120,-416.5 26,-416.5"/>
+<text text-anchor="start" x="32.5" y="-423.3" font-family="Times,serif" font-size="14.00">description</text>
+<polygon fill="none" stroke="black" points="26,-395.5 26,-416.5 120,-416.5 120,-395.5 26,-395.5"/>
+<text text-anchor="start" x="45.5" y="-402.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="26,-374.5 26,-395.5 120,-395.5 120,-374.5 26,-374.5"/>
+<text text-anchor="start" x="50.5" y="-381.3" font-family="Times,serif" font-size="14.00">secret</text>
+</g>
+<!-- almanac -->
+<g id="node8" class="node">
+<title>almanac</title>
+<polygon fill="lightblue" stroke="transparent" points="31,-541.5 31,-564.5 115,-564.5 115,-541.5 31,-541.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="32,-542.5 32,-563.5 114,-563.5 114,-542.5 32,-542.5"/>
+<text text-anchor="start" x="35" y="-549.3" font-family="Times,serif" font-size="14.00">ALMANAC</text>
+<polygon fill="none" stroke="black" points="31,-520.5 31,-541.5 115,-541.5 115,-520.5 31,-520.5"/>
+<text text-anchor="start" x="53" y="-527.3" font-family="Times,serif" font-size="14.00">event</text>
+<polygon fill="none" stroke="black" points="31,-499.5 31,-520.5 115,-520.5 115,-499.5 31,-499.5"/>
+<text text-anchor="start" x="51.5" y="-506.3" font-family="Times,serif" font-size="14.00">epoch</text>
+</g>
+</g>
+</svg>
diff --git a/documentation/media/ticket-page.png b/documentation/media/ticket-page.png
new file mode 100644 (file)
index 0000000..5865180
Binary files /dev/null and b/documentation/media/ticket-page.png differ
diff --git a/package-lock.json b/package-lock.json
new file mode 100644 (file)
index 0000000..79c2b74
--- /dev/null
@@ -0,0 +1,9787 @@
+{
+  "name": "@squeep/indie-auther",
+  "version": "1.0.0",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "@squeep/indie-auther",
+      "version": "1.0.0",
+      "license": "ISC",
+      "dependencies": {
+        "@squeep/amqp-helper": "git+https://git.squeep.com/squeep-amqp-helper#v1.0.0",
+        "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.2.9",
+        "@squeep/authentication-module": "git+https://git.squeep.com/squeep-authentication-module/#v1.2.12",
+        "@squeep/chores": "git+https://git.squeep.com/squeep-chores/#v1.0.0",
+        "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.4.0",
+        "@squeep/indieauth-helper": "git+https://git.squeep.com/squeep-indieauth-helper/#v1.2.2",
+        "@squeep/logger-json-console": "git+https://git.squeep.com/squeep-logger-json-console#v1.0.2",
+        "@squeep/mystery-box": "^1.2.0",
+        "@squeep/resource-authentication-module": "git+https://git.squeep.com/squeep-resource-authentication-module#v1.0.0",
+        "@squeep/roman": "^1.0.0",
+        "@squeep/web-linking": "^1.0.7",
+        "better-sqlite3": "^8.0.1",
+        "pg-promise": "^10.15.4",
+        "uuid": "^9.0.0"
+      },
+      "devDependencies": {
+        "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v1.0.0",
+        "eslint": "^8.30.0",
+        "eslint-plugin-node": "^11.1.0",
+        "eslint-plugin-promise": "^6.1.1",
+        "eslint-plugin-security": "^1.5.0",
+        "eslint-plugin-sonarjs": "^0.17.0",
+        "html-minifier-lint": "^2.0.0",
+        "mocha": "^10.2.0",
+        "mocha-steps": "^1.3.0",
+        "nyc": "^15.1.0",
+        "pre-commit": "^1.2.2",
+        "sinon": "^15.0.1"
+      },
+      "engines": {
+        "node": ">=14.0"
+      }
+    },
+    "node_modules/@acuminous/bitsyntax": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/@acuminous/bitsyntax/-/bitsyntax-0.1.2.tgz",
+      "integrity": "sha512-29lUK80d1muEQqiUsSo+3A0yP6CdspgC95EnKBMi22Xlwt79i/En4Vr67+cXhU+cZjbti3TgGGC5wy1stIywVQ==",
+      "dependencies": {
+        "buffer-more-ints": "~1.0.0",
+        "debug": "^4.3.4",
+        "safe-buffer": "~5.1.2"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/@ampproject/remapping": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
+      "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.1.0",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
+      "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/highlight": "^7.18.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.20.10",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.10.tgz",
+      "integrity": "sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.7.tgz",
+      "integrity": "sha512-t1ZjCluspe5DW24bn2Rr1CDb2v9rn/hROtg9a2tmd0+QYf4bsloYfLQzjG4qHPNMhWtKdGC33R5AxGR2Af2cBw==",
+      "dev": true,
+      "dependencies": {
+        "@ampproject/remapping": "^2.1.0",
+        "@babel/code-frame": "^7.18.6",
+        "@babel/generator": "^7.20.7",
+        "@babel/helper-compilation-targets": "^7.20.7",
+        "@babel/helper-module-transforms": "^7.20.7",
+        "@babel/helpers": "^7.20.7",
+        "@babel/parser": "^7.20.7",
+        "@babel/template": "^7.20.7",
+        "@babel/traverse": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "convert-source-map": "^1.7.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.1",
+        "semver": "^6.3.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/core/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.7.tgz",
+      "integrity": "sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.20.7",
+        "@jridgewell/gen-mapping": "^0.3.2",
+        "jsesc": "^2.5.1"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+      "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/set-array": "^1.0.1",
+        "@jridgewell/sourcemap-codec": "^1.4.10",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz",
+      "integrity": "sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/compat-data": "^7.20.5",
+        "@babel/helper-validator-option": "^7.18.6",
+        "browserslist": "^4.21.3",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/helper-environment-visitor": {
+      "version": "7.18.9",
+      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
+      "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-function-name": {
+      "version": "7.19.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz",
+      "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==",
+      "dev": true,
+      "dependencies": {
+        "@babel/template": "^7.18.10",
+        "@babel/types": "^7.19.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-hoist-variables": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
+      "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.18.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+      "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.18.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.20.11",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz",
+      "integrity": "sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-environment-visitor": "^7.18.9",
+        "@babel/helper-module-imports": "^7.18.6",
+        "@babel/helper-simple-access": "^7.20.2",
+        "@babel/helper-split-export-declaration": "^7.18.6",
+        "@babel/helper-validator-identifier": "^7.19.1",
+        "@babel/template": "^7.20.7",
+        "@babel/traverse": "^7.20.10",
+        "@babel/types": "^7.20.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-simple-access": {
+      "version": "7.20.2",
+      "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz",
+      "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.20.2"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-split-export-declaration": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
+      "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.18.6"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
+      "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.19.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+      "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz",
+      "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.7.tgz",
+      "integrity": "sha512-PBPjs5BppzsGaxHQCDKnZ6Gd9s6xl8bBCluz3vEInLGRJmnZan4F6BYCeqtyXqkk4W5IlPmjK4JlOuZkpJ3xZA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/template": "^7.20.7",
+        "@babel/traverse": "^7.20.7",
+        "@babel/types": "^7.20.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/highlight": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
+      "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.18.6",
+        "chalk": "^2.0.0",
+        "js-tokens": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+      "dev": true
+    },
+    "node_modules/@babel/highlight/node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.7.tgz",
+      "integrity": "sha512-T3Z9oHybU+0vZlY9CiDSJQTD5ZapcW18ZctFMi0MOAl/4BjFF4ul7NVSARLdbGO5vDqy9eQiGTV0LtKfvCYvcg==",
+      "dev": true,
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
+      "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.18.6",
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.20.10",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.10.tgz",
+      "integrity": "sha512-oSf1juCgymrSez8NI4A2sr4+uB/mFd9MXplYGPEBnfAuWmmyeVcHa6xLPiaRBcXkcb/28bgxmQLTVwFKE1yfsg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.18.6",
+        "@babel/generator": "^7.20.7",
+        "@babel/helper-environment-visitor": "^7.18.9",
+        "@babel/helper-function-name": "^7.19.0",
+        "@babel/helper-hoist-variables": "^7.18.6",
+        "@babel/helper-split-export-declaration": "^7.18.6",
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "debug": "^4.1.0",
+        "globals": "^11.1.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse/node_modules/globals": {
+      "version": "11.12.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+      "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.7.tgz",
+      "integrity": "sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.19.4",
+        "@babel/helper-validator-identifier": "^7.19.1",
+        "to-fast-properties": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.0.tgz",
+      "integrity": "sha512-7yfvXy6MWLgWSFsLhz5yH3iQ52St8cdUY6FoGieKkRDVxuxmrNuUetIuu6cmjNWwniUHiWXjxCr5tTXDrbYS5A==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^9.4.0",
+        "globals": "^13.19.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@humanwhocodes/config-array": {
+      "version": "0.11.8",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
+      "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+      "dev": true,
+      "dependencies": {
+        "@humanwhocodes/object-schema": "^1.2.1",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.5"
+      },
+      "engines": {
+        "node": ">=10.10.0"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.22"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/object-schema": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+      "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+      "dev": true
+    },
+    "node_modules/@istanbuljs/load-nyc-config": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+      "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+      "dev": true,
+      "dependencies": {
+        "camelcase": "^5.3.1",
+        "find-up": "^4.1.0",
+        "get-package-type": "^0.1.0",
+        "js-yaml": "^3.13.1",
+        "resolve-from": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "dev": true,
+      "dependencies": {
+        "sprintf-js": "~1.0.2"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
+      "version": "3.14.1",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+      "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+      "dev": true,
+      "dependencies": {
+        "argparse": "^1.0.7",
+        "esprima": "^4.0.0"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "dev": true,
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+      "dev": true
+    },
+    "node_modules/@istanbuljs/schema": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+      "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
+      "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/set-array": "^1.0.0",
+        "@jridgewell/sourcemap-codec": "^1.4.10"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+      "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/set-array": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+      "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.4.14",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+      "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+      "dev": true
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.17",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz",
+      "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/resolve-uri": "3.1.0",
+        "@jridgewell/sourcemap-codec": "1.4.14"
+      }
+    },
+    "node_modules/@mapbox/node-pre-gyp": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz",
+      "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==",
+      "optional": true,
+      "dependencies": {
+        "detect-libc": "^2.0.0",
+        "https-proxy-agent": "^5.0.0",
+        "make-dir": "^3.1.0",
+        "node-fetch": "^2.6.7",
+        "nopt": "^5.0.0",
+        "npmlog": "^5.0.1",
+        "rimraf": "^3.0.2",
+        "semver": "^7.3.5",
+        "tar": "^6.1.11"
+      },
+      "bin": {
+        "node-pre-gyp": "bin/node-pre-gyp"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@phc/format": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
+      "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@sinonjs/commons": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz",
+      "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==",
+      "dev": true,
+      "dependencies": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "node_modules/@sinonjs/fake-timers": {
+      "version": "10.0.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.0.2.tgz",
+      "integrity": "sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^2.0.0"
+      }
+    },
+    "node_modules/@sinonjs/samsam": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-7.0.1.tgz",
+      "integrity": "sha512-zsAk2Jkiq89mhZovB2LLOdTCxJF4hqqTToGP0ASWlhp4I1hqOjcfmZGafXntCN7MDC6yySH0mFHrYtHceOeLmw==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^2.0.0",
+        "lodash.get": "^4.4.2",
+        "type-detect": "^4.0.8"
+      }
+    },
+    "node_modules/@sinonjs/text-encoding": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz",
+      "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==",
+      "dev": true
+    },
+    "node_modules/@squeep/amqp-helper": {
+      "version": "1.0.0",
+      "resolved": "git+https://git.squeep.com/squeep-amqp-helper#174280d3f44ba13dac0b26d42d968189a4f4fa93",
+      "license": "ISC",
+      "dependencies": {
+        "amqplib": "^0.10.3"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@squeep/api-dingus": {
+      "version": "1.2.9",
+      "resolved": "git+https://git.squeep.com/squeep-api-dingus/#3b15b5ff792fc5d61be8337989058c297460cd99",
+      "license": "ISC",
+      "dependencies": {
+        "mime-db": "^1.52.0",
+        "uuid": "^9.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@squeep/authentication-module": {
+      "version": "1.2.12",
+      "resolved": "git+https://git.squeep.com/squeep-authentication-module/#9a7d5352698481c0857ba8827e31c7cb97625133",
+      "license": "ISC",
+      "dependencies": {
+        "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.2.9",
+        "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.4.0",
+        "@squeep/indieauth-helper": "git+https://git.squeep.com/squeep-indieauth-helper/#v1.1.7",
+        "@squeep/mystery-box": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "optionalDependencies": {
+        "argon2": "^0.30.1",
+        "node-linux-pam": "^0.2.1"
+      }
+    },
+    "node_modules/@squeep/base64url": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@squeep/base64url/-/base64url-1.0.5.tgz",
+      "integrity": "sha512-J1UBXYQ4tBuHGnFfg0MdmxCP3oEti7jJWK/qBsg520d8tZd10sL6TXiMDGdBiH9GD3OtlfSYf6wWIN4QfdW21A==",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@squeep/chores": {
+      "version": "1.0.0",
+      "resolved": "git+https://git.squeep.com/squeep-chores/#d98a3c114eb33bd68477c0ca750e6f82b4d02964",
+      "license": "ISC"
+    },
+    "node_modules/@squeep/html-template-helper": {
+      "version": "1.4.0",
+      "resolved": "git+https://git.squeep.com/squeep-html-template-helper#100046316a87631fb8814f80b35647709e6c7319",
+      "license": "ISC",
+      "dependencies": {
+        "@squeep/lazy-property": "^1.1.2"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@squeep/indieauth-helper": {
+      "version": "1.2.2",
+      "resolved": "git+https://git.squeep.com/squeep-indieauth-helper/#9d77dc15cee59356a4a4f7935db6a450b04a083c",
+      "license": "ISC",
+      "dependencies": {
+        "@squeep/base64url": "^1.0.5",
+        "@squeep/web-linking": "^1.0.7",
+        "axios": "^1.2.1",
+        "iconv": "^3.0.1",
+        "ip-address": "^8.1.0",
+        "microformats-parser": "^1.4.1"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@squeep/lazy-property": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@squeep/lazy-property/-/lazy-property-1.1.2.tgz",
+      "integrity": "sha512-wRdR4IOqWXoDMArx0HPo5MtM2Wk5wemAULbZ6PabVw1ylSQekkzKfoAUuupxsKuzjcRPjZvbpGDv+i04hBMnQw==",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@squeep/logger-json-console": {
+      "version": "1.0.2",
+      "resolved": "git+https://git.squeep.com/squeep-logger-json-console#dbff0fa5f018f7a302f73250e55f761c0ccf24b1",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/@squeep/mystery-box": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@squeep/mystery-box/-/mystery-box-1.2.0.tgz",
+      "integrity": "sha512-HYxsF+mv2yrOsgQO2am2i07SRgp5L4o0CySmlM3rIsVWJXlywVPGk9xZFpKtk49BXCGxopWfvtPhpM8U0roaRg==",
+      "dependencies": {
+        "@squeep/base64url": "^1.0.5"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@squeep/resource-authentication-module": {
+      "version": "1.0.0",
+      "resolved": "git+https://git.squeep.com/squeep-resource-authentication-module#69a2f5e7d73dd3f58e07b652c306daa8b253245d",
+      "license": "ISC",
+      "dependencies": {
+        "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.2.8",
+        "@squeep/base64url": "^1.0.5",
+        "uuid": "^9.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@squeep/roman": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@squeep/roman/-/roman-1.0.0.tgz",
+      "integrity": "sha512-D02jDw/we8tc6QiCPor7tWVviY8MLITyp/egqp3XqrrKtsFMYdguAhaFKUmIBu1ZL1uPKgoLBOy8hIptmh8cWA=="
+    },
+    "node_modules/@squeep/test-helper": {
+      "version": "1.0.0",
+      "resolved": "git+https://git.squeep.com/squeep-test-helper#7a5a384abb99757b53c8898c508023f0ba9e94b1",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "eslint": "^8.23.1",
+        "eslint-plugin-node": "^11.1.0",
+        "eslint-plugin-security": "^1.5.0",
+        "eslint-plugin-sonarjs": "^0.15.0",
+        "mocha": "^10.0.0",
+        "nyc": "^15.1.0",
+        "pre-commit": "^1.2.2",
+        "sinon": "^14.0.0"
+      }
+    },
+    "node_modules/@squeep/test-helper/node_modules/@sinonjs/fake-timers": {
+      "version": "9.1.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz",
+      "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^1.7.0"
+      }
+    },
+    "node_modules/@squeep/test-helper/node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": {
+      "version": "1.8.6",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz",
+      "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==",
+      "dev": true,
+      "dependencies": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "node_modules/@squeep/test-helper/node_modules/eslint-plugin-sonarjs": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.15.0.tgz",
+      "integrity": "sha512-LuxHdAe6VqSbi1phsUvNjbmXLuvlobmryQJJNyQYbdubCfz6K8tmgoqNiJPnz0pP2AbYDbtuPm0ajOMgMrC+dQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/@squeep/test-helper/node_modules/sinon": {
+      "version": "14.0.2",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-14.0.2.tgz",
+      "integrity": "sha512-PDpV0ZI3ZCS3pEqx0vpNp6kzPhHrLx72wA0G+ZLaaJjLIYeE0n8INlgaohKuGy7hP0as5tbUd23QWu5U233t+w==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^2.0.0",
+        "@sinonjs/fake-timers": "^9.1.2",
+        "@sinonjs/samsam": "^7.0.1",
+        "diff": "^5.0.0",
+        "nise": "^5.1.2",
+        "supports-color": "^7.2.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/sinon"
+      }
+    },
+    "node_modules/@squeep/web-linking": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@squeep/web-linking/-/web-linking-1.0.7.tgz",
+      "integrity": "sha512-9d3QijrWc/WNE7p/K7NLUHbmPf92CURRqfzDLV0cGqYNA4QWAPfzwC8hxWpdUkUnep3KakvLKK60l0kEBMM3ag==",
+      "engines": {
+        "node": ">=14.0"
+      }
+    },
+    "node_modules/abbrev": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+      "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+      "optional": true
+    },
+    "node_modules/acorn": {
+      "version": "8.8.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz",
+      "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "optional": true,
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/aggregate-error": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+      "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+      "dev": true,
+      "dependencies": {
+        "clean-stack": "^2.0.0",
+        "indent-string": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/amqplib": {
+      "version": "0.10.3",
+      "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.3.tgz",
+      "integrity": "sha512-UHmuSa7n8vVW/a5HGh2nFPqAEr8+cD4dEZ6u9GjP91nHfr1a54RyAKyra7Sb5NH7NBKOUlyQSMXIp0qAixKexw==",
+      "dependencies": {
+        "@acuminous/bitsyntax": "^0.1.2",
+        "buffer-more-ints": "~1.0.0",
+        "readable-stream": "1.x >=1.1.9",
+        "url-parse": "~1.5.10"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/ansi-colors": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+      "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "devOptional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "devOptional": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/append-transform": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz",
+      "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==",
+      "dev": true,
+      "dependencies": {
+        "default-require-extensions": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/aproba": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+      "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+      "optional": true
+    },
+    "node_modules/archy": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
+      "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==",
+      "dev": true
+    },
+    "node_modules/are-we-there-yet": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+      "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+      "optional": true,
+      "dependencies": {
+        "delegates": "^1.0.0",
+        "readable-stream": "^3.6.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/are-we-there-yet/node_modules/readable-stream": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+      "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+      "optional": true,
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/are-we-there-yet/node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "optional": true
+    },
+    "node_modules/are-we-there-yet/node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "optional": true,
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/argon2": {
+      "version": "0.30.2",
+      "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.30.2.tgz",
+      "integrity": "sha512-RBbXTUsrJUQH259/72CCJxQa0hV961pV4PyZ7R1czGkArSsQP4DToCS2axmNfHywXaBNEMPWMW6rM82EArulYA==",
+      "hasInstallScript": true,
+      "optional": true,
+      "dependencies": {
+        "@mapbox/node-pre-gyp": "^1.0.10",
+        "@phc/format": "^1.0.0",
+        "node-addon-api": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true
+    },
+    "node_modules/assert-options": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.8.0.tgz",
+      "integrity": "sha512-qSELrEaEz4sGwTs4Qh+swQkjiHAysC4rot21+jzXU86dJzNG+FDqBzyS3ohSoTRf4ZLA3FSwxQdiuNl5NXUtvA==",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
+    "node_modules/axios": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.1.tgz",
+      "integrity": "sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==",
+      "dependencies": {
+        "follow-redirects": "^1.15.0",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "devOptional": true
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/better-sqlite3": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.0.1.tgz",
+      "integrity": "sha512-JhTZjpyapA1icCEjIZB4TSSgkGdFgpWZA2Wszg7Cf4JwJwKQmbvuNnJBeR+EYG/Z29OXvR4G//Rbg31BW/Z7Yg==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "bindings": "^1.5.0",
+        "prebuild-install": "^7.1.0"
+      }
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/bindings": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+      "dependencies": {
+        "file-uri-to-path": "1.0.0"
+      }
+    },
+    "node_modules/bl": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "dependencies": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      }
+    },
+    "node_modules/bl/node_modules/readable-stream": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+      "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/bl/node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/bl/node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "devOptional": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dev": true,
+      "dependencies": {
+        "fill-range": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browser-stdout": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
+      "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
+      "dev": true
+    },
+    "node_modules/browserslist": {
+      "version": "4.21.4",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
+      "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        }
+      ],
+      "dependencies": {
+        "caniuse-lite": "^1.0.30001400",
+        "electron-to-chromium": "^1.4.251",
+        "node-releases": "^2.0.6",
+        "update-browserslist-db": "^1.0.9"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "dev": true
+    },
+    "node_modules/buffer-more-ints": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz",
+      "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg=="
+    },
+    "node_modules/buffer-writer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
+      "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/caching-transform": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz",
+      "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==",
+      "dev": true,
+      "dependencies": {
+        "hasha": "^5.0.0",
+        "make-dir": "^3.0.0",
+        "package-hash": "^4.0.0",
+        "write-file-atomic": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/camel-case": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-3.0.0.tgz",
+      "integrity": "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==",
+      "dev": true,
+      "dependencies": {
+        "no-case": "^2.2.0",
+        "upper-case": "^1.1.1"
+      }
+    },
+    "node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "devOptional": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001441",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001441.tgz",
+      "integrity": "sha512-OyxRR4Vof59I3yGWXws6i908EtGbMzVUi3ganaZQHmydk1iwDhRnvaPG2WaR0KcqrDFKrxVZHULT396LEPhXfg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        }
+      ]
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "3.5.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+      "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://paulmillr.com/funding/"
+        }
+      ],
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chokidar/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/chownr": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+      "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/clean-css": {
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+      "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
+      "dev": true,
+      "dependencies": {
+        "source-map": "~0.6.0"
+      },
+      "engines": {
+        "node": ">= 4.0"
+      }
+    },
+    "node_modules/clean-stack": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+      "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+      "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+      "dev": true,
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "node_modules/code-point-at": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+      "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "devOptional": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "devOptional": true
+    },
+    "node_modules/color-support": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+      "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+      "optional": true,
+      "bin": {
+        "color-support": "bin.js"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/commander": {
+      "version": "2.17.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
+      "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
+      "dev": true
+    },
+    "node_modules/commondir": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+      "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+      "dev": true
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "devOptional": true
+    },
+    "node_modules/concat-stream": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+      "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+      "dev": true,
+      "engines": [
+        "node >= 0.8"
+      ],
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^2.2.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "node_modules/concat-stream/node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "dev": true
+    },
+    "node_modules/concat-stream/node_modules/readable-stream": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+      "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+      "dev": true,
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/concat-stream/node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dev": true,
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/console-control-strings": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+      "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+      "optional": true
+    },
+    "node_modules/convert-source-map": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+      "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+      "dev": true
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+      "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+      "devOptional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "dependencies": {
+        "mimic-response": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/deep-extend": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
+    "node_modules/default-require-extensions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
+      "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==",
+      "dev": true,
+      "dependencies": {
+        "strip-bom": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/delegates": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+      "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+      "optional": true
+    },
+    "node_modules/detect-libc": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz",
+      "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/diff": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
+      "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.4.284",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz",
+      "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==",
+      "dev": true
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "devOptional": true
+    },
+    "node_modules/end-of-stream": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+      "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
+    "node_modules/es6-error": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
+      "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+      "dev": true
+    },
+    "node_modules/escalade": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+      "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "8.30.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.30.0.tgz",
+      "integrity": "sha512-MGADB39QqYuzEGov+F/qb18r4i7DohCDOfatHaxI2iGlPuC65bwG2gxgO+7DkyL38dRFaRH7RaRAgU6JKL9rMQ==",
+      "dev": true,
+      "dependencies": {
+        "@eslint/eslintrc": "^1.4.0",
+        "@humanwhocodes/config-array": "^0.11.8",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@nodelib/fs.walk": "^1.2.8",
+        "ajv": "^6.10.0",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "doctrine": "^3.0.0",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^7.1.1",
+        "eslint-utils": "^3.0.0",
+        "eslint-visitor-keys": "^3.3.0",
+        "espree": "^9.4.0",
+        "esquery": "^1.4.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "globals": "^13.19.0",
+        "grapheme-splitter": "^1.0.4",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
+        "js-sdsl": "^4.1.4",
+        "js-yaml": "^4.1.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.1",
+        "regexpp": "^3.2.0",
+        "strip-ansi": "^6.0.1",
+        "strip-json-comments": "^3.1.0",
+        "text-table": "^0.2.0"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-plugin-es": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz",
+      "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==",
+      "dev": true,
+      "dependencies": {
+        "eslint-utils": "^2.0.0",
+        "regexpp": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      },
+      "peerDependencies": {
+        "eslint": ">=4.19.1"
+      }
+    },
+    "node_modules/eslint-plugin-es/node_modules/eslint-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz",
+      "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==",
+      "dev": true,
+      "dependencies": {
+        "eslint-visitor-keys": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      }
+    },
+    "node_modules/eslint-plugin-es/node_modules/eslint-visitor-keys": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+      "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-plugin-node": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz",
+      "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==",
+      "dev": true,
+      "dependencies": {
+        "eslint-plugin-es": "^3.0.0",
+        "eslint-utils": "^2.0.0",
+        "ignore": "^5.1.1",
+        "minimatch": "^3.0.4",
+        "resolve": "^1.10.1",
+        "semver": "^6.1.0"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      },
+      "peerDependencies": {
+        "eslint": ">=5.16.0"
+      }
+    },
+    "node_modules/eslint-plugin-node/node_modules/eslint-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz",
+      "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==",
+      "dev": true,
+      "dependencies": {
+        "eslint-visitor-keys": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      }
+    },
+    "node_modules/eslint-plugin-node/node_modules/eslint-visitor-keys": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+      "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-plugin-node/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/eslint-plugin-promise": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz",
+      "integrity": "sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "eslint": "^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-security": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.5.0.tgz",
+      "integrity": "sha512-hAFVwLZ/UeXrlyVD2TDarv/x00CoFVpaY0IUZhKjPjiFxqkuQVixsK4f2rxngeQOqSxi6OUjzJM/jMwKEVjJ8g==",
+      "dev": true,
+      "dependencies": {
+        "safe-regex": "^2.1.1"
+      }
+    },
+    "node_modules/eslint-plugin-sonarjs": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.17.0.tgz",
+      "integrity": "sha512-jtGtxI49UbJJeJj7CVRLI3+LLH+y+hkR3GOOwM7vBbci9DEFIRGCWvEd2BJScrzltZ6D6iubukTAfc9cyG7sdw==",
+      "dev": true,
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
+      "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/eslint-utils": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
+      "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
+      "dev": true,
+      "dependencies": {
+        "eslint-visitor-keys": "^2.0.0"
+      },
+      "engines": {
+        "node": "^10.0.0 || ^12.0.0 || >= 14.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      },
+      "peerDependencies": {
+        "eslint": ">=5"
+      }
+    },
+    "node_modules/eslint-utils/node_modules/eslint-visitor-keys": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+      "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
+      "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/espree": {
+      "version": "9.4.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz",
+      "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.8.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^3.3.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+      "dev": true,
+      "bin": {
+        "esparse": "bin/esparse.js",
+        "esvalidate": "bin/esvalidate.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+      "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/expand-template": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true
+    },
+    "node_modules/fastq": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.14.0.tgz",
+      "integrity": "sha512-eR2D+V9/ExcbF9ls441yIuN6TI2ED1Y2ZcA5BmMtJsOkWOFRJQ0Jt0g1UwqXJJVAb+V+umH5Dfr8oh4EVP7VVg==",
+      "dev": true,
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "dependencies": {
+        "flat-cache": "^3.0.4"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
+    },
+    "node_modules/fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dev": true,
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/find-cache-dir": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+      "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+      "dev": true,
+      "dependencies": {
+        "commondir": "^1.0.1",
+        "make-dir": "^3.0.2",
+        "pkg-dir": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+      "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
+      "dev": true,
+      "bin": {
+        "flat": "cli.js"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+      "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+      "dev": true,
+      "dependencies": {
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
+      "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
+      "dev": true
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.2",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
+      "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/foreground-child": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
+      "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
+      "dev": true,
+      "dependencies": {
+        "cross-spawn": "^7.0.0",
+        "signal-exit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fromentries": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz",
+      "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/fs-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
+    },
+    "node_modules/fs-minipass": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+      "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+      "optional": true,
+      "dependencies": {
+        "minipass": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/fs-minipass/node_modules/minipass": {
+      "version": "3.3.6",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+      "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+      "optional": true,
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fs-minipass/node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "optional": true
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "devOptional": true
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+      "dev": true
+    },
+    "node_modules/gauge": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+      "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+      "optional": true,
+      "dependencies": {
+        "aproba": "^1.0.3 || ^2.0.0",
+        "color-support": "^1.1.2",
+        "console-control-strings": "^1.0.0",
+        "has-unicode": "^2.0.1",
+        "object-assign": "^4.1.1",
+        "signal-exit": "^3.0.0",
+        "string-width": "^4.2.3",
+        "strip-ansi": "^6.0.1",
+        "wide-align": "^1.1.2"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "devOptional": true,
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/get-package-type": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+      "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/github-from-package": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
+    },
+    "node_modules/glob": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
+      "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
+      "devOptional": true,
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/globals": {
+      "version": "13.19.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz",
+      "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==",
+      "dev": true,
+      "dependencies": {
+        "type-fest": "^0.20.2"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.10",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
+      "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
+      "dev": true
+    },
+    "node_modules/grapheme-splitter": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+      "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
+      "dev": true
+    },
+    "node_modules/has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-unicode": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+      "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+      "optional": true
+    },
+    "node_modules/hasha": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz",
+      "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==",
+      "dev": true,
+      "dependencies": {
+        "is-stream": "^2.0.0",
+        "type-fest": "^0.8.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/hasha/node_modules/type-fest": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+      "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "bin": {
+        "he": "bin/he"
+      }
+    },
+    "node_modules/html-escaper": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+      "dev": true
+    },
+    "node_modules/html-minifier": {
+      "version": "3.5.21",
+      "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-3.5.21.tgz",
+      "integrity": "sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==",
+      "dev": true,
+      "dependencies": {
+        "camel-case": "3.0.x",
+        "clean-css": "4.2.x",
+        "commander": "2.17.x",
+        "he": "1.2.x",
+        "param-case": "2.1.x",
+        "relateurl": "0.2.x",
+        "uglify-js": "3.4.x"
+      },
+      "bin": {
+        "html-minifier": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/html-minifier-lint": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/html-minifier-lint/-/html-minifier-lint-2.0.0.tgz",
+      "integrity": "sha512-halWZUg/us7Y16irVM90DTdyAUP3ksFthWfFPJTG1jpBaYYyGHt9azTW9H++hZ8LWRArzQm9oIcrfM/o/CO+4A==",
+      "dev": true,
+      "dependencies": {
+        "html-minifier": "3.x"
+      },
+      "bin": {
+        "html-minifier-lint": "cli.js"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "optional": true,
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/iconv": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/iconv/-/iconv-3.0.1.tgz",
+      "integrity": "sha512-lJnFLxVc0d82R7GfU7a9RujKVUQ3Eee19tPKWZWBJtAEGRHVEyFzCtbNl3GPKuDnHBBRT4/nDS4Ru9AIDT72qA==",
+      "hasInstallScript": true,
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/ignore": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
+      "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "devOptional": true,
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
+    },
+    "node_modules/ip-address": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-8.1.0.tgz",
+      "integrity": "sha512-Wz91gZKpNKoXtqvY8ScarKYwhXoK4r/b5QuT+uywe/azv0/nUCo7Bh0IRRI7F9DHR06kJNWtzMGLIbXavngbKA==",
+      "dependencies": {
+        "jsbn": "1.1.0",
+        "sprintf-js": "1.1.2"
+      },
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.11.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
+      "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
+      "dev": true,
+      "dependencies": {
+        "has": "^1.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "devOptional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-plain-obj": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+      "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-typedarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+      "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
+      "dev": true
+    },
+    "node_modules/is-unicode-supported": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+      "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-windows": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+      "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+      "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true
+    },
+    "node_modules/istanbul-lib-coverage": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+      "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-hook": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz",
+      "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==",
+      "dev": true,
+      "dependencies": {
+        "append-transform": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-instrument": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz",
+      "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "^7.7.5",
+        "@istanbuljs/schema": "^0.1.2",
+        "istanbul-lib-coverage": "^3.0.0",
+        "semver": "^6.3.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-instrument/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/istanbul-lib-processinfo": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz",
+      "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==",
+      "dev": true,
+      "dependencies": {
+        "archy": "^1.0.0",
+        "cross-spawn": "^7.0.3",
+        "istanbul-lib-coverage": "^3.2.0",
+        "p-map": "^3.0.0",
+        "rimraf": "^3.0.0",
+        "uuid": "^8.3.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-processinfo/node_modules/uuid": {
+      "version": "8.3.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+      "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+      "dev": true,
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
+    "node_modules/istanbul-lib-report": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+      "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==",
+      "dev": true,
+      "dependencies": {
+        "istanbul-lib-coverage": "^3.0.0",
+        "make-dir": "^3.0.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-source-maps": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+      "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^4.1.1",
+        "istanbul-lib-coverage": "^3.0.0",
+        "source-map": "^0.6.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/istanbul-reports": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz",
+      "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==",
+      "dev": true,
+      "dependencies": {
+        "html-escaper": "^2.0.0",
+        "istanbul-lib-report": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/js-sdsl": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz",
+      "integrity": "sha512-dyBIzQBDkCqCu+0upx25Y2jGdbTGxE9fshMsCdK0ViOongpV+n5tXRcZY9v7CaVQ79AGS9KA1KHtojxiM7aXSQ==",
+      "dev": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/js-sdsl"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/jsbn": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+      "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="
+    },
+    "node_modules/jsesc": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+      "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+      "dev": true,
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true
+    },
+    "node_modules/json5": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.2.tgz",
+      "integrity": "sha512-46Tk9JiOL2z7ytNQWFLpj99RZkVgeHf87yGQKsIkaPz1qSH9UczKH1rO7K3wgRselo0tYMUNfecYpm/p1vC7tQ==",
+      "dev": true,
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/just-extend": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz",
+      "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==",
+      "dev": true
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash.flattendeep": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
+      "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==",
+      "dev": true
+    },
+    "node_modules/lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
+      "dev": true
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "node_modules/log-symbols": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+      "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^4.1.0",
+        "is-unicode-supported": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lower-case": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz",
+      "integrity": "sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==",
+      "dev": true
+    },
+    "node_modules/lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "dependencies": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "node_modules/make-dir": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+      "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+      "devOptional": true,
+      "dependencies": {
+        "semver": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/make-dir/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "devOptional": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/microformats-parser": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-1.4.1.tgz",
+      "integrity": "sha512-BSg9Y/Aik8hvvme/fkxnXMRvTKuVwOeTapeZdaPQ+92DEubyM31iMtwbgFZ1383om643UvfYY5G23E9s1FY2KQ==",
+      "dependencies": {
+        "parse5": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mimic-response": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "devOptional": true,
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
+      "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/minipass": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.0.0.tgz",
+      "integrity": "sha512-g2Uuh2jEKoht+zvO6vJqXmYpflPqzRBT+Th2h01DKh5z7wbY/AZ2gCQ78cP70YoHPyFdY30YBV5WxgLOEwOykw==",
+      "optional": true,
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/minipass/node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "optional": true
+    },
+    "node_modules/minizlib": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+      "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+      "optional": true,
+      "dependencies": {
+        "minipass": "^3.0.0",
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/minizlib/node_modules/minipass": {
+      "version": "3.3.6",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+      "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+      "optional": true,
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/minizlib/node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "optional": true
+    },
+    "node_modules/mkdirp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+      "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+      "optional": true,
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
+    },
+    "node_modules/mocha": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz",
+      "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==",
+      "dev": true,
+      "dependencies": {
+        "ansi-colors": "4.1.1",
+        "browser-stdout": "1.3.1",
+        "chokidar": "3.5.3",
+        "debug": "4.3.4",
+        "diff": "5.0.0",
+        "escape-string-regexp": "4.0.0",
+        "find-up": "5.0.0",
+        "glob": "7.2.0",
+        "he": "1.2.0",
+        "js-yaml": "4.1.0",
+        "log-symbols": "4.1.0",
+        "minimatch": "5.0.1",
+        "ms": "2.1.3",
+        "nanoid": "3.3.3",
+        "serialize-javascript": "6.0.0",
+        "strip-json-comments": "3.1.1",
+        "supports-color": "8.1.1",
+        "workerpool": "6.2.1",
+        "yargs": "16.2.0",
+        "yargs-parser": "20.2.4",
+        "yargs-unparser": "2.0.0"
+      },
+      "bin": {
+        "_mocha": "bin/_mocha",
+        "mocha": "bin/mocha.js"
+      },
+      "engines": {
+        "node": ">= 14.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mochajs"
+      }
+    },
+    "node_modules/mocha-steps": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/mocha-steps/-/mocha-steps-1.3.0.tgz",
+      "integrity": "sha512-KZvpMJTqzLZw3mOb+EEuYi4YZS41C9iTnb7skVFRxHjUd1OYbl64tCMSmpdIRM9LnwIrSOaRfPtNpF5msgv6Eg==",
+      "dev": true
+    },
+    "node_modules/mocha/node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/mocha/node_modules/minimatch": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+      "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/mocha/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true
+    },
+    "node_modules/mocha/node_modules/supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz",
+      "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==",
+      "dev": true,
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/napi-build-utils": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
+      "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg=="
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true
+    },
+    "node_modules/nise": {
+      "version": "5.1.4",
+      "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.4.tgz",
+      "integrity": "sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^2.0.0",
+        "@sinonjs/fake-timers": "^10.0.2",
+        "@sinonjs/text-encoding": "^0.7.1",
+        "just-extend": "^4.0.2",
+        "path-to-regexp": "^1.7.0"
+      }
+    },
+    "node_modules/no-case": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz",
+      "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==",
+      "dev": true,
+      "dependencies": {
+        "lower-case": "^1.1.1"
+      }
+    },
+    "node_modules/node-abi": {
+      "version": "3.30.0",
+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.30.0.tgz",
+      "integrity": "sha512-qWO5l3SCqbwQavymOmtTVuCWZE23++S+rxyoHjXqUmPyzRcaoI4lA2gO55/drddGnedAyjA7sk76SfQ5lfUMnw==",
+      "dependencies": {
+        "semver": "^7.3.5"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/node-addon-api": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.0.0.tgz",
+      "integrity": "sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA==",
+      "optional": true
+    },
+    "node_modules/node-fetch": {
+      "version": "2.6.7",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
+      "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
+      "optional": true,
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/node-linux-pam": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/node-linux-pam/-/node-linux-pam-0.2.1.tgz",
+      "integrity": "sha512-OeMZW0Bs1bffsvXI/bJQbU0rkiWTOo0ceT6+mrbU84TJ33vAKykIZrLI+ApfRqkBQW5jzW5rJ7x+NSyToafqig==",
+      "hasInstallScript": true,
+      "optional": true,
+      "dependencies": {
+        "@mapbox/node-pre-gyp": "1.0.5",
+        "bindings": "1.5.0",
+        "node-addon-api": "3.1.0",
+        "string-template": "1.0.0",
+        "yargs": "15.4.1"
+      },
+      "bin": {
+        "nlp": "cli.js"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/@mapbox/node-pre-gyp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz",
+      "integrity": "sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA==",
+      "optional": true,
+      "dependencies": {
+        "detect-libc": "^1.0.3",
+        "https-proxy-agent": "^5.0.0",
+        "make-dir": "^3.1.0",
+        "node-fetch": "^2.6.1",
+        "nopt": "^5.0.0",
+        "npmlog": "^4.1.2",
+        "rimraf": "^3.0.2",
+        "semver": "^7.3.4",
+        "tar": "^6.1.0"
+      },
+      "bin": {
+        "node-pre-gyp": "bin/node-pre-gyp"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/ansi-regex": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+      "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/aproba": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+      "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+      "optional": true
+    },
+    "node_modules/node-linux-pam/node_modules/are-we-there-yet": {
+      "version": "1.1.7",
+      "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
+      "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==",
+      "optional": true,
+      "dependencies": {
+        "delegates": "^1.0.0",
+        "readable-stream": "^2.0.6"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/cliui": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+      "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+      "optional": true,
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^6.2.0"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/cliui/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/cliui/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "optional": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/detect-libc": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+      "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+      "optional": true,
+      "bin": {
+        "detect-libc": "bin/detect-libc.js"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "optional": true,
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/gauge": {
+      "version": "2.7.4",
+      "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+      "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==",
+      "optional": true,
+      "dependencies": {
+        "aproba": "^1.0.3",
+        "console-control-strings": "^1.0.0",
+        "has-unicode": "^2.0.0",
+        "object-assign": "^4.1.0",
+        "signal-exit": "^3.0.0",
+        "string-width": "^1.0.1",
+        "strip-ansi": "^3.0.1",
+        "wide-align": "^1.1.0"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/gauge/node_modules/string-width": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+      "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==",
+      "optional": true,
+      "dependencies": {
+        "code-point-at": "^1.0.0",
+        "is-fullwidth-code-point": "^1.0.0",
+        "strip-ansi": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/is-fullwidth-code-point": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+      "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
+      "optional": true,
+      "dependencies": {
+        "number-is-nan": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "optional": true
+    },
+    "node_modules/node-linux-pam/node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "optional": true,
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/node-addon-api": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.1.0.tgz",
+      "integrity": "sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw==",
+      "optional": true
+    },
+    "node_modules/node-linux-pam/node_modules/npmlog": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+      "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+      "optional": true,
+      "dependencies": {
+        "are-we-there-yet": "~1.1.2",
+        "console-control-strings": "~1.1.0",
+        "gauge": "~2.7.3",
+        "set-blocking": "~2.0.0"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "optional": true,
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "optional": true,
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/readable-stream": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+      "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+      "optional": true,
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "optional": true,
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/strip-ansi": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+      "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+      "optional": true,
+      "dependencies": {
+        "ansi-regex": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/wrap-ansi": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+      "optional": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/wrap-ansi/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/wrap-ansi/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "optional": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/y18n": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+      "optional": true
+    },
+    "node_modules/node-linux-pam/node_modules/yargs": {
+      "version": "15.4.1",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+      "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+      "optional": true,
+      "dependencies": {
+        "cliui": "^6.0.0",
+        "decamelize": "^1.2.0",
+        "find-up": "^4.1.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^4.2.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^18.1.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/node-linux-pam/node_modules/yargs-parser": {
+      "version": "18.1.3",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+      "optional": true,
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/node-preload": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz",
+      "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==",
+      "dev": true,
+      "dependencies": {
+        "process-on-spawn": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz",
+      "integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==",
+      "dev": true
+    },
+    "node_modules/nopt": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+      "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+      "optional": true,
+      "dependencies": {
+        "abbrev": "1"
+      },
+      "bin": {
+        "nopt": "bin/nopt.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/npmlog": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+      "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+      "optional": true,
+      "dependencies": {
+        "are-we-there-yet": "^2.0.0",
+        "console-control-strings": "^1.1.0",
+        "gauge": "^3.0.0",
+        "set-blocking": "^2.0.0"
+      }
+    },
+    "node_modules/number-is-nan": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+      "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/nyc": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz",
+      "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==",
+      "dev": true,
+      "dependencies": {
+        "@istanbuljs/load-nyc-config": "^1.0.0",
+        "@istanbuljs/schema": "^0.1.2",
+        "caching-transform": "^4.0.0",
+        "convert-source-map": "^1.7.0",
+        "decamelize": "^1.2.0",
+        "find-cache-dir": "^3.2.0",
+        "find-up": "^4.1.0",
+        "foreground-child": "^2.0.0",
+        "get-package-type": "^0.1.0",
+        "glob": "^7.1.6",
+        "istanbul-lib-coverage": "^3.0.0",
+        "istanbul-lib-hook": "^3.0.0",
+        "istanbul-lib-instrument": "^4.0.0",
+        "istanbul-lib-processinfo": "^2.0.2",
+        "istanbul-lib-report": "^3.0.0",
+        "istanbul-lib-source-maps": "^4.0.0",
+        "istanbul-reports": "^3.0.2",
+        "make-dir": "^3.0.0",
+        "node-preload": "^0.2.1",
+        "p-map": "^3.0.0",
+        "process-on-spawn": "^1.0.0",
+        "resolve-from": "^5.0.0",
+        "rimraf": "^3.0.0",
+        "signal-exit": "^3.0.2",
+        "spawn-wrap": "^2.0.0",
+        "test-exclude": "^6.0.0",
+        "yargs": "^15.0.2"
+      },
+      "bin": {
+        "nyc": "bin/nyc.js"
+      },
+      "engines": {
+        "node": ">=8.9"
+      }
+    },
+    "node_modules/nyc/node_modules/cliui": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+      "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+      "dev": true,
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^6.2.0"
+      }
+    },
+    "node_modules/nyc/node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/nyc/node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/nyc/node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "dev": true,
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/nyc/node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/nyc/node_modules/resolve-from": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/nyc/node_modules/wrap-ansi": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/nyc/node_modules/y18n": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+      "dev": true
+    },
+    "node_modules/nyc/node_modules/yargs": {
+      "version": "15.4.1",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+      "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+      "dev": true,
+      "dependencies": {
+        "cliui": "^6.0.0",
+        "decamelize": "^1.2.0",
+        "find-up": "^4.1.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^4.2.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^18.1.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/nyc/node_modules/yargs-parser": {
+      "version": "18.1.3",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+      "dev": true,
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+      "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+      "dev": true,
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.3"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/os-shim": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz",
+      "integrity": "sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-map": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz",
+      "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==",
+      "dev": true,
+      "dependencies": {
+        "aggregate-error": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "devOptional": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/package-hash": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz",
+      "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.1.15",
+        "hasha": "^5.0.0",
+        "lodash.flattendeep": "^4.4.0",
+        "release-zalgo": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/packet-reader": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
+      "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
+    },
+    "node_modules/param-case": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
+      "integrity": "sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==",
+      "dev": true,
+      "dependencies": {
+        "no-case": "^2.2.0"
+      }
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/parse5": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+      "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "devOptional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "devOptional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "node_modules/path-to-regexp": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
+      "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
+      "dev": true,
+      "dependencies": {
+        "isarray": "0.0.1"
+      }
+    },
+    "node_modules/pg": {
+      "version": "8.8.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
+      "integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==",
+      "dependencies": {
+        "buffer-writer": "2.0.0",
+        "packet-reader": "1.0.0",
+        "pg-connection-string": "^2.5.0",
+        "pg-pool": "^3.5.2",
+        "pg-protocol": "^1.5.0",
+        "pg-types": "^2.1.0",
+        "pgpass": "1.x"
+      },
+      "engines": {
+        "node": ">= 8.0.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz",
+      "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ=="
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-minify": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.6.2.tgz",
+      "integrity": "sha512-1KdmFGGTP6jplJoI8MfvRlfvMiyBivMRP7/ffh4a11RUFJ7kC2J0ZHlipoKiH/1hz+DVgceon9U2qbaHpPeyPg==",
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.5.2",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz",
+      "integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-promise": {
+      "version": "10.15.4",
+      "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-10.15.4.tgz",
+      "integrity": "sha512-BKlHCMCdNUmF6gagVbehRWSEiVcZzPVltEx14OJExR9Iz9/1R6KETDWLLGv2l6yRqYFnEZZy1VDjRhArzeIGrw==",
+      "dependencies": {
+        "assert-options": "0.8.0",
+        "pg": "8.8.0",
+        "pg-minify": "1.6.2",
+        "spex": "3.2.0"
+      },
+      "engines": {
+        "node": ">=12.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz",
+      "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ=="
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+      "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+      "dev": true
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pkg-dir": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+      "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+      "dev": true,
+      "dependencies": {
+        "find-up": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/pkg-dir/node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/pkg-dir/node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/pkg-dir/node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "dev": true,
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/pkg-dir/node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
+      "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/pre-commit": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/pre-commit/-/pre-commit-1.2.2.tgz",
+      "integrity": "sha512-qokTiqxD6GjODy5ETAIgzsRgnBWWQHQH2ghy86PU7mIn/wuWeTwF3otyNQZxWBwVn8XNr8Tdzj/QfUXpH+gRZA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "dependencies": {
+        "cross-spawn": "^5.0.1",
+        "spawn-sync": "^1.0.15",
+        "which": "1.2.x"
+      }
+    },
+    "node_modules/pre-commit/node_modules/cross-spawn": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
+      "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==",
+      "dev": true,
+      "dependencies": {
+        "lru-cache": "^4.0.1",
+        "shebang-command": "^1.2.0",
+        "which": "^1.2.9"
+      }
+    },
+    "node_modules/pre-commit/node_modules/lru-cache": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
+      "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
+      "dev": true,
+      "dependencies": {
+        "pseudomap": "^1.0.2",
+        "yallist": "^2.1.2"
+      }
+    },
+    "node_modules/pre-commit/node_modules/shebang-command": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+      "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==",
+      "dev": true,
+      "dependencies": {
+        "shebang-regex": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/pre-commit/node_modules/shebang-regex": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+      "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/pre-commit/node_modules/which": {
+      "version": "1.2.14",
+      "resolved": "https://registry.npmjs.org/which/-/which-1.2.14.tgz",
+      "integrity": "sha512-16uPglFkRPzgiUXYMi1Jf8Z5EzN1iB4V0ZtMXcHZnwsBtQhhHeCqoWw7tsUY42hJGNDWtUsVLTjakIa5BgAxCw==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "which": "bin/which"
+      }
+    },
+    "node_modules/pre-commit/node_modules/yallist": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+      "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==",
+      "dev": true
+    },
+    "node_modules/prebuild-install": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
+      "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==",
+      "dependencies": {
+        "detect-libc": "^2.0.0",
+        "expand-template": "^2.0.3",
+        "github-from-package": "0.0.0",
+        "minimist": "^1.2.3",
+        "mkdirp-classic": "^0.5.3",
+        "napi-build-utils": "^1.0.1",
+        "node-abi": "^3.3.0",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^4.0.0",
+        "tar-fs": "^2.0.0",
+        "tunnel-agent": "^0.6.0"
+      },
+      "bin": {
+        "prebuild-install": "bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "devOptional": true
+    },
+    "node_modules/process-on-spawn": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz",
+      "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==",
+      "dev": true,
+      "dependencies": {
+        "fromentries": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
+    "node_modules/pseudomap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
+      "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==",
+      "dev": true
+    },
+    "node_modules/pump": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+      "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+      "dependencies": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/querystringify": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+      "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "dev": true,
+      "dependencies": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
+    "node_modules/rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "dependencies": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "bin": {
+        "rc": "cli.js"
+      }
+    },
+    "node_modules/rc/node_modules/strip-json-comments": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "1.1.14",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+      "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.1",
+        "isarray": "0.0.1",
+        "string_decoder": "~0.10.x"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/regexp-tree": {
+      "version": "0.1.24",
+      "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz",
+      "integrity": "sha512-s2aEVuLhvnVJW6s/iPgEGK6R+/xngd2jNQ+xy4bXNDKxZKJH6jpPHY6kVeVv1IeLCHgswRj+Kl3ELaDjG6V1iw==",
+      "dev": true,
+      "bin": {
+        "regexp-tree": "bin/regexp-tree"
+      }
+    },
+    "node_modules/regexpp": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+      "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      }
+    },
+    "node_modules/relateurl": {
+      "version": "0.2.7",
+      "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
+      "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/release-zalgo": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz",
+      "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==",
+      "dev": true,
+      "dependencies": {
+        "es6-error": "^4.0.1"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "devOptional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "devOptional": true
+    },
+    "node_modules/requires-port": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+      "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
+    },
+    "node_modules/resolve": {
+      "version": "1.22.1",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
+      "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.9.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true,
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "devOptional": true,
+      "dependencies": {
+        "glob": "^7.1.3"
+      },
+      "bin": {
+        "rimraf": "bin.js"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "node_modules/safe-regex": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz",
+      "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==",
+      "dev": true,
+      "dependencies": {
+        "regexp-tree": "~0.1.1"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.3.8",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
+      "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/semver/node_modules/lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/semver/node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+    },
+    "node_modules/serialize-javascript": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+      "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+      "dev": true,
+      "dependencies": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+      "devOptional": true
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "devOptional": true
+    },
+    "node_modules/simple-concat": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/simple-get": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "decompress-response": "^6.0.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
+    "node_modules/sinon": {
+      "version": "15.0.1",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.0.1.tgz",
+      "integrity": "sha512-PZXKc08f/wcA/BMRGBze2Wmw50CWPiAH3E21EOi4B49vJ616vW4DQh4fQrqsYox2aNR/N3kCqLuB0PwwOucQrg==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^2.0.0",
+        "@sinonjs/fake-timers": "10.0.2",
+        "@sinonjs/samsam": "^7.0.1",
+        "diff": "^5.0.0",
+        "nise": "^5.1.2",
+        "supports-color": "^7.2.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/sinon"
+      }
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/spawn-sync": {
+      "version": "1.0.15",
+      "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz",
+      "integrity": "sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "dependencies": {
+        "concat-stream": "^1.4.7",
+        "os-shim": "^0.1.2"
+      }
+    },
+    "node_modules/spawn-wrap": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz",
+      "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==",
+      "dev": true,
+      "dependencies": {
+        "foreground-child": "^2.0.0",
+        "is-windows": "^1.0.2",
+        "make-dir": "^3.0.0",
+        "rimraf": "^3.0.0",
+        "signal-exit": "^3.0.2",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/spex": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/spex/-/spex-3.2.0.tgz",
+      "integrity": "sha512-9srjJM7NaymrpwMHvSmpDeIK5GoRMX/Tq0E8aOlDPS54dDnDUIp30DrP9SphMPEETDLzEM9+4qo+KipmbtPecg==",
+      "engines": {
+        "node": ">=4.5"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz",
+      "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/sprintf-js": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
+      "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
+    },
+    "node_modules/string_decoder": {
+      "version": "0.10.31",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+      "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="
+    },
+    "node_modules/string-template": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/string-template/-/string-template-1.0.0.tgz",
+      "integrity": "sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg==",
+      "optional": true
+    },
+    "node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "devOptional": true,
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "devOptional": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-bom": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
+      "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/tar": {
+      "version": "6.1.13",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz",
+      "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==",
+      "optional": true,
+      "dependencies": {
+        "chownr": "^2.0.0",
+        "fs-minipass": "^2.0.0",
+        "minipass": "^4.0.0",
+        "minizlib": "^2.1.1",
+        "mkdirp": "^1.0.3",
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/tar-fs": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
+      "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==",
+      "dependencies": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.1.4"
+      }
+    },
+    "node_modules/tar-fs/node_modules/chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
+    },
+    "node_modules/tar-stream": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "dependencies": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/tar-stream/node_modules/readable-stream": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+      "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/tar-stream/node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/tar-stream/node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/tar/node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "optional": true
+    },
+    "node_modules/test-exclude": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+      "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+      "dev": true,
+      "dependencies": {
+        "@istanbuljs/schema": "^0.1.2",
+        "glob": "^7.1.4",
+        "minimatch": "^3.0.4"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+      "dev": true
+    },
+    "node_modules/to-fast-properties": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+      "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "optional": true
+    },
+    "node_modules/tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "dev": true
+    },
+    "node_modules/typedarray-to-buffer": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+      "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+      "dev": true,
+      "dependencies": {
+        "is-typedarray": "^1.0.0"
+      }
+    },
+    "node_modules/uglify-js": {
+      "version": "3.4.10",
+      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.10.tgz",
+      "integrity": "sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==",
+      "dev": true,
+      "dependencies": {
+        "commander": "~2.19.0",
+        "source-map": "~0.6.1"
+      },
+      "bin": {
+        "uglifyjs": "bin/uglifyjs"
+      },
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/uglify-js/node_modules/commander": {
+      "version": "2.19.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz",
+      "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==",
+      "dev": true
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",
+      "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        }
+      ],
+      "dependencies": {
+        "escalade": "^3.1.1",
+        "picocolors": "^1.0.0"
+      },
+      "bin": {
+        "browserslist-lint": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/upper-case": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz",
+      "integrity": "sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==",
+      "dev": true
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/url-parse": {
+      "version": "1.5.10",
+      "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+      "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+      "dependencies": {
+        "querystringify": "^2.1.1",
+        "requires-port": "^1.0.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+    },
+    "node_modules/uuid": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
+      "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "optional": true
+    },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "optional": true,
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/which-module": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
+      "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==",
+      "devOptional": true
+    },
+    "node_modules/wide-align": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+      "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+      "optional": true,
+      "dependencies": {
+        "string-width": "^1.0.2 || 2 || 3 || 4"
+      }
+    },
+    "node_modules/word-wrap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/workerpool": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz",
+      "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==",
+      "dev": true
+    },
+    "node_modules/wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
+    },
+    "node_modules/write-file-atomic": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+      "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
+      "dev": true,
+      "dependencies": {
+        "imurmurhash": "^0.1.4",
+        "is-typedarray": "^1.0.0",
+        "signal-exit": "^3.0.2",
+        "typedarray-to-buffer": "^3.1.5"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "engines": {
+        "node": ">=0.4"
+      }
+    },
+    "node_modules/y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true
+    },
+    "node_modules/yargs": {
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+      "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+      "dev": true,
+      "dependencies": {
+        "cliui": "^7.0.2",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.0",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^20.2.2"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "20.2.4",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz",
+      "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs-unparser": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz",
+      "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==",
+      "dev": true,
+      "dependencies": {
+        "camelcase": "^6.0.0",
+        "decamelize": "^4.0.0",
+        "flat": "^5.0.2",
+        "is-plain-obj": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs-unparser/node_modules/camelcase": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+      "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/yargs-unparser/node_modules/decamelize": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz",
+      "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    }
+  },
+  "dependencies": {
+    "@acuminous/bitsyntax": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/@acuminous/bitsyntax/-/bitsyntax-0.1.2.tgz",
+      "integrity": "sha512-29lUK80d1muEQqiUsSo+3A0yP6CdspgC95EnKBMi22Xlwt79i/En4Vr67+cXhU+cZjbti3TgGGC5wy1stIywVQ==",
+      "requires": {
+        "buffer-more-ints": "~1.0.0",
+        "debug": "^4.3.4",
+        "safe-buffer": "~5.1.2"
+      }
+    },
+    "@ampproject/remapping": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
+      "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==",
+      "dev": true,
+      "requires": {
+        "@jridgewell/gen-mapping": "^0.1.0",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      }
+    },
+    "@babel/code-frame": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz",
+      "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==",
+      "dev": true,
+      "requires": {
+        "@babel/highlight": "^7.18.6"
+      }
+    },
+    "@babel/compat-data": {
+      "version": "7.20.10",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.10.tgz",
+      "integrity": "sha512-sEnuDPpOJR/fcafHMjpcpGN5M2jbUGUHwmuWKM/YdPzeEDJg8bgmbcWQFUfE32MQjti1koACvoPVsDe8Uq+idg==",
+      "dev": true
+    },
+    "@babel/core": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.20.7.tgz",
+      "integrity": "sha512-t1ZjCluspe5DW24bn2Rr1CDb2v9rn/hROtg9a2tmd0+QYf4bsloYfLQzjG4qHPNMhWtKdGC33R5AxGR2Af2cBw==",
+      "dev": true,
+      "requires": {
+        "@ampproject/remapping": "^2.1.0",
+        "@babel/code-frame": "^7.18.6",
+        "@babel/generator": "^7.20.7",
+        "@babel/helper-compilation-targets": "^7.20.7",
+        "@babel/helper-module-transforms": "^7.20.7",
+        "@babel/helpers": "^7.20.7",
+        "@babel/parser": "^7.20.7",
+        "@babel/template": "^7.20.7",
+        "@babel/traverse": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "convert-source-map": "^1.7.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.2.1",
+        "semver": "^6.3.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "dev": true
+        }
+      }
+    },
+    "@babel/generator": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.20.7.tgz",
+      "integrity": "sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.20.7",
+        "@jridgewell/gen-mapping": "^0.3.2",
+        "jsesc": "^2.5.1"
+      },
+      "dependencies": {
+        "@jridgewell/gen-mapping": {
+          "version": "0.3.2",
+          "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+          "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+          "dev": true,
+          "requires": {
+            "@jridgewell/set-array": "^1.0.1",
+            "@jridgewell/sourcemap-codec": "^1.4.10",
+            "@jridgewell/trace-mapping": "^0.3.9"
+          }
+        }
+      }
+    },
+    "@babel/helper-compilation-targets": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.7.tgz",
+      "integrity": "sha512-4tGORmfQcrc+bvrjb5y3dG9Mx1IOZjsHqQVUz7XCNHO+iTmqxWnVg3KRygjGmpRLJGdQSKuvFinbIb0CnZwHAQ==",
+      "dev": true,
+      "requires": {
+        "@babel/compat-data": "^7.20.5",
+        "@babel/helper-validator-option": "^7.18.6",
+        "browserslist": "^4.21.3",
+        "lru-cache": "^5.1.1",
+        "semver": "^6.3.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "dev": true
+        }
+      }
+    },
+    "@babel/helper-environment-visitor": {
+      "version": "7.18.9",
+      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz",
+      "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==",
+      "dev": true
+    },
+    "@babel/helper-function-name": {
+      "version": "7.19.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz",
+      "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==",
+      "dev": true,
+      "requires": {
+        "@babel/template": "^7.18.10",
+        "@babel/types": "^7.19.0"
+      }
+    },
+    "@babel/helper-hoist-variables": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz",
+      "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.18.6"
+      }
+    },
+    "@babel/helper-module-imports": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
+      "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.18.6"
+      }
+    },
+    "@babel/helper-module-transforms": {
+      "version": "7.20.11",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.20.11.tgz",
+      "integrity": "sha512-uRy78kN4psmji1s2QtbtcCSaj/LILFDp0f/ymhpQH5QY3nljUZCaNWz9X1dEj/8MBdBEFECs7yRhKn8i7NjZgg==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-environment-visitor": "^7.18.9",
+        "@babel/helper-module-imports": "^7.18.6",
+        "@babel/helper-simple-access": "^7.20.2",
+        "@babel/helper-split-export-declaration": "^7.18.6",
+        "@babel/helper-validator-identifier": "^7.19.1",
+        "@babel/template": "^7.20.7",
+        "@babel/traverse": "^7.20.10",
+        "@babel/types": "^7.20.7"
+      }
+    },
+    "@babel/helper-simple-access": {
+      "version": "7.20.2",
+      "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz",
+      "integrity": "sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.20.2"
+      }
+    },
+    "@babel/helper-split-export-declaration": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz",
+      "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.18.6"
+      }
+    },
+    "@babel/helper-string-parser": {
+      "version": "7.19.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
+      "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
+      "dev": true
+    },
+    "@babel/helper-validator-identifier": {
+      "version": "7.19.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+      "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
+      "dev": true
+    },
+    "@babel/helper-validator-option": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz",
+      "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==",
+      "dev": true
+    },
+    "@babel/helpers": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.20.7.tgz",
+      "integrity": "sha512-PBPjs5BppzsGaxHQCDKnZ6Gd9s6xl8bBCluz3vEInLGRJmnZan4F6BYCeqtyXqkk4W5IlPmjK4JlOuZkpJ3xZA==",
+      "dev": true,
+      "requires": {
+        "@babel/template": "^7.20.7",
+        "@babel/traverse": "^7.20.7",
+        "@babel/types": "^7.20.7"
+      }
+    },
+    "@babel/highlight": {
+      "version": "7.18.6",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
+      "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-validator-identifier": "^7.18.6",
+        "chalk": "^2.0.0",
+        "js-tokens": "^4.0.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "3.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+          "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^1.9.0"
+          }
+        },
+        "chalk": {
+          "version": "2.4.2",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+          "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^3.2.1",
+            "escape-string-regexp": "^1.0.5",
+            "supports-color": "^5.3.0"
+          }
+        },
+        "color-convert": {
+          "version": "1.9.3",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+          "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+          "dev": true,
+          "requires": {
+            "color-name": "1.1.3"
+          }
+        },
+        "color-name": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+          "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+          "dev": true
+        },
+        "escape-string-regexp": {
+          "version": "1.0.5",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+          "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+          "dev": true
+        },
+        "has-flag": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+          "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
+    "@babel/parser": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.7.tgz",
+      "integrity": "sha512-T3Z9oHybU+0vZlY9CiDSJQTD5ZapcW18ZctFMi0MOAl/4BjFF4ul7NVSARLdbGO5vDqy9eQiGTV0LtKfvCYvcg==",
+      "dev": true
+    },
+    "@babel/template": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz",
+      "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.18.6",
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7"
+      }
+    },
+    "@babel/traverse": {
+      "version": "7.20.10",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.20.10.tgz",
+      "integrity": "sha512-oSf1juCgymrSez8NI4A2sr4+uB/mFd9MXplYGPEBnfAuWmmyeVcHa6xLPiaRBcXkcb/28bgxmQLTVwFKE1yfsg==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.18.6",
+        "@babel/generator": "^7.20.7",
+        "@babel/helper-environment-visitor": "^7.18.9",
+        "@babel/helper-function-name": "^7.19.0",
+        "@babel/helper-hoist-variables": "^7.18.6",
+        "@babel/helper-split-export-declaration": "^7.18.6",
+        "@babel/parser": "^7.20.7",
+        "@babel/types": "^7.20.7",
+        "debug": "^4.1.0",
+        "globals": "^11.1.0"
+      },
+      "dependencies": {
+        "globals": {
+          "version": "11.12.0",
+          "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+          "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+          "dev": true
+        }
+      }
+    },
+    "@babel/types": {
+      "version": "7.20.7",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.7.tgz",
+      "integrity": "sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-string-parser": "^7.19.4",
+        "@babel/helper-validator-identifier": "^7.19.1",
+        "to-fast-properties": "^2.0.0"
+      }
+    },
+    "@eslint/eslintrc": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.0.tgz",
+      "integrity": "sha512-7yfvXy6MWLgWSFsLhz5yH3iQ52St8cdUY6FoGieKkRDVxuxmrNuUetIuu6cmjNWwniUHiWXjxCr5tTXDrbYS5A==",
+      "dev": true,
+      "requires": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^9.4.0",
+        "globals": "^13.19.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      }
+    },
+    "@humanwhocodes/config-array": {
+      "version": "0.11.8",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
+      "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
+      "dev": true,
+      "requires": {
+        "@humanwhocodes/object-schema": "^1.2.1",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.5"
+      }
+    },
+    "@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true
+    },
+    "@humanwhocodes/object-schema": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+      "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+      "dev": true
+    },
+    "@istanbuljs/load-nyc-config": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+      "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+      "dev": true,
+      "requires": {
+        "camelcase": "^5.3.1",
+        "find-up": "^4.1.0",
+        "get-package-type": "^0.1.0",
+        "js-yaml": "^3.13.1",
+        "resolve-from": "^5.0.0"
+      },
+      "dependencies": {
+        "argparse": {
+          "version": "1.0.10",
+          "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+          "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+          "dev": true,
+          "requires": {
+            "sprintf-js": "~1.0.2"
+          }
+        },
+        "find-up": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+          "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+          "dev": true,
+          "requires": {
+            "locate-path": "^5.0.0",
+            "path-exists": "^4.0.0"
+          }
+        },
+        "js-yaml": {
+          "version": "3.14.1",
+          "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+          "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+          "dev": true,
+          "requires": {
+            "argparse": "^1.0.7",
+            "esprima": "^4.0.0"
+          }
+        },
+        "locate-path": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+          "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+          "dev": true,
+          "requires": {
+            "p-locate": "^4.1.0"
+          }
+        },
+        "p-limit": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+          "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+          "dev": true,
+          "requires": {
+            "p-try": "^2.0.0"
+          }
+        },
+        "p-locate": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+          "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+          "dev": true,
+          "requires": {
+            "p-limit": "^2.2.0"
+          }
+        },
+        "resolve-from": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+          "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+          "dev": true
+        },
+        "sprintf-js": {
+          "version": "1.0.3",
+          "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+          "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+          "dev": true
+        }
+      }
+    },
+    "@istanbuljs/schema": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+      "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+      "dev": true
+    },
+    "@jridgewell/gen-mapping": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
+      "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==",
+      "dev": true,
+      "requires": {
+        "@jridgewell/set-array": "^1.0.0",
+        "@jridgewell/sourcemap-codec": "^1.4.10"
+      }
+    },
+    "@jridgewell/resolve-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
+      "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==",
+      "dev": true
+    },
+    "@jridgewell/set-array": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+      "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+      "dev": true
+    },
+    "@jridgewell/sourcemap-codec": {
+      "version": "1.4.14",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
+      "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
+      "dev": true
+    },
+    "@jridgewell/trace-mapping": {
+      "version": "0.3.17",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz",
+      "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==",
+      "dev": true,
+      "requires": {
+        "@jridgewell/resolve-uri": "3.1.0",
+        "@jridgewell/sourcemap-codec": "1.4.14"
+      }
+    },
+    "@mapbox/node-pre-gyp": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz",
+      "integrity": "sha512-4ySo4CjzStuprMwk35H5pPbkymjv1SF3jGLj6rAHp/xT/RF7TL7bd9CTm1xDY49K2qF7jmR/g7k+SkLETP6opA==",
+      "optional": true,
+      "requires": {
+        "detect-libc": "^2.0.0",
+        "https-proxy-agent": "^5.0.0",
+        "make-dir": "^3.1.0",
+        "node-fetch": "^2.6.7",
+        "nopt": "^5.0.0",
+        "npmlog": "^5.0.1",
+        "rimraf": "^3.0.2",
+        "semver": "^7.3.5",
+        "tar": "^6.1.11"
+      }
+    },
+    "@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      }
+    },
+    "@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true
+    },
+    "@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      }
+    },
+    "@phc/format": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
+      "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
+      "optional": true
+    },
+    "@sinonjs/commons": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz",
+      "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==",
+      "dev": true,
+      "requires": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "@sinonjs/fake-timers": {
+      "version": "10.0.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.0.2.tgz",
+      "integrity": "sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^2.0.0"
+      }
+    },
+    "@sinonjs/samsam": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-7.0.1.tgz",
+      "integrity": "sha512-zsAk2Jkiq89mhZovB2LLOdTCxJF4hqqTToGP0ASWlhp4I1hqOjcfmZGafXntCN7MDC6yySH0mFHrYtHceOeLmw==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^2.0.0",
+        "lodash.get": "^4.4.2",
+        "type-detect": "^4.0.8"
+      }
+    },
+    "@sinonjs/text-encoding": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz",
+      "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==",
+      "dev": true
+    },
+    "@squeep/amqp-helper": {
+      "version": "git+https://git.squeep.com/squeep-amqp-helper#174280d3f44ba13dac0b26d42d968189a4f4fa93",
+      "from": "@squeep/amqp-helper@git+https://git.squeep.com/squeep-amqp-helper#v1.0.0",
+      "requires": {
+        "amqplib": "^0.10.3"
+      }
+    },
+    "@squeep/api-dingus": {
+      "version": "git+https://git.squeep.com/squeep-api-dingus/#3b15b5ff792fc5d61be8337989058c297460cd99",
+      "from": "@squeep/api-dingus@git+https://git.squeep.com/squeep-api-dingus/#v1.2.9",
+      "requires": {
+        "mime-db": "^1.52.0",
+        "uuid": "^9.0.0"
+      }
+    },
+    "@squeep/authentication-module": {
+      "version": "git+https://git.squeep.com/squeep-authentication-module/#9a7d5352698481c0857ba8827e31c7cb97625133",
+      "from": "@squeep/authentication-module@git+https://git.squeep.com/squeep-authentication-module/#v1.2.12",
+      "requires": {
+        "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.2.9",
+        "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.4.0",
+        "@squeep/indieauth-helper": "git+https://git.squeep.com/squeep-indieauth-helper/#v1.1.7",
+        "@squeep/mystery-box": "^1.2.0",
+        "argon2": "^0.30.1",
+        "node-linux-pam": "^0.2.1"
+      }
+    },
+    "@squeep/base64url": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/@squeep/base64url/-/base64url-1.0.5.tgz",
+      "integrity": "sha512-J1UBXYQ4tBuHGnFfg0MdmxCP3oEti7jJWK/qBsg520d8tZd10sL6TXiMDGdBiH9GD3OtlfSYf6wWIN4QfdW21A=="
+    },
+    "@squeep/chores": {
+      "version": "git+https://git.squeep.com/squeep-chores/#d98a3c114eb33bd68477c0ca750e6f82b4d02964",
+      "from": "@squeep/chores@git+https://git.squeep.com/squeep-chores/#v1.0.0"
+    },
+    "@squeep/html-template-helper": {
+      "version": "git+https://git.squeep.com/squeep-html-template-helper#100046316a87631fb8814f80b35647709e6c7319",
+      "from": "@squeep/html-template-helper@git+https://git.squeep.com/squeep-html-template-helper#v1.4.0",
+      "requires": {
+        "@squeep/lazy-property": "^1.1.2"
+      }
+    },
+    "@squeep/indieauth-helper": {
+      "version": "git+https://git.squeep.com/squeep-indieauth-helper/#9d77dc15cee59356a4a4f7935db6a450b04a083c",
+      "from": "@squeep/indieauth-helper@git+https://git.squeep.com/squeep-indieauth-helper/#v1.2.2",
+      "requires": {
+        "@squeep/base64url": "^1.0.5",
+        "@squeep/web-linking": "^1.0.7",
+        "axios": "^1.2.1",
+        "iconv": "^3.0.1",
+        "ip-address": "^8.1.0",
+        "microformats-parser": "^1.4.1"
+      }
+    },
+    "@squeep/lazy-property": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@squeep/lazy-property/-/lazy-property-1.1.2.tgz",
+      "integrity": "sha512-wRdR4IOqWXoDMArx0HPo5MtM2Wk5wemAULbZ6PabVw1ylSQekkzKfoAUuupxsKuzjcRPjZvbpGDv+i04hBMnQw=="
+    },
+    "@squeep/logger-json-console": {
+      "version": "git+https://git.squeep.com/squeep-logger-json-console#dbff0fa5f018f7a302f73250e55f761c0ccf24b1",
+      "from": "@squeep/logger-json-console@git+https://git.squeep.com/squeep-logger-json-console#v1.0.2"
+    },
+    "@squeep/mystery-box": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@squeep/mystery-box/-/mystery-box-1.2.0.tgz",
+      "integrity": "sha512-HYxsF+mv2yrOsgQO2am2i07SRgp5L4o0CySmlM3rIsVWJXlywVPGk9xZFpKtk49BXCGxopWfvtPhpM8U0roaRg==",
+      "requires": {
+        "@squeep/base64url": "^1.0.5"
+      }
+    },
+    "@squeep/resource-authentication-module": {
+      "version": "git+https://git.squeep.com/squeep-resource-authentication-module#69a2f5e7d73dd3f58e07b652c306daa8b253245d",
+      "from": "@squeep/resource-authentication-module@git+https://git.squeep.com/squeep-resource-authentication-module#v1.0.0",
+      "requires": {
+        "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.2.8",
+        "@squeep/base64url": "^1.0.5",
+        "uuid": "^9.0.0"
+      }
+    },
+    "@squeep/roman": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@squeep/roman/-/roman-1.0.0.tgz",
+      "integrity": "sha512-D02jDw/we8tc6QiCPor7tWVviY8MLITyp/egqp3XqrrKtsFMYdguAhaFKUmIBu1ZL1uPKgoLBOy8hIptmh8cWA=="
+    },
+    "@squeep/test-helper": {
+      "version": "git+https://git.squeep.com/squeep-test-helper#7a5a384abb99757b53c8898c508023f0ba9e94b1",
+      "dev": true,
+      "from": "@squeep/test-helper@git+https://git.squeep.com/squeep-test-helper#v1.0.0",
+      "requires": {
+        "eslint": "^8.23.1",
+        "eslint-plugin-node": "^11.1.0",
+        "eslint-plugin-security": "^1.5.0",
+        "eslint-plugin-sonarjs": "^0.15.0",
+        "mocha": "^10.0.0",
+        "nyc": "^15.1.0",
+        "pre-commit": "^1.2.2",
+        "sinon": "^14.0.0"
+      },
+      "dependencies": {
+        "@sinonjs/fake-timers": {
+          "version": "9.1.2",
+          "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz",
+          "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==",
+          "dev": true,
+          "requires": {
+            "@sinonjs/commons": "^1.7.0"
+          },
+          "dependencies": {
+            "@sinonjs/commons": {
+              "version": "1.8.6",
+              "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz",
+              "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==",
+              "dev": true,
+              "requires": {
+                "type-detect": "4.0.8"
+              }
+            }
+          }
+        },
+        "eslint-plugin-sonarjs": {
+          "version": "0.15.0",
+          "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.15.0.tgz",
+          "integrity": "sha512-LuxHdAe6VqSbi1phsUvNjbmXLuvlobmryQJJNyQYbdubCfz6K8tmgoqNiJPnz0pP2AbYDbtuPm0ajOMgMrC+dQ==",
+          "dev": true,
+          "requires": {}
+        },
+        "sinon": {
+          "version": "14.0.2",
+          "resolved": "https://registry.npmjs.org/sinon/-/sinon-14.0.2.tgz",
+          "integrity": "sha512-PDpV0ZI3ZCS3pEqx0vpNp6kzPhHrLx72wA0G+ZLaaJjLIYeE0n8INlgaohKuGy7hP0as5tbUd23QWu5U233t+w==",
+          "dev": true,
+          "requires": {
+            "@sinonjs/commons": "^2.0.0",
+            "@sinonjs/fake-timers": "^9.1.2",
+            "@sinonjs/samsam": "^7.0.1",
+            "diff": "^5.0.0",
+            "nise": "^5.1.2",
+            "supports-color": "^7.2.0"
+          }
+        }
+      }
+    },
+    "@squeep/web-linking": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/@squeep/web-linking/-/web-linking-1.0.7.tgz",
+      "integrity": "sha512-9d3QijrWc/WNE7p/K7NLUHbmPf92CURRqfzDLV0cGqYNA4QWAPfzwC8hxWpdUkUnep3KakvLKK60l0kEBMM3ag=="
+    },
+    "abbrev": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+      "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+      "optional": true
+    },
+    "acorn": {
+      "version": "8.8.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz",
+      "integrity": "sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==",
+      "dev": true
+    },
+    "acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "requires": {}
+    },
+    "agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "optional": true,
+      "requires": {
+        "debug": "4"
+      }
+    },
+    "aggregate-error": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+      "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+      "dev": true,
+      "requires": {
+        "clean-stack": "^2.0.0",
+        "indent-string": "^4.0.0"
+      }
+    },
+    "ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "requires": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "amqplib": {
+      "version": "0.10.3",
+      "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.3.tgz",
+      "integrity": "sha512-UHmuSa7n8vVW/a5HGh2nFPqAEr8+cD4dEZ6u9GjP91nHfr1a54RyAKyra7Sb5NH7NBKOUlyQSMXIp0qAixKexw==",
+      "requires": {
+        "@acuminous/bitsyntax": "^0.1.2",
+        "buffer-more-ints": "~1.0.0",
+        "readable-stream": "1.x >=1.1.9",
+        "url-parse": "~1.5.10"
+      }
+    },
+    "ansi-colors": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+      "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+      "dev": true
+    },
+    "ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "devOptional": true
+    },
+    "ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "devOptional": true,
+      "requires": {
+        "color-convert": "^2.0.1"
+      }
+    },
+    "anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "requires": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      }
+    },
+    "append-transform": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz",
+      "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==",
+      "dev": true,
+      "requires": {
+        "default-require-extensions": "^3.0.0"
+      }
+    },
+    "aproba": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+      "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+      "optional": true
+    },
+    "archy": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
+      "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==",
+      "dev": true
+    },
+    "are-we-there-yet": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+      "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+      "optional": true,
+      "requires": {
+        "delegates": "^1.0.0",
+        "readable-stream": "^3.6.0"
+      },
+      "dependencies": {
+        "readable-stream": {
+          "version": "3.6.0",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+          "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+          "optional": true,
+          "requires": {
+            "inherits": "^2.0.3",
+            "string_decoder": "^1.1.1",
+            "util-deprecate": "^1.0.1"
+          }
+        },
+        "safe-buffer": {
+          "version": "5.2.1",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+          "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+          "optional": true
+        },
+        "string_decoder": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+          "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+          "optional": true,
+          "requires": {
+            "safe-buffer": "~5.2.0"
+          }
+        }
+      }
+    },
+    "argon2": {
+      "version": "0.30.2",
+      "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.30.2.tgz",
+      "integrity": "sha512-RBbXTUsrJUQH259/72CCJxQa0hV961pV4PyZ7R1czGkArSsQP4DToCS2axmNfHywXaBNEMPWMW6rM82EArulYA==",
+      "optional": true,
+      "requires": {
+        "@mapbox/node-pre-gyp": "^1.0.10",
+        "@phc/format": "^1.0.0",
+        "node-addon-api": "^5.0.0"
+      }
+    },
+    "argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true
+    },
+    "assert-options": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.8.0.tgz",
+      "integrity": "sha512-qSELrEaEz4sGwTs4Qh+swQkjiHAysC4rot21+jzXU86dJzNG+FDqBzyS3ohSoTRf4ZLA3FSwxQdiuNl5NXUtvA=="
+    },
+    "asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
+    "axios": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.2.1.tgz",
+      "integrity": "sha512-I88cFiGu9ryt/tfVEi4kX2SITsvDddTajXTOFmt2uK1ZVA8LytjtdeyefdQWEf5PU8w+4SSJDoYnggflB5tW4A==",
+      "requires": {
+        "follow-redirects": "^1.15.0",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "devOptional": true
+    },
+    "base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
+    },
+    "better-sqlite3": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.0.1.tgz",
+      "integrity": "sha512-JhTZjpyapA1icCEjIZB4TSSgkGdFgpWZA2Wszg7Cf4JwJwKQmbvuNnJBeR+EYG/Z29OXvR4G//Rbg31BW/Z7Yg==",
+      "requires": {
+        "bindings": "^1.5.0",
+        "prebuild-install": "^7.1.0"
+      }
+    },
+    "binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "dev": true
+    },
+    "bindings": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+      "requires": {
+        "file-uri-to-path": "1.0.0"
+      }
+    },
+    "bl": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "requires": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      },
+      "dependencies": {
+        "readable-stream": {
+          "version": "3.6.0",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+          "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+          "requires": {
+            "inherits": "^2.0.3",
+            "string_decoder": "^1.1.1",
+            "util-deprecate": "^1.0.1"
+          }
+        },
+        "safe-buffer": {
+          "version": "5.2.1",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+          "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
+        },
+        "string_decoder": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+          "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+          "requires": {
+            "safe-buffer": "~5.2.0"
+          }
+        }
+      }
+    },
+    "brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "devOptional": true,
+      "requires": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dev": true,
+      "requires": {
+        "fill-range": "^7.0.1"
+      }
+    },
+    "browser-stdout": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
+      "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
+      "dev": true
+    },
+    "browserslist": {
+      "version": "4.21.4",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz",
+      "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==",
+      "dev": true,
+      "requires": {
+        "caniuse-lite": "^1.0.30001400",
+        "electron-to-chromium": "^1.4.251",
+        "node-releases": "^2.0.6",
+        "update-browserslist-db": "^1.0.9"
+      }
+    },
+    "buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "requires": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "dev": true
+    },
+    "buffer-more-ints": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz",
+      "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg=="
+    },
+    "buffer-writer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
+      "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw=="
+    },
+    "caching-transform": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz",
+      "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==",
+      "dev": true,
+      "requires": {
+        "hasha": "^5.0.0",
+        "make-dir": "^3.0.0",
+        "package-hash": "^4.0.0",
+        "write-file-atomic": "^3.0.0"
+      }
+    },
+    "callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true
+    },
+    "camel-case": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-3.0.0.tgz",
+      "integrity": "sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==",
+      "dev": true,
+      "requires": {
+        "no-case": "^2.2.0",
+        "upper-case": "^1.1.1"
+      }
+    },
+    "camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "devOptional": true
+    },
+    "caniuse-lite": {
+      "version": "1.0.30001441",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001441.tgz",
+      "integrity": "sha512-OyxRR4Vof59I3yGWXws6i908EtGbMzVUi3ganaZQHmydk1iwDhRnvaPG2WaR0KcqrDFKrxVZHULT396LEPhXfg==",
+      "dev": true
+    },
+    "chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      }
+    },
+    "chokidar": {
+      "version": "3.5.3",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
+      "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
+      "dev": true,
+      "requires": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "fsevents": "~2.3.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "dependencies": {
+        "glob-parent": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+          "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+          "dev": true,
+          "requires": {
+            "is-glob": "^4.0.1"
+          }
+        }
+      }
+    },
+    "chownr": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+      "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+      "optional": true
+    },
+    "clean-css": {
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+      "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
+      "dev": true,
+      "requires": {
+        "source-map": "~0.6.0"
+      }
+    },
+    "clean-stack": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+      "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+      "dev": true
+    },
+    "cliui": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+      "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+      "dev": true,
+      "requires": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "code-point-at": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+      "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==",
+      "optional": true
+    },
+    "color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "devOptional": true,
+      "requires": {
+        "color-name": "~1.1.4"
+      }
+    },
+    "color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "devOptional": true
+    },
+    "color-support": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+      "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+      "optional": true
+    },
+    "combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "requires": {
+        "delayed-stream": "~1.0.0"
+      }
+    },
+    "commander": {
+      "version": "2.17.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz",
+      "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
+      "dev": true
+    },
+    "commondir": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+      "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+      "dev": true
+    },
+    "concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "devOptional": true
+    },
+    "concat-stream": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+      "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+      "dev": true,
+      "requires": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^2.2.2",
+        "typedarray": "^0.0.6"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+          "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+          "dev": true
+        },
+        "readable-stream": {
+          "version": "2.3.7",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+          "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+          "dev": true,
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.3",
+            "isarray": "~1.0.0",
+            "process-nextick-args": "~2.0.0",
+            "safe-buffer": "~5.1.1",
+            "string_decoder": "~1.1.1",
+            "util-deprecate": "~1.0.1"
+          }
+        },
+        "string_decoder": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+          "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+          "dev": true,
+          "requires": {
+            "safe-buffer": "~5.1.0"
+          }
+        }
+      }
+    },
+    "console-control-strings": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+      "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+      "optional": true
+    },
+    "convert-source-map": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+      "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+      "dev": true
+    },
+    "core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
+    },
+    "cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "requires": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      }
+    },
+    "debug": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+      "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+      "requires": {
+        "ms": "2.1.2"
+      }
+    },
+    "decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+      "devOptional": true
+    },
+    "decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "requires": {
+        "mimic-response": "^3.1.0"
+      }
+    },
+    "deep-extend": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="
+    },
+    "deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
+    "default-require-extensions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz",
+      "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==",
+      "dev": true,
+      "requires": {
+        "strip-bom": "^4.0.0"
+      }
+    },
+    "delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
+    },
+    "delegates": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+      "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+      "optional": true
+    },
+    "detect-libc": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz",
+      "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w=="
+    },
+    "diff": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
+      "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==",
+      "dev": true
+    },
+    "doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "requires": {
+        "esutils": "^2.0.2"
+      }
+    },
+    "electron-to-chromium": {
+      "version": "1.4.284",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz",
+      "integrity": "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==",
+      "dev": true
+    },
+    "emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "devOptional": true
+    },
+    "end-of-stream": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+      "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+      "requires": {
+        "once": "^1.4.0"
+      }
+    },
+    "es6-error": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
+      "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+      "dev": true
+    },
+    "escalade": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+      "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+      "dev": true
+    },
+    "escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true
+    },
+    "eslint": {
+      "version": "8.30.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.30.0.tgz",
+      "integrity": "sha512-MGADB39QqYuzEGov+F/qb18r4i7DohCDOfatHaxI2iGlPuC65bwG2gxgO+7DkyL38dRFaRH7RaRAgU6JKL9rMQ==",
+      "dev": true,
+      "requires": {
+        "@eslint/eslintrc": "^1.4.0",
+        "@humanwhocodes/config-array": "^0.11.8",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@nodelib/fs.walk": "^1.2.8",
+        "ajv": "^6.10.0",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "doctrine": "^3.0.0",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^7.1.1",
+        "eslint-utils": "^3.0.0",
+        "eslint-visitor-keys": "^3.3.0",
+        "espree": "^9.4.0",
+        "esquery": "^1.4.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "globals": "^13.19.0",
+        "grapheme-splitter": "^1.0.4",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "is-path-inside": "^3.0.3",
+        "js-sdsl": "^4.1.4",
+        "js-yaml": "^4.1.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.1",
+        "regexpp": "^3.2.0",
+        "strip-ansi": "^6.0.1",
+        "strip-json-comments": "^3.1.0",
+        "text-table": "^0.2.0"
+      }
+    },
+    "eslint-plugin-es": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz",
+      "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==",
+      "dev": true,
+      "requires": {
+        "eslint-utils": "^2.0.0",
+        "regexpp": "^3.0.0"
+      },
+      "dependencies": {
+        "eslint-utils": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz",
+          "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==",
+          "dev": true,
+          "requires": {
+            "eslint-visitor-keys": "^1.1.0"
+          }
+        },
+        "eslint-visitor-keys": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+          "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-plugin-node": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz",
+      "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==",
+      "dev": true,
+      "requires": {
+        "eslint-plugin-es": "^3.0.0",
+        "eslint-utils": "^2.0.0",
+        "ignore": "^5.1.1",
+        "minimatch": "^3.0.4",
+        "resolve": "^1.10.1",
+        "semver": "^6.1.0"
+      },
+      "dependencies": {
+        "eslint-utils": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz",
+          "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==",
+          "dev": true,
+          "requires": {
+            "eslint-visitor-keys": "^1.1.0"
+          }
+        },
+        "eslint-visitor-keys": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+          "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+          "dev": true
+        },
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-plugin-promise": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.1.tgz",
+      "integrity": "sha512-tjqWDwVZQo7UIPMeDReOpUgHCmCiH+ePnVT+5zVapL0uuHnegBUs2smM13CzOs2Xb5+MHMRFTs9v24yjba4Oig==",
+      "dev": true,
+      "requires": {}
+    },
+    "eslint-plugin-security": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.5.0.tgz",
+      "integrity": "sha512-hAFVwLZ/UeXrlyVD2TDarv/x00CoFVpaY0IUZhKjPjiFxqkuQVixsK4f2rxngeQOqSxi6OUjzJM/jMwKEVjJ8g==",
+      "dev": true,
+      "requires": {
+        "safe-regex": "^2.1.1"
+      }
+    },
+    "eslint-plugin-sonarjs": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.17.0.tgz",
+      "integrity": "sha512-jtGtxI49UbJJeJj7CVRLI3+LLH+y+hkR3GOOwM7vBbci9DEFIRGCWvEd2BJScrzltZ6D6iubukTAfc9cyG7sdw==",
+      "dev": true,
+      "requires": {}
+    },
+    "eslint-scope": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
+      "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+      "dev": true,
+      "requires": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      }
+    },
+    "eslint-utils": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
+      "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
+      "dev": true,
+      "requires": {
+        "eslint-visitor-keys": "^2.0.0"
+      },
+      "dependencies": {
+        "eslint-visitor-keys": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+          "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-visitor-keys": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
+      "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
+      "dev": true
+    },
+    "espree": {
+      "version": "9.4.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz",
+      "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==",
+      "dev": true,
+      "requires": {
+        "acorn": "^8.8.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^3.3.0"
+      }
+    },
+    "esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+      "dev": true
+    },
+    "esquery": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+      "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.1.0"
+      }
+    },
+    "esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.2.0"
+      }
+    },
+    "estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true
+    },
+    "esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true
+    },
+    "expand-template": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="
+    },
+    "fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true
+    },
+    "fastq": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.14.0.tgz",
+      "integrity": "sha512-eR2D+V9/ExcbF9ls441yIuN6TI2ED1Y2ZcA5BmMtJsOkWOFRJQ0Jt0g1UwqXJJVAb+V+umH5Dfr8oh4EVP7VVg==",
+      "dev": true,
+      "requires": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "requires": {
+        "flat-cache": "^3.0.4"
+      }
+    },
+    "file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
+    },
+    "fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dev": true,
+      "requires": {
+        "to-regex-range": "^5.0.1"
+      }
+    },
+    "find-cache-dir": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+      "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
+      "dev": true,
+      "requires": {
+        "commondir": "^1.0.1",
+        "make-dir": "^3.0.2",
+        "pkg-dir": "^4.1.0"
+      }
+    },
+    "find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "requires": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      }
+    },
+    "flat": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+      "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
+      "dev": true
+    },
+    "flat-cache": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+      "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+      "dev": true,
+      "requires": {
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      }
+    },
+    "flatted": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
+      "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
+      "dev": true
+    },
+    "follow-redirects": {
+      "version": "1.15.2",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
+      "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
+    },
+    "foreground-child": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
+      "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
+      "dev": true,
+      "requires": {
+        "cross-spawn": "^7.0.0",
+        "signal-exit": "^3.0.2"
+      }
+    },
+    "form-data": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+      "requires": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      }
+    },
+    "fromentries": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz",
+      "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==",
+      "dev": true
+    },
+    "fs-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
+    },
+    "fs-minipass": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+      "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+      "optional": true,
+      "requires": {
+        "minipass": "^3.0.0"
+      },
+      "dependencies": {
+        "minipass": {
+          "version": "3.3.6",
+          "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+          "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+          "optional": true,
+          "requires": {
+            "yallist": "^4.0.0"
+          }
+        },
+        "yallist": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+          "optional": true
+        }
+      }
+    },
+    "fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "devOptional": true
+    },
+    "fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "optional": true
+    },
+    "function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+      "dev": true
+    },
+    "gauge": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+      "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+      "optional": true,
+      "requires": {
+        "aproba": "^1.0.3 || ^2.0.0",
+        "color-support": "^1.1.2",
+        "console-control-strings": "^1.0.0",
+        "has-unicode": "^2.0.1",
+        "object-assign": "^4.1.1",
+        "signal-exit": "^3.0.0",
+        "string-width": "^4.2.3",
+        "strip-ansi": "^6.0.1",
+        "wide-align": "^1.1.2"
+      }
+    },
+    "gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "dev": true
+    },
+    "get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "devOptional": true
+    },
+    "get-package-type": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+      "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+      "dev": true
+    },
+    "github-from-package": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
+    },
+    "glob": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
+      "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
+      "devOptional": true,
+      "requires": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      }
+    },
+    "glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "requires": {
+        "is-glob": "^4.0.3"
+      }
+    },
+    "globals": {
+      "version": "13.19.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz",
+      "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==",
+      "dev": true,
+      "requires": {
+        "type-fest": "^0.20.2"
+      }
+    },
+    "graceful-fs": {
+      "version": "4.2.10",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
+      "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
+      "dev": true
+    },
+    "grapheme-splitter": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+      "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
+      "dev": true
+    },
+    "has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1"
+      }
+    },
+    "has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true
+    },
+    "has-unicode": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+      "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+      "optional": true
+    },
+    "hasha": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz",
+      "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==",
+      "dev": true,
+      "requires": {
+        "is-stream": "^2.0.0",
+        "type-fest": "^0.8.0"
+      },
+      "dependencies": {
+        "type-fest": {
+          "version": "0.8.1",
+          "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+          "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+          "dev": true
+        }
+      }
+    },
+    "he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true
+    },
+    "html-escaper": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+      "dev": true
+    },
+    "html-minifier": {
+      "version": "3.5.21",
+      "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-3.5.21.tgz",
+      "integrity": "sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==",
+      "dev": true,
+      "requires": {
+        "camel-case": "3.0.x",
+        "clean-css": "4.2.x",
+        "commander": "2.17.x",
+        "he": "1.2.x",
+        "param-case": "2.1.x",
+        "relateurl": "0.2.x",
+        "uglify-js": "3.4.x"
+      }
+    },
+    "html-minifier-lint": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/html-minifier-lint/-/html-minifier-lint-2.0.0.tgz",
+      "integrity": "sha512-halWZUg/us7Y16irVM90DTdyAUP3ksFthWfFPJTG1jpBaYYyGHt9azTW9H++hZ8LWRArzQm9oIcrfM/o/CO+4A==",
+      "dev": true,
+      "requires": {
+        "html-minifier": "3.x"
+      }
+    },
+    "https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "optional": true,
+      "requires": {
+        "agent-base": "6",
+        "debug": "4"
+      }
+    },
+    "iconv": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/iconv/-/iconv-3.0.1.tgz",
+      "integrity": "sha512-lJnFLxVc0d82R7GfU7a9RujKVUQ3Eee19tPKWZWBJtAEGRHVEyFzCtbNl3GPKuDnHBBRT4/nDS4Ru9AIDT72qA=="
+    },
+    "ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
+    },
+    "ignore": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
+      "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+      "dev": true
+    },
+    "import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "requires": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      }
+    },
+    "imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true
+    },
+    "indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true
+    },
+    "inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "devOptional": true,
+      "requires": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
+    "ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
+    },
+    "ip-address": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-8.1.0.tgz",
+      "integrity": "sha512-Wz91gZKpNKoXtqvY8ScarKYwhXoK4r/b5QuT+uywe/azv0/nUCo7Bh0IRRI7F9DHR06kJNWtzMGLIbXavngbKA==",
+      "requires": {
+        "jsbn": "1.1.0",
+        "sprintf-js": "1.1.2"
+      }
+    },
+    "is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "requires": {
+        "binary-extensions": "^2.0.0"
+      }
+    },
+    "is-core-module": {
+      "version": "2.11.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
+      "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
+      "dev": true,
+      "requires": {
+        "has": "^1.0.3"
+      }
+    },
+    "is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true
+    },
+    "is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "devOptional": true
+    },
+    "is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "requires": {
+        "is-extglob": "^2.1.1"
+      }
+    },
+    "is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true
+    },
+    "is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true
+    },
+    "is-plain-obj": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+      "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+      "dev": true
+    },
+    "is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "dev": true
+    },
+    "is-typedarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+      "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
+      "dev": true
+    },
+    "is-unicode-supported": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+      "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+      "dev": true
+    },
+    "is-windows": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+      "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+      "dev": true
+    },
+    "isarray": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+      "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="
+    },
+    "isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true
+    },
+    "istanbul-lib-coverage": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+      "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+      "dev": true
+    },
+    "istanbul-lib-hook": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz",
+      "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==",
+      "dev": true,
+      "requires": {
+        "append-transform": "^2.0.0"
+      }
+    },
+    "istanbul-lib-instrument": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz",
+      "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==",
+      "dev": true,
+      "requires": {
+        "@babel/core": "^7.7.5",
+        "@istanbuljs/schema": "^0.1.2",
+        "istanbul-lib-coverage": "^3.0.0",
+        "semver": "^6.3.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "dev": true
+        }
+      }
+    },
+    "istanbul-lib-processinfo": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz",
+      "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==",
+      "dev": true,
+      "requires": {
+        "archy": "^1.0.0",
+        "cross-spawn": "^7.0.3",
+        "istanbul-lib-coverage": "^3.2.0",
+        "p-map": "^3.0.0",
+        "rimraf": "^3.0.0",
+        "uuid": "^8.3.2"
+      },
+      "dependencies": {
+        "uuid": {
+          "version": "8.3.2",
+          "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+          "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+          "dev": true
+        }
+      }
+    },
+    "istanbul-lib-report": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+      "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==",
+      "dev": true,
+      "requires": {
+        "istanbul-lib-coverage": "^3.0.0",
+        "make-dir": "^3.0.0",
+        "supports-color": "^7.1.0"
+      }
+    },
+    "istanbul-lib-source-maps": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+      "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
+      "dev": true,
+      "requires": {
+        "debug": "^4.1.1",
+        "istanbul-lib-coverage": "^3.0.0",
+        "source-map": "^0.6.1"
+      }
+    },
+    "istanbul-reports": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz",
+      "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==",
+      "dev": true,
+      "requires": {
+        "html-escaper": "^2.0.0",
+        "istanbul-lib-report": "^3.0.0"
+      }
+    },
+    "js-sdsl": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz",
+      "integrity": "sha512-dyBIzQBDkCqCu+0upx25Y2jGdbTGxE9fshMsCdK0ViOongpV+n5tXRcZY9v7CaVQ79AGS9KA1KHtojxiM7aXSQ==",
+      "dev": true
+    },
+    "js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true
+    },
+    "js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "requires": {
+        "argparse": "^2.0.1"
+      }
+    },
+    "jsbn": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+      "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="
+    },
+    "jsesc": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+      "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+      "dev": true
+    },
+    "json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true
+    },
+    "json5": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.2.tgz",
+      "integrity": "sha512-46Tk9JiOL2z7ytNQWFLpj99RZkVgeHf87yGQKsIkaPz1qSH9UczKH1rO7K3wgRselo0tYMUNfecYpm/p1vC7tQ==",
+      "dev": true
+    },
+    "just-extend": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz",
+      "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==",
+      "dev": true
+    },
+    "levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      }
+    },
+    "locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "requires": {
+        "p-locate": "^5.0.0"
+      }
+    },
+    "lodash.flattendeep": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
+      "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==",
+      "dev": true
+    },
+    "lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
+      "dev": true
+    },
+    "lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "log-symbols": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+      "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+      "dev": true,
+      "requires": {
+        "chalk": "^4.1.0",
+        "is-unicode-supported": "^0.1.0"
+      }
+    },
+    "lower-case": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz",
+      "integrity": "sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==",
+      "dev": true
+    },
+    "lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "dev": true,
+      "requires": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "make-dir": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+      "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+      "devOptional": true,
+      "requires": {
+        "semver": "^6.0.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "devOptional": true
+        }
+      }
+    },
+    "microformats-parser": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-1.4.1.tgz",
+      "integrity": "sha512-BSg9Y/Aik8hvvme/fkxnXMRvTKuVwOeTapeZdaPQ+92DEubyM31iMtwbgFZ1383om643UvfYY5G23E9s1FY2KQ==",
+      "requires": {
+        "parse5": "^6.0.0"
+      }
+    },
+    "mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
+    },
+    "mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "requires": {
+        "mime-db": "1.52.0"
+      }
+    },
+    "mimic-response": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="
+    },
+    "minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "devOptional": true,
+      "requires": {
+        "brace-expansion": "^1.1.7"
+      }
+    },
+    "minimist": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
+      "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g=="
+    },
+    "minipass": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.0.0.tgz",
+      "integrity": "sha512-g2Uuh2jEKoht+zvO6vJqXmYpflPqzRBT+Th2h01DKh5z7wbY/AZ2gCQ78cP70YoHPyFdY30YBV5WxgLOEwOykw==",
+      "optional": true,
+      "requires": {
+        "yallist": "^4.0.0"
+      },
+      "dependencies": {
+        "yallist": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+          "optional": true
+        }
+      }
+    },
+    "minizlib": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+      "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+      "optional": true,
+      "requires": {
+        "minipass": "^3.0.0",
+        "yallist": "^4.0.0"
+      },
+      "dependencies": {
+        "minipass": {
+          "version": "3.3.6",
+          "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+          "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+          "optional": true,
+          "requires": {
+            "yallist": "^4.0.0"
+          }
+        },
+        "yallist": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+          "optional": true
+        }
+      }
+    },
+    "mkdirp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+      "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+      "optional": true
+    },
+    "mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
+    },
+    "mocha": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz",
+      "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==",
+      "dev": true,
+      "requires": {
+        "ansi-colors": "4.1.1",
+        "browser-stdout": "1.3.1",
+        "chokidar": "3.5.3",
+        "debug": "4.3.4",
+        "diff": "5.0.0",
+        "escape-string-regexp": "4.0.0",
+        "find-up": "5.0.0",
+        "glob": "7.2.0",
+        "he": "1.2.0",
+        "js-yaml": "4.1.0",
+        "log-symbols": "4.1.0",
+        "minimatch": "5.0.1",
+        "ms": "2.1.3",
+        "nanoid": "3.3.3",
+        "serialize-javascript": "6.0.0",
+        "strip-json-comments": "3.1.1",
+        "supports-color": "8.1.1",
+        "workerpool": "6.2.1",
+        "yargs": "16.2.0",
+        "yargs-parser": "20.2.4",
+        "yargs-unparser": "2.0.0"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+          "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+          "dev": true,
+          "requires": {
+            "balanced-match": "^1.0.0"
+          }
+        },
+        "minimatch": {
+          "version": "5.0.1",
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+          "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+          "dev": true,
+          "requires": {
+            "brace-expansion": "^2.0.1"
+          }
+        },
+        "ms": {
+          "version": "2.1.3",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+          "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "8.1.1",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+          "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "mocha-steps": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/mocha-steps/-/mocha-steps-1.3.0.tgz",
+      "integrity": "sha512-KZvpMJTqzLZw3mOb+EEuYi4YZS41C9iTnb7skVFRxHjUd1OYbl64tCMSmpdIRM9LnwIrSOaRfPtNpF5msgv6Eg==",
+      "dev": true
+    },
+    "ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "nanoid": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz",
+      "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==",
+      "dev": true
+    },
+    "napi-build-utils": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
+      "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg=="
+    },
+    "natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true
+    },
+    "nise": {
+      "version": "5.1.4",
+      "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.4.tgz",
+      "integrity": "sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^2.0.0",
+        "@sinonjs/fake-timers": "^10.0.2",
+        "@sinonjs/text-encoding": "^0.7.1",
+        "just-extend": "^4.0.2",
+        "path-to-regexp": "^1.7.0"
+      }
+    },
+    "no-case": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz",
+      "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==",
+      "dev": true,
+      "requires": {
+        "lower-case": "^1.1.1"
+      }
+    },
+    "node-abi": {
+      "version": "3.30.0",
+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.30.0.tgz",
+      "integrity": "sha512-qWO5l3SCqbwQavymOmtTVuCWZE23++S+rxyoHjXqUmPyzRcaoI4lA2gO55/drddGnedAyjA7sk76SfQ5lfUMnw==",
+      "requires": {
+        "semver": "^7.3.5"
+      }
+    },
+    "node-addon-api": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.0.0.tgz",
+      "integrity": "sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA==",
+      "optional": true
+    },
+    "node-fetch": {
+      "version": "2.6.7",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
+      "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
+      "optional": true,
+      "requires": {
+        "whatwg-url": "^5.0.0"
+      }
+    },
+    "node-linux-pam": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/node-linux-pam/-/node-linux-pam-0.2.1.tgz",
+      "integrity": "sha512-OeMZW0Bs1bffsvXI/bJQbU0rkiWTOo0ceT6+mrbU84TJ33vAKykIZrLI+ApfRqkBQW5jzW5rJ7x+NSyToafqig==",
+      "optional": true,
+      "requires": {
+        "@mapbox/node-pre-gyp": "1.0.5",
+        "bindings": "1.5.0",
+        "node-addon-api": "3.1.0",
+        "string-template": "1.0.0",
+        "yargs": "15.4.1"
+      },
+      "dependencies": {
+        "@mapbox/node-pre-gyp": {
+          "version": "1.0.5",
+          "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz",
+          "integrity": "sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA==",
+          "optional": true,
+          "requires": {
+            "detect-libc": "^1.0.3",
+            "https-proxy-agent": "^5.0.0",
+            "make-dir": "^3.1.0",
+            "node-fetch": "^2.6.1",
+            "nopt": "^5.0.0",
+            "npmlog": "^4.1.2",
+            "rimraf": "^3.0.2",
+            "semver": "^7.3.4",
+            "tar": "^6.1.0"
+          }
+        },
+        "ansi-regex": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==",
+          "optional": true
+        },
+        "aproba": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+          "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
+          "optional": true
+        },
+        "are-we-there-yet": {
+          "version": "1.1.7",
+          "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
+          "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==",
+          "optional": true,
+          "requires": {
+            "delegates": "^1.0.0",
+            "readable-stream": "^2.0.6"
+          }
+        },
+        "cliui": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+          "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+          "optional": true,
+          "requires": {
+            "string-width": "^4.2.0",
+            "strip-ansi": "^6.0.0",
+            "wrap-ansi": "^6.2.0"
+          },
+          "dependencies": {
+            "ansi-regex": {
+              "version": "5.0.1",
+              "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+              "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+              "optional": true
+            },
+            "strip-ansi": {
+              "version": "6.0.1",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+              "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+              "optional": true,
+              "requires": {
+                "ansi-regex": "^5.0.1"
+              }
+            }
+          }
+        },
+        "detect-libc": {
+          "version": "1.0.3",
+          "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+          "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+          "optional": true
+        },
+        "find-up": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+          "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+          "optional": true,
+          "requires": {
+            "locate-path": "^5.0.0",
+            "path-exists": "^4.0.0"
+          }
+        },
+        "gauge": {
+          "version": "2.7.4",
+          "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+          "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==",
+          "optional": true,
+          "requires": {
+            "aproba": "^1.0.3",
+            "console-control-strings": "^1.0.0",
+            "has-unicode": "^2.0.0",
+            "object-assign": "^4.1.0",
+            "signal-exit": "^3.0.0",
+            "string-width": "^1.0.1",
+            "strip-ansi": "^3.0.1",
+            "wide-align": "^1.1.0"
+          },
+          "dependencies": {
+            "string-width": {
+              "version": "1.0.2",
+              "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+              "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==",
+              "optional": true,
+              "requires": {
+                "code-point-at": "^1.0.0",
+                "is-fullwidth-code-point": "^1.0.0",
+                "strip-ansi": "^3.0.0"
+              }
+            }
+          }
+        },
+        "is-fullwidth-code-point": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+          "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==",
+          "optional": true,
+          "requires": {
+            "number-is-nan": "^1.0.0"
+          }
+        },
+        "isarray": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+          "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+          "optional": true
+        },
+        "locate-path": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+          "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+          "optional": true,
+          "requires": {
+            "p-locate": "^4.1.0"
+          }
+        },
+        "node-addon-api": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.1.0.tgz",
+          "integrity": "sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw==",
+          "optional": true
+        },
+        "npmlog": {
+          "version": "4.1.2",
+          "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+          "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+          "optional": true,
+          "requires": {
+            "are-we-there-yet": "~1.1.2",
+            "console-control-strings": "~1.1.0",
+            "gauge": "~2.7.3",
+            "set-blocking": "~2.0.0"
+          }
+        },
+        "p-limit": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+          "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+          "optional": true,
+          "requires": {
+            "p-try": "^2.0.0"
+          }
+        },
+        "p-locate": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+          "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+          "optional": true,
+          "requires": {
+            "p-limit": "^2.2.0"
+          }
+        },
+        "readable-stream": {
+          "version": "2.3.7",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+          "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+          "optional": true,
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.3",
+            "isarray": "~1.0.0",
+            "process-nextick-args": "~2.0.0",
+            "safe-buffer": "~5.1.1",
+            "string_decoder": "~1.1.1",
+            "util-deprecate": "~1.0.1"
+          }
+        },
+        "string_decoder": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+          "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+          "optional": true,
+          "requires": {
+            "safe-buffer": "~5.1.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==",
+          "optional": true,
+          "requires": {
+            "ansi-regex": "^2.0.0"
+          }
+        },
+        "wrap-ansi": {
+          "version": "6.2.0",
+          "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+          "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+          "optional": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "string-width": "^4.1.0",
+            "strip-ansi": "^6.0.0"
+          },
+          "dependencies": {
+            "ansi-regex": {
+              "version": "5.0.1",
+              "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+              "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+              "optional": true
+            },
+            "strip-ansi": {
+              "version": "6.0.1",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+              "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+              "optional": true,
+              "requires": {
+                "ansi-regex": "^5.0.1"
+              }
+            }
+          }
+        },
+        "y18n": {
+          "version": "4.0.3",
+          "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+          "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+          "optional": true
+        },
+        "yargs": {
+          "version": "15.4.1",
+          "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+          "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+          "optional": true,
+          "requires": {
+            "cliui": "^6.0.0",
+            "decamelize": "^1.2.0",
+            "find-up": "^4.1.0",
+            "get-caller-file": "^2.0.1",
+            "require-directory": "^2.1.1",
+            "require-main-filename": "^2.0.0",
+            "set-blocking": "^2.0.0",
+            "string-width": "^4.2.0",
+            "which-module": "^2.0.0",
+            "y18n": "^4.0.0",
+            "yargs-parser": "^18.1.2"
+          }
+        },
+        "yargs-parser": {
+          "version": "18.1.3",
+          "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+          "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+          "optional": true,
+          "requires": {
+            "camelcase": "^5.0.0",
+            "decamelize": "^1.2.0"
+          }
+        }
+      }
+    },
+    "node-preload": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz",
+      "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==",
+      "dev": true,
+      "requires": {
+        "process-on-spawn": "^1.0.0"
+      }
+    },
+    "node-releases": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.8.tgz",
+      "integrity": "sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==",
+      "dev": true
+    },
+    "nopt": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+      "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+      "optional": true,
+      "requires": {
+        "abbrev": "1"
+      }
+    },
+    "normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true
+    },
+    "npmlog": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+      "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+      "optional": true,
+      "requires": {
+        "are-we-there-yet": "^2.0.0",
+        "console-control-strings": "^1.1.0",
+        "gauge": "^3.0.0",
+        "set-blocking": "^2.0.0"
+      }
+    },
+    "number-is-nan": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+      "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==",
+      "optional": true
+    },
+    "nyc": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz",
+      "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==",
+      "dev": true,
+      "requires": {
+        "@istanbuljs/load-nyc-config": "^1.0.0",
+        "@istanbuljs/schema": "^0.1.2",
+        "caching-transform": "^4.0.0",
+        "convert-source-map": "^1.7.0",
+        "decamelize": "^1.2.0",
+        "find-cache-dir": "^3.2.0",
+        "find-up": "^4.1.0",
+        "foreground-child": "^2.0.0",
+        "get-package-type": "^0.1.0",
+        "glob": "^7.1.6",
+        "istanbul-lib-coverage": "^3.0.0",
+        "istanbul-lib-hook": "^3.0.0",
+        "istanbul-lib-instrument": "^4.0.0",
+        "istanbul-lib-processinfo": "^2.0.2",
+        "istanbul-lib-report": "^3.0.0",
+        "istanbul-lib-source-maps": "^4.0.0",
+        "istanbul-reports": "^3.0.2",
+        "make-dir": "^3.0.0",
+        "node-preload": "^0.2.1",
+        "p-map": "^3.0.0",
+        "process-on-spawn": "^1.0.0",
+        "resolve-from": "^5.0.0",
+        "rimraf": "^3.0.0",
+        "signal-exit": "^3.0.2",
+        "spawn-wrap": "^2.0.0",
+        "test-exclude": "^6.0.0",
+        "yargs": "^15.0.2"
+      },
+      "dependencies": {
+        "cliui": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+          "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+          "dev": true,
+          "requires": {
+            "string-width": "^4.2.0",
+            "strip-ansi": "^6.0.0",
+            "wrap-ansi": "^6.2.0"
+          }
+        },
+        "find-up": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+          "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+          "dev": true,
+          "requires": {
+            "locate-path": "^5.0.0",
+            "path-exists": "^4.0.0"
+          }
+        },
+        "locate-path": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+          "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+          "dev": true,
+          "requires": {
+            "p-locate": "^4.1.0"
+          }
+        },
+        "p-limit": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+          "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+          "dev": true,
+          "requires": {
+            "p-try": "^2.0.0"
+          }
+        },
+        "p-locate": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+          "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+          "dev": true,
+          "requires": {
+            "p-limit": "^2.2.0"
+          }
+        },
+        "resolve-from": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+          "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+          "dev": true
+        },
+        "wrap-ansi": {
+          "version": "6.2.0",
+          "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+          "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "string-width": "^4.1.0",
+            "strip-ansi": "^6.0.0"
+          }
+        },
+        "y18n": {
+          "version": "4.0.3",
+          "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+          "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+          "dev": true
+        },
+        "yargs": {
+          "version": "15.4.1",
+          "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+          "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+          "dev": true,
+          "requires": {
+            "cliui": "^6.0.0",
+            "decamelize": "^1.2.0",
+            "find-up": "^4.1.0",
+            "get-caller-file": "^2.0.1",
+            "require-directory": "^2.1.1",
+            "require-main-filename": "^2.0.0",
+            "set-blocking": "^2.0.0",
+            "string-width": "^4.2.0",
+            "which-module": "^2.0.0",
+            "y18n": "^4.0.0",
+            "yargs-parser": "^18.1.2"
+          }
+        },
+        "yargs-parser": {
+          "version": "18.1.3",
+          "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+          "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+          "dev": true,
+          "requires": {
+            "camelcase": "^5.0.0",
+            "decamelize": "^1.2.0"
+          }
+        }
+      }
+    },
+    "object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "optional": true
+    },
+    "once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "requires": {
+        "wrappy": "1"
+      }
+    },
+    "optionator": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+      "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+      "dev": true,
+      "requires": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.3"
+      }
+    },
+    "os-shim": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz",
+      "integrity": "sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A==",
+      "dev": true
+    },
+    "p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "requires": {
+        "yocto-queue": "^0.1.0"
+      }
+    },
+    "p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "requires": {
+        "p-limit": "^3.0.2"
+      }
+    },
+    "p-map": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz",
+      "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==",
+      "dev": true,
+      "requires": {
+        "aggregate-error": "^3.0.0"
+      }
+    },
+    "p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "devOptional": true
+    },
+    "package-hash": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz",
+      "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==",
+      "dev": true,
+      "requires": {
+        "graceful-fs": "^4.1.15",
+        "hasha": "^5.0.0",
+        "lodash.flattendeep": "^4.4.0",
+        "release-zalgo": "^1.0.0"
+      }
+    },
+    "packet-reader": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
+      "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
+    },
+    "param-case": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
+      "integrity": "sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==",
+      "dev": true,
+      "requires": {
+        "no-case": "^2.2.0"
+      }
+    },
+    "parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "requires": {
+        "callsites": "^3.0.0"
+      }
+    },
+    "parse5": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+      "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
+    },
+    "path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "devOptional": true
+    },
+    "path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "devOptional": true
+    },
+    "path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true
+    },
+    "path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "path-to-regexp": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
+      "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
+      "dev": true,
+      "requires": {
+        "isarray": "0.0.1"
+      }
+    },
+    "pg": {
+      "version": "8.8.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.8.0.tgz",
+      "integrity": "sha512-UXYN0ziKj+AeNNP7VDMwrehpACThH7LUl/p8TDFpEUuSejCUIwGSfxpHsPvtM6/WXFy6SU4E5RG4IJV/TZAGjw==",
+      "requires": {
+        "buffer-writer": "2.0.0",
+        "packet-reader": "1.0.0",
+        "pg-connection-string": "^2.5.0",
+        "pg-pool": "^3.5.2",
+        "pg-protocol": "^1.5.0",
+        "pg-types": "^2.1.0",
+        "pgpass": "1.x"
+      }
+    },
+    "pg-connection-string": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz",
+      "integrity": "sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ=="
+    },
+    "pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="
+    },
+    "pg-minify": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-1.6.2.tgz",
+      "integrity": "sha512-1KdmFGGTP6jplJoI8MfvRlfvMiyBivMRP7/ffh4a11RUFJ7kC2J0ZHlipoKiH/1hz+DVgceon9U2qbaHpPeyPg=="
+    },
+    "pg-pool": {
+      "version": "3.5.2",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.5.2.tgz",
+      "integrity": "sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w==",
+      "requires": {}
+    },
+    "pg-promise": {
+      "version": "10.15.4",
+      "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-10.15.4.tgz",
+      "integrity": "sha512-BKlHCMCdNUmF6gagVbehRWSEiVcZzPVltEx14OJExR9Iz9/1R6KETDWLLGv2l6yRqYFnEZZy1VDjRhArzeIGrw==",
+      "requires": {
+        "assert-options": "0.8.0",
+        "pg": "8.8.0",
+        "pg-minify": "1.6.2",
+        "spex": "3.2.0"
+      }
+    },
+    "pg-protocol": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.5.0.tgz",
+      "integrity": "sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ=="
+    },
+    "pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "requires": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      }
+    },
+    "pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "requires": {
+        "split2": "^4.1.0"
+      }
+    },
+    "picocolors": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+      "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+      "dev": true
+    },
+    "picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true
+    },
+    "pkg-dir": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+      "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+      "dev": true,
+      "requires": {
+        "find-up": "^4.0.0"
+      },
+      "dependencies": {
+        "find-up": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+          "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+          "dev": true,
+          "requires": {
+            "locate-path": "^5.0.0",
+            "path-exists": "^4.0.0"
+          }
+        },
+        "locate-path": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+          "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+          "dev": true,
+          "requires": {
+            "p-locate": "^4.1.0"
+          }
+        },
+        "p-limit": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+          "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+          "dev": true,
+          "requires": {
+            "p-try": "^2.0.0"
+          }
+        },
+        "p-locate": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+          "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+          "dev": true,
+          "requires": {
+            "p-limit": "^2.2.0"
+          }
+        }
+      }
+    },
+    "postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="
+    },
+    "postgres-bytea": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
+      "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="
+    },
+    "postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="
+    },
+    "postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "requires": {
+        "xtend": "^4.0.0"
+      }
+    },
+    "pre-commit": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/pre-commit/-/pre-commit-1.2.2.tgz",
+      "integrity": "sha512-qokTiqxD6GjODy5ETAIgzsRgnBWWQHQH2ghy86PU7mIn/wuWeTwF3otyNQZxWBwVn8XNr8Tdzj/QfUXpH+gRZA==",
+      "dev": true,
+      "requires": {
+        "cross-spawn": "^5.0.1",
+        "spawn-sync": "^1.0.15",
+        "which": "1.2.x"
+      },
+      "dependencies": {
+        "cross-spawn": {
+          "version": "5.1.0",
+          "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
+          "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==",
+          "dev": true,
+          "requires": {
+            "lru-cache": "^4.0.1",
+            "shebang-command": "^1.2.0",
+            "which": "^1.2.9"
+          }
+        },
+        "lru-cache": {
+          "version": "4.1.5",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
+          "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
+          "dev": true,
+          "requires": {
+            "pseudomap": "^1.0.2",
+            "yallist": "^2.1.2"
+          }
+        },
+        "shebang-command": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+          "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==",
+          "dev": true,
+          "requires": {
+            "shebang-regex": "^1.0.0"
+          }
+        },
+        "shebang-regex": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+          "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==",
+          "dev": true
+        },
+        "which": {
+          "version": "1.2.14",
+          "resolved": "https://registry.npmjs.org/which/-/which-1.2.14.tgz",
+          "integrity": "sha512-16uPglFkRPzgiUXYMi1Jf8Z5EzN1iB4V0ZtMXcHZnwsBtQhhHeCqoWw7tsUY42hJGNDWtUsVLTjakIa5BgAxCw==",
+          "dev": true,
+          "requires": {
+            "isexe": "^2.0.0"
+          }
+        },
+        "yallist": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+          "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==",
+          "dev": true
+        }
+      }
+    },
+    "prebuild-install": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
+      "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==",
+      "requires": {
+        "detect-libc": "^2.0.0",
+        "expand-template": "^2.0.3",
+        "github-from-package": "0.0.0",
+        "minimist": "^1.2.3",
+        "mkdirp-classic": "^0.5.3",
+        "napi-build-utils": "^1.0.1",
+        "node-abi": "^3.3.0",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^4.0.0",
+        "tar-fs": "^2.0.0",
+        "tunnel-agent": "^0.6.0"
+      }
+    },
+    "prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true
+    },
+    "process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "devOptional": true
+    },
+    "process-on-spawn": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz",
+      "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==",
+      "dev": true,
+      "requires": {
+        "fromentries": "^1.2.0"
+      }
+    },
+    "proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
+    "pseudomap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
+      "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==",
+      "dev": true
+    },
+    "pump": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+      "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+      "requires": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+      "dev": true
+    },
+    "querystringify": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+      "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
+    },
+    "queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true
+    },
+    "randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "dev": true,
+      "requires": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
+    "rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "requires": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "dependencies": {
+        "strip-json-comments": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+          "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="
+        }
+      }
+    },
+    "readable-stream": {
+      "version": "1.1.14",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+      "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
+      "requires": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.1",
+        "isarray": "0.0.1",
+        "string_decoder": "~0.10.x"
+      }
+    },
+    "readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "requires": {
+        "picomatch": "^2.2.1"
+      }
+    },
+    "regexp-tree": {
+      "version": "0.1.24",
+      "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz",
+      "integrity": "sha512-s2aEVuLhvnVJW6s/iPgEGK6R+/xngd2jNQ+xy4bXNDKxZKJH6jpPHY6kVeVv1IeLCHgswRj+Kl3ELaDjG6V1iw==",
+      "dev": true
+    },
+    "regexpp": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+      "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+      "dev": true
+    },
+    "relateurl": {
+      "version": "0.2.7",
+      "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
+      "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
+      "dev": true
+    },
+    "release-zalgo": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz",
+      "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==",
+      "dev": true,
+      "requires": {
+        "es6-error": "^4.0.1"
+      }
+    },
+    "require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+      "devOptional": true
+    },
+    "require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "devOptional": true
+    },
+    "requires-port": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+      "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
+    },
+    "resolve": {
+      "version": "1.22.1",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
+      "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
+      "dev": true,
+      "requires": {
+        "is-core-module": "^2.9.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      }
+    },
+    "resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true
+    },
+    "reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true
+    },
+    "rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "devOptional": true,
+      "requires": {
+        "glob": "^7.1.3"
+      }
+    },
+    "run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "requires": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "safe-regex": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz",
+      "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==",
+      "dev": true,
+      "requires": {
+        "regexp-tree": "~0.1.1"
+      }
+    },
+    "semver": {
+      "version": "7.3.8",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz",
+      "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==",
+      "requires": {
+        "lru-cache": "^6.0.0"
+      },
+      "dependencies": {
+        "lru-cache": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+          "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+          "requires": {
+            "yallist": "^4.0.0"
+          }
+        },
+        "yallist": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+        }
+      }
+    },
+    "serialize-javascript": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+      "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+      "dev": true,
+      "requires": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+      "devOptional": true
+    },
+    "shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "requires": {
+        "shebang-regex": "^3.0.0"
+      }
+    },
+    "shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true
+    },
+    "signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "devOptional": true
+    },
+    "simple-concat": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="
+    },
+    "simple-get": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+      "requires": {
+        "decompress-response": "^6.0.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
+    "sinon": {
+      "version": "15.0.1",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.0.1.tgz",
+      "integrity": "sha512-PZXKc08f/wcA/BMRGBze2Wmw50CWPiAH3E21EOi4B49vJ616vW4DQh4fQrqsYox2aNR/N3kCqLuB0PwwOucQrg==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^2.0.0",
+        "@sinonjs/fake-timers": "10.0.2",
+        "@sinonjs/samsam": "^7.0.1",
+        "diff": "^5.0.0",
+        "nise": "^5.1.2",
+        "supports-color": "^7.2.0"
+      }
+    },
+    "source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "dev": true
+    },
+    "spawn-sync": {
+      "version": "1.0.15",
+      "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz",
+      "integrity": "sha512-9DWBgrgYZzNghseho0JOuh+5fg9u6QWhAWa51QC7+U5rCheZ/j1DrEZnyE0RBBRqZ9uEXGPgSSM0nky6burpVw==",
+      "dev": true,
+      "requires": {
+        "concat-stream": "^1.4.7",
+        "os-shim": "^0.1.2"
+      }
+    },
+    "spawn-wrap": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz",
+      "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==",
+      "dev": true,
+      "requires": {
+        "foreground-child": "^2.0.0",
+        "is-windows": "^1.0.2",
+        "make-dir": "^3.0.0",
+        "rimraf": "^3.0.0",
+        "signal-exit": "^3.0.2",
+        "which": "^2.0.1"
+      }
+    },
+    "spex": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/spex/-/spex-3.2.0.tgz",
+      "integrity": "sha512-9srjJM7NaymrpwMHvSmpDeIK5GoRMX/Tq0E8aOlDPS54dDnDUIp30DrP9SphMPEETDLzEM9+4qo+KipmbtPecg=="
+    },
+    "split2": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz",
+      "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ=="
+    },
+    "sprintf-js": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
+      "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
+    },
+    "string_decoder": {
+      "version": "0.10.31",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+      "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="
+    },
+    "string-template": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/string-template/-/string-template-1.0.0.tgz",
+      "integrity": "sha512-SLqR3GBUXuoPP5MmYtD7ompvXiG87QjT6lzOszyXjTM86Uu7At7vNnt2xgyTLq5o9T4IxTYFyGxcULqpsmsfdg==",
+      "optional": true
+    },
+    "string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "devOptional": true,
+      "requires": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      }
+    },
+    "strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "devOptional": true,
+      "requires": {
+        "ansi-regex": "^5.0.1"
+      }
+    },
+    "strip-bom": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
+      "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
+      "dev": true
+    },
+    "strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true
+    },
+    "supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "requires": {
+        "has-flag": "^4.0.0"
+      }
+    },
+    "supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true
+    },
+    "tar": {
+      "version": "6.1.13",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz",
+      "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==",
+      "optional": true,
+      "requires": {
+        "chownr": "^2.0.0",
+        "fs-minipass": "^2.0.0",
+        "minipass": "^4.0.0",
+        "minizlib": "^2.1.1",
+        "mkdirp": "^1.0.3",
+        "yallist": "^4.0.0"
+      },
+      "dependencies": {
+        "yallist": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+          "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+          "optional": true
+        }
+      }
+    },
+    "tar-fs": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
+      "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==",
+      "requires": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.1.4"
+      },
+      "dependencies": {
+        "chownr": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+          "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
+        }
+      }
+    },
+    "tar-stream": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "requires": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "dependencies": {
+        "readable-stream": {
+          "version": "3.6.0",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+          "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+          "requires": {
+            "inherits": "^2.0.3",
+            "string_decoder": "^1.1.1",
+            "util-deprecate": "^1.0.1"
+          }
+        },
+        "safe-buffer": {
+          "version": "5.2.1",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+          "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
+        },
+        "string_decoder": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+          "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+          "requires": {
+            "safe-buffer": "~5.2.0"
+          }
+        }
+      }
+    },
+    "test-exclude": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+      "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+      "dev": true,
+      "requires": {
+        "@istanbuljs/schema": "^0.1.2",
+        "glob": "^7.1.4",
+        "minimatch": "^3.0.4"
+      }
+    },
+    "text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+      "dev": true
+    },
+    "to-fast-properties": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+      "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+      "dev": true
+    },
+    "to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "requires": {
+        "is-number": "^7.0.0"
+      }
+    },
+    "tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "optional": true
+    },
+    "tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+      "requires": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "^1.2.1"
+      }
+    },
+    "type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true
+    },
+    "type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true
+    },
+    "typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+      "dev": true
+    },
+    "typedarray-to-buffer": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+      "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+      "dev": true,
+      "requires": {
+        "is-typedarray": "^1.0.0"
+      }
+    },
+    "uglify-js": {
+      "version": "3.4.10",
+      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.10.tgz",
+      "integrity": "sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==",
+      "dev": true,
+      "requires": {
+        "commander": "~2.19.0",
+        "source-map": "~0.6.1"
+      },
+      "dependencies": {
+        "commander": {
+          "version": "2.19.0",
+          "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz",
+          "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==",
+          "dev": true
+        }
+      }
+    },
+    "update-browserslist-db": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",
+      "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==",
+      "dev": true,
+      "requires": {
+        "escalade": "^3.1.1",
+        "picocolors": "^1.0.0"
+      }
+    },
+    "upper-case": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz",
+      "integrity": "sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==",
+      "dev": true
+    },
+    "uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "requires": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "url-parse": {
+      "version": "1.5.10",
+      "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+      "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+      "requires": {
+        "querystringify": "^2.1.1",
+        "requires-port": "^1.0.0"
+      }
+    },
+    "util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+    },
+    "uuid": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
+      "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="
+    },
+    "webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "optional": true
+    },
+    "whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "optional": true,
+      "requires": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
+    "which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "requires": {
+        "isexe": "^2.0.0"
+      }
+    },
+    "which-module": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
+      "integrity": "sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==",
+      "devOptional": true
+    },
+    "wide-align": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+      "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+      "optional": true,
+      "requires": {
+        "string-width": "^1.0.2 || 2 || 3 || 4"
+      }
+    },
+    "word-wrap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "dev": true
+    },
+    "workerpool": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz",
+      "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==",
+      "dev": true
+    },
+    "wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      }
+    },
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
+    },
+    "write-file-atomic": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+      "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
+      "dev": true,
+      "requires": {
+        "imurmurhash": "^0.1.4",
+        "is-typedarray": "^1.0.0",
+        "signal-exit": "^3.0.2",
+        "typedarray-to-buffer": "^3.1.5"
+      }
+    },
+    "xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
+    },
+    "y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true
+    },
+    "yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+      "dev": true
+    },
+    "yargs": {
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+      "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+      "dev": true,
+      "requires": {
+        "cliui": "^7.0.2",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.0",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^20.2.2"
+      }
+    },
+    "yargs-parser": {
+      "version": "20.2.4",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz",
+      "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==",
+      "dev": true
+    },
+    "yargs-unparser": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz",
+      "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==",
+      "dev": true,
+      "requires": {
+        "camelcase": "^6.0.0",
+        "decamelize": "^4.0.0",
+        "flat": "^5.0.2",
+        "is-plain-obj": "^2.1.0"
+      },
+      "dependencies": {
+        "camelcase": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+          "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+          "dev": true
+        },
+        "decamelize": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz",
+          "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
+          "dev": true
+        }
+      }
+    },
+    "yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644 (file)
index 0000000..24e8e44
--- /dev/null
@@ -0,0 +1,64 @@
+{
+  "name": "@squeep/indie-auther",
+  "version": "1.0.0",
+  "description": "A stand-alone IndieAuth identity-provider service, for most of your IndieAuth endpoint needs.",
+  "keywords": [
+    "IdP",
+    "Identity Provider",
+    "IndieAuth",
+    "IndieWeb",
+    "OAuth2",
+    "Authentication"
+  ],
+  "main": "server.js",
+  "scripts": {
+    "coverage": "nyc npm test",
+    "coverage-check": "nyc check-coverage",
+    "eslint": "eslint *.js src",
+    "test": "mocha --recursive"
+  },
+  "pre-commit": [
+    "eslint",
+    "coverage",
+    "coverage-check"
+  ],
+  "engines": {
+    "node": ">=14.0"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://git.squeep.com/squeep-indie-auther/"
+  },
+  "author": "Justin Wind <jwind-indieauther@squeep.com>",
+  "license": "ISC",
+  "dependencies": {
+    "@squeep/amqp-helper": "git+https://git.squeep.com/squeep-amqp-helper#v1.0.0",
+    "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.2.9",
+    "@squeep/authentication-module": "git+https://git.squeep.com/squeep-authentication-module/#v1.2.12",
+    "@squeep/chores": "git+https://git.squeep.com/squeep-chores/#v1.0.0",
+    "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.4.0",
+    "@squeep/indieauth-helper": "git+https://git.squeep.com/squeep-indieauth-helper/#v1.2.2",
+    "@squeep/logger-json-console": "git+https://git.squeep.com/squeep-logger-json-console#v1.0.2",
+    "@squeep/mystery-box": "^1.2.0",
+    "@squeep/resource-authentication-module": "git+https://git.squeep.com/squeep-resource-authentication-module#v1.0.0",
+    "@squeep/roman": "^1.0.0",
+    "@squeep/web-linking": "^1.0.7",
+    "better-sqlite3": "^8.0.1",
+    "pg-promise": "^10.15.4",
+    "uuid": "^9.0.0"
+  },
+  "devDependencies": {
+    "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v1.0.0",
+    "eslint": "^8.30.0",
+    "eslint-plugin-node": "^11.1.0",
+    "eslint-plugin-promise": "^6.1.1",
+    "eslint-plugin-security": "^1.5.0",
+    "eslint-plugin-sonarjs": "^0.17.0",
+    "html-minifier-lint": "^2.0.0",
+    "mocha": "^10.2.0",
+    "mocha-steps": "^1.3.0",
+    "nyc": "^15.1.0",
+    "pre-commit": "^1.2.2",
+    "sinon": "^15.0.1"
+  }
+}
diff --git a/server.js b/server.js
new file mode 100644 (file)
index 0000000..1e9c089
--- /dev/null
+++ b/server.js
@@ -0,0 +1,39 @@
+'use strict';
+
+const http = require('http');
+
+const Config = require('./config');
+const DB = require('./src/db');
+const Service = require('./src/service');
+const Logger = require('./src/logger');
+const { fileScope } = require('./src/common');
+const _fileScope = fileScope(__filename);
+const { version } = require('./package.json');
+
+const PORT = process.env.PORT || 3002;
+const ADDR = process.env.LISTEN_ADDR || '127.0.0.1';
+
+(async function main () {
+  const _scope = _fileScope('main');
+  let config, logger, db, service;
+  try {
+    config = new Config(process.env.NODE_ENV);
+    logger = new Logger(config);
+    db = new DB(logger, config);
+    await db.initialize();
+    service = new Service(logger, db, config);
+    await service.initialize();
+
+    http.createServer((req, res) => {
+      service.dispatch(req, res);
+    }).listen(PORT, ADDR, (err) => {
+      if (err) {
+        logger.error(_scope, 'error starting server', err);
+        throw err;
+      }
+      logger.info(_scope, 'server started', { version, listenAddress: ADDR, listenPort: PORT });
+    });
+  } catch (e) {
+    (logger || console).error(_scope, 'error starting server', e);
+  }
+})();
\ No newline at end of file
diff --git a/src/chores.js b/src/chores.js
new file mode 100644 (file)
index 0000000..0f3d379
--- /dev/null
@@ -0,0 +1,70 @@
+'use strict';
+
+const common = require('./common');
+const Enum = require('./enum');
+const { Chores: BaseChores } = require('@squeep/chores');
+const _fileScope = common.fileScope(__filename);
+
+/**
+ * Wrangle periodic tasks what need doing.
+ */
+
+class Chores extends BaseChores {
+  constructor(logger, db, options) {
+    super(logger);
+    this.options = options;
+    this.db = db;
+
+    this.establishChore(Enum.Chore.CleanTokens, this.cleanTokens.bind(this), options?.chores?.tokenCleanupMs);
+    this.establishChore(Enum.Chore.CleanScopes, this.cleanScopes.bind(this), options?.chores?.scopeCleanupMs);
+  }
+
+  /**
+   * Attempt to remove tokens which are expired or otherwise no longer valid.
+   * @param {Number} atLeastMsSinceLast
+   */
+  async cleanTokens(atLeastMsSinceLast = this.options?.chores?.tokenCleanupMs || 0) {
+    const _scope = _fileScope('cleanTokens');
+    this.logger.debug(_scope, 'called', atLeastMsSinceLast);
+
+    let tokensCleaned;
+    try {
+      await this.db.context(async (dbCtx) => {
+        const codeValidityTimeoutSeconds = Math.ceil(this.options.manager.codeValidityTimeoutMs / 1000);
+        tokensCleaned = await this.db.tokenCleanup(dbCtx, codeValidityTimeoutSeconds, atLeastMsSinceLast);
+      }); // dbCtx
+      if (tokensCleaned) {
+        this.logger.info(_scope, 'finished', { tokensCleaned });
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+
+  /**
+   * Attempt to remove ephemeral scopes which are no longer referenced by tokens.
+   * @param {Number} atLeastMsSinceLast
+   */
+  async cleanScopes(atLeastMsSinceLast = this.options?.chores?.scopeCleanupMs || 0) {
+    const _scope = _fileScope('cleanScopes');
+    this.logger.debug(_scope, 'called', atLeastMsSinceLast);
+
+    let scopesCleaned;
+    try {
+      await this.db.context(async (dbCtx) => {
+        scopesCleaned = await this.db.scopeCleanup(dbCtx, atLeastMsSinceLast);
+      }); // dbCtx
+      if (scopesCleaned) {
+        this.logger.info(_scope, 'finished', { scopesCleaned });
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+} // IAChores
+
+module.exports = Chores;
\ No newline at end of file
diff --git a/src/common.js b/src/common.js
new file mode 100644 (file)
index 0000000..d58f535
--- /dev/null
@@ -0,0 +1,167 @@
+'use strict';
+
+const { common } = require('@squeep/api-dingus');
+
+const { randomBytes } = require('crypto');
+const { promisify } = require('util');
+const randomBytesAsync = promisify(randomBytes);
+
+/**
+ * Pick out useful axios response fields.
+ * @param {*} res
+ * @returns
+ */
+const axiosResponseLogData = (res) => {
+  const data = common.pick(res, [
+    'status',
+    'statusText',
+    'headers',
+    'elapsedTimeMs',
+    'data',
+  ]);
+  if (data.data) {
+    data.data = logTruncate(data.data, 100);
+  }
+  return data;
+};
+
+/**
+ * Limit length of string to keep logs sane
+ * @param {String} str
+ * @param {Number} len
+ * @returns {String}
+ */
+const logTruncate = (str, len) => {
+  if (typeof str !== 'string' || str.toString().length <= len) {
+    return str;
+  }
+  return str.toString().slice(0, len) + `... (${str.toString().length} bytes)`;
+};
+
+/**
+ * Turn a snake into a camel.
+ * @param {String} snakeCase
+ * @param {String|RegExp} delimiter
+ * @returns {String}
+ */
+const camelfy = (snakeCase, delimiter = '_') => {
+  if (!snakeCase || typeof snakeCase.split !== 'function') {
+    return undefined;
+  }
+  const words = snakeCase.split(delimiter);
+  return [
+    words.shift(),
+    ...words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)),
+  ].join('');
+};
+
+/**
+ * Return an array containing x if x is not an array.
+ * @param {*} x
+ */
+const ensureArray = (x) => {
+  if (x === undefined) {
+    return [];
+  }
+  if (!Array.isArray(x)) {
+    return Array(x);
+  }
+  return x;
+};
+
+/**
+ * Recursively freeze an object.
+ * @param {Object} o 
+ * @returns {Object}
+ */
+const freezeDeep = (o) => {
+  Object.freeze(o);
+  Object.getOwnPropertyNames(o).forEach((prop) => {
+    if (Object.hasOwnProperty.call(o, prop)
+    &&  ['object', 'function'].includes(typeof o[prop]) // eslint-disable-line security/detect-object-injection
+    &&  !Object.isFrozen(o[prop])) { // eslint-disable-line security/detect-object-injection
+      return freezeDeep(o[prop]); // eslint-disable-line security/detect-object-injection
+    }
+  });
+  return o;
+};
+
+
+/** Oauth2.1 §3.2.3.1
+ * %x20-21 / %x23-5B / %x5D-7E
+ * @param {String} char
+ */
+const validErrorChar = (char) => {
+  const value = char.charCodeAt(0);
+  return value === 0x20 || value === 0x21
+    || (value >= 0x23 && value <= 0x5b)
+    || (value >= 0x5d && value <= 0x7e);
+};
+
+
+/**
+ * Determine if an OAuth error message is valid.
+ * @param {String} error
+ * @returns {Boolean}
+ */
+const validError = (error) => {
+  return error && error.split('').filter((c) => !validErrorChar(c)).length === 0 || false;
+};
+
+
+/**
+ * OAuth2.1 §3.2.2.1
+ * scope-token = 1*( %x21 / %x23-5B / %x5D-7E )
+ * @param {String} char
+ */
+const validScopeChar = (char) => {
+  const value = char.charCodeAt(0);
+  return value === 0x21
+    || (value >= 0x23 && value <= 0x5b)
+    || (value >= 0x5d && value <= 0x7e);
+};
+
+
+/**
+ * Determine if a scope has a valid name.
+ * @param {String} scope
+ * @returns {Boolean}
+ */
+const validScope = (scope) => {
+  return scope && scope.split('').filter((c) => !validScopeChar(c)).length === 0 || false;
+};
+
+
+/**
+ * 
+ * @param {Number} bytes
+ */
+const newSecret = async (bytes = 64) => {
+  return (await randomBytesAsync(bytes * 3 / 4)).toString('base64');
+};
+
+
+/**
+ * Convert a Date object to epoch seconds.
+ * @param {Date=} date
+ * @returns {Number}
+ */
+const dateToEpoch = (date) => {
+  const dateMs = date ? date.getTime() : Date.now();
+  return Math.ceil(dateMs / 1000);
+};
+
+module.exports = {
+  ...common,
+  axiosResponseLogData,
+  camelfy,
+  dateToEpoch,
+  ensureArray,
+  freezeDeep,
+  logTruncate,
+  newSecret,
+  randomBytesAsync,
+  validScope,
+  validError,
+};
+
diff --git a/src/db/abstract.js b/src/db/abstract.js
new file mode 100644 (file)
index 0000000..26867e4
--- /dev/null
@@ -0,0 +1,563 @@
+/* eslint-disable no-unused-vars */
+'use strict';
+
+const common = require('../common');
+const DatabaseErrors = require('./errors');
+const svh = require('./schema-version-helper');
+const uuid = require('uuid');
+
+const _fileScope = common.fileScope(__filename);
+
+class Database {
+  constructor(logger, options) {
+    this.logger = logger;
+    this.options = options;
+  }
+
+
+  /**
+   * Perform tasks needed to prepare database for use.  Ensure this is called
+   * after construction, and before any other database activity.
+   * At the minimum, this will validate a compatible schema is present and usable.
+   * Some engines will also perform other initializations or async actions which
+   * are easier handled outside the constructor.
+  */
+  async initialize() {
+    const _scope = _fileScope('initialize');
+
+    const currentSchema = await this._currentSchema();
+    const current = svh.schemaVersionObjectToNumber(currentSchema);
+    const min = svh.schemaVersionObjectToNumber(this.schemaVersionsSupported.min);
+    const max = svh.schemaVersionObjectToNumber(this.schemaVersionsSupported.max);
+    if (current >= min && current <= max) {
+      this.logger.debug(_scope, 'schema supported', { currentSchema, schemaVersionsSupported: this.schemaVersionsSupported });
+    } else {
+      this.logger.error(_scope, 'schema not supported', { currentSchema, schemaVersionsSupported: this.schemaVersionsSupported });
+      throw new DatabaseErrors.MigrationNeeded();
+    }
+  }
+
+
+  /**
+   * Query the current schema version.
+   * This is a standalone query function, as it is called before statements are loaded.
+   * @returns {Object} version
+   * @returns {Number} version.major
+   * @returns {Number} version.minor
+   * @returns {Number} version.patch
+   */
+  async _currentSchema() {
+    this._notImplemented('_currentSchema', arguments);
+  }
+
+
+  /**
+   * Perform db connection health-check, if applicable.
+   * Throw something if a database situation should pull us out of a load-balancer.
+   */
+  async healthCheck() {
+    this._notImplemented('healthCheck', arguments);
+  }
+
+
+  /**
+   * Wrap a function call in a database context.
+   * @param {Function} fn fn(ctx)
+   */
+  async context(fn) {
+    this._notImplemented('context', arguments);
+  }
+
+  /**
+   * Wrap a function call in a transaction context.
+   * @param {*} dbCtx
+   * @param {Function} fn fn(txCtx)
+   */
+  async transaction(dbCtx, fn) {
+    this._notImplemented('transaction', arguments);
+  }
+
+
+  /**
+   * @param {*} x
+   * @returns {Boolean}
+   */
+  static _isUUID(x) {
+    try {
+      uuid.parse(x);
+      return true;
+    } catch (e) {
+      return false;
+    }
+  }
+
+
+  /**
+   * @param {*} x
+   * @returns {Boolean}
+   */
+  static _isInfinites(x) {
+    return typeof(x) === 'number'
+      && Math.abs(x) === Infinity;
+  }
+
+  /**
+   * Basic type checking of object properties.
+   * @param {Object} object
+   * @param {String[]} properties
+   * @param {String[]} types
+   */
+  _ensureTypes(object, properties, types) {
+    const _scope = _fileScope('_ensureTypes');
+
+    if (!(object && properties && types)) {
+      this.logger.error(_scope, 'undefined argument', { object, properties, types });
+      throw new DatabaseErrors.DataValidation();
+    }
+    properties.forEach((p) => {
+      // eslint-disable-next-line security/detect-object-injection
+      const pObj = object[p];
+      const pType = typeof pObj;
+      if (!types.includes(pType)
+      &&  !(types.includes('array') && Array.isArray(pObj))
+      &&  !(types.includes('buffer') && pObj instanceof Buffer)
+      &&  !(types.includes('date') && pObj instanceof Date)
+      &&  !(types.includes('infinites'))
+      &&  !(types.includes('null') && pObj === null)
+      &&  !(types.includes('number') && pType === 'bigint')
+      &&  !(types.includes('uuid') && Database._isUUID(pObj))) {
+        const reason = `'${p}' is '${pType}', but must be ${types.length > 1 ? 'one of ' : ''}'${types}'`;
+        this.logger.error(_scope, reason, {});
+        throw new DatabaseErrors.DataValidation(reason);
+      }
+    });
+  }
+
+
+  /**
+   * @typedef {Object} Authentication
+   * @property {String} identifier
+   * @property {String=} credential
+   * @property {Date} created
+   * @property {Date=} lastAuthenticated
+   */
+  /**
+   * @param {Authentication} authentication 
+   */
+  _validateAuthentication(authentication) {
+    [
+      [['identifier'], ['string']],
+      [['credential'], ['string', 'null']],
+      [['created'], ['date']],
+      [['lastAuthenticated'], ['date', 'infinites']],
+    ].forEach(([properties, types]) => this._ensureTypes(authentication, properties, types));
+  }
+
+
+  /**
+   * @typedef {Object} Resource
+   * @property {String} resourceId - uuid
+   * @property {String} secret
+   * @property {String} description
+   * @property {Date} created
+   */
+  /**
+   * @param {Resource} resource
+   */
+  _validateResource(resource) {
+    [
+      [['resourceId', 'secret', 'description'], ['string']],
+      [['resourceId'], ['uuid']],
+      [['created'], ['date']],
+    ].forEach(([properties, types]) => this._ensureTypes(resource, properties, types));
+  }
+
+
+  /**
+   * @typedef {Object} Token
+   * @property {String} codeId - uuid
+   * @property {String} profile
+   * @property {Date} created
+   * @property {Date=} expires
+   * @property {Date=} refreshExpires
+   * @property {Date=} refreshed
+   * @property {*=} duration
+   * @property {*=} refreshDuration
+   * @property {Number|BigInt=} refresh_count
+   * @property {Boolean} is_revoked
+   * @property {Boolean} is_token
+   * @property {String} client_id
+   * @property {String[]} scopes
+   * @property {Object=} profileData
+   */
+  /**
+   * @param {Token} token
+   */
+  _validateToken(token) {
+    [
+      [['codeId', 'profile', 'clientId'], ['string']],
+      [['codeId'], ['uuid']],
+      [['created'], ['date']],
+      [['expires', 'refreshExpires', 'refreshed'], ['date', 'null']],
+      [['isToken', 'isRevoked'], ['boolean']],
+      [['scopes'], ['array']],
+      [['profileData'], ['object', 'null']],
+    ].forEach(([properties, types]) => this._ensureTypes(token, properties, types));
+    this._ensureTypes(token.scopes, Object.keys(token.scopes), ['string']);
+  }
+
+
+  /**
+   * Interface methods need implementations.  Ensure the db-interaction
+   * methods on the base class call this, so they may be overridden by
+   * implementation classes.
+   * @param {String} method
+   * @param {arguments} args
+   */
+  _notImplemented(method, args) {
+    this.logger.error(_fileScope(method), 'abstract method called', Array.from(args));
+    throw new DatabaseErrors.NotImplemented(method);
+  }
+
+
+  /**
+   * Get all the almanac entries.
+   * @param {*} dbCtx
+   */
+  async almanacGetAll(dbCtx) {
+    this._notImplemented('almanacGetAll', arguments);
+  }
+
+
+  /**
+   * Fetch the authentication record for an identifier.
+   * @param {*} dbCtx
+   * @param {String} identifier
+   * @returns {Promise<Authentication>}
+   */
+  async authenticationGet(dbCtx, identifier) {
+    this._notImplemented('authenticationGet', arguments);
+  }
+
+
+  /**
+   * Update the authentication record for the identifier that
+   * correct credentials have been supplied.
+   * @param {*} dbCtx
+   * @param {String} identifier
+   * @returns {Promise<void>}
+   */
+  async authenticationSuccess(dbCtx, identifier) {
+    this._notImplemented('authenticationSuccess', arguments);
+  }
+
+
+  /**
+   * Insert or update the credential for an identifier.
+   * @param {*} dbCtx
+   * @param {String} identifier
+   * @param {String} credential
+   * @returns {Promise<void>}
+   */
+  async authenticationUpsert(dbCtx, identifier, credential) {
+    this._notImplemented('authenticationUpsert', arguments);
+  }
+
+
+  /**
+   * Determine if profile url is known to this service.
+   * @param {*} dbCtx
+   * @param {String} profile
+   * @returns {Promise<Boolean>}
+   */
+  async profileIsValid(dbCtx, profile) {
+    this._notImplemented('profileGet', arguments);
+  }
+
+
+  /**
+   * Insert a new relationship between a profile endpoint and
+   * an authenticated identifier.
+   * @param {*} dbCtx
+   * @param {String} profile
+   * @param {String} identifier
+   * @returns {Promise<void>}
+   */
+  async profileIdentifierInsert(dbCtx, profile, identifier) {
+    this._notImplemented('profileIdentifierInsert', arguments);
+  }
+
+
+  /**
+   * Adds a scope to be available for a profile to include on any authorization request.
+   * @param {*} dbCtx
+   * @param {String} profile
+   * @param {String} scope
+   * @returns {Promise<void>}
+   */
+  async profileScopeInsert(dbCtx, profile, scope) {
+    this._notImplemented('profileScopeInsert', arguments);
+  }
+
+
+  /**
+   * @typedef {Object} ScopeDetails
+   * @property {String} description
+   * @property {String[]=} profiles
+   */
+  /**
+   * @typedef {Object.<String, Object>} ProfileScopes
+   * @property {Object.<String, Object>} profile
+   * @property {Object.<String, ScopeDetails>} profile.scope
+   */
+  /**
+   * @typedef {Object.<String, Object>} ScopeIndex
+   * @property {ScopeDetails} scope
+   */
+  /**
+   * @typedef {Object} ProfilesScopesReturn
+   * @property {ProfileScopes} profileScopes
+   * @property {ScopeIndex} scopeIndex
+   * @property {String[]} profiles
+   */
+  /**
+   * Returns an object containing:
+   * - an object with profiles as keys to objects with scopes as keys to scope objects,
+   *   which each contain a description of the scope and a list of profiles offering it
+   * - an object with scopes as keys to the same scope objects
+   * - a list of profiles
+   * @param {*} dbCtx
+   * @param {String} identifier
+   * @returns {Promise<ProfileScopesReturn>}
+   */
+  async profilesScopesByIdentifier(dbCtx, identifier) {
+    this._notImplemented('profilesScopesByIdentifier', arguments);
+  }
+
+
+  /**
+   * @typedef ProfileScopesRow
+   * @property profile
+   * @property scope
+   * @property description
+   * @property application
+   * @property isPermanent
+   * @property isManuallyAdded
+   */
+  /**
+   * Convert db row data into associative structures.
+   * Same behavior is shared by multiple engines.
+   * @param {ProfileScopesRow[]} profileScopesRows
+   * @returns {ProfileScopesReturn}
+   */
+  static _profilesScopesBuilder(profileScopesRows) {
+    const scopeIndex = {};
+    const profileScopes = {};
+    const profileSet = new Set();
+
+    (profileScopesRows || []).forEach(({ profile, scope, description, application, isPermanent, isManuallyAdded }) => {
+      if (scope && !(scope in scopeIndex)) {
+        scopeIndex[scope] = { // eslint-disable-line security/detect-object-injection
+          description,
+          application,
+          isPermanent,
+          isManuallyAdded,
+          profiles: [],
+        };
+      }
+      if (profile) {
+        profileSet.add(profile);
+        if (!(profile in profileScopes)) {
+          profileScopes[profile] = {}; // eslint-disable-line security/detect-object-injection
+        }
+      }
+      if (profile && scope) {
+        scopeIndex[scope].profiles.push(profile); // eslint-disable-line security/detect-object-injection
+        profileScopes[profile][scope] = scopeIndex[scope]; // eslint-disable-line security/detect-object-injection
+      }
+    });
+
+    return {
+      profiles: [...profileSet],
+      profileScopes,
+      scopeIndex,
+    };
+  }
+
+
+  /**
+   * Sets list of additional scopes available to profile.
+   * @param {*} dbCtx
+   * @param {String} profile
+   * @param {String[]} scopes
+   * @returns {Promise<void>}
+   */
+  async profileScopesSetAll(dbCtx, profile, scopes) {
+    this._notImplemented('profileScopesSetAll', arguments);
+  }
+
+
+  /**
+   * Create (or revoke a duplicate) code as a token entry.
+   * @param {*} dbCtx
+   * @param {Object} data
+   * @param {String} data.codeId
+   * @param {Date} data.created
+   * @param {Boolean} data.isToken
+   * @param {String} data.clientId
+   * @param {String} data.profile - profile uri
+   * @param {String} data.identifier
+   * @param {String[]} data.scopes
+   * @param {Number|Null} data.lifespanSeconds - null sets expiration to Infinity
+   * @param {Number|Null} data.refreshLifespanSeconds - null sets refresh to none
+   * @param {String|Null} data.resource
+   * @param {Object|Null} data.profileData - profile data from profile uri
+   * @returns {Promise<Boolean>} whether redemption was successful
+   */
+  async redeemCode(dbCtx, { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, profileData } = {}) {
+    this._notImplemented('redeemCode', arguments);
+  }
+
+
+  /**
+   * @typedef {Object} RefreshedToken
+   * @property {Date} expires
+   * @property {Date} refreshExpires
+   * @property {String[]=} scopes if scopes were reduced
+   */
+  /**
+   * Redeem a refresh token to renew token codeId.
+   * @param {*} dbCtx
+   * @param {String} codeId
+   * @param {Date} refreshed
+   * @param {String[]} removeScopes
+   * @returns {Promise<RefreshedToken>}
+   */
+  async refreshCode(dbCtx, codeId, refreshed, removeScopes) {
+    this._notImplemented('refreshCode', arguments);
+  }
+
+
+  /**
+   * Fetch a resource server record.
+   * @param {*} dbCtx
+   * @param {String} identifier uuid
+   * @returns {Promise<Resource>}
+   */
+  async resourceGet(dbCtx, resourceId) {
+    this._notImplemented('resourceGet', arguments);
+  }
+
+
+  /**
+   * Create, or update description of, a resourceId.
+   * @param {*} dbCtx
+   * @param {String=} resourceId uuid
+   * @param {String=} secret
+   * @param {String=} description
+   * @returns {Promise<void>}
+   */
+  async resourceUpsert(dbCtx, resourceId, secret, description) {
+    this._notImplemented('resourceUpsert', arguments);
+  }
+
+
+  /**
+   * Register a scope and its description.
+   * @param {*} dbCtx
+   * @param {String} scope
+   * @param {String} application
+   * @param {String} description
+   * @returns {Promise<void>}
+   */
+  async scopeUpsert(dbCtx, scope, application, description, manuallyAdded = false) {
+    this._notImplemented('scopeUpsert', arguments);
+  }
+
+
+  /**
+   * Remove a non-permanent scope if it is not currently in use.
+   * @param {*} dbCtx
+   * @param {String} scope
+   * @returns {Promise<Boolean>}
+   */
+  async scopeDelete(dbCtx, scope) {
+    this._notImplemented('scopeDelete', arguments);
+  }
+
+
+  /**
+   * @typedef {Number|BigInt} CleanupResult
+   */
+  /**
+   * @typedef {Object} CleanupResult
+   */
+  /**
+   * Remove any non-permanent and non-manually-created scopes not currently in use.
+   * @param {*} dbCtx
+   * @param {Number} atLeastMsSinceLast skip cleanup if already executed this recently
+   * @returns {Promise<CleanupResult>}
+   */
+  async scopeCleanup(dbCtx, atLeastMsSinceLast) {
+    this._notImplemented('scopeClean', arguments);
+  }
+
+
+  /**
+   * Forget tokens after they have expired, and redeemed codes after they have expired.
+   * @param {*} dbCtx
+   * @param {Number} codeLifespanSeconds
+   * @param {Number} atLeastMsSinceLast skip cleanup if already executed this recently
+   * @returns {Promise<CleanupResult>}
+   */
+  async tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast) {
+    this._notImplemented('tokenCleanup', arguments);
+  }
+
+
+  /**
+   * Look up a redeemed token by code_id.
+   * @param {*} dbCtx
+   * @param {String} codeId
+   * @returns {Promise<Token>}
+   */
+  async tokenGetByCodeId(dbCtx, codeId) {
+    this._notImplemented('tokenGetByCodeId', arguments);
+  }
+
+
+  /**
+   * Sets a redeemed token as revoked.
+   * @param {*} dbCtx
+   * @param {String} codeId - uuid
+   * @returns {Promise<void>}
+   */
+  async tokenRevokeByCodeId(dbCtx, codeId) {
+    this._notImplemented('tokenRevokeByCodeId', arguments);
+  }
+
+
+  /**
+   * Revoke the refreshability of a codeId.
+   * @param {*} dbCtx
+   * @param {String} codeId - uuid
+   * @returns {Promise<void>}
+   */
+  async tokenRefreshRevokeByCodeId(dbCtx, codeId) {
+    this._notImplemented('tokenRefreshRevokeByCodeId', arguments);
+  }
+
+
+  /**
+   * Get all tokens assigned to identifier.
+   * @param {*} dbCtx
+   * @param {String} identifier
+   * @returns {Promise<Tokens[]>}
+   */
+  async tokensGetByIdentifier(dbCtx, identifier) {
+    this._notImplemented('tokensGetByIdentifier', arguments);
+  }
+
+}
+
+module.exports = Database;
\ No newline at end of file
diff --git a/src/db/errors.js b/src/db/errors.js
new file mode 100644 (file)
index 0000000..cd43239
--- /dev/null
@@ -0,0 +1,56 @@
+'use strict';
+
+class DatabaseError extends Error {
+  constructor(...args) {
+    super(...args);
+    Error.captureStackTrace(DatabaseError);
+  }
+
+  get name() {
+    /* istanbul ignore next */
+    return this.constructor.name;
+  }
+}
+
+class DataValidation extends DatabaseError {
+  constructor(...args) {
+    super(...args);
+    Error.captureStackTrace(DataValidation);
+  }
+}
+
+class NotImplemented extends DatabaseError {
+  constructor(...args) {
+    super(...args);
+    Error.captureStackTrace(NotImplemented);
+  }
+}
+
+class UnexpectedResult extends DatabaseError {
+  constructor(...args) {
+    super(...args);
+    Error.captureStackTrace(UnexpectedResult);
+  }
+}
+
+class UnsupportedEngine extends DatabaseError {
+  constructor(...args) {
+    super(...args);
+    Error.captureStackTrace(UnsupportedEngine);
+  }
+}
+
+class MigrationNeeded extends DatabaseError {
+  constructor(...args) {
+    super(...args);
+  }
+}
+
+module.exports = {
+  DatabaseError,
+  DataValidation,
+  MigrationNeeded,
+  NotImplemented,
+  UnexpectedResult,
+  UnsupportedEngine,
+};
diff --git a/src/db/index.js b/src/db/index.js
new file mode 100644 (file)
index 0000000..0d5ef16
--- /dev/null
@@ -0,0 +1,42 @@
+'use strict';
+
+const common = require('../common');
+const DatabaseErrors = require('./errors');
+
+const _fileScope = common.fileScope(__filename);
+
+class DatabaseFactory {
+  constructor(logger, options, ...rest) {
+    const _scope = _fileScope('constructor');
+
+    const connectionString = options.db.connectionString || '';
+    const protocol = connectionString.slice(0, connectionString.indexOf('://')).toLowerCase();
+
+    let Engine;
+    switch (protocol) {
+      case DatabaseFactory.Engines.PostgreSQL:
+        Engine = require('./postgres');
+        break;
+
+      case DatabaseFactory.Engines.SQLite:
+        Engine = require('./sqlite');
+        break;
+
+      default:
+        logger.error(_scope, 'unsupported connectionString', { protocol, options });
+        throw new DatabaseErrors.UnsupportedEngine(protocol);
+    }
+
+    return new Engine(logger, options, ...rest);
+  }
+
+  static get Engines() {
+    return {
+      PostgreSQL: 'postgresql',
+      SQLite: 'sqlite',
+    };
+  }
+
+}
+
+module.exports = DatabaseFactory;
diff --git a/src/db/postgres/index.js b/src/db/postgres/index.js
new file mode 100644 (file)
index 0000000..54c6e9b
--- /dev/null
@@ -0,0 +1,638 @@
+/* eslint-disable security/detect-object-injection */
+'use strict';
+
+const pgpInitOptions = {
+  capSQL: true,
+};
+
+const path = require('path');
+const pgp = require('pg-promise')(pgpInitOptions);
+const svh = require('../schema-version-helper');
+const Database = require('../abstract');
+const DBErrors = require('../errors');
+const common = require('../../common');
+
+const _fileScope = common.fileScope(__filename);
+
+const PGTypeIdINT8 = 20; // Type Id 20 == INT8 (BIGINT)
+const PGTYpeIdINT8Array = 1016; //Type Id 1016 == INT8[] (BIGINT[])
+pgp.pg.types.setTypeParser(PGTypeIdINT8, BigInt); // Type Id 20 = INT8 (BIGINT)
+const parseBigIntArray = pgp.pg.types.getTypeParser(PGTYpeIdINT8Array); // Type Id 1016 = INT8[] (BIGINT[])
+pgp.pg.types.setTypeParser(PGTYpeIdINT8Array, (a) => parseBigIntArray(a).map(BigInt));
+
+const schemaVersionsSupported = {
+  min: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+  max: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+};
+
+class DatabasePostgres extends Database {
+  constructor(logger, options, _pgp = pgp) {
+    super(logger, options);
+
+    this.db = _pgp(options.db.connectionString);
+    this.schemaVersionsSupported = schemaVersionsSupported;
+
+    // Suppress QF warnings when running tests
+    this.noWarnings = options.db.noWarnings;
+
+    // Log queries
+    const queryLogLevel = options.db.queryLogLevel;
+    if (queryLogLevel) {
+      const queryScope = _fileScope('pgp:query');
+      pgpInitOptions.query = (event) => {
+        this.logger[queryLogLevel](queryScope, '', { ...common.pick(event, ['query', 'params']) });
+      };
+    }
+
+    // Log errors
+    const errorScope = _fileScope('pgp:error');
+    pgpInitOptions.error = (err, event) => {
+      this.logger.error(errorScope, '', { err, event });
+    };
+
+    // Deophidiate column names in-place, log results
+    pgpInitOptions.receive = (data, result, event) => {
+      const exemplaryRow = data[0];
+      for (const prop in exemplaryRow) {
+        const camel = common.camelfy(prop);
+        if (!(camel in exemplaryRow)) {
+          for (const d of data) {
+            d[camel] = d[prop];
+            delete d[prop];
+          }
+        }
+      }
+      if (queryLogLevel) {
+        // Omitting .rows
+        const resultLog = common.pick(result, ['command', 'rowCount', 'duration']);
+        this.logger[queryLogLevel](_fileScope('pgp:result'), '', { query: event.query, ...resultLog });
+      }
+    };
+
+    // Expose these for test coverage
+    this.pgpInitOptions = pgpInitOptions;
+    this._pgp = _pgp;
+
+    this._initStatements(_pgp);
+  }
+
+
+  _queryFileHelper(_pgp) {
+    return (file) => {
+      const _scope = _fileScope('_queryFile');
+      /* istanbul ignore next */
+      const qfParams = {
+        minify: true,
+        ...(this.noWarnings && { noWarnings: this.noWarnings }),
+      };
+      const qf = new _pgp.QueryFile(file, qfParams);
+      if (qf.error) {
+        this.logger.error(_scope, 'failed to create SQL statement', { error: qf.error, file });
+        throw qf.error;
+      }
+      return qf;
+    };
+  }
+
+
+  async initialize(applyMigrations = true) {
+    const _scope = _fileScope('initialize');
+    this.logger.debug(_scope, 'called', { applyMigrations });
+    if (applyMigrations) {
+      await this._initTables();
+    }
+    await super.initialize();
+    if (this.listener) {
+      await this.listener.start();
+    }
+  }
+
+
+  async _initTables(_pgp) {
+    const _scope = _fileScope('_initTables');
+    this.logger.debug(_scope, 'called', {});
+
+    const _queryFile = this._queryFileHelper(_pgp || this._pgp);
+
+    // Migrations rely upon this table, ensure it exists.
+    const metaVersionTable = '_meta_schema_version';
+
+    const tableExists = async (name) => this.db.oneOrNone('SELECT table_name FROM information_schema.tables WHERE table_name=$(name)', { name });
+    let metaExists = await tableExists(metaVersionTable);
+    if (!metaExists) {
+      const fPath = path.join(__dirname, 'sql', 'schema', 'init.sql');
+      const initSql = _queryFile(fPath);
+      const results = await this.db.multiResult(initSql);
+      this.logger.debug(_scope, 'executed init sql', { results });
+      metaExists = await tableExists(metaVersionTable);
+      /* istanbul ignore if */
+      if (!metaExists) {
+        throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`);
+      }
+      this.logger.info(_scope, 'created schema version table', { metaVersionTable });
+    }
+
+    // Apply migrations
+    const currentSchema = await this._currentSchema();
+    const migrationsWanted = svh.unappliedSchemaVersions(__dirname, currentSchema, this.schemaVersionsSupported);
+    this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted });
+    for (const v of migrationsWanted) {
+      const fPath = path.join(__dirname, 'sql', 'schema', v, 'apply.sql');
+      const migrationSql = _queryFile(fPath);
+      const results = await this.db.multiResult(migrationSql);
+      this.logger.debug(_scope, 'executed migration sql', { version: v, results });
+      this.logger.info(_scope, 'applied migration', { version: v });
+    }
+  }
+
+  
+  _initStatements(_pgp) {
+    const _scope = _fileScope('_initStatements');
+    const _queryFile = this._queryFileHelper(_pgp);
+    this.statement = _pgp.utils.enumSql(path.join(__dirname, 'sql'), {}, _queryFile);
+    this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length });
+  }
+
+  
+  async healthCheck() {
+    const _scope = _fileScope('healthCheck');
+    this.logger.debug(_scope, 'called', {});
+    const c = await this.db.connect();
+    c.done();
+    return { serverVersion: c.client.serverVersion };
+  }
+
+
+  async _currentSchema() {
+    return this.db.one('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1');
+  }
+
+  
+  async _closeConnection() {
+    const _scope = _fileScope('_closeConnection');
+    try {
+      if (this.listener) {
+        await this.listener.stop();
+      }
+      await this._pgp.end();
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+  
+  /* istanbul ignore next */
+  async _purgeTables(really = false) {
+    const _scope = _fileScope('_purgeTables');
+    try {
+      if (really) {
+        await this.db.tx(async (t) => {
+          await t.batch([
+            'authentication',
+            'resource',
+            'profile',
+            'token',
+          ].map(async (table) => t.query('TRUNCATE TABLE $(table:name) CASCADE', { table })));
+        });
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+
+  async context(fn) {
+    return this.db.task(async (t) => fn(t));
+  }
+
+
+  // eslint-disable-next-line class-methods-use-this
+  async transaction(dbCtx, fn) {
+    return dbCtx.txIf(async (t) => fn(t));
+  }
+
+
+  async almanacGetAll(dbCtx) {
+    const _scope = _fileScope('almanacGetAll');
+    this.logger.debug(_scope, 'called');
+
+    try {
+      return await dbCtx.manyOrNone(this.statement.almanacGetAll);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+
+  async authenticationGet(dbCtx, identifier) {
+    const _scope = _fileScope('authenticationGet');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    try {
+      return await dbCtx.oneOrNone(this.statement.authenticationGet, { identifier });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier });
+      throw e;
+    }
+  }
+
+
+  async authenticationSuccess(dbCtx, identifier) {
+    const _scope = _fileScope('authenticationSuccess');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    try {
+      const result = await dbCtx.result(this.statement.authenticationSuccess, { identifier });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not update authentication success event');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier });
+      throw e;
+    }
+  }
+
+
+  async authenticationUpsert(dbCtx, identifier, credential) {
+    const _scope = _fileScope('authenticationUpsert');
+    const scrubbedCredential = '*'.repeat((credential || '').length);
+    this.logger.debug(_scope, 'called', { identifier, scrubbedCredential });
+
+    try {
+      const result = await dbCtx.result(this.statement.authenticationUpsert, { identifier, credential });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not upsert authentication');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier, scrubbedCredential });
+      throw e;
+    }
+  }
+
+
+  async profileIdentifierInsert(dbCtx, profile, identifier) {
+    const _scope = _fileScope('profileIdentifierInsert');
+    this.logger.debug(_scope, 'called', { profile, identifier });
+
+    try {
+      const result = await dbCtx.result(this.statement.profileIdentifierInsert, { profile, identifier });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not insert identifier');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, profile, identifier });
+      throw e;
+    }
+  }
+
+
+  async profileIsValid(dbCtx, profile) {
+    const _scope = _fileScope('profileIsValid');
+    this.logger.debug(_scope, 'called', { profile });
+
+    try {
+      const profileResponse = await dbCtx.oneOrNone(this.statement.profileGet, { profile });
+      return !!profileResponse;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, profile });
+      throw e;
+    }
+  }
+
+
+  async profileScopeInsert(dbCtx, profile, scope) {
+    const _scope = _fileScope('profileScopeInsert');
+    this.logger.debug(_scope, 'called', { profile, scope });
+
+    try {
+      const result = await dbCtx.result(this.statement.profileScopeInsert, { profile, scope });
+      // Duplicate inserts get ignored
+      if (result.rowCount != 1 && result.rowCount != 0) {
+        throw new DBErrors.UnexpectedResult('did not insert profile scope');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, profile, scope });
+      throw e;
+    }
+  }
+
+
+  async profileScopesSetAll(dbCtx, profile, scopes) {
+    const _scope = _fileScope('profileScopesSetAll');
+    this.logger.debug(_scope, 'called', { profile, scopes });
+
+    try {
+      await this.transaction(dbCtx, async (txCtx) => {
+        await txCtx.result(this.statement.profileScopesClear, { profile });
+        if (scopes.length) {
+          await txCtx.result(this.statement.profileScopesSetAll, { profile, scopes });
+        }
+      }); // transaction
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, profile, scopes });
+      throw e;
+    }
+  }
+
+
+  async profilesScopesByIdentifier(dbCtx, identifier) {
+    const _scope = _fileScope('profilesScopesByIdentifier');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    try {
+      const profileScopesRows = await dbCtx.manyOrNone(this.statement.profilesScopesByIdentifier, { identifier });
+      return Database._profilesScopesBuilder(profileScopesRows);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier });
+      throw e;
+    }
+  }
+
+
+  async redeemCode(dbCtx, { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, resource, profileData }) {
+    const _scope = _fileScope('redeemCode');
+    this.logger.debug(_scope, 'called', { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, resource, profileData });
+
+    let result, ret = false;
+    try {
+      await this.transaction(dbCtx, async (txCtx) => {
+        result = await txCtx.result(this.statement.redeemCode, { codeId, created, isToken, clientId, profile, identifier, lifespanSeconds, refreshLifespanSeconds, resource, profileData });
+        if (result.rowCount != 1) {
+          this.logger.error(_scope, 'failed', { result });
+          throw new DBErrors.UnexpectedResult('did not redeem code');
+        }
+        // Abort and return false if redemption resulted in revocation.
+        if (result.rows[0].isRevoked) {
+          return;
+        }
+        this.logger.debug(_scope, 'code redeemed', { redeemed: result.rows[0] });
+
+        // Ensure there are entries for all scopes.
+        if (scopes.length !== 0) {
+          await txCtx.result(this.statement.scopesInsert, { scopes });
+        }
+
+        // Record accepted scopes for this token.
+        result = await txCtx.result(this.statement.tokenScopesSet, { codeId, scopes });
+        if (result.rowCount != scopes.length) {
+          this.logger.error(_scope, 'token scope count mismatch', { codeId, scopes, result });
+          throw new DBErrors.UnexpectedResult('did not set all scopes on token');
+        }
+        ret = true;
+      }); // txCtx
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, profileData });
+      throw e;
+    }
+
+    return ret;
+  }
+
+
+  async refreshCode(dbCtx, codeId, refreshed, removeScopes) {
+    const _scope = _fileScope('refreshCode');
+    this.logger.debug(_scope, 'called', { codeId, refreshed, removeScopes });
+
+    try {
+      return await this.transaction(dbCtx, async (txCtx) => {
+        const refreshedToken = await txCtx.oneOrNone(this.statement.refreshCode, { codeId, refreshed });
+        if (refreshedToken) {
+          if (removeScopes.length) {
+            const removeResult = await txCtx.result(this.statement.tokenScopesRemove, { codeId, removeScopes });
+            if (removeResult.rowCount != removeScopes.length) {
+              this.logger.error(_scope, 'failed to remove token scopes', { actual: removeResult.rowCount, expected: removeScopes.length });
+              throw new DBErrors.UnexpectedResult('did not remove scopes from token');
+            }
+          } else {
+            delete refreshedToken.scopes; // Not updated, remove from response.
+          }
+        } else {
+          this.logger.debug(_scope, 'did not refresh token', {});
+        }
+        return refreshedToken;
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeId });
+      throw e;
+    }
+  }
+
+
+  async resourceGet(dbCtx, resourceId) {
+    const _scope = _fileScope('resourceGet');
+    this.logger.debug(_scope, 'called', { resourceId });
+
+    try {
+      return await dbCtx.oneOrNone(this.statement.resourceGet, { resourceId });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, resourceId });
+      throw e;
+    }
+  }
+
+
+  async resourceUpsert(dbCtx, resourceId, secret, description) {
+    const _scope = _fileScope('resourceUpsert');
+    const logSecret = secret?.length && common.logTruncate('*'.repeat(secret.length), 3) || undefined;
+    this.logger.debug(_scope, 'called', { resourceId, secret: logSecret, description });
+
+    try {
+      const result = await dbCtx.result(this.statement.resourceUpsert, { resourceId, secret, description });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not upsert resource');
+      }
+      return result.rows[0];
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, resourceId, secret: logSecret, description });
+      throw e;
+    }
+  }
+
+
+  async scopeCleanup(dbCtx, atLeastMsSinceLast) {
+    const _scope = _fileScope('scopeCleanup');
+    this.logger.debug(_scope, 'called', { atLeastMsSinceLast });
+
+    const almanacEvent = 'scopeCleanup';
+    try {
+      return await this.transaction(dbCtx, async (txCtx) => {
+
+        // Check that enough time has passed since last cleanup
+        const now = new Date();
+        const cleanupNotAfter = new Date(now.getTime() - atLeastMsSinceLast);
+        const { date: lastCleanupDate } = await txCtx.oneOrNone(this.statement.almanacGet, { event: almanacEvent }) || { date: new Date(0) };
+        if (lastCleanupDate >= cleanupNotAfter) {
+          this.logger.debug(_scope, 'skipping token cleanup, too soon', { lastCleanupDate, cleanupNotAfter, atLeastMsSinceLast });
+          return;
+        }
+
+        // Do the cleanup
+        const { rowCount: scopesRemoved } = await txCtx.result(this.statement.scopeCleanup);
+
+        // Update the last cleanup time
+        const result = await txCtx.result(this.statement.almanacUpsert, { event: almanacEvent, date: now });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not update almanac');
+        }
+
+        this.logger.debug(_scope, 'completed', { scopesRemoved, atLeastMsSinceLast });
+        return scopesRemoved;
+      }); // tx
+
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, atLeastMsSinceLast });
+      throw e;
+    }
+  }
+
+
+  async scopeDelete(dbCtx, scope) {
+    const _scope = _fileScope('scopeDelete');
+    this.logger.debug(_scope, 'called', { scope });
+
+    try {
+      return await this.transaction(dbCtx, async (txCtx) => {
+        const { inUse } = await txCtx.one(this.statement.scopeInUse, { scope });
+        if (inUse) {
+          this.logger.debug(_scope, 'not deleted, in use', { scope });
+          return false;
+        }
+        const result = await txCtx.result(this.statement.scopeDelete, { scope });
+        if (result.rowCount == 0) {
+          this.logger.debug(_scope, 'no such scope', { scope });
+        } else {
+          this.logger.debug(_scope, 'deleted', { scope });
+        }
+        return true;
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, scope });
+      throw e;
+    }
+  }
+
+
+  async scopeUpsert(dbCtx, scope, application, description, manuallyAdded = false) {
+    const _scope = _fileScope('scopeUpsert');
+    this.logger.debug(_scope, 'called', { scope, description });
+
+    try {
+      const result = await dbCtx.result(this.statement.scopeUpsert, { scope, application, description, manuallyAdded });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not upsert scope');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, scope, application, description });
+      throw e;
+    }
+  }
+
+
+  async tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast) {
+    const _scope = _fileScope('tokenCleanup');
+    this.logger.debug(_scope, 'called', { codeLifespanSeconds, atLeastMsSinceLast });
+
+    const almanacEvent = 'tokenCleanup';
+    try {
+      return await this.transaction(dbCtx, async (txCtx) => {
+
+        // Check that enough time has passed since last cleanup
+        const now = new Date();
+        const cleanupNotAfter = new Date(now.getTime() - atLeastMsSinceLast);
+        const { date: lastCleanupDate } = await txCtx.oneOrNone(this.statement.almanacGet, { event: almanacEvent }) || { date: new Date(0) };
+        if (lastCleanupDate >= cleanupNotAfter) {
+          this.logger.debug(_scope, 'skipping token cleanup, too soon', { lastCleanupDate, cleanupNotAfter, codeLifespanSeconds, atLeastMsSinceLast });
+          return;
+        }
+
+        // Do the cleanup
+        const { rowCount: tokensRemoved } = await txCtx.result(this.statement.tokenCleanup, { codeLifespanSeconds });
+
+        // Update the last cleanup time
+        const result = await txCtx.result(this.statement.almanacUpsert, { event: almanacEvent, date: now });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not update almanac');
+        }
+
+        this.logger.debug(_scope, 'completed', { tokensRemoved, codeLifespanSeconds, atLeastMsSinceLast });
+        return tokensRemoved;
+      }); // tx
+
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, atLeastMsSinceLast });
+      throw e;
+    }
+  }
+
+
+  async tokenGetByCodeId(dbCtx, codeId) {
+    const _scope = _fileScope('tokenGetByCodeId');
+    this.logger.debug(_scope, 'called', { codeId });
+
+    try {
+      return await dbCtx.oneOrNone(this.statement.tokenGetByCodeId, { codeId });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeId });
+      throw e;
+    }
+  }
+
+
+  async tokenRevokeByCodeId(dbCtx, codeId) {
+    const _scope = _fileScope('tokenRevokeByCodeId');
+    this.logger.debug(_scope, 'called', { codeId });
+
+    try {
+      const result = await dbCtx.result(this.statement.tokenRevokeByCodeId, { codeId });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not revoke token');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeId });
+      throw e;
+    }
+  }
+
+
+  async tokenRefreshRevokeByCodeId(dbCtx, codeId) {
+    const _scope = _fileScope('tokenRefreshRevokeByCodeId');
+    this.logger.debug(_scope, 'called', { codeId });
+
+    try {
+      const result = await dbCtx.result(this.statement.tokenRefreshRevokeByCodeId, { codeId });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not revoke token');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeId });
+      throw e;
+    }
+  }
+
+
+  async tokensGetByIdentifier(dbCtx, identifier) {
+    const _scope = _fileScope('tokensGetByIdentifier');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    try {
+      return await dbCtx.manyOrNone(this.statement.tokensGetByIdentifier, { identifier });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier });
+      throw e;
+    }
+  }
+
+}
+
+module.exports = DatabasePostgres;
diff --git a/src/db/postgres/sql/almanac-get-all.sql b/src/db/postgres/sql/almanac-get-all.sql
new file mode 100644 (file)
index 0000000..b534d20
--- /dev/null
@@ -0,0 +1,3 @@
+--
+SELECT * FROM almanac
+
diff --git a/src/db/postgres/sql/almanac-get.sql b/src/db/postgres/sql/almanac-get.sql
new file mode 100644 (file)
index 0000000..077dfe2
--- /dev/null
@@ -0,0 +1,6 @@
+--
+SELECT date
+FROM almanac
+WHERE event = $(event)
+FOR UPDATE
+
diff --git a/src/db/postgres/sql/almanac-upsert.sql b/src/db/postgres/sql/almanac-upsert.sql
new file mode 100644 (file)
index 0000000..4bda1bc
--- /dev/null
@@ -0,0 +1,9 @@
+--
+INSERT INTO almanac
+       (event, date)
+VALUES
+       ($(event), $(date))
+ON CONFLICT (event) DO UPDATE
+SET
+       date = $(date)
+
diff --git a/src/db/postgres/sql/authentication-get.sql b/src/db/postgres/sql/authentication-get.sql
new file mode 100644 (file)
index 0000000..f75533e
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT *
+FROM authentication
+WHERE identifier = $(identifier)
diff --git a/src/db/postgres/sql/authentication-success.sql b/src/db/postgres/sql/authentication-success.sql
new file mode 100644 (file)
index 0000000..81983ea
--- /dev/null
@@ -0,0 +1,4 @@
+--
+UPDATE authentication
+       SET last_authentication = now()
+       WHERE identifier = $(identifier)
diff --git a/src/db/postgres/sql/authentication-upsert.sql b/src/db/postgres/sql/authentication-upsert.sql
new file mode 100644 (file)
index 0000000..e86d4fb
--- /dev/null
@@ -0,0 +1,9 @@
+--
+INSERT INTO authentication
+       (identifier, credential)
+VALUES
+       ($(identifier), $(credential))
+ON CONFLICT (identifier) DO UPDATE
+SET
+       identifier = $(identifier),
+       credential = $(credential)
diff --git a/src/db/postgres/sql/profile-get.sql b/src/db/postgres/sql/profile-get.sql
new file mode 100644 (file)
index 0000000..e4172d1
--- /dev/null
@@ -0,0 +1,3 @@
+--
+SELECT * FROM profile
+WHERE profile = $(profile)
diff --git a/src/db/postgres/sql/profile-identifier-insert.sql b/src/db/postgres/sql/profile-identifier-insert.sql
new file mode 100644 (file)
index 0000000..2c42fac
--- /dev/null
@@ -0,0 +1,4 @@
+--
+INSERT INTO profile (profile, identifier_id)
+       SELECT $(profile), identifier_id FROM authentication WHERE identifier = $(identifier)
+ON CONFLICT (identifier_id, profile) DO NOTHING
diff --git a/src/db/postgres/sql/profile-scope-insert.sql b/src/db/postgres/sql/profile-scope-insert.sql
new file mode 100644 (file)
index 0000000..26cc6bb
--- /dev/null
@@ -0,0 +1,5 @@
+--
+INSERT INTO profile_scope (profile_id, scope_id)
+       SELECT p.profile_id, s.scope_id FROM profile p, scope s
+       WHERE p.profile = $(profile) AND s.scope = $(scope)
+ON CONFLICT (profile_id, scope_id) DO NOTHING
diff --git a/src/db/postgres/sql/profile-scopes-clear.sql b/src/db/postgres/sql/profile-scopes-clear.sql
new file mode 100644 (file)
index 0000000..bd7827b
--- /dev/null
@@ -0,0 +1,6 @@
+--
+DELETE FROM profile_scope
+WHERE profile_id IN (
+       SELECT profile_id FROM profile WHERE profile = $(profile)
+)
+
diff --git a/src/db/postgres/sql/profile-scopes-set-all.sql b/src/db/postgres/sql/profile-scopes-set-all.sql
new file mode 100644 (file)
index 0000000..a7e74cf
--- /dev/null
@@ -0,0 +1,5 @@
+--
+INSERT INTO profile_scope (profile_id, scope_id)
+       SELECT p.profile_id, s.scope_id FROM profile p, scope s
+       WHERE p.profile = $(profile) AND s.scope = ANY ($(scopes))
+
diff --git a/src/db/postgres/sql/profiles-scopes-by-identifier.sql b/src/db/postgres/sql/profiles-scopes-by-identifier.sql
new file mode 100644 (file)
index 0000000..525ced6
--- /dev/null
@@ -0,0 +1,10 @@
+--
+SELECT p.profile, s.*
+       FROM profile p
+               INNER JOIN authentication a USING (identifier_id)
+               FULL JOIN profile_scope ps USING (profile_id)
+               FULL JOIN scope s USING (scope_id)
+       WHERE a.identifier = $(identifier)
+UNION ALL SELECT NULL AS profile, *
+       FROM scope
+       WHERE is_manually_added OR is_permanent
diff --git a/src/db/postgres/sql/redeem-code.sql b/src/db/postgres/sql/redeem-code.sql
new file mode 100644 (file)
index 0000000..e61d503
--- /dev/null
@@ -0,0 +1,30 @@
+--
+INSERT INTO token (
+       created,
+       code_id,
+       is_token,
+       client_id,
+       profile_id,
+       duration,
+       expires,
+       refresh_duration,
+       refresh_expires,
+       resource,
+       profile_data
+) SELECT
+       $(created)::timestamptz,
+       $(codeId),
+       $(isToken),
+       $(clientId),
+       p.profile_id,
+       $(lifespanSeconds)::text::interval,
+       CASE WHEN $(lifespanSeconds) IS NULL THEN NULL ELSE $(created)::timestamptz + $(lifespanSeconds)::text::interval END,
+       $(refreshLifespanSeconds)::text::interval,
+       CASE WHEN $(refreshLifespanSeconds) IS NULL THEN NULL ELSE $(created)::timestamptz + $(refreshLifespanSeconds)::text::interval END,
+       $(resource),
+       $(profileData)
+FROM profile p INNER JOIN authentication a USING (identifier_id)
+WHERE p.profile = $(profile) AND a.identifier = $(identifier)
+ON CONFLICT (code_id) DO UPDATE -- repeated redemption attempt invalidates existing token
+       SET is_revoked = true
+RETURNING *
diff --git a/src/db/postgres/sql/refresh-code.sql b/src/db/postgres/sql/refresh-code.sql
new file mode 100644 (file)
index 0000000..339e488
--- /dev/null
@@ -0,0 +1,19 @@
+--
+UPDATE token SET
+       expires = $(refreshed)::timestamptz + duration,
+       refreshed = $(refreshed)::timestamptz,
+       refresh_expires = $(refreshed)::timestamptz + refresh_duration,
+       refresh_count = refresh_count + 1
+WHERE
+       code_id = $(codeId)
+AND
+       NOT is_revoked
+AND
+       (refresh_expires IS NOT NULL AND refresh_expires > $(refreshed)::timestamptz)
+RETURNING
+       expires,
+       refresh_expires,
+       ARRAY(
+               SELECT s.scope FROM token_scope ts INNER JOIN scope s USING (scope_id)
+               WHERE ts.code_id = code_id
+       ) AS scopes
diff --git a/src/db/postgres/sql/resource-get.sql b/src/db/postgres/sql/resource-get.sql
new file mode 100644 (file)
index 0000000..53100f1
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT *
+FROM resource
+WHERE resource_id = $(resourceId)
diff --git a/src/db/postgres/sql/resource-upsert.sql b/src/db/postgres/sql/resource-upsert.sql
new file mode 100644 (file)
index 0000000..51d4e94
--- /dev/null
@@ -0,0 +1,10 @@
+--
+INSERT INTO resource
+       (resource_id, secret, description)
+VALUES
+       (COALESCE($(resourceId)::UUID, uuid_generate_v4()), $(secret), $(description))
+ON CONFLICT (resource_id) DO UPDATE
+SET
+       secret = COALESCE(EXCLUDED.secret, resource.secret),
+       description = COALESCE(EXCLUDED.description, resource.description)
+RETURNING *
diff --git a/src/db/postgres/sql/schema/1.0.0/apply.sql b/src/db/postgres/sql/schema/1.0.0/apply.sql
new file mode 100644 (file)
index 0000000..274dd7a
--- /dev/null
@@ -0,0 +1,147 @@
+BEGIN;
+
+       CREATE TABLE almanac (
+               event TEXT NOT NULL PRIMARY KEY,
+               date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz
+       );
+       COMMENT ON TABLE almanac IS $docstring$
+Notable events for service administration.
+       $docstring$;
+
+       CREATE TABLE authentication (
+               identifier_id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+               created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               last_authentication TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz,
+               identifier TEXT NOT NULL UNIQUE,
+               credential TEXT
+       );
+       COMMENT ON TABLE authentication IS $docstring$
+Users and their credentials.
+       $docstring$;
+
+       CREATE TABLE resource (
+               resource_id UUID NOT NULL PRIMARY KEY DEFAULT uuid_generate_v4(), -- few insertions, v4 preferred over v1
+               description TEXT NOT NULL DEFAULT '',
+               created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               secret TEXT NOT NULL CHECK(length(secret) > 0)
+       );
+       COMMENT ON TABLE resource IS $docstring$
+External resource servers and their credentials.
+       $docstring$;
+
+       CREATE TABLE profile (
+               profile_id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+               identifier_id BIGINT NOT NULL REFERENCES authentication(identifier_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               profile TEXT NOT NULL,
+               CONSTRAINT unique_identifier_id_profile UNIQUE (identifier_id, profile)
+       );
+       CREATE INDEX profile_identifier_id_idx ON profile(identifier_id);
+       CREATE INDEX profile_profile_idx ON profile(profile);
+       COMMENT ON TABLE profile IS $docstring$
+Profile URIs and the users they are associated with.
+$docstring$;
+
+       CREATE TABLE scope (
+               scope_id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+               scope TEXT NOT NULL UNIQUE,
+               description TEXT NOT NULL DEFAULT '',
+               application TEXT NOT NULL DEFAULT '',
+               is_permanent BOOLEAN NOT NULL DEFAULT false,
+               is_manually_added BOOLEAN NOT NULL DEFAULT false
+       );
+       COMMENT ON TABLE scope IS $docstring$
+All and any scopes encountered.
+$docstring$;
+       COMMENT ON COLUMN scope.is_permanent IS $docstring$
+Prevents deletion of scope, set on seeded scope rows.
+$docstring$;
+       COMMENT ON COLUMN scope.is_manually_added IS $docstring$
+Prevents deletion of scope when no longer referenced, set on user-added scopes.
+User can still delete manually.
+$docstring$;
+       CREATE INDEX scope_garbage_collectable_idx ON scope(scope_id) WHERE NOT (is_permanent OR is_manually_added);
+       COMMENT ON INDEX scope_garbage_collectable_idx IS $docstring$
+Shadow the primary index with a partial to help GC cleanup of client-provided scopes.
+$docstring$;
+       CREATE INDEX scope_is_permanent_idx ON scope(scope_id) WHERE is_permanent;
+       CREATE INDEX scope_is_manual_idx ON scope(scope_id) WHERE is_manually_added;
+
+       CREATE TABLE profile_scope (
+               profile_id BIGINT NOT NULL REFERENCES profile(profile_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               scope_id BIGINT NOT NULL REFERENCES scope(scope_id) ON DELETE NO ACTION ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               PRIMARY KEY (profile_id, scope_id)
+       );
+       COMMENT ON TABLE profile_scope IS $docstring$
+Convenience bindings of available scopes for a profile.
+$docstring$;
+       CREATE INDEX profile_scope_scope_id_idx ON profile_scope(scope_id);
+
+       CREATE TABLE token (
+               code_id UUID NOT NULL PRIMARY KEY,
+               profile_id BIGINT NOT NULL REFERENCES profile(profile_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               expires TIMESTAMP WITH TIME ZONE,
+               refresh_expires TIMESTAMP WITH TIME ZONE,
+               refreshed TIMESTAMP WITH TIME ZONE,
+               duration INTERVAL,
+               CONSTRAINT expires_needs_duration CHECK (expires IS NULL OR duration IS NOT NULL),
+               refresh_duration INTERVAL,
+               CONSTRAINT refresh_expires_needs_refresh_duration CHECK (refresh_expires IS NULL OR refresh_duration IS NOT NULL),
+               refresh_count BIGINT NOT NULL DEFAULT 0,
+               is_revoked BOOLEAN NOT NULL DEFAULT false,
+               is_token BOOLEAN NOT NULL,
+               client_id TEXT NOT NULL,
+               resource TEXT,
+               profile_data JSONB
+       );
+       CREATE INDEX token_profile_id_idx ON token(profile_id);
+       CREATE INDEX token_created_idx ON token(created);
+       CREATE INDEX token_expires_idx ON token(expires);
+       CREATE INDEX token_refresh_expires_idx ON token(refresh_expires);
+       CREATE INDEX token_is_revoked_idx ON token(is_revoked) WHERE is_revoked;
+       COMMENT ON TABLE token IS $docstring$
+Redeemed codes and their current states.
+$docstring$;
+       COMMENT ON COLUMN token.is_token IS $docstring$
+Whether code was redeemed for a token, or only a profile.
+We track non-token redemptions to prevent re-redemption while
+code is still valid.
+$docstring$;
+       COMMENT ON COLUMN token.resource IS $docstring$
+Tokens granted by ticket redemption are associated with a
+resource url for which they are valid.
+$docstring$;
+       COMMENT ON INDEX token_is_revoked_idx IS $docstring$
+A partial index on revoked tokens, to ease cleanup query.
+$docstring$;
+
+       CREATE TABLE token_scope (
+               code_id UUID NOT NULL REFERENCES token(code_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               scope_id BIGINT NOT NULL REFERENCES scope(scope_id) ON DELETE NO ACTION ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               PRIMARY KEY (code_id, scope_id)
+       );
+       COMMENT ON TABLE token_scope IS $docstring$
+Scopes associated with a token.
+$docstring$;
+       CREATE INDEX token_scope_scope_id_idx ON token_scope(scope_id);
+
+       -- Update schema version
+       INSERT INTO _meta_schema_version (major, minor, patch) VALUES (1, 0, 0);
+
+       -- Seed scope data
+       INSERT INTO scope (scope, description, application, is_permanent) VALUES
+               ('profile', 'Access detailed profile information, including name, image, and url.', 'IndieAuth', true),
+               ('email', 'Include email address with detailed profile information.', 'IndieAuth', true),
+               ('create', 'Create new items.', 'MicroPub', true),
+               ('draft', 'All created items are drafts.', 'MicroPub', true),
+               ('update', 'Edit existing items.', 'MicroPub', true),
+               ('delete', 'Remove items.', 'MicroPub', true),
+               ('media', 'Allow file uploads.', 'MicroPub', true),
+               ('read', 'Read access to channels.', 'MicroSub', true),
+               ('follow', 'Management of following list.', 'MicroSub', true),
+               ('mute', 'Management of user muting.', 'MicroSub', true),
+               ('block', 'Management of user blocking.', 'MicroSub', true),
+               ('channels', 'Management of channels.', 'MicroSub', true)
+       ;
+
+COMMIT;
diff --git a/src/db/postgres/sql/schema/1.0.0/er.dot b/src/db/postgres/sql/schema/1.0.0/er.dot
new file mode 100644 (file)
index 0000000..147f50b
--- /dev/null
@@ -0,0 +1,105 @@
+digraph indieAutherERD {
+       graph[
+               rankdir=LR,
+               overlap=false,
+               splines=true,
+               label="IndieAuther Entity-Relations\nPostgres\nSchema 1.0.0",
+               labelloc="t",
+               fontsize=26,
+       ];
+       // layout=neato;
+       node[shape=plain];
+       edge[arrowhead=crow];
+
+       token [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">TOKEN</td></tr>
+               <tr><td port="pk_code_id">code_id</td></tr>
+               <tr><td port="fk_profile_id">profile_id</td></tr>
+               <tr><td port="">created</td></tr>
+               <tr><td port="">expires</td></tr>
+               <tr><td port="">refresh_expires</td></tr>
+               <tr><td port="">refreshed</td></tr>
+               <tr><td port="">duration</td></tr>
+               <tr><td port="">refresh_duration</td></tr>
+               <tr><td port="">refresh_count</td></tr>
+               <tr><td port="">is_revoked</td></tr>
+               <tr><td port="">is_token</td></tr>
+               <tr><td port="">client_id</td></tr>
+               <tr><td port="">resource</td></tr>
+               <tr><td port="">profile_data</td></tr>
+       </table>
+       >];
+       profile:pk_profile_id -> token:fk_profile_id;
+
+       scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">SCOPE</td></tr>
+               <tr><td port="pk_scope_id">scope_id</td></tr>
+               <tr><td port="">scope</td></tr>
+               <tr><td port="">description</td></tr>
+               <tr><td port="">application</td></tr>
+               <tr><td port="">is_permanent</td></tr>
+               <tr><td port="">is_manually_added</td></tr>
+       </table>
+       >];
+
+       token_scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">TOKEN_SCOPE</td></tr>
+               <tr><td port="fk_code_id">code_id</td></tr>
+               <tr><td port="fk_scope_id">scope_id</td></tr>
+       </table>
+       >];
+       token:pk_code_id -> token_scope:fk_code_id;
+       scope:pk_scope_id -> token_scope:fk_scope_id;
+
+       profile [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">PROFILE</td></tr>
+               <tr><td port="pk_profile_id">profile_id</td></tr>
+               <tr><td port="fk_identifier_id">identifier_id</td></tr>
+               <tr><td port="">profile</td></tr>
+       </table>
+       >];
+       authentication:pk_identifier_id -> profile:fk_identifier_id;
+
+       profile_scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">PROFILE_SCOPE</td></tr>
+               <tr><td port="fk_profile_id">profile_id</td></tr>
+               <tr><td port="fk_scope_id">scope_id</td></tr>
+       </table>
+       >];
+       profile:pk_profile_id -> profile_scope:fk_profile_id;
+       scope:pk_scope_id -> profile_scope:fk_scope_id;
+
+       authentication [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+                       <tr><td border="2" bgcolor="lightblue">AUTHENTICATION</td></tr>
+                       <tr><td port="pk_identifier_id">identifier_id</td></tr>
+                       <tr><td port="">created</td></tr>
+                       <tr><td port="">last_authenticated</td></tr>
+                       <tr><td port="">identifier</td></tr>
+                       <tr><td port="">credential</td></tr>
+       </table>
+       >];
+
+       resource [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">RESOURCE</td></tr>
+               <tr><td port="pk_resource_id">resource_id</td></tr>
+               <tr><td port="">description</td></tr>
+               <tr><td port="">created</td></tr>
+               <tr><td port="">secret</td></tr>
+       </table>
+       >];
+
+       almanac [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">ALMANAC</td></tr>
+               <tr><td port="pk_event">event</td></tr>
+               <tr><td port="">date</td></tr>
+       </table>
+       >];
+}
diff --git a/src/db/postgres/sql/schema/1.0.0/revert.sql b/src/db/postgres/sql/schema/1.0.0/revert.sql
new file mode 100644 (file)
index 0000000..af4665a
--- /dev/null
@@ -0,0 +1,9 @@
+BEGIN;
+       DROP TABLE authentication CASCADE;
+       DROP TABLE profile CASCADE;
+       DROP TABLE token CASCADE;
+       DROP TABLE scope CASCADE;
+       DROP TABLE profile_scope CASCADE;
+
+       DELETE FROM _meta_schema_version WHERE major = 1 AND minor = 0 AND patch = 0;
+COMMIT;
diff --git a/src/db/postgres/sql/schema/init.sql b/src/db/postgres/sql/schema/init.sql
new file mode 100644 (file)
index 0000000..618781f
--- /dev/null
@@ -0,0 +1,15 @@
+--
+CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+--
+BEGIN;
+CREATE TABLE IF NOT EXISTS _meta_schema_version (
+       major BIGINT NOT NULL,
+       minor BIGINT NOT NULL,
+       patch BIGINT NOT NULL,
+       applied TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+       PRIMARY KEY (major, minor, patch)
+);
+INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0);
+COMMIT;
diff --git a/src/db/postgres/sql/scope-cleanup.sql b/src/db/postgres/sql/scope-cleanup.sql
new file mode 100644 (file)
index 0000000..120e3d6
--- /dev/null
@@ -0,0 +1,8 @@
+-- Remove any un-used, non-permament, non-manually-created scopes.
+DELETE FROM scope WHERE scope_id NOT IN (
+       SELECT scope_id FROM scope s
+               INNER JOIN profile_scope ps USING (scope_id)
+       UNION All
+       SELECT scope_id FROM scope s
+               INNER JOIN token_scope ts USING (scope_id)
+) AND NOT (is_permanent OR is_manually_added)
diff --git a/src/db/postgres/sql/scope-delete.sql b/src/db/postgres/sql/scope-delete.sql
new file mode 100644 (file)
index 0000000..9fd42a2
--- /dev/null
@@ -0,0 +1,3 @@
+-- remove an inpermanent scope
+DELETE FROM scope
+       WHERE scope = $(scope) AND is_permanent = false
diff --git a/src/db/postgres/sql/scope-in-use.sql b/src/db/postgres/sql/scope-in-use.sql
new file mode 100644 (file)
index 0000000..a09c540
--- /dev/null
@@ -0,0 +1,13 @@
+-- return whether a scope is currently in use, either a profile setting or on a token
+SELECT EXISTS (
+       SELECT 1 FROM scope s
+               INNER JOIN profile_scope ps USING (scope_id)
+               WHERE s.scope = $(scope)
+       UNION All
+       SELECT 1 FROM scope s
+               INNER JOIN token_scope ts USING (scope_id)
+               WHERE s.scope = $(scope)
+       UNION All
+       SELECT 1 FROM scope s
+               WHERE s.scope = $(scope) AND s.is_permanent
+) AS in_use
diff --git a/src/db/postgres/sql/scope-upsert.sql b/src/db/postgres/sql/scope-upsert.sql
new file mode 100644 (file)
index 0000000..543d89d
--- /dev/null
@@ -0,0 +1,14 @@
+--
+-- N.B. weirdness with postgres empty string null equivalence and coalesce
+INSERT INTO scope (
+       scope, application, description, is_manually_added
+) VALUES (
+       $(scope),
+       CASE WHEN $(application) IS NULL THEN '' ELSE $(application) END,
+       CASE WHEN $(description) IS NULL THEN '' ELSE $(description) END,
+       COALESCE($(manuallyAdded), false)
+) ON CONFLICT (scope) DO UPDATE
+SET
+       application = CASE WHEN $(application) IS NULL THEN EXCLUDED.application ELSE $(application) END,
+       description = CASE WHEN $(description) IS NULL THEN EXCLUDED.description ELSE $(description) END,
+       is_manually_added = EXCLUDED.is_manually_added OR COALESCE($(manuallyAdded), false)
diff --git a/src/db/postgres/sql/scopes-insert.sql b/src/db/postgres/sql/scopes-insert.sql
new file mode 100644 (file)
index 0000000..a933e28
--- /dev/null
@@ -0,0 +1,6 @@
+-- Insert an externally-provided scope, or ignore.
+INSERT INTO scope
+       (scope)
+SELECT
+       unnest(${scopes})
+ON CONFLICT (scope) DO NOTHING
diff --git a/src/db/postgres/sql/token-cleanup.sql b/src/db/postgres/sql/token-cleanup.sql
new file mode 100644 (file)
index 0000000..9c2c744
--- /dev/null
@@ -0,0 +1,26 @@
+-- Remove tokens no longer in use.
+-- only clean after code has expired
+WITH
+cleanable_codes AS (
+       SELECT t.code_id, t.is_token, t.is_revoked, t.expires, t.refresh_expires FROM token t
+               WHERE t.created < (now() - $(codeLifespanSeconds)::text::interval)
+)
+DELETE FROM token WHERE code_id IN (
+       SELECT code_id FROM cleanable_codes
+       WHERE
+               NOT is_token -- profile-only redemptions
+       OR (
+               is_token AND (
+                       is_revoked -- revoked
+                       OR (
+                               expires < now() AND (
+                                       -- expired and unrefreshable
+                                       refresh_expires IS NULL
+                                       OR
+                                       -- expired and refresh expired
+                                       (refresh_expires IS NOT NULL AND refresh_expires < now())
+                               )
+                       )
+               )
+       )
+)
diff --git a/src/db/postgres/sql/token-get-by-code-id.sql b/src/db/postgres/sql/token-get-by-code-id.sql
new file mode 100644 (file)
index 0000000..23f0f73
--- /dev/null
@@ -0,0 +1,24 @@
+--
+SELECT
+       t.code_id,
+       p.profile,
+       t.created,
+       t.expires,
+       t.refresh_expires,
+       t.refreshed,
+       t.duration,
+       t.refresh_duration,
+       t.refresh_count,
+       t.is_revoked,
+       t.is_token,
+       t.client_id,
+       t.profile_data,
+       a.identifier,
+       ARRAY(
+               SELECT s.scope FROM token_scope ts INNER JOIN scope s USING (scope_id)
+               WHERE ts.code_id = t.code_id
+       ) AS scopes
+FROM token t
+       INNER JOIN profile p USING (profile_id)
+       INNER JOIN authentication a USING (identifier_id)
+WHERE code_id = $(codeId)
diff --git a/src/db/postgres/sql/token-refresh-revoke-by-code-id.sql b/src/db/postgres/sql/token-refresh-revoke-by-code-id.sql
new file mode 100644 (file)
index 0000000..4c7033d
--- /dev/null
@@ -0,0 +1,5 @@
+-- Revoke the refresh-token for a token
+UPDATE token SET
+       refresh_expires = NULL,
+       refresh_duration = NULL
+WHERE code_id = $(codeId)
diff --git a/src/db/postgres/sql/token-revoke-by-code-id.sql b/src/db/postgres/sql/token-revoke-by-code-id.sql
new file mode 100644 (file)
index 0000000..7a57efc
--- /dev/null
@@ -0,0 +1,5 @@
+--
+UPDATE token SET
+       is_revoked = true,
+       refresh_expires = NULL
+WHERE code_id = $(codeId)
diff --git a/src/db/postgres/sql/token-scopes-remove.sql b/src/db/postgres/sql/token-scopes-remove.sql
new file mode 100644 (file)
index 0000000..dd667f2
--- /dev/null
@@ -0,0 +1,7 @@
+--
+DELETE FROM token_scope
+WHERE (code_id, scope_id) IN (
+       SELECT code_id, scope_id FROM token_scope ts
+               INNER JOIN scope s USING (scope_id)
+               WHERE scope = ANY($(removeScopes)) AND code_id = $(codeId)
+)
diff --git a/src/db/postgres/sql/token-scopes-set.sql b/src/db/postgres/sql/token-scopes-set.sql
new file mode 100644 (file)
index 0000000..1557179
--- /dev/null
@@ -0,0 +1,4 @@
+--
+INSERT INTO token_scope (code_id, scope_id)
+       SELECT $(codeId), scope_id FROM scope WHERE scope = ANY ($(scopes))
+
diff --git a/src/db/postgres/sql/tokens-get-by-identifier.sql b/src/db/postgres/sql/tokens-get-by-identifier.sql
new file mode 100644 (file)
index 0000000..f8ec90e
--- /dev/null
@@ -0,0 +1,26 @@
+--
+SELECT 
+       t.code_id,
+       p.profile,
+       t.created,
+       t.expires,
+       t.refresh_expires,
+       t.refreshed,
+       t.duration,
+       t.refresh_duration,
+       t.refresh_count,
+       t.is_revoked,
+       t.is_token,
+       t.client_id,
+       t.resource,
+       t.profile_data,
+       a.identifier,
+       ARRAY(
+               SELECT s.scope FROM token_scope ts INNER JOIN scope s USING (scope_id)
+               WHERE ts.code_id = t.code_id
+       ) AS scopes
+FROM token t
+       INNER JOIN profile p USING (profile_id)
+       INNER JOIN authentication a USING (identifier_id)
+       WHERE a.identifier = $(identifier)
+       ORDER BY GREATEST(t.created, t.refreshed) DESC
diff --git a/src/db/schema-version-helper.js b/src/db/schema-version-helper.js
new file mode 100644 (file)
index 0000000..65a1e39
--- /dev/null
@@ -0,0 +1,131 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * Utility functions for wrangling schema migrations.
+ * This mostly just deals with sorting and comparing 'x.y.z' version
+ * strings, with some presumptions about directory layouts and whatnot.
+ */
+
+/**
+ * @typedef {Object} SchemaVersionObject
+ * @property {Number} major
+ * @property {Number} minor
+ * @property {Number} patch
+ */
+
+
+/**
+ * Split a dotted version string into parts.
+ * @param {String} v
+ * @returns {SchemaVersionObject}
+ */
+function schemaVersionStringToObject(v) {
+  const [ major, minor, patch ] = v.split('.', 3).map((x) => parseInt(x, 10));
+  return { major, minor, patch };
+}
+
+
+/**
+ * Render a version object numerically.
+ * @param {SchemaVersionObject} v
+ * @returns {Number}
+ */
+function schemaVersionObjectToNumber(v) {
+  const vScale = 1000;
+  return parseInt(v.major) * vScale * vScale + parseInt(v.minor) * vScale + parseInt(v.patch);
+}
+
+
+/**
+ * Convert dotted version string into number.
+ * @param {String} v
+ * @returns {Number}
+ */
+function schemaVersionStringToNumber(v) {
+  return schemaVersionObjectToNumber(schemaVersionStringToObject(v));
+}
+
+
+/**
+ * Version string comparison, for sorting.
+ * @param {String} a
+ * @param {String} b
+ * @returns {Number}
+ */
+function schemaVersionStringCmp(a, b) {
+  return schemaVersionStringToNumber(a) - schemaVersionStringToNumber(b);
+}
+
+
+/**
+ * Check if an entry in a directory is a directory containing a migration file.
+ * @param {String} schemaDir
+ * @param {String} name
+ * @returns {Boolean}
+ */
+function isSchemaMigrationDirectory(schemaDir, name, migrationFile = 'apply.sql') {
+  // eslint-disable-next-line security/detect-non-literal-fs-filename
+  const nameStat = fs.statSync(path.join(schemaDir, name));
+  if (nameStat.isDirectory()) {
+    let applyStat;
+    try {
+      // eslint-disable-next-line security/detect-non-literal-fs-filename
+      applyStat = fs.statSync(path.join(schemaDir, name, migrationFile));
+      return applyStat.isFile();
+    } catch (e) {
+      return false;
+    }
+  }
+  return false;
+}
+
+
+/**
+ * Return an array of schema migration directory names within engineDir,
+ * sorted in increasing order.
+ * @param {String} engineDir
+ * @returns {String[]}
+ */
+function allSchemaVersions(engineDir) {
+  const schemaDir = path.join(engineDir, 'sql', 'schema');
+  // eslint-disable-next-line security/detect-non-literal-fs-filename
+  const availableVersions = fs.readdirSync(schemaDir).filter((d) => isSchemaMigrationDirectory(schemaDir, d));
+  availableVersions.sort(schemaVersionStringCmp);
+  return availableVersions;
+}
+
+
+/**
+ * Return an array of schema migration directory names within engineDir,
+ * which are within supported range, and are greater than the current
+ * @param {String} engineDir
+ * @param {SchemaVersionObject} current
+ * @param {Object} supported
+ * @param {SchemaVersionObject} supported.min
+ * @param {SchemaVersionObject} supported.max
+ * @returns {String[]}
+ */
+function unappliedSchemaVersions(engineDir, current, supported) {
+  const min = schemaVersionObjectToNumber(supported.min);
+  const max = schemaVersionObjectToNumber(supported.max);
+  const cur = schemaVersionObjectToNumber(current);
+  const available = allSchemaVersions(engineDir);
+  return available.filter((a) => {
+    a = schemaVersionStringToNumber(a);
+    return a >= min && a <= max && a > cur;
+  });
+}
+
+
+module.exports = {
+  schemaVersionStringToObject,
+  schemaVersionObjectToNumber,
+  schemaVersionStringToNumber,
+  schemaVersionStringCmp,
+  isSchemaMigrationDirectory,
+  allSchemaVersions,
+  unappliedSchemaVersions,
+};
\ No newline at end of file
diff --git a/src/db/sqlite/index.js b/src/db/sqlite/index.js
new file mode 100644 (file)
index 0000000..878a004
--- /dev/null
@@ -0,0 +1,739 @@
+'use strict';
+
+const common = require('../../common');
+const Database = require('../abstract');
+const DBErrors = require('../errors');
+const svh = require('../schema-version-helper');
+const SQLite = require('better-sqlite3');
+const fs = require('fs');
+const path = require('path');
+const uuid = require('uuid');
+const { performance } = require('perf_hooks');
+
+const _fileScope = common.fileScope(__filename);
+
+const schemaVersionsSupported = {
+  min: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+  max: {
+    major: 1,
+    minor: 0,
+    patch: 0,
+  },
+};
+
+// max of signed int64 (2^63 - 1), should be enough
+// const EPOCH_FOREVER = BigInt('9223372036854775807');
+
+class DatabaseSQLite extends Database {
+  constructor(logger, options) {
+    super(logger, options);
+
+    const connectionString = options.db.connectionString || 'sqlite://:memory:';
+    const csDelim = '://';
+    const dbFilename = connectionString.slice(connectionString.indexOf(csDelim) + csDelim.length);
+
+    const queryLogLevel = options.db.queryLogLevel;
+
+    const sqliteOptions = {
+      ...(queryLogLevel && {
+        // eslint-disable-next-line security/detect-object-injection
+        verbose: (query) => this.logger[queryLogLevel](_fileScope('SQLite:verbose'), '', { query }),
+      }),
+    };
+    this.db = new SQLite(dbFilename, sqliteOptions);
+    this.schemaVersionsSupported = schemaVersionsSupported;
+    this.changesSinceLastOptimize = BigInt(0);
+    this.optimizeAfterChanges = options.db.sqliteOptimizeAfterChanges || 0; // Default to no periodic optimization.
+    this.db.pragma('foreign_keys = on'); // Enforce consistency.
+    this.db.pragma('journal_mode = WAL'); // Be faster, expect local filesystem.
+    this.db.defaultSafeIntegers(true); // This probably isn't necessary, but by using these BigInts we keep weird floats out of the query logs.
+
+    this._initTables();
+    this._initStatements();
+  }
+
+
+  /**
+   * Boolean to 0/1 representation for SQLite params.
+   * @param {Boolean} bool
+   * @returns {Number}
+   */
+  static _booleanToNumeric(bool) {
+    // eslint-disable-next-line security/detect-object-injection
+    return {
+      true: 1,
+      false: 0,
+    }[bool];
+  }
+
+
+  /**
+   * SQLite cannot prepare its statements without a schema, ensure such exists.
+   */
+  _initTables() {
+    const _scope = _fileScope('_initTables');
+
+    // Migrations rely upon this table, ensure it exists.
+    const metaVersionTable = '_meta_schema_version';
+    const tableExists = this.db.prepare('SELECT name FROM sqlite_master WHERE type=:type AND name=:name').pluck(true).bind({ type: 'table', name: metaVersionTable });
+    let metaExists = tableExists.get();
+    if (metaExists === undefined) {
+      const fPath = path.join(__dirname, 'sql', 'schema', 'init.sql');
+      // eslint-disable-next-line security/detect-non-literal-fs-filename
+      const fSql = fs.readFileSync(fPath, { encoding: 'utf8' });
+      this.db.exec(fSql);
+      metaExists = tableExists.get();
+      /* istanbul ignore if */
+      if (metaExists === undefined) {
+        throw new DBErrors.UnexpectedResult(`did not create ${metaVersionTable} table`);
+      }
+      this.logger.info(_scope, 'created schema version table', { metaVersionTable });
+    }
+
+    // Apply migrations
+    const currentSchema = this._currentSchema();
+    const migrationsWanted = svh.unappliedSchemaVersions(__dirname, currentSchema, this.schemaVersionsSupported);
+    this.logger.debug(_scope, 'schema migrations wanted', { migrationsWanted });
+    migrationsWanted.forEach((v) => {
+      const fPath = path.join(__dirname, 'sql', 'schema', v, 'apply.sql');
+      // eslint-disable-next-line security/detect-non-literal-fs-filename
+      const fSql = fs.readFileSync(fPath, { encoding: 'utf8' });
+      this.logger.info(_scope, 'applying migration', { version: v });
+      this.db.exec(fSql);
+    });
+  }
+
+
+  _initStatements() {
+    const _scope = _fileScope('_initStatements');
+    const sqlDir = path.join(__dirname, 'sql');
+    this.statement = {};
+
+    // Decorate the statement calls we use with timing and logging.
+    const wrapFetch = (logName, statementName, fn) => {
+      const _wrapScope = _fileScope(logName);
+      return (...args) => {
+        const startTimestampMs = performance.now();
+        const rows = fn(...args);
+        DatabaseSQLite._deOphidiate(rows);
+        const elapsedTimeMs = performance.now() - startTimestampMs;
+        this.logger.debug(_wrapScope, 'complete', { statementName, elapsedTimeMs });
+        return rows;
+      };
+    };
+    const wrapRun = (logName, statementName, fn) => {
+      const _wrapScope = _fileScope(logName);
+      return (...args) => {
+        const startTimestampMs = performance.now();
+        const result = fn(...args);
+        const elapsedTimeMs = performance.now() - startTimestampMs;
+        this._updateChanges(result);
+        this.logger.debug(_wrapScope, 'complete', { ...result, statementName, elapsedTimeMs });
+        result.duration = elapsedTimeMs;
+        return result;
+      };
+    };
+
+    // eslint-disable-next-line security/detect-non-literal-fs-filename
+    for (const f of fs.readdirSync(sqlDir)) {
+      const fPath = path.join(sqlDir, f);
+      const { name: fName, ext: fExt } = path.parse(f);
+      // eslint-disable-next-line security/detect-non-literal-fs-filename
+      const stat = fs.statSync(fPath);
+      if (!stat.isFile()
+      ||  fExt.toLowerCase() !== '.sql') {
+        continue;
+      }
+      // eslint-disable-next-line security/detect-non-literal-fs-filename
+      const fSql = fs.readFileSync(fPath, { encoding: 'utf8' });
+      const statementName = common.camelfy(fName.toLowerCase(), '-');
+      let statement;
+      try {
+        statement = this.db.prepare(fSql);
+      } catch (e) /* istanbul ignore next */ {
+        this.logger.error(_scope, 'failed to prepare statement', { error: e, file: f });
+        throw e;
+      }
+      // eslint-disable-next-line security/detect-object-injection
+      this.statement[statementName] = statement;
+      const { get: origGet, all: origAll, run: origRun } = statement;
+      statement.get = wrapFetch('SQLite:get', statementName, origGet.bind(statement));
+      statement.all = wrapFetch('SQLite:all', statementName, origAll.bind(statement));
+      statement.run = wrapRun('SQLite:run', statementName, origRun.bind(statement));
+    }
+    this.statement._optimize = this.db.prepare('SELECT * FROM pragma_optimize(0xffff)');
+
+    this.logger.debug(_scope, 'statements initialized', { statements: Object.keys(this.statement).length });
+  }
+
+
+  static _deOphidiate(rows) {
+    const rowsIsArray = Array.isArray(rows);
+    if (!rowsIsArray) {
+      rows = [rows];
+    }
+    const exemplaryRow = rows[0];
+    for (const prop in exemplaryRow) {
+      const camel = common.camelfy(prop);
+      if (!(camel in exemplaryRow)) {
+        for (const d of rows) {
+          d[camel] = d[prop]; // eslint-disable-line security/detect-object-injection
+          delete d[prop]; // eslint-disable-line security/detect-object-injection
+        }
+      }
+    }
+    return rowsIsArray ? rows : rows[0];
+  }
+
+
+  _currentSchema() {
+    return this.db.prepare('SELECT major, minor, patch FROM _meta_schema_version ORDER BY major DESC, minor DESC, patch DESC LIMIT 1').get();
+  }
+
+
+  healthCheck() {
+    const _scope = _fileScope('healthCheck');
+    this.logger.debug(_scope, 'called', {});
+    if (!this.db.open) {
+      throw new DBErrors.UnexpectedResult('database is not open');
+    }
+    return { open: this.db.open };
+  }
+
+
+  _closeConnection() {
+    this.db.close();
+  }
+
+
+  _optimize() {
+    const _scope = _fileScope('_optimize');
+
+    const optimize = this.statement._optimize.all();
+    this.logger.debug(_scope, 'optimize', { optimize, changes: this.changesSinceLastOptimize });
+    this.db.pragma('optimize');
+    this.changesSinceLastOptimize = BigInt(0);
+  }
+
+
+  _updateChanges(dbResult) {
+    if (this.optimizeAfterChanges) {
+      this.changesSinceLastOptimize += BigInt(dbResult.changes);
+      if (this.changesSinceLastOptimize >= this.optimizeAfterChanges) {
+        this._optimize();
+      }
+    }
+  }
+
+
+  _purgeTables(really) {
+    if (really) {
+      [
+        'authentication',
+        'profile',
+        'token',
+      ].map((table) => {
+        const result = this.db.prepare(`DELETE FROM ${table}`).run();
+        this.logger.debug(_fileScope('_purgeTables'), 'success', { table, result });
+      });
+    }
+  }
+
+
+  context(fn) {
+    return fn(this.db);
+  }
+
+
+  transaction(dbCtx, fn) {
+    dbCtx = dbCtx || this.db;
+    return dbCtx.transaction(fn)();
+  }
+
+
+  static _almanacToNative(entry) {
+    return {
+      event: entry.event,
+      date: new Date(Number(entry.epoch) * 1000),
+    };
+  }
+
+  almanacGetAll(dbCtx) { // eslint-disable-line no-unused-vars
+    const _scope = _fileScope('almanacGetAll');
+    this.logger.debug(_scope, 'called');
+
+    try {
+      const entries = this.statement.almanacGetAll.all();
+      return entries.map((entry) => DatabaseSQLite._almanacToNative(entry));
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+
+  static _authenticationToNative(authentication) {
+    if (authentication) {
+      authentication.created = new Date(Number(authentication.created) * 1000);
+      authentication.lastAuthentication = new Date(Number(authentication.lastAuthentication) * 1000);
+    }
+    return authentication;
+  }
+
+
+  authenticationGet(dbCtx, identifier) {
+    const _scope = _fileScope('authenticationGet');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    try {
+      const authentication = this.statement.authenticationGet.get({ identifier });
+      return DatabaseSQLite._authenticationToNative(authentication);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier });
+      throw e;
+    }
+  }
+
+
+  authenticationSuccess(dbCtx, identifier) {
+    const _scope = _fileScope('authenticationSuccess');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    try {
+      const result = this.statement.authenticationSuccess.run({ identifier });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not update authentication success');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier });
+      throw e;
+    }
+  }
+
+
+  authenticationUpsert(dbCtx, identifier, credential) {
+    const _scope = _fileScope('authenticationUpsert');
+    const scrubbedCredential = '*'.repeat((credential || '').length);
+    this.logger.debug(_scope, 'called', { identifier, scrubbedCredential });
+
+    let result;
+    try {
+      result = this.statement.authenticationUpsert.run({ identifier, credential });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not upsert authentication');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier, scrubbedCredential });
+      throw e;
+    }
+  }
+
+
+  profileIdentifierInsert(dbCtx, profile, identifier) {
+    const _scope = _fileScope('profileIdentifierInsert');
+    this.logger.debug(_scope, 'called', { profile, identifier });
+
+    try {
+      const result = this.statement.profileIdentifierInsert.run({ profile, identifier });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not insert profile identifier relationship');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, profile, identifier });
+      throw e;
+    }
+  }
+
+
+  profileIsValid(dbCtx, profile) {
+    const _scope = _fileScope('profileIsValid');
+    this.logger.debug(_scope, 'called', { profile });
+
+    try {
+      const profileResponse = this.statement.profileGet.get({ profile });
+      return !!profileResponse;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, profile });
+      throw e;
+    }
+  }
+
+
+  profileScopeInsert(dbCtx, profile, scope) {
+    const _scope = _fileScope('profileScopeInsert');
+    this.logger.debug(_scope, 'called', { profile, scope });
+
+    try {
+      const result = this.statement.profileScopeInsert.run({ profile, scope });
+      // Duplicate inserts get ignored
+      if (result.changes != 1 && result.changes != 0) {
+        throw new DBErrors.UnexpectedResult('did not insert profile scope');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, profile, scope });
+      throw e;
+    }
+  }
+
+
+  profileScopesSetAll(dbCtx, profile, scopes) {
+    const _scope = _fileScope('profileScopesSetAll');
+    this.logger.debug(_scope, 'called', { profile, scopes });
+
+    try {
+      this.transaction(dbCtx, () => {
+        this.statement.profileScopesClear.run({ profile });
+        if (scopes.length) {
+          scopes.forEach((scope) => {
+            this.statement.profileScopeInsert.run({ profile, scope });
+          });
+        }
+      }); // transaction
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, profile, scopes });
+      throw e;
+    }
+  }
+
+
+  profilesScopesByIdentifier(dbCtx, identifier) {
+    const _scope = _fileScope('profilesScopesByIdentifier');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    try {
+      const profileScopesRows = this.statement.profilesScopesByIdentifier.all({ identifier });
+      return Database._profilesScopesBuilder(profileScopesRows);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier });
+      throw e;
+    }
+  }
+
+
+  redeemCode(dbCtx, { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, profileData }) {
+    const _scope = _fileScope('redeemCode');
+    this.logger.debug(_scope, 'called', { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, profileData });
+
+    let result, ret = false;
+    try {
+      if (profileData) {
+        profileData = JSON.stringify(profileData);
+      }
+      this.transaction(dbCtx, () => {
+        result = this.statement.redeemCode.get({ codeId, created: common.dateToEpoch(created), isToken: DatabaseSQLite._booleanToNumeric(isToken), clientId, profile, identifier, lifespanSeconds, refreshLifespanSeconds, profileData });
+        if (!result) {
+          this.logger.error(_scope, 'failed', { result });
+          throw new DBErrors.UnexpectedResult('did not redeem code');
+        }
+        // Abort and return false if redemption resulted in revocation.
+        if (result.isRevoked) {
+          return;
+        }
+
+        // Ensure there are entries for all scopes, and associate with token.
+        scopes.forEach((scope) => {
+          this.statement.scopeInsert.run({ scope });
+          this.statement.tokenScopeSet.run({ codeId, scope });
+        });
+        ret = true;
+      }); // tx
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeId, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, profileData });
+      throw e;
+    }
+    return ret;
+  }
+
+
+  static _refreshCodeResponseToNative(refreshResponse) {
+    if (refreshResponse) {
+      ['expires', 'refreshExpires'].forEach((epochField) => {
+        if (refreshResponse[epochField]) { // eslint-disable-line security/detect-object-injection
+          refreshResponse[epochField] = new Date(Number(refreshResponse[epochField]) * 1000); // eslint-disable-line security/detect-object-injection
+        }
+      });
+    }
+    return refreshResponse;
+  }
+
+
+  refreshCode(dbCtx, codeId, refreshed, removeScopes) {
+    const _scope = _fileScope('refreshCode');
+    this.logger.debug(_scope, 'called', { codeId, refreshed, removeScopes });
+
+    try {
+      return this.transaction(dbCtx, () => {
+        const refreshResponse = this.statement.refreshCode.get({ codeId, refreshed: common.dateToEpoch(refreshed) });
+        if (refreshResponse) {
+          removeScopes.forEach((scope) => {
+            const result = this.statement.tokenScopeRemove.run({ codeId, scope });
+            if (result?.changes != 1) {
+              this.logger.error(_scope, 'failed to remove token scope', { codeId, scope });
+              throw new DBErrors.UnexpectedResult('did not remove scope from token');
+            }
+          });
+          if (removeScopes.length) {
+            refreshResponse.scopes = (this.statement.tokenScopesGetByCodeId.all({ codeId }) || [])
+              .map((row) => row.scope);  
+          }
+        } else {
+          this.logger.debug(_scope, 'did not refresh token', {});
+        }
+        return DatabaseSQLite._refreshCodeResponseToNative(refreshResponse);
+      }); // tx
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeId, refreshed });
+      throw e;
+    }
+  }
+
+
+  static _resourceToNative(resource) {
+    if (resource) {
+      resource.created = new Date(Number(resource.created) * 1000);
+    }
+    return resource;
+  }
+
+
+  resourceGet(dbCtx, resourceId) {
+    const _scope = _fileScope('resourceGet');
+    this.logger.debug(_scope, 'called', { resourceId });
+
+    try {
+      const resource = this.statement.resourceGet.get({ resourceId });
+      return DatabaseSQLite._resourceToNative(resource);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, resourceId });
+      throw e;
+    }
+  }
+
+
+  resourceUpsert(dbCtx, resourceId, secret, description) {
+    const _scope = _fileScope('resourceUpsert');
+    this.logger.debug(_scope, 'called', { resourceId });
+
+    try {
+      if (!resourceId) {
+        resourceId = uuid.v4();
+      }
+      const result = this.statement.resourceUpsert.run({ resourceId, secret, description });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not upsert resource');
+      }
+      const resource = this.statement.resourceGet.get({ resourceId });
+      return DatabaseSQLite._resourceToNative(resource);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, resourceId, secret, description });
+      throw e;
+    }
+  }
+
+
+  scopeCleanup(dbCtx, atLeastMsSinceLast) {
+    const _scope = _fileScope('scopeCleanup');
+    this.logger.debug(_scope, 'called', { atLeastMsSinceLast });
+
+    const almanacEvent = 'scopeCleanup';
+    try {
+      return this.db.transaction(() => {
+
+        // Check that enough time has passed since last cleanup
+        const nowEpoch = BigInt(common.dateToEpoch());
+        const { epoch: lastCleanupEpoch } = this.statement.almanacGet.get({ event: almanacEvent }) || { epoch: 0n };
+        const elapsedMs = (nowEpoch - lastCleanupEpoch) * 1000n;
+        if (elapsedMs < atLeastMsSinceLast) {
+          this.logger.debug(_scope, 'skipping token cleanup, too soon', { lastCleanupEpoch, elapsedMs, atLeastMsSinceLast });
+          return;
+        }
+
+        // Do the cleanup
+        const { changes: scopesRemoved } = this.statement.scopeCleanup.run();
+
+        // Update the last cleanup time
+        const result = this.statement.almanacUpsert.run({ event: almanacEvent, epoch: nowEpoch });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not update almanac');
+        }
+
+        this.logger.debug(_scope, 'finished', { scopesRemoved, atLeastMsSinceLast });
+        return scopesRemoved;
+      }).exclusive();
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, atLeastMsSinceLast });
+      throw e;
+    }
+  }
+
+
+  scopeDelete(dbCtx, scope) {
+    const _scope = _fileScope('scopeDelete');
+    this.logger.debug(_scope, 'called', { scope });
+
+    try {
+      return this.transaction(dbCtx, () => {
+        const { inUse } = this.statement.scopeInUse.get({ scope });
+        if (inUse) {
+          this.logger.debug(_scope, 'not deleted, in use', { scope });
+          return false;
+        }
+        const result = this.statement.scopeDelete.run({ scope });
+        if (result.changes == 0) {
+          this.logger.debug(_scope, 'no such scope', { scope });
+        } else {
+          this.logger.debug(_scope, 'deleted', { scope });
+        }
+        return true;
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, scope });
+      throw e;
+    }
+  }
+
+
+  scopeUpsert(dbCtx, scope, application, description, manuallyAdded) {
+    const _scope = _fileScope('scopeUpsert');
+    this.logger.debug(_scope, 'called', { scope, application, description, manuallyAdded });
+
+    try {
+      const result = this.statement.scopeUpsert.run({ scope, application, description, manuallyAdded: DatabaseSQLite._booleanToNumeric(manuallyAdded) });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not upsert scope');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, scope, application, description, manuallyAdded });
+      throw e;
+    }
+  }
+
+
+  tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast) {
+    const _scope = _fileScope('tokenCleanup');
+    this.logger.debug(_scope, 'called', { codeLifespanSeconds, atLeastMsSinceLast });
+
+    const almanacEvent = 'tokenCleanup';
+    try {
+      return this.db.transaction(() => {
+
+        // Check that enough time has passed since last cleanup
+        const nowEpoch = BigInt(common.dateToEpoch());
+        const { epoch: lastCleanupEpoch } = this.statement.almanacGet.get({ event: almanacEvent }) || { epoch: 0n };
+        const elapsedMs = (nowEpoch - lastCleanupEpoch) * 1000n;
+        if (elapsedMs < atLeastMsSinceLast) {
+          this.logger.debug(_scope, 'skipping token cleanup, too soon', { lastCleanupEpoch, elapsedMs, atLeastMsSinceLast });
+          return;
+        }
+
+        // Do the cleanup
+        const { changes: tokensRemoved } = this.statement.tokenCleanup.run({ codeLifespanSeconds });
+
+        // Update the last cleanup time
+        const result = this.statement.almanacUpsert.run({ event: almanacEvent, epoch: nowEpoch });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not update almanac');
+        }
+
+        this.logger.debug(_scope, 'finished', { tokensRemoved, codeLifespanSeconds, atLeastMsSinceLast });
+        return tokensRemoved;
+      }).exclusive();
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeLifespanSeconds, atLeastMsSinceLast });
+      throw e;
+    }
+  }
+
+
+  static _tokenToNative(token) {
+    if (token) {
+      token.created = new Date(Number(token.created) * 1000);
+      if (token.expires || token.expires == 0) {
+        token.expires = new Date(Number(token.expires) * 1000);
+      }
+      if (token.refreshExpires || token.refreshExpires == 0) {
+        token.refreshExpires = new Date(Number(token.refreshExpires) * 1000);
+      }
+      if (token.refreshed || token.refreshed == 0) {
+        token.refreshed = new Date(Number(token.refreshed) * 1000);
+      }
+      token.isRevoked = !!token.isRevoked;
+      token.isToken = !!token.isToken;
+      if (token.profileData) {
+        token.profileData = JSON.parse(token.profileData);
+      }
+    }
+    return token;
+  }
+
+
+  tokenGetByCodeId(dbCtx, codeId) {
+    const _scope = _fileScope('tokenGetByCodeId');
+    this.logger.debug(_scope, 'called', { codeId });
+
+    try {
+      return this.transaction(dbCtx, () => {
+        const token = this.statement.tokenGetByCodeId.get({ codeId });
+        token.scopes = (this.statement.tokenScopesGetByCodeId.all({ codeId }) || [])
+          .map((row) => row.scope);
+        return DatabaseSQLite._tokenToNative(token);  
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeId });
+      throw e;
+    }
+  }
+
+
+  tokenRefreshRevokeByCodeId(dbCtx, codeId) {
+    const _scope = _fileScope('tokenRefreshRevokeByCodeId');
+    this.logger.debug(_scope, 'called', { codeId });
+
+    try {
+      const result = this.statement.tokenRefreshRevokeByCodeId.run({ codeId });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not revoke refresh');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeId });
+      throw e;
+    }
+  }
+
+
+  tokenRevokeByCodeId(dbCtx, codeId) {
+    const _scope = _fileScope('tokenRevokeByCodeId');
+    this.logger.debug(_scope, 'called', { codeId });
+
+    try {
+      const result = this.statement.tokenRevokeByCodeId.run({ codeId });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not revoke token');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, codeId });
+      throw e;
+    }
+  }
+
+
+  tokensGetByIdentifier(dbCtx, identifier) {
+    const _scope = _fileScope('tokensGetByIdentifier');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    try {
+      const tokens = this.statement.tokensGetByIdentifier.all({ identifier });
+      return tokens.map(DatabaseSQLite._tokenToNative);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, identifier });
+      throw e;
+    }
+  }
+
+}
+
+module.exports = DatabaseSQLite;
\ No newline at end of file
diff --git a/src/db/sqlite/sql/almanac-get-all.sql b/src/db/sqlite/sql/almanac-get-all.sql
new file mode 100644 (file)
index 0000000..fddd282
--- /dev/null
@@ -0,0 +1,2 @@
+--
+SELECT * FROM almanac
diff --git a/src/db/sqlite/sql/almanac-get.sql b/src/db/sqlite/sql/almanac-get.sql
new file mode 100644 (file)
index 0000000..00dc708
--- /dev/null
@@ -0,0 +1,2 @@
+--
+SELECT epoch FROM almanac WHERE event = :event
diff --git a/src/db/sqlite/sql/almanac-upsert.sql b/src/db/sqlite/sql/almanac-upsert.sql
new file mode 100644 (file)
index 0000000..fc8be48
--- /dev/null
@@ -0,0 +1,9 @@
+--
+INSERT INTO almanac
+       (event, epoch)
+VALUES
+       (:event, :epoch)
+ON CONFLICT (event) DO UPDATE
+SET
+       epoch = :epoch
+
diff --git a/src/db/sqlite/sql/authentication-get.sql b/src/db/sqlite/sql/authentication-get.sql
new file mode 100644 (file)
index 0000000..687e54a
--- /dev/null
@@ -0,0 +1,8 @@
+--
+SELECT
+       created,
+       last_authentication,
+       identifier,
+       credential
+FROM authentication
+WHERE identifier = :identifier
diff --git a/src/db/sqlite/sql/authentication-success.sql b/src/db/sqlite/sql/authentication-success.sql
new file mode 100644 (file)
index 0000000..b4c45a6
--- /dev/null
@@ -0,0 +1,4 @@
+--
+UPDATE authentication
+       SET last_authentication = strftime('%s', 'now')
+       WHERE identifier = :identifier
diff --git a/src/db/sqlite/sql/authentication-upsert.sql b/src/db/sqlite/sql/authentication-upsert.sql
new file mode 100644 (file)
index 0000000..8a141ee
--- /dev/null
@@ -0,0 +1,9 @@
+--
+INSERT INTO authentication
+       (identifier, credential)
+VALUES
+       (:identifier, :credential)
+ON CONFLICT (identifier) DO UPDATE
+SET
+       identifier = :identifier,
+       credential = :credential
diff --git a/src/db/sqlite/sql/profile-get.sql b/src/db/sqlite/sql/profile-get.sql
new file mode 100644 (file)
index 0000000..74aa7a5
--- /dev/null
@@ -0,0 +1,3 @@
+--
+SELECT * FROM profile
+WHERE profile = :profile
diff --git a/src/db/sqlite/sql/profile-identifier-insert.sql b/src/db/sqlite/sql/profile-identifier-insert.sql
new file mode 100644 (file)
index 0000000..b357dce
--- /dev/null
@@ -0,0 +1,4 @@
+--
+INSERT INTO profile (profile, identifier_id)
+       SELECT :profile, identifier_id FROM authentication WHERE identifier = :identifier
+
diff --git a/src/db/sqlite/sql/profile-scope-insert.sql b/src/db/sqlite/sql/profile-scope-insert.sql
new file mode 100644 (file)
index 0000000..be4f212
--- /dev/null
@@ -0,0 +1,5 @@
+--
+INSERT INTO profile_scope (profile_id, scope_id)
+       SELECT p.profile_id, s.scope_id FROM profile p, scope s
+       WHERE p.profile = :profile AND s.scope = :scope
+ON CONFLICT (profile_id, scope_id) DO NOTHING
diff --git a/src/db/sqlite/sql/profile-scopes-clear.sql b/src/db/sqlite/sql/profile-scopes-clear.sql
new file mode 100644 (file)
index 0000000..be544fc
--- /dev/null
@@ -0,0 +1,5 @@
+--
+DELETE FROM profile_scope
+WHERE profile_id IN(
+       SELECT profile_id FROM profile WHERE profile = :profile
+)
diff --git a/src/db/sqlite/sql/profiles-scopes-by-identifier.sql b/src/db/sqlite/sql/profiles-scopes-by-identifier.sql
new file mode 100644 (file)
index 0000000..485d043
--- /dev/null
@@ -0,0 +1,10 @@
+--
+SELECT p.profile, s.*
+       FROM profile p
+               INNER JOIN authentication a USING (identifier_id)
+               FULL JOIN profile_scope ps USING (profile_id)
+               FULL JOIN scope s USING (scope_id)
+       WHERE a.identifier = :identifier
+UNION ALL SELECT NULL AS profile, *
+       FROM scope
+       WHERE is_manually_added OR is_permanent
diff --git a/src/db/sqlite/sql/redeem-code.sql b/src/db/sqlite/sql/redeem-code.sql
new file mode 100644 (file)
index 0000000..5f237e8
--- /dev/null
@@ -0,0 +1,27 @@
+--
+INSERT INTO token (
+       code_id,
+       created,
+       is_token,
+       client_id,
+       profile_id,
+       expires,
+       duration,
+       refresh_expires,
+       refresh_duration
+)
+       SELECT
+               :codeId,
+               :created,
+               :isToken,
+               :clientId,
+               p.profile_id,
+               CASE WHEN :lifespanSeconds IS NULL THEN NULL ELSE :created + :lifespanSeconds END,
+               :lifespanSeconds,
+               CASE WHEN :refreshLifespanSeconds IS NULL THEN NULL ELSE :created + :refreshLifespanSeconds END,
+               :refreshLifespanSeconds
+       FROM profile p INNER JOIN authentication a USING (identifier_id)
+               WHERE p.profile = :profile AND a.identifier = :identifier
+ON CONFLICT (code_id) DO UPDATE
+       SET is_revoked = true
+RETURNING is_revoked
diff --git a/src/db/sqlite/sql/refresh-code.sql b/src/db/sqlite/sql/refresh-code.sql
new file mode 100644 (file)
index 0000000..e2820c8
--- /dev/null
@@ -0,0 +1,15 @@
+-- 
+UPDATE token set
+       refreshed = :refreshed,
+       expires = :refreshed + duration,
+       refresh_expires = :refreshed + refresh_duration,
+       refresh_count = refresh_count + 1
+WHERE
+       code_id = :codeId
+AND
+       NOT is_revoked
+AND
+       (refresh_expires IS NOT NULL AND refresh_expires > :refreshed)
+RETURNING
+       expires,
+       refresh_expires
diff --git a/src/db/sqlite/sql/resource-get.sql b/src/db/sqlite/sql/resource-get.sql
new file mode 100644 (file)
index 0000000..6e481d5
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT *
+FROM resource
+WHERE resource_id = :resourceId
diff --git a/src/db/sqlite/sql/resource-upsert.sql b/src/db/sqlite/sql/resource-upsert.sql
new file mode 100644 (file)
index 0000000..84d333c
--- /dev/null
@@ -0,0 +1,9 @@
+--
+INSERT INTO resource
+       (resource_id, secret, description)
+VALUES (:resourceId, :secret, :description)
+ON CONFLICT (resource_id) DO UPDATE
+SET
+       secret = COALESCE(EXCLUDED.secret, resource.secret),
+       description = COALESCE(EXCLUDED.description, resource.description)
+RETURNING *
diff --git a/src/db/sqlite/sql/schema/1.0.0/apply.sql b/src/db/sqlite/sql/schema/1.0.0/apply.sql
new file mode 100644 (file)
index 0000000..2b2be31
--- /dev/null
@@ -0,0 +1,97 @@
+BEGIN;
+
+       CREATE TABLE almanac (
+               event TEXT NOT NULL PRIMARY KEY CHECK (typeof(event) = 'text'),
+               epoch INTEGER NOT NULL DEFAULT 0 CHECK (typeof(epoch) = 'integer')
+       );
+
+       CREATE TABLE authentication (
+               identifier_id INTEGER NOT NULL PRIMARY KEY,
+               created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(created) = 'integer'),
+               last_authentication INTEGER NOT NULL DEFAULT 0 CHECK (typeof(last_authentication) = 'integer'),
+               identifier TEXT NOT NULL UNIQUE CHECK (typeof(identifier) = 'text'),
+               credential TEXT CHECK (typeof(credential) IN ('text', 'null'))
+       );
+
+       CREATE TABLE resource (
+               resource_id TEXT NOT NULL PRIMARY KEY CHECK (typeof(resource_id) = 'text'),
+               description TEXT NOT NULL DEFAULT '' CHECK (typeof(description) = 'text'),
+               created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(created) = 'integer'),
+               secret TEXT NOT NULL CHECK (typeof(secret) = 'text')
+       );
+
+       CREATE TABLE profile (
+               profile_id INTEGER NOT NULL PRIMARY KEY,
+               identifier_id INTEGER NOT NULL REFERENCES authentication(identifier_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               profile TEXT NOT NULL CHECK (typeof(profile) = 'text'),
+               UNIQUE (identifier_id, profile) ON CONFLICT IGNORE
+       );
+       CREATE INDEX profile_identifier_id_idx ON profile(identifier_id);
+       CREATE INDEX profile_profile_idx ON profile(profile);
+
+       CREATE TABLE scope (
+               scope_id INTEGER NOT NULL PRIMARY KEY,
+               scope TEXT NOT NULL UNIQUE CHECK (typeof(scope) = 'text'),
+               description TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' CHECK (typeof(description) = 'text'),
+               application TEXT NOT NULL ON CONFLICT REPLACE DEFAULT '' CHECK (typeof(application) = 'text'),
+               is_permanent INTEGER NOT NULL DEFAULT 0 CHECK (typeof(is_permanent) = 'integer' AND is_permanent IN (0, 1)),
+               is_manually_added INTEGER NOT NULL DEFAULT 0 CHECK (typeof(is_manually_added) = 'integer' AND is_manually_added IN (0, 1))
+       );
+
+       CREATE TABLE profile_scope (
+               profile_id INTEGER NOT NULL REFERENCES profile(profile_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               scope_id INTEGER NOT NULL REFERENCES scope(scope_id) ON DELETE NO ACTION ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               PRIMARY KEY (profile_id, scope_id)
+       );
+       CREATE INDEX profile_scope_scope_id_idx ON profile_scope(scope_id);
+
+       CREATE TABLE token (
+               code_id TEXT NOT NULL PRIMARY KEY CHECK (typeof(code_id) = 'text'),
+               profile_id INTEGER NOT NULL REFERENCES profile (profile_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(created) = 'integer'),
+               expires INTEGER CHECK (typeof(expires) IN ('integer', 'null')),
+               refresh_expires INTEGER CHECK (typeof(refresh_expires) IN ('integer', 'null')),
+               refreshed INTEGER CHECK (typeof(refreshed) IN ('integer', 'null')),
+               duration INTEGER CHECK (typeof(duration) IN ('integer', 'null')),
+               refresh_duration INTEGER CHECK (typeof(refresh_duration) IN ('integer', 'null')),
+               refresh_count INTEGER NOT NULL DEFAULT 0 CHECK (typeof(refresh_count) = 'integer'),
+               is_revoked INTEGER NOT NULL DEFAULT 0 CHECK (typeof(is_revoked) = 'integer' AND is_revoked IN (0, 1)),
+               is_token INTEGER NOT NULL CHECK (typeof(is_token) = 'integer' AND is_token IN (0, 1)),
+               client_id TEXT NOT NULL CHECK (typeof(client_id) = 'text'),
+               resource TEXT CHECK (typeof(resource) IN ('integer', 'null')),
+               profile_data TEXT,
+               CONSTRAINT expires_needs_duration CHECK (expires IS NULL OR duration IS NOT NULL),
+               CONSTRAINT refresh_expires_needs_refresh_duration CHECK (refresh_expires IS NULL OR refresh_duration IS NOT NULL)
+       );
+       CREATE INDEX token_profile_id_idx ON token(profile_id);
+       CREATE INDEX token_created_idx ON token(created);
+       CREATE INDEX token_expires_idx ON token(expires) WHERE expires IS NOT NULL;
+       CREATE INDEX token_refresh_expires_idx ON token(refresh_expires) WHERE refresh_expires IS NOT NULL;
+
+       CREATE TABLE token_scope (
+               code_id TEXT NOT NULL REFERENCES token(code_id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               scope_id INTEGER NOT NULL REFERENCES scope(scope_id) ON DELETE NO ACTION ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               PRIMARY KEY (code_id, scope_id)
+       );
+       CREATE INDEX token_scope_scope_id_idx ON token_scope(scope_id);
+
+       -- Update schema version
+       INSERT INTO _meta_schema_version (major, minor, patch) VALUES (1, 0, 0);
+
+       -- Seed scope data
+       INSERT INTO scope (scope, description, application, is_permanent) VALUES
+               ('profile', 'Access detailed profile information, including name, image, and url.', 'IndieAuth', 1),
+               ('email', 'Include email address with detailed profile information.', 'IndieAuth', 1),
+               ('create', 'Create new items.', 'MicroPub', 1),
+               ('draft', 'All created items are drafts.', 'MicroPub', 1),
+               ('update', 'Edit existing items.', 'MicroPub', 1),
+               ('delete', 'Remove items.', 'MicroPub', 1),
+               ('media', 'Allow file uploads.', 'MicroPub', 1),
+               ('read', 'Read access to channels.', 'MicroSub', 1),
+               ('follow', 'Management of following list.', 'MicroSub', 1),
+               ('mute', 'Management of user muting.', 'MicroSub', 1),
+               ('block', 'Management of user blocking.', 'MicroSub', 1),
+               ('channels', 'Management of channels.', 'MicroSub', 1)
+       ;
+
+COMMIT;
diff --git a/src/db/sqlite/sql/schema/1.0.0/er.dot b/src/db/sqlite/sql/schema/1.0.0/er.dot
new file mode 100644 (file)
index 0000000..2e43133
--- /dev/null
@@ -0,0 +1,106 @@
+digraph indieAutherERD {
+       graph[
+               rankdir=LR,
+               overlap=false,
+               splines=true,
+               label="IndieAuther Entity-Relations\SQLite\nSchema 1.0.0",
+               labelloc="t",
+               fontsize=26,
+       ];
+       // layout=neato;
+       node[shape=plain];
+       edge[arrowhead=crow];
+
+       token [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">TOKEN</td></tr>
+               <tr><td port="pk_code_id">code_id</td></tr>
+               <tr><td port="fk_profile_id">profile_id</td></tr>
+               <tr><td port="">created</td></tr>
+               <tr><td port="">expires</td></tr>
+               <tr><td port="">refresh_expires</td></tr>
+               <tr><td port="">refreshed</td></tr>
+               <tr><td port="">duration</td></tr>
+               <tr><td port="">refresh_duration</td></tr>
+               <tr><td port="">refresh_count</td></tr>
+               <tr><td port="">is_revoked</td></tr>
+               <tr><td port="">is_token</td></tr>
+               <tr><td port="">client_id</td></tr>
+               <tr><td port="">resource</td></tr>
+               <tr><td port="">profile_data</td></tr>
+       </table>
+       >];
+       profile:pk_profile_id -> token:fk_profile_id;
+
+       scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">SCOPE</td></tr>
+               <tr><td port="pk_scope_id">scope_id</td></tr>
+               <tr><td port="">scope</td></tr>
+               <tr><td port="">description</td></tr>
+               <tr><td port="">application</td></tr>
+               <tr><td port="">is_permanent</td></tr>
+               <tr><td port="">is_manually_added</td></tr>
+       </table>
+       >];
+
+       token_scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">TOKEN_SCOPE</td></tr>
+               <tr><td port="fk_code_id">code_id</td></tr>
+               <tr><td port="fk_scope_id">scope_id</td></tr>
+       </table>
+       >];
+       token:pk_code_id -> token_scope:fk_code_id;
+       scope:pk_scope_id -> token_scope:fk_scope_id;
+
+       profile [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">PROFILE</td></tr>
+               <tr><td port="pk_profile_id">profile_id</td></tr>
+               <tr><td port="fk_identifier_id">identifier_id</td></tr>
+               <tr><td port="">profile</td></tr>
+       </table>
+       >];
+       authentication:pk_identifier_id -> profile:fk_identifier_id;
+
+       profile_scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">PROFILE_SCOPE</td></tr>
+               <tr><td port="fk_profile_id">profile_id</td></tr>
+               <tr><td port="fk_scope_id">scope_id</td></tr>
+       </table>
+       >];
+       profile:pk_profile_id -> profile_scope:fk_profile_id;
+       scope:pk_scope_id -> profile_scope:fk_scope_id;
+
+       authentication [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+                       <tr><td border="2" bgcolor="lightblue">AUTHENTICATION</td></tr>
+                       <tr><td port="pk_identifier_id">identifier_id</td></tr>
+                       <tr><td port="">created</td></tr>
+                       <tr><td port="">last_authenticated</td></tr>
+                       <tr><td port="">identifier</td></tr>
+                       <tr><td port="">credential</td></tr>
+       </table>
+       >];
+
+       resource [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">RESOURCE</td></tr>
+               <tr><td port="pk_resource_id">resource_id</td></tr>
+               <tr><td port="">description</td></tr>
+               <tr><td port="">created</td></tr>
+               <tr><td port="">secret</td></tr>
+       </table>
+       >];
+
+       almanac [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">ALMANAC</td></tr>
+               <tr><td port="pk_event">event</td></tr>
+               <tr><td port="">epoch</td></tr>
+       </table>
+       >];
+
+}
diff --git a/src/db/sqlite/sql/schema/1.0.0/revert.sql b/src/db/sqlite/sql/schema/1.0.0/revert.sql
new file mode 100644 (file)
index 0000000..20ccf68
--- /dev/null
@@ -0,0 +1,7 @@
+BEGIN;
+       DROP TABLE authentication;
+       DROP TABLE profile;
+       DROP TABLE token;
+       DROP TABLE scope;
+       DROP TABLE profile_scope;
+COMMIT;
\ No newline at end of file
diff --git a/src/db/sqlite/sql/schema/init.sql b/src/db/sqlite/sql/schema/init.sql
new file mode 100644 (file)
index 0000000..8e2a475
--- /dev/null
@@ -0,0 +1,11 @@
+--
+BEGIN;
+       CREATE TABLE _meta_schema_version (
+               major INTEGER NOT NULL CHECK (typeof(major) = 'integer'),
+               minor INTEGER NOT NULL CHECK (typeof(minor) = 'integer'),
+               patch INTEGER NOT NULL CHECK (typeof(patch) = 'integer'),
+               applied INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(applied) = 'integer'),
+               PRIMARY KEY (major DESC, minor DESC, patch DESC)
+       ) WITHOUT ROWID;
+       INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0);
+COMMIT;
diff --git a/src/db/sqlite/sql/scope-cleanup.sql b/src/db/sqlite/sql/scope-cleanup.sql
new file mode 100644 (file)
index 0000000..4e9bd37
--- /dev/null
@@ -0,0 +1,8 @@
+-- Remove any un-used, non-permanent, non-manually-created scopes.
+DELETE FROM scope WHERE scope_id NOT IN (
+       SELECT scope_id FROM scope s
+               INNER JOIN profile_scope ps USING (scope_id)
+       UNION ALL
+       SELECT scope_id FROM scope s
+               INNER JOIN token_scope ts USING (scope_id)
+) AND NOT (is_permanent OR is_manually_added)
diff --git a/src/db/sqlite/sql/scope-delete.sql b/src/db/sqlite/sql/scope-delete.sql
new file mode 100644 (file)
index 0000000..562bb58
--- /dev/null
@@ -0,0 +1,3 @@
+-- remove an impermanent scope
+DELETE FROM scope
+       WHERE scope = :scope AND is_permanent = false
diff --git a/src/db/sqlite/sql/scope-in-use.sql b/src/db/sqlite/sql/scope-in-use.sql
new file mode 100644 (file)
index 0000000..1129caf
--- /dev/null
@@ -0,0 +1,13 @@
+-- return whether a scope is currently in use, either a profile setting or on a token
+SELECT EXISTS (
+       SELECT 1 FROM scope s
+               INNER JOIN profile_scope ps USING (scope_id)
+               WHERE s.scope = :scope
+       UNION All
+       SELECT 1 FROM scope s
+               INNER JOIN token_scope ts USING (scope_id)
+               WHERE s.scope = :scope
+       UNION All
+       SELECT 1 FROM scope s
+               WHERE s.scope = :scope AND s.is_permanent
+) AS in_use
diff --git a/src/db/sqlite/sql/scope-insert.sql b/src/db/sqlite/sql/scope-insert.sql
new file mode 100644 (file)
index 0000000..78d26c0
--- /dev/null
@@ -0,0 +1,5 @@
+-- Insert an externally-provided scope, or ignore.
+INSERT INTO scope
+       (scope)
+VALUES (:scope)
+ON CONFLICT (scope) DO NOTHING
diff --git a/src/db/sqlite/sql/scope-upsert.sql b/src/db/sqlite/sql/scope-upsert.sql
new file mode 100644 (file)
index 0000000..cb2770f
--- /dev/null
@@ -0,0 +1,16 @@
+--
+INSERT INTO scope (
+       scope,
+       description,
+       application,
+       is_manually_added
+) VALUES (
+       :scope,
+       CASE WHEN :application IS NULL THEN '' ELSE :application END,
+       CASE WHEN :description IS NULL THEN '' ELSE :description END,
+       COALESCE(:manuallyAdded, false)
+) ON CONFLICT (scope) DO UPDATE
+SET
+       application = COALESCE(:application, EXCLUDED.application),
+       description = COALESCE(:description, EXCLUDED.description),
+       is_manually_added = EXCLUDED.is_manually_added OR COALESCE(:manuallyAdded, false)
diff --git a/src/db/sqlite/sql/token-cleanup.sql b/src/db/sqlite/sql/token-cleanup.sql
new file mode 100644 (file)
index 0000000..45f16b7
--- /dev/null
@@ -0,0 +1,32 @@
+-- Remove tokens no longer in use.
+WITH
+-- reduce verbosity of using epoch timestamps
+times AS (
+       SELECT
+               (strftime('%s', 'now') - :codeLifespanSeconds) AS code_valid_offset,
+               strftime('%s', 'now') AS now
+),
+-- only clean after code has expired
+cleanable_codes AS (
+       SELECT t.code_id, t.is_token, t.is_revoked, t.expires, t.refresh_expires FROM token t, times
+               WHERE t.created < times.code_valid_offset
+)
+DELETE FROM token WHERE code_id IN (
+       SELECT code_id FROM cleanable_codes c, times
+       WHERE
+               NOT c.is_token -- profile-only redemption
+       OR (
+               c.is_token AND (
+                       c.is_revoked -- revoked
+                       OR (
+                               c.expires < times.now AND (
+                                       -- expired and unrefreshable
+                                       refresh_expires IS NULL
+                                       OR
+                                       -- expired and refresh expired
+                                       (refresh_expires IS NOT NULL AND refresh_expires < times.now)
+                               )
+                       )
+               )
+       )
+)
\ No newline at end of file
diff --git a/src/db/sqlite/sql/token-get-by-code-id.sql b/src/db/sqlite/sql/token-get-by-code-id.sql
new file mode 100644 (file)
index 0000000..c3ea5bb
--- /dev/null
@@ -0,0 +1,20 @@
+--
+SELECT
+       t.code_id,
+       p.profile,
+       t.created,
+       t.expires,
+       t.refresh_expires,
+       t.refreshed,
+       t.duration,
+       t.refresh_duration,
+       t.refresh_count,
+       t.is_revoked,
+       t.is_token,
+       t.client_id,
+       t.profile_data,
+       a.identifier
+FROM token t
+       INNER JOIN profile p USING (profile_id)
+       INNER JOIN authentication a USING (identifier_id)
+WHERE code_id = :codeId
diff --git a/src/db/sqlite/sql/token-refresh-revoke-by-code-id.sql b/src/db/sqlite/sql/token-refresh-revoke-by-code-id.sql
new file mode 100644 (file)
index 0000000..52a851c
--- /dev/null
@@ -0,0 +1,5 @@
+-- Revoke the refreshability for a token
+UPDATE token SET
+       refresh_expires = NULL,
+       refresh_duration = NULL
+WHERE code_id = :codeId
diff --git a/src/db/sqlite/sql/token-revoke-by-code-id.sql b/src/db/sqlite/sql/token-revoke-by-code-id.sql
new file mode 100644 (file)
index 0000000..27febe1
--- /dev/null
@@ -0,0 +1,5 @@
+--
+UPDATE token SET
+       is_revoked = true,
+       refresh_expires = NULL
+WHERE code_id = :codeId
diff --git a/src/db/sqlite/sql/token-scope-remove.sql b/src/db/sqlite/sql/token-scope-remove.sql
new file mode 100644 (file)
index 0000000..94debac
--- /dev/null
@@ -0,0 +1,3 @@
+--
+DELETE FROM token_scope
+WHERE code_id = :codeId AND scope_id = (SELECT scope_id FROM scope WHERE scope = :scope)
diff --git a/src/db/sqlite/sql/token-scope-set.sql b/src/db/sqlite/sql/token-scope-set.sql
new file mode 100644 (file)
index 0000000..7b52dc6
--- /dev/null
@@ -0,0 +1,3 @@
+--
+INSERT INTO token_scope (code_id, scope_id)
+       SELECT :codeId, scope_id FROM scope WHERE scope = :scope
diff --git a/src/db/sqlite/sql/token-scopes-get-by-code-id.sql b/src/db/sqlite/sql/token-scopes-get-by-code-id.sql
new file mode 100644 (file)
index 0000000..5bd7b30
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT s.scope FROM token_scope ts
+       INNER JOIN scope s USING (scope_id)
+       WHERE ts.code_id = :codeId
diff --git a/src/db/sqlite/sql/tokens-get-by-identifier.sql b/src/db/sqlite/sql/tokens-get-by-identifier.sql
new file mode 100644 (file)
index 0000000..63a3f2c
--- /dev/null
@@ -0,0 +1,21 @@
+--
+SELECT 
+       t.code_id,
+       p.profile,
+       t.created,
+       t.expires,
+       t.refresh_expires,
+       t.refreshed,
+       t.duration,
+       t.refresh_duration,
+       t.refresh_count,
+       t.is_revoked,
+       t.is_token,
+       t.client_id,
+       t.profile_data,
+       a.identifier
+FROM token t
+       INNER JOIN profile p USING (profile_id)
+       INNER JOIN authentication a USING (identifier_id)
+WHERE a.identifier = :identifier
+ORDER BY max(t.created, COALESCE(t.refreshed, 0)) DESC
diff --git a/src/enum.js b/src/enum.js
new file mode 100644 (file)
index 0000000..8179941
--- /dev/null
@@ -0,0 +1,27 @@
+'use strict';
+const common = require('./common');
+const { Enum } = require('@squeep/api-dingus');
+
+common.mergeEnum(Enum, {
+  Specification: 'living-standard-20220212',
+
+  ContentType: {
+    ApplicationOctetStream: 'application/octet-stream',
+  },
+
+  Header: {
+    Authorization: 'Authorization',
+    Link: 'Link',
+    Location: 'Location',
+    Pragma: 'Pragma',
+    UserAgent: 'User-Agent',
+    WWWAuthenticate: 'WWW-Authenticate',
+  },
+
+  Chore: {
+    CleanTokens: 'cleanTokens',
+    CleanScopes: 'cleanScopes',
+  },
+});
+
+module.exports = Enum;
diff --git a/src/errors.js b/src/errors.js
new file mode 100644 (file)
index 0000000..9f4cb2b
--- /dev/null
@@ -0,0 +1,21 @@
+'use strict';
+
+const { Errors } = require('@squeep/api-dingus');
+
+/**
+ * A stack-less exception for general data issues.
+ */
+class ValidationError extends Error {
+  constructor(...args) {
+    super(...args);
+    delete this.stack;
+  }
+
+  get name() {
+    return this.constructor.name;
+  }
+}
+module.exports = {
+  ...Errors,
+  ValidationError,
+};
\ No newline at end of file
diff --git a/src/logger/data-sanitizers.js b/src/logger/data-sanitizers.js
new file mode 100644 (file)
index 0000000..450842e
--- /dev/null
@@ -0,0 +1,116 @@
+'use strict';
+
+/**
+ * Scrub credential from POST login body data.
+ * @param {Object} data
+ * @param {Boolean} sanitize
+ * @returns {Boolean}
+ */
+function sanitizePostCredential(data, sanitize = true) {
+  let unclean = false;
+
+  const credentialLength = data?.ctx?.parsedBody?.credential?.length;
+  if (credentialLength) {
+    unclean = true;
+  }
+  if (unclean && sanitize) {
+    data.ctx.parsedBody.credential = '*'.repeat(credentialLength);
+  }
+
+  return unclean;
+}
+
+
+/**
+ * Reduce logged data about scopes from profilesScopes.
+ * For all referenced scopes, only include profiles list.
+ * Remove scopes without profile references from scopeIndex.
+ * @param {Object} data
+ * @param {Boolean} sanitize
+ */
+function reduceScopeVerbosity(data, sanitize = true) {
+  let unclean = false;
+
+  const {
+    scopesEntries: ctxScopesEntries,
+    profilesEntries: ctxProfilesEntries,
+    needsSanitize: ctxNeedsSanitize,
+  } = _scopesFrom(data?.ctx?.profilesScopes);
+
+  const {
+    scopesEntries: sessionScopesEntries,
+    profilesEntries: sessionProfilesEntries,
+    needsSanitize: sessionNeedsSanitize,
+  } = _scopesFrom(data?.ctx?.session);
+
+  if (ctxNeedsSanitize || sessionNeedsSanitize) {
+    unclean = true;
+  }
+  if (unclean && sanitize) {
+    if (ctxNeedsSanitize) {
+      Object.assign(data.ctx.profilesScopes, _sanitizeProfilesScopes(ctxScopesEntries, ctxProfilesEntries));
+    }
+    if (sessionNeedsSanitize) {
+      Object.assign(data.ctx.session, _sanitizeProfilesScopes(sessionScopesEntries, sessionProfilesEntries));
+    }
+  }
+
+  return unclean;
+}
+
+
+/**
+ * Return any scope entries on an object, and whether sanitization is needed.
+ * @param {Object=} obj
+ * @returns {Object}
+ */
+const _scopesFrom = (obj) => {
+  const scopesEntries = Object.entries(obj?.scopeIndex || {});
+  const profilesEntries = Object.entries(obj?.profileScopes || {});
+  const needsSanitize = scopesEntries.length || profilesEntries.length;
+  return {
+    scopesEntries,
+    profilesEntries,
+    needsSanitize,
+  };
+};
+
+
+/**
+ * @typedef {[String, Object]} ScopeEntry
+ */
+/**
+ * Return new list of entries with scrubbed scopeDetails.
+ * @param {ScopeEntry[]} entries
+ * @returns {ScopeEntry[]}
+ */
+const _scopeEntriesScrubber = (entries) => entries.map(([scopeName, scopeDetails]) => ([scopeName, { profiles: scopeDetails.profiles }]));
+
+
+/**
+ * Create a new profilesScopes type object with scrubbed scope details.
+ * @param {ScopeEntry[]} scopesEntries
+ * @param {ScopeEntry[]} profilesEntries
+ * @returns {Object}
+ */
+const _sanitizeProfilesScopes = (scopesEntries, profilesEntries) => {
+  const referencedScopesEntries = scopesEntries.filter(([_scopeName, scopeDetails]) => scopeDetails?.profiles?.length); // eslint-disable-line no-unused-vars
+  const scrubbedScopesEntries = _scopeEntriesScrubber(referencedScopesEntries);
+
+  const scrubbedProfilesEntries = profilesEntries.map(([profileName, profileScopes]) => {
+    const profileScopeEntries = Object.entries(profileScopes);
+    const scrubbedProfileScopeEntries = _scopeEntriesScrubber(profileScopeEntries);
+    const scrubbedProfileScopes = Object.fromEntries(scrubbedProfileScopeEntries);
+    return [profileName, scrubbedProfileScopes];
+  });
+
+  return {
+    scopeIndex: Object.fromEntries(scrubbedScopesEntries),
+    profileScopes: Object.fromEntries(scrubbedProfilesEntries),
+  };
+};
+
+module.exports = {
+  sanitizePostCredential,
+  reduceScopeVerbosity,
+};
\ No newline at end of file
diff --git a/src/logger/index.js b/src/logger/index.js
new file mode 100644 (file)
index 0000000..7944cf6
--- /dev/null
@@ -0,0 +1,13 @@
+'use strict';
+
+const BaseLogger = require('@squeep/logger-json-console');
+const dataSanitizers = require('./data-sanitizers');
+
+class Logger extends BaseLogger {
+  constructor(options, ...args) {
+    super(options, ...args);
+    Array.prototype.push.apply(this.dataSanitizers, Object.values(dataSanitizers));
+  }
+}
+
+module.exports = Logger;
\ No newline at end of file
diff --git a/src/manager.js b/src/manager.js
new file mode 100644 (file)
index 0000000..6c16414
--- /dev/null
@@ -0,0 +1,2015 @@
+'use strict';
+
+const common = require('./common');
+const { ResponseError, ValidationError } = require('./errors');
+const Enum = require('./enum');
+const { Communication, Errors: CommunicationErrors } = require('@squeep/indieauth-helper');
+const Template = require('./template');
+const { MysteryBox } = require('@squeep/mystery-box');
+const DBErrors = require('./db/errors');
+const Chores = require('./chores');
+const { Publisher: QueuePublisher } = require('@squeep/amqp-helper');
+
+const _fileScope = common.fileScope(__filename);
+
+// These are used during request ingestion and validation
+const validBase64URLRE = /^[-A-Za-z0-9_]+$/;
+const scopeSplitRE = / +/;
+
+const supportedCodeChallengeMethods = ['S256', 'SHA256'];
+
+class Manager {
+  constructor(logger, db, options) {
+    this.options = options;
+    this.logger = logger;
+    this.db = db;
+    this.chores = new Chores(logger, db, options);
+    this.communication = new Communication(logger, options);
+    if (options.queues.amqp.url) {
+      this.queuePublisher = new QueuePublisher(logger, options.queues.amqp);
+    }
+    this.mysteryBox = new MysteryBox(logger, options);
+
+    // We need to know how the outside world sees us, to verify if a
+    // profile indicates us as the auth server.
+    // selfBaseUrl should already include proxy prefix and end with a /
+    this.selfAuthorizationEndpoint = options.dingus.selfBaseUrl + options.route.authorization;
+  }
+
+
+  /**
+   * Perform any async startup tasks.
+   */
+  async initialize() {
+    if (this.queuePublisher) {
+      await this._connectQueues();
+    }
+  }
+
+
+  async _connectQueues() {
+    await this.queuePublisher.connect();
+    await this.queuePublisher.establishAMQPPlumbing(this.options.queues.ticketPublishName);
+  }
+
+
+  /**
+   * Add an error to a session, keeping only the most-severe code, but all descriptions.
+   * This error is sent along on the redirection back to client endpoint.
+   * @param {Object} ctx
+   * @param {Object} ctx.session
+   * @param {String[]=} ctx.session.errorDescriptions
+   * @param {String=} ctx.session.error
+   * @param {String} error
+   * @param {String} errorDescription
+   */
+  static _setError(ctx, error, errorDescription) {
+    const errorPrecedence = [ // By increasing severity
+      'invalid_scope',
+      'unsupported_response_type',
+      'access_denied',
+      'unauthorized_client',
+      'invalid_grant',
+      'invalid_request',
+      'temporarily_unavailable',
+      'server_error',
+    ];
+    if (!(errorPrecedence.includes(error))) {
+      throw new RangeError(`invalid error value '${error}'`);
+    }
+    if (!ctx.session.errorDescriptions) {
+      ctx.session.errorDescriptions = [];
+    }
+    if (!common.validError(errorDescription)) {
+      throw new RangeError(`invalid error description '${errorDescription}'`);
+    }
+    const isHigherPrecedence = errorPrecedence.indexOf(error) > errorPrecedence.indexOf(ctx.session.error);
+    if (!ctx.session.error || isHigherPrecedence) {
+      ctx.session.error = error;
+    }
+    if (isHigherPrecedence) {
+      ctx.session.errorDescriptions.unshift(errorDescription);
+    } else {
+      ctx.session.errorDescriptions.push(errorDescription);
+    }
+  }
+
+
+  /**
+   * Discourage caching of a response.
+   * OAuth 2.1 §3.2.3
+   * The authorization server MUST include the HTTP Cache-Control response
+   * header field with a value of no-store in any response
+   * containing tokens, credentials, or other sensitive information.
+   * @param {http.ServerResponse} res
+   */
+  static _sensitiveResponse(res) {
+    Object.entries({
+      [Enum.Header.CacheControl]: 'no-store',
+      [Enum.Header.Pragma]: 'no-cache',
+    }).forEach(([k, v]) => res.setHeader(k, v));
+  }
+
+
+  /**
+   * Sets params entries as url search parameters.
+   * @param {URL} url
+   * @param {Object} params
+   */
+  static _setSearchParams(url, params) {
+    Object.entries(params).forEach((param) => url.searchParams.set(...param));
+  }
+
+
+  /**
+   * Serve the informational root page.
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async getRoot(res, ctx) {
+    const _scope = _fileScope('getRoot');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    res.end(Template.rootHTML(ctx, this.options));
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Serve the metadata for this service.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async getMeta(res, ctx) {
+    const _scope = _fileScope('getMeta');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const base = this.options.dingus.selfBaseUrl;
+    const endpoint = (r) => `${base}${this.options.route[r]}`; // eslint-disable-line security/detect-object-injection
+  
+    const metadata = {
+      issuer: base,
+      'authorization_endpoint': endpoint('authorization'),
+      'token_endpoint': endpoint('token'),
+      ...(this.queuePublisher && { 'ticket_endpoint': endpoint('ticket') }),
+      'introspection_endpoint': endpoint('introspection'),
+      'introspection_endpoint_auth_methods_supported': ['Bearer'],
+      'revocation_endpoint': endpoint('revocation'),
+      'revocation_endpoint_auth_methods_supported': ['none'],
+      'scopes_supported': ['profile', 'email'], // only advertise minimum IA scopes
+      'response_types_supported': 'code',
+      'grant_types_supported': [
+        'authorization_code',
+        'refresh_token',
+        ...(this.queuePublisher && ['ticket'] || []),
+      ],
+      'service_documentation': 'https://indieauth.spec.indieweb.org/',
+      'code_challenge_methods_supported': supportedCodeChallengeMethods,
+      'authorization_response_iss_parameter_supported': true,
+      'userinfo_endpoint': endpoint('userinfo'),
+    };
+
+    res.end(JSON.stringify(metadata));
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+  
+  /**
+   * Process an authorization request from a client.
+   * User has authenticated, check if user matches profile,
+   * present user with consent form.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async getAuthorization(res, ctx) {
+    const _scope = _fileScope('getAuthorization');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    ctx.session = Object.assign({}, ctx.session, {
+      errorDescriptions: [],
+    });
+
+    // Ingest and validate expected data, populating ctx.session.
+    await this._clientIdRequired(ctx);
+    Manager._redirectURIRequired(ctx);
+    Manager._responseTypeRequired(ctx);
+    Manager._stateRequired(ctx);
+    this._codeChallengeMethodRequired(ctx);
+    this._codeChallengeRequired(ctx);
+    this._scopeOptional(ctx);
+    await this._meOptional(ctx);
+
+    if (!ctx.session.clientIdentifier || !ctx.session.redirectUri) {
+      // Do not redirect if either of these fields were invalid, just report error.
+      this.logger.debug(_scope, 'invalid request, not redirecting', { ctx });
+
+      // Set error response for template to render.
+      ctx.errors.push('Cannot redirect to client application.');
+      ctx.errorContent = [
+        'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
+      ];
+      res.statusCode = 400;
+      res.end(Template.authorizationErrorHTML(ctx, this.options));
+      this.logger.info(_scope, 'bad request', { ctx });
+      return;
+    }
+
+    await this.db.context(async (dbCtx) => {
+      const profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, ctx.authenticationId);
+      Object.assign(ctx.session, {
+        profiles: [],
+        profileScopes: {},
+        scopeIndex: {},
+      }, profilesScopes);
+    }); // dbCtx
+
+    if (!ctx.session.profiles.length) {
+      this.logger.error(_scope, 'identifier has no profiles', { ctx });
+      Manager._setError(ctx, 'access_denied', 'Profile not valid for the authenticated user.');
+    }
+
+    if (!this._profileValidForIdentifier(ctx)) {
+      // if the hinted profile supplied in me does not match any known
+      // profile mappings for the authenticated identifier, remove the
+      // hint.  UI will prompt to choose from available profiles.
+      this.logger.debug(_scope, 'removing provided me hint, not valid for identifier', { ctx });
+      delete ctx.session.me;
+    }
+
+    // Ugly support logic for allowing legacy non-pkce requests, for the micropub.rocks site until it is updated.
+    // Require both be missing to qualify as a legacy request, otherwise still fail.
+    const isMissingBothPKCE = (!ctx.session.codeChallengeMethod) && (!ctx.session.codeChallenge);
+    if (isMissingBothPKCE && this.options.manager.allowLegacyNonPKCE) {
+      ctx.notifications.push('<div class="legacy-warning">This request was submitted using an unsupported legacy format, which does not include PKCE safeguards!  This is a security issue!  This request should not be accepted!</div>');
+    } else {
+      if (!ctx.session.codeChallenge) {
+        Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge\'');
+      }
+      if (!ctx.session.codeChallengeMethod) {
+        Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
+      }
+    }
+
+    // If anything went wrong, redirect with error report.
+    if (ctx.session.error) {
+      // Valid redirect_url and client_id, errors hop back to them.
+      this.logger.debug(_scope, 'invalid request, redirecting', { ctx });
+
+      Manager._setSearchParams(ctx.session.redirectUri, {
+        'state': ctx.session.state,
+        'error': ctx.session.error,
+        'error_description': ctx.session.errorDescriptions.join(', '),
+      });
+      res.statusCode = 302; // Found
+      res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
+      res.end();
+      this.logger.info(_scope, 'bad request', { ctx });
+      return;
+    }
+
+    // Store the current state of this session, to be forwarded on to consent processing.
+    // This blob will be passed on as a form field in consent response.
+    ctx.session.persist = await this.mysteryBox.pack({
+      id: common.requestId(), // codeId in database
+      clientId: ctx.session.clientId.href,
+      clientIdentifier: ctx.session.clientIdentifier,
+      redirectUri: ctx.session.redirectUri.href,
+      responseType: ctx.session.responseType,
+      state: ctx.session.state,
+      codeChallengeMethod: ctx.session.codeChallengeMethod,
+      codeChallenge: ctx.session.codeChallenge,
+      me: ctx.session.me,
+      profiles: ctx.session.profiles,
+      requestedScopes: ctx.session.scope,
+      authenticationId: ctx.authenticationId,
+    });
+
+    // Present authenticated user the option to submit consent
+    const content = Template.authorizationRequestHTML(ctx, this.options);
+    res.end(content);
+
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data.
+   * @param {Object} ctx
+   */
+  async _clientIdRequired(ctx) {
+    if (ctx.queryParams['client_id']) {
+      try {
+        ctx.session.clientId = await this.communication.validateClientIdentifier(ctx.queryParams['client_id']);
+        ctx.session.clientIdentifier = await this.communication.fetchClientIdentifier(ctx.session.clientId);
+        if (!ctx.session.clientIdentifier) {
+          Manager._setError(ctx, 'invalid_request', 'invalid client_id: could not fetch');
+          throw new ValidationError('could not fetch');
+        }
+      } catch (e) {
+        ctx.session.clientId = undefined;
+        if (e instanceof CommunicationErrors.ValidationError) {
+          Manager._setError(ctx, 'invalid_request', e.message);
+        }
+        Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'client_id\'');
+      }
+    } else {
+      Manager._setError(ctx, 'invalid_request', 'missing required parameter \'client_id\'');
+    }
+  }
+
+
+  /**
+   * Ensure redirect_uri exists and is corroborated by clientIdentifier data.
+   * @param {Object} ctx 
+   */
+  static _redirectURIRequired(ctx) {
+    if (ctx.queryParams['redirect_uri']) {
+      try {
+        ctx.session.redirectUri = new URL(ctx.queryParams['redirect_uri']);
+
+        if (ctx.session.clientId) {
+          // Either all these parts must match, or a specific alternative must be specified.
+          const redirectMatchesClientId = ['protocol', 'hostname', 'port']
+            .map((p) => ctx.session.redirectUri[p] == ctx.session.clientId[p]) // eslint-disable-line security/detect-object-injection
+            .reduce((acc, match) => acc && match, true);
+
+          // Check for alternate redirect_uri entries on client_id data if no initial match
+          if (!redirectMatchesClientId) {
+            const validRedirectUris = ctx.session?.clientIdentifier?.['rels']?.['redirect_uri'] || [];
+            if (!validRedirectUris.includes(ctx.session.redirectUri.href)) {
+              Manager._setError(ctx, 'invalid_request', 'redirect_uri not valid for that client_id');
+              // Remove invalid redirect_uri from session; doing this eases error routing.
+              ctx.session.redirectUri = undefined;
+            }
+          }
+        }
+      } catch (e) {
+        Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'redirect_uri\'');
+      }
+    } else {
+      Manager._setError(ctx, 'invalid_request', 'missing required parameter \'redirect_uri\'');
+    }
+  }
+
+
+  /**
+   * response_type must be valid
+   * @param {Object} ctx
+   */
+  static _responseTypeRequired(ctx) {
+    ctx.session.responseType = ctx.queryParams['response_type'];
+    if (ctx.session.responseType) {
+      // Must be one of these types
+      if (!['code'].includes(ctx.session.responseType)) {
+        Manager._setError(ctx, 'unsupported_response_type', 'invalid value for parameter \'response_type\'');
+      }
+    } else {
+      Manager._setError(ctx, 'invalid_request', 'missing required parameter \'response_type\'');
+    }
+  }
+
+
+  /**
+   * A state parameter must be present
+   * @param {Object} ctx 
+   */
+  static _stateRequired(ctx) {
+    ctx.session.state = ctx.queryParams['state'];
+    if (ctx.session.state) {
+      // No restrictions on content of this
+    } else {
+      Manager._setError(ctx, 'invalid_request', 'missing required parameter \'state\'');
+    }
+  }
+
+
+  /**
+   * A code_challenge_method must be present and valid
+   * @param {Object} ctx
+   */
+  _codeChallengeMethodRequired(ctx) {
+    ctx.session.codeChallengeMethod = ctx.queryParams['code_challenge_method'];
+    if (ctx.session.codeChallengeMethod) {
+      if (!supportedCodeChallengeMethods.includes(ctx.session.codeChallengeMethod)) {
+        Manager._setError(ctx, 'invalid_request', 'unsupported code_challenge_method');
+      }
+    } else {
+      if (this.options.manager.allowLegacyNonPKCE) {
+        return;
+      }
+      Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
+    }
+  }
+
+
+  /**
+   * A code_challenge must be present
+   * @param {Object} ctx
+   */
+  _codeChallengeRequired(ctx) {
+    ctx.session.codeChallenge = ctx.queryParams['code_challenge'];
+    if (ctx.session.codeChallenge) {
+      if (!validBase64URLRE.test(ctx.session.codeChallenge)) {
+        Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'code_challenge\'');
+      }
+    } else {
+      if (this.options.manager.allowLegacyNonPKCE) {
+        return;
+      }
+      Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge\'');
+    }
+  }
+
+
+  /**
+   * Scopes may be present, with one known combination limitation
+   * @param {Object} ctx
+   */
+  _scopeOptional(ctx) {
+    const _scope = _fileScope('_scopeOptional');
+    const scope = ctx.queryParams['scope'];
+    ctx.session.scope = [];
+    if (scope) {
+      const allScopes = scope.split(scopeSplitRE);
+      const validScopes = allScopes.filter((s) => common.validScope(s));
+      ctx.session.scope.push(...validScopes);
+      if (allScopes.length != validScopes.length) {
+        const invalidScopes = allScopes.filter((s) => !common.validScope(s));
+        this.logger.debug(_scope, 'client requested invalid scope', { ctx, invalidScopes });
+      }
+    }
+    // If email scope is requested, profile scope must also be explicitly requested.
+    if (ctx.session.scope.includes('email')
+    &&  !ctx.session.scope.includes('profile')) {
+      Manager._setError(ctx, 'invalid_scope', 'cannot provide \'email\' scope without \'profile\' scope');
+    }
+  }
+
+
+  /**
+   * Parses me, if provided
+   * @param {Object} ctx
+   */
+  async _meOptional(ctx) {
+    const me = ctx.queryParams['me'];
+    if (me) {
+      try {
+        ctx.session.me = await this.communication.validateProfile(me);
+      } catch (e) {
+        ctx.session.me = undefined;
+      }
+    }
+  }
+
+
+  /**
+   * Ensure authenticated identifier matches profile.
+   * @param {Object} ctx
+   * @returns {Boolean}
+   */
+  _profileValidForIdentifier(ctx) {
+    const _scope = _fileScope('_profileValidForIdentifier');
+
+    if (!ctx.session.me) {
+      this.logger.debug(_scope, 'no profile provided, cannot correlate', { ctx });
+      return false;
+    }
+
+    return ctx.session.profiles.includes(ctx.session.me.href);
+  }
+
+
+  /**
+   * Get numeric value from form field data.
+   * @param {*} ctx
+   * @param {String} field
+   * @param {String} customField
+   * @returns {Number=}
+   */
+  _parseLifespan(ctx, field, customField) {
+    const _scope = _fileScope('_parseLifespan');
+
+    const presetValues = {
+      'never': undefined,
+      '1d': 86400,
+      '1w': 86400 * 7,
+      '1m': 86400 * 31,
+    };
+    const fieldValue = ctx.parsedBody[field]; // eslint-disable-line security/detect-object-injection
+    if (fieldValue in presetValues) {
+      return presetValues[fieldValue]; // eslint-disable-line security/detect-object-injection
+    }
+
+    if (fieldValue === 'custom') {
+      const expiresSeconds = parseInt(ctx.parsedBody[customField], 10); // eslint-disable-line security/detect-object-injection
+      if (isFinite(expiresSeconds) && expiresSeconds > 0) {
+        return expiresSeconds;
+      } else {
+        this.logger.debug(_scope, 'invalid custom value', { ctx, field, customField });
+      }
+    }
+
+    this.logger.debug(_scope, 'invalid value', { ctx, field, customField });
+    return undefined;
+  }
+
+
+  /**
+   * Validate any accepted scopes, ensure uniqueness, return as array.
+   * @param {Object} ctx
+   * @returns {String=}
+   */
+  _parseConsentScopes(ctx) {
+    const _scope = _fileScope('_ingestConsentScopes');
+    const acceptedScopesSet = new Set();
+    const rejectedScopesSet = new Set();
+
+    const submittedScopes = common.ensureArray(ctx.parsedBody['accepted_scopes'])
+      .concat((ctx.parsedBody['ad_hoc_scopes'] || '').split(scopeSplitRE));
+    submittedScopes.forEach((scope) => {
+      if (scope) {
+        (common.validScope(scope) ? acceptedScopesSet : rejectedScopesSet).add(scope);
+      }
+    });
+
+    // If email scope was accepted but profile was not, elide email scope
+    if (acceptedScopesSet.has('email')
+    &&  !acceptedScopesSet.has('profile')) {
+      acceptedScopesSet.delete('email');
+      rejectedScopesSet.add('email (without profile)');
+    }
+
+    if (rejectedScopesSet.size) {
+      this.logger.debug(_scope, 'ignoring invalid scopes', { ctx, rejectedScopes: Array.from(rejectedScopesSet) });
+    }
+
+    return Array.from(acceptedScopesSet);
+  }
+
+
+  /**
+   * Parse and validate selected me is a valid profile option.
+   * @param {Object} ctx
+   * @returns {URL}
+   */
+  _parseConsentMe(ctx) {
+    const _scope = _fileScope('_parseConsentMe');
+    const selectedMe = ctx.parsedBody['me'];
+    try {
+      const me = new URL(selectedMe);
+      if (ctx.session.profiles.includes(me.href)) {
+        return me;
+      } else {
+        this.logger.debug(_scope, 'selected \'me\' profile not among available', { me, available: ctx.session.profiles, ctx });
+        Manager._setError(ctx, 'invalid_request', 'invalid profile url');
+      }
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to parse selected \'me\' as url', { error: e, ctx });
+      Manager._setError(ctx, 'invalid_request', 'invalid profile url');
+    }
+    return undefined;
+  }
+
+
+  /**
+   * Get up-to-date profile data from selected profile endpoint.
+   * @param {Object} ctx
+   * @returns {Object}
+   */
+  async _fetchConsentProfileData(ctx) {
+    const _scope = _fileScope('_fetchConsentProfileData');
+    try {
+      const profile = await this.communication.fetchProfile(ctx.session.me);
+      if (!profile) {
+        this.logger.debug(_scope, 'no profile data at \'me\' endpoint', { ctx });
+        Manager._setError(ctx, 'temporarily_unavailable', 'unable to retrieve profile');
+      } else {
+        // Profile info gets persisted in code, only include known profile fields to help keep size down.
+        return common.pick(profile, [
+          'name',
+          'photo',
+          'url',
+          'email',
+        ]);
+      }
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to fetch \'me\' endpoint', { error: e, ctx });
+      Manager._setError(ctx, 'temporarily_unavailable', 'could not reach profile endpoint');
+    }
+    return undefined;
+  }
+
+
+  /**
+   * Ingest user consent response details, redirect as needed.
+   * Receives POST request from consent page, expecting these form fields:
+   *   session - encrypted data collected from initial auth call
+   *   accept - 'true' if consent was granted
+   *   accepted_scopes - list of scopes to grant
+   *   ad_hoc_scopes - additional scopes specified by user
+   *   me - selected profile to identify as
+   *   expires - optional lifespan
+   *   expires-seconds - optional custom lifespan
+   *   refresh - optional refresh lifespan
+   *   refresh-seconds - optional custom refresh lifespan
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async postConsent(res, ctx) {
+    const _scope = _fileScope('postConsent');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    // Ensure session exists, persisting any login session data.
+    ctx.session = Object.assign({}, ctx.session);
+    try {
+      // Recover the session established on initial auth request.
+      const oldSession = await this.mysteryBox.unpack(ctx.parsedBody['session']);
+      Object.assign(ctx.session, oldSession);
+      ctx.session.redirectUri = new URL(ctx.session.redirectUri);
+      ctx.session.clientId = new URL(ctx.session.clientId);
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to unpack session', { error: e, ctx });
+      Manager._setError(ctx, 'invalid_request', 'un-parsable data in authorization consent');
+    }
+
+    // If these are missing, we cannot proceed.
+    if (!ctx.session.clientId || !ctx.session.redirectUri) {
+      // Set error response for html template to render.
+      ctx.errors = [
+        'Cannot redirect to client application.',
+      ];
+      ctx.errorContent = [
+        'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
+      ];
+      res.statusCode = 400;
+      res.end(Template.authorizationErrorHTML(ctx, this.options));
+      this.logger.info(_scope, 'bad request, cannot redirect', { ctx });
+      return;
+    }
+    
+    // TODO: Should probably re-validate more unpacked session values, even though those should be trustable.
+
+    // Check if we need to check anything else.
+    ctx.session.accept = (ctx.parsedBody['accept'] === 'true');
+    if (!ctx.session.accept) {
+      this.logger.debug(_scope, 'consent denied', { ctx });
+      Manager._setError(ctx, 'access_denied', 'authorization was not granted');
+    } else {
+      // Ingest form data.
+      ctx.session.acceptedScopes = this._parseConsentScopes(ctx);
+      ctx.session.me = this._parseConsentMe(ctx);
+      ctx.session.profile = await this._fetchConsentProfileData(ctx);
+      ctx.session.tokenLifespan = this._parseLifespan(ctx, 'expires', 'expires-seconds');
+      if (ctx.session.tokenLifespan) {
+        ctx.session.refreshLifespan = this._parseLifespan(ctx, 'refresh', 'refresh-seconds');
+      }
+    }
+
+    if (ctx.session.error) {
+      this.logger.debug(_scope, 'invalid request, redirecting', { ctx });
+
+      // Set all errors as parameters for client to interpret upon redirection.
+      Manager._setSearchParams(ctx.session.redirectUri, {
+        'state': ctx.session.state,
+        'error': ctx.session.error,
+        'error_description': ctx.session.errorDescriptions.join(', '),
+      });
+      res.statusCode = 302; // Found
+      res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
+      res.end();
+      this.logger.info(_scope, 'bad request, redirected', { ctx });
+      return;
+    }
+
+    // Consented, off we go. Keep all this session state as the code.
+    const code = await this.mysteryBox.pack({
+      codeId: ctx.session.id,
+      codeChallengeMethod: ctx.session.codeChallengeMethod,
+      codeChallenge: ctx.session.codeChallenge,
+      clientId: ctx.session.clientId.href,
+      redirectUri: ctx.session.redirectUri.href,
+      acceptedScopes: ctx.session.acceptedScopes,
+      tokenLifespan: ctx.session.tokenLifespan,
+      refreshLifespan: ctx.session.refreshLifespan,
+      me: ctx.session.me.href,
+      profile: ctx.session.profile,
+      identifier: ctx.session.authenticatedIdentifier, // need this to pair with profile
+      minted: Date.now(),
+    });
+  
+    Manager._setSearchParams(ctx.session.redirectUri, {
+      'code': code,
+      'state': ctx.session.state,
+      'iss': this.options.dingus.selfBaseUrl,
+    });
+    res.statusCode = 302;
+    res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
+    res.end();
+
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Redeem a code for a profile url, and maybe more profile info.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async postAuthorization(res, ctx) {
+    const _scope = _fileScope('postAuthorization');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    await this._ingestPostAuthorizationRequest(ctx);
+
+    const response = {
+      me: ctx.session.me,
+      ...(ctx.session?.acceptedScopes?.includes('profile') && { profile: ctx.session.profile }),
+      scope: ctx.session.acceptedScopes,
+    };
+    if (response.profile && !ctx.session?.acceptedScopes?.includes('email')) {
+      delete response.profile.email;
+    }
+
+    if (!ctx.session.error) {
+      await this.db.context(async (dbCtx) => {
+        // Record code redemption without token.
+        const valid = await this.db.redeemCode(dbCtx, {
+          codeId: ctx.session.codeId,
+          created: new Date(),
+          isToken: false,
+          clientId: ctx.session.clientId.href,
+          profile: ctx.session.me,
+          identifier: ctx.session.identifier,
+          scopes: ctx.session.acceptedScopes,
+          lifespanSeconds: Math.ceil(this.options.manager.codeValidityTimeoutMs / 1000),
+          profileData: response.profile,
+        });
+        if (!valid) {
+          this.logger.debug(_scope, 'code already redeemed', { ctx });
+          Manager._setError(ctx, 'access_denied', 'code already redeemed');
+        }
+      }); // dbCtx
+    }
+
+    if (ctx.session.error) {
+      res.statusCode = 400;
+      res.end(JSON.stringify({
+        'error': ctx.session.error,
+        'error_description': ctx.session.errorDescriptions.join(', '),
+      }));
+      this.logger.info(_scope, 'invalid request', { ctx });
+      return;
+    }
+
+    res.end(JSON.stringify(response));
+
+    this.logger.info(_scope, 'finished', { ctx, response });
+  }
+
+
+  /**
+   * Ingest an incoming authorization redemption request, parsing fields
+   * onto a new session object on the context.
+   * @param {*} dbCtx
+   * @param {Object} ctx
+   */
+  async _ingestPostAuthorizationRequest(ctx) {
+    const _scope = _fileScope('_ingestPostAuthorizationRequest');
+
+    ctx.session = Object.assign({}, ctx.session, {
+      errorDescriptions: [],
+    });
+
+    if (!ctx.parsedBody) {
+      this.logger.debug(_scope, 'no body data', { ctx });
+      Manager._setError(ctx, 'invalid_request', 'missing data');
+    }
+
+    await this._restoreSessionFromCode(ctx);
+    this._checkSessionMatchingClientId(ctx);
+    this._checkSessionMatchingRedirectUri(ctx);
+    this._checkGrantType(ctx);
+    this._checkSessionMatchingCodeVerifier(ctx);
+
+    if (!ctx.session.me || !ctx.session.minted) {
+      this.logger.debug(_scope, 'session missing fields', { ctx });
+      Manager._setError(ctx, 'invalid_request', 'malformed code');
+      return;
+    }
+
+    const expires = new Date(ctx.session.minted + this.options.manager.codeValidityTimeoutMs);
+    const now = new Date();
+    if (expires < now) {
+      this.logger.debug(_scope, 'code expired', { ctx });
+      Manager._setError(ctx, 'invalid_request', 'code has expired');
+    }
+  }
+
+
+  /**
+   * Unpack the session data from provided code overtop of context session ..
+   * @param {Object} ctx
+   */
+  async _restoreSessionFromCode(ctx) {
+    const _scope = _fileScope('_restoreSessionFromCode');
+
+    const code = ctx.parsedBody['code'];
+    if (code) {
+      try {
+        const oldSession = await this.mysteryBox.unpack(code);
+
+        // TODO: Validate unpacked fields better
+        const missingFields = [
+          'codeId',
+          'codeChallengeMethod',
+          'codeChallenge',
+          'clientId',
+          'redirectUri',
+          'acceptedScopes',
+          'me',
+          'profile',
+          'identifier',
+          'minted',
+        ].filter((requiredField) => !(requiredField in oldSession));
+        if (missingFields.length) {
+          if (this.options.manager.allowLegacyNonPKCE
+          &&  missingFields.length === 2
+          &&  missingFields.includes('codeChallenge')
+          &&  missingFields.includes('codeChallengeMethod')) {
+            this.logger.debug(_scope, 'allowing legacy non-PKCE session', { ctx });
+          } else {
+            this.logger.debug(_scope, 'unpacked code is missing required field', { missingFields, ctx });
+            Manager._setError(ctx, 'invalid_request', 'code is not valid');
+          }
+        }
+
+        Object.assign(ctx.session, oldSession);
+      } catch (e) {
+        this.logger.debug(_scope, 'failed to parse code', { error: e, ctx });
+        Manager._setError(ctx, 'invalid_request', 'code is not valid');
+      }
+    } else {
+      Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code\'');
+    }
+  }
+
+
+  /**
+   * Ensure provided client_id matches session clientId.
+   * @param {Object} ctx
+   */
+  _checkSessionMatchingClientId(ctx) {
+    const _scope = _fileScope('_checkSessionMatchingClientId');
+
+    let clientId = ctx.parsedBody['client_id'];
+    if (clientId) {
+      try {
+        clientId = new URL(clientId);
+        ctx.session.clientId = new URL(ctx.session.clientId);
+      } catch (e) {
+        this.logger.debug(_scope, 'un-parsable client_id url', { ctx });
+        delete ctx.session.clientId;
+        Manager._setError(ctx, 'invalid_request', 'malformed client_id');
+        return;
+      }
+      if (clientId.href !== ctx.session.clientId.href) {
+        this.logger.debug(_scope, 'clientId mismatched', { clientId, ctx });
+        delete ctx.session.clientId;
+        Manager._setError(ctx, 'invalid_request', 'code does not belong to that client_id');
+      }
+    } else {
+      Manager._setError(ctx, 'invalid_request', 'missing required parameter \'client_id\'');
+    }
+  }
+
+
+  /**
+   * @param {Object} ctx
+   */
+  _checkSessionMatchingRedirectUri(ctx) {
+    const _scope = _fileScope('_checkSessionMatchingClientId');
+
+    let redirectUri = ctx.parsedBody['redirect_uri'];
+    if (redirectUri) {
+      try {
+        redirectUri = new URL(redirectUri);
+        ctx.session.redirectUri = new URL(ctx.session.redirectUri);
+      } catch (e) {
+        this.logger.debug(_scope, 'un-parsable redirect_uri url', { ctx });
+        delete ctx.session.redirectUri;
+        Manager._setError(ctx, 'invalid_request', 'malformed redirect_url');
+        return;
+      }
+      if (redirectUri.href !== ctx.session.redirectUri.href) {
+        this.logger.debug(_scope, 'redirectUri mismatched', { redirectUri, ctx });
+        delete ctx.session.redirectUri;
+        Manager._setError(ctx, 'invalid_request', 'code does not belong to that redirect_uri');
+      }
+    } else {
+      Manager._setError(ctx, 'invalid_request', 'missing required parameter \'redirect_uri\'');
+    }
+  }
+
+
+  /**
+   * Validate grant_type, either persist on session or set error.
+   * @param {Object} ctx
+   * @param {String[]} validGrantTypes
+   * @param {Boolean} treatEmptyAs
+   */
+  _checkGrantType(ctx, validGrantTypes = ['authorization_code'], treatEmptyAs = 'authorization_code') {
+    const _scope = _fileScope('_checkGrantType');
+
+    const grantType = ctx.parsedBody['grant_type'] || treatEmptyAs;
+    if (!ctx.parsedBody['grant_type'] && treatEmptyAs) {
+      this.logger.debug(_scope, `missing grant_type, treating as ${treatEmptyAs}`, { ctx });
+    }
+    if (validGrantTypes.includes(grantType)) {
+      ctx.session.grantType = grantType;
+    } else {
+      Manager._setError(ctx, 'invalid_request', 'grant_type not supported');
+    }
+  }
+
+
+  /**
+   * @param {Object} ctx
+   */
+  _checkSessionMatchingCodeVerifier(ctx) {
+    const _scope = _fileScope('_checkSessionMatchingCodeVerifier');
+
+    const codeVerifier = ctx.parsedBody['code_verifier'];
+    if (codeVerifier) {
+      try {
+        const valid = Communication.verifyChallenge(ctx.session.codeChallenge, codeVerifier, ctx.session.codeChallengeMethod);
+        if (!valid) {
+          this.logger.debug(_scope, 'challenge mismatched', { ctx });
+          Manager._setError(ctx, 'invalid_request', 'challenge verification failed');
+        }
+      } catch (e) /* istanbul ignore next */ {
+        this.logger.error(_scope, 'challenge validation failed', { error: e, ctx });
+        Manager._setError(ctx, 'invalid_request', 'challenge verification failed');
+      }
+    } else {
+      if (this.options.manager.allowLegacyNonPKCE
+      &&  !ctx.session.codeChallenge
+      &&  !ctx.session.codeChallengeMethod) {
+        this.logger.debug(_scope, 'allowing non-PKCE', { ctx });
+        return;
+      }
+      Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_verifier\'');
+    }
+  }
+
+
+  /**
+   * Attempt to revoke a token.
+   * @param {*} dbCtx
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async _revokeToken(dbCtx, res, ctx) {
+    const _scope = _fileScope('_revokeToken');
+    try {
+      const token = ctx.parsedBody['token'];
+      const tokenTypeHint = ctx.parsedBody['token_type_hint'];
+      switch (tokenTypeHint) {
+        case undefined:
+          break;
+        case 'access_token':
+          break;
+        case 'refresh_token':
+          break;
+        default:
+          this.logger.debug(_scope, 'unknown token_type_hint', { ctx });
+      }
+      if (!token) {
+        throw new ValidationError('Token Missing');
+      }
+      ctx.token = await this.mysteryBox.unpack(token);
+      if (!(ctx.token?.c || ctx.token?.rc)) {
+        throw new ValidationError('Token Invalid');
+      }
+    } catch (e) {
+      this.logger.debug(_scope, 'invalid token', { error: e, ctx });
+      res.statusCode = 400;
+      res.end();
+      this.logger.info(_scope, 'finished, revoke request not valid', { error: e, ctx });
+      return;
+    }
+
+    try {
+      if (ctx.token.c) {
+        await this.db.tokenRevokeByCodeId(dbCtx, ctx.token.c);
+      } else {
+        await this.db.tokenRefreshRevokeByCodeId(dbCtx, ctx.token.rc);
+      }
+    } catch (e) {
+      if (e instanceof DBErrors.UnexpectedResult) {
+        res.statusCode = 404;
+        res.end();
+        this.logger.info(_scope, 'finished, no token to revoke', { error: e, ctx });
+        return;
+      }
+      this.logger.error(_scope, 'revoke token failed', { error: e, ctx });
+      throw e;
+    }
+
+    res.end();
+    this.logger.info(_scope, 'finished, token revoked', { ctx });
+  }
+
+
+  /**
+   * Legacy token validation flow.
+   * @param {*} dbCtx
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async _validateToken(dbCtx, req, res, ctx) {
+    const _scope = _fileScope('_validateToken');
+    await this._checkTokenValidationRequest(dbCtx, req, ctx);
+    if (ctx.bearer.isValid) {
+      Manager._sensitiveResponse(res);
+      res.end(JSON.stringify({
+        me: ctx.token.profile,
+        'client_id': ctx.token.clientId,
+        scope: ctx.token.scopes,
+      }));
+      this.logger.info(_scope, 'finished, token validated', { ctx });
+    } else {
+      const responseErrorParts = ['Bearer'];
+      const error = ctx.session.error ? `error="${ctx.session.error}"` : '';
+      if (error) {
+        responseErrorParts.push(error);
+      }
+      const errorDescription = ctx.session.errorDescriptions ? `error_description="${ctx.session.errorDescriptions.join(', ')}"` : '';
+      if (errorDescription) {
+        responseErrorParts.push(errorDescription);
+      }
+      res.setHeader(Enum.Header.WWWAuthenticate, responseErrorParts.join(', '));
+      this.logger.info(_scope, 'finished, token not validated', { ctx });
+      throw new ResponseError(Enum.ErrorResponse.Unauthorized);
+    }
+  }
+
+
+  /**
+   * Given a list of newly-requested scopes, return a list of scopes
+   * from previousScopes which are not in requestedScopes.
+   * @param {String[]} previousScopes
+   * @param {String[]} requestedScopes
+   * @returns {String[]}
+   */
+  static _scopeDifference(previousScopes, requestedScopes) {
+    const scopesToRemove = [];
+    const existingScopesSet = new Set(previousScopes);
+    const validRequestedScopes = requestedScopes.filter((s) => common.validScope(s));
+    const requestedScopesSet = new Set(validRequestedScopes);
+    existingScopesSet.forEach((s) => {
+      if (!requestedScopesSet.has(s)) {
+        scopesToRemove.push(s);
+      }
+    });
+    return scopesToRemove;
+  }
+
+
+  /**
+   * Redeem a refresh token for a new token.
+   * @param {*} dbCtx
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async _refreshToken(dbCtx, req, res, ctx) {
+    const _scope = _fileScope('_refreshToken');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const {
+      'client_id': clientId,
+      scope,
+    } = ctx.parsedBody;
+
+    try {
+      ctx.refreshToken = await this.mysteryBox.unpack(ctx.parsedBody['refresh_token']);
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to unpack token', { error: e, ctx });
+    }
+
+    const now = new Date();
+    const nowEpoch = common.dateToEpoch(now);
+
+    await this.db.transaction(dbCtx, async (txCtx) => {
+      if (ctx.refreshToken?.rc) {
+        ctx.token = await this.db.tokenGetByCodeId(txCtx, ctx.refreshToken.rc);
+      }
+
+      if (!ctx.token) {
+        this.logger.debug(_scope, 'no token to refresh', { ctx });
+        throw new ResponseError(Enum.ErrorResponse.NotFound);
+      }
+
+      if (!ctx.token.refreshExpires
+      ||  ctx.token.refreshExpires < now) {
+        this.logger.debug(_scope, 'token not refreshable or refresh expired', { ctx });
+        throw new ResponseError(Enum.ErrorResponse.BadRequest);
+      }
+
+      const refreshExpiresEpoch = common.dateToEpoch(ctx.token.refreshExpires);
+      if (ctx.refreshToken.exp < refreshExpiresEpoch) {
+        this.logger.debug(_scope, 'token already refreshed', { ctx });
+        throw new ResponseError(Enum.ErrorResponse.BadRequest);
+      }
+
+      if (clientId !== ctx.token.clientId) {
+        this.logger.debug(_scope, 'client identifier mismatch', { ctx });
+        throw new ResponseError(Enum.ErrorResponse.BadRequest);
+      }
+
+      const scopesToRemove = scope ? Manager._scopeDifference(ctx.token.scopes, scope.split(scopeSplitRE)) : [];
+      if (scopesToRemove.length) {
+        this.logger.debug(_scope, 'scope reduction requested', { ctx, scopesToRemove });
+      }
+  
+      const refreshedTokenData = await this.db.refreshCode(txCtx, ctx.refreshToken.rc, now, scopesToRemove);
+      if (refreshedTokenData) {
+        Object.assign(ctx.token, refreshedTokenData);
+      } else {
+        this.logger.debug(_scope, 'could not refresh token', { ctx });
+        throw new ResponseError(Enum.ErrorResponse.NotFound);
+      }
+    }); // tx
+
+    const [token, refreshToken] = await Promise.all([
+      {
+        c: ctx.token.codeId,
+        ts: nowEpoch,
+      },
+      {
+        rc: ctx.token.codeId,
+        ts: nowEpoch,
+        exp: nowEpoch + ctx.token.refreshDuration,
+      },
+    ].map(this.mysteryBox.pack));
+
+    const response = {
+      'access_token': token,
+      'token_type': 'Bearer',
+      ...(ctx.token.duration && { 'expires_in': nowEpoch + ctx.token.duration }),
+      ...(refreshToken && { 'refresh_token': refreshToken }),
+      scope: ctx.token.scopes.join(' '),
+      me: ctx.session.me,
+      ...(ctx.token.scopes.includes('profile') && { profile: ctx.token.profileData }),
+    };
+    if (ctx.token.scopes.includes('profile') && !ctx.token.scopes.includes('email')) {
+      delete response?.profile?.email;
+    }
+
+    Manager._sensitiveResponse(res);
+    res.end(JSON.stringify(response));
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Generate a new ticket for later redemption.
+   * @param {Object} payload
+   * @param {} payload.subject deliver ticket to this endpoint
+   * @param {} payload.resource url the redeemed ticket is valid for accessing
+   * @param {String[]} payload.scopes list of scopes assigned to ticket
+   * @param {String} payload.identifier user generating ticket
+   * @param {} payload.profile profile of user generating ticket
+   * @param {Number} payload.ticketLifespanSeconds ticket redeemable for this long
+   * @returns {String}
+  */
+  async _mintTicket({ subject, resource, scopes, identifier, profile, ticketLifespanSeconds }) {
+    const _scope = _fileScope('_mintTicket');
+    this.logger.debug(_scope, 'called', { subject, resource, scopes, identifier, profile, ticketLifespanSeconds });
+
+    const nowEpoch = common.dateToEpoch();
+    return this.mysteryBox.pack({
+      c: common.requestId(),
+      iss: nowEpoch,
+      exp: nowEpoch + ticketLifespanSeconds,
+      sub: subject,
+      res: resource,
+      scope: scopes,
+      ident: identifier,
+      profile: profile,
+    });
+  }
+
+
+  /**
+   * @typedef Ticket
+   * @property {String} codeId
+   * @property {Date} issued
+   * @property {Date} expires
+   * @property {URL} subject
+   * @property {URL} resource
+   * @property {String[]} scopes
+   * @property {String} identifier
+   * @property {URL} profile
+   */
+  /**
+   * 
+   * @param {String} ticket
+   * @returns {Ticket}
+   */
+  async _unpackTicket(ticket) {
+    const ticketObj = await this.mysteryBox.unpack(ticket);
+    return {
+      codeId: ticketObj.c,
+      issued: new Date(ticketObj.iss * 1000),
+      expires: new Date(ticketObj.exp * 1000),
+      subject: new URL(ticketObj.sub),
+      resource: new URL(ticketObj.res),
+      scopes: ticketObj.scope,
+      identifier: ticketObj.ident,
+      profile: new URL(ticketObj.profile),
+    };
+  }
+
+
+  /**
+   * Redeem a ticket for a token.
+   * @param {*} dbCtx
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async _ticketAuthToken(dbCtx, req, res, ctx) {
+    const _scope = _fileScope('_ticketAuthToken');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    try {
+      ctx.ticket = await this._unpackTicket(ctx.parsedBody['ticket']);
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to unpack ticket', { error: e, ctx });
+      throw new ResponseError(Enum.ErrorResponse.BadRequest);
+    }
+
+    const now = new Date();
+    if (now > ctx.ticket.expires) {
+      this.logger.debug(_scope, 'ticket has expired', { ctx });
+      throw new ResponseError(Enum.ErrorResponse.Forbidden, { reason: 'Ticket has expired.', expired: ctx.ticket.expires });
+    }
+
+    const nowEpoch = common.dateToEpoch(now);
+    const token = await this.mysteryBox.pack({
+      c: ctx.ticket.codeId,
+      ts: nowEpoch,
+    });
+
+    const response = {
+      'access_token': token,
+      'token_type': 'Bearer',
+      scope: ctx.ticket.scopes.join(' '),
+      me: ctx.ticket.profile.href,
+    };
+
+    const isValid = await this.db.redeemCode(dbCtx, {
+      created: now,
+      codeId: ctx.ticket.codeId,
+      isToken: true,
+      clientId: ctx.ticket.subject.href,
+      resource: ctx.ticket.resource.href,
+      profile: ctx.ticket.profile.href,
+      identifier: ctx.ticket.identifier,
+      scopes: ctx.ticket.scopes,
+    });
+    if (!isValid) {
+      this.logger.debug(_scope, 'redemption failed, already redeemed', { ctx });
+      throw new ResponseError(Enum.ErrorResponse.Forbidden);
+    }
+
+    Manager._sensitiveResponse(res);
+    res.end(JSON.stringify(response));
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Redeem a code for a token.
+   * @param {*} dbCtx
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async _codeToken(dbCtx, req, res, ctx) {
+    const _scope = _fileScope('_codeToken');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    await this._restoreSessionFromCode(ctx);
+    await this._checkSessionMatchingRedirectUri(ctx);
+
+    if (ctx.session.error) {
+      throw new ResponseError(Enum.ErrorResponse.BadRequest);
+    }
+
+    /**
+     * Note the creation date here rather than in database, so that stored
+     * expire dates are ensured to match those packed in tokens.
+     * An alternative would be to return the date generated by the database,
+     * but then we would need to hold the transaction open while minting the
+     * tokens to ensure success.  Perhaps that would be worth it, but for now
+     * this is how it is.
+     */
+    const now = new Date();
+    const nowEpoch = common.dateToEpoch(now);
+    const tokenMinters = [];
+
+    tokenMinters.push(this.mysteryBox.pack({
+      c: ctx.session.codeId,
+      ts: nowEpoch,
+      ...(ctx.session.tokenLifespan && { exp: nowEpoch + ctx.session.tokenLifespan }),
+    }));
+
+    if (ctx.session.tokenLifespan
+    &&  ctx.session.refreshLifespan) {
+      tokenMinters.push(this.mysteryBox.pack({
+        rc: ctx.session.codeId,
+        ts: nowEpoch,
+        exp: nowEpoch + ctx.session.refreshLifespan,
+      }));
+    }
+
+    const [token, refreshToken] = await Promise.all(tokenMinters);
+
+    const response = {
+      'access_token': token,
+      'token_type': 'Bearer',
+      ...(ctx.session.tokenLifespan && { 'expires_in': nowEpoch + ctx.session.tokenLifespan }),
+      ...(refreshToken && { 'refresh_token': refreshToken }),
+      scope: ctx.session.acceptedScopes.join(' '),
+      me: ctx.session.me,
+      ...(ctx.session.acceptedScopes.includes('profile') && { profile: ctx.session.profile }),
+    };
+    if (!ctx.session.acceptedScopes.includes('email') && response.profile) {
+      delete response.profile.email;
+    }
+
+    const isValid = await this.db.redeemCode(dbCtx, {
+      created: now,
+      codeId: ctx.session.codeId,
+      isToken: true,
+      clientId: ctx.session.clientId,
+      profile: ctx.session.me,
+      identifier: ctx.session.identifier,
+      scopes: ctx.session.acceptedScopes,
+      lifespanSeconds: ctx.session.tokenLifespan,
+      refreshLifespanSeconds: ctx.session.refreshLifespan,
+      profileData: response.profile,
+    });
+    if (!isValid) {
+      this.logger.debug(_scope, 'redemption failed, already redeemed', { ctx });
+      throw new ResponseError(Enum.ErrorResponse.Forbidden);
+    }
+
+    Manager._sensitiveResponse(res);
+    res.end(JSON.stringify(response));
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Issue, refresh, or validate a token.
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async postToken(req, res, ctx) {
+    const _scope = _fileScope('postToken');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    ctx.session = Object.assign({}, ctx.session);
+
+    await this.db.context(async (dbCtx) => {
+
+      // Is this a (legacy) revocation request?
+      if (ctx.parsedBody['action'] === 'revoke') {
+        await this._revokeToken(dbCtx, res, ctx);
+        return;
+      }
+
+      // Is this a (legacy) token validation request?
+      if (req.getHeader(Enum.Header.Authorization)) {
+        await this._validateToken(dbCtx, res, ctx);
+        return;
+      }
+
+      const validGrantTypes = [
+        'authorization_code',
+        'refresh_token',
+        ...(this.queuePublisher && ['ticket'] || []),
+      ];
+      this._checkGrantType(ctx, validGrantTypes, 'authorization_code');
+
+      switch (ctx.session.grantType) {
+        case 'refresh_token':
+          return this._refreshToken(dbCtx, req, res, ctx);
+
+        case 'ticket':
+          return this._ticketAuthToken(dbCtx, req, res, ctx);
+
+        case 'authorization_code':
+          return this._codeToken(dbCtx, req, res, ctx);
+
+        default:
+          this.logger.debug(_scope, 'unknown grant_type', { ctx });
+          Manager._setError(ctx, 'invalid_request', 'grant_type not supported');
+      }
+
+      // Only way of getting here is due to error.
+      throw new ResponseError(Enum.ErrorResponse.BadRequest);
+    }); // dbCtx
+  }
+
+
+  /**
+   * Ingest token from authorization header, setting ctx.bearer.isValid appropriately.
+   * ctx.bearer not set if auth method not recognized.
+   * This is for legacy validation on token endpoint.
+   * @param {*} dbCtx
+   * @param {http.ClientRequest} req
+   * @param {Object} ctx
+   */
+  async _checkTokenValidationRequest(dbCtx, req, ctx) {
+    const _scope = _fileScope('_checkTokenValidationRequest');
+    const authHeader = req.getHeader(Enum.Header.Authorization);
+
+    if (authHeader) {
+      const [authMethod, authString] = common.splitFirst(authHeader, ' ', '');
+      switch (authMethod.toLowerCase()) { // eslint-disable-line sonarjs/no-small-switch
+        case 'bearer': {
+          ctx.bearer = {
+            isValid: false,
+          };  
+          try {
+            Object.assign(ctx.bearer, await this.mysteryBox.unpack(authString));
+          } catch (e) {
+            this.logger.debug(_scope, 'failed to unpack token', { ctx });
+            Manager._setError(ctx, 'invalid_request', 'invalid token');
+            return;
+          }
+          if (!ctx.bearer.c) {
+            this.logger.debug(_scope, 'incomplete token', { ctx });
+            Manager._setError(ctx, 'invalid_request', 'invalid token');
+            return;
+          }
+
+          try {
+            ctx.token = await this.db.tokenGetByCodeId(dbCtx, ctx.bearer.c);
+          } catch (e) {
+            this.logger.error(_scope, 'failed to look up token', { error: e, ctx });
+            throw e;
+          }
+
+          if (!ctx.token) {
+            this.logger.debug(_scope, 'no token found', { ctx });
+            Manager._setError(ctx, 'invalid_request', 'invalid token');
+            return;
+          }
+
+          if (!ctx.token.isRevoked
+          &&  ctx.token.expires > new Date()) {
+            ctx.bearer.isValid = true;
+          }
+          break;
+        }
+
+        default:
+          this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
+          return;
+      }
+    }
+  }
+
+
+  /**
+   * Accept an unsolicited ticket proffering.
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async postTicket(req, res, ctx) {
+    const _scope = _fileScope('postTicket');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    if (!this.queuePublisher) {
+      this.logger.debug(_scope, 'ticket endpoint not configured', { ctx });
+      throw new ResponseError(Enum.ErrorResponse.BadRequest);
+    }
+
+    const queueName = this.options.queues.ticketPublishName;
+    const { ticket, resource, subject } = ctx.parsedBody;
+    
+    try {
+      new URL(resource);
+    } catch (e) {
+      this.logger.debug(_scope, 'unparsable resource', { ticket, resource, subject, ctx });
+      throw new ResponseError(Enum.ErrorResponse.BadRequest);
+    }
+
+    await this.db.context(async (dbCtx) => {
+      const isValidProfile = await this.db.profileIsValid(dbCtx, subject);
+      if (!isValidProfile) {
+        this.logger.debug(_scope, 'invalid subject', { ticket, resource, subject, ctx });
+        throw new ResponseError(Enum.ErrorResponse.NotFound, { error: 'subject not under our purview' });
+      }
+
+      try {
+        const result = await this.queuePublisher.publish(queueName, { ticket, resource, subject });
+        this.logger.debug(_scope, 'accepted ticket offer', { queueName, ticket, resource, subject, ctx, result });
+      } catch (e) {
+        this.logger.error(_scope, 'failed to publish ticket to queue', { error: e, queueName, ticket, resource, subject, ctx });
+        throw e; // return a 500
+      }
+
+      res.statusCode = 202;
+      res.end();
+      this.logger.info(_scope, 'finished', { resource, subject, ctx });
+    });
+  }
+
+
+  /**
+   * Validate a token and return data about it.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async postIntrospection(res, ctx) {
+    const _scope = _fileScope('postIntrospection');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    let response = {
+      active: false,
+    };
+
+    const tokenIsTicket = (ctx.parsedBody['token_hint_type'] || '').toLowerCase() === 'ticket';
+
+    try {
+      const token = ctx.parsedBody['token'];
+      if (tokenIsTicket) {
+        ctx.token = await this._unpackTicket(token);
+      } else {
+        ctx.token = await this.mysteryBox.unpack(token);
+      }
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to unpack token', { error: e, ctx });
+    }
+
+    if (ctx.token
+    &&  !tokenIsTicket) {
+      await this.db.context(async (dbCtx) => {
+        ctx.token = await this.db.tokenGetByCodeId(dbCtx, ctx.token.c);
+      }); // dbCtx
+    }
+
+    if (ctx.token
+    &&  !ctx.token.isRevoked) {
+      // fuss around for postgres 'Infinity' date
+      const expiresMs = (ctx.token.expires instanceof Date) ? ctx.token.expires.getTime() : ctx.token.expires;
+      if (expiresMs > Date.now()) {
+        response = {
+          active: true,
+          me: ctx.token.profile,
+          ...(ctx.token.clientId && { 'client_id': ctx.token.clientId }),
+          scope: ctx.token.scopes.join(' '),
+          iat: common.dateToEpoch(ctx.token.created || ctx.token.issued),
+          ...(isFinite(expiresMs) && { exp: Math.ceil(expiresMs / 1000) }),
+          ...(tokenIsTicket && { 'token_type': 'ticket' }),
+        };
+      }
+    }
+
+    Manager._sensitiveResponse(res);
+    res.end(JSON.stringify(response));
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Revoke a token or refresh token.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async postRevocation(res, ctx) {
+    const _scope = _fileScope('postRevocation');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    try {
+      await this.db.context(async (dbCtx) => {
+        await this._revokeToken(dbCtx, res, ctx);
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, ctx });
+      throw e;
+    }
+
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Profile information for a token.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async postUserInfo(res, ctx) {
+    const _scope = _fileScope('postUserInfo');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const token = ctx.parsedBody['token'];
+    if (!token) {
+      res.statusCode = 400;
+      res.end('"invalid_request"');
+      this.logger.info(_scope, 'finished, invalid request', { ctx });
+      return;
+    }
+
+    try {
+      ctx.token = await this.mysteryBox.unpack(ctx.parsedBody['token']);
+    } catch (e) {
+      this.logger.debug(_scope, 'failed to unpack token', { error: e, ctx });
+    }
+
+    if (ctx.token) {
+      await this.db.context(async (dbCtx) => {
+        ctx.token = await this.db.tokenGetByCodeId(dbCtx, ctx.token.c);
+      }); // dbCtx
+    }
+  
+    if (!ctx.token
+    ||  ctx.token.isRevoked
+    // || tokenIsExpired(token)
+    ) {
+      res.statusCode = 401;
+      res.end('"invalid_token"');
+      this.logger.info(_scope, 'finished, invalid token', { ctx });
+      return;
+    }
+
+    if (!ctx.token.scopes.includes('profile')) {
+      res.statusCode = 403;
+      res.end('"insufficient_scope"');
+      this.logger.info(_scope, 'finished, insufficient scope', { ctx });
+      return;
+    }
+
+    const response = {
+      ...ctx.token.profile,
+    };
+    if (!ctx.token.scopes.includes('email')) {
+      delete response.email;
+    }
+
+    Manager._sensitiveResponse(res);
+    res.end(JSON.stringify(response));
+
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Show admin interface, allowing manipulation of profiles and scopes.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async getAdmin(res, ctx) {
+    const _scope = _fileScope('getAdmin');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const identifier = ctx.session.authenticatedIdentifier;
+
+    await this.db.context(async (dbCtx) => {
+      ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
+      ctx.tokens = await this.db.tokensGetByIdentifier(dbCtx, identifier);
+    }); // dbCtx
+
+    res.end(Template.adminHTML(ctx, this.options));
+
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+  
+  /**
+   * Process admin interface events.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async postAdmin(res, ctx) {
+    const _scope = _fileScope('postAdmin');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    await this.db.context(async (dbCtx) => {
+      const identifier = ctx.session.authenticatedIdentifier;
+      const action = ctx?.parsedBody?.['action'] || '';
+
+      if (action === 'save-scopes') {
+        // Update the convenience scopes set for profiles.
+        // Expect 'scopes-<profile>' with value of array of scopes
+        const profileKeys = ctx.parsedBody && Object.keys(ctx.parsedBody)
+          .filter((k) => k.startsWith('scopes-'));
+        try {
+          await this.db.transaction(dbCtx, async (txCtx) => {
+            await Promise.all(
+              /* For each scopes-profile submitted, set those. */
+              profileKeys.map((profileKey) => {
+                /* elide 'scope-' prefix to get the profile */
+                const profile = profileKey.slice(7);
+                /* (should validate profile here) */
+
+                /* remove invalid scopes from submitted list */
+                const scopes = ctx.parsedBody[profileKey].filter((scope) => scope && common.validScope(scope)); // eslint-disable-line security/detect-object-injection
+                return this.db.profileScopesSetAll(txCtx, profile, scopes);
+              }),
+            );
+          }); // txCtx
+          ctx.notifications.push('Profile/Scope Availability Matrix updated!');
+        } catch (e) {
+          this.logger.error(_scope, 'did not set profile scopes', { error: e, ctx });
+          ctx.errors.push('Failed to update profile scopes.');
+        }
+
+      } else if (action === 'new-profile') {
+        // Validate and create a new profile uri.
+        let profile;
+        const profileUri = ctx.parsedBody['profile'];
+        try {
+          profile = await this.communication.validateProfile(profileUri);
+        } catch (e) {
+          this.logger.debug(_scope, 'invalid profile url', { error: e, ctx });
+          ctx.errors.push(`'${profileUri}' is not a valid profile URI.${(e instanceof CommunicationErrors.ValidationError) ? ('(' + e.message + ')') : ''}`);
+        }
+        if (profile) {
+          // Validate profile uri
+          const profileData = await this.communication.fetchProfile(profile);
+          if (profileData.metadata.authorizationEndpoint !== this.selfAuthorizationEndpoint) {
+            this.logger.debug(_scope, 'profile does not list this server as auth', { profileData, ctx });
+            ctx.errors.push('Requested profile does not list this service, not adding.');
+          } else {
+            try {
+              await this.db.transaction(dbCtx, async (txCtx) => {
+                await this.db.profileIdentifierInsert(txCtx, profile.href, identifier);
+                await this.db.profileScopesSetAll(txCtx, profile.href, ['profile', 'email']);
+              }); // txCtx
+              ctx.notifications.push('Profile added!');
+            } catch (e) {
+              this.logger.error(_scope, 'did not insert profile', { error: e, ctx });
+              ctx.errors.push('Failed to add profile.');
+            }
+          }
+        }
+
+      } else if (action === 'new-scope') {
+        // Add or update a manually-added convenience scope.
+        const { scope, application = '', description = '' } = ctx.parsedBody;
+        if (scope) {
+          if (!common.validScope(scope)) {
+            ctx.errors.push(`"${scope}" is not a valid scope name, did not add it.`);
+          } else {
+            try {
+              await this.db.scopeUpsert(dbCtx, scope, application, description, true);
+              ctx.notifications.push('Scope List updated!');
+            } catch (e) {
+              this.logger.error(_scope, 'did not upsert scope', { error: e, scope, application, description, ctx });
+              ctx.errors.push('Failed to update scope.');
+            }
+          }
+        }
+
+      } else if (action.startsWith('delete-scope-')) {
+        // Remove a manually-added convenience scope.
+        const scope = decodeURIComponent(action.slice(13));
+        if (scope) {
+          try {
+            const deleted = await this.db.scopeDelete(dbCtx, scope);
+            if (deleted) {
+              ctx.notifications.push('Scope deleted.');
+            } else {
+              ctx.notifications.push('Unable to delete scope.');
+            }
+          } catch (e) {
+            this.logger.error(_scope, 'did not delete scope', { error: e, scope, ctx });
+            ctx.errors.push('Failed to delete scope.');
+          }
+        }
+
+      } else if (action.startsWith('revoke-')) {
+        // Revoke an active token.
+        const codeId = action.slice(8);
+        if (codeId) {
+          try {
+            await this.db.tokenRevokeByCodeId(dbCtx, codeId, identifier);
+            ctx.notifications.push('Revoked token!');
+          } catch (e) {
+            this.logger.error(_scope, 'did not revoke token', { error: e, codeId, identifier, ctx });
+            ctx.errors.push('Unable to revoke token.');
+          }
+        }
+
+      } else if (action) {
+        ctx.errors.push(`Do not know how to '${action}'.`);
+      }
+
+      ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
+      ctx.tokens = await this.db.tokensGetByIdentifier(dbCtx, identifier);
+    }); // dbCtx
+
+    res.end(Template.adminHTML(ctx, this.options));
+
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Show ticket proffer interface.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async getAdminTicket(res, ctx) {
+    const _scope = _fileScope('getAdminTicket');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const identifier = ctx.session.authenticatedIdentifier;
+
+    await this.db.context(async (dbCtx) => {
+      ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
+      ctx.profiles = ctx.profilesScopes.profiles;
+      ctx.scopes = Object.keys(ctx.profilesScopes.scopeIndex);
+    }); // dbCtx
+
+    res.end(Template.adminTicketHTML(ctx, this.options));
+
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Handle ticket proffer interface submission.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async postAdminTicket(res, ctx) {
+    const _scope = _fileScope('postAdminTicket');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    switch (ctx.parsedBody['action']) { // eslint-disable-line sonarjs/no-small-switch
+      case 'proffer-ticket': {
+        const identifier = ctx.session.authenticatedIdentifier;
+        [
+          { ctxProp: 'ticketProfileUrl', bodyParam: 'profile', err: 'Invalid Profile URL selected.' },
+          { ctxProp: 'ticketResourceUrl', bodyParam: 'resource', err: 'Invalid Resource URL.' },
+          { ctxProp: 'ticketSubjectUrl', bodyParam: 'subject', err: 'Invalid Recipient URL.' },
+        ].forEach((param) => {
+          try {
+            ctx[param.ctxProp] = new URL(ctx.parsedBody[param.bodyParam]);
+          } catch (e) {
+            this.logger.debug(_scope, `invalid ${param.bodyParam}`, { ctx });
+            ctx.errors.push(param.err);
+          }
+        });
+
+        const subjectData = await this.communication.fetchProfile(ctx.ticketSubjectUrl);
+        if (!subjectData?.metadata?.ticketEndpoint) {
+          this.logger.debug(_scope, 'subject has no ticket endpoint', { ctx });
+          ctx.errors.push('Recipient does not list a ticket endpoint to deliver to.');
+        } else {
+          try {
+            ctx.ticketEndpointUrl = new URL(subjectData.metadata.ticketEndpoint);
+          } catch (e) {
+            this.logger.debug(_scope, 'subject has invalid ticket endpoint', { error: e, ctx });
+            ctx.errors.push(`Recipient lists an invalid ticket endpoint, cannot deliver. (${e})`);
+          }
+        }
+
+        const scopesSet = new Set();
+        const rawScopes = [
+          ...(common.ensureArray(ctx.parsedBody['scopes'])),
+          ...((ctx.parsedBody['adhoc'] || '').split(scopeSplitRE)),
+        ].filter((scope) => scope);
+        rawScopes.forEach((scope) => {
+          if (common.validScope(scope)) {
+            scopesSet.add(scope);
+          } else {
+            this.logger.debug(_scope, 'invalid adhoc scope', { scope, ctx });
+            ctx.errors.push(`'${scope}' is not a valid scope.`);
+          }
+        });
+        ctx.ticketScopes = [...scopesSet];
+        const actionScopes = ctx.ticketScopes.filter((scope) => !['profile', 'email'].includes(scope));
+        if (!actionScopes.length) {
+          this.logger.debug(_scope, 'no valid scopes included', { ctx });
+          ctx.errors.push('At least one actionable scope must be included.');
+        }
+
+        if (!ctx.errors.length) {
+          const ticketData = {
+            subject: ctx.ticketSubjectUrl.href,
+            resource: ctx.ticketResourceUrl.href,
+            scopes: ctx.ticketScopes,
+            identifier,
+            profile: ctx.ticketProfileUrl.href,
+            ticketLifespanSeconds: this.options.manager.ticketLifespanSeconds,
+          };
+          const ticket = await this._mintTicket(ticketData);
+
+          await this.db.context(async (dbCtx) => {
+            // re-populate form fields
+            ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
+      
+            // TODO: queue ticket for delivery/retry to subject instead of trying immediately
+            // ctx.notifications.push('Success! Ticket will be delivered!');
+
+            this.logger.debug(_scope, 'ticket created', { ctx, ticketData, subjectData });
+
+            try {
+              const result = await this.communication.deliverTicket(ctx.ticketEndpointUrl, ctx.ticketResourceUrl, ctx.ticketSubjectUrl, ticket);
+              ctx.notifications.push(`Success! Ticket was delivered. (${result?.statusText})`);
+              this.logger.info(_scope, 'ticket delivered', { ctx, result });
+            } catch (e) {
+              this.logger.error(_scope, 'failed to deliver ticket', { ctx, error: e });
+              ctx.errors.push(`Failed to deliver ticket. (${e})`);
+            }
+
+          }); // dbCtx
+
+        } else {
+          // populate form fields again
+          await this.db.context(async (dbCtx) => {
+            ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
+            ctx.scopes = Object.keys(ctx.profilesScopes.scopeIndex);      
+          }); // dbCtx      
+        }
+
+        break;
+      }
+
+      default:
+        this.logger.debug(_scope, 'unknown action', { ctx });
+    }
+
+    res.end(Template.adminTicketHTML(ctx, this.options));
+
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * Report on generally uninteresting backend information.
+   * Also allow a few event invocations.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async getAdminMaintenance(res, ctx) {
+    const _scope = _fileScope('getAdminMaintenance');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const maintenanceTasks = [];
+
+    await this.db.context(async (dbCtx) => {
+
+      Object.values(Enum.Chore).forEach((chore) => {
+        if (chore in ctx.queryParams) {
+          maintenanceTasks.push(
+            this.chores.runChore(chore, 0), // Provide arg to force chore run.
+          );
+          ctx.notifications.push(`Running maintenance chore "${chore}".`);
+        }
+      });
+
+      await Promise.all(maintenanceTasks);
+
+      ctx.almanac = await this.db.almanacGetAll(dbCtx);
+    }); // dbCtx
+
+    const winnowChoreEntry = ([name, value]) => [name, common.pick(value, ['intervalMs', 'nextSchedule'])];
+    ctx.chores = Object.fromEntries(
+      Object.entries(this.chores.chores).map(winnowChoreEntry),
+    );
+
+    res.end(Template.adminMaintenanceHTML(ctx, this.options));
+
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * 
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx 
+   */
+  async getHealthcheck(res, ctx) {
+    const _scope = _fileScope('getHealthcheck');
+    this.logger.debug(_scope, 'called', { ctx });
+    await this.db.healthCheck();
+    res.end();
+  }
+
+}
+
+module.exports = Manager;
\ No newline at end of file
diff --git a/src/service.js b/src/service.js
new file mode 100644 (file)
index 0000000..5fbf950
--- /dev/null
@@ -0,0 +1,544 @@
+'use strict';
+
+/**
+ * Here we extend the base API server to define our routes and any route-specific
+ * behavior (middlewares) before handing off to the manager.
+ */
+
+const path = require('path');
+const { Dingus } = require('@squeep/api-dingus');
+const common = require('./common');
+const Manager = require('./manager');
+const { Authenticator, SessionManager } = require('@squeep/authentication-module');
+const { ResourceAuthenticator } = require('@squeep/resource-authentication-module');
+const { TemplateHelper: { initContext } } = require('@squeep/html-template-helper');
+const Enum = require('./enum');
+
+const _fileScope = common.fileScope(__filename);
+
+class Service extends Dingus {
+  constructor(logger, db, options) {
+    super(logger, {
+      ...options.dingus,
+      ignoreTrailingSlash: false,
+    });
+
+    this.staticPath = path.normalize(path.join(__dirname, '..', 'static'));
+    this.manager = new Manager(logger, db, options);
+    this.authenticator = new Authenticator(logger, db, options);
+    this.sessionManager = new SessionManager(logger, this.authenticator, options);
+    this.resourceAuthenticator = new ResourceAuthenticator(logger, db, options);
+    this.loginPath = `${options.dingus.proxyPrefix}/admin/login`;
+
+    // N.B. /admin routes not currently configurable
+    const route = (r) => `/${options.route[r]}`; // eslint-disable-line security/detect-object-injection
+
+    // Service discovery
+    this.on(['GET', 'HEAD'], route('metadata'), this.handlerGetMeta.bind(this));
+    // Also respond with metadata on well-known oauth2 endpoint if base has no prefix
+    if ((options?.dingus?.selfBaseUrl?.match(/\//g) || []).length === 3) {
+      this.on(['GET', 'HEAD'], '/.well-known/oauth-authorization-server', this.handlerGetMeta.bind(this));
+    }
+
+    // Primary endpoints
+    this.on(['GET'], route('authorization'), this.handlerGetAuthorization.bind(this));
+    this.on(['POST'], route('authorization'), this.handlerPostAuthorization.bind(this));
+    this.on(['POST'], route('consent'), this.handlerPostConsent.bind(this));
+    this.on(['POST'], route('revocation'), this.handlerPostRevocation.bind(this));
+    this.on(['POST'], route('ticket'), this.handlerPostTicket.bind(this));
+    this.on(['POST'], route('token'), this.handlerPostToken.bind(this));
+
+    // Resource endpoints
+    this.on('POST', route('introspection'), this.handlerPostIntrospection.bind(this));
+    this.on('POST', route('userinfo'), this.handlerPostUserInfo.bind(this));
+
+    // Information page about service
+    this.on(['GET', 'HEAD'], '/', this.handlerGetRoot.bind(this));
+
+    // Give load-balancers something to check
+    this.on(['GET', 'HEAD'], route('healthcheck'), this.handlerGetHealthcheck.bind(this));
+
+    // These routes are intended for accessing static content during development.
+    // In production, a proxy server would likely handle these first.
+    this.on(['GET', 'HEAD'], '/static', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/static/`);
+    this.on(['GET', 'HEAD'], '/static/', this.handlerGetStaticFile.bind(this), 'index.html');
+    this.on(['GET', 'HEAD'], '/static/:file', this.handlerGetStaticFile.bind(this));
+    this.on(['GET', 'HEAD'], '/favicon.ico', this.handlerGetStaticFile.bind(this), 'favicon.ico');
+    this.on(['GET', 'HEAD'], '/robots.txt', this.handlerGetStaticFile.bind(this), 'robots.txt');
+
+    // Profile and token management for authenticated sessions
+    this.on(['GET', 'HEAD'], '/admin', this.handlerRedirect.bind(this), `${options.dingus.proxyPrefix}/admin/`);
+    this.on(['GET', 'HEAD'], '/admin/', this.handlerGetAdmin.bind(this));
+    this.on(['POST'], '/admin/', this.handlerPostAdmin.bind(this));
+
+    // Ticket-proffering interface for authenticated sessions
+    this.on(['GET', 'HEAD'], '/admin/ticket', this.handlerGetAdminTicket.bind(this));
+    this.on(['POST'], '/admin/ticket', this.handlerPostAdminTicket.bind(this));
+
+    // User authentication and session establishment
+    this.on(['GET', 'HEAD'], '/admin/login', this.handlerGetAdminLogin.bind(this));
+    this.on(['POST'], '/admin/login', this.handlerPostAdminLogin.bind(this));
+    this.on(['GET'], '/admin/logout', this.handlerGetAdminLogout.bind(this));
+
+    // Page for upkeep info et cetera
+    this.on(['GET', 'HEAD'], '/admin/maintenance', this.handlerGetAdminMaintenance.bind(this));
+
+  }
+
+
+  /**
+   * Perform any async startup tasks.
+   */
+  async initialize() {
+    await this.manager.initialize();
+  }
+
+
+  /**
+   * Do a little more on each request.
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async preHandler(req, res, ctx) {
+    await super.preHandler(req, res, ctx);
+    ctx.url = req.url; // Persist this for logout redirect
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerGetAdminLogin(req, res, ctx) {
+    const _scope = _fileScope('handlerGetAdminLogin');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.sessionManager.getAdminLogin(res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerPostAdminLogin(req, res, ctx) {
+    const _scope = _fileScope('handlerPostAdminLogin');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    initContext(ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.authenticator.sessionOptionalLocal(req, res, ctx);
+
+    await this.ingestBody(req, res, ctx);
+
+    await this.sessionManager.postAdminLogin(res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerGetAdminLogout(req, res, ctx) {
+    const _scope = _fileScope('handlerGetAdminLogout');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    initContext(ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.authenticator.sessionOptionalLocal(req, res, ctx);
+
+    await this.sessionManager.getAdminLogout(res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerGetAdmin(req, res, ctx) {
+    const _scope = _fileScope('handlerGetAdmin');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    initContext(ctx);
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
+      await this.manager.getAdmin(res, ctx);
+    }
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerPostAdmin(req, res, ctx) {
+    const _scope = _fileScope('handlerPostAdmin');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    initContext(ctx);
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
+      await this.ingestBody(req, res, ctx);
+      await this.manager.postAdmin(res, ctx);
+    }
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerGetAdminTicket(req, res, ctx) {
+    const _scope = _fileScope('handlerGetAdminTicket');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    initContext(ctx);
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
+      await this.manager.getAdminTicket(res, ctx);
+    }
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerPostAdminTicket(req, res, ctx) {
+    const _scope = _fileScope('handlerPostAdminTicket');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    initContext(ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
+      await this.ingestBody(req, res, ctx);
+      await this.manager.postAdminTicket(res, ctx);
+    }
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerGetMeta(req, res, ctx) {
+    const _scope = _fileScope('handlerGetMeta');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    const responseTypes = [
+      Enum.ContentType.ApplicationJson,
+      Enum.ContentType.TextPlain,
+    ];
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.authenticator.sessionOptionalLocal(req, res, ctx);
+
+    await this.manager.getMeta(res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerGetAuthorization(req, res, ctx) {
+    const _scope = _fileScope('handlerGetAuthorization');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    initContext(ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
+      await this.manager.getAuthorization(res, ctx);
+    }
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerPostAuthorization(req, res, ctx) {
+    const _scope = _fileScope('handlerPostAuthorization');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    const responseTypes = [
+      Enum.ContentType.ApplicationJson,
+      Enum.ContentType.TextPlain,
+    ];
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.authenticator.sessionOptionalLocal(req, res, ctx);
+
+    await this.ingestBody(req, res, ctx);
+
+    await this.manager.postAuthorization(res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerPostConsent(req, res, ctx) {
+    const _scope = _fileScope('handlerPostConsent');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    initContext(ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    // This isn't specified as required as any valid payload carries intrinsic auth data.
+    await this.authenticator.sessionOptionalLocal(req, res, ctx);
+
+    await this.ingestBody(req, res, ctx);
+
+    await this.manager.postConsent(res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerPostTicket(req, res, ctx) {
+    const _scope = _fileScope('handlerPostTicket');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    const responseTypes = [
+      Enum.ContentType.ApplicationJson,
+      Enum.ContentType.TextPlain,
+    ];
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.ingestBody(req, res, ctx);
+
+    await this.manager.postTicket(req, res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerPostToken(req, res, ctx) {
+    const _scope = _fileScope('handlerPostToken');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    const responseTypes = [
+      Enum.ContentType.ApplicationJson,
+      Enum.ContentType.TextPlain,
+    ];
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.ingestBody(req, res, ctx);
+
+    await this.manager.postToken(req, res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerPostRevocation(req, res, ctx) {
+    const _scope = _fileScope('handlerPostRevocation');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    const responseTypes = [
+      Enum.ContentType.ApplicationJson,
+      Enum.ContentType.TextPlain,
+    ];
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.ingestBody(req, res, ctx);
+
+    await this.manager.postRevocation(req, res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerPostIntrospection(req, res, ctx) {
+    const _scope = _fileScope('handlerPostIntrospection');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    const responseTypes = [
+      Enum.ContentType.ApplicationJson,
+      Enum.ContentType.TextPlain,
+    ];
+
+    await this.resourceAuthenticator.required(req, res, ctx);
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.ingestBody(req, res, ctx);
+
+    await this.manager.postIntrospection(res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerPostUserInfo(req, res, ctx) {
+    const _scope = _fileScope('handlerPostUserInfo');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    const responseTypes = [
+      Enum.ContentType.ApplicationJson,
+      Enum.ContentType.TextPlain,
+    ];
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.ingestBody(req, res, ctx);
+
+    await this.manager.postUserInfo(res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerGetRoot(req, res, ctx) {
+    const _scope = _fileScope('handlerGetRoot');
+    const responseTypes = [
+      Enum.ContentType.TextHTML,
+    ];
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    initContext(ctx);
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.authenticator.sessionOptionalLocal(req, res, ctx);
+
+    await this.manager.getRoot(res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   */
+  async handlerGetHealthcheck(req, res, ctx) {
+    const _scope = _fileScope('handlerGetHealthcheck');
+    this.logger.debug(_scope, 'called', { req, ctx });
+  
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.manager.getHealthcheck(res, ctx);
+  }
+
+
+  /**
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerGetAdminMaintenance(req, res, ctx) {
+    const _scope = _fileScope('handlerGetAdminMaintenance');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    initContext(ctx);
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    if (await this.authenticator.sessionRequiredLocal(req, res, ctx, this.loginPath)) {
+      await this.manager.getAdminMaintenance(res, ctx);
+    }
+  }
+
+
+  /**
+   * FIXME: This doesn't seem to be working as envisioned. Maybe override render error method instead???
+   * Intercept this and redirect if we have enough information, otherwise default to framework.
+   * The redirect attempt should probably be contained in a Manager method, but here it is for now.
+   * @param {http.IncomingMessage} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerInternalServerError(req, res, ctx) {
+    const _scope = _fileScope('handlerInternalServerError');
+    this.logger.debug(_scope, 'called', { req, ctx });
+
+    if (ctx?.session?.redirectUri && ctx?.session?.clientIdentifier) {
+      Object.entries({
+        ...(ctx.session.state && { 'state': ctx.session.state }),
+        'error': 'server_error',
+        'error_description': 'An internal server error occurred',
+      }).forEach(([name, value]) => ctx.session.redirectUri.searchParams.set(name, value));
+      res.statusCode = 302; // Found
+      res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
+      res.end();
+      return;
+    }
+
+    super.handlerInternalServerError(req, res, ctx);
+  }
+
+
+}
+
+module.exports = Service;
+
diff --git a/src/template/admin-html.js b/src/template/admin-html.js
new file mode 100644 (file)
index 0000000..085b95a
--- /dev/null
@@ -0,0 +1,214 @@
+'use strict';
+
+/**
+ * This renders the administrative view for an account,
+ * allowing for adding profile URIs, custom scope bundles,
+ * and management of issued tokens.
+ */
+
+const th = require('./template-helper');
+
+
+function renderProfileLI(profile) {
+  return `\t<li><a class="uri" name="${profile}">${profile}</a></li>`;
+}
+
+
+function renderProfileScopeIndicator(profile, scope, selected) {
+  const checked = selected ? ' checked' : '';
+  return `\t\t<td>
+\t\t\t<input type="checkbox" id="${profile}-${scope}" name="scopes-${profile}" value="${scope}"${checked}>
+\t\t</td>`;
+}
+
+function renderScopeRow(scope, details, profiles) {
+  return `\t<tr class="scope">
+${(profiles || []).map((profile) => renderProfileScopeIndicator(profile, scope, details.profiles.includes(profile))).join('\n')}
+\t\t<th scope="row"><label>${scope}<label></th>
+\t\t<td class="description">${details.description}</td>
+\t\t<td>${details.application}</td>
+\t\t<td class="scope-actions">` +
+    (details.isManuallyAdded ? `
+\t\t\t<button name="action" value="delete-scope-${encodeURIComponent(scope)}">Delete</button>
+` : '') + `
+\t\t</td>
+\t</tr>`;
+}
+
+
+function renderProfileHeader(profile) {
+  return `<th class="vertical uri">
+\t\t${profile}
+</th>`;
+}
+
+
+function scopeIndexTable(scopeIndex, profiles) {
+  return `<table>
+<thead>
+\t<tr>
+${(profiles || []).map((profile) => renderProfileHeader(profile)).join('\n')}
+\t\t<th>Scope</th>
+\t\t<th>Description</th>
+\t\t<th>Application</th>
+\t\t<th class="scope-actions"></th>
+\t</tr>
+</thead>
+<tbody>
+${Object.entries(scopeIndex).sort(th.scopeCompare).map(([scope, details]) => renderScopeRow(scope, details, profiles)).join('\n')}
+</tbody>
+</table>`;
+}
+
+function _tokenType(token) {
+  if (token.resource) {
+    return 'ticket-token';
+  }
+  if (!token.isToken) {
+    return 'profile';
+  }
+  return 'token';
+}
+
+function renderTokenRow(token) {
+  const createdTitle = token.refreshed ? 'Refreshed At' : 'Created At';
+  const createdDate = token.refreshed ? token.refreshed : token.created;
+  return `\t\t<tr>
+<td>${_tokenType(token)}</td>
+\t\t\t<td class="uri">${token.clientId}</td>
+\t\t\t<td class="uri">${token.profile}</td>
+<td class="scope">${(token.scopes || []).join(', ')}</td>
+\t\t\t<td class="code">${token.codeId}</td>
+\t\t\t<td>${th.timeElement(createdDate, { title: createdTitle })}</td>
+\t\t\t<td>${th.timeElement(token.expires, { title: 'Expires At' })}</td>
+\t\t\t<td>${token.isRevoked}</td>
+<td>${token.resource ? token.resource : ''}</td>
+\t\t\t<td>` + (
+    token.isRevoked ? '' : `
+\t\t\t\t<button name="action" value="revoke-${token.codeId}">Revoke</button>`) + `
+\t\t\t</td>
+\t\t</tr>`;
+}
+
+function noTokensRows() {
+  return [`\t\t<tr>
+\t\t\t<td colspan="100%" class="centered">(No active or recent tokens.)</td>
+\t\t</tr>`];
+}
+
+function tokenTable(tokens) {
+  const tokenRows = tokens?.length ? tokens.map((token) => renderTokenRow(token)) : noTokensRows();
+  return `<table>
+\t<thead>
+\t\t<tr>
+<th>Type</th>
+\t\t\t<th>Client Identifier</th>
+\t\t\t<th>Profile</th>
+<th>Scopes</th>
+\t\t\t<th>Code</th>
+\t\t\t<th>Created or Refreshed</th>
+\t\t\t<th>Expires</th>
+\t\t\t<th>Revoked</th>
+<th>Resource</th>
+\t\t\t<th></th>
+\t\t</tr>
+\t</thead>
+\t<tbody>
+${tokenRows.join('\n')}
+\t</tbody>
+</table>`;
+}
+
+function mainContent(ctx) {
+  return `<section>
+\t<h2>Profiles</h2>
+\t<ul>
+\t${(ctx.profilesScopes?.profiles || []).map((p) => renderProfileLI(p)).join('\n')}
+\t</ul>
+\t<form action="" method="POST">
+\t\t<fieldset>
+\t\t\t<legend>Add New Profile</legend>
+\t\t\t<div>
+\t\t\t\tThe profile identity URIs associated with this account.
+\t\t\t\tEach must indicate this service as the authorization endpoint.
+\t\t\t</div>
+\t\t\t<br>
+\t\t\t<label for="profile">Profile URL:</label>
+\t\t\t<input type="url" id="profile" name="profile" size="96">
+\t\t\t<button name="action" value="new-profile">Add Profile</button>
+\t\t</fieldset>
+\t</form>
+</section>
+<section>
+\t<h2>Scopes</h2>
+\t<form action="" method="POST">
+\t\t<details>
+\t\t<summary>
+\t\tScopes Associated with Profiles for Convenience
+\t\t</summary>
+\t\t\t<fieldset>
+\t\t\t\t<legend>Manage Additional Profile Scope Availability</legend>
+\t\t\t\t<div>
+\t\t\t\t\tThis table lists pre-defined scopes which you can choose to add to any authorization request, whether the client requested them or not.
+\t\t\t\t\tSelecting one for a profile makes it conveniently available for quick inclusion when authorizing a client request.
+\t\t\t\t\tAny scope not in this table or not selected for a profile can always be added in the ad hoc field on the authorization request.
+\t\t\t\t</div>
+\t\t\t\t<br>
+\t\t${scopeIndexTable(ctx.profilesScopes.scopeIndex, ctx.profilesScopes.profiles)}
+\t\t\t\t<button name="action" value="save-scopes">Save</button>
+\t\t\t</fieldset>
+\t\t</form>
+\t\t<br>
+\t\t<form action="" method="POST">
+\t\t\t<fieldset>
+\t\t\t\t<legend>Add New Scope</legend>
+\t\t\t\t<label for="scope">Scope:</label>
+\t\t\t\t<input type="text" id="scope" name="scope">
+\t\t\t\t<label for="description">Description:</label>
+\t\t\t\t<input type="text" id="description" name="description">
+\t\t\t\t<label for="application">Application:</label>
+\t\t\t\t<input type="text" id="application" name="application">
+\t\t\t\t<button name="action" value="new-scope">Add Scope</button>
+\t\t\t</fieldset>
+\t\t</details>
+\t</form>
+</section>
+<section>
+\t<h2>Tokens</h2>
+\t<form action="" method="POST">
+${tokenTable(ctx.tokens)}
+\t</form>
+</section>`;
+}
+
+
+/**
+ * 
+ * @param {Object} ctx
+ * @param {Object} ctx.profilesScopes.scopeIndex
+ * @param {String[]} ctx.profilesScopes.profiles
+ * @param {Object[]} ctx.tokens
+ * @param {Object} options
+ * @param {Object} options.manager
+ * @param {String} options.manager.pageTitle
+ * @param {String} options.manager.logoUrl
+ * @param {String[]} options.manager.footerEntries
+ * @returns {String}
+ */
+module.exports = (ctx, options) => {
+  const htmlOptions = {
+    pageTitle: options.manager.pageTitle,
+    logoUrl: options.manager.logoUrl,
+    footerEntries: options.manager.footerEntries,
+    navLinks: [
+      {
+        text: 'Ticket',
+        href: 'ticket',
+      },
+    ],
+  };
+  const content = [
+    mainContent(ctx),
+  ];
+  return th.htmlPage(1, ctx, htmlOptions, content);
+};
diff --git a/src/template/admin-maintenance-html.js b/src/template/admin-maintenance-html.js
new file mode 100644 (file)
index 0000000..031eb30
--- /dev/null
@@ -0,0 +1,86 @@
+'use strict';
+
+const th = require('./template-helper');
+
+function renderAlmanacRow(entry) {
+  const { event, date } = entry;
+  return `<tr>
+\t<td>${event}</td>
+\t<td>${th.timeElement(date, { title: 'Occurred' })}</td>
+</tr>`;
+}
+
+function almanacSection(almanac) {
+  return `<section>
+\t<h2>Almanac</h2>
+\t<table>
+\t\t<thead>
+\t\t\t<th>Event</th>
+\t\t\t<th>Date</th>
+\t\t</thead>
+\t\t<tbody>
+${almanac.map((entry) => renderAlmanacRow(entry)).join('\n')}
+\t\t</tbody>
+\t<table>
+</section>`;
+}
+
+function renderChoreRow(choreName, choreDetails) {
+  const { intervalMs, nextSchedule } = choreDetails;
+  return `<tr>
+\t<td>${choreName}</td>
+\t<td>${th.secondsToPeriod(Math.ceil(intervalMs / 1000))}</td>
+\t<td>${th.timeElement(nextSchedule)}</td>
+</tr>`;
+}
+
+function choresSection(chores) {
+  return `<section>
+\t<h2>Chores</h2>
+\t<table>
+\t\t<thead>
+\t\t\t<th>Chore</th>
+\t\t\t<th>Frequency</th>
+\t\t\t<th>Next Run</th>
+\t\t</thead>
+\t\t<tbody>
+${Object.entries(chores).map((chore) => renderChoreRow(...chore)).join('\n')}
+\t\t</tbody>
+\t<table>
+</section>`;
+}
+
+/**
+ * 
+ * @param {Object} ctx
+ * @param {Object[]} ctx.almanac
+ * @param {Object} ctx.chores
+ * @param {Object} options
+ * @param {Object} options.manager
+ * @param {String} options.manager.pageTitle
+ * @param {String[]} options.manager.footerEntries
+ * @param {String} options.adminContactHTML
+ * @returns {String}
+ */
+module.exports = (ctx, options) => {
+  const htmlOptions = {
+    pageTitle: options.manager.pageTitle + ' - Maintenance',
+    logoUrl: options.manager.logoUrl,
+    footerEntries: options.manager.footerEntries,
+    navLinks: [
+      {
+        text: 'Admin',
+        href: '.',
+      },
+      {
+        text: 'Ticket',
+        href: './ticket',
+      },
+    ],
+  };
+  const content = [
+    almanacSection(ctx.almanac || []),
+    choresSection(ctx.chores || {}),
+  ];
+  return th.htmlPage(1, ctx, htmlOptions, content);
+};
\ No newline at end of file
diff --git a/src/template/admin-ticket-html.js b/src/template/admin-ticket-html.js
new file mode 100644 (file)
index 0000000..492c40d
--- /dev/null
@@ -0,0 +1,98 @@
+'use strict';
+
+/**
+ * This renders the interface for submitting a ticket proffer to a third-party.
+ */
+
+const th = require('./template-helper');
+
+
+function renderProfileOption(profile) {
+  return `<option value="${profile}">${profile}</option>`;
+}
+
+function renderScopeCheckboxTR(scope) {
+  const defaultChecked = ['read'];
+  const checked = defaultChecked.includes(scope) ? ' checked' : '';
+  return `<tr class="scope">
+\t<td><input type="checkbox" id="scopes-${scope}" name="scopes" value="${scope}"${checked}></td>
+\t<td>${scope}</td>
+</tr>`;
+}
+
+function mainContent(ctx) {
+  const profileOptions = th.indented(4, (ctx?.profilesScopes?.profiles || []).map((profile) => renderProfileOption(profile)))
+    .join('\n');
+  const elideScopes = ['profile', 'email'];
+  const allScopes = Object.keys(ctx?.profilesScopes?.scopeIndex || {});
+  const displayScopes = (allScopes).filter((scope) => !elideScopes.includes(scope));
+  const scopesCheckboxRows = th.indented(4, displayScopes.map((scope) => renderScopeCheckboxTR(scope)))
+    .join('\n');
+  return `<section>
+\t<form action="" method="POST">
+\t\t<div>
+\t\t\tYou may proactively send a ticket to a third-party site,
+\t\t\twhich they may redeem for an access token which grants additional
+\t\t\taccess to the specified resource.
+\t\t</div>
+\t\t<br>
+\t\t<fieldset>
+\t\t\t<legend>Proffer A Ticket</legend>
+\t\t\t<label for="profile-select">Profile Granting this Ticket</label>
+\t\t\t<select id="profile-select" name="profile">
+${profileOptions}
+\t\t\t</select>
+\t\t\t<br>
+\t\t\t<label for="resource-url">Resource URL:</label>
+\t\t\t<input type="url" id="resource-url" name="resource" size="96">
+\t\t\t<br>
+\t\t\t<label for="recipient-url">Recipient URL:</label>
+\t\t\t<input type="url" id="recipient-url" name="subject" size="96">
+\t\t\t<br>
+<fieldset>
+<legend>Scopes</legend>
+\t\t\t<table>
+${scopesCheckboxRows}
+\t\t\t</table>
+</fieldset>
+\t\t\t<br>
+\t\t\t<label for="scopes-adhoc">Additional Scopes (space separated):</label>
+\t\t\t<input type="text" id="scopes-adhoc" name="adhoc" size="96">
+\t\t\t<br>
+\t\t\t<button name="action" value="proffer-ticket">Send Ticket</button>
+\t\t</fieldset>
+\t</form>
+</section>`;
+}
+
+
+/**
+ * 
+ * @param {Object} ctx
+ * @param {Object} ctx.profilesScopes.scopeIndex
+ * @param {String[]} ctx.profileScopes.profiles
+ * @param {Object} options
+ * @param {Object} options.manager
+ * @param {String} options.manager.pageTitle
+ * @param {String} options.manager.logoUrl
+ * @param {String[]} options.manager.footerEntries
+ * @returns {String}
+ */
+module.exports = (ctx, options) => {
+  const htmlOptions = {
+    pageTitle: options.manager.pageTitle + ' - Ticket Proffer',
+    logoUrl: options.manager.logoUrl,
+    footerEntries: options.manager.footerEntries,
+    navLinks: [
+      {
+        text: 'Admin',
+        href: '../admin/',
+      },
+    ],
+    errorContent: ['Unable to send ticket.'],
+  };
+  const content = [
+    mainContent(ctx),
+  ];
+  return th.htmlPage(2, ctx, htmlOptions, content);
+};
\ No newline at end of file
diff --git a/src/template/authorization-error-html.js b/src/template/authorization-error-html.js
new file mode 100644 (file)
index 0000000..ee2e4f2
--- /dev/null
@@ -0,0 +1,26 @@
+'use strict';
+
+const th = require('./template-helper');
+
+
+/**
+ * 
+ * @param {Object} ctx
+ * @param {Object} ctx.session
+ * @param {String=} ctx.session.error
+ * @param {String[]=} ctx.session.errorDescriptions
+ * @param {Object} options
+ * @param {Object} options.manager
+ * @param {String} options.manager.pageTitle
+ * @param {String} options.manager.footerEntries
+ * @returns {String}
+ */
+module.exports = (ctx, options) => {
+  const htmlOptions = {
+    pageTitle: options.manager.pageTitle,
+    logoUrl: options.manager.logoUrl,
+    footerEntries: options.manager.footerEntries,
+    errorContent: ctx.errorContent || ['Unknown Error'],
+  };
+  return th.htmlPage(0, ctx, htmlOptions, []);
+};
\ No newline at end of file
diff --git a/src/template/authorization-request-html.js b/src/template/authorization-request-html.js
new file mode 100644 (file)
index 0000000..3e312af
--- /dev/null
@@ -0,0 +1,376 @@
+'use strict';
+
+const th = require('./template-helper');
+
+
+/**
+ * @param {Object} hApp
+ * @param {Object} hApp.properties
+ * @param {String[]=} hApp.properties.url
+ * @param {String[]=} hApp.properties.summary
+ * @param {String[]=} hApp.properties.logo
+ * @param {String[]=} hApp.properties.name
+ * @returns {String}
+ */
+function renderClientIdentifierProperties(hApp) {
+  const properties = hApp.properties || {};
+  const parts = [];
+  let imgTitle = '';
+  const { url, summary, logo, name } = properties;
+
+  parts.push('<span class="client-identifier">');
+  if (url && url.length) {
+    parts.push(`<a href="${url[0]}">`);
+  }
+  if (summary && summary.length) {
+    imgTitle = ` title="${summary[0]}"`;
+  }
+  if (logo && logo.length) {
+    let src, alt;
+    if (typeof logo[0] === 'string') {
+      src = logo[0];
+      alt = 'Client Identifier Logo';
+    } else {
+      ({ value: src, alt } = logo[0]);
+    }
+    parts.push(`<img src="${src}" alt="${alt}"${imgTitle}>`);
+  }
+  if (name && name.length) {
+    parts.push(properties['name'][0]);
+  }
+  if (url && url.length) {
+    parts.push('</a>');
+  }
+  parts.push('</span>');
+  return parts.join('');
+}
+
+
+/**
+ * @param {Object} clientIdentifier 
+ * @param {Object[]} clientIdentifier.items
+ * @returns {String}
+ */
+function renderClientIdentifier(clientIdentifier) {
+  const hAppEntries = clientIdentifier && clientIdentifier.items || [];
+  return hAppEntries.map(renderClientIdentifierProperties).join('');
+}
+
+
+/**
+ * @param {String} profile
+ * @param {Boolean} selected
+ * @returns {String}
+ */
+function renderProfileOption(profile, selected) {
+  return `<option value="${profile}"${selected ? ' selected' : ''}>${profile}</option>`;
+}
+
+
+/**
+ * @param {String[]} availableProfiles
+ * @param {String} hintProfile
+ * @returns {String}
+ */
+function renderProfileFieldset(availableProfiles, hintProfile) {
+  if (!availableProfiles || availableProfiles.length <= 1) {
+    const profile = availableProfiles && availableProfiles[0] || hintProfile;
+    return `<input type="hidden" name="me" value="${profile}">`;
+  }
+  return `
+  <br>
+  <fieldset>
+    <legend>Select Profile</legend>
+    <div>
+      You may choose to identify to this client with a different profile.
+    </div>
+    <label for="me">Choose your identifying profile</label>
+    <select class="uri" name="me" id="me">
+${availableProfiles.map((profile) => renderProfileOption(profile, profile === hintProfile)).join('\n')}
+    </select>
+  </fieldset>`;
+}
+
+
+/**
+ * @param {ScopeDetails} scope
+ * @param {String} scope.scope
+ * @param {String} scope.description
+ * @param {String[]} scope.profiles
+ * @param {Boolean} checked
+ * @returns {String}
+ */
+function renderScopeCheckboxLI(scope, checked) {
+  let scopeDescription;
+  if (scope.description) {
+    scopeDescription = `
+          <span class="description">${scope.description}</span>`;
+  } else {
+    scopeDescription = '';
+  }
+  let profileClass;
+  if (scope.profiles && scope.profiles.length) {
+    profileClass = ['profile-scope'].concat(scope.profiles.map((profile) => th.escapeCSS(profile))).join(' ');
+  } else {
+    profileClass = '';
+  }
+  return `
+        <li class="${profileClass}">
+          <input type="checkbox" id="scope_${scope.scope}" name="accepted_scopes" value="${scope.scope}"${checked ? ' checked' : ''}>
+          <label for="scope_${scope.scope}">${scope.scope}</label>${scopeDescription}
+        </li>`;
+}
+
+
+function renderRequestedScopes(requestedScopes) {
+  if (!requestedScopes || !requestedScopes.length) {
+    return '';
+  }
+  return `
+  <br>
+  <fieldset>
+    <legend>Grants Requested By Client</legend>
+    <div>
+      In addition to identifying you by your profile URL, this client has requested the following additional scope thingies.  You may disable any of them here.
+    </div>
+    <ul class="scope" id="requested-scope-list">
+${requestedScopes.map((scopeDetails) => renderScopeCheckboxLI(scopeDetails, true)).join('\n')}
+    </ul>
+  </fieldset>`;
+}
+
+/**
+ * @param {ScopeDetails[]} additionalScopes
+ * @returns {String}
+ */
+function renderAdditionalScopes(additionalScopes) {
+  const parts = [];
+  parts.push(`
+  <br>
+  <fieldset>
+    <legend>Additional Grants</legend>`);
+  if (additionalScopes?.length) {
+    parts.push(`
+    <div>
+      Your profile has been configured to offer scopes which were not explicitly requested by the client application.
+      Select any you would like to include.
+    </div>
+    <ul class="scope" id="additional-scope-list">
+${additionalScopes.map((scopeDetails) => renderScopeCheckboxLI(scopeDetails, false)).join('\n')}
+    </ul>
+    <br>`);
+  }
+  parts.push(`
+    <div>
+      You may also specify a space-separated list of any additional ad hoc scopes you would like to associate with this authorization request, which were not explicitly requested by the client application.
+    </div>
+    <label for="ad-hoc-scopes">Ad Hoc Scopes</label>
+    <input id="ad-hoc-scopes" name="ad_hoc_scopes" value="">
+  </fieldset>`);
+  return parts.join('');
+}
+
+
+/**
+ * 
+ */
+function renderExpiration(requestedScopes) {
+  const tokenableScopes = requestedScopes.filter((s) => !['profile', 'email'].includes(s));
+  if (!tokenableScopes.length) {
+    return '';
+  }
+  return `
+\t<br>
+\t<fieldset>
+\t\t<legend>Expiration</legend>
+\t\t<div>
+\t\t\tBy default, tokens issued do not automatically expire, but a longevity can be enforced.
+\t\t</div>
+\t\t<br>
+\t\t<details>
+\t\t\t<summary>Set Expiration</summary>
+\t\t\t<div>
+\t\t\t\t${radioButton('expires', 'never', 'Never', true)}
+\t\t\t</div>
+\t\t\t<div>
+\t\t\t\t${radioButton('expires', '1d', '1 Day')}
+\t\t\t</div>
+\t\t\t<div>
+\t\t\t\t${radioButton('expires', '1w', '1 Week')}
+\t\t\t</div>
+\t\t\t<div>
+\t\t\t\t${radioButton('expires', '1m', '1 Month')}
+\t\t\t</div>
+\t\t\t<div>
+\t\t\t\t${radioButton('expires', 'custom', 'Other:')}
+\t\t\t\t<input type="number" id="expires-seconds" name="expires-seconds">
+\t\t\t\t<label for="expires-seconds">seconds</label>
+\t\t\t</div>
+\t\t\t<br>
+\t\t\t<div>
+\t\t\t\tTokens with expirations may be allowed to be renewed for a fresh token for an amount of time after they expire.
+\t\t\t</div>
+\t\t\t<div>
+\t\t\t\t${radioButton('refresh', 'none', 'Not Refreshable', true)}
+\t\t\t</div>
+\t\t\t<div>
+\t\t\t\t${radioButton('refresh', '1d', '1 Day')}
+\t\t\t</div>
+\t\t\t<div>
+\t\t\t\t${radioButton('refresh', '1w', '1 Week')}
+\t\t\t</div>
+\t\t\t<div>
+\t\t\t\t${radioButton('refresh', '1m', '1 Month')}
+\t\t\t<div>
+\t\t\t\t${radioButton('refresh', 'custom', 'Other:')}
+\t\t\t\t<input type="number" id="refresh-seconds" name="refresh-seconds">
+\t\t\t\t<label for="refresh-seconds">seconds</label>
+\t\t\t </div>
+\t\t</details>
+\t</fieldset>`;
+}
+
+function radioButton(name, value, label, checked = false, indent = 0) {
+  const id = `${name}-${value}`;
+  return th.indented(indent, [
+    '<div>',
+    `\t<input type="radio" name="${name}" id="${id}" value="${value}"${checked ? ' checked' : ''}>`,
+    `\t<label for="${id}">${label}</label>`,
+    '</div>',
+  ]).join('');
+}
+
+/**
+ * 
+ * @param {Object} ctx
+ * @param {Object[]} ctx.notifications
+ * @param {Object} ctx.session
+ * @param {String[]=} ctx.session.scope
+ * @param {URL=} ctx.session.me
+ * @param {String[]} ctx.session.profiles
+ * @param {ScopeIndex} ctx.session.scopeIndex
+ * @param {Object} ctx.session.clientIdentifier
+ * @param {Object[]} ctx.session.clientIdentifier.items
+ * @param {Object} ctx.session.clientIdentifier.items.properties
+ * @param {String[]=} ctx.session.clientIdentifier.items.properties.url
+ * @param {String[]=} ctx.session.clientIdentifier.items.properties.summary
+ * @param {String[]=} ctx.session.clientIdentifier.items.properties.logo
+ * @param {String[]=} ctx.session.clientIdentifier.items.properties.name
+ * @param {String} ctx.session.clientId
+ * @param {String} ctx.session.persist
+ * @param {String} ctx.session.redirectUri
+ * @param {Object} options
+ * @returns {String}
+ */
+function mainContent(ctx, options) { // eslint-disable-line no-unused-vars
+  const session = ctx.session || {};
+  const hintedProfile = (session.me && session.me.href) || (session.profiles && session.profiles.length && session.profiles[0]) || '';
+  const scopeIndex = session.scopeIndex || {};
+
+  // Add requested scopes to index, if not already present,
+  // and de-associate requested scopes from profiles.
+  const scopes = session.scope || [];
+  scopes.forEach((scopeName) => {
+    if ((scopeName in scopeIndex)) {
+      scopeIndex[scopeName].profiles = []; // eslint-disable-line security/detect-object-injection
+    } else {
+      scopeIndex[scopeName] = { // eslint-disable-line security/detect-object-injection
+        description: '',
+        profiles: [],
+      };
+    }
+  });
+
+  // Divide scopes between requested and additional from profiles.
+  const requestedScopes = scopes.map((scope) => ({
+    scope,
+    description: scopeIndex[scope].description, // eslint-disable-line security/detect-object-injection
+  }));
+  const additionalScopes = Object.keys(scopeIndex)
+    .filter((scope) => scopeIndex[scope].profiles.length) // eslint-disable-line security/detect-object-injection
+    .map((scope) => ({
+      scope,
+      description: scopeIndex[scope].description, // eslint-disable-line security/detect-object-injection
+      profiles: scopeIndex[scope].profiles, // eslint-disable-line security/detect-object-injection
+    }));
+
+  return [
+    `<section class="information">
+\tThe application client
+\t${renderClientIdentifier(session.clientIdentifier)}
+\tat <a class="uri" name="${session.clientId}">${session.clientId}</a> would like to identify you as <a class="uri" name="${hintedProfile}">${hintedProfile}</a>.
+</section>
+<section class="choices">
+\t<form action="consent" method="POST" class="form-consent">`,
+    renderProfileFieldset(session.profiles, hintedProfile),
+    renderRequestedScopes(requestedScopes),
+    renderAdditionalScopes(additionalScopes),
+    renderExpiration(requestedScopes),
+    `
+\t\t<br>
+\t\t<fieldset>
+\t\t\t<legend>Do you want to allow this?</legend>
+\t\t\t<button class="button-accept" name="accept" value="true">Accept</button>
+\t\t\t<button class="button-decline" name="accept" value="false">Decline</button>
+\t\t</fieldset>
+\t\t<input type="hidden" name="session" value="${session.persist}">
+\t</form>
+\t<br>
+\t<div>
+\t\tYou will be redirected to <a class="uri" name="${session.redirectUri}">${session.redirectUri}</a>.
+\t</div>
+</section>`,
+  ];
+}
+
+/**
+ * 
+ * @param {Object} ctx
+ * @param {Object} ctx.session
+ * @param {String[]=} ctx.session.scope
+ * @param {URL=} ctx.session.me
+ * @param {String[]} ctx.session.profiles
+ * @param {ScopeIndex} ctx.session.scopeIndex
+ * @param {Object} ctx.session.clientIdentifier
+ * @param {String} ctx.session.clientId
+ * @param {String} ctx.session.persist
+ * @param {String} ctx.session.redirectUri
+ * @param {Object} options
+ * @param {Object} options.manager
+ * @param {String} options.manager.pageTitle
+ * @param {String} options.manager.footerEntries
+ * @returns {String}
+ */
+module.exports = (ctx, options) => {
+  const htmlOptions = {
+    pageTitle: `${options.manager.pageTitle} &mdash; Authorization Request`,
+    logoUrl: options.manager.logoUrl,
+    footerEntries: options.manager.footerEntries,
+    headElements: [
+      `<script>
+function queryAll(query, fn) {
+  const nodes = document.querySelectorAll(query);
+  console.log('query ' + query + ' selected ' + nodes.length);
+  return nodes.forEach(fn);
+}
+function profileSelected(element) {
+  const profileClass = CSS.escape(element.value);
+  console.log('new profile:', element.value, profileClass);
+  queryAll('.profile-scope input', (n) => n.setAttribute('disabled', true));
+  queryAll('.profile-scope', (n) => n.classList.add('disabled'));
+  const profileQuery = '.profile-scope.' + profileClass;
+  queryAll(profileQuery + ' input', (n) => n.setAttribute('disabled', false));
+  queryAll(profileQuery, (n) => n.classList.remove('disabled'));
+}
+function onLoad() {
+  return; // The escaped class selection does not seem to work, so ignore it all for now.
+  const profileSelect = document.getElementById('me');
+  profileSelect.onchange = () => profileSelected(profileSelect);
+  profileSelected(profileSelect);
+}
+</script>`,
+    ],
+  };
+  const content = mainContent(ctx, options);
+  return th.htmlPage(0, ctx, htmlOptions, content);
+};
diff --git a/src/template/index.js b/src/template/index.js
new file mode 100644 (file)
index 0000000..1278a7d
--- /dev/null
@@ -0,0 +1,10 @@
+'use strict';
+
+module.exports = {
+  adminHTML: require('./admin-html'),
+  adminTicketHTML: require('./admin-ticket-html'),
+  adminMaintenanceHTML: require('./admin-maintenance-html'),
+  authorizationRequestHTML: require('./authorization-request-html'),
+  authorizationErrorHTML: require('./authorization-error-html'),
+  rootHTML: require('./root-html'),
+};
\ No newline at end of file
diff --git a/src/template/root-html.js b/src/template/root-html.js
new file mode 100644 (file)
index 0000000..7baaa41
--- /dev/null
@@ -0,0 +1,63 @@
+'use strict';
+
+const th = require('./template-helper');
+
+function aboutSection() {
+  return `
+      <section class="about">
+        <h2>What</h2>
+        <p>
+          This is an <a class="external" href="https://indieweb.org/IndieAuth">IndieAuth</a> service.
+        </p>
+        <p>
+          It facilitates distributed authentication.
+        </p>
+        <p>
+          If you are not an established user of this service, or some sort of web application, there is very little here for you.
+        </p>
+      </section>`;
+}
+
+function contactSection(contactHTML) {
+  let section = '';
+  if (contactHTML) {
+    section = `      <section>
+${contactHTML}
+      </section>`;
+  }
+  return section;
+}
+
+/**
+ * 
+ * @param {Object} ctx
+ * @param {Object} options
+ * @param {Object} options.manager
+ * @param {String} options.manager.pageTitle
+ * @param {String[]} options.manager.footerEntries
+ * @param {String} options.adminContactHTML
+ * @returns {String}
+ */
+module.exports = (ctx, options) => {
+  const contactHTML = options.adminContactHTML;
+  const htmlOptions = {
+    pageTitle: options.manager.pageTitle,
+    logoUrl: options.manager.logoUrl,
+    footerEntries: options.manager.footerEntries,
+    navLinks: [
+      {
+        text: 'Admin',
+        href: 'admin/',
+      },
+      {
+        text: 'Ticket',
+        href: 'admin/ticket',
+      },
+    ],
+  };
+  const content = [
+    aboutSection(),
+    contactSection(contactHTML),
+  ];
+  return th.htmlPage(1, ctx, htmlOptions, content);
+};
\ No newline at end of file
diff --git a/src/template/template-helper.js b/src/template/template-helper.js
new file mode 100644 (file)
index 0000000..a33c8b0
--- /dev/null
@@ -0,0 +1,53 @@
+'use strict';
+
+const { TemplateHelper } = require('@squeep/html-template-helper');
+
+
+/**
+ * Escape a string to be suitable as a CSS name.
+ * @param {String} unsafeName 
+ * @returns {String}
+ */
+function escapeCSS(unsafeName) {
+  return unsafeName.replace(/([^0-9a-zA-Z-])/g, '\\$1');
+}
+
+
+/**
+ * Given a pair of Array tuples containing scope names and scope details,
+ * return the comparison between the two, for sorting.
+ * Scopes are sorted such that they are grouped by application, then name.
+ * Empty applications are sorted ahead of extant applications.
+ * @param {Array} a
+ * @param {Array} b
+ * @returns {Number}
+ */
+function scopeCompare([aScope, aDetails], [bScope, bDetails]) {
+  const { application: aApp } = aDetails;
+  const { application: bApp } = bDetails;
+  if ((aApp || bApp) && (aApp !== bApp)) {
+    if (!aApp) {
+      return -1;
+    }
+    if (!bApp) {
+      return 1;
+    }
+    if (aApp > bApp) {
+      return 1;
+    }
+    return -1;
+  }
+
+  if (aScope > bScope) {
+    return 1;
+  } else if (aScope < bScope) {
+    return -1;
+  }
+  return 0;
+}
+
+
+module.exports = Object.assign(Object.create(TemplateHelper), {
+  escapeCSS,
+  scopeCompare,
+});
\ No newline at end of file
diff --git a/static/.external-link.svg.meta b/static/.external-link.svg.meta
new file mode 100644 (file)
index 0000000..5be8006
--- /dev/null
@@ -0,0 +1,2 @@
+Source: https://commons.wikimedia.org/wiki/File:VisualEditor_-_Icon_-_External-link.svg
+License: https://commons.wikimedia.org/wiki/Category:Expat/MIT_License
diff --git a/static/.favicon.ico.meta b/static/.favicon.ico.meta
new file mode 100644 (file)
index 0000000..1d975d0
--- /dev/null
@@ -0,0 +1,3 @@
+Source: https://thenounproject.com/term/passport/1878030/
+License: https://creativecommons.org/licenses/by/3.0/us/legalcode
+Author: https://madexmade.com/
diff --git a/static/.logo.svg.meta b/static/.logo.svg.meta
new file mode 100644 (file)
index 0000000..1d975d0
--- /dev/null
@@ -0,0 +1,3 @@
+Source: https://thenounproject.com/term/passport/1878030/
+License: https://creativecommons.org/licenses/by/3.0/us/legalcode
+Author: https://madexmade.com/
diff --git a/static/Makefile b/static/Makefile
new file mode 100644 (file)
index 0000000..fa512e5
--- /dev/null
@@ -0,0 +1,14 @@
+.PHONY: all
+
+SOURCES = theme.css favicon.ico logo.svg
+TARGETS = $(SOURCES:=.gz) $(SOURCES:=.br)
+
+all: $(TARGETS)
+
+%.br: %
+       brotli --verbose --no-copy-stat --keep --force "$<"
+
+%.gz: %
+       cp "$<" "$<".tmp
+       gzip "$<".tmp
+       mv "$<".tmp.gz "$@"
diff --git a/static/custom.css b/static/custom.css
new file mode 100644 (file)
index 0000000..7a7a218
--- /dev/null
@@ -0,0 +1,6 @@
+header {
+        background: linear-gradient(0deg, rgba(255,255,255,0) 0%, rgb(230, 230, 230) 100%);
+}
+footer {
+        background: linear-gradient(180deg, rgba(255,255,255,0) 0%, rgb(230, 230, 230) 100%);
+}
diff --git a/static/external-link.svg b/static/external-link.svg
new file mode 100644 (file)
index 0000000..ae7d45a
--- /dev/null
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="12px"
+        height="12px" viewBox="0 0 12 12" style="enable-background:new 0 0 12 12;" xml:space="preserve">
+<g id="Icons" style="opacity:0.75;">
+       <g id="external">
+               <polygon id="box" style="fill-rule:evenodd;clip-rule:evenodd;" points="2,2 5,2 5,3 3,3 3,9 9,9 9,7 10,7 10,10 2,10              "/>
+               <polygon id="arrow_13_" style="fill-rule:evenodd;clip-rule:evenodd;" points="6.211,2 10,2 10,5.789 8.579,4.368 6.447,6.5
+                       5.5,5.553 7.632,3.421           "/>
+       </g>
+</g>
+<g id="Guides" style="display:none;">
+</g>
+</svg>
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644 (file)
index 0000000..67b2e3b
Binary files /dev/null and b/static/favicon.ico differ
diff --git a/static/index.html b/static/index.html
new file mode 100644 (file)
index 0000000..9437f20
--- /dev/null
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+       <meta charset="utf-8">
+       <title>Static Assets</title>
+       <link rel="stylesheet" href="theme.css">
+</head>
+<body>
+       <header>
+               <h1>Static Assets</h1>
+       </header>
+       <main>
+               welcome to my static
+       </main>
+</body>
+</html>
diff --git a/static/logo.svg b/static/logo.svg
new file mode 100644 (file)
index 0000000..8d6cd8d
--- /dev/null
@@ -0,0 +1 @@
+<svg height='300px' width='300px'  fill="#000000" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve"><path d="M74.4033203,9.9516602H25.4296875c-3.5458984,0-6.4306641,2.8842773-6.4306641,6.4296875v67.140625  c0,3.5454102,2.8847656,6.4296875,6.4306641,6.4296875h48.9736328c3.5449219,0,6.4296875-2.8842773,6.4296875-6.4296875v-67.140625  C80.8330078,12.8359375,77.9482422,9.9516602,74.4033203,9.9516602z M78.8330078,83.5219727  c0,2.4423828-1.9873047,4.4296875-4.4296875,4.4296875H25.4296875c-2.4433594,0-4.4306641-1.9873047-4.4306641-4.4296875v-67.140625  c0-2.4423828,1.9873047-4.4296875,4.4306641-4.4296875h48.9736328c2.4423828,0,4.4296875,1.9873047,4.4296875,4.4296875V83.5219727z  "></path><path d="M54.5175781,40.3291016h15.9443359c0.5527344,0,1-0.4477539,1-1s-0.4472656-1-1-1H54.5175781c-0.5527344,0-1,0.4477539-1,1  S53.9648438,40.3291016,54.5175781,40.3291016z"></path><path d="M54.5175781,21.3291016h15.9443359c0.5527344,0,1-0.4477539,1-1s-0.4472656-1-1-1H54.5175781c-0.5527344,0-1,0.4477539-1,1  S53.9648438,21.3291016,54.5175781,21.3291016z"></path><path d="M54.5175781,30.8291016h15.9443359c0.5527344,0,1-0.4477539,1-1s-0.4472656-1-1-1H54.5175781c-0.5527344,0-1,0.4477539-1,1  S53.9648438,30.8291016,54.5175781,30.8291016z"></path><path d="M73.8515625,48.5791016h-2.4833984c-0.5527344,0-1,0.4477539-1,1s0.4472656,1,1,1h2.4833984c0.5527344,0,1-0.4477539,1-1  S74.4042969,48.5791016,73.8515625,48.5791016z"></path><path d="M66.2871094,48.5791016h-2.4833984c-0.5527344,0-1,0.4477539-1,1s0.4472656,1,1,1h2.4833984c0.5527344,0,1-0.4477539,1-1  S66.8398438,48.5791016,66.2871094,48.5791016z"></path><path d="M56.2392578,48.5791016c-0.5527344,0-1,0.4477539-1,1s0.4472656,1,1,1h2.4833984c0.5527344,0,1-0.4477539,1-1  s-0.4472656-1-1-1H56.2392578z"></path><path d="M48.6738281,50.5791016h2.484375c0.5527344,0,1-0.4477539,1-1s-0.4472656-1-1-1h-2.484375c-0.5527344,0-1,0.4477539-1,1  S48.1210938,50.5791016,48.6738281,50.5791016z"></path><path d="M41.109375,48.5791016c-0.5527344,0-1,0.4477539-1,1s0.4472656,1,1,1h2.484375c0.5527344,0,1-0.4477539,1-1  s-0.4472656-1-1-1H41.109375z"></path><path d="M37.0292969,49.5791016c0-0.5522461-0.4472656-1-1-1h-2.484375c-0.5527344,0-1,0.4477539-1,1s0.4472656,1,1,1h2.484375  C36.5820312,50.5791016,37.0292969,50.1313477,37.0292969,49.5791016z"></path><path d="M28.4648438,48.5791016h-2.484375c-0.5527344,0-1,0.4477539-1,1s0.4472656,1,1,1h2.484375c0.5527344,0,1-0.4477539,1-1  S29.0175781,48.5791016,28.4648438,48.5791016z"></path><path d="M47.0966797,39.5V20.3291016c0-0.5522461-0.4472656-1-1-1H29.6894531c-0.5527344,0-1,0.4477539-1,1V39.5  c0,0.5522461,0.4472656,1,1,1h16.4072266C46.6494141,40.5,47.0966797,40.0522461,47.0966797,39.5z M45.0966797,38.5H30.6894531  V21.3291016h14.4072266V38.5z"></path><path d="M65.4990234,65.6132812c0.3759766-0.2441406,0.5429688-0.7080078,0.4101562-1.1352539  c-0.1337891-0.4277344-0.5361328-0.7368164-0.9814453-0.7026367c-1.6650391,0.0356445-3.2128906-0.7075195-4.140625-2.0092773  c-0.8515625-1.1943359-1.0478516-2.6577148-0.5390625-4.0151367c0.1552734-0.4140625,0.0205078-0.8803711-0.3320312-1.1479492  c-0.3515625-0.2661133-0.8369141-0.2714844-1.1943359-0.0102539c-1.3017578,0.949707-3.0029297,1.2104492-4.5605469,0.6967773  c-1.4824219-0.4902344-2.5849609-1.5849609-3.0244141-3.0029297c-0.1298828-0.418457-0.5166016-0.7036133-0.9550781-0.7036133  s-0.8251953,0.2851562-0.9550781,0.7036133c-0.4394531,1.4179688-1.5419922,2.5126953-3.0244141,3.0029297  c-1.5546875,0.5141602-3.2587891,0.2543945-4.5605469-0.6967773c-0.3554688-0.2612305-0.8408203-0.2558594-1.1943359,0.0102539  c-0.3525391,0.2675781-0.4873047,0.7338867-0.3320312,1.1479492c0.5087891,1.3574219,0.3125,2.8208008-0.5390625,4.015625  c-0.9277344,1.3017578-2.4746094,2.0444336-4.1396484,2.0087891c-0.4433594-0.0332031-0.8476562,0.2749023-0.9814453,0.7026367  c-0.1328125,0.4272461,0.0341797,0.8911133,0.4101562,1.1352539c1.3183594,0.8579102,2.0751953,2.2138672,2.0742188,3.7202148  c0,1.5058594-0.7558594,2.8613281-2.0742188,3.7192383c-0.3759766,0.2441406-0.5429688,0.7080078-0.4101562,1.1352539  c0.1337891,0.4272461,0.5371094,0.7109375,0.9814453,0.7026367c1.6630859-0.0249023,3.2119141,0.7075195,4.1396484,2.0087891  c0.8515625,1.1948242,1.0478516,2.6582031,0.5390625,4.015625c-0.1552734,0.4140625-0.0205078,0.8803711,0.3320312,1.1474609  c0.3535156,0.2680664,0.8388672,0.2724609,1.1943359,0.0107422c1.3007812-0.9501953,3.0019531-1.2109375,4.5605469-0.6962891  c1.4824219,0.4902344,2.5849609,1.5849609,3.0244141,3.0029297c0.1298828,0.418457,0.5166016,0.7036133,0.9550781,0.7036133  s0.8251953-0.2851562,0.9550781-0.7036133c0.4394531-1.4179688,1.5419922-2.5126953,3.0244141-3.0029297  c1.5576172-0.5146484,3.2607422-0.2539062,4.5605469,0.6962891c0.3574219,0.2617188,0.8427734,0.2573242,1.1943359-0.0107422  c0.3525391-0.2670898,0.4873047-0.7333984,0.3320312-1.1474609c-0.5087891-1.3574219-0.3125-2.8208008,0.5390625-4.0151367  c0.9277344-1.3022461,2.4755859-2.0458984,4.140625-2.0092773c0.4453125,0.0087891,0.8476562-0.2753906,0.9814453-0.7026367  c0.1328125-0.4272461-0.0341797-0.8911133-0.4101562-1.1352539c-1.3183594-0.8579102-2.0742188-2.2133789-2.0742188-3.7192383  C63.4238281,67.8271484,64.1806641,66.4711914,65.4990234,65.6132812z M62.7402344,73.1884766  c-1.4384766,0.4326172-2.703125,1.3168945-3.5820312,2.550293c-0.8027344,1.1269531-1.2060547,2.4194336-1.1904297,3.7294922  c-1.4189453-0.4643555-2.9746094-0.472168-4.4345703,0.0097656c-1.3876953,0.4594727-2.5439453,1.2998047-3.3515625,2.4052734  c-0.8076172-1.1054688-1.9638672-1.9458008-3.3515625-2.4052734c-0.7304688-0.2416992-1.4853516-0.3598633-2.234375-0.3598633  c-0.7480469,0-1.4912109,0.1181641-2.2001953,0.3500977c0.015625-1.3105469-0.3876953-2.6025391-1.1904297-3.7294922  c-0.8789062-1.2333984-2.1435547-2.1176758-3.5810547-2.550293c0.84375-1.1030273,1.3154297-2.4482422,1.3154297-3.8549805  c0.0009766-1.4072266-0.4716797-2.7529297-1.3164062-3.855957c1.4384766-0.4326172,2.703125-1.3168945,3.5820312-2.550293  c0.8027344-1.1264648,1.2060547-2.4189453,1.1904297-3.7294922c1.4199219,0.465332,2.9755859,0.4726562,4.4345703-0.0092773  c1.3876953-0.4594727,2.5439453-1.2993164,3.3515625-2.4052734c0.8076172,1.105957,1.9638672,1.9458008,3.3515625,2.4052734  c1.4589844,0.4819336,3.0146484,0.4746094,4.4345703,0.0092773c-0.015625,1.3105469,0.3876953,2.6025391,1.1904297,3.7294922  c0.8798828,1.2333984,2.1435547,2.1176758,3.5820312,2.550293c-0.84375,1.1035156-1.3164062,2.4487305-1.3154297,3.855957  C61.4248047,70.7402344,61.8964844,72.0854492,62.7402344,73.1884766z"></path><path d="M50.1816406,61.0014648c-4.5947266,0-8.3320312,3.7373047-8.3320312,8.331543s3.7373047,8.3320312,8.3320312,8.3320312  s8.3320312-3.737793,8.3320312-8.3320312S54.7763672,61.0014648,50.1816406,61.0014648z M50.1816406,75.6650391  c-3.4912109,0-6.3320312-2.840332-6.3320312-6.3320312c0-3.4912109,2.8408203-6.331543,6.3320312-6.331543  s6.3320312,2.840332,6.3320312,6.331543C56.5136719,72.824707,53.6728516,75.6650391,50.1816406,75.6650391z"></path></svg>
\ No newline at end of file
diff --git a/static/robots.txt b/static/robots.txt
new file mode 100644 (file)
index 0000000..1f53798
--- /dev/null
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /
diff --git a/static/theme.css b/static/theme.css
new file mode 100644 (file)
index 0000000..837151d
--- /dev/null
@@ -0,0 +1,175 @@
+html {
+       height: 100vh;
+}
+body {
+       background-color: #fff;
+       font-family: Helvetica, Verdana, sans-serif;
+       margin: 0 1em 0 1em;
+       min-height: 100vh;
+       display: flex;
+       flex-direction: column;
+}
+header {}
+header nav {
+       margin-bottom: 1em;
+}
+header nav ol {
+       list-style-type: none;
+       margin: 0;
+       padding: 0;
+}
+header nav ol li {
+       display: inline;
+       text-align: center;
+       border-top: 2px solid #666;
+       border-bottom: 2px solid #666;
+       border-left: 1px solid #666;
+       border-right: 1px solid #666;
+       padding: .3em .5em .2em .5em;
+}
+header nav ol li:hover {
+       background-color: #ddd;
+}
+header nav ol > li:first-child {
+       border-left: 2px solid #666;
+}
+header nav ol > li:last-child {
+       border-right: 2px solid #666;
+}
+header nav ol a {
+       font-variant-caps: small-caps;
+       text-decoration: none;
+       font-weight: bold;
+}
+h1 {
+       margin-top: 1em;
+       margin-bottom: 1.25em;
+       text-align: center;
+}
+h2 {
+       background-color: #ddd;
+       padding: .25em 0 .1em 0.25em;
+}
+main {
+       flex-grow: 1;
+}
+section {}
+.logo {
+       vertical-align: middle;
+       height: 2em;
+}
+.about {}
+.usage {}
+.copyright {
+       font-size: small;
+}
+.error ul {
+       font-weight: bolder;
+       border: 2px solid red;
+       padding: 1em;
+       background-color: lightsalmon;
+}
+.notification ul {
+       background-color: aliceblue;
+       border: 1px solid slateblue;
+       padding: 1em;
+}
+.legacy-warning {
+       background-color: coral;
+}
+.external {
+       background-image: url("external-link.svg");
+       background-position: right center;
+       background-repeat: no-repeat;
+       padding-right: 13px;
+}
+.information img {
+       max-width: 4em;
+       max-height: 4em;
+       width: 100%;
+       height: auto;
+       vertical-align: middle;
+}
+.uri {
+       font-family: Courier, monospace, serif;
+       font-size: 1em;
+       background-color: lavender;
+       padding: .16em;
+}
+.code {
+       font-family: Courier, monospace, serif;
+       font-size: .75em;
+       white-space: nowrap;
+       overflow-x: hidden;
+}
+.client-identifier {
+       display: inline-block;
+       height: max-content;
+       padding: .5em;
+       border: 1px dotted #999;
+       margin-bottom: .5em;
+}
+.scope {
+       list-style-type: none;
+}
+.scope label {
+       font-variant: small-caps;
+       font-weight: bold;
+}
+.scope .description {
+       font-size: smaller;
+       font-style: italic;
+}
+.scope .disabled {
+       color: grey;
+       background-color: #eee;
+}
+.form-consent button {
+       border-width: thick;
+       font-size: x-large;
+       padding: .5em;
+       margin-left: .75em;
+       margin-right: .75em;
+}
+.button-accept {
+       background-color: lightgreen;
+       border-color: lightgreen;
+}
+.button-decline {
+       background-color: salmon;
+       border-color: salmon;
+}
+.vertical {
+       writing-mode: vertical-lr;
+       vertical-align: bottom;
+}
+table {
+       border: 0;
+       width: 100%;
+}
+thead tr th {
+       background-color: #ddd;
+       vertical-align: bottom;
+       text-align: start;
+}
+tbody tr th {
+       text-align: start;
+}
+tbody tr:nth-child(even) td, tbody tr:nth-child(even) th {
+       background-color: #eee;
+}
+tbody tr:nth-child(odd) td, tbody tr:nth-child(odd) th {}
+footer {
+       text-align: center;
+       width: 100%;
+       border-top: .33em dotted #666;
+       margin-top: 1em;
+}
+footer ol {
+       list-style-type: none;
+       margin: .5em;
+       padding: 0;
+}
+.centered {
+       text-align: center;
+}
\ No newline at end of file
diff --git a/test/config.js b/test/config.js
new file mode 100644 (file)
index 0000000..f2f8018
--- /dev/null
@@ -0,0 +1,23 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const Config = require('../config');
+
+describe('Config', function () {
+  it('covers default environment', function () {
+    const config = new Config();
+    assert.strictEqual(config.environment, 'development');
+    assert(Object.isFrozen(config));
+  });
+  it('covers default environment, unfrozen', function () {
+    const config = new Config(undefined, false);
+    assert.strictEqual(config.environment, 'development');
+    assert(!Object.isFrozen(config));
+  });
+  it('covers test environment', function () {
+    const config = new Config('test');
+    assert.strictEqual(config.environment, 'test');
+    assert(!Object.isFrozen(config));
+  });
+}); // Config
\ No newline at end of file
diff --git a/test/src/chores.js b/test/src/chores.js
new file mode 100644 (file)
index 0000000..51c015c
--- /dev/null
@@ -0,0 +1,140 @@
+/* eslint-env mocha */
+/* eslint-disable node/no-unpublished-require */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon');
+const StubDB = require('../stub-db');
+const StubLogger = require('../stub-logger');
+const Chores = require('../../src/chores');
+
+const snooze = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+const expectedException = new Error('oh no');
+
+describe('Chores', function () {
+  let chores, stubLogger, stubDb, options;
+  beforeEach(function () {
+    stubLogger = new StubLogger();
+    stubLogger._reset();
+    stubDb = new StubDB();
+    stubDb._reset();
+  });
+  afterEach(function () {
+    chores?.stopAllChores();
+    sinon.restore();
+  });
+
+  describe('constructor', function () {
+
+    it('empty options, no cleaning', async function () {
+      options = undefined;
+      chores = new Chores(stubLogger, stubDb, options);
+      assert.strictEqual(chores.chores.cleanTokens.timeoutObj, undefined);
+      assert.strictEqual(chores.chores.cleanScopes.timeoutObj, undefined);
+    });
+
+    it('cleans scopes', async function () {
+      options = {
+        chores: {
+          scopeCleanupMs: 1,
+        },
+      };
+      chores = new Chores(stubLogger, stubDb, options);
+      await snooze(50);
+      assert(chores.chores.cleanScopes.timeoutObj);
+      assert(chores.db.scopeCleanup.called);
+    });
+
+    it('cleans tokens', async function () {
+      options = {
+        chores: {
+          tokenCleanupMs: 1,
+        },
+        manager: {
+          codeValidityTimeoutMs: 10,
+        },
+      };
+      chores = new Chores(stubLogger, stubDb, options);
+      await snooze(50);
+      assert(chores.chores.cleanTokens.timeoutObj);
+      assert(chores.db.tokenCleanup.called);
+    });
+
+  }); // constructor
+
+  describe('cleanTokens', function () {
+    it('logs cleaning', async function () {
+      const cleaned = 10;
+      options = {
+        chores: {
+          tokenCleanupMs: 100,
+        },
+        manager: {
+          codeValidityTimeoutMs: 10,
+        },
+      };
+      stubDb.tokenCleanup.resolves(cleaned);
+      chores = new Chores(stubLogger, stubDb, options);
+      clearTimeout(chores.cleanTokensTimeout);
+      await chores.cleanTokens();
+      assert(stubLogger.info.called);
+    });
+    it('covers failure', async function () {
+      options = {
+        chores: {
+          tokenCleanupMs: 1,
+        },
+        manager: {
+          codeValidityTimeoutMs: 10,
+        },
+      };
+      stubDb.tokenCleanup.rejects(expectedException);
+      chores = new Chores(stubLogger, stubDb, options);
+      await assert.rejects(() => chores.cleanTokens(), expectedException);
+    });
+    it('covers default', async function () {
+      stubDb.tokenCleanup.resolves(0);
+      chores = new Chores(stubLogger, stubDb, {
+        manager: {
+          codeValidityTimeoutMs: 10,
+        },
+      });
+      await chores.cleanTokens();
+      assert(stubDb.tokenCleanup.called);
+    });
+  }); // cleanTokens
+
+  describe('cleanScopes', function () {
+    it('logs cleaning', async function () {
+      const cleaned = 10;
+      options = {
+        chores: {
+          scopeCleanupMs: 100,
+        },
+      };
+      stubDb.scopeCleanup.resolves(cleaned);
+      chores = new Chores(stubLogger, stubDb, options);
+      clearTimeout(chores.cleanScopesTimeout);
+      await chores.cleanScopes();
+      assert(stubLogger.info.called);
+    });
+    it('covers failure', async function () {
+      options = {
+        chores: {
+          scopeCleanupMs: 1,
+        },
+      };
+      stubDb.scopeCleanup.rejects(expectedException);
+      chores = new Chores(stubLogger, stubDb, options);
+      await assert.rejects(() => chores.cleanScopes(), expectedException);
+    });
+    it('covers default', async function () {
+      stubDb.scopeCleanup.resolves(0);
+      chores = new Chores(stubLogger, stubDb, {});
+      await chores.cleanScopes();
+      assert(stubDb.scopeCleanup.called);
+    });
+  }); // cleanScopes
+
+}); // Chores
\ No newline at end of file
diff --git a/test/src/common.js b/test/src/common.js
new file mode 100644 (file)
index 0000000..1115f0a
--- /dev/null
@@ -0,0 +1,181 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const common = require('../../src/common');
+
+describe('Common', function () {
+  
+  describe('camelfy', function () {
+    it('covers', function () {
+      const snake = 'snake_case';
+      const expected = 'snakeCase';
+      const result = common.camelfy(snake);
+      assert.strictEqual(result, expected);
+    });
+    it('covers edge-cases', function () {
+      const kebab = '-kebab-case-';
+      const expected = 'KebabCase';
+      const result = common.camelfy(kebab, '-');
+      assert.strictEqual(result, expected);
+    });
+    it('covers empty input', function () {
+      const empty = '';
+      const expected = undefined;
+      const result = common.camelfy(empty);
+      assert.strictEqual(result, expected);
+    });
+    it('covers un-camelfiable input', function () {
+      const bad = {};
+      const expected = undefined;
+      const result = common.camelfy(bad);
+      assert.strictEqual(result, expected);
+    });
+  }); // camelfy
+
+  describe('freezeDeep', function () {
+    it('freezes things', function () {
+      const obj = {
+        sub1: {
+          sub2: {
+            foo: 'blah',
+          },
+        },
+      };
+      const result = common.freezeDeep(obj);
+      assert(Object.isFrozen(result));
+      assert(Object.isFrozen(result.sub1));
+      assert(Object.isFrozen(result.sub1.sub2));
+      assert(Object.isFrozen(result.sub1.sub2.foo));
+    });
+  }); // freezeDeep
+
+  describe('axiosResponseLogData', function () {
+    it('covers', function () {
+      const response = {
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'Content-Type': 'text/plain',
+        },
+        otherData: 'blah',
+        data: 'Old Mother West Wind had stopped to talk with the Slender Fir Tree. "I\'ve just come across the Green Meadows," said Old Mother West Wind, “and there I saw the Best Thing in the World.”',
+      };
+      const expected = {
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'Content-Type': 'text/plain',
+        },
+        data: 'Old Mother West Wind had stopped to talk with the Slender Fir Tree. "I\'ve just come across the Green... (184 bytes)',
+      };
+      const result = common.axiosResponseLogData(response);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers no data', function () {
+      const response = {
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'Content-Type': 'text/plain',
+        },
+      };
+      const expected = {
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'Content-Type': 'text/plain',
+        },
+      };
+      const result = common.axiosResponseLogData(response);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // axiosResponseLogData
+
+  describe('logTruncate', function () {
+    it('returns short string', function () {
+      const str = 'this is a short string';
+      const result = common.logTruncate(str, 100);
+      assert.strictEqual(result, str);
+    });
+    it('truncates long string', function () {
+      const str = 'this is not really a very long string but it is long enough for this test';
+      const result = common.logTruncate(str, 10);
+      assert(result.length < str.length);
+    });
+  }); // logTruncate
+
+  describe('ensureArray', function () {
+    it('returns empty array for no data', function () {
+      const result = common.ensureArray();
+      assert.deepStrictEqual(result, []);
+    });
+    it('returns same array passed in', function () {
+      const expected = [1, 2, 3, 'foo'];
+      const result = common.ensureArray(expected);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('returns array containing non-array data', function () {
+      const data = 'bar';
+      const result = common.ensureArray(data);
+      assert.deepStrictEqual(result, [data]);
+    });
+  }); // ensureArray
+
+  describe('validError', function () {
+    it('covers valid', function () {
+      const result = common.validError('error');
+      assert.strictEqual(result, true);
+    });
+    it('covers invalid', function () {
+      const result = common.validError('🐔');
+      assert.strictEqual(result, false);
+    });
+    it('covers empty', function () {
+      const result = common.validError();
+      assert.strictEqual(result, false);
+    });
+  }); // validError
+
+  describe('validScope', function () {
+    it('covers valid', function () {
+      const result = common.validScope('scope');
+      assert.strictEqual(result, true);
+    });
+    it('covers invalid', function () {
+      const result = common.validScope('🐔');
+      assert.strictEqual(result, false);
+    });
+    it('covers empty', function () {
+      const result = common.validScope();
+      assert.strictEqual(result, false);
+    });
+  }); // validScope
+
+  describe('newSecret', function () {
+    it('covers default', async function () {
+      const result = await common.newSecret();
+      assert(result.length);
+    });
+    it('covers specified', async function () {
+      const result = await common.newSecret(21);
+      assert(result.length);
+    });
+  }); // newSecret
+
+  describe('dateToEpoch', function () {
+    it('covers no supplied date', function () {
+      const nowMs = Date.now() / 1000;
+      const result = common.dateToEpoch();
+      const drift = Math.abs(result - nowMs);
+      assert(drift < 2000);
+    });
+    it('covers supplied date', function () {
+      const now = new Date();
+      const nowEpoch = Math.ceil(now / 1000);
+      const result = common.dateToEpoch(now);
+      assert.strictEqual(result, nowEpoch);
+    });
+  }); // dateToEpoch
+
+}); // Common
diff --git a/test/src/db/abstract.js b/test/src/db/abstract.js
new file mode 100644 (file)
index 0000000..9d7dbf9
--- /dev/null
@@ -0,0 +1,295 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+
+const StubDatabase = require('../../stub-db');
+const StubLogger = require('../../stub-logger');
+const DB = require('../../../src/db/abstract');
+const DBErrors = require('../../../src/db/errors');
+
+describe('DatabaseBase', function () {
+  let db, logger, stubDb;
+  before(function () {
+    logger = new StubLogger();
+    logger._reset();
+    stubDb = new StubDatabase();
+  });
+  beforeEach(function () {
+    db = new DB(logger, {});
+  });
+  afterEach(function () {
+    sinon.restore();
+  });
+  
+  it('covers no options', function () {
+    db = new DB();
+  });
+
+  describe('Interface', function () {
+    it('covers abstract methods', async function () {
+      await Promise.all(stubDb._implementation.map(async (m) => {
+        try {
+          // eslint-disable-next-line security/detect-object-injection
+          await db[m]();
+          assert.fail(`${m}: did not catch NotImplemented exception`);
+        } catch (e) {
+          assert(e instanceof DBErrors.NotImplemented, `${m}: unexpected exception ${e.name}`);
+        }
+      }));
+    }); // covers abstract methods
+    it('covers private abstract methods', async function () {
+      [
+      ].map((m) => {
+        try {
+          // eslint-disable-next-line security/detect-object-injection
+          db[m]();
+        } catch (e) {
+          assert(e instanceof DBErrors.NotImplemented, `${m}: unexpected exception ${e.name}`);
+        }
+      });
+    });
+  }); // Interface
+
+  describe('_isUUID', function () {
+    it('is a uuid', function () {
+      const result = DB._isUUID('8fde351e-2d63-11ed-8b0c-0025905f714a');
+      assert.strictEqual(result, true);
+    });
+    it('is not a uuid', function () {
+      const result = DB._isUUID('not a uuid');
+      assert.strictEqual(result, false);
+    });
+  });
+
+  describe('_isInfinites', function () {
+    it('is true for Infinity', function () {
+      const result = DB._isInfinites(Infinity);
+      assert.strictEqual(result, true);
+    });
+    it('is true for negative Infinity', function () {
+      const result = DB._isInfinites(-Infinity);
+      assert.strictEqual(result, true);
+    });
+    it('is false for finite value', function () {
+      const result = DB._isInfinites(5);
+      assert.strictEqual(result, false);
+    });
+    it('is false for NaN', function () {
+      const result = DB._isInfinites(NaN);
+      assert.strictEqual(result, false);
+    });
+  });
+
+  describe('_ensureTypes', function () {
+    let object;
+    beforeEach(function () {
+      object = {
+        array: ['foo', 'bar'],
+        bignum: BigInt(456),
+        buf: Buffer.from('foop'),
+        date: new Date(),
+        num: 123,
+        obj: {},
+        str: 'some words',
+        uuid: 'a4dd5106-2d64-11ed-a2ba-0025905f714a',
+        veryNull: null,
+      };
+    });
+    it('succeeds', function () {
+      db._ensureTypes(object, ['array'], ['array']);
+      db._ensureTypes(object, ['bignum', 'num'], ['number']);
+      db._ensureTypes(object, ['buf'], ['buffer']);
+      db._ensureTypes(object, ['date'], ['date']);
+      db._ensureTypes(object, ['str', 'veryNull'], ['string', 'null']);
+    });
+    it('data failure', function () {
+      try {
+        db._ensureTypes(object, ['missingField'], ['string', 'null']);
+        assert.fail('validation should have failed');
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+    it('failure covers singular', function () {
+      try {
+        db._ensureTypes(object, ['missingField'], ['string']);
+        assert.fail('validation should have failed');
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+    it('parameter failure', function () {
+      try {
+        db._ensureTypes(object, ['missingField'], undefined);
+        assert.fail('validation should have failed');
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+  }); // _ensureTypes
+
+  describe('_validateAuthentication', function () {
+    let authentication;
+    beforeEach(function () {
+      authentication = {
+        identifier: 'username',
+        credential: '$plain$secret',
+        created: new Date(),
+        lastAuthenticated: -Infinity,
+      };
+    });
+    it('covers', function () {
+      db._validateAuthentication(authentication);
+    });
+    it('covers failure', function () {
+      assert.throws(() => db._validateAuthentication(undefined), DBErrors.DataValidation);
+    });
+  }); // _validateAuthentication
+
+  describe('_validateResource', function () {
+    let resource;
+    beforeEach(function () {
+      resource = {
+        resourceId: '42016c1e-2d66-11ed-9e10-0025905f714a',
+        secret: 'secretSecret',
+        description: 'Some other service',
+        created: new Date(),
+      };
+    });
+    it('covers', function () {
+      db._validateResource(resource);
+    });
+    it('covers failure', function () {
+      assert.throws(() => db._validateResource(undefined), DBErrors.DataValidation);
+    });
+  }); // _validateResource
+
+  describe('_validateToken', function () {
+    let token;
+    beforeEach(function () {
+      token = {
+        codeId: '9efc7882-2d66-11ed-b03c-0025905f714a',
+        profile: 'https://profile.example.com/',
+        resource: null,
+        clientId: 'https://app.example.com/',
+        created: new Date(),
+        expires: new Date(),
+        refreshExpires: null,
+        refreshed: null,
+        isToken: true,
+        isRevoked: false,
+        scopes: ['scope'],
+        profileData: {
+          name: 'User von Namey',
+        },
+      };
+    });
+    it('covers', function () {
+      db._validateToken(token);
+    });
+    it('covers failure', function () {
+      assert.throws(() => db._validateToken(undefined), DBErrors.DataValidation);
+    });
+  }); // _validateToken
+
+  describe('_profilesScopesBuilder', function () {
+    it('covers empty', function () {
+      const result = DB._profilesScopesBuilder();
+      assert.deepStrictEqual(result, {
+        profileScopes: {},
+        scopeIndex: {},
+        profiles: [],
+      });
+    });
+    it('builds expected structure', function () {
+      const profileScopesRows = [
+        { profile: 'https://scopeless.example.com/', scope: null, description: null, application: null, isPermanent: null, isManuallyAdded: null },
+        { profile: 'https://profile.example.com/', scope: 'role:private', description: 'level', application: '', isPermanent: false, isManuallyAdded: true },
+        { profile: null, scope: 'profile', description: 'profile', application: 'IndieAuth', isPermanent: true, isManuallyAdded: false },
+        { profile: null, scope: 'role:private', description: 'level', application: '', isPermanent: false, isManuallyAdded: true },
+        { profile: null, scope: 'read', description: 'read', application: 'MicroPub', isPermanent: true, isManuallyAdded: false },
+        { profile: 'https://profile.example.com/', scope: 'profile', description: 'profile', application: 'IndieAuth', isPermanent: true, isManuallyAdded: false },
+        { profile: 'https://another.example.com/', scope: 'profile', description: 'profile', application: 'IndieAuth', isPermanent: true, isManuallyAdded: false },
+      ];
+      const expected = {
+        profileScopes: {
+          'https://scopeless.example.com/': {},
+          'https://profile.example.com/': {},
+          'https://another.example.com/': {},
+        },
+        scopeIndex: {
+          'role:private': {
+            description: 'level',
+            application: '',
+            isPermanent: false,
+            isManuallyAdded: true,
+            profiles: ['https://profile.example.com/'],
+          },
+          'profile': {
+            description: 'profile',
+            application: 'IndieAuth',
+            isPermanent: true,
+            isManuallyAdded: false,
+            profiles: ['https://profile.example.com/', 'https://another.example.com/'],
+          },
+          'read': {
+            description: 'read',
+            application: 'MicroPub',
+            isPermanent: true,
+            isManuallyAdded: false,
+            profiles: [],
+          },
+        },
+        profiles: ['https://scopeless.example.com/', 'https://profile.example.com/', 'https://another.example.com/'],
+      };
+      expected.profileScopes['https://profile.example.com/']['role:private'] = expected.scopeIndex['role:private'];
+      expected.profileScopes['https://profile.example.com/']['profile'] = expected.scopeIndex['profile'];
+      expected.profileScopes['https://another.example.com/']['profile'] = expected.scopeIndex['profile'];
+
+      const result = DB._profilesScopesBuilder(profileScopesRows);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // _profilesScopesBuilder
+
+  describe('initialize', function () {
+    let currentSchema;
+    beforeEach(function () {
+      currentSchema = {
+        major: 1,
+        minor: 0,
+        patch: 0,
+      };
+      db.schemaVersionsSupported = {
+        min: { ...currentSchema },
+        max: { ...currentSchema },
+      };
+      sinon.stub(db, '_currentSchema').resolves(currentSchema);
+    });
+    it('covers success', async function () {
+      await db.initialize();
+    });
+    it('covers failure', async function() {
+      db.schemaVersionsSupported = {
+        min: {
+          major: 3,
+          minor: 2,
+          patch: 1,
+        },
+        max: {
+          major: 5,
+          minor: 0,
+          patch: 0,
+        },
+      };
+      try {
+        await db.initialize();
+        assert.fail('did not get expected exception');
+      } catch (e) {
+        assert(e instanceof DBErrors.MigrationNeeded);
+      }
+    });
+  }); // initialize
+
+}); // DatabaseBase
diff --git a/test/src/db/factory.js b/test/src/db/factory.js
new file mode 100644 (file)
index 0000000..7b9402c
--- /dev/null
@@ -0,0 +1,49 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+const common = require('../../../src/common');
+const DB = require('../../../src/db');
+const DBErrors = require('../../../src/db/errors');
+const DatabasePostgres = require('../../../src/db/postgres');
+const DatabaseSQLite = require('../../../src/db/sqlite');
+
+describe('DatabaseFactory', function () {
+  let logger, options;
+  beforeEach(function () {
+    logger = common.nullLogger;
+    options = {
+      db: {
+        connectionString: '',
+      },
+    };
+  });
+  afterEach(function () {
+    sinon.restore();
+  });
+  it('gets engines', function () {
+    const result = DB.Engines;
+    assert(result instanceof Object);
+    assert(Object.keys(result).length);
+  });
+  it('creates postgres db', function () {
+    options.db.connectionString = 'postgresql://blah';
+    const db = new DB(logger, options);
+    assert(db instanceof DatabasePostgres);
+  });
+  it('creates sqlite db', function () {
+    options.db.connectionString = 'sqlite://:memory:';
+    const db = new DB(logger, options);
+    assert(db instanceof DatabaseSQLite);
+  });
+  it('handles missing db', function () {
+    delete options.db.connectionString;
+    try {
+      new DB(logger, options);
+      assert.fail('did not get expected exception');
+    } catch (e) {
+      assert(e instanceof DBErrors.UnsupportedEngine);
+    }
+  });
+}); // DatabaseFactory
diff --git a/test/src/db/integration.js b/test/src/db/integration.js
new file mode 100644 (file)
index 0000000..7634b30
--- /dev/null
@@ -0,0 +1,503 @@
+/* eslint-env mocha */
+/* eslint-disable sonarjs/no-identical-functions */
+'use strict';
+
+/**
+ * These are LIVE FIRE tests to exercise actual database operations.
+ * They should be configured to use local test databases, as they
+ * perform DESTRUCTIVE ACTIONS on all tables, beginning with a COMPLETE
+ * DATA WIPE.
+ * 
+ * They will only run if all the appropriate environmental settings exist:
+ * - INTEGRATION_TESTS must be set
+ * - <ENGINE>_TEST_PATH must point to the endpoint/db
+ * 
+ * These tests are sequential, relying on the state created along the way.
+ * 
+ */
+
+const assert = require('assert');
+const { step } = require('mocha-steps'); // eslint-disable-line node/no-unpublished-require
+const StubLogger = require('../../stub-logger');
+// const DBErrors = require('../../../src/db/errors');
+// const testData = require('../../test-data/db-integration');
+
+describe('Database Integration', function () {
+  const implementations = [];
+
+  if (!process.env.INTEGRATION_TESTS) {
+    it.skip('integration tests not requested');
+    return;
+  }
+
+  if (process.env.POSTGRES_TEST_PATH) {
+    implementations.push({
+      name: 'PostgreSQL',
+      module: '../../../src/db/postgres',
+      config: {
+        db: {
+          connectionString: `postgresql://${process.env.POSTGRES_TEST_PATH}`,
+          queryLogLevel: 'debug',
+          noWarnings: true,
+        },
+      },
+    });
+  }
+
+  if (process.env.SQLITE_TEST_PATH) {
+    implementations.push({
+      name: 'SQLite',
+      module: '../../../src/db/sqlite',
+      config: {
+        db: {
+          connectionString: `sqlite://${process.env.SQLITE_TEST_PATH}`,
+          queryLogLevel: 'debug',
+          sqliteOptimizeAfterChanges: 10,
+        },
+      },
+    });
+  }
+
+  if (!implementations.length) {
+    it('have some implementations to test', function () {
+      assert.fail('No implementations have been configured for requested integration tests');
+    });
+  }
+
+  implementations.forEach(function (i) {
+    describe(i.name, function () {
+      let logger;
+      let DB, db;
+      let profile, identifier;
+
+      before(async function () {
+        this.timeout(10 * 1000); // Allow some time for creating tables et cetera.
+        logger = new StubLogger();
+        logger._reset();
+        // eslint-disable-next-line security/detect-non-literal-require
+        DB = require(i.module);
+        db = new DB(logger, i.config);
+        await db.initialize();
+        await db._purgeTables(true);
+      });
+      after(async function () {
+        await db._closeConnection();
+      });
+
+      beforeEach(function () {
+        identifier = 'username';
+        profile = 'https://example.com/profile';
+      });
+
+      describe('Healthcheck', function () {
+        it('should succeed', async function () {
+          const result = await db.healthCheck();
+          assert(result);
+        });
+      });
+
+      describe('Resources', function () {
+        let resourceId, secret, description;
+        before(function () {
+          secret = 'shared secret';
+          description = 'A resource server that needs to verify our tokens.';
+        });
+        step('returns nothing when resource does not exist', async function () {
+          await db.context(async (dbCtx) => {
+            const badResourceId = 'f1669969-c87e-46f8-83bb-a6712981d15d';
+            const result = await db.resourceGet(dbCtx, badResourceId);
+            assert(!result);
+          });
+        });
+        step('creates resource', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.resourceUpsert(dbCtx, undefined, secret, description);
+            assert(result.resourceId);
+            resourceId = result.resourceId;
+          });
+        });
+        step('gets resource', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.resourceGet(dbCtx, resourceId);
+            assert.strictEqual(result.secret, secret);
+            db._validateResource(result);
+          });
+        });
+        step('updates resource', async function () {
+          await db.context(async (dbCtx) => {
+            secret = 'new shared secret';
+            description = 'Still a resource server, but with a new description.';
+            await db.resourceUpsert(dbCtx, resourceId, secret, description);
+            const result = await db.resourceGet(dbCtx, resourceId);
+            assert.strictEqual(result.resourceId, resourceId);
+            assert.strictEqual(result.secret, secret);
+            assert.strictEqual(result.description, description);
+          });
+        });
+      }); // Resources
+
+      describe('Users and Profiles and Scopes', function () {
+        let credential;
+        beforeEach(function () {
+          credential = '$plain$myPassword';
+        });
+        step('returns nothing when auth does not exist', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.authenticationGet(dbCtx, identifier);
+            assert(!result);
+          });
+        });
+        step('create auth entry', async function () {
+          await db.context(async (dbCtx) => {
+            await db.authenticationUpsert(dbCtx, identifier, credential);
+          });
+        });
+        step('get auth entry', async function () {
+          await db.context(async (dbCtx) => {
+            const authInfo = await db.authenticationGet(dbCtx, identifier);
+            assert.strictEqual(authInfo.credential, credential);
+            db._validateAuthentication(authInfo);
+          });
+        });
+        step('valid auth event', async function () {
+          await db.context(async (dbCtx) => {
+            await db.authenticationSuccess(dbCtx, identifier);
+            const authInfo = await db.authenticationGet(dbCtx, identifier);
+            db._validateAuthentication(authInfo);
+            assert.notStrictEqual(authInfo.lastAuthentication, undefined);
+          });
+        });
+        step('update auth entry', async function () {
+          await db.context(async (dbCtx) => {
+            credential = '$plain$myNewPassword';
+            await db.authenticationUpsert(dbCtx, identifier, credential);
+            const authInfo = await db.authenticationGet(dbCtx, identifier);
+            assert.strictEqual(authInfo.credential, credential);
+          });
+        });
+        step('profile is not valid', async function () {
+          await db.context(async (dbCtx) => {
+            const isValid = await db.profileIsValid(dbCtx, profile);
+            assert.strictEqual(isValid, false);
+          });
+        });
+        step('user-profile relation does not exist', async function () {
+          await db.context(async (dbCtx) => {
+            const { profiles } = await db.profilesScopesByIdentifier(dbCtx, identifier);
+            const exists = profiles.includes(profile);
+            assert.strictEqual(exists, false);
+          });
+        });
+        step('create user-profile relation', async function () {
+          await db.context(async (dbCtx) => {
+            await db.profileIdentifierInsert(dbCtx, profile, identifier);
+          });
+        });
+        step('profile is valid', async function () {
+          await db.context(async (dbCtx) => {
+            const isValid = await db.profileIsValid(dbCtx, profile);
+            assert.strictEqual(isValid, true);
+          });
+        });
+        step('user-profile relation does exist', async function () {
+          await db.context(async (dbCtx) => {
+            const { profiles } = await db.profilesScopesByIdentifier(dbCtx, identifier);
+            const exists = profiles.includes(profile);
+            assert.strictEqual(exists, true);
+          });
+        });
+        step('create scope', async function () {
+          await db.context(async (dbCtx) => {
+            await db.scopeUpsert(dbCtx, 'new_scope', '', 'Allows something to happen.');
+          });
+        });
+        step('create and delete scope', async function () {
+          await db.context(async (dbCtx) => {
+            await db.scopeUpsert(dbCtx, 'sacrificial', 'No App', 'Exists to be destroyed.', true);
+            const result = await db.scopeDelete(dbCtx, 'sacrificial');
+            assert.strictEqual(result, true);
+          });
+        });
+        step('do not delete in-use scope', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.scopeDelete(dbCtx, 'profile');
+            assert.strictEqual(result, false);
+          });
+        });
+        step('ignore delete of non-existent scope', async function () {
+          await db.context(async (dbCtx) => {
+            await db.scopeDelete(dbCtx, 'non-existent');
+          });
+        });
+        step('assign scope to profile', async function () {
+          const scope = 'new_scope';
+          await db.context(async (dbCtx) => {
+            await db.profileScopeInsert(dbCtx, profile, scope);
+            const { scopeIndex, profileScopes, profiles } = await db.profilesScopesByIdentifier(dbCtx, identifier);
+            const scopeExistsInProfile = scope in profileScopes[profile];
+            const profileExistsInScope = scopeIndex[scope].profiles.includes(profile);
+            const profileExists = profiles.includes(profile);
+            assert.strictEqual(scopeExistsInProfile, true);
+            assert.strictEqual(profileExistsInScope, true);
+            assert.strictEqual(profileExists, true);
+          });
+        });
+        step('update scope', async function () {
+          await db.context(async (dbCtx) => {
+            await db.scopeUpsert(dbCtx, 'new_scope', 'Application', 'Updated description.');
+          });
+        });
+        step('re-assigning scope to profile is ignored', async function () {
+          const scope = 'new_scope';
+          await db.context(async (dbCtx) => {
+            await db.profileScopeInsert(dbCtx, profile, scope);
+            const { scopeIndex, profileScopes } = await db.profilesScopesByIdentifier(dbCtx, identifier);
+            const scopeExistsInProfile = scope in profileScopes[profile];
+            const profileExistsInScope = scopeIndex[scope].profiles.includes(profile);
+            assert.strictEqual(scopeExistsInProfile, true);
+            assert.strictEqual(profileExistsInScope, true);
+          });
+        });
+        step('clear all scopes for a profile', async function () {
+          const scopes = [];
+          await db.context(async (dbCtx) => {
+            await db.profileScopesSetAll(dbCtx, profile, scopes);
+            const { profileScopes } = await db.profilesScopesByIdentifier(dbCtx, identifier);
+            const exists = profile in profileScopes;
+            assert(exists);
+            const numScopes = Object.keys(profileScopes[profile]).length;
+            assert.strictEqual(numScopes, 0);
+          });
+        });
+        step('set multiple scopes for a profile', async function () {
+          const scopes = ['profile', 'email', 'create'];
+          await db.context(async (dbCtx) => {
+            await db.profileScopesSetAll(dbCtx, profile, scopes);
+            const { profileScopes } = await db.profilesScopesByIdentifier(dbCtx, identifier);
+            assert.strictEqual(Object.keys(profileScopes[profile]).length, scopes.length);
+          });
+        });
+        step('garbage-collect client scopes', async function () {
+          await db.context(async (dbCtx) => {
+            await db.scopeUpsert(dbCtx, 'extra_scope', 'useless', 'useless');
+            const result = await db.scopeCleanup(dbCtx, 0);
+            assert(result);
+          });
+        });
+        step('too-soon garbage-collect skips', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.scopeCleanup(dbCtx, 86400000);
+            assert.strictEqual(result, undefined);
+          });
+        });
+      }); // Users and Profiles and Scopes
+
+      describe('Token', function () {
+        let created, codeId, profileCodeId, ticketCodeId, scopes, clientId, lifespanSeconds, resource;
+        beforeEach(function () {
+          created = new Date();
+          codeId = '907a95fc-384b-11ec-a541-0025905f714a';
+          profileCodeId = '93d6314a-384e-11ec-94e4-0025905f714a';
+          ticketCodeId = 'bc5c39a8-5ca0-11ed-94cd-0025905f714a';
+          clientId = 'https://app.example.com/';
+          scopes = ['create', 'email', 'profile'];
+          lifespanSeconds = 600;
+          resource = 'https://example.com/profile/feed';
+        });
+        step('redeems code for token', async function () {
+          await db.context(async (dbCtx) => {
+            lifespanSeconds = null;
+            const result = await db.redeemCode(dbCtx, {
+              created,
+              codeId,
+              isToken: true,
+              clientId,
+              profile,
+              identifier,
+              scopes,
+              lifespanSeconds,
+              refreshLifespanSeconds: null,
+              profileData: null,
+            });
+            assert.strictEqual(result, true);
+            const t = await db.tokenGetByCodeId(dbCtx, codeId);
+            assert(t);
+            db._validateToken(t);
+          });
+        });
+        step('revokes token', async function () {
+          await db.context(async (dbCtx) => {
+            await db.tokenRevokeByCodeId(dbCtx, codeId, identifier);
+            const t = await db.tokenGetByCodeId(dbCtx, codeId);
+            assert.strictEqual(t.isRevoked, true);
+          });
+        });
+        step('redeems code for profile', async function () {
+          await db.context(async (dbCtx) => {
+            await db.redeemCode(dbCtx, {
+              created,
+              codeId: profileCodeId,
+              isToken: false,
+              clientId,
+              profile,
+              identifier,
+              lifespanSeconds,
+              scopes,
+            });
+            const t = await db.tokenGetByCodeId(dbCtx, codeId);
+            assert(t);
+            db._validateToken(t);
+          });
+        });
+        step('redeems ticket', async function () {
+          await db.context(async (dbCtx) => {
+            await db.redeemCode(dbCtx, {
+              created,
+              codeId: ticketCodeId,
+              isToken: true,
+              clientId,
+              resource,
+              profile,
+              identifier,
+              scopes,
+            });
+          });
+        });
+        step('gets tokens', async function () {
+          await db.context(async (dbCtx) => {
+            const tokens = await db.tokensGetByIdentifier(dbCtx, identifier);
+            assert(tokens.length);
+          });
+        });
+        step('revokes multiply-redeemed code', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.redeemCode(dbCtx, {
+              created,
+              codeId,
+              isToken: false,
+              clientId,
+              profile,
+              identifier,
+              scopes,
+            });
+            assert.strictEqual(result, false);
+            const t = await db.tokenGetByCodeId(dbCtx, codeId);
+            assert.strictEqual(t.isRevoked, true);
+          });
+        });
+        step('garbage-collect tokens', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.tokenCleanup(dbCtx, -86400, 0);
+            assert(result);
+          });
+        });
+        step('too-soon garbage-collect skips', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.tokenCleanup(dbCtx, 0, 86400000);
+            assert.strictEqual(result, undefined);
+          });
+        });
+        step('garbage collection is recorded', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.almanacGetAll(dbCtx);
+            assert(result?.length);
+          });
+        });
+      }); // Token
+
+      describe('Refreshable Token', function () {
+        let created, codeId, scopes, clientId, profileData, lifespanSeconds, refreshLifespanSeconds, removeScopes;
+        beforeEach(function () {
+          created = new Date();
+          codeId = '20ff1c5e-24d9-11ed-83b9-0025905f714a';
+          scopes = ['profile', 'email', 'create', 'fancy:scope'];
+          clientId = 'https://app.example.com/';
+          lifespanSeconds = 86400;
+          refreshLifespanSeconds = 172800;
+          profileData = {
+            url: 'https://profile.example.com/',
+            name: 'Namey McUser',
+            photo: 'https://profile.example.com/picture.jpg',
+            email: 'usey@example.com',
+          };
+          removeScopes = [];
+        });
+        step('redeems code for refreshable token', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.redeemCode(dbCtx, {
+              created,
+              codeId,
+              isToken: true,
+              clientId,
+              profile,
+              identifier,
+              scopes,
+              lifespanSeconds,
+              refreshLifespanSeconds,
+              profileData,
+            });
+            assert.strictEqual(result, true);
+            const t = await db.tokenGetByCodeId(dbCtx, codeId);
+            assert(t);
+            db._validateToken(t);
+            const requestedScopesSet = new Set(scopes);
+            const tokenScopesSet = new Set(t.scopes);
+            for (const s of tokenScopesSet) {
+              if (requestedScopesSet.has(s)) {
+                requestedScopesSet.delete(s);
+              } else {
+                requestedScopesSet.add(s);
+              }
+            }
+            assert(!requestedScopesSet.size, [...requestedScopesSet].toString());
+          });
+        });
+        step('refreshes token', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.refreshCode(dbCtx, codeId, new Date(), removeScopes);
+            assert(result);
+            assert(result.expires);
+            assert(result.refreshExpires);
+            assert(!result.scopes);
+          });
+        });
+        step('refreshes token and reduces scope', async function () {
+          await db.context(async (dbCtx) => {
+            removeScopes = ['create', 'fancy:scope'];
+            const result = await db.refreshCode(dbCtx, codeId, new Date(), removeScopes);
+            assert(result);
+            assert(result.scopes);
+            const t = await db.tokenGetByCodeId(dbCtx, codeId);
+            const remainingScopesSet = new Set(scopes);
+            removeScopes.forEach((s) => remainingScopesSet.delete(s));
+            const tokenScopesSet = new Set(t.scopes);
+            for (const s of tokenScopesSet) {
+              if (remainingScopesSet.has(s)) {
+                remainingScopesSet.delete(s);
+              } else {
+                remainingScopesSet.add(s);
+              }
+            }
+            assert(!remainingScopesSet.size, [...remainingScopesSet].toString());
+
+          });
+        });
+        step('revokes token refreshability', async function () {
+          await db.context(async (dbCtx) => {
+            await db.tokenRefreshRevokeByCodeId(dbCtx, codeId);
+            const t = await db.tokenGetByCodeId(dbCtx, codeId);
+            assert(!t.refreshExpires);
+          });
+        });
+        step('token not refreshable', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.refreshCode(dbCtx, codeId, new Date(), removeScopes);
+            assert(!result);
+          });
+        });
+      }); // Refreshable Token
+
+    }); // specific implementation
+  }); // foreach
+
+}); // Database Integration
diff --git a/test/src/db/postgres.js b/test/src/db/postgres.js
new file mode 100644 (file)
index 0000000..02f8986
--- /dev/null
@@ -0,0 +1,953 @@
+/* eslint-disable sonarjs/no-identical-functions */
+/* eslint-env mocha */
+/* eslint-disable sonarjs/no-duplicate-string */
+'use strict';
+
+/* This provides implementation coverage, stubbing pg-promise. */
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+const StubLogger = require('../../stub-logger');
+const StubDatabase = require('../../stub-db');
+const DB = require('../../../src/db/postgres');
+const DBErrors = require('../../../src/db/errors');
+const common = require('../../../src/common');
+const Config = require('../../../config');
+
+const expectedException = new Error('oh no');
+
+describe('DatabasePostgres', function () {
+  let db, logger, options, pgpStub;
+  let dbCtx;
+  before(function () {
+    pgpStub = () => {
+      const stub = {
+        result: () => ({ rows: [] }),
+        all: common.nop,
+        get: common.nop,
+        run: common.nop,
+        one: common.nop,
+        manyOrNone: common.nop,
+        oneOrNone: common.nop,
+        query: common.nop,
+        batch: common.nop,
+        multiResult: common.nop,
+        connect: common.nop,
+      };
+      stub.tx = (fn) => fn(stub);
+      stub.txIf = (fn) => fn(stub);
+      stub.task = (fn) => fn(stub);
+      return stub;
+    };
+    pgpStub.utils = {
+      enumSql: () => ({}),
+    };
+    pgpStub.QueryFile = class {};
+    pgpStub.end = common.nop;
+  });
+  beforeEach(function () {
+    logger = new StubLogger();
+    logger._reset();
+    options = new Config('test');
+    db = new DB(logger, options, pgpStub);
+    dbCtx = db.db;
+  });
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  it('covers no query logging', function () {
+    delete options.db.queryLogLevel;
+    db = new DB(logger, options, pgpStub);
+  });
+
+
+  // Ensure all interface methods are implemented
+  describe('Implementation', function () {
+    it('implements interface', async function () {
+      const stubDb = new StubDatabase();
+      const results = await Promise.allSettled(stubDb._implementation.map(async (fn) => {
+        try {
+          // eslint-disable-next-line security/detect-object-injection
+          await db[fn](db.db);
+        } catch (e) {
+          assert(!(e instanceof DBErrors.NotImplemented), `${fn} not implemented`);
+        }
+      }));
+      const failures = results.filter((x) => x.status === 'rejected');
+      assert(!failures.length, failures.map((x) => {
+        x = x.reason.toString();
+        return x.slice(x.indexOf(': '));
+      }));
+    });
+  }); // Implementation
+
+  describe('pgpInitOptions', function () {
+    describe('error', function () {
+      it('covers', function () {
+        const err = {};
+        const event = {};
+        db.pgpInitOptions.error(err, event);
+        assert(db.logger.error.called);
+      });
+    }); // error
+    describe('query', function () {
+      it('covers', function () {
+        const event = {};
+        db.pgpInitOptions.query(event);
+        assert(db.logger.debug.called);
+      });
+    }); // query
+    describe('receive', function () {
+      it('covers', function () {
+        const data = [
+          {
+            column_one: 'one', // eslint-disable-line camelcase
+            column_two: 2, // eslint-disable-line camelcase
+          },
+          {
+            column_one: 'foo', // eslint-disable-line camelcase
+            column_two: 4, // eslint-disable-line camelcase
+          },
+        ];
+        const result = {};
+        const event = {};
+        const expectedData = [
+          {
+            columnOne: 'one',
+            columnTwo: 2,
+          },
+          {
+            columnOne: 'foo',
+            columnTwo: 4,
+          },
+        ];
+        db.pgpInitOptions.receive(data, result, event)
+        assert(db.logger.debug.called);
+        assert.deepStrictEqual(data, expectedData);
+      });
+      it('covers no query logging', function () {
+        delete options.db.queryLogLevel;
+        db = new DB(logger, options, pgpStub);
+        const data = [
+          {
+            column_one: 'one', // eslint-disable-line camelcase
+            column_two: 2, // eslint-disable-line camelcase
+          },
+          {
+            column_one: 'foo', // eslint-disable-line camelcase
+            column_two: 4, // eslint-disable-line camelcase
+          },
+        ];
+        const result = {};
+        const event = {};
+        const expectedData = [
+          {
+            columnOne: 'one',
+            columnTwo: 2,
+          },
+          {
+            columnOne: 'foo',
+            columnTwo: 4,
+          },
+        ];
+        db.pgpInitOptions.receive(data, result, event)
+        assert(db.logger.debug.called);
+        assert.deepStrictEqual(data, expectedData);
+      });
+
+    }); // receive
+  }); // pgpInitOptions
+
+  describe('_initTables', function () {
+    beforeEach(function () {
+      sinon.stub(db.db, 'oneOrNone');
+      sinon.stub(db.db, 'multiResult');
+      sinon.stub(db, '_currentSchema');
+    });
+
+    it('covers apply', async function() {
+      db.db.oneOrNone.onCall(0).resolves(null).onCall(1).resolves({});
+      db._currentSchema.resolves({ major: 0, minor: 0, patch: 0 });
+      await db._initTables();
+    });
+    it('covers exists', async function() {
+      db.db.oneOrNone.resolves({});
+      db._currentSchema.resolves(db.schemaVersionsSupported.max);
+      await db._initTables();
+    });
+  }); // _initTables
+
+  describe('initialize', function () {
+    after(function () {
+      delete db.listener;
+    });
+    it('passes supported version', async function () {
+      const version = { major: 1, minor: 0, patch: 0 };
+      sinon.stub(db.db, 'one').resolves(version);
+      await db.initialize(false);
+    });
+    it('fails low version', async function () {
+      const version = { major: 0, minor: 0, patch: 0 };
+      sinon.stub(db.db, 'one').resolves(version);
+      await assert.rejects(() => db.initialize(false), DBErrors.MigrationNeeded);
+    });
+    it('fails high version', async function () {
+      const version = { major: 100, minor: 100, patch: 100 };
+      sinon.stub(db.db, 'one').resolves(version);
+      await assert.rejects(() => db.initialize(false));
+    });
+    it('covers migration', async function() {
+      sinon.stub(db.db, 'oneOrNone').resolves({});
+      sinon.stub(db.db, 'multiResult');
+      sinon.stub(db, '_currentSchema').resolves(db.schemaVersionsSupported.max);
+      sinon.stub(db.db, 'one').resolves(db.schemaVersionsSupported.max);
+      await db.initialize();
+    });
+    it('covers listener', async function() {
+      db.listener = {
+        start: sinon.stub(),
+      };
+      const version = { major: 1, minor: 0, patch: 0 };
+      sinon.stub(db.db, 'one').resolves(version);
+      await db.initialize(false);
+      assert(db.listener.start.called);
+    });
+  }); // initialize
+
+  describe('healthCheck', function () {
+    beforeEach(function () {
+      sinon.stub(db.db, 'connect').resolves({
+        done: () => {},
+        client: {
+          serverVersion: '0.0',
+        },
+      });
+    });
+    it('covers', async function () {
+      const result = await db.healthCheck();
+      assert.deepStrictEqual(result, { serverVersion: '0.0' });
+    });
+  }); // healthCheck
+
+  describe('_queryFileHelper', function () {
+    it('covers success', function () {
+      const _queryFile = db._queryFileHelper(pgpStub);
+      _queryFile();
+    });
+    it('covers failure', function () {
+      pgpStub.QueryFile = class {
+        constructor() {
+          this.error = expectedException;
+        }
+      };
+      const _queryFile = db._queryFileHelper(pgpStub);
+      assert.throws(() => _queryFile(), expectedException);
+    });
+  }); // _queryFileHelper
+
+  describe('_closeConnection', function () {
+    after(function () {
+      delete db.listener;
+    });
+    it('success', async function () {
+      sinon.stub(db._pgp, 'end');
+      await db._closeConnection();
+      assert(db._pgp.end.called);
+    });
+    it('failure', async function () {
+      sinon.stub(db._pgp, 'end').throws(expectedException);
+      await assert.rejects(() => db._closeConnection(), expectedException);
+    });
+    it('covers listener', async function () {
+      db.listener = {
+        stop: sinon.stub(),
+      };
+      sinon.stub(db._pgp, 'end');
+      await db._closeConnection();
+      assert(db._pgp.end.called);
+    });
+  }); // _closeConnection
+
+  describe('_purgeTables', function () {
+    it('covers not really', async function () {
+      sinon.stub(db.db, 'tx');
+      await db._purgeTables(false);
+      assert(!db.db.tx.called);
+    });
+    it('success', async function () {
+      sinon.stub(db.db, 'batch');
+      await db._purgeTables(true);
+      assert(db.db.batch.called);
+    });
+    it('failure', async function () {
+      sinon.stub(db.db, 'tx').rejects(expectedException)
+      await assert.rejects(() => db._purgeTables(true), expectedException);
+    });
+  }); // _purgeTables
+
+  describe('context', function () {
+    it('covers', async function () {
+      await db.context(common.nop);
+    });
+  }); // context
+
+  describe('transaction', function () {
+    it('covers', async function () {
+      await db.transaction(db.db, common.nop);
+    });
+  }); // transaction
+
+  describe('almanacGetAll', function () {
+    beforeEach(function () {
+      sinon.stub(db.db, 'manyOrNone');
+    });
+    it('success', async function () {
+      const expected = [{ event: 'someEvent', date: new Date() }];
+      db.db.manyOrNone.resolves(expected);
+      const result = await db.almanacGetAll(dbCtx);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      db.db.manyOrNone.rejects(expectedException);
+      await assert.rejects(() => db.almanacGetAll(dbCtx), expectedException);
+    });
+  }); // almanacGetAll
+
+  describe('authenticationSuccess', function () {
+    let identifier;
+    beforeEach(function () {
+      identifier = 'username';
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.authenticationSuccess(dbCtx, identifier);
+    });
+    it('failure', async function() {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await assert.rejects(() => db.authenticationSuccess(dbCtx, identifier), DBErrors.UnexpectedResult);
+    });
+  }); // authenticationSuccess
+
+  describe('authenticationGet', function () {
+    let identifier, credential;
+    beforeEach(function () {
+      identifier = 'username';
+      credential = '$z$foo';
+    });
+    it('success', async function () {
+      const dbResult = { identifier, credential };
+      sinon.stub(db.db, 'oneOrNone').resolves(dbResult);
+      const result = await db.authenticationGet(dbCtx, identifier);
+      assert.deepStrictEqual(result, dbResult);
+    });
+    it('failure', async function() {
+      sinon.stub(db.db, 'oneOrNone').rejects(expectedException);
+      await assert.rejects(() => db.authenticationGet(dbCtx, identifier, credential), expectedException);
+    });
+  }); // authenticationGet
+
+  describe('authenticationUpsert', function () {
+    let identifier, credential;
+    beforeEach(function () {
+      identifier = 'username';
+      credential = '$z$foo';
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.authenticationUpsert(dbCtx, identifier, credential);
+    });
+    it('failure', async function() {
+      credential = undefined;
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await assert.rejects(() => db.authenticationUpsert(dbCtx, identifier, credential), DBErrors.UnexpectedResult);
+    });
+  }); // authenticationUpsert
+
+  describe('profileIdentifierInsert', function () {
+    let profile, identifier;
+    beforeEach(function () {
+      profile = 'https://profile.example.com/';
+      identifier = 'username';
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.profileIdentifierInsert(dbCtx, profile, identifier);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await assert.rejects(() => db.profileIdentifierInsert(dbCtx, profile, identifier), DBErrors.UnexpectedResult);
+    });
+  }); // profileIdentifierInsert
+
+  describe('profileIsValid', function () {
+    let profile;
+    beforeEach(function () {
+      profile = 'https://profile.exmaple.com';
+    });
+    it('valid profile', async function () {
+      sinon.stub(db.db, 'oneOrNone').resolves({ profile });
+      const result = await db.profileIsValid(dbCtx, profile);
+      assert.strictEqual(result, true);
+    });
+    it('invalid profile', async function () {
+      sinon.stub(db.db, 'oneOrNone').resolves();
+      const result = await db.profileIsValid(dbCtx, profile);
+      assert.strictEqual(result, false);
+    });
+    it('failure', async function () {
+      sinon.stub(db.db, 'oneOrNone').rejects(expectedException);
+      await assert.rejects(() => db.profileIsValid(dbCtx, profile), expectedException);
+    });
+  }); // profileIsValid
+
+  describe('tokenGetByCodeId', function () {
+    let codeId;
+    beforeEach(function () {
+      sinon.stub(db.db, 'oneOrNone');
+      codeId = 'xxxxxxxx';
+    });
+    it('success', async function() {
+      const dbResult = {
+        token: '',
+        codeId,
+        created: new Date(),
+        expires: new Date(Date.now() +  24 * 60 * 60 * 1000),
+      };
+      db.db.oneOrNone.resolves(dbResult);
+      const result = await db.tokenGetByCodeId(dbCtx, codeId);
+      assert.deepStrictEqual(result, dbResult);
+    });
+    it('failure', async function () {
+      db.db.oneOrNone.rejects(expectedException);
+      await assert.rejects(() => db.tokenGetByCodeId(dbCtx, codeId), expectedException);
+    });
+  }); // tokenGetByCodeId
+
+  describe('profileScopeInsert', function () {
+    let profile, scope;
+    beforeEach(function () {
+      profile = 'https://profile.example.com/';
+      scope = 'scope';
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.profileScopeInsert(dbCtx, profile, scope);
+    });
+    it('failure', async function () {
+      sinon.stub(db.db, 'result').rejects(expectedException);
+      await assert.rejects(() => db.profileScopeInsert(dbCtx, profile, scope), expectedException);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 2,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await assert.rejects(() => db.profileScopeInsert(dbCtx, profile, scope), DBErrors.UnexpectedResult);
+    });
+  }); // profileScopeInsert
+
+  describe('profileScopesSetAll', function () {
+    let profile, scopes;
+    beforeEach(function () {
+      profile = 'https://example.com/';
+      scopes = [];
+      sinon.stub(db.db, 'result');
+    });
+    it('success, no scopes', async function () {
+      db.db.result.resolves();
+      await db.profileScopesSetAll(dbCtx, profile, scopes);
+    });
+    it('success, scopes', async function () {
+      db.db.result.resolves();
+      scopes.push('profile', 'email', 'create');
+      await db.profileScopesSetAll(dbCtx, profile, scopes);
+    });
+    it('failure', async function () {
+      db.db.result.rejects(expectedException);
+      await assert.rejects(() => db.profileScopesSetAll(dbCtx, profile, scopes), expectedException);
+    });
+  }); // profileScopesSetAll
+
+  describe('profilesScopesByIdentifier', function () {
+    let identifier, scopeIndex, profileScopes, profiles;
+    beforeEach(function () {
+      identifier = 'identifier';
+      scopeIndex = {
+        'scope': {
+          description: 'A scope.',
+          application: 'test',
+          isPermanent: false,
+          isManuallyAdded: true,
+          profiles: ['https://first.example.com/', 'https://second.example.com/'],
+        },
+        'another_scope': {
+          description: 'Another scope.',
+          application: 'another test',
+          isPermanent: true,
+          isManuallyAdded: false,
+          profiles: ['https://first.example.com/'],
+        },
+        'no_app_scope': {
+          description: 'A scope without application.',
+          application: '',
+          isPermanent: false,
+          isManuallyAdded: false,
+          profiles: ['https://second.example.com/'],
+        },
+        'no_profile_scope': {
+          description: 'A scope without profiles.',
+          application: 'test',
+          isPermanent: false,
+          isManuallyAdded: false,
+          profiles: [],
+        },
+      };
+      profileScopes = {
+        'https://first.example.com/': {
+          'scope': scopeIndex['scope'],
+          'another_scope': scopeIndex['another_scope'],
+        },
+        'https://second.example.com/': {
+          'scope': scopeIndex['scope'],
+          'no_app_scope': scopeIndex['no_app_scope'],
+        },
+        'https://scopeless.example.com/': {},
+      };
+      profiles = [
+        'https://first.example.com/',
+        'https://second.example.com/',
+        'https://scopeless.example.com/',
+      ];
+    });
+    it('success', async function () {
+      const dbResult = [
+        { profile: 'https://first.example.com/', scope: 'scope', application: 'test', description: 'A scope.', isPermanent: false, isManuallyAdded: true },
+        { profile: 'https://first.example.com/', scope: 'another_scope', application: 'another test', description: 'Another scope.', isPermanent: true, isManuallyAdded: false },
+        { profile: 'https://second.example.com/', scope: 'no_app_scope', application: '', description: 'A scope without application.', isPermanent: false, isManuallyAdded: false },
+        { profile: 'https://second.example.com/', scope: 'scope', application: 'test', description: 'A scope.', isPermanent: false, isManuallyAdded: true },
+        { profile: null, scope: 'no_profile_scope', application: 'test', description: 'A scope without profiles.', isPermanent: false, isManuallyAdded: false },
+        { profile: 'https://scopeless.example.com/', scope: null, application: null, description: null, isPermanent: null, isManuallyAdded: null },
+      ];
+      const expected = {
+        scopeIndex,
+        profileScopes,
+        profiles,
+      };
+      sinon.stub(db.db, 'manyOrNone').resolves(dbResult);
+      const result = await db.profilesScopesByIdentifier(dbCtx, identifier);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      sinon.stub(db.db, 'manyOrNone').rejects(expectedException);
+      await assert.rejects(() => db.profilesScopesByIdentifier(dbCtx, identifier), expectedException);
+    });
+  }); // profilesScopesByIdentifier
+
+  describe('redeemCode', function () {
+    let codeId, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshId, profileData;
+    beforeEach(function () {
+      codeId = '41945b8e-3e82-11ec-82d1-0025905f714a';
+      isToken = false;
+      clientId = 'https://app.example.com/';
+      profile = 'https://profile.example.com/';
+      identifier = 'username';
+      scopes = ['scope1', 'scope2'];
+      lifespanSeconds = 600;
+      refreshId = undefined;
+      profileData = undefined;
+    });
+    it('success redeem', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ isRevoked: false }],
+        duration: 22,
+      };
+      const dbResultScopes = {
+        rowCount: scopes.length,
+        rows: [],
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult).onCall(2).resolves(dbResultScopes);
+      const result = await db.redeemCode(dbCtx, { codeId, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshId, profileData });
+      assert.strictEqual(result, true);
+    });
+    it('success redeem, no scopes', async function () {
+      scopes = [];
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ isRevoked: false }],
+        duration: 22,
+      };
+      const dbResultScopes = {
+        rowCount: scopes.length,
+        rows: [],
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult).onCall(1).resolves(dbResultScopes);
+      const result = await db.redeemCode(dbCtx, { codeId, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshId, profileData });
+      assert.strictEqual(result, true);
+    });
+    it('success revoke', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ isRevoked: true }],
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.redeemCode(dbCtx, { codeId, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshId, profileData });
+      assert.strictEqual(result, false);
+    });
+    it('failure', async function() {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await assert.rejects(() => db.redeemCode(dbCtx, { codeId, clientId, profile, identifier, scopes, lifespanSeconds, refreshId, profileData }), DBErrors.UnexpectedResult);
+    });
+    it('failure token scopes', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ isRevoked: false }],
+        duration: 22,
+      };
+      const dbResultNone = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult).onCall(2).resolves(dbResultNone);
+      await assert.rejects(() => db.redeemCode(dbCtx, { codeId, clientId, profile, identifier, scopes, lifespanSeconds, refreshId, profileData }), DBErrors.UnexpectedResult);
+    });
+  }); // redeemCode
+
+  describe('refreshCode', function () {
+    let codeId, now, removeScopes;
+    beforeEach(function () {
+      codeId = '41945b8e-3e82-11ec-82d1-0025905f714a';
+      now = new Date();
+      removeScopes = [];
+      sinon.stub(db.db, 'result').resolves({ rowCount: removeScopes.length });
+      sinon.stub(db.db, 'oneOrNone');
+    });
+    it('success', async function () {
+      db.db.oneOrNone.resolves({
+        expires: now,
+        refreshExpires: now,
+      });
+      const result = await db.refreshCode(dbCtx, codeId, now, removeScopes);
+      assert(db.db.result.notCalled);
+      assert(result);
+      assert(result.expires);
+      assert(result.refreshExpires);
+      assert(!result.scopes);
+    });
+    it('success with scope reduction', async function () {
+      removeScopes = ['create'];
+      db.db.oneOrNone.resolves({
+        expires: now,
+        refreshExpires: now,
+        scopes: [],
+      });
+      db.db.result.resolves({ rowCount: removeScopes.length });
+      const result = await db.refreshCode(dbCtx, codeId, now, removeScopes);
+      assert(result);
+      assert(result.expires);
+      assert(result.refreshExpires);
+      assert(!result.scopes.includes('create'));
+    });
+    it('failure', async function () {
+      db.db.oneOrNone.rejects(expectedException);
+      await assert.rejects(async () => db.refreshCode(dbCtx, codeId, now, removeScopes), expectedException);
+    });
+    it('failure with scope reduction', async function () {
+      removeScopes = ['create'];
+      db.db.oneOrNone.resolves({});
+      db.db.result.resolves({ rowCount: 0 });
+      await assert.rejects(async () => db.refreshCode(dbCtx, codeId, now, removeScopes), DBErrors.UnexpectedResult);
+    });
+  }); // refreshCode
+
+  describe('resourceGet', function () {
+    let identifier;
+    beforeEach(function () {
+      sinon.stub(db.db, 'oneOrNone');
+      identifier = '05b81112-b224-11ec-a9c6-0025905f714a';
+    });
+    it('success', async function () {
+      const dbResult = {
+        identifier,
+        secret: 'secrety',
+      };
+      db.db.oneOrNone.resolves(dbResult);
+      const result = await db.resourceGet(dbCtx, identifier);
+      assert.deepStrictEqual(result, dbResult);
+    });
+    it('failure', async function() {
+      db.db.oneOrNone.rejects(expectedException);
+      await assert.rejects(() => db.resourceGet(dbCtx, identifier), expectedException);
+    });
+  }); // resourceGet
+
+  describe('resourceUpsert', function () {
+    let resourceId, secret, description;
+    beforeEach(function () {
+      resourceId = '98b8d9ec-f8e2-11ec-aceb-0025905f714a';
+      secret = 'supersecret';
+      description = 'some service';
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.resourceUpsert(dbCtx, resourceId, secret, description)
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await assert.rejects(() => db.resourceUpsert(dbCtx, resourceId, undefined, description), DBErrors.UnexpectedResult);
+    });
+  }); // resourceUpsert
+
+  describe('scopeCleanup', function () {
+    let atLeastMsSinceLast;
+    beforeEach(function () {
+      sinon.stub(db.db, 'result');
+      sinon.stub(db.db, 'oneOrNone');
+      atLeastMsSinceLast = 86400000;
+    });
+    it('success, empty almanac', async function () {
+      const cleaned = 10;
+      db.db.result
+        .onFirstCall().resolves({ rowCount: cleaned })
+        .onSecondCall().resolves({ rowCount: 1 });
+      const result = await db.scopeCleanup(dbCtx, atLeastMsSinceLast);
+      assert.strictEqual(result, cleaned);
+    });
+    it('success, too soon', async function () {
+      db.db.oneOrNone.resolves({ date: new Date(Date.now() - 4000) });
+      const result = await db.scopeCleanup(dbCtx, atLeastMsSinceLast);
+      assert.strictEqual(result, undefined);
+      assert(db.db.result.notCalled);
+    });
+    it('failure', async function () {
+      db.db.result.resolves({ rowCount: 0 });
+      await assert.rejects(async () => db.scopeCleanup(dbCtx, atLeastMsSinceLast), DBErrors.UnexpectedResult);
+    });
+  }); // scopeCleanup
+
+  describe('scopeDelete', function () {
+    let scope;
+    beforeEach(function () {
+      scope = 'somescope';
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'one').resolves({ inUse: false });
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.scopeDelete(dbCtx, scope);
+      assert(db.db.result.called);
+      assert.strictEqual(result, true);
+    });
+    it('success, no scope', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'one').resolves({ inUse: false });
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.scopeDelete(dbCtx, scope);
+      assert(db.db.result.called);
+      assert.strictEqual(result, true);
+    });
+    it('scope in use', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'one').resolves({ inUse: true });
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.scopeDelete(dbCtx, scope);
+      assert(db.db.result.notCalled);
+      assert.strictEqual(result, false);
+    });
+    it('failure', async function () {
+      sinon.stub(db.db, 'one').rejects(expectedException);
+      await assert.rejects(() => db.scopeDelete(dbCtx, scope), expectedException);
+    });
+  }); // scopeDelete
+
+  describe('scopeUpsert', function () {
+    let scope, description;
+    beforeEach(function () {
+      scope = 'username';
+      description = '$z$foo';
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.scopeUpsert(dbCtx, scope, description);
+    });
+    it('failure', async function() {
+      scope = undefined;
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await assert.rejects(() => db.scopeUpsert(dbCtx, scope, description), DBErrors.UnexpectedResult);
+    });
+  }); // scopeUpsert
+
+  describe('tokenCleanup', function () {
+    let codeLifespanSeconds, atLeastMsSinceLast;
+    beforeEach(function () {
+      sinon.stub(db.db, 'result');
+      sinon.stub(db.db, 'oneOrNone');
+      codeLifespanSeconds = 600000;
+      atLeastMsSinceLast = 86400000;
+    });
+    it('success, empty almanac', async function () {
+      const cleaned = 10;
+      db.db.result
+        .onFirstCall().resolves({ rowCount: cleaned })
+        .onSecondCall().resolves({ rowCount: 1 });
+      const result = await db.tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast);
+      assert.strictEqual(result, cleaned);
+    });
+    it('success, too soon', async function () {
+      db.db.oneOrNone.resolves({ date: new Date(Date.now() - 4000) });
+      const result = await db.tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast);
+      assert.strictEqual(result, undefined);
+      assert(db.db.result.notCalled);
+    });
+    it('failure', async function () {
+      db.db.result.resolves({ rowCount: 0 });
+      await assert.rejects(() => db.tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast), DBErrors.UnexpectedResult);
+    });
+  }); // tokenCleanup
+
+  describe('tokenRevokeByCodeId', function () {
+    let codeId;
+    beforeEach(function () {
+      codeId = 'a74bda94-3dae-11ec-8908-0025905f714a';
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.tokenRevokeByCodeId(dbCtx, codeId);
+    });
+    it('failure', async function() {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await assert.rejects(() => db.tokenRevokeByCodeId(dbCtx, codeId), DBErrors.UnexpectedResult);
+    });
+  }); // tokenRevokeByCodeId
+
+  describe('tokenRefreshRevokeByCodeId', function () {
+    let codeId;
+    beforeEach(function () {
+      codeId = '279947c8-2584-11ed-a2d6-0025905f714a';
+      sinon.stub(db.db, 'result');
+    });
+    it('success', async function () {
+      db.db.result.resolves({ rowCount: 1 });
+      await db.tokenRefreshRevokeByCodeId(dbCtx, codeId);
+    });
+    it('failure, no code', async function () {
+      db.db.result.resolves({ rowCount: 0 });
+      assert.rejects(async () => db.tokenRefreshRevokeByCodeId(dbCtx, codeId), DBErrors.UnexpectedResult);
+    });
+    it('failure', async function () {
+      db.db.result.rejects(expectedException);
+      assert.rejects(async () => db.tokenRefreshRevokeByCodeId(dbCtx, codeId), expectedException);
+    });
+  }); // tokenRefreshRevokeByCodeId
+
+  describe('tokensGetByIdentifier', function () {
+    let identifier;
+    beforeEach(function () {
+      identifier = 'identifier';
+    });
+    it('success', async function () {
+      const dbResult = [
+        {
+          'created': new Date(),
+          'expires': new Date(),
+          'isRevoked': false,
+          'token': '',
+          'codeId': '',
+          'profile': '',
+          'identifier': '',
+        },
+      ];
+      const expected = dbResult;
+      sinon.stub(db.db, 'manyOrNone').resolves(dbResult);
+      const result = await db.tokensGetByIdentifier(dbCtx, identifier);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      sinon.stub(db.db, 'manyOrNone').rejects(expectedException);
+      await assert.rejects(() => db.tokensGetByIdentifier(dbCtx, identifier), expectedException);
+    });
+  }); // tokensGetByIdentifier
+
+
+}); // DatabasePostgres
diff --git a/test/src/db/schema-version-helper.js b/test/src/db/schema-version-helper.js
new file mode 100644 (file)
index 0000000..b69735e
--- /dev/null
@@ -0,0 +1,159 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+const fs = require('fs');
+const svh = require('../../../src/db/schema-version-helper');
+
+describe('SchemaVersionHelper', function () {
+  const isDir = {
+    isDirectory: () => true,
+  };
+  const isMig = {
+    isFile: () => true,
+  };
+  const notDir = {
+    isDirectory: () => false,
+  };
+  afterEach(function () {
+    sinon.restore();
+  });
+  describe('schemaVersionStringToObject', function () {
+    it('covers', function () {
+      const expected = {
+        major: 1,
+        minor: 2,
+        patch: 3,
+      };
+      const result = svh.schemaVersionStringToObject('1.2.3');
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // schemaVersionStringToObject
+
+  describe('schemaVersionObjectToNumber', function () {
+    it('covers', function () {
+      const expected = 1002003;
+      const result = svh.schemaVersionObjectToNumber({
+        major: 1,
+        minor: 2,
+        patch: 3,
+      });
+      assert.strictEqual(result, expected);
+    });
+  }); // schemaVersionObjectToNumber
+
+  describe('schemaVersionStringToNumber', function () {
+    it('covers', function () {
+      const expected = 1002003;
+      const result = svh.schemaVersionStringToNumber('1.2.3');
+      assert.strictEqual(result, expected);
+    });
+  }); // schemaVersionStringToNumber
+
+  describe('schemaVersionStringCmp', function () {
+    it('sorts', function () {
+      const expected = ['0.0.0', '1.0.0', '1.5.3', '64.123.998', '64.123.999'];
+      const source = ['1.5.3', '64.123.998', '1.0.0', '64.123.999', '0.0.0'];
+      source.sort(svh.schemaVersionStringCmp);
+      assert.deepStrictEqual(source, expected);
+    });
+  }); // schemaVersionStringCmp
+
+  describe('isSchemaMigrationDirectory', function () {
+    beforeEach(function () {
+      sinon.stub(fs, 'statSync');
+    });
+    it('is directory, is file', function () {
+      fs.statSync.returns({
+        isDirectory: () => true,
+        isFile: () => true,
+      });
+      const result = svh.isSchemaMigrationDirectory('path', '1.0.0');
+      assert.strictEqual(result, true);
+    });
+    it('is directory, not file', function () {
+      fs.statSync.returns({
+        isDirectory: () => true,
+        isFile: () => false,
+      });
+      const result = svh.isSchemaMigrationDirectory('path', '1.0.0');
+      assert.strictEqual(result, false);
+    });
+    it('not directory', function () {
+      fs.statSync.returns({
+        isDirectory: () => false,
+        isFile: () => {
+          throw new Error('unexpected invocation');
+        },
+      });
+      const result = svh.isSchemaMigrationDirectory('path', '1.0.0');
+      assert.strictEqual(result, false);
+    });
+    it('file error', function () {
+      fs.statSync.returns({
+        isDirectory: () => true,
+        isFile: () => {
+          throw new Error('expected error');
+        },
+      });
+      const result = svh.isSchemaMigrationDirectory('path', '1.0.0');
+      assert.strictEqual(result, false);
+    });
+  }); // isSchemaMigrationDirectory
+
+  describe('allSchemaVersions', function () {
+    beforeEach(function () {
+      sinon.stub(fs, 'readdirSync');
+      sinon.stub(fs, 'statSync');
+      sinon.stub(svh, 'isSchemaMigrationDirectory');
+    });
+    it('covers', function () {
+      const expected = ['1.0.0', '1.0.1', '1.1.0', '1.1.1', '1.1.2'];
+      fs.readdirSync.returns(['1.1.2', 'file.txt', '1.1.0', '1.1.1', 'init.sql', '1.0.1', '1.0.0']);
+      // cannot seem to stub isSchemaMigration, so here are the internals of it stubbed
+      let i = 0;
+      fs.statSync
+        .onCall(i++).returns(isDir).onCall(i++).returns(isMig) // '1.1.2'
+        .onCall(i++).returns(notDir) // 'file.txt'
+        .onCall(i++).returns(isDir).onCall(i++).returns(isMig) // '1.1.0'
+        .onCall(i++).returns(isDir).onCall(i++).returns(isMig) // '1.1.1'
+        .onCall(i++).returns(notDir) // 'init.sql'
+        .onCall(i++).returns(isDir).onCall(i++).returns(isMig) // '1.0.1'
+        .onCall(i++).returns(isDir).onCall(i++).returns(isMig) // '1.0.0'
+      const result = svh.allSchemaVersions('path');
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // allSchemaVersions
+
+  describe('unappliedSchemaVersions', function () {
+    let current, supported;
+    beforeEach(function () {
+      sinon.stub(fs, 'readdirSync');
+      sinon.stub(fs, 'statSync');
+      sinon.stub(svh, 'isSchemaMigrationDirectory');
+      supported = {
+        min: { major: 1, minor: 0, patch: 1 },
+        max: { major: 1, minor: 1, patch: 1 },
+      };
+      current = { major: 1, minor: 0, patch: 1 };
+      });
+    it('covers', function () {
+      const expected = ['1.1.0', '1.1.1'];
+      fs.readdirSync.returns(['1.1.2', 'file.txt', '1.1.0', '1.1.1', 'init.sql', '1.0.1', '1.0.0']);
+      // cannot seem to stub isSchemaMigration, so here are the internals of it stubbed
+      let i = 0;
+      fs.statSync
+        .onCall(i++).returns(isDir).onCall(i++).returns(isMig) // '1.1.2'
+        .onCall(i++).returns(notDir) // 'file.txt'
+        .onCall(i++).returns(isDir).onCall(i++).returns(isMig) // '1.1.0'
+        .onCall(i++).returns(isDir).onCall(i++).returns(isMig) // '1.1.1'
+        .onCall(i++).returns(notDir) // 'init.sql'
+        .onCall(i++).returns(isDir).onCall(i++).returns(isMig) // '1.0.1'
+        .onCall(i++).returns(isDir).onCall(i++).returns(isMig) // '1.0.0'
+      const result = svh.unappliedSchemaVersions('path', current, supported);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // unappliedSchemaVersions
+
+});
\ No newline at end of file
diff --git a/test/src/db/sqlite.js b/test/src/db/sqlite.js
new file mode 100644 (file)
index 0000000..f693594
--- /dev/null
@@ -0,0 +1,918 @@
+/* eslint-disable sonarjs/no-identical-functions */
+/* eslint-env mocha */
+/* eslint-disable sonarjs/no-duplicate-string */
+'use strict';
+
+/* This provides implementation coverage, stubbing parts of better-sqlite3. */
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+const StubDatabase = require('../../stub-db');
+const StubLogger = require('../../stub-logger');
+const DB = require('../../../src/db/sqlite');
+const DBErrors = require('../../../src/db/errors');
+const common = require('../../../src/common');
+const Config = require('../../../config');
+
+const expectedException = new Error('oh no');
+
+describe('DatabaseSQLite', function () {
+  let db, options, logger, stubDb;
+  let dbCtx;
+  before(function () {
+    logger = new StubLogger();
+    logger._reset();
+    stubDb = new StubDatabase();
+  });
+  beforeEach(function () {
+    options = new Config('test');
+    options.db.connectionString = 'sqlite://:memory:';
+    db = new DB(logger, options);
+    dbCtx = db.db;
+  });
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  it('covers constructor options', function () {
+    delete options.db.connectionString;
+    db = new DB(logger, options);
+  });
+
+  // Ensure all interface methods are implemented
+  describe('Implementation', function () {
+    it('implements interface', async function () {
+      const results = await Promise.allSettled(stubDb._implementation.map((fn) => {
+        try {
+          // eslint-disable-next-line security/detect-object-injection
+          db[fn](db.db);
+        } catch (e) {
+          assert(!(e instanceof DBErrors.NotImplemented), `${fn} not implemented`);
+        }
+      }));
+      const failures = results.filter((x) => x.status === 'rejected');
+      assert(!failures.length, failures.map((x) => {
+        x = x.reason.toString();
+        return x.slice(x.indexOf(': '));
+      }));
+    });
+  }); // Implementation
+
+  describe('_currentSchema', function () {
+    it('covers', function () {
+      const version = { major: 1, minor: 0, patch: 0 };
+      sinon.stub(db.db, 'prepare').returns({
+        get: () => version,
+      });
+      const result = db._currentSchema();
+      assert.deepStrictEqual(result, version);
+    });
+  }); // _currentSchema
+
+  describe('_closeConnection', function () {
+    it('success', function () {
+      sinon.stub(db.db, 'close');
+      db._closeConnection();
+      assert(db.db.close.called);
+    });
+    it('failure', function () {
+      sinon.stub(db.db, 'close').throws(expectedException);
+      assert.throws(() => db._closeConnection(), expectedException);
+    });
+  }); // _closeConnection
+
+  describe('_purgeTables', function () {
+    beforeEach(function () {
+      sinon.stub(db.db, 'prepare').returns({
+        run: sinon.stub(),
+      });
+    });
+    it('covers not really', function () {
+      db._purgeTables(false);
+      assert(!db.db.prepare.called);
+    });
+    it('success', function () {
+      db._purgeTables(true);
+      assert(db.db.prepare.called);
+    });
+    it('failure', function () {
+      db.db.prepare.restore();
+      sinon.stub(db.db, 'prepare').throws(expectedException);
+      assert.throws(() => db._purgeTables(true), expectedException);
+    });
+  }); // _purgeTables
+
+  describe('_optimize', function () {
+    beforeEach(function () {
+      sinon.stub(db.statement._optimize, 'all');
+      sinon.stub(db.db, 'pragma');
+    });
+    it('covers', function () {
+      db.changesSinceLastOptimize = BigInt(20);
+      db._optimize();
+      assert(db.db.pragma.called);
+      assert(db.statement._optimize.all.called);
+      assert.strictEqual(db.changesSinceLastOptimize, 0n)
+    });
+  }); // _optimize
+
+  describe('_updateChanges', function () {
+    let dbResult;
+    beforeEach(function () {
+      dbResult = {
+        changes: 4,
+      };
+      sinon.stub(db, '_optimize');
+    });
+    it('does not optimize if not wanted', function () {
+      db.optimizeAfterChanges = 0n;
+      db._updateChanges(dbResult);
+      assert(db._optimize.notCalled);
+    });
+    it('does not optimize if under threshold', function () {
+      db.optimizeAfterChanges = 100n;
+      db._updateChanges(dbResult);
+      assert(db._optimize.notCalled);
+    });
+    it('optimizes over threshold', function () {
+      db.optimizeAfterChanges = 1n;
+      db._updateChanges(dbResult);
+      assert(db._optimize.called);
+    });
+  }); // _updateChanges
+
+  describe('_deOphidiate', function () {
+    it('covers non-array', function () {
+      const obj = {
+        'snake_case': 1,
+      };
+      const expected = {
+        snakeCase: 1,
+      };
+      const result = DB._deOphidiate(obj);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers array', function () {
+      const rows = [
+        {
+          'snek_field': 'foo',
+        },
+        {
+          'snek_field': 'bar',
+        },
+      ];
+      const expected = [
+        {
+          snekField: 'foo',
+        },
+        {
+          snekField: 'bar',
+        },
+      ];
+      const result = DB._deOphidiate(rows);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // _deOphidiate
+
+  describe('healthCheck', function () {
+    it('covers', function () {
+      db.healthCheck();
+    });
+    it('covers failure', function () {
+      db.db = { open: false };
+      assert.throws(() => db.healthCheck(), DBErrors.UnexpectedResult);
+    });
+  }); // healthCheck
+
+  describe('context', function () {
+    it('covers', function () {
+      db.context(common.nop);
+    });
+  }); // context
+
+  describe('transaction', function () {
+    it('covers', function () {
+      db.transaction(db.db, common.nop);
+    });
+    it('covers no context', function () {
+      db.transaction(undefined, common.nop);
+    });
+  }); // transaction
+
+  describe('almanacGetAll', function () {
+    beforeEach(function () {
+      sinon.stub(db.statement.almanacGetAll, 'all');
+    });
+    it('success', function () {
+      const dbResult = [{ event: 'someEvent', epoch: '1668887796' } ];
+      const expected = [{ event: 'someEvent', date: new Date('Sat Nov 19 11:56:36 AM PST 2022') }];
+      db.statement.almanacGetAll.all.returns(dbResult);
+      const result = db.almanacGetAll(dbCtx);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', function () {
+      db.statement.almanacGetAll.all.throws(expectedException);
+      assert.throws(() => db.almanacGetAll(dbCtx), expectedException);
+    });
+  }); // almanacGetAll
+
+  describe('authenticationGet', function () {
+    let identifier, credential;
+    beforeEach(function () {
+      identifier = 'username';
+      credential = '$z$foo';
+      sinon.stub(db.statement.authenticationGet, 'get');
+    });
+    it('success', function() {
+      const expected = {
+        identifier,
+        credential,
+      };
+      db.statement.authenticationGet.get.returns(expected);
+      const result = db.authenticationGet(dbCtx, identifier);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', function () {
+      db.statement.authenticationGet.get.throws(expectedException);
+      assert.throws(() => db.authenticationGet(dbCtx, identifier), expectedException);
+    });
+  }); // authenticationGet
+
+  describe('authenticationSuccess', function () {
+    let dbResult, identifier;
+    beforeEach(function () {
+      identifier = 'username';
+      sinon.stub(db.statement.authenticationSuccess, 'run');
+      dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+    });
+    it('success', function() {
+      db.statement.authenticationSuccess.run.returns(dbResult);
+      db.authenticationSuccess(dbCtx, identifier);
+    });
+    it('failure', function () {
+      dbResult.changes = 0;
+      db.statement.authenticationSuccess.run.returns(dbResult);
+      assert.throws(() => db.authenticationSuccess(dbCtx, identifier), DBErrors.UnexpectedResult);
+    });
+  }); // authenticationSuccess
+
+  describe('authenticationUpsert', function () {
+    let identifier, credential;
+    beforeEach(function () {
+      identifier = 'username';
+      credential = '$z$foo';
+    });
+    it('success', function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.authenticationUpsert, 'run').returns(dbResult);
+      db.authenticationUpsert(dbCtx, identifier, credential);
+    });
+    it('failure', function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.authenticationUpsert, 'run').returns(dbResult);
+      assert.throws(() => db.authenticationUpsert(dbCtx, identifier, credential), DBErrors.UnexpectedResult);
+    });
+  }); // authenticationUpsert
+
+  describe('profileIdentifierInsert', function () {
+    let profile, identifier;
+    beforeEach(function () {
+      profile = 'https://profile.example.com/';
+      identifier = 'identifier';
+      sinon.stub(db.statement.profileIdentifierInsert, 'run');
+    });
+    it('success', function () {
+      db.statement.profileIdentifierInsert.run.returns({ changes: 1 });
+      db.profileIdentifierInsert(dbCtx, profile, identifier);
+    });
+    it('failure', function () {
+      db.statement.profileIdentifierInsert.run.returns({ changes: 0 });
+      assert.throws(() => db.profileIdentifierInsert(dbCtx, profile, identifier), DBErrors.UnexpectedResult);
+    });
+  }); // profileIdentifierInsert
+
+  describe('profileScopeInsert', function () {
+    let profile, scope;
+    beforeEach(function () {
+      profile = 'https://profile.example.com/';
+      scope = 'scope';
+      sinon.stub(db.statement.profileScopeInsert, 'run');
+    });
+    it('success', function () {
+      db.statement.profileScopeInsert.run.returns({ changes: 1 });
+      db.profileScopeInsert(dbCtx, profile, scope);
+    });
+    it('failure', function () {
+      db.statement.profileScopeInsert.run.returns({ changes: 2 });
+      assert.throws(() => db.profileScopeInsert(dbCtx, profile, scope), DBErrors.UnexpectedResult);
+    });
+  }); // profileScopeInsert
+
+  describe('profileIsValid', function () {
+    let profile;
+    beforeEach(function () {
+      profile = 'https://profile.exmaple.com';
+    });
+    it('valid profile', function () {
+      sinon.stub(db.statement.profileGet, 'get').returns({ profile });
+      const result = db.profileIsValid(dbCtx, profile);
+      assert.deepStrictEqual(result, true);
+    });
+    it('invalid profile', function () {
+      sinon.stub(db.statement.profileGet, 'get').returns();
+      const result = db.profileIsValid(dbCtx, profile);
+      assert.deepStrictEqual(result, false);
+    });
+    it('failure', function() {
+      sinon.stub(db.statement.profileGet, 'get').throws(expectedException);
+      assert.throws(() => db.profileIsValid(dbCtx, profile), expectedException);
+    });
+  }); // profileIsValid
+
+  describe('profilesScopesByIdentifier', function () {
+    let identifier, scopeIndex, profileScopes, profiles;
+    beforeEach(function  () {
+      identifier = 'identifier';
+      scopeIndex = {
+        'scope': {
+          description: 'A scope.',
+          application: 'test',
+          isPermanent: false,
+          isManuallyAdded: false,
+          profiles: ['https://first.example.com/', 'https://second.example.com/'],
+        },
+        'another_scope': {
+          description: 'Another scope.',
+          application: 'another test',
+          isPermanent: false,
+          isManuallyAdded: false,
+          profiles: ['https://first.example.com/'],
+        },
+      };
+      profileScopes = {
+        'https://first.example.com/': {
+          'scope': scopeIndex['scope'],
+          'another_scope': scopeIndex['another_scope'],
+        },
+        'https://second.example.com/': {
+          'scope': scopeIndex['scope'],
+        },
+      };
+      profiles = ['https://first.example.com/', 'https://second.example.com/'];
+    });
+    it('success', function () {
+      const dbResult = [
+        { profile: 'https://first.example.com/', scope: 'scope', application: 'test', description: 'A scope.', isPermanent: false, isManuallyAdded: false },
+        { profile: 'https://first.example.com/', scope: 'another_scope', application: 'another test', description: 'Another scope.', isPermanent: false, isManuallyAdded: false  },
+        { profile: 'https://second.example.com/', scope: 'scope', application: 'test', description: 'A scope.', isPermanent: false, isManuallyAdded: false  },
+      ];
+      const expected = {
+        scopeIndex,
+        profileScopes,
+        profiles,
+      };
+      sinon.stub(db.statement.profilesScopesByIdentifier, 'all').returns(dbResult);
+      const result = db.profilesScopesByIdentifier(dbCtx, identifier);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', function() {
+      sinon.stub(db.statement.profilesScopesByIdentifier, 'all').throws(expectedException);
+      assert.throws(() => db.profilesScopesByIdentifier(dbCtx, identifier), expectedException);
+    });
+  }); // profilesScopesByIdentifier
+
+  describe('profileScopesSetAll', function () {
+    let profile, scopes;
+    beforeEach(function () {
+      profile = 'https://example.com/';
+      scopes = ['scope1', 'scope2'];
+      sinon.stub(db.statement.profileScopesClear, 'run').returns();
+      sinon.stub(db.statement.profileScopeInsert, 'run');
+    });
+    it('success, no scopes', function () {
+      db.statement.profileScopeInsert.run.returns();
+      scopes = [];
+      db.profileScopesSetAll(dbCtx, profile, scopes);
+    });
+    it('success, scopes', function () {
+      db.statement.profileScopeInsert.run.returns();
+      scopes.push('profile', 'email', 'create');
+      db.profileScopesSetAll(dbCtx, profile, scopes);
+    });
+    it('failure', function () {
+      db.statement.profileScopeInsert.run.throws(expectedException);
+      assert.throws(() => db.profileScopesSetAll(dbCtx, profile, scopes), expectedException);
+    });
+
+  }); // profileScopesSetAll
+
+  describe('redeemCode', function () {
+    let codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, profileData;
+    beforeEach(function () {
+      codeId = '2f226616-3e79-11ec-ad0f-0025905f714a';
+      isToken = false;
+      clientId = 'https://app.exmaple.com/';
+      profile = 'https://profile.example.com/';
+      identifier = 'username';
+      scopes = ['scope1', 'scope2'];
+      lifespanSeconds = 600;
+      profileData = undefined;
+      created = new Date();
+
+      sinon.stub(db.statement.scopeInsert, 'run');
+      sinon.stub(db.statement.tokenScopeSet, 'run');
+      sinon.stub(db.statement.redeemCode, 'get');
+    });
+    it('success', function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const dbGet = {
+        isRevoked: false,
+      };
+      db.statement.scopeInsert.run.returns(dbResult);
+      db.statement.tokenScopeSet.run.returns(dbResult);
+      db.statement.redeemCode.get.returns(dbGet);
+      profileData = {
+        name: 'Some Name',
+      };
+      const result = db.redeemCode(dbCtx, { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, profileData });
+      assert.strictEqual(result, true);
+    });
+    it('success (revoked)', function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const dbGet = {
+        isRevoked: true,
+      };
+      db.statement.scopeInsert.run.returns(dbResult);
+      db.statement.tokenScopeSet.run.returns(dbResult);
+      db.statement.redeemCode.get.returns(dbGet);
+      const result = db.redeemCode(dbCtx, { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, profileData });
+      assert.strictEqual(result, false);
+    });
+    it('failure', function () {
+      db.statement.scopeInsert.run.throws();
+      db.statement.tokenScopeSet.run.throws();
+      db.statement.redeemCode.get.returns();
+      assert.throws(() => db.redeemCode(dbCtx, { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds }), DBErrors.UnexpectedResult);
+    });
+  }); // redeemCode
+
+  describe('refreshCode', function () {
+    let refreshResponse, removeResponse, scopesResponse, codeId, refreshed, removeScopes;
+    beforeEach(function () {
+      sinon.stub(db.statement.refreshCode, 'get');
+      sinon.stub(db.statement.tokenScopeRemove, 'run');
+      sinon.stub(db.statement.tokenScopesGetByCodeId, 'all');
+      codeId = '73db7b18-27bb-11ed-8edd-0025905f714a';
+      refreshed = new Date();
+      removeScopes = ['foop'];
+      const refreshedEpoch = Math.ceil(refreshed.getTime() / 1000);
+      refreshResponse = {
+        expires: refreshedEpoch + 86400,
+        refreshExpires: refreshedEpoch + 172800,
+      };
+      removeResponse = {
+        changes: removeScopes.length,
+      };
+      scopesResponse = [
+        { scope: 'blah' },
+      ];
+    });
+    it('success', function () {
+      db.statement.refreshCode.get.returns(refreshResponse);
+      db.statement.tokenScopeRemove.run.returns(removeResponse);
+      db.statement.tokenScopesGetByCodeId.all.returns(scopesResponse);
+      const expectedResponse = {
+        expires: new Date(refreshResponse.expires * 1000),
+        refreshExpires: new Date(refreshResponse.refreshExpires * 1000),
+        scopes: ['blah'],
+      }
+      const response = db.refreshCode(dbCtx, codeId, refreshed, removeScopes);
+      assert.deepStrictEqual(response, expectedResponse);
+    });
+    it('success without scope removal', function () {
+      db.statement.refreshCode.get.returns(refreshResponse);
+      db.statement.tokenScopeRemove.run.returns(removeResponse);
+      const expectedResponse = {
+        expires: new Date(refreshResponse.expires * 1000),
+        refreshExpires: new Date(refreshResponse.refreshExpires * 1000),
+      }
+      removeScopes = [];
+      const response = db.refreshCode(dbCtx, codeId, refreshed, removeScopes);
+      assert.deepStrictEqual(response, expectedResponse);
+    });
+    it('success with no scopes left', function () {
+      db.statement.refreshCode.get.returns(refreshResponse);
+      db.statement.tokenScopeRemove.run.returns(removeResponse);
+      const expectedResponse = {
+        expires: new Date(refreshResponse.expires * 1000),
+        refreshExpires: new Date(refreshResponse.refreshExpires * 1000),
+        scopes: [],
+      }
+      const response = db.refreshCode(dbCtx, codeId, refreshed, removeScopes);
+      assert.deepStrictEqual(response, expectedResponse);
+    });
+    it('no code', function () {
+      db.statement.refreshCode.get.returns();
+      removeResponse.changes = 0;
+      db.statement.tokenScopeRemove.run.returns();
+      const expectedResponse = undefined;
+      const response = db.refreshCode(dbCtx, codeId, refreshed, removeScopes);
+      assert.deepStrictEqual(response, expectedResponse);
+    });
+    it('failure', function () {
+      db.statement.refreshCode.get.throws(expectedException);
+      assert.throws(() => db.refreshCode(dbCtx, codeId, refreshed, removeScopes), expectedException);
+    });
+    it('scope removal failure', function () {
+      removeResponse.changes = 0;
+      db.statement.tokenScopeRemove.run.returns(removeResponse);
+      db.statement.refreshCode.get.returns(refreshResponse);
+      assert.throws(() => db.refreshCode(dbCtx, codeId, refreshed, removeScopes), DBErrors.UnexpectedResult);
+    });
+
+    describe('_refreshCodeResponseToNative', function () {
+      it('coverage', function () {
+        const expected = { foo: 'bar' };
+        const result = DB._refreshCodeResponseToNative(expected);
+        assert.deepStrictEqual(result, expected);
+      });
+      it('coverage', function () {
+        const result = DB._refreshCodeResponseToNative();
+        assert.strictEqual(result, undefined);
+      });
+    });
+  }); // refreshCode
+
+  describe('resourceGet', function () {
+    let identifier;
+    beforeEach(function () {
+      sinon.stub(db.statement.resourceGet, 'get');
+      identifier = '05b81112-b224-11ec-a9c6-0025905f714a';
+    });
+    it('success', function () {
+      const dbResult = {
+        identifier,
+        secret: 'secrety',
+      };
+      db.statement.resourceGet.get.returns(dbResult);
+      const result = db.resourceGet(dbCtx, identifier);
+      assert.deepStrictEqual(result, dbResult);
+    });
+    it('failure', function() {
+      db.statement.resourceGet.get.throws(expectedException);
+      assert.throws(() => db.resourceGet(dbCtx, identifier), expectedException);
+    });
+  }); // resourceGet
+
+  describe('resourceUpsert', function () {
+    let resourceId, secret, description;
+    beforeEach(function () {
+      resourceId = '4086661a-f980-11ec-ba19-0025905f714a';
+      secret = 'secret';
+      description = 'some application';
+    });
+    it('success', function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.resourceUpsert, 'run').returns(dbResult);
+      db.resourceUpsert(dbCtx, resourceId, secret, description);
+    });
+    it('creates id if not provided', function () {
+      resourceId = undefined;
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.resourceUpsert, 'run').returns(dbResult);
+      db.resourceUpsert(dbCtx, resourceId, secret, description);
+    });
+    it('failure', function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.resourceUpsert, 'run').returns(dbResult);
+      assert.throws(() => db.resourceUpsert(dbCtx, resourceId, secret, description), DBErrors.UnexpectedResult);
+    });
+  }); // resourceUpsert
+
+  describe('scopeCleanup', function () {
+    let atLeastMsSinceLast;
+    beforeEach(function () {
+      atLeastMsSinceLast = 86400000;
+      sinon.stub(db.statement.scopeCleanup, 'run');
+      sinon.stub(db.statement.almanacGet, 'get');
+      sinon.stub(db.statement.almanacUpsert, 'run');
+    });
+    it('success, empty almanac', function () {
+      const cleaned = 10n;
+      db.statement.almanacGet.get.returns();
+      db.statement.scopeCleanup.run.returns({ changes: cleaned });
+      db.statement.almanacUpsert.run.returns({ changes: 1 });
+      const result = db.scopeCleanup(dbCtx, atLeastMsSinceLast);
+      assert.strictEqual(result, cleaned);
+    });
+    it('success, too soon', function () {
+      db.statement.almanacGet.get.returns({ epoch: BigInt(Math.ceil(Date.now() / 1000) - 4) });
+      const result = db.scopeCleanup(dbCtx, atLeastMsSinceLast);
+      assert.strictEqual(result, undefined);
+      assert(db.statement.scopeCleanup.run.notCalled);
+    });
+    it('failure', function () {
+      db.statement.almanacGet.get.returns({ epoch: 0n });
+      db.statement.scopeCleanup.run.returns({ changes: 1 });
+      db.statement.almanacUpsert.run.returns({ changes: 0 });
+      assert.throws(() => db.scopeCleanup(dbCtx, atLeastMsSinceLast), DBErrors.UnexpectedResult);
+    });
+  }); // scopeCleanup
+
+  describe('scopeDelete', function () {
+    let dbGetResult, dbRunResult, scope;
+    beforeEach(function () {
+      sinon.stub(db.statement.scopeInUse, 'get');
+      dbGetResult = {
+        inUse: false,
+      }
+      sinon.stub(db.statement.scopeDelete, 'run');
+      dbRunResult = {
+        changes: 1,
+      };
+      scope = 'some_scope';
+    });
+    it('success', function () {
+      db.statement.scopeInUse.get.returns(dbGetResult);
+      db.statement.scopeDelete.run.returns(dbRunResult);
+      const result = db.scopeDelete(dbCtx, scope);
+      assert.strictEqual(result, true);
+    });
+    it('in use', function () {
+      dbGetResult.inUse = true;
+      db.statement.scopeInUse.get.returns(dbGetResult);
+      db.statement.scopeDelete.run.returns(dbRunResult);
+      const result = db.scopeDelete(dbCtx, scope);
+      assert.strictEqual(result, false);
+    });
+    it('no scope', function () {
+      dbRunResult.changes = 0;
+      db.statement.scopeInUse.get.returns(dbGetResult);
+      db.statement.scopeDelete.run.returns(dbRunResult);
+      const result = db.scopeDelete(dbCtx, scope);
+      assert.strictEqual(result, true);
+    });
+    it('failure', function () {
+      db.statement.scopeInUse.get.throws(expectedException);
+      assert.throws(() => db.scopeDelete(dbCtx, scope), expectedException);
+    });
+  }); // scopeDelete
+
+  describe('scopeUpsert', function () {
+    let dbResult, scope, application, description;
+    beforeEach(function () {
+      scope = 'scope';
+      application = undefined;
+      description = 'description';
+      sinon.stub(db.statement.scopeUpsert, 'run');
+      dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+    });
+    it('success', function() {
+      db.statement.scopeUpsert.run.returns(dbResult);
+      db.scopeUpsert(dbCtx, scope, application, description);
+    });
+    it('failure', function () {
+      dbResult.changes = 0;
+      db.statement.scopeUpsert.run.returns(dbResult);
+      assert.throws(() => db.scopeUpsert(dbCtx, scope, application, description), DBErrors.UnexpectedResult);
+    });
+    it('failure, error', function () {
+      db.statement.scopeUpsert.run.throws(expectedException);
+      assert.throws(() => db.scopeUpsert(dbCtx, scope, application, description), expectedException);
+    });
+  }); // scopeUpsert
+
+  describe('tokenCleanup', function () {
+    let codeLifespanSeconds, atLeastMsSinceLast;
+    beforeEach(function () {
+      codeLifespanSeconds = 600;
+      atLeastMsSinceLast = 86400000;
+      sinon.stub(db.statement.tokenCleanup, 'run');
+      sinon.stub(db.statement.almanacGet, 'get');
+      sinon.stub(db.statement.almanacUpsert, 'run');
+    });
+    it('success, empty almanac', function() {
+      const cleaned = 10n;
+      db.statement.almanacGet.get.returns();
+      db.statement.tokenCleanup.run.returns({ changes: cleaned });
+      db.statement.almanacUpsert.run.returns({ changes: 1 });
+      const result = db.tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast);
+      assert.strictEqual(result, cleaned);
+    });
+    it('success, too soon', function () {
+      db.statement.almanacGet.get.returns({ epoch: BigInt(Math.ceil(Date.now() / 1000) - 4) });
+      const result = db.tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast);
+      assert.strictEqual(result, undefined);
+      assert(db.statement.tokenCleanup.run.notCalled);
+    });
+    it('failure', function () {
+      db.statement.almanacGet.get.returns({ epoch: 0n });
+      db.statement.tokenCleanup.run.returns({ changes: 10 });
+      db.statement.almanacUpsert.run.returns({ changes: 0 });
+      assert.throws(() => db.tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast), DBErrors.UnexpectedResult);
+    });
+  }); // tokenCleanup
+
+  describe('tokenGetByCodeId', function () {
+    let codeId, token;
+    beforeEach(function () {
+      codeId = '184a26f6-2612-11ec-9e88-0025905f714a';
+      token = 'TokenTokenTokenToken';
+      sinon.stub(db.statement.tokenGetByCodeId, 'get');
+      sinon.stub(db.statement.tokenScopesGetByCodeId, 'all');
+    });
+    it('success', function() {
+      const now = new Date();
+      const nowEpoch = Math.ceil(now / 1000);
+      const expected = {
+        created: new Date(nowEpoch * 1000), 
+        expires: null,
+        refreshExpires: null,
+        refreshed: null,
+        isRevoked: false,
+        isToken: false,
+        token,
+        codeId,
+        scopes: [],
+        profileData: {
+          name: 'Some Name',
+        },
+      };
+      const dbResult = {
+        created: Math.ceil(nowEpoch),
+        expires: null,
+        refreshExpires: null,
+        refreshed: null,
+        isToken: 0,
+        token,
+        codeId,
+        profileData: '{"name":"Some Name"}',
+      };
+      db.statement.tokenGetByCodeId.get.returns(dbResult);
+      const result = db.tokenGetByCodeId(dbCtx, codeId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('success without profile data', function () {
+      const now = new Date();
+      const nowEpoch = Math.ceil(now / 1000);
+      const expected = {
+        created: new Date(nowEpoch * 1000), 
+        expires: null,
+        refreshExpires: null,
+        refreshed: null,
+        isRevoked: false,
+        isToken: false,
+        token,
+        codeId,
+        scopes: ['foop', 'baa'],
+      };
+      const dbResult = {
+        created: Math.ceil(nowEpoch),
+        expires: null,
+        refreshExpires: null,
+        refreshed: null,
+        isToken: 0,
+        token,
+        codeId,
+      };
+      db.statement.tokenGetByCodeId.get.returns(dbResult);
+      db.statement.tokenScopesGetByCodeId.all.returns([{ scope: 'foop' }, { scope: 'baa' }]);
+      const result = db.tokenGetByCodeId(dbCtx, codeId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', function () {
+      db.statement.tokenGetByCodeId.get.throws(expectedException);
+      assert.throws(() => db.tokenGetByCodeId(dbCtx, codeId), expectedException);
+    });
+
+    describe('_tokenToNative', function () {
+      it('covers', function () {
+        const result = DB._tokenToNative();
+        assert.strictEqual(result, undefined);
+      });
+    }); // _tokenToNative
+  }); // tokenGetByCodeId
+
+  describe('tokenRevokeByCodeId', function () {
+    let dbResult, codeId;
+    beforeEach(function () {
+      codeId = '2f226616-3e79-11ec-ad0f-0025905f714a';
+      sinon.stub(db.statement.tokenRevokeByCodeId, 'run')
+      dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+    });
+    it('success', function() {
+      db.statement.tokenRevokeByCodeId.run.returns(dbResult);
+      db.tokenRevokeByCodeId(dbCtx, codeId);
+    });
+    it('failure', function () {
+      dbResult.changes = 0;
+      db.statement.tokenRevokeByCodeId.run.returns(dbResult);
+      assert.throws(() => db.tokenRevokeByCodeId(dbCtx, codeId), DBErrors.UnexpectedResult);
+    });
+    it('failure, error', function () {
+      db.statement.tokenRevokeByCodeId.run.throws(expectedException);
+      assert.throws(() => db.tokenRevokeByCodeId(dbCtx, codeId), expectedException);
+    });
+  }); // tokenRevokeByCodeId
+
+  describe('tokenRefreshRevokeByCodeId', function () {
+    let dbResult, codeId;
+    beforeEach(function () {
+      dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      codeId = 'eabba58e-2633-11ed-bbad-0025905f714a';
+      sinon.stub(db.statement.tokenRefreshRevokeByCodeId, 'run');
+    });
+    it('success', function () {
+      db.statement.tokenRefreshRevokeByCodeId.run.returns(dbResult);
+      db.tokenRefreshRevokeByCodeId(dbCtx, codeId);
+    });
+    it('failure', function () {
+      dbResult.changes = 0;
+      db.statement.tokenRefreshRevokeByCodeId.run.returns(dbResult);
+      assert.throws(() => db.tokenRefreshRevokeByCodeId(dbCtx, codeId), DBErrors.UnexpectedResult);
+    });
+    it('failure, error', function () {
+      const expected = new Error('oh no');
+      db.statement.tokenRefreshRevokeByCodeId.run.throws(expected);
+      assert.throws(() => db.tokenRefreshRevokeByCodeId(dbCtx, codeId), expected);
+    });
+  }); // tokenRefreshRevokeByCodeId
+
+  describe('tokensGetByIdentifier', function () {
+    let identifier;
+    beforeEach(function  () {
+      identifier = 'identifier';
+      sinon.stub(db.statement.tokensGetByIdentifier, 'all');
+    });
+    it('success', function () {
+      const nowEpoch = Math.ceil(Date.now() / 1000);
+      const dbResult = [
+        {
+          created: nowEpoch,
+          expires: nowEpoch + 86400,
+          duration: 86400,
+          refreshed: nowEpoch + 600,
+          refreshExpires: nowEpoch + 172800,
+          isRevoked: false,
+          isToken: true,
+          codeId: 'c0a7cef4-2637-11ed-a830-0025905f714a',
+          profile: 'https://profile.example.com/',
+          profileData: '{"name":"Some Name"}',
+          identifier: 'username',
+        },
+      ];
+      const expected = [
+        Object.assign({}, dbResult[0], {
+          created: new Date(dbResult[0].created * 1000),
+          expires: new Date(dbResult[0].expires * 1000),
+          refreshed: new Date(dbResult[0].refreshed * 1000),
+          refreshExpires: new Date(dbResult[0].refreshExpires * 1000),
+          profileData: {
+            name: 'Some Name',
+          },
+        }),
+      ];
+      db.statement.tokensGetByIdentifier.all.returns(dbResult);
+      const result = db.tokensGetByIdentifier(dbCtx, identifier);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', function() {
+      db.statement.tokensGetByIdentifier.all.throws(expectedException);
+      assert.throws(() => db.tokensGetByIdentifier(dbCtx, identifier), expectedException);
+    });
+  }); // tokensGetByIdentifier
+
+}); // DatabaseSQLite
diff --git a/test/src/errors.js b/test/src/errors.js
new file mode 100644 (file)
index 0000000..509f3b1
--- /dev/null
@@ -0,0 +1,15 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const Errors = require('../../src/errors');
+
+describe('Errors', function () {
+  describe('ValidationError', function () {
+    it('covers', function () {
+      const e = new Errors.ValidationError('message');
+      assert.strictEqual(e.name, 'ValidationError');
+      assert.strictEqual(e.stack, undefined);
+    });
+  });
+}); // Errors
\ No newline at end of file
diff --git a/test/src/logger.js b/test/src/logger.js
new file mode 100644 (file)
index 0000000..3441f94
--- /dev/null
@@ -0,0 +1,136 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+const Logger = require('../../src/logger');
+const Config = require('../../config');
+
+describe('Logger', function () {
+  let config;
+  let logger;
+
+  beforeEach(function () {
+    config = new Config('test');
+    logger = new Logger(config);
+    Object.keys(Logger.nullLogger).forEach((level) => sinon.stub(logger.backend, level));
+  });
+
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  it('logs', function () {
+    logger.info('testScope', 'message', { baz: 'quux' }, { foo: 1 }, 'more other');
+    assert(logger.backend.info.called);
+  });
+
+  it('logs BigInts', function () {
+    logger.info('testScope', 'message', { aBigInteger: BigInt(2) });
+    assert(logger.backend.info.called);
+    assert(logger.backend.info.args[0][0].includes('"2"'));
+  });
+
+  it('logs Errors', function () {
+    logger.error('testScope', 'message', { e: new Error('an error') });
+    assert(logger.backend.error.called);
+    assert(logger.backend.error.args[0][0].includes('an error'));
+  });
+
+  it('masks credentials', function () {
+    logger.info('testScope', 'message', {
+      ctx: {
+        parsedBody: {
+          identity: 'username',
+          credential: 'password',
+        },
+      },
+    });
+    assert(logger.backend.info.called);
+    assert(logger.backend.info.args[0][0].includes('"username"'));
+    assert(logger.backend.info.args[0][0].includes('"********"'));
+  });
+
+  it('strips uninteresting scope dross', function () {
+    logger.info('testScope', 'message', {
+      ctx: {
+        profilesScopes: {
+          profileScopes: {
+            'https://thuza.ratfeathers.com/': {
+              profile: {
+                description: 'Access detailed profile information, including name, image, and url.',
+                application: 'IndieAuth',
+                profiles: [
+                  'https://thuza.ratfeathers.com/',
+                ],
+                isPermanent: true,
+                isManuallyAdded: false,
+              },
+            },
+          },
+          scopeIndex: {
+            profile: {
+              description: 'Access detailed profile information, including name, image, and url.',
+              application: 'IndieAuth',
+              profiles: [
+                'https://thuza.ratfeathers.com/',
+              ],
+              isPermanent: true,
+              isManuallyAdded: false,
+            },
+            email: {
+              description: 'Include email address with detailed profile information.',
+              application: 'IndieAuth',
+              profiles: [],
+              isPermanent: true,
+              isManuallyAdded: false,
+            },
+          },
+        },
+      },
+    });
+    assert(logger.backend.info.called);
+  });
+
+  it('strips uninteresting scope dross from session', function () {
+    logger.info('testScope', 'message', {
+      ctx: {
+        session: {
+          profileScopes: {
+            'https://thuza.ratfeathers.com/': {
+              profile: {
+                description: 'Access detailed profile information, including name, image, and url.',
+                application: 'IndieAuth',
+                profiles: [
+                  'https://thuza.ratfeathers.com/',
+                ],
+                isPermanent: true,
+                isManuallyAdded: false,
+              },
+            },
+          },
+          scopeIndex: {
+            profile: {
+              description: 'Access detailed profile information, including name, image, and url.',
+              application: 'IndieAuth',
+              profiles: [
+                'https://thuza.ratfeathers.com/',
+              ],
+              isPermanent: true,
+              isManuallyAdded: false,
+            },
+            email: {
+              description: 'Include email address with detailed profile information.',
+              application: 'IndieAuth',
+              profiles: [],
+              isPermanent: true,
+              isManuallyAdded: false,
+            },
+          },
+        },
+      },
+    });
+    assert(logger.backend.info.called);
+  });
+
+}); // Logger
diff --git a/test/src/manager.js b/test/src/manager.js
new file mode 100644 (file)
index 0000000..7336e1f
--- /dev/null
@@ -0,0 +1,2323 @@
+/* eslint-env mocha */
+/* eslint-disable capitalized-comments, sonarjs/no-duplicate-string, sonarjs/no-identical-functions */
+
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+
+const Manager = require('../../src/manager');
+const Config = require('../../config');
+const Enum = require('../../src/enum');
+const { ResponseError } = require('../../src/errors');
+const { UnexpectedResult } = require('../../src/db/errors');
+const dns = require('dns');
+
+const StubDatabase = require('../stub-db');
+const StubLogger = require('../stub-logger');
+
+const expectedException = new Error('oh no');
+const noExpectedException = 'did not get expected exception';
+
+describe('Manager', function () {
+  let manager, options, stubDb, logger;
+  let req, res, ctx;
+
+  beforeEach(function () {
+    logger = new StubLogger();
+    logger._reset();
+    stubDb = new StubDatabase();
+    stubDb._reset();
+    options = new Config('test');
+    req = {
+      getHeader : sinon.stub(),
+    };
+    res = {
+      end: sinon.stub(),
+      setHeader: sinon.stub(),
+      statusCode: 200,
+    };
+    ctx = {
+      params: {},
+      parsedBody: {},
+      queryParams: {},
+      session: {},
+      errors: [],
+      notifications: [],
+    };
+    manager = new Manager(logger, stubDb, options);
+    sinon.stub(manager.communication, 'fetchProfile');
+    sinon.stub(manager.communication, 'fetchClientIdentifier');
+    sinon.stub(manager.communication, 'deliverTicket');
+    sinon.stub(dns, 'lookupAsync').resolves([{ family: 4, address: '10.11.12.13' }]);
+    sinon.stub(manager.queuePublisher, 'connect');
+    sinon.stub(manager.queuePublisher, 'establishAMQPPlumbing');
+    sinon.stub(manager.queuePublisher, 'publish');
+  });
+
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  describe('constructor', function () {
+    it('instantiates', function () {
+      assert(manager);
+    });
+    it('covers no queuing', function () {
+      options.queues.amqp.url = undefined;
+      manager = new Manager(logger, stubDb, options);
+      assert(manager);
+    });
+  }); // constructor
+
+  describe('initialize', function () {
+    let spy;
+    beforeEach(function () {
+      spy = sinon.spy(manager, '_connectQueues');
+    });
+    it('covers', async function () {
+      await manager.initialize();
+      assert(spy.called);
+    });
+    it('covers no queue', async function () {
+      delete options.queues.amqp.url;
+      manager = new Manager(logger, stubDb, options);
+      await manager.initialize();
+      assert(spy.notCalled);
+    });
+  }); // initialize
+
+  describe('getRoot', function () {
+    it('normal response', async function () {
+      await manager.getRoot(res, ctx);
+      assert(res.end.called);
+    });
+  }); // getRoot
+
+  describe('getMeta', function () {
+    it('normal response', async function () {
+      await manager.getMeta(res, ctx);
+      assert(res.end.called);
+      JSON.parse(res.end.args[0][0]);
+    });
+    it('covers no ticket queue', async function () {
+      delete options.queues.amqp.url;
+      manager = new Manager(logger, stubDb, options);
+      await manager.getMeta(res, ctx);
+      assert(res.end.called);
+    });
+  }); // getMeta
+
+  describe('getHealthcheck', function () {
+    it('normal response', async function () {
+      await manager.getHealthcheck(res, ctx);
+      assert(res.end.called);
+    });
+  }); // getHealthcheck
+
+  describe('getAuthorization', function () {
+    it('covers missing redirect fields', async function () {
+      await manager.getAuthorization(res, ctx);
+      assert.strictEqual(res.statusCode, 400);
+    });
+    it('requires a configured profile', async function () {
+      manager.db.profilesScopesByIdentifier.resolves({
+        profileScopes: {
+        },
+        scopeIndex: {
+          'profile': {
+            description: '',
+            profiles: [],
+          },
+          'email': {
+            description: '',
+            profiles: [],
+          },
+        },
+        profiles: [],
+      });
+      manager.communication.fetchClientIdentifier.resolves({
+        items: [],
+      });
+      ctx.authenticationId = 'username';
+      Object.assign(ctx.queryParams, {
+        'client_id': 'https://client.example.com/',
+        'redirect_uri': 'https://client.example.com/action',
+        'response_type': 'code',
+        'state': '123456',
+        'code_challenge_method': 'S256',
+        'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog',
+        'scope': 'profile email',
+      });
+      await manager.getAuthorization(res, ctx);
+      assert.strictEqual(res.statusCode, 302);
+      assert(ctx.session.error);
+      assert(res.setHeader.called);
+    });
+    it('covers valid', async function () {
+      manager.db.profilesScopesByIdentifier.resolves({
+        profileScopes: {
+          'https://profile.example.com/': {
+            'create': {
+              description: '',
+              profiles: ['https://profile.example.com'],
+            },
+          },
+        },
+        scopeIndex: {
+          'profile': {
+            description: '',
+            profiles: [],
+          },
+          'email': {
+            description: '',
+            profiles: [],
+          },
+          'create': {
+            description: '',
+            profiles: ['https://profile.example.com/'],
+          },
+        },
+        profiles: ['https://profile.example.com/'],
+      });
+      manager.communication.fetchClientIdentifier.resolves({
+        items: [],
+      });
+      ctx.authenticationId = 'username';
+      Object.assign(ctx.queryParams, {
+        'client_id': 'https://client.example.com/',
+        'redirect_uri': 'https://client.example.com/action',
+        'response_type': 'code',
+        'state': '123456',
+        'code_challenge_method': 'S256',
+        'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog',
+        'scope': 'profile email',
+        'me': 'https://profile.example.com/',
+      });
+      await manager.getAuthorization(res, ctx);
+      assert.strictEqual(res.statusCode, 200);
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.strictEqual(ctx.session.errorDescriptions.length, 0);
+      assert.strictEqual(ctx.notifications.length, 0);
+    });
+    it('succeeds with mismatched profile hint', async function () {
+      manager.db.profilesScopesByIdentifier.resolves({
+        profileScopes: {
+          'https://profile.example.com/': {
+            'create': {
+              description: '',
+              profiles: ['https://profile.example.com'],
+            },
+          },
+        },
+        scopeIndex: {
+          'profile': {
+            description: '',
+            profiles: [],
+          },
+          'email': {
+            description: '',
+            profiles: [],
+          },
+          'create': {
+            description: '',
+            profiles: ['https://profile.example.com/'],
+          },
+        },
+        profiles: ['https://profile.example.com/'],
+      });
+      manager.communication.fetchClientIdentifier.resolves({
+        items: [],
+      });
+      ctx.authenticationId = 'username';
+      Object.assign(ctx.queryParams, {
+        'client_id': 'https://client.example.com/',
+        'redirect_uri': 'https://client.example.com/action',
+        'response_type': 'code',
+        'state': '123456',
+        'code_challenge_method': 'S256',
+        'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog',
+        'scope': 'profile email',
+        'me': 'https://somethingelse.example.com/',
+      });
+      await manager.getAuthorization(res, ctx);
+      assert(!('me' in ctx.session));
+      assert.strictEqual(res.statusCode, 200);
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.strictEqual(ctx.session.errorDescriptions.length, 0);
+    });
+    it('covers invalid redirect', async function () {
+      manager.db.profilesScopesByIdentifier.resolves({
+        profileScopes: {
+          'https://profile.example.com/': {
+            'create': {
+              description: '',
+              profiles: ['https://profile.example.com'],
+            },
+          },
+        },
+        scopeIndex: {
+          'profile': {
+            description: '',
+            profiles: [],
+          },
+          'email': {
+            description: '',
+            profiles: [],
+          },
+          'create': {
+            description: '',
+            profiles: ['https://profile.example.com/'],
+          },
+        },
+        profiles: ['https://profile.example.com/'],
+      });
+      manager.communication.fetchClientIdentifier.resolves({
+        items: [],
+      });
+      ctx.authenticationId = 'username';
+      Object.assign(ctx.queryParams, {
+        'client_id': 'https://client.example.com/',
+        'redirect_uri': 'https://client.example.com/action',
+        'response_type': 'blargl',
+        'state': '',
+        'code_challenge_method': 'S256',
+        'code_challenge': 'IZ9Jmupp0tvhT37e1KxfSZQXwcAGKHuVE51Z3xf5eog',
+      });
+      await manager.getAuthorization(res, ctx);
+      assert.strictEqual(res.statusCode, 302);
+      assert.strictEqual(ctx.session.error, 'invalid_request');
+      assert.strictEqual(ctx.session.errorDescriptions.length, 2);
+    });
+    it('covers legacy non-PKCE missing fields', async function () {
+      manager.db.profilesScopesByIdentifier.resolves({
+        profileScopes: {
+          'https://profile.example.com/': {
+            'create': {
+              description: '',
+              profiles: ['https://profile.example.com'],
+            },
+          },
+        },
+        scopeIndex: {
+          'profile': {
+            description: '',
+            profiles: [],
+          },
+          'email': {
+            description: '',
+            profiles: [],
+          },
+          'create': {
+            description: '',
+            profiles: ['https://profile.example.com/'],
+          },
+        },
+        profiles: ['https://profile.example.com/'],
+      });
+      manager.communication.fetchClientIdentifier.resolves({
+        items: [],
+      });
+      ctx.authenticationId = 'username';
+      Object.assign(ctx.queryParams, {
+        'client_id': 'https://client.example.com/',
+        'redirect_uri': 'https://client.example.com/action',
+        'response_type': 'code',
+        'state': '123456',
+        'scope': 'profile email',
+        'me': 'https://profile.example.com/',
+      });
+      manager.options.manager.allowLegacyNonPKCE = true;
+
+      await manager.getAuthorization(res, ctx);
+      assert.strictEqual(res.statusCode, 200);
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.strictEqual(ctx.session.errorDescriptions.length, 0);
+    });
+    it('rejects legacy non-PKCE not missing all fields', async function () {
+      manager.db.profilesScopesByIdentifier.resolves({
+        profileScopes: {
+          'https://profile.example.com/': {
+            'create': {
+              description: '',
+              profiles: ['https://profile.example.com'],
+            },
+          },
+        },
+        scopeIndex: {
+          'profile': {
+            description: '',
+            profiles: [],
+          },
+          'email': {
+            description: '',
+            profiles: [],
+          },
+          'create': {
+            description: '',
+            profiles: ['https://profile.example.com/'],
+          },
+        },
+        profiles: ['https://profile.example.com/'],
+      });
+      manager.communication.fetchClientIdentifier.resolves({
+        items: [],
+      });
+      ctx.authenticationId = 'username';
+      Object.assign(ctx.queryParams, {
+        'client_id': 'https://client.example.com/',
+        'redirect_uri': 'https://client.example.com/action',
+        'response_type': 'code',
+        'code_challenge_method': 'S256',
+        'state': '123456',
+        'scope': 'profile email',
+        'me': 'https://profile.example.com/',
+      });
+      manager.options.manager.allowLegacyNonPKCE = true;
+
+      await manager.getAuthorization(res, ctx);
+      assert.strictEqual(res.statusCode, 302);
+      assert.strictEqual(ctx.session.error, 'invalid_request');
+      assert.strictEqual(ctx.session.errorDescriptions.length, 1);
+    });
+    it('rejects legacy non-PKCE not missing all fields', async function () {
+      manager.db.profilesScopesByIdentifier.resolves({
+        profileScopes: {
+          'https://profile.example.com/': {
+            'create': {
+              description: '',
+              profiles: ['https://profile.example.com'],
+            },
+          },
+        },
+        scopeIndex: {
+          'profile': {
+            description: '',
+            profiles: [],
+          },
+          'email': {
+            description: '',
+            profiles: [],
+          },
+          'create': {
+            description: '',
+            profiles: ['https://profile.example.com/'],
+          },
+        },
+        profiles: ['https://profile.example.com/'],
+      });
+      manager.communication.fetchClientIdentifier.resolves({
+        items: [],
+      });
+      ctx.authenticationId = 'username';
+      Object.assign(ctx.queryParams, {
+        'client_id': 'https://client.example.com/',
+        'redirect_uri': 'https://client.example.com/action',
+        'response_type': 'code',
+        'code_challenge': 'xxx',
+        'state': '123456',
+        'scope': 'profile email',
+        'me': 'https://profile.example.com/',
+      });
+      manager.options.manager.allowLegacyNonPKCE = true;
+
+      await manager.getAuthorization(res, ctx);
+      assert.strictEqual(res.statusCode, 302);
+      assert.strictEqual(ctx.session.error, 'invalid_request');
+      assert.strictEqual(ctx.session.errorDescriptions.length, 1);
+    });  }); // getAuthorization
+
+  describe('_setError', function () {
+    it('covers', function () {
+      const err = 'invalid_request';
+      const errDesc = 'something went wrong';
+      Manager._setError(ctx, err, errDesc);
+    });
+    it('covers bad error', function () {
+      const err = 'floopy';
+      const errDesc = 'something went wrong';
+      try {
+        Manager._setError(ctx, err, errDesc);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof RangeError);
+      }
+    });
+    it('covers invalid error description', function () {
+      const err = 'invalid_scope';
+      const errDesc = 'something "went wrong"!';
+      try {
+        Manager._setError(ctx, err, errDesc);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof RangeError);
+      }
+    });
+  }); // _setError
+
+  describe('_clientIdRequired', function () {
+    let clientIdentifier;
+    beforeEach(function () {
+      clientIdentifier = {
+        // h-card here
+      };
+      manager.communication.fetchClientIdentifier.resolves(clientIdentifier);
+    });
+    it('covers valid', async function () {
+      ctx.queryParams['client_id'] = 'https://client.example.com/';
+
+      await manager._clientIdRequired(ctx);
+
+      assert.deepStrictEqual(ctx.session.clientIdentifier, clientIdentifier);
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+    });
+    it('requires client_id', async function () {
+      ctx.queryParams['client_id'] = undefined;
+
+      await manager._clientIdRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('requires valid client_id', async function () {
+      ctx.queryParams['client_id'] = 'not a url';
+
+      await manager._clientIdRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('rejects strange schema', async function () {
+      ctx.queryParams['client_id'] = 'file:///etc/shadow';
+
+      await manager._clientIdRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('rejects un-allowed parts', async function () {
+      ctx.queryParams['client_id'] = 'https://user:pass@client.example.com/#here';
+
+      await manager._clientIdRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('rejects relative paths', async function () {
+      ctx.queryParams['client_id'] = 'https://client.example.com/x/../y/';
+
+      await manager._clientIdRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('rejects ipv6 hostname', async function () {
+      ctx.queryParams['client_id'] = 'https://[fd12:3456:789a:1::1]/';
+
+      await manager._clientIdRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('allows ipv6 loopback hostname', async function () {
+      ctx.queryParams['client_id'] = 'https://[::1]/';
+
+      await manager._clientIdRequired(ctx);
+
+      assert.deepStrictEqual(ctx.session.clientIdentifier, clientIdentifier);
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+    });
+    it('rejects ipv4 hostname', async function () {
+      ctx.queryParams['client_id'] = 'https://10.9.8.7/';
+
+      await manager._clientIdRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('allows ipv4 loopback hostname', async function () {
+      ctx.queryParams['client_id'] = 'https:/127.0.10.100/';
+
+      await manager._clientIdRequired(ctx);
+
+      assert.deepStrictEqual(ctx.session.clientIdentifier, clientIdentifier);
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+    });
+    it('requires response', async function () {
+      manager.communication.fetchClientIdentifier.restore();
+      sinon.stub(manager.communication, 'fetchClientIdentifier').resolves();
+      ctx.queryParams['client_id'] = 'https://client.example.com/';
+
+      await manager._clientIdRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+  }); // _clientIdRequired
+
+  describe('_redirectURIRequired', function () {
+    beforeEach(function () {
+      ctx.session.clientId = new URL('https://client.example.com/');
+      ctx.session.clientIdentifier = {
+        rels: {
+          'redirect_uri': ['https://alternate.example.com/', 'https://other.example.com/'],
+        },
+      };
+    });
+    it('covers valid', function () {
+      ctx.queryParams['redirect_uri'] = 'https://client.example.com/return';
+  
+      Manager._redirectURIRequired(ctx);
+
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+    });
+    it('requires redirect_uri', function () {
+      ctx.queryParams['redirect_uri'] = undefined;
+  
+      Manager._redirectURIRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('requires valid redirect_uri', function () {
+      ctx.queryParams['redirect_uri'] = 'not a url';
+  
+      Manager._redirectURIRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('rejects no matching alternate redirect_uri from client_id', function () {
+      ctx.queryParams['redirect_uri'] = 'https://unlisted.example.com/';
+  
+      Manager._redirectURIRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('allows alternate redirect_uri from client_id', function () {
+      ctx.queryParams['redirect_uri'] = 'https://alternate.example.com/';
+  
+      Manager._redirectURIRequired(ctx);
+
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+    });
+  }); // _redirectURIRequired
+
+  describe('_responseTypeRequired', function () {
+    it('covers valid', function () {
+      ctx.queryParams['response_type'] = 'code';
+
+      Manager._responseTypeRequired(ctx);
+
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+    });
+    it('requires response_type', function () {
+      ctx.queryParams['response_type'] = undefined;
+
+      Manager._responseTypeRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('rejects invalid', function () {
+      ctx.queryParams['response_type'] = 'flarp';
+
+      Manager._responseTypeRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+  }); // _responseTypeRequired
+
+  describe('_stateRequired', function () {
+    it('covers valid', function () {
+      ctx.queryParams['state'] = 'StateStateState';
+
+      Manager._stateRequired(ctx);
+
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+    });
+    it('requires state', function () {
+      ctx.queryParams['state'] = undefined;
+
+      Manager._stateRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+  }); // _stateRequired
+
+  describe('_codeChallengeMethodRequired', function () {
+    it('covers valid', function () {
+      ctx.queryParams['code_challenge_method'] = 'S256';
+
+      manager._codeChallengeMethodRequired(ctx);
+
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+    });
+    it('requires code_challenge_method', function () {
+      ctx.queryParams['code_challenge_method'] = undefined;
+
+      manager._codeChallengeMethodRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('rejects invalid', function () {
+      ctx.queryParams['code_challenge_method'] = 'MD5';
+
+      manager._codeChallengeMethodRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('covers legacy non-PKCE', function () {
+      ctx.queryParams['code_challenge_method'] = undefined;
+      manager.options.manager.allowLegacyNonPKCE = true;
+
+      manager._codeChallengeMethodRequired(ctx);
+
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+    });
+  }); // _codeChallengeMethodRequired
+
+  describe('_codeChallengeRequired', function () {
+    it('covers valid', function () {
+      ctx.queryParams['code_challenge'] = 'NBKNqs1TfjQFqpewPNOstmQ5MJnLoeTTbjqtQ9JbZOo';
+
+      manager._codeChallengeRequired(ctx);
+
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+    });
+    it('requires code_challenge', function () {
+      ctx.queryParams['code_challenge'] = undefined;
+
+      manager._codeChallengeRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('rejects invalid', function () {
+      ctx.queryParams['code_challenge'] = 'not base64/url encoded';
+
+      manager._codeChallengeRequired(ctx);
+
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('covers legacy non-PKCE', function () {
+      ctx.queryParams['code_challenge'] = undefined;
+      manager.options.manager.allowLegacyNonPKCE = true;
+
+      manager._codeChallengeRequired(ctx);
+
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+
+    });
+  }); // _codeChallengeRequired
+
+  describe('_redirectURIRequired', function () {
+    beforeEach(function () {
+      sinon.stub(Manager, '_setError');
+      ctx.queryParams['redirect_uri'] = 'https://example.com/redirect';
+      ctx.session.clientId = new URL('https://example.com/');
+    });
+    it('requires redirect_uri', function () {
+      delete ctx.queryParams['redirect_uri'];
+      Manager._redirectURIRequired(ctx);
+      assert(Manager._setError.called);
+    });
+    it('requires valid redirect_uri', function () {
+      ctx.queryParams['redirect_uri'] = 'not a uri';
+      Manager._redirectURIRequired(ctx);
+      assert(Manager._setError.called);
+    });
+    it('sets redirectUri if no clientId', function () {
+      delete ctx.session.clientId;
+      Manager._redirectURIRequired(ctx);
+      assert(Manager._setError.notCalled);
+      assert(ctx.session.redirectUri instanceof URL);
+    });
+    it('sets redirectUri if clientId matches', function () {
+      Manager._redirectURIRequired(ctx);
+      assert(Manager._setError.notCalled);
+      assert(ctx.session.redirectUri instanceof URL);
+    });
+    it('rejects mis-matched', function () {
+      ctx.queryParams['redirect_uri'] = 'https://example.com:8080/redirect';
+      Manager._redirectURIRequired(ctx);
+      assert(Manager._setError.called);
+      assert.strictEqual(ctx.session.redirectUri, undefined);
+    });
+    it('allows client-specified alternate redirect uri', function () {
+      ctx.session.clientIdentifier = {
+        rels: {
+          'redirect_uri': ['https://alternate.example.com/redirect'],
+        },
+      };
+      ctx.queryParams['redirect_uri'] = 'https://alternate.example.com/redirect';
+      Manager._redirectURIRequired(ctx);
+      assert(Manager._setError.notCalled);
+      assert(ctx.session.redirectUri instanceof URL);
+    });
+  }); // _redirectURIRequired
+
+  describe('_scopeOptional', function () {
+    it('covers valid', function () {
+      ctx.queryParams['scope'] = 'profile email';
+      manager._scopeOptional(ctx);
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+      assert.strictEqual(ctx.session.scope.length, 2);
+    });
+    it('allows empty', function () {
+      ctx.queryParams['scope'] = undefined;
+      manager._scopeOptional(ctx);
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+      assert.strictEqual(ctx.session.scope.length, 0);
+    });
+    it('rejects invalid scope combination', function () {
+      ctx.queryParams['scope'] = 'email';
+      manager._scopeOptional(ctx);
+      assert(ctx.session.error);
+      assert(ctx.session.errorDescriptions.length);
+    });
+    it('ignores invalid scope', function () {
+      ctx.queryParams['scope'] = 'profile email "funny_business"';
+      manager._scopeOptional(ctx);
+      assert.strictEqual(ctx.session.error, undefined);
+      assert.deepStrictEqual(ctx.session.errorDescriptions, undefined);
+      assert.strictEqual(ctx.session.scope.length, 2);
+    });
+  }); // _scopeOptional
+
+  describe('_meOptional', function () {
+    this.beforeEach(function () {
+      ctx.queryParams['me'] = 'https://profile.example.com/';
+    });
+    it('covers valid', async function () {
+      await manager._meOptional(ctx);
+
+      assert.strictEqual(ctx.session.me.href, ctx.queryParams['me']);
+    });
+    it('ignore invalid', async function () {
+      ctx.queryParams['me'] = 'not a url';
+
+      await manager._meOptional(ctx);
+
+      assert.strictEqual(ctx.session.me, undefined);
+    });
+    it('allows empty', async function () {
+      ctx.queryParams['me'] = undefined;
+
+      await manager._meOptional(ctx);
+
+      assert.strictEqual(ctx.session.me, undefined);
+    });
+  }); // _meOptional
+
+  describe('_profileValidForIdentifier', function () {
+    beforeEach(function () {
+      ctx.session = {
+        profiles: ['https://profile.example.com/', 'https://example.com/profile'],
+        me: new URL('https://example.com/profile'),
+      };
+    });
+    it('covers valid', async function () {
+
+      const result = await manager._profileValidForIdentifier(ctx);
+
+      assert.strictEqual(result, true);
+    });
+    it('covers missing me', async function () {
+      delete ctx.session.me;
+
+      const result = await manager._profileValidForIdentifier(ctx);
+
+      assert.strictEqual(result, false);
+    });
+  }); // _profileValidForIdentifier
+
+  describe('_parseLifespan', function () {
+    let field, customField;
+    beforeEach(function () {
+      field = 'lifespan';
+      customField = 'lifespan-seconds';
+      ctx.parsedBody['lifespan'] = undefined;
+      ctx.parsedBody['lifespan-seconds'] = undefined; 
+    });
+    it('returns nothing without fields', function () {
+      const result = manager._parseLifespan(ctx, field, customField);
+      assert.strictEqual(result, undefined);
+    });
+    it('returns nothing for unrecognized field', function () {
+      ctx.parsedBody['lifespan'] = 'a while';
+      const result = manager._parseLifespan(ctx, field, customField);
+      assert.strictEqual(result, undefined);
+    });
+    it('returns recognized preset value', function () {
+      ctx.parsedBody['lifespan'] = '1d';
+      const result = manager._parseLifespan(ctx, field, customField);
+      assert.strictEqual(result, 86400);
+    });
+    it('returns valid custom value', function () {
+      ctx.parsedBody['lifespan'] = 'custom';
+      ctx.parsedBody['lifespan-seconds'] = '123'; 
+      const result = manager._parseLifespan(ctx, field, customField);
+      assert.strictEqual(result, 123);
+    });
+    it('returns nothing for invalid custom value', function () {
+      ctx.parsedBody['lifespan'] = 'custom';
+      ctx.parsedBody['lifespan-seconds'] = 'Not a number'; 
+      const result = manager._parseLifespan(ctx, field, customField);
+      assert.strictEqual(result, undefined);
+    });
+    it('returns nothing for invalid custom value', function () {
+      ctx.parsedBody['lifespan'] = 'custom';
+      ctx.parsedBody['lifespan-seconds'] = '-50'; 
+      const result = manager._parseLifespan(ctx, field, customField);
+      assert.strictEqual(result, undefined);
+    });
+  }); // _parseLifespan
+
+  describe('_parseConsentScopes', function () {
+    it('covers no scopes', function () {
+      const result = manager._parseConsentScopes(ctx);
+      assert.deepStrictEqual(result, []);
+    });
+    it('filters invalid scopes', function () {
+      ctx.parsedBody['accepted_scopes'] = ['read', 'email'];
+      ctx.parsedBody['ad_hoc_scopes'] = 'bad"scope  create ';
+      const result = manager._parseConsentScopes(ctx);
+      assert.deepStrictEqual(result, ['read', 'create']);
+    });
+  }); // _parseConsentScopes
+
+  describe('_parseConsentMe', function () {
+    beforeEach(function () {
+      ctx.session.profiles = ['https://me.example.com/'];
+    });
+    it('covers valid', function () {
+      const expected = 'https://me.example.com/';
+      ctx.parsedBody['me'] = expected;
+      const result = manager._parseConsentMe(ctx);
+      assert(result);
+      assert.strictEqual(result.href, expected);
+    });
+    it('rejects unsupported', function () {
+      ctx.parsedBody['me'] = 'https://notme.example.com/';
+      const result = manager._parseConsentMe(ctx);
+      assert(!result);
+      assert(ctx.session.error);
+    });
+    it('rejects invalid', function () {
+      ctx.parsedBody['me'] = 'bagel';
+      const result = manager._parseConsentMe(ctx);
+      assert(!result);
+      assert(ctx.session.error);
+    });
+  }); // _parseConsentMe
+
+  describe('_fetchConsentProfileData', function () {
+    let profileResponse;
+    beforeEach(function () {
+      profileResponse = {
+        url: 'https://profile.example.com/',
+      };
+      manager.communication.fetchProfile.resolves(profileResponse);
+    });
+    it('covers success', async function () {
+      const expected = profileResponse;
+      const result = await manager._fetchConsentProfileData(ctx);
+      assert.deepStrictEqual(result, expected);
+      assert(!ctx.session.error);
+    });
+    it('covers empty response', async function () {
+      manager.communication.fetchProfile.resolves();
+      const result = await manager._fetchConsentProfileData(ctx);
+      assert.deepStrictEqual(result, undefined);
+      assert(ctx.session.error);
+    });
+    it('covers failure', async function () {
+      manager.communication.fetchProfile.rejects();
+      const result = await manager._fetchConsentProfileData(ctx);
+      assert.deepStrictEqual(result, undefined);
+      assert(ctx.session.error);
+    });
+  }); // _fetchConsentProfileData
+
+  describe('postConsent', function () {
+    let oldSession;
+    beforeEach(function () {
+      sinon.stub(manager.mysteryBox, 'unpack');
+      sinon.stub(manager.mysteryBox, 'pack');
+      manager.communication.fetchProfile.resolves({
+        url: 'https://profile.example.com/',
+      });
+      oldSession = {
+        clientId: 'https://example.com/',
+        redirectUri: 'https://example.com/_redirect',
+        profiles: ['https://profile.example.com/'],
+      };
+      manager.mysteryBox.unpack.resolves(oldSession);
+      ctx.parsedBody['me'] = 'https://profile.example.com/';
+      ctx.parsedBody['accept'] = 'true';
+    });
+    it('covers valid', async function () {
+      await manager.postConsent(res, ctx);
+      assert(!ctx.session.error, ctx.session.error);
+      assert.strictEqual(res.statusCode, 302);
+    });
+    it('covers valid with expiration and refresh', async function () {
+      ctx.parsedBody['expires'] = '1d';
+      ctx.parsedBody['refresh'] = '1w';
+      await manager.postConsent(res, ctx);
+      assert(!ctx.session.error, ctx.session.error);
+      assert.strictEqual(res.statusCode, 302);
+    });
+    it('covers denial', async function () {
+      ctx.parsedBody['accept'] = 'false';
+      await manager.postConsent(res, ctx);
+      assert(ctx.session.error);
+      assert.strictEqual(ctx.session.error, 'access_denied');
+      assert.strictEqual(res.statusCode, 302);
+    });
+    it('covers profile fetch failure', async function () {
+      manager.communication.fetchProfile.resolves();
+      await manager.postConsent(res, ctx);
+      assert.strictEqual(res.statusCode, 302);
+      assert(ctx.session.error);
+    });
+    it('covers bad code', async function () {
+      manager.mysteryBox.unpack.rejects();
+      await manager.postConsent(res, ctx);
+      assert.strictEqual(res.statusCode, 400);
+      assert(ctx.session.error);
+    });
+    it('removes email scope without profile', async function () {
+      ctx.parsedBody['accepted_scopes'] = ['email', 'create'];
+      await manager.postConsent(res, ctx);
+      assert(!ctx.session.acceptedScopes.includes('email'));
+    });
+    it('merges valid ad-hoc scopes', async function () {
+      ctx.parsedBody['accepted_scopes'] = ['email', 'create'];
+      ctx.parsedBody['ad_hoc_scopes'] = '  my:scope  "badScope';
+      await manager.postConsent(res, ctx);
+      assert(ctx.session.acceptedScopes.includes('my:scope'));
+    });
+    it('covers invalid selected me profile', async function () {
+      ctx.parsedBody['me'] = 'https://different.example.com/';
+      await manager.postConsent(res, ctx);
+      assert(ctx.session.error);
+    });
+    it('covers invalid me url', async function () {
+      ctx.parsedBody['me'] = 'bagel';
+      await manager.postConsent(res, ctx);
+      assert(ctx.session.error);
+    });
+    it('covers profile fetch error', async function () {
+      manager.communication.fetchProfile.rejects(expectedException);
+      await manager.postConsent(res, ctx);
+      assert.strictEqual(res.statusCode, 302);
+      assert(ctx.session.error);
+    });
+  }); // postConsent
+
+  describe('postAuthorization', function () {
+    let code, parsedBody;
+    beforeEach(function () {
+      sinon.stub(manager.mysteryBox, 'unpack');
+      code = {
+        codeId: 'cffe1558-35f0-11ec-98bc-0025905f714a',
+        codeChallengeMethod: 'S256',
+        codeChallenge: 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ',
+        clientId: 'https://app.example.com/',
+        redirectUri: 'https://app.example.com/_redirect',
+        acceptedScopes: ['profile'],
+        minted: Date.now(),
+        me: 'https://client.example.com/',
+        identifier: 'username',
+        profile: {
+          name: 'Firsty McLastname',
+          email: 'f.mclastname@example.com',
+        },
+      };
+      parsedBody = {
+        code: 'codeCodeCode',
+        'client_id': 'https://app.example.com/',
+        'redirect_uri': 'https://app.example.com/_redirect',
+        'grant_type': 'authorization_code',
+        'code_verifier': 'verifier',
+      };
+    });
+    it('covers valid', async function () {
+      manager.db.redeemCode.resolves(true);
+      manager.mysteryBox.unpack.resolves(code);
+      Object.assign(ctx.parsedBody, parsedBody);
+
+      await manager.postAuthorization(res, ctx);
+      assert(!ctx.session.error, ctx.session.error);
+      assert(!res.end.firstCall.args[0].includes('email'));
+    });
+    it('includes email if accepted in scope', async function () {
+      code.acceptedScopes = ['profile', 'email'];
+      manager.db.redeemCode.resolves(true);
+      manager.mysteryBox.unpack.resolves(code);
+      Object.assign(ctx.parsedBody, parsedBody);
+
+      await manager.postAuthorization(res, ctx);
+      assert(!ctx.session.error);
+      assert(res.end.firstCall.args[0].includes('email'));
+    });
+    it('fails if already redeemed', async function () {
+      manager.db.redeemCode.resolves(false);
+      manager.mysteryBox.unpack.resolves(code);
+      Object.assign(ctx.parsedBody, parsedBody);
+
+      await manager.postAuthorization(res, ctx);
+      assert(ctx.session.error);
+    });
+    it('covers bad request', async function () {
+      manager.mysteryBox.unpack.rejects(expectedException);
+      Object.assign(ctx.parsedBody, parsedBody);
+
+      await manager.postAuthorization(res, ctx);
+      assert(ctx.session.error);
+    });
+  }); // postAuthorization
+
+  describe('_ingestPostAuthorizationRequest', function () {
+    beforeEach(function () {
+      sinon.stub(manager, '_restoreSessionFromCode');
+      sinon.stub(manager, '_checkSessionMatchingClientId');
+      sinon.stub(manager, '_checkSessionMatchingRedirectUri');
+      sinon.stub(manager, '_checkGrantType');
+      sinon.stub(manager, '_checkSessionMatchingCodeVerifier');
+    });
+    it('covers valid', async function () {
+      manager._restoreSessionFromCode.callsFake((ctx) => {
+        ctx.session = {
+          me: 'https://profile.example.com/',
+          minted: Date.now(),
+        };
+      });
+
+      await manager._ingestPostAuthorizationRequest(ctx);
+      assert(!ctx.session.error);
+    });
+    it('requires data', async function () {
+      delete ctx.parsedBody;
+      await manager._ingestPostAuthorizationRequest(ctx);
+      assert(ctx.session.error);
+    });
+    it('requires me field', async function () {
+      manager._restoreSessionFromCode.callsFake((ctx) => {
+        ctx.session = {
+          minted: Date.now(),
+        };
+      });
+      await manager._ingestPostAuthorizationRequest(ctx);
+      assert(ctx.session.error);
+    });
+    it('requires minted field', async function () {
+      manager._restoreSessionFromCode.callsFake((ctx) => {
+        ctx.session = {
+          me: 'https://profile.example.com/',
+        };
+      });
+      await manager._ingestPostAuthorizationRequest(ctx);
+      assert(ctx.session.error);
+    });
+    it('rejects expired code', async function () {
+      manager._restoreSessionFromCode.callsFake((ctx) => {
+        ctx.session = {
+          me: 'https://profile.example.com/',
+          minted: Date.now() - 86400000,
+        };
+      });
+
+      await manager._ingestPostAuthorizationRequest(ctx);
+      assert(ctx.session.error);
+    });
+  }); // _ingestPostAuthorizationRequest
+
+  describe('_restoreSessionFromCode', function () {
+    let unpackedCode;
+    beforeEach(function () {
+      sinon.stub(manager.mysteryBox, 'unpack');
+      unpackedCode = {
+        codeId: 'cffe1558-35f0-11ec-98bc-0025905f714a',
+        codeChallengeMethod: 'S256',
+        codeChallenge: 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ',
+        clientId: 'https://app.example.com/',
+        redirectUri: 'https://app.example.com/_redirect',
+        acceptedScopes: ['profile'],
+        minted: Date.now(),
+        me: 'https://client.example.com/',
+        identifier: 'username',
+        profile: {
+          name: 'Firsty McLastname',
+          email: 'f.mclastname@example.com',
+        },
+      };
+    });
+    it('covers valid', async function () {
+      ctx.parsedBody['code'] = 'codeCodeCode';
+      manager.mysteryBox.unpack.resolves(unpackedCode);
+      const expected = Object.assign({}, ctx, {
+        session: unpackedCode,
+      });
+      await manager._restoreSessionFromCode(ctx);
+      assert.deepStrictEqual(ctx, expected);
+      assert(!ctx.session.error);
+    });
+    it('requires code', async function () {
+      ctx.parsedBody['code'] = '';
+      manager.mysteryBox.unpack.resolves({
+        me: 'https://example.com/me',
+      });
+      await manager._restoreSessionFromCode(ctx);
+      assert(ctx.session.error);
+    });
+    it('covers invalid code', async function () {
+      ctx.parsedBody['code'] = 'codeCodeCode';
+      manager.mysteryBox.unpack.rejects();
+      await manager._restoreSessionFromCode(ctx);
+      assert(ctx.session.error);
+    });
+    it('covers missing code fields', async function () {
+      ctx.parsedBody['code'] = 'codeCodeCode';
+      delete unpackedCode.clientId;
+      manager.mysteryBox.unpack.resolves(unpackedCode);
+      await manager._restoreSessionFromCode(ctx);
+      assert(ctx.session.error);
+    });
+    it('covers legacy non-PKCE missing fields', async function () {
+      ctx.parsedBody['code'] = 'codeCodeCode';
+      delete unpackedCode.codeChallengeMethod;
+      delete unpackedCode.codeChallenge;
+      manager.mysteryBox.unpack.resolves(unpackedCode);
+      manager.options.manager.allowLegacyNonPKCE = true;
+      const expected = Object.assign({}, ctx, {
+        session: unpackedCode,
+      });
+      await manager._restoreSessionFromCode(ctx);
+      assert.deepStrictEqual(ctx, expected);
+      assert(!ctx.session.error);
+    });
+  }); // _restoreSessionFromCode
+
+  describe('_checkSessionMatchingClientId', function () {
+    it('covers valid', async function () {
+      ctx.session = {
+        clientId: 'https://client.example.com/',
+      };
+      ctx.parsedBody['client_id'] = 'https://client.example.com/';
+
+      manager._checkSessionMatchingClientId(ctx);
+      assert(!ctx.session.error);
+    });
+    it('covers missing', async function () {
+      ctx.session = {
+        clientId: 'https://client.example.com/',
+      };
+      ctx.parsedBody['client_id'] = undefined;
+
+      manager._checkSessionMatchingClientId(ctx);
+      assert(ctx.session.error);
+    });
+    it('covers un-parsable', async function () {
+      ctx.session = {
+        clientId: 'https://client.example.com/',
+      };
+      ctx.parsedBody['client_id'] = 'not a url';
+
+      manager._checkSessionMatchingClientId(ctx);
+      assert(ctx.session.error);
+    });
+    it('covers mismatch', async function () {
+      ctx.session = {
+        clientId: 'https://client.example.com/',
+      };
+      ctx.parsedBody['client_id'] = 'https://otherclient.example.com/';
+
+      manager._checkSessionMatchingClientId(ctx);
+      assert(ctx.session.error);
+    });
+  }); // _checkSessionMatchingClientId
+
+  describe('_checkSessionMatchingRedirectUri', function () {
+    it('covers valid', async function () {
+      ctx.parsedBody['redirect_uri'] = 'https://client.example.com/_redirect';
+      ctx.session.redirectUri = 'https://client.example.com/_redirect';
+
+      manager._checkSessionMatchingRedirectUri(ctx);
+      assert(!ctx.session.error);
+    });
+    it('requires field', async function () {
+      ctx.parsedBody['redirect_uri'] = undefined;
+      ctx.session.redirectUri = 'https://client.example.com/_redirect';
+
+      manager._checkSessionMatchingRedirectUri(ctx);
+      assert(ctx.session.error);
+    });
+    it('requires valid field', async function () {
+      ctx.parsedBody['redirect_uri'] = 'not a url';
+      ctx.session.redirectUri = 'https://client.example.com/_redirect';
+
+      manager._checkSessionMatchingRedirectUri(ctx);
+      assert(ctx.session.error);
+    });
+    it('requires match', async function () {
+      ctx.parsedBody['redirect_uri'] = 'https://client.example.com/other';
+      ctx.session.redirectUri = 'https://client.example.com/_redirect';
+
+      manager._checkSessionMatchingRedirectUri(ctx);
+      assert(ctx.session.error);
+    });
+  }); // _checkSessionMatchingRedirectUri
+
+  describe('_checkGrantType', function () {
+    it('covers valid', async function () {
+      ctx.parsedBody['grant_type'] = 'authorization_code';
+      
+      manager._checkGrantType(ctx);
+      assert(!ctx.session.error);
+    });
+    it('allows missing, because of one client', async function () {
+      ctx.parsedBody['grant_type'] = undefined;
+      
+      manager._checkGrantType(ctx);
+      assert(!ctx.session.error);
+    });
+    it('rejects invalid', async function () {
+      ctx.parsedBody['grant_type'] = 'pigeon_dance';
+      
+      manager._checkGrantType(ctx);
+      assert(ctx.session.error);
+    });
+  }); // _checkGrantType
+
+  describe('_checkSessionMatchingCodeVerifier', function () {
+    it('covers valid', async function () {
+      ctx.parsedBody['code_verifier'] = 'verifier';
+      ctx.session.codeChallengeMethod = 'S256';
+      ctx.session.codeChallenge = 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ';
+
+      manager._checkSessionMatchingCodeVerifier(ctx);
+      assert(!ctx.session.error);
+    });
+    it('requires field', async function () {
+      ctx.parsedBody['code_verifier'] = undefined;
+      ctx.session.codeChallengeMethod = 'S256';
+      ctx.session.codeChallenge = 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ';
+
+      manager._checkSessionMatchingCodeVerifier(ctx);
+      assert(ctx.session.error);
+    });
+    it('requires match', async function () {
+      ctx.parsedBody['code_verifier'] = 'wrongverifier';
+      ctx.session.codeChallengeMethod = 'S256';
+      ctx.session.codeChallenge = 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ';
+
+      manager._checkSessionMatchingCodeVerifier(ctx);
+      assert(ctx.session.error);
+    });
+    it('covers legacy non-PKCE missing fields', async function () {
+      ctx.parsedBody['code_verifier'] = undefined;
+      ctx.session.codeChallengeMethod = undefined;
+      ctx.session.codeChallenge = undefined;
+      manager.options.manager.allowLegacyNonPKCE = true;
+
+      manager._checkSessionMatchingCodeVerifier(ctx);
+      assert(!ctx.session.error);
+    });
+  }); // _checkSessionMatchingCodeVerifier
+
+  describe('postToken', function () {
+    let unpackedCode;
+    beforeEach(function () {
+      ctx.session.acceptedScopes = [];
+      unpackedCode = {
+        codeId: 'cffe1558-35f0-11ec-98bc-0025905f714a',
+        codeChallengeMethod: 'S256',
+        codeChallenge: 'iMnq5o6zALKXGivsnlom_0F5_WYda32GHkxlV7mq7hQ',
+        clientId: 'https://app.example.com/',
+        redirectUri: 'https://app.example.com/return',
+        acceptedScopes: ['profile', 'email', 'tricks'],
+        minted: Date.now(),
+        me: 'https://client.example.com/',
+        identifier: 'username',
+        profile: {
+          name: 'Firsty McLastname',
+          email: 'f.mclastname@example.com',
+          url: 'https://example.com/',
+        },
+      };
+    });
+    describe('Revocation (legacy)', function () {
+      beforeEach(function () {
+        sinon.stub(manager, '_revokeToken');
+      });
+      it('covers revocation', async function () {
+        manager._revokeToken.resolves();
+        ctx.parsedBody = {
+          action: 'revoke',
+          token: 'XXX',
+        };
+        await manager.postToken(req, res, ctx);
+        assert(manager._revokeToken.called);
+      });
+    }); // Revocation
+    describe('Validation (legacy)', function () {
+      beforeEach(function () {
+        sinon.stub(manager, '_validateToken');
+        req.getHeader.returns({ Authorization: 'Bearer XXX' });
+      });
+      it('covers validation', async function () {
+        ctx.bearer = { isValid: true };
+        await manager.postToken(req, res, ctx);
+        assert(manager._validateToken.called);
+      });
+    }); // Validation
+    describe('Refresh', function () {
+      beforeEach(function () {
+        sinon.stub(manager, '_refreshToken');
+      });
+      it('covers refresh', async function () {
+        ctx.parsedBody['grant_type'] = 'refresh_token';
+        await manager.postToken(req, res, ctx);
+        assert(manager._refreshToken.called);
+      });
+    }); // Refresh
+    describe('Ticket Redemption', function () {
+      beforeEach(function () {
+        sinon.stub(manager, '_ticketAuthToken');
+      });
+      it('covers ticket', async function () {
+        ctx.parsedBody['grant_type'] = 'ticket';
+        await manager.postToken(req, res, ctx);
+        assert(manager._ticketAuthToken.called);
+      });
+      it('covers no ticket queue', async function () {
+        delete options.queues.amqp.url;
+        manager = new Manager(logger, stubDb, options);
+        sinon.stub(manager.communication, 'fetchProfile');
+        sinon.stub(manager.communication, 'fetchClientIdentifier');
+        sinon.stub(manager.communication, 'deliverTicket');
+
+        ctx.parsedBody['grant_type'] = 'ticket';
+        await assert.rejects(() => manager.postToken(req, res, ctx), ResponseError);
+      });
+    }); // Ticket Redemption
+    describe('Code Redemption', function () {
+      beforeEach(function () {
+        sinon.stub(manager.mysteryBox, 'unpack');
+        sinon.spy(manager.mysteryBox, 'pack');
+        manager.mysteryBox.unpack.resolves(unpackedCode);
+        ctx.parsedBody = {
+          'redirect_uri': 'https://app.example.com/return',
+          'code': 'xxx',
+        };
+      });
+      it('covers invalid code', async function () {
+        manager.mysteryBox.unpack.rejects(expectedException);
+        try {
+          await manager.postToken(req, res, ctx);
+          assert.fail(noExpectedException);
+        } catch (e) {
+          assert(e instanceof ResponseError);
+        }
+      });
+      it('covers mismatched redirect', async function () {
+        ctx.parsedBody['redirect_uri'] = 'https://elsewhere.example.com/';
+        try {
+          await manager.postToken(req, res, ctx);
+          assert.fail(noExpectedException);
+        } catch (e) {
+          assert(e instanceof ResponseError);
+        }
+      });
+      it('covers success', async function () {
+        manager.db.redeemCode.resolves(true);
+        await manager.postToken(req, res, ctx);
+        assert(res.end.called);
+        assert.strictEqual(manager.mysteryBox.pack.callCount, 1);
+      });
+      it('covers success with refresh', async function () {
+        manager.db.redeemCode.resolves(true);
+        unpackedCode.refreshLifespan = 86400;
+        unpackedCode.tokenLifespan = 86400;
+        manager.mysteryBox.unpack.resolves(unpackedCode);
+        await manager.postToken(req, res, ctx);
+        assert(res.end.called);
+        assert.strictEqual(manager.mysteryBox.pack.callCount, 2);
+      });
+      it('covers redemption failure', async function () {
+        manager.db.redeemCode.resolves(false);
+        try {
+          await manager.postToken(req, res, ctx);
+          assert.fail(noExpectedException);
+        } catch (e) {
+          assert(e instanceof ResponseError);
+        }
+      });
+      it('removes email from profile if not in scope', async function () {
+        manager.db.redeemCode.resolves(true);
+        unpackedCode.acceptedScopes = ['profile', 'tricks'];
+        manager.mysteryBox.unpack.resolves(unpackedCode);
+        await manager.postToken(req, res, ctx);
+        assert(res.end.called);
+        const response = JSON.parse(res.end.args[0][0]);
+        assert(!('email' in response.profile));
+      });
+
+    }); // Code Redemption
+    describe('Invalid grant_type', function () {
+      it('throws response error', async function () {
+        ctx.parsedBody['grant_type'] = 'bad';
+        try {
+          await manager.postToken(req, res, ctx);
+          assert.fail(noExpectedException);
+        } catch (e) {
+          assert(e instanceof ResponseError);
+        }
+      });
+    }); // Invalid grant_type
+  }); // postToken
+
+  describe('_validateToken', function () {
+    let dbCtx;
+    beforeEach(function () {
+      dbCtx = {};
+      sinon.stub(manager, '_checkTokenValidationRequest');
+    });
+    it('covers valid token', async function () {
+      ctx.bearer = {
+        isValid: true,
+      };
+      ctx.token = {
+      };
+      await manager._validateToken(dbCtx, req, res, ctx);
+      assert(res.end.called);
+    });
+    it('covers invalid token', async function () {
+      ctx.bearer = {
+        isValid: false,
+      };
+      await assert.rejects(manager._validateToken(dbCtx, req, res, ctx), ResponseError);
+    });
+    it('covers errors', async function () {
+      ctx.bearer = {
+        isValid: false,
+      };
+      ctx.session.error = 'error';
+      ctx.session.errorDescriptions = ['error_description'];
+      await assert.rejects(manager._validateToken(dbCtx, req, res, ctx), ResponseError);
+    });
+  }); // _validateToken
+
+  describe('_checkTokenValidationRequest', function () {
+    let dbCtx;
+    beforeEach(function () {
+      dbCtx = {};
+      sinon.stub(manager.mysteryBox, 'unpack');
+    });
+    it('does nothing with no auth header', async function () {
+      await manager._checkTokenValidationRequest(dbCtx, req, ctx);
+    });
+    it('does nothing with unknown auth header', async function () {
+      req.getHeader.returns('flarp authy woo');
+      await manager._checkTokenValidationRequest(dbCtx, req, ctx);
+    });
+    it('requires a valid auth token', async function () {
+      manager.mysteryBox.unpack.rejects(expectedException);
+      req.getHeader.returns('Bearer XXX');
+      await manager._checkTokenValidationRequest(dbCtx, req, ctx);
+      assert(ctx.session.error);
+    });
+    it('requires valid auth token fields', async function () {
+      manager.mysteryBox.unpack.resolves({});
+      req.getHeader.returns('Bearer XXX');
+      await manager._checkTokenValidationRequest(dbCtx, req, ctx);
+      assert(ctx.session.error)
+    });
+    it('covers no token', async function () {
+      manager.mysteryBox.unpack.resolves({ c: 'xxx' });
+      req.getHeader.returns('Bearer XXX');
+      await manager._checkTokenValidationRequest(dbCtx, req, ctx);
+      assert(ctx.session.error)
+    });
+    it('covers db error', async function () {
+      manager.mysteryBox.unpack.resolves({ c: 'xxx' });
+      manager.db.tokenGetByCodeId.rejects(expectedException);
+      req.getHeader.returns('Bearer XXX');
+      await assert.rejects(manager._checkTokenValidationRequest(dbCtx, req, ctx), expectedException);
+    });
+    it('valid token', async function () {
+      manager.mysteryBox.unpack.resolves({ c: 'xxx' });
+      manager.db.tokenGetByCodeId.resolves({
+        isRevoked: false,
+        expires: new Date(Date.now() + 86400000),
+      });
+      req.getHeader.returns('Bearer XXX');
+      await manager._checkTokenValidationRequest(dbCtx, req, ctx);
+      assert.strictEqual(ctx.bearer.isValid, true);
+    });
+    it('revoked token', async function () {
+      manager.mysteryBox.unpack.resolves({ c: 'xxx' });
+      manager.db.tokenGetByCodeId.resolves({
+        isRevoked: true,
+        expires: new Date(Date.now() + 86400000),
+      });
+      req.getHeader.returns('Bearer XXX');
+      await manager._checkTokenValidationRequest(dbCtx, req, ctx);
+      assert.strictEqual(ctx.bearer.isValid, false);
+    });
+    it('expired token', async function () {
+      manager.mysteryBox.unpack.resolves({ c: 'xxx' });
+      manager.db.tokenGetByCodeId.resolves({
+        isRevoked: false,
+        expires: new Date(Date.now() - 86400000),
+      });
+      req.getHeader.returns('Bearer XXX');
+      await manager._checkTokenValidationRequest(dbCtx, req, ctx);
+      assert.strictEqual(ctx.bearer.isValid, false);
+    });
+  }); // _checkTokenValidationRequest
+
+  describe('postIntrospection', function () {
+    let inactiveToken, activeToken, dbResponse;
+    beforeEach(function () {
+      dbResponse = {
+        profile: 'https://profile.example.com/',
+        clientId: 'https://client.example.com/',
+        scopes: ['scope1', 'scope2'],
+        created: new Date(),
+        expires: Infinity,
+      };
+      inactiveToken = JSON.stringify({
+        active: false,
+      });
+      activeToken = JSON.stringify({
+        active: true,
+        me: dbResponse.profile,
+        'client_id': dbResponse.clientId,
+        scope: dbResponse.scopes.join(' '),
+        iat: Math.ceil(dbResponse.created.getTime() / 1000),
+      });
+      sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '7e9991dc-9cd5-11ec-85c4-0025905f714a' });
+      manager.db.tokenGetByCodeId.resolves(dbResponse);
+    });
+    it('covers bad token', async function () {
+      manager.mysteryBox.unpack.rejects();
+      await manager.postIntrospection(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.end.args[0][0], inactiveToken);
+    });
+    it('covers token not in db', async function () {
+      manager.db.tokenGetByCodeId.resolves();
+      await manager.postIntrospection(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.end.args[0][0], inactiveToken);
+    });
+    it('covers valid token', async function () {
+      await manager.postIntrospection(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.end.args[0][0], activeToken);
+    });
+    it('covers expired token', async function () {
+      dbResponse.expires = new Date((new Date()).getTime() - 86400000);
+      await manager.postIntrospection(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.end.args[0][0], inactiveToken);
+    });
+    it('covers expiring token', async function () {
+      dbResponse.expires = new Date((new Date()).getTime() + 86400000);
+      activeToken = JSON.stringify({
+        active: true,
+        me: dbResponse.profile,
+        'client_id': dbResponse.clientId,
+        scope: dbResponse.scopes.join(' '),
+        iat: Math.ceil(dbResponse.created.getTime() / 1000),
+        exp: Math.ceil(dbResponse.expires / 1000),
+      });
+      await manager.postIntrospection(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.end.args[0][0], activeToken);
+    });
+    it('covers ticket', async function () {
+      ctx.parsedBody['token_hint_type'] = 'ticket';
+      const nowEpoch = Math.ceil(Date.now() / 1000);
+      manager.mysteryBox.unpack.resolves({
+        c: '515172ae-5b0b-11ed-a6af-0025905f714a',
+        iss: nowEpoch - 86400,
+        exp: nowEpoch + 86400,
+        sub: 'https://subject.exmaple.com/',
+        res: 'https://profile.example.com/feed',
+        scope: ['read', 'role:private'],
+        ident: 'username',
+        profile: 'https://profile.example.com/',
+      });
+      await manager.postIntrospection(res, ctx);
+      assert(res.end.called);
+    });
+  }); // postIntrospection
+
+  describe('_revokeToken', function () {
+    let dbCtx;
+    beforeEach(function () {
+      dbCtx = {};
+    });
+    it('requires token field', async function () {
+      await manager._revokeToken(dbCtx, res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 400);
+    });
+    it('requires parsable token', async function () {
+      sinon.stub(manager.mysteryBox, 'unpack').resolves({ notC: 'foop' });
+      ctx.parsedBody['token'] = 'invalid token';
+      ctx.parsedBody['token_type_hint'] = 'access_token';
+      await manager._revokeToken(dbCtx, res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 400);
+    });
+    it('requires parsable token', async function () {
+      sinon.stub(manager.mysteryBox, 'unpack').resolves();
+      ctx.parsedBody['token'] = 'invalid token';
+      ctx.parsedBody['token_type_hint'] = 'refresh_token';
+      await manager._revokeToken(dbCtx, res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 400);
+    });
+    it('succeeds', async function () {
+      sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '8e4aed9e-fa3e-11ec-992e-0025905f714a' });
+      ctx.parsedBody['token'] = 'valid token';
+      await manager._revokeToken(dbCtx, res, ctx);
+      assert(manager.db.tokenRevokeByCodeId.called);
+      assert(res.end.called);
+    });
+    it('succeeds for refresh token', async function () {
+      sinon.stub(manager.mysteryBox, 'unpack').resolves({ rc: '8e4aed9e-fa3e-11ec-992e-0025905f714a' });
+      ctx.parsedBody['token'] = 'valid token';
+      await manager._revokeToken(dbCtx, res, ctx);
+      assert(manager.db.tokenRefreshRevokeByCodeId.called);
+      assert(res.end.called);
+    });
+    it('covers non-revokable token', async function () {
+      sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '8e4aed9e-fa3e-11ec-992e-0025905f714a' });
+      manager.db.tokenRevokeByCodeId.rejects(new UnexpectedResult());
+      ctx.parsedBody['token'] = 'valid token';
+      await manager._revokeToken(dbCtx, res, ctx);
+      assert.strictEqual(res.statusCode, 404);
+    });
+    it('covers failure', async function () {
+      sinon.stub(manager.mysteryBox, 'unpack').resolves({ c: '8e4aed9e-fa3e-11ec-992e-0025905f714a' });
+      manager.db.tokenRevokeByCodeId.rejects(expectedException);
+      ctx.parsedBody['token'] = 'valid token';
+      ctx.parsedBody['token_type_hint'] = 'ignores_bad_hint';
+      await assert.rejects(manager._revokeToken(dbCtx, res, ctx), expectedException, noExpectedException);
+    });    
+  }); // _revokeToken
+
+  describe('_scopeDifference', function () {
+    let previousScopes, requestedScopes;
+    beforeEach(function () {
+      previousScopes = ['a', 'b', 'c'];
+      requestedScopes = ['b', 'c', 'd'];
+    });
+    it('covers', function () {
+      const expected = ['a'];
+      const result = Manager._scopeDifference(previousScopes, requestedScopes);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // _scopeDifference
+
+  describe('_refreshToken', function () {
+    let dbCtx;
+    beforeEach(function () {
+      dbCtx = {};
+      ctx.parsedBody['client_id'] = 'https://client.example.com/';
+      const nowEpoch = Math.ceil(Date.now() / 1000);
+      sinon.stub(manager.mysteryBox, 'unpack').resolves({
+        rc: '03bb8ab0-1dc7-11ed-99f2-0025905f714a',
+        ts: nowEpoch - 86400,
+        exp: nowEpoch + 86400,
+      });
+      sinon.stub(manager.mysteryBox, 'pack').resolves('newToken');
+      const futureDate = new Date(Date.now() + 86400000);
+      manager.db.tokenGetByCodeId.resolves({
+        refreshExpires: futureDate,
+        duration: 86400,
+        clientId: 'https://client.example.com/',
+        scopes: ['profile', 'create'],
+      });
+      manager.db.refreshCode.resolves({
+        expires: futureDate,
+        refreshExpires: futureDate,
+      });
+    });
+    it('requires a token', async function () {
+      manager.mysteryBox.unpack.rejects();
+      await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
+    });
+    it('requires token to have refresh field', async function () {
+      manager.mysteryBox.unpack.resolves();
+      await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
+    });
+    it('requires token to exist in db', async function () {
+      manager.db.tokenGetByCodeId.resolves();
+      await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
+    });
+    it('requires token be refreshable', async function () {
+      manager.db.tokenGetByCodeId.resolves({
+        refreshExpires: undefined,
+      });
+      await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
+    });
+    it('requires refresh of token not be expired', async function () {
+      manager.db.tokenGetByCodeId.resolves({
+        refreshExpires: 1000,
+      });
+      await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
+    });
+    it('requires token not to have been already refreshed', async function () {
+      const nowEpoch = Math.ceil(Date.now() / 1000);
+      manager.mysteryBox.unpack.resolves({
+        rc: '03bb8ab0-1dc7-11ed-99f2-0025905f714a',
+        ts: nowEpoch - 864000,
+        exp: nowEpoch - 86400,
+      });
+      await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
+    });
+    it('requires client_id requesting refresh match', async function () {
+      ctx.parsedBody['client_id'] = 'https://wrong.example.com/';
+      await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
+    });
+    it('succeeds', async function () {
+      await manager._refreshToken(dbCtx, req, res, ctx);
+      assert(res.end.called);
+    });
+    it('covers non-expiring', async function () {
+      manager.db.tokenGetByCodeId.resolves({
+        refreshExpires: new Date(Date.now() + 86400000),
+        duration: 86400,
+        clientId: 'https://client.example.com/',
+        scopes: ['profile', 'create'],
+      });
+      await manager._refreshToken(dbCtx, req, res, ctx);
+      assert(res.end.called);
+    });
+    it('covers profile and email', async function () {
+      manager.db.tokenGetByCodeId.resolves({
+        refreshExpires: new Date(Date.now() + 86400000),
+        duration: 86400,
+        clientId: 'https://client.example.com/',
+        scopes: ['profile', 'email', 'create'],
+      });
+      await manager._refreshToken(dbCtx, req, res, ctx);
+      assert(res.end.called);
+    });
+    it('succeeds with scope reduction', async function () {
+      ctx.parsedBody['scope'] = 'profile fancy';
+      manager.db.tokenGetByCodeId.resolves({
+        refreshExpires: new Date(Date.now() + 86400000),
+        clientId: 'https://client.example.com/',
+        scopes: ['profile', 'create'],
+      });
+      await manager._refreshToken(dbCtx, req, res, ctx);
+      assert(res.end.called);
+    });
+    it('covers refresh failed', async function () {
+      manager.db.refreshCode.resolves();
+      await assert.rejects(() => manager._refreshToken(dbCtx, req, res, ctx), ResponseError);
+    });
+  }); // _refreshToken
+
+  describe('_mintTicket', function () {
+    let dbCtx, payload;
+    beforeEach(function () {
+      dbCtx = {};
+      payload = {
+        subject: 'https://third-party.example.com/',
+        resource: 'https://private.example.com/feed',
+        scopes: ['read'],
+        identifier: 'account',
+        profile: 'https://profile.example.com/',
+        ticketLifespanSeconds: 86400,
+      };
+    });
+    it('covers', async function () {
+      const expected = 'xxx';
+      sinon.stub(manager.mysteryBox, 'pack').resolves(expected);
+      const result = await manager._mintTicket(dbCtx, payload);
+      assert.strictEqual(result, expected);
+    });
+  }); // _mintTicket
+
+  describe('_ticketAuthToken', function () {
+    let dbCtx, ticketPayload, nowEpoch;
+    beforeEach(function () {
+      dbCtx = {};
+      nowEpoch = Math.ceil(Date.now() / 1000);
+      ticketPayload = {
+        c: '5ec17f78-5576-11ed-b444-0025905f714a',
+        iss: nowEpoch - 86400,
+        exp: nowEpoch + 86400,
+        sub: 'https://third-party.example.com/',
+        res: 'https://private.example.com/feed',
+        scope: ['read', 'flap'],
+        ident: 'account',
+        profile: 'https://profile.example.com/',
+      };
+      sinon.stub(manager.mysteryBox, 'unpack').resolves(ticketPayload);
+      sinon.stub(manager.mysteryBox, 'pack').resolves('ticket');
+    });
+    it('covers invalid ticket', async function () {
+      manager.mysteryBox.unpack.resolves();
+      await assert.rejects(() => manager._ticketAuthToken(dbCtx, req, res, ctx), ResponseError);
+    });
+    it('covers expired ticket', async function () {
+      manager.mysteryBox.unpack.resolves({
+        c: '5ec17f78-5576-11ed-b444-0025905f714a',
+        iss: nowEpoch - 172800,
+        exp: nowEpoch - 86400,
+        sub: 'https://third-party.example.com/',
+        res: 'https://private.example.com/feed',
+        scope: ['read', 'flap'],
+        ident: 'account',
+        profile: 'https://profile.example.com/',
+      });
+      await assert.rejects(() => manager._ticketAuthToken(dbCtx, req, res, ctx), ResponseError);
+    });
+    it('covers success', async function () {
+      manager.db.redeemCode.resolves(true);
+      await manager._ticketAuthToken(dbCtx, req, res, ctx);
+      assert(res.end.called);
+    });
+    it('covers invalid redeem', async function () {
+      manager.db.redeemCode.resolves(false);
+      await assert.rejects(() => manager._ticketAuthToken(dbCtx, req, res, ctx), ResponseError);
+    });
+  }); // _ticketAuthToken
+
+  describe('postRevocation', function () {
+    beforeEach(function () {
+      sinon.stub(manager, '_revokeToken');
+    });
+    it('covers success', async function () {
+      manager._revokeToken.resolves();
+      await manager.postRevocation(res, ctx);
+      assert(manager._revokeToken.called);
+    });
+    it('covers failure', async function () {
+      manager._revokeToken.rejects(expectedException);
+      await assert.rejects(manager.postRevocation(res, ctx));
+    });
+  }); // postRevocation
+
+  describe('postUserInfo', function () {
+    beforeEach(function () {
+      ctx.parsedBody['token'] = 'XXX';
+      sinon.stub(manager.mysteryBox, 'unpack');
+    });
+    it('requires a token', async function () {
+      delete ctx.parsedBody.token;
+      await manager.postUserInfo(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 400);
+    });
+    it('requires a valid token', async function () {
+      manager.mysteryBox.unpack.rejects(expectedException);
+      await manager.postUserInfo(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 401);
+    });
+    it('requires token to have profile scope', async function () {
+      manager.mysteryBox.unpack.resolves({});
+      manager.db.tokenGetByCodeId.resolves({
+        scopes: [],
+      });
+      await manager.postUserInfo(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 403);
+    });
+    it('succeeds', async function () {
+      manager.mysteryBox.unpack.resolves({});
+      manager.db.tokenGetByCodeId.resolves({
+        scopes: ['profile', 'email'],
+        profile: {
+          url: 'https://example.com/',
+          email: 'user@example.com',
+        },
+      });
+      await manager.postUserInfo(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 200);
+    });
+    it('succeeds, and does not include email without scope', async function () {
+      manager.mysteryBox.unpack.resolves({});
+      manager.db.tokenGetByCodeId.resolves({
+        scopes: ['profile'],
+        profile: {
+          url: 'https://example.com/',
+          email: 'user@example.com',
+        },
+      });
+      await manager.postUserInfo(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 200);
+      const response = JSON.parse(res.end.args[0][0]);
+      assert(!('email' in response));
+    });
+  }); // postUserInfo
+
+  describe('getAdmin', function () {
+    beforeEach(function () {
+      manager.db.profilesScopesByIdentifier.resolves({
+        profileScopes: {
+          'https://profile.example.com/': {
+            'scope': {
+              'scope1': {
+                description: 'a scope',
+                profiles: ['https://profile.example.com/'],
+              },
+            },
+          },
+        },
+        scopeIndex: {
+          'scope1': {
+            description: 'a scope',
+            profiles: ['https://profile.example.com/'],
+          },
+        },
+        profiles: ['https://profile.example.com/'],
+      });
+      manager.db.tokensGetByIdentifier.resolves();
+    });
+    it('covers', async function () {
+      await manager.getAdmin(res, ctx);
+    });
+  }); // getAdmin
+
+  describe('postAdmin', function () {
+    beforeEach(function () {
+      manager.db.profilesScopesByIdentifier.resolves({
+        profileScopes: {
+          'https://profile.example.com/': {
+            'scope': {
+              'scope1': {
+                description: 'a scope',
+                profiles: ['https://profile.example.com/'],
+              },
+            },
+          },
+        },
+        scopeIndex: {
+          'scope1': {
+            description: 'a scope',
+            profiles: ['https://profile.example.com/'],
+          },
+        },
+        profiles: ['https://profile.example.com/'],
+      });
+      manager.db.tokensGetByIdentifier.resolves([]);
+      manager.db.tokenRevokeByCodeId.resolves();
+      manager.db.profileIdentifierInsert.resolves();
+      manager.db.profileScopesSetAll.resolves();
+      manager.communication.fetchProfile.resolves({
+        metadata: {
+          authorizationEndpoint: manager.selfAuthorizationEndpoint,
+        },
+      });
+    });
+    describe('save-scopes action', function () {
+      beforeEach(function () {
+        ctx.parsedBody['action'] = 'save-scopes';
+        ctx.parsedBody['scopes-https://profile/example.com/'] = ['scope1', 'scope2'];
+      });
+      it('covers saving scopes', async function () {
+        await manager.postAdmin(res, ctx);
+        assert(ctx.notifications.length);
+        assert(manager.db.profileScopesSetAll.called);
+      });
+      it('covers saving scopes error', async function () {
+        manager.db.profileScopesSetAll.rejects();
+        await manager.postAdmin(res, ctx);
+        assert(ctx.errors.length);
+      });
+    }); // save-scopes action
+    describe('new-profile action', function () {
+      beforeEach(function () {
+        ctx.parsedBody['action'] = 'new-profile';
+      });
+      it('covers new profile', async function () {
+        ctx.parsedBody['profile'] = 'https://profile.example.com/';
+        await manager.postAdmin(res, ctx);
+        assert(ctx.notifications.length);
+        assert(manager.db.profileIdentifierInsert.called);
+        assert(manager.db.profileScopesSetAll.called);
+      });
+      it('covers invalid profile', async function () {
+        ctx.parsedBody['action'] = 'new-profile';
+        ctx.parsedBody['profile'] = 'not a url';
+        await manager.postAdmin(res, ctx);
+        assert(ctx.errors.length);
+      });
+      it('covers other validation failure', async function () {
+        sinon.stub(manager.communication, 'validateProfile').rejects(expectedException);
+        ctx.parsedBody['action'] = 'new-profile';
+        ctx.parsedBody['profile'] = 'not a url';
+        await manager.postAdmin(res, ctx);
+        assert(ctx.errors.length);
+      });
+      it('covers mismatched profile', async function () {
+        ctx.parsedBody['action'] = 'new-profile';
+        ctx.parsedBody['profile'] = 'https://profile.example.com/';
+        manager.communication.fetchProfile.resolves({
+          metadata: {
+            authorizationEndpoint: 'https://other.example.com/auth',
+          },
+        });
+        await manager.postAdmin(res, ctx);
+        assert(ctx.errors.length);
+      });
+      it('covers new profile error', async function () {
+        ctx.parsedBody['action'] = 'new-profile';
+        ctx.parsedBody['profile'] = 'https://profile.example.com/';
+        manager.db.profileIdentifierInsert.rejects();
+        await manager.postAdmin(res, ctx);
+        assert(ctx.errors.length);
+      });
+    }); // new-profile action
+    describe('new-scope action', function () {
+      beforeEach(function () {
+        ctx.parsedBody['action'] = 'new-scope';
+      });
+      it('covers new scope', async function () {
+        ctx.parsedBody['scope'] = 'newscope';
+        await manager.postAdmin(res, ctx);
+        assert(ctx.notifications.length);
+        assert(manager.db.scopeUpsert.called);
+      });
+      it('covers bad scope', async function () {
+        ctx.parsedBody['scope'] = 'bad scope';
+        await manager.postAdmin(res, ctx);
+        assert(ctx.errors.length);
+      });
+      it('covers new scope error', async function () {
+        ctx.parsedBody['scope'] = 'newscope';
+        manager.db.scopeUpsert.rejects();
+        await manager.postAdmin(res, ctx);
+        assert(ctx.errors.length);
+      });
+      it('covers empty scope', async function () {
+        delete ctx.parsedBody.scope;
+        await manager.postAdmin(res, ctx);
+        assert(!ctx.errors.length);
+      });
+    }); // new-scope action
+    describe('delete-scope-* action', function () {
+      beforeEach(function () {
+        ctx.parsedBody['action'] = 'delete-scope-food%3Ayum';
+      });
+      it('covers delete', async function () {
+        manager.db.scopeDelete.resolves(true);
+        await manager.postAdmin(res, ctx);
+        assert(ctx.notifications.length);
+        assert(manager.db.scopeDelete.called);
+      });
+      it('covers no delete', async function () {
+        manager.db.scopeDelete.resolves(false);
+        await manager.postAdmin(res, ctx);
+        assert(ctx.notifications.length);
+        assert(manager.db.scopeDelete.called);
+      });
+      it('covers delete error', async function () {
+        manager.db.scopeDelete.rejects();
+        await manager.postAdmin(res, ctx);
+        assert(ctx.errors.length);
+        assert(manager.db.scopeDelete.called);
+      });
+      it('ignores empty scope', async function () {
+        ctx.parsedBody['action'] = 'delete-scope-';
+        await manager.postAdmin(res, ctx);
+        assert(manager.db.scopeDelete.notCalled);
+        assert(!ctx.notifications.length);
+        assert(!ctx.errors.length);
+      });
+    }); // delete-scope-* action
+    describe('revoke-* action', function () {
+      beforeEach(function () {
+        ctx.parsedBody['action'] = 'revoke-b1591c00-9cb7-11ec-a05c-0025905f714a';
+      });
+      it('covers revocation', async function () {
+        await manager.postAdmin(res, ctx);
+        assert(ctx.notifications.length);
+        assert(manager.db.tokenRevokeByCodeId.called);
+      });
+      it('covers revocation error', async function () {
+        manager.db.tokenRevokeByCodeId.rejects();
+        await manager.postAdmin(res, ctx);
+        assert(ctx.errors.length);
+      });
+      it('covers no code', async function () {
+        ctx.parsedBody['action'] = 'revoke-';
+        await manager.postAdmin(res, ctx);
+        assert(!ctx.notifications.length);
+        assert(!ctx.errors.length);
+        assert(manager.db.tokenRevokeByCodeId.notCalled);
+      });
+    }); // revoke-* action
+    it('covers empty action', async function () {
+      delete ctx.parsedBody.action;
+      await manager.postAdmin(res, ctx);
+      assert(!ctx.errors.length);
+    });
+    it('covers unknown action', async function () {
+      ctx.parsedBody['action'] = 'unsupported-action';
+      await manager.postAdmin(res, ctx);
+      assert(ctx.errors.length);
+    });
+  }); // postAdmin
+
+  describe('getAdminTicket', function () {
+    it('covers', async function () {
+      manager.db.profilesScopesByIdentifier.resolves({ scopeIndex: {} });
+      await manager.getAdminTicket(res, ctx);
+      assert(res.end.called);
+    });
+  }); // getAdminTicket
+
+  describe('postAdminTicket', function () {
+    beforeEach(function () {
+      ctx.parsedBody['action'] = 'proffer-ticket';
+      ctx.parsedBody['scopes'] = ['read', 'role:private'];
+      ctx.parsedBody['adhoc'] = 'adhoc_scope';
+      ctx.parsedBody['profile'] = 'https://profile.example.com/';
+      ctx.parsedBody['resource'] = 'https://profile.example.com/feed';
+      ctx.parsedBody['subject'] = 'https://subject.example.com/';
+      manager.db.profilesScopesByIdentifier.resolves({ scopeIndex: {} });
+      sinon.stub(manager.mysteryBox, 'pack').resolves('ticket');
+      manager.communication.fetchProfile.resolves({
+        metadata: {
+          ticketEndpoint: 'https://example.com/ticket',
+        },
+      });
+    });
+    it('covers success', async function () {
+      await manager.postAdminTicket(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(ctx.errors.length, 0);
+      assert.strictEqual(ctx.notifications.length, 1);
+    });
+    it('requires params', async function () {
+      delete ctx.parsedBody['adhoc'];
+      ctx.parsedBody['profile'] = 'bad url';
+      ctx.parsedBody['resource'] = 'bad url';
+      ctx.parsedBody['subject'] = 'bad url';
+      ctx.parsedBody['scopes'] = ['fl"hrgl', 'email'];
+      await manager.postAdminTicket(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(ctx.errors.length, 5);
+      assert.strictEqual(ctx.notifications.length, 0);
+    });
+    it('ignores unknown action', async function () {
+      ctx.parsedBody['action'] = 'prove-dough';
+      await manager.postAdminTicket(res, ctx);
+      assert(res.end.called);
+    });
+    it('covers delivery failure', async function () {
+      manager.communication.deliverTicket.rejects(expectedException);
+      await manager.postAdminTicket(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(ctx.errors.length, 1);
+      assert.strictEqual(ctx.notifications.length, 0);
+    });
+    it('covers no ticket endpoint', async function () {
+      manager.communication.fetchProfile.resolves({
+        metadata: {
+        },
+      });
+      await manager.postAdminTicket(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(ctx.errors.length, 1);
+      assert.strictEqual(ctx.notifications.length, 0);
+    });
+    it('covers bad ticket endpoint', async function () {
+      manager.communication.fetchProfile.resolves({
+        metadata: {
+          ticketEndpoint: 'not a url',
+        },
+      });
+      await manager.postAdminTicket(res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(ctx.errors.length, 1);
+      assert.strictEqual(ctx.notifications.length, 0);
+    });
+  }); // postAdminTicket
+
+  describe('postTicket', function () {
+    beforeEach(function () {
+      ctx.parsedBody = {
+        ticket: 'ticket123',
+        resource: 'https://blog.example.com/',
+        subject: 'https://otheruser.example.com/',
+      };
+    });
+    it('accepts a ticket for a known profile', async function () {
+      manager.db.profileIsValid.resolves(true);
+      await manager.postTicket(req, res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 202);
+    });
+    it('rejects invalid resource', async function () {
+      ctx.parsedBody.resource = 'invalid url';
+      await assert.rejects(() => manager.postTicket(req, res, ctx), ResponseError);
+    });
+    it('rejects invalid subject', async function () {
+      manager.db.profileIsValid(false);
+      await assert.rejects(() => manager.postTicket(req, res, ctx), ResponseError);
+    });
+    it('covers queue publish failure', async function () {
+      manager.db.profileIsValid.resolves(true);
+      manager.queuePublisher.publish.rejects(expectedException);
+      await assert.rejects(() => manager.postTicket(req, res, ctx), expectedException);
+    });
+    it('covers no ticket queue', async function () {
+      delete options.queues.amqp.url;
+      manager = new Manager(logger, stubDb, options);
+
+      await assert.rejects(() => manager.postTicket(req, res, ctx), ResponseError);
+    });
+
+  }); // postTicket
+
+  describe('getAdminMaintenance', function () {
+    it('covers information', async function () {
+      await manager.getAdminMaintenance(res, ctx);
+      assert(res.end.called);
+    });
+    it('covers tasks', async function () {
+      ctx.queryParams = {
+        [Enum.Chore.CleanTokens]: '',
+      };
+      await manager.getAdminMaintenance(res, ctx);
+      assert(res.end.called);
+    });
+  }); // getAdminMaintenance
+
+}); // Manager
\ No newline at end of file
diff --git a/test/src/service.js b/test/src/service.js
new file mode 100644 (file)
index 0000000..48f0c0b
--- /dev/null
@@ -0,0 +1,278 @@
+/* eslint-env mocha */
+/* eslint-disable capitalized-comments */
+
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+
+const stubDb = require('../stub-db');
+const stubLogger = require('../stub-logger');
+const Service = require('../../src/service');
+const Config = require('../../config');
+
+
+describe('Service', function () {
+  let service, options;
+  let req, res, ctx;
+
+  beforeEach(function () {
+    options = new Config('test');
+    service = new Service(stubLogger, stubDb, options);
+    sinon.stub(service.manager);
+    sinon.stub(service.sessionManager);
+    sinon.stub(service.authenticator);
+    sinon.stub(service.resourceAuthenticator);
+    sinon.stub(service, 'setResponseType');
+    sinon.stub(service, 'serveFile');
+    sinon.stub(service, 'ingestBody').resolves();
+    req = {
+      getHeader: sinon.stub(),
+    };
+    res = {
+      setHeader: sinon.stub(),
+      write: sinon.stub(),
+      end: sinon.stub(),
+    };
+    ctx = {
+      params: {},
+    };
+  });
+
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  it('instantiates', function () {
+    assert(service);
+  });
+
+  it('instantiates with config coverage', async function () {
+    options.dingus.selfBaseUrl = 'https://example.com/';
+    service = new Service(stubLogger, stubDb, options);
+    assert(service);
+  });
+
+  it('instantiates with config coverage', async function () {
+    delete options.dingus.selfBaseUrl;
+    service = new Service(stubLogger, stubDb, options);
+    assert(service);
+  });
+
+  describe('initialize', function () {
+    it('covers', async function () {
+      await service.initialize();
+      assert(service.manager.initialize.called);
+    });
+  }); // initialize
+
+  describe('preHandler', function () {
+    it('persists url into context', async function () {
+      req.url = 'https://example.com/foo';
+      sinon.stub(service.__proto__.__proto__, 'preHandler').resolves();
+      await service.preHandler(req, res, ctx);
+      assert.strictEqual(ctx.url, req.url);
+    });
+  }); // preHandler
+
+  describe('handlerGetAdminLogin', function () {
+    it('covers', async function () {
+      await service.handlerGetAdminLogin(req, res, ctx);
+      assert(service.sessionManager.getAdminLogin.called);
+    });
+  }); // handlerGetAdminLogin
+
+  describe('handlerPostAdminLogin', function () {
+    it('covers', async function () {
+      await service.handlerPostAdminLogin(req, res, ctx);
+      assert(service.sessionManager.postAdminLogin.called);
+    });
+  }); // handlerPostAdminLogin
+
+  describe('handlerGetAdminLogout', function () {
+    it('covers', async function () {
+      await service.handlerGetAdminLogout(req, res, ctx);
+      assert(service.sessionManager.getAdminLogout.called);
+    });
+  }); // handlerGetAdminLogout
+
+  describe('handlerGetAdmin', function () {
+    it('covers authenticated', async function () {
+      service.authenticator.sessionRequiredLocal.resolves(true);
+      await service.handlerGetAdmin(req, res, ctx);
+      assert(service.manager.getAdmin.called);
+    });
+    it('covers unauthenticated', async function () {
+      service.authenticator.sessionRequiredLocal.resolves(false);
+      await service.handlerGetAdmin(req, res, ctx);
+      assert(service.manager.getAdmin.notCalled);
+    });
+  }); // handlerGetAdmin
+
+  describe('handlerPostAdmin', function () {
+    it('covers authenticated', async function () {
+      service.authenticator.sessionRequiredLocal.resolves(true);
+      await service.handlerPostAdmin(req, res, ctx);
+      assert(service.manager.postAdmin.called);
+    });
+    it('covers unauthenticated', async function () {
+      service.authenticator.sessionRequiredLocal.resolves(false);
+      await service.handlerPostAdmin(req, res, ctx);
+      assert(service.manager.getAdmin.notCalled);
+    });
+  }); // handlerPostAdmin
+
+  describe('handlerGetRoot', function () {
+    it('covers', async function () {
+      await service.handlerGetRoot(req, res, ctx);
+      assert(service.manager.getRoot.called);
+    });
+  }); // handlerGetRoot
+
+  describe('handlerGetAdminTicket', function () {
+    it('covers authenticated', async function () {
+      service.authenticator.sessionRequiredLocal.resolves(true);
+      await service.handlerGetAdminTicket(req, res, ctx);
+      assert(service.manager.getAdminTicket.called);
+    });
+    it('covers unauthenticated', async function () {
+      service.authenticator.sessionRequiredLocal.resolves(false);
+      await service.handlerGetAdminTicket(req, res, ctx);
+      assert(service.manager.getAdminTicket.notCalled);
+    });
+  }); // handlerGetAdminTicket
+
+  describe('handlerPostAdminTicket', function () {
+    it('covers authenticated', async function () {
+      service.authenticator.sessionRequiredLocal.resolves(true);
+      await service.handlerPostAdminTicket(req, res, ctx);
+      assert(service.manager.postAdminTicket.called);
+    });
+    it('covers unauthenticated', async function () {
+      service.authenticator.sessionRequiredLocal.resolves(false);
+      await service.handlerPostAdminTicket(req, res, ctx);
+      assert(service.manager.postAdminTicket.notCalled);
+    });
+  }); // handlerPostAdminTicket
+
+  describe('handlerGetMeta', function () {
+    it('covers', async function () {
+      await service.handlerGetMeta(req, res, ctx);
+      assert(service.manager.getMeta.called);
+    });
+  }); // handlerGetMeta
+
+  describe('handlerGetHealthcheck', function () {
+    it('covers', async function () {
+      await service.handlerGetHealthcheck(req, res, ctx);
+      assert(service.manager.getHealthcheck.called);
+    });
+    it('cover errors', async function () {
+      const expectedException = 'blah';
+      service.manager.getHealthcheck.rejects(expectedException);
+      try {
+        await service.handlerGetHealthcheck(req, res, ctx);
+        assert.fail('did not get expected exception');
+      } catch (e) {
+        assert.strictEqual(e.name, expectedException, 'did not get expected exception');
+      }
+      assert(service.manager.getHealthcheck.called);
+    });
+  }); // handlerGetHealthcheck
+
+  describe('handlerInternalServerError', function () {
+    it('covers no redirect', async function () {
+      sinon.stub(service.__proto__.__proto__, 'handlerInternalServerError');
+      await service.handlerInternalServerError(req, res, ctx);
+      assert(service.__proto__.__proto__.handlerInternalServerError.called);
+    });
+    it('covers redirect', async function () {
+      sinon.stub(service.__proto__.__proto__, 'handlerInternalServerError');
+      ctx.session = {
+        redirectUri: new URL('https://client.example.com/app'),
+        clientIdentifier: new URL('https://client.exmaple.com/'),
+        state: '123456',
+      };
+      await service.handlerInternalServerError(req, res, ctx);
+      assert(!service.__proto__.__proto__.handlerInternalServerError.called);
+      assert(res.setHeader);
+    });
+  }); // handlerInternalServerError
+
+  describe('handlerGetAuthorization', function () {
+    it('covers authenticated', async function() {
+      service.authenticator.sessionRequiredLocal.resolves(true);
+      await service.handlerGetAuthorization(req, res, ctx);
+      assert(service.manager.getAuthorization.called);
+    });
+    it('covers unauthenticated', async function() {
+      service.authenticator.sessionRequiredLocal.resolves(false);
+      await service.handlerGetAuthorization(req, res, ctx);
+      assert(service.manager.getAuthorization.notCalled);
+    });
+  }); // handlerGetAuthorization
+
+  describe('handlerPostAuthorization', function () {
+    it('covers', async function () {
+      await service.handlerPostAuthorization(req, res, ctx);
+      assert(service.manager.postAuthorization.called);
+    });
+  }); // handlerPostAuthorization
+
+  describe('handlerPostConsent', function () {
+    it('covers', async function () {
+      service.serveFile.resolves();
+      await service.handlerPostConsent(req, res, ctx);
+      assert(service.manager.postConsent.called);
+    });
+  }); // handlerPostConsent
+
+  describe('handlerPostToken', function () {
+    it('covers', async function () {
+      await service.handlerPostToken(req, res, ctx);
+      assert(service.manager.postToken.called);
+    });
+  }); // handlerPostToken
+
+  describe('handlerPostTicket', function () {
+    it('covers', async function () {
+      await service.handlerPostTicket(req, res, ctx);
+      assert(service.manager.postTicket.called);
+    });
+  }); // handlerPostTicket
+
+  describe('handlerPostIntrospection', function () {
+    it('covers', async function () {
+      await service.handlerPostIntrospection(req, res, ctx);
+      assert(service.manager.postIntrospection.called);
+    });
+  }); // handlerPostIntrospection
+
+  describe('handlerPostRevocation', function () {
+    it('covers', async function () {
+      await service.handlerPostRevocation(req, res, ctx);
+      assert(service.manager.postRevocation.called);
+    });
+  }); // handlerPostRevocation
+
+  describe('handlerPostUserInfo', function () {
+    it('covers', async function () {
+      await service.handlerPostUserInfo(req, res, ctx);
+      assert(service.manager.postUserInfo.called);
+    });
+  }); // handlerPostUserInfo
+
+  describe('handlerGetAdminMaintenance', function () {
+    it('covers authenticated', async function () {
+      service.authenticator.sessionRequiredLocal.resolves(true);
+      await service.handlerGetAdminMaintenance(req, res, ctx);
+      assert(service.manager.getAdminMaintenance.called);
+    });
+    it('covers unauthenticated', async function () {
+      service.authenticator.sessionRequiredLocal.resolves(false);
+      await service.handlerGetAdminMaintenance(req, res, ctx);
+      assert(service.manager.getAdminMaintenance.notCalled);
+    });
+  }); // handlerGetAdminMaintenance
+
+});
\ No newline at end of file
diff --git a/test/src/template/admin-html.js b/test/src/template/admin-html.js
new file mode 100644 (file)
index 0000000..4c9e7a7
--- /dev/null
@@ -0,0 +1,112 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/admin-html');
+const Config = require('../../../config');
+const StubLogger = require('../../stub-logger');
+const lint = require('html-minifier-lint').lint; // eslint-disable-line node/no-unpublished-require
+
+const stubLogger = new StubLogger();
+
+function lintHtml(html) {
+  const result = lint(html);
+  stubLogger.debug('validHtml', '', { result, html });
+  assert(!result);
+}
+
+describe('Admin HTML Template', function () {
+  let ctx, config;
+  beforeEach(function () {
+    ctx = {
+      profilesScopes: {
+        scopeIndex: {
+          'scope': {
+            application: '',
+            description: '',
+            isPermanent: true,
+            isManuallyAdded: false,
+            profiles: ['https://example.com/'],
+          },
+          'other_scope': {
+            application: 'app1',
+            description: '',
+            isPermanent: false,
+            isManuallyAdded: true,
+            profiles: [],
+          },
+          'more_scope': {
+            application: 'app2',
+            description: '',
+            isPermanent: false,
+            isManuallyAdded: false,
+            profiles: [],
+          },
+          'scopitty_scope': {
+            application: 'app2',
+            description: '',
+            isPermanent: false,
+            isManuallyAdded: false,
+            profiles: [],
+          },
+          'last_scope': {
+            application: 'app1',
+            description: '',
+            isPermanent: false,
+            isManuallyAdded: false,
+            profiles: [],
+          },
+        },
+        profiles: ['https://example.com/'],
+      },
+      tokens: [
+        {
+          codeId: 'xxx',
+          clientId: 'https://client.example.com/',
+          profile: 'https://profile.example.com/',
+          created: new Date(),
+          expires: null,
+          isRevoked: false,
+        },
+        {
+          codeId: 'yyy',
+          clientId: 'https://client.example.com/',
+          profile: 'https://profile.example.com/',
+          isToken: true,
+          created: new Date(Date.now() - 86400000),
+          refreshed: new Date(),
+          expires: new Date(Date.now() + 86400000),
+          isRevoked: true,
+        },
+        {
+          codeId: 'zzz',
+          clientId: 'https://client.exmaple.com/',
+          profile: 'https://profile.example.com/',
+          resource: 'https://resource.example.com/',
+          created: new Date(),
+          scopes: ['read'],
+        },
+      ],
+    };
+    config = new Config('test');
+  });
+  it('renders', function () {
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+  it('renders no tokens', function () {
+    ctx.tokens = [];
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+  it('covers options', function () {
+    delete ctx.profilesScopes.profiles;
+    delete ctx.profilesScopes.scopeIndex.scope.profiles;
+    delete ctx.tokens;
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+}); // Admin HTML Template
diff --git a/test/src/template/admin-maintenance-html.js b/test/src/template/admin-maintenance-html.js
new file mode 100644 (file)
index 0000000..49d3b15
--- /dev/null
@@ -0,0 +1,47 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/admin-maintenance-html');
+const Config = require('../../../config');
+const StubLogger = require('../../stub-logger');
+const lint = require('html-minifier-lint').lint; // eslint-disable-line node/no-unpublished-require
+
+const stubLogger = new StubLogger();
+
+function lintHtml(html) {
+  const result = lint(html);
+  stubLogger.debug('validHtml', '', { result, html });
+  assert(!result);
+}
+
+describe('Admin Management HTML Template', function () {
+  let ctx, config;
+  beforeEach(function () {
+    ctx = {
+      almanac: [{
+        event: 'exampleChore',
+        date: new Date(),
+      }],
+      chores: {
+        exampleChore: {
+          intervalMs: 86400,
+          nextSchedule: new Date(),
+        },
+      },
+    };
+    config = new Config('test');
+  });
+  it('renders', function () {
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+  it('covers failsafes', function () {
+    delete ctx.almanac;
+    delete ctx.chores;
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+}); // Admin Ticket HTML Template
diff --git a/test/src/template/admin-ticket-html.js b/test/src/template/admin-ticket-html.js
new file mode 100644 (file)
index 0000000..9622aa5
--- /dev/null
@@ -0,0 +1,87 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/admin-ticket-html');
+const Config = require('../../../config');
+const StubLogger = require('../../stub-logger');
+const lint = require('html-minifier-lint').lint; // eslint-disable-line node/no-unpublished-require
+
+const stubLogger = new StubLogger();
+
+function lintHtml(html) {
+  const result = lint(html);
+  stubLogger.debug('validHtml', '', { result, html });
+  assert(!result);
+}
+
+describe('Admin Ticket HTML Template', function () {
+  let ctx, config;
+  beforeEach(function () {
+    ctx = {
+      profilesScopes: {
+        scopeIndex: {
+          'profile': {
+            application: '',
+            description: '',
+            isPermanent: true,
+            isManuallyAdded: false,
+            profiles: ['https://example.com/'],
+          },
+          'other_scope': {
+            application: 'app1',
+            description: '',
+            isPermanent: false,
+            isManuallyAdded: true,
+            profiles: [],
+          },
+          'read': {
+            application: 'app2',
+            description: '',
+            isPermanent: true,
+            isManuallyAdded: false,
+            profiles: [],
+          },
+          'scopitty_scope': {
+            application: 'app2',
+            description: '',
+            isPermanent: false,
+            isManuallyAdded: false,
+            profiles: [],
+          },
+          'last_scope': {
+            application: 'app1',
+            description: '',
+            isPermanent: false,
+            isManuallyAdded: false,
+            profiles: [],
+          },
+        },
+        profileScopes: {
+          'https://example.com': {
+            'profile': {
+              application: '',
+              description: '',
+              isPermanent: true,
+              isManuallyAdded: false,
+              profiles: ['https://example.com/'],
+            },
+          },
+        },
+        profiles: ['https://example.com/'],
+      },
+    };
+    config = new Config('test');
+  });
+  it('renders', function () {
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+  it('covers branches', function () {
+    delete ctx.profilesScopes;
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+}); // Admin Ticket HTML Template
diff --git a/test/src/template/authorization-error-html.js b/test/src/template/authorization-error-html.js
new file mode 100644 (file)
index 0000000..ba92f30
--- /dev/null
@@ -0,0 +1,38 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/authorization-error-html');
+const Config = require('../../../config');
+const StubLogger = require('../../stub-logger');
+const lint = require('html-minifier-lint').lint; // eslint-disable-line node/no-unpublished-require
+
+const stubLogger = new StubLogger();
+
+function lintHtml(html) {
+  const result = lint(html);
+  stubLogger.debug('validHtml', '', { result, html });
+  assert(!result);
+}
+
+describe('Authorization Error HTML Template', function () {
+  let ctx, config;
+  beforeEach(function () {
+    ctx = {};
+    config = new Config('test');
+  });
+  it('renders', function () {
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+  it('renders errors', function () {
+    ctx.session = {
+      error: 'error_name',
+      errorDescriptions: ['something went wrong', 'another thing went wrong'],
+    }
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+}); // Authorization Error HTML Template
diff --git a/test/src/template/authorization-request-html.js b/test/src/template/authorization-request-html.js
new file mode 100644 (file)
index 0000000..b599df3
--- /dev/null
@@ -0,0 +1,144 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/authorization-request-html');
+const Config = require('../../../config');
+const StubLogger = require('../../stub-logger');
+const lint = require('html-minifier-lint').lint; // eslint-disable-line node/no-unpublished-require
+
+const stubLogger = new StubLogger();
+
+function lintHtml(html) {
+  const result = lint(html);
+  stubLogger.debug('validHtml', '', { result, html });
+  assert(!result);
+}
+
+describe('Authorization Request HTML Template', function () {
+  let ctx, config;
+  beforeEach(function () {
+    ctx = {};
+    config = new Config('test');
+  });
+  it('renders', function () {
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+  it('covers options', function () {
+    ctx.session = {
+      scope: ['profile', 'email'],
+      scopeIndex: {
+        'profile': {
+          description: 'Profile',
+        },
+        'email': {
+          description: 'Email',
+        },
+        'create': {
+          description: 'Create',
+          profiles: ['https://exmaple.com/profile'],
+        },
+      },
+      me: new URL('https://example.com/profile'),
+      profiles: ['https://another.example.com/profile', 'https://example.com/profile'],
+      clientIdentifier: {
+        items: [{
+          properties: {
+            url: 'https://client.example.com/app/',
+            summary: 'This is an app',
+            logo: 'https://client.example.com/app/logo.png',
+            name: 'Some Fancy Application',
+          },
+        }],
+      },
+      clientId: 'https://client.example.com/app/',
+      persist: 'encodedData',
+      redirectUri: 'https://client.example.com/app/_return',
+    };
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+  it('covers alternate scopes and client logo', function () {
+    ctx.session = {
+      scope: ['profile', 'email'],
+      scopeIndex: {
+        'profile': {
+          description: 'Profile',
+        },
+        'email': {
+          description: 'Email',
+        },
+        'create': {
+          description: 'Create',
+          profiles: ['https://example.com/profile'],
+        },
+        'other': {
+          description: 'Another Scope',
+          profiles: ['https://example.com/profile'],
+        },
+      },
+      me: new URL('https://example.com/profile'),
+      profiles: ['https://another.example.com/profile', 'https://example.com/profile'],
+      clientIdentifier: {
+        items: [{
+          properties: {
+            url: 'https://client.example.com/app/',
+            summary: 'This is an app',
+            logo: [{
+              value: 'https://client.example.com/app/logo.png',
+              alt: 'alt',
+            }],
+            name: 'Some Fancy Application',
+          },
+        }],
+      },
+      clientId: 'https://client.example.com/app/',
+      persist: 'encodedData',
+      redirectUri: 'https://client.example.com/app/_return',
+    };
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+  it('covers partial data', function () {
+    ctx.session = {
+      scope: ['profile', 'email', 'create'],
+      profiles: ['https://another.example.com/profile', 'https://example.com/profile'],
+      clientIdentifier: {
+        items: [{
+          properties: {
+            url: 'https://client.example.com/app/',
+            summary: 'This is an app',
+            logo: 'https://client.example.com/app/logo.png',
+            name: 'Some Fancy Application',
+          },
+        }],
+      },
+      clientId: 'https://client.example.com/app/',
+      persist: 'encodedData',
+      redirectUri: 'https://client.example.com/app/_return',
+    };
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+  it('covers partial data', function () {
+    ctx.session = {
+      scope: ['profile', 'email', 'create'],
+      profiles: [],
+      clientIdentifier: {
+        items: [{
+        }],
+      },
+      clientId: 'https://client.example.com/app/',
+      persist: 'encodedData',
+      redirectUri: 'https://client.example.com/app/_return',
+    };
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+}); // Authorization Request HTML Template
diff --git a/test/src/template/root-html.js b/test/src/template/root-html.js
new file mode 100644 (file)
index 0000000..e2956df
--- /dev/null
@@ -0,0 +1,35 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/root-html');
+const Config = require('../../../config');
+const StubLogger = require('../../stub-logger');
+const lint = require('html-minifier-lint').lint; // eslint-disable-line node/no-unpublished-require
+
+const stubLogger = new StubLogger();
+
+function lintHtml(html) {
+  const result = lint(html);
+  stubLogger.debug('validHtml', '', { result, html });
+  assert(!result);
+}
+
+describe('Root HTML Template', function () {
+  let ctx, config;
+  beforeEach(function () {
+    ctx = {};
+    config = new Config('test');
+  });
+  it('renders', function () {
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+  it('covers options', function () {
+    config.adminContactHTML = '<div>support</div>';
+    const result = template(ctx, config);
+    lintHtml(result);
+    assert(result);
+  });
+}); // Root HTML Template
diff --git a/test/src/template/template-helper.js b/test/src/template/template-helper.js
new file mode 100644 (file)
index 0000000..7903dd8
--- /dev/null
@@ -0,0 +1,95 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const th = require('../../../src/template/template-helper');
+
+describe('Template Helper', function () {
+
+  describe('escapeCSS', function () {
+    it('allows valid', function () {
+      const str = 'valid-identifier';
+      const result = th.escapeCSS(str);
+      assert.strictEqual(result, str);
+    });
+    it('escapes invalid', function () {
+      const str = '(invalid*identifier)';
+      const expected = '\\(invalid\\*identifier\\)';
+      const result = th.escapeCSS(str);
+      assert.strictEqual(result, expected);
+    });
+  }); // escapeCSS
+
+  describe('scopeCompare', function () {
+    let a, b;
+    describe('empty app', function () {
+      it('sorts by name, first lower', function () {
+        a = ['scopeA', { application: '' }];
+        b = ['scopeB', { application: '' }];
+        const result = th.scopeCompare(a, b);
+        assert.strictEqual(result, -1);
+      });
+      it('sorts by name, first higher', function () {
+        a = ['scopeA', { application: '' }];
+        b = ['scopeB', { application: '' }];
+        const result = th.scopeCompare(b, a);
+        assert.strictEqual(result, 1);
+      });
+      it('sorts by name, equal', function () {
+        a = ['scopeA', { application: '' }];
+        b = ['scopeA', { application: '' }];
+        const result = th.scopeCompare(a, b);
+        assert.strictEqual(result, 0);
+      });
+    });
+
+    describe('equal app', function () {
+      it('sorts by name, first lower', function () {
+        a = ['scopeA', { application: 'app' }];
+        b = ['scopeB', { application: 'app' }];
+        const result = th.scopeCompare(a, b);
+        assert.strictEqual(result, -1);
+      });
+      it('sorts by name, first higher', function () {
+        a = ['scopeA', { application: 'app' }];
+        b = ['scopeB', { application: 'app' }];
+        const result = th.scopeCompare(b, a);
+        assert.strictEqual(result, 1);
+      });
+      it('sorts by name, equal', function () {
+        a = ['scopeA', { application: 'app' }];
+        b = ['scopeA', { application: 'app' }];
+        const result = th.scopeCompare(a, b);
+        assert.strictEqual(result, 0);
+      });
+    });
+
+    describe('different app', function () {
+      it('sorts by app, first lower', function () {
+        a = ['scopeA', { application: 'appA' }];
+        b = ['scopeB', { application: 'appB' }];
+        const result = th.scopeCompare(a, b);
+        assert.strictEqual(result, -1);
+      });
+      it('sorts by app, first higher', function () {
+        a = ['scopeA', { application: 'appA' }];
+        b = ['scopeB', { application: 'appB' }];
+        const result = th.scopeCompare(b, a);
+        assert.strictEqual(result, 1);
+      });
+      it('sorts by app, empty first', function () {
+        a = ['scopeA', { application: '' }];
+        b = ['scopeB', { application: 'app' }];
+        const result = th.scopeCompare(a, b);
+        assert.strictEqual(result, -1);
+      });
+      it('sorts by app, empty last', function () {
+        a = ['scopeA', { application: 'app' }];
+        b = ['scopeB', { application: '' }];
+        const result = th.scopeCompare(a, b);
+        assert.strictEqual(result, 1);
+      });
+    });
+  }); // scopeCompare
+
+}); // Template Helper
diff --git a/test/stub-db.js b/test/stub-db.js
new file mode 100644 (file)
index 0000000..f973b8d
--- /dev/null
@@ -0,0 +1,35 @@
+/* eslint-disable security/detect-object-injection */
+'use strict';
+
+const { StubDatabase: Base } = require('@squeep/test-helper'); // eslint-disable-line node/no-unpublished-require
+
+class StubDatabase extends Base {
+  get _stubFns() {
+    return [
+      ...super._stubFns,
+      'almanacGetAll',
+      'authenticationGet',
+      'authenticationSuccess',
+      'authenticationUpsert',
+      'profileIdentifierInsert',
+      'profileIsValid',
+      'profileScopeInsert',
+      'profileScopesSetAll',
+      'profilesScopesByIdentifier',
+      'redeemCode',
+      'refreshCode',
+      'resourceGet',
+      'resourceUpsert',
+      'scopeCleanup',
+      'scopeDelete',
+      'scopeUpsert',
+      'tokenCleanup',
+      'tokenGetByCodeId',
+      'tokenRefreshRevokeByCodeId',
+      'tokenRevokeByCodeId',
+      'tokensGetByIdentifier',    
+    ];
+  }
+}
+
+module.exports = StubDatabase;
\ No newline at end of file
diff --git a/test/stub-logger.js b/test/stub-logger.js
new file mode 100644 (file)
index 0000000..8be142f
--- /dev/null
@@ -0,0 +1,10 @@
+'use strict';
+
+const { StubLogger: Base } = require('@squeep/test-helper'); // eslint-disable-line node/no-unpublished-require
+
+
+class StubLogger extends Base {
+
+}
+
+module.exports = StubLogger;
\ No newline at end of file