Initial release v1.0.0
authorJustin Wind <justin.wind+git@gmail.com>
Fri, 2 Jul 2021 04:07:11 +0000 (21:07 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Sun, 1 Aug 2021 20:56:37 +0000 (13:56 -0700)
160 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]
LICENSE [new file with mode: 0644]
README.md [new file with mode: 0644]
bin/authUserAdd.js [new file with mode: 0644]
bin/dumpConfig.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]
package-lock.json [new file with mode: 0644]
package.json [new file with mode: 0644]
server.js [new file with mode: 0644]
src/authenticator.js [new file with mode: 0644]
src/common.js [new file with mode: 0644]
src/communication.js [new file with mode: 0644]
src/db/base.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/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/schema/1.0.0/apply.sql [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/subscription-count-by-topic-url.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-delete.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-delivery-attempts.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-delivery-claim-by-id.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-delivery-claim.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-delivery-done.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-delivery-failure.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-delivery-success.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-get-by-id.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-get.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-update.sql [new file with mode: 0644]
src/db/postgres/sql/subscription-upsert.sql [new file with mode: 0644]
src/db/postgres/sql/subscriptions-by-topic-id.sql [new file with mode: 0644]
src/db/postgres/sql/topic-attempts-increment.sql [new file with mode: 0644]
src/db/postgres/sql/topic-attempts-reset.sql [new file with mode: 0644]
src/db/postgres/sql/topic-attempts.sql [new file with mode: 0644]
src/db/postgres/sql/topic-content-fetch-claim-by-id.sql [new file with mode: 0644]
src/db/postgres/sql/topic-content-fetch-claim.sql [new file with mode: 0644]
src/db/postgres/sql/topic-content-fetch-done.sql [new file with mode: 0644]
src/db/postgres/sql/topic-content-fetch-requested.sql [new file with mode: 0644]
src/db/postgres/sql/topic-deleted.sql [new file with mode: 0644]
src/db/postgres/sql/topic-get-by-id.sql [new file with mode: 0644]
src/db/postgres/sql/topic-get-by-url.sql [new file with mode: 0644]
src/db/postgres/sql/topic-get-content-by-id.sql [new file with mode: 0644]
src/db/postgres/sql/topic-get-info-all.sql [new file with mode: 0644]
src/db/postgres/sql/topic-set-content.sql [new file with mode: 0644]
src/db/postgres/sql/topic-update.sql [new file with mode: 0644]
src/db/postgres/sql/topic-upsert.sql [new file with mode: 0644]
src/db/postgres/sql/verification-attempt-increment.sql [new file with mode: 0644]
src/db/postgres/sql/verification-attempt-reset.sql [new file with mode: 0644]
src/db/postgres/sql/verification-attempts.sql [new file with mode: 0644]
src/db/postgres/sql/verification-claim-by-id.sql [new file with mode: 0644]
src/db/postgres/sql/verification-claim.sql [new file with mode: 0644]
src/db/postgres/sql/verification-done.sql [new file with mode: 0644]
src/db/postgres/sql/verification-get-by-id.sql [new file with mode: 0644]
src/db/postgres/sql/verification-insert.sql [new file with mode: 0644]
src/db/postgres/sql/verification-scrub.sql [new file with mode: 0644]
src/db/postgres/sql/verification-update.sql [new file with mode: 0644]
src/db/postgres/sql/verification-validate.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/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/schema/1.0.0/apply.sql [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/subscription-count-by-topic-url.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-delete.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-delivery-attempts.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-delivery-claim-by-id.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-delivery-done.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-delivery-failure.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-delivery-needed.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-delivery-success.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-get-by-id.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-get.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-update.sql [new file with mode: 0644]
src/db/sqlite/sql/subscription-upsert.sql [new file with mode: 0644]
src/db/sqlite/sql/subscriptions-by-topic-id.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-attempts-increment.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-attempts-reset.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-attempts.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-content-fetch-claim-by-id.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-content-fetch-done.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-content-fetch-needed.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-content-fetch-requested.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-deleted.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-get-by-id.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-get-by-url.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-get-content-by-id.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-get-info-all.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-set-content.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-update.sql [new file with mode: 0644]
src/db/sqlite/sql/topic-upsert.sql [new file with mode: 0644]
src/db/sqlite/sql/verification-attempts-increment.sql [new file with mode: 0644]
src/db/sqlite/sql/verification-attempts.sql [new file with mode: 0644]
src/db/sqlite/sql/verification-claim-by-id.sql [new file with mode: 0644]
src/db/sqlite/sql/verification-done.sql [new file with mode: 0644]
src/db/sqlite/sql/verification-get-by-id.sql [new file with mode: 0644]
src/db/sqlite/sql/verification-insert.sql [new file with mode: 0644]
src/db/sqlite/sql/verification-needed.sql [new file with mode: 0644]
src/db/sqlite/sql/verification-scrub.sql [new file with mode: 0644]
src/db/sqlite/sql/verification-update.sql [new file with mode: 0644]
src/db/sqlite/sql/verification-validate.sql [new file with mode: 0644]
src/enum.js [new file with mode: 0644]
src/errors.js [new file with mode: 0644]
src/link-helper.js [new file with mode: 0644]
src/logger.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-overview-html.js [new file with mode: 0644]
src/template/admin-topic-details-html.js [new file with mode: 0644]
src/template/badge-svg.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]
src/worker.js [new file with mode: 0644]
static/.external-link.svg.meta [new file with mode: 0644]
static/external-link.svg [new file with mode: 0644]
static/index.html [new file with mode: 0644]
static/robots.txt [new file with mode: 0644]
static/theme.css [new file with mode: 0644]
test-e2e/fake-servers-client.js [new file with mode: 0644]
test-e2e/fake-servers.js [new file with mode: 0644]
test-e2e/postgres.env [new file with mode: 0644]
test-e2e/test-many.js [new file with mode: 0644]
test-e2e/test-one.js [new file with mode: 0644]
test/src/authenticator.js [new file with mode: 0644]
test/src/common.js [new file with mode: 0644]
test/src/communication.js [new file with mode: 0644]
test/src/db/base.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/link-helper.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-overview-html.js [new file with mode: 0644]
test/src/template/admin-topic-details-html.js [new file with mode: 0644]
test/src/template/badge-svg.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/src/worker.js [new file with mode: 0644]
test/stub-db.js [new file with mode: 0644]
test/stub-logger.js [new file with mode: 0644]
test/test-data/db-integration.js [new file with mode: 0644]
test/test-data/link-helper.js [new file with mode: 0644]
test/test-data/manager.js [new file with mode: 0644]

diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644 (file)
index 0000000..1a60b64
--- /dev/null
@@ -0,0 +1,82 @@
+{
+  "env": {
+    "browser": false,
+    "es6": true,
+    "node": true
+  },
+  "extends": [
+    "eslint:recommended",
+    "plugin:node/recommended",
+    "plugin:security/recommended",
+    "plugin:sonarjs/recommended"
+  ],
+  "parserOptions": {
+    "ecmaVersion": 2018
+  },
+  "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"
+    ],
+    "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..622f9a7
--- /dev/null
@@ -0,0 +1,5 @@
+node_modules
+.nyc_output
+coverage
+.vscode
+*.sqlite*
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..d299b62
--- /dev/null
@@ -0,0 +1,16 @@
+# Changelog
+
+Releases and notable changes to this project are documented here.
+
+## [Unreleased]
+
+## [v1.0.0] - 2021-08-01
+
+### Added
+
+Everything.  MVP first stable release.
+
+---
+
+[Unreleased]: https://git.squeep.com/?p=websub-hub;a=commitdiff;h=HEAD;hp=v1.0.0
+[v1.0.0]: https://git.squeep.com/?p=websub-hub;a=commitdiff;h=v1.0.0;hp=v0.0.0
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..c6d1669
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,5 @@
+Copyright (c) 2021 Justin Wind
+
+Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..32ca1bb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,151 @@
+# Welcome to my WebSub Hub
+
+## What
+
+[WebSub](https://www.w3.org/TR/websub/) is a protocol for subscribing to content updates from publishers.  The Hub is the central component which manages that relationship.
+
+This Hub implementation was created with personal [self-hostable](https://indieweb.org/WebSub) deployment in mind.  It is content-agnostic, supports multiple database backends, and can scale to multiple nodes for robustness and capacity.
+
+## Beware
+
+This is currently a Minimum Viable Product release.  Basic functionality is complete, but the administration experience may be challenging.
+
+## Up And Running
+
+Customize configuration within `config/${NODE_ENV}.js`.  All envs inherit settings from `default.js` if not specified.  Environment is selected using the `NODE_ENV` value, defaulting to `development` if unset.
+
+Database table initialization and schema version migrations are automated.  Configure SQLite with a database file, or point PostgreSQL to a created database.
+
+A user will need to be created in order to view the `/admin` pages; the `bin/authAddUser.js` script will do this.
+
+The bundled logger spews JSON to stdout.
+
+### Quickstart Example
+
+One way of deploying this server is behind nginx, with the pm2 package to manage the server process, and a local postgres database.  Some details on this are presented here as a rough guide to any parts of this stack which may be unfamiliar.
+
+- Have NodeJS 12-ish available.
+- Have PostgreSQL available.
+- Clone the server repository.  
+  ```git clone https://git.squeep.com/websub-hub```  
+- Install the production dependencies.  
+  ```cd websub-hub```  
+  ```NODE_ENV=production npm i```  
+- Create a ```config/production.js``` configuration file.  See ```config/default.js``` for available settings.  
+  > <pre>
+  > 'use strict';
+  > // Minimum required configuration settings
+  > module.exports = {
+  >   dingus: {
+  >     selfBaseUrl: 'https://hub.squeep.com/',
+  >   },
+  >   db: {
+  >     connectionString: 'postgresql://websubhub:mypassword@localhost/websubhub',
+  >   },
+  > };
+  > </pre>
+- Prepare PostgreSQL with a user and database, using e.g. ```psql```.  
+  > <pre>
+  > CREATE ROLE websubhub WITH CREATEDB LOGIN PASSWORD 'mypassword';
+  > GRANT websubhub TO postgres
+  > CREATE DATABASE websubhub OWNER=websubhub;
+  > GRANT ALL PRIVILEGES ON DATABASE websubhub TO websubhub;
+  > \c websubhub
+  > CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
+  > </pre>
+- Install the process manager, system-wide.  
+  ```npm i -g pm2```
+- Configure the process manager to keep the server logs from growing unbounded.  
+  ```pm2 install pm2-logrotate```  
+  ```pm2 set pm2-logrotate:rotateInterval '0 0 1 * *'``` (rotate monthly)  
+  ```pm2 set pm2-logrotate:compress true```  
+  ```pm2 startup``` (arrange to start process monitor on system boot)  
+- Launch the server, running one process per CPU, and persist it through reboots.
+  ```NODE_ENV=production pm2 start server.js --name websubhub -i max```  
+  ```pm2 save```
+- Create an administration user.
+  ```NODE_ENV=production node bin/authUserAdd.js admin```
+- Copy the static files to somewhere nginx will serve them from.  This will vary greatly depending on your setup.  
+  ```cp -rp static /home/websubhub/hub.squeep.com/html/static```
+- Expose the server through nginx.  
+  > <pre>
+  > server {
+  >   listen 443 ssl http2;
+  >   ssl_certificate /etc/ssl/nginx/server-chain.pem;
+  >   ssl_certificate_key /etc/ssl/nginx/server.key;
+  >   server_name hub.squeep.com;
+  >   root /home/websubhub/hub.squeep.com/html
+  >   try_files $uri $uri/ @websubhub;
+  > 
+  >   location @websubhub {
+  >     proxy_pass http://websubhub$uri;
+  >     proxy_set_header Host $host;
+  >     proxy_set_header X-Forwarded-For $remote_addr;
+  >     proxy_set_header X-Forwarded-Proto $scheme;
+  >     proxy_http_version 1.1;
+  >   }
+  > 
+  >   location = / {
+  >     proxy_pass http://websubhub$is_args$args;
+  >     proxy_set_header Host $host;
+  >     proxy_set_header X-Forwarded-For $remote_addr;
+  >     proxy_set_header X-Forwarded-Proto $scheme;
+  >     proxy_http_version 1.1;
+  >   }
+  > }
+  > </pre>
+  ```nginx -s reload```
+- The Hub server should now be available!
+
+## Frills
+
+A rudimentary tally of a topic's subscribers is available on the `/info?topic=topicUrl` endpoint.  The topicUrl should be URI encoded.  Formats available are SVG badge, JSON, and plain text, selectable by setting e.g. `format=svg` in the query parameters.
+
+## Architecture
+
+The Hub keeps track of three primary entities:
+
+- Topics: data and metadata for a published content endpoint.  Topics are unique by source URL.
+- Subscriptions: the relationship between a client requesting content and the topic providing it.  Subscriptions are unique by topic and client URL.
+- Verifications: updates to subscriptions which are pending confirmation.  Verifications are not unique, but only the most recent for any Subscription pairing will be acted upon.
+
+Any tasks in progress (notably: fetching new topic content, distributing that content to subscribers, or confirming pending verifications) are doled out and managed by a cooperative advisory locking mechanism.  The task queue is wrangled in the database within the `*_in_progress` tables.
+
+A Hub node will periodically check for more tasks to perform, executing them up to a set concurrency limit.
+
+### Quirks
+
+This implementation is built atop an in-house API framework, for Reasons.  It would not be hard to replace such with something more mainstream, but that is not currently 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/
+  - common.js - utility functions
+  - communication.js - outgoing requests and associated logic
+  - db/
+    - base.js - abstract 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 implementation
+      - sql/ - statements and schemas
+    - sqlite/
+      - index.js - SQLite implementation
+      - sql/ - statements and schemas
+  - enum.js - invariants
+  - errors.js - local Error types
+  - link-helper.js - processes Link headers
+  - logger.js - a very simple logging class
+  - manager.js - process incoming requests
+  - service.js - defines incoming endpoints, linking the API server framework to the manager methods
+  - template/ - HTML content
+  - worker.js - maintains a pool of tasks in progress, for sending out updates, performing verifications, et cetera
+- test/ - unit and coverage tests
+- test-e2e/ - support for whole-service testing
diff --git a/bin/authUserAdd.js b/bin/authUserAdd.js
new file mode 100644 (file)
index 0000000..4eaff65
--- /dev/null
@@ -0,0 +1,56 @@
+'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 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 () => {
+  const password = await readPassword('password: ');
+  const 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/dumpConfig.js b/bin/dumpConfig.js
new file mode 100644 (file)
index 0000000..6a8450a
--- /dev/null
@@ -0,0 +1,6 @@
+'use strict';
+
+const Config = require('../config');
+const config = new Config(process.env.NODE_ENV);
+
+console.log(config);
diff --git a/config/default.js b/config/default.js
new file mode 100644 (file)
index 0000000..5c7091f
--- /dev/null
@@ -0,0 +1,72 @@
+'use strict';
+
+// Provide default values for all configuration.
+
+const packageName = require('../package.json').name;
+const common = require('../src/common');
+
+const defaultOptions = {
+  // Uniquely identify this instance, used to tag work-in-progress.
+  nodeId: common.requestId(), // Default to ephemeral ID: easiest for clustered deployments.
+
+  // Dingus API Server Framework options. Be sure to set the one REQUIRED option here.
+  dingus: {
+    // This needs to be the full externally accessible root URL, including any proxyPrefix component, which clients will connect to, and which topics will list as their hub link.
+    selfBaseUrl: '', // REQUIRED
+
+    // 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. /hub
+  },
+
+  // Database options
+  db: {
+    connectionString: '', // e.g. sqlite://path/to/dbfile.sqlite
+    queryLogLevel: undefined, // Set to log queries
+  },
+
+  // Logging options
+  logger: {
+    ignoreBelowLevel: 'info',
+  },
+
+  // Lease time limits, if not specified per-topic. Comments are defaults in code.
+  topicLeaseDefaults: {
+    // leaseSecondsPreferred: 86400 * 10,
+    // leaseSecondsMin: 86400 * 1,
+    // leaseSecondsMax: 86400 * 365,
+  },
+
+  manager: {
+    pageTitle: packageName, // title on html pages
+    strictSecrets: false, // If true, reject requests with secrets but not over https
+    publicHub: true, // Accept publish requests as new topics.
+    processImmediately: true, // If true, immediately attempt to process requests when accepted.
+  },
+
+  communication: {
+    strictTopicHubLink: true, // If true, deletes topics which do not list us (dingus.selfBaseUrl) as a hub relation.
+    retryBackoffSeconds: [60, 120, 360, 1440, 7200, 43200, 86400], // failed requests retry according to number of attempts
+    claimTimeoutSeconds: 600, // how long until an in-progress task is deemed abandoned
+  },
+
+  // Outgoing request UA header. Comments are defaults in code.
+  userAgent: {
+    // product: packageName,
+    // version: packageVersion,
+    // implementation: Enum.Specification,
+  },
+
+  authenticator: {
+    basicRealm: packageName, // Realm prompt for login on administration pages
+    secureAuthOnly: true, // Require secure transport for authentication.
+  },
+
+  worker: {
+    concurrency: 10, // maximum number of tasks to process at once
+    pollingEnabled: true, // periodically check for new tasks
+    recurrSleepMs: 60000, // check this often
+  },
+
+};
+
+module.exports = defaultOptions;
\ No newline at end of file
diff --git a/config/development.js b/config/development.js
new file mode 100644 (file)
index 0000000..4cd4a8d
--- /dev/null
@@ -0,0 +1,10 @@
+'use strict';
+
+module.exports = {
+  logger: {
+    ignoreBelowLevel: 'debug',
+  },
+  db: {
+    connectionString: `postgresql://${encodeURIComponent('/home/develop/websub-hub/postgres_dev-13')}/websubhub`,
+  },
+};
diff --git a/config/index.js b/config/index.js
new file mode 100644 (file)
index 0000000..d06162e
--- /dev/null
@@ -0,0 +1,24 @@
+'use strict';
+
+const common = require('../src/common');
+
+const defaultEnvironment = 'development';
+const testEnvironment = 'test';
+
+function Config(environment) {
+  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 (!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..091b6c0
--- /dev/null
@@ -0,0 +1,12 @@
+'use strict';
+module.exports = {
+  dingus: {
+    selfBaseUrl: 'https://example.com/hub/',
+  },
+  worker: {
+    pollingEnabled: false,
+  },
+  db: {
+    queryLogLevel: 'debug',
+  },
+};
diff --git a/package-lock.json b/package-lock.json
new file mode 100644 (file)
index 0000000..410c0aa
--- /dev/null
@@ -0,0 +1,3771 @@
+{
+  "name": "websub-hub",
+  "version": "1.0.0",
+  "lockfileVersion": 1,
+  "requires": true,
+  "dependencies": {
+    "@babel/code-frame": {
+      "version": "7.12.11",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
+      "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==",
+      "dev": true,
+      "requires": {
+        "@babel/highlight": "^7.10.4"
+      }
+    },
+    "@babel/compat-data": {
+      "version": "7.14.7",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.7.tgz",
+      "integrity": "sha512-nS6dZaISCXJ3+518CWiBfEr//gHyMO02uDxBkXTKZDN5POruCnOZ1N4YBRZDCabwF8nZMWBpRxIicmXtBs+fvw==",
+      "dev": true
+    },
+    "@babel/core": {
+      "version": "7.14.6",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.6.tgz",
+      "integrity": "sha512-gJnOEWSqTk96qG5BoIrl5bVtc23DCycmIePPYnamY9RboYdI4nFy5vAQMSl81O5K/W0sLDWfGysnOECC+KUUCA==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.14.5",
+        "@babel/generator": "^7.14.5",
+        "@babel/helper-compilation-targets": "^7.14.5",
+        "@babel/helper-module-transforms": "^7.14.5",
+        "@babel/helpers": "^7.14.6",
+        "@babel/parser": "^7.14.6",
+        "@babel/template": "^7.14.5",
+        "@babel/traverse": "^7.14.5",
+        "@babel/types": "^7.14.5",
+        "convert-source-map": "^1.7.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.1.2",
+        "semver": "^6.3.0",
+        "source-map": "^0.5.0"
+      },
+      "dependencies": {
+        "@babel/code-frame": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz",
+          "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==",
+          "dev": true,
+          "requires": {
+            "@babel/highlight": "^7.14.5"
+          }
+        },
+        "@babel/helper-validator-identifier": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz",
+          "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==",
+          "dev": true
+        },
+        "@babel/highlight": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz",
+          "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==",
+          "dev": true,
+          "requires": {
+            "@babel/helper-validator-identifier": "^7.14.5",
+            "chalk": "^2.0.0",
+            "js-tokens": "^4.0.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"
+          }
+        },
+        "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.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.5.tgz",
+      "integrity": "sha512-y3rlP+/G25OIX3mYKKIOlQRcqj7YgrvHxOLbVmyLJ9bPmi5ttvUmpydVjcFjZphOktWuA7ovbx91ECloWTfjIA==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.14.5",
+        "jsesc": "^2.5.1",
+        "source-map": "^0.5.0"
+      }
+    },
+    "@babel/helper-compilation-targets": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.5.tgz",
+      "integrity": "sha512-v+QtZqXEiOnpO6EYvlImB6zCD2Lel06RzOPzmkz/D/XgQiUu3C/Jb1LOqSt/AIA34TYi/Q+KlT8vTQrgdxkbLw==",
+      "dev": true,
+      "requires": {
+        "@babel/compat-data": "^7.14.5",
+        "@babel/helper-validator-option": "^7.14.5",
+        "browserslist": "^4.16.6",
+        "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-function-name": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz",
+      "integrity": "sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-get-function-arity": "^7.14.5",
+        "@babel/template": "^7.14.5",
+        "@babel/types": "^7.14.5"
+      }
+    },
+    "@babel/helper-get-function-arity": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz",
+      "integrity": "sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.14.5"
+      }
+    },
+    "@babel/helper-hoist-variables": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz",
+      "integrity": "sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.14.5"
+      }
+    },
+    "@babel/helper-member-expression-to-functions": {
+      "version": "7.14.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.14.7.tgz",
+      "integrity": "sha512-TMUt4xKxJn6ccjcOW7c4hlwyJArizskAhoSTOCkA0uZ+KghIaci0Qg9R043kUMWI9mtQfgny+NQ5QATnZ+paaA==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.14.5"
+      }
+    },
+    "@babel/helper-module-imports": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz",
+      "integrity": "sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.14.5"
+      }
+    },
+    "@babel/helper-module-transforms": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.14.5.tgz",
+      "integrity": "sha512-iXpX4KW8LVODuAieD7MzhNjmM6dzYY5tfRqT+R9HDXWl0jPn/djKmA+G9s/2C2T9zggw5tK1QNqZ70USfedOwA==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-module-imports": "^7.14.5",
+        "@babel/helper-replace-supers": "^7.14.5",
+        "@babel/helper-simple-access": "^7.14.5",
+        "@babel/helper-split-export-declaration": "^7.14.5",
+        "@babel/helper-validator-identifier": "^7.14.5",
+        "@babel/template": "^7.14.5",
+        "@babel/traverse": "^7.14.5",
+        "@babel/types": "^7.14.5"
+      },
+      "dependencies": {
+        "@babel/helper-validator-identifier": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz",
+          "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==",
+          "dev": true
+        }
+      }
+    },
+    "@babel/helper-optimise-call-expression": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz",
+      "integrity": "sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.14.5"
+      }
+    },
+    "@babel/helper-replace-supers": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.14.5.tgz",
+      "integrity": "sha512-3i1Qe9/8x/hCHINujn+iuHy+mMRLoc77b2nI9TB0zjH1hvn9qGlXjWlggdwUcju36PkPCy/lpM7LLUdcTyH4Ow==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-member-expression-to-functions": "^7.14.5",
+        "@babel/helper-optimise-call-expression": "^7.14.5",
+        "@babel/traverse": "^7.14.5",
+        "@babel/types": "^7.14.5"
+      }
+    },
+    "@babel/helper-simple-access": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.14.5.tgz",
+      "integrity": "sha512-nfBN9xvmCt6nrMZjfhkl7i0oTV3yxR4/FztsbOASyTvVcoYd0TRHh7eMLdlEcCqobydC0LAF3LtC92Iwxo0wyw==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.14.5"
+      }
+    },
+    "@babel/helper-split-export-declaration": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz",
+      "integrity": "sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.14.5"
+      }
+    },
+    "@babel/helper-validator-identifier": {
+      "version": "7.14.8",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.8.tgz",
+      "integrity": "sha512-ZGy6/XQjllhYQrNw/3zfWRwZCTVSiBLZ9DHVZxn9n2gip/7ab8mv2TWlKPIBk26RwedCBoWdjLmn+t9na2Gcow==",
+      "dev": true
+    },
+    "@babel/helper-validator-option": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz",
+      "integrity": "sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==",
+      "dev": true
+    },
+    "@babel/helpers": {
+      "version": "7.14.6",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.14.6.tgz",
+      "integrity": "sha512-yesp1ENQBiLI+iYHSJdoZKUtRpfTlL1grDIX9NRlAVppljLw/4tTyYupIB7uIYmC3stW/imAv8EqaKaS/ibmeA==",
+      "dev": true,
+      "requires": {
+        "@babel/template": "^7.14.5",
+        "@babel/traverse": "^7.14.5",
+        "@babel/types": "^7.14.5"
+      }
+    },
+    "@babel/highlight": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz",
+      "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-validator-identifier": "^7.14.5",
+        "chalk": "^2.0.0",
+        "js-tokens": "^4.0.0"
+      },
+      "dependencies": {
+        "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"
+          }
+        }
+      }
+    },
+    "@babel/parser": {
+      "version": "7.14.7",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.7.tgz",
+      "integrity": "sha512-X67Z5y+VBJuHB/RjwECp8kSl5uYi0BvRbNeWqkaJCVh+LiTPl19WBUfG627psSgp9rSf6ojuXghQM3ha6qHHdA==",
+      "dev": true
+    },
+    "@babel/template": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz",
+      "integrity": "sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.14.5",
+        "@babel/parser": "^7.14.5",
+        "@babel/types": "^7.14.5"
+      },
+      "dependencies": {
+        "@babel/code-frame": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz",
+          "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==",
+          "dev": true,
+          "requires": {
+            "@babel/highlight": "^7.14.5"
+          }
+        },
+        "@babel/helper-validator-identifier": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz",
+          "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==",
+          "dev": true
+        },
+        "@babel/highlight": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz",
+          "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==",
+          "dev": true,
+          "requires": {
+            "@babel/helper-validator-identifier": "^7.14.5",
+            "chalk": "^2.0.0",
+            "js-tokens": "^4.0.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"
+          }
+        }
+      }
+    },
+    "@babel/traverse": {
+      "version": "7.14.7",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.7.tgz",
+      "integrity": "sha512-9vDr5NzHu27wgwejuKL7kIOm4bwEtaPQ4Z6cpCmjSuaRqpH/7xc4qcGEscwMqlkwgcXl6MvqoAjZkQ24uSdIZQ==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.14.5",
+        "@babel/generator": "^7.14.5",
+        "@babel/helper-function-name": "^7.14.5",
+        "@babel/helper-hoist-variables": "^7.14.5",
+        "@babel/helper-split-export-declaration": "^7.14.5",
+        "@babel/parser": "^7.14.7",
+        "@babel/types": "^7.14.5",
+        "debug": "^4.1.0",
+        "globals": "^11.1.0"
+      },
+      "dependencies": {
+        "@babel/code-frame": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz",
+          "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==",
+          "dev": true,
+          "requires": {
+            "@babel/highlight": "^7.14.5"
+          }
+        },
+        "@babel/helper-validator-identifier": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz",
+          "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==",
+          "dev": true
+        },
+        "@babel/highlight": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz",
+          "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==",
+          "dev": true,
+          "requires": {
+            "@babel/helper-validator-identifier": "^7.14.5",
+            "chalk": "^2.0.0",
+            "js-tokens": "^4.0.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"
+          }
+        },
+        "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.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.5.tgz",
+      "integrity": "sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-validator-identifier": "^7.14.5",
+        "to-fast-properties": "^2.0.0"
+      },
+      "dependencies": {
+        "@babel/helper-validator-identifier": {
+          "version": "7.14.5",
+          "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz",
+          "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==",
+          "dev": true
+        }
+      }
+    },
+    "@eslint/eslintrc": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz",
+      "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==",
+      "dev": true,
+      "requires": {
+        "ajv": "^6.12.4",
+        "debug": "^4.1.1",
+        "espree": "^7.3.0",
+        "globals": "^13.9.0",
+        "ignore": "^4.0.6",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^3.13.1",
+        "minimatch": "^3.0.4",
+        "strip-json-comments": "^3.1.1"
+      }
+    },
+    "@humanwhocodes/config-array": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz",
+      "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==",
+      "dev": true,
+      "requires": {
+        "@humanwhocodes/object-schema": "^1.2.0",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.4"
+      }
+    },
+    "@humanwhocodes/object-schema": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz",
+      "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==",
+      "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": {
+        "camelcase": {
+          "version": "5.3.1",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+          "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+          "dev": 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==",
+          "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
+        }
+      }
+    },
+    "@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
+    },
+    "@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==",
+      "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"
+      }
+    },
+    "@phc/format": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
+      "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ=="
+    },
+    "@sinonjs/commons": {
+      "version": "1.8.3",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz",
+      "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==",
+      "dev": true,
+      "requires": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "@sinonjs/fake-timers": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz",
+      "integrity": "sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^1.7.0"
+      }
+    },
+    "@sinonjs/samsam": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.0.2.tgz",
+      "integrity": "sha512-jxPRPp9n93ci7b8hMfJOFDPRLFYadN6FSpeROFTR4UNF4i5b+EK6m4QXPO46BDhFgRy1JuS87zAnFOzCUwMJcQ==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^1.6.0",
+        "lodash.get": "^4.4.2",
+        "type-detect": "^4.0.8"
+      }
+    },
+    "@sinonjs/text-encoding": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz",
+      "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==",
+      "dev": true
+    },
+    "@squeep/api-dingus": {
+      "version": "git+https://git.squeep.com/squeep-api-dingus/#16db6709ab8407b1f696e3d5f92aa6980f182f39",
+      "from": "git+https://git.squeep.com/squeep-api-dingus/#v1.0.0",
+      "requires": {
+        "mime-db": "^1.49.0",
+        "uuid": "^8.3.2"
+      },
+      "dependencies": {
+        "mime-db": {
+          "version": "1.49.0",
+          "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.49.0.tgz",
+          "integrity": "sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA=="
+        },
+        "uuid": {
+          "version": "8.3.2",
+          "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+          "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
+        }
+      }
+    },
+    "@squeep/web-linking": {
+      "version": "git+https://git.squeep.com/squeep-web-linking/#6506e54d8183d383fa9c127b87783179a0662fc1",
+      "from": "git+https://git.squeep.com/squeep-web-linking/#v1.0.0"
+    },
+    "@ungap/promise-all-settled": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz",
+      "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==",
+      "dev": true
+    },
+    "abbrev": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+      "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
+    },
+    "acorn": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
+      "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+      "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
+    },
+    "addressparser": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz",
+      "integrity": "sha1-R6++GiqSYhkdtoOOT9HTm0CCF0Y="
+    },
+    "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==",
+      "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"
+      }
+    },
+    "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.0",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
+      "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
+      "dev": true
+    },
+    "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"
+      }
+    },
+    "anymatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+      "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+      "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": "1.2.0",
+      "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+      "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
+    },
+    "archy": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
+      "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=",
+      "dev": true
+    },
+    "are-we-there-yet": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz",
+      "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==",
+      "requires": {
+        "delegates": "^1.0.0",
+        "readable-stream": "^2.0.6"
+      }
+    },
+    "argon2": {
+      "version": "0.28.2",
+      "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.28.2.tgz",
+      "integrity": "sha512-8oRk3kPlL0lLletENzhpbF9zoZJqvIHwTkjBseMrg1uD4gBMqhqnjJz1z3lEtwT0oqQAEkEwsEpsjaQBBRHcWw==",
+      "requires": {
+        "@mapbox/node-pre-gyp": "^1.0.1",
+        "@phc/format": "^1.0.0",
+        "node-addon-api": "^3.0.2",
+        "opencollective-postinstall": "^2.0.3"
+      }
+    },
+    "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"
+      }
+    },
+    "array-indexofobject": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/array-indexofobject/-/array-indexofobject-0.0.1.tgz",
+      "integrity": "sha1-qqEo5iybPDWAlFaMIZ/2T+SJ1Co="
+    },
+    "assert-options": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/assert-options/-/assert-options-0.7.0.tgz",
+      "integrity": "sha512-7q9uNH/Dh8gFgpIIb9ja8PJEWA5AQy3xnBC8jtKs8K/gNVCr1K6kIvlm59HUyYgvM7oEDoLzGgPcGd9FqhtXEQ=="
+    },
+    "astral-regex": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+      "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+      "dev": true
+    },
+    "axios": {
+      "version": "0.21.1",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
+      "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
+      "requires": {
+        "follow-redirects": "^1.10.0"
+      }
+    },
+    "balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+    },
+    "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": "7.4.3",
+      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-7.4.3.tgz",
+      "integrity": "sha512-07bKjClZg/f4KMVRkzWtoIvazVPcF1gsvVKVIXlxwleC2DxuIhnra3KCMlUT1rFeRYXXckot2a46UciF2d9KLw==",
+      "requires": {
+        "bindings": "^1.5.0",
+        "prebuild-install": "^6.0.1",
+        "tar": "^6.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"
+          }
+        }
+      }
+    },
+    "brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "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.16.6",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.16.6.tgz",
+      "integrity": "sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ==",
+      "dev": true,
+      "requires": {
+        "caniuse-lite": "^1.0.30001219",
+        "colorette": "^1.2.2",
+        "electron-to-chromium": "^1.3.723",
+        "escalade": "^3.1.1",
+        "node-releases": "^1.1.71"
+      }
+    },
+    "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.1",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
+      "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
+      "dev": true
+    },
+    "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
+    },
+    "camelcase": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz",
+      "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==",
+      "dev": true
+    },
+    "caniuse-lite": {
+      "version": "1.0.30001243",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001243.tgz",
+      "integrity": "sha512-vNxw9mkTBtkmLFnJRv/2rhs1yufpDfCkBZexG3Y0xdOH2Z/eE/85E4Dl5j1YUN34nZVsSp6vVRFQRrez9wJMRA==",
+      "dev": true
+    },
+    "chalk": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
+      "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "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==",
+          "dev": 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==",
+          "dev": true
+        },
+        "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
+        },
+        "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"
+          }
+        }
+      }
+    },
+    "chokidar": {
+      "version": "3.5.2",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
+      "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==",
+      "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"
+      }
+    },
+    "chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
+    },
+    "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": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
+    },
+    "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": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+      "dev": true
+    },
+    "colorette": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
+      "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==",
+      "dev": true
+    },
+    "commondir": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+      "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
+      "dev": true
+    },
+    "concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+    },
+    "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"
+      }
+    },
+    "console-control-strings": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+      "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
+    },
+    "convert-source-map": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz",
+      "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==",
+      "dev": true,
+      "requires": {
+        "safe-buffer": "~5.1.1"
+      },
+      "dependencies": {
+        "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==",
+          "dev": true
+        }
+      }
+    },
+    "core-util-is": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+    },
+    "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.2",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+      "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+      "requires": {
+        "ms": "2.1.2"
+      }
+    },
+    "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
+    },
+    "decompress-response": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
+      "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
+      "requires": {
+        "mimic-response": "^2.0.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.3",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
+      "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=",
+      "dev": true
+    },
+    "default-require-extensions": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz",
+      "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==",
+      "dev": true,
+      "requires": {
+        "strip-bom": "^4.0.0"
+      }
+    },
+    "delegates": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+      "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
+    },
+    "detect-libc": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+      "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups="
+    },
+    "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"
+      }
+    },
+    "dom-serializer": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz",
+      "integrity": "sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==",
+      "requires": {
+        "domelementtype": "^2.0.1",
+        "domhandler": "^4.2.0",
+        "entities": "^2.0.0"
+      }
+    },
+    "domelementtype": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz",
+      "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A=="
+    },
+    "domhandler": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.2.0.tgz",
+      "integrity": "sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA==",
+      "requires": {
+        "domelementtype": "^2.2.0"
+      }
+    },
+    "domutils": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.7.0.tgz",
+      "integrity": "sha512-8eaHa17IwJUPAiB+SoTYBo5mCdeMgdcAoXJ59m6DT1vw+5iLS3gNoqYaRowaBKtGVrOF1Jz4yDTgYKLK2kvfJg==",
+      "requires": {
+        "dom-serializer": "^1.0.1",
+        "domelementtype": "^2.2.0",
+        "domhandler": "^4.2.0"
+      }
+    },
+    "electron-to-chromium": {
+      "version": "1.3.769",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.769.tgz",
+      "integrity": "sha512-B+3hW8D76/uoTPSobWI3D/CFn2S4jPn88dVJ+BkD88Lz6LijQpL+hfdzIFJGTQK4KdE0XwmNbjUQFH1OQVwKdQ==",
+      "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==",
+      "dev": 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"
+      }
+    },
+    "enquirer": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
+      "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
+      "dev": true,
+      "requires": {
+        "ansi-colors": "^4.1.1"
+      }
+    },
+    "entities": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
+      "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="
+    },
+    "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": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+      "dev": true
+    },
+    "eslint": {
+      "version": "7.32.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz",
+      "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "7.12.11",
+        "@eslint/eslintrc": "^0.4.3",
+        "@humanwhocodes/config-array": "^0.5.0",
+        "ajv": "^6.10.0",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.0.1",
+        "doctrine": "^3.0.0",
+        "enquirer": "^2.3.5",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^5.1.1",
+        "eslint-utils": "^2.1.0",
+        "eslint-visitor-keys": "^2.0.0",
+        "espree": "^7.3.1",
+        "esquery": "^1.4.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "functional-red-black-tree": "^1.0.1",
+        "glob-parent": "^5.1.2",
+        "globals": "^13.6.0",
+        "ignore": "^4.0.6",
+        "import-fresh": "^3.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "js-yaml": "^3.13.1",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.0.4",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.1",
+        "progress": "^2.0.0",
+        "regexpp": "^3.1.0",
+        "semver": "^7.2.1",
+        "strip-ansi": "^6.0.0",
+        "strip-json-comments": "^3.1.0",
+        "table": "^6.0.9",
+        "text-table": "^0.2.0",
+        "v8-compile-cache": "^2.0.3"
+      },
+      "dependencies": {
+        "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-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"
+      }
+    },
+    "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": {
+        "ignore": {
+          "version": "5.1.8",
+          "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
+          "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==",
+          "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-security": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.4.0.tgz",
+      "integrity": "sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA==",
+      "dev": true,
+      "requires": {
+        "safe-regex": "^1.1.0"
+      }
+    },
+    "eslint-plugin-sonarjs": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.9.1.tgz",
+      "integrity": "sha512-KKFofk1LPjGHWeAZijYWv32c/C4mz+OAeBNVxhxHu1hknrTOhu415MWC8qKdAdsmOlBPShs9evM4mI1o7MNMhw==",
+      "dev": true
+    },
+    "eslint-scope": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+      "dev": true,
+      "requires": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^4.1.1"
+      }
+    },
+    "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"
+      },
+      "dependencies": {
+        "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-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
+    },
+    "espree": {
+      "version": "7.3.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz",
+      "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==",
+      "dev": true,
+      "requires": {
+        "acorn": "^7.4.0",
+        "acorn-jsx": "^5.3.1",
+        "eslint-visitor-keys": "^1.3.0"
+      },
+      "dependencies": {
+        "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
+        }
+      }
+    },
+    "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"
+      },
+      "dependencies": {
+        "estraverse": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+          "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+          "dev": true
+        }
+      }
+    },
+    "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"
+      },
+      "dependencies": {
+        "estraverse": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+          "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+          "dev": true
+        }
+      }
+    },
+    "estraverse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+      "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": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
+      "dev": true
+    },
+    "feedparser": {
+      "version": "2.2.10",
+      "resolved": "https://registry.npmjs.org/feedparser/-/feedparser-2.2.10.tgz",
+      "integrity": "sha512-WoAOooa61V8/xuKMi2pEtK86qQ3ZH/M72EEGdqlOTxxb3m6ve1NPvZcmPFs3wEDfcBbFLId2GqZ4YjsYi+h1xA==",
+      "requires": {
+        "addressparser": "^1.0.1",
+        "array-indexofobject": "~0.0.1",
+        "lodash.assign": "^4.2.0",
+        "lodash.get": "^4.4.2",
+        "lodash.has": "^4.5.2",
+        "lodash.uniq": "^4.5.0",
+        "mri": "^1.1.5",
+        "readable-stream": "^2.3.7",
+        "sax": "^1.2.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.1",
+      "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz",
+      "integrity": "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==",
+      "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.2",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.2.tgz",
+      "integrity": "sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==",
+      "dev": true
+    },
+    "follow-redirects": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz",
+      "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg=="
+    },
+    "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"
+      }
+    },
+    "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==",
+      "requires": {
+        "minipass": "^3.0.0"
+      }
+    },
+    "fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+    },
+    "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
+    },
+    "functional-red-black-tree": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+      "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+      "dev": true
+    },
+    "gauge": {
+      "version": "2.7.4",
+      "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+      "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
+      "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": {
+        "ansi-regex": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
+        },
+        "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": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
+          "requires": {
+            "number-is-nan": "^1.0.0"
+          }
+        },
+        "string-width": {
+          "version": "1.0.2",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+          "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
+          "requires": {
+            "code-point-at": "^1.0.0",
+            "is-fullwidth-code-point": "^1.0.0",
+            "strip-ansi": "^3.0.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "requires": {
+            "ansi-regex": "^2.0.0"
+          }
+        }
+      }
+    },
+    "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==",
+      "dev": 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": "sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4="
+    },
+    "glob": {
+      "version": "7.1.7",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+      "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+      "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": "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"
+      }
+    },
+    "globals": {
+      "version": "13.10.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.10.0.tgz",
+      "integrity": "sha512-piHC3blgLGFjvOuMmWZX60f+na1lXFDhQXBf1UYp2fXPXqvEUbOhNwi6BsQ0bQishwedgnjkwv1d9zKf+MWw3g==",
+      "dev": true,
+      "requires": {
+        "type-fest": "^0.20.2"
+      },
+      "dependencies": {
+        "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
+        }
+      }
+    },
+    "graceful-fs": {
+      "version": "4.2.6",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
+      "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==",
+      "dev": true
+    },
+    "growl": {
+      "version": "1.10.5",
+      "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz",
+      "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==",
+      "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": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+      "dev": true
+    },
+    "has-unicode": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+      "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
+    },
+    "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"
+      }
+    },
+    "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
+    },
+    "htmlparser2": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
+      "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
+      "requires": {
+        "domelementtype": "^2.0.1",
+        "domhandler": "^4.0.0",
+        "domutils": "^2.5.2",
+        "entities": "^2.0.0"
+      }
+    },
+    "https-proxy-agent": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
+      "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
+      "requires": {
+        "agent-base": "6",
+        "debug": "4"
+      }
+    },
+    "ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
+    },
+    "ignore": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+      "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+      "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": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+      "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": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "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=="
+    },
+    "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.4.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz",
+      "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==",
+      "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": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "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==",
+      "dev": true
+    },
+    "is-glob": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
+      "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+      "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-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.0",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz",
+      "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==",
+      "dev": true
+    },
+    "is-typedarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+      "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
+      "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": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+    },
+    "isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+      "dev": true
+    },
+    "istanbul-lib-coverage": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz",
+      "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==",
+      "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.2",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz",
+      "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==",
+      "dev": true,
+      "requires": {
+        "archy": "^1.0.0",
+        "cross-spawn": "^7.0.0",
+        "istanbul-lib-coverage": "^3.0.0-alpha.1",
+        "make-dir": "^3.0.0",
+        "p-map": "^3.0.0",
+        "rimraf": "^3.0.0",
+        "uuid": "^3.3.3"
+      }
+    },
+    "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"
+      },
+      "dependencies": {
+        "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
+        },
+        "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"
+          }
+        }
+      }
+    },
+    "istanbul-lib-source-maps": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz",
+      "integrity": "sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==",
+      "dev": true,
+      "requires": {
+        "debug": "^4.1.1",
+        "istanbul-lib-coverage": "^3.0.0",
+        "source-map": "^0.6.1"
+      },
+      "dependencies": {
+        "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
+        }
+      }
+    },
+    "istanbul-reports": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.2.tgz",
+      "integrity": "sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==",
+      "dev": true,
+      "requires": {
+        "html-escaper": "^2.0.0",
+        "istanbul-lib-report": "^3.0.0"
+      }
+    },
+    "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": "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"
+      }
+    },
+    "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": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
+      "dev": true
+    },
+    "json5": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
+      "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
+      "dev": true,
+      "requires": {
+        "minimist": "^1.2.5"
+      }
+    },
+    "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.assign": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz",
+      "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc="
+    },
+    "lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=",
+      "dev": true
+    },
+    "lodash.flattendeep": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
+      "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=",
+      "dev": true
+    },
+    "lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
+    },
+    "lodash.has": {
+      "version": "4.5.2",
+      "resolved": "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz",
+      "integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI="
+    },
+    "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
+    },
+    "lodash.truncate": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+      "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
+      "dev": true
+    },
+    "lodash.uniq": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+      "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M="
+    },
+    "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"
+      }
+    },
+    "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"
+      }
+    },
+    "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==",
+      "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=="
+        }
+      }
+    },
+    "mimic-response": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
+      "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA=="
+    },
+    "minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+      "requires": {
+        "brace-expansion": "^1.1.7"
+      }
+    },
+    "minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+    },
+    "minipass": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz",
+      "integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==",
+      "requires": {
+        "yallist": "^4.0.0"
+      }
+    },
+    "minizlib": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+      "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+      "requires": {
+        "minipass": "^3.0.0",
+        "yallist": "^4.0.0"
+      }
+    },
+    "mkdirp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+      "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
+    },
+    "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": "9.0.3",
+      "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.0.3.tgz",
+      "integrity": "sha512-hnYFrSefHxYS2XFGtN01x8un0EwNu2bzKvhpRFhgoybIvMaOkkL60IVPmkb5h6XDmUl4IMSB+rT5cIO4/4bJgg==",
+      "dev": true,
+      "requires": {
+        "@ungap/promise-all-settled": "1.1.2",
+        "ansi-colors": "4.1.1",
+        "browser-stdout": "1.3.1",
+        "chokidar": "3.5.2",
+        "debug": "4.3.1",
+        "diff": "5.0.0",
+        "escape-string-regexp": "4.0.0",
+        "find-up": "5.0.0",
+        "glob": "7.1.7",
+        "growl": "1.10.5",
+        "he": "1.2.0",
+        "js-yaml": "4.1.0",
+        "log-symbols": "4.1.0",
+        "minimatch": "3.0.4",
+        "ms": "2.1.3",
+        "nanoid": "3.1.23",
+        "serialize-javascript": "6.0.0",
+        "strip-json-comments": "3.1.1",
+        "supports-color": "8.1.1",
+        "which": "2.0.2",
+        "wide-align": "1.1.3",
+        "workerpool": "6.1.5",
+        "yargs": "16.2.0",
+        "yargs-parser": "20.2.4",
+        "yargs-unparser": "2.0.0"
+      },
+      "dependencies": {
+        "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
+        },
+        "debug": {
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+          "dev": true,
+          "requires": {
+            "ms": "2.1.2"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.1.2",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+              "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+              "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
+        },
+        "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
+        },
+        "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"
+          }
+        },
+        "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
+    },
+    "mri": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.6.tgz",
+      "integrity": "sha512-oi1b3MfbyGa7FJMP9GmLTttni5JoICpYBRlq+x5V16fZbLsnL9N3wFqqIm/nIG43FjUFkFh9Epzp/kzUGUnJxQ=="
+    },
+    "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.1.23",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
+      "integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
+      "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": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+      "dev": true
+    },
+    "nise": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.0.tgz",
+      "integrity": "sha512-W5WlHu+wvo3PaKLsJJkgPup2LrsXCcm7AWwyNZkUnn5rwPkuPBi3Iwk5SQtN0mv+K65k7nKKjwNQ30wg3wLAQQ==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^1.7.0",
+        "@sinonjs/fake-timers": "^7.0.4",
+        "@sinonjs/text-encoding": "^0.7.1",
+        "just-extend": "^4.0.2",
+        "path-to-regexp": "^1.7.0"
+      }
+    },
+    "node-abi": {
+      "version": "2.30.0",
+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.30.0.tgz",
+      "integrity": "sha512-g6bZh3YCKQRdwuO/tSZZYJAw622SjsRfJ2X0Iy4sSOHZ34/sPPdVBn8fev2tj7njzLwuqPw9uMtGsGkO5kIQvg==",
+      "requires": {
+        "semver": "^5.4.1"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "5.7.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+          "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
+        }
+      }
+    },
+    "node-addon-api": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz",
+      "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A=="
+    },
+    "node-fetch": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
+      "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
+    },
+    "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": "1.1.73",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.73.tgz",
+      "integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==",
+      "dev": true
+    },
+    "nopt": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+      "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+      "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": "4.1.2",
+      "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+      "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+      "requires": {
+        "are-we-there-yet": "~1.1.2",
+        "console-control-strings": "~1.1.0",
+        "gauge": "~2.7.3",
+        "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": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
+    },
+    "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": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "camelcase": {
+          "version": "5.3.1",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+          "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+          "dev": true
+        },
+        "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"
+          }
+        },
+        "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==",
+          "dev": 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==",
+          "dev": true
+        },
+        "decamelize": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+          "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+          "dev": 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==",
+          "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": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+    },
+    "once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "requires": {
+        "wrappy": "1"
+      }
+    },
+    "opencollective-postinstall": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
+      "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q=="
+    },
+    "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": "sha1-a2LDeRz3kJ6jXtRuF2WLtBfLORc=",
+      "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==",
+      "dev": 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=="
+    },
+    "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"
+      }
+    },
+    "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==",
+      "dev": true
+    },
+    "path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+    },
+    "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"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "0.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+          "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=",
+          "dev": true
+        }
+      }
+    },
+    "pg": {
+      "version": "8.7.1",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.7.1.tgz",
+      "integrity": "sha512-7bdYcv7V6U3KAtWjpQJJBww0UEsWuh4yQ/EjNf2HeO/NnvKjpvhEIe/A/TleP6wtmSKnUnghs5A9jUoK6iDdkA==",
+      "requires": {
+        "buffer-writer": "2.0.0",
+        "packet-reader": "1.0.0",
+        "pg-connection-string": "^2.5.0",
+        "pg-pool": "^3.4.1",
+        "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.4.1",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.4.1.tgz",
+      "integrity": "sha512-TVHxR/gf3MeJRvchgNHxsYsTCHQ+4wm3VIHSS19z8NC0+gioEhq1okDY1sm/TYbfoP6JLFx01s0ShvZ3puP/iQ=="
+    },
+    "pg-promise": {
+      "version": "10.11.0",
+      "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-10.11.0.tgz",
+      "integrity": "sha512-UntgHZNv+gpGJKhh+tzGSGHLkniKWV+ZQ8/SNdtvElsg9Aa7ZJ4Fgyl6pl2x0ZtJ7uFNy+OIq3Z+Ei6iplqTDQ==",
+      "requires": {
+        "assert-options": "0.7.0",
+        "pg": "8.7.1",
+        "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.4",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.4.tgz",
+      "integrity": "sha512-YmuA56alyBq7M59vxVBfPJrGSozru8QAdoNlWuW3cz8l+UX3cWge0vTvjKhsSHSJpo3Bom8/Mm6hf0TR5GY0+w==",
+      "requires": {
+        "split2": "^3.1.1"
+      }
+    },
+    "picomatch": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
+      "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==",
+      "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": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU="
+    },
+    "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": "sha1-287g7p3nI15X95xW186UZBpp7sY=",
+      "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": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
+          "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": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
+          "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": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
+          "dev": true
+        },
+        "which": {
+          "version": "1.2.14",
+          "resolved": "https://registry.npmjs.org/which/-/which-1.2.14.tgz",
+          "integrity": "sha1-mofEN48D6CfOyvGs31bHNsAcFOU=",
+          "dev": true,
+          "requires": {
+            "isexe": "^2.0.0"
+          }
+        },
+        "yallist": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+          "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=",
+          "dev": true
+        }
+      }
+    },
+    "prebuild-install": {
+      "version": "6.1.3",
+      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-6.1.3.tgz",
+      "integrity": "sha512-iqqSR84tNYQUQHRXalSKdIaM8Ov1QxOVuBNWI7+BzZWv6Ih9k75wOnH1rGQ9WWTaaLkTpxWKIciOF0KyfM74+Q==",
+      "requires": {
+        "detect-libc": "^1.0.3",
+        "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": "^2.21.0",
+        "npmlog": "^4.0.1",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^3.0.3",
+        "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=="
+    },
+    "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"
+      }
+    },
+    "progress": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+      "dev": true
+    },
+    "pseudomap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
+      "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
+      "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
+    },
+    "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": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
+        }
+      }
+    },
+    "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==",
+      "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"
+      },
+      "dependencies": {
+        "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=="
+        },
+        "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==",
+          "requires": {
+            "safe-buffer": "~5.1.0"
+          }
+        }
+      }
+    },
+    "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"
+      }
+    },
+    "regexpp": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+      "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+      "dev": true
+    },
+    "release-zalgo": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz",
+      "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=",
+      "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": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
+      "dev": true
+    },
+    "require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": 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==",
+      "dev": true
+    },
+    "resolve": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+      "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
+      "dev": true,
+      "requires": {
+        "is-core-module": "^2.2.0",
+        "path-parse": "^1.0.6"
+      }
+    },
+    "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
+    },
+    "ret": {
+      "version": "0.1.15",
+      "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
+      "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
+      "dev": true
+    },
+    "rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "requires": {
+        "glob": "^7.1.3"
+      }
+    },
+    "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=="
+    },
+    "safe-regex": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
+      "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
+      "dev": true,
+      "requires": {
+        "ret": "~0.1.10"
+      }
+    },
+    "sax": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+      "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
+    },
+    "semver": {
+      "version": "7.3.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+      "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+      "requires": {
+        "lru-cache": "^6.0.0"
+      }
+    },
+    "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": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
+    },
+    "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.3",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
+      "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA=="
+    },
+    "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": "3.1.0",
+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.0.tgz",
+      "integrity": "sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA==",
+      "requires": {
+        "decompress-response": "^4.2.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
+    "sinon": {
+      "version": "11.1.2",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-11.1.2.tgz",
+      "integrity": "sha512-59237HChms4kg7/sXhiRcUzdSkKuydDeTiamT/jesUVHshBgL8XAmhgFo0GfK6RruMDM/iRSij1EybmMog9cJw==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^1.8.3",
+        "@sinonjs/fake-timers": "^7.1.2",
+        "@sinonjs/samsam": "^6.0.2",
+        "diff": "^5.0.0",
+        "nise": "^5.1.0",
+        "supports-color": "^7.2.0"
+      },
+      "dependencies": {
+        "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
+        },
+        "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"
+          }
+        }
+      }
+    },
+    "slice-ansi": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+      "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "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==",
+          "dev": 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==",
+          "dev": true
+        }
+      }
+    },
+    "source-map": {
+      "version": "0.5.7",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+      "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
+      "dev": true
+    },
+    "spawn-sync": {
+      "version": "1.0.15",
+      "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz",
+      "integrity": "sha1-sAeZVX63+wyDdsKdROih6mfldHY=",
+      "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": "3.2.2",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz",
+      "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==",
+      "requires": {
+        "readable-stream": "^3.0.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"
+          }
+        }
+      }
+    },
+    "sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+      "dev": true
+    },
+    "string-width": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
+      "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==",
+      "dev": true,
+      "requires": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.0"
+      }
+    },
+    "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"
+      }
+    },
+    "strip-ansi": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
+      "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+      "dev": true,
+      "requires": {
+        "ansi-regex": "^5.0.0"
+      }
+    },
+    "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": "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"
+      }
+    },
+    "table": {
+      "version": "6.7.1",
+      "resolved": "https://registry.npmjs.org/table/-/table-6.7.1.tgz",
+      "integrity": "sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg==",
+      "dev": true,
+      "requires": {
+        "ajv": "^8.0.1",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.truncate": "^4.4.2",
+        "slice-ansi": "^4.0.0",
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "8.6.2",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.2.tgz",
+          "integrity": "sha512-9807RlWAgT564wT+DjeyU5OFMPjmzxVobvDFmNAhY+5zD6A2ly3jDp6sgnfyDtlIQ+7H97oc/DGCzzfu9rjw9w==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "json-schema-traverse": "^1.0.0",
+            "require-from-string": "^2.0.2",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "json-schema-traverse": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+          "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+          "dev": true
+        }
+      }
+    },
+    "tar": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
+      "integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==",
+      "requires": {
+        "chownr": "^2.0.0",
+        "fs-minipass": "^2.0.0",
+        "minipass": "^3.0.0",
+        "minizlib": "^2.1.1",
+        "mkdirp": "^1.0.3",
+        "yallist": "^4.0.0"
+      },
+      "dependencies": {
+        "chownr": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+          "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="
+        }
+      }
+    },
+    "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"
+      }
+    },
+    "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"
+          }
+        }
+      }
+    },
+    "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": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
+      "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": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
+      "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"
+      }
+    },
+    "tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+      "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.8.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+      "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+      "dev": true
+    },
+    "typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
+      "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"
+      }
+    },
+    "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"
+      }
+    },
+    "util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+    },
+    "uuid": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+      "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
+      "dev": true
+    },
+    "v8-compile-cache": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+      "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+      "dev": true
+    },
+    "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": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=",
+      "dev": true
+    },
+    "wide-align": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
+      "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
+      "requires": {
+        "string-width": "^1.0.2 || 2"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+        },
+        "is-fullwidth-code-point": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+          "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+        },
+        "string-width": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+          "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
+          "requires": {
+            "is-fullwidth-code-point": "^2.0.0",
+            "strip-ansi": "^4.0.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+          "requires": {
+            "ansi-regex": "^3.0.0"
+          }
+        }
+      }
+    },
+    "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.1.5",
+      "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.1.5.tgz",
+      "integrity": "sha512-XdKkCK0Zqc6w3iTxLckiuJ81tiD/o5rBE/m+nXpRCB+/Sq4DqkfXZ/x0jW02DG1tGsfUGXbTJyZDP+eu67haSw==",
+      "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"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "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==",
+          "dev": 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==",
+          "dev": true
+        }
+      }
+    },
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+    },
+    "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": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+    },
+    "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"
+      }
+    },
+    "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..3be524d
--- /dev/null
@@ -0,0 +1,55 @@
+{
+  "name": "websub-hub",
+  "version": "1.0.0",
+  "description": "A WebSub Hub server implementation.",
+  "main": "server.js",
+  "scripts": {
+    "coverage": "nyc npm test",
+    "coverage-check": "nyc check-coverage",
+    "eslint": "eslint *.js src",
+    "test": "mocha --recursive"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://git.squeep.com/websub-hub/"
+  },
+  "keywords": [
+    "WebSub",
+    "Hub",
+    "REC-websub-20180123",
+    "IndieWeb",
+    "PubSubHubBub",
+    "syndication"
+  ],
+  "engines": {
+    "node": ">=12.9"
+  },
+  "author": "Justin Wind <jwind-websubhub@squeep.com>",
+  "license": "ISC",
+  "pre-commit": [
+    "eslint",
+    "coverage",
+    "coverage-check"
+  ],
+  "dependencies": {
+    "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.0.0",
+    "@squeep/web-linking": "git+https://git.squeep.com/squeep-web-linking/#v1.0.0",
+    "argon2": "^0.28.2",
+    "axios": "^0.21.1",
+    "better-sqlite3": "^7.4.3",
+    "feedparser": "^2.2.10",
+    "htmlparser2": "^6.1.0",
+    "pg-promise": "^10.11.0"
+  },
+  "devDependencies": {
+    "eslint": "^7.32.0",
+    "eslint-plugin-node": "^11.1.0",
+    "eslint-plugin-security": "^1.4.0",
+    "eslint-plugin-sonarjs": "^0.9.1",
+    "mocha": "^9.0.3",
+    "mocha-steps": "^1.3.0",
+    "nyc": "^15.1.0",
+    "pre-commit": "^1.2.2",
+    "sinon": "^11.1.2"
+  }
+}
diff --git a/server.js b/server.js
new file mode 100644 (file)
index 0000000..985e575
--- /dev/null
+++ b/server.js
@@ -0,0 +1,37 @@
+'use strict';
+
+const http = require('http');
+const DB = require('./src/db');
+const Logger = require('./src/logger');
+const Service = require('./src/service');
+const Config = require('./config');
+const { fileScope } = require('./src/common');
+const { version } = require('./package.json');
+
+const _scope = fileScope(__filename)('main');
+
+const PORT = process.env.PORT || 4001;
+const ADDR = process.env.LISTEN_ADDR || '127.0.0.1';
+
+(async function 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.schemaCheck();
+    service = new Service(logger, db, config);
+
+    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 (version ${version}) started on ${ADDR}:${PORT}`);
+    });
+  } catch (e) {
+    logger.error(_scope, 'error starting server', e);
+  }
+})();
\ No newline at end of file
diff --git a/src/authenticator.js b/src/authenticator.js
new file mode 100644 (file)
index 0000000..ed4fc40
--- /dev/null
@@ -0,0 +1,113 @@
+'use strict';
+
+const argon2 = require('argon2');
+const common = require('./common');
+const Enum = require('./enum');
+const Errors = require('./errors');
+
+const _fileScope = common.fileScope(__filename);
+
+class Authenticator {
+  constructor(logger, db, options) {
+    this.logger = logger;
+    this.db = db;
+    this.basicRealm = options.authenticator.basicRealm;
+    this.secureAuthOnly = options.authenticator.secureAuthOnly;
+  }
+
+
+  /**
+   * Check for valid Basic auth, updates ctx with identifier if valid.
+   * @param {String} credentials
+   * @param {Object} ctx
+   * @returns {Boolean}
+   */
+  async isValidBasic(credentials, ctx) {
+    const _scope = _fileScope('isValidBasic');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const [identifier, credential] = common.splitFirst(credentials, ':', '');
+
+    let valid = false;
+    await this.db.context(async (dbCtx) => {
+      const authData = await this.db.authenticationGet(dbCtx, identifier);
+      if (!authData) {
+        this.logger.debug(_scope, 'failed, invalid authentication id', { ctx });
+        return false;
+      }
+
+      if (authData.credential.startsWith('$argon2')) {
+        valid = await argon2.verify(authData.credential, credential);
+      } else {
+        this.logger.error(_scope, 'failed, unknown type of stored password hash', { ctx });
+      }
+      if (valid) {
+        ctx.authenticationId = identifier;
+        await this.db.authenticationSuccess(dbCtx, identifier);
+      }
+    });
+
+    return valid;
+  }
+
+
+  /**
+   * Determine which Authorization header is available, and if it is valid.
+   * @param {String} authorizationHeader
+   * @param {Object} ctx
+   */
+  async isValidAuthorization(authorizationHeader, ctx) {
+    const _scope = _fileScope('isValidAuthorization');
+    this.logger.debug(_scope, 'called', { authorizationHeader, ctx });
+
+    const [authMethod, authString] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim());
+    // eslint-disable-next-line sonarjs/no-small-switch
+    switch (authMethod.toLowerCase()) {
+      case 'basic': {
+        const credentials = Buffer.from(authString, 'base64').toString('utf-8');
+        return await this.isValidBasic(credentials, ctx);
+      }
+
+      default:
+        this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
+        return false;
+    }
+  }
+
+
+  /**
+   * Send a response requesting basic auth.
+   * @param {http.ServerResponse} res
+   */
+  requestBasic(res) {
+    res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`);
+    throw new Errors.ResponseError(Enum.ErrorResponse.Unauthorized);
+  }
+
+
+  /**
+   * Require that a request has valid auth over secure channel, requests if missing.
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async required(req, res, ctx) {
+    const _scope = _fileScope('required');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    if (this.secureAuthOnly && ctx.clientProtocol.toLowerCase() !== 'https') {
+      this.logger.debug(_scope, 'rejecting insecure auth', ctx);
+      throw new Errors.ResponseError(Enum.ErrorResponse.Forbidden, 'authentication required, but connection is insecure; cannot continue');
+    }
+
+    const authData = req.getHeader(Enum.Header.Authorization);
+    if (authData
+    &&  await this.isValidAuthorization(authData, ctx)) {
+        return true;
+    }
+    return this.requestBasic(res);
+  }
+
+}
+
+module.exports = Authenticator;
\ No newline at end of file
diff --git a/src/common.js b/src/common.js
new file mode 100644 (file)
index 0000000..b82b13e
--- /dev/null
@@ -0,0 +1,149 @@
+/* eslint-disable security/detect-object-injection */
+'use strict';
+
+/**
+ * Assorted utility functions.
+ */
+
+const { common } = require('@squeep/api-dingus');
+
+const { randomBytes, getHashes } = require('crypto');
+const { promisify } = require('util');
+
+
+/**
+ * Wrap this in a promise, as crypto.randomBytes is capable of blocking.
+ */
+const randomBytesAsync = promisify(randomBytes);
+
+
+/**
+ * The HMAC hashes we are willing to support.
+ * @param {String} algorithm
+ * @returns {Boolean}
+ */
+const validHash = (algorithm) => getHashes()
+  .filter((h) => h.match(/^sha[0-9]+$/))
+  .includes(algorithm);
+
+/**
+ * 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])
+    &&  !Object.isFrozen(o[prop])) {
+      return freezeDeep(o[prop]);
+    }
+  });
+  return o;
+}
+
+
+/**
+ * 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;
+};
+
+
+/**
+ * Fallback values, if not configured.
+ * @returns {Object}
+ */
+const topicLeaseDefaults = () => {
+  return Object.freeze({
+    leaseSecondsPreferred: 86400 * 10,
+    leaseSecondsMin: 86400 * 1,
+    leaseSecondsMax: 86400 * 365,
+  });
+};
+
+
+/**
+ * Pick from a range, constrained, with some fuzziness.
+ * @param {Number} attempt
+ * @param {Number[]} delays
+ * @param {Number} jitter
+ * @returns {Number}
+ */
+  const attemptRetrySeconds = (attempt, retryBackoffSeconds = [60, 120, 360, 1440, 7200, 43200, 86400], jitter = 0.618) => {
+  const maxAttempt = retryBackoffSeconds.length - 1;
+  if (typeof attempt !== 'number' || attempt < 0) {
+    attempt = 0;
+  } else if (attempt > maxAttempt) {
+    attempt = maxAttempt;
+  }
+  // eslint-disable-next-line security/detect-object-injection
+  let seconds = retryBackoffSeconds[attempt];
+  seconds += Math.floor(Math.random() * seconds * jitter);
+  return seconds;
+}
+
+
+/**
+ * Return array items split as an array of arrays of no more than per items each.
+ * @param {Array} array
+ * @param {Number} per
+ */
+ const arrayChunk = (array, per = 1) => {
+  const nChunks = Math.ceil(array.length / per);
+  return Array.from(Array(nChunks), (_, i) => array.slice(i * per, (i + 1) * per));
+}
+
+
+/**
+ * Be paranoid about blowing the stack when pushing to an array.
+ * @param {Array} dst
+ * @param {Array} src
+ */
+ const stackSafePush = (dst, src) => {
+  const jsEngineMaxArguments = 2**16; // Current as of Node 12
+  arrayChunk(src, jsEngineMaxArguments).forEach((items) => {
+    Array.prototype.push.apply(dst, items);
+  });
+}
+
+
+/**
+ * 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)`;
+};
+
+module.exports = {
+  ...common,
+  arrayChunk,
+  attemptRetrySeconds,
+  axiosResponseLogData,
+  freezeDeep,
+  logTruncate,
+  randomBytesAsync,
+  stackSafePush,
+  topicLeaseDefaults,
+  validHash,
+};
diff --git a/src/communication.js b/src/communication.js
new file mode 100644 (file)
index 0000000..72f2642
--- /dev/null
@@ -0,0 +1,703 @@
+'use strict';
+
+/**
+ * Here we wrangle all outgoing requests, as well as the
+ * worker which initiates most of them.
+ */
+
+const axios = require('axios');
+const common = require('./common');
+const crypto = require('crypto');
+const Enum = require('./enum');
+const Errors = require('./errors');
+const Worker = require('./worker');
+const LinkHelper = require('./link-helper');
+const { version: packageVersion, name: packageName } = require('../package.json'); // For default UA string
+
+const { performance } = require('perf_hooks');
+
+const _fileScope = common.fileScope(__filename);
+
+class Communication {
+  constructor(logger, db, options) {
+    this.logger = logger;
+    this.db = db;
+    this.options = options;
+    this.linkHelper = new LinkHelper(logger, options);
+
+    if (this.options.dingus.selfBaseUrl) {
+      this.linkHub = `, <${this.options.dingus.selfBaseUrl}>; rel="hub"`;
+    } else {
+      this.linkHub = '';
+      this.logger.error(_fileScope('constructor'), 'empty dingus.selfBaseUrl value, server responses will not be compliant');
+    }
+
+    // Set common options
+    this.axios = axios.create({
+      validateStatus: null, // Non-success responses are not exceptional
+      headers: {
+        [Enum.Header.UserAgent]: Communication.userAgentString(options.userAgent),
+      },
+    });
+
+    this.axios.interceptors.request.use((request) => {
+      request.startTimestampMs = performance.now();
+      return request;
+    });
+    this.axios.interceptors.response.use((response) => {
+      response.elapsedTimeMs = performance.now() - response.config.startTimestampMs;
+      return response;
+    });
+
+    this.worker = new Worker(logger, this.workFeed.bind(this), options);
+    this.worker.start();
+  }
+
+
+  static userAgentString(userAgentConfig) {
+    // eslint-disable-next-line security/detect-object-injection
+    const _conf = (field, def) => (userAgentConfig && field in userAgentConfig) ? userAgentConfig[field] : def;
+    const product = _conf('product', packageName);
+    const version = _conf('version', packageVersion);
+    let implementation = _conf('implementation', Enum.Specification);
+    if (implementation) {
+      implementation = ` (${implementation})`;
+    }
+    return `${product}/${version}${implementation}`;
+  }
+
+
+  /**
+   * Generate a random string.
+   * @param {Integer} bytes 
+   * @returns {String}
+   */
+  static async generateChallenge(bytes = 30) {
+    return (await common.randomBytesAsync(bytes)).toString('base64');
+  }
+
+
+  /**
+   * Generate the signature string for content.
+   * @param {Buffer} message 
+   * @param {Buffer} secret 
+   * @param {String} algorithm 
+   * @returns {String}
+   */
+  static signature(message, secret, algorithm) {
+    const hmac = crypto.createHmac(algorithm, secret);
+    hmac.update(message);
+    return `${algorithm}=${hmac.digest('hex')}`;
+  }
+
+
+  /**
+   * Generate the hash for content.
+   * @param {Buffer} content 
+   * @param {String} algorithm 
+   * @returns 
+   */
+  static contentHash(content, algorithm) {
+    const hash = crypto.createHash(algorithm);
+    hash.update(content);
+    return hash.digest('hex');
+  }
+
+
+  /**
+   * A request skeleton config.
+   * @param {String} method
+   * @param {String} requestUrl
+   * @param {String} body
+   * @param {Object} params
+   */
+  static _axiosConfig(method, requestUrl, body, params = {}, headers = {}) {
+    const urlObj = new URL(requestUrl);
+    const config = {
+      method,
+      url: `${urlObj.origin}${urlObj.pathname}`,
+      params: urlObj.searchParams,
+      headers,
+      ...(body && { data: body }),
+      // Setting this does not appear to be enough to keep axios from parsing JSON response into object
+      responseType: 'text',
+      // So force the matter by eliding all response transformations
+      transformResponse: [ (res) => res ],
+    };
+    Object.entries(params).map(([k, v]) => config.params.set(k, v));
+    return config;
+  }
+
+
+  /**
+   * Create request config for verifying an intent.
+   * @param {URL} requestUrl
+   * @param {String} topicUrl
+   * @param {String} mode
+   * @param {Integer} leaseSeconds
+   * @param {String} challenge
+   */
+  static _intentVerifyAxiosConfig(requestUrl, topicUrl, mode, leaseSeconds, challenge) {
+    // Explicitly convert leaseSeconds to string, due to some DB backends. (Looking at you, sqlite..)
+    leaseSeconds = leaseSeconds.toString();
+
+    return Communication._axiosConfig('GET', requestUrl, undefined, {
+      'hub.mode': mode,
+      'hub.topic': topicUrl,
+      'hub.challenge': challenge,
+      'hub.lease_seconds': leaseSeconds,
+    }, {});
+  }
+
+
+  /**
+   * Create request config for denying an intent.
+   * @param {String} requestUrl 
+   * @param {String} topicUrl 
+   * @param {String} reason 
+   * @returns {String}
+   */
+  static _intentDenyAxiosConfig(requestUrl, topicUrl, reason) {
+    return Communication._axiosConfig('GET', requestUrl, undefined, {
+      'hub.mode': Enum.Mode.Denied,
+      'hub.topic': topicUrl,
+      ...(reason && { 'hub.reason': reason }),
+    }, {});
+  }
+
+
+  /**
+   * Create request config for querying publisher for subscription validation.
+   * @param {Topic} topic 
+   * @param {Verification} verification 
+   * @returns {String}
+   */
+  static _publisherValidationAxiosConfig(topic, verification) {
+    const body = {
+      callback: verification.callback,
+      topic: topic.url,
+      ...(verification.httpFrom && { from: verification.httpFrom }),
+      ...(verification.httpRemoteAddr && { address: verification.httpRemoteAddr }),
+    };
+    return Communication._axiosConfig('POST', topic.publisherValidationUrl, body, {}, {
+      [Enum.Header.ContentType]: Enum.ContentType.ApplicationJson,
+    });
+  }
+
+
+  /**
+   * Create request config for fetching topic content.
+   * Prefer existing content-type, but accept anything.
+   * @param {Topic} topic 
+   * @returns {String}
+   */
+  static _topicFetchAxiosConfig(topic) {
+    const acceptWildcard = '*/*' + (topic.contentType ? ';q=0.9' : '');
+    const acceptPreferred = [topic.contentType, acceptWildcard].filter((x) => x).join(', ');
+    return Communication._axiosConfig('GET', topic.url, undefined, {}, {
+      [Enum.Header.Accept]: acceptPreferred,
+    });
+  }
+
+
+  /**
+   * Attempt to verify a requested intent with client callback endpoint.
+   * @param {*} dbCtx
+   * @param {*} verificationId
+   * @param {String} requestId
+   * @returns {Boolean} whether to subsequently attempt next task if verification succeeds
+   */
+  async verificationProcess(dbCtx, verificationId, requestId) {
+    const _scope = _fileScope('verificationProcess');
+
+    const verification = await this.db.verificationGetById(dbCtx, verificationId);
+    if (!verification) {
+      this.logger.error(_scope, 'no such verification', { verificationId, requestId });
+      throw new Errors.InternalInconsistencyError('no such verification id');
+    }
+
+    const topic = await this.db.topicGetById(dbCtx, verification.topicId);
+    if (!topic) {
+      this.logger.error(_scope, 'no such topic id', { verification, requestId });
+      throw new Errors.InternalInconsistencyError('no such topic id');
+    }
+
+    if (!topic.isActive) {
+      this.logger.debug(_scope, 'topic not active, skipping verification', { verification, requestId });
+      await this.db.verificationRelease(dbCtx, verificationId);
+      return;
+    }
+
+    // If topic is deleted, deny any subscriptions.
+    // Un-subscriptions can continue to be verified.
+    if (topic.isDeleted && verification.mode === Enum.Mode.Subscribe) {
+      this.logger.info(_scope, 'topic is deleted, verification becomes denial', { verification, requestId });
+
+      verification.mode = Enum.Mode.Denied;
+      verification.reason = 'Gone: topic no longer valid on this hub.';
+      verification.isPublisherValidated = true;
+      await this.db.verificationUpdate(dbCtx, verification);
+    }
+
+    // If verification needs publisher validation, this delivery is for publisher.
+    if (verification.mode === Enum.Mode.Subscribe && verification.isPublisherValidated === false) {
+      this.logger.debug(_scope, 'attempting publisher validation', { verification, requestId });
+      const continueVerification = await this.publisherValidate(dbCtx, topic, verification);
+
+      // If publisher validation completed, verification will proceed.
+      // If not, we're done for now and shall try again later.
+      if (!continueVerification) {
+        this.logger.debug(_scope, 'publisher validation did not complete, belaying verification', { verification });
+        await this.db.verificationIncomplete(dbCtx, verificationId, this.options.communication.retryBackoffSeconds);
+        return;
+      }
+    }
+
+    const u = new URL(verification.callback);
+    let callbackRequestConfig, challenge;
+    if (verification.mode === Enum.Mode.Denied) {
+      // Denials don't have a challenge.
+      callbackRequestConfig = Communication._intentDenyAxiosConfig(u, topic.url, verification.reason);
+    } else {
+      // Subscriptions and unsubscriptions require challenge matching.
+      challenge = await Communication.generateChallenge();
+      callbackRequestConfig = Communication._intentVerifyAxiosConfig(u, topic.url, verification.mode, verification.leaseSeconds, challenge);
+    }
+
+    const logInfoData = {
+      callbackUrl: u.href,
+      topicUrl: topic.url,
+      mode: verification.mode,
+      originalRequestId: verification.requestId,
+      requestId,
+      verificationId,
+    };
+
+    this.logger.info(_scope, 'verification request', logInfoData);
+
+    let response;
+    try {
+      response = await this.axios(callbackRequestConfig);
+    } catch (e) {
+      this.logger.error(_scope, 'verification request failed', { ...logInfoData, error: e });
+      await this.db.verificationIncomplete(dbCtx, verificationId, this.options.communication.retryBackoffSeconds);
+      return;
+    }
+    logInfoData.response = common.axiosResponseLogData(response);
+    this.logger.debug(_scope, 'verification response', logInfoData );
+
+    let verificationAccepted = true; // Presume success.
+
+    switch (common.httpStatusCodeClass(response.status)) {
+      case 2:
+        // Success, fall out of switch.
+        break;
+
+      case 5:
+        // Retry
+        this.logger.info(_scope, 'verification remote server error', logInfoData );
+        await this.db.verificationIncomplete(dbCtx, verificationId, this.options.communication.retryBackoffSeconds);
+        return;
+
+      default:
+        // Anything else is unsuccessful.
+        this.logger.info(_scope, 'verification rejected by status', logInfoData );
+        verificationAccepted = false;
+    }
+
+    // Any denial is not accepted.
+    if (verification.mode === Enum.Mode.Denied) {
+      this.logger.info(_scope, 'verification denial accepted', logInfoData );
+      verificationAccepted = false;
+    }
+
+    if ([Enum.Mode.Subscribe, Enum.Mode.Unsubscribe].includes(verification.mode)
+    &&  response.data !== challenge) {
+      this.logger.info(_scope, 'verification rejected by challenge', logInfoData);
+      verificationAccepted = false;
+    }
+
+    await this.db.transaction(dbCtx, async (txCtx) => {
+      switch (verification.mode) {
+        case Enum.Mode.Subscribe:
+          if (verificationAccepted) {
+            await this.db.subscriptionUpsert(txCtx, verification);
+          }
+          break;
+      
+        case Enum.Mode.Unsubscribe:
+          if (verificationAccepted) {
+            await this.db.subscriptionDelete(txCtx, verification.callback, verification.topicId);
+          }
+          break;
+
+        case Enum.Mode.Denied:
+          await this.db.subscriptionDelete(txCtx, verification.callback, verification.topicId);
+          break;
+
+        default:
+          this.logger.error(_scope, 'unanticipated mode', { logInfoData });
+          throw new Errors.InternalInconsistencyError(verification.mode);
+      }
+
+      await this.db.verificationComplete(dbCtx, verificationId, verification.callback, verification.topicId);
+    }); // txCtx
+
+    this.logger.info(_scope, 'verification complete', { ...logInfoData, verificationAccepted });
+  }
+
+
+  /**
+   * Attempt to verify a pending subscription request with publisher.
+   * Updates (and persists) verification.
+   * Returns boolean of status of publisher contact, and hence
+   * whether to continue verification with client.
+   * @param {*} dbCtx
+   * @param {TopicData} topic
+   * @param {VerificationData} verification
+   * @returns {Boolean}
+   */
+  async publisherValidate(dbCtx, topic, verification) {
+    const _scope = _fileScope('publisherValidate');
+    const publisherValidationRequestConfig = Communication._publisherValidationAxiosConfig(topic, verification);
+    const logInfoData = {
+      topicUrl: topic.url,
+      callbackUrl: verification.callback,
+      requestId: verification.requestId,
+    };
+    let response;
+
+    this.logger.info(_scope, 'publisher validation request', logInfoData);
+
+    try {
+      response = await this.axios(publisherValidationRequestConfig);
+    } catch (e) {
+      this.logger.error(_scope, 'publisher validation failed', { ...logInfoData, error: e });
+      return false; // Do not continue with client verification.
+    }
+
+    logInfoData.response = common.axiosResponseLogData(response);
+    this.logger.debug(_scope, 'validation response', logInfoData);
+
+    let verificationNeedsUpdate = false;
+    switch (common.httpStatusCodeClass(response.status)) {
+      case 2:
+        this.logger.info(_scope, 'publisher validation complete, allowed', logInfoData);
+        break;
+
+      case 5:
+        this.logger.info(_scope, 'publisher validation remote server error', logInfoData);
+        return false; // Do not continue with client verification.
+
+      default:
+        this.logger.info(_scope, 'publisher validation complete, denied', logInfoData);
+        // Change client verification
+        verification.mode = Enum.Mode.Denied;
+        verification.reason = 'publisher rejected request'; // TODO: details from response?
+        verificationNeedsUpdate = true;
+    }
+
+    // Success from publisher, either accepted or denied.
+    // Set validated flag, and allow client verification to continue.
+    await this.db.transaction(dbCtx, async (txCtx) => {
+      if (verificationNeedsUpdate) {
+        await this.db.verificationUpdate(txCtx, verification.id, verification);
+      }
+      await this.db.verificationValidated(txCtx, verification.id);
+    });
+    return true;
+  }
+
+
+  /**
+   * Retrieve content from a topic.
+   * @param {*} dbCtx
+   * @param {*} topicId
+   * @param {String} requestId
+   * @returns
+   */
+  async topicFetchProcess(dbCtx, topicId, requestId) {
+    const _scope = _fileScope('topicFetchProcess');
+    const logInfoData = {
+      topicId,
+      requestId,
+    };
+
+    this.logger.debug(_scope, 'called', logInfoData);
+
+    const topic = await this.db.topicGetById(dbCtx, topicId);
+    if (topic === undefined) {
+      this.logger.error(_scope, 'no such topic id', logInfoData);
+      throw new Errors.InternalInconsistencyError('no such topic id');
+    }
+
+    logInfoData.url = topicId.url;
+
+    if (topic.isDeleted) {
+      this.logger.debug(_scope, 'topic deleted, skipping update request', logInfoData);
+      return;
+    }
+
+    const updateRequestConfig = Communication._topicFetchAxiosConfig(topic);
+
+    this.logger.info(_scope, 'topic update request', logInfoData);
+    
+    let response;
+    try {
+      response = await this.axios(updateRequestConfig);
+    } catch (e) {
+      this.logger.error(_scope, 'update request failed', logInfoData);
+      await this.db.topicFetchIncomplete(dbCtx, topicId, this.options.communication.retryBackoffSeconds);
+      return;
+    }
+    logInfoData.response = common.axiosResponseLogData(response);
+    this.logger.debug(_scope, 'fetch response', logInfoData);
+
+    switch (common.httpStatusCodeClass(response.status)) {
+      case 2:
+        // Fall out of switch on success
+        break;
+
+      case 5:
+        this.logger.info(_scope, 'update remote server error', logInfoData);
+        await this.db.topicFetchIncomplete(dbCtx, topicId, this.options.communication.retryBackoffSeconds);
+        return;
+  
+      default:
+        this.logger.info(_scope, 'fetch failed by status', logInfoData);
+        await this.db.topicFetchIncomplete(dbCtx, topicId, this.options.communication.retryBackoffSeconds);
+        return;
+    }
+
+    const contentHash = Communication.contentHash(response.data, topic.contentHashAlgorithm);
+    logInfoData.contentHash = contentHash;
+    if (topic.contentHash === contentHash) {
+      this.logger.info(_scope, 'content has not changed', logInfoData);
+      await this.db.topicFetchComplete(dbCtx, topicId);
+      return;
+    }
+
+    const validHub = await this.linkHelper.validHub(topic.url, response.headers, response.data);
+    if (!validHub) {
+      this.logger.debug(_scope, 'retrieved topic does not list us as hub', { logInfoData });
+      if (this.options.communication.strictTopicHubLink) {
+        await this.db.transaction(dbCtx, async (txCtx) => {
+          // Set as deleted and set content_updated so subscriptions are notified.
+          await this.db.topicDeleted(txCtx, topicId);
+          await this.db.topicFetchComplete(txCtx, topicId);
+        });
+        return;
+      }
+    }
+
+    const contentType = response.headers[Enum.Header.ContentType.toLowerCase()];
+
+    await this.db.transaction(dbCtx, async (txCtx) => {
+      await this.db.topicSetContent(txCtx, {
+        topicId,
+        content: Buffer.from(response.data),
+        contentHash,
+        ...(contentType && { contentType }),
+      });
+
+      await this.db.topicFetchComplete(txCtx, topicId);
+    });
+    this.logger.info(_scope, 'content updated', logInfoData);
+  }
+
+
+  /**
+   * Attempt to deliver a topic's content to a subscription.
+   * @param {*} dbCtx 
+   * @param {String} callback 
+   * @param {*} topicId 
+   * @param {String} requestId 
+   */
+  async subscriptionDeliveryProcess(dbCtx, subscriptionId, requestId) {
+    const _scope = _fileScope('subscriptionDeliveryProcess');
+
+    const logInfoData = {
+      subscriptionId,
+      requestId,
+    };
+
+    this.logger.debug(_scope, 'called', logInfoData);
+
+    const subscription = await this.db.subscriptionGetById(dbCtx, subscriptionId);
+    if (!subscription) {
+      this.logger.error(_scope, 'no such subscription', logInfoData);
+      throw new Errors.InternalInconsistencyError('no such subscription');
+    }
+
+    logInfoData.callback = subscription.callback;
+
+    const topic = await this.db.topicGetContentById(dbCtx, subscription.topicId);
+    if (!topic) {
+      this.logger.error(_scope, 'no such topic', logInfoData);
+      throw new Errors.InternalInconsistencyError('no such topic');
+    }
+
+    if (topic.isDeleted) {
+      // If a topic has been set deleted, it does not list us as a valid hub.
+      // Queue an unsubscription.
+      const verification = {
+        topicId: subscription.topicId,
+        callback: subscription.callback,
+        mode: Enum.Mode.Denied,
+        reason: 'Gone: topic no longer valid on this hub.',
+        isPublisherValidated: true,
+        requestId,
+      };
+
+      await this.db.transaction(dbCtx, async (txCtx) => {
+        await this.db.verificationInsert(txCtx, verification);
+        await this.db.subscriptionDeliveryComplete(txCtx, subscription.callback, subscription.topicId);
+      });
+      this.logger.info(_scope, 'update unsubscription for deleted topic', logInfoData);
+      return;
+    }
+
+    logInfoData.contentLength = topic.content.length;
+    logInfoData.contentHash = topic.contentHash;
+
+    const updateAxiosConfig = Communication._axiosConfig('POST', subscription.callback, topic.content, {}, {
+      [Enum.Header.Link]: `<${topic.url}>; rel="self"${this.linkHub}`,
+      [Enum.Header.ContentType]: topic.contentType || Enum.ContentType.TextPlain,
+      ...(subscription.secret && { [Enum.Header.XHubSignature]: Communication.signature(topic.content, subscription.secret, subscription.signatureAlgorithm) }),
+    });
+
+    this.logger.info(_scope, 'update request', logInfoData);
+
+    let response;
+    try {
+      response = await this.axios(updateAxiosConfig);
+    } catch (e) {
+      this.logger.error(_scope, 'update request failed', { ...logInfoData, error: e });
+      await this.db.subscriptionDeliveryIncomplete(dbCtx, subscription.callback, subscription.topicId, this.options.communication.retryBackoffSeconds);
+      return;
+    }
+    logInfoData.response = common.axiosResponseLogData(response);
+    this.logger.debug(_scope, 'update response', logInfoData);
+
+    switch (common.httpStatusCodeClass(response.status)) {
+      case 2:
+        // Fall out of switch on success.
+        break;
+
+      case 5:
+        this.logger.info(_scope, 'update remote server error', logInfoData);
+        await this.db.subscriptionDeliveryIncomplete(dbCtx, subscription.callback, subscription.topicId, this.options.communication.retryBackoffSeconds);
+        return;
+
+      case 4:
+        if (response.status === 410) { // GONE
+          this.logger.info(_scope, 'client declined further updates', logInfoData);
+          await this.db.subscriptionDeliveryGone(dbCtx, subscription.callback, subscription.topicId);
+          return;
+        }
+        // All other 4xx falls through as failure
+
+      default:
+        this.logger.info(_scope, 'update failed with non-2xx status code', logInfoData);
+        await this.db.subscriptionDeliveryIncomplete(dbCtx, subscription.callback, subscription.topicId, this.options.communication.retryBackoffSeconds);
+        return;
+    }
+
+    await this.db.subscriptionDeliveryComplete(dbCtx, subscription.callback, subscription.topicId);
+    this.logger.info(_scope, 'update success', logInfoData);
+  }
+
+
+  /**
+   * Claim and work a specific topic fetch task.
+   * @param {*} dbCtx 
+   * @param {*} id 
+   * @param {String} requestId 
+   */
+  async topicFetchClaimAndProcessById(dbCtx, topicId, requestId) {
+    const _scope = _fileScope('topicFetchClaimAndProcessById');
+  
+    const claimResult = await this.db.topicFetchClaimById(dbCtx, topicId, this.options.communication.claimTimeoutSeconds, this.options.nodeId);
+    if (claimResult.changes != 1) {
+      this.logger.debug(_scope, 'did not claim topic fetch', { topicId, requestId });
+      return;
+    }
+    await this.topicFetchProcess(dbCtx, topicId, requestId);
+  }
+
+
+  /**
+   * Claim and work a specific verification confirmation task.
+   * @param {*} dbCtx
+   * @param {*} verificationId
+   * @param {String} requestId
+   * @returns 
+   */
+  async verificationClaimAndProcessById(dbCtx, verificationId, requestId) {
+    const _scope = _fileScope('verificationClaimAndProcessById');
+
+    const claimResult = await this.db.verificationClaimById(dbCtx, verificationId, this.options.communication.claimTimeoutSeconds, this.options.nodeId);
+    if (claimResult.changes != 1) {
+      this.logger.debug(_scope, 'did not claim verification', { verificationId, requestId });
+      return;
+    }
+    await this.verificationProcess(dbCtx, verificationId, requestId);
+  }
+
+
+  /**
+   * 
+   * @param {Number} wanted maximum tasks to claim
+   * @returns {Promise<void>[]}
+   */
+  async workFeed(wanted) {
+    const _scope = _fileScope('workFeed');
+    const inProgress = [];
+    const requestId = common.requestId();
+    const claimTimeoutSeconds = this.options.communication.claimTimeoutSeconds;
+    const nodeId = this.options.nodeId;
+    let topicFetchPromises = [], verificationPromises = [], updatePromises = [];
+
+    this.logger.debug(_scope, 'called', { wanted });
+
+    try {
+      await this.db.context(async (dbCtx) => {
+        if (wanted > 0) {
+          // Update topics before anything else.
+          const topicFetchIds = await this.db.topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, nodeId);
+          // Each task gets a new context, as these map to connections in some dbs.
+          // This dbCtx goes away after launching the processing functions, so would not be available to tasks.
+          topicFetchPromises = topicFetchIds.map((id) => this.db.context((ctx) => this.topicFetchProcess(ctx, id, requestId)));
+          inProgress.push(...topicFetchPromises);
+          wanted -= topicFetchPromises.length;
+        }
+
+        if (wanted > 0) {
+          // Then any pending verifications.
+          const verifications = await this.db.verificationClaim(dbCtx, wanted, claimTimeoutSeconds, nodeId);
+          verificationPromises = verifications.map((id) => this.db.context((ctx) => this.verificationProcess(ctx, id, requestId)));
+          inProgress.push(...verificationPromises);
+          wanted -= verificationPromises.length;
+        }
+
+        if (wanted > 0) {
+          // Finally dole out content.
+          const updates = await this.db.subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, nodeId);
+          updatePromises = updates.map((id) => this.db.context((ctx) => this.subscriptionDeliveryProcess(ctx, id, requestId)));
+          inProgress.push(...updatePromises);
+          wanted -= updatePromises.length;
+        }
+      }); // dbCtx
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      // do not re-throw, return what we've claimed so far
+    }
+    this.logger.debug(_scope, 'searched for work', { topics: topicFetchPromises.length, verifications: verificationPromises.length, updates: updatePromises.length, wantedRemaining: wanted, requestId });
+
+    return inProgress;
+  }
+
+
+}
+
+module.exports = Communication;
diff --git a/src/db/base.js b/src/db/base.js
new file mode 100644 (file)
index 0000000..8a1df74
--- /dev/null
@@ -0,0 +1,723 @@
+/* eslint-disable no-unused-vars */
+'use strict';
+
+/**
+ * This is the semi-abstract database class, providing interface and utility methods.
+ */
+
+const common = require('../common');
+const DBErrors = require('./errors');
+const svh = require('./schema-version-helper');
+
+const _fileScope = common.fileScope(__filename);
+
+class Database {
+  constructor(logger = common.nullLogger, options = {}) {
+    this.logger = logger;
+    common.ensureLoggerLevels(this.logger);
+
+    // Store the merged config and default values for lease values.
+    // N.B. breaking hierarchy of config options here
+    this.topicLeaseDefaults = {};
+    common.setOptions(this.topicLeaseDefaults, common.topicLeaseDefaults(), options.topicLeaseDefaults || {});
+  }
+
+
+  /**
+   * Turn a snake into a camel.
+   * Used when translating SQL column names to JS object style.
+   * @param {String} snakeCase
+   * @param {String|RegExp} delimiter
+   * @returns {String}
+   */
+  static _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('');
+  }
+
+
+  /**
+   * 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 DBErrors.DataValidation();
+    }
+    properties.forEach((p) => {
+      // eslint-disable-next-line security/detect-object-injection
+      const pObj = object[p];
+      const pType = typeof pObj;
+      if (!types.includes(pType)
+      &&  !(pObj instanceof Buffer && types.includes('buffer'))
+      &&  !(pObj === null && types.includes('null'))
+      &&  !(pType === 'bigint' && types.includes('number'))) {
+        const reason = `'${p}' is '${pType}', but must be ${types.length > 1 ? 'one of ' : ''}'${types}'`;
+        this.logger.error(_scope, reason, {});
+        throw new DBErrors.DataValidation(reason);
+      }
+    });
+  }
+
+
+  /**
+   * Interface methods need implementations.
+   * @param {String} method
+   * @param {arguments} args
+   */
+   _notImplemented(method, args) {
+    this.logger.error(_fileScope(method), 'abstract method called', Array.from(args));
+    throw new DBErrors.NotImplemented(method);
+  }
+
+
+  /**
+   * Validate schema compatibility.
+   * Ensure this is called immediately after instantiating a DB instance,
+   * as some engines also finish initialization and validation here, which
+   * was easier than wrangling async calls in constructor.
+   * In light of this behavior, this method could be named better.
+   */
+   async schemaCheck() {
+    const _scope = _fileScope('schemaCheck');
+
+    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 (min >= current && max <= current) {
+      this.logger.debug(_scope, 'schema supported', { currentSchema, schemaVersionsSupported: this.schemaVersionsSupported });
+    } else {
+      this.logger.error(_scope, 'schema not supported', { currentSchema, schemaVersionsSupported: this.schemaVersionsSupported });
+      throw new DBErrors.MigrationNeeded();
+    }
+  }
+
+
+  /**
+   * Perform db connection healthcheck.
+   */
+  async healthCheck() {
+    this._notImplemented('healthCheck', arguments);
+  }
+
+
+  /**
+   * Replace any NULL from topic DB entry with default values.
+   * @param {Object} topic
+   * @returns {Object}
+   */
+  _topicDefaults(topic) {
+    if (topic) {
+      for (const [key, value] of Object.entries(this.topicLeaseDefaults)) {
+        // eslint-disable-next-line security/detect-object-injection
+        if (!(key in topic) || topic[key] === null) {
+          // eslint-disable-next-line security/detect-object-injection
+          topic[key] = value;
+        }
+      }
+    }
+    return topic;
+  }
+
+
+  /**
+   * Ensures any lease durations in data are consistent.
+   * @param {Object} data
+   */
+  _leaseDurationsValidate(data) {
+    const leaseProperties = Object.keys(this.topicLeaseDefaults)
+    this._ensureTypes(data, leaseProperties, ['number', 'undefined', 'null']);
+
+    // Populate defaults on a copy of values so we can check proper numerical ordering
+    const leaseValues = common.pick(data, leaseProperties);
+    this._topicDefaults(leaseValues);
+    for (const [prop, value] of Object.entries(leaseValues)) {
+      if (value <= 0) {
+        throw new DBErrors.DataValidation(`${prop} must be positive`);
+      }
+    }
+    if (!(leaseValues.leaseSecondsMin <= leaseValues.leaseSecondsPreferred && leaseValues.leaseSecondsPreferred <= leaseValues.leaseSecondsMax)) {
+      throw new DBErrors.DataValidation('lease durations violate numerical ordering');
+    }
+  }
+
+
+  /**
+   * Basic field validation for setting topic data.
+   * @param {Object} data
+   */
+  _topicSetDataValidate(data) {
+    this._ensureTypes(data, ['url'], ['string']);
+    this._ensureTypes(data, ['publisherValidationUrl'], ['string', 'undefined', 'null']);
+    this._leaseDurationsValidate(data);
+  }
+
+
+  /**
+   * Basic field validation for setting topic content.
+   * @param {Object} data
+   */
+  _topicSetContentDataValidate(data) {
+    this._ensureTypes(data, ['content'], ['string', 'buffer']);
+    this._ensureTypes(data, ['contentHash'], ['string']);
+    this._ensureTypes(data, ['contentType'], ['string', 'null', 'undefined']);
+  }
+
+
+  /**
+   * Basic field validation for updating topic.
+   * @param {Object} data
+   */
+  _topicUpdateDataValidate(data) {
+    this._ensureTypes(data, ['publisherValidationUrl'], ['string', 'undefined', 'null']);
+    if (data.publisherValidationUrl) {
+      try {
+        new URL(data.publisherValidationUrl);
+      } catch (e) {
+        throw new DBErrors.DataValidation('invalid URL format');
+      }
+    }
+    this._ensureTypes(data, ['contentHashAlgorithm'], ['string']);
+    if (!common.validHash(data.contentHashAlgorithm)) {
+      throw new DBErrors.DataValidation('unsupported hash algorithm');
+    }
+    this._leaseDurationsValidate(data);
+  }
+
+
+  /**
+   * Basic field validation for setting verification data.
+   * @param {Object} data
+   */
+  _verificationDataValidate(data) {
+    this._ensureTypes(data, ['topicId'], ['string', 'number']);
+    this._ensureTypes(data, ['callback', 'mode'], ['string']);
+    this._ensureTypes(data, ['secret', 'httpRemoteAddr', 'httpFrom', 'requestId'], ['string', 'null', 'undefined']);
+    this._ensureTypes(data, ['leaseSeconds'], ['number']);
+    this._ensureTypes(data, ['isPublisherValidated'], ['boolean']);
+  }
+
+
+  /**
+   * Basic field validation for updating verification data.
+   * @param {Object} verification
+   */
+  _verificationUpdateDataValidate(data) {
+    this._ensureTypes(data, ['verificationId'], ['string', 'number']);
+    this._ensureTypes(data, ['mode'], ['string']);
+    this._ensureTypes(data, ['reason'], ['string', 'null', 'undefined']);
+    this._ensureTypes(data, ['isPublisherValidated'], ['boolean']);
+  }
+
+
+  /**
+   * Basic field validation for upserting subscription data.
+   * @param {Object} subscription
+   */
+  _subscriptionUpsertDataValidate(data) {
+    this._ensureTypes(data, ['topicId'], ['string', 'number']);
+    this._ensureTypes(data, ['callback'], ['string']);
+    this._ensureTypes(data, ['leaseSeconds'], ['number']);
+    this._ensureTypes(data, ['secret', 'httpRemoteAddr', 'httpFrom'], ['string', 'null', 'undefined']);
+  }
+
+
+  _subscriptionUpdateDataValidate(data) {
+    this._ensureTypes(data, ['signatureAlgorithm'], ['string', 'null', 'undefined']);
+    if (!common.validHash(data.signatureAlgorithm)) {
+      throw new DBErrors.DataValidation('unsupported hash algorithm');
+    }
+
+  }
+
+  /* Interface methods */
+
+  /**
+   * Normalize query information to a common form from a specific backend.
+   * @param {*} result
+   * @returns {Object} info
+   * @returns {Number} info.changes
+   * @returns {*} info.lastInsertRowid
+   * @returns {Number} info.duration
+   */
+  _engineInfo(result) {
+    this._notImplemented('engineInfo', arguments);
+  }
+
+
+  /**
+   * 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);
+  }
+
+
+  /**
+   * 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);
+  }
+
+
+  /**
+   * Store an authentication success event.
+   * @param {*} dbCtx
+   * @param {String} identifier
+   */
+  async authenticationSuccess(dbCtx, identifier) {
+    this._notImplemented('authenticationSuccess', arguments);
+  }
+
+
+  /**
+   * Fetch authentication data for identifier.
+   * @param {*} dbCtx
+   * @param {*} identifier
+   */
+  async authenticationGet(dbCtx, identifier) {
+    this._notImplemented('authenticationGet', arguments);
+  }
+
+
+  /**
+   * Create or update an authentication entity.
+   * @param {*} dbCtx
+   * @param {String} identifier
+   * @param {String} credential
+   */
+  async authenticationUpsert(dbCtx, identifier, credential) {
+    this._notImplemented('authenticationUpsert', arguments);
+  }
+
+
+  /**
+   * All subscriptions to a topic.
+   * @param {*} dbCtx
+   * @param {String} topicId
+   */
+  async subscriptionsByTopicId(dbCtx, topicId) {
+    this._notImplemented('subscriptionsByTopicId', arguments);
+  }
+
+
+  /**
+   * Number of subscriptions to a topic.
+   * @param {*} dbCtx
+   * @param {String} topicUrl
+   */
+  async subscriptionCountByTopicUrl(dbCtx, topicUrl) {
+    this._notImplemented('subscriptionCountByTopicUrl', arguments);
+  }
+
+
+  /**
+   * Remove an existing subscription.
+   * @param {*} dbCtx
+   * @param {String} callback
+   * @param {*} topicId
+   */
+  async subscriptionDelete(dbCtx, callback, topicId) {
+    this._notImplemented('subscriptionDelete', arguments);
+  }
+
+
+  /**
+   * Claim subscriptions needing content updates attempted.
+   * @param {*} dbCtx 
+   * @param {Number} wanted maximum subscription updates to claim
+   * @param {Integer} claimTimeoutSeconds age of claimed updates to reclaim
+   * @param {String} claimant
+   * @returns {Array} list of subscriptions
+   */
+  async subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant) {
+    this._notImplemented('subscriptionDeliveryClaim', arguments);
+  }
+
+
+  /**
+   * Claim a subscription delivery.
+   * @param {*} dbCtx 
+   * @param {*} subscriptionId 
+   * @param {*} claimTimeoutSeconds 
+   * @param {*} claimant 
+   */
+  async subscriptionDeliveryClaimById(dbCtx, subscriptionId, claimTimeoutSeconds, claimant) {
+    this._notImplemented('subscriptionDeliveryClaimById', arguments);
+  }
+
+
+  /**
+   * A subscriber successfully received new topic content, update subscription.
+   * @param {*} dbCtx 
+   * @param {String} callback
+   * @param {*} topicId
+   */
+  async subscriptionDeliveryComplete(dbCtx, callback, topicId) {
+    this._notImplemented('subscriptionDeliveryComplete', arguments);
+  }
+
+
+  /**
+   * A subscriber denied new topic content, remove subscription.
+   * @param {*} dbCtx 
+   * @param {String} callback
+   * @param {*} topicId
+   */
+  async subscriptionDeliveryGone(dbCtx, callback, topicId) {
+    this._notImplemented('subscriptionDeliveryGone', arguments);
+  }
+
+
+  /**
+   * An attempt to deliver content to a subscriber did not complete, update delivery accordingly.
+   * @param {*} dbCtx 
+   * @param {String} callback
+   * @param {*} topicId
+   * @param {Number[]} retryDelays
+   */
+  async subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays) {
+    this._notImplemented('subscriptionDeliveryIncomplete', arguments);
+  }
+  
+
+  /**
+   * Fetch subscription details
+   * @param {*} dbCtx
+   * @param {String} callback
+   * @param {*} topicId
+   */
+   async subscriptionGet(dbCtx, callback, topicId) {
+    this._notImplemented('subscriptionGet', arguments);
+  }
+
+  
+  /**
+   * Fetch subscription details
+   * @param {*} dbCtx 
+   * @param {*} subscriptionId 
+   */
+  async subscriptionGetById(dbCtx, subscriptionId) {
+    this._notImplemented('subscriptionGetById', arguments);
+  }
+
+
+  /**
+   * Set subscription details
+   * @param {*} dbCtx
+   * @param {Object} data
+   * @param {String} data.callback
+   * @param {*} data.topicId
+   * @param {Number} data.leaseSeconds
+   * @param {String=} data.secret
+   * @param {String=} data.httpRemoteAddr
+   * @param {String=} data.httpFrom
+   */
+   async subscriptionUpsert(dbCtx, data) {
+    this._notImplemented('subscriptionUpsert', arguments);
+  }
+
+
+  /**
+   * Set some subscription fields
+   * @param {*} dbCtx
+   * @param {Object} data
+   * @param {*} data.subscriptionId
+   * @param {String} data.signatureAlgorithm
+   */
+  async subscriptionUpdate(dbCtx, data) {
+    this._notImplemented('subscriptionUpdate', arguments);
+  }
+
+
+  /**
+   * Sets the isDeleted flag on a topic, and reset update time.
+   * @param {*} txCtx
+   * @param {*} topicId
+   */
+  async topicDeleted(dbCtx, topicId) {
+    this._notImplemented('topicDeleted', arguments);
+  }
+
+
+  /**
+   * Claim topics to fetch updates for, from available.
+   * @param {*} dbCtx 
+   * @param {Integer} wanted maximum topic fetches to claim
+   * @param {Integer} claimTimeoutSeconds age of claimed topics to reclaim
+   * @param {String} claimant node id claiming these fetches
+   */
+  async topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, claimant) {
+    this._notImplemented('topicFetchClaim', arguments);
+  }
+
+
+  /**
+   * Claim a topic to update.
+   * @param {*} dbCtx 
+   * @param {*} topicId 
+   * @param {Integer} claimTimeoutSeconds age of claimed topics to reclaim
+   * @param {String} claimant node id claiming these fetches
+   */
+  async topicFetchClaimById(dbCtx, topicId, claimTimeoutSeconds, claimant) {
+    this._notImplemented('topicFetchClaim', arguments);
+  }
+
+
+  /**
+   * Reset publish state, and reset deliveries for subscribers.
+   * @param {*} dbCtx 
+   * @param {*} topicId
+   */
+  async topicFetchComplete(dbCtx, topicId) {
+    this._notImplemented('topicFetchComplete', arguments);
+  }
+
+
+  /**
+   * Bump count of attempts and release claim on update.
+   * @param {*} dbCtx 
+   * @param {*} topicId
+   * @param {Number[]} retryDelays
+   */
+  async topicFetchIncomplete(dbCtx, topicId, retryDelays) {
+    this._notImplemented('topicFetchIncomplete', arguments);
+  }
+
+
+  /**
+   * Set a topic as ready to be checked for an update.
+   * @param {*} dbCtx
+   * @param {*} topicId
+   * @returns {Boolean}
+   */
+   async topicFetchRequested(dbCtx, topicId) {
+    this._notImplemented('topicPublish', arguments);
+  }
+
+
+  /**
+   * Get all data for all topics, including subscription count.
+   * @param {*} dbCtx
+   */
+  async topicGetAll(dbCtx) {
+    this._notImplemented('topicGetAll', arguments);
+  }
+
+  /**
+   * Get topic data, without content.
+   * @param {*} dbCtx 
+   * @param {String} topicUrl
+   */
+  async topicGetByUrl(dbCtx, topicUrl) {
+    this._notImplemented('topicGetByUrl', arguments);
+  }
+
+
+  /**
+   * Get topic data, without content.
+   * @param {*} dbCtx 
+   * @param {*} topicId
+   * @param {Boolean} applyDefaults
+   */
+  async topicGetById(dbCtx, topicId, applyDefaults = true) {
+    this._notImplemented('topicGetById', arguments);
+  }
+  
+
+  /**
+   * Returns topic data with content.
+   * @param {*} dbCx
+   * @param {*} topicId
+   */
+  async topicGetContentById(dbCx, topicId) {
+    this._notImplemented('topicGetContentById', arguments);
+  }
+
+  // /**
+  //  * Call after an unsubscribe, to check if a topic is awaiting deletion, and that
+  //  * was the last subscription belaying it.
+  //  * @param {String|Integer} data topic url or id
+  //  */
+  // async topicPendingDelete(dbCtx, data) {
+  //   this._notImplemented('topicPendingDelete', arguments);
+  // }
+
+
+  /**
+   * Create or update the basic parameters of a topic.
+   * @param {*} dbCtx 
+   * @param {TopicData} data
+   */
+  async topicSet(dbCtx, data) {
+    this._notImplemented('topicSet', arguments);
+  }
+
+
+  /**
+   * Updates a topic's content data and content update timestamp.
+   * @param {Object} data
+   * @param {Integer} data.topicId
+   * @param {String} data.content
+   * @param {String} data.contentHash
+   * @param {String=} data.contentType
+   */
+  async topicSetContent(dbCtx, data) {
+    this._notImplemented('topicSetContent', arguments);
+  }
+
+
+  /**
+   * Set some topic fields.
+   * @param {*} dbCtx
+   * @param {Object} data
+   * @param {*} data.topicId
+   * @param {Number=} data.leaseSecondsPreferred
+   * @param {Number=} data.leaseSecondsMin
+   * @param {Number=} data.leaseSecondsMax
+   * @param {String=} data.publisherValidationUrl
+   * @param {String=} data.contentHashAlgorithm
+   */
+  async topicUpdate(dbCtx, data) {
+    this._notImplemented('topicUpdate', arguments);
+  }
+
+
+  /**
+   * Claim pending verifications for attempted resolution.
+   * @param {*} dbCtx 
+   * @param {Integer} wanted maximum verifications to claim
+   * @param {Integer} claimTimeoutSeconds age of claimed verifications to reclaim
+   * @returns {Verification[]} array of claimed verifications
+   */
+  async verificationClaim(dbCtx, wanted, claimTimeoutSeconds, claimant) {
+    this._notImplemented('verificationClaim', arguments);
+  }
+
+
+  /**
+   * Claim a specific verification by id, if no other similar verification claimed.
+   * @param {*} dbCtx
+   * @param {*} verificationId
+   * @param {Number} claimTimeoutSeconds
+   * @param {String} claimant
+   */
+  async verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant) {
+    this._notImplemented('verificationClaimById', arguments);
+  }
+
+
+  /**
+   * Remove the verification, any older
+   * verifications for that same client/topic, and the claim.
+   * @param {*} dbCtx
+   * @param {*} verificationId
+   * @param {String} callback
+   * @param {*} topicId
+   */
+  async verificationComplete(dbCtx, verificationId, callback, topicId) {
+    this._notImplemented('verificationComplete', arguments);
+  }
+
+
+  /**
+   * Get verification data.
+   * @param {*} dbCtx
+   * @param {*} verificationId
+   */
+  async verificationGetById(dbCtx, verificationId) {
+    this._notImplemented('verificationGetById', arguments);
+  }
+
+
+  /**
+   * Update database that a client verification was unable to complete.
+   * This releases the delivery claim and reschedules for some future time.
+   * @param {*} dbCtx
+   * @param {String} callback client callback url
+   * @param {*} topicId internal topic id
+   * @param {Number[]} retryDelays
+   */
+  async verificationIncomplete(dbCtx, verificationId, retryDelays) {
+    this._notImplemented('verificationIncomplete', arguments);
+  }
+
+
+  /**
+   * Create a new pending verification.
+   * @param {*} dbCtx
+   * @param {VerificationData} data
+   * @param {Boolean} claim
+   * @returns {*} verificationId
+   */
+   async verificationInsert(dbCtx, verification) {
+    this._notImplemented('verificationInsert', arguments);
+  }
+
+
+  /**
+   * Relinquish the claim on a verification, without any other updates.
+   * @param {*} dbCtx
+   * @param {String} callback client callback url
+   * @param {*} topicId internal topic id
+   */
+  async verificationRelease(dbCtx, verificationId) {
+    this._notImplemented('verificationRelease', arguments);
+  }
+
+
+  /**
+   * Updates some fields of an existing (presumably claimed) verification.
+   * @param {*} dbCtx
+   * @param {*} verificationId
+   * @param {Object} data
+   * @param {String} data.mode
+   * @param {String} data.reason
+   * @param {Boolean} data.isPublisherValidated
+   */
+   async verificationUpdate(dbCtx, verificationId, data) {
+    this._notImplemented('verificationUpdate', arguments);
+   }
+
+
+  /**
+   * Sets the isPublisherValidated flag on a verification and resets the delivery
+   * @param {*} dbCtx
+   * @param {*} verificationId
+   */
+  async verificationValidated(dbCtx, verificationId) {
+    this._notImplemented('verificationValidated', arguments);
+  }
+
+}
+
+module.exports = Database;
diff --git a/src/db/errors.js b/src/db/errors.js
new file mode 100644 (file)
index 0000000..8053b0f
--- /dev/null
@@ -0,0 +1,45 @@
+'use strict';
+
+const { DatabaseError } = require('../errors');
+
+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 = {
+  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..f4f690a
--- /dev/null
@@ -0,0 +1,973 @@
+/* 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('../base');
+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) {
+      pgpInitOptions.query = (event) => {
+        this.logger[queryLogLevel](_fileScope('pgp:query'), '', { ...common.pick(event, ['query', 'params']) });
+      };
+    }
+
+    // Log errors
+    pgpInitOptions.error = (err, event) => {
+      this.logger.error(_fileScope('pgp:error'), '', { 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 = Database._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');
+      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 schemaCheck(applyMigrations = true) {
+    const _scope = _fileScope('schemaCheck');
+    this.logger.debug(_scope, 'called', { applyMigrations });
+    if (applyMigrations) {
+      await this._initTables();
+    }
+    await super.schemaCheck();
+  }
+
+
+  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 {
+      await this._pgp.end();
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+  
+  async _purgeTables(really = false) {
+    const _scope = _fileScope('_purgeTables');
+    try {
+      if (really) {
+        await this.db.tx(async (t) => {
+          await t.batch([
+            'topic',
+            // 'topic_fetch_in_progress',
+            // 'verification',
+            // 'verification_in_progress',
+            // 'subscription',
+            // 'subscription_delivery_in_progress',
+          ].map(async (table) => t.query('TRUNCATE TABLE $(table:name) CASCADE', { table })));
+        });
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+
+  // eslint-disable-next-line class-methods-use-this
+  _engineInfo(result) {
+    return {
+      changes: result.rowCount,
+      lastInsertRowid: result.rows.length ? result.rows[0].id : undefined,
+      duration: result.duration,
+    };
+  }
+
+
+  // eslint-disable-next-line class-methods-use-this
+  _resultLog(result) {
+    return common.pick(result, ['command', 'rowCount', 'duration']);
+  }
+
+
+  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 authenticationSuccess(dbCtx, identifier) {
+    const _scope = _fileScope('authenticationSuccess');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    let result;
+    try {
+      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 authenticationGet(dbCtx, identifier) {
+    const _scope = _fileScope('authenticationGet');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    let auth;
+    try {
+      auth = await dbCtx.oneOrNone(this.statement.authenticationGet, { identifier });
+      return auth;
+    } 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 });
+
+    let result;
+    try {
+      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 subscriptionsByTopicId(dbCtx, topicId) {
+    const _scope = _fileScope('subscriptionsByTopicId');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let count;
+    try {
+      count = await dbCtx.manyOrNone(this.statement.subscriptionsByTopicId, { topicId });
+      return count;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topicId });
+      throw e;
+    }
+  }
+
+
+  async subscriptionCountByTopicUrl(dbCtx, topicUrl) {
+    const _scope = _fileScope('subscriptionCountByTopicUrl');
+    this.logger.debug(_scope, 'called', { topicUrl });
+
+    let count;
+    try {
+      count = await dbCtx.one(this.statement.subscriptionCountByTopicUrl, { topicUrl });
+      return count;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topicUrl });
+      throw e;
+    }
+  }
+
+
+  async subscriptionDelete(dbCtx, callback, topicId) {
+    const _scope = _fileScope('subscriptionDelete');
+    this.logger.debug(_scope, 'called', { callback, topicId });
+
+    try {
+      const result = await dbCtx.result(this.statement.subscriptionDelete, { callback, topicId });
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, callback, topicId });
+      throw e;
+    }
+  }
+
+
+  async subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('subscriptionDeliveryClaim');
+    this.logger.debug(_scope, 'called', { wanted, claimTimeoutSeconds, claimant });
+
+    try {
+      const claims = await dbCtx.txIf(async (txCtx) => {
+        return txCtx.manyOrNone(this.statement.subscriptionDeliveryClaim, { claimant, wanted, claimTimeoutSeconds });
+      });
+      return claims.map((r) => r.id);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, claimant, wanted, claimTimeoutSeconds });
+      throw e;
+    }
+  }
+
+
+  async subscriptionDeliveryClaimById(dbCtx, subscriptionId, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('subscriptionDeliveryClaimById');
+    this.logger.debug(_scope, 'called', { subscriptionId, claimTimeoutSeconds, claimant });
+
+    let result;
+    try {
+      result = await dbCtx.txIf(async (txCtx) => {
+        result = await txCtx.result(this.statement.subscriptionDeliveryClaimById, { claimant, subscriptionId, claimTimeoutSeconds });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not claim subscription delivery');
+        }
+        return result;
+      });
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, claimant, subscriptionId, claimTimeoutSeconds });
+      throw e;
+    }
+  }
+
+
+  async subscriptionDeliveryComplete(dbCtx, callback, topicId) {
+    const _scope = _fileScope('subscriptionDeliveryComplete');
+    this.logger.debug(_scope, 'called', { callback, topicId });
+
+    let result;
+    try {
+      await dbCtx.txIf(async (txCtx) => {
+        result = await txCtx.result(this.statement.subscriptionDeliverySuccess, { callback, topicId });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not set subscription delivery success');
+        }
+        result = await txCtx.result(this.statement.subscriptionDeliveryDone, { callback, topicId });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not release subscription delivery');
+        }
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, callback, topicId });
+      throw e;
+    }
+  }
+
+
+  async subscriptionDeliveryGone(dbCtx, callback, topicId) {
+    const _scope = _fileScope('subscriptionDeliveryGone');
+    this.logger.debug(_scope, 'called', { callback, topicId });
+
+    let result;
+    try {
+      await dbCtx.txIf(async (txCtx) => {
+        result = await txCtx.result(this.statement.subscriptionDelete, { callback, topicId });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not delete subscription');
+        }
+        // Delete cascades to delivery
+        // result = await txCtx.result(this.statement.subscriptionDeliveryDone, { callback, topicId });
+        // if (result.rowCount != 1) {
+        //   throw new DBErrors.UnexpectedResult('did not release subscription delivery');
+        // }
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, callback, topicId });
+      throw e;
+    }
+  }
+
+
+  async subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays = [60]) {
+    const _scope = _fileScope('subscriptionDeliveryIncomplete');
+    this.logger.debug(_scope, 'called', { callback, topicId, retryDelays });
+
+    let result;
+    try {
+      await dbCtx.txIf(async (txCtx) => {
+        const { currentAttempt } = await txCtx.one(this.statement.subscriptionDeliveryAttempts, { callback, topicId });
+        const nextAttemptDelaySeconds = common.attemptRetrySeconds(currentAttempt, retryDelays);
+        result = await txCtx.result(this.statement.subscriptionDeliveryFailure, { nextAttemptDelaySeconds, callback, topicId });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not set subscription delivery failure');
+        }
+        result = await txCtx.result(this.statement.subscriptionDeliveryDone, { callback, topicId });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not release subscription delivery');
+        }
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, callback, topicId });
+      throw e;
+    }
+  }
+
+
+  async subscriptionGet(dbCtx, callback, topicId) {
+    const _scope = _fileScope('subscriptionGet');
+    this.logger.debug(_scope, 'called', { callback, topicId });
+
+    let subscription;
+    try {
+      subscription = await dbCtx.oneOrNone(this.statement.subscriptionGet, { callback, topicId });
+      return subscription;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, callback, topicId });
+      throw e;
+    }
+  }
+
+
+  async subscriptionGetById(dbCtx, subscriptionId) {
+    const _scope = _fileScope('subscriptionGetById');
+    this.logger.debug(_scope, 'called', { subscriptionId });
+
+    let subscription;
+    try {
+      subscription = await dbCtx.oneOrNone(this.statement.subscriptionGetById, { subscriptionId });
+      return subscription;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, subscriptionId });
+      throw e;
+    }
+  }
+
+
+  async subscriptionUpdate(dbCtx, data) {
+    const _scope = _fileScope('subscriptionUpdate');
+    this.logger.debug(_scope, 'called', { data });
+
+    const subscriptionData = {
+      ...data,
+    };
+
+    this._subscriptionUpdateDataValidate(subscriptionData);
+
+    let result;
+    try {
+      result = await dbCtx.result(this.statement.subscriptionUpdate, subscriptionData);
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not update subscription');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, subscriptionData });
+      throw e;
+    }
+  }
+
+
+  async subscriptionUpsert(dbCtx, data) {
+    const _scope = _fileScope('subscriptionUpsert');
+    this.logger.debug(_scope, 'called', { ...data });
+
+    const subscriptionData = {
+      secret: null,
+      httpRemoteAddr: null,
+      httpFrom: null,
+      ...data,
+    };
+    this._subscriptionUpsertDataValidate(subscriptionData);
+
+    let result;
+    try {
+      result = await dbCtx.result(this.statement.subscriptionUpsert, subscriptionData);
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not upsert subscription');
+      }
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, subscriptionData });
+      throw e;
+    }
+  }
+
+
+  async topicDeleted(dbCtx, topicId) {
+    const _scope = _fileScope('topicDeleted');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let result;
+    try {
+      result = await dbCtx.result(this.statement.topicDeleted, { topicId });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not update topic as deleted');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed to update topic as deleted', { error: e, topicId });
+      throw e;
+    }
+  }
+
+
+  async topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('topicFetchClaim');
+    this.logger.debug(_scope, 'called', { wanted, claimTimeoutSeconds });
+
+    let claims;
+    try {
+      await dbCtx.txIf(async (txCtx) => {
+        claims = await txCtx.manyOrNone(this.statement.topicContentFetchClaim, { claimant, wanted, claimTimeoutSeconds });
+      });
+      return claims.map((r) => r.id);
+    } catch (e) {
+      this.logger.error(_scope, 'failed to claim topics for fetch', { error: e });
+      throw e;
+    }
+  }
+
+
+  async topicFetchClaimById(dbCtx, topicId, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('topicFetchClaimById');
+    this.logger.debug(_scope, 'called', { topicId, claimTimeoutSeconds, claimant });
+
+    let result;
+    try {
+      await dbCtx.txIf(async (txCtx) => {
+        result = await txCtx.result(this.statement.topicContentFetchClaimById, { topicId, claimant, claimTimeoutSeconds });
+      });
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topicId });
+      throw e;
+    }
+  }
+
+
+  async topicFetchComplete(dbCtx, topicId) {
+    const _scope = _fileScope('topicFetchComplete');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let result;
+    try {
+      await dbCtx.txIf(async (txCtx) => {
+        result = await txCtx.result(this.statement.topicAttemptsReset, { topicId });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not reset topic attempts');
+        }
+        result = await txCtx.result(this.statement.topicContentFetchDone, { topicId });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not release topic fetch');
+        }
+      });
+      this.logger.debug(_scope, 'success', { topicId, ...this._resultLog(result) });
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, result, topicId });
+      throw e;
+    }
+  }
+
+
+  async topicFetchIncomplete(dbCtx, topicId, retryDelays = [60]) {
+    const _scope = _fileScope('topicFetchIncomplete');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let result;
+    try {
+      result = await dbCtx.txIf(async (txCtx) => {
+        const { contentFetchAttemptsSinceSuccess: currentAttempt } = await txCtx.one(this.statement.topicAttempts, { topicId });
+        const nextAttemptDelaySeconds = common.attemptRetrySeconds(currentAttempt, retryDelays);
+        result = await txCtx.result(this.statement.topicAttemptsIncrement, { topicId, nextAttemptDelaySeconds });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not set topic attempts');
+        }
+        result = await txCtx.result(this.statement.topicContentFetchDone, { topicId });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not release topic fetch');
+        }
+        return result;
+      });
+      this.logger.debug(_scope, 'success', { topicId, ...this._resultLog(result) });
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, result, topicId });
+      throw e;
+    }
+  }
+
+
+  async topicFetchRequested(dbCtx, topicId) {
+    const _scope = _fileScope('topicFetchRequested');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let result;
+    try {
+      result = await dbCtx.result(this.statement.topicContentFetchRequested, { topicId });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not set topic fetch requested');
+      }
+      this.logger.debug(_scope, 'success', { topicId, ...this._resultLog(result) });
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topicId });
+      throw e;
+    }
+  }
+
+
+  async topicGetAll(dbCtx) {
+    const _scope = _fileScope('topicGetAll');
+    this.logger.debug(_scope, 'called');
+
+    let topics;
+    try {
+      topics = await dbCtx.manyOrNone(this.statement.topicGetInfoAll);
+      } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topics });
+      throw e;
+    }
+    if (topics) {
+      topics = topics.map(this._topicDefaults.bind(this));
+    }
+    return topics;
+  }
+
+
+  async topicGetById(dbCtx, topicId, applyDefaults = true) {
+    const _scope = _fileScope('topicGetById');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let topic;
+    try {
+      topic = await dbCtx.oneOrNone(this.statement.topicGetById, { topicId });
+      if (applyDefaults) {
+        topic = this._topicDefaults(topic);
+      }
+      return topic;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topic, topicId });
+      throw e;
+    }
+  }
+
+
+  async topicGetByUrl(dbCtx, topicUrl) {
+    const _scope = _fileScope('topicGetByUrl');
+    this.logger.debug(_scope, 'called', { topicUrl });
+
+    let topic;
+    try {
+      topic = await dbCtx.oneOrNone(this.statement.topicGetByUrl, { topicUrl });
+      return this._topicDefaults(topic);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topic, topicUrl });
+      throw e;
+    }
+  }
+
+
+  async topicGetContentById(dbCtx, topicId) {
+    const _scope = _fileScope('topicGetContentById');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let topic;
+    try {
+      topic = await dbCtx.oneOrNone(this.statement.topicGetContentById, { topicId });
+      return this._topicDefaults(topic);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topic, topicId });
+      throw e;
+    }
+  }
+
+
+  async topicSet(dbCtx, data) {
+    const _scope = _fileScope('topicSet');
+    this.logger.debug(_scope, 'called', data);
+
+    const topicSetData = {
+      publisherValidationUrl: null,
+      leaseSecondsPreferred: null,
+      leaseSecondsMin: null,
+      leaseSecondsMax: null,
+      ...data,
+    };
+  
+    let result;
+    try {
+      this._topicSetDataValidate(topicSetData);
+      result = await dbCtx.result(this.statement.topicUpsert, topicSetData);
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not set topic data');
+      }
+      this.logger.debug(_scope, 'success', { topicSetData, ...this._resultLog(result) });
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, result });
+      throw e;
+    }
+  }
+
+
+  async topicSetContent(dbCtx, data) {
+    const _scope = _fileScope('topicSetContent');
+    const topicSetContentData = {
+      contentType: null,
+      ...data,
+    };
+    const logData = {
+      ...topicSetContentData,
+      content: common.logTruncate(topicSetContentData.content, 100),
+    };
+    this.logger.debug(_scope, 'called', data);
+
+    let result;
+    try {
+      this._topicSetContentDataValidate(topicSetContentData);
+      result = await dbCtx.result(this.statement.topicSetContent, topicSetContentData);
+      logData.result = this._resultLog(result);
+      if (result.rowCount !=  1) {
+        throw new DBErrors.UnexpectedResult('did not set topic content');
+      }
+      this.logger.debug(_scope, 'success', { ...logData });
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, ...logData });
+      throw e;
+    }
+  }
+
+
+  async topicUpdate(dbCtx, data) {
+    const _scope = _fileScope('topicUpdate');
+    this.logger.debug(_scope, 'called', { data });
+
+    const topicData = {
+      leaseSecondsPreferred: null,
+      leaseSecondsMin: null,
+      leaseSecondsMax: null,
+      publisherValidationUrl: null,
+      ...data,
+    };
+
+    this._topicUpdateDataValidate(topicData);
+
+    let result;
+    try {
+      result = await dbCtx.result(this.statement.topicUpdate, topicData);
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not update topic');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topicData });
+      throw e;
+    }
+  }
+
+
+  async verificationClaim(dbCtx, wanted, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('verificationClaim');
+    this.logger.debug(_scope, 'called', { wanted, claimTimeoutSeconds });
+
+    let result;
+    try {
+      await dbCtx.txIf(async (txCtx) => {
+        result = await txCtx.manyOrNone(this.statement.verificationClaim, { claimant, wanted, claimTimeoutSeconds });
+      });
+      return result.map((r) => r.id);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { wanted, claimTimeoutSeconds });
+      throw e;
+    }
+  }
+
+
+
+  async verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('verificationClaimById');
+    this.logger.debug(_scope, 'called', { verificationId, claimant, claimTimeoutSeconds });
+
+    let result;
+    try {
+      await dbCtx.txIf(async (txCtx) => {
+        result = await txCtx.result(this.statement.verificationClaimById, { verificationId, claimant, claimTimeoutSeconds });
+      });
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { verificationId, claimant, claimTimeoutSeconds });
+      throw e;
+    }
+  }
+
+
+  async verificationComplete(dbCtx, verificationId, callback, topicId) {
+    const _scope = _fileScope('verificationComplete');
+    this.logger.debug(_scope, 'called', { verificationId });
+
+    let result;
+    try {
+      await dbCtx.txIf(async (txCtx) => {
+        result = await txCtx.result(this.statement.verificationScrub, { verificationId, callback, topicId });
+        if (result.rowCount < 1) {
+          throw new DBErrors.UnexpectedResult('did not remove verifications');
+        }
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { verificationId });
+      throw e;
+    }
+    return this._engineInfo(result);
+  }
+
+
+  async verificationGetById(dbCtx, verificationId) {
+    const _scope = _fileScope('verificationGetById');
+    this.logger.debug(_scope, 'called', { verificationId });
+
+    let verification;
+    try {
+      verification = await dbCtx.oneOrNone(this.statement.verificationGetById, { verificationId });
+      return verification;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationId });
+      throw e;
+    }
+  }
+
+
+  async verificationIncomplete(dbCtx, verificationId, retryDelays = [60]) {
+    const _scope = _fileScope('verificationIncomplete');
+    this.logger.debug(_scope, 'called', { verificationId });
+
+    let result;
+    try {
+      await dbCtx.txIf(async (txCtx) => {
+        const { attempts } = await txCtx.one(this.statement.verificationAttempts, { verificationId });
+        const nextAttemptDelaySeconds = common.attemptRetrySeconds(attempts, retryDelays);
+        result = await txCtx.result(this.statement.verificationAttemptIncrement, { verificationId, nextAttemptDelaySeconds });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not update verification attempts');
+        }
+        result = await txCtx.result(this.statement.verificationDone, { verificationId });
+        if (result.rowCount != 1) {
+          throw new DBErrors.UnexpectedResult('did not release verification');
+        }
+      });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationId });
+      throw e;
+    }
+  }
+
+
+  async verificationInsert(dbCtx, verification) {
+    const _scope = _fileScope('verificationInsert');
+    this.logger.debug(_scope, 'called', { verification });
+
+    const verificationData = {
+      secret: null,
+      httpRemoteAddr: null,
+      httpFrom: null,
+      requestId: null,
+      ...verification,
+    };
+
+    let result, verificationId;
+    try {
+      this._verificationDataValidate(verificationData);
+      result = await dbCtx.result(this.statement.verificationInsert, verificationData);
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not insert verification');
+      }
+      verificationId = result.rows[0].id;
+      this.logger.debug(_scope, 'inserted verification', { verificationId });
+
+      return verificationId;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationData });
+      throw e;
+    }
+  }
+
+
+  async verificationRelease(dbCtx, verificationId) {
+    const _scope = _fileScope('verificationRelease');
+    this.logger.debug(_scope, 'called', { verificationId });
+
+    let result;
+    try {
+      result = await dbCtx.result(this.statement.verificationDone, { verificationId });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not release verification');
+      }
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationId });
+      throw e;
+    }
+  }
+
+
+  async verificationUpdate(dbCtx, verificationId, data) {
+    const _scope = _fileScope('verificationUpdate');
+    this.logger.debug(_scope, 'called', { verificationId, data });
+
+    const verificationData = {
+      reason: null,
+      verificationId,
+      ...data,
+    };
+
+    let result;
+    try {
+      this._verificationUpdateDataValidate(verificationData);
+      result = await dbCtx.result(this.statement.verificationUpdate, verificationData);
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not update verification');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationData });
+      throw e;
+    }
+  }
+
+
+  async verificationValidated(dbCtx, verificationId) {
+    const _scope = _fileScope('verificationValidated');
+    this.logger.debug(_scope, 'called', { verificationId });
+
+    let result;
+    try {
+      result = await dbCtx.result(this.statement.verificationValidate, { verificationId });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not set verification validation');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationId });
+      throw e;
+    } 
+  }
+
+}
+
+module.exports = DatabasePostgres;
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/schema/1.0.0/apply.sql b/src/db/postgres/sql/schema/1.0.0/apply.sql
new file mode 100644 (file)
index 0000000..2665882
--- /dev/null
@@ -0,0 +1,196 @@
+BEGIN;
+       CREATE TABLE topic (
+               id UUID PRIMARY KEY DEFAULT uuid_generate_v1(), -- v1 timebased is best for indexes
+               created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               url TEXT NOT NULL UNIQUE,
+               lease_seconds_preferred INTERVAL,
+               lease_seconds_min INTERVAL,
+               lease_seconds_max INTERVAL,
+               publisher_validation_url TEXT,
+               content_hash_algorithm VARCHAR(16) NOT NULL DEFAULT 'sha512',
+               -- end of topic config/behavior values
+
+               is_active BOOLEAN DEFAULT false, -- will be active after first successful fetch
+               is_deleted BOOLEAN DEFAULT false, -- topic deletion pending on 'denied' notification to active subscriptions
+
+               last_publish TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz, -- time of latest publish notification
+               content_fetch_next_attempt TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz, -- time of next content update attempt
+               content_fetch_attempts_since_success INTEGER NOT NULL DEFAULT 0,
+
+               content_updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz, -- time of latest content update
+               content BYTEA,
+               content_hash TEXT,
+               content_type VARCHAR(255)
+       );
+       CREATE INDEX topic_content_updated_idx ON topic(content_updated);
+       CREATE INDEX topic_content_fetch_next_attempt_idx ON topic(content_fetch_next_attempt); -- sort out which need updates
+
+       CREATE TABLE topic_fetch_in_progress (
+               id UUID PRIMARY KEY NOT NULL REFERENCES topic(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               claimant UUID NOT NULL,
+               claimed TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               claim_expires TIMESTAMP WITH TIME ZONE NOT NULL
+       );
+       CREATE INDEX topic_fetch_in_progress_claim_expires_idx ON topic_fetch_in_progress(claim_expires);
+
+       CREATE VIEW topic_fetch_in_progress_active AS
+               SELECT *
+               FROM topic_fetch_in_progress
+               WHERE claim_expires >= now()
+       ;
+
+       CREATE VIEW topic_fetch_needed AS
+               SELECT *
+               FROM topic
+               WHERE
+                       is_deleted = false
+               AND
+                       content_fetch_next_attempt <= now()
+               AND
+                       id NOT IN (SELECT id FROM topic_fetch_in_progress_active)
+               ORDER BY last_publish ASC
+       ;
+
+       -- send notices when topic is updated, for any nodes caching content
+
+       CREATE OR REPLACE FUNCTION topic_changed()
+               RETURNS TRIGGER
+               LANGUAGE plpgsql
+       AS $$
+               DECLARE
+                       payload varchar;
+               BEGIN
+                       payload = CAST(NEW.id AS text);
+                       PERFORM pg_notify('topic_changed', payload);
+                       RETURN NEW;
+               END;
+       $$
+       ;
+
+       CREATE TRIGGER topic_changed
+       AFTER UPDATE ON topic
+       FOR EACH ROW
+               EXECUTE PROCEDURE topic_changed()
+       ;
+
+       --
+
+       CREATE TABLE subscription (
+               id UUID PRIMARY KEY DEFAULT uuid_generate_v1(),
+               created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               
+               topic_id UUID NOT NULL REFERENCES topic(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               callback TEXT NOT NULL,
+               CONSTRAINT subscription_reference UNIQUE (topic_id, callback),
+
+               verified TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz,
+               expires TIMESTAMP WITH TIME ZONE NOT NULL,
+
+               secret VARCHAR(199),
+               signature_algorithm VARCHAR(16) DEFAULT 'sha512',
+               http_remote_addr TEXT,
+               http_from TEXT,
+
+               content_delivered TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz,
+               delivery_attempts_since_success INTEGER NOT NULL DEFAULT 0,
+               delivery_next_attempt TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz
+       );
+       CREATE INDEX subscription_content_delivered_idx ON subscription(content_delivered);
+       CREATE INDEX subscription_expires_idx ON subscription(expires);
+
+       CREATE TABLE subscription_delivery_in_progress (
+               id UUID PRIMARY KEY NOT NULL REFERENCES subscription(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               claimant UUID NOT NULL,
+               claimed TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               claim_expires TIMESTAMP WITH TIME ZONE NOT NULL
+       );
+       CREATE INDEX subscription_delivery_in_progress_claim_expires_idx ON subscription_delivery_in_progress(claim_expires);
+
+       CREATE VIEW subscription_delivery_in_progress_active AS
+               SELECT *
+               FROM subscription_delivery_in_progress
+               WHERE claim_expires >= now()
+       ;
+
+       CREATE VIEW subscription_delivery_needed AS
+               SELECT s.*
+               FROM subscription s JOIN topic t ON s.topic_id = t.id
+               WHERE
+                       s.expires > now()
+               AND
+                       s.content_delivered < t.content_updated
+               AND
+                       s.delivery_next_attempt < now()
+               AND
+                       s.id NOT IN (SELECT id FROM subscription_delivery_in_progress_active)
+       ;
+
+       --
+
+       CREATE TABLE verification (
+               id UUID PRIMARY KEY DEFAULT uuid_generate_v1(),
+               created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+               topic_id UUID NOT NULL REFERENCES topic(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+               callback TEXT NOT NULL, -- hub.callback
+               -- no unique constraint, multiple verifications can be pending
+
+               secret VARCHAR(199),
+               signature_algorithm VARCHAR(16) DEFAULT 'sha512',
+               http_remote_addr TEXT,
+               http_from TEXT,
+
+               mode TEXT NOT NULL, -- hub.mode
+               reason TEXT, -- denials may have a reason
+               lease_seconds INTEGER NOT NULL, -- 68 years should be long enough
+               is_publisher_validated BOOLEAN NOT NULL DEFAULT false,
+               request_id TEXT, -- client request which created this verification, null if server-generated (ie topic delete)
+
+               next_attempt TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               attempts INTEGER NOT NULL DEFAULT 0
+       );
+       CREATE INDEX verification_reference_idx ON verification(topic_id, callback, created);
+
+       CREATE TABLE verification_in_progress (
+               id UUID PRIMARY KEY NOT NULL REFERENCES verification(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+
+               topic_id UUID NOT NULL REFERENCES topic(id),
+               callback TEXT NOT NULL,
+               CONSTRAINT verification_in_progress_reference UNIQUE (topic_id, callback),
+
+               claimant UUID NOT NULL,
+               claimed TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               claim_expires TIMESTAMP WITH TIME ZONE NOT NULL
+       );
+       CREATE INDEX verification_in_progress_claim_expires_idx ON verification_in_progress(claim_expires);
+
+       CREATE VIEW verification_in_progress_active AS
+               SELECT *
+               FROM verification_in_progress
+               WHERE claim_expires >= now()
+       ;
+
+       CREATE VIEW verification_needed AS
+               SELECT *
+               FROM verification
+               WHERE
+                       (topic_id, callback, created) IN (SELECT topic_id, callback, max(created) AS created FROM verification GROUP BY (topic_id, callback))
+               AND
+                       (topic_id, callback) NOT IN (SELECT topic_id, callback FROM verification_in_progress_active)
+               AND
+                       next_attempt <= now()
+       ;
+
+       --
+
+       CREATE TABLE authentication (
+               created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               last_authentication TIMESTAMP WITH TIME ZONE,
+               identifier TEXT NOT NULL PRIMARY KEY,
+               credential TEXT
+       );
+
+       -- Update schema version
+       INSERT INTO _meta_schema_version (major, minor, patch) VALUES (1, 0, 0);
+
+COMMIT;
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..5e22ed0
--- /dev/null
@@ -0,0 +1,11 @@
+BEGIN;
+       DROP TABLE topic CASCADE;
+       DROP TABLE topic_fetch_in_progress CASCADE;
+       DROP TABLE subscription CASCADE;
+       DROP TABLE subscription_delivery_in_progress CASCADE;
+       DROP TABLE verification CASCADE;
+       DROP TABLE verification_in_progress CASCADE;
+       DROP TABLE authentication 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/subscription-count-by-topic-url.sql b/src/db/postgres/sql/subscription-count-by-topic-url.sql
new file mode 100644 (file)
index 0000000..35e8fc8
--- /dev/null
@@ -0,0 +1,5 @@
+--
+SELECT COUNT(s.id) AS count
+FROM subscription s
+JOIN topic t ON s.topic_id = t.id
+WHERE t.url = $(topicUrl) AND s.expires > now()
diff --git a/src/db/postgres/sql/subscription-delete.sql b/src/db/postgres/sql/subscription-delete.sql
new file mode 100644 (file)
index 0000000..f9f2863
--- /dev/null
@@ -0,0 +1,3 @@
+--
+DELETE FROM subscription
+WHERE topic_id = $(topicId) AND callback = $(callback)
diff --git a/src/db/postgres/sql/subscription-delivery-attempts.sql b/src/db/postgres/sql/subscription-delivery-attempts.sql
new file mode 100644 (file)
index 0000000..0d7955d
--- /dev/null
@@ -0,0 +1,5 @@
+--
+SELECT delivery_attempts_since_success
+FROM subscription
+WHERE topic_id = $(topicId) AND callback = $(callback)
+
diff --git a/src/db/postgres/sql/subscription-delivery-claim-by-id.sql b/src/db/postgres/sql/subscription-delivery-claim-by-id.sql
new file mode 100644 (file)
index 0000000..3295dc8
--- /dev/null
@@ -0,0 +1,9 @@
+--
+INSERT INTO subscription_delivery_in_progress (id, claimant, claimed, claim_expires)
+VALUES ($(subscriptionId), $(claimant), now(), now() + $(claimTimeoutSeconds)::text::interval)
+ON CONFLICT (id) DO UPDATE
+SET
+       claimant = $(claimant),
+       claimed = now(),
+       claim_expires = now() + $(claimTimeoutSeconds)::text::interval
+RETURNING id
diff --git a/src/db/postgres/sql/subscription-delivery-claim.sql b/src/db/postgres/sql/subscription-delivery-claim.sql
new file mode 100644 (file)
index 0000000..c01314a
--- /dev/null
@@ -0,0 +1,13 @@
+--
+INSERT INTO subscription_delivery_in_progress (id, claimant, claimed, claim_expires)
+SELECT id, $(claimant) AS claimant, now() AS claimed, now() + $(claimTimeoutSeconds)::text::interval AS claim_expires
+       FROM subscription_delivery_needed
+       ORDER BY topic_id -- cluster topics together, so processing nodes can cache topic content
+       LIMIT $(wanted)
+       FOR UPDATE OF subscription_delivery_needed SKIP LOCKED
+ON CONFLICT (id) DO UPDATE
+SET
+       claimant = $(claimant),
+       claimed = now(),
+       claim_expires = now() + $(claimTimeoutSeconds)::text::interval
+RETURNING id
diff --git a/src/db/postgres/sql/subscription-delivery-done.sql b/src/db/postgres/sql/subscription-delivery-done.sql
new file mode 100644 (file)
index 0000000..dfd319b
--- /dev/null
@@ -0,0 +1,6 @@
+--
+DELETE FROM subscription_delivery_in_progress
+WHERE id = (
+       SELECT id FROM subscription
+       WHERE topic_id = $(topicId) AND callback = $(callback)
+)
diff --git a/src/db/postgres/sql/subscription-delivery-failure.sql b/src/db/postgres/sql/subscription-delivery-failure.sql
new file mode 100644 (file)
index 0000000..1b8e550
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE subscription SET
+       delivery_attempts_since_success = delivery_attempts_since_success + 1,
+       delivery_next_attempt = now() + ($(nextAttemptDelaySeconds) * INTERVAL '1 second')
+WHERE
+       topic_id = $(topicId) AND callback = $(callback)
diff --git a/src/db/postgres/sql/subscription-delivery-success.sql b/src/db/postgres/sql/subscription-delivery-success.sql
new file mode 100644 (file)
index 0000000..9ff01e0
--- /dev/null
@@ -0,0 +1,7 @@
+--
+UPDATE subscription SET
+       content_delivered = now(),
+       delivery_attempts_since_success = 0,
+       delivery_next_attempt = '-infinity'::timestamptz
+WHERE
+       topic_id = $(topicId) AND callback = $(callback)
diff --git a/src/db/postgres/sql/subscription-get-by-id.sql b/src/db/postgres/sql/subscription-get-by-id.sql
new file mode 100644 (file)
index 0000000..90577b0
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT * FROM subscription
+WHERE id = $(subscriptionId)
+
diff --git a/src/db/postgres/sql/subscription-get.sql b/src/db/postgres/sql/subscription-get.sql
new file mode 100644 (file)
index 0000000..70259c6
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT * FROM subscription
+WHERE callback = $(callback) AND topic_id = $(topicId)
+
diff --git a/src/db/postgres/sql/subscription-update.sql b/src/db/postgres/sql/subscription-update.sql
new file mode 100644 (file)
index 0000000..7ba44f7
--- /dev/null
@@ -0,0 +1,7 @@
+--
+UPDATE subscription
+SET
+       signature_algorithm = $(signatureAlgorithm)
+WHERE
+       id = $(subscriptionId)
+
diff --git a/src/db/postgres/sql/subscription-upsert.sql b/src/db/postgres/sql/subscription-upsert.sql
new file mode 100644 (file)
index 0000000..4a548d9
--- /dev/null
@@ -0,0 +1,13 @@
+--
+INSERT INTO subscription
+       (topic_id, callback, verified, expires, secret, http_remote_addr, http_from)
+VALUES
+       ($(topicId), $(callback), now(), now() + ($(leaseSeconds) * INTERVAL '1 second'), $(secret), $(httpRemoteAddr), $(httpFrom))
+ON CONFLICT (topic_id, callback) DO UPDATE
+SET
+       verified = now(),
+       expires = now() + ($(leaseSeconds) * INTERVAL '1 second'),
+       secret = $(secret),
+       http_remote_addr = $(httpRemoteAddr),
+       http_from = $(httpFrom)
+RETURNING id
diff --git a/src/db/postgres/sql/subscriptions-by-topic-id.sql b/src/db/postgres/sql/subscriptions-by-topic-id.sql
new file mode 100644 (file)
index 0000000..81c22f8
--- /dev/null
@@ -0,0 +1,6 @@
+--
+SELECT *
+FROM subscription
+WHERE topic_id = $(topicId)
+AND expires > now()
+
diff --git a/src/db/postgres/sql/topic-attempts-increment.sql b/src/db/postgres/sql/topic-attempts-increment.sql
new file mode 100644 (file)
index 0000000..51ac2dd
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE topic SET
+       content_fetch_attempts_since_success = content_fetch_attempts_since_success + 1,
+       content_fetch_next_attempt = now() + ($(nextAttemptDelaySeconds) * INTERVAL '1 second')
+WHERE
+       id = $(topicId)
diff --git a/src/db/postgres/sql/topic-attempts-reset.sql b/src/db/postgres/sql/topic-attempts-reset.sql
new file mode 100644 (file)
index 0000000..1724d28
--- /dev/null
@@ -0,0 +1,7 @@
+--
+UPDATE topic SET
+       content_fetch_attempts_since_success = 0,
+       content_fetch_next_attempt = 'infinity'::timestamptz
+WHERE
+       id = $(topicId)
+
diff --git a/src/db/postgres/sql/topic-attempts.sql b/src/db/postgres/sql/topic-attempts.sql
new file mode 100644 (file)
index 0000000..10a0cde
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT content_fetch_attempts_since_success
+FROM topic
+WHERE id = $(topicId)
diff --git a/src/db/postgres/sql/topic-content-fetch-claim-by-id.sql b/src/db/postgres/sql/topic-content-fetch-claim-by-id.sql
new file mode 100644 (file)
index 0000000..62c4a14
--- /dev/null
@@ -0,0 +1,10 @@
+-- claim a specific topic needing the content fetched
+INSERT INTO topic_fetch_in_progress (id, claimant, claimed, claim_expires)
+VALUES ($(topicId), $(claimant), now(), now() + $(claimTimeoutSeconds)::text::interval)
+ON CONFLICT (id) DO UPDATE
+SET
+       claimant = $(claimant),
+       claimed = now(),
+       claim_expires = now() + $(claimTimeoutSeconds)::text::interval
+WHERE topic_fetch_in_progress.claim_expires < now()
+RETURNING id
diff --git a/src/db/postgres/sql/topic-content-fetch-claim.sql b/src/db/postgres/sql/topic-content-fetch-claim.sql
new file mode 100644 (file)
index 0000000..f7cc342
--- /dev/null
@@ -0,0 +1,12 @@
+-- claim some topics needing their contents fetched
+INSERT INTO topic_fetch_in_progress (id, claimant, claimed, claim_expires)
+SELECT id, $(claimant) AS claimant, now() AS claimed, now() + $(claimTimeoutSeconds)::text::interval AS claim_expires
+       FROM topic_fetch_needed
+       LIMIT $(wanted)
+       FOR UPDATE OF topic_fetch_needed SKIP LOCKED
+ON CONFLICT (id) DO UPDATE
+SET
+       claimant = $(claimant),
+       claimed = now(),
+       claim_expires = now() + $(claimTimeoutSeconds)::text::interval
+RETURNING id
diff --git a/src/db/postgres/sql/topic-content-fetch-done.sql b/src/db/postgres/sql/topic-content-fetch-done.sql
new file mode 100644 (file)
index 0000000..caf9b15
--- /dev/null
@@ -0,0 +1,3 @@
+--
+DELETE FROM topic_fetch_in_progress
+WHERE id = $(topicId)
diff --git a/src/db/postgres/sql/topic-content-fetch-requested.sql b/src/db/postgres/sql/topic-content-fetch-requested.sql
new file mode 100644 (file)
index 0000000..753c94c
--- /dev/null
@@ -0,0 +1,7 @@
+-- update topic that a new publish notification occurred
+UPDATE topic
+SET
+       last_publish = now(),
+       content_fetch_next_attempt = now()
+WHERE id = $(topicId)
+
diff --git a/src/db/postgres/sql/topic-deleted.sql b/src/db/postgres/sql/topic-deleted.sql
new file mode 100644 (file)
index 0000000..10f4216
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE topic SET
+       content_updated = now(),
+       is_deleted = true
+WHERE id = $(topicId)
+
diff --git a/src/db/postgres/sql/topic-get-by-id.sql b/src/db/postgres/sql/topic-get-by-id.sql
new file mode 100644 (file)
index 0000000..aa18336
--- /dev/null
@@ -0,0 +1,20 @@
+-- excluding content field
+SELECT
+       id,
+       created,
+       url,
+       extract(epoch FROM lease_seconds_preferred) AS lease_seconds_preferred,
+       extract(epoch FROM lease_seconds_min) AS lease_seconds_min,
+       extract(epoch FROM lease_seconds_max) AS lease_seconds_max,
+       publisher_validation_url,
+       content_hash_algorithm,
+       is_active,
+       is_deleted,
+       last_publish,
+       content_fetch_next_attempt,
+       content_fetch_attempts_since_success,
+       content_updated,
+       content_hash,
+       content_type
+FROM topic
+WHERE id = $(topicId)
diff --git a/src/db/postgres/sql/topic-get-by-url.sql b/src/db/postgres/sql/topic-get-by-url.sql
new file mode 100644 (file)
index 0000000..adc9ffb
--- /dev/null
@@ -0,0 +1,20 @@
+-- excluding content field
+SELECT
+       id,
+       created,
+       url,
+       extract(epoch FROM lease_seconds_preferred) AS lease_seconds_preferred,
+       extract(epoch FROM lease_seconds_min) AS lease_seconds_min,
+       extract(epoch FROM lease_seconds_max) AS lease_seconds_max,
+       publisher_validation_url,
+       content_hash_algorithm,
+       is_active,
+       is_deleted,
+       last_publish,
+       content_fetch_next_attempt,
+       content_fetch_attempts_since_success,
+       content_updated,
+       content_hash,
+       content_type
+FROM topic
+WHERE url = $(topicUrl)
diff --git a/src/db/postgres/sql/topic-get-content-by-id.sql b/src/db/postgres/sql/topic-get-content-by-id.sql
new file mode 100644 (file)
index 0000000..f603991
--- /dev/null
@@ -0,0 +1,7 @@
+-- topic including content
+SELECT *,
+       extract(epoch FROM lease_seconds_preferred) AS lease_seconds_preferred,
+       extract(epoch FROM lease_seconds_min) AS lease_seconds_min,
+       extract(epoch FROM lease_seconds_max) AS lease_seconds_max
+FROM topic
+WHERE id = $(topicId)
diff --git a/src/db/postgres/sql/topic-get-info-all.sql b/src/db/postgres/sql/topic-get-info-all.sql
new file mode 100644 (file)
index 0000000..e428903
--- /dev/null
@@ -0,0 +1,23 @@
+-- meta data with subscriber count
+SELECT
+       t.id,
+       created,
+       url,
+       extract(epoch FROM lease_seconds_preferred) AS lease_seconds_preferred,
+       extract(epoch FROM lease_seconds_min) AS lease_seconds_min,
+       extract(epoch FROM lease_seconds_max) AS lease_seconds_max,
+       t.publisher_validation_url,
+       t.content_hash_algorithm,
+       t.is_active,
+       t.is_deleted,
+       last_publish,
+       content_fetch_next_attempt,
+       t.content_fetch_attempts_since_success,
+       content_updated,
+       t.content_hash,
+       t.content_type,
+       COUNT (s.id) AS subscribers
+FROM topic t
+LEFT JOIN (SELECT id, topic_id FROM subscription WHERE expires > now()) s ON t.id = s.topic_id
+GROUP BY t.id
+ORDER BY subscribers DESC, t.created DESC
diff --git a/src/db/postgres/sql/topic-set-content.sql b/src/db/postgres/sql/topic-set-content.sql
new file mode 100644 (file)
index 0000000..c14258b
--- /dev/null
@@ -0,0 +1,9 @@
+-- 
+UPDATE topic
+SET
+       is_active = true,
+       content_updated = now(),
+       content = $(content),
+       content_hash = $(contentHash),
+       content_type = $(contentType)
+WHERE id = $(topicId)
diff --git a/src/db/postgres/sql/topic-update.sql b/src/db/postgres/sql/topic-update.sql
new file mode 100644 (file)
index 0000000..df038ea
--- /dev/null
@@ -0,0 +1,10 @@
+--
+UPDATE topic
+SET
+       lease_seconds_preferred = $(leaseSecondsPreferred)::text::interval,
+       lease_seconds_min = $(leaseSecondsMin)::text::interval,
+       lease_seconds_max = $(leaseSecondsMax)::text::interval,
+       publisher_validation_url = $(publisherValidationUrl),
+       content_hash_algorithm = $(contentHashAlgorithm)
+WHERE
+       id = $(topicId)
diff --git a/src/db/postgres/sql/topic-upsert.sql b/src/db/postgres/sql/topic-upsert.sql
new file mode 100644 (file)
index 0000000..1909248
--- /dev/null
@@ -0,0 +1,17 @@
+--
+INSERT INTO topic
+       (url, lease_seconds_preferred, lease_seconds_min, lease_seconds_max, publisher_validation_url)
+VALUES (
+       $(url),
+       $(leaseSecondsPreferred)::text::interval,
+       $(leaseSecondsMin)::text::interval,
+       $(leaseSecondsMax)::text::interval,
+       $(publisherValidationUrl)
+) ON CONFLICT (url) DO UPDATE
+SET
+       is_deleted = false,
+       lease_seconds_preferred = $(leaseSecondsPreferred)::text::interval,
+       lease_seconds_min = $(leaseSecondsMin)::text::interval,
+       lease_seconds_max = $(leaseSecondsMax)::text::interval,
+       publisher_validation_url = $(publisherValidationUrl)
+RETURNING id
diff --git a/src/db/postgres/sql/verification-attempt-increment.sql b/src/db/postgres/sql/verification-attempt-increment.sql
new file mode 100644 (file)
index 0000000..c9bf303
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE verification SET
+       next_attempt = (now() + $(nextAttemptDelaySeconds)::text::INTERVAL),
+       attempts = attempts + 1
+WHERE id = $(verificationId)
+
diff --git a/src/db/postgres/sql/verification-attempt-reset.sql b/src/db/postgres/sql/verification-attempt-reset.sql
new file mode 100644 (file)
index 0000000..2705bc6
--- /dev/null
@@ -0,0 +1,6 @@
+-- Resetting verification attempts happens on publisher validation.
+UPDATE verification SET
+       next_attempt = now(),
+       attempts = 0
+WHERE id = $(verificationId)
+
diff --git a/src/db/postgres/sql/verification-attempts.sql b/src/db/postgres/sql/verification-attempts.sql
new file mode 100644 (file)
index 0000000..6e883ee
--- /dev/null
@@ -0,0 +1,5 @@
+--
+SELECT attempts
+FROM verification
+WHERE id = $(verificationId)
+
diff --git a/src/db/postgres/sql/verification-claim-by-id.sql b/src/db/postgres/sql/verification-claim-by-id.sql
new file mode 100644 (file)
index 0000000..8da1960
--- /dev/null
@@ -0,0 +1,13 @@
+--
+INSERT INTO verification_in_progress
+       (id, topic_id, callback, claimant, claim_expires)
+SELECT id, topic_id, callback, $(claimant) AS claimant, now() + $(claimTimeoutSeconds)::text::interval AS claim_expires
+       FROM verification
+       WHERE id = $(verificationId)
+       FOR UPDATE OF verification SKIP LOCKED
+ON CONFLICT (topic_id, callback) DO UPDATE
+SET
+       claimant = $(claimant),
+       claimed = now(),
+       claim_expires = now() + $(claimTimeoutSeconds)::text::interval
+RETURNING id
diff --git a/src/db/postgres/sql/verification-claim.sql b/src/db/postgres/sql/verification-claim.sql
new file mode 100644 (file)
index 0000000..3b58014
--- /dev/null
@@ -0,0 +1,12 @@
+--
+INSERT INTO verification_in_progress (id, topic_id, callback, claimant, claimed, claim_expires)
+SELECT id, topic_id, callback, $(claimant) AS claimant, now() AS claimed, now() + $(claimTimeoutSeconds)::text::interval AS claim_expires
+       FROM verification_needed
+       LIMIT $(wanted)
+       FOR UPDATE OF verification_needed SKIP LOCKED
+ON CONFLICT (topic_id, callback) DO UPDATE
+SET
+       claimant = $(claimant),
+       claimed = now(),
+       claim_expires = now() + $(claimTimeoutSeconds)::text::interval
+RETURNING id
diff --git a/src/db/postgres/sql/verification-done.sql b/src/db/postgres/sql/verification-done.sql
new file mode 100644 (file)
index 0000000..fb04b05
--- /dev/null
@@ -0,0 +1,3 @@
+--
+DELETE FROM verification_in_progress
+WHERE id = $(verificationId)
diff --git a/src/db/postgres/sql/verification-get-by-id.sql b/src/db/postgres/sql/verification-get-by-id.sql
new file mode 100644 (file)
index 0000000..ddb91b3
--- /dev/null
@@ -0,0 +1,5 @@
+--
+SELECT *
+FROM verification
+WHERE id = $(verificationId)
+
diff --git a/src/db/postgres/sql/verification-insert.sql b/src/db/postgres/sql/verification-insert.sql
new file mode 100644 (file)
index 0000000..c2c0a18
--- /dev/null
@@ -0,0 +1,6 @@
+--
+INSERT INTO verification
+       (topic_id, callback, mode, secret, lease_seconds, http_remote_addr, http_from, is_publisher_validated, request_id)
+VALUES
+       ($(topicId), $(callback), $(mode), $(secret), $(leaseSeconds), $(httpRemoteAddr), $(httpFrom), $(isPublisherValidated), $(requestId))
+RETURNING id
diff --git a/src/db/postgres/sql/verification-scrub.sql b/src/db/postgres/sql/verification-scrub.sql
new file mode 100644 (file)
index 0000000..1a829e7
--- /dev/null
@@ -0,0 +1,4 @@
+--
+DELETE FROM verification
+WHERE id IN
+       (SELECT id FROM verification WHERE topic_id = $(topicId) AND callback = $(callback) AND created <= (SELECT created FROM verification WHERE id = $(verificationId)))
diff --git a/src/db/postgres/sql/verification-update.sql b/src/db/postgres/sql/verification-update.sql
new file mode 100644 (file)
index 0000000..6c9d1ee
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE verification SET
+       mode = $(mode),
+       reason = $(reason),
+       is_publisher_validated = $(isPublisherValidated)
+WHERE id = $(verificationId)
diff --git a/src/db/postgres/sql/verification-validate.sql b/src/db/postgres/sql/verification-validate.sql
new file mode 100644 (file)
index 0000000..d617df2
--- /dev/null
@@ -0,0 +1,7 @@
+--
+UPDATE verification SET
+       is_publisher_validated = true,
+       attempts = 0,
+       next_attempt = now()
+WHERE id = $(verificationId)
+
diff --git a/src/db/schema-version-helper.js b/src/db/schema-version-helper.js
new file mode 100644 (file)
index 0000000..1f2d374
--- /dev/null
@@ -0,0 +1,128 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * Utility functions for wrangling schema migrations.
+ */
+
+/**
+ * @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) {
+  // 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, 'apply.sql'));
+      return applyStat.isFile();
+    } catch (e) {
+      return false;
+    }
+  }
+  return false;
+}
+
+
+/**
+ * Return an array of schema migration directory names within engineDir.
+ * @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..775708d
--- /dev/null
@@ -0,0 +1,1052 @@
+'use strict';
+
+const common = require('../../common');
+const Database = require('../base');
+const DBErrors = require('../errors');
+const svh = require('../schema-version-helper');
+const SQLite = require('better-sqlite3');
+const fs = require('fs');
+const path = require('path');
+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.connectionString.optimizeAfterChanges;
+    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();
+  }
+
+
+  /**
+   * 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.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 = Database._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 });
+        /* istanbul ignore next */
+        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(0x03)');
+
+    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 = Database._camelfy(prop);
+      if (!(camel in exemplaryRow)) {
+        for (const d of rows) {
+          // eslint-disable-next-line security/detect-object-injection
+          d[camel] = d[prop];
+          // eslint-disable-next-line security/detect-object-injection
+          delete d[prop];
+        }
+      }
+    }
+    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 };
+  }
+
+
+  _engineInfo(result) {
+    if (result.changes) {
+      this.changesSinceLastOptimize += BigInt(result.changes);
+      this._optimize();
+    }
+    return {
+      changes: Number(result.changes),
+      lastInsertRowid: result.lastInsertRowid,
+    };
+  }
+
+
+  _closeConnection() {
+    this.db.close();
+  }
+
+
+  _optimize() {
+    const _scope = _fileScope('_optimize');
+
+    if (this.optimizeAfterChanges
+    &&  this.changesSinceLastOptimize >= this.optimizeAfterChanges) {
+      const optimize = this.statement._optimize.all();
+      this.logger.debug(_scope, 'optimize', { optimize });
+      this.db.pragma('optimize');
+      this.changesSinceLastOptimize = BigInt(0);
+    }
+  }
+
+
+  _purgeTables(really) {
+    if (really) {
+      [
+        'topic',
+        'topic_fetch_in_progress',
+        'verification',
+        'verification_in_progress',
+        'subscription',
+        'subscription_delivery_in_progress',
+      ].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)();
+  }
+
+
+  authenticationSuccess(dbCtx, identifier) {
+    const _scope = _fileScope('authenticationSuccess');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    let result;
+    try {
+      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;
+    }
+  }
+
+
+  authenticationGet(dbCtx, identifier) {
+    const _scope = _fileScope('authenticationGet');
+    this.logger.debug(_scope, 'called', { identifier });
+
+    try {
+      return this.statement.authenticationGet.get({ identifier });
+    } 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;
+    }
+  }
+
+
+  subscriptionsByTopicId(dbCtx, topicId) {
+    const _scope = _fileScope('subscriptionsByTopicId');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    try {
+      return this.statement.subscriptionsByTopicId.all({ topicId });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topicId });
+      throw e;
+    }
+  }
+
+
+  subscriptionCountByTopicUrl(dbCtx, topicUrl) {
+    const _scope = _fileScope('subscriptionCountByTopicUrl');
+    this.logger.debug(_scope, 'called', { topicUrl });
+
+    try {
+      return this.statement.subscriptionCountByTopicUrl.get({ topicUrl });
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topicUrl });
+      throw e;
+    }
+  }
+
+
+  subscriptionDelete(dbCtx, callback, topicId) {
+    const _scope = _fileScope('subscriptionDelete');
+    this.logger.debug(_scope, 'called', { callback, topicId });
+
+    try {
+      const result = this.statement.subscriptionDelete.run({ callback, topicId });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not delete subscription');
+      }
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, callback, topicId });
+      throw e;
+    }
+  }
+
+
+  subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('subscriptionDeliveryClaim');
+    this.logger.debug(_scope, 'called', { wanted, claimTimeoutSeconds, claimant });
+
+    let subscriptionIds;
+    try {
+      this.db.transaction(() => {
+        subscriptionIds = this.statement.subscriptionDeliveryNeeded.all({ wanted }).map((claim) => claim.id);
+        subscriptionIds.forEach((subscriptionId) => {
+          const result = this.statement.subscriptionDeliveryClaimById.run({ subscriptionId, claimTimeoutSeconds, claimant });
+          if (result.changes != 1) {
+            throw new DBErrors.UnexpectedResult('did not claim subscription delivery');
+          }
+        });
+      })();
+      return subscriptionIds;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, wanted, claimTimeoutSeconds, claimant, subscriptionIds });
+      throw e;
+    }
+  }
+
+
+  subscriptionDeliveryClaimById(dbCtx, subscriptionId, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('subscriptionDeliveryClaimById');
+    this.logger.debug(_scope, 'called', { subscriptionId, claimTimeoutSeconds, claimant });
+
+    try {
+      const result = this.statement.subscriptionDeliveryClaimById.run({ subscriptionId, claimTimeoutSeconds, claimant });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not claim subscription delivery');
+      }
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, subscriptionId, claimTimeoutSeconds, claimant });
+      throw e;
+    }
+  }
+
+
+  subscriptionDeliveryComplete(dbCtx, callback, topicId) {
+    const _scope = _fileScope('subscriptionDeliveryComplete');
+    this.logger.debug(_scope, 'called', { callback, topicId });
+
+    let result;
+    try {
+      this.db.transaction(() => {
+        result = this.statement.subscriptionDeliverySuccess.run({ callback, topicId });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not set subscription delivery success');
+        }
+        result = this.statement.subscriptionDeliveryDone.run({ callback, topicId });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not complete subscription delivery');
+        }
+      })();
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, callback, topicId });
+      throw e;
+    }
+  }
+
+
+  subscriptionDeliveryGone(dbCtx, callback, topicId) {
+    const _scope = _fileScope('subscriptionDeliveryGone');
+    this.logger.debug(_scope, 'called', { callback, topicId });
+
+    let result;
+    try {
+      this.db.transaction(() => {
+        result = this.statement.subscriptionDelete.run({ callback, topicId });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not delete subscription');
+        }
+        // Delete cascades to delivery
+        // result = this.statement.subscriptionDeliveryDone.run({ callback, topicId });
+        // if (result.changes != 1) {
+        //   throw new DBErrors.UnexpectedResult('did not complete subscription delivery');
+        // }
+      })();
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, callback, topicId });
+      throw e;
+    }
+  }
+
+
+  subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays = [60]) {
+    const _scope = _fileScope('subscriptionDeliveryIncomplete');
+    this.logger.debug(_scope, 'called', { callback, topicId, retryDelays });
+
+    let result;
+    try {
+      this.db.transaction(() => {
+        const { currentAttempt } = this.statement.subscriptionDeliveryAttempts.get({ callback, topicId });
+        const nextAttemptDelaySeconds = common.attemptRetrySeconds(currentAttempt, retryDelays);
+        result = this.statement.subscriptionDeliveryFailure.run({ nextAttemptDelaySeconds, callback, topicId });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not set delivery failure');
+        }
+        result = this.statement.subscriptionDeliveryDone.run({ callback, topicId });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not complete subscription delivery');
+        }
+      })();
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, callback, topicId });
+      throw e;
+    }
+  }
+
+
+  subscriptionGet(dbCtx, callback, topicId) {
+    const _scope = _fileScope('subscriptionGet');
+    this.logger.debug(_scope, 'called', { callback, topicId });
+
+    let subscription;
+    try {
+      subscription = this.statement.subscriptionGet.get({ callback, topicId });
+      return subscription;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, callback, topicId });
+      throw e;
+    }
+  }
+
+
+  subscriptionGetById(dbCtx, subscriptionId) {
+    const _scope = _fileScope('subscriptionGetById');
+    this.logger.debug(_scope, 'called', { subscriptionId });
+
+    let subscription;
+    try {
+      subscription = this.statement.subscriptionGetById.get({ subscriptionId });
+      return subscription;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, subscriptionId });
+      throw e;
+    }
+  }
+
+
+  subscriptionUpdate(dbCtx, data) {
+    const _scope = _fileScope('subscriptionUpdate');
+    this.logger.debug(_scope, 'called', { data });
+
+    const subscriptionData = {
+      ...data,
+    };
+
+    this._subscriptionUpdateDataValidate(subscriptionData);
+
+    try {
+      const result = this.statement.subscriptionUpdate.run(subscriptionData);
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not update subscription');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, subscriptionData });
+      throw e;
+    }
+  }
+
+
+  subscriptionUpsert(dbCtx, data) {
+    const _scope = _fileScope('subscriptionUpsert');
+    this.logger.debug(_scope, 'called', { ...data });
+
+    const subscriptionData = {
+      secret: null,
+      httpRemoteAddr: null,
+      httpFrom: null,
+      ...data,
+    }
+    this._subscriptionUpsertDataValidate(subscriptionData);
+
+    let result;
+    try {
+      result = this.statement.subscriptionUpsert.run(subscriptionData);
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not upsert subscription');
+      }
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, subscriptionData });
+      throw e;
+    }
+  }
+
+
+  topicDeleted(dbCtx, topicId) {
+    const _scope = _fileScope('topicDeleted');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let result;
+    try {
+      result = this.statement.topicDeleted.run({ topicId });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not update topic as deleted');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topicId });
+      throw e;
+    }
+  }
+
+
+  topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('topicFetchClaim');
+    this.logger.debug(_scope, 'called', { wanted, claimTimeoutSeconds });
+
+    let topicIds;
+    try {
+      this.db.transaction(() => {
+        topicIds = this.statement.topicContentFetchNeeded.all({ wanted }).map((claim) => claim.id);
+        topicIds.forEach((topicId) => {
+          const result = this.statement.topicContentFetchClaimById.run({ topicId, claimTimeoutSeconds, claimant });
+          if (result.changes != 1) {
+            throw new DBErrors.UnexpectedResult('did not claim topic fetch');
+          }
+        });
+      })();
+      return topicIds;
+    } catch (e) {
+      this.logger.error(_scope, 'failed to claim topics for fetch', { error: e, wanted, claimTimeoutSeconds, claimant, topicIds });
+      throw e;
+    }
+  }
+
+
+  topicFetchClaimById(dbCtx, topicId, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('topicFetchClaimById');
+    this.logger.debug(_scope, 'called', { topicId, claimTimeoutSeconds, claimant });
+
+    let result;
+    try {
+      result = this.statement.topicContentFetchClaimById.run({ topicId, claimTimeoutSeconds, claimant });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not claim topic fetch');
+      }
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed to claim topics for fetch', { error: e, topicId, claimTimeoutSeconds, claimant });
+      throw e;
+    }
+  }
+
+
+  topicFetchComplete(dbCtx, topicId) {
+    const _scope = _fileScope('topicFetchComplete');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let result;
+    try {
+      this.db.transaction(() => {
+        result = this.statement.topicAttemptsReset.run({ topicId, forever: EPOCH_FOREVER });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not reset topic attempts');
+        }
+        result = this.statement.topicContentFetchDone.run({ topicId });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not release topic fetch');
+        }
+      })();
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, result, topicId });
+      throw e;
+    }
+  }
+
+
+  topicFetchIncomplete(dbCtx, topicId, retryDelays = [60]) {
+    const _scope = _fileScope('topicFetchIncomplete');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let result;
+    try {
+      this.db.transaction(() => {
+        const { contentFetchAttemptsSinceSuccess: currentAttempt } = this.statement.topicAttempts.get({ topicId });
+        const nextAttemptDelaySeconds = common.attemptRetrySeconds(currentAttempt, retryDelays);
+        result = this.statement.topicAttemptsIncrement.run({ topicId, nextAttemptDelaySeconds });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not set topic attempts');
+        }
+        result = this.statement.topicContentFetchDone.run({ topicId });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not release topic fetch');
+        }
+        return result;
+      })();
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, result, topicId });
+      throw e;
+    }
+  }
+
+
+  topicFetchRequested(dbCtx, topicId) {
+    const _scope = _fileScope('topicFetchRequested');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let result;
+    try {
+      result = this.statement.topicContentFetchRequested.run({ topicId });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not set topic fetch requested');
+      }
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topicId });
+      throw e;
+    }
+  }
+
+
+  /**
+   * Converts engine topic fields to native types.
+   * @param {Object} data
+   */
+  static _topicDataToNative(data) {
+    const epochToDate = (epoch) => new Date(Number(epoch) * 1000);
+    if (data) {
+      data.isActive = !!data.isActive;
+      data.isDeleted = !!data.isDeleted;
+      ['created', 'lastPublish', 'contentFetchNextAttempt', 'contentUpdated'].forEach((field) => {
+        // eslint-disable-next-line security/detect-object-injection
+        data[field] = epochToDate(data[field]);
+      });
+    }
+    return data;
+  }
+
+
+  // eslint-disable-next-line no-unused-vars
+  topicGetAll(dbCtx) {
+    const _scope = _fileScope('topicGetAll');
+    this.logger.debug(_scope, 'called');
+
+    let topics;
+    try {
+      topics = this.statement.topicGetInfoAll.all();
+      } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topics });
+      throw e;
+    }
+    if (topics) {
+      topics = topics
+        .map(DatabaseSQLite._topicDataToNative)
+        .map(this._topicDefaults.bind(this));
+    }
+    return topics;
+  }
+
+
+  topicGetById(dbCtx, topicId, applyDefaults = true) {
+    const _scope = _fileScope('topicGetById');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let topic;
+    try {
+      topic = this.statement.topicGetById.get({ topicId });
+      DatabaseSQLite._topicDataToNative(topic);
+      if (applyDefaults) {
+        topic = this._topicDefaults(topic);
+      }
+      return topic;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topic, topicId });
+      throw e;
+    }
+  }
+
+
+  topicGetByUrl(dbCtx, topicUrl) {
+    const _scope = _fileScope('topicGetByUrl');
+    this.logger.debug(_scope, 'called', { topicUrl });
+
+    let topic;
+    try {
+      topic = this.statement.topicGetByUrl.get({ topicUrl });
+      DatabaseSQLite._topicDataToNative(topic);
+      return this._topicDefaults(topic);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topic, topicUrl });
+      throw e;
+    }
+  }
+
+
+  topicGetContentById(dbCtx, topicId) {
+    const _scope = _fileScope('topicGetContentById');
+    this.logger.debug(_scope, 'called', { topicId });
+
+    let topic;
+    try {
+      topic = this.statement.topicGetContentById.get({ topicId });
+      DatabaseSQLite._topicDataToNative(topic);
+      return this._topicDefaults(topic);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topic, topicId });
+      throw e;
+    }
+  }
+
+
+  topicSet(dbCtx, data) {
+    const _scope = _fileScope('topicSet');
+    this.logger.debug(_scope, 'called', data);
+
+    const topicSetData = {
+      publisherValidationUrl: null,
+      leaseSecondsPreferred: null,
+      leaseSecondsMin: null,
+      leaseSecondsMax: null,
+      ...data,
+    };
+  
+    let result;
+    try {
+      this._topicSetDataValidate(topicSetData);
+      result = this.statement.topicUpsert.run(topicSetData);
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not set topic data');
+      }
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, result });
+      throw e;
+    }
+  }
+
+
+  topicSetContent(dbCtx, data) {
+    const _scope = _fileScope('topicSetContent');
+    const topicSetContentData = {
+      contentType: null,
+      ...data,
+    };
+    const logData = {
+      ...topicSetContentData,
+      content: common.logTruncate(topicSetContentData.content, 100),
+    };
+    this.logger.debug(_scope, 'called', logData);
+
+    let result;
+    try {
+      this._topicSetContentDataValidate(topicSetContentData);
+      result = this.statement.topicSetContent.run(topicSetContentData);
+      logData.result = result;
+      if (result.changes !=  1) {
+        throw new DBErrors.UnexpectedResult('did not set topic content');
+      }
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, ...logData });
+      throw e;
+    }
+  }
+
+
+  topicUpdate(dbCtx, data) {
+    const _scope = _fileScope('topicUpdate');
+    this.logger.debug(_scope, 'called', { data });
+
+    const topicData = {
+      leaseSecondsPreferred: null,
+      leaseSecondsMin: null,
+      leaseSecondsMax: null,
+      publisherValidationUrl: null,
+      ...data,
+    };
+
+    this._topicUpdateDataValidate(topicData);
+
+    try {
+      const result = this.statement.topicUpdate.run(topicData);
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not update topic');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, topicData });
+      throw e;
+    }
+  }
+
+
+  verificationClaim(dbCtx, wanted, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('verificationClaim');
+    this.logger.debug(_scope, 'called', { wanted, claimTimeoutSeconds });
+
+    let verificationIds;
+    try {
+      this.db.transaction(() => {
+        verificationIds = this.statement.verificationNeeded.all({ wanted }).map((claim) => claim.id);
+        verificationIds.forEach((verificationId) => {
+          const result = this.statement.verificationClaimById.run({ verificationId, claimTimeoutSeconds, claimant });
+          if (result.changes != 1) {
+            throw new DBErrors.UnexpectedResult('did not claim verification');
+          }
+        });
+      })();
+      return verificationIds;
+    } catch (e) {
+      this.logger.error(_scope, 'failed to claim verifications', { wanted, claimTimeoutSeconds });
+      throw e;
+    }
+  }
+
+
+  verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant) {
+    const _scope = _fileScope('verificationClaimById');
+    this.logger.debug(_scope, 'called', { verificationId, claimTimeoutSeconds, claimant });
+
+    let result;
+    try {
+      result = this.statement.verificationClaimById.run({ verificationId, claimTimeoutSeconds, claimant });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not claim verification');
+      }
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed to claim verification', { error: e, verificationId, claimTimeoutSeconds, claimant });
+      throw e;
+    }
+  }
+
+
+  verificationComplete(dbCtx, verificationId, callback, topicId) {
+    const _scope = _fileScope('verificationComplete');
+    this.logger.debug(_scope, 'called', { verificationId });
+
+    let result;
+    try {
+      this.db.transaction(() => {
+        result = this.statement.verificationScrub.run({ verificationId, callback, topicId });
+        if (result.changes < 1) {
+          throw new DBErrors.UnexpectedResult('did not remove verifications');
+        }
+      })();
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { verificationId });
+      throw e;
+    }
+    return this._engineInfo(result);
+  }
+
+
+  /**
+   * Converts engine verification fields to native types.
+   * @param {Object} data
+   */
+  static _verificationDataToNative(data) {
+    if (data) {
+      data.isPublisherValidated = !!data.isPublisherValidated;
+    }
+  }
+
+
+  verificationGetById(dbCtx, verificationId) {
+    const _scope = _fileScope('verificationGetById');
+    this.logger.debug(_scope, 'called', { verificationId });
+
+    let verification;
+    try {
+      verification = this.statement.verificationGetById.get({ verificationId });
+      DatabaseSQLite._verificationDataToNative(verification);
+      return verification;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationId });
+      throw e;
+    }
+  }
+
+
+  verificationIncomplete(dbCtx, verificationId, retryDelays = [60]) {
+    const _scope = _fileScope('verificationIncomplete');
+    this.logger.debug(_scope, 'called', { verificationId });
+
+    let result;
+    try {
+      this.db.transaction(() => {
+        const { attempts: currentAttempt } = this.statement.verificationAttempts.get({ verificationId });
+        const nextAttemptDelaySeconds = common.attemptRetrySeconds(currentAttempt, retryDelays);
+        result = this.statement.verificationAttemptsIncrement.run({ verificationId, nextAttemptDelaySeconds });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not increment verification attempts');
+        }
+        result = this.statement.verificationDone.run({ verificationId });
+        if (result.changes != 1) {
+          throw new DBErrors.UnexpectedResult('did not release verification in progress');
+        }
+        return result;
+      })();
+      return this._engineInfo(result);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, result, verificationId });
+      throw e;
+    }
+  }
+
+
+  /**
+   * Convert native verification fields to engine types.
+   */
+  static _verificationDataToEngine(data) {
+    if (data) {
+      data.isPublisherValidated = data.isPublisherValidated ? 1 : 0;
+    }
+  }
+
+
+  verificationInsert(dbCtx, verification) {
+    const _scope = _fileScope('verificationInsert');
+    this.logger.debug(_scope, 'called', { verification });
+
+    const verificationData = {
+      secret: null,
+      httpRemoteAddr: null,
+      httpFrom: null,
+      requestId: null,
+      ...verification,
+    };
+
+    let result, verificationId;
+    try {
+      this._verificationDataValidate(verificationData);
+      DatabaseSQLite._verificationDataToEngine(verificationData);
+      result = this.statement.verificationInsert.run(verificationData);
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not insert verification');
+      }
+      verificationId = result.lastInsertRowid;
+      this.logger.debug(_scope, 'inserted verification', { verificationId });
+
+      return verificationId;
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationData });
+      throw e;
+    }
+  }
+
+
+  verificationRelease(dbCtx, verificationId) {
+    const _scope = _fileScope('verificationRelease');
+    this.logger.debug(_scope, 'called', { verificationId });
+
+    let result;
+    try {
+      result = this.statement.verificationDone.run({ verificationId });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not release verification');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationId });
+      throw e;
+    }
+  }
+
+
+  verificationUpdate(dbCtx, verificationId, data) {
+    const _scope = _fileScope('verificationUpdate');
+    this.logger.debug(_scope, 'called', { verificationId, data });
+
+    const verificationData = {
+      reason: null,
+      verificationId,
+      ...data,
+    };
+
+    let result;
+    try {
+      this._verificationUpdateDataValidate(verificationData);
+      DatabaseSQLite._verificationDataToEngine(verificationData);
+      result = this.statement.verificationUpdate.run(verificationData);
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not update verification');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationData });
+      throw e;
+    }
+  }
+
+
+  verificationValidated(dbCtx, verificationId) {
+    const _scope = _fileScope('verificationValidated');
+    this.logger.debug(_scope, 'called', { verificationId });
+
+    let result;
+    try {
+      result = this.statement.verificationValidate.run({ verificationId });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not set verification validation');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, verificationId });
+      throw e;
+    } 
+  }
+
+
+}
+
+module.exports = DatabaseSQLite;
\ No newline at end of file
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..8d97733
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT *
+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/schema/1.0.0/apply.sql b/src/db/sqlite/sql/schema/1.0.0/apply.sql
new file mode 100644 (file)
index 0000000..5b9423b
--- /dev/null
@@ -0,0 +1,179 @@
+--
+BEGIN;
+
+CREATE TABLE topic (
+       id INTEGER PRIMARY KEY AUTOINCREMENT,
+       created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+       url TEXT NOT NULL UNIQUE,
+       lease_seconds_preferred INTEGER, -- default if client doesn't specify
+       lease_seconds_min INTEGER, -- limit to client requested
+       lease_seconds_max INTEGER, -- limit to client requested
+       publisher_validation_url TEXT,
+       content_hash_algorithm TEXT CHECK (length(content_hash_algorithm) <= 16) NOT NULL DEFAULT 'sha512',
+
+       is_active BOOLEAN NOT NULL DEFAULT 0 CHECK (is_active IN (0, 1)), -- will be active after first successful fetch
+       is_deleted BOOLEAN NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1)), -- topic deletion pending on 'denied' notification to active subscriptions
+
+       last_publish INTEGER NOT NULL DEFAULT 0,
+       content_fetch_next_attempt INTEGER NOT NULL DEFAULT 0, -- time of next content update attempt
+       content_fetch_attempts_since_success INTEGER NOT NULL DEFAULT 0,
+
+       content_updated INTEGER NOT NULL DEFAULT 0,
+       content LONGBLOB,
+       content_hash TEXT,
+       content_type TEXT CHECK (length(content_type) <= 255)
+);
+CREATE INDEX topic_content_updated_idx ON topic(content_updated);
+CREATE INDEX topic_content_fetch_next_attempt_idx ON topic(content_fetch_next_attempt);
+
+CREATE TABLE topic_fetch_in_progress (
+       id INTEGER NOT NULL PRIMARY KEY REFERENCES topic(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+       claimant TEXT NOT NULL,
+       claimed INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+       claim_expires INTEGER NOT NULL
+);
+CREATE INDEX topic_fetch_in_progress_claim_expires_idx ON topic_fetch_in_progress(claim_expires);
+
+CREATE VIEW topic_fetch_in_progress_active AS
+       SELECT *
+       FROM topic_fetch_in_progress
+       WHERE claim_expires >= (strftime('%s', 'now'))
+;
+
+CREATE VIEW topic_fetch_needed AS
+       SELECT *
+       FROM topic
+       WHERE
+               is_deleted = false
+       AND
+               content_fetch_next_attempt <= (strftime('%s', 'now'))
+       AND
+               id NOT IN (SELECT id FROM topic_fetch_in_progress_active)
+       ORDER BY last_publish ASC
+;
+
+--
+
+CREATE TABLE subscription (
+       id INTEGER PRIMARY KEY AUTOINCREMENT,
+       created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
+       topic_id INTEGER NOT NULL REFERENCES topic(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+       callback TEXT NOT NULL,
+       -- Not sure why this does not work inline here; punting to unique index.
+       -- CONSTRAINT subscription_reference UNIQUE (topic_id, callback),
+
+       verified INTEGER NOT NULL DEFAULT 0,
+       expires INTEGER NOT NULL,
+
+       secret TEXT CHECK (length(secret) <= 199),
+       signature_algorithm TEXT DEFAULT 'sha512' CHECK (length(signature_algorithm) <= 16),
+       http_remote_addr TEXT,
+       http_from TEXT,
+
+       content_delivered INTEGER NOT NULL DEFAULT 0,
+       delivery_attempts_since_success INTEGER NOT NULL DEFAULT 0,
+       delivery_next_attempt INTEGER NOT NULL DEFAULT 0
+);
+CREATE UNIQUE INDEX subscription_unique_idx ON subscription(topic_id, callback);
+CREATE INDEX subscription_content_delivered_idx ON subscription(content_delivered);
+CREATE INDEX subscription_expires_idx ON subscription(expires);
+
+CREATE TABLE subscription_delivery_in_progress (
+       id INTEGER NOT NULL PRIMARY KEY REFERENCES subscription(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+       claimant TEXT NOT NULL,
+       claimed INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+       claim_expires INTEGER NOT NULL
+);
+CREATE INDEX subscription_delivery_in_progress_claim_expires_idx ON subscription_delivery_in_progress(claim_expires);
+
+CREATE VIEW subscription_delivery_in_progress_active AS
+       SELECT *
+       FROM subscription_delivery_in_progress
+       WHERE claim_expires >= (strftime('%s', 'now'))
+;
+
+CREATE VIEW subscription_delivery_needed AS
+       SELECT s.*
+       FROM subscription s JOIN topic t ON s.topic_id = t.id
+       WHERE
+               s.expires > (strftime('%s', 'now'))
+       AND
+               s.content_delivered < t.content_updated
+       AND
+               s.delivery_next_attempt < (strftime('%s', 'now'))
+       AND
+               s.id NOT IN (SELECT id FROM subscription_delivery_in_progress_active)
+;
+
+--
+
+CREATE TABLE verification (
+       id INTEGER PRIMARY KEY AUTOINCREMENT,
+       created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
+       topic_id INTEGER NOT NULL REFERENCES topic(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+       callback TEXT NOT NULL, -- hub.callback
+       -- no unique constraint, multiple verifications can be pending
+
+       secret TEXT CHECK(length(secret) <= 199),
+       signature_algorithm TEXT DEFAULT 'sha512' CHECK (length(signature_algorithm) <= 16),
+       http_remote_addr TEXT,
+       http_from TEXT,
+
+       mode TEXT NOT NULL, -- hub.mode
+       reason TEXT, -- denials may have a reason
+       lease_seconds INTEGER NOT NULL, -- 68 years shuold be long enough
+       is_publisher_validated BOOLEAN NOT NULL DEFAULT 0 CHECK(is_publisher_validated IN (0, 1)),
+       request_id TEXT, -- client request which created this verification, null if server-generated (ie topic delete)
+
+       next_attempt INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+       attempts INTEGER NOT NULL DEFAULT 0
+);
+CREATE INDEX verification_reference_idx ON verification(topic_id, callback, created);
+
+CREATE TABLE verification_in_progress (
+       id INTEGER NOT NULL PRIMARY KEY REFERENCES verification(id) ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED,
+
+       topic_id INTEGER NOT NULL REFERENCES topic(id),
+       callback TEXT NOT NULL,
+       -- CONSTRAINT verification_in_progress_reference UNIQUE (topic_id, callback),
+
+       claimant TEXT NOT NULL,
+       claimed INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+       claim_expires INTEGER NOT NULL
+);
+CREATE UNIQUE INDEX verification_in_progress_unique_idx ON verification_in_progress(topic_id, callback);
+CREATE INDEX verification_in_progress_claim_expires_idx ON verification_in_progress(claim_expires);
+
+CREATE VIEW verification_in_progress_active AS
+       SELECT *
+       FROM verification_in_progress
+       WHERE claim_expires >= (strftime('%s', 'now'))
+;
+
+CREATE VIEW verification_needed AS
+       SELECT *
+       FROM verification
+       WHERE
+               (topic_id, callback, created) IN (SELECT topic_id, callback, max(created) AS created FROM verification GROUP BY topic_id, callback)
+       AND
+               (topic_id, callback) NOT IN (SELECT topic_id, callback FROM verification_in_progress_active)
+       AND
+               next_attempt <= (strftime('%s', 'now'))
+;
+
+--
+
+CREATE TABLE authentication (
+       created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+       last_authentication INTEGER,
+       identifier TEXT NOT NULL PRIMARY KEY,
+       credential TEXT
+);
+
+--
+
+INSERT INTO _meta_schema_version (major, minor, patch) VALUES (1, 0, 0);
+
+COMMIT;
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..3c47a8c
--- /dev/null
@@ -0,0 +1,17 @@
+BEGIN;
+       DROP TABLE topic;
+       DROP VIEW topic_fetch_needed;
+       DROP TABLE topic_fetch_in_progress;
+       DROP VIEW topic_fetch_in_progress_active;
+       DROP TABLE subscription;
+       DROP VIEW subscription_delivery_needed;
+       DROP TABLE subscription_delivery_in_progress;
+       DROP VIEW subscription_delivery_in_progress_active;
+       DROP TABLE verification;
+       DROP VIEW verification_needed;
+       DROP TABLE verification_in_progress;
+       DROP VIEW verification_in_progress_active;
+       DROP TABLE authentication;
+
+       DELETE FROM _meta_schema_version WHERE major = 1 AND minor = 0 AND patch = 0;
+COMMIT;
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..2b8cd70
--- /dev/null
@@ -0,0 +1,11 @@
+--
+BEGIN;
+CREATE TABLE _meta_schema_version (
+       major INTEGER NOT NULL,
+       minor INTEGER NOT NULL,
+       patch INTEGER NOT NULL,
+       applied INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+       PRIMARY KEY (major, minor, patch)
+) WITHOUT ROWID;
+INSERT INTO _meta_schema_version (major, minor, patch) VALUES (0, 0, 0);
+COMMIT;
diff --git a/src/db/sqlite/sql/subscription-count-by-topic-url.sql b/src/db/sqlite/sql/subscription-count-by-topic-url.sql
new file mode 100644 (file)
index 0000000..d17c390
--- /dev/null
@@ -0,0 +1,5 @@
+--
+SELECT COUNT(s.id) AS count
+FROM subscription s
+JOIN topic t ON s.topic_id = t.id
+WHERE t.url = :topicUrl AND s.expires > strftime('%s', 'now')
diff --git a/src/db/sqlite/sql/subscription-delete.sql b/src/db/sqlite/sql/subscription-delete.sql
new file mode 100644 (file)
index 0000000..966e04b
--- /dev/null
@@ -0,0 +1,3 @@
+--
+DELETE FROM subscription
+WHERE topic_id = :topicId AND callback = :callback
diff --git a/src/db/sqlite/sql/subscription-delivery-attempts.sql b/src/db/sqlite/sql/subscription-delivery-attempts.sql
new file mode 100644 (file)
index 0000000..875fc96
--- /dev/null
@@ -0,0 +1,5 @@
+--
+SELECT delivery_attempts_since_success
+FROM subscription
+WHERE topic_id = :topicId AND callback = :callback
+
diff --git a/src/db/sqlite/sql/subscription-delivery-claim-by-id.sql b/src/db/sqlite/sql/subscription-delivery-claim-by-id.sql
new file mode 100644 (file)
index 0000000..559fd1a
--- /dev/null
@@ -0,0 +1,8 @@
+--
+INSERT INTO subscription_delivery_in_progress (id, claimant, claimed, claim_expires)
+VALUES (:subscriptionId, :claimant, strftime('%s', 'now'), strftime('%s', 'now') + :claimTimeoutSeconds)
+ON CONFLICT (id) DO UPDATE
+SET
+       claimant = :claimant,
+       claimed = strftime('%s', 'now'),
+       claim_expires = strftime('%s', 'now') + :claimTimeoutSeconds
diff --git a/src/db/sqlite/sql/subscription-delivery-done.sql b/src/db/sqlite/sql/subscription-delivery-done.sql
new file mode 100644 (file)
index 0000000..ee5d0aa
--- /dev/null
@@ -0,0 +1,6 @@
+--
+DELETE FROM subscription_delivery_in_progress
+WHERE id = (
+       SELECT id FROM subscription
+       WHERE topic_id = :topicId AND callback = :callback
+)
diff --git a/src/db/sqlite/sql/subscription-delivery-failure.sql b/src/db/sqlite/sql/subscription-delivery-failure.sql
new file mode 100644 (file)
index 0000000..8753ed9
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE subscription SET
+       delivery_attempts_since_success = delivery_attempts_since_success + 1,
+       delivery_next_attempt = strftime('%s', 'now') + :nextAttemptDelaySeconds
+WHERE
+       topic_id = :topicId AND callback = :callback
diff --git a/src/db/sqlite/sql/subscription-delivery-needed.sql b/src/db/sqlite/sql/subscription-delivery-needed.sql
new file mode 100644 (file)
index 0000000..b7fce3e
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT id
+       FROM subscription_delivery_needed
+       LIMIT :wanted
diff --git a/src/db/sqlite/sql/subscription-delivery-success.sql b/src/db/sqlite/sql/subscription-delivery-success.sql
new file mode 100644 (file)
index 0000000..02b89e8
--- /dev/null
@@ -0,0 +1,7 @@
+--
+UPDATE subscription SET
+       content_delivered = strftime('%s', 'now'),
+       delivery_attempts_since_success = 0,
+       delivery_next_attempt = 0
+WHERE
+       topic_id = :topicId AND callback = :callback
diff --git a/src/db/sqlite/sql/subscription-get-by-id.sql b/src/db/sqlite/sql/subscription-get-by-id.sql
new file mode 100644 (file)
index 0000000..187ccbc
--- /dev/null
@@ -0,0 +1,3 @@
+--
+SELECT * FROM subscription
+WHERE id = :subscriptionId
diff --git a/src/db/sqlite/sql/subscription-get.sql b/src/db/sqlite/sql/subscription-get.sql
new file mode 100644 (file)
index 0000000..5dc3d03
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT * FROM subscription
+WHERE callback = :callback AND topic_id = :topicId
+
diff --git a/src/db/sqlite/sql/subscription-update.sql b/src/db/sqlite/sql/subscription-update.sql
new file mode 100644 (file)
index 0000000..1f7bb5f
--- /dev/null
@@ -0,0 +1,7 @@
+--
+UPDATE subscription
+SET
+       signature_algorithm = :signatureAlgorithm
+WHERE
+       id = :subscriptionId
+
diff --git a/src/db/sqlite/sql/subscription-upsert.sql b/src/db/sqlite/sql/subscription-upsert.sql
new file mode 100644 (file)
index 0000000..569e876
--- /dev/null
@@ -0,0 +1,12 @@
+--
+INSERT INTO subscription
+       (topic_id, callback, verified, expires, secret, http_remote_addr, http_from)
+VALUES
+       (:topicId, :callback, strftime('%s', 'now'), strftime('%s', 'now') + :leaseSeconds, :secret, :httpRemoteAddr, :httpFrom)
+ON CONFLICT (topic_id, callback) DO UPDATE
+SET
+       verified = strftime('%s', 'now'),
+       expires = strftime('%s', 'now') + :leaseSeconds,
+       secret = :secret,
+       http_remote_addr = :httpRemoteAddr,
+       http_from = :httpFrom
diff --git a/src/db/sqlite/sql/subscriptions-by-topic-id.sql b/src/db/sqlite/sql/subscriptions-by-topic-id.sql
new file mode 100644 (file)
index 0000000..e6fdd39
--- /dev/null
@@ -0,0 +1,4 @@
+SELECT *
+FROM subscription
+WHERE topic_id = :topicId
+AND expires > strftime('%s', 'now')
diff --git a/src/db/sqlite/sql/topic-attempts-increment.sql b/src/db/sqlite/sql/topic-attempts-increment.sql
new file mode 100644 (file)
index 0000000..fae888f
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE topic SET
+       content_fetch_attempts_since_success = content_fetch_attempts_since_success + 1,
+       content_fetch_next_attempt = strftime('%s', 'now') + :nextAttemptDelaySeconds
+WHERE
+       id = :topicId
diff --git a/src/db/sqlite/sql/topic-attempts-reset.sql b/src/db/sqlite/sql/topic-attempts-reset.sql
new file mode 100644 (file)
index 0000000..02fec65
--- /dev/null
@@ -0,0 +1,7 @@
+--
+UPDATE topic SET
+       content_fetch_attempts_since_success = 0,
+       content_fetch_next_attempt = :forever
+WHERE
+       id = :topicId
+
diff --git a/src/db/sqlite/sql/topic-attempts.sql b/src/db/sqlite/sql/topic-attempts.sql
new file mode 100644 (file)
index 0000000..de9b8c4
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT content_fetch_attempts_since_success
+FROM topic
+WHERE id = :topicId
diff --git a/src/db/sqlite/sql/topic-content-fetch-claim-by-id.sql b/src/db/sqlite/sql/topic-content-fetch-claim-by-id.sql
new file mode 100644 (file)
index 0000000..8b318ab
--- /dev/null
@@ -0,0 +1,8 @@
+-- claim a specific topic needing the content fetched
+INSERT INTO topic_fetch_in_progress (id, claimant, claimed, claim_expires)
+VALUES (:topicId, :claimant, strftime('%s', 'now'), strftime('%s', 'now') + :claimTimeoutSeconds)
+ON CONFLICT (id) DO UPDATE
+SET
+       claimant = :claimant,
+       claimed = strftime('%s', 'now'),
+       claim_expires = strftime('%s', 'now') + :claimTimeoutSeconds
diff --git a/src/db/sqlite/sql/topic-content-fetch-done.sql b/src/db/sqlite/sql/topic-content-fetch-done.sql
new file mode 100644 (file)
index 0000000..44c0060
--- /dev/null
@@ -0,0 +1,3 @@
+--
+DELETE FROM topic_fetch_in_progress
+WHERE id = :topicId
diff --git a/src/db/sqlite/sql/topic-content-fetch-needed.sql b/src/db/sqlite/sql/topic-content-fetch-needed.sql
new file mode 100644 (file)
index 0000000..e7999d0
--- /dev/null
@@ -0,0 +1,4 @@
+-- claim some topics needing their contents fetched
+SELECT id
+       FROM topic_fetch_needed
+       LIMIT :wanted
diff --git a/src/db/sqlite/sql/topic-content-fetch-requested.sql b/src/db/sqlite/sql/topic-content-fetch-requested.sql
new file mode 100644 (file)
index 0000000..90eac80
--- /dev/null
@@ -0,0 +1,6 @@
+-- update topic that a new publish notification occurred
+UPDATE topic
+SET
+       last_publish = strftime('%s', 'now'),
+       content_fetch_next_attempt = strftime('%s', 'now')
+WHERE id = :topicId
diff --git a/src/db/sqlite/sql/topic-deleted.sql b/src/db/sqlite/sql/topic-deleted.sql
new file mode 100644 (file)
index 0000000..657258c
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE topic SET
+       content_updated = strftime('%s', 'now'),
+       is_deleted = true
+WHERE id = :topicId
+
diff --git a/src/db/sqlite/sql/topic-get-by-id.sql b/src/db/sqlite/sql/topic-get-by-id.sql
new file mode 100644 (file)
index 0000000..86ea7ed
--- /dev/null
@@ -0,0 +1,20 @@
+-- excluding content field
+SELECT
+       id,
+       created,
+       url,
+       lease_seconds_preferred,
+       lease_seconds_min,
+       lease_seconds_max,
+       publisher_validation_url,
+       content_hash_algorithm,
+       is_active,
+       is_deleted,
+       last_publish,
+       content_fetch_next_attempt,
+       content_fetch_attempts_since_success,
+       content_updated,
+       content_hash,
+       content_type
+FROM topic
+WHERE id = :topicId
diff --git a/src/db/sqlite/sql/topic-get-by-url.sql b/src/db/sqlite/sql/topic-get-by-url.sql
new file mode 100644 (file)
index 0000000..d0f5548
--- /dev/null
@@ -0,0 +1,20 @@
+-- excluding content field
+SELECT
+       id,
+       created,
+       url,
+       lease_seconds_preferred,
+       lease_seconds_min,
+       lease_seconds_max,
+       publisher_validation_url,
+       content_hash_algorithm,
+       is_active,
+       is_deleted,
+       last_publish,
+       content_fetch_next_attempt,
+       content_fetch_attempts_since_success,
+       content_updated,
+       content_hash,
+       content_type
+FROM topic
+WHERE url = :topicUrl
diff --git a/src/db/sqlite/sql/topic-get-content-by-id.sql b/src/db/sqlite/sql/topic-get-content-by-id.sql
new file mode 100644 (file)
index 0000000..cc9071c
--- /dev/null
@@ -0,0 +1,4 @@
+-- topic including content
+SELECT *
+FROM topic
+WHERE id = :topicId
diff --git a/src/db/sqlite/sql/topic-get-info-all.sql b/src/db/sqlite/sql/topic-get-info-all.sql
new file mode 100644 (file)
index 0000000..348fc87
--- /dev/null
@@ -0,0 +1,23 @@
+-- meta data with subscriber count
+SELECT
+       t.id,
+       t.created,
+       url,
+       t.lease_seconds_preferred,
+       t.lease_seconds_min,
+       t.lease_seconds_max,
+       t.publisher_validation_url,
+       t.content_hash_algorithm,
+       t.is_active,
+       t.is_deleted,
+       t.last_publish,
+       t.content_fetch_next_attempt,
+       t.content_fetch_attempts_since_success,
+       t.content_updated,
+       t.content_hash,
+       t.content_type,
+       COUNT (s.id) AS subscribers
+FROM topic t
+LEFT JOIN (SELECT id, topic_id FROM subscription WHERE expires > strftime('%s', 'now')) s ON t.id = s.topic_id
+GROUP BY t.id
+ORDER BY subscribers DESC, t.created DESC
diff --git a/src/db/sqlite/sql/topic-set-content.sql b/src/db/sqlite/sql/topic-set-content.sql
new file mode 100644 (file)
index 0000000..222d448
--- /dev/null
@@ -0,0 +1,9 @@
+-- 
+UPDATE topic
+SET
+       is_active = true,
+       content_updated = strftime('%s', 'now'),
+       content = :content,
+       content_hash = :contentHash,
+       content_type = :contentType
+WHERE id = :topicId
diff --git a/src/db/sqlite/sql/topic-update.sql b/src/db/sqlite/sql/topic-update.sql
new file mode 100644 (file)
index 0000000..ad11291
--- /dev/null
@@ -0,0 +1,10 @@
+--
+UPDATE topic
+SET
+       lease_seconds_preferred = :leaseSecondsPreferred,
+       lease_seconds_min = :leaseSecondsMin,
+       lease_seconds_max = :leaseSecondsMax,
+       publisher_validation_url = :publisherValidationUrl,
+       content_hash_algorithm = :contentHashAlgorithm
+WHERE
+       id = :topicId
diff --git a/src/db/sqlite/sql/topic-upsert.sql b/src/db/sqlite/sql/topic-upsert.sql
new file mode 100644 (file)
index 0000000..3596a4d
--- /dev/null
@@ -0,0 +1,17 @@
+--
+INSERT INTO topic
+       (url, lease_seconds_preferred, lease_seconds_min, lease_seconds_max, publisher_validation_url)
+VALUES (
+       :url,
+       :leaseSecondsPreferred,
+       :leaseSecondsMin,
+       :leaseSecondsMax,
+       :publisherValidationUrl
+) ON CONFLICT (url) DO UPDATE
+SET
+       is_deleted = 0,
+       lease_seconds_preferred = :leaseSecondsPreferred,
+       lease_seconds_min = :leaseSecondsMin,
+       lease_seconds_max = :leaseSecondsMax,
+       publisher_validation_url = :publisherValidationUrl
+RETURNING id
\ No newline at end of file
diff --git a/src/db/sqlite/sql/verification-attempts-increment.sql b/src/db/sqlite/sql/verification-attempts-increment.sql
new file mode 100644 (file)
index 0000000..dc83990
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE verification SET
+       next_attempt = strftime('%s', 'now') + :nextAttemptDelaySeconds,
+       attempts = attempts + 1
+WHERE id = :verificationId
+
diff --git a/src/db/sqlite/sql/verification-attempts.sql b/src/db/sqlite/sql/verification-attempts.sql
new file mode 100644 (file)
index 0000000..319403d
--- /dev/null
@@ -0,0 +1,5 @@
+--
+SELECT attempts
+FROM verification
+WHERE id = :verificationId
+
diff --git a/src/db/sqlite/sql/verification-claim-by-id.sql b/src/db/sqlite/sql/verification-claim-by-id.sql
new file mode 100644 (file)
index 0000000..4bdee88
--- /dev/null
@@ -0,0 +1,11 @@
+--
+INSERT INTO verification_in_progress
+       (id, topic_id, callback, claimant, claim_expires)
+SELECT id, topic_id, callback, :claimant AS claimant, strftime('%s', 'now') + :claimTimeoutSeconds AS claim_expires
+       FROM verification
+       WHERE id = :verificationId
+ON CONFLICT (id) DO UPDATE
+SET
+       claimant = :claimant,
+       claimed = strftime('%s', 'now'),
+       claim_expires = strftime('%s', 'now') + :claimTimeoutSeconds
diff --git a/src/db/sqlite/sql/verification-done.sql b/src/db/sqlite/sql/verification-done.sql
new file mode 100644 (file)
index 0000000..c3fadfc
--- /dev/null
@@ -0,0 +1,3 @@
+--
+DELETE FROM verification_in_progress
+WHERE id = :verificationId
diff --git a/src/db/sqlite/sql/verification-get-by-id.sql b/src/db/sqlite/sql/verification-get-by-id.sql
new file mode 100644 (file)
index 0000000..88dd54e
--- /dev/null
@@ -0,0 +1,5 @@
+--
+SELECT *
+FROM verification
+WHERE id = :verificationId
+
diff --git a/src/db/sqlite/sql/verification-insert.sql b/src/db/sqlite/sql/verification-insert.sql
new file mode 100644 (file)
index 0000000..af76cc1
--- /dev/null
@@ -0,0 +1,6 @@
+--
+INSERT INTO verification
+       (topic_id, callback, mode, secret, lease_seconds, http_remote_addr, http_from, is_publisher_validated, request_id)
+VALUES
+       (:topicId, :callback, :mode, :secret, :leaseSeconds, :httpRemoteAddr, :httpFrom, :isPublisherValidated, :requestId)
+RETURNING id
diff --git a/src/db/sqlite/sql/verification-needed.sql b/src/db/sqlite/sql/verification-needed.sql
new file mode 100644 (file)
index 0000000..02bb2db
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT id
+       FROM verification_needed
+       LIMIT :wanted
diff --git a/src/db/sqlite/sql/verification-scrub.sql b/src/db/sqlite/sql/verification-scrub.sql
new file mode 100644 (file)
index 0000000..ce4b479
--- /dev/null
@@ -0,0 +1,8 @@
+--
+DELETE FROM verification
+WHERE
+       topic_id = :topicId
+AND
+       callback = :callback
+AND
+       created <= (SELECT created FROM verification WHERE id = :verificationId)
diff --git a/src/db/sqlite/sql/verification-update.sql b/src/db/sqlite/sql/verification-update.sql
new file mode 100644 (file)
index 0000000..b45711d
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE verification SET
+       mode = :mode,
+       reason = :reason,
+       is_publisher_validated = :isPublisherValidated
+WHERE id = :verificationId
diff --git a/src/db/sqlite/sql/verification-validate.sql b/src/db/sqlite/sql/verification-validate.sql
new file mode 100644 (file)
index 0000000..2e03b6a
--- /dev/null
@@ -0,0 +1,6 @@
+--
+UPDATE verification SET
+       is_publisher_validated = true,
+       attempts = 0,
+       next_attempt = strftime('%s', 'now')
+WHERE id = :verificationId
diff --git a/src/enum.js b/src/enum.js
new file mode 100644 (file)
index 0000000..8752bfb
--- /dev/null
@@ -0,0 +1,38 @@
+'use strict';
+
+const common = require('./common');
+const { Enum: DingusEnum } = require('@squeep/api-dingus');
+
+const Enum = common.mergeDeep(DingusEnum, {
+  Specification: 'W3C.REC-websub-20180123',
+
+  Mode: {
+    Denied: 'denied',
+    Publish: 'publish',
+    Subscribe: 'subscribe',
+    Unsubscribe: 'unsubscribe',
+  },
+  
+  Header: {
+    Authorization: 'Authorization',
+    From: 'From',
+    LastSeen: 'Last-Seen',
+    Link: 'Link',
+    Location: 'Location',
+    Signature: 'Signature',
+    UserAgent: 'User-Agent',
+    WWWAuthenticate: 'WWW-Authenticate',
+    XHubSignature: 'X-Hub-Signature',
+  },
+
+  ContentType: {
+    ApplicationAtom: 'application/atom+xml',
+    ApplicationRDF: 'application/rdf+xml',
+    ApplicationRSS: 'application/rss+xml',
+    ApplicationXML: 'application/xml',
+    ImageSVG: 'image/svg+xml',
+    TextXML: 'text/xml',
+  },
+});
+
+module.exports = common.freezeDeep(Enum);
\ No newline at end of file
diff --git a/src/errors.js b/src/errors.js
new file mode 100644 (file)
index 0000000..63a58c7
--- /dev/null
@@ -0,0 +1,33 @@
+'use strict';
+
+const { Errors } = require('@squeep/api-dingus');
+
+class DatabaseError extends Error {
+  constructor(...args) {
+    super(...args);
+    Error.captureStackTrace(DatabaseError);
+  }
+
+  get name() {
+    /* istanbul ignore next */
+    return this.constructor.name;
+  }
+}
+
+class InternalInconsistencyError extends Error {
+  constructor(...args) {
+    super(...args);
+    Error.captureStackTrace(InternalInconsistencyError);
+  }
+
+  get name() {
+    /* istanbul ignore next */
+    return this.constructor.name;
+  }
+}
+
+module.exports = {
+  ...Errors,
+  DatabaseError,
+  InternalInconsistencyError,
+};
\ No newline at end of file
diff --git a/src/link-helper.js b/src/link-helper.js
new file mode 100644 (file)
index 0000000..428f031
--- /dev/null
@@ -0,0 +1,196 @@
+'use strict';
+
+/**
+ * A utility class for checking link values in a topic's data and metadata.
+ * Used to determine if we are a valid hub for topic.
+ */
+
+const { parse: parseLinkHeader, SyntaxError: ParseSyntaxError } = require('@squeep/web-linking');
+const common = require('./common');
+const Enum = require('./enum');
+const FeedParser = require('feedparser');
+const { Readable } = require('stream');
+const htmlparser2 = require('htmlparser2');
+
+const _fileScope = common.fileScope(__filename);
+
+function getHeader(headers, header) {
+  return headers[header.toLowerCase()];
+}
+
+
+class LinkHelper {
+  constructor(logger, options) {
+    this.logger = logger;
+    this.options = options;
+    this.selfUrl = options.dingus.selfBaseUrl;
+  }
+
+
+  /**
+   * Determine if this hub is listed in response data from url.
+   * @param {String} url
+   * @param {Object} headers
+   * @param {String|Buffer} body
+   * @returns {Boolean}
+   */
+  async validHub(url, headers, body) {
+    const _scope = _fileScope('validHub');
+    this.logger.debug(_scope, 'called', { headers, body: common.logTruncate(body, 100) });
+
+    // Add Link headers first, as they take priority over link elements in body.
+    const linkHeader = getHeader(headers, Enum.Header.Link);
+    const links = [];
+    try {
+      links.push(...parseLinkHeader(linkHeader));
+    } catch (e) {
+      if (e instanceof ParseSyntaxError) {
+        this.logger.debug(_scope, 'failed to parse link header, bad syntax', { error: e, linkHeader });
+      } else {
+        this.logger.error(_scope, 'failed to parse link header', { error: e, linkHeader });
+      }
+    }
+
+    const contentType = getHeader(headers, Enum.Header.ContentType);
+    let bodyLinks = [];
+    switch (contentType) {
+      case Enum.ContentType.ApplicationAtom:
+      case Enum.ContentType.ApplicationRDF:
+      case Enum.ContentType.ApplicationRSS:
+      case Enum.ContentType.ApplicationXML:
+      case Enum.ContentType.TextXML: {
+        bodyLinks = await this.linksFromFeedBody(url, body);
+        break;
+      }
+
+      case Enum.ContentType.TextHTML:
+        bodyLinks = this.linksFromHTMLBody(body);
+        break;
+
+      default:
+        this.logger.debug(_scope, 'no parser for content type', { contentType });
+    }
+    links.push(...bodyLinks);
+
+    // Fetch all hub relation targets from headers, resolving relative URIs.
+    const hubs = LinkHelper.locateHubTargets(links).map((link) => this.absoluteURI(link, url));
+
+    return hubs.includes(this.selfUrl);
+  }
+
+
+  /**
+   * Parse XML-ish feed content, extracting link elements into our own format.
+   * @param {String} feedurl
+   * @param {String} body
+   * @returns {Object[]}
+   */
+  async linksFromFeedBody(feedurl, body) {
+    const _scope = _fileScope('linksFromFeedBody');
+    this.logger.debug(_scope, 'called', { feedurl, body: common.logTruncate(body, 100) });
+
+    const feedParser = new FeedParser({
+      feedurl,
+      addmeta: false,
+    });
+    const bodyStream = Readable.from(body);
+    const links = [];
+
+    return new Promise((resolve) => {
+      feedParser.on('error', (err) => {
+        this.logger.debug(_scope, 'FeedParser error', { err, feedurl, body });
+      });
+      feedParser.on('end', () => {
+        this.logger.debug(_scope, 'FeedParser finished', { links });
+        resolve(links);
+      });
+      feedParser.on('meta', (meta) => {
+        this.logger.debug(_scope, 'FeedParser meta', { meta });
+        const feedLinks = meta['atom:link'] || [];
+        feedLinks
+          .map((l) => l['@'])
+          .forEach((l) => {
+            const link = {
+              target: l.href,
+              attributes: Object.entries(l)
+                .filter(([name]) => name !== 'href')
+                .map(([name, value]) => ({ name, value })),
+            };
+            links.push(link);
+        });
+
+      });
+      feedParser.on('readable', () => {
+        let _item;
+        while ((_item = feedParser.read())) {
+          // Quietly consume remaining stream content
+        }
+      });
+
+      bodyStream.pipe(feedParser);
+    });
+  }
+
+
+  /**
+   * Parse HTML-ish content, extracting link elements into our own format.
+   * @param {String} body
+   */
+  linksFromHTMLBody(body) {
+    const _scope = _fileScope('linksFromHTMLBody');
+    this.logger.debug(_scope, 'called', { body: common.logTruncate(body, 100) });
+
+    const links = [];
+    const parser = new htmlparser2.Parser({
+      onopentag(tagName, attributes) {
+        if (tagName.toLowerCase() === 'link') {
+          const link = {
+            target: attributes.href,
+            attributes: Object.entries(attributes)
+            .filter(([name]) => name !== 'href')
+            .map(([name, value]) => ({ name, value })),
+          };
+          links.push(link);
+        }
+      },
+    });
+    parser.write(body);
+    parser.end();
+    return links;
+  }
+
+  /**
+   * Attempt to resolve a relative target URI
+   * @param {String} uri
+   * @param {String} context
+   * @returns {String}
+   */
+  absoluteURI(uri, context) {
+    const _scope = _fileScope('absoluteURI');
+    try {
+      new URL(uri);
+    } catch (e) {
+      try {
+        uri = new URL(uri, context).href;
+      } catch (e) {
+        this.logger.debug(_scope, 'could not resolve link URI', { uri, context });
+      }
+    }
+    return uri;
+  }
+
+
+  /**
+   * Return all link targets with a hub relation.
+   * @param {Object[]} links
+   * @returns {String[]}
+   */
+  static locateHubTargets(links) {
+    return links
+      .filter((link) => link.attributes.some((attr) => attr.name === 'rel' && ` ${attr.value} `.includes(' hub ')))
+      .map((link) => link.target);
+  }
+
+}
+
+module.exports = LinkHelper;
diff --git a/src/logger.js b/src/logger.js
new file mode 100644 (file)
index 0000000..934c7da
--- /dev/null
@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * Log as JSON to stdout/stderr.
+ */
+
+const common = require('./common');
+
+// This is uncomfortable, but is the simplest way to let logging work for BigInts.
+// TODO: revisit with better solution
+BigInt.prototype.toJSON = function() {
+  return this.toString();
+}
+
+// Also uncomfortable, but let us log Errors reasonably.
+Object.defineProperty(Error.prototype, 'toJSON', {
+  configurable: true,
+  writable: true, // Required to let Axios override on its own Errors
+  value: function () {
+    const result = {};
+    const dupKey = function (key) {
+      // eslint-disable-next-line security/detect-object-injection
+      result[key] = this[key];
+    };
+    Object.getOwnPropertyNames(this)
+      // .filter((prop) => !(prop in []))
+      .forEach(dupKey, this);
+    return result;
+  },
+});
+
+class Logger {
+  /**
+   * Wrap backend calls with payload normalization.
+   * @param {Object} options
+   * @param {*} backend Console style interface
+   * @param {Object} options.logger
+   * @param {String} options.logger.ignoreBelowLevel minimum level to log
+   * @param {String} options.nodeId
+   */
+  constructor(options, backend = console) {
+    const logLevels = Object.keys(common.nullLogger);
+    const ignoreBelowLevel = options && options.logger && options.logger.ignoreBelowLevel || 'debug';
+    this.nodeId = options.nodeId;
+
+    if (!logLevels.includes(ignoreBelowLevel)) {
+      throw new RangeError(`unrecognized minimum log level '${ignoreBelowLevel}'`);
+    }
+    const ignoreLevelIdx = logLevels.indexOf(ignoreBelowLevel);
+    logLevels.forEach((level) => {
+      // eslint-disable-next-line security/detect-object-injection
+      this[level] = (logLevels.indexOf(level) > ignoreLevelIdx) ?
+        common.nop :
+        this.levelTemplateFn(backend, level);
+    });
+  }
+  
+  levelTemplateFn(backend, level) {
+    // eslint-disable-next-line security/detect-object-injection
+    if (!(level in backend) || typeof backend[level] !== 'function') {
+      return common.nop;
+    }
+
+    // eslint-disable-next-line security/detect-object-injection
+    return (...args) => backend[level](this.payload(level, ...args));
+  }
+
+  payload(level, scope, message, data, ...other) {
+    const now = new Date();
+    return JSON.stringify({
+      nodeId: this.nodeId,
+      timestamp: now.toISOString(),
+      timestampMs: now.getTime(),
+      level: level,
+      scope: scope || '[unknown]',
+      message: message || '',
+      data: data || {},
+      ...(other.length && { other }),
+    });
+  }
+}
+
+module.exports = Logger;
diff --git a/src/manager.js b/src/manager.js
new file mode 100644 (file)
index 0000000..b3aff45
--- /dev/null
@@ -0,0 +1,677 @@
+'use strict';
+
+/**
+ * Here we process all the incoming requests.
+ * Payload validation occurs here, before committing the pending work to the
+ * database and (usually) calling a processor to act upon it.
+ *
+ * As this is the mediator between server framework and actions, this would
+ * be where most of a rewrite for a new server framework would happen.
+ */
+
+const common = require('./common');
+const Communication = require('./communication');
+const Enum = require('./enum');
+const Errors = require('./errors');
+const DBErrors = require('./db/errors');
+const { ResponseError } = require('./errors');
+const Template = require('./template');
+
+const _fileScope = common.fileScope(__filename);
+
+class Manager {
+  constructor(logger, db, options) {
+    this.logger = logger;
+    this.db = db;
+    this.options = options;
+    this.communication = new Communication(logger, db, options);
+
+    // Precalculate the invariant root GET metadata.
+    this.getRootContent = Template.rootHTML(undefined, options);
+    const now = new Date();
+    this.startTimeString = now.toGMTString();
+    this.startTimeMs = now.getTime();
+    this.getRootETag = common.generateETag(undefined, undefined, this.getRootContent);
+  }
+
+
+  /**
+   * GET request for healthcheck.
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+   async getHealthcheck(res, ctx) {
+    const _scope = _fileScope('getHealthcheck');
+    const health = 'happy';
+
+    // What else could we check...
+    const dbHealth = await this.db.healthCheck();
+    this.logger.debug(_scope, 'called', { health, dbHealth, ctx });
+    res.end(health);
+  }
+
+
+  /**
+   * GET request for root.
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async getRoot(req, res, ctx) {
+    const _scope = _fileScope('getRoot');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    res.setHeader(Enum.Header.LastModified, this.startTimeString);
+    res.setHeader(Enum.Header.ETag, this.getRootETag);
+
+    if (common.isClientCached(req, this.startTimeMs, this.getRootETag)) {
+      this.logger.debug(_scope, 'client cached response', { ctx });
+      res.statusCode = 304;
+      res.end();
+      return;
+    }
+    res.end(this.getRootContent);
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /** All the fields the root handler deals with.
+   * @typedef {object} RootData
+   * @property {string} callback - url
+   * @property {string} mode
+   * @property {string} topic
+   * @property {number} topicId
+   * @property {string} leaseSeconds
+   * @property {string} secret
+   * @property {string} httpRemoteAddr
+   * @property {string} httpFrom
+   * @property {boolean} isSecure
+   * @property {boolean} isPublisherValidated
+   */
+
+  /**
+   * Extract api parameters.
+   * @param {http.ClientRequest} req 
+   * @param {Object} ctx
+   * @returns {RootData}
+   */
+  static _getRootData(req, ctx) {
+    const postData = ctx.parsedBody;
+    const mode = (postData['hub.mode'] || '').toLowerCase();
+    return {
+      callback: postData['hub.callback'],
+      mode,
+      ...(mode === Enum.Mode.Publish && { url: postData['hub.url'] }), // Publish accepts either hub.url or hub.topic
+      topic: postData['hub.topic'],
+      ...(postData['hub.lease_seconds'] && { leaseSeconds: parseInt(postData['hub.lease_seconds'], 10) }),
+      secret: postData['hub.secret'],
+      httpRemoteAddr: ctx.clientAddress,
+      httpFrom: req.getHeader(Enum.Header.From),
+      isSecure: ((ctx.clientProtocol || '').toLowerCase() === 'https'),
+      isPublisherValidated: true, // Default to true. Will be set to false later, if topic has publisher validation url.
+    };
+  }
+
+
+  /**
+   * 
+   * @param {*} dbCtx
+   * @param {RootData} data
+   * @param {String[]} warn
+   * @param {String[]} err
+   * @param {String} requestId
+   */
+  async _validateRootData(dbCtx, data, warn, err, requestId) {
+    // These checks can modify data, so order matters.
+    await this._checkTopic(dbCtx, data, warn, err, requestId);
+    this._checkCallbackAndSecrets(data, warn, err, requestId);
+    await this._checkMode(dbCtx, data, warn, err, requestId);
+  }
+
+
+  /**
+   * Check that requested topic exists and values are in range.
+   * Sets topic id, publisher validation state, and requested lease
+   * seconds on data.
+   * @param {*} dbCtx
+   * @param {RootData} data
+   * @param {String[]} warn
+   * @param {String[]} err
+   */
+  async _checkTopic(dbCtx, data, warn, err, requestId) {
+    const _scope = _fileScope('_checkTopic');
+    let topic;
+
+    if (data.topic) {
+      topic = await this.db.topicGetByUrl(dbCtx, data.topic);
+
+      if (!topic && this.options.manager.publicHub) {
+        this.logger.info(_scope, 'new topic from subscribe request', { data, requestId });
+
+        try {
+          new URL(data.topic);
+        } catch (e) {
+          err.push('invalid topic url (failed to parse url)');
+          return;
+        }
+
+        await this.db.topicSet(dbCtx, {
+          url: data.topic,
+        });
+        topic = await this.db.topicGetByUrl(dbCtx, data.topic);
+      }
+    }
+
+    if (!topic || topic.isDeleted) {
+      err.push('not a supported topic');
+      return;
+    }
+
+    data.topicId = topic.id;
+
+    if (data.leaseSeconds === undefined || isNaN(data.leaseSeconds)) {
+      data.leaseSeconds = topic.leaseSecondsPreferred;
+    } else {
+      if (data.leaseSeconds > topic.leaseSecondsMax) {
+        data.leaseSeconds = topic.leaseSecondsMax;
+        warn.push(`requested lease too long, using ${data.leaseSeconds}`);
+      } else if (data.leaseSeconds < topic.leaseSecondsMin) {
+        data.leaseSeconds = topic.leaseSecondsMin;
+        warn.push(`requested lease too short, using ${data.leaseSeconds}`);
+      }
+    }
+
+    if (topic.publisherValidationUrl) {
+      data.isPublisherValidated = false;
+    }
+  }
+
+
+  /**
+   * Check data for valid callback url and scheme constraints.
+   * @param {RootData} data
+   * @param {String[]} warn
+   * @param {String[]} err
+   */
+  _checkCallbackAndSecrets(data, warn, err) {
+    let isCallbackSecure = false;
+
+    if (!data.callback) {
+      err.push('invalid callback url (empty)');
+    } else {
+      try {
+        const c = new URL(data.callback);
+        isCallbackSecure = (c.protocol.toLowerCase() === 'https:'); // Colon included because url module is weird
+      } catch (e) {
+        err.push('invalid callback url (failed to parse url');
+        return;
+      }
+    }
+
+    if (!isCallbackSecure) {
+      warn.push('insecure callback');
+    }
+
+    if (data.secret) {
+      const secretSeverity = this.options.manager.strictSecrets ? err : warn;
+      if (!data.isSecure) {
+        secretSeverity.push('secret not safe (insecure hub)');
+      }
+      if (!isCallbackSecure) {
+        secretSeverity.push('secret not safe (insecure callback)');
+      }
+      if (data.secret.length > 199) {
+        err.push('cannot keep a secret that big');
+      }
+    }
+  }
+
+  /**
+   * Check mode validity and subscription requirements.
+   * Publish mode is handled elsewhere in the flow.
+   * @param {*} dbCtx
+   * @param {RootData} data
+   * @param {String[]} warn
+   * @param {String[]} err
+   * @param {String} requestId
+   */
+  async _checkMode(dbCtx, data, warn, err) {
+    switch (data.mode) {
+      case Enum.Mode.Subscribe:
+        break;
+
+      case Enum.Mode.Unsubscribe: {
+        const currentEpoch = Date.now() / 1000;
+        let s;
+        if (data.callback && data.topicId) {
+          s = await this.db.subscriptionGet(dbCtx, data.callback, data.topicId);
+        }
+        if (s === undefined) {
+          err.push('not subscribed');
+        } else {
+          if (s.expires < currentEpoch) {
+            err.push('subscription already expired');
+          }
+        }
+        break;
+      }
+
+      default: {
+        err.push('invalid mode');
+      }
+    }
+  }
+
+
+  /**
+   * Check that a publish request topic is valid and exists,
+   * and if it is, add topicId to data.
+   * For a public publish request, create topic if not exists.
+   * @param {*} dbCtx
+   * @param {RootData} data
+   * @param {String[]} warn
+   * @param {String[]} err
+   * @param {String} requestId
+   */
+  async _checkPublish(dbCtx, data, warn, err, requestId) {
+    const _scope = _fileScope('_checkPublish');
+
+    const publishUrl = data.url || data.topic;
+
+    let topic = await this.db.topicGetByUrl(dbCtx, publishUrl);
+    if (!topic && this.options.manager.publicHub) {
+      this.logger.info(_scope, 'new topic from publish request', { data, requestId });
+
+      try {
+        new URL(publishUrl);
+      } catch (e) {
+        err.push('invalid topic url (failed to parse url)');
+        return;
+      }
+
+      await this.db.topicSet(dbCtx, {
+        url: publishUrl,
+      });
+      topic = await this.db.topicGetByUrl(dbCtx, publishUrl);
+    }
+
+    if (!topic || topic.isDeleted) {
+      err.push('not a supported topic');
+      return;
+    }
+
+    data.topicId = topic.id;
+  }
+
+
+  /**
+   * POST request for root.
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async postRoot(req, res, ctx) {
+    const _scope = _fileScope('postRoot');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    res.statusCode = 202; // Presume success.
+
+    const warn = [];
+    const err = [];
+    const data = Manager._getRootData(req, ctx);
+    const requestId = ctx.requestId;
+
+    await this.db.context(async (dbCtx) => {
+
+      if (data.mode === Enum.Mode.Publish) {
+        await this._checkPublish(dbCtx, data, warn, err, requestId);
+      } else {
+        await this._validateRootData(dbCtx, data, warn, err, requestId);
+      }
+
+      const prettyErr = err.map((entry) => `error: ${entry}`);
+      const prettyWarn = warn.map((entry) => `warning: ${entry}`);
+      const details = prettyErr.concat(prettyWarn);
+
+      // Any errors are fatal.  Stop and report anything that went wrong.
+      if (err.length) {
+        this.logger.debug(_scope, { msg: 'invalid request', data, err, warn, requestId });
+        throw new ResponseError(Enum.ErrorResponse.BadRequest, details);
+      }
+
+      // Commit the request for later processing.
+      let fn, info, id;
+      try {
+        if (data.mode === Enum.Mode.Publish) {
+          fn = 'topicPublish';
+          info = await this.db.topicFetchRequested(dbCtx, data.topicId);
+          id = data.topicId;
+        } else {
+          fn = 'verificationInsert';
+          id = await this.db.verificationInsert(dbCtx, { ...data, requestId });
+        }
+      } catch (e) {
+        this.logger.error(_scope, `${fn} failed`, { e, info, data, warn, id, requestId });
+        throw e;
+      }
+
+      // If we committed to the db, we've succeeded as far as the client is concerned.
+      res.end(details.join('\n'));
+      this.logger.info(_scope, 'request accepted', { data, warn, requestId });
+
+      // Immediately attempt to claim and process the request.
+      if (this.options.manager.processImmediately
+      &&  id) {
+        try {
+          if (data.mode === Enum.Mode.Publish) {
+            fn = 'topicFetchClaimAndProcessById';
+            await this.communication.topicFetchClaimAndProcessById(dbCtx, id, requestId);
+          } else {
+            fn = 'processVerification';
+            await this.communication.verificationClaimAndProcessById(dbCtx, id, requestId);
+          }
+        } catch (e) {
+          this.logger.error(_scope, `${fn} failed`, { ...data, id, requestId });
+          // Don't bother re-throwing, as we've already ended this response.
+        }
+      }
+    }); // dbCtx
+  }
+
+
+  /**
+   * Render topic info content.
+   * @param {Object} ctx
+   * @param {String} ctx.responseType
+   * @param {String} ctx.topicUrl
+   * @param {Number} ctx.count
+   * @returns {String}
+   */
+  // eslint-disable-next-line class-methods-use-this
+  infoContent(ctx) {
+    // eslint-disable-next-line sonarjs/no-small-switch
+    switch (ctx.responseType) {
+      case Enum.ContentType.ApplicationJson:
+        return JSON.stringify({
+          topic: ctx.topicUrl,
+          count: ctx.count,
+        });
+
+      case Enum.ContentType.ImageSVG:
+        return Template.badgeSVG({}, ` ${ctx.topicUrl} `, ` ${ctx.count} subscribers `, `${ctx.topicUrl} has ${ctx.count} subscribers.`);
+
+      default:
+        return ctx.count.toString();
+    }
+  }
+
+
+  /**
+   * GET request for /info?topic=url&format=type
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async getInfo(res, ctx) {
+    const _scope = _fileScope('getInfo');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    if (!ctx.queryParams.topic) {
+      throw new ResponseError(Enum.ErrorResponse.BadRequest, 'missing required parameter');
+    }
+    ctx.topicUrl = ctx.queryParams.topic;
+
+    switch ((ctx.queryParams.format || '').toLowerCase()) {
+      case 'svg':
+        ctx.responseType = Enum.ContentType.ImageSVG;
+        res.setHeader(Enum.Header.ContentType, ctx.responseType);
+        break;
+
+      case 'json':
+        ctx.responseType = Enum.ContentType.ApplicationJson;
+        res.setHeader(Enum.Header.ContentType, ctx.responseType);
+        break;
+
+      default:
+        break;
+    }
+
+    try {
+      new URL(ctx.topicUrl);
+    } catch (e) {
+      throw new ResponseError(Enum.ErrorResponse.BadRequest, 'invalid topic');
+    }
+
+    let count;
+    await this.db.context(async (dbCtx) => {
+      count = await this.db.subscriptionCountByTopicUrl(dbCtx, ctx.topicUrl);
+      if (!count) {
+        throw new ResponseError(Enum.ErrorResponse.NotFound, 'no such topic');
+      }
+      ctx.count = count.count;
+    });
+
+    res.end(this.infoContent(ctx));
+    this.logger.info(_scope, 'finished', { ...ctx });
+  }
+
+
+  /**
+   * GET request for authorized /admin information.
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async getAdminOverview(res, ctx) {
+    const _scope = _fileScope('getAdminOverview');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    await this.db.context(async (dbCtx) => {
+      ctx.topics = await this.db.topicGetAll(dbCtx);
+    });
+    this.logger.debug(_scope, 'got topics', { topics: ctx.topics });
+
+    res.end(Template.adminOverviewHTML(ctx, this.options));
+    this.logger.info(_scope, 'finished', { ...ctx, topics: ctx.topics.length })
+  }
+
+
+  /**
+   * GET request for authorized /admin/topic/:topicId information.
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async getTopicDetails(res, ctx) {
+    const _scope = _fileScope('getTopicDetails');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const topicId = ctx.params.topicId;
+    await this.db.context(async (dbCtx) => {
+      ctx.topic = await this.db.topicGetById(dbCtx, topicId);
+      ctx.subscriptions = await this.db.subscriptionsByTopicId(dbCtx, topicId);
+    });
+    this.logger.debug(_scope, 'got topic details', { topic: ctx.topic, subscriptions: ctx.subscriptions });
+
+    res.end(Template.adminTopicDetailsHTML(ctx, this.options));
+    this.logger.info(_scope, 'finished', { ...ctx, subscriptions: ctx.subscriptions.length, topic: ctx.topic.id });
+  }
+
+
+  /**
+   * PATCH and DELETE for updating topic data.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async updateTopic(res, ctx) {
+    const _scope = _fileScope('updateTopic');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const topicId = ctx.params.topicId;
+
+    await this.db.context(async (dbCtx) => {
+      await this.db.transaction(dbCtx, async (txCtx) => {
+        // Get topic without defaults filled in, to persist nulls
+        const topic = await this.db.topicGetById(txCtx, topicId, false);
+        if (!topic) {
+          this.logger.debug(_scope, 'no topic', { ctx });
+          throw new Errors.ResponseError(Enum.ErrorResponse.NotFound);
+        }
+
+        if (ctx.method === 'DELETE') {
+          await this.db.topicDeleted(txCtx, topicId);
+          res.end();
+          this.logger.info(_scope, 'topic set deleted', { ctx, topicId });
+          return;
+        }
+
+        const updatableFields = [
+          'leaseSecondsPreferred',
+          'leaseSecondsMin',
+          'leaseSecondsMax',
+          'publisherValidationUrl',
+          'contentHashAlgorithm',
+        ];
+    
+        const patchValues = common.pick({
+          ...ctx.queryParams,
+          ...ctx.parsedBody,
+        }, updatableFields);
+
+        [
+          'leaseSecondsPreferred',
+          'leaseSecondsMin',
+          'leaseSecondsMax',
+        ].filter((field) => field in patchValues).forEach((field) => {
+          // eslint-disable-next-line security/detect-object-injection
+          patchValues[field] = parseInt(patchValues[field], 10);
+        });
+
+        const patchKeys = Object.keys(patchValues);
+        if (patchKeys.length === 0
+        // eslint-disable-next-line security/detect-object-injection
+        ||  patchKeys.every((k) => patchValues[k] == topic[k])) {
+          res.statusCode = 204;
+          res.end();
+          this.logger.info(_scope, 'empty topic update', { ctx, topicId });
+          return;
+        }
+        const patchedTopic = {
+          ...topic,
+          ...patchValues,
+        };
+
+        this.logger.debug(_scope, 'data', { topic, patchValues, patchedTopic });
+
+        try {
+          await this.db.topicUpdate(txCtx, { topicId, ...patchedTopic });
+        } catch (e) {
+          if (e instanceof DBErrors.DataValidation) {
+            this.logger.debug(_scope, 'validation error', { error: e, ctx, topicId });
+            throw new Errors.ResponseError(Enum.ErrorResponse.BadRequest, e.message);
+          }
+          this.logger.error(_scope, 'failed', { error: e, ctx, topicId });
+          throw e;
+        }
+        res.end();
+        this.logger.info(_scope, 'topic updated', { ctx, topicId, patchValues });
+      }); // transaction
+    }); // context
+  }
+
+
+  /**
+   * PATCH and DELETE for updating subscription data.
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async updateSubscription(res, ctx) {
+    const _scope = _fileScope('updateSubscription');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    const subscriptionId = ctx.params.subscriptionId;
+
+    await this.db.context(async (dbCtx) => {
+      await this.db.transaction(dbCtx, async (txCtx) => {
+        const subscription = await this.db.subscriptionGetById(txCtx, subscriptionId);
+        if (!subscription) {
+          this.logger.debug(_scope, 'no subscription', { ctx });
+          throw new Errors.ResponseError(Enum.ErrorResponse.NotFound);
+        }
+
+        if (ctx.method === 'DELETE') {
+          const deleteFields = common.pick({
+            ...ctx.queryParams,
+            ...ctx.parsedBody,
+          }, ['reason']);
+
+          // Queue an unsubscription.
+          const verification = {
+            topicId: subscription.topicId,
+            callback: subscription.callback,
+            mode: Enum.Mode.Denied,
+            reason: 'subscription removed by administrative action',
+            isPublisherValidated: true,
+            requestId: ctx.requestId,
+            ...deleteFields,
+          };
+
+          await this.db.verificationInsert(txCtx, verification);
+          this.logger.info(_scope, 'subscription removal initiated', { ctx, verification });
+          res.end();
+          return;
+        }
+
+        const updatableFields = [
+          'signatureAlgorithm',
+        ];
+
+        const patchValues = common.pick({
+          ...ctx.queryParams,
+          ...ctx.parsedBody,
+        }, updatableFields);
+
+        const patchKeys = Object.keys(patchValues);
+        if (patchKeys.length === 0
+        // eslint-disable-next-line security/detect-object-injection
+        ||  patchKeys.every((k) => patchValues[k] == subscription[k])) {
+          res.statusCode = 204;
+          res.end();
+          return;
+        }
+        const patchedSubscription = {
+          ...subscription,
+          ...patchValues,
+        };
+
+        try {
+          await this.db.subscriptionUpdate(txCtx, { subscriptionId, ...patchedSubscription });
+        } catch (e) {
+          if (e instanceof DBErrors.DataValidation) {
+            this.logger.debug(_scope, 'validation error', { error: e, ctx, subscriptionId });
+            throw new Errors.ResponseError(Enum.ErrorResponse.BadRequest, e.message);
+          }
+          this.logger.info(_scope, 'failed', { error: e, ctx, subscriptionId });
+          throw e;
+        }
+        res.end();
+        this.logger.info(_scope, 'subscription updated', { ctx, subscriptionId, patchValues });
+      }); // transaction
+    }); // context
+  }
+
+  /**
+   * POST request for manually running worker.
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async processTasks(res, ctx) {
+    const _scope = _fileScope('getTopicDetails');
+    this.logger.debug(_scope, 'called', { ctx });
+
+    // N.B. no await on this
+    this.communication.worker.process();
+
+    res.end();
+    this.logger.info(_scope, 'invoked worker process', { ctx });
+  }
+
+}
+
+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..dfba6b8
--- /dev/null
@@ -0,0 +1,278 @@
+'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 { Dingus } = require('@squeep/api-dingus');
+const common = require('./common');
+const Enum = require('./enum');
+const Manager = require('./manager');
+const Authenticator = require('./authenticator');
+const path = require('path');
+
+const _fileScope = common.fileScope(__filename);
+
+class Service extends Dingus {
+  constructor(logger, db, options) {
+    super(logger, {
+      ...options.dingus,
+      ignoreTrailingSlash: false,
+    });
+
+    this.manager = new Manager(logger, db, options);
+    this.authenticator = new Authenticator(logger, db, options);
+    this.staticPath = path.join(__dirname, '..', 'static');
+
+    // Primary API endpoint
+    this.on('POST', '/', this.handlerPostRoot.bind(this));
+
+    // Information page about service
+    this.on(['GET', 'HEAD'], '/', this.handlerGetRoot.bind(this));
+
+    // Give load-balancers something to check
+    this.on(['GET', 'HEAD'], '/healthcheck', this.handlerGetHealthcheck.bind(this));
+
+    // Public information about topics
+    this.on('GET', '/info', this.handlerGetInfo.bind(this));
+    this.on('GET', '/info/', this.handlerGetInfo.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', (req, res, ctx) => this.handlerRedirect(req, res, ctx, `${options.dingus.proxyPrefix}/static/`));
+    this.on(['GET', 'HEAD'], '/static/', (req, res, ctx) => this.handlerGetStaticFile(req, res, ctx, 'index.html'));
+    this.on(['GET', 'HEAD'], '/static/:file', this.handlerGetStaticFile.bind(this));
+    this.on(['GET', 'HEAD'], '/favicon.ico', (req, res, ctx) => this.handlerGetStaticFile(req, res, ctx, 'favicon.ico'));
+    this.on(['GET', 'HEAD'], '/robots.txt', (req, res, ctx) => this.handlerGetStaticFile(req, res, ctx, 'robots.txt'));
+
+    // Private informational endpoints
+    this.on(['GET', 'HEAD'], '/admin', (req, res, ctx) => this.handlerRedirect(req, res, ctx, `${options.dingus.proxyPrefix}/admin/`));
+    this.on(['GET', 'HEAD'], '/admin/', this.handlerGetAdminOverview.bind(this));
+    this.on(['GET', 'HEAD'], '/admin/topic/:topicId', this.handlerGetAdminTopicDetails.bind(this));
+
+    // Private data-editing endpoints
+    this.on(['PATCH', 'DELETE'], '/admin/topic/:topicId', this.handlerUpdateTopic.bind(this));
+    this.on(['PATCH', 'DELETE'], '/admin/subscription/:subscriptionId', this.handlerUpdateSubscription.bind(this));
+
+    // Private server-action endpoints
+    this.on('POST', '/admin/process', this.handlerPostAdminProcess.bind(this));
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {Object} ctx 
+   * @param {String} newPath
+  */
+   async handlerRedirect(req, res, ctx, newPath) {
+    const _scope = _fileScope('handlerRedirect');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    res.setHeader(Enum.Header.Location, newPath);
+    res.statusCode = 307; // Temporary Redirect
+    res.end();
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  async handlerPostRoot(req, res, ctx) {
+    const _scope = _fileScope('handlerPostRoot');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    await this.ingestBody(req, res, ctx);
+
+    await this.manager.postRoot(req, res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} 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: common.requestLogData(req), ctx });
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.manager.getRoot(req, res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  async handlerGetHealthcheck(req, res, ctx) {
+    const _scope = _fileScope('handlerGetHealthcheck');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+  
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.manager.getHealthcheck(res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerGetInfo(req, res, ctx) {
+    const _scope = _fileScope('handlerGetInfo');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    const responseTypes = [...this.responseTypes, Enum.ContentType.ImageSVG];
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(responseTypes, req, res, ctx);
+
+    await this.manager.getInfo(res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerGetAdminOverview(req, res, ctx) {
+    const _scope = _fileScope('handlerGetAdminOverview');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.authenticator.required(req, res, ctx);
+
+    await this.manager.getAdminOverview(res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerGetAdminTopicDetails(req, res, ctx) {
+    const _scope = _fileScope('handlerGetAdminTopicDetails');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.authenticator.required(req, res, ctx);
+
+    await this.manager.getTopicDetails(res, ctx);
+  }
+
+  /**
+   * Same as super.ingestBody, but if no body was send, do not parse (and
+   * thus avoid possible unsupported media type error).
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async maybeIngestBody(req, res, ctx) {
+    ctx.rawBody = await this.bodyData(req);
+    const contentType = Dingus.getRequestContentType(req);
+    if (ctx.rawBody) {
+      this.parseBody(contentType, ctx);
+    }
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+   async handlerUpdateTopic(req, res, ctx) {
+    const _scope = _fileScope('handlerUpdateTopic');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.authenticator.required(req, res, ctx);
+
+    await this.maybeIngestBody(req, res, ctx);
+    ctx.method = req.method;
+    await this.manager.updateTopic(res, ctx);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {Object} ctx
+   */
+  async handlerUpdateSubscription(req, res, ctx) {
+  const _scope = _fileScope('handlerUpdateSubscription');
+  this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+  this.setResponseType(this.responseTypes, req, res, ctx);
+
+  await this.authenticator.required(req, res, ctx);
+
+  await this.maybeIngestBody(req, res, ctx);
+  ctx.method = req.method;
+  await this.manager.updateSubscription(res, ctx);
+}
+  
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerGetStaticFile(req, res, ctx, file) {
+    const _scope = _fileScope('handlerGetStaticFile');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx, file });
+
+    Dingus.setHeadHandler(req, res, ctx);
+
+    // Set a default response type to handle any errors; will be re-set to serve actual static content type.
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.serveFile(req, res, ctx, this.staticPath, file || ctx.params.file);
+    this.logger.info(_scope, 'finished', { ctx });
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerPostAdminProcess(req, res, ctx) {
+    const _scope = _fileScope('handlerPostAdminProcess');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    this.setResponseType(this.responseTypes, req, res, ctx);
+
+    await this.authenticator.required(req, res, ctx);
+
+    await this.manager.processTasks(res, ctx);
+  }
+}
+
+module.exports = Service;
diff --git a/src/template/admin-overview-html.js b/src/template/admin-overview-html.js
new file mode 100644 (file)
index 0000000..b86f7af
--- /dev/null
@@ -0,0 +1,34 @@
+'use strict';
+
+const th = require('./template-helper');
+
+/**
+ * Show a summary of all topics.
+ * @param {Object} ctx
+ * @param {Object[]} ctx.topics
+ * @param {Object} options
+ * @param {Object} options.manager
+ * @param {String} options.manager.pageTitle
+ * @returns {String}
+ */
+module.exports = (ctx, options) => {
+  const pageTitle = `${options.manager.pageTitle} - Topics`;
+  const headElements = [];
+  const navLinks = [];
+  if (!ctx.topics) {
+    ctx.topics = [];
+  }
+  return th.htmlTemplate(1, pageTitle, headElements, navLinks, [
+    `      <section class="topics">
+        <p>${ctx.topics.length ? ctx.topics.length : 'no'} topic${(ctx.topics.length === 1) ? '' : 's'}</p>
+        <table>
+          <thead>`,
+    th.renderTopicRowHeader(),
+    `          </thead>
+        <tbody>`,
+    ...(ctx.topics && ctx.topics.map((topic) => th.renderTopicRow(topic, { length: topic.subscribers }))),
+    `        </tbody>
+        </table>
+      </section>`,
+  ]);
+};
\ No newline at end of file
diff --git a/src/template/admin-topic-details-html.js b/src/template/admin-topic-details-html.js
new file mode 100644 (file)
index 0000000..448de09
--- /dev/null
@@ -0,0 +1,50 @@
+'use strict';
+
+const th = require('./template-helper');
+
+/**
+ * Show a topic with all of its subscribers.
+ * @param {Object} ctx
+ * @param {Object} ctx.topic
+ * @param {Object[]} ctx.subscriptions
+ * @param {Object} options
+ * @param {Object} options.manager
+ * @param {String} options.manager.pageTitle
+ * @returns {String}
+ */
+module.exports = (ctx, options) => {
+  const pageTitle = `${options.manager.pageTitle} - Topic Details`;
+  const headElements = [];
+  const navLinks = [
+    {
+      href: '..',
+      text: '&uarr; All Topics',
+    },
+  ];
+  if (!ctx.subscriptions) {
+    ctx.subscriptions = [];
+  }
+  return th.htmlTemplate(2, pageTitle, headElements, navLinks, [
+    `      <section class="topics">
+        <table>
+          <thead>`,
+    th.renderTopicRowHeader(),
+    `          </thead>
+        <tbody>`,
+    ...(ctx.topic && [ th.renderTopicRow(ctx.topic, ctx.subscriptions, false) ]),
+    `        </tbody>
+        </table>
+      </section>`,
+    `      <section class="subscriptions">
+        <p>${ctx.subscriptions.length ? ctx.subscriptions.length : 'no'} subscription${(ctx.subscriptions.length === 1) ? '' : 's'}</p>
+        <table>
+          <thead>`,
+    th.renderSubscriptionRowHeader(),
+    `          </thead>
+          <tbody>`,
+    ...(ctx.subscriptions && ctx.subscriptions.map(th.renderSubscriptionRow)),
+    `          </tbody>
+        </table>
+      </section>`,
+  ]);
+};
\ No newline at end of file
diff --git a/src/template/badge-svg.js b/src/template/badge-svg.js
new file mode 100644 (file)
index 0000000..681e072
--- /dev/null
@@ -0,0 +1,81 @@
+'use strict';
+
+function escapeXml(s) {
+  if (typeof s === 'number') {
+    return s;
+  } else if (typeof s !== 'string') {
+    return undefined;
+  } else {
+    return s
+      .replace(/&/g, '&amp;')
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;')
+      .replace(/'/g, '&apos;');
+  }
+}
+
+
+const ctxDefaults = {
+  charWidth: 7.2,
+  height: 20,
+  labelColor: '#444',
+  messageColor: '#f73',
+  color: '#fff',
+  fontFamily: 'DejaVu Sans,Verdana,Geneva,sans-serif',
+};
+
+
+function fixedRound(n, p = 2) {
+  return Number(n.toFixed(p));
+}
+
+
+/**
+ * image/svg+xml;charset=utf-8 formatted badge with subscriber count for a topic
+ * @param {Object} ctx - badge-specific context (not request context)
+ * @param {String} label
+ * @param {String} message
+ * @param {String} accessibleText
+ * @returns {String}
+ */
+module.exports = (ctx, label, message, accessibleText) => {
+
+  ctx = Object.assign({}, ctxDefaults, ctx, {
+    label,
+    message,
+    accessibleText,
+  });
+  ctx.verticalMargin = fixedRound(ctx.height * 0.69);
+  ctx.labelWidth = fixedRound(ctx.label.length * ctx.charWidth);
+  ctx.messageWidth = fixedRound(ctx.message.length * ctx.charWidth);
+  ctx.width = ctx.labelWidth + ctx.messageWidth;
+  ctx.halfCharWidth = fixedRound(ctx.charWidth * 0.5);
+
+  /* 
+   * This SVG content mostly replicates the output of the 'Plastic' badge
+   * renderer from https://github.com/badges/shields/tree/master/badge-maker which
+   * is under the http://creativecommons.org/publicdomain/zero/1.0/ license.
+   */
+  return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${ctx.width}" height="${ctx.height}" role="img" aria-label="${escapeXml(ctx.accessibleText)}">
+  <title>${escapeXml(ctx.accessibleText)}</title>
+  <linearGradient id="s" x2="0" y2="100%">
+    <stop offset="0"  stop-color="#fff" stop-opacity=".7"/>
+    <stop offset=".1" stop-color="#aaa" stop-opacity=".1"/>
+    <stop offset=".9" stop-color="#000" stop-opacity=".3"/>
+    <stop offset="1"  stop-color="#000" stop-opacity=".5"/>
+  </linearGradient>
+  <clipPath id="r">
+    <rect width="${ctx.width}" height="${ctx.height}" rx="4" fill="#fff"/>
+  </clipPath>
+  <g clip-path="url(#r)">
+    <rect width="${ctx.labelWidth}" height="${ctx.height}" fill="${ctx.labelColor}"/>
+    <rect x="${ctx.labelWidth}" width="${ctx.messageWidth}" height="${ctx.height}" fill="${ctx.messageColor}"/>
+    <rect width="${ctx.width}" height="${ctx.height}" fill="url(#s)"/>
+  </g>
+  <g fill="${ctx.color}" text-anchor="left" font-family="${ctx.fontFamily}" text-rendering="geometricPrecision" font-size="11" font-weight="bold">
+    <text x="${ctx.halfCharWidth}" y="${ctx.verticalMargin}">${escapeXml(ctx.label)}</text>
+    <text x="${fixedRound(ctx.halfCharWidth + ctx.labelWidth)}" y="${ctx.verticalMargin}">${escapeXml(ctx.message)}</text>
+  </g>
+</svg>`;
+};
diff --git a/src/template/index.js b/src/template/index.js
new file mode 100644 (file)
index 0000000..e177788
--- /dev/null
@@ -0,0 +1,8 @@
+'use strict';
+
+module.exports = {
+  adminOverviewHTML: require('./admin-overview-html'),
+  adminTopicDetailsHTML: require('./admin-topic-details-html'),
+  badgeSVG: require('./badge-svg'),
+  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..97a1ad9
--- /dev/null
@@ -0,0 +1,131 @@
+'use strict';
+
+const th = require('./template-helper');
+
+function aboutSection() {
+  return `      <section class="about">
+        <h2>What</h2>
+        <p>
+          This is a <a class="external" href="https://www.w3.org/TR/websub/">WebSub</a> Hub service.
+        </p>
+        <p>
+          It facilitates the timely distribution of new content from publishers to subscribers.  
+        </p>
+        <aside>
+          The typical use-case is where the content is a blog or news feed, but any type of content may be syndicated.
+        </aside>
+      </section>`;
+}
+
+function usageSection(isPublicHub, hubURL) {
+  const usageContent = isPublicHub ? `      <h2>Public Hub</h2>
+      <p>
+        This hub is available as a public resource; any topic which lists it as a hub can be syndicated.
+      </p>
+      <p>
+        To use this hub, your content needs to include some Link relations.
+      </p>
+      <div>
+        <h3>For Any Content</h3>
+        <ul>
+          <li>
+            The content must be served with a <code>Link</code> HTTP header indicating this service as the <code>hub</code> relation.
+            <figure>
+              <figcaption>Example:</figcaption>
+              <code>
+                Link: &lt;${hubURL}&gt;; rel="hub"
+              </code>
+            </figure>
+          </li>
+          <li>
+            The content must be served with a <code>Link</code> HTTP header indicating its own URL with the <code>self</code> relation.
+            <figure>
+              <figcaption>Example:</figcaption>
+              <code>
+                Link: &lt;https://example.com/feed/&gt;; rel="self"
+              </code>
+            </figure>
+          </li>
+        </ul>
+      </div>
+      <div>
+        <h3>For Atom or RSS feeds</h3>
+        <ul>
+          <li>
+            The feed must include a <code>link</code> element within the <code>http://www.w3.org/2005/Atom</code> namespace with the <code>hub</code> relation and this service as the <code>href</code> attribute.
+            <figure>
+              <figcaption>Example:</figcaption>
+              <code>
+                &lt;link xmlns="http://www.w3.org/2005/Atom" href="${hubURL}" rel="hub"&gt;
+              </code>
+            </figure>
+          </li>
+          <li>
+            The feed must include a <code>link</code> element within the <code>http://www.w3.org/2005/Atom</code> namespace with the <code>self</code> relation, its own URL as the <code>href</code> attribute, and its content-type as the <code>type</code> attribute.
+            <figure>
+              <figcaption>Example:</figcaption>
+              <code>
+                &lt;link xmlns="http://www.w3.org/2005/Atom" href="https://example.com/blog/feed" rel="self" type="application/atom+xml"&gt;
+              </code>
+            </figure>
+          </li>
+        <ul>
+      </div>
+      <div>
+        <h3>Publishing Updates</h3>
+        Send a <code>POST</code> request to this hub with Form Data:
+        <ul>
+          <li>
+            <code>hub.mode</code> set to <code>publish</code>
+          </li>
+          <li>
+            <code>hub.url</code> set to the <code>self</code> link relation of the content
+          </li>
+        </ul>
+      </div>`
+  : `
+      <h2>Private Hub</h2>
+      <p>
+        This hub only serves specific topics.
+      </p>`;
+  return `
+      <section class="usage">
+${usageContent}
+      </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.adminContactHTML
+ * @param {String} options.manager.pageTitle
+ * @param {String} options.manager.publicHub
+ * @param {Object} options.dingus
+ * @param {String} options.dingus.selfBaseUrl
+ * @returns {String}
+ */
+module.exports = (ctx, options) => {
+  const pageTitle = options.manager.pageTitle;
+  const isPublicHub = options.manager.publicHub;
+  const contactHTML = options.adminContactHTML;
+  const hubURL = options.dingus.selfBaseUrl || '<s>https://hub.example.com/</s>';
+  const headElements = [];
+  const navLinks = [];
+  return th.htmlTemplate(1, pageTitle, headElements, navLinks, [
+    aboutSection(),
+    usageSection(isPublicHub, hubURL),
+    contactSection(contactHTML),
+  ]);
+};
\ 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..57a3f50
--- /dev/null
@@ -0,0 +1,275 @@
+'use strict';
+
+/**
+ * A bunch of shorthand to put together common parts of an HTML page. 
+ */
+
+/**
+ * Some fields may have values outside normal dates, handle them here.
+ * @param {Date} date
+ * @param {String} otherwise
+ */
+const dateOrNot = (date, otherwise) => {
+  if (!date) {
+    return otherwise;
+  }
+  if (typeof date === 'number') {
+    date = new Date(date);
+  }
+  const dateMs = date.getTime();
+  if (!Number.isFinite(dateMs)
+  ||  dateMs == 0) {
+    return otherwise;
+  }
+  return date.toString();
+};
+
+
+/**
+ * Render a duration.
+ * @param {Number} seconds
+ * @returns {String}
+ */
+ const secondsToPeriod = (seconds) => {
+  let value = seconds;
+  const result = [];
+
+  const nextResult = (factor, label) => {
+    const r = factor ? value % factor : value;
+    if (r) {
+      result.push(`${r} ${label}${r != 1 ? 's' : ''}`);
+    }
+    value = factor ? Math.floor(value / factor) : value;
+  }
+
+  nextResult(60, 'second');
+  nextResult(60, 'minute');
+  nextResult(24, 'hour');
+  nextResult(30, 'day');
+  nextResult(undefined, 'month');
+
+  result.reverse();
+  return result.join(' ');
+};
+
+
+/**
+ * Render a topic as a row of details.
+ * @param {Object} topic
+ * @param {Object[]} subscribers
+ * @param {Boolean} detailsLink
+ * @returns {String}
+ */
+function renderTopicRow(topic, subscribers, detailsLink = true) {
+  return `<tr>
+  <th scope="row">${detailsLink ? '<a href="topic/' + topic.id + '">' : ''}${topic.url}${detailsLink ? '</a>' : ''}</th>
+  <td>${subscribers.length}</td>
+  <td>${dateOrNot(topic.created, 'Unknown')}</td>
+  <td>${secondsToPeriod(topic.leaseSecondsPreferred)}</td>
+  <td>${secondsToPeriod(topic.leaseSecondsMin)}</td>
+  <td>${secondsToPeriod(topic.leaseSecondsMax)}</td>
+  <td>${topic.publisherValidationUrl ? topic.publisherValidationUrl : 'None'}</td>
+  <td>${topic.isActive}</td>
+  <td>${topic.isDeleted}</td>
+  <td>${dateOrNot(topic.lastPublish, 'Never')}</td>
+  <td>${dateOrNot(topic.contentFetchNextAttempt, 'Next Publish')}</td>
+  <td>${topic.contentFetchAttemptsSinceSuccess}</td>
+  <td>${dateOrNot(topic.contentUpdated, 'Never')}</td>
+  <td>${topic.contentType}</td>
+  <td>${topic.id}</td>
+</tr>`;
+}
+
+
+/**
+ * Render the header row for topic details.
+ * @returns {String}
+ */
+function renderTopicRowHeader() {
+  return `<tr>
+  <th scope="col">Topic URL</th>
+  <th scope="col">Subscribers</th>
+  <th scope="col">Created</th>
+  <th scope="col">Lease Time Preferred</th>
+  <th scope="col">Lease Time Minimum</th>
+  <th scope="col">Lease Time Maximum</th>
+  <th scope="col">Publisher Validation URL</th>
+  <th scope="col">Active</th>
+  <th scope="col">Deleted</th>
+  <th scope="col">Last Publish Notification</th>
+  <th scope="col">Next Content Fetch</th>
+  <th scope="col">Content Fetch Failures</th>
+  <th scope="col">Content Updated</th>
+  <th scope="col">Content Type</th>
+  <th scope="col">ID</th>
+</tr>`;
+}
+
+
+/**
+ * Render a subscription as a row of details.
+ * @param {Object} subscription
+ * @returns {String}
+ */
+function renderSubscriptionRow(subscription) {
+  return `<tr>
+  <td scope="row">${subscription.callback}</td>
+  <td>${dateOrNot(subscription.created, 'Unknown')}</td>
+  <td>${dateOrNot(subscription.verified, 'Never')}</td>
+  <td>${dateOrNot(subscription.expires, 'Never')}</td>
+  <td>${!!subscription.secret}</td>
+  <td>${subscription.signatureAlgorithm}</td>
+  <td>${subscription.httpRemoteAddr}</td>
+  <td>${subscription.httpFrom}</td>
+  <td>${dateOrNot(subscription.contentDelivered, 'Never')}</td>
+  <td>${subscription.deliveryAttemptsSinceSuccess}</td>
+  <td>${dateOrNot(subscription.deliveryNextAttempt, 'Next Publish')}</td>
+  <td>${subscription.id}</td>
+</tr>`;
+}
+
+
+/**
+ * Render a row of headers for subscription details.
+ * @returns {String}
+ */
+function renderSubscriptionRowHeader() {
+  return `<tr>
+  <th scope="col">Callback URL</th>
+  <th scope="col">Created</th>
+  <th scope="col">Verified</th>
+  <th scope="col">Expires</th>
+  <th scope="col">Using Secret</th>
+  <th scope="col">Signature Type</th>
+  <th scope="col">Remote Address</th>
+  <th scope="col">From</th>
+  <th scope="col">Content Delivered</th>
+  <th scope="col">Content Delivery Failures</th>
+  <th scope="col">Next Delivery</th>
+  <th scope="col">ID</th>
+</tr>
+`;
+}
+
+
+/**
+ * Render the preamble for an HTML page, up through body.
+ * @param {Number} pagePathLevel number of paths below root this page is
+ * @param {String} pageTitle
+ * @param {String[]} headElements
+ * @returns 
+ */
+function htmlHead(pagePathLevel, pageTitle, headElements = []) {
+  const rootPathPfx = '../'.repeat(pagePathLevel);
+  return `<!DOCTYPE html>
+<html lang="en>
+  <head>
+    <meta charset="utf-8">` +
+  headElements.map((e) => `${'  '.repeat(2)}${e}`).join('\n') + `
+    <title>${pageTitle}</title>
+    <link rel="stylesheet" href="${rootPathPfx}static/theme.css">
+  </head>
+  <body>`;
+}
+
+
+/**
+ * Closes remainder of HTML page body.
+ * @returns {String}
+ */
+function htmlTail() {
+  return `  </body>
+</html>`;
+}
+
+
+/**
+ * Render a navigation link for the header section.
+ * @param {Object} nav
+ * @param {String} nav.href
+ * @param {String} nav.class
+ * @param {String} nav.text
+ * @returns {String}
+ */
+function renderNavLink(nav) {
+  return `<li>
+  <a href="${nav.href}"${nav.class ? (' class="' + nav.class + '"') : ''}>${nav.text}</a>
+</li>`;
+}
+
+
+/**
+ * Render the navigation header, and open the main section.
+ * @param {String} pageTitle
+ * @param {Object[]} navLinks
+ * @returns {String}
+ */
+function htmlHeader(pageTitle, navLinks = []) {
+  return `    <header>
+      <h1>${pageTitle}</h1>
+      <nav>` +
+    (navLinks.length ? `
+        <ol>
+          ${navLinks.map((l) => renderNavLink(l)).join('\n')}
+        </ol>`
+    : '') + `
+      </nav>
+    </header>
+    <main>`;
+}
+
+
+/**
+ * Close the main section and finish off with boilerplate.
+ * @returns {String}
+ */
+function htmlFooter() {
+  return `    </main>
+    <footer>
+      <ol>
+        <a href="https://git.squeep.com/?p=websub-hub;a=tree">Development Repository</a>
+      </ol>
+      <ol>
+        <a href="https://squeep.com/">A Squeep Infrastructure Component</a>
+      </ol>
+      <ol>
+        &copy;<time datetime="2021">&#8559;&#8559;&#8553;&#8553;&#8544;</time>
+      </ol>
+    </footer>`;
+}
+
+
+/**
+ * Render all parts of an HTML page.
+ * @param {Number} pagePathLevel
+ * @param {String} pageTitle
+ * @param {String[]} headElements
+ * @param {Object[]} navLinks
+ * @param {String[]} main
+ * @returns {String}
+ */
+function htmlTemplate(pagePathLevel, pageTitle, headElements = [], navLinks = [], main = []) {
+  return [
+    htmlHead(pagePathLevel, pageTitle, headElements),
+    htmlHeader(pageTitle, navLinks),
+    ...main,
+    htmlFooter(),
+    htmlTail(),
+  ].join('\n');
+}
+
+
+module.exports = {
+  dateOrNot,
+  secondsToPeriod,
+  htmlHeader,
+  htmlFooter,
+  htmlHead,
+  htmlTail,
+  renderNavLink,
+  renderTopicRowHeader,
+  renderTopicRow,
+  renderSubscriptionRowHeader,
+  renderSubscriptionRow,
+  htmlTemplate,
+};
\ No newline at end of file
diff --git a/src/worker.js b/src/worker.js
new file mode 100644 (file)
index 0000000..f956ba1
--- /dev/null
@@ -0,0 +1,212 @@
+'use strict';
+
+const common = require('./common');
+
+const _fileScope = common.fileScope(__filename);
+
+/**
+ * Always try to do some things, but not too many.
+ * This is a generic polling promise-wrangler, keeping a set number
+ * of promises in flight, trying to replace them as they finish.
+ */
+
+/**
+ * @callback Worker~promiseGiver
+ * @param {number} atMost
+ * @returns {Promise<void>[]}
+ */
+
+class Worker {
+  /**
+   * @param {object} logger 
+   * @param {Worker~promiseGiver} promiseGiver 
+   * @param {object} options
+   * @param {object} options.worker
+   * @param {object} options.worker.pollingEnabled
+   * @param {number} options.worker.recurrSleepMs
+   * @param {number} options.worker.concurrency
+   */
+  constructor(logger, promiseGiver, options) {
+    this.logger = logger;
+    this.options = options;
+    if (!promiseGiver || typeof promiseGiver !== 'function') {
+      throw new TypeError('function required');
+    }
+    this.promiseGiver = promiseGiver;
+
+    this.concurrency = this.options.worker.concurrency;
+    this.recurrSleepMs = this.options.worker.recurrSleepMs;
+    this.inFlight = []; // Our work heap of Promises  
+    this.nextTimeout = undefined; // Allow clearTimeout() to reset waiting period.
+    this.running = false;
+  }
+
+  /**
+   * Begin the scheduled loop.
+   */
+  start(stagger = 0.618) {
+    const _scope = _fileScope('start');
+    this.logger.debug(_scope, 'called', {});
+    if (this.options.worker.pollingEnabled) {
+      this.running = true;
+      // Try to keep clustered nodes from all processing at the same time.
+      const staggerMs = Math.floor(Math.random() * this.recurrSleepMs * stagger);
+      this.nextTimeout = setTimeout(this._recurr.bind(this), staggerMs);
+    }
+  }
+
+  /**
+   * Cancel the scheduled loop.
+   */
+  stop() {
+    const _scope = _fileScope('stop');
+    this.logger.debug(_scope, 'called', {});
+    this.running = false;
+    clearTimeout(this.nextTimeout);
+    this.nextTimeout = undefined;
+  }
+
+  /**
+   * The problem: Promise.race doesn't report which promise(s) settled, and 
+   * there is no native interface for querying promise state.
+   * So we will wrap all our pending-work promises with a flag and the
+   * results, and use the race as a sort of condvar for checking everything
+   * in the list of what we were waiting for.
+   * NB this means promise cannot be further chained, or it loses the magic.
+   * @param {Promise} promise
+   * @returns {Promise} watchedPromise
+   */
+  static watchedPromise(promise) {
+    if (Object.prototype.hasOwnProperty.call(promise, 'isSettled')) {
+      return promise;
+    }
+
+    let isSettled = false;
+    let resolved = undefined;
+    let rejected = undefined;
+
+    promise = promise.then(
+      (res) => {
+        isSettled = true;
+        resolved = res;
+        return res;
+      },
+      (rej) => {
+        isSettled = true;
+        rejected = rej;
+        throw rej;
+    });
+
+    Object.defineProperties(promise, {
+      isSettled: { get: () => isSettled },
+      resolved: { get: () => resolved },
+      rejected: { get: () => rejected },
+    });
+
+    return promise;
+  }
+
+  /**
+   * Process the list of promises, removing any which have settled,
+   * and passes their fulfilled values to the handler.
+   *
+   * @param {HandlerFunction} handler 
+   * @returns {number} handled
+   */
+  _handleWatchedList(handler) {
+    let handled = 0;
+    for (let i = this.inFlight.length - 1; i >= 0; i--) {
+      // eslint-disable-next-line security/detect-object-injection
+      const p = this.inFlight[i];
+      if (p.isSettled) {
+        handler(p.resolved, p.rejected);
+        this.inFlight.splice(i, 1);
+        handled += 1;
+      }
+    }
+    return handled;
+  }
+
+  /**
+   * Refill the workpool with our special promises.
+   * @returns {Promise[]}
+   */
+  async _getWork() {
+    const _scope = _fileScope('_getWork');
+    let newPromises = [];
+    const wanted = this.concurrency - this.inFlight.length;
+    if (wanted > 0) {
+      newPromises = await this.promiseGiver(wanted);
+      newPromises = newPromises.map((p) => Worker.watchedPromise(p));
+      common.stackSafePush(this.inFlight, newPromises);
+    }
+    this.logger.debug(_scope, 'completed', { wanted, added: newPromises.length });
+    return newPromises;
+  }
+
+  /**
+   * Simply log results of promises, for now.
+   * @param {*} resolved 
+   * @param {*} rejected 
+   */
+  _watchedHandler(resolved, rejected) {
+    const _scope = _fileScope('_watchedHandler');
+
+    this.logger.debug(_scope, { resolved, rejected });
+    if (rejected) {
+      this.logger.error(_scope, { rejected });
+    }
+  }
+
+  /**
+   * Schedule the next getWork.
+   */
+  _recurr() {
+    if (this.running && this.recurrSleepMs) {
+      this.nextTimeout = setTimeout(this.process.bind(this), this.recurrSleepMs);
+    }
+  }
+
+  /**
+   * Attempt to do as much work as we can.
+   */
+  async process() {
+    const _scope = _fileScope('process');
+
+    this.logger.debug(_scope, 'called', {});
+
+    // Interrupt any pending sleep, if we were called out of timeout-cycle.
+    clearTimeout(this.nextTimeout);
+
+    // Try to fill the hopper
+    await this._getWork();
+
+    while (this.inFlight.length > 0) {
+      /* Wait for one or more to be resolved.
+       * We don't care what the result was, as we have to scan the list
+       * for all settled promises anyhow, and our wrapper has stored the
+       * results.
+       */
+      try {
+        await Promise.race(this.inFlight);
+      } catch (e) {
+        // NOP here, as we'll handle it when we scan the list
+      }
+      this.logger.debug(_scope, { msg: 'race completed' });
+
+      // Address settled promises..
+      const settled = this._handleWatchedList(this._watchedHandler.bind(this));
+      this.logger.debug(_scope, { settled });
+      
+      // Try to fill the vacancy
+      // TODO: maybe rate-limit this call based on slot availability
+      await this._getWork();
+    }
+
+    // No more work, wait a while and retry
+    this._recurr();
+  }
+
+}
+
+module.exports = Worker;
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/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/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/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..06f03a9
--- /dev/null
@@ -0,0 +1,68 @@
+* {}
+html {
+       height: 100vh;
+}
+body {
+       background-color: #fff;
+       font-family: Helvetica, Verdana, sans-serif;
+       margin: 1em;
+       min-height: 100vh;
+       display: flex;
+       flex-direction: column;
+}
+header {}
+header nav ol {
+       list-style-type: none;
+       margin: 0;
+       padding: 0;
+       border: 1px solid #000;
+}
+header nav ol li a {
+       display: block;
+       width: 10em;
+       text-align: center;
+}
+h1 {
+       margin-top: 1.3em;
+       margin-bottom: 2.5em;
+       text-align: center;
+}
+h2 {
+       background-color: #ddd;
+}
+main {
+       flex-grow: 1;
+}
+section {}
+.about {}
+.usage {}
+.topics {}
+.subscriptions {}
+.external {
+       background-image: url("external-link.svg");
+       background-position: right center;
+       background-repeat: no-repeat;
+       padding-right: 13px;
+}
+table {
+       border: 0;
+       width: 100%;
+}
+thead tr th {
+       background-color: #ddd;
+}
+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: 4px dotted #666;
+}
+footer nav ol {
+       list-style-type: none;
+       margin: 0;
+       padding: 0;
+       border: 1px solid #000;
+}
diff --git a/test-e2e/fake-servers-client.js b/test-e2e/fake-servers-client.js
new file mode 100644 (file)
index 0000000..7dfea3b
--- /dev/null
@@ -0,0 +1,173 @@
+'use strict';
+
+/**
+ * Wrapper interface for controlling fake-servers.
+ */
+
+const axios = require('axios');
+
+class FakeClient {
+  constructor(host, subscriberPort, topicPort) {
+    this.logger = console;
+    this.host = host;
+    this.subscriberPort = subscriberPort;
+    this.topicPort = topicPort;
+    this.axios = axios.create({
+      validateStatus: (statusCode) => (Math.floor(statusCode / 100)) === 2,
+      headers: {
+        'User-Agent': 'FakeClient',
+      },
+    });
+  }
+
+  topicUrl(id) {
+    return `http://${this.host}:${this.topicPort}/topic/${id}`;
+  }
+
+  subscriberUrl(id, extra = '') {
+    return `http://${this.host}:${this.subscriberPort}/subscriber/${id}${extra}`;
+  }
+
+  static _axiosRequestConfig(method, url, params = {}, headers = {}, data) {
+    const urlObj = new URL(url);
+    const config = {
+      method,
+      url: `${urlObj.origin}${urlObj.pathname}`,
+      params: urlObj.searchParams,
+      headers,
+      ...(data && { data }),
+      responseType: 'text',
+      transformResponse: [ (res) => res ],
+    };
+    Object.entries(params).map(([k, v]) => config.params.set(k, v));
+    return config;
+  }
+
+  async subscribe(hubUrl, subscriberId, topicId, postData = {}) {
+    const topicUrl = this.topicUrl(topicId);
+    const subscriberUrl = this.subscriberUrl(subscriberId);
+    const data = {
+      'hub.callback': subscriberUrl,
+      'hub.mode': 'subscribe',
+      'hub.topic': topicUrl,
+      'hub.lease_seconds': 60,
+      'hub.secret': 'sharedSecret',
+      ...postData,
+    };
+    const formData = new URLSearchParams(data).toString();
+    const headers = {
+      'Content-Type': 'application/x-www-form-urlencoded',
+    };
+  
+    try {
+      return this.axios(FakeClient._axiosRequestConfig('POST', hubUrl, {}, headers, formData));
+    } catch (e) {
+      this.logger.error('subscribe', e);
+      throw e;
+    }
+  }
+  
+  /**
+   * Set the behavior for a topic id.
+   * @param {String} id
+   * @param {Object} behavior
+   * @param {Number} behavior.statusCode
+   * @param {String} behavior.content
+   * @param {String} behavior.contentType
+   * @param {String} behavior.hubUrl
+   */
+  async topicSet(id, behavior = {}) {
+    const defaultBehavior = {
+      statusCode: 200,
+      content: 'some content',
+      contentType: 'text/plain',
+    };
+    const url = this.topicUrl(id);
+    try {
+      return this.axios(FakeClient._axiosRequestConfig('PUT', url, {
+        ...defaultBehavior,
+        ...behavior,
+      }));
+    } catch (e) {
+      this.logger.error('topicSet', e);
+      throw e;
+    }
+  }
+
+  /**
+   * Remove a topic id.
+   * @param {String} id
+   */
+  async topicDelete(id) {
+    const url =this.topicUrl(id);
+    try {
+      return this.axios(FakeClient._axiosRequestConfig('DELETE', url));
+    } catch (e) {
+      this.logger.error('topicDelete', e);
+      throw e;
+    }
+  }
+
+  /**
+   * Set the behavior for a subscriber id verify response.
+   * @param {String} id
+   * @param {Object} behavior
+   * @param {Number} behavior.statusCode
+   * @param {Boolean} behavior.matchChallenge
+   */
+  async subscriberSetVerify(id, behavior = {}) {
+    const defaultBehavior = {
+      statusCode: 200,
+      matchChallenge: true,
+    };
+    const url = this.subscriberUrl(id, '/verify');
+    try {
+      return this.axios(FakeClient._axiosRequestConfig('PUT', url, {
+        ...defaultBehavior,
+        ...behavior,
+      }));
+    } catch (e) {
+      this.logger.error('subscriberSetVerify', e);
+      throw e;
+    }
+  }
+
+  /**
+   * Set the behavior for a subscriber id content-update response.
+   * @param {String} id
+   * @param {Object} behavior
+   * @param {Number} behavior.statusCode
+   */
+   async subscriberSetContent(id, behavior = {}) {
+     const defaultBehavior = {
+       statusCode: 200,
+     };
+    const url = this.subscriberUrl(id, '/content');
+    try {
+      return this.axios(FakeClient._axiosRequestConfig('PUT', url, {
+        ...defaultBehavior,
+        ...behavior,
+      }));
+    } catch (e) {
+      this.logger.error('subscriberSetContent', e);
+      throw e;
+    }
+  }
+
+  /**
+   * Removes a topic id.
+   * @param {String} id
+   */
+  async subscriberDelete(id) {
+    const url = this.subscriberUrl(id);
+    try {
+      return this.axios(FakeClient._axiosRequestConfig('DELETE', url));
+    } catch (e) {
+      this.logger.error('subscriberDelete', e);
+      throw e;
+    }
+  }
+
+} // FakeClient
+
+module.exports = FakeClient;
diff --git a/test-e2e/fake-servers.js b/test-e2e/fake-servers.js
new file mode 100644 (file)
index 0000000..0adff34
--- /dev/null
@@ -0,0 +1,174 @@
+/* eslint-disable sonarjs/no-duplicate-string */
+'use strict';
+
+/**
+ * For testing, this is a configurable endpoint server.
+ * 
+ * Set how a subscriber id responds to a verification GET call
+ *   PUT /subscriber/:id/verify?responseCode=xxx&matchChallenge=true
+ * Set how an id responds to a content delivery POST call
+ *   PUT /subscriber/:id/content?responseCode=xxx
+ * Remove an id
+ *   DELETE /subscriber/:id
+ * 
+ * Set how a topic id returns
+ *   PUT /topic/:id?statusCode=xxx&content=xxx&contentType=foo/bar
+ */
+
+const http = require('http');
+const { Dingus, Enum, Errors } = require('@squeep/api-dingus');
+
+const subscriberPort = process.env.FAKE_SUBSCRIBER_PORT || 9876;
+const topicPort = process.env.FAKE_TOPIC_PORT || 9875;
+const listenAddress = process.env.FAKE_LISTEN_ADDR || '127.0.0.1';
+
+class TopicFake extends Dingus {
+  constructor() {
+    super(console, {
+      ignoreTrailingSlash: true,
+    });
+    this.topicBehaviors = new Map();
+    this.on(['GET'], '/topic/:id', this.getId.bind(this));
+
+    this.on(['PUT'], '/topic/:id', this.putId.bind(this));
+    this.on(['DELETE'], '/topic/:id', this.deleteId.bind(this));
+  }
+
+  async getId(req, res, ctx) {
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    const behavior = this.topicBehaviors.get(ctx.params.id);
+    if (!behavior) {
+      throw new Errors.ResponseError(Enum.ErrorResponse.NotFound);
+    }
+    if (behavior.contentType) {
+      res.setHeader(Enum.Header.ContentType, behavior.contentType);
+    }
+    res.setHeader('Link', behavior.selfLink + (behavior.hubLink ? `, ${behavior.hubLink}` : ''));
+    res.statusCode = behavior.statusCode;
+    res.end(behavior.content);
+    this.logger.info({ method: req.method, statusCode: res.statusCode });
+  }
+
+  async putId(req, res, ctx) {
+    const id = ctx.params.id;
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    const behavior = {
+      statusCode: ctx.queryParams.statusCode || 200,
+      ...(ctx.queryParams.contentType && { contentType: ctx.queryParams.contentType }),
+      content: ctx.queryParams.content,
+      selfLink: `<http://${listenAddress}:${topicPort}/${id}>; rel="self"`,
+      ...(ctx.queryParams.hubUrl && { hubLink: `<${ctx.queryParams.hubUrl}>; rel="hub"` }),
+    };
+    this.topicBehaviors.set(id, behavior);
+    res.statusCode = 200;
+    res.end();
+  }
+
+  async deleteId(req, res, ctx) {
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    this.topicBehaviors.delete(ctx.params.id);
+    res.statusCode = 200;
+    res.end();
+  }
+
+} // TopicFake
+
+class SubscriberFake extends Dingus {
+  constructor() {
+    super(console, {
+      ignoreTrailingSlash: true,
+    });
+    this.verifyBehaviors = new Map();
+    this.contentBehaviors = new Map();
+    this.on(['GET'], '/subscriber/:id', this.getId.bind(this));
+    this.on(['POST'], '/subscriber/:id', this.postId.bind(this));
+
+    this.on(['PUT'], '/subscriber/:id/verify', this.putVerify.bind(this));
+    this.on(['PUT'], '/subscriber/:id/content', this.putContent.bind(this));
+    this.on(['DELETE'], '/subscriber/:id', this.deleteId.bind(this));
+  }
+
+  // eslint-disable-next-line class-methods-use-this
+  parseBody() {
+    // do not parse, just ingest
+  }
+
+  async getId(req, res, ctx) {
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    const behavior = this.verifyBehaviors.get(ctx.params.id);
+    res.statusCode = behavior ? behavior.statusCode : 404;
+    const response = (behavior && behavior.matchChallenge) ? ctx.queryParams['hub.challenge'] : (behavior && behavior.response);
+    res.end(response);
+    this.logger.info({ method: req.method, statusCode: res.statusCode, matchChallenge: !!(behavior && behavior.matchChallenge) });
+  }
+
+  async postId(req, res, ctx) {
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    await this.ingestBody(req, res, ctx);
+    const behavior = this.contentBehaviors.get(ctx.params.id);
+    res.statusCode = behavior ? behavior.statusCode : 404;
+    if (behavior) {
+      behavior.updated = new Date();
+      behavior.content = ctx.rawBody;
+    }
+    res.end();
+    this.logger.info({ content: behavior && behavior.content, method: req.method, statusCode: res.statusCode, matchChallenge: !!(behavior && behavior.matchChallenge) });
+  }
+
+  async putVerify(req, res, ctx) {
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    const behavior = {
+      matchChallenge: ctx.queryParams.matchChallenge === 'true',
+      statusCode: ctx.queryParams.statusCode || 200,
+    };
+    this.verifyBehaviors.set(ctx.params.id, behavior);
+    if (!this.contentBehaviors.get(ctx.params.id)) {
+      this.contentBehaviors.set(ctx.params.id, {
+        statusCode: 200,
+      });
+    }
+    res.statusCode = 200;
+    res.end();
+  }
+
+  async putContent(req, res, ctx) {
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    const behavior = {
+      statusCode: ctx.queryParams.statusCode || 200,
+    };
+    this.contentBehaviors.set(ctx.params.id, behavior);
+    res.statusCode = 200;
+    res.end();
+  }
+
+  async deleteId(req, res, ctx) {
+      this.setResponseType(this.responseTypes, req, res, ctx);
+      this.contentBehaviors.delete(ctx.params.id);
+      this.verifyBehaviors.delete(ctx.params.id);
+      res.statusCode = 200;
+      res.end();
+  }
+
+} // SubscriberFake
+
+const subscriberService = new SubscriberFake();
+http.createServer((req, res) => {
+  subscriberService.dispatch(req, res);
+}).listen(subscriberPort, listenAddress, (err) => {
+  if (err) {
+    console.error(err);
+    throw err;
+  }
+  console.log(`Fake Subscriber Server started on ${listenAddress}:${subscriberPort}`);
+});
+
+const topicService = new TopicFake();
+http.createServer((req, res) => {
+  topicService.dispatch(req, res);
+}).listen(topicPort, listenAddress, (err) => {
+  if (err) {
+    console.error(err);
+    throw err;
+  }
+  console.log(`Fake Topic Server started on ${listenAddress}:${topicPort}`);
+});
diff --git a/test-e2e/postgres.env b/test-e2e/postgres.env
new file mode 100644 (file)
index 0000000..1140fe7
--- /dev/null
@@ -0,0 +1,31 @@
+#!/bin/bash
+# source this
+
+# Place the data directory inside the project directory
+export PGDATA="$(pwd)/postgres_dev-13"
+
+envname="$(basename "${PGDATA}")"
+
+# Place Postgres' Unix socket inside the data directory
+export PGHOST="$PGDATA"
+
+if [[ ! -d "$PGDATA" ]]; then
+       # If the data directory doesn't exist, create an empty one, and...
+       initdb --data-checksums
+       # ...configure it to listen only on the Unix socket, and...
+       cat >> "$PGDATA/postgresql.conf" <<-EOF
+               listen_addresses = ''
+               unix_socket_directories = '$PGHOST'
+               shared_preload_libraries = 'pg_stat_statements'
+       EOF
+       # ...create a database using the name Postgres defaults to.
+       echo "CREATE DATABASE $USER;" | postgres --single -E postgres
+fi
+
+export PS1="(${envname})${PS1}"
+
+echo<<EOF
+#
+# ex: pg_ctl -l "${PGDATA}/logfile" start
+#
+EOF
diff --git a/test-e2e/test-many.js b/test-e2e/test-many.js
new file mode 100644 (file)
index 0000000..1ff40c9
--- /dev/null
@@ -0,0 +1,42 @@
+'use strict';
+
+const assert = require('assert');
+const uuid = require('uuid');
+const FakeServersClient = require('./fake-servers-client');
+
+const subscriberPort = process.env.FAKE_SUBSCRIBER_PORT || 9876;
+const topicPort = process.env.FAKE_TOPIC_PORT || 9875;
+const listenAddress = process.env.FAKE_LISTEN_ADDR || '127.0.0.1';
+const hubAddress = process.env.LISTEN_ADDR || '127.0.0.1';
+const hubPort = process.env.PORT || 4001;
+const hubUrl = `http://${hubAddress}:${hubPort}/`;
+
+const client = new FakeServersClient(listenAddress, subscriberPort, topicPort);
+
+async function newTopic() {
+  const id = uuid.v4();
+  await client.topicSet(id, { hubUrl });
+  console.log('created fake topic', id);
+  return id;
+}
+
+async function newSubscriber() {
+  const id = uuid.v4();
+  await client.subscriberSetVerify(id);
+  console.log('created fake subscriber', id);
+  return id;
+}
+
+(async function main() {
+  const topicId = await newTopic();
+  const numSubscribers = 100;
+
+  const subIds = await Promise.all([...Array(numSubscribers)].map(() => newSubscriber()));
+  const results = await Promise.all(subIds.map((id) => client.subscribe(hubUrl, id, topicId)));
+  console.log('results', results);
+
+  console.log('done');
+})().catch((e) => {
+  console.log(e);
+  throw e;
+});
\ No newline at end of file
diff --git a/test-e2e/test-one.js b/test-e2e/test-one.js
new file mode 100644 (file)
index 0000000..8c6fdf2
--- /dev/null
@@ -0,0 +1,41 @@
+'use strict';
+
+const assert = require('assert');
+const uuid = require('uuid');
+const FakeServersClient = require('./fake-servers-client');
+
+const subscriberPort = process.env.FAKE_SUBSCRIBER_PORT || 9876;
+const topicPort = process.env.FAKE_TOPIC_PORT || 9875;
+const listenAddress = process.env.FAKE_LISTEN_ADDR || '127.0.0.1';
+const hubAddress = process.env.LISTEN_ADDR || '127.0.0.1';
+const hubPort = process.env.PORT || 4001;
+const hubUrl = `http://${hubAddress}:${hubPort}/`;
+
+const client = new FakeServersClient(listenAddress, subscriberPort, topicPort);
+
+async function newTopic() {
+  const id = uuid.v4();
+  await client.topicSet(id, { hubUrl });
+  console.log('created fake topic', id);
+  return id;
+}
+
+async function newSubscriber() {
+  const id = uuid.v4();
+  await client.subscriberSetVerify(id);
+  console.log('created fake subscriber', id);
+  return id;
+}
+
+(async function main() {
+  const topicId = await newTopic();
+  const subscriberId = await newSubscriber();
+
+  const result = await client.subscribe(hubUrl, subscriberId, topicId);
+  console.log('subscribed', { status: result.status, headers: result.headers, data: result.data });
+  
+  console.log('done');
+})().catch((e) => {
+  console.log(e);
+  throw e;
+});
\ No newline at end of file
diff --git a/test/src/authenticator.js b/test/src/authenticator.js
new file mode 100644 (file)
index 0000000..8c17436
--- /dev/null
@@ -0,0 +1,143 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon');
+const Authenticator = require('../../src/authenticator');
+const stubLogger = require('../stub-logger');
+const stubDb = require('../stub-db');
+const Errors = require('../../src/errors');
+const Enum = require('../../src/enum');
+
+const noExpectedException = 'did not receive expected exception';
+
+describe('Authenticator', function () {
+  let authenticator, credential, ctx, identifier, password, options;
+  beforeEach(function () {
+    options = {
+      authenticator: {
+        basicRealm: 'realm',
+        secureAuthOnly: true,
+      },
+    };
+    authenticator = new Authenticator(stubLogger, stubDb, options);
+    identifier = 'username';
+    credential = '$argon2id$v=19$m=4096,t=3,p=1$1a6zRlX4BI4$sZGcQ72BTpDOlxUI/j3DmE1PMcu+Cs5liZ/D6kk79Ew';
+    ctx = {};
+    password = 'badPassword';
+  });
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  describe('isValidBasic', function () {
+    it('succeeds', async function () {
+      sinon.stub(authenticator.db, 'authenticationGet').resolves({
+        identifier,
+        credential,
+      });
+      const authString = `${identifier}:${password}`;
+      const result = await authenticator.isValidBasic(authString, ctx);
+      assert.strictEqual(result, true);
+      assert.strictEqual(ctx.authenticationId, identifier);
+    });
+    it('fails', async function () {
+      sinon.stub(authenticator.db, 'authenticationGet').resolves({
+        identifier,
+        credential,
+      });
+      const authString = `${identifier}:wrongPassword}`;
+      const result = await authenticator.isValidBasic(authString, ctx);
+      assert.strictEqual(result, false);
+      assert.strictEqual(ctx.authenticationId, undefined);
+    });
+    it('covers no entry', async function() {
+      sinon.stub(authenticator.db, 'authenticationGet').resolves();
+      const authString = `${identifier}:wrongPassword}`;
+      const result = await authenticator.isValidBasic(authString, ctx);
+      assert.strictEqual(result, false);
+      assert.strictEqual(ctx.authenticationId, undefined);
+    });
+    it('covers unknown password hash', async function () {
+      sinon.stub(authenticator.db, 'authenticationGet').resolves({
+        identifier,
+        credential: '$other$kind_of_credential',
+      });
+      const authString = `${identifier}:wrongPassword}`;
+      const result = await authenticator.isValidBasic(authString, ctx);
+      assert.strictEqual(result, false);
+      assert.strictEqual(ctx.authenticationId, undefined);
+    });
+  }); // isValidBasic
+
+  describe('isValidAuthorization', function () {
+    it('handles basic', async function () {
+      const expected = true;
+      const authorizationHeader = 'basic Zm9vOmJhcg==';
+      sinon.stub(authenticator, 'isValidBasic').resolves(expected);
+      const result = await authenticator.isValidAuthorization(authorizationHeader, ctx);
+      assert.strictEqual(result, expected);
+    });
+    it('handles other', async function () {
+      const expected = false;
+      const authorizationHeader = 'bearer Zm9vOmJhcg==';
+      const result = await authenticator.isValidAuthorization(authorizationHeader, ctx);
+      assert.strictEqual(result, expected);
+    });
+  }); // isValidAuthorization
+
+  describe('requestBasic', function () {
+    it('covers', function () {
+      try {
+        const res = {
+          setHeader: () => {},
+        };
+        authenticator.requestBasic(res);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.ResponseError);
+        assert.strictEqual(e.statusCode, Enum.ErrorResponse.Unauthorized.statusCode);
+      }
+    });
+  }); // requestBasic
+
+  describe('required', function () {
+    let req, res;
+    beforeEach(function () {
+      ctx.clientProtocol = 'https';
+      req = {
+        getHeader: sinon.stub(),
+      };
+      res = {
+        setHeader: sinon.stub(),
+      }
+    });
+    it('succeeds', async function() {
+      req.getHeader.returns('auth header');
+      sinon.stub(authenticator, 'isValidAuthorization').resolves(true);
+      const result = await authenticator.required(req, res, ctx);
+      assert.strictEqual(result, true);
+    });
+    it('rejects insecure connection', async function () {
+      ctx.clientProtocol = 'http';
+      try {
+        await authenticator.required(req, res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.ResponseError);
+        assert.strictEqual(e.statusCode, Enum.ErrorResponse.Forbidden.statusCode);
+      }
+    });
+    it('rejects invalid auth', async function () {
+      try {
+        req.getHeader.returns('auth header');
+        sinon.stub(authenticator, 'isValidAuthorization').resolves(false);
+        await authenticator.required(req, res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.ResponseError);
+        assert.strictEqual(e.statusCode, Enum.ErrorResponse.Unauthorized.statusCode);
+      }
+    });
+  }); // required
+}); // Authenticator
diff --git a/test/src/common.js b/test/src/common.js
new file mode 100644 (file)
index 0000000..b64ecb4
--- /dev/null
@@ -0,0 +1,163 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const common = require('../../src/common');
+
+describe('Common', function () {
+  
+  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('topicLeaseDefaults', function () {
+    it('supplies necessary properties', function () {
+      const result = common.topicLeaseDefaults();
+      assert('leaseSecondsPreferred' in result);
+      assert.strictEqual(typeof result.leaseSecondsPreferred, 'number');
+      assert('leaseSecondsMax' in result);
+      assert.strictEqual(typeof result.leaseSecondsMax, 'number');
+      assert('leaseSecondsMin' in result);
+      assert.strictEqual(typeof result.leaseSecondsMin, 'number');
+    });
+    it('cannot be changed', function () {
+      const result = common.topicLeaseDefaults();
+      const origMin = result.leaseSecondsMin;
+      try {
+        result.leaseSecondsMin += 10;
+        assert.fail('assign should fail');
+      } catch (e) {
+        assert(e instanceof TypeError);
+      }
+      assert.strictEqual(result.leaseSecondsMin, origMin);
+    });
+  }); // topicLeaseDefaults
+
+  describe('attemptRetrySeconds', function () {
+    const retries = [0, 1, 2];
+    const jitter = 0;
+    it('defaults without a number', function () {
+      const result = common.attemptRetrySeconds('not a number', retries, jitter);
+      assert.strictEqual(result, retries[0]);
+    });
+    it('brackets lower range', function () {
+      const result = common.attemptRetrySeconds(-10, retries, jitter);
+      assert.strictEqual(result, retries[0]);
+    });
+    it('brackets upper range', function () {
+      const result = common.attemptRetrySeconds(10, retries, jitter);
+      assert.strictEqual(result, retries[retries.length - 1]);
+    });
+    it('covers middle', function () {
+      const result = common.attemptRetrySeconds(1, retries, jitter);
+      assert.strictEqual(result, retries[1]);
+    });
+    it('covers default', function () {
+      const result = common.attemptRetrySeconds(0);
+      assert(result >= 60);
+      assert(result <= 60 * 1.618)
+    });
+  }); // attemptRetrySeconds
+
+  describe('arrayChunk', function () {
+    it('covers default', function () {
+      const result = common.arrayChunk([1, 2, 3]);
+      assert.deepStrictEqual(result, [[1], [2], [3]]);
+    });
+    it('covers remainders', function () {
+      const result = common.arrayChunk([1, 2, 3], 2);
+      assert.deepStrictEqual(result, [[1, 2], [3]]);
+    });
+  }); // arrayChunk
+
+  describe('stackSafePush', function () {
+    it('pushes', function () {
+      const bigArray = new Array(2**18);
+      const dst = [];
+
+      common.stackSafePush(dst, bigArray);
+
+      assert.strictEqual(dst.length, bigArray.length);
+    });
+  }); // stackSafePush
+
+  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('validHash', function () {
+    it('should succeed', function () {
+      const result = common.validHash('sha256');
+      assert.strictEqual(result, true);
+    });
+    it('should fail', function () {
+      const result = common.validHash('md5');
+      assert.strictEqual(result, false);
+    });
+  }); // validHash
+
+}); // Common
diff --git a/test/src/communication.js b/test/src/communication.js
new file mode 100644 (file)
index 0000000..7c62227
--- /dev/null
@@ -0,0 +1,880 @@
+/* eslint-env mocha */
+/* eslint-disable capitalized-comments, sonarjs/no-duplicate-string */
+
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+
+const Communication = require('../../src/communication');
+const Config = require('../../config');
+const Errors = require('../../src/errors');
+
+const stubDb = require('../stub-db');
+const stubLogger = require('../stub-logger');
+
+const noExpectedException = 'did not get expected exception';
+
+describe('Communication', function () {
+  let communication, options;
+
+  beforeEach(function () {
+    options = new Config('test');
+    communication = new Communication(stubLogger, stubDb, options);
+    stubDb._reset();
+    stubLogger._reset();
+  });
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  it('instantiates', function () {
+    assert(communication);
+  });
+
+  it('covers config value', function () {
+    options.dingus.selfBaseUrl = undefined;
+    communication = new Communication(stubLogger, stubDb, options);
+  });
+
+  describe('Axios timing coverage', function () {
+    const request = {};
+    const response = {
+      config: request,
+    };
+    it('tags request', function () {
+      communication.axios.interceptors.request.handlers[0].fulfilled(request);
+      assert(request.startTimestampMs);
+    });
+    it('tags response', function () {
+      communication.axios.interceptors.response.handlers[0].fulfilled(response);
+      assert(response.elapsedTimeMs);
+    });
+  }); // Axios timing coverage
+
+  describe('userAgentString', function () {
+    it('has default behavior', function () {
+      const result = Communication.userAgentString();
+      assert(result);
+      assert(result.length > 30);
+    });
+    it('is settable', function () {
+      const result = Communication.userAgentString({
+        product: 'myhub',
+        version: '9.9.9',
+        implementation: 'custom',
+      });
+      assert(result);
+      assert.strictEqual(result, 'myhub/9.9.9 (custom)');
+    });
+    it('covers branches', function () {
+      const result = Communication.userAgentString({
+        product: 'myhub',
+        version: '9.9.9',
+        implementation: '',
+      });
+      assert(result);
+      assert.strictEqual(result, 'myhub/9.9.9');
+    });
+  });
+
+  describe('generateChallenge', function () {
+    it('generates a thing', async function () {
+      const result = await Communication.generateChallenge();
+      assert(result);
+      assert(result.length);
+    });
+  });
+
+  describe('signature', function () {
+    let message, secret, algorithm, expected;
+    beforeEach(function () {
+      message = 'Jackdaws love my big sphinx of quartz.';
+      secret = 'secretsecret';
+      algorithm = 'sha256';
+      expected = 'sha256=ee92148d9cd043cdfb8da7cf5ee1897abaafdb5ab840e85010abd4bf235fa31e';
+    });
+    it('signs a thing', function () {
+      const result = Communication.signature(message, secret, algorithm);
+      assert.strictEqual(result, expected);
+    });
+  });
+
+  describe('contentHash', function () {
+    let content, algorithm, expected;
+    beforeEach(function () {
+      content = 'Jived fox nymph grabs quick waltz.';
+      algorithm = 'sha256';
+      expected = '6e5e1a93bde78910b0d7c5fd8aba393294d4eca5d3fbf2bfd49100df3d5cc85d';
+    });
+    it('hashes', function () {
+      const result = Communication.contentHash(content, algorithm);
+      assert.strictEqual(result, expected);
+    })
+  });
+
+  describe('Axios Configurations', function () {
+    let requestUrl, expectedUrl, topicUrl;
+    beforeEach(function () {
+      requestUrl = 'https://example.com/callback/?id=123';
+      expectedUrl = 'https://example.com/callback/';
+      topicUrl = 'http://example.com/blog/';
+    });
+    it('_axiosConfig', function () {
+      const method = 'GET';
+      const contentType = 'text/plain';
+      const body = undefined;
+      const params = {
+        'extra_parameter': 'foobar',
+      };
+      const expectedUrlObj = new URL('https://example.com/callback/?id=123&extra_parameter=foobar');
+      const expected = {
+        method,
+        url: 'https://example.com/callback/',
+        headers: {
+          'Content-Type': 'text/plain',
+        },
+        params: expectedUrlObj.searchParams,
+        responseType: 'text',
+      };
+      const result = Communication._axiosConfig(method, requestUrl, body, params, {
+        'Content-Type': contentType,
+      });
+      delete result.transformResponse;
+      assert.deepStrictEqual(result, expected);
+    });
+    it('_axiosConfig covers defaults', function () {
+      const method = 'OPTIONS';
+      const expectedUrlObj = new URL(requestUrl);
+      const expected = {
+        method,
+        url: expectedUrl,
+        headers: {},
+        params: expectedUrlObj.searchParams,
+        responseType: 'text',
+      };
+      const result = Communication._axiosConfig(method, requestUrl);
+      delete result.transformResponse;
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers null response transform', function () {
+      const result = Communication._axiosConfig('GET', 'https://example.com/', undefined, {}, {});
+      result.transformResponse[0]();
+    });
+    it('_intentVerifyAxiosConfig', function () {
+      const mode = 'subscribe';
+      const leaseSeconds = 864000;
+      const challenge = 'abcxyz';
+      const expectedUrlObj = new URL(`${requestUrl}&hub.mode=${mode}&hub.topic=${encodeURIComponent(topicUrl)}&hub.challenge=${challenge}&hub.lease_seconds=${leaseSeconds}`);
+      const expected = {
+        method: 'GET',
+        url: expectedUrl,
+        headers: {},
+        params: expectedUrlObj.searchParams,
+        responseType: 'text',
+      };
+      const result = Communication._intentVerifyAxiosConfig(requestUrl, topicUrl, mode, leaseSeconds, challenge);
+      delete result.transformResponse;
+      assert.deepStrictEqual(result, expected);
+    });
+    it('_intentDenyAxiosConfig', function () {
+      const reason = 'something';
+      const expectedUrlObj = new URL(`${requestUrl}&hub.mode=denied&hub.topic=${encodeURIComponent(topicUrl)}&hub.reason=${reason}`);
+      const expected = {
+        method: 'GET',
+        url: expectedUrl,
+        headers: {},
+        params: expectedUrlObj.searchParams,
+        responseType: 'text',
+      };
+      const result = Communication._intentDenyAxiosConfig(requestUrl, topicUrl, reason);
+      delete result.transformResponse;
+      assert.deepStrictEqual(result, expected);
+    });
+    it('_publisherValidationAxiosConfig', function () {
+      const topic = {
+        url: topicUrl,
+        publisherValidationUrl: 'https://example.com/publisher/',
+      };
+      const verification = {
+        callback: requestUrl,
+        topic: topicUrl,
+      };
+      const expectedUrlObj = new URL(topic.publisherValidationUrl);
+      const expected = {
+        method: 'POST',
+        url: topic.publisherValidationUrl,
+        data: {
+          callback: requestUrl,
+          topic: topicUrl,
+        },
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        params: expectedUrlObj.searchParams,
+        responseType: 'text',
+      };
+      const result = Communication._publisherValidationAxiosConfig(topic, verification);
+      delete result.transformResponse;
+      assert.deepStrictEqual(result, expected);
+    });
+    it('_topicFetchAxiosConfig', function () {
+      const topic = {
+        url: topicUrl,
+        contentType: 'text/plain',
+      };
+      const expectedUrlObj = new URL(topicUrl);
+      const expected = {
+        method: 'GET',
+        url: topicUrl,
+        params: expectedUrlObj.searchParams,
+        headers: {
+          Accept: 'text/plain, */*;q=0.9',
+        },
+        responseType: 'text',
+      };
+      const result = Communication._topicFetchAxiosConfig(topic);
+      delete result.transformResponse;
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // Axios Configurations
+
+  describe('verificationProcess', function () {
+    const challenge = 'a_challenge';
+    let dbCtx, callback, requestId, topicId;
+    let topic, verification;
+    beforeEach(function () {
+      dbCtx = {};
+      callback = 'https://example.com/callback/?id=123';
+      requestId = '7d37ea20-4ef7-417e-a08d-c0ba71269ab1';
+      topicId = '234ec6fb-f1cd-4ac3-8ea9-29ed42ae0e21';
+      topic = {
+        id: topicId,
+        url: 'https://example.com/blog/',
+        isActive: true,
+        isDeleted: false,
+      };
+      verification = {
+        callback,
+        mode: 'subscribe',
+        isPublisherValidated: true,
+        leaseSeconds: 864000,
+      };
+
+      sinon.stub(Communication, 'generateChallenge').resolves(challenge);
+      sinon.stub(communication, 'publisherValidate').resolves(true);
+      sinon.stub(communication, 'axios').resolves({
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'content-type': 'text/plain',
+        },
+        data: challenge,
+      });
+
+      communication.db.verificationGetById.resolves(verification);
+      communication.db.topicGetById.resolves(topic);
+      communication.db.verificationRelease.resolves({});
+      communication.db.verificationUpdate.resolves({});
+      communication.db.verificationIncomplete.resolves({});
+      communication.db.verificationComplete.resolves({});
+    });
+
+    it('errors on non-existent verification', async function () {
+      communication.db.verificationGetById.restore();
+      sinon.stub(communication.db, 'verificationGetById').resolves();
+
+      try {
+        await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.InternalInconsistencyError);
+      }
+    });
+
+    it('errors on non-existent topic', async function () {
+      communication.db.topicGetById.restore();
+      sinon.stub(communication.db, 'topicGetById').resolves();
+
+      try {
+        await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.InternalInconsistencyError);
+      }
+    });
+
+    it('skips inactive topic', async function () {
+      communication.db.topicGetById.restore();
+      topic.isActive = false;
+      sinon.stub(communication.db, 'topicGetById').resolves(topic);
+
+      await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+
+      assert(communication.db.verificationRelease.called);
+      assert(!communication.axios.called);
+    });
+
+    it('denies subscription to deleted topic', async function () {
+      communication.db.topicGetById.restore();
+      topic.isDeleted = true;
+      sinon.stub(communication.db, 'topicGetById').resolves(topic);
+
+      await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+
+      assert(communication.db.verificationUpdate.called);
+      assert.strictEqual(verification.mode, 'denied');
+    });
+
+    it('checks publisher validation if needed', async function() {
+      communication.db.verificationGetById.restore();
+      verification.isPublisherValidated = false;
+      sinon.stub(communication.db, 'verificationGetById').resolves(verification);
+      communication.db.topicGetById.restore();
+      topic.publisherValidationUrl = 'https://example.com/publisher/';
+      sinon.stub(communication.db, 'topicGetById').resolves(topic);
+
+      await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+
+      assert(communication.publisherValidate.called);
+      assert(communication.db.verificationComplete.called);
+    });
+
+    it('handles publisher validation failure', async function() {
+      communication.db.verificationGetById.restore();
+      verification.isPublisherValidated = false;
+      sinon.stub(communication.db, 'verificationGetById').resolves(verification);
+      communication.db.topicGetById.restore();
+      topic.publisherValidationUrl = 'https://example.com/publisher/';
+      sinon.stub(communication.db, 'topicGetById').resolves(topic);
+      communication.publisherValidate.restore();
+      sinon.stub(communication, 'publisherValidate').resolves(false);
+
+      await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+
+      assert(communication.publisherValidate.called);
+      assert(communication.db.verificationIncomplete.called);
+    });
+
+    it('handles request error', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').throws(new Error());
+
+      await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+
+      assert(communication.db.verificationIncomplete.called);
+    });
+
+    it('handles 500 response', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 500,
+      });
+
+      await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+
+      assert(communication.db.verificationIncomplete.called);
+    });
+
+    it('handles non-200 response', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 400,
+      });
+
+      await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+
+      assert(communication.db.verificationComplete.called);
+    });
+
+    it('subscription succeeds', async function () {
+      await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+
+      assert(communication.db.subscriptionUpsert.called);
+      assert(communication.db.verificationComplete.called);
+    });
+
+    it('unsubscription succeeds', async function () {
+      communication.db.verificationGetById.restore();
+      verification.mode = 'unsubscribe';
+      sinon.stub(communication.db, 'verificationGetById').resolves(verification);
+
+      await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+
+      assert(communication.db.subscriptionDelete.called);
+      assert(communication.db.verificationComplete.called);
+    });
+
+    it('unsubscription denial succeeds', async function () {
+      communication.db.verificationGetById.restore();
+      verification.mode = 'unsubscribe';
+      sinon.stub(communication.db, 'verificationGetById').resolves(verification);
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'content-type': 'text/plain',
+        },
+        data: 'not the challenge',
+      });
+
+      await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+
+      assert(!communication.db.subscriptionDelete.called);
+      assert(communication.db.verificationComplete.called);
+    });
+
+    it('does not handle strange mode', async function() {
+      communication.db.verificationGetById.restore();
+      verification.mode = 'flarp';
+      sinon.stub(communication.db, 'verificationGetById').resolves(verification);
+
+      try {
+        await communication.verificationProcess(dbCtx, callback, topicId, requestId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.InternalInconsistencyError);
+      }
+    });
+  }); // verificationProcess
+
+  describe('publisherValidate', function () {
+    let dbCtx, topic, verification;
+    beforeEach(function () {
+      dbCtx = {};
+      topic = {
+        url: 'https://example.com/topic/',
+        publisherValidationUrl: 'https://example.com/pub_valid/',
+      };
+      verification = {
+        callback: 'https://exmaple.com/callback/?id=123',
+        httpFrom: 'user@example.com',
+        httpRemoteAddr: '127.0.0.0',
+      };
+
+      sinon.stub(communication, 'axios').resolves({
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'content-type': 'application/json',
+        },
+      });
+
+      communication.db.verificationIncomplete.resolves();
+      communication.db.verificationUpdate.resolves();
+      communication.db.verificationValidated.resolves();
+    });
+
+    it('succeeds', async function () {
+      const result = await communication.publisherValidate(dbCtx, topic, verification);
+
+      assert(communication.db.verificationValidated.called);
+      assert.strictEqual(result, true);
+    });
+
+    it('succeeds with rejection', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 400,
+        statusText: 'Bad Request',
+        headers: {
+          'content-type': 'application/json',
+        },
+      });
+
+      const result = await communication.publisherValidate(dbCtx, topic, verification);
+
+      assert(communication.db.verificationValidated.called);
+      assert(communication.db.verificationUpdate.called);
+      assert.strictEqual(result, true);
+    });
+
+    it('defers on request server error', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 502,
+        statusText: 'Bad Gateway',
+        headers: {
+          'content-type': 'text/plain',
+        },
+      });
+
+      const result = await communication.publisherValidate(dbCtx, topic, verification);
+
+      assert.strictEqual(result, false);
+    });
+
+    it('handles request error', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').throws(new Error());
+
+      const result = await communication.publisherValidate(dbCtx, topic, verification);
+
+      assert.strictEqual(result, false);
+    });
+
+  }); // publisherValidate
+
+  describe('topicFetchProcess', function () {
+    let dbCtx, topic, requestId, topicId;
+
+    beforeEach(function () {
+      dbCtx = {};
+      topic = {
+        url: 'https://example.com/topic/',
+        isDeleted: false,
+        contentHashAlgorithm: 'sha512',
+      };
+      requestId = '7d37ea20-4ef7-417e-a08d-c0ba71269ab1';
+      topicId = '234ec6fb-f1cd-4ac3-8ea9-29ed42ae0e21';
+
+      sinon.stub(communication, 'axios').resolves({
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'content-type': 'text/plain',
+          link: '<https://example.com/hub/>; rel="hub"',
+        },
+        data: 'Jackdaws love my big sphinx of quartz.',
+      });
+
+      communication.db.topicGetById.resolves(topic);
+    });
+
+    it('requires topic exists', async function () {
+      communication.db.topicGetById.restore();
+      sinon.stub(communication.db, 'topicGetById').resolves();
+
+      try {
+        await communication.topicFetchProcess(dbCtx, topicId, requestId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.InternalInconsistencyError);
+      }
+    });
+
+    it ('skips deleted topic', async function () {
+      communication.db.topicGetById.restore();
+      topic.isDeleted = true;
+      sinon.stub(communication.db, 'topicGetById').resolves(topic);
+
+      await communication.topicFetchProcess(dbCtx, topicId, requestId);
+
+      assert(!communication.axios.called);
+    });
+
+    it('handles request error', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').throws(new Error());
+
+      await communication.topicFetchProcess(dbCtx, topicId, requestId);
+
+      assert(communication.db.topicFetchIncomplete.called);
+    });
+
+    it('handles 500 response', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 500,
+        statusText: 'Internal Server Error',
+        headers: {
+          'content-type': 'text/plain',
+        },
+      });
+
+      await communication.topicFetchProcess(dbCtx, topicId, requestId);
+
+      assert(communication.db.topicFetchIncomplete.called);
+    });
+
+    it('handles bad response', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 404,
+        statusText: 'Not Found',
+        headers: {
+          'content-type': 'text/plain',
+        },
+      });
+
+      await communication.topicFetchProcess(dbCtx, topicId, requestId);
+
+      assert(communication.db.topicFetchIncomplete.called);
+    });
+
+    it('recognizes unchanged content', async function () {
+      communication.db.topicGetById.restore();
+      topic.contentHash = 'a630999c61738f3e066d79a1b299a295c5d0598c173e0904d04a707d43988e3e81660bfc1b1779377f4ec26f837d1bb31fa2b860c9ad2d37495d83de32647fea';
+      sinon.stub(communication.db, 'topicGetById').resolves(topic);
+
+      await communication.topicFetchProcess(dbCtx, topicId, requestId);
+
+      assert(communication.db.topicFetchComplete.called);
+      assert(!communication.db.topicSetContent.called);
+    });
+
+    it('updates content', async function () {
+      await communication.topicFetchProcess(dbCtx, topicId, requestId);
+
+      assert(communication.db.topicFetchComplete.called);
+      assert(communication.db.topicSetContent.called);
+    });
+
+    it('updates content with lax link enforcement', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'content-type': 'text/plain',
+          link: '<https://example.com/other/hub/>; rel="hub"',
+        },
+        data: 'Jackdaws love my big sphinx of quartz.',
+      });
+
+      communication.options.communication.strictTopicHubLink = false;
+
+      await communication.topicFetchProcess(dbCtx, topicId, requestId);
+
+      assert(communication.db.topicFetchComplete.called);
+      assert(communication.db.topicSetContent.called);
+    });
+
+    it('deletes topic when hub relation unsatisfied', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'content-type': 'text/plain',
+          link: '<https://example.com/other/hub/>; rel="hub"',
+        },
+        data: 'Jackdaws love my big sphinx of quartz.',
+      });
+
+      await communication.topicFetchProcess(dbCtx, topicId, requestId);
+
+      assert(communication.db.topicFetchComplete.called);
+      assert(communication.db.topicDeleted.called);
+    });
+  }); // topicFetchProcess
+
+  describe('subscriptionDeliveryProcess', function () {
+    let dbCtx, requestId, topic, topicId, subscription, subscriptionId;
+
+    beforeEach(function () {
+      dbCtx = {};
+      topic = {
+        url: 'https://example.com/topic/',
+        isDeleted: false,
+        contentHashAlgorithm: 'sha512',
+        content: 'Jackdaws love my big sphinx of quartz.',
+      };
+      requestId = '7d37ea20-4ef7-417e-a08d-c0ba71269ab1';
+      topicId = '234ec6fb-f1cd-4ac3-8ea9-29ed42ae0e21';
+      subscriptionId = 'c5e6a3ac-dab8-11eb-b758-0025905f714a';
+      subscription = {
+        topicId,
+        callback: 'https://example.com/callback/123',
+        secret: 'superdupersecret',
+        signatureAlgorithm: 'sha512',
+      };
+
+      sinon.stub(communication, 'axios').resolves({
+        status: 200,
+        statusText: 'OK',
+        headers: {
+          'content-type': 'text/plain',
+        },
+        data: 'Jackdaws love my big sphinx of quartz.',
+      });
+
+      communication.db.topicGetContentById.resolves(topic);
+      communication.db.subscriptionGetById.resolves(subscription);
+    });
+
+    it('requires subscription to exist', async function () {
+      communication.db.subscriptionGetById.restore();
+      sinon.stub(communication.db, 'subscriptionGetById').resolves();
+      try {
+        await communication.subscriptionDeliveryProcess(dbCtx, subscriptionId, requestId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.InternalInconsistencyError);
+      }
+    });
+
+    it('requires topic to exist', async function () {
+      communication.db.topicGetContentById.restore();
+      sinon.stub(communication.db, 'topicGetContentById').resolves();
+      try {
+        await communication.subscriptionDeliveryProcess(dbCtx, subscriptionId, requestId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.InternalInconsistencyError);
+      }
+    });
+
+    it('succeeds', async function () {
+      await communication.subscriptionDeliveryProcess(dbCtx, subscriptionId, requestId);
+
+      assert(communication.db.subscriptionDeliveryComplete.called);
+    });
+
+    it('handles request error', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').throws();
+
+      await communication.subscriptionDeliveryProcess(dbCtx, subscriptionId, requestId);
+
+      assert(communication.db.subscriptionDeliveryIncomplete.called);
+    });
+
+    it('handles 5xx response', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 500,
+        statusText: 'Internal Server Error',
+        headers: {
+          'content-type': 'text/plain',
+        },
+      });
+
+      await communication.subscriptionDeliveryProcess(dbCtx, subscriptionId, requestId);
+
+      assert(communication.db.subscriptionDeliveryIncomplete.called);
+    });
+
+    it('handles 4xx response', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 404,
+        statusText: 'Not Found',
+        headers: {
+          'content-type': 'text/plain',
+        },
+      });
+
+      await communication.subscriptionDeliveryProcess(dbCtx, subscriptionId, requestId);
+
+      assert(communication.db.subscriptionDeliveryIncomplete.called);
+    });
+
+    it('handles 410 response', async function () {
+      communication.axios.restore();
+      sinon.stub(communication, 'axios').resolves({
+        status: 410,
+        statusText: 'Gone',
+        headers: {
+          'content-type': 'text/plain',
+        },
+      });
+
+      await communication.subscriptionDeliveryProcess(dbCtx, subscriptionId, requestId);
+
+      assert(communication.db.subscriptionDeliveryGone.called);
+    });
+
+    it('unsubscribes when topic is deleted', async function () {
+      topic.isDeleted = true;
+      communication.db.topicGetContentById.restore();
+      sinon.stub(communication.db, 'topicGetContentById').resolves(topic);
+
+      await communication.subscriptionDeliveryProcess(dbCtx, subscriptionId, requestId);
+
+      assert(communication.db.verificationInsert.called);
+      assert(communication.db.subscriptionDeliveryComplete.called);
+    });
+  }); // subscriptionDeliveryProcess
+
+  describe('topicFetchClaimAndProcessById', function () {
+    let dbCtx, topicId, requestId;
+    beforeEach(function () {
+      dbCtx = {};
+      requestId = '7d37ea20-4ef7-417e-a08d-c0ba71269ab1';
+      topicId = '234ec6fb-f1cd-4ac3-8ea9-29ed42ae0e21';
+      sinon.stub(communication, 'topicFetchProcess');
+    });
+    it('covers claim', async function () {
+      communication.db.topicFetchClaimById.resolves({
+        changes: 1,
+      })
+      await communication.topicFetchClaimAndProcessById(dbCtx, topicId, requestId);
+      assert(communication.topicFetchProcess.called);
+    });
+    it('covers no claim', async function () {
+      communication.db.topicFetchClaimById.resolves({
+        changes: 0,
+      })
+      await communication.topicFetchClaimAndProcessById(dbCtx, topicId, requestId);
+      assert(!communication.topicFetchProcess.called);
+    });
+  }); // topicFetchClaimAndProcessById
+
+  describe('verificationClaimAndProcessById', function () {
+    let dbCtx, verificationId, requestId;
+    beforeEach(function () {
+      dbCtx = {};
+      verificationId = '28488311-6652-42ea-9839-7bbc42b246cb';
+      requestId = '7d37ea20-4ef7-417e-a08d-c0ba71269ab1';
+      sinon.stub(communication, 'verificationProcess');
+    });
+    it('covers claim', async function () {
+      communication.db.verificationClaimById.resolves({
+        changes: 1,
+      })
+      await communication.verificationClaimAndProcessById(dbCtx, verificationId, requestId);
+      assert(communication.verificationProcess.called);
+    });
+    it('covers no claim', async function () {
+      communication.db.verificationClaimById.resolves({
+        changes: 0,
+      })
+      await communication.verificationClaimAndProcessById(dbCtx, verificationId, requestId);
+      assert(!communication.verificationProcess.called);
+    });
+  }); // verificationClaimAndProcessById
+
+  describe('workFeed', function () {
+    let wanted;
+    beforeEach(function () {
+      sinon.stub(communication, 'topicFetchProcess');
+      sinon.stub(communication, 'verificationProcess');
+      sinon.stub(communication, 'subscriptionDeliveryProcess');
+    });
+    it('succeeds', async function () {
+      const topicIds = [ { id: '' }, { id: '' } ];
+      communication.db.topicFetchClaim.resolves(topicIds);
+      const verificationIds = [ { id: '' }, { id: '' } ];
+      communication.db.verificationClaim.resolves(verificationIds);
+      const subscriptionIds = [ { id: '' }, { id: '' } ];
+      communication.db.subscriptionDeliveryClaim.resolves(subscriptionIds);
+      const expectedLength = [topicIds, verificationIds, subscriptionIds].map((x) => x.length).reduce((a, b) => a + b, 0);
+      wanted = 10;
+
+      const result = await communication.workFeed(wanted);
+
+      assert.strictEqual(result.length, expectedLength);
+    });
+    it('covers no wanted work', async function () {
+      const result = await communication.workFeed(0);
+      assert.strictEqual(result.length, 0);
+      assert(!communication.db.topicFetchClaim.called);
+      assert(!communication.db.verificationClaim.called);
+      assert(!communication.db.subscriptionDeliveryClaim.called);
+    });
+    it('deals with failure', async function () {
+      const topicIds = [ { id: '' }, { id: '' } ];
+      communication.db.topicFetchClaim.resolves(topicIds);
+      communication.db.verificationClaim.throws();
+      const expectedLength = topicIds.length;
+      wanted = 10;
+
+      const result = await communication.workFeed(wanted);
+
+      assert.strictEqual(result.length, expectedLength);
+    });
+  }); // workFeed
+
+}); // Communication
\ No newline at end of file
diff --git a/test/src/db/base.js b/test/src/db/base.js
new file mode 100644 (file)
index 0000000..18871f4
--- /dev/null
@@ -0,0 +1,303 @@
+/* eslint-env mocha */
+'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 DB = require('../../../src/db/base');
+const DBErrors = require('../../../src/db/errors');
+
+describe('DatabaseBase', function () {
+  let db;
+  beforeEach(function () {
+    db = new DB(stubLogger);
+  });
+  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 () {
+      [
+        '_engineInfo',
+      ].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('_camelfy', function () {
+    it('empty arg', function () {
+      const result = DB._camelfy();
+      assert.strictEqual(result, undefined);
+    });
+    it('no change', function () {
+      const str = 'camelCase';
+      const result = DB._camelfy(str);
+      assert.strictEqual(result, str);
+    });
+    it('does expected', function () {
+      const str = 'snake_case_thing';
+      const result = DB._camelfy(str);
+      assert.strictEqual(result, 'snakeCaseThing');
+    });
+  }); // _camelfy
+
+  describe('_ensureTypes', function () {
+    let object;
+    beforeEach(function () {
+      object = {
+        num: 123,
+        bignum: BigInt(456),
+        str: 'some words',
+        veryNull: null,
+        obj: {},
+        buf: Buffer.from('foop'),
+      };
+    });
+    it('succeeds', function () {
+      db._ensureTypes(object, ['num', 'bignum'], ['number']);
+      db._ensureTypes(object, ['str', 'veryNull'], ['string', 'null']);
+      db._ensureTypes(object, ['buf'], ['buffer']);
+    });
+    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('schemaCheck', 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.schemaCheck();
+    });
+    it('covers failure', async function() {
+      db.schemaVersionsSupported = {
+        min: {
+          major: 3,
+          minor: 2,
+          patch: 1,
+        },
+        max: {
+          major: 5,
+          minor: 0,
+          patch: 0,
+        },
+      };
+      try {
+        await db.schemaCheck();
+        assert.fail('did not get expected exception');
+      } catch (e) {
+        assert(e instanceof DBErrors.MigrationNeeded);
+      }
+    });
+  }); // schemaCheck
+
+  describe('_topicDefaults', function () {
+    let topic;
+    beforeEach(function () {
+      topic = {};
+    });
+    it('covers', function () {
+      db._topicDefaults(topic);
+      assert.strictEqual(topic.leaseSecondsPreferred, db.topicLeaseDefaults.leaseSecondsPreferred);
+    });
+    it('covers empty', function () {
+      db._topicDefaults();
+    });
+  }); // _topicDefaults
+
+  describe('_topicSetDataValidate', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        url: 'https://example.com/',
+
+      };
+    });
+    it('covers success', function () {
+      db._topicSetDataValidate(data);
+    });
+    it('covers invalid value', function () {
+     data.leaseSecondsPreferred = -100;
+     try {
+       db._topicSetDataValidate(data);
+       assert.fail('did not get expected exception');
+     } catch (e) {
+       assert(e instanceof DBErrors.DataValidation);
+     }
+    });
+    it('covers invalid range', function () {
+      data.leaseSecondsPreferred = 10000;
+      data.leaseSecondsMax = 1000;
+      try {
+        db._topicSetDataValidate(data);
+        assert.fail('did not get expected exception');
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+  }); // _topicSetDataValidation
+
+  describe('_topicSetContentDataValidate', function () {
+    it('covers', function () {
+      db._topicSetContentDataValidate({
+        content: Buffer.from('foo'),
+        contentHash: '123',
+      });
+    });
+  }); // _topicSetContentDataValidate
+
+  describe('_topicUpdateDataValidate', function () {
+    it('succeeds', function () {
+      db._topicUpdateDataValidate({
+        leaseSecondsPreferred: 123,
+        leaseSecondsMin: 100,
+        leaseSecondsMax: 1000,
+        publisherValidationUrl: 'https://example.com/pub/',
+        contentHashAlgorithm: 'sha256',
+      });
+    });
+    it('covers no url', function () {
+      db._topicUpdateDataValidate({
+        leaseSecondsPreferred: 123,
+        leaseSecondsMin: 100,
+        leaseSecondsMax: 1000,
+        contentHashAlgorithm: 'sha256',
+      });
+    });
+    it('rejects invalid url', function () {
+      try {
+        db._topicUpdateDataValidate({
+          leaseSecondsPreferred: 123,
+          leaseSecondsMin: 100,
+          leaseSecondsMax: 1000,
+          publisherValidationUrl: 'flarbl',
+          contentHashAlgorithm: 'sha256',
+        });
+        assert.fail('did not get expected exception');
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+    it('rejects invalid algorithm', function () {
+      try {
+        db._topicUpdateDataValidate({
+          leaseSecondsPreferred: 123,
+          leaseSecondsMin: 100,
+          leaseSecondsMax: 1000,
+          publisherValidationUrl: 'https://example.com/pub/',
+          contentHashAlgorithm: 'md6',
+        });
+        assert.fail('did not get expected exception');
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+  }); // _topicUpdateDataValidate
+
+  describe('_verificationDataValidate', function () {
+    it('covers', function () {
+      db._verificationDataValidate({
+        topicId: 'b9ede5aa-e595-11eb-b30f-0025905f714a',
+        callback: 'https://example.com/cb',
+        mode: 'subscribe',
+        leaseSeconds: 123,
+        isPublisherValidated: true,
+      });
+    });
+  }); // _verificationDataValidate
+
+  describe('_subscriptionUpsertDataValidate', function () {
+    it('covers', function () {
+      db._subscriptionUpsertDataValidate({
+        topicId: 'b9ede5aa-e595-11eb-b30f-0025905f714a',
+        callback: 'https://example.com/cb',
+        leaseSeconds: 123,
+      });
+    });
+  }); // _subscriptionUpsertDataValidate
+
+  describe('_subscriptionUpdateDataValidate', function () {
+    it('succeeds', function () {
+      db._subscriptionUpdateDataValidate({
+        signatureAlgorithm: 'sha256',
+      });
+    });
+    it('rejects invalid', function () {
+      try {
+        db._subscriptionUpdateDataValidate({
+          signatureAlgorithm: 'md5',
+        });
+        assert.fail('did not get expected exception');
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+  }); // _subscriptionUpdateDataValidate
+
+  describe('_verificationUpdateDataValidate', function () {
+    it('covers', function () {
+      db._verificationUpdateDataValidate({
+        verificationId: 'b9ede5aa-e595-11eb-b30f-0025905f714a',
+        mode: 'denied',
+        isPublisherValidated: true,
+      });
+    });
+  }); // _verificationUpdateDataValidate
+
+}); // 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..3cb07f9
--- /dev/null
@@ -0,0 +1,469 @@
+/* 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 noExpectedException = 'did not receive expected exception';
+  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',
+        },
+      },
+    });
+  }
+
+  implementations.forEach(function (i) {
+    describe(i.name, function () {
+      let DB, db;
+      let topicId, subscriptionId, verificationId;
+      const claimant = '96bff010-d9e6-11eb-b95d-0025905f714a';
+
+      before(async function () {
+        this.timeout(10 * 1000); // Allow some time for creating tables et cetera.
+        // eslint-disable-next-line security/detect-non-literal-require
+        DB = require(i.module);
+        db = new DB(stubLogger, i.config);
+        await db.schemaCheck();
+        await db._purgeTables(true);
+      });
+      after(async function () {
+        await db._closeConnection();
+      });
+      it('instantiated', function () {
+        assert(db);
+      });
+
+      describe('Authentication', function () {
+        let identifier, credential;
+        beforeEach(function () {
+          identifier = 'username';
+          credential = 'myEncryptedPassword';
+        });
+        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);
+          });
+        });
+        step('valid auth event', async function() {
+          await db.context(async (dbCtx) => {
+            await db.authenticationSuccess(dbCtx, identifier);
+            const authInfo = await db.authenticationGet(dbCtx, identifier);
+            assert.notStrictEqual(authInfo.lastAuthentication, undefined);
+          });
+        });
+        step('update auth entry', async function() {
+          await db.context(async (dbCtx) => {
+            credential = 'myNewPassword';
+            await db.authenticationUpsert(dbCtx, identifier, credential);
+            const authInfo = await db.authenticationGet(dbCtx, identifier);
+            assert.strictEqual(authInfo.credential, credential);
+          });
+        });
+      }); // Authentication
+
+      describe('Topic', function () {
+        step('requires data', async function () {
+          try {
+            await db.context(async (dbCtx) => {
+              await db.topicSet(dbCtx);
+            });
+            assert.fail(noExpectedException);
+          } catch (e) {
+            assert(e instanceof DBErrors.DataValidation);
+          }
+        });
+        step('creates topic', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.topicSet(dbCtx, testData.topicSet);
+            topicId = result.lastInsertRowid;
+            assert.strictEqual(result.changes, 1);
+          });
+        });
+        step('gets topic by url', async function () {
+          await db.context(async (dbCtx) => {
+            const topic = await db.topicGetByUrl(dbCtx, testData.topicSet.url);
+            assert.strictEqual(topic.url, testData.topicSet.url);
+          });
+        });
+        step('updates topic', async function () {
+          await db.context(async(dbCtx) => {
+            const result = await db.topicSet(dbCtx, testData.topicUpdate);
+            assert.strictEqual(result.changes, 1);
+          });
+        });
+        step('also updates topic', async function () {
+          const data = {
+            topicId,
+            leaseSecondsMin: 60,
+          }
+          await db.context(async(dbCtx) => {
+            let topic = await db.topicGetByUrl(dbCtx, testData.topicSet.url);
+            await db.topicUpdate(dbCtx, { ...topic, ...data });
+            topic = await db.topicGetByUrl(dbCtx, testData.topicSet.url);
+            assert.strictEqual(Number(topic.leaseSecondsMin), data.leaseSecondsMin);
+          });
+        });
+        step('gets topic by id', async function () {
+          await db.context(async (dbCtx) => {
+            const topic = await db.topicGetById(dbCtx, topicId);
+            assert.strictEqual(topic.url, testData.topicSet.url);
+            assert.strictEqual(Number(topic.leaseSecondsPreferred), testData.topicUpdate.leaseSecondsPreferred);
+          });
+        });
+        step('sets topic content', async function () {
+          const data = {
+            ...testData.topicSetContent,
+            topicId,
+          };
+          await db.context(async (dbCtx) => {
+            const result = await db.topicSetContent(dbCtx, data);
+            assert.strictEqual(result.changes, 1);
+          });
+        });
+        step('gets topic content', async function () {
+          await db.context(async (dbCtx) => {
+            const topic = await db.topicGetContentById(dbCtx, topicId);
+            assert.strictEqual(topic.contentHash, testData.topicSetContent.contentHash);
+          });
+        });
+        step('sets publish request', async function() {
+          await db.context(async (dbCtx) => {
+            const result = await db.topicFetchRequested(dbCtx, topicId);
+            assert.strictEqual(result.changes, 1);
+            
+            const topic = await db.topicGetById(dbCtx, topicId);
+            assert(topic.lastPublish);
+          });
+        });
+        step('claims topic fetch', async function () {
+          const claimTimeoutSeconds = 10;
+          const wanted = 5;
+          let topicIds;
+          await db.context(async (dbCtx) => {
+            topicIds = await db.topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+          });
+          assert(topicIds.includes(topicId));
+        });
+        step('incompletes topic fetch', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.topicFetchIncomplete(dbCtx, topicId);
+            assert.strictEqual(result.changes, 1);
+            const topic = await db.topicGetById(dbCtx, topicId);
+            assert.strictEqual(Number(topic.contentFetchAttemptsSinceSuccess), 1);
+          });
+        });
+        step('claims topic fetch by id', async function () {
+          const claimTimeoutSeconds = 10;
+          await db.context(async (dbCtx) => {
+            const result = await db.topicFetchClaimById(dbCtx, topicId, claimTimeoutSeconds, claimant);
+            assert.strictEqual(result.changes, 1);
+          });
+        });
+        step('completes topic fetch', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.topicFetchComplete(dbCtx, topicId);
+            assert.strictEqual(result.changes, 1);
+            const topic = await db.topicGetById(dbCtx, topicId);
+            assert.strictEqual(Number(topic.contentFetchAttemptsSinceSuccess), 0);
+          });
+        });
+        step('deletes a topic', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.topicSet(dbCtx, testData.anotherTopicSet);
+            const anotherTopicId = result.lastInsertRowid;
+            await db.topicDeleted(dbCtx, anotherTopicId);
+            const topic = await db.topicGetById(dbCtx, anotherTopicId);
+            assert.strictEqual(topic.isDeleted, true);
+          });
+        });
+        step('update un-deletes a topic', async function () {
+          await db.context(async (dbCtx) => {
+            const result = await db.topicSet(dbCtx, testData.anotherTopicSet);
+            const anotherTopicId = result.lastInsertRowid;
+            const topic = await db.topicGetById(dbCtx, anotherTopicId);
+            assert.strictEqual(topic.isDeleted, false);
+          });
+        });
+        step('gets all topics', async function() {
+          await db.context(async (dbCtx) => {
+            const topics = await db.topicGetAll(dbCtx);
+            assert(topics.length);
+          });
+        });
+      }); // Topic
+
+      describe('Subscription', function () {
+        step('requires data', async function () {
+          try {
+            await db.context(async (dbCtx) => {
+              await db.subscriptionUpsert(dbCtx);
+            });
+            assert.fail(noExpectedException);
+          } catch (e) {
+            assert(e instanceof DBErrors.DataValidation);
+          }
+        });
+        step('creates subscription', async function () {
+          const data = {
+            ...testData.subscriptionUpsert,
+            topicId,
+          }
+          await db.context(async (dbCtx) => {
+            const result = await db.subscriptionUpsert(dbCtx, data);
+            assert(result.lastInsertRowid);
+            subscriptionId = result.lastInsertRowid;
+            assert.strictEqual(result.changes, 1);
+          });
+        });
+        step('gets subscription', async function () {
+          await db.context(async (dbCtx) => {
+            const subscription = await db.subscriptionGet(dbCtx, testData.subscriptionUpsert.callback, topicId);
+            assert.strictEqual(subscription.secret, testData.subscriptionUpsert.secret);
+          });
+        });
+        step('gets subscription by id', async function () {
+          await db.context(async (dbCtx) => {
+            const subscription = await db.subscriptionGetById(dbCtx, subscriptionId);
+            assert.strictEqual(subscription.secret, testData.subscriptionUpsert.secret);
+          });
+        });
+        step('gets subscriptions by topic', async function() {
+          await db.context(async (dbCtx) => {
+            const subscriptions = await db.subscriptionsByTopicId(dbCtx, topicId);
+            assert(subscriptions.length);
+          });
+        });
+        step('count subscriptions', async function () {
+          await db.context(async (dbCtx) => {
+            const count = await db.subscriptionCountByTopicUrl(dbCtx, testData.topicSet.url);
+            assert.strictEqual(Number(count.count), 1);
+          });
+        });
+        step('claim subscription', async function () {
+          const claimTimeoutSeconds = 10;
+          const wanted = 5;
+          let subscriptionIds;
+          await db.context(async (dbCtx) => {
+            subscriptionIds = await db.subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+          });
+          assert(subscriptionIds.includes(subscriptionId));
+        });
+        step('incompletes subscription', async function () {
+          const { callback } = testData.subscriptionUpsert;
+          await db.context(async (dbCtx) => {
+            await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId);
+            const topic = await db.subscriptionGetById(dbCtx, subscriptionId);
+            assert.strictEqual(Number(topic.deliveryAttemptsSinceSuccess), 1);
+          });
+        });
+        step('claim subscription by id', async function () {
+          const claimTimeoutSeconds = 10;
+          await db.context(async (dbCtx) => {
+            const result = await db.subscriptionDeliveryClaimById(dbCtx, subscriptionId, claimTimeoutSeconds, claimant);
+            assert.strictEqual(result.changes, 1);
+          });
+        });
+        step('complete subscription', async function () {
+          const { callback } = testData.subscriptionUpsert;
+          await db.context(async (dbCtx) => {
+            await db.subscriptionDeliveryComplete(dbCtx, callback, topicId);
+            const subscription = await db.subscriptionGetById(dbCtx, subscriptionId);
+            assert.strictEqual(Number(subscription.deliveryAttemptsSinceSuccess), 0);
+          });
+        });
+        step('subscription delete', async function () {
+          const { callback } = testData.subscriptionUpsert;
+          await db.context(async (dbCtx) => {
+            const result = await db.subscriptionDelete(dbCtx, callback, topicId);
+            assert.strictEqual(result.changes, 1);
+            const subscription = await db.subscriptionGetById(dbCtx, subscriptionId);
+            assert(!subscription);
+          });
+        });
+        step('create subscription', async function () {
+          const data = {
+            ...testData.subscriptionUpsert,
+            secret: 'newSecret',
+            topicId,
+          }
+          await db.context(async (dbCtx) => {
+            const result = await db.subscriptionUpsert(dbCtx, data);
+            assert(result.lastInsertRowid);
+            assert.notStrictEqual(result.lastInsertRowid, subscriptionId);
+            subscriptionId = result.lastInsertRowid;
+            assert.strictEqual(result.changes, 1);
+          });
+        });
+        step('update subscription', async function () {
+          const data = {
+            subscriptionId,
+            signatureAlgorithm: 'sha256',
+          };
+          await db.context(async (dbCtx) => {
+            await db.subscriptionUpdate(dbCtx, data);
+          });
+        });
+        step('claim subscription', async function () {
+          const claimTimeoutSeconds = 10;
+          const wanted = 5;
+          let subscriptionIds;
+          await db.context(async (dbCtx) => {
+            subscriptionIds = await db.subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+          });
+          assert(subscriptionIds.includes(subscriptionId));
+        });
+        step('subscription gone', async function () {
+          const { callback } = testData.subscriptionUpsert;
+          await db.context(async (dbCtx) => {
+            await db.subscriptionDeliveryGone(dbCtx, callback, topicId);
+            const subscription = await db.subscriptionGetById(dbCtx, subscriptionId);
+            assert(!subscription);
+          });
+        });
+      }); // Subscription
+
+      describe('Verification', function () {
+        step('requires data', async function() {
+          try {
+            await db.context(async (dbCtx) => {
+              await db.verificationInsert(dbCtx);
+            });
+            assert.fail(noExpectedException);
+          } catch (e) {
+            assert(e instanceof DBErrors.DataValidation);
+          }
+        });
+        step('creates verification', async function() {
+          const verificationData = {
+            ...testData.verificationInsert,
+            topicId,
+          };
+          await db.context(async (dbCtx) => {
+            verificationId = await db.verificationInsert(dbCtx, verificationData);
+            assert(verificationId);
+          });
+        });
+        step('gets verification', async function() {
+          await db.context(async (dbCtx) => {
+            const verification = await db.verificationGetById(dbCtx, verificationId);
+            assert.strictEqual(verification.mode, testData.verificationInsert.mode);
+          });
+        });
+        step('validates verification', async function() {
+          await db.context(async (dbCtx) => {
+            await db.verificationValidated(dbCtx, verificationId);
+            const verification = await db.verificationGetById(dbCtx, verificationId);
+            assert.strictEqual(verification.isPublisherValidated, true);
+          });
+        });
+        step('claims verification', async function() {
+          const claimTimeoutSeconds = 10;
+          const wanted = 5;
+          let verificationIds;
+          await db.context(async (dbCtx) => {
+            verificationIds = await db.verificationClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+          });
+          assert(verificationIds.includes(verificationId));
+        });
+        step('releases verification', async function() {
+          await db.context(async (dbCtx) => {
+            await db.verificationRelease(dbCtx, verificationId);
+          });
+        });
+        step('updates verification', async function() {
+          const verificationData = {
+            ...testData.verificationUpdate,
+          };
+          await db.context(async (dbCtx) => {
+            db.verificationUpdate(dbCtx, verificationId, verificationData);
+            const verification = await db.verificationGetById(dbCtx, verificationId);
+            assert.strictEqual(verification.isPublisherValidated, testData.verificationUpdate.isPublisherValidated);
+          });
+        });
+        step('claims verification by id', async function() {
+          const claimTimeoutSeconds = 10;
+          await db.context(async (dbCtx) => {
+            const result = await db.verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant);
+            assert.strictEqual(result.changes, 1);
+          });
+        });
+        step('incompletes verification', async function() {
+          await db.context(async (dbCtx) => {
+            await db.verificationIncomplete(dbCtx, verificationId);
+          });
+        });
+        step('claims verification by id', async function() {
+          const claimTimeoutSeconds = 10;
+          await db.context(async (dbCtx) => {
+            const result = await db.verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant);
+            assert.strictEqual(result.changes, 1);
+          });
+        });
+        step('completes verification', async function() {
+          await db.context(async (dbCtx) => {
+            const verification = await db.verificationGetById(dbCtx, verificationId);
+            await db.subscriptionUpsert(dbCtx, verification);
+            await db.verificationComplete(dbCtx, verificationId, testData.verificationInsert.callback, topicId);
+            const count = await db.subscriptionCountByTopicUrl(dbCtx, testData.topicSet.url);
+            assert.strictEqual(Number(count.count), 1);
+          });
+        });
+
+      }); // Verification
+
+    }); // 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..5df49fd
--- /dev/null
@@ -0,0 +1,1502 @@
+/* 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');
+// eslint-disable-next-line node/no-unpublished-require
+const sinon = require('sinon');
+const DBStub = require('../../stub-db');
+const stubLogger = require('../../stub-logger');
+const DB = require('../../../src/db/postgres');
+const DBErrors = require('../../../src/db/errors');
+const common = require('../../../src/common');
+const Config = require('../../../config');
+
+const noExpectedException = 'did not receive expected exception';
+
+describe('DatabasePostgres', function () {
+  let db, options, pgpStub;
+  let dbCtx, claimant, claimTimeoutSeconds, callback, subscriptionId, topicId, verificationId;
+  let topicUrl, leaseSeconds, secret, httpRemoteAddr, httpFrom, retryDelays, wanted;
+  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,
+    options = new Config('test');
+    db = new DB(stubLogger, options, pgpStub);
+  });
+  beforeEach(function () {
+    stubLogger._reset();
+    dbCtx = db.db;
+    claimant = '19af19b8-6be3-4a6f-8946-65f5f1ccc5d7';
+    claimTimeoutSeconds = 300;
+    subscriptionId = 'fbaf8f19-ed9c-4a21-89ae-98b7005e3bf6';
+    topicUrl = 'https://example.com/blog';
+    callback = 'https://example.com/callback?id=123';
+    topicId = 'c59d4bda-10ad-41d9-99df-4ce8bc331424';
+    verificationId = '55cd7748-d2d5-11eb-b355-0025905f714a';
+    retryDelays = [60];
+    leaseSeconds = 86400;
+    secret = 'secret';
+    httpRemoteAddr = '127.0.0.1';
+    httpFrom = 'user@example.com';
+    wanted = 5;
+});
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  // Ensure all interface methods are implemented
+  describe('Implementation', function () {
+    it('implements interface', async function () {
+      const results = await Promise.allSettled(DBStub._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);
+      });
+    }); // 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('schemaCheck', function () {
+    it('passes supported version', async function () {
+      const version = { major: 1, minor: 0, patch: 0 };
+      sinon.stub(db.db, 'one').resolves(version);
+      await db.schemaCheck(false);
+    });
+    it('fails low version', async function () {
+      const version = { major: 0, minor: 0, patch: 0 };
+      sinon.stub(db.db, 'one').resolves(version);
+      try {
+        await db.schemaCheck(false);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.MigrationNeeded);
+      }
+    });
+    it('fails high version', async function () {
+      const version = { major: 100, minor: 100, patch: 100 };
+      sinon.stub(db.db, 'one').resolves(version);
+      try {
+        await db.schemaCheck(false);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.MigrationNeeded);
+      }
+    });
+    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.schemaCheck();
+    });
+  }); // schemaCheck
+
+  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 () {
+      const err = new Error();
+      pgpStub.QueryFile = class {
+        constructor() {
+          this.error = err;
+        }
+      };
+      const _queryFile = db._queryFileHelper(pgpStub);
+      try {
+        _queryFile();
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.strictEqual(e, err);
+      }
+    });
+  }); // _queryFileHelper
+
+  describe('_closeConnection', function () {
+    it('success', async function () {
+      sinon.stub(db._pgp, 'end');
+      await db._closeConnection();
+      assert(db._pgp.end.called);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db._pgp, 'end').throws(expected);
+      try {
+        await db._closeConnection();
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // _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 () {
+      const expected = new Error();
+      sinon.stub(db.db, 'tx').rejects(expected);
+      try {
+        await db._purgeTables(true);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // _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('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);
+      try {
+        await db.authenticationSuccess(dbCtx, identifier);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof 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() {
+      const expected = new Error('blah');
+      sinon.stub(db.db, 'oneOrNone').rejects(expected);
+      try {
+        await db.authenticationGet(dbCtx, identifier, credential);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // 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);
+      try {
+        await db.authenticationUpsert(dbCtx, identifier, credential);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // authenticationUpsert
+
+  describe('subscriptionsByTopicId', function () {
+    it('success', async function () {
+      const expected = [];
+      sinon.stub(db.db, 'manyOrNone').resolves(expected);
+      const result = await db.subscriptionsByTopicId(dbCtx, topicUrl);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'manyOrNone').throws(expected);
+      try {
+        await db.subscriptionsByTopicId(dbCtx, topicUrl);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionsByTopicId
+
+  describe('subscriptionCountByTopicUrl', function () {
+    it('success', async function () {
+      const expected = { count: 3 };
+      sinon.stub(db.db, 'one').resolves(expected);
+      const result = await db.subscriptionCountByTopicUrl(dbCtx, topicUrl);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'one').throws(expected);
+      try {
+        await db.subscriptionCountByTopicUrl(dbCtx, topicUrl);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionCountByTopicUrl
+
+  describe('subscriptionDelete', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [ {} ],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      }
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.subscriptionDelete(dbCtx, callback, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'result').throws(expected);
+      try {
+        await db.subscriptionDelete(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionDelete
+
+  describe('subscriptionDeliveryClaim', function () {
+    it('success', async function() {
+      const dbResult = [
+        {
+          id: 'c2e254c5-aa6e-4a8f-b1a1-e474b07392bb',
+        },
+      ];
+      const expected = ['c2e254c5-aa6e-4a8f-b1a1-e474b07392bb'];
+      sinon.stub(db.db, 'manyOrNone').resolves(dbResult);
+      const result = await db.subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'manyOrNone').throws(expected);
+      try {
+        await db.subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant );
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionDeliveryClaim
+
+  describe('subscriptionDeliveryClaimById', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ id: 'c2e254c5-aa6e-4a8f-b1a1-e474b07392bb' }],
+        duration: 11,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: 'c2e254c5-aa6e-4a8f-b1a1-e474b07392bb',
+        duration: 11,
+      }
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.subscriptionDeliveryClaimById(dbCtx, subscriptionId, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 11,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.subscriptionDeliveryClaimById(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryClaimById
+
+  describe('subscriptionDeliveryComplete', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.subscriptionDeliveryComplete(dbCtx, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult);
+      try {
+        await db.subscriptionDeliveryComplete(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbResult0 = {
+        rowCount: 1,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.subscriptionDeliveryComplete(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryComplete
+
+  describe('subscriptionDeliveryGone', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.subscriptionDeliveryGone(dbCtx, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.subscriptionDeliveryGone(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryGone
+
+  describe('subscriptionDeliveryIncomplete', function () {
+    it('success', async function() {
+      const dbOne = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays);
+    });
+    it('success covers default', async function() {
+      const dbOne = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbOne = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbOne = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryIncomplete
+
+  describe('subscriptionGet', function () {
+    it('success', async function() {
+      const expected = {
+        id: subscriptionId,
+      };
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.subscriptionGet(dbCtx, callback, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.subscriptionGet(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionGet
+
+  describe('subscriptionGetById', function () {
+    it('success', async function() {
+      const expected = {
+        id: subscriptionId,
+      };
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.subscriptionGetById(dbCtx, subscriptionId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.subscriptionGetById(dbCtx, subscriptionId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionGetById
+
+  describe('subscriptionUpsert', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        callback,
+        topicId,
+        leaseSeconds,
+        secret,
+        httpRemoteAddr,
+        httpFrom,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ id: subscriptionId }],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: subscriptionId,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.subscriptionUpsert(dbCtx, data);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.subscriptionUpsert(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionUpsert
+
+  describe('subscriptionUpdate', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        signatureAlgorithm: 'sha256',
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.subscriptionUpdate(dbCtx, data);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.subscriptionUpdate(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionUpdate
+
+  describe('topicDeleted', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.topicDeleted(dbCtx, topicId);
+    });
+    it('failure', async function() {
+      const dbResult = {
+        rowCount: 0,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicDeleted(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicDeleted
+
+  describe('topicFetchClaim', function () {
+    it('success', async function() {
+      const dbResult = [{ id: topicId }];
+      const expected = [topicId];
+      sinon.stub(db.db, 'manyOrNone').resolves(dbResult);
+      const result = await db.topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'manyOrNone').throws(expected);
+      try {
+        await db.topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicFetchClaim
+
+  describe('topicFetchClaimById', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.topicFetchClaimById(dbCtx, topicId, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'result').throws(expected);
+      try {
+        await db.topicFetchClaimById(dbCtx, topicId, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicFetchClaimById
+
+  describe('topicFetchComplete', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.topicFetchComplete(dbCtx, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicFetchComplete(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.topicFetchComplete(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchComplete
+
+  describe('topicFetchIncomplete', function () {
+    it('success', async function() {
+      const dbOne = { currentAttempt: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      }
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      const result = await db.topicFetchIncomplete(dbCtx, topicId, retryDelays);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers defaults', async function() {
+      const dbOne = { currentAttempt: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      }
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      const result = await db.topicFetchIncomplete(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbOne = { currentAttempt: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      }
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.topicFetchIncomplete(dbCtx, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbOne = { currentAttempt: 0 };
+      const dbResult0 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      }
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.topicFetchIncomplete(dbCtx, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchIncomplete
+
+  describe('topicFetchRequested', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.topicFetchRequested(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicFetchRequested(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchRequested
+
+  describe('topicGetAll', function () {
+    it('success', async function() {
+      const expected = [{ id: topicId }];
+      sinon.stub(db.db, 'manyOrNone').resolves(expected);
+      const result = await db.topicGetAll(dbCtx);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers default', async function() {
+      const expected = undefined;
+      sinon.stub(db.db, 'manyOrNone').resolves(expected);
+      const result = await db.topicGetAll(dbCtx);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'manyOrNone').throws(expected);
+      try {
+        await db.topicGetAll(dbCtx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetById
+
+  describe('topicGetById', function () {
+    it('success', async function() {
+      const expected = { id: topicId };
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers none', async function() {
+      const expected = undefined;
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers no defaults', async function () {
+      const expected = { id: topicId };
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetById(dbCtx, topicId, false);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.topicGetById(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetById
+
+  describe('topicGetByUrl', function () {
+    it('success', async function() {
+      const expected = [];
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetByUrl(dbCtx, topicUrl);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.topicGetByUrl(dbCtx, topicUrl);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetByUrl
+
+  describe('topicGetContentById', function () {
+    it('success', async function() {
+      const expected = { id: topicId };
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetContentById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers default', async function() {
+      const expected = undefined;
+      sinon.stub(db.db, 'oneOrNone').resolves(expected);
+      const result = await db.topicGetContentById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.topicGetContentById(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetContentById
+
+  describe('topicSet', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        url: topicUrl,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ id: topicId }],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: topicId,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.topicSet(dbCtx, data);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicSet(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('fails invalid value', async function () {
+      sinon.stub(db.db, 'result');
+      try {
+        data.leaseSecondsPreferred = -100;
+        await db.topicSet(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+      assert(!db.db.result.called);
+    });
+    it('fails invalid values', async function () {
+      sinon.stub(db.db, 'result');
+      try {
+        data.leaseSecondsPreferred = 10;
+        data.leaseSecondsMax = 100;
+        data.leaseSecondsMin = 50;
+        await db.topicSet(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+      assert(!db.db.result.called);
+    });
+  }); // topicSet
+
+  describe('topicSetContent', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        content: 'content',
+        contentType: 'text/plain',
+        contentHash: 'abc123',
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.topicSetContent(dbCtx, data);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicSetContent(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicSetContent
+
+  describe('topicUpdate', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        leaseSecondsPreferred: 123,
+        leaseSecondsMin: 100,
+        leaseSecondsMax: 1000,
+        publisherValidationUrl: null,
+        contentHashAlgorithm: 'sha256',
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.topicUpdate(dbCtx, data);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.topicUpdate(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+
+  });
+
+  describe('verificationClaim', function () {
+    it('success', async function() {
+      const dbManyOrNone = [{ id: verificationId }];
+      const expected = [verificationId];
+      sinon.stub(db.db, 'manyOrNone').resolves(dbManyOrNone);
+      const result = await db.verificationClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'manyOrNone').throws(expected);
+      try {
+        await db.verificationClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // verificationClaim
+
+  describe('verificationClaimById', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [ { id: verificationId } ],
+        duration: 10,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: verificationId,
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'result').throws(expected);
+      try {
+        await db.verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // verificationClaimById
+
+  describe('verificationComplete', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.verificationComplete(dbCtx, verificationId, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.verificationComplete(dbCtx, verificationId, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationComplete
+
+  describe('verificationGetById', function () {
+    it('success', async function() {
+      const dbOneOrNone = { id: verificationId };
+      const expected = { id: verificationId };
+      sinon.stub(db.db, 'oneOrNone').resolves(dbOneOrNone);
+      const result = await db.verificationGetById(dbCtx, verificationId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'oneOrNone').throws(expected);
+      try {
+        await db.verificationGetById(dbCtx, verificationId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // verificationGetById
+
+  describe('verificationIncomplete', function () {
+    it('success', async function() {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      await db.verificationIncomplete(dbCtx, verificationId, retryDelays);
+    });
+    it('covers defaults', async function() {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      await db.verificationIncomplete(dbCtx, verificationId);
+    });
+    it('failure', async function () {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.verificationIncomplete(dbCtx, verificationId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      const dbResult1 = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'one').resolves(dbOne);
+      sinon.stub(db.db, 'result').onCall(0).resolves(dbResult0).onCall(1).resolves(dbResult1);
+      try {
+        await db.verificationIncomplete(dbCtx, verificationId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationIncomplete
+
+  describe('verificationInsert', function () {
+    let verification;
+    beforeEach(function () {
+      verification = {
+        topicId,
+        callback,
+        mode: 'subscribe',
+        isPublisherValidated: true,
+        leaseSeconds: 86400,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [{ id: verificationId }],
+        duration: 10,
+      };
+      const expected = verificationId;
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      const result = await db.verificationInsert(dbCtx, verification);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.verificationInsert(dbCtx, verification);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('fails validation', async function () {
+      delete verification.leaseSeconds;
+      try {
+        await db.verificationInsert(dbCtx, verification);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+  }); // verificationInsert
+
+  describe('verificationRelease', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.verificationRelease(dbCtx, verificationId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.verificationRelease(dbCtx, verificationId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationRelease
+
+  describe('verificationUpdate', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        mode: 'subscribe',
+        isPublisherValidated: true,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.verificationUpdate(dbCtx, verificationId, data);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      }
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.verificationUpdate(dbCtx, verificationId, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult, e.name);
+      }
+    });
+    it('fails validation', async function () {
+      delete data.mode;
+      try {
+        await db.verificationUpdate(dbCtx, verificationId, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+  }); // verificationUpdate
+
+  describe('verificationValidated', function () {
+    it('success', async function() {
+      const dbResult = {
+        rowCount: 1,
+        rows: [],
+        duration: 10,
+      }
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.verificationValidated(dbCtx, verificationId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: [],
+        duration: 10,
+      }
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      try {
+        await db.verificationValidated(dbCtx, verificationId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationValidated
+
+}); // 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..310e87a
--- /dev/null
@@ -0,0 +1,1504 @@
+/* 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');
+// eslint-disable-next-line node/no-unpublished-require
+const sinon = require('sinon');
+const DBStub = 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 noExpectedException = 'did not receive expected exception';
+
+describe('DatabaseSQLite', function () {
+  let db, options;
+  let dbCtx, claimant, claimTimeoutSeconds, callback, subscriptionId, topicId, verificationId;
+  let topicUrl, leaseSeconds, secret, httpRemoteAddr, httpFrom, retryDelays, wanted;
+  before(function () {
+    options = new Config('test');
+    options.db.connectionString = 'sqlite://:memory:';
+    db = new DB(stubLogger, options);
+  });
+  beforeEach(function () {
+    stubLogger._reset();
+    dbCtx = db.db;
+    claimant = '19af19b8-6be3-4a6f-8946-65f5f1ccc5d7';
+    claimTimeoutSeconds = 300;
+    subscriptionId = 'fbaf8f19-ed9c-4a21-89ae-98b7005e3bf6';
+    topicUrl = 'https://example.com/blog';
+    callback = 'https://example.com/callback?id=123';
+    topicId = 'c59d4bda-10ad-41d9-99df-4ce8bc331424';
+    verificationId = '55cd7748-d2d5-11eb-b355-0025905f714a';
+    retryDelays = [60];
+    leaseSeconds = 86400;
+    secret = 'secret';
+    httpRemoteAddr = '127.0.0.1';
+    httpFrom = 'user@example.com';
+    wanted = 5;
+  });
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  // Ensure all interface methods are implemented
+  describe('Implementation', function () {
+    it('implements interface', async function () {
+      const results = await Promise.allSettled(DBStub._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('_currentSchema', function () {
+    it('covers', async function () {
+      const version = { major: 1, minor: 0, patch: 0 };
+      sinon.stub(db.db, 'prepare').returns({
+        get: () => version,
+      });
+      const result = await db._currentSchema();
+      assert.deepStrictEqual(result, version);
+    });
+  }); // _currentSchema
+
+  describe('_closeConnection', function () {
+    it('success', async function () {
+      sinon.stub(db.db, 'close');
+      await db._closeConnection();
+      assert(db.db.close.called);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.db, 'close').throws(expected);
+      try {
+        await db._closeConnection();
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // _closeConnection
+
+  describe('_purgeTables', function () {
+    beforeEach(function () {
+      sinon.stub(db.db, 'prepare').returns({
+        run: sinon.stub(),
+      });
+    });
+    it('covers not really', async function () {
+      await db._purgeTables(false);
+      assert(!db.db.prepare.called);
+    });
+    it('success', async function () {
+      await db._purgeTables(true);
+      assert(db.db.prepare.called);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      db.db.prepare.restore();
+      sinon.stub(db.db, 'prepare').throws(expected);
+      try {
+        await db._purgeTables(true);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // _purgeTables
+
+  describe('_optimize', function () {
+    let origOAC;
+    beforeEach(function () {
+      origOAC = db.optimizeAfterChanges;
+      sinon.stub(db.statement._optimize, 'all');
+      sinon.stub(db.db, 'pragma');
+    });
+    this.afterEach(function () {
+      db.optimizeAfterChanges = origOAC;
+    });
+    it('covers', async function () {
+      db.optimizeAfterChanges = 10;
+      db.changesSinceLastOptimize = BigInt(20);
+      await db._optimize();
+      assert(db.db.pragma.called);
+    });
+    it('covers none', async function () {
+      db.optimizeAfterChanges = 0;
+      await db._optimize();
+      assert(!db.db.pragma.called);
+    });
+    it('covers not enough changes', async function () {
+      db.optimizeAfterChanges = 10;
+      db.changesSinceLastOptimize = BigInt(5);
+      await db._optimize();
+      assert(!db.db.pragma.called);
+    });
+  }); // _optimize
+
+  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('_topicDataToNative', function () {
+    it('covers', function () {
+      const now = new Date();
+      const nowEpoch = now.getTime() / 1000;
+      const topic = {
+        isActive: 1,
+        isDeleted: 0,
+        created: nowEpoch,
+        lastPublish: nowEpoch,
+        contentFetchNextAttempt: nowEpoch,
+        contentUpdated: nowEpoch,
+        url: 'https://example.com/',
+      };
+      const expected = {
+        isActive: true,
+        isDeleted: false,
+        created: now,
+        lastPublish: now,
+        contentFetchNextAttempt: now,
+        contentUpdated: now,
+        url: topic.url,
+      }
+      const result = DB._topicDataToNative(topic);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers empty', function () {
+      const topic = undefined;
+      const result = DB._topicDataToNative(topic);
+      assert.deepStrictEqual(result, topic);
+    });
+  }); // _topicDataToNative
+
+  describe('healthCheck', function () {
+    let origDb;
+    beforeEach(function () {
+      origDb = db.db;
+    });
+    afterEach(function () {
+      db.db = origDb;
+    });
+    it('covers', function () {
+      db.healthCheck();
+    });
+    it('covers failure', function () {
+      db.db = { open: false };
+      try {
+        db.healthCheck();
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // healthCheck
+
+  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);
+    });
+    it('covers no context', async function () {
+      await db.transaction(undefined, common.nop);
+    });
+  }); // transaction
+
+  describe('authenticationSuccess', function () {
+    let identifier;
+    beforeEach(function () {
+      identifier = 'username';
+    });
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.authenticationSuccess, 'run').returns(dbResult);
+      await db.authenticationSuccess(dbCtx, identifier);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.authenticationSuccess, 'run').returns(dbResult);
+      try {
+        await db.authenticationSuccess(dbCtx, identifier);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // authenticationSuccess
+
+  describe('authenticationGet', function () {
+    let identifier, credential;
+    beforeEach(function () {
+      identifier = 'username';
+      credential = '$z$foo';
+    });
+    it('success', async function() {
+      const expected = {
+        identifier,
+        credential,
+      };
+      sinon.stub(db.statement.authenticationGet, 'get').returns(expected);
+      const result = await db.authenticationGet(dbCtx, identifier);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.statement.authenticationGet, 'get').throws(expected);
+      try {
+        await db.authenticationGet(dbCtx, identifier);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // authenticationGet
+
+  describe('authenticationUpsert', function () {
+    let identifier, credential;
+    beforeEach(function () {
+      identifier = 'username';
+      credential = '$z$foo';
+    });
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.authenticationUpsert, 'run').returns(dbResult);
+      await db.authenticationUpsert(dbCtx, identifier, credential);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.authenticationUpsert, 'run').returns(dbResult);
+      try {
+        await db.authenticationUpsert(dbCtx, identifier, credential);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // authenticationUpsert
+
+  describe('subscriptionsByTopicId', function () {
+    it('success', async function () {
+      const expected = { count: 3 };
+      sinon.stub(db.statement.subscriptionsByTopicId, 'all').returns(expected);
+      const result = await db.subscriptionsByTopicId(dbCtx, topicUrl);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.statement.subscriptionsByTopicId, 'all').throws(expected);
+      try {
+        await db.subscriptionsByTopicId(dbCtx, topicUrl);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionsByTopicId
+
+  describe('subscriptionCountByTopicUrl', function () {
+    it('success', async function () {
+      const expected = { count: 3 };
+      sinon.stub(db.statement.subscriptionCountByTopicUrl, 'get').returns(expected);
+      const result = await db.subscriptionCountByTopicUrl(dbCtx, topicUrl);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.statement.subscriptionCountByTopicUrl, 'get').throws(expected);
+      try {
+        await db.subscriptionCountByTopicUrl(dbCtx, topicUrl);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionCountByTopicUrl
+
+  describe('subscriptionDelete', function () {
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.subscriptionDelete, 'run').returns(dbResult);
+      const result = await db.subscriptionDelete(dbCtx, callback, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.subscriptionDelete, 'run').returns(dbResult);
+      try {
+        await db.subscriptionDelete(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDelete
+
+  describe('subscriptionDeliveryClaim', function () {
+    it('success', async function() {
+      const dbAllResult = [
+        {
+          id: 'c2e254c5-aa6e-4a8f-b1a1-e474b07392bb',
+        },
+      ];
+      const dbRunResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const expected = ['c2e254c5-aa6e-4a8f-b1a1-e474b07392bb'];
+      sinon.stub(db.statement.subscriptionDeliveryNeeded, 'all').returns(dbAllResult);
+      sinon.stub(db.statement.subscriptionDeliveryClaimById, 'run').returns(dbRunResult);
+      const result = await db.subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbAllResult = [
+        {
+          id: 'c2e254c5-aa6e-4a8f-b1a1-e474b07392bb',
+        },
+      ];
+      const dbRunResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.subscriptionDeliveryNeeded, 'all').returns(dbAllResult);
+      sinon.stub(db.statement.subscriptionDeliveryClaimById, 'run').returns(dbRunResult);
+      try {
+        await db.subscriptionDeliveryClaim(dbCtx, wanted, claimTimeoutSeconds, claimant );
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryClaim
+
+  describe('subscriptionDeliveryClaimById', function () {
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.subscriptionDeliveryClaimById, 'run').returns(dbResult);
+      const result = await db.subscriptionDeliveryClaimById(dbCtx, subscriptionId, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, dbResult);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.subscriptionDeliveryClaimById, 'run').returns(dbResult);
+      try {
+        await db.subscriptionDeliveryClaimById(dbCtx, subscriptionId, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryClaimById
+
+  describe('subscriptionDeliveryComplete', function () {
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+      };
+      sinon.stub(db.statement.subscriptionDeliverySuccess, 'run').returns(dbResult);
+      sinon.stub(db.statement.subscriptionDeliveryDone, 'run').returns(dbResult);
+      await db.subscriptionDeliveryComplete(dbCtx, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+      };
+      sinon.stub(db.statement.subscriptionDeliverySuccess, 'run').returns(dbResult);
+      sinon.stub(db.statement.subscriptionDeliveryDone, 'run').returns(dbResult);
+      try {
+        await db.subscriptionDeliveryComplete(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbResult0 = {
+        changes: 1,
+      };
+      const dbResult1 = {
+        changes: 0,
+      };
+      sinon.stub(db.statement.subscriptionDeliverySuccess, 'run').returns(dbResult0);
+      sinon.stub(db.statement.subscriptionDeliveryDone, 'run').returns(dbResult1);
+      try {
+        await db.subscriptionDeliveryComplete(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryComplete
+
+  describe('subscriptionDeliveryGone', function () {
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+      };
+      sinon.stub(db.statement.subscriptionDelete, 'run').returns(dbResult);
+      await db.subscriptionDeliveryGone(dbCtx, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+      };
+      sinon.stub(db.statement.subscriptionDelete, 'run').returns(dbResult);
+      try {
+        await db.subscriptionDeliveryGone(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryGone
+
+  describe('subscriptionDeliveryIncomplete', function () {
+    it('success', async function() {
+      const dbGet = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult = {
+        changes: 1,
+      };
+      sinon.stub(db.statement.subscriptionDeliveryAttempts, 'get').returns(dbGet);
+      sinon.stub(db.statement.subscriptionDeliveryFailure, 'run').returns(dbResult);
+      sinon.stub(db.statement.subscriptionDeliveryDone, 'run').returns(dbResult);
+      await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays);
+    });
+    it('success covers default', async function() {
+      const dbGet = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult = {
+        changes: 1,
+      };
+      sinon.stub(db.statement.subscriptionDeliveryAttempts, 'get').returns(dbGet);
+      sinon.stub(db.statement.subscriptionDeliveryFailure, 'run').returns(dbResult);
+      sinon.stub(db.statement.subscriptionDeliveryDone, 'run').returns(dbResult);
+      await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbGet = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult = {
+        changes: 0,
+      };
+      sinon.stub(db.statement.subscriptionDeliveryAttempts, 'get').returns(dbGet);
+      sinon.stub(db.statement.subscriptionDeliveryFailure, 'run').returns(dbResult);
+      sinon.stub(db.statement.subscriptionDeliveryDone, 'run').returns(dbResult);
+      try {
+        await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbGet = { deliveryAttemptsSinceSuccess: 0 };
+      const dbResult0 = {
+        changes: 1,
+      };
+      const dbResult1 = {
+        changes: 0,
+      };
+      sinon.stub(db.statement.subscriptionDeliveryAttempts, 'get').returns(dbGet);
+      sinon.stub(db.statement.subscriptionDeliveryFailure, 'run').returns(dbResult0);
+      sinon.stub(db.statement.subscriptionDeliveryDone, 'run').returns(dbResult1);
+      try {
+        await db.subscriptionDeliveryIncomplete(dbCtx, callback, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionDeliveryIncomplete
+
+  describe('subscriptionGet', function () {
+    it('success', async function() {
+      const expected = {
+        id: subscriptionId,
+      };
+      sinon.stub(db.statement.subscriptionGet, 'get').returns(expected);
+      const result = await db.subscriptionGet(dbCtx, callback, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.statement.subscriptionGet, 'get').throws(expected);
+      try {
+        await db.subscriptionGet(dbCtx, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionGet
+
+  describe('subscriptionGetById', function () {
+    it('success', async function() {
+      const expected = {
+        id: subscriptionId,
+      };
+      sinon.stub(db.statement.subscriptionGetById, 'get').returns(expected);
+      const result = await db.subscriptionGetById(dbCtx, subscriptionId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.statement.subscriptionGetById, 'get').throws(expected);
+      try {
+        await db.subscriptionGetById(dbCtx, subscriptionId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // subscriptionGetById
+
+  describe('subscriptionUpdate', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        subscriptionId,
+        signatureAlgorithm: 'sha256',
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: subscriptionId,
+      };
+      sinon.stub(db.statement.subscriptionUpdate, 'run').returns(dbResult);
+      await db.subscriptionUpdate(dbCtx, data);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+      };
+      sinon.stub(db.statement.subscriptionUpdate, 'run').returns(dbResult);
+      try {
+        await db.subscriptionUpdate(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult, e);
+      }
+    });
+  }); // subscriptionUpdate
+
+  describe('subscriptionUpsert', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        callback,
+        topicId,
+        leaseSeconds,
+        secret,
+        httpRemoteAddr,
+        httpFrom,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: subscriptionId,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: subscriptionId,
+      };
+      sinon.stub(db.statement.subscriptionUpsert, 'run').returns(dbResult);
+      const result = await db.subscriptionUpsert(dbCtx, data);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+      };
+      sinon.stub(db.statement.subscriptionUpsert, 'run').returns(dbResult);
+      try {
+        await db.subscriptionUpsert(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // subscriptionUpsert
+
+  describe('topicDeleted', function () {
+    it('success', async function () {
+      sinon.stub(db.statement.topicDeleted, 'run').returns({ changes: 1 });
+      await db.topicDeleted(dbCtx, { topicId });
+    });
+    it('failure', async function () {
+      sinon.stub(db.statement.topicDeleted, 'run').returns({ changes: 0 });
+      try {
+        await db.topicDeleted(dbCtx, { topicId });
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicDeleted
+
+  describe('topicFetchClaim', function () {
+    it('success', async function() {
+      const dbAll = [{ id: topicId }];
+      const dbResult = {
+        changes: 1,
+      };
+      const expected = [topicId];
+      sinon.stub(db.statement.topicContentFetchNeeded, 'all').returns(dbAll);
+      sinon.stub(db.statement.topicContentFetchClaimById, 'run').returns(dbResult);
+      const result = await db.topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbAll = [{ id: topicId }];
+      const dbResult = {
+        changes: 0,
+      };
+      sinon.stub(db.statement.topicContentFetchNeeded, 'all').returns(dbAll);
+      sinon.stub(db.statement.topicContentFetchClaimById, 'run').returns(dbResult);
+      try {
+        await db.topicFetchClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchClaim
+
+  describe('topicFetchClaimById', function () {
+    it('success', async function() {
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicContentFetchClaimById, 'run').returns(expected);
+      const result = await db.topicFetchClaimById(dbCtx, topicId, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicContentFetchClaimById, 'run').returns(expected);
+      try {
+        await db.topicFetchClaimById(dbCtx, topicId, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchClaimById
+
+  describe('topicFetchComplete', function () {
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicAttemptsReset, 'run').returns(dbResult);
+      sinon.stub(db.statement.topicContentFetchDone, 'run').returns(dbResult);
+      await db.topicFetchComplete(dbCtx, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicAttemptsReset, 'run').returns(dbResult);
+      sinon.stub(db.statement.topicContentFetchDone, 'run').returns(dbResult);
+      try {
+        await db.topicFetchComplete(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbResult0 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const dbResult1 = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicAttemptsReset, 'run').returns(dbResult0);
+      sinon.stub(db.statement.topicContentFetchDone, 'run').returns(dbResult1);
+      try {
+        await db.topicFetchComplete(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchComplete
+
+  describe('topicFetchIncomplete', function () {
+    it('success', async function() {
+      const dbGet = { currentAttempt: 0 };
+      const dbResult0 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const dbResult1 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      }
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicAttempts, 'get').returns(dbGet);
+      sinon.stub(db.statement.topicAttemptsIncrement, 'run').returns(dbResult0);
+      sinon.stub(db.statement.topicContentFetchDone, 'run').returns(dbResult1);
+      const result = await db.topicFetchIncomplete(dbCtx, topicId, retryDelays);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers defaults', async function() {
+      const dbGet = { currentAttempt: 0 };
+      const dbResult0 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const dbResult1 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      }
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicAttempts, 'get').returns(dbGet);
+      sinon.stub(db.statement.topicAttemptsIncrement, 'run').returns(dbResult0);
+      sinon.stub(db.statement.topicContentFetchDone, 'run').returns(dbResult1);
+      const result = await db.topicFetchIncomplete(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbGet = { currentAttempt: 0 };
+      const dbResult0 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const dbResult1 = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      }
+      sinon.stub(db.statement.topicAttempts, 'get').returns(dbGet);
+      sinon.stub(db.statement.topicAttemptsIncrement, 'run').returns(dbResult0);
+      sinon.stub(db.statement.topicContentFetchDone, 'run').returns(dbResult1);
+      try {
+        await db.topicFetchIncomplete(dbCtx, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbGet = { currentAttempt: 0 };
+      const dbResult0 = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      const dbResult1 = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      }
+      sinon.stub(db.statement.topicAttempts, 'get').returns(dbGet);
+      sinon.stub(db.statement.topicAttemptsIncrement, 'run').returns(dbResult0);
+      sinon.stub(db.statement.topicContentFetchDone, 'run').returns(dbResult1);
+      try {
+        await db.topicFetchIncomplete(dbCtx, topicId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchIncomplete
+
+  describe('topicFetchRequested', function () {
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicContentFetchRequested, 'run').returns(dbResult);
+      const result = await db.topicFetchRequested(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicContentFetchRequested, 'run').returns(dbResult);
+      try {
+        await db.topicFetchRequested(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicFetchRequested
+
+  describe('topicGetAll', function () {
+    it('success', async function() {
+      const expected = [{ id: topicId }];
+      sinon.stub(db.statement.topicGetInfoAll, 'all').returns(expected);
+      const result = await db.topicGetAll(dbCtx);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers none', async function() {
+      const expected = undefined;
+      sinon.stub(db.statement.topicGetInfoAll, 'all').returns(expected);
+      const result = await db.topicGetAll(dbCtx);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.statement.topicGetInfoAll, 'all').throws(expected);
+      try {
+        await db.topicGetAll(dbCtx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetById
+
+  describe('topicGetById', function () {
+    it('success', async function() {
+      const expected = { id: topicId };
+      sinon.stub(db.statement.topicGetById, 'get').returns(expected);
+      const result = await db.topicGetById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers no defaults', async function () {
+      const expected = { id: topicId };
+      sinon.stub(db.statement.topicGetById, 'get').returns(expected);
+      const result = await db.topicGetById(dbCtx, topicId, false);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers default', async function() {
+      const expected = undefined;
+      sinon.stub(db.statement.topicGetById, 'get').returns(expected);
+      const result = await db.topicGetById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.statement.topicGetById, 'get').throws(expected);
+      try {
+        await db.topicGetById(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetById
+
+  describe('topicGetByUrl', function () {
+    it('success', async function() {
+      const expected = [];
+      sinon.stub(db.statement.topicGetByUrl, 'get').returns(expected);
+      const result = await db.topicGetByUrl(dbCtx, topicUrl);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.statement.topicGetByUrl, 'get').throws(expected);
+      try {
+        await db.topicGetByUrl(dbCtx, topicUrl);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetByUrl
+
+  describe('topicGetContentById', function () {
+    it('success', async function() {
+      const expected = { id: topicId };
+      sinon.stub(db.statement.topicGetContentById, 'get').returns(expected);
+      const result = await db.topicGetContentById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers default', async function() {
+      const expected = undefined;
+      sinon.stub(db.statement.topicGetContentById, 'get').returns(expected);
+      const result = await db.topicGetContentById(dbCtx, topicId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.statement.topicGetContentById, 'get').throws(expected);
+      try {
+        await db.topicGetContentById(dbCtx, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // topicGetContentById
+
+  describe('topicSet', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        url: topicUrl,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: topicId,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: topicId,
+      };
+      sinon.stub(db.statement.topicUpsert, 'run').returns(dbResult);
+      const result = await db.topicSet(dbCtx, data);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicUpsert, 'run').returns(dbResult);
+      try {
+        await db.topicSet(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('fails invalid value', async function () {
+      sinon.stub(db.statement.topicUpsert, 'run');
+      try {
+        data.leaseSecondsPreferred = -100;
+        await db.topicSet(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+      assert(!db.statement.topicUpsert.run.called);
+    });
+    it('fails invalid values', async function () {
+      sinon.stub(db.statement.topicUpsert, 'run');
+      try {
+        data.leaseSecondsPreferred = 10;
+        data.leaseSecondsMax = 100;
+        data.leaseSecondsMin = 50;
+        await db.topicSet(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+      assert(!db.statement.topicUpsert.run.called);
+    });
+  }); // topicSet
+
+  describe('topicSetContent', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        content: 'content',
+        contentType: 'text/plain',
+        contentHash: 'abc123',
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const expected = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicSetContent, 'run').returns(dbResult);
+      const result = await db.topicSetContent(dbCtx, data);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicSetContent, 'run').returns(dbResult);
+      try {
+        await db.topicSetContent(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // topicSetContent
+
+  describe('topicUpdate', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        topicId,
+        leaseSecondsPreferred: 9999,
+        leaseSecondsMax: 99999,
+        leaseSecondsMin: 999,
+        publisherValidationUrl: null,
+        contentHashAlgorithm: 'sha256',
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: topicId,
+      };
+      sinon.stub(db.statement.topicUpdate, 'run').returns(dbResult);
+      await db.topicUpdate(dbCtx, data);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.topicUpdate, 'run').returns(dbResult);
+      try {
+        await db.topicUpdate(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult, e);
+      }
+    });
+    it('fails invalid value', async function () {
+      sinon.stub(db.statement.topicUpdate, 'run');
+      try {
+        data.leaseSecondsPreferred = -100;
+        await db.topicUpdate(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation, e);
+      }
+      assert(!db.statement.topicUpdate.run.called);
+    });
+    it('fails invalid values', async function () {
+      sinon.stub(db.statement.topicUpdate, 'run');
+      try {
+        data.leaseSecondsPreferred = 10;
+        data.leaseSecondsMax = 100;
+        data.leaseSecondsMin = 50;
+        await db.topicUpdate(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation, e);
+      }
+      assert(!db.statement.topicUpdate.run.called);
+    });
+  }); // topicUpdate
+
+  describe('verificationClaim', function () {
+    it('success', async function() {
+      const dbAll = [{ id: verificationId }];
+      const dbRun = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const expected = [verificationId];
+      sinon.stub(db.statement.verificationNeeded, 'all').returns(dbAll);
+      sinon.stub(db.statement.verificationClaimById, 'run').returns(dbRun);
+      const result = await db.verificationClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbAll = [{ id: verificationId }];
+      const dbRun = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationNeeded, 'all').returns(dbAll);
+      sinon.stub(db.statement.verificationClaimById, 'run').returns(dbRun);
+      try {
+        await db.verificationClaim(dbCtx, wanted, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationClaim
+
+  describe('verificationClaimById', function () {
+    it('success', async function() {
+      const dbRun = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationClaimById, 'run').returns(dbRun);
+      const result = await db.verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant);
+      assert.deepStrictEqual(result, dbRun);
+    });
+    it('failure', async function () {
+      const dbRun = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationClaimById, 'run').returns(dbRun);
+      try {
+        await db.verificationClaimById(dbCtx, verificationId, claimTimeoutSeconds, claimant);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationClaimById
+
+  describe('verificationComplete', function () {
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationScrub, 'run').returns(dbResult);
+      await db.verificationComplete(dbCtx, verificationId, callback, topicId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationScrub, 'run').returns(dbResult);
+      try {
+        await db.verificationComplete(dbCtx, verificationId, callback, topicId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationComplete
+
+  describe('verificationGetById', function () {
+    it('success', async function() {
+      const dbOneOrNone = { id: verificationId, isPublisherValidated: 1 };
+      const expected = { id: verificationId, isPublisherValidated: true };
+      sinon.stub(db.statement.verificationGetById, 'get').returns(dbOneOrNone);
+      const result = await db.verificationGetById(dbCtx, verificationId);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const expected = new Error();
+      sinon.stub(db.statement.verificationGetById, 'get').throws(expected);
+      try {
+        await db.verificationGetById(dbCtx, verificationId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // verificationGetById
+
+  describe('verificationIncomplete', function () {
+    it('success', async function() {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const dbResult1 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationAttempts, 'get').returns(dbOne);
+      sinon.stub(db.statement.verificationAttemptsIncrement, 'run').returns(dbResult0);
+      sinon.stub(db.statement.verificationDone, 'run').returns(dbResult1);
+      await db.verificationIncomplete(dbCtx, verificationId, retryDelays);
+    });
+    it('covers defaults', async function() {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const dbResult1 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationAttempts, 'get').returns(dbOne);
+      sinon.stub(db.statement.verificationAttemptsIncrement, 'run').returns(dbResult0);
+      sinon.stub(db.statement.verificationDone, 'run').returns(dbResult1);
+      await db.verificationIncomplete(dbCtx, verificationId);
+    });
+    it('failure', async function () {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      const dbResult1 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationAttempts, 'get').returns(dbOne);
+      sinon.stub(db.statement.verificationAttemptsIncrement, 'run').returns(dbResult0);
+      sinon.stub(db.statement.verificationDone, 'run').returns(dbResult1);
+      try {
+        await db.verificationIncomplete(dbCtx, verificationId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('second failure', async function () {
+      const dbOne = { attempts: 0 };
+      const dbResult0 = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      const dbResult1 = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationAttempts, 'get').returns(dbOne);
+      sinon.stub(db.statement.verificationAttemptsIncrement, 'run').returns(dbResult0);
+      sinon.stub(db.statement.verificationDone, 'run').returns(dbResult1);
+      try {
+        await db.verificationIncomplete(dbCtx, verificationId, retryDelays);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationIncomplete
+
+  describe('_verificationDataToEngine', function () {
+    it('covers no data', function () {
+      DB._verificationDataToEngine();
+    });
+    it('covers true', function () {
+      const data = {
+        isPublisherValidated: true,
+      };
+      DB._verificationDataToEngine(data);
+      assert.strictEqual(data.isPublisherValidated, 1);
+    });
+    it('covers false', function () {
+      const data = {
+        isPublisherValidated: false,
+      };
+      DB._verificationDataToEngine(data);
+      assert.strictEqual(data.isPublisherValidated, 0);
+    });
+  }) // _verificationDataToEngine
+
+  describe('verificationInsert', function () {
+    let verification;
+    beforeEach(function () {
+      verification = {
+        topicId,
+        callback,
+        mode: 'subscribe',
+        isPublisherValidated: true,
+        leaseSeconds: 86400,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: verificationId,
+      };
+      const expected = verificationId;
+      sinon.stub(db.statement.verificationInsert, 'run').returns(dbResult);
+      const result = await db.verificationInsert(dbCtx, verification);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationInsert, 'run').returns(dbResult);
+      try {
+        await db.verificationInsert(dbCtx, verification);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+    it('fails validation', async function () {
+      delete verification.leaseSeconds;
+      try {
+        await db.verificationInsert(dbCtx, verification);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+  }); // verificationInsert
+
+  describe('verificationRelease', function () {
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationDone, 'run').returns(dbResult);
+      await db.verificationRelease(dbCtx, verificationId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationDone, 'run').returns(dbResult);
+      try {
+        await db.verificationRelease(dbCtx, verificationId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationRelease
+
+  describe('verificationUpdate', function () {
+    let data;
+    beforeEach(function () {
+      data = {
+        mode: 'subscribe',
+        isPublisherValidated: true,
+      };
+    });
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+      sinon.stub(db.statement.verificationUpdate, 'run').returns(dbResult);
+      await db.verificationUpdate(dbCtx, verificationId, data);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      }
+      sinon.stub(db.statement.verificationUpdate, 'run').returns(dbResult);
+      try {
+        await db.verificationUpdate(dbCtx, verificationId, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult, e.name);
+      }
+    });
+    it('fails validation', async function () {
+      delete data.mode;
+      try {
+        await db.verificationUpdate(dbCtx, data);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.DataValidation);
+      }
+    });
+  }); // verificationUpdate
+
+  describe('verificationValidated', function () {
+    it('success', async function() {
+      const dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      }
+      sinon.stub(db.statement.verificationValidate, 'run').returns(dbResult);
+      await db.verificationValidated(dbCtx, verificationId);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        changes: 0,
+        lastInsertRowid: undefined,
+      }
+      sinon.stub(db.statement.verificationValidate, 'run').returns(dbResult);
+      try {
+        await db.verificationValidated(dbCtx, verificationId);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DBErrors.UnexpectedResult);
+      }
+    });
+  }); // verificationValidated
+
+}); // DatabasePostgres
diff --git a/test/src/link-helper.js b/test/src/link-helper.js
new file mode 100644 (file)
index 0000000..535d4c9
--- /dev/null
@@ -0,0 +1,305 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+const LinkHelper = require('../../src/link-helper');
+const stubLogger = require('../stub-logger');
+const testData = require('../test-data/link-helper');
+
+describe('LinkHelper', function () {
+  let lh, options;
+  beforeEach(function () {
+    options = {
+      dingus: {
+        selfBaseUrl: 'https://example.com/hub/',
+      },
+    };
+    lh = new LinkHelper(stubLogger, options);
+  });
+  afterEach(function () {
+    sinon.restore();
+  });
+  describe('validHub', function () {
+    let url, headers, body;
+    beforeEach(function () {
+      url = 'https://example.com/feed/';
+      headers = {};
+      body = '';
+    });
+    it('covers success', async function () {
+      headers = {
+        link: '<https://example.com/hub/>; rel="hub"',
+      };
+      const expected = true;
+      const result = await lh.validHub(url, headers, body);
+      assert.strictEqual(result, expected);
+    });
+    it('covers wrong hub', async function () {
+      headers = {
+        link: '<https://example.com/other/hub/>; rel="hub"',
+      };
+      const expected = false;
+      const result = await lh.validHub(url, headers, body);
+      assert.strictEqual(result, expected);
+    });
+    it('covers link in Atom body', async function () {
+      headers = {
+        'content-type': 'application/xml',
+      };
+      body = testData.atomFeedBody;
+      url = testData.atomFeedUrl;
+      lh.selfUrl = 'https://hub.squeep.com/';
+      const expected = true;
+      const result = await lh.validHub(url, headers, body);
+      assert.strictEqual(result, expected);
+    });
+    it('covers link in HTML body', async function () {
+      headers = {
+        'content-type': 'text/html',
+      };
+      body = '<html><head><link rel="hub" href="https://example.com/hub/"></head></html>';
+      const expected = true;
+      const result = await lh.validHub(url, headers, body);
+      assert.strictEqual(result, expected);
+    });
+    it('covers parser failure', async function () {
+      headers = {
+        link: 'Invalid Link Header',
+      };
+      const expected = false;
+      const result = await lh.validHub(url, headers, body);
+      assert.strictEqual(result, expected);
+    });
+    it('covers other failure', async function () {
+      const expected = false;
+      const result = await lh.validHub(url, headers, body);
+      assert.strictEqual(result, expected);
+    });
+  }); // validHub
+
+  describe('absoluteURI', function () {
+    it('success', function () {
+      const uri = '../rel';
+      const context = 'https://example.com/base/';
+      const expected = 'https://example.com/rel';
+      const result = lh.absoluteURI(uri, context);
+      assert.strictEqual(result, expected);
+    });
+    it('failure', function () {
+      const uri = '../rel';
+      const context = '/not/valid';
+      const expected = '../rel';
+      const result = lh.absoluteURI(uri, context);
+      assert.strictEqual(result, expected);
+    });
+  }); // absoluteURI
+
+  describe('locateHubTargets', function () {
+    it('covers', function () {
+      const links = [
+        {
+          target: 'https://example.com/hub1/',
+          attributes: [
+            {
+              name: 'rel',
+              value: 'hub',
+            },
+          ],
+        },
+        {
+          target: 'https://example.com/index',
+          attributes: [
+            {
+              name: 'rel',
+              value: 'index',
+            },
+          ],
+        },
+        {
+          target: 'https://example.com/hub2/',
+          attributes: [
+            {
+              name: 'rel',
+              value: 'hub other',
+            },
+          ],
+        },
+      ];
+      const expected = ['https://example.com/hub1/', 'https://example.com/hub2/'];
+      const result = LinkHelper.locateHubTargets(links);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // locateHubTargets
+
+  describe('linksFromFeedBody', function () {
+    it('parses rss', async function () {
+      const feedData = testData.rssFeedBody;
+      const feedUrl = testData.rssFeedUrl;
+      const expected = [
+        {
+          attributes: [
+            {
+              name: 'rel',
+              value: 'self',
+            },
+            {
+              name: 'type',
+              value: 'application/rss+xml',
+            },
+          ],
+          target: 'https://puppetcircuits.wordpress.com/feed/',
+        },
+        {
+          attributes: [
+            {
+              name: 'rel',
+              value: 'search',
+            },
+            {
+              name: 'type',
+              value: 'application/opensearchdescription+xml',
+            },
+            {
+              name: 'title',
+              value: 'Puppet Circuits',
+            },
+          ],
+          target: 'https://puppetcircuits.wordpress.com/osd.xml',
+        },
+        {
+          attributes: [
+            {
+              name: 'rel',
+              value: 'hub',
+            },
+          ],
+          target: 'https://puppetcircuits.wordpress.com/?pushpress=hub',
+        },
+      ];
+      const result = await lh.linksFromFeedBody(feedUrl, feedData);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('parses atom', async function () {
+      const feedData = testData.atomFeedBody;
+      const feedUrl = testData.atomFeedUrl;
+      const expected = [
+        {
+          attributes: [
+            {
+              name: 'rel',
+              value: 'alternate',
+            },
+            {
+              name: 'type',
+              value: 'text/xhtml',
+            },
+          ],
+          target: 'https://squeep.com/eats/',
+        },
+        {
+          attributes: [
+            {
+              name: 'rel',
+              value: 'self',
+            },
+            {
+              name: 'type',
+              value: 'application/atom+xml',
+            },
+          ],
+          target: 'https://squeep.com/eats/atom/',
+        },
+        {
+          attributes: [
+            {
+              name: 'rel',
+              value: 'hub',
+            },
+          ],
+          target: 'https://hub.squeep.com/',
+        },
+      ];
+      const result = await lh.linksFromFeedBody(feedUrl, feedData);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('does not parse HTML', async function () {
+      const feedData = testData.htmlBody;
+      const feedUrl = testData.htmlUrl;
+      const expected = [];
+      const result = await lh.linksFromFeedBody(feedUrl, feedData);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // hubLinksFromFeedBody
+
+  describe('linksFromHTMLBody', function () {
+    it('parses HTML', function () {
+      const htmlData = testData.htmlBody;
+      const expected = [
+        {
+          attributes: [
+            {
+              name: 'rel',
+              value: 'preload',
+            },
+            {
+              name: 'as',
+              value: 'font',
+            },
+            {
+              name: 'type',
+              value: 'font/opentype',
+            },
+            {
+              name: 'crossorigin',
+              value: 'anonymous',
+            },
+          ],
+          target: 'oldstyle.otf',
+        },
+        {
+          attributes: [
+            {
+              name: 'rel',
+              value: 'stylesheet',
+            },
+            {
+              name: 'type',
+              value: 'text/css',
+            },
+          ],
+          target: 'eats.css',
+        },
+        {
+          attributes: [
+            {
+              name: 'rel',
+              value: 'hub',
+            },
+          ],
+          target: 'https://hub.squeep.com/',
+        },
+        {
+          attributes: [
+            {
+              name: 'rel',
+              value: 'alternate',
+            },
+            {
+              name: 'type',
+              value: 'application/atom+xml',
+            },
+            {
+              name: 'title',
+              value: 'Atom 1.0',
+            },
+          ],
+          target: 'https://squeep.com/eats/atom/',
+        },
+      ];
+      const result = lh.linksFromHTMLBody(htmlData);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // linksFromHTMLBody
+
+}); // LinkHelper
\ 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..2a20504
--- /dev/null
@@ -0,0 +1,51 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const Logger = require('../../src/logger');
+const Config = require('../../config');
+
+describe('Logger', function () {
+  let config;
+  let logger;
+
+  beforeEach(function () {
+    config = new Config('test');
+  });
+
+  it('logs', function () {
+    logger = new Logger(config);
+    logger.info('testScope', 'message', { baz: 'quux' }, { foo: 1 }, 'more other');
+  });
+
+  it('stubs missing levels', function () {
+    const backend = {};
+    logger = new Logger(config, backend);
+    assert.strictEqual(typeof logger.info, 'function');
+  });
+
+  it('logs BigInts', function () {
+    logger = new Logger(config);
+    logger.info('testScope', 'message', { aBigInteger: BigInt(2) });
+  });
+
+  it('logs Errors', function () {
+    logger = new Logger(config);
+    logger.error('testScope', 'message', { e: new Error('an error') });
+  });
+
+  it('covers config error', function () {
+    config.logger.ignoreBelowLevel = 'not a level';
+    try {
+      logger = new Logger(config);
+      assert.fail('expected RangeError here');
+    } catch (e) {
+      assert(e instanceof RangeError);
+    }
+  });
+
+  it('covers empty fields', function () {
+    logger = new Logger(config);
+    logger.info();
+  });
+}); // Logger
diff --git a/test/src/manager.js b/test/src/manager.js
new file mode 100644 (file)
index 0000000..d8c0921
--- /dev/null
@@ -0,0 +1,759 @@
+/* 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 common = require('../../src/common');
+const Errors = require('../../src/errors');
+const DBErrors = require('../../src/db/errors');
+
+const stubDb = require('../stub-db');
+const stubLogger = require('../stub-logger');
+const testData = require('../test-data/manager');
+
+const noExpectedException = 'did not get expected exception';
+
+describe('Manager', function () {
+  let manager, options;
+  let req, res, ctx;
+
+  beforeEach(function () {
+    options = new Config('test');
+    req = {
+      getHeader : sinon.stub(),
+    };
+    res = {
+      end: sinon.stub(),
+      setHeader: sinon.stub(),
+    };
+    ctx = {
+      params: {},
+    };
+    manager = new Manager(stubLogger, stubDb, options);
+    sinon.stub(manager.communication, 'verificationProcess');
+    sinon.stub(manager.communication, 'topicFetchProcess');
+    stubDb._reset();
+    stubLogger._reset();
+  });
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  it('instantiates', function () {
+    assert(manager);
+  });
+
+  describe('getRoot', function () {
+    beforeEach(function () {
+      sinon.stub(common, 'isClientCached');
+      req = {};
+    });
+    it('normal response', async function () {
+      common.isClientCached.returns(false);
+      await manager.getRoot(req, res, ctx);
+      assert(res.end.called);
+    });
+    it('repeat response', async function () {
+      manager.startTime = (new Date()).toGMTString();
+      common.isClientCached.returns(true);
+      await manager.getRoot(req, res, ctx);
+      assert(res.end.called);
+    });
+    it('cached response', async function () {
+      common.isClientCached.returns(true);
+      await manager.getRoot(req, res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 304);      
+    });
+  }); // getRoot
+
+  describe('getHealthcheck', function () {
+    it('normal response', async function () {
+      await manager.getHealthcheck(res, ctx);
+      assert(res.end.called);
+    });
+  }); // getById
+
+  describe('getInfo', function () {
+    it('requires query param', async function() {
+      ctx.queryParams = {};
+      try {
+        await manager.getInfo(res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.strictEqual(e.statusCode, 400);
+      }
+    });
+    it('requires parsable query param', async function() {
+      ctx.queryParams = { topic: 'not a url' };
+      try {
+        await manager.getInfo(res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.strictEqual(e.statusCode, 400);
+      }
+    });
+    it('does not find unhandled topic', async function() {
+      ctx.queryParams = { topic: 'https://example.com/blog/' };
+      try {
+        await manager.getInfo(res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.strictEqual(e.statusCode, 404);
+      }
+    });
+    it('returns a count', async function() {
+      manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 });
+      ctx.queryParams = {
+        topic: 'https://example.com/blog/',
+      };
+      await manager.getInfo(res, ctx);
+      assert(res.end.called);
+    });
+    it('returns a count as json', async function() {
+      manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 });
+      ctx.responseType = 'application/json';
+      ctx.queryParams = {
+        topic: 'https://example.com/blog/',
+      };
+      await manager.getInfo(res, ctx);
+      assert(res.end.called);
+    });
+    it('returns a count as json as override format', async function() {
+      manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 });
+      ctx.responseType = 'text/html';
+      ctx.queryParams = {
+        topic: 'https://example.com/blog/',
+        format: 'json',
+      };
+      await manager.getInfo(res, ctx);
+      assert(res.end.called);
+      assert(res.setHeader.called);
+    });
+    it('returns an svg badge as override format', async function() {
+      manager.db.subscriptionCountByTopicUrl.resolves({ count: 4 });
+      ctx.responseType = 'text/html';
+      ctx.queryParams = {
+        topic: 'https://example.com/blog/',
+        format: 'svg',
+      };
+      await manager.getInfo(res, ctx);
+      assert(res.end.called);
+      assert(res.setHeader.called);
+    });
+  }); // getInfo
+
+  describe('getAdminOverview', function () {
+    it('covers', async function () {
+      manager.db.topicGetAll.resolves([
+        {
+          id: '56c557ce-e667-11eb-bd80-0025905f714a',
+          created: new Date(),
+          url: 'https://example.com/',
+          leaseSecondsPreferred: 123,
+          leaseSecondsMin: 12,
+          leaseSecondsMax: 123456789,
+          publisherValidationUrl: null,
+          contentHashAlgorithm: 'hashy',
+          isActive: true,
+          isDeleted: false,
+          lastPublish: new Date(-Infinity),
+          contentFetchNextAttempt: undefined,
+          contentFetchAttemptsSinceSuccess: 3,
+          contentUpdated: new Date(0),
+          contentHash: 'abc',
+          contentType: 'foo',
+          subscribers: 12,
+        },
+      ]);
+      await manager.getAdminOverview(res, ctx);
+      assert(res.end.called);
+    });
+  });
+
+  describe('getTopicDetails', function () {
+    it('covers', async function() {
+      ctx.params.topicId = '56c557ce-e667-11eb-bd80-0025905f714a';
+      manager.db.topicGetById.resolves({
+        id: '56c557ce-e667-11eb-bd80-0025905f714a',
+        created: new Date(),
+        url: 'https://example.com/',
+        leaseSecondsPreferred: 123,
+        leaseSecondsMin: 12,
+        leaseSecondsMax: 123456789,
+        publisherValidationUrl: null,
+        contentHashAlgorithm: 'hashy',
+        isActive: true,
+        isDeleted: false,
+        lastPublish: new Date(-Infinity),
+        contentFetchNextAttempt: undefined,
+        contentFetchAttemptsSinceSuccess: 3,
+        contentUpdated: new Date(0),
+        contentHash: 'abc',
+        contentType: 'foo',
+        subscribers: 12,
+      });
+      manager.db.subscriptionsByTopicId.resolves([{
+        id: '',
+        created: new Date(),
+        topicId: '56c557ce-e667-11eb-bd80-0025905f714a',
+        callback: '',
+        verified: new Date(),
+        expires: new Date(),
+        secret: '',
+        signatureAlgorithm: 'hmacy',
+        httpRemoteAddr: '',
+        httpFrom: '',
+        contentDelivered: new Date(),
+        deliveryAttemptsSinceSuccess: 0,
+        deliveryNextAttempt: new Date(-Infinity),
+      }]);
+      await manager.getTopicDetails(res, ctx);
+      assert(res.end.called);
+    });
+  }); // getTopicDetails
+
+  describe('postRoot', function () {
+    let origProcessImmediately;
+    beforeEach(function () {
+      origProcessImmediately = manager.options.manager.processImmediately;
+      ctx.parsedBody = {};
+    });
+    this.afterEach(function () {
+      manager.options.manager.processImmediately = origProcessImmediately;
+    });
+    it('requires parameters', async function () {
+      try {
+        await manager.postRoot(req, res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.strictEqual(e.message, 'Bad Request');
+      }
+    });
+    it('accepts valid subscription', async function () {
+      ctx = Object.assign({}, testData.validSubscribeCtx);
+      manager.db.topicGetByUrl.resolves({
+        id: 111,
+      });
+      manager.db.verificationInsert.resolves({
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 12,
+      });
+      await manager.postRoot(req, res, ctx);
+      assert(manager.db.verificationInsert.called);
+      assert(res.end.called);
+    });
+    it('accepts valid subscription without claiming work', async function () {
+      manager.options.manager.processImmediately = false;
+      ctx = Object.assign({}, testData.validSubscribeCtx);
+      manager.db.topicGetByUrl.resolves({
+        id: 111,
+      });
+      manager.db.verificationInsert.resolves({
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 12,
+      });
+      await manager.postRoot(req, res, ctx);
+      assert(manager.db.verificationInsert.called);
+      assert(!manager.communication.verificationProcess.called);
+      assert(res.end.called);
+    });
+    it('accepts valid subscription, covers processVerification failure', async function () {
+      ctx = Object.assign({}, testData.validSubscribeCtx);
+      manager.communication.verificationProcess.rejects('failed');
+      manager.db.topicGetByUrl.resolves({
+        id: 111,
+      });
+      manager.db.verificationInsert.resolves({
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 12,
+      });
+      await manager.postRoot(req, res, ctx);
+      assert(manager.db.verificationInsert.called);
+      assert(res.end.called);
+      assert(manager.logger.error.called);
+    });
+    it('covers db.verificationInsert failure', async function () {
+      const expectedException = new Error('failure');
+      ctx = Object.assign({}, testData.validSubscribeCtx);
+      manager.db.topicGetByUrl.resolves({
+        id: 111,
+      });
+      manager.db.verificationInsert.rejects(expectedException);
+      assert.rejects(async () => {
+        await manager.postRoot(req, res, ctx);
+      }, expectedException);
+    });
+    it('accepts valid unsubscription', async function () {
+      ctx = Object.assign({}, testData.validUnsubscribeCtx);
+      manager.db.topicGetByUrl.resolves({
+        id: 111,
+      });
+      manager.db.subscriptionGet.resolves({
+        id: 22,
+      });
+      manager.db.verificationInsert.resolves({
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 12,
+      });
+      await manager.postRoot(req, res, ctx);
+      assert(res.end.called);
+    });
+    it('accepts valid publish', async function () {
+      ctx = Object.assign({}, testData.validPublishCtx);
+      manager.db.topicGetByUrl.resolves({
+        id: 111,
+      });
+      manager.db.topicFetchRequested.resolves({
+        changes: 1,
+        lastInsertRowid: undefined,
+        duration: 12,
+      });
+      await manager.postRoot(req, res, ctx);
+      assert(res.end.called);
+    });
+  }); // postRoot
+
+  describe('_getRootData', function () {
+    it('extracts expected values', function () {
+      req.getHeader.returns('user@example.com');
+      ctx = Object.assign({}, testData.validSubscribeCtx)
+      const result = Manager._getRootData(req, ctx);
+      assert.deepStrictEqual(result, testData.validRootData);
+    });
+  }); // _getRootData
+
+  describe('_validateRootData', function () {
+    // This only wraps the other _check functions, not bothering with coverage.
+  }); // _validateRootData
+
+  describe('_checkTopic', function () {
+    let dbCtx, data, warn, err;
+    const topic = {
+      id: 111,
+      leaseSecondsPreferred: 86400 * 10,
+      leaseSecondsMax: 86400 * 20,
+      leaseSecondsMin: 86400,
+    };
+    beforeEach(function () {
+      dbCtx = {};
+      data = {};
+      warn = [];
+      err = [];
+    });
+    it('succeeds', async function () {
+      data = {
+        topic: 'http://example.com/blog',
+      };
+      manager.db.topicGetByUrl.resolves(topic);
+      await manager._checkTopic(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 0, warn);
+      assert.strictEqual(err.length, 0, err);
+      assert.strictEqual(data.topicId, 111);
+      assert.strictEqual(data.leaseSeconds, 864000);
+    });
+    it('errors on unknown topic', async function () {
+      manager.db.topicGetByUrl.resolves();
+      await manager._checkTopic(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 0, warn);
+      assert.strictEqual(err.length, 1, err);
+    });
+    it('warns on lease under min range', async function () {
+      data = {
+        topic: 'http://example.com/blog',
+        leaseSeconds: 97,
+      };
+      manager.db.topicGetByUrl.resolves(topic);
+      await manager._checkTopic(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 1, warn);
+      assert.strictEqual(err.length, 0, err);
+      assert.strictEqual(data.topicId, 111);
+      assert.strictEqual(data.leaseSeconds, 86400);
+    });
+    it('warns on lease over max range', async function () {
+      data = {
+        topic: 'http://example.com/blog',
+        leaseSeconds: 86400 * 100,
+      };
+      manager.db.topicGetByUrl.resolves(topic);
+      await manager._checkTopic(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 1, warn);
+      assert.strictEqual(err.length, 0, err);
+      assert.strictEqual(data.topicId, 111);
+      assert.strictEqual(data.leaseSeconds, 86400 * 20);
+    });
+    it('sets publisher validation state when available', async function () {
+      data = {
+        topic: 'http://example.com/blog',
+      };
+      manager.db.topicGetByUrl.resolves(Object.assign({}, topic, {
+        publisherValidationUrl: 'http://example.com/validate',
+      }));
+      await manager._checkTopic(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 0, warn);
+      assert.strictEqual(err.length, 0, err);
+      assert.strictEqual(data.topicId, 111);
+      assert.strictEqual(data.leaseSeconds, 864000);
+      assert.strictEqual(data.isPublisherValidated, false);
+    });
+    it('accepts new public subscribe topic', async function () {
+      manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves(topic);
+      data = {
+        topic: 'http://example.com/blog',
+      };
+      await manager._checkTopic(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 0, 'unexpected warnings length');
+      assert.strictEqual(err.length, 0, 'unexpected errors length');
+      assert.strictEqual(data.topicId, 111, 'unexpected topic id');
+    });
+    it('does not accept new public subscribe for invalid topic', async function () {
+      manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves(topic);
+      data = {
+        topic: 'not a topic',
+      };
+      await manager._checkTopic(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 0, 'unexpected warnings length');
+      assert.strictEqual(err.length, 1, 'unexpected errors length');
+    });
+  }); // _checkTopic
+
+  describe('_checkCallbackAndSecrets', function () {
+    let data, warn, err;
+    let origStrictSecrets;
+    before(function () {
+      origStrictSecrets = manager.options.manager.strictSecrets;
+    });
+    beforeEach(function () {
+      data = {};
+      warn = [];
+      err = [];
+    });
+    afterEach(function () {
+      manager.options.manager.strictSecrets = origStrictSecrets;
+    });
+    it('succeeds', function () {
+      data = {
+        callback: 'https://example.com/callback',
+        secret: 'so safe',
+        isSecure: true,
+      };
+      manager._checkCallbackAndSecrets(data, warn, err);
+      assert.strictEqual(warn.length, 0, warn);
+      assert.strictEqual(err.length, 0, err);
+    });
+    it('errors with invalid callback', function () {
+      data = {
+        callback: 'not a url',
+        secret: 'so safe',
+        isSecure: true,
+      };
+      manager._checkCallbackAndSecrets(data, warn, err);
+      assert.strictEqual(warn.length, 0, warn);
+      assert.strictEqual(err.length, 1, err);
+    });
+    it('errors when secret too large', function () {
+      data = {
+        callback: 'https://example.com/callback',
+        secret: 'x'.repeat(256),
+        isSecure: true,
+      };
+      manager._checkCallbackAndSecrets(data, warn, err);
+      assert.strictEqual(warn.length, 0, warn);
+      assert.strictEqual(err.length, 1, err);
+    });
+    it('warns when callback is insecure', function () {
+      data = {
+        callback: 'http://example.com/callback',
+        isSecure: true,
+      };
+      manager._checkCallbackAndSecrets(data, warn, err);
+      assert.strictEqual(warn.length, 1, warn);
+      assert.strictEqual(err.length, 0, err);
+    });
+    it('warns when hub is insecure with secret', function () {
+      data = {
+        callback: 'https://example.com/callback',
+        secret: 'so safe',
+        isSecure: false,
+      };
+      manager._checkCallbackAndSecrets(data, warn, err);
+      assert.strictEqual(warn.length, 1, warn);
+      assert.strictEqual(err.length, 0, err);
+    });
+    it('errors when callback is insecure with secret and strict', function () {
+      manager.options.manager.strictSecrets = true;
+      data = {
+        callback: 'http://example.com/callback',
+        secret: 'so safe',
+        isSecure: true,
+      };
+      manager._checkCallbackAndSecrets(data, warn, err);
+      assert.strictEqual(warn.length, 1, warn);
+      assert.strictEqual(err.length, 1, err);
+    });
+  }); // _checkCallbackAndSecrets
+
+  describe('_checkMode', function () {
+    let dbCtx, data, warn, err;
+    beforeEach(function () {
+      dbCtx = {};
+      data = {};
+      warn = [];
+      err = [];
+    });
+    it('subscribe succeeds', async function () {
+      data = {
+        mode: 'subscribe',
+      };
+      await manager._checkMode(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 0);
+      assert.strictEqual(err.length, 0);
+    });
+    it('unsubscribe succeeds', async function () {
+      data = {
+        mode: 'unsubscribe',
+        callback: 'http://example.com',
+        topicId: 123,
+      };
+      manager.db.subscriptionGet.resolves({
+        expires: (Date.now() / 1000) + 60,
+      });
+      await manager._checkMode(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 0, warn);
+      assert.strictEqual(err.length, 0, err);
+    });
+    it('unsubscribe requires valid data', async function () {
+      data = {
+        mode: 'unsubscribe',
+        callback: 'http://example.com',
+        topicId: undefined,
+      };
+      manager.db.subscriptionGet.resolves({
+        expires: (Date.now() / 1000) - 60,
+      });
+      await manager._checkMode(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 0, warn);
+      assert.strictEqual(err.length, 1, err);
+    });
+    it('unsubscribe ignores expired subscription', async function () {
+      data = {
+        mode: 'unsubscribe',
+        callback: 'http://example.com',
+        topicId: 123,
+      };
+      manager.db.subscriptionGet.resolves({
+        expires: (Date.now() / 1000) - 60,
+      });
+      await manager._checkMode(dbCtx, data, warn, err);
+      assert.strictEqual(warn.length, 0, warn);
+      assert.strictEqual(err.length, 1, err);
+    });
+  }); // _checkMode
+
+  describe('_checkPublish', function () {
+    let dbCtx, data, warn, err, requestId;
+    beforeEach(function () {
+      dbCtx = {};
+      data = {};
+      warn = [];
+      err = [];
+      requestId = 'blah';
+    });
+    it('succeeds', async function () {
+      manager.db.topicGetByUrl.resolves({
+        id: 222,
+      });
+      Object.assign(data, testData.validPublishRootData);
+      await manager._checkPublish(dbCtx, data, warn, err, requestId);
+      assert.strictEqual(warn.length, 0, 'unexpected warnings length');
+      assert.strictEqual(err.length, 0, 'unexpected errors length');
+      assert.strictEqual(data.topicId, 222, 'unexpected topic id');
+    });
+    it('fails bad url', async function () {
+      Object.assign(data, testData.validPublishRootData, { topic: 'not_a_url' });
+      await manager._checkPublish(dbCtx, data, warn, err, requestId);
+      assert.strictEqual(err.length, 1, 'unexpected errors length');
+      assert.strictEqual(warn.length, 0);
+    });
+    it('accepts new public publish topic', async function () {
+      manager.db.topicGetByUrl.onCall(0).resolves().onCall(1).resolves({
+        id: 222,
+      });
+      Object.assign(data, testData.validPublishRootData);
+      await manager._checkPublish(dbCtx, data, warn, err, requestId);
+      assert.strictEqual(warn.length, 0, 'unexpected warnings length');
+      assert.strictEqual(err.length, 0, 'unexpected errors length');
+      assert.strictEqual(data.topicId, 222, 'unexpected topic id');
+    });
+    it('does not publish deleted topic', async function () {
+      manager.db.topicGetByUrl.resolves({
+        id: 222,
+        isDeleted: true,
+      });
+      Object.assign(data, testData.validPublishRootData);
+      await manager._checkPublish(dbCtx, data, warn, err, requestId);
+      assert.strictEqual(warn.length, 0, 'unexpected warnings length');
+      assert.strictEqual(err.length, 1, 'unexpected errors length');
+      assert.strictEqual(data.topicId, undefined, 'unexpected topic id');
+    });
+  }); // _checkPublish
+
+  describe('processTasks', function () {
+    it('covers', async function () {
+      sinon.stub(manager.communication.worker, 'process');
+      await manager.processTasks(res, ctx);
+      assert(manager.communication.worker.process.called);
+      assert(res.end.called);
+    });
+  }); // processTasks
+
+  describe('updateTopic', function () {
+    it('fails if no topic exists', async function () {
+      try {
+        await manager.updateTopic(res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.ResponseError);
+      }
+    });
+    it('deletes', async function () {
+      ctx.method = 'DELETE';
+      manager.db.topicGetById.resolves({});
+      await manager.updateTopic(res, ctx);
+      assert(manager.db.topicDeleted.called);
+    });
+    it('does not patch without data', async function () {
+      manager.db.topicGetById.resolves({});
+      await manager.updateTopic(res, ctx);
+      assert(!manager.db.topicUpdate.called);
+      assert.strictEqual(res.statusCode, 204);
+    });
+    it('does not patch with same data', async function () {
+      manager.db.topicGetById.resolves({
+        leaseSecondsPreferred: '86400',
+      });
+      ctx.parsedBody = {
+        leaseSecondsPreferred: '86400',
+      };
+      await manager.updateTopic(res, ctx);
+      assert(!manager.db.topicUpdate.called);
+      assert.strictEqual(res.statusCode, 204);
+    });
+    it('patches', async function () {
+      ctx.queryParams = {
+        leaseSecondsPreferred: '86400',
+      };
+      manager.db.topicGetById.resolves({});
+      await manager.updateTopic(res, ctx);
+      assert(manager.db.topicUpdate.called);
+    });
+    it('handles validation error', async function () {
+      ctx.queryParams = {
+        leaseSecondsPreferred: 'blorp',
+      };
+      manager.db.topicGetById.resolves({});
+      manager.db.topicUpdate.rejects(new DBErrors.DataValidation('something'));
+      try {
+        await manager.updateTopic(res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.ResponseError);
+        assert.strictEqual(e.statusCode, 400);
+      }
+    });
+    it('handles generic error', async function () {
+      const expected = new Error('blah');
+      ctx.queryParams = {
+        leaseSecondsPreferred: '123',
+      };
+      manager.db.topicGetById.resolves({});
+      manager.db.topicUpdate.rejects(expected);
+      try {
+        await manager.updateTopic(res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // updateTopic
+
+  describe('updateSubscription', function () {
+    it('fails if no subscription exists', async function () {
+      try {
+        await manager.updateSubscription(res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.ResponseError);
+      }
+    });
+    it('deletes', async function () {
+      ctx.method = 'DELETE';
+      manager.db.subscriptionGetById.resolves({});
+      await manager.updateSubscription(res, ctx);
+      assert(manager.db.verificationInsert.called);
+    });
+    it('does not patch without data', async function () {
+      manager.db.subscriptionGetById.resolves({});
+      await manager.updateSubscription(res, ctx);
+      assert(!manager.db.subscriptionUpdate.called);
+      assert.strictEqual(res.statusCode, 204);
+    });
+    it('does not patch with same data', async function () {
+      manager.db.subscriptionGetById.resolves({
+        signatureAlgorithm: 'sha256',
+      });
+      ctx.parsedBody = {
+        signatureAlgorithm: 'sha256',
+      };
+      await manager.updateSubscription(res, ctx);
+      assert(!manager.db.subscriptionUpdate.called);
+      assert.strictEqual(res.statusCode, 204);
+    });
+    it('patches', async function () {
+      ctx.queryParams = {
+        signatureAlgorithm: 'sha256',
+      };
+      manager.db.subscriptionGetById.resolves({});
+      await manager.updateSubscription(res, ctx);
+      assert(manager.db.subscriptionUpdate.called);
+    });
+    it('handles validation error', async function () {
+      ctx.queryParams = {
+        signatureAlgorithm: 123,
+      };
+      manager.db.subscriptionGetById.resolves({});
+      manager.db.subscriptionUpdate.rejects(new DBErrors.DataValidation('something'));
+      try {
+        await manager.updateSubscription(res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof Errors.ResponseError);
+        assert.strictEqual(e.statusCode, 400);
+      }
+    });
+    it('handles generic error', async function () {
+      const expected = new Error('blah');
+      ctx.queryParams = {
+        signatureAlgorithm: 'blorp',
+      };
+      manager.db.subscriptionGetById.resolves({});
+      manager.db.subscriptionUpdate.rejects(expected);
+      try {
+        await manager.updateSubscription(res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, expected);
+      }
+    });
+  }); // updateSubscription
+
+}); // 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..a00b2ce
--- /dev/null
@@ -0,0 +1,161 @@
+/* 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.authenticator);
+    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);
+  });
+
+  describe('maybeIngestBody', function () {
+    beforeEach(function () {
+      sinon.stub(service, 'bodyData');
+      sinon.stub(service, 'parseBody').returns();
+    });
+    it('covers no body', async function() {
+      service.bodyData.resolves();
+      await service.maybeIngestBody(req, res, ctx);
+    });
+    it('covers body', async function() {
+      service.bodyData.resolves('data');
+      await service.maybeIngestBody(req, res, ctx);
+    });
+  }); // maybeIngestBody
+
+  describe('handlerRedirect', function () {
+    it('covers', async function () {
+      await service.handlerRedirect(req, res, ctx, '/');
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 307);
+    });
+  }); // handlerRedirect
+
+  describe('handlerPostRoot', function () {
+    it('covers public mode', async function () {
+      await service.handlerPostRoot(req, res, ctx);
+      assert(service.manager.postRoot.called);
+    });
+  }); // handlerPostRoot
+
+  describe('handlerGetRoot', function () {
+    it('covers', async function () {
+      await service.handlerGetRoot(req, res, ctx);
+      assert(service.manager.getRoot.called);
+    });
+  }); // handlerGetRoot
+
+  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('handlerGetInfo', function () {
+    it('covers', async function() {
+      await service.handlerGetInfo(req, res, ctx);
+      assert(service.manager.getInfo.called);
+    });
+  }); // handlerGetInfo
+
+  describe('handlerGetAdminOverview', function () {
+    it('covers', async function () {
+      await service.handlerGetAdminOverview(req, res, ctx);
+      assert(service.authenticator.required.called);
+      assert(service.manager.getAdminOverview.called);
+    })
+  }); // handlerGetAdminOverview
+
+  describe('handlerGetAdminTopicDetails', function () {
+    it('covers', async function () {
+      await service.handlerGetAdminTopicDetails(req, res, ctx);
+      assert(service.authenticator.required.called);
+      assert(service.manager.getTopicDetails.called);
+    })
+  }); // handlerGetAdminTopicDetails
+
+  describe('handlerGetStaticFile', function () {
+    it('covers', async function () {
+      service.serveFile.resolves();
+      await service.handlerGetStaticFile(req, res, ctx);
+      assert(service.serveFile.called);
+    });
+  }); // handlerGetStaticFile
+
+  describe('handlerPostAdminProcess', function () {
+    it('covers', async function () {
+      service.serveFile.resolves();
+      await service.handlerPostAdminProcess(req, res, ctx);
+      assert(service.authenticator.required.called);
+      assert(service.manager.processTasks.called);
+    });
+  }); // handlerPostAdminProcess
+
+  describe('handlerUpdateTopic', function () {
+    it('covers', async function () {
+      sinon.stub(service, 'bodyData').resolves();
+      await service.handlerUpdateTopic(req, res, ctx);
+      assert(service.authenticator.required.called);
+      assert(service.manager.updateTopic.called);
+    });
+  }); // handlerUpdateTopic
+
+  describe('handlerUpdateSubscription', function () {
+    it('covers', async function () {
+      sinon.stub(service, 'bodyData').resolves();
+      await service.handlerUpdateSubscription(req, res, ctx);
+      assert(service.authenticator.required.called);
+      assert(service.manager.updateSubscription.called);
+    });
+  }); // handlerUpdateSubscription
+
+});
\ No newline at end of file
diff --git a/test/src/template/admin-overview-html.js b/test/src/template/admin-overview-html.js
new file mode 100644 (file)
index 0000000..5fa0a0e
--- /dev/null
@@ -0,0 +1,30 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/admin-overview-html');
+const Config = require('../../../config');
+const config = new Config('test');
+
+describe('Admin Overview HTML Template', function () {
+  let ctx;
+
+  beforeEach(function () {
+    ctx = {};
+  });
+
+  it('covers missing topics', function () {
+    const result = template(ctx, config);
+    assert(result);
+  });
+  it('covers single topic', function () {
+    ctx.topics = [{}];
+    const result = template(ctx, config);
+    assert(result);
+  });
+  it('covers plural topics', function () {
+    ctx.topics = [{}, {}, {}];
+    const result = template(ctx, config);
+    assert(result);
+  });
+});
diff --git a/test/src/template/admin-topic-details-html.js b/test/src/template/admin-topic-details-html.js
new file mode 100644 (file)
index 0000000..52a8ed8
--- /dev/null
@@ -0,0 +1,36 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/admin-topic-details-html');
+const Config = require('../../../config');
+const config = new Config('test');
+
+describe('Admin Topic Details HTML Template', function () {
+  let ctx;
+
+  beforeEach(function () {
+    ctx = {
+      topic: {},
+      subscriptions: [
+        {},
+      ],
+    };
+  });
+
+  it('renders', function () {
+    const result = template(ctx, config);
+    assert(result);
+  });
+  it('covers missing subscriptions', function () {
+    delete ctx.subscriptions;
+    const result = template(ctx, config);
+    assert(result);
+  });
+  it('covers plural subscriptions', function () {
+    ctx.subscriptions = [{}, {}, {}];
+    const result = template(ctx, config);
+    assert(result);
+  });
+
+});
diff --git a/test/src/template/badge-svg.js b/test/src/template/badge-svg.js
new file mode 100644 (file)
index 0000000..1f4e026
--- /dev/null
@@ -0,0 +1,34 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/badge-svg');
+const Config = require('../../../config');
+
+describe('Badge SVG Template', function () {
+  let ctx, label, message, accessibleText;
+
+  beforeEach(function () {
+    ctx = {};
+    label = 'label';
+    message = 'message';
+    accessibleText = 'accessibleText';
+  });
+
+  it('renders', function () {
+    const result = template(ctx, label, message, accessibleText);
+    assert(result);
+  });
+
+  it('covers escaping number', function () {
+    label = 123;
+    const result = template(ctx, label, message, accessibleText);
+    assert(result);
+  });
+
+  it('covers escaping unknown', function () {
+    label = {};
+    const result = template(ctx, label, message, accessibleText);
+    assert(result);
+  });
+});
diff --git a/test/src/template/root-html.js b/test/src/template/root-html.js
new file mode 100644 (file)
index 0000000..f48cb08
--- /dev/null
@@ -0,0 +1,34 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const template = require('../../../src/template/root-html');
+const Config = require('../../../config');
+
+describe('Root HTML Template', function () {
+  let ctx, config;
+
+  beforeEach(function () {
+    ctx = {};
+    config = new Config('test');
+  });
+
+  it('renders', function () {
+    const result = template(ctx, config);
+    assert(result);
+  });
+
+  it('covers options', function () {
+    delete config.dingus.selfBaseUrl;
+    const result = template(ctx, config);
+    assert(result);
+  });
+
+  it('covers options', function () {
+    config.adminContactHTML = '<div>support</div>';
+    config.manager.publicHub = false;
+    const result = template(ctx, config);
+    assert(result);
+  });
+
+});
diff --git a/test/src/template/template-helper.js b/test/src/template/template-helper.js
new file mode 100644 (file)
index 0000000..4ddf360
--- /dev/null
@@ -0,0 +1,206 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const th = require('../../../src/template/template-helper');
+const Config = require('../../../config');
+const config = new Config('test');
+
+describe('Template Helper', function () {
+  let ctx;
+
+  beforeEach(function () {
+    ctx = {};
+  });
+
+  describe('dateOrNot', function () {
+    let date, otherwise;
+    beforeEach(function () {
+      date = new Date();
+      otherwise = 'otherwise';
+    });
+    it('covers', function () {
+      const result = th.dateOrNot(date, otherwise);
+      assert.strictEqual(result, date.toString());
+    });
+    it('covers no date', function () {
+      date = undefined;
+      const result = th.dateOrNot(date, otherwise);
+      assert.strictEqual(result, otherwise);
+    });
+    it('covers ms', function () {
+      const result = th.dateOrNot(date.getTime(), otherwise);
+      assert.strictEqual(result, date.toString());
+    });
+    it('covers naught', function () {
+      const result = th.dateOrNot(0, otherwise);
+      assert.strictEqual(result, otherwise);
+    });
+    it('covers the infinite', function () {
+      const result = th.dateOrNot(-Infinity, otherwise);
+      assert.strictEqual(result, otherwise);
+    });
+  }); // dateOrNot
+
+  describe('secondsToPeriod', function () {
+    it('covers seconds', function () {
+      const result = th.secondsToPeriod(45);
+      assert.strictEqual(result, '45 seconds');
+    });
+    it('covers minutes', function () {
+      const result = th.secondsToPeriod(105);
+      assert.strictEqual(result, '1 minute 45 seconds');
+    });
+    it('covers hours', function () {
+      const result = th.secondsToPeriod(3705);
+      assert.strictEqual(result, '1 hour 1 minute 45 seconds');
+    });
+    it('covers days', function () {
+      const result = th.secondsToPeriod(90105);
+      assert.strictEqual(result, '1 day 1 hour 1 minute 45 seconds');
+    });
+    it('covers months', function () {
+      const result = th.secondsToPeriod(5274105);
+      assert.strictEqual(result, '2 months 1 day 1 hour 1 minute 45 seconds');
+    });
+  }); // secondsToPeriod
+
+  describe('renderTopicRow', function () {
+    let topic, subscribers;
+    beforeEach(function () {
+      topic = {};
+      subscribers = [];
+    });
+    it('covers', function () {
+      const result = th.renderTopicRow(topic, subscribers);
+      assert(result);
+    });
+    it('covers no link', function () {
+      subscribers = [{}, {}];
+      const result = th.renderTopicRow(topic, subscribers, false);
+      assert(result);
+    });
+    it('covers validation', function () {
+      topic.publisherValidationUrl = 'https://example.com/';
+      const result = th.renderTopicRow(topic, subscribers, false);
+      assert(result);
+    });
+  }); // renderTopicRow
+
+  describe('renderTopicRowHeader', function () {
+    it('covers', function () {
+      const result = th.renderTopicRowHeader();
+      assert(result);
+    });
+  }); // renderTopicRowHeader
+
+  describe('renderSubscriptionRow', function () {
+    let subscription;
+    beforeEach(function () {
+      subscription = {};
+    });
+    it('covers', function () {
+      const result = th.renderSubscriptionRow(subscription);
+      assert(result);
+    });
+  }); // renderSubscriptionRow
+
+  describe('renderSubscriptionRowHeader', function () {
+    it('covers', function () {
+      const result = th.renderSubscriptionRowHeader();
+      assert(result);
+    });
+  }); // renderSubscriptionRowHeader
+
+  describe('htmlHead', function () {
+    let pagePathLevel, pageTitle, headElements;
+    beforeEach(function () {
+      pagePathLevel = 2;
+      pageTitle = 'title';
+    });
+    it('covers', function () {
+      const result = th.htmlHead(pagePathLevel, pageTitle, headElements);
+      assert(result);
+    });
+    it('covers elements', function () {
+      headElements = [ '<div>foop</div>', '<div>poof</div>' ];
+      const result = th.htmlHead(pagePathLevel, pageTitle, headElements);
+      assert(result);
+    });
+  }); // htmlHead
+
+  describe('htmlTail', function () {
+    it('covers', function () {
+      const result = th.htmlTail();
+      assert(result);
+    });
+  }); // htmlTail
+
+  describe('renderNavLink', function () {
+    let nav;
+    beforeEach(function () {
+      nav = {
+        href: 'https://example.com/',
+        text: 'example',
+      };
+    });
+    it('covers no class', function () {
+      const result = th.renderNavLink(nav);
+      assert(result);
+    });
+    it('covers class', function () {
+      nav.class = 'foo bar';
+      const result = th.renderNavLink(nav);
+      assert(result);
+    });
+  }); // renderNavLink
+
+  describe('htmlHeader', function () {
+    let pageTitle, navLinks;
+    beforeEach(function () {
+      pageTitle = 'title';
+      navLinks = [];
+    });
+    it('covers no links', function () {
+      const result = th.htmlHeader(pageTitle);
+      assert(result);
+    });
+    it('covers links', function () {
+      navLinks = [
+        {
+          href: 'https://exmaple.com/',
+          text: 'example',
+        },
+      ];
+      const result = th.htmlHeader(pageTitle, navLinks);
+      assert(result);
+    });
+  }); // htmlHeader
+
+  describe('htmlFooter', function () {
+    it('covers', function () {
+      const result = th.htmlFooter();
+      assert(result);
+    });
+  }); // htmlFooter
+
+  describe('htmlTemplate', function () {
+    let pagePathLevel, pageTitle, headElements, navLinks, main;
+    beforeEach(function () {
+      pagePathLevel = 1;
+      pageTitle = 'title';
+      headElements = [];
+      navLinks = [];
+      main = [];
+    });
+    it('covers', function () {
+      const result = th.htmlTemplate(pagePathLevel, pageTitle, headElements, navLinks, main);
+      assert(result);
+    });
+    it('covers defaults', function () {
+      const result = th.htmlTemplate(pagePathLevel, pageTitle);
+      assert(result);
+    });
+  }); // htmlTemplate
+
+});
diff --git a/test/src/worker.js b/test/src/worker.js
new file mode 100644 (file)
index 0000000..a9047a0
--- /dev/null
@@ -0,0 +1,195 @@
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+
+const Worker = require('../../src/worker');
+const Config = require('../../config');
+
+const stubLogger = require('../stub-logger');
+
+const noExpectedException = 'did not get expected exception';
+
+describe('Worker', function () {
+  let config;
+  let worker;
+  let promiseGiver;
+
+  beforeEach(function () {
+    config = new Config('test');
+    promiseGiver = sinon.stub();
+    worker = new Worker(stubLogger, promiseGiver, config);
+  });
+
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  describe('constructor', function () {
+    it('instantiates', function () {
+      assert(worker);
+    });
+  
+    it('requires a promiseGiver function', function () {
+      try {
+        worker = new Worker(stubLogger, undefined, config);
+        assert.fail('should require function argument');
+      } catch (e) {
+        assert(e instanceof TypeError);
+      }
+    });
+  }); // constructor
+
+  describe('start', function () {
+    it('starts without polling', function () {
+      config.worker.pollingEnabled = false;
+      worker = new Worker(stubLogger, promiseGiver, config);
+      worker.start();
+      assert.strictEqual(worker.running, false);
+    });
+    it('starts with polling', function () {
+      config.worker.pollingEnabled = true;
+      worker = new Worker(stubLogger, promiseGiver, config);
+      sinon.stub(worker, '_recurr');
+      worker.start();
+      clearTimeout(worker.nextTimeout);
+      assert.strictEqual(worker.running, true);
+    });
+  }); // start
+
+  describe('stop', function () {
+    it('stops', function () {
+      worker = new Worker(stubLogger, promiseGiver, config);
+      worker.start();
+      worker.stop();
+      assert.strictEqual(worker.running, false);
+      assert.strictEqual(worker.nextTimeout, undefined);
+    });
+  }); // stop
+
+  describe('watchedPromise', function () {
+    let promise;
+    it('watches a resolvable promise', async function () {
+      const res = 'yay';
+      promise = Promise.resolve(res);
+      const watched = Worker.watchedPromise(promise);
+      const result = await watched;
+      assert.strictEqual(result, res);
+      assert.strictEqual(watched.resolved, res);
+      assert(watched.isSettled);
+    });
+    it('watches a rejectable promise', async function () {
+      const rej = new Error('boo');
+      promise = Promise.reject(rej);
+      const watched = Worker.watchedPromise(promise);
+      try {
+        await watched;
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.deepStrictEqual(e, rej);
+        assert.deepStrictEqual(watched.rejected, rej);
+        assert(watched.isSettled);
+      }
+    });
+    it('covers wrapped promise', async function () {
+      const res = 'yay';
+      promise = Promise.resolve(res);
+      const watched = Worker.watchedPromise(promise);
+      const rewatched = Worker.watchedPromise(watched);
+      const result = await rewatched;
+      assert.strictEqual(result, res);
+      assert.strictEqual(rewatched.resolved, res);
+      assert(rewatched.isSettled);
+    });
+  }); // watchedPromise
+
+  describe('_handleWatchedList', function () {
+    let handler;
+    beforeEach(function () {
+      handler = sinon.stub();
+    });
+    it('handled resolveds', function () {
+      worker.inFlight = [
+        { isSettled: false, resolved: undefined, rejected: undefined },
+        { isSettled: true, resolved: 'value', rejected: undefined },
+        { isSettled: true, resolved: undefined, rejected: 'error' },
+        { isSettled: false, resolved: undefined, rejected: undefined },
+      ];
+      const result = worker._handleWatchedList(handler);
+      assert.strictEqual(result, 2);
+      assert.strictEqual(worker.inFlight.length, 2);
+      assert.strictEqual(handler.callCount, 2);
+    });
+  }); // _handleWatchedList
+
+  describe('_getWork', function () {
+    it('gets tasks', async function () {
+      const expected = [
+        Promise.resolve('first'),
+        Promise.reject('bad'),
+        Promise.resolve('second'),
+      ];
+      worker.promiseGiver.resolves(expected);
+      const result = await worker._getWork();
+      assert.deepStrictEqual(result, expected);
+      assert.strictEqual(worker.inFlight.length, expected.length);
+    });
+    it('covers none wanted', async function () {
+      worker.concurrency = 3;
+      worker.inFlight = [
+        Promise.resolve('first'),
+        Promise.reject('bad'),
+        Promise.resolve('second'),
+      ];
+      const result = await worker._getWork();
+      assert(!worker.promiseGiver.called);
+      assert.deepStrictEqual(result, []);
+    });
+  }); // _getWork
+
+  describe('_watchedHandler', function () {
+    it('covers resolved', function () {
+      worker._watchedHandler('resolved', undefined);
+    });
+    it('covers rejected', function () {
+      worker._watchedHandler(undefined, 'rejected');
+    });
+  }); // _watchedHandler
+
+  describe('_recurr', function () {
+    it('covers', function (done) {
+      worker.recurrSleepMs = 10;
+      this.slow(worker.recurrSleepMs * 3);
+      sinon.stub(worker, 'process').callsFake(done);
+      worker.running = true;
+      worker._recurr();
+    });
+    it('covers not running', function () {
+      worker.running = false;
+      worker._recurr();
+    });
+  }); // _recurr
+
+  describe('process', function () {
+    beforeEach(function () {
+      sinon.stub(worker, '_getWork');
+      sinon.stub(worker, '_recurr');
+    });
+    it('covers', async function () {
+      worker.inFlight = [
+        Worker.watchedPromise(Promise.resolve('one')),
+        Worker.watchedPromise(Promise.reject('foo')),
+      ];
+      await worker.process();
+      assert.strictEqual(worker._getWork.callCount, 2);
+      assert.strictEqual(worker._recurr.callCount, 1);
+    });
+    it('covers no work', async function () {
+      await worker.process();
+      assert.strictEqual(worker._getWork.callCount, 1);
+      assert.strictEqual(worker._recurr.callCount, 1);
+    });
+  }); // process
+
+}); // Worker
diff --git a/test/stub-db.js b/test/stub-db.js
new file mode 100644 (file)
index 0000000..608ea77
--- /dev/null
@@ -0,0 +1,67 @@
+/* eslint-disable security/detect-object-injection */
+'use strict';
+
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+
+const spyFns = [
+  'context',
+  'transaction',
+];
+
+const stubFns = [
+  'authenticationSuccess',
+  'authenticationGet',
+  'authenticationUpsert',
+  'healthCheck',
+  'schemaCheck',
+  'subscriptionsByTopicId',
+  'subscriptionCountByTopicUrl',
+  'subscriptionDelete',
+  'subscriptionDeliveryClaim',
+  'subscriptionDeliveryClaimById',
+  'subscriptionDeliveryComplete',
+  'subscriptionDeliveryGone',
+  'subscriptionDeliveryIncomplete',
+  'subscriptionGet',
+  'subscriptionGetById',
+  'subscriptionUpdate',
+  'subscriptionUpsert',
+  'topicDeleted',
+  'topicFetchClaim',
+  'topicFetchClaimById',
+  'topicFetchComplete',
+  'topicFetchIncomplete',
+  'topicFetchRequested',
+  'topicGetAll',
+  'topicGetById',
+  'topicGetByUrl',
+  'topicGetContentById',
+  'topicSet',
+  'topicSetContent',
+  'topicUpdate',
+  'verificationClaim',
+  'verificationClaimById',
+  'verificationComplete',
+  'verificationGetById',
+  'verificationIncomplete',
+  'verificationInsert',
+  'verificationRelease',
+  'verificationUpdate',
+  'verificationValidated',
+];
+
+const stubDatabase = {
+  _implementation: [ ...spyFns, ...stubFns ],
+  _reset: () => {
+    spyFns.forEach((fn) => sinon.spy(stubDatabase, fn));
+    stubFns.forEach((fn) => sinon.stub(stubDatabase, fn));
+  },
+  context: async (fn) => await fn({}),
+  transaction: async (dbCtx, fn) => await fn(dbCtx),
+};
+
+stubFns.forEach((fn) => {
+  stubDatabase[fn] = () => {};
+});
+
+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..5ac0b98
--- /dev/null
@@ -0,0 +1,18 @@
+'use strict';
+
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+
+const nop = () => { /* */ };
+const stubLogger = process.env.VERBOSE_TESTS ? console : {
+  debug: nop,
+  error: nop,
+  info: nop,
+};
+stubLogger['_reset'] = () => {
+  sinon.spy(stubLogger, 'debug');
+  sinon.spy(stubLogger, 'error');
+  sinon.spy(stubLogger, 'info');
+},
+
+
+module.exports = stubLogger;
\ No newline at end of file
diff --git a/test/test-data/db-integration.js b/test/test-data/db-integration.js
new file mode 100644 (file)
index 0000000..a94611c
--- /dev/null
@@ -0,0 +1,54 @@
+'use strict';
+
+const remoteAddress = '127.0.0.10';
+const callbackUrl = 'https://example.com/consume?id=123&token=abc';
+const topicUrl = 'https://example.com/some_blog';
+const modeSubscribe = 'subscribe';
+
+module.exports = {
+  topicSet: {
+    url: topicUrl,
+    leaseSecondsPreferred: null,
+    leaseSecondsMin: 86400,
+    leaseSecondsMax: 8640000,
+    publisherValidationUrl: null,
+  },
+  anotherTopicSet: {
+    url: 'https://example.com/another_blog/',
+    leaseSecondsPreferred: null,
+    leaseSecondsMin: 86400,
+    leaseSecondsMax: 8640000,
+    publisherValidationUrl: null,
+  },
+  topicUpdate: {
+    url: topicUrl,
+    leaseSecondsPreferred: 864000,
+  },
+  topicSetContent: {
+    topicId: undefined,
+    content: 'content',
+    contentHash: 'b2d1d285b5199c85f988d03649c37e44fd3dde01e5d69c50fef90651962f48110e9340b60d49a479c4c0b53f5f07d690686dd87d2481937a512e8b85ee7c617f',
+    contentType: 'text/plain',
+  },
+  subscriptionUpsert: {
+    callback: callbackUrl,
+    topicId: undefined,
+    leaseSeconds: 172800,
+    secret: 'SecretSecret',
+    httpRemoteAddr: remoteAddress,
+  },
+  verificationInsert: {
+    topicId: undefined,
+    callback: callbackUrl,
+    mode: modeSubscribe,
+    secret: 'SecretSecret',
+    leaseSeconds: 864000,
+    httpRemoteAddr: remoteAddress,
+    isPublisherValidated: false,
+  },
+  verificationUpdate: {
+    mode: modeSubscribe,
+    reason: 'reason',
+    isPublisherValidated: true,
+  },
+};
\ No newline at end of file
diff --git a/test/test-data/link-helper.js b/test/test-data/link-helper.js
new file mode 100644 (file)
index 0000000..f402915
--- /dev/null
@@ -0,0 +1,179 @@
+'use strict';
+
+const rssFeedUrl = 'https://puppetcircuits.wordpress.com/feed/';
+const rssFeedBody = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
+       xmlns:content="http://purl.org/rss/1.0/modules/content/"
+       xmlns:wfw="http://wellformedweb.org/CommentAPI/"
+       xmlns:dc="http://purl.org/dc/elements/1.1/"
+       xmlns:atom="http://www.w3.org/2005/Atom"
+       xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
+       xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
+       xmlns:georss="http://www.georss.org/georss" xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#" xmlns:media="http://search.yahoo.com/mrss/"
+       >
+
+<channel>
+       <title>Puppet Circuits</title>
+       <atom:link href="https://puppetcircuits.wordpress.com/feed/" rel="self" type="application/rss+xml" />
+       <link>https://puppetcircuits.wordpress.com</link>
+       <description></description>
+       <lastBuildDate>Fri, 10 Feb 2012 19:26:16 +0000</lastBuildDate>
+       <language>en</language>
+       <sy:updatePeriod>
+       hourly  </sy:updatePeriod>
+       <sy:updateFrequency>
+       1       </sy:updateFrequency>
+       <generator>http://wordpress.com/</generator>
+<cloud domain='puppetcircuits.wordpress.com' port='80' path='/?rsscloud=notify' registerProcedure='' protocol='http-post' />
+<image>
+               <url>https://s0.wp.com/i/buttonw-com.png</url>
+               <title>Puppet Circuits</title>
+               <link>https://puppetcircuits.wordpress.com</link>
+       </image>
+       <atom:link rel="search" type="application/opensearchdescription+xml" href="https://puppetcircuits.wordpress.com/osd.xml" title="Puppet Circuits" />
+       <atom:link rel='hub' href='https://puppetcircuits.wordpress.com/?pushpress=hub'/>
+       <item>
+               <title>Been a while! And: fun video from Eric</title>
+               <link>https://puppetcircuits.wordpress.com/2012/02/10/been-a-while-and-fun-video-from-eric/</link>
+                                       <comments>https://puppetcircuits.wordpress.com/2012/02/10/been-a-while-and-fun-video-from-eric/#respond</comments>
+
+               <dc:creator><![CDATA[raphaelabrams]]></dc:creator>
+               <pubDate>Fri, 10 Feb 2012 19:26:16 +0000</pubDate>
+                               <category><![CDATA[Uncategorized]]></category>
+               <guid isPermaLink="false">http://puppetcircuits.wordpress.com/?p=164</guid>
+
+                                       <description><![CDATA[Yes, I DO think I can space my posts one year apart! Here&#8217;s a really very nice video that Eric Skiff made that features me and one of my creations.]]></description>
+                                                                               <content:encoded><![CDATA[<p>Yes, I DO think I can space my posts one year apart!</p>
+<p>Here&#8217;s a really very nice video that Eric Skiff made that features me and one of my creations.</p>
+<iframe class='youtube-player' width='560' height='315' src='https://www.youtube.com/embed/7yHF0Ubxdnc?version=3&#038;rel=0&#038;showsearch=0&#038;showinfo=1&#038;iv_load_policy=1&#038;fs=1&#038;hl=en&#038;autohide=2&#038;wmode=transparent' allowfullscreen='true' style='border:0;' sandbox='allow-scripts allow-same-origin allow-popups allow-presentation'></iframe>
+]]></content:encoded>
+
+                                       <wfw:commentRss>https://puppetcircuits.wordpress.com/2012/02/10/been-a-while-and-fun-video-from-eric/feed/</wfw:commentRss>
+                       <slash:comments>0</slash:comments>
+
+
+
+               <media:content url="https://0.gravatar.com/avatar/c19e5ba7f3abc36656779edcc9c6b6eb?s=96&#38;d=identicon&#38;r=G" medium="image">
+                       <media:title type="html">raphaelabrams</media:title>
+               </media:content>
+       </item>
+               <item>
+               <title>Botacon!</title>
+               <link>https://puppetcircuits.wordpress.com/2011/02/22/botacon/</link>
+                                       <comments>https://puppetcircuits.wordpress.com/2011/02/22/botacon/#respond</comments>
+
+               <dc:creator><![CDATA[raphaelabrams]]></dc:creator>
+               <pubDate>Tue, 22 Feb 2011 06:55:49 +0000</pubDate>
+                               <category><![CDATA[robotics]]></category>
+               <category><![CDATA[servos]]></category>
+               <guid isPermaLink="false">http://puppetcircuits.wordpress.com/?p=158</guid>
+
+                                       <description><![CDATA[A little late, but here&#8217;s a video of me talking at Botacon a few months ago.]]></description>
+                                                                               <content:encoded><![CDATA[<p>A little late, but here&#8217;s a video of me talking at Botacon a few months ago.</p>
+<iframe class='youtube-player' width='450' height='254' src='https://www.youtube.com/embed/OV5gG29jvaQ?version=3&#038;rel=1&#038;showsearch=0&#038;showinfo=1&#038;iv_load_policy=1&#038;fs=1&#038;hl=en&#038;autohide=2&#038;wmode=transparent' allowfullscreen='true' style='border:0;' sandbox='allow-scripts allow-same-origin allow-popups allow-presentation'></iframe>
+]]></content:encoded>
+
+                                       <wfw:commentRss>https://puppetcircuits.wordpress.com/2011/02/22/botacon/feed/</wfw:commentRss>
+                       <slash:comments>0</slash:comments>
+
+
+
+               <media:content url="https://0.gravatar.com/avatar/c19e5ba7f3abc36656779edcc9c6b6eb?s=96&#38;d=identicon&#38;r=G" medium="image">
+                       <media:title type="html">raphaelabrams</media:title>
+               </media:content>
+       </item>
+               <item>
+               <title>Oddball Hotplate</title>
+               <link>https://puppetcircuits.wordpress.com/2011/01/22/oddball-hotplate/</link>
+                                       <comments>https://puppetcircuits.wordpress.com/2011/01/22/oddball-hotplate/#comments</comments>
+
+               <dc:creator><![CDATA[raphaelabrams]]></dc:creator>
+               <pubDate>Sat, 22 Jan 2011 21:26:55 +0000</pubDate>
+                               <category><![CDATA[electronics]]></category>
+               <guid isPermaLink="false">http://puppetcircuits.wordpress.com/?p=155</guid>
+
+                                       <description><![CDATA[I made a special hotplate for soldering all the circuit boards for the Blueman project. It&#8217;s 22&#8243; long and 1&#8243; wide. I made it out of a single block of aluminum that I CNC milled to fit two 750 watt heaters and a thermocouple. There&#8217;s also a PIC that monitors the thermocouple and has a [&#8230;]]]></description>
+                                                                               <content:encoded><![CDATA[<iframe class='youtube-player' width='450' height='254' src='https://www.youtube.com/embed/Va2SkWK1QII?version=3&#038;rel=1&#038;showsearch=0&#038;showinfo=1&#038;iv_load_policy=1&#038;fs=1&#038;hl=en&#038;autohide=2&#038;wmode=transparent' allowfullscreen='true' style='border:0;' sandbox='allow-scripts allow-same-origin allow-popups allow-presentation'></iframe>
+<p>I made a special hotplate for soldering all the circuit boards for the Blueman project. It&#8217;s 22&#8243; long and 1&#8243; wide. I made it out of a single block of aluminum that I CNC milled to fit two 750 watt heaters and a thermocouple. There&#8217;s also a PIC that monitors the thermocouple and has a relay to turn the heaters on and off. The display shows target and current temperatures to within 1/4 degree Celcius. Having this to do my soldering kept me from losing my sanity. There&#8217;s 0ver 1000 solder joints on each wand!</p>
+<p>&nbsp;</p>
+]]></content:encoded>
+
+                                       <wfw:commentRss>https://puppetcircuits.wordpress.com/2011/01/22/oddball-hotplate/feed/</wfw:commentRss>
+                       <slash:comments>1</slash:comments>
+
+
+
+               <media:content url="https://0.gravatar.com/avatar/c19e5ba7f3abc36656779edcc9c6b6eb?s=96&#38;d=identicon&#38;r=G" medium="image">
+                       <media:title type="html">raphaelabrams</media:title>
+               </media:content>
+       </item>
+       </channel>
+</rss>`;
+
+const atomFeedUrl = 'https://squeep.com/eats/atom/';
+const atomFeedBody = `<?xml version="1.0" encoding="UTF-8"?>
+<feed
+ xmlns="http://www.w3.org/2005/Atom"
+ xmlns:thr="http://purl.org/syndication/thread/1.0"
+ xml:lang="en" >
+       <title type="text">Fyne Vyttles</title>
+       <subtitle type="text">An incomplete collection of foods worth sharing.</subtitle>
+       <generator>Recipe Index v0.0</generator>
+       <updated>2020-10-08T19:47:16Z</updated>
+       <link rel="alternate" type="text/xhtml" href="https://squeep.com/eats/" />
+       <link rel="self" type="application/atom+xml" href="https://squeep.com/eats/atom/" />
+       <link rel="hub" href="https://hub.squeep.com/" />
+       <id>https://squeep.com/eats/atom/</id>
+       <entry>
+               <author>
+                       <name></name>
+                       <uri>https://squeep.com/eats/</uri>
+               </author>
+               <title>![CDATA[E&amp;O Trading Company Corn Fritters]]</title>
+               <link rel="alternate" type="text/xhtml" href="https://squeep.com/eats/secret/e-and-o-corn-fritters.xhtml" />
+               <id>https://squeep.com/eats/secret/e-and-o-corn-fritters.xhtml</id>
+               <category scheme="https://squeep.com/eats/" term="secret" />
+               <updated>2020-10-08T19:47:16Z</updated>
+               <published>2020-10-08T19:47:16Z</published>
+       </entry>
+       <entry>
+               <author>
+                       <name></name>
+                       <uri>https://squeep.com/eats/</uri>
+               </author>
+               <title>![CDATA[Chevy's Sweet Corn Tamalito]]</title>
+               <link rel="alternate" type="text/xhtml" href="https://squeep.com/eats/secret/chevys-sweet-corn-tamalito.xhtml" />
+               <id>https://squeep.com/eats/secret/chevys-sweet-corn-tamalito.xhtml</id>
+               <category scheme="https://squeep.com/eats/" term="secret" />
+               <updated>2020-07-15T02:15:28Z</updated>
+               <published>2020-07-15T02:15:28Z</published>
+       </entry>
+</feed>
+`;
+
+const htmlUrl = 'https://squeep.com/eats/';
+const htmlBody = `<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
+   "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"
+      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+      xsi:schemaLocation="http://www.w3.org/1999/xhtml
+      http://www.w3.org/MarkUp/SCHEMA/xhtml11.xsd"
+>
+<head>
+       <title>Squeep Recipe Book</title>
+       <link rel="preload" href="oldstyle.otf" as="font" type="font/opentype" crossorigin="anonymous" />
+       <link rel="stylesheet" type="text/css" href="eats.css" />
+       <link rel="hub" href="https://hub.squeep.com/" />
+       <link rel="alternate" type="application/atom+xml" title="Atom 1.0" href="https://squeep.com/eats/atom/" />
+</head>
+<body>
+<hr />`;
+
+module.exports = {
+       atomFeedBody,
+       atomFeedUrl,
+       htmlBody,
+       htmlUrl,
+  rssFeedBody,
+       rssFeedUrl,
+};
diff --git a/test/test-data/manager.js b/test/test-data/manager.js
new file mode 100644 (file)
index 0000000..a1900f0
--- /dev/null
@@ -0,0 +1,55 @@
+'use strict';
+
+const remoteAddress = '127.0.0.10';
+const callbackUrl = 'https://example.com/consume?id=123&token=abc';
+const topicUrl = 'https://example.com/some_blog';
+const modePublish = 'publish';
+const modeSubscribe = 'subscribe';
+
+module.exports = {
+  validSubscribeCtx: {
+    clientAddress: remoteAddress,
+    clientProtocol: 'https',
+    parsedBody: {
+      'hub.mode': modeSubscribe,
+      'hub.callback': callbackUrl,
+      'hub.topic': topicUrl,
+      'hub.extra': 'unused value',
+      'hub.lease_seconds': '864000',
+      'hub.secret': 'such secret',
+    },
+  },
+  validRootData: {
+    callback: callbackUrl,
+    mode: modeSubscribe,
+    topic: topicUrl,
+    leaseSeconds: 864000,
+    secret: 'such secret',
+    httpRemoteAddr: remoteAddress,
+    httpFrom: 'user@example.com',
+    isSecure: true,
+    isPublisherValidated: true,
+  },
+  validUnsubscribeCtx: {
+    clientAddress: remoteAddress,
+    clientProtocol: 'https',
+    parsedBody: {
+      'hub.mode': 'unsubscribe',
+      'hub.callback': callbackUrl,
+      'hub.topic': topicUrl,
+    },
+  },
+  validPublishCtx: {
+    clientAddress: remoteAddress,
+    clientProtocol: 'https',
+    parsedBody: {
+      'hub.mode': modePublish,
+      'hub.topic': topicUrl,
+    },
+  },
+  validPublishRootData: {
+    httpRemoteAddr: remoteAddress,
+    mode: modePublish,
+    topic: topicUrl,
+  },
+};
\ No newline at end of file