redeem proffered tickets, db schema 1.1.0
authorJustin Wind <justin.wind+git@gmail.com>
Fri, 22 Dec 2023 21:03:30 +0000 (13:03 -0800)
committerJustin Wind <justin.wind+git@gmail.com>
Fri, 22 Dec 2023 23:43:14 +0000 (15:43 -0800)
38 files changed:
.eslintrc.json
README.md
bin/publishTickets.js [new file with mode: 0644]
bin/ticket-queue-profile.js [new file with mode: 0644]
config/default.js
config/test.js
documentation/media/postgres-er.svg
documentation/media/sqlite-er.svg
package-lock.json
package.json
src/chores.js
src/db/abstract.js
src/db/postgres/index.js
src/db/postgres/sql/schema/1.1.0/apply.sql [new file with mode: 0644]
src/db/postgres/sql/schema/1.1.0/er.dot [new file with mode: 0644]
src/db/postgres/sql/schema/1.1.0/revert.sql [new file with mode: 0644]
src/db/postgres/sql/ticket-redeemed.sql [new file with mode: 0644]
src/db/postgres/sql/ticket-token-get-unpublished.sql [new file with mode: 0644]
src/db/postgres/sql/ticket-token-published.sql [new file with mode: 0644]
src/db/sqlite/index.js
src/db/sqlite/sql/schema/1.0.0/revert.sql
src/db/sqlite/sql/schema/1.1.0/apply.sql [new file with mode: 0644]
src/db/sqlite/sql/schema/1.1.0/er.dot [new file with mode: 0644]
src/db/sqlite/sql/schema/1.1.0/revert.sql [new file with mode: 0644]
src/db/sqlite/sql/ticket-redeemed.sql [new file with mode: 0644]
src/db/sqlite/sql/ticket-token-get-unpublished.sql [new file with mode: 0644]
src/db/sqlite/sql/ticket-token-published.sql [new file with mode: 0644]
src/enum.js
src/manager.js
src/service.js
src/template/admin-html.js
src/template/root-html.js
test/src/chores.js
test/src/db/integration.js
test/src/db/postgres.js
test/src/db/sqlite.js
test/src/manager.js
test/stub-db.js

index b3ffbe2a33db74e7d1c2dafd6be10a0371ae7343..a5481e7a6c357cf5b39ec531fe16f0688f9be71a 100644 (file)
@@ -7,7 +7,7 @@
   "extends": [
     "eslint:recommended",
     "plugin:node/recommended",
-    "plugin:security/recommended",
+    "plugin:security/recommended-legacy",
     "plugin:sonarjs/recommended"
   ],
   "parserOptions": {
index e37aea1d3e989dbc9147e864f9fe18c13f375869..b5e6fb28054706ab2654062300dbe4cca7431c28 100644 (file)
--- a/README.md
+++ b/README.md
@@ -12,23 +12,25 @@ A ```user``` is an entity known to this service, with a credential (currently a
 Authentication of a ```user``` is handled by either a [hashed password](https://en.wikipedia.org/wiki/Argon2) stored securely in one of the available database engines, or by optionally delegating to the host machine's [<abbr title="Pluggable Authentication Module">PAM</abbr> subsystem](https://en.wikipedia.org/wiki/Pluggable_Authentication_Modules).
 PAM can be used to leverage, exempli gratia, LDAP integration for user authentication.
 
-A ```profile``` is a URL (under control of a ```user```) which contents includes the necessary meta-data informing an application to contact this server for identification validation.  Each ```user``` may have one or more ```profile```s.
+A ```profile``` is a URL (under control of a ```user```) which contents includes the necessary meta-data informing an application to contact this service for identification validation.  Each ```user``` may have one or more ```profile```s.
 
 Each ```profile``` may also be associated with a customizable list of additional [scopes](https://www.oauth.com/oauth2-servers/scope/) which may be added to any application client grant for convenience.
 
 An example of the user-interface when granting consent to a client application:
 ![Consent page](./documentation/media/consent-page.png)
 
-A rudimentary ticket-sending UI is also available:
-![Ticket Offer page](./documentation/media/ticket-page.png)
-
 ## Resource Service Integration
 
 Other services (resources) may make calls to validate token grants by configuring a pre-shared secret, and authenticating to this server using [an HMAC-style bearer token scheme](https://git.squeep.com/?p=squeep-resource-authentication-module;a=blob_plain;f=README.md;hb=HEAD).
 
 ## Ticket Auth
 
-This service can accept proffered [authentication tickets](https://indieweb.org/IndieAuth_Ticket_Auth).  It will simply publish any proffered tickets for valid profiles to a configured AMQP/RabbitMQ queue for some other service to redeem and make use of.
+This service can accept proffered [authentication tickets](https://indieweb.org/IndieAuth_Ticket_Auth).  It will attempt to redeem any proffered tickets, then publish the resulting tokens to a configured AMQP/RabbitMQ queue for other services to make use of.  If no AMQP server is configured, the ticket endpoint will be disabled and not advertised.
+
+Ensure the output of the script `bin/ticket-queue-profile.js` is executed on RabbitMQ server to install the needed queue profile.
+
+A rudimentary ticket-sending UI is also available:
+![Ticket Offer page](./documentation/media/ticket-page.png)
 
 ## Architecture
 
diff --git a/bin/publishTickets.js b/bin/publishTickets.js
new file mode 100644 (file)
index 0000000..c8de1f8
--- /dev/null
@@ -0,0 +1,30 @@
+'use strict';
+
+const DB = require('../src/db');
+const Logger = require('../src/logger');
+const Config = require('../config');
+const config = new Config(process.env.NODE_ENV, false);
+const Chores = require('../src/chores');
+const { Publisher: QueuePublisher } = require('@squeep/amqp-helper');
+
+const logger = new Logger(config);
+const db = new DB(logger, config);
+
+(async () => {
+  if (!config.queues.amqp.url) {
+    console.log('no queue configured, nothing to do');
+    return;
+  }
+  await db.initialize();
+  const queuePublisher = new QueuePublisher(logger, config.queues.amqp);
+  // no automatic chores
+  config.chores.tokenCleanupMs = 0;
+  config.chores.scopeCleanupMs = 0;
+  config.chores.publishTicketsMs = 0;
+  const chores = new Chores(logger, db, queuePublisher, config);
+
+  await chores.publishTickets();
+
+  console.log('done');
+  await db._closeConnection();
+})();
diff --git a/bin/ticket-queue-profile.js b/bin/ticket-queue-profile.js
new file mode 100644 (file)
index 0000000..b32d2d2
--- /dev/null
@@ -0,0 +1,13 @@
+'use strict';
+
+/**
+ * Generates the required command line to configure rabbitmq queue profile.
+ */
+
+const { Publisher } = require('@squeep/amqp-helper');
+const Config = require('../config');
+
+const config = new Config(process.env.NODE_ENV);
+const publisher = new Publisher(console, config.queues.amqp);
+const result = publisher.policyCommand(config.queues.ticketPublishName);
+console.log(result);
index 55945fd6b449c68985adc2ab4439b62c2e8c04ee..3b3fdcb9d864fac4faf3027b3eca98980c0e51da 100644 (file)
@@ -52,9 +52,10 @@ const defaultOptions = {
   queues: {
     amqp: {
       url: undefined, // AMQP endpoint, e.g. 'amqp://user:pass@rmq.host:5672'  If not specified, ticket endpoint will be disabled
-      prefix: undefined,
+      prefix: 'indieauth',
     },
-    ticketPublishName: 'indieauth.ticket.proffered', // exchange to publish proffered tickets to
+    ticketPublishName: 'ticket.proffered', // exchange to publish proffered tickets to
+    ticketRedeemedName: 'ticket.redeemed', // exchange to publish redeemed ticket tokens to
   },
 
   // Logging options
@@ -77,6 +78,7 @@ const defaultOptions = {
   chores: {
     scopeCleanupMs: 0, // how often to clean up unreferenced scopes, 0 for never
     tokenCleanupMs: 0, // how often to clean up no-longer-valid scopes, 0 for never
+    publishTicketsMs: 0, // how often to try to re-publish unpublished redeemed ticket tokens
   },
 
   // Outgoing request UA header. Setting these here to override helper defaults.
index b92272f1a31a186d929e51daa8c3c14bb9acfb44..59d7d299fc6f0daffb647f95eb1d21da21eb891a 100644 (file)
@@ -10,7 +10,7 @@ module.exports = {
   },
   queues: {
     amqp: {
-      url: 'ampq://localhost:5432',
+      url: 'amqp://localhost:5432',
     },
   },
 };
index 670582a2c0c2200362a4d9a18c3bd0005c12b412..ac8658e4be78f93ff519221d14b5384a21da411f 100644 (file)
 <!-- Generated by graphviz version 2.50.0 (20211204.2007)
  -->
 <!-- Title: indieAutherERD Pages: 1 -->
-<svg width="656pt" height="668pt"
- viewBox="0.00 0.00 656.00 668.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+<svg width="669pt" height="668pt"
+ viewBox="0.00 0.00 669.00 668.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
 <g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 664)">
 <title>indieAutherERD</title>
-<polygon fill="white" stroke="transparent" points="-4,4 -4,-664 652,-664 652,4 -4,4"/>
-<text text-anchor="middle" x="324" y="-635.2" font-family="Times,serif" font-size="26.00">IndieAuther Entity&#45;Relations</text>
-<text text-anchor="middle" x="324" y="-606.2" font-family="Times,serif" font-size="26.00">Postgres</text>
-<text text-anchor="middle" x="324" y="-577.2" font-family="Times,serif" font-size="26.00">Schema 1.0.0</text>
-<!-- token -->
+<polygon fill="white" stroke="transparent" points="-4,4 -4,-664 665,-664 665,4 -4,4"/>
+<text text-anchor="middle" x="330.5" y="-635.2" font-family="Times,serif" font-size="26.00">IndieAuther Entity&#45;Relations</text>
+<text text-anchor="middle" x="330.5" y="-606.2" font-family="Times,serif" font-size="26.00">Postgres</text>
+<text text-anchor="middle" x="330.5" y="-577.2" font-family="Times,serif" font-size="26.00">Schema 1.1.0</text>
+<!-- redeemed_ticket -->
 <g id="node1" class="node">
+<title>redeemed_ticket</title>
+<polygon fill="lightblue" stroke="transparent" points="0.5,-186.5 0.5,-209.5 159.5,-209.5 159.5,-186.5 0.5,-186.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="1.5,-187.5 1.5,-208.5 158.5,-208.5 158.5,-187.5 1.5,-187.5"/>
+<text text-anchor="start" x="4.5" y="-194.3" font-family="Times,serif" font-size="14.00">REDEEMED_TICKET</text>
+<polygon fill="none" stroke="black" points="0.5,-165.5 0.5,-186.5 159.5,-186.5 159.5,-165.5 0.5,-165.5"/>
+<text text-anchor="start" x="49.5" y="-172.3" font-family="Times,serif" font-size="14.00">ticket_id</text>
+<polygon fill="none" stroke="black" points="0.5,-144.5 0.5,-165.5 159.5,-165.5 159.5,-144.5 0.5,-144.5"/>
+<text text-anchor="start" x="52.5" y="-151.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="0.5,-123.5 0.5,-144.5 159.5,-144.5 159.5,-123.5 0.5,-123.5"/>
+<text text-anchor="start" x="54" y="-130.3" font-family="Times,serif" font-size="14.00">subject</text>
+<polygon fill="none" stroke="black" points="0.5,-102.5 0.5,-123.5 159.5,-123.5 159.5,-102.5 0.5,-102.5"/>
+<text text-anchor="start" x="48.5" y="-109.3" font-family="Times,serif" font-size="14.00">resource</text>
+<polygon fill="none" stroke="black" points="0.5,-81.5 0.5,-102.5 159.5,-102.5 159.5,-81.5 0.5,-81.5"/>
+<text text-anchor="start" x="70" y="-88.3" font-family="Times,serif" font-size="14.00">iss</text>
+<polygon fill="none" stroke="black" points="0.5,-60.5 0.5,-81.5 159.5,-81.5 159.5,-60.5 0.5,-60.5"/>
+<text text-anchor="start" x="60" y="-67.3" font-family="Times,serif" font-size="14.00">token</text>
+<polygon fill="none" stroke="black" points="0.5,-39.5 0.5,-60.5 159.5,-60.5 159.5,-39.5 0.5,-39.5"/>
+<text text-anchor="start" x="59.5" y="-46.3" font-family="Times,serif" font-size="14.00">ticket</text>
+<polygon fill="none" stroke="black" points="0.5,-18.5 0.5,-39.5 159.5,-39.5 159.5,-18.5 0.5,-18.5"/>
+<text text-anchor="start" x="45" y="-25.3" font-family="Times,serif" font-size="14.00">published</text>
+</g>
+<!-- token -->
+<g id="node2" class="node">
 <title>token</title>
-<polygon fill="lightblue" stroke="transparent" points="362.5,-501.5 362.5,-524.5 488.5,-524.5 488.5,-501.5 362.5,-501.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="363.5,-502.5 363.5,-523.5 487.5,-523.5 487.5,-502.5 363.5,-502.5"/>
-<text text-anchor="start" x="398.5" y="-509.3" font-family="Times,serif" font-size="14.00">TOKEN</text>
-<polygon fill="none" stroke="black" points="362.5,-480.5 362.5,-501.5 488.5,-501.5 488.5,-480.5 362.5,-480.5"/>
-<text text-anchor="start" x="398.5" y="-487.3" font-family="Times,serif" font-size="14.00">code_id</text>
-<polygon fill="none" stroke="black" points="362.5,-459.5 362.5,-480.5 488.5,-480.5 488.5,-459.5 362.5,-459.5"/>
-<text text-anchor="start" x="392" y="-466.3" font-family="Times,serif" font-size="14.00">profile_id</text>
-<polygon fill="none" stroke="black" points="362.5,-438.5 362.5,-459.5 488.5,-459.5 488.5,-438.5 362.5,-438.5"/>
-<text text-anchor="start" x="398" y="-445.3" font-family="Times,serif" font-size="14.00">created</text>
-<polygon fill="none" stroke="black" points="362.5,-417.5 362.5,-438.5 488.5,-438.5 488.5,-417.5 362.5,-417.5"/>
-<text text-anchor="start" x="399" y="-424.3" font-family="Times,serif" font-size="14.00">expires</text>
-<polygon fill="none" stroke="black" points="362.5,-396.5 362.5,-417.5 488.5,-417.5 488.5,-396.5 362.5,-396.5"/>
-<text text-anchor="start" x="369.5" y="-403.3" font-family="Times,serif" font-size="14.00">refresh_expires</text>
-<polygon fill="none" stroke="black" points="362.5,-375.5 362.5,-396.5 488.5,-396.5 488.5,-375.5 362.5,-375.5"/>
-<text text-anchor="start" x="390.5" y="-382.3" font-family="Times,serif" font-size="14.00">refreshed</text>
-<polygon fill="none" stroke="black" points="362.5,-354.5 362.5,-375.5 488.5,-375.5 488.5,-354.5 362.5,-354.5"/>
-<text text-anchor="start" x="395" y="-361.3" font-family="Times,serif" font-size="14.00">duration</text>
-<polygon fill="none" stroke="black" points="362.5,-333.5 362.5,-354.5 488.5,-354.5 488.5,-333.5 362.5,-333.5"/>
-<text text-anchor="start" x="365.5" y="-340.3" font-family="Times,serif" font-size="14.00">refresh_duration</text>
-<polygon fill="none" stroke="black" points="362.5,-312.5 362.5,-333.5 488.5,-333.5 488.5,-312.5 362.5,-312.5"/>
-<text text-anchor="start" x="376" y="-319.3" font-family="Times,serif" font-size="14.00">refresh_count</text>
-<polygon fill="none" stroke="black" points="362.5,-291.5 362.5,-312.5 488.5,-312.5 488.5,-291.5 362.5,-291.5"/>
-<text text-anchor="start" x="387.5" y="-298.3" font-family="Times,serif" font-size="14.00">is_revoked</text>
-<polygon fill="none" stroke="black" points="362.5,-270.5 362.5,-291.5 488.5,-291.5 488.5,-270.5 362.5,-270.5"/>
-<text text-anchor="start" x="396" y="-277.3" font-family="Times,serif" font-size="14.00">is_token</text>
-<polygon fill="none" stroke="black" points="362.5,-249.5 362.5,-270.5 488.5,-270.5 488.5,-249.5 362.5,-249.5"/>
-<text text-anchor="start" x="395" y="-256.3" font-family="Times,serif" font-size="14.00">client_id</text>
-<polygon fill="none" stroke="black" points="362.5,-228.5 362.5,-249.5 488.5,-249.5 488.5,-228.5 362.5,-228.5"/>
-<text text-anchor="start" x="394" y="-235.3" font-family="Times,serif" font-size="14.00">resource</text>
-<polygon fill="none" stroke="black" points="362.5,-207.5 362.5,-228.5 488.5,-228.5 488.5,-207.5 362.5,-207.5"/>
-<text text-anchor="start" x="383" y="-214.3" font-family="Times,serif" font-size="14.00">profile_data</text>
+<polygon fill="lightblue" stroke="transparent" points="375.5,-501.5 375.5,-524.5 501.5,-524.5 501.5,-501.5 375.5,-501.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="376.5,-502.5 376.5,-523.5 500.5,-523.5 500.5,-502.5 376.5,-502.5"/>
+<text text-anchor="start" x="411.5" y="-509.3" font-family="Times,serif" font-size="14.00">TOKEN</text>
+<polygon fill="none" stroke="black" points="375.5,-480.5 375.5,-501.5 501.5,-501.5 501.5,-480.5 375.5,-480.5"/>
+<text text-anchor="start" x="411.5" y="-487.3" font-family="Times,serif" font-size="14.00">code_id</text>
+<polygon fill="none" stroke="black" points="375.5,-459.5 375.5,-480.5 501.5,-480.5 501.5,-459.5 375.5,-459.5"/>
+<text text-anchor="start" x="405" y="-466.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="375.5,-438.5 375.5,-459.5 501.5,-459.5 501.5,-438.5 375.5,-438.5"/>
+<text text-anchor="start" x="411" y="-445.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="375.5,-417.5 375.5,-438.5 501.5,-438.5 501.5,-417.5 375.5,-417.5"/>
+<text text-anchor="start" x="412" y="-424.3" font-family="Times,serif" font-size="14.00">expires</text>
+<polygon fill="none" stroke="black" points="375.5,-396.5 375.5,-417.5 501.5,-417.5 501.5,-396.5 375.5,-396.5"/>
+<text text-anchor="start" x="382.5" y="-403.3" font-family="Times,serif" font-size="14.00">refresh_expires</text>
+<polygon fill="none" stroke="black" points="375.5,-375.5 375.5,-396.5 501.5,-396.5 501.5,-375.5 375.5,-375.5"/>
+<text text-anchor="start" x="403.5" y="-382.3" font-family="Times,serif" font-size="14.00">refreshed</text>
+<polygon fill="none" stroke="black" points="375.5,-354.5 375.5,-375.5 501.5,-375.5 501.5,-354.5 375.5,-354.5"/>
+<text text-anchor="start" x="408" y="-361.3" font-family="Times,serif" font-size="14.00">duration</text>
+<polygon fill="none" stroke="black" points="375.5,-333.5 375.5,-354.5 501.5,-354.5 501.5,-333.5 375.5,-333.5"/>
+<text text-anchor="start" x="378.5" y="-340.3" font-family="Times,serif" font-size="14.00">refresh_duration</text>
+<polygon fill="none" stroke="black" points="375.5,-312.5 375.5,-333.5 501.5,-333.5 501.5,-312.5 375.5,-312.5"/>
+<text text-anchor="start" x="389" y="-319.3" font-family="Times,serif" font-size="14.00">refresh_count</text>
+<polygon fill="none" stroke="black" points="375.5,-291.5 375.5,-312.5 501.5,-312.5 501.5,-291.5 375.5,-291.5"/>
+<text text-anchor="start" x="400.5" y="-298.3" font-family="Times,serif" font-size="14.00">is_revoked</text>
+<polygon fill="none" stroke="black" points="375.5,-270.5 375.5,-291.5 501.5,-291.5 501.5,-270.5 375.5,-270.5"/>
+<text text-anchor="start" x="409" y="-277.3" font-family="Times,serif" font-size="14.00">is_token</text>
+<polygon fill="none" stroke="black" points="375.5,-249.5 375.5,-270.5 501.5,-270.5 501.5,-249.5 375.5,-249.5"/>
+<text text-anchor="start" x="408" y="-256.3" font-family="Times,serif" font-size="14.00">client_id</text>
+<polygon fill="none" stroke="black" points="375.5,-228.5 375.5,-249.5 501.5,-249.5 501.5,-228.5 375.5,-228.5"/>
+<text text-anchor="start" x="407" y="-235.3" font-family="Times,serif" font-size="14.00">resource</text>
+<polygon fill="none" stroke="black" points="375.5,-207.5 375.5,-228.5 501.5,-228.5 501.5,-207.5 375.5,-207.5"/>
+<text text-anchor="start" x="396" y="-214.3" font-family="Times,serif" font-size="14.00">profile_data</text>
 </g>
 <!-- token_scope -->
-<g id="node4" class="node">
+<g id="node5" class="node">
 <title>token_scope</title>
-<polygon fill="lightblue" stroke="transparent" points="528,-315.5 528,-338.5 648,-338.5 648,-315.5 528,-315.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="529,-316.5 529,-337.5 647,-337.5 647,-316.5 529,-316.5"/>
-<text text-anchor="start" x="532" y="-323.3" font-family="Times,serif" font-size="14.00">TOKEN_SCOPE</text>
-<polygon fill="none" stroke="black" points="528,-294.5 528,-315.5 648,-315.5 648,-294.5 528,-294.5"/>
-<text text-anchor="start" x="561" y="-301.3" font-family="Times,serif" font-size="14.00">code_id</text>
-<polygon fill="none" stroke="black" points="528,-273.5 528,-294.5 648,-294.5 648,-273.5 528,-273.5"/>
-<text text-anchor="start" x="557.5" y="-280.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+<polygon fill="lightblue" stroke="transparent" points="541,-315.5 541,-338.5 661,-338.5 661,-315.5 541,-315.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="542,-316.5 542,-337.5 660,-337.5 660,-316.5 542,-316.5"/>
+<text text-anchor="start" x="545" y="-323.3" font-family="Times,serif" font-size="14.00">TOKEN_SCOPE</text>
+<polygon fill="none" stroke="black" points="541,-294.5 541,-315.5 661,-315.5 661,-294.5 541,-294.5"/>
+<text text-anchor="start" x="574" y="-301.3" font-family="Times,serif" font-size="14.00">code_id</text>
+<polygon fill="none" stroke="black" points="541,-273.5 541,-294.5 661,-294.5 661,-273.5 541,-273.5"/>
+<text text-anchor="start" x="570.5" y="-280.3" font-family="Times,serif" font-size="14.00">scope_id</text>
 </g>
 <!-- token&#45;&gt;token_scope -->
 <g id="edge2" class="edge">
 <title>token:pk_code_id&#45;&gt;token_scope:fk_code_id</title>
-<path fill="none" stroke="black" d="M489.5,-491.5C570.54,-491.5 454.5,-320.92 517.83,-305.6"/>
-<polygon fill="black" stroke="black" points="518.06,-305.57 528.48,-308.97 523.03,-305.04 528,-304.5 528,-304.5 528,-304.5 523.03,-305.04 527.52,-300.03 518.06,-305.57 518.06,-305.57"/>
+<path fill="none" stroke="black" d="M502.5,-491.5C583.54,-491.5 467.5,-320.92 530.83,-305.6"/>
+<polygon fill="black" stroke="black" points="531.06,-305.57 541.48,-308.97 536.03,-305.04 541,-304.5 541,-304.5 541,-304.5 536.03,-305.04 540.52,-300.03 531.06,-305.57 531.06,-305.57"/>
 </g>
 <!-- profile -->
-<g id="node2" class="node">
+<g id="node3" class="node">
 <title>profile</title>
-<polygon fill="lightblue" stroke="transparent" points="206.5,-355.5 206.5,-378.5 299.5,-378.5 299.5,-355.5 206.5,-355.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="207.5,-356.5 207.5,-377.5 298.5,-377.5 298.5,-356.5 207.5,-356.5"/>
-<text text-anchor="start" x="219.5" y="-363.3" font-family="Times,serif" font-size="14.00">PROFILE</text>
-<polygon fill="none" stroke="black" points="206.5,-334.5 206.5,-355.5 299.5,-355.5 299.5,-334.5 206.5,-334.5"/>
-<text text-anchor="start" x="219.5" y="-341.3" font-family="Times,serif" font-size="14.00">profile_id</text>
-<polygon fill="none" stroke="black" points="206.5,-313.5 206.5,-334.5 299.5,-334.5 299.5,-313.5 206.5,-313.5"/>
-<text text-anchor="start" x="209.5" y="-320.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
-<polygon fill="none" stroke="black" points="206.5,-292.5 206.5,-313.5 299.5,-313.5 299.5,-292.5 206.5,-292.5"/>
-<text text-anchor="start" x="229.5" y="-299.3" font-family="Times,serif" font-size="14.00">profile</text>
+<polygon fill="lightblue" stroke="transparent" points="219.5,-355.5 219.5,-378.5 312.5,-378.5 312.5,-355.5 219.5,-355.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="220.5,-356.5 220.5,-377.5 311.5,-377.5 311.5,-356.5 220.5,-356.5"/>
+<text text-anchor="start" x="232.5" y="-363.3" font-family="Times,serif" font-size="14.00">PROFILE</text>
+<polygon fill="none" stroke="black" points="219.5,-334.5 219.5,-355.5 312.5,-355.5 312.5,-334.5 219.5,-334.5"/>
+<text text-anchor="start" x="232.5" y="-341.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="219.5,-313.5 219.5,-334.5 312.5,-334.5 312.5,-313.5 219.5,-313.5"/>
+<text text-anchor="start" x="222.5" y="-320.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
+<polygon fill="none" stroke="black" points="219.5,-292.5 219.5,-313.5 312.5,-313.5 312.5,-292.5 219.5,-292.5"/>
+<text text-anchor="start" x="242.5" y="-299.3" font-family="Times,serif" font-size="14.00">profile</text>
 </g>
 <!-- profile&#45;&gt;token -->
 <g id="edge1" class="edge">
 <title>profile:pk_profile_id&#45;&gt;token:fk_profile_id</title>
-<path fill="none" stroke="black" d="M300.5,-345.5C358.57,-345.5 306.81,-455.82 351.58,-469.18"/>
-<polygon fill="black" stroke="black" points="351.59,-469.18 360.91,-474.96 356.54,-469.84 361.5,-470.5 361.5,-470.5 361.5,-470.5 356.54,-469.84 362.09,-466.04 351.59,-469.18 351.59,-469.18"/>
+<path fill="none" stroke="black" d="M313.5,-345.5C371.57,-345.5 319.81,-455.82 364.58,-469.18"/>
+<polygon fill="black" stroke="black" points="364.59,-469.18 373.91,-474.96 369.54,-469.84 374.5,-470.5 374.5,-470.5 374.5,-470.5 369.54,-469.84 375.09,-466.04 364.59,-469.18 364.59,-469.18"/>
 </g>
 <!-- profile_scope -->
-<g id="node6" class="node">
+<g id="node7" class="node">
 <title>profile_scope</title>
-<polygon fill="lightblue" stroke="transparent" points="359.5,-166.5 359.5,-189.5 492.5,-189.5 492.5,-166.5 359.5,-166.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="360.5,-167.5 360.5,-188.5 491.5,-188.5 491.5,-167.5 360.5,-167.5"/>
-<text text-anchor="start" x="363.5" y="-174.3" font-family="Times,serif" font-size="14.00">PROFILE_SCOPE</text>
-<polygon fill="none" stroke="black" points="359.5,-145.5 359.5,-166.5 492.5,-166.5 492.5,-145.5 359.5,-145.5"/>
-<text text-anchor="start" x="392.5" y="-152.3" font-family="Times,serif" font-size="14.00">profile_id</text>
-<polygon fill="none" stroke="black" points="359.5,-124.5 359.5,-145.5 492.5,-145.5 492.5,-124.5 359.5,-124.5"/>
-<text text-anchor="start" x="395.5" y="-131.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+<polygon fill="lightblue" stroke="transparent" points="372.5,-166.5 372.5,-189.5 505.5,-189.5 505.5,-166.5 372.5,-166.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="373.5,-167.5 373.5,-188.5 504.5,-188.5 504.5,-167.5 373.5,-167.5"/>
+<text text-anchor="start" x="376.5" y="-174.3" font-family="Times,serif" font-size="14.00">PROFILE_SCOPE</text>
+<polygon fill="none" stroke="black" points="372.5,-145.5 372.5,-166.5 505.5,-166.5 505.5,-145.5 372.5,-145.5"/>
+<text text-anchor="start" x="405.5" y="-152.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="372.5,-124.5 372.5,-145.5 505.5,-145.5 505.5,-124.5 372.5,-124.5"/>
+<text text-anchor="start" x="408.5" y="-131.3" font-family="Times,serif" font-size="14.00">scope_id</text>
 </g>
 <!-- profile&#45;&gt;profile_scope -->
 <g id="edge5" class="edge">
 <title>profile:pk_profile_id&#45;&gt;profile_scope:fk_profile_id</title>
-<path fill="none" stroke="black" d="M300.5,-345.5C385.06,-345.5 280.42,-171.48 348.72,-156.52"/>
-<polygon fill="black" stroke="black" points="349.05,-156.49 359.45,-159.98 354.02,-155.99 359,-155.5 359,-155.5 359,-155.5 354.02,-155.99 358.55,-151.02 349.05,-156.49 349.05,-156.49"/>
+<path fill="none" stroke="black" d="M313.5,-345.5C398.06,-345.5 293.42,-171.48 361.72,-156.52"/>
+<polygon fill="black" stroke="black" points="362.05,-156.49 372.45,-159.98 367.02,-155.99 372,-155.5 372,-155.5 372,-155.5 367.02,-155.99 371.55,-151.02 362.05,-156.49 362.05,-156.49"/>
 </g>
 <!-- scope -->
-<g id="node3" class="node">
+<g id="node4" class="node">
 <title>scope</title>
-<polygon fill="lightblue" stroke="transparent" points="182.5,-125.5 182.5,-148.5 323.5,-148.5 323.5,-125.5 182.5,-125.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="183.5,-126.5 183.5,-147.5 322.5,-147.5 322.5,-126.5 183.5,-126.5"/>
-<text text-anchor="start" x="227" y="-133.3" font-family="Times,serif" font-size="14.00">SCOPE</text>
-<polygon fill="none" stroke="black" points="182.5,-104.5 182.5,-125.5 323.5,-125.5 323.5,-104.5 182.5,-104.5"/>
-<text text-anchor="start" x="222.5" y="-111.3" font-family="Times,serif" font-size="14.00">scope_id</text>
-<polygon fill="none" stroke="black" points="182.5,-83.5 182.5,-104.5 323.5,-104.5 323.5,-83.5 182.5,-83.5"/>
-<text text-anchor="start" x="232.5" y="-90.3" font-family="Times,serif" font-size="14.00">scope</text>
-<polygon fill="none" stroke="black" points="182.5,-62.5 182.5,-83.5 323.5,-83.5 323.5,-62.5 182.5,-62.5"/>
-<text text-anchor="start" x="212.5" y="-69.3" font-family="Times,serif" font-size="14.00">description</text>
-<polygon fill="none" stroke="black" points="182.5,-41.5 182.5,-62.5 323.5,-62.5 323.5,-41.5 182.5,-41.5"/>
-<text text-anchor="start" x="213.5" y="-48.3" font-family="Times,serif" font-size="14.00">application</text>
-<polygon fill="none" stroke="black" points="182.5,-20.5 182.5,-41.5 323.5,-41.5 323.5,-20.5 182.5,-20.5"/>
-<text text-anchor="start" x="204.5" y="-27.3" font-family="Times,serif" font-size="14.00">is_permanent</text>
-<polygon fill="none" stroke="black" points="182.5,0.5 182.5,-20.5 323.5,-20.5 323.5,0.5 182.5,0.5"/>
-<text text-anchor="start" x="185.5" y="-6.3" font-family="Times,serif" font-size="14.00">is_manually_added</text>
+<polygon fill="lightblue" stroke="transparent" points="195.5,-125.5 195.5,-148.5 336.5,-148.5 336.5,-125.5 195.5,-125.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="196.5,-126.5 196.5,-147.5 335.5,-147.5 335.5,-126.5 196.5,-126.5"/>
+<text text-anchor="start" x="240" y="-133.3" font-family="Times,serif" font-size="14.00">SCOPE</text>
+<polygon fill="none" stroke="black" points="195.5,-104.5 195.5,-125.5 336.5,-125.5 336.5,-104.5 195.5,-104.5"/>
+<text text-anchor="start" x="235.5" y="-111.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+<polygon fill="none" stroke="black" points="195.5,-83.5 195.5,-104.5 336.5,-104.5 336.5,-83.5 195.5,-83.5"/>
+<text text-anchor="start" x="245.5" y="-90.3" font-family="Times,serif" font-size="14.00">scope</text>
+<polygon fill="none" stroke="black" points="195.5,-62.5 195.5,-83.5 336.5,-83.5 336.5,-62.5 195.5,-62.5"/>
+<text text-anchor="start" x="225.5" y="-69.3" font-family="Times,serif" font-size="14.00">description</text>
+<polygon fill="none" stroke="black" points="195.5,-41.5 195.5,-62.5 336.5,-62.5 336.5,-41.5 195.5,-41.5"/>
+<text text-anchor="start" x="226.5" y="-48.3" font-family="Times,serif" font-size="14.00">application</text>
+<polygon fill="none" stroke="black" points="195.5,-20.5 195.5,-41.5 336.5,-41.5 336.5,-20.5 195.5,-20.5"/>
+<text text-anchor="start" x="217.5" y="-27.3" font-family="Times,serif" font-size="14.00">is_permanent</text>
+<polygon fill="none" stroke="black" points="195.5,0.5 195.5,-20.5 336.5,-20.5 336.5,0.5 195.5,0.5"/>
+<text text-anchor="start" x="198.5" y="-6.3" font-family="Times,serif" font-size="14.00">is_manually_added</text>
 </g>
 <!-- scope&#45;&gt;token_scope -->
 <g id="edge3" class="edge">
 <title>scope:pk_scope_id&#45;&gt;token_scope:fk_scope_id</title>
-<path fill="none" stroke="black" d="M323,-115.5C398.11,-115.5 433.59,-68.28 492,-115.5 548.48,-161.16 461,-271.95 518.03,-282.66"/>
-<polygon fill="black" stroke="black" points="518.03,-282.66 527.62,-287.98 523.02,-283.08 528,-283.5 528,-283.5 528,-283.5 523.02,-283.08 528.38,-279.02 518.03,-282.66 518.03,-282.66"/>
+<path fill="none" stroke="black" d="M336,-115.5C411.11,-115.5 446.59,-68.28 505,-115.5 561.48,-161.16 474,-271.95 531.03,-282.66"/>
+<polygon fill="black" stroke="black" points="531.03,-282.66 540.62,-287.98 536.02,-283.08 541,-283.5 541,-283.5 541,-283.5 536.02,-283.08 541.38,-279.02 531.03,-282.66 531.03,-282.66"/>
 </g>
 <!-- scope&#45;&gt;profile_scope -->
 <g id="edge6" class="edge">
 <title>scope:pk_scope_id&#45;&gt;profile_scope:fk_scope_id</title>
-<path fill="none" stroke="black" d="M323,-115.5C336.99,-115.5 340.05,-126.87 349.09,-132.02"/>
-<polygon fill="black" stroke="black" points="349.3,-132.07 357.91,-138.86 354.15,-133.28 359,-134.5 359,-134.5 359,-134.5 354.15,-133.28 360.09,-130.14 349.3,-132.07 349.3,-132.07"/>
+<path fill="none" stroke="black" d="M336,-115.5C349.99,-115.5 353.05,-126.87 362.09,-132.02"/>
+<polygon fill="black" stroke="black" points="362.3,-132.07 370.91,-138.86 367.15,-133.28 372,-134.5 372,-134.5 372,-134.5 367.15,-133.28 373.09,-130.14 362.3,-132.07 362.3,-132.07"/>
 </g>
 <!-- authentication -->
-<g id="node5" class="node">
+<g id="node6" class="node">
 <title>authentication</title>
-<polygon fill="lightblue" stroke="transparent" points="0,-333.5 0,-356.5 146,-356.5 146,-333.5 0,-333.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="1,-334.5 1,-355.5 145,-355.5 145,-334.5 1,-334.5"/>
-<text text-anchor="start" x="4" y="-341.3" font-family="Times,serif" font-size="14.00">AUTHENTICATION</text>
-<polygon fill="none" stroke="black" points="0,-312.5 0,-333.5 146,-333.5 146,-312.5 0,-312.5"/>
-<text text-anchor="start" x="29.5" y="-319.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
-<polygon fill="none" stroke="black" points="0,-291.5 0,-312.5 146,-312.5 146,-291.5 0,-291.5"/>
-<text text-anchor="start" x="45.5" y="-298.3" font-family="Times,serif" font-size="14.00">created</text>
-<polygon fill="none" stroke="black" points="0,-270.5 0,-291.5 146,-291.5 146,-270.5 0,-270.5"/>
-<text text-anchor="start" x="6.5" y="-277.3" font-family="Times,serif" font-size="14.00">last_authenticated</text>
-<polygon fill="none" stroke="black" points="0,-249.5 0,-270.5 146,-270.5 146,-249.5 0,-249.5"/>
-<text text-anchor="start" x="39.5" y="-256.3" font-family="Times,serif" font-size="14.00">identifier</text>
-<polygon fill="none" stroke="black" points="0,-228.5 0,-249.5 146,-249.5 146,-228.5 0,-228.5"/>
-<text text-anchor="start" x="36.5" y="-235.3" font-family="Times,serif" font-size="14.00">credential</text>
+<polygon fill="lightblue" stroke="transparent" points="6.5,-333.5 6.5,-356.5 152.5,-356.5 152.5,-333.5 6.5,-333.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="7.5,-334.5 7.5,-355.5 151.5,-355.5 151.5,-334.5 7.5,-334.5"/>
+<text text-anchor="start" x="10.5" y="-341.3" font-family="Times,serif" font-size="14.00">AUTHENTICATION</text>
+<polygon fill="none" stroke="black" points="6.5,-312.5 6.5,-333.5 152.5,-333.5 152.5,-312.5 6.5,-312.5"/>
+<text text-anchor="start" x="36" y="-319.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
+<polygon fill="none" stroke="black" points="6.5,-291.5 6.5,-312.5 152.5,-312.5 152.5,-291.5 6.5,-291.5"/>
+<text text-anchor="start" x="52" y="-298.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="6.5,-270.5 6.5,-291.5 152.5,-291.5 152.5,-270.5 6.5,-270.5"/>
+<text text-anchor="start" x="13" y="-277.3" font-family="Times,serif" font-size="14.00">last_authenticated</text>
+<polygon fill="none" stroke="black" points="6.5,-249.5 6.5,-270.5 152.5,-270.5 152.5,-249.5 6.5,-249.5"/>
+<text text-anchor="start" x="46" y="-256.3" font-family="Times,serif" font-size="14.00">identifier</text>
+<polygon fill="none" stroke="black" points="6.5,-228.5 6.5,-249.5 152.5,-249.5 152.5,-228.5 6.5,-228.5"/>
+<text text-anchor="start" x="43" y="-235.3" font-family="Times,serif" font-size="14.00">credential</text>
 </g>
 <!-- authentication&#45;&gt;profile -->
 <g id="edge4" class="edge">
 <title>authentication:pk_identifier_id&#45;&gt;profile:fk_identifier_id</title>
-<path fill="none" stroke="black" d="M146,-323.5C168.73,-323.5 176.8,-323.5 195.41,-323.5"/>
-<polygon fill="black" stroke="black" points="195.5,-323.5 205.5,-328 200.5,-323.5 205.5,-323.5 205.5,-323.5 205.5,-323.5 200.5,-323.5 205.5,-319 195.5,-323.5 195.5,-323.5"/>
+<path fill="none" stroke="black" d="M153.5,-323.5C178.66,-323.5 187.39,-323.5 208.32,-323.5"/>
+<polygon fill="black" stroke="black" points="208.5,-323.5 218.5,-328 213.5,-323.5 218.5,-323.5 218.5,-323.5 218.5,-323.5 213.5,-323.5 218.5,-319 208.5,-323.5 208.5,-323.5"/>
 </g>
 <!-- resource -->
-<g id="node7" class="node">
+<g id="node8" class="node">
 <title>resource</title>
-<polygon fill="lightblue" stroke="transparent" points="26,-458.5 26,-481.5 120,-481.5 120,-458.5 26,-458.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="27,-459.5 27,-480.5 119,-480.5 119,-459.5 27,-459.5"/>
-<text text-anchor="start" x="30" y="-466.3" font-family="Times,serif" font-size="14.00">RESOURCE</text>
-<polygon fill="none" stroke="black" points="26,-437.5 26,-458.5 120,-458.5 120,-437.5 26,-437.5"/>
-<text text-anchor="start" x="31.5" y="-444.3" font-family="Times,serif" font-size="14.00">resource_id</text>
-<polygon fill="none" stroke="black" points="26,-416.5 26,-437.5 120,-437.5 120,-416.5 26,-416.5"/>
-<text text-anchor="start" x="32.5" y="-423.3" font-family="Times,serif" font-size="14.00">description</text>
-<polygon fill="none" stroke="black" points="26,-395.5 26,-416.5 120,-416.5 120,-395.5 26,-395.5"/>
-<text text-anchor="start" x="45.5" y="-402.3" font-family="Times,serif" font-size="14.00">created</text>
-<polygon fill="none" stroke="black" points="26,-374.5 26,-395.5 120,-395.5 120,-374.5 26,-374.5"/>
-<text text-anchor="start" x="50.5" y="-381.3" font-family="Times,serif" font-size="14.00">secret</text>
+<polygon fill="lightblue" stroke="transparent" points="32.5,-458.5 32.5,-481.5 126.5,-481.5 126.5,-458.5 32.5,-458.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="33.5,-459.5 33.5,-480.5 125.5,-480.5 125.5,-459.5 33.5,-459.5"/>
+<text text-anchor="start" x="36.5" y="-466.3" font-family="Times,serif" font-size="14.00">RESOURCE</text>
+<polygon fill="none" stroke="black" points="32.5,-437.5 32.5,-458.5 126.5,-458.5 126.5,-437.5 32.5,-437.5"/>
+<text text-anchor="start" x="38" y="-444.3" font-family="Times,serif" font-size="14.00">resource_id</text>
+<polygon fill="none" stroke="black" points="32.5,-416.5 32.5,-437.5 126.5,-437.5 126.5,-416.5 32.5,-416.5"/>
+<text text-anchor="start" x="39" y="-423.3" font-family="Times,serif" font-size="14.00">description</text>
+<polygon fill="none" stroke="black" points="32.5,-395.5 32.5,-416.5 126.5,-416.5 126.5,-395.5 32.5,-395.5"/>
+<text text-anchor="start" x="52" y="-402.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="32.5,-374.5 32.5,-395.5 126.5,-395.5 126.5,-374.5 32.5,-374.5"/>
+<text text-anchor="start" x="57" y="-381.3" font-family="Times,serif" font-size="14.00">secret</text>
 </g>
 <!-- almanac -->
-<g id="node8" class="node">
+<g id="node9" class="node">
 <title>almanac</title>
-<polygon fill="lightblue" stroke="transparent" points="31,-541.5 31,-564.5 115,-564.5 115,-541.5 31,-541.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="32,-542.5 32,-563.5 114,-563.5 114,-542.5 32,-542.5"/>
-<text text-anchor="start" x="35" y="-549.3" font-family="Times,serif" font-size="14.00">ALMANAC</text>
-<polygon fill="none" stroke="black" points="31,-520.5 31,-541.5 115,-541.5 115,-520.5 31,-520.5"/>
-<text text-anchor="start" x="53" y="-527.3" font-family="Times,serif" font-size="14.00">event</text>
-<polygon fill="none" stroke="black" points="31,-499.5 31,-520.5 115,-520.5 115,-499.5 31,-499.5"/>
-<text text-anchor="start" x="57" y="-506.3" font-family="Times,serif" font-size="14.00">date</text>
+<polygon fill="lightblue" stroke="transparent" points="37.5,-541.5 37.5,-564.5 121.5,-564.5 121.5,-541.5 37.5,-541.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="38.5,-542.5 38.5,-563.5 120.5,-563.5 120.5,-542.5 38.5,-542.5"/>
+<text text-anchor="start" x="41.5" y="-549.3" font-family="Times,serif" font-size="14.00">ALMANAC</text>
+<polygon fill="none" stroke="black" points="37.5,-520.5 37.5,-541.5 121.5,-541.5 121.5,-520.5 37.5,-520.5"/>
+<text text-anchor="start" x="59.5" y="-527.3" font-family="Times,serif" font-size="14.00">event</text>
+<polygon fill="none" stroke="black" points="37.5,-499.5 37.5,-520.5 121.5,-520.5 121.5,-499.5 37.5,-499.5"/>
+<text text-anchor="start" x="63.5" y="-506.3" font-family="Times,serif" font-size="14.00">date</text>
 </g>
 </g>
 </svg>
index 8d036edbeb7b15a8bd63ba7d5a6f8bfda8e19dfa..ca8ee1c83c8c755209d09b5aa32be649149d3edd 100644 (file)
 <!-- Generated by graphviz version 2.50.0 (20211204.2007)
  -->
 <!-- Title: indieAutherERD Pages: 1 -->
-<svg width="656pt" height="639pt"
- viewBox="0.00 0.00 656.00 639.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+<svg width="669pt" height="639pt"
+ viewBox="0.00 0.00 669.00 639.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
 <g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 635)">
 <title>indieAutherERD</title>
-<polygon fill="white" stroke="transparent" points="-4,4 -4,-635 652,-635 652,4 -4,4"/>
-<text text-anchor="middle" x="324" y="-606.2" font-family="Times,serif" font-size="26.00">IndieAuther Entity&#45;RelationsSQLite</text>
-<text text-anchor="middle" x="324" y="-577.2" font-family="Times,serif" font-size="26.00">Schema 1.0.0</text>
-<!-- token -->
+<polygon fill="white" stroke="transparent" points="-4,4 -4,-635 665,-635 665,4 -4,4"/>
+<text text-anchor="middle" x="330.5" y="-606.2" font-family="Times,serif" font-size="26.00">IndieAuther Entity&#45;RelationsSQLite</text>
+<text text-anchor="middle" x="330.5" y="-577.2" font-family="Times,serif" font-size="26.00">Schema 1.1.0</text>
+<!-- redeemed_ticket -->
 <g id="node1" class="node">
+<title>redeemed_ticket</title>
+<polygon fill="lightblue" stroke="transparent" points="0.5,-186.5 0.5,-209.5 159.5,-209.5 159.5,-186.5 0.5,-186.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="1.5,-187.5 1.5,-208.5 158.5,-208.5 158.5,-187.5 1.5,-187.5"/>
+<text text-anchor="start" x="4.5" y="-194.3" font-family="Times,serif" font-size="14.00">REDEEMED_TICKET</text>
+<polygon fill="none" stroke="black" points="0.5,-165.5 0.5,-186.5 159.5,-186.5 159.5,-165.5 0.5,-165.5"/>
+<text text-anchor="start" x="49.5" y="-172.3" font-family="Times,serif" font-size="14.00">ticket_id</text>
+<polygon fill="none" stroke="black" points="0.5,-144.5 0.5,-165.5 159.5,-165.5 159.5,-144.5 0.5,-144.5"/>
+<text text-anchor="start" x="52.5" y="-151.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="0.5,-123.5 0.5,-144.5 159.5,-144.5 159.5,-123.5 0.5,-123.5"/>
+<text text-anchor="start" x="54" y="-130.3" font-family="Times,serif" font-size="14.00">subject</text>
+<polygon fill="none" stroke="black" points="0.5,-102.5 0.5,-123.5 159.5,-123.5 159.5,-102.5 0.5,-102.5"/>
+<text text-anchor="start" x="48.5" y="-109.3" font-family="Times,serif" font-size="14.00">resource</text>
+<polygon fill="none" stroke="black" points="0.5,-81.5 0.5,-102.5 159.5,-102.5 159.5,-81.5 0.5,-81.5"/>
+<text text-anchor="start" x="70" y="-88.3" font-family="Times,serif" font-size="14.00">iss</text>
+<polygon fill="none" stroke="black" points="0.5,-60.5 0.5,-81.5 159.5,-81.5 159.5,-60.5 0.5,-60.5"/>
+<text text-anchor="start" x="60" y="-67.3" font-family="Times,serif" font-size="14.00">token</text>
+<polygon fill="none" stroke="black" points="0.5,-39.5 0.5,-60.5 159.5,-60.5 159.5,-39.5 0.5,-39.5"/>
+<text text-anchor="start" x="59.5" y="-46.3" font-family="Times,serif" font-size="14.00">ticket</text>
+<polygon fill="none" stroke="black" points="0.5,-18.5 0.5,-39.5 159.5,-39.5 159.5,-18.5 0.5,-18.5"/>
+<text text-anchor="start" x="45" y="-25.3" font-family="Times,serif" font-size="14.00">published</text>
+</g>
+<!-- token -->
+<g id="node2" class="node">
 <title>token</title>
-<polygon fill="lightblue" stroke="transparent" points="362.5,-501.5 362.5,-524.5 488.5,-524.5 488.5,-501.5 362.5,-501.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="363.5,-502.5 363.5,-523.5 487.5,-523.5 487.5,-502.5 363.5,-502.5"/>
-<text text-anchor="start" x="398.5" y="-509.3" font-family="Times,serif" font-size="14.00">TOKEN</text>
-<polygon fill="none" stroke="black" points="362.5,-480.5 362.5,-501.5 488.5,-501.5 488.5,-480.5 362.5,-480.5"/>
-<text text-anchor="start" x="398.5" y="-487.3" font-family="Times,serif" font-size="14.00">code_id</text>
-<polygon fill="none" stroke="black" points="362.5,-459.5 362.5,-480.5 488.5,-480.5 488.5,-459.5 362.5,-459.5"/>
-<text text-anchor="start" x="392" y="-466.3" font-family="Times,serif" font-size="14.00">profile_id</text>
-<polygon fill="none" stroke="black" points="362.5,-438.5 362.5,-459.5 488.5,-459.5 488.5,-438.5 362.5,-438.5"/>
-<text text-anchor="start" x="398" y="-445.3" font-family="Times,serif" font-size="14.00">created</text>
-<polygon fill="none" stroke="black" points="362.5,-417.5 362.5,-438.5 488.5,-438.5 488.5,-417.5 362.5,-417.5"/>
-<text text-anchor="start" x="399" y="-424.3" font-family="Times,serif" font-size="14.00">expires</text>
-<polygon fill="none" stroke="black" points="362.5,-396.5 362.5,-417.5 488.5,-417.5 488.5,-396.5 362.5,-396.5"/>
-<text text-anchor="start" x="369.5" y="-403.3" font-family="Times,serif" font-size="14.00">refresh_expires</text>
-<polygon fill="none" stroke="black" points="362.5,-375.5 362.5,-396.5 488.5,-396.5 488.5,-375.5 362.5,-375.5"/>
-<text text-anchor="start" x="390.5" y="-382.3" font-family="Times,serif" font-size="14.00">refreshed</text>
-<polygon fill="none" stroke="black" points="362.5,-354.5 362.5,-375.5 488.5,-375.5 488.5,-354.5 362.5,-354.5"/>
-<text text-anchor="start" x="395" y="-361.3" font-family="Times,serif" font-size="14.00">duration</text>
-<polygon fill="none" stroke="black" points="362.5,-333.5 362.5,-354.5 488.5,-354.5 488.5,-333.5 362.5,-333.5"/>
-<text text-anchor="start" x="365.5" y="-340.3" font-family="Times,serif" font-size="14.00">refresh_duration</text>
-<polygon fill="none" stroke="black" points="362.5,-312.5 362.5,-333.5 488.5,-333.5 488.5,-312.5 362.5,-312.5"/>
-<text text-anchor="start" x="376" y="-319.3" font-family="Times,serif" font-size="14.00">refresh_count</text>
-<polygon fill="none" stroke="black" points="362.5,-291.5 362.5,-312.5 488.5,-312.5 488.5,-291.5 362.5,-291.5"/>
-<text text-anchor="start" x="387.5" y="-298.3" font-family="Times,serif" font-size="14.00">is_revoked</text>
-<polygon fill="none" stroke="black" points="362.5,-270.5 362.5,-291.5 488.5,-291.5 488.5,-270.5 362.5,-270.5"/>
-<text text-anchor="start" x="396" y="-277.3" font-family="Times,serif" font-size="14.00">is_token</text>
-<polygon fill="none" stroke="black" points="362.5,-249.5 362.5,-270.5 488.5,-270.5 488.5,-249.5 362.5,-249.5"/>
-<text text-anchor="start" x="395" y="-256.3" font-family="Times,serif" font-size="14.00">client_id</text>
-<polygon fill="none" stroke="black" points="362.5,-228.5 362.5,-249.5 488.5,-249.5 488.5,-228.5 362.5,-228.5"/>
-<text text-anchor="start" x="394" y="-235.3" font-family="Times,serif" font-size="14.00">resource</text>
-<polygon fill="none" stroke="black" points="362.5,-207.5 362.5,-228.5 488.5,-228.5 488.5,-207.5 362.5,-207.5"/>
-<text text-anchor="start" x="383" y="-214.3" font-family="Times,serif" font-size="14.00">profile_data</text>
+<polygon fill="lightblue" stroke="transparent" points="375.5,-501.5 375.5,-524.5 501.5,-524.5 501.5,-501.5 375.5,-501.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="376.5,-502.5 376.5,-523.5 500.5,-523.5 500.5,-502.5 376.5,-502.5"/>
+<text text-anchor="start" x="411.5" y="-509.3" font-family="Times,serif" font-size="14.00">TOKEN</text>
+<polygon fill="none" stroke="black" points="375.5,-480.5 375.5,-501.5 501.5,-501.5 501.5,-480.5 375.5,-480.5"/>
+<text text-anchor="start" x="411.5" y="-487.3" font-family="Times,serif" font-size="14.00">code_id</text>
+<polygon fill="none" stroke="black" points="375.5,-459.5 375.5,-480.5 501.5,-480.5 501.5,-459.5 375.5,-459.5"/>
+<text text-anchor="start" x="405" y="-466.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="375.5,-438.5 375.5,-459.5 501.5,-459.5 501.5,-438.5 375.5,-438.5"/>
+<text text-anchor="start" x="411" y="-445.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="375.5,-417.5 375.5,-438.5 501.5,-438.5 501.5,-417.5 375.5,-417.5"/>
+<text text-anchor="start" x="412" y="-424.3" font-family="Times,serif" font-size="14.00">expires</text>
+<polygon fill="none" stroke="black" points="375.5,-396.5 375.5,-417.5 501.5,-417.5 501.5,-396.5 375.5,-396.5"/>
+<text text-anchor="start" x="382.5" y="-403.3" font-family="Times,serif" font-size="14.00">refresh_expires</text>
+<polygon fill="none" stroke="black" points="375.5,-375.5 375.5,-396.5 501.5,-396.5 501.5,-375.5 375.5,-375.5"/>
+<text text-anchor="start" x="403.5" y="-382.3" font-family="Times,serif" font-size="14.00">refreshed</text>
+<polygon fill="none" stroke="black" points="375.5,-354.5 375.5,-375.5 501.5,-375.5 501.5,-354.5 375.5,-354.5"/>
+<text text-anchor="start" x="408" y="-361.3" font-family="Times,serif" font-size="14.00">duration</text>
+<polygon fill="none" stroke="black" points="375.5,-333.5 375.5,-354.5 501.5,-354.5 501.5,-333.5 375.5,-333.5"/>
+<text text-anchor="start" x="378.5" y="-340.3" font-family="Times,serif" font-size="14.00">refresh_duration</text>
+<polygon fill="none" stroke="black" points="375.5,-312.5 375.5,-333.5 501.5,-333.5 501.5,-312.5 375.5,-312.5"/>
+<text text-anchor="start" x="389" y="-319.3" font-family="Times,serif" font-size="14.00">refresh_count</text>
+<polygon fill="none" stroke="black" points="375.5,-291.5 375.5,-312.5 501.5,-312.5 501.5,-291.5 375.5,-291.5"/>
+<text text-anchor="start" x="400.5" y="-298.3" font-family="Times,serif" font-size="14.00">is_revoked</text>
+<polygon fill="none" stroke="black" points="375.5,-270.5 375.5,-291.5 501.5,-291.5 501.5,-270.5 375.5,-270.5"/>
+<text text-anchor="start" x="409" y="-277.3" font-family="Times,serif" font-size="14.00">is_token</text>
+<polygon fill="none" stroke="black" points="375.5,-249.5 375.5,-270.5 501.5,-270.5 501.5,-249.5 375.5,-249.5"/>
+<text text-anchor="start" x="408" y="-256.3" font-family="Times,serif" font-size="14.00">client_id</text>
+<polygon fill="none" stroke="black" points="375.5,-228.5 375.5,-249.5 501.5,-249.5 501.5,-228.5 375.5,-228.5"/>
+<text text-anchor="start" x="407" y="-235.3" font-family="Times,serif" font-size="14.00">resource</text>
+<polygon fill="none" stroke="black" points="375.5,-207.5 375.5,-228.5 501.5,-228.5 501.5,-207.5 375.5,-207.5"/>
+<text text-anchor="start" x="396" y="-214.3" font-family="Times,serif" font-size="14.00">profile_data</text>
 </g>
 <!-- token_scope -->
-<g id="node4" class="node">
+<g id="node5" class="node">
 <title>token_scope</title>
-<polygon fill="lightblue" stroke="transparent" points="528,-315.5 528,-338.5 648,-338.5 648,-315.5 528,-315.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="529,-316.5 529,-337.5 647,-337.5 647,-316.5 529,-316.5"/>
-<text text-anchor="start" x="532" y="-323.3" font-family="Times,serif" font-size="14.00">TOKEN_SCOPE</text>
-<polygon fill="none" stroke="black" points="528,-294.5 528,-315.5 648,-315.5 648,-294.5 528,-294.5"/>
-<text text-anchor="start" x="561" y="-301.3" font-family="Times,serif" font-size="14.00">code_id</text>
-<polygon fill="none" stroke="black" points="528,-273.5 528,-294.5 648,-294.5 648,-273.5 528,-273.5"/>
-<text text-anchor="start" x="557.5" y="-280.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+<polygon fill="lightblue" stroke="transparent" points="541,-315.5 541,-338.5 661,-338.5 661,-315.5 541,-315.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="542,-316.5 542,-337.5 660,-337.5 660,-316.5 542,-316.5"/>
+<text text-anchor="start" x="545" y="-323.3" font-family="Times,serif" font-size="14.00">TOKEN_SCOPE</text>
+<polygon fill="none" stroke="black" points="541,-294.5 541,-315.5 661,-315.5 661,-294.5 541,-294.5"/>
+<text text-anchor="start" x="574" y="-301.3" font-family="Times,serif" font-size="14.00">code_id</text>
+<polygon fill="none" stroke="black" points="541,-273.5 541,-294.5 661,-294.5 661,-273.5 541,-273.5"/>
+<text text-anchor="start" x="570.5" y="-280.3" font-family="Times,serif" font-size="14.00">scope_id</text>
 </g>
 <!-- token&#45;&gt;token_scope -->
 <g id="edge2" class="edge">
 <title>token:pk_code_id&#45;&gt;token_scope:fk_code_id</title>
-<path fill="none" stroke="black" d="M489.5,-491.5C570.54,-491.5 454.5,-320.92 517.83,-305.6"/>
-<polygon fill="black" stroke="black" points="518.06,-305.57 528.48,-308.97 523.03,-305.04 528,-304.5 528,-304.5 528,-304.5 523.03,-305.04 527.52,-300.03 518.06,-305.57 518.06,-305.57"/>
+<path fill="none" stroke="black" d="M502.5,-491.5C583.54,-491.5 467.5,-320.92 530.83,-305.6"/>
+<polygon fill="black" stroke="black" points="531.06,-305.57 541.48,-308.97 536.03,-305.04 541,-304.5 541,-304.5 541,-304.5 536.03,-305.04 540.52,-300.03 531.06,-305.57 531.06,-305.57"/>
 </g>
 <!-- profile -->
-<g id="node2" class="node">
+<g id="node3" class="node">
 <title>profile</title>
-<polygon fill="lightblue" stroke="transparent" points="206.5,-355.5 206.5,-378.5 299.5,-378.5 299.5,-355.5 206.5,-355.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="207.5,-356.5 207.5,-377.5 298.5,-377.5 298.5,-356.5 207.5,-356.5"/>
-<text text-anchor="start" x="219.5" y="-363.3" font-family="Times,serif" font-size="14.00">PROFILE</text>
-<polygon fill="none" stroke="black" points="206.5,-334.5 206.5,-355.5 299.5,-355.5 299.5,-334.5 206.5,-334.5"/>
-<text text-anchor="start" x="219.5" y="-341.3" font-family="Times,serif" font-size="14.00">profile_id</text>
-<polygon fill="none" stroke="black" points="206.5,-313.5 206.5,-334.5 299.5,-334.5 299.5,-313.5 206.5,-313.5"/>
-<text text-anchor="start" x="209.5" y="-320.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
-<polygon fill="none" stroke="black" points="206.5,-292.5 206.5,-313.5 299.5,-313.5 299.5,-292.5 206.5,-292.5"/>
-<text text-anchor="start" x="229.5" y="-299.3" font-family="Times,serif" font-size="14.00">profile</text>
+<polygon fill="lightblue" stroke="transparent" points="219.5,-355.5 219.5,-378.5 312.5,-378.5 312.5,-355.5 219.5,-355.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="220.5,-356.5 220.5,-377.5 311.5,-377.5 311.5,-356.5 220.5,-356.5"/>
+<text text-anchor="start" x="232.5" y="-363.3" font-family="Times,serif" font-size="14.00">PROFILE</text>
+<polygon fill="none" stroke="black" points="219.5,-334.5 219.5,-355.5 312.5,-355.5 312.5,-334.5 219.5,-334.5"/>
+<text text-anchor="start" x="232.5" y="-341.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="219.5,-313.5 219.5,-334.5 312.5,-334.5 312.5,-313.5 219.5,-313.5"/>
+<text text-anchor="start" x="222.5" y="-320.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
+<polygon fill="none" stroke="black" points="219.5,-292.5 219.5,-313.5 312.5,-313.5 312.5,-292.5 219.5,-292.5"/>
+<text text-anchor="start" x="242.5" y="-299.3" font-family="Times,serif" font-size="14.00">profile</text>
 </g>
 <!-- profile&#45;&gt;token -->
 <g id="edge1" class="edge">
 <title>profile:pk_profile_id&#45;&gt;token:fk_profile_id</title>
-<path fill="none" stroke="black" d="M300.5,-345.5C358.57,-345.5 306.81,-455.82 351.58,-469.18"/>
-<polygon fill="black" stroke="black" points="351.59,-469.18 360.91,-474.96 356.54,-469.84 361.5,-470.5 361.5,-470.5 361.5,-470.5 356.54,-469.84 362.09,-466.04 351.59,-469.18 351.59,-469.18"/>
+<path fill="none" stroke="black" d="M313.5,-345.5C371.57,-345.5 319.81,-455.82 364.58,-469.18"/>
+<polygon fill="black" stroke="black" points="364.59,-469.18 373.91,-474.96 369.54,-469.84 374.5,-470.5 374.5,-470.5 374.5,-470.5 369.54,-469.84 375.09,-466.04 364.59,-469.18 364.59,-469.18"/>
 </g>
 <!-- profile_scope -->
-<g id="node6" class="node">
+<g id="node7" class="node">
 <title>profile_scope</title>
-<polygon fill="lightblue" stroke="transparent" points="359.5,-166.5 359.5,-189.5 492.5,-189.5 492.5,-166.5 359.5,-166.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="360.5,-167.5 360.5,-188.5 491.5,-188.5 491.5,-167.5 360.5,-167.5"/>
-<text text-anchor="start" x="363.5" y="-174.3" font-family="Times,serif" font-size="14.00">PROFILE_SCOPE</text>
-<polygon fill="none" stroke="black" points="359.5,-145.5 359.5,-166.5 492.5,-166.5 492.5,-145.5 359.5,-145.5"/>
-<text text-anchor="start" x="392.5" y="-152.3" font-family="Times,serif" font-size="14.00">profile_id</text>
-<polygon fill="none" stroke="black" points="359.5,-124.5 359.5,-145.5 492.5,-145.5 492.5,-124.5 359.5,-124.5"/>
-<text text-anchor="start" x="395.5" y="-131.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+<polygon fill="lightblue" stroke="transparent" points="372.5,-166.5 372.5,-189.5 505.5,-189.5 505.5,-166.5 372.5,-166.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="373.5,-167.5 373.5,-188.5 504.5,-188.5 504.5,-167.5 373.5,-167.5"/>
+<text text-anchor="start" x="376.5" y="-174.3" font-family="Times,serif" font-size="14.00">PROFILE_SCOPE</text>
+<polygon fill="none" stroke="black" points="372.5,-145.5 372.5,-166.5 505.5,-166.5 505.5,-145.5 372.5,-145.5"/>
+<text text-anchor="start" x="405.5" y="-152.3" font-family="Times,serif" font-size="14.00">profile_id</text>
+<polygon fill="none" stroke="black" points="372.5,-124.5 372.5,-145.5 505.5,-145.5 505.5,-124.5 372.5,-124.5"/>
+<text text-anchor="start" x="408.5" y="-131.3" font-family="Times,serif" font-size="14.00">scope_id</text>
 </g>
 <!-- profile&#45;&gt;profile_scope -->
 <g id="edge5" class="edge">
 <title>profile:pk_profile_id&#45;&gt;profile_scope:fk_profile_id</title>
-<path fill="none" stroke="black" d="M300.5,-345.5C385.06,-345.5 280.42,-171.48 348.72,-156.52"/>
-<polygon fill="black" stroke="black" points="349.05,-156.49 359.45,-159.98 354.02,-155.99 359,-155.5 359,-155.5 359,-155.5 354.02,-155.99 358.55,-151.02 349.05,-156.49 349.05,-156.49"/>
+<path fill="none" stroke="black" d="M313.5,-345.5C398.06,-345.5 293.42,-171.48 361.72,-156.52"/>
+<polygon fill="black" stroke="black" points="362.05,-156.49 372.45,-159.98 367.02,-155.99 372,-155.5 372,-155.5 372,-155.5 367.02,-155.99 371.55,-151.02 362.05,-156.49 362.05,-156.49"/>
 </g>
 <!-- scope -->
-<g id="node3" class="node">
+<g id="node4" class="node">
 <title>scope</title>
-<polygon fill="lightblue" stroke="transparent" points="182.5,-125.5 182.5,-148.5 323.5,-148.5 323.5,-125.5 182.5,-125.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="183.5,-126.5 183.5,-147.5 322.5,-147.5 322.5,-126.5 183.5,-126.5"/>
-<text text-anchor="start" x="227" y="-133.3" font-family="Times,serif" font-size="14.00">SCOPE</text>
-<polygon fill="none" stroke="black" points="182.5,-104.5 182.5,-125.5 323.5,-125.5 323.5,-104.5 182.5,-104.5"/>
-<text text-anchor="start" x="222.5" y="-111.3" font-family="Times,serif" font-size="14.00">scope_id</text>
-<polygon fill="none" stroke="black" points="182.5,-83.5 182.5,-104.5 323.5,-104.5 323.5,-83.5 182.5,-83.5"/>
-<text text-anchor="start" x="232.5" y="-90.3" font-family="Times,serif" font-size="14.00">scope</text>
-<polygon fill="none" stroke="black" points="182.5,-62.5 182.5,-83.5 323.5,-83.5 323.5,-62.5 182.5,-62.5"/>
-<text text-anchor="start" x="212.5" y="-69.3" font-family="Times,serif" font-size="14.00">description</text>
-<polygon fill="none" stroke="black" points="182.5,-41.5 182.5,-62.5 323.5,-62.5 323.5,-41.5 182.5,-41.5"/>
-<text text-anchor="start" x="213.5" y="-48.3" font-family="Times,serif" font-size="14.00">application</text>
-<polygon fill="none" stroke="black" points="182.5,-20.5 182.5,-41.5 323.5,-41.5 323.5,-20.5 182.5,-20.5"/>
-<text text-anchor="start" x="204.5" y="-27.3" font-family="Times,serif" font-size="14.00">is_permanent</text>
-<polygon fill="none" stroke="black" points="182.5,0.5 182.5,-20.5 323.5,-20.5 323.5,0.5 182.5,0.5"/>
-<text text-anchor="start" x="185.5" y="-6.3" font-family="Times,serif" font-size="14.00">is_manually_added</text>
+<polygon fill="lightblue" stroke="transparent" points="195.5,-125.5 195.5,-148.5 336.5,-148.5 336.5,-125.5 195.5,-125.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="196.5,-126.5 196.5,-147.5 335.5,-147.5 335.5,-126.5 196.5,-126.5"/>
+<text text-anchor="start" x="240" y="-133.3" font-family="Times,serif" font-size="14.00">SCOPE</text>
+<polygon fill="none" stroke="black" points="195.5,-104.5 195.5,-125.5 336.5,-125.5 336.5,-104.5 195.5,-104.5"/>
+<text text-anchor="start" x="235.5" y="-111.3" font-family="Times,serif" font-size="14.00">scope_id</text>
+<polygon fill="none" stroke="black" points="195.5,-83.5 195.5,-104.5 336.5,-104.5 336.5,-83.5 195.5,-83.5"/>
+<text text-anchor="start" x="245.5" y="-90.3" font-family="Times,serif" font-size="14.00">scope</text>
+<polygon fill="none" stroke="black" points="195.5,-62.5 195.5,-83.5 336.5,-83.5 336.5,-62.5 195.5,-62.5"/>
+<text text-anchor="start" x="225.5" y="-69.3" font-family="Times,serif" font-size="14.00">description</text>
+<polygon fill="none" stroke="black" points="195.5,-41.5 195.5,-62.5 336.5,-62.5 336.5,-41.5 195.5,-41.5"/>
+<text text-anchor="start" x="226.5" y="-48.3" font-family="Times,serif" font-size="14.00">application</text>
+<polygon fill="none" stroke="black" points="195.5,-20.5 195.5,-41.5 336.5,-41.5 336.5,-20.5 195.5,-20.5"/>
+<text text-anchor="start" x="217.5" y="-27.3" font-family="Times,serif" font-size="14.00">is_permanent</text>
+<polygon fill="none" stroke="black" points="195.5,0.5 195.5,-20.5 336.5,-20.5 336.5,0.5 195.5,0.5"/>
+<text text-anchor="start" x="198.5" y="-6.3" font-family="Times,serif" font-size="14.00">is_manually_added</text>
 </g>
 <!-- scope&#45;&gt;token_scope -->
 <g id="edge3" class="edge">
 <title>scope:pk_scope_id&#45;&gt;token_scope:fk_scope_id</title>
-<path fill="none" stroke="black" d="M323,-115.5C398.11,-115.5 433.59,-68.28 492,-115.5 548.48,-161.16 461,-271.95 518.03,-282.66"/>
-<polygon fill="black" stroke="black" points="518.03,-282.66 527.62,-287.98 523.02,-283.08 528,-283.5 528,-283.5 528,-283.5 523.02,-283.08 528.38,-279.02 518.03,-282.66 518.03,-282.66"/>
+<path fill="none" stroke="black" d="M336,-115.5C411.11,-115.5 446.59,-68.28 505,-115.5 561.48,-161.16 474,-271.95 531.03,-282.66"/>
+<polygon fill="black" stroke="black" points="531.03,-282.66 540.62,-287.98 536.02,-283.08 541,-283.5 541,-283.5 541,-283.5 536.02,-283.08 541.38,-279.02 531.03,-282.66 531.03,-282.66"/>
 </g>
 <!-- scope&#45;&gt;profile_scope -->
 <g id="edge6" class="edge">
 <title>scope:pk_scope_id&#45;&gt;profile_scope:fk_scope_id</title>
-<path fill="none" stroke="black" d="M323,-115.5C336.99,-115.5 340.05,-126.87 349.09,-132.02"/>
-<polygon fill="black" stroke="black" points="349.3,-132.07 357.91,-138.86 354.15,-133.28 359,-134.5 359,-134.5 359,-134.5 354.15,-133.28 360.09,-130.14 349.3,-132.07 349.3,-132.07"/>
+<path fill="none" stroke="black" d="M336,-115.5C349.99,-115.5 353.05,-126.87 362.09,-132.02"/>
+<polygon fill="black" stroke="black" points="362.3,-132.07 370.91,-138.86 367.15,-133.28 372,-134.5 372,-134.5 372,-134.5 367.15,-133.28 373.09,-130.14 362.3,-132.07 362.3,-132.07"/>
 </g>
 <!-- authentication -->
-<g id="node5" class="node">
+<g id="node6" class="node">
 <title>authentication</title>
-<polygon fill="lightblue" stroke="transparent" points="0,-333.5 0,-356.5 146,-356.5 146,-333.5 0,-333.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="1,-334.5 1,-355.5 145,-355.5 145,-334.5 1,-334.5"/>
-<text text-anchor="start" x="4" y="-341.3" font-family="Times,serif" font-size="14.00">AUTHENTICATION</text>
-<polygon fill="none" stroke="black" points="0,-312.5 0,-333.5 146,-333.5 146,-312.5 0,-312.5"/>
-<text text-anchor="start" x="29.5" y="-319.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
-<polygon fill="none" stroke="black" points="0,-291.5 0,-312.5 146,-312.5 146,-291.5 0,-291.5"/>
-<text text-anchor="start" x="45.5" y="-298.3" font-family="Times,serif" font-size="14.00">created</text>
-<polygon fill="none" stroke="black" points="0,-270.5 0,-291.5 146,-291.5 146,-270.5 0,-270.5"/>
-<text text-anchor="start" x="6.5" y="-277.3" font-family="Times,serif" font-size="14.00">last_authenticated</text>
-<polygon fill="none" stroke="black" points="0,-249.5 0,-270.5 146,-270.5 146,-249.5 0,-249.5"/>
-<text text-anchor="start" x="39.5" y="-256.3" font-family="Times,serif" font-size="14.00">identifier</text>
-<polygon fill="none" stroke="black" points="0,-228.5 0,-249.5 146,-249.5 146,-228.5 0,-228.5"/>
-<text text-anchor="start" x="36.5" y="-235.3" font-family="Times,serif" font-size="14.00">credential</text>
+<polygon fill="lightblue" stroke="transparent" points="6.5,-333.5 6.5,-356.5 152.5,-356.5 152.5,-333.5 6.5,-333.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="7.5,-334.5 7.5,-355.5 151.5,-355.5 151.5,-334.5 7.5,-334.5"/>
+<text text-anchor="start" x="10.5" y="-341.3" font-family="Times,serif" font-size="14.00">AUTHENTICATION</text>
+<polygon fill="none" stroke="black" points="6.5,-312.5 6.5,-333.5 152.5,-333.5 152.5,-312.5 6.5,-312.5"/>
+<text text-anchor="start" x="36" y="-319.3" font-family="Times,serif" font-size="14.00">identifier_id</text>
+<polygon fill="none" stroke="black" points="6.5,-291.5 6.5,-312.5 152.5,-312.5 152.5,-291.5 6.5,-291.5"/>
+<text text-anchor="start" x="52" y="-298.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="6.5,-270.5 6.5,-291.5 152.5,-291.5 152.5,-270.5 6.5,-270.5"/>
+<text text-anchor="start" x="13" y="-277.3" font-family="Times,serif" font-size="14.00">last_authenticated</text>
+<polygon fill="none" stroke="black" points="6.5,-249.5 6.5,-270.5 152.5,-270.5 152.5,-249.5 6.5,-249.5"/>
+<text text-anchor="start" x="46" y="-256.3" font-family="Times,serif" font-size="14.00">identifier</text>
+<polygon fill="none" stroke="black" points="6.5,-228.5 6.5,-249.5 152.5,-249.5 152.5,-228.5 6.5,-228.5"/>
+<text text-anchor="start" x="43" y="-235.3" font-family="Times,serif" font-size="14.00">credential</text>
 </g>
 <!-- authentication&#45;&gt;profile -->
 <g id="edge4" class="edge">
 <title>authentication:pk_identifier_id&#45;&gt;profile:fk_identifier_id</title>
-<path fill="none" stroke="black" d="M146,-323.5C168.73,-323.5 176.8,-323.5 195.41,-323.5"/>
-<polygon fill="black" stroke="black" points="195.5,-323.5 205.5,-328 200.5,-323.5 205.5,-323.5 205.5,-323.5 205.5,-323.5 200.5,-323.5 205.5,-319 195.5,-323.5 195.5,-323.5"/>
+<path fill="none" stroke="black" d="M153.5,-323.5C178.66,-323.5 187.39,-323.5 208.32,-323.5"/>
+<polygon fill="black" stroke="black" points="208.5,-323.5 218.5,-328 213.5,-323.5 218.5,-323.5 218.5,-323.5 218.5,-323.5 213.5,-323.5 218.5,-319 208.5,-323.5 208.5,-323.5"/>
 </g>
 <!-- resource -->
-<g id="node7" class="node">
+<g id="node8" class="node">
 <title>resource</title>
-<polygon fill="lightblue" stroke="transparent" points="26,-458.5 26,-481.5 120,-481.5 120,-458.5 26,-458.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="27,-459.5 27,-480.5 119,-480.5 119,-459.5 27,-459.5"/>
-<text text-anchor="start" x="30" y="-466.3" font-family="Times,serif" font-size="14.00">RESOURCE</text>
-<polygon fill="none" stroke="black" points="26,-437.5 26,-458.5 120,-458.5 120,-437.5 26,-437.5"/>
-<text text-anchor="start" x="31.5" y="-444.3" font-family="Times,serif" font-size="14.00">resource_id</text>
-<polygon fill="none" stroke="black" points="26,-416.5 26,-437.5 120,-437.5 120,-416.5 26,-416.5"/>
-<text text-anchor="start" x="32.5" y="-423.3" font-family="Times,serif" font-size="14.00">description</text>
-<polygon fill="none" stroke="black" points="26,-395.5 26,-416.5 120,-416.5 120,-395.5 26,-395.5"/>
-<text text-anchor="start" x="45.5" y="-402.3" font-family="Times,serif" font-size="14.00">created</text>
-<polygon fill="none" stroke="black" points="26,-374.5 26,-395.5 120,-395.5 120,-374.5 26,-374.5"/>
-<text text-anchor="start" x="50.5" y="-381.3" font-family="Times,serif" font-size="14.00">secret</text>
+<polygon fill="lightblue" stroke="transparent" points="32.5,-458.5 32.5,-481.5 126.5,-481.5 126.5,-458.5 32.5,-458.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="33.5,-459.5 33.5,-480.5 125.5,-480.5 125.5,-459.5 33.5,-459.5"/>
+<text text-anchor="start" x="36.5" y="-466.3" font-family="Times,serif" font-size="14.00">RESOURCE</text>
+<polygon fill="none" stroke="black" points="32.5,-437.5 32.5,-458.5 126.5,-458.5 126.5,-437.5 32.5,-437.5"/>
+<text text-anchor="start" x="38" y="-444.3" font-family="Times,serif" font-size="14.00">resource_id</text>
+<polygon fill="none" stroke="black" points="32.5,-416.5 32.5,-437.5 126.5,-437.5 126.5,-416.5 32.5,-416.5"/>
+<text text-anchor="start" x="39" y="-423.3" font-family="Times,serif" font-size="14.00">description</text>
+<polygon fill="none" stroke="black" points="32.5,-395.5 32.5,-416.5 126.5,-416.5 126.5,-395.5 32.5,-395.5"/>
+<text text-anchor="start" x="52" y="-402.3" font-family="Times,serif" font-size="14.00">created</text>
+<polygon fill="none" stroke="black" points="32.5,-374.5 32.5,-395.5 126.5,-395.5 126.5,-374.5 32.5,-374.5"/>
+<text text-anchor="start" x="57" y="-381.3" font-family="Times,serif" font-size="14.00">secret</text>
 </g>
 <!-- almanac -->
-<g id="node8" class="node">
+<g id="node9" class="node">
 <title>almanac</title>
-<polygon fill="lightblue" stroke="transparent" points="31,-541.5 31,-564.5 115,-564.5 115,-541.5 31,-541.5"/>
-<polygon fill="none" stroke="black" stroke-width="2" points="32,-542.5 32,-563.5 114,-563.5 114,-542.5 32,-542.5"/>
-<text text-anchor="start" x="35" y="-549.3" font-family="Times,serif" font-size="14.00">ALMANAC</text>
-<polygon fill="none" stroke="black" points="31,-520.5 31,-541.5 115,-541.5 115,-520.5 31,-520.5"/>
-<text text-anchor="start" x="53" y="-527.3" font-family="Times,serif" font-size="14.00">event</text>
-<polygon fill="none" stroke="black" points="31,-499.5 31,-520.5 115,-520.5 115,-499.5 31,-499.5"/>
-<text text-anchor="start" x="51.5" y="-506.3" font-family="Times,serif" font-size="14.00">epoch</text>
+<polygon fill="lightblue" stroke="transparent" points="37.5,-541.5 37.5,-564.5 121.5,-564.5 121.5,-541.5 37.5,-541.5"/>
+<polygon fill="none" stroke="black" stroke-width="2" points="38.5,-542.5 38.5,-563.5 120.5,-563.5 120.5,-542.5 38.5,-542.5"/>
+<text text-anchor="start" x="41.5" y="-549.3" font-family="Times,serif" font-size="14.00">ALMANAC</text>
+<polygon fill="none" stroke="black" points="37.5,-520.5 37.5,-541.5 121.5,-541.5 121.5,-520.5 37.5,-520.5"/>
+<text text-anchor="start" x="59.5" y="-527.3" font-family="Times,serif" font-size="14.00">event</text>
+<polygon fill="none" stroke="black" points="37.5,-499.5 37.5,-520.5 121.5,-520.5 121.5,-499.5 37.5,-499.5"/>
+<text text-anchor="start" x="58" y="-506.3" font-family="Times,serif" font-size="14.00">epoch</text>
 </g>
 </g>
 </svg>
index f93c9a7ce2f5a6cf133693fbdfb75633043e1d39..63ffa54b0220424a1474d7edb0855580b682d47b 100644 (file)
       "license": "ISC",
       "dependencies": {
         "@squeep/amqp-helper": "git+https://git.squeep.com/squeep-amqp-helper#v1.0.0",
-        "@squeep/api-dingus": "^2.0.0",
+        "@squeep/api-dingus": "^2.0.1",
         "@squeep/authentication-module": "git+https://git.squeep.com/squeep-authentication-module/#v1.3.2",
-        "@squeep/chores": "git+https://git.squeep.com/squeep-chores/#v1.0.0",
+        "@squeep/chores": "git+https://git.squeep.com/squeep-chores/#v1.0.1",
         "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.4.0",
-        "@squeep/indieauth-helper": "^1.3.0",
-        "@squeep/logger-json-console": "^2.0.1",
+        "@squeep/indieauth-helper": "^1.4.1",
+        "@squeep/logger-json-console": "^3.0.1",
         "@squeep/mystery-box": "^2.0.1",
         "@squeep/resource-authentication-module": "git+https://git.squeep.com/squeep-resource-authentication-module#v1.0.1",
         "@squeep/roman": "^1.0.0",
         "@squeep/web-linking": "^1.0.8",
-        "better-sqlite3": "^9.1.1",
+        "better-sqlite3": "^9.2.2",
         "pg-promise": "^11.5.4",
         "uuid": "^9.0.1"
       },
       "devDependencies": {
-        "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v1.0.0",
-        "eslint": "^8.53.0",
+        "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v1.0.1",
+        "eslint": "^8.56.0",
         "eslint-plugin-node": "^11.1.0",
         "eslint-plugin-promise": "^6.1.1",
-        "eslint-plugin-security": "^1.7.1",
+        "eslint-plugin-security": "^2.1.0",
         "eslint-plugin-sonarjs": "^0.23.0",
         "html-minifier-lint": "^2.0.0",
         "mocha": "^10.2.0",
       }
     },
     "node_modules/@babel/code-frame": {
-      "version": "7.22.13",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
-      "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
+      "version": "7.23.5",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
+      "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==",
       "dev": true,
       "dependencies": {
-        "@babel/highlight": "^7.22.13",
+        "@babel/highlight": "^7.23.4",
         "chalk": "^2.4.2"
       },
       "engines": {
       }
     },
     "node_modules/@babel/compat-data": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz",
-      "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==",
+      "version": "7.23.5",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz",
+      "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/core": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.3.tgz",
-      "integrity": "sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.6.tgz",
+      "integrity": "sha512-FxpRyGjrMJXh7X3wGLGhNDCRiwpWEF74sKjTLDJSG5Kyvow3QZaG0Adbqzi9ZrVjTWpsX+2cxWXD71NMg93kdw==",
       "dev": true,
       "dependencies": {
         "@ampproject/remapping": "^2.2.0",
-        "@babel/code-frame": "^7.22.13",
-        "@babel/generator": "^7.23.3",
-        "@babel/helper-compilation-targets": "^7.22.15",
+        "@babel/code-frame": "^7.23.5",
+        "@babel/generator": "^7.23.6",
+        "@babel/helper-compilation-targets": "^7.23.6",
         "@babel/helper-module-transforms": "^7.23.3",
-        "@babel/helpers": "^7.23.2",
-        "@babel/parser": "^7.23.3",
+        "@babel/helpers": "^7.23.6",
+        "@babel/parser": "^7.23.6",
         "@babel/template": "^7.22.15",
-        "@babel/traverse": "^7.23.3",
-        "@babel/types": "^7.23.3",
+        "@babel/traverse": "^7.23.6",
+        "@babel/types": "^7.23.6",
         "convert-source-map": "^2.0.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
       }
     },
     "node_modules/@babel/generator": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz",
-      "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
+      "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==",
       "dev": true,
       "dependencies": {
-        "@babel/types": "^7.23.3",
+        "@babel/types": "^7.23.6",
         "@jridgewell/gen-mapping": "^0.3.2",
         "@jridgewell/trace-mapping": "^0.3.17",
         "jsesc": "^2.5.1"
       }
     },
     "node_modules/@babel/helper-compilation-targets": {
-      "version": "7.22.15",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz",
-      "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz",
+      "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==",
       "dev": true,
       "dependencies": {
-        "@babel/compat-data": "^7.22.9",
-        "@babel/helper-validator-option": "^7.22.15",
-        "browserslist": "^4.21.9",
+        "@babel/compat-data": "^7.23.5",
+        "@babel/helper-validator-option": "^7.23.5",
+        "browserslist": "^4.22.2",
         "lru-cache": "^5.1.1",
         "semver": "^6.3.1"
       },
       }
     },
     "node_modules/@babel/helper-string-parser": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
-      "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
+      "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helper-validator-option": {
-      "version": "7.22.15",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz",
-      "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==",
+      "version": "7.23.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz",
+      "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==",
       "dev": true,
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/helpers": {
-      "version": "7.23.2",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz",
-      "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.6.tgz",
+      "integrity": "sha512-wCfsbN4nBidDRhpDhvcKlzHWCTlgJYUUdSJfzXb2NuBssDSIjc3xcb+znA7l+zYsFljAcGM0aFkN40cR3lXiGA==",
       "dev": true,
       "dependencies": {
         "@babel/template": "^7.22.15",
-        "@babel/traverse": "^7.23.2",
-        "@babel/types": "^7.23.0"
+        "@babel/traverse": "^7.23.6",
+        "@babel/types": "^7.23.6"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/highlight": {
-      "version": "7.22.20",
-      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
-      "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
+      "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
       "dev": true,
       "dependencies": {
         "@babel/helper-validator-identifier": "^7.22.20",
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz",
-      "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
+      "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==",
       "dev": true,
       "bin": {
         "parser": "bin/babel-parser.js"
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz",
-      "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.6.tgz",
+      "integrity": "sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ==",
       "dev": true,
       "dependencies": {
-        "@babel/code-frame": "^7.22.13",
-        "@babel/generator": "^7.23.3",
+        "@babel/code-frame": "^7.23.5",
+        "@babel/generator": "^7.23.6",
         "@babel/helper-environment-visitor": "^7.22.20",
         "@babel/helper-function-name": "^7.23.0",
         "@babel/helper-hoist-variables": "^7.22.5",
         "@babel/helper-split-export-declaration": "^7.22.6",
-        "@babel/parser": "^7.23.3",
-        "@babel/types": "^7.23.3",
-        "debug": "^4.1.0",
+        "@babel/parser": "^7.23.6",
+        "@babel/types": "^7.23.6",
+        "debug": "^4.3.1",
         "globals": "^11.1.0"
       },
       "engines": {
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz",
-      "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz",
+      "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==",
       "dev": true,
       "dependencies": {
-        "@babel/helper-string-parser": "^7.22.5",
+        "@babel/helper-string-parser": "^7.23.4",
         "@babel/helper-validator-identifier": "^7.22.20",
         "to-fast-properties": "^2.0.0"
       },
       }
     },
     "node_modules/@eslint/eslintrc": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz",
-      "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==",
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+      "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
       "dev": true,
       "dependencies": {
         "ajv": "^6.12.4",
       }
     },
     "node_modules/@eslint/js": {
-      "version": "8.53.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.53.0.tgz",
-      "integrity": "sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==",
+      "version": "8.56.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
+      "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       }
     },
     "node_modules/@squeep/api-dingus": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@squeep/api-dingus/-/api-dingus-2.0.0.tgz",
-      "integrity": "sha512-HKz/yB1KNmEcHB92KIvrNvwMph5fSdJBrxKSgERYfyQkLFl2vSwDV+IlFvi68DYmMBP3lWKzQcTXWBMYlW3c0g==",
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@squeep/api-dingus/-/api-dingus-2.0.1.tgz",
+      "integrity": "sha512-b4FWPyHNpn8JtvrTQszukz6mF5OmqhJba0czVffCzhOdbfUk6PKejDRjAtSj4m8fgn4QnvvtAOTHBDvQwNAftw==",
       "dependencies": {
+        "@squeep/log-helper": "^1.0.0",
         "mime-db": "^1.52.0",
-        "uuid": "^9.0.0"
+        "uuid": "^9.0.1"
       },
       "engines": {
         "node": ">=14"
         "node-linux-pam": "^0.2.1"
       }
     },
+    "node_modules/@squeep/authentication-module/node_modules/@squeep/api-dingus": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@squeep/api-dingus/-/api-dingus-2.0.0.tgz",
+      "integrity": "sha512-HKz/yB1KNmEcHB92KIvrNvwMph5fSdJBrxKSgERYfyQkLFl2vSwDV+IlFvi68DYmMBP3lWKzQcTXWBMYlW3c0g==",
+      "dependencies": {
+        "mime-db": "^1.52.0",
+        "uuid": "^9.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/@squeep/chores": {
-      "version": "1.0.0",
-      "resolved": "git+https://git.squeep.com/squeep-chores/#d98a3c114eb33bd68477c0ca750e6f82b4d02964",
-      "license": "ISC"
+      "version": "1.0.1",
+      "resolved": "git+https://git.squeep.com/squeep-chores/#a77e8814cbba0ad751e249850d1f7b144da6446b",
+      "license": "ISC",
+      "dependencies": {
+        "@squeep/log-helper": "^1.0.0"
+      }
     },
     "node_modules/@squeep/html-template-helper": {
       "version": "1.4.0",
       }
     },
     "node_modules/@squeep/indieauth-helper": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@squeep/indieauth-helper/-/indieauth-helper-1.3.0.tgz",
-      "integrity": "sha512-v7tae925KePhAmtpisQjSQ/xUi3XOGGdruxkhcy8sZpQ0/DOwRdwsuKEDT7aNKVB/STm0rYn8kgiH4SOBRDvWA==",
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/@squeep/indieauth-helper/-/indieauth-helper-1.4.1.tgz",
+      "integrity": "sha512-x/yqrjrbp0vTdvIW8e9CKsr5+YwmDp/x4nK4H5LInt07rk4nthxv7e7qIgy97OFtvFnoNee+N9gyi3TzIIiGoQ==",
       "dependencies": {
+        "@squeep/log-helper": "^1.0.0",
         "@squeep/web-linking": "^1.0.8",
         "got": "^13.0.0",
         "iconv": "^3.0.1",
-        "ip-address": "^8.1.0",
-        "microformats-parser": "^1.4.1"
+        "ip-address": "^9.0.5",
+        "microformats-parser": "^2.0.2"
       },
       "engines": {
         "node": "^14 >=14.18 || >=15.7"
         "node": ">=14"
       }
     },
-    "node_modules/@squeep/logger-json-console": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/@squeep/logger-json-console/-/logger-json-console-2.0.1.tgz",
-      "integrity": "sha512-JDlyevhn/JPMPUU6ItJpou3537gXzkSLn4cGm5VvZPrxb6od3I0ls6ywQC3dtaxgH2ZJmFUZwBTXRhZojwvukQ==",
+    "node_modules/@squeep/log-helper": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@squeep/log-helper/-/log-helper-1.0.0.tgz",
+      "integrity": "sha512-i61ECZLWQI2rhkXj9pDzH1Md5ICghL9zvh5QFVo0BTayuSrdS9SWkJ6gV1qWki/Xz6SuE0y0y145NyHlvOuVaw==",
       "engines": {
         "node": ">=14"
       }
     },
+    "node_modules/@squeep/logger-json-console": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@squeep/logger-json-console/-/logger-json-console-3.0.1.tgz",
+      "integrity": "sha512-Po4PPtKHoYHuDyx/PMdHdIByCeArVh2McGCw8IxTaFBf+PCdAosZmDNpreKb2Xw7OVpbkX1u3iOmrVgQWv6aBg==",
+      "engines": {
+        "node": ">=17"
+      }
+    },
     "node_modules/@squeep/mystery-box": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/@squeep/mystery-box/-/mystery-box-2.0.1.tgz",
         "node": "^14 >=14.18 || >=15.7"
       }
     },
+    "node_modules/@squeep/resource-authentication-module/node_modules/@squeep/api-dingus": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@squeep/api-dingus/-/api-dingus-2.0.0.tgz",
+      "integrity": "sha512-HKz/yB1KNmEcHB92KIvrNvwMph5fSdJBrxKSgERYfyQkLFl2vSwDV+IlFvi68DYmMBP3lWKzQcTXWBMYlW3c0g==",
+      "dependencies": {
+        "mime-db": "^1.52.0",
+        "uuid": "^9.0.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/@squeep/roman": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/@squeep/roman/-/roman-1.0.0.tgz",
       "integrity": "sha512-D02jDw/we8tc6QiCPor7tWVviY8MLITyp/egqp3XqrrKtsFMYdguAhaFKUmIBu1ZL1uPKgoLBOy8hIptmh8cWA=="
     },
     "node_modules/@squeep/test-helper": {
-      "version": "1.0.0",
-      "resolved": "git+https://git.squeep.com/squeep-test-helper#7a5a384abb99757b53c8898c508023f0ba9e94b1",
+      "version": "1.0.1",
+      "resolved": "git+https://git.squeep.com/squeep-test-helper#cc0f69b40de9ae3342f1b7a1784d37769e7f1e84",
       "dev": true,
       "license": "ISC",
       "dependencies": {
-        "eslint": "^8.23.1",
+        "eslint": "^8.53.0",
         "eslint-plugin-node": "^11.1.0",
-        "eslint-plugin-security": "^1.5.0",
-        "eslint-plugin-sonarjs": "^0.15.0",
-        "mocha": "^10.0.0",
+        "eslint-plugin-security": "^1.7.1",
+        "eslint-plugin-sonarjs": "^0.23.0",
+        "mocha": "^10.2.0",
         "nyc": "^15.1.0",
         "pre-commit": "^1.2.2",
-        "sinon": "^14.0.0"
-      }
-    },
-    "node_modules/@squeep/test-helper/node_modules/@sinonjs/commons": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz",
-      "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==",
-      "dev": true,
-      "dependencies": {
-        "type-detect": "4.0.8"
-      }
-    },
-    "node_modules/@squeep/test-helper/node_modules/@sinonjs/fake-timers": {
-      "version": "9.1.2",
-      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz",
-      "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==",
-      "dev": true,
-      "dependencies": {
-        "@sinonjs/commons": "^1.7.0"
-      }
-    },
-    "node_modules/@squeep/test-helper/node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": {
-      "version": "1.8.6",
-      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz",
-      "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==",
-      "dev": true,
-      "dependencies": {
-        "type-detect": "4.0.8"
-      }
-    },
-    "node_modules/@squeep/test-helper/node_modules/@sinonjs/samsam": {
-      "version": "7.0.1",
-      "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-7.0.1.tgz",
-      "integrity": "sha512-zsAk2Jkiq89mhZovB2LLOdTCxJF4hqqTToGP0ASWlhp4I1hqOjcfmZGafXntCN7MDC6yySH0mFHrYtHceOeLmw==",
-      "dev": true,
-      "dependencies": {
-        "@sinonjs/commons": "^2.0.0",
-        "lodash.get": "^4.4.2",
-        "type-detect": "^4.0.8"
-      }
-    },
-    "node_modules/@squeep/test-helper/node_modules/eslint-plugin-sonarjs": {
-      "version": "0.15.0",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.15.0.tgz",
-      "integrity": "sha512-LuxHdAe6VqSbi1phsUvNjbmXLuvlobmryQJJNyQYbdubCfz6K8tmgoqNiJPnz0pP2AbYDbtuPm0ajOMgMrC+dQ==",
-      "dev": true,
-      "engines": {
-        "node": ">=12"
-      },
-      "peerDependencies": {
-        "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+        "sinon": "^17.0.1"
       }
     },
-    "node_modules/@squeep/test-helper/node_modules/sinon": {
-      "version": "14.0.2",
-      "resolved": "https://registry.npmjs.org/sinon/-/sinon-14.0.2.tgz",
-      "integrity": "sha512-PDpV0ZI3ZCS3pEqx0vpNp6kzPhHrLx72wA0G+ZLaaJjLIYeE0n8INlgaohKuGy7hP0as5tbUd23QWu5U233t+w==",
-      "deprecated": "16.1.1",
+    "node_modules/@squeep/test-helper/node_modules/eslint-plugin-security": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.7.1.tgz",
+      "integrity": "sha512-sMStceig8AFglhhT2LqlU5r+/fn9OwsA72O5bBuQVTssPCdQAOQzL+oMn/ZcpeUY6KcNfLJArgcrsSULNjYYdQ==",
       "dev": true,
       "dependencies": {
-        "@sinonjs/commons": "^2.0.0",
-        "@sinonjs/fake-timers": "^9.1.2",
-        "@sinonjs/samsam": "^7.0.1",
-        "diff": "^5.0.0",
-        "nise": "^5.1.2",
-        "supports-color": "^7.2.0"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/sinon"
+        "safe-regex": "^2.1.1"
       }
     },
     "node_modules/@squeep/web-linking": {
       ]
     },
     "node_modules/better-sqlite3": {
-      "version": "9.1.1",
-      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.1.1.tgz",
-      "integrity": "sha512-FhW7bS7cXwkB2SFnPJrSGPmQerVSCzwBgmQ1cIRcYKxLsyiKjljzCbyEqqhYXo5TTBqt5BISiBj2YE2Sy2ynaA==",
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.2.2.tgz",
+      "integrity": "sha512-qwjWB46il0lsDkeB4rSRI96HyDQr8sxeu1MkBVLMrwusq1KRu4Bpt1TMI+8zIJkDUtZ3umjAkaEjIlokZKWCQw==",
       "hasInstallScript": true,
       "dependencies": {
         "bindings": "^1.5.0",
       "dev": true
     },
     "node_modules/browserslist": {
-      "version": "4.22.1",
-      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz",
-      "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==",
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz",
+      "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==",
       "dev": true,
       "funding": [
         {
         }
       ],
       "dependencies": {
-        "caniuse-lite": "^1.0.30001541",
-        "electron-to-chromium": "^1.4.535",
-        "node-releases": "^2.0.13",
+        "caniuse-lite": "^1.0.30001565",
+        "electron-to-chromium": "^1.4.601",
+        "node-releases": "^2.0.14",
         "update-browserslist-db": "^1.0.13"
       },
       "bin": {
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001561",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz",
-      "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==",
+      "version": "1.0.30001571",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001571.tgz",
+      "integrity": "sha512-tYq/6MoXhdezDLFZuCO/TKboTzuQ/xR5cFdgXPfDtM7/kchBO3b4VWghE/OAi/DV7tTdhmLjZiZBZi1fA/GheQ==",
       "dev": true,
       "funding": [
         {
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.4.580",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.580.tgz",
-      "integrity": "sha512-T5q3pjQon853xxxHUq3ZP68ZpvJHuSMY2+BZaW3QzjS4HvNuvsMmZ/+lU+nCrftre1jFZ+OSlExynXWBihnXzw==",
+      "version": "1.4.616",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz",
+      "integrity": "sha512-1n7zWYh8eS0L9Uy+GskE0lkBUNK83cXTVJI0pU3mGprFsbfSdAc15VTFbo+A+Bq4pwstmL30AVcEU3Fo463lNg==",
       "dev": true
     },
     "node_modules/emoji-regex": {
         "once": "^1.4.0"
       }
     },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/es6-error": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
       }
     },
     "node_modules/eslint": {
-      "version": "8.53.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.53.0.tgz",
-      "integrity": "sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==",
+      "version": "8.56.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
+      "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
       "dev": true,
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
-        "@eslint/eslintrc": "^2.1.3",
-        "@eslint/js": "8.53.0",
+        "@eslint/eslintrc": "^2.1.4",
+        "@eslint/js": "8.56.0",
         "@humanwhocodes/config-array": "^0.11.13",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
       }
     },
     "node_modules/eslint-plugin-security": {
-      "version": "1.7.1",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.7.1.tgz",
-      "integrity": "sha512-sMStceig8AFglhhT2LqlU5r+/fn9OwsA72O5bBuQVTssPCdQAOQzL+oMn/ZcpeUY6KcNfLJArgcrsSULNjYYdQ==",
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-2.1.0.tgz",
+      "integrity": "sha512-ywxclP954bf8d3gr6KOQ/AFc+PRvWuhOxtPOEtiHmVYiZr/mcgQtmSJq6+hTEXC5ylTjHnPPG+PEnzlDiWMXbQ==",
       "dev": true,
       "dependencies": {
         "safe-regex": "^2.1.1"
       "dev": true
     },
     "node_modules/fastq": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
-      "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz",
+      "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==",
       "dev": true,
       "dependencies": {
         "reusify": "^1.0.4"
       }
     },
     "node_modules/flat-cache": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz",
-      "integrity": "sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==",
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+      "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
       "dev": true,
       "dependencies": {
         "flatted": "^3.2.9",
         "rimraf": "^3.0.2"
       },
       "engines": {
-        "node": ">=12.0.0"
+        "node": "^10.12.0 || >=12.0.0"
       }
     },
     "node_modules/flatted": {
       }
     },
     "node_modules/globals": {
-      "version": "13.23.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz",
-      "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==",
+      "version": "13.24.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+      "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
       "dev": true,
       "dependencies": {
         "type-fest": "^0.20.2"
       "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
     },
     "node_modules/http2-wrapper": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.0.tgz",
-      "integrity": "sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==",
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
+      "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
       "dependencies": {
         "quick-lru": "^5.1.1",
         "resolve-alpn": "^1.2.0"
       ]
     },
     "node_modules/ignore": {
-      "version": "5.2.4",
-      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
-      "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz",
+      "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==",
       "dev": true,
       "engines": {
         "node": ">= 4"
       "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
     },
     "node_modules/ip-address": {
-      "version": "8.1.0",
-      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-8.1.0.tgz",
-      "integrity": "sha512-Wz91gZKpNKoXtqvY8ScarKYwhXoK4r/b5QuT+uywe/azv0/nUCo7Bh0IRRI7F9DHR06kJNWtzMGLIbXavngbKA==",
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
+      "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
       "dependencies": {
         "jsbn": "1.1.0",
-        "sprintf-js": "1.1.2"
+        "sprintf-js": "^1.1.3"
       },
       "engines": {
         "node": ">= 12"
       }
     },
     "node_modules/microformats-parser": {
-      "version": "1.5.2",
-      "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-1.5.2.tgz",
-      "integrity": "sha512-EcHm8zxEm3CggOLgILfxCo2wDiJEOnACzpV/FXWGLaRk24ECei+JkoWNdKdo2vzo/Pww9EvrQNeQsdv4JuHy7Q==",
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-2.0.2.tgz",
+      "integrity": "sha512-tUf9DmN4Jq/tGyp1YH2V6D/Cud+9Uc0WhjjUFirqVeHTRkkfLDacv6BQFT7h7HFsD0Z8wja5eKkRgzZU8bv0Fw==",
       "dependencies": {
-        "parse5": "^6.0.0"
+        "parse5": "^7.1.2"
       },
       "engines": {
-        "node": ">=14"
+        "node": ">=18"
       }
     },
     "node_modules/mime-db": {
       }
     },
     "node_modules/node-abi": {
-      "version": "3.51.0",
-      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.51.0.tgz",
-      "integrity": "sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==",
+      "version": "3.52.0",
+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.52.0.tgz",
+      "integrity": "sha512-JJ98b02z16ILv7859irtXn4oUaFWADtvkzy2c0IAatNVX2Mc9Yoh8z6hZInn3QwvMEYhHuQloYi+TTQy67SIdQ==",
       "dependencies": {
         "semver": "^7.3.5"
       },
       }
     },
     "node_modules/node-releases": {
-      "version": "2.0.13",
-      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
-      "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==",
+      "version": "2.0.14",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
+      "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
       "dev": true
     },
     "node_modules/nopt": {
       }
     },
     "node_modules/parse5": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
-      "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
+      "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
+      "dependencies": {
+        "entities": "^4.4.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
     },
     "node_modules/path-exists": {
       "version": "4.0.0",
       }
     },
     "node_modules/sprintf-js": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
-      "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+      "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
     },
     "node_modules/string_decoder": {
       "version": "0.10.31",
       }
     },
     "@babel/code-frame": {
-      "version": "7.22.13",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
-      "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
+      "version": "7.23.5",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
+      "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==",
       "dev": true,
       "requires": {
-        "@babel/highlight": "^7.22.13",
+        "@babel/highlight": "^7.23.4",
         "chalk": "^2.4.2"
       },
       "dependencies": {
       }
     },
     "@babel/compat-data": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz",
-      "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==",
+      "version": "7.23.5",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz",
+      "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==",
       "dev": true
     },
     "@babel/core": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.3.tgz",
-      "integrity": "sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.6.tgz",
+      "integrity": "sha512-FxpRyGjrMJXh7X3wGLGhNDCRiwpWEF74sKjTLDJSG5Kyvow3QZaG0Adbqzi9ZrVjTWpsX+2cxWXD71NMg93kdw==",
       "dev": true,
       "requires": {
         "@ampproject/remapping": "^2.2.0",
-        "@babel/code-frame": "^7.22.13",
-        "@babel/generator": "^7.23.3",
-        "@babel/helper-compilation-targets": "^7.22.15",
+        "@babel/code-frame": "^7.23.5",
+        "@babel/generator": "^7.23.6",
+        "@babel/helper-compilation-targets": "^7.23.6",
         "@babel/helper-module-transforms": "^7.23.3",
-        "@babel/helpers": "^7.23.2",
-        "@babel/parser": "^7.23.3",
+        "@babel/helpers": "^7.23.6",
+        "@babel/parser": "^7.23.6",
         "@babel/template": "^7.22.15",
-        "@babel/traverse": "^7.23.3",
-        "@babel/types": "^7.23.3",
+        "@babel/traverse": "^7.23.6",
+        "@babel/types": "^7.23.6",
         "convert-source-map": "^2.0.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
       }
     },
     "@babel/generator": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz",
-      "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
+      "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==",
       "dev": true,
       "requires": {
-        "@babel/types": "^7.23.3",
+        "@babel/types": "^7.23.6",
         "@jridgewell/gen-mapping": "^0.3.2",
         "@jridgewell/trace-mapping": "^0.3.17",
         "jsesc": "^2.5.1"
       }
     },
     "@babel/helper-compilation-targets": {
-      "version": "7.22.15",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz",
-      "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz",
+      "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==",
       "dev": true,
       "requires": {
-        "@babel/compat-data": "^7.22.9",
-        "@babel/helper-validator-option": "^7.22.15",
-        "browserslist": "^4.21.9",
+        "@babel/compat-data": "^7.23.5",
+        "@babel/helper-validator-option": "^7.23.5",
+        "browserslist": "^4.22.2",
         "lru-cache": "^5.1.1",
         "semver": "^6.3.1"
       },
       }
     },
     "@babel/helper-string-parser": {
-      "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
-      "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
+      "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
       "dev": true
     },
     "@babel/helper-validator-identifier": {
       "dev": true
     },
     "@babel/helper-validator-option": {
-      "version": "7.22.15",
-      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz",
-      "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==",
+      "version": "7.23.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz",
+      "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==",
       "dev": true
     },
     "@babel/helpers": {
-      "version": "7.23.2",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz",
-      "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.6.tgz",
+      "integrity": "sha512-wCfsbN4nBidDRhpDhvcKlzHWCTlgJYUUdSJfzXb2NuBssDSIjc3xcb+znA7l+zYsFljAcGM0aFkN40cR3lXiGA==",
       "dev": true,
       "requires": {
         "@babel/template": "^7.22.15",
-        "@babel/traverse": "^7.23.2",
-        "@babel/types": "^7.23.0"
+        "@babel/traverse": "^7.23.6",
+        "@babel/types": "^7.23.6"
       }
     },
     "@babel/highlight": {
-      "version": "7.22.20",
-      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
-      "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
+      "version": "7.23.4",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
+      "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
       "dev": true,
       "requires": {
         "@babel/helper-validator-identifier": "^7.22.20",
       }
     },
     "@babel/parser": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz",
-      "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
+      "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==",
       "dev": true
     },
     "@babel/template": {
       }
     },
     "@babel/traverse": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz",
-      "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.6.tgz",
+      "integrity": "sha512-czastdK1e8YByZqezMPFiZ8ahwVMh/ESl9vPgvgdB9AmFMGP5jfpFax74AQgl5zj4XHzqeYAg2l8PuUeRS1MgQ==",
       "dev": true,
       "requires": {
-        "@babel/code-frame": "^7.22.13",
-        "@babel/generator": "^7.23.3",
+        "@babel/code-frame": "^7.23.5",
+        "@babel/generator": "^7.23.6",
         "@babel/helper-environment-visitor": "^7.22.20",
         "@babel/helper-function-name": "^7.23.0",
         "@babel/helper-hoist-variables": "^7.22.5",
         "@babel/helper-split-export-declaration": "^7.22.6",
-        "@babel/parser": "^7.23.3",
-        "@babel/types": "^7.23.3",
-        "debug": "^4.1.0",
+        "@babel/parser": "^7.23.6",
+        "@babel/types": "^7.23.6",
+        "debug": "^4.3.1",
         "globals": "^11.1.0"
       },
       "dependencies": {
       }
     },
     "@babel/types": {
-      "version": "7.23.3",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz",
-      "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==",
+      "version": "7.23.6",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz",
+      "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==",
       "dev": true,
       "requires": {
-        "@babel/helper-string-parser": "^7.22.5",
+        "@babel/helper-string-parser": "^7.23.4",
         "@babel/helper-validator-identifier": "^7.22.20",
         "to-fast-properties": "^2.0.0"
       }
       "dev": true
     },
     "@eslint/eslintrc": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz",
-      "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==",
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+      "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
       "dev": true,
       "requires": {
         "ajv": "^6.12.4",
       }
     },
     "@eslint/js": {
-      "version": "8.53.0",
-      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.53.0.tgz",
-      "integrity": "sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==",
+      "version": "8.56.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
+      "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
       "dev": true
     },
     "@humanwhocodes/config-array": {
       }
     },
     "@squeep/api-dingus": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/@squeep/api-dingus/-/api-dingus-2.0.0.tgz",
-      "integrity": "sha512-HKz/yB1KNmEcHB92KIvrNvwMph5fSdJBrxKSgERYfyQkLFl2vSwDV+IlFvi68DYmMBP3lWKzQcTXWBMYlW3c0g==",
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@squeep/api-dingus/-/api-dingus-2.0.1.tgz",
+      "integrity": "sha512-b4FWPyHNpn8JtvrTQszukz6mF5OmqhJba0czVffCzhOdbfUk6PKejDRjAtSj4m8fgn4QnvvtAOTHBDvQwNAftw==",
       "requires": {
+        "@squeep/log-helper": "^1.0.0",
         "mime-db": "^1.52.0",
-        "uuid": "^9.0.0"
+        "uuid": "^9.0.1"
       }
     },
     "@squeep/authentication-module": {
         "@squeep/mystery-box": "^2.0.1",
         "argon2": "^0.31.0",
         "node-linux-pam": "^0.2.1"
+      },
+      "dependencies": {
+        "@squeep/api-dingus": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/@squeep/api-dingus/-/api-dingus-2.0.0.tgz",
+          "integrity": "sha512-HKz/yB1KNmEcHB92KIvrNvwMph5fSdJBrxKSgERYfyQkLFl2vSwDV+IlFvi68DYmMBP3lWKzQcTXWBMYlW3c0g==",
+          "requires": {
+            "mime-db": "^1.52.0",
+            "uuid": "^9.0.0"
+          }
+        }
       }
     },
     "@squeep/chores": {
-      "version": "git+https://git.squeep.com/squeep-chores/#d98a3c114eb33bd68477c0ca750e6f82b4d02964",
-      "from": "@squeep/chores@git+https://git.squeep.com/squeep-chores/#v1.0.0"
+      "version": "git+https://git.squeep.com/squeep-chores/#a77e8814cbba0ad751e249850d1f7b144da6446b",
+      "from": "@squeep/chores@git+https://git.squeep.com/squeep-chores/#v1.0.1",
+      "requires": {
+        "@squeep/log-helper": "^1.0.0"
+      }
     },
     "@squeep/html-template-helper": {
       "version": "git+https://git.squeep.com/squeep-html-template-helper#100046316a87631fb8814f80b35647709e6c7319",
       }
     },
     "@squeep/indieauth-helper": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/@squeep/indieauth-helper/-/indieauth-helper-1.3.0.tgz",
-      "integrity": "sha512-v7tae925KePhAmtpisQjSQ/xUi3XOGGdruxkhcy8sZpQ0/DOwRdwsuKEDT7aNKVB/STm0rYn8kgiH4SOBRDvWA==",
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/@squeep/indieauth-helper/-/indieauth-helper-1.4.1.tgz",
+      "integrity": "sha512-x/yqrjrbp0vTdvIW8e9CKsr5+YwmDp/x4nK4H5LInt07rk4nthxv7e7qIgy97OFtvFnoNee+N9gyi3TzIIiGoQ==",
       "requires": {
+        "@squeep/log-helper": "^1.0.0",
         "@squeep/web-linking": "^1.0.8",
         "got": "^13.0.0",
         "iconv": "^3.0.1",
-        "ip-address": "^8.1.0",
-        "microformats-parser": "^1.4.1"
+        "ip-address": "^9.0.5",
+        "microformats-parser": "^2.0.2"
       }
     },
     "@squeep/lazy-property": {
       "resolved": "https://registry.npmjs.org/@squeep/lazy-property/-/lazy-property-1.1.2.tgz",
       "integrity": "sha512-wRdR4IOqWXoDMArx0HPo5MtM2Wk5wemAULbZ6PabVw1ylSQekkzKfoAUuupxsKuzjcRPjZvbpGDv+i04hBMnQw=="
     },
+    "@squeep/log-helper": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@squeep/log-helper/-/log-helper-1.0.0.tgz",
+      "integrity": "sha512-i61ECZLWQI2rhkXj9pDzH1Md5ICghL9zvh5QFVo0BTayuSrdS9SWkJ6gV1qWki/Xz6SuE0y0y145NyHlvOuVaw=="
+    },
     "@squeep/logger-json-console": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/@squeep/logger-json-console/-/logger-json-console-2.0.1.tgz",
-      "integrity": "sha512-JDlyevhn/JPMPUU6ItJpou3537gXzkSLn4cGm5VvZPrxb6od3I0ls6ywQC3dtaxgH2ZJmFUZwBTXRhZojwvukQ=="
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@squeep/logger-json-console/-/logger-json-console-3.0.1.tgz",
+      "integrity": "sha512-Po4PPtKHoYHuDyx/PMdHdIByCeArVh2McGCw8IxTaFBf+PCdAosZmDNpreKb2Xw7OVpbkX1u3iOmrVgQWv6aBg=="
     },
     "@squeep/mystery-box": {
       "version": "2.0.1",
       "requires": {
         "@squeep/api-dingus": "v2.0.0",
         "uuid": "^9.0.0"
+      },
+      "dependencies": {
+        "@squeep/api-dingus": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/@squeep/api-dingus/-/api-dingus-2.0.0.tgz",
+          "integrity": "sha512-HKz/yB1KNmEcHB92KIvrNvwMph5fSdJBrxKSgERYfyQkLFl2vSwDV+IlFvi68DYmMBP3lWKzQcTXWBMYlW3c0g==",
+          "requires": {
+            "mime-db": "^1.52.0",
+            "uuid": "^9.0.0"
+          }
+        }
       }
     },
     "@squeep/roman": {
       "integrity": "sha512-D02jDw/we8tc6QiCPor7tWVviY8MLITyp/egqp3XqrrKtsFMYdguAhaFKUmIBu1ZL1uPKgoLBOy8hIptmh8cWA=="
     },
     "@squeep/test-helper": {
-      "version": "git+https://git.squeep.com/squeep-test-helper#7a5a384abb99757b53c8898c508023f0ba9e94b1",
+      "version": "git+https://git.squeep.com/squeep-test-helper#cc0f69b40de9ae3342f1b7a1784d37769e7f1e84",
       "dev": true,
-      "from": "@squeep/test-helper@git+https://git.squeep.com/squeep-test-helper#v1.0.0",
+      "from": "@squeep/test-helper@git+https://git.squeep.com/squeep-test-helper#v1.0.1",
       "requires": {
-        "eslint": "^8.23.1",
+        "eslint": "^8.53.0",
         "eslint-plugin-node": "^11.1.0",
-        "eslint-plugin-security": "^1.5.0",
-        "eslint-plugin-sonarjs": "^0.15.0",
-        "mocha": "^10.0.0",
+        "eslint-plugin-security": "^1.7.1",
+        "eslint-plugin-sonarjs": "^0.23.0",
+        "mocha": "^10.2.0",
         "nyc": "^15.1.0",
         "pre-commit": "^1.2.2",
-        "sinon": "^14.0.0"
+        "sinon": "^17.0.1"
       },
       "dependencies": {
-        "@sinonjs/commons": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz",
-          "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==",
-          "dev": true,
-          "requires": {
-            "type-detect": "4.0.8"
-          }
-        },
-        "@sinonjs/fake-timers": {
-          "version": "9.1.2",
-          "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz",
-          "integrity": "sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==",
-          "dev": true,
-          "requires": {
-            "@sinonjs/commons": "^1.7.0"
-          },
-          "dependencies": {
-            "@sinonjs/commons": {
-              "version": "1.8.6",
-              "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz",
-              "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==",
-              "dev": true,
-              "requires": {
-                "type-detect": "4.0.8"
-              }
-            }
-          }
-        },
-        "@sinonjs/samsam": {
-          "version": "7.0.1",
-          "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-7.0.1.tgz",
-          "integrity": "sha512-zsAk2Jkiq89mhZovB2LLOdTCxJF4hqqTToGP0ASWlhp4I1hqOjcfmZGafXntCN7MDC6yySH0mFHrYtHceOeLmw==",
-          "dev": true,
-          "requires": {
-            "@sinonjs/commons": "^2.0.0",
-            "lodash.get": "^4.4.2",
-            "type-detect": "^4.0.8"
-          }
-        },
-        "eslint-plugin-sonarjs": {
-          "version": "0.15.0",
-          "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.15.0.tgz",
-          "integrity": "sha512-LuxHdAe6VqSbi1phsUvNjbmXLuvlobmryQJJNyQYbdubCfz6K8tmgoqNiJPnz0pP2AbYDbtuPm0ajOMgMrC+dQ==",
-          "dev": true,
-          "requires": {}
-        },
-        "sinon": {
-          "version": "14.0.2",
-          "resolved": "https://registry.npmjs.org/sinon/-/sinon-14.0.2.tgz",
-          "integrity": "sha512-PDpV0ZI3ZCS3pEqx0vpNp6kzPhHrLx72wA0G+ZLaaJjLIYeE0n8INlgaohKuGy7hP0as5tbUd23QWu5U233t+w==",
+        "eslint-plugin-security": {
+          "version": "1.7.1",
+          "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.7.1.tgz",
+          "integrity": "sha512-sMStceig8AFglhhT2LqlU5r+/fn9OwsA72O5bBuQVTssPCdQAOQzL+oMn/ZcpeUY6KcNfLJArgcrsSULNjYYdQ==",
           "dev": true,
           "requires": {
-            "@sinonjs/commons": "^2.0.0",
-            "@sinonjs/fake-timers": "^9.1.2",
-            "@sinonjs/samsam": "^7.0.1",
-            "diff": "^5.0.0",
-            "nise": "^5.1.2",
-            "supports-color": "^7.2.0"
+            "safe-regex": "^2.1.1"
           }
         }
       }
       "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
     },
     "better-sqlite3": {
-      "version": "9.1.1",
-      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.1.1.tgz",
-      "integrity": "sha512-FhW7bS7cXwkB2SFnPJrSGPmQerVSCzwBgmQ1cIRcYKxLsyiKjljzCbyEqqhYXo5TTBqt5BISiBj2YE2Sy2ynaA==",
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.2.2.tgz",
+      "integrity": "sha512-qwjWB46il0lsDkeB4rSRI96HyDQr8sxeu1MkBVLMrwusq1KRu4Bpt1TMI+8zIJkDUtZ3umjAkaEjIlokZKWCQw==",
       "requires": {
         "bindings": "^1.5.0",
         "prebuild-install": "^7.1.1"
       "dev": true
     },
     "browserslist": {
-      "version": "4.22.1",
-      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz",
-      "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==",
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz",
+      "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==",
       "dev": true,
       "requires": {
-        "caniuse-lite": "^1.0.30001541",
-        "electron-to-chromium": "^1.4.535",
-        "node-releases": "^2.0.13",
+        "caniuse-lite": "^1.0.30001565",
+        "electron-to-chromium": "^1.4.601",
+        "node-releases": "^2.0.14",
         "update-browserslist-db": "^1.0.13"
       }
     },
       "devOptional": true
     },
     "caniuse-lite": {
-      "version": "1.0.30001561",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz",
-      "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==",
+      "version": "1.0.30001571",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001571.tgz",
+      "integrity": "sha512-tYq/6MoXhdezDLFZuCO/TKboTzuQ/xR5cFdgXPfDtM7/kchBO3b4VWghE/OAi/DV7tTdhmLjZiZBZi1fA/GheQ==",
       "dev": true
     },
     "chalk": {
       }
     },
     "electron-to-chromium": {
-      "version": "1.4.580",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.580.tgz",
-      "integrity": "sha512-T5q3pjQon853xxxHUq3ZP68ZpvJHuSMY2+BZaW3QzjS4HvNuvsMmZ/+lU+nCrftre1jFZ+OSlExynXWBihnXzw==",
+      "version": "1.4.616",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz",
+      "integrity": "sha512-1n7zWYh8eS0L9Uy+GskE0lkBUNK83cXTVJI0pU3mGprFsbfSdAc15VTFbo+A+Bq4pwstmL30AVcEU3Fo463lNg==",
       "dev": true
     },
     "emoji-regex": {
         "once": "^1.4.0"
       }
     },
+    "entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
+    },
     "es6-error": {
       "version": "4.1.1",
       "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
       "dev": true
     },
     "eslint": {
-      "version": "8.53.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.53.0.tgz",
-      "integrity": "sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==",
+      "version": "8.56.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
+      "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
       "dev": true,
       "requires": {
         "@eslint-community/eslint-utils": "^4.2.0",
         "@eslint-community/regexpp": "^4.6.1",
-        "@eslint/eslintrc": "^2.1.3",
-        "@eslint/js": "8.53.0",
+        "@eslint/eslintrc": "^2.1.4",
+        "@eslint/js": "8.56.0",
         "@humanwhocodes/config-array": "^0.11.13",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@nodelib/fs.walk": "^1.2.8",
       "requires": {}
     },
     "eslint-plugin-security": {
-      "version": "1.7.1",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.7.1.tgz",
-      "integrity": "sha512-sMStceig8AFglhhT2LqlU5r+/fn9OwsA72O5bBuQVTssPCdQAOQzL+oMn/ZcpeUY6KcNfLJArgcrsSULNjYYdQ==",
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-2.1.0.tgz",
+      "integrity": "sha512-ywxclP954bf8d3gr6KOQ/AFc+PRvWuhOxtPOEtiHmVYiZr/mcgQtmSJq6+hTEXC5ylTjHnPPG+PEnzlDiWMXbQ==",
       "dev": true,
       "requires": {
         "safe-regex": "^2.1.1"
       "dev": true
     },
     "fastq": {
-      "version": "1.15.0",
-      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
-      "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz",
+      "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==",
       "dev": true,
       "requires": {
         "reusify": "^1.0.4"
       "dev": true
     },
     "flat-cache": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz",
-      "integrity": "sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==",
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+      "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
       "dev": true,
       "requires": {
         "flatted": "^3.2.9",
       }
     },
     "globals": {
-      "version": "13.23.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz",
-      "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==",
+      "version": "13.24.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+      "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
       "dev": true,
       "requires": {
         "type-fest": "^0.20.2"
       "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
     },
     "http2-wrapper": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.0.tgz",
-      "integrity": "sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==",
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
+      "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
       "requires": {
         "quick-lru": "^5.1.1",
         "resolve-alpn": "^1.2.0"
       "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
     },
     "ignore": {
-      "version": "5.2.4",
-      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
-      "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz",
+      "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==",
       "dev": true
     },
     "import-fresh": {
       "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
     },
     "ip-address": {
-      "version": "8.1.0",
-      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-8.1.0.tgz",
-      "integrity": "sha512-Wz91gZKpNKoXtqvY8ScarKYwhXoK4r/b5QuT+uywe/azv0/nUCo7Bh0IRRI7F9DHR06kJNWtzMGLIbXavngbKA==",
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
+      "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
       "requires": {
         "jsbn": "1.1.0",
-        "sprintf-js": "1.1.2"
+        "sprintf-js": "^1.1.3"
       }
     },
     "is-binary-path": {
       }
     },
     "microformats-parser": {
-      "version": "1.5.2",
-      "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-1.5.2.tgz",
-      "integrity": "sha512-EcHm8zxEm3CggOLgILfxCo2wDiJEOnACzpV/FXWGLaRk24ECei+JkoWNdKdo2vzo/Pww9EvrQNeQsdv4JuHy7Q==",
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-2.0.2.tgz",
+      "integrity": "sha512-tUf9DmN4Jq/tGyp1YH2V6D/Cud+9Uc0WhjjUFirqVeHTRkkfLDacv6BQFT7h7HFsD0Z8wja5eKkRgzZU8bv0Fw==",
       "requires": {
-        "parse5": "^6.0.0"
+        "parse5": "^7.1.2"
       }
     },
     "mime-db": {
       }
     },
     "node-abi": {
-      "version": "3.51.0",
-      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.51.0.tgz",
-      "integrity": "sha512-SQkEP4hmNWjlniS5zdnfIXTk1x7Ome85RDzHlTbBtzE97Gfwz/Ipw4v/Ryk20DWIy3yCNVLVlGKApCnmvYoJbA==",
+      "version": "3.52.0",
+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.52.0.tgz",
+      "integrity": "sha512-JJ98b02z16ILv7859irtXn4oUaFWADtvkzy2c0IAatNVX2Mc9Yoh8z6hZInn3QwvMEYhHuQloYi+TTQy67SIdQ==",
       "requires": {
         "semver": "^7.3.5"
       }
       }
     },
     "node-releases": {
-      "version": "2.0.13",
-      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
-      "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==",
+      "version": "2.0.14",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
+      "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
       "dev": true
     },
     "nopt": {
       }
     },
     "parse5": {
-      "version": "6.0.1",
-      "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
-      "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
+      "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
+      "requires": {
+        "entities": "^4.4.0"
+      }
     },
     "path-exists": {
       "version": "4.0.0",
       "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="
     },
     "sprintf-js": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
-      "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+      "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
     },
     "string_decoder": {
       "version": "0.10.31",
index 85ec0be7762790b37276c12d9a490c48c0c0d503..f594e636d37b7b10c60f0585a643361e3c9b70fb 100644 (file)
   "license": "ISC",
   "dependencies": {
     "@squeep/amqp-helper": "git+https://git.squeep.com/squeep-amqp-helper#v1.0.0",
-    "@squeep/api-dingus": "^2.0.0",
+    "@squeep/api-dingus": "^2.0.1",
     "@squeep/authentication-module": "git+https://git.squeep.com/squeep-authentication-module/#v1.3.2",
-    "@squeep/chores": "git+https://git.squeep.com/squeep-chores/#v1.0.0",
+    "@squeep/chores": "git+https://git.squeep.com/squeep-chores/#v1.0.1",
     "@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.4.0",
-    "@squeep/indieauth-helper": "^1.3.0",
-    "@squeep/logger-json-console": "^2.0.1",
+    "@squeep/indieauth-helper": "^1.4.1",
+    "@squeep/logger-json-console": "^3.0.1",
     "@squeep/mystery-box": "^2.0.1",
     "@squeep/resource-authentication-module": "git+https://git.squeep.com/squeep-resource-authentication-module#v1.0.1",
     "@squeep/roman": "^1.0.0",
     "@squeep/web-linking": "^1.0.8",
-    "better-sqlite3": "^9.1.1",
+    "better-sqlite3": "^9.2.2",
     "pg-promise": "^11.5.4",
     "uuid": "^9.0.1"
   },
   "devDependencies": {
-    "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v1.0.0",
-    "eslint": "^8.53.0",
+    "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v1.0.1",
+    "eslint": "^8.56.0",
     "eslint-plugin-node": "^11.1.0",
     "eslint-plugin-promise": "^6.1.1",
-    "eslint-plugin-security": "^1.7.1",
+    "eslint-plugin-security": "^2.1.0",
     "eslint-plugin-sonarjs": "^0.23.0",
     "html-minifier-lint": "^2.0.0",
     "mocha": "^10.2.0",
index 0f3d379e402529d57e8000328f0de86a886ce647..40021580ce1d665eeb1e7cffe9341e1ef7a28328 100644 (file)
@@ -10,13 +10,15 @@ const _fileScope = common.fileScope(__filename);
  */
 
 class Chores extends BaseChores {
-  constructor(logger, db, options) {
+  constructor(logger, db, queuePublisher, options) {
     super(logger);
     this.options = options;
     this.db = db;
+    this.queuePublisher = queuePublisher;
 
     this.establishChore(Enum.Chore.CleanTokens, this.cleanTokens.bind(this), options?.chores?.tokenCleanupMs);
     this.establishChore(Enum.Chore.CleanScopes, this.cleanScopes.bind(this), options?.chores?.scopeCleanupMs);
+    this.establishChore(Enum.Chore.PublishTickets, this.publishTickets.bind(this), options?.chores?.publishTicketsMs);
   }
 
   /**
@@ -25,7 +27,7 @@ class Chores extends BaseChores {
    */
   async cleanTokens(atLeastMsSinceLast = this.options?.chores?.tokenCleanupMs || 0) {
     const _scope = _fileScope('cleanTokens');
-    this.logger.debug(_scope, 'called', atLeastMsSinceLast);
+    this.logger.debug(_scope, 'called', { atLeastMsSinceLast });
 
     let tokensCleaned;
     try {
@@ -49,7 +51,7 @@ class Chores extends BaseChores {
    */
   async cleanScopes(atLeastMsSinceLast = this.options?.chores?.scopeCleanupMs || 0) {
     const _scope = _fileScope('cleanScopes');
-    this.logger.debug(_scope, 'called', atLeastMsSinceLast);
+    this.logger.debug(_scope, 'called', { atLeastMsSinceLast });
 
     let scopesCleaned;
     try {
@@ -65,6 +67,34 @@ class Chores extends BaseChores {
     }
   }
 
-} // IAChores
 
-module.exports = Chores;
\ No newline at end of file
+  /**
+   * Attempt to deliver any redeemed but un-delivered ticket tokens.
+   */
+  async publishTickets() {
+    const _scope = _fileScope('publishTickets');
+    this.logger.debug(_scope, 'called');
+
+    try {
+      const queueName = this.options.queues.ticketRedeemedName;
+      await this.db.context(async (dbCtx) => {
+        const ticketTokens = await this.db.ticketTokenGetUnpublished(dbCtx);
+        for await (const data of ticketTokens) {
+          try {
+            const result = await this.queuePublisher.publish(queueName, data);
+            this.logger.info(_scope, 'published ticket token', { queueName, result, ...data });
+            const redeemedData = common.pick(data, ['resource', 'subject', 'iss', 'ticket', 'token']);
+            await this.db.ticketTokenPublished(dbCtx, redeemedData);
+          } catch (e) {
+            this.logger.error(_scope, 'publish failed', { error: e, data });
+          }
+        }
+      }); // dbCtx
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+} // Chores
+
+module.exports = Chores;
index 2e9f46d0bb248dcad86b39cc5e681c046076c733..d5367df933c109f632140517b55790deb0251045 100644 (file)
@@ -47,7 +47,7 @@ class Database {
   /**
    * Query the current schema version.
    * This is a standalone query function, as it is called before statements are loaded.
-   * @returns {SchemaVersionObject}
+   * @returns {Promise<SchemaVersionObject>}
    */
   async _currentSchema() {
     this._notImplemented('_currentSchema', arguments);
@@ -251,6 +251,17 @@ class Database {
   }
 
 
+  /**
+   * Insert or update an almanac entry.
+   * @param {*} dbCtx
+   * @param {String} event
+   * @param {Date=} date
+   */
+  async almanacUpsert(dbCtx, event, date) {
+    this._notImplemented('almanacUpsert', arguments);
+  }
+
+
   /**
    * Fetch the authentication record for an identifier.
    * @param {*} dbCtx
@@ -580,6 +591,44 @@ class Database {
     this._notImplemented('tokensGetByIdentifier', arguments);
   }
 
+
+  /** @typedef {Object} RedeemedTicketData
+   * @property {String} subject
+   * @property {String} resource
+   * @property {String=} iss
+   * @property {String} ticket
+   * @property {String} token
+   */
+  /**
+   * Persist details of a redeemed ticket.
+   * @param {*} dbCtx
+   * @param {RedeemedTicketData} redeemedData
+   * @returns {Promise<void>}
+   */
+  async ticketRedeemed(dbCtx, redeemedData) {
+    this._notImplemented('ticketRedeemed', arguments);
+  }
+
+
+  /**
+   * Update details of a redeemed ticket that it has been published.
+   * @param {*} dbCtx
+   * @param {RedeemedTicketData} redeemedData
+   * @returns {Promise<void>}
+   */
+  async ticketTokenPublished(dbCtx, redeemedData) {
+    this._notImplemented('ticketTokenPublished', arguments);
+  }
+
+  /**
+   * Retrieve redeemed tokens which have not yet been published to queue.
+   * @param {Number} limit
+   * @returns {Promise<RedeemedData[]>}
+   */
+  async ticketTokenGetUnpublished(dbCtx, limit) {
+    this._notImplemented('ticketTokenGetUnpublished', arguments);
+  }
+
 }
 
-module.exports = Database;
\ No newline at end of file
+module.exports = Database;
index 3a536f48f5d4250cce11f0483843bbac74335b02..535a90153c50170662d1f57f9a1772c01c94c690 100644 (file)
@@ -11,6 +11,7 @@ const { unappliedSchemaVersions } = require('../schema-version-helper');
 const Database = require('../abstract');
 const DBErrors = require('../errors');
 const common = require('../../common');
+const Enum = require('../../enum');
 
 const _fileScope = common.fileScope(__filename);
 
@@ -28,7 +29,7 @@ const schemaVersionsSupported = {
   },
   max: {
     major: 1,
-    minor: 0,
+    minor: 1,
     patch: 0,
   },
 };
@@ -197,9 +198,11 @@ class DatabasePostgres extends Database {
       if (really) {
         await this.db.tx(async (t) => {
           await t.batch([
+            'almanac',
             'authentication',
-            'resource',
             'profile',
+            'redeemed_ticket',
+            'resource',
             'token',
           ].map(async (table) => t.query('TRUNCATE TABLE $(table:name) CASCADE', { table })));
         });
@@ -235,6 +238,22 @@ class DatabasePostgres extends Database {
   }
 
 
+  async almanacUpsert(dbCtx, event, date) {
+    const _scope = _fileScope('almanacUpsert');
+    this.logger.debug(_scope, 'called', { event, date });
+
+    try {
+      const result = await dbCtx.result(this.statement.almanacUpsert, { event, date: date ?? new Date() });
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not upsert almanac event');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, event, date });
+      throw e;
+    }
+  }
+
+
   async authenticationGet(dbCtx, identifier) {
     const _scope = _fileScope('authenticationGet');
     this.logger.debug(_scope, 'called', { identifier });
@@ -464,7 +483,7 @@ class DatabasePostgres extends Database {
     const _scope = _fileScope('scopeCleanup');
     this.logger.debug(_scope, 'called', { atLeastMsSinceLast });
 
-    const almanacEvent = 'scopeCleanup';
+    const almanacEvent = Enum.AlmanacEntry.ScopeCleanup;
     try {
       return await this.transaction(dbCtx, async (txCtx) => {
 
@@ -543,7 +562,7 @@ class DatabasePostgres extends Database {
     const _scope = _fileScope('tokenCleanup');
     this.logger.debug(_scope, 'called', { codeLifespanSeconds, atLeastMsSinceLast });
 
-    const almanacEvent = 'tokenCleanup';
+    const almanacEvent = Enum.AlmanacEntry.TokenCleanup;
     try {
       return await this.transaction(dbCtx, async (txCtx) => {
 
@@ -633,6 +652,55 @@ class DatabasePostgres extends Database {
     }
   }
 
+
+  async ticketRedeemed(dbCtx, redeemedData) {
+    const _scope = _fileScope('ticketRedeemed');
+    this.logger.debug(_scope, 'called', { ...redeemedData });
+
+    try {
+      const result = await dbCtx.result(this.statement.ticketRedeemed, redeemedData);
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not store redeemed ticket');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, ...redeemedData });
+      throw e;
+    }
+  }
+
+
+  async ticketTokenPublished(dbCtx, redeemedData) {
+    const _scope = _fileScope('ticketRedeemed');
+    this.logger.debug(_scope, 'called', { ...redeemedData });
+
+    const almanacEvent = Enum.AlmanacEntry.TicketPublished;
+    try {
+      const result = await dbCtx.result(this.statement.ticketTokenPublished, redeemedData);
+      if (result.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not store redeemed ticket');
+      }
+      const almanacResult = await dbCtx.result(this.statement.almanacUpsert, { event: almanacEvent, date: new Date() });
+      if (almanacResult.rowCount != 1) {
+        throw new DBErrors.UnexpectedResult('did not update almanac');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, ...redeemedData });
+      throw e;
+    }
+  }
+
+  async ticketTokenGetUnpublished(dbCtx) {
+    const _scope = _fileScope('ticketTokenGetUnpublished');
+    this.logger.debug(_scope, 'called');
+
+    try {
+      return await dbCtx.manyOrNone(this.statement.ticketTokenGetUnpublished);
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
 }
 
 module.exports = DatabasePostgres;
diff --git a/src/db/postgres/sql/schema/1.1.0/apply.sql b/src/db/postgres/sql/schema/1.1.0/apply.sql
new file mode 100644 (file)
index 0000000..8ccc2a5
--- /dev/null
@@ -0,0 +1,23 @@
+BEGIN;
+
+       CREATE TABLE redeemed_ticket (
+               ticket_id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+               created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+               subject TEXT NOT NULL,
+               resource TEXT NOT NULL,
+               iss TEXT,
+               token TEXT NOT NULL,
+               ticket TEXT NOT NULL,
+               published TIMESTAMP WITH TIME ZONE
+       );
+       COMMENT ON TABLE redeemed_ticket IS $docstring$
+Tickets which have been redeemed for tokens and published.
+       $docstring$;
+       CREATE INDEX redeemed_ticket_created_idx ON redeemed_ticket(created);
+       CREATE INDEX redeemed_ticket_published_idx ON redeemed_ticket(published) WHERE published IS NOT NULL;
+       CREATE INDEX redeemed_ticket_ref_idx ON redeemed_ticket(subject, resource, iss, ticket);
+
+       -- Update schema version
+       INSERT INTO _meta_schema_version (major, minor, patch) VALUES (1, 1, 0);
+
+COMMIT;
diff --git a/src/db/postgres/sql/schema/1.1.0/er.dot b/src/db/postgres/sql/schema/1.1.0/er.dot
new file mode 100644 (file)
index 0000000..087c21a
--- /dev/null
@@ -0,0 +1,120 @@
+digraph indieAutherERD {
+       graph[
+               rankdir=LR,
+               overlap=false,
+               splines=true,
+               label="IndieAuther Entity-Relations\nPostgres\nSchema 1.1.0",
+               labelloc="t",
+               fontsize=26,
+       ];
+       // layout=neato;
+       node[shape=plain];
+       edge[arrowhead=crow];
+
+       redeemed_ticket [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">REDEEMED_TICKET</td></tr>
+               <tr><td port="pk_ticket_id">ticket_id</td></tr>
+               <tr><td port="">created</td></tr>
+               <tr><td port="">subject</td></tr>
+               <tr><td port="">resource</td></tr>
+               <tr><td port="">iss</td></tr>
+               <tr><td port="">token</td></tr>
+               <tr><td port="">ticket</td></tr>
+               <tr><td port="">published</td></tr>
+       </table>
+       >]
+
+       token [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">TOKEN</td></tr>
+               <tr><td port="pk_code_id">code_id</td></tr>
+               <tr><td port="fk_profile_id">profile_id</td></tr>
+               <tr><td port="">created</td></tr>
+               <tr><td port="">expires</td></tr>
+               <tr><td port="">refresh_expires</td></tr>
+               <tr><td port="">refreshed</td></tr>
+               <tr><td port="">duration</td></tr>
+               <tr><td port="">refresh_duration</td></tr>
+               <tr><td port="">refresh_count</td></tr>
+               <tr><td port="">is_revoked</td></tr>
+               <tr><td port="">is_token</td></tr>
+               <tr><td port="">client_id</td></tr>
+               <tr><td port="">resource</td></tr>
+               <tr><td port="">profile_data</td></tr>
+       </table>
+       >];
+       profile:pk_profile_id -> token:fk_profile_id;
+
+       scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">SCOPE</td></tr>
+               <tr><td port="pk_scope_id">scope_id</td></tr>
+               <tr><td port="">scope</td></tr>
+               <tr><td port="">description</td></tr>
+               <tr><td port="">application</td></tr>
+               <tr><td port="">is_permanent</td></tr>
+               <tr><td port="">is_manually_added</td></tr>
+       </table>
+       >];
+
+       token_scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">TOKEN_SCOPE</td></tr>
+               <tr><td port="fk_code_id">code_id</td></tr>
+               <tr><td port="fk_scope_id">scope_id</td></tr>
+       </table>
+       >];
+       token:pk_code_id -> token_scope:fk_code_id;
+       scope:pk_scope_id -> token_scope:fk_scope_id;
+
+       profile [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">PROFILE</td></tr>
+               <tr><td port="pk_profile_id">profile_id</td></tr>
+               <tr><td port="fk_identifier_id">identifier_id</td></tr>
+               <tr><td port="">profile</td></tr>
+       </table>
+       >];
+       authentication:pk_identifier_id -> profile:fk_identifier_id;
+
+       profile_scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">PROFILE_SCOPE</td></tr>
+               <tr><td port="fk_profile_id">profile_id</td></tr>
+               <tr><td port="fk_scope_id">scope_id</td></tr>
+       </table>
+       >];
+       profile:pk_profile_id -> profile_scope:fk_profile_id;
+       scope:pk_scope_id -> profile_scope:fk_scope_id;
+
+       authentication [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+                       <tr><td border="2" bgcolor="lightblue">AUTHENTICATION</td></tr>
+                       <tr><td port="pk_identifier_id">identifier_id</td></tr>
+                       <tr><td port="">created</td></tr>
+                       <tr><td port="">last_authenticated</td></tr>
+                       <tr><td port="">identifier</td></tr>
+                       <tr><td port="">credential</td></tr>
+       </table>
+       >];
+
+       resource [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">RESOURCE</td></tr>
+               <tr><td port="pk_resource_id">resource_id</td></tr>
+               <tr><td port="">description</td></tr>
+               <tr><td port="">created</td></tr>
+               <tr><td port="">secret</td></tr>
+       </table>
+       >];
+
+       almanac [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">ALMANAC</td></tr>
+               <tr><td port="pk_event">event</td></tr>
+               <tr><td port="">date</td></tr>
+       </table>
+       >];
+
+}
diff --git a/src/db/postgres/sql/schema/1.1.0/revert.sql b/src/db/postgres/sql/schema/1.1.0/revert.sql
new file mode 100644 (file)
index 0000000..8d70e0c
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN;
+       DROP TABLE redeemed_ticket CASCADE;
+
+       DELETE FROM _meta_schema_version WHERE major = 1 AND minor = 1 AND patch = 0;
+COMMIT;
diff --git a/src/db/postgres/sql/ticket-redeemed.sql b/src/db/postgres/sql/ticket-redeemed.sql
new file mode 100644 (file)
index 0000000..06e49fc
--- /dev/null
@@ -0,0 +1,5 @@
+--
+INSERT INTO redeemed_ticket
+       (subject, resource, iss, token, ticket)
+VALUES
+       ($(subject), $(resource), $(iss), $(token), $(ticket))
diff --git a/src/db/postgres/sql/ticket-token-get-unpublished.sql b/src/db/postgres/sql/ticket-token-get-unpublished.sql
new file mode 100644 (file)
index 0000000..425fc3e
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT * FROM redeemed_ticket
+WHERE
+       published IS NULL
diff --git a/src/db/postgres/sql/ticket-token-published.sql b/src/db/postgres/sql/ticket-token-published.sql
new file mode 100644 (file)
index 0000000..9c2e542
--- /dev/null
@@ -0,0 +1,11 @@
+--
+UPDATE redeemed_ticket SET
+       published = now()
+WHERE
+       subject = $(subject)
+AND
+       resource = $(resource)
+AND
+       iss = $(iss)
+AND
+       ticket = $(ticket)
index 1858a657825196bad0eae4bde50204bdcbc47af9..453dbf8eada1b9d39d79ac2c47a6e27738c59b40 100644 (file)
@@ -1,6 +1,7 @@
 'use strict';
 
 const common = require('../../common');
+const Enum = require('../../enum');
 const Database = require('../abstract');
 const DBErrors = require('../errors');
 const { unappliedSchemaVersions } = require('../schema-version-helper');
@@ -20,7 +21,7 @@ const schemaVersionsSupported = {
   },
   max: {
     major: 1,
-    minor: 0,
+    minor: 1,
     patch: 0,
   },
 };
@@ -233,10 +234,13 @@ class DatabaseSQLite extends Database {
   _purgeTables(really) {
     if (really) {
       [
+        'almanac',
         'authentication',
         'profile',
+        'redeemed_ticket',
+        'resource',
         'token',
-      ].map((table) => {
+      ].forEach((table) => {
         const result = this.db.prepare(`DELETE FROM ${table}`).run();
         this.logger.debug(_fileScope('_purgeTables'), 'success', { table, result });
       });
@@ -262,6 +266,7 @@ class DatabaseSQLite extends Database {
     };
   }
 
+
   almanacGetAll(dbCtx) { // eslint-disable-line no-unused-vars
     const _scope = _fileScope('almanacGetAll');
     this.logger.debug(_scope, 'called');
@@ -276,6 +281,23 @@ class DatabaseSQLite extends Database {
   }
 
 
+  almanacUpsert(dbCtx, event, date) {
+    const _scope = _fileScope('almanacUpsert');
+    this.logger.debug(_scope, 'called', { event, date });
+
+    try {
+      const epoch = common.dateToEpoch(date);
+      const result = this.statement.almanacUpsert.run({ event, epoch });
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not upsert almanac event');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e, event, date });
+      throw e;
+    }
+  }
+
+
   static _authenticationToNative(authentication) {
     if (authentication) {
       authentication.created = new Date(Number(authentication.created) * 1000);
@@ -539,7 +561,7 @@ class DatabaseSQLite extends Database {
     const _scope = _fileScope('scopeCleanup');
     this.logger.debug(_scope, 'called', { atLeastMsSinceLast });
 
-    const almanacEvent = 'scopeCleanup';
+    const almanacEvent = Enum.AlmanacEntry.ScopeCleanup;
     try {
       return this.db.transaction(() => {
 
@@ -617,7 +639,7 @@ class DatabaseSQLite extends Database {
     const _scope = _fileScope('tokenCleanup');
     this.logger.debug(_scope, 'called', { codeLifespanSeconds, atLeastMsSinceLast });
 
-    const almanacEvent = 'tokenCleanup';
+    const almanacEvent = Enum.AlmanacEntry.TokenCleanup;
     try {
       return this.db.transaction(() => {
 
@@ -734,6 +756,66 @@ class DatabaseSQLite extends Database {
     }
   }
 
+
+  ticketRedeemed(dbCtx, redeemedData) {
+    const _scope = _fileScope('ticketRedeemed');
+    this.logger.debug(_scope, 'called', { ...redeemedData });
+
+    try {
+      const result = this.statement.ticketRedeemed.run(redeemedData);
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not store redeemed ticket');
+      }
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+
+  ticketTokenPublished(dbCtx, redeemedData) {
+    const _scope = _fileScope('ticketRedeemed');
+    this.logger.debug(_scope, 'called', { ...redeemedData });
+
+    const almanacEvent = Enum.AlmanacEntry.TicketPublished;
+    try {
+      const result = this.statement.ticketTokenPublished.run(redeemedData);
+      if (result.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not store redeemed ticket');
+      }
+      const epoch = common.dateToEpoch();
+      const almanacResult = this.statement.almanacUpsert.run({ event: almanacEvent, epoch });
+      if (almanacResult.changes != 1) {
+        throw new DBErrors.UnexpectedResult('did not update almanac');
+      }
+
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
+  static _redeemedTicketToNative(redeemedTicket) {
+    redeemedTicket.created = new Date(Number(redeemedTicket.created) * 1000);
+    if (redeemedTicket.published) {
+      redeemedTicket.published = new Date(Number(redeemedTicket.published) * 1000);
+    }
+    return redeemedTicket;
+  }
+
+  ticketTokenGetUnpublished() {
+    const _scope = _fileScope('ticketTokenGetUnpublished');
+    this.logger.debug(_scope, 'called');
+
+    try {
+      const unpublished = this.statement.ticketTokenGetUnpublished.all();
+      return unpublished.map((x) => DatabaseSQLite._redeemedTicketToNative(x));
+    } catch (e) {
+      this.logger.error(_scope, 'failed', { error: e });
+      throw e;
+    }
+  }
+
 }
 
-module.exports = DatabaseSQLite;
\ No newline at end of file
+module.exports = DatabaseSQLite;
index 20ccf689ce7333ad45a3853c6d17119654ba94ed..9ef50c2bbef5fa403cad7486b69663405125addf 100644 (file)
@@ -4,4 +4,6 @@ BEGIN;
        DROP TABLE token;
        DROP TABLE scope;
        DROP TABLE profile_scope;
+
+       DELETE FROM _meta_schema_version WHERE major = 1 AND minor = 0 AND patch = 0;
 COMMIT;
\ No newline at end of file
diff --git a/src/db/sqlite/sql/schema/1.1.0/apply.sql b/src/db/sqlite/sql/schema/1.1.0/apply.sql
new file mode 100644 (file)
index 0000000..81ebc0d
--- /dev/null
@@ -0,0 +1,20 @@
+BEGIN;
+
+       CREATE TABLE redeemed_ticket (
+               ticket_id INTEGER NOT NULL PRIMARY KEY CHECK (typeof(ticket_id) = 'integer'),
+               created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(created) = 'integer'),
+               subject TEXT NOT NULL CHECK (typeof(subject) = 'text'),
+               resource TEXT NOT NULL CHECK (typeof(resource) = 'text'),
+               iss TEXT CHECK (typeof(iss) = 'text'),
+               token TEXT NOT NULL  CHECK (typeof(token) = 'text'),
+               ticket TEXT NOT NULL CHECK (typeof(ticket) = 'text'),
+               published INTEGER CHECK (typeof(published) IN ('integer', 'null'))
+       );
+       CREATE INDEX redeemed_ticket_created_idx ON redeemed_ticket(created);
+       CREATE INDEX redeemed_ticket_published_idx ON redeemed_ticket(published) WHERE published IS NOT NULL;
+       CREATE INDEX redeemed_ticket_ref_idx ON redeemed_ticket(subject, resource, iss, ticket);
+
+       -- Update schema version
+       INSERT INTO _meta_schema_version (major, minor, patch) VALUES (1, 1, 0);
+
+COMMIT;
diff --git a/src/db/sqlite/sql/schema/1.1.0/er.dot b/src/db/sqlite/sql/schema/1.1.0/er.dot
new file mode 100644 (file)
index 0000000..1925993
--- /dev/null
@@ -0,0 +1,120 @@
+digraph indieAutherERD {
+       graph[
+               rankdir=LR,
+               overlap=false,
+               splines=true,
+               label="IndieAuther Entity-Relations\SQLite\nSchema 1.1.0",
+               labelloc="t",
+               fontsize=26,
+       ];
+       // layout=neato;
+       node[shape=plain];
+       edge[arrowhead=crow];
+
+       redeemed_ticket [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">REDEEMED_TICKET</td></tr>
+               <tr><td port="pk_ticket_id">ticket_id</td></tr>
+               <tr><td port="">created</td></tr>
+               <tr><td port="">subject</td></tr>
+               <tr><td port="">resource</td></tr>
+               <tr><td port="">iss</td></tr>
+               <tr><td port="">token</td></tr>
+               <tr><td port="">ticket</td></tr>
+               <tr><td port="">published</td></tr>
+       </table>
+       >]
+
+       token [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">TOKEN</td></tr>
+               <tr><td port="pk_code_id">code_id</td></tr>
+               <tr><td port="fk_profile_id">profile_id</td></tr>
+               <tr><td port="">created</td></tr>
+               <tr><td port="">expires</td></tr>
+               <tr><td port="">refresh_expires</td></tr>
+               <tr><td port="">refreshed</td></tr>
+               <tr><td port="">duration</td></tr>
+               <tr><td port="">refresh_duration</td></tr>
+               <tr><td port="">refresh_count</td></tr>
+               <tr><td port="">is_revoked</td></tr>
+               <tr><td port="">is_token</td></tr>
+               <tr><td port="">client_id</td></tr>
+               <tr><td port="">resource</td></tr>
+               <tr><td port="">profile_data</td></tr>
+       </table>
+       >];
+       profile:pk_profile_id -> token:fk_profile_id;
+
+       scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">SCOPE</td></tr>
+               <tr><td port="pk_scope_id">scope_id</td></tr>
+               <tr><td port="">scope</td></tr>
+               <tr><td port="">description</td></tr>
+               <tr><td port="">application</td></tr>
+               <tr><td port="">is_permanent</td></tr>
+               <tr><td port="">is_manually_added</td></tr>
+       </table>
+       >];
+
+       token_scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">TOKEN_SCOPE</td></tr>
+               <tr><td port="fk_code_id">code_id</td></tr>
+               <tr><td port="fk_scope_id">scope_id</td></tr>
+       </table>
+       >];
+       token:pk_code_id -> token_scope:fk_code_id;
+       scope:pk_scope_id -> token_scope:fk_scope_id;
+
+       profile [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">PROFILE</td></tr>
+               <tr><td port="pk_profile_id">profile_id</td></tr>
+               <tr><td port="fk_identifier_id">identifier_id</td></tr>
+               <tr><td port="">profile</td></tr>
+       </table>
+       >];
+       authentication:pk_identifier_id -> profile:fk_identifier_id;
+
+       profile_scope [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">PROFILE_SCOPE</td></tr>
+               <tr><td port="fk_profile_id">profile_id</td></tr>
+               <tr><td port="fk_scope_id">scope_id</td></tr>
+       </table>
+       >];
+       profile:pk_profile_id -> profile_scope:fk_profile_id;
+       scope:pk_scope_id -> profile_scope:fk_scope_id;
+
+       authentication [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+                       <tr><td border="2" bgcolor="lightblue">AUTHENTICATION</td></tr>
+                       <tr><td port="pk_identifier_id">identifier_id</td></tr>
+                       <tr><td port="">created</td></tr>
+                       <tr><td port="">last_authenticated</td></tr>
+                       <tr><td port="">identifier</td></tr>
+                       <tr><td port="">credential</td></tr>
+       </table>
+       >];
+
+       resource [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">RESOURCE</td></tr>
+               <tr><td port="pk_resource_id">resource_id</td></tr>
+               <tr><td port="">description</td></tr>
+               <tr><td port="">created</td></tr>
+               <tr><td port="">secret</td></tr>
+       </table>
+       >];
+
+       almanac [label=<
+       <table cellspacing="0" cellborder="1" border="0">
+               <tr><td border="2" bgcolor="lightblue">ALMANAC</td></tr>
+               <tr><td port="pk_event">event</td></tr>
+               <tr><td port="">epoch</td></tr>
+       </table>
+       >];
+
+}
diff --git a/src/db/sqlite/sql/schema/1.1.0/revert.sql b/src/db/sqlite/sql/schema/1.1.0/revert.sql
new file mode 100644 (file)
index 0000000..8d70e0c
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN;
+       DROP TABLE redeemed_ticket CASCADE;
+
+       DELETE FROM _meta_schema_version WHERE major = 1 AND minor = 1 AND patch = 0;
+COMMIT;
diff --git a/src/db/sqlite/sql/ticket-redeemed.sql b/src/db/sqlite/sql/ticket-redeemed.sql
new file mode 100644 (file)
index 0000000..6e00cbe
--- /dev/null
@@ -0,0 +1,6 @@
+--
+INSERT INTO redeemed_ticket
+       (subject, resource, iss, ticket, token)
+VALUES
+       (:subject, :resource, :iss, :ticket, :token)
+
diff --git a/src/db/sqlite/sql/ticket-token-get-unpublished.sql b/src/db/sqlite/sql/ticket-token-get-unpublished.sql
new file mode 100644 (file)
index 0000000..425fc3e
--- /dev/null
@@ -0,0 +1,4 @@
+--
+SELECT * FROM redeemed_ticket
+WHERE
+       published IS NULL
diff --git a/src/db/sqlite/sql/ticket-token-published.sql b/src/db/sqlite/sql/ticket-token-published.sql
new file mode 100644 (file)
index 0000000..077c24f
--- /dev/null
@@ -0,0 +1,11 @@
+--
+UPDATE redeemed_ticket SET
+       published = (strftime('%s', 'now'))
+WHERE
+       subject = :subject
+AND
+       resource = :resource
+AND
+       iss = :iss
+AND
+       ticket = :ticket
index 817994101388d15b75ae8e1e3eeb800317f56cef..8e8a4f17f2c40e65c843c51d37e34043b93b5b29 100644 (file)
@@ -21,6 +21,13 @@ common.mergeEnum(Enum, {
   Chore: {
     CleanTokens: 'cleanTokens',
     CleanScopes: 'cleanScopes',
+    PublishTickets: 'publishTickets',
+  },
+
+  AlmanacEntry: {
+    TokenCleanup: 'tokenCleanup',
+    ScopeCleanup: 'scopeCleanup',
+    TicketPublished: 'ticketPublished',
   },
 });
 
index 2b64ed16b14f50558b614b2b33afc2ca4f2145e9..c79c7529670b8b83dba45f55ccb824716bd427c6 100644 (file)
@@ -8,7 +8,7 @@ const Template = require('./template');
 const { MysteryBox } = require('@squeep/mystery-box');
 const DBErrors = require('./db/errors');
 const Chores = require('./chores');
-const { Publisher: QueuePublisher } = require('@squeep/amqp-helper');
+const { Publisher: QueuePublisher, Consumer: QueueConsumer } = require('@squeep/amqp-helper');
 
 const _fileScope = common.fileScope(__filename);
 
@@ -23,11 +23,12 @@ class Manager {
     this.options = options;
     this.logger = logger;
     this.db = db;
-    this.chores = new Chores(logger, db, options);
     this.communication = new Communication(logger, options);
     if (options.queues.amqp.url) {
       this.queuePublisher = new QueuePublisher(logger, options.queues.amqp);
+      this.queueConsumer = new QueueConsumer(logger, options.queues.amqp);
     }
+    this.chores = new Chores(logger, db, this.queuePublisher, options);
     this.mysteryBox = new MysteryBox(options);
     this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
 
@@ -51,6 +52,12 @@ class Manager {
   async _connectQueues() {
     await this.queuePublisher.connect();
     await this.queuePublisher.establishAMQPPlumbing(this.options.queues.ticketPublishName);
+    await this.queuePublisher.establishAMQPPlumbing(this.options.queues.ticketRedeemedName);
+
+    await this.queueConsumer.connect();
+    await this.queueConsumer.establishAMQPPlumbing(this.options.queues.ticketPublishName);
+    const boundTicketProcessor = this.queuedTicketProcessor.bind(this);
+    await this.queueConsumer.consume(this.options.queues.ticketPublishName, boundTicketProcessor);
   }
 
 
@@ -578,7 +585,7 @@ class Manager {
   /**
    * Get up-to-date profile data from selected profile endpoint.
    * @param {Object} ctx
-   * @returns {Object}
+   * @returns {Promise<Object>}
    */
   async _fetchConsentProfileData(ctx) {
     const _scope = _fileScope('_fetchConsentProfileData');
@@ -1188,7 +1195,7 @@ class Manager {
    * @param {String} payload.identifier user generating ticket
    * @param {} payload.profile profile of user generating ticket
    * @param {Number} payload.ticketLifespanSeconds ticket redeemable for this long
-   * @returns {String}
+   * @returns {Promise<String>}
   */
   async _mintTicket({ subject, resource, scopes, identifier, profile, ticketLifespanSeconds }) {
     const _scope = _fileScope('_mintTicket');
@@ -1222,7 +1229,7 @@ class Manager {
   /**
    * 
    * @param {String} ticket
-   * @returns {Ticket}
+   * @returns {Promise<Ticket>}
    */
   async _unpackTicket(ticket) {
     const ticketObj = await this.mysteryBox.unpack(ticket);
@@ -1309,7 +1316,7 @@ class Manager {
     this.logger.debug(_scope, 'called', { ctx });
 
     await this._restoreSessionFromCode(ctx);
-    await this._checkSessionMatchingRedirectUri(ctx);
+    this._checkSessionMatchingRedirectUri(ctx);
 
     if (ctx.session.error) {
       throw new ResponseError(Enum.ErrorResponse.BadRequest);
@@ -1510,8 +1517,16 @@ class Manager {
     }
 
     const queueName = this.options.queues.ticketPublishName;
-    const { ticket, resource, subject } = ctx.parsedBody;
+    const { ticket, resource, subject, iss } = ctx.parsedBody;
     
+    if (iss) {
+      try {
+        new URL(iss);
+      } catch (e) {
+        this.logger.debug(_scope, 'unparsable issuer', { ticket, resource, subject, iss, ctx });
+        // continue, will try resource for metadata
+      }
+    }
     try {
       new URL(resource);
     } catch (e) {
@@ -1527,7 +1542,7 @@ class Manager {
       }
 
       try {
-        const result = await this.queuePublisher.publish(queueName, { ticket, resource, subject, epochMs: Date.now() });
+        const result = await this.queuePublisher.publish(queueName, { ticket, resource, subject, iss, epochMs: Date.now() });
         this.logger.debug(_scope, 'accepted ticket offer', { queueName, ticket, resource, subject, ctx, result });
       } catch (e) {
         this.logger.error(_scope, 'failed to publish ticket to queue', { error: e, queueName, ticket, resource, subject, ctx });
@@ -1541,6 +1556,104 @@ class Manager {
   }
 
 
+  /**
+   * Process messages from proffered ticket queue.
+   * Attempt to redeem ticket and publish to redeemed token queue.
+   * @param {AMQPChannel} channel
+   * @param {Buffer} message
+   */
+  async queuedTicketProcessor(channel, message) {
+    const _scope = _fileScope('queuedTicketProcessor');
+
+    const queueName = this.options.queues.ticketRedeemedName;
+    let payload, ticket, resource, subject, iss;
+    try {
+      payload = message.content.toString();
+
+      this.logger.debug(_scope, 'processing ticket', { payload });
+      ({
+        ticket,
+        resource,
+        subject,
+        iss,
+      } = JSON.parse(payload));
+    } catch (e) {
+      this.logger.error(_scope, 'could not parse message, discarding', { error: e, message });
+      channel.ack(message);
+      return;
+    }
+
+    let issuerUrlObj;
+    try {
+      if (iss) {
+        issuerUrlObj = new URL(iss);
+      }
+    } catch (e) {
+      this.logger.debug(_scope, 'unparsable issuer, falling back to resource discovery', { error: e, payload });
+    }
+
+    let resourceUrlObj;
+    try {
+      resourceUrlObj = new URL(resource);
+    } catch (e) {
+      this.logger.error(_scope, 'unparsable resource, discarding', { payload });
+      channel.ack(message);
+      return;
+    }
+
+    let isNotRetryable = false;
+    try {
+      await this.db.context(async (dbCtx) => {
+
+        let token;
+        try {
+          token = await this.communication.redeemTicket(ticket, resourceUrlObj, issuerUrlObj);
+          isNotRetryable = true; // assume we cannot redeem a ticket more than once
+          this.logger.debug(_scope, 'successfully redeemed ticket', { token, payload });
+          channel.ack(message);
+        } catch (e) {
+          this.logger.error(_scope, 'failed to redeem ticket', { error: e, payload });
+          throw e;
+        }
+
+        // persist our redemption
+        const redeemedData = {
+          subject,
+          resource,
+          iss,
+          ticket,
+          token,
+        };
+        await this.db.ticketRedeemed(dbCtx, redeemedData);
+
+        try {
+          const result = await this.queuePublisher.publish(queueName, redeemedData);
+          this.logger.info(_scope, 'published ticket token', { queueName, ticket, resource, subject, iss, result });
+        } catch (e) {
+          this.logger.error(_scope, 'failed to publish token to queue', { error: e, queueName, ticket, token, resource, subject });
+          throw e; // return a 500
+        }
+
+        await this.db.ticketTokenPublished(dbCtx, redeemedData);
+
+      }); // dbCtx
+
+    } catch (e) {
+      isNotRetryable = isNotRetryable
+        || (e instanceof CommunicationErrors.ValidationError)
+        || (e?.response?.statusCode < 500)
+      ;
+      if (isNotRetryable) {
+        this.logger.error(_scope, 'failed to process ticket, not requeuing', { error: e, payload });
+        channel.ack(message);
+        return;
+      }
+      this.logger.error(_scope, 'failed to process ticket, requeuing', { error: e, payload });
+      throw e;
+    }
+  }
+
+
   /**
    * Validate a token and return data about it.
    * @param {http.ServerResponse} res
@@ -1933,9 +2046,10 @@ class Manager {
             this.logger.debug(_scope, 'ticket created', { ctx, ticketData, subjectData });
 
             try {
-              const result = await this.communication.deliverTicket(ctx.ticketEndpointUrl, ctx.ticketResourceUrl, ctx.ticketSubjectUrl, ticket);
+              const issuerUrl = new URL(this.options.dingus.selfBaseUrl);
+              const result = await this.communication.deliverTicket(ctx.ticketEndpointUrl, ctx.ticketResourceUrl, ctx.ticketSubjectUrl, ticket, issuerUrl);
               ctx.notifications.push(`Success! Ticket was delivered. (${result?.statusMessage})`);
-              this.logger.info(_scope, 'ticket delivered', { ctx, result });
+              this.logger.info(_scope, 'ticket delivered', { ctx });
             } catch (e) {
               this.logger.error(_scope, 'failed to deliver ticket', { ctx, error: e });
               ctx.errors.push(`Failed to deliver ticket. (${e})`);
@@ -2017,4 +2131,4 @@ class Manager {
 
 }
 
-module.exports = Manager;
\ No newline at end of file
+module.exports = Manager;
index 2a159a9181c7b4d327282035c014a4e87a194e55..c1e600691642130c40557cffec8721e43f7a2cef 100644 (file)
@@ -101,12 +101,19 @@ class Service extends Dingus {
    * @param {Object} ctx
    */
   async preHandler(req, res, ctx) {
+    const _scope = _fileScope('preHandler');
+
     await super.preHandler(req, res, ctx);
     ctx.url = req.url; // Persist this for logout redirect
 
     const logObject = this.asyncLocalStorage.getStore();
-    logObject.requestId = ctx.requestId;
-    delete ctx.requestId;
+    // istanbul ignore else
+    if (logObject) { // debugging in vscode seems to kill ALS, work around
+      logObject.requestId = ctx.requestId;
+      delete ctx.requestId;
+    } else {
+      this.logger.debug(_scope, 'no async local store');
+    }
   }
 
 
index 085b95a0ed083eaf4fafa4c131a0496664d37f93..a6a83a0629f3219fc4b7f2ea59965966494f3e33 100644 (file)
@@ -102,7 +102,7 @@ function tokenTable(tokens) {
 \t<thead>
 \t\t<tr>
 <th>Type</th>
-\t\t\t<th>Client Identifier</th>
+\t\t\t<th>Client Identifier / Ticket Subject</th>
 \t\t\t<th>Profile</th>
 <th>Scopes</th>
 \t\t\t<th>Code</th>
index 7baaa417b02938a08b000ff947b75fa24d414f23..1c3f4ebec70a547e9151cd08a1d93e9fbda48f2e 100644 (file)
@@ -54,6 +54,9 @@ module.exports = (ctx, options) => {
         href: 'admin/ticket',
       },
     ],
+    headElements: [
+      `<link rel="indieauth-metadata" href="${options.dingus.selfBaseUrl}${options.route.metadata}">`,
+    ],
   };
   const content = [
     aboutSection(),
index 51c015ceabae23661489713942ace123b19944f7..02729908033803e449d340c1a928cf4656a5a0f9 100644 (file)
@@ -13,12 +13,15 @@ const snooze = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
 const expectedException = new Error('oh no');
 
 describe('Chores', function () {
-  let chores, stubLogger, stubDb, options;
+  let chores, stubLogger, stubDb, stubQueuePublisher, options;
   beforeEach(function () {
     stubLogger = new StubLogger();
     stubLogger._reset();
     stubDb = new StubDB();
     stubDb._reset();
+    stubQueuePublisher = {
+      publish: sinon.stub(),
+    };
   });
   afterEach(function () {
     chores?.stopAllChores();
@@ -26,12 +29,13 @@ describe('Chores', function () {
   });
 
   describe('constructor', function () {
-
+    this.slow(200);
     it('empty options, no cleaning', async function () {
       options = undefined;
-      chores = new Chores(stubLogger, stubDb, options);
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, options);
       assert.strictEqual(chores.chores.cleanTokens.timeoutObj, undefined);
       assert.strictEqual(chores.chores.cleanScopes.timeoutObj, undefined);
+      assert.strictEqual(chores.chores.publishTickets.timeoutObj, undefined);
     });
 
     it('cleans scopes', async function () {
@@ -40,7 +44,7 @@ describe('Chores', function () {
           scopeCleanupMs: 1,
         },
       };
-      chores = new Chores(stubLogger, stubDb, options);
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, options);
       await snooze(50);
       assert(chores.chores.cleanScopes.timeoutObj);
       assert(chores.db.scopeCleanup.called);
@@ -55,12 +59,27 @@ describe('Chores', function () {
           codeValidityTimeoutMs: 10,
         },
       };
-      chores = new Chores(stubLogger, stubDb, options);
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, options);
       await snooze(50);
       assert(chores.chores.cleanTokens.timeoutObj);
       assert(chores.db.tokenCleanup.called);
     });
 
+    it('publishes tickets', async function () {
+      options = {
+        chores: {
+          publishTicketsMs: 1,
+        },
+        queues: {
+          ticketRedeemedName: 'queue',
+        },
+      };
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, options);
+      await snooze(50);
+      assert(chores.chores.publishTickets.timeoutObj);
+      assert(chores.db.ticketTokenGetUnpublished.called);
+    });
+
   }); // constructor
 
   describe('cleanTokens', function () {
@@ -75,7 +94,7 @@ describe('Chores', function () {
         },
       };
       stubDb.tokenCleanup.resolves(cleaned);
-      chores = new Chores(stubLogger, stubDb, options);
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, options);
       clearTimeout(chores.cleanTokensTimeout);
       await chores.cleanTokens();
       assert(stubLogger.info.called);
@@ -90,12 +109,12 @@ describe('Chores', function () {
         },
       };
       stubDb.tokenCleanup.rejects(expectedException);
-      chores = new Chores(stubLogger, stubDb, options);
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, options);
       await assert.rejects(() => chores.cleanTokens(), expectedException);
     });
     it('covers default', async function () {
       stubDb.tokenCleanup.resolves(0);
-      chores = new Chores(stubLogger, stubDb, {
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, {
         manager: {
           codeValidityTimeoutMs: 10,
         },
@@ -114,7 +133,7 @@ describe('Chores', function () {
         },
       };
       stubDb.scopeCleanup.resolves(cleaned);
-      chores = new Chores(stubLogger, stubDb, options);
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, options);
       clearTimeout(chores.cleanScopesTimeout);
       await chores.cleanScopes();
       assert(stubLogger.info.called);
@@ -126,15 +145,43 @@ describe('Chores', function () {
         },
       };
       stubDb.scopeCleanup.rejects(expectedException);
-      chores = new Chores(stubLogger, stubDb, options);
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, options);
       await assert.rejects(() => chores.cleanScopes(), expectedException);
     });
     it('covers default', async function () {
       stubDb.scopeCleanup.resolves(0);
-      chores = new Chores(stubLogger, stubDb, {});
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, {});
       await chores.cleanScopes();
       assert(stubDb.scopeCleanup.called);
     });
   }); // cleanScopes
 
-}); // Chores
\ No newline at end of file
+  describe('publishTickets', function () {
+    beforeEach(function () {
+      options = {
+        queues: {
+          ticketRedeemedName: 'queue',
+        },
+      };
+      stubDb.ticketTokenGetUnpublished.resolves([{
+        ticket: 'xxxTICKETxxx',
+        resource: 'https://resource.example.com/',
+        subject: 'https://subject.example.com/',
+        iss: null,
+      }]);
+      chores = new Chores(stubLogger, stubDb, stubQueuePublisher, options);
+    });
+    it('publishes a ticket', async function () {
+      await chores.publishTickets();
+      assert(stubQueuePublisher.publish.called);
+      assert(stubDb.ticketTokenPublished.called);
+    });
+    it('covers error', async function () {
+      stubQueuePublisher.publish.rejects(expectedException);
+      await chores.publishTickets();
+      assert(stubQueuePublisher.publish.called);
+      assert(stubDb.ticketTokenPublished.notCalled);
+    });
+  }); // publishTickets
+
+}); // Chores
index 7634b30c748a05726332457baf098e9cc7749bb6..d866af6678cf7bafc79b9bc1f8f6a912ca334ad4 100644 (file)
@@ -405,6 +405,65 @@ describe('Database Integration', function () {
         });
       }); // Token
 
+      describe('Ticket Token Tracking', function () {
+        let redeemedData;
+        beforeEach(function () {
+          redeemedData = {
+            subject: 'https://entity.example.com/',
+            resource: 'https://blog.example.com/secret_entry',
+            iss: 'https://idp.example.com/',
+            ticket: 'xxxTICKETxxx',
+            token: 'xxxTOKENxxx',
+          };
+        });
+        step('stores redeemed ticket data', async function () {
+          await db.context(async (dbCtx) => {
+            await db.ticketRedeemed(dbCtx, redeemedData);
+          });
+        });
+        step('gets one pending-publish ticket tokens', async function () {
+          await db.context(async (dbCtx) => {
+            const unpublished = await db.ticketTokenGetUnpublished(dbCtx);
+            assert.strictEqual(unpublished.length, 1);
+            const record = unpublished[0];
+            assert(record.created);
+            assert(!record.published);
+            assert(record.ticketId);
+            delete record.created;
+            delete record.published;
+            delete record.ticketId;
+            assert.deepStrictEqual(record, redeemedData);
+          });
+        });
+        step('stores published ticket token data', async function () {
+          await db.context(async (dbCtx) => {
+            await db.ticketTokenPublished(dbCtx, redeemedData);
+          });
+        });
+        step('gets no pending-publish ticket tokens', async function () {
+          await db.context(async (dbCtx) => {
+            const unpublished = await db.ticketTokenGetUnpublished(dbCtx);
+            assert.strictEqual(unpublished.length, 0);
+          });
+        });
+      }); // Ticket Token Tracking
+
+      describe('Bookkeeping', function () {
+        let event, date;
+        beforeEach(function () {
+          event = 'integrationTestEvent';
+          date = new Date('Fri Dec 22 03:27 UTC 2023');
+        });
+        step('inserts event', async function () {
+          await db.context(async (dbCtx) => {
+            await db.almanacUpsert(dbCtx, event, date);
+            const result = await db.almanacGetAll(dbCtx);
+            const [storedEvent] = result.filter((e) => e.event === event);
+            assert.deepStrictEqual(storedEvent.date, date);
+          });
+        });
+      }); // Bookkeeping
+
       describe('Refreshable Token', function () {
         let created, codeId, scopes, clientId, profileData, lifespanSeconds, refreshLifespanSeconds, removeScopes;
         beforeEach(function () {
index 7311210f9d7c4e47511340b3f965d63f9dbd7153..3920d340f53e2471b4fe5df55a32d25546996153 100644 (file)
@@ -314,6 +314,42 @@ describe('DatabasePostgres', function () {
     });
   }); // almanacGetAll
 
+  describe('almanacUpsert', function () {
+    let event, date;
+    beforeEach(function () {
+      event = 'test_event';
+      date = new Date('Fri Dec 22 03:27 UTC 2023')
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.almanacUpsert(dbCtx, event, date);
+    });
+    it('success with default date', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.almanacUpsert(dbCtx, event);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await assert.rejects(() => db.almanacUpsert(dbCtx, event, date), DBErrors.UnexpectedResult);
+    });
+  }); // almanacUpsert
+
+
   describe('authenticationSuccess', function () {
     let identifier;
     beforeEach(function () {
@@ -949,5 +985,101 @@ describe('DatabasePostgres', function () {
     });
   }); // tokensGetByIdentifier
 
+  describe('ticketRedeemed', function () {
+    let redeemedData;
+    beforeEach(function () {
+      redeemedData = {
+        resource: 'https://resource.example.com/',
+        subject: 'https://subject.example.com/',
+        iss: 'https://idp.example.com/',
+        ticket: 'xxxTICKETxxx',
+        token: 'xxxTOKENxxx',
+      };
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await db.ticketRedeemed(dbCtx, redeemedData);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      sinon.stub(db.db, 'result').resolves(dbResult);
+      await assert.rejects(() => db.ticketRedeemed(dbCtx, redeemedData), DBErrors.UnexpectedResult);
+    });
+  }); // ticketRedeemed
+
+  describe('ticketTokenPublished', function () {
+    let redeemedData;
+    beforeEach(function () {
+      redeemedData = {
+        resource: 'https://resource.example.com/',
+        subject: 'https://subject.example.com/',
+        iss: 'https://idp.example.com/',
+        ticket: 'xxxTICKETxxx',
+        token: 'xxxTOKENxxx',
+      };
+      sinon.stub(db.db, 'result');
+    });
+    it('success', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      db.db.result.resolves(dbResult);
+      await db.ticketTokenPublished(dbCtx, redeemedData);
+    });
+    it('failure', async function () {
+      const dbResult = {
+        rowCount: 0,
+        rows: undefined,
+        duration: 22,
+      };
+      db.db.result.resolves(dbResult);
+      await assert.rejects(() => db.ticketTokenPublished(dbCtx, redeemedData), DBErrors.UnexpectedResult);
+    });
+    it('failure of almanac', async function () {
+      const dbResult = {
+        rowCount: 1,
+        rows: undefined,
+        duration: 22,
+      };
+      const dbResultAlmanac = {
+        ...dbResult,
+        rowCount: 0,
+      };
+      db.db.result.resolves(dbResult).onCall(1).resolves(dbResultAlmanac);
+      await assert.rejects(() => db.ticketTokenPublished(dbCtx, redeemedData), DBErrors.UnexpectedResult);
+    });
+  }); // ticketTokenPublished
+
+  describe('ticketTokenGetUnpublished', function () {
+    it('success', async function () {
+      const expected = [{
+        resource: 'https://resource.example.com/',
+        subject: 'https://subject.example.com/',
+        iss: 'https://idp.example.com/',
+        ticket: 'xxxTICKETxxx',
+        token: 'xxxTOKENxxx',
+        created: new Date(),
+        published: null,
+      }];
+      sinon.stub(db.db, 'manyOrNone').resolves(expected);
+      const result = await db.ticketTokenGetUnpublished(dbCtx);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('failure', async function () {
+      sinon.stub(db.db, 'manyOrNone').rejects(expectedException);
+      await assert.rejects(() => db.ticketTokenGetUnpublished(dbCtx), expectedException);
+    });
+  }); // ticketTokenGetUnpublished
 
 }); // DatabasePostgres
index f6935943eb1f088174185c15aac0ea5f9e373441..e79e75fa421db1526db49f523de99bd9d18428ce 100644 (file)
@@ -216,6 +216,32 @@ describe('DatabaseSQLite', function () {
     });
   }); // almanacGetAll
 
+  describe('almanacUpsert', function () {
+    let event, date, dbResult;
+    beforeEach(function () {
+      event = 'test_event';
+      date = new Date('Fri Dec 22 03:27 UTC 2023')
+      sinon.stub(db.statement.almanacUpsert, 'run');
+      dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+    });
+    it('success', function () {
+      db.statement.almanacUpsert.run.returns(dbResult);
+      db.almanacUpsert(dbCtx, event, date);
+    });
+    it('success with default date', function () {
+      db.statement.almanacUpsert.run.returns(dbResult);
+      db.almanacUpsert(dbCtx, event);
+    });
+    it('failure', function () {
+      dbResult.changes = 0;
+      db.statement.almanacUpsert.run.returns(dbResult);
+      assert.throws(() => db.almanacUpsert(dbCtx, { event, date }), DBErrors.UnexpectedResult);
+    });
+  }); // almanacUpsert
+
   describe('authenticationGet', function () {
     let identifier, credential;
     beforeEach(function () {
@@ -915,4 +941,120 @@ describe('DatabaseSQLite', function () {
     });
   }); // tokensGetByIdentifier
 
+  describe('ticketRedeemed', function () {
+    let redeemedData, dbResult;
+    beforeEach(function () {
+      redeemedData = {
+        resource: 'https://resource.example.com/',
+        subject: 'https://subject.example.com/',
+        iss: 'https://idp.example.com/',
+        ticket: 'xxxTICKETxxx',
+        token: 'xxxTOKENxxx',
+      };
+      sinon.stub(db.statement.ticketRedeemed, 'run');
+      dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+    });
+    it('success', function () {
+      db.statement.ticketRedeemed.run.returns(dbResult);
+      db.ticketRedeemed(dbCtx, redeemedData);
+    });
+    it('failure', function () {
+      dbResult.changes = 0;
+      db.statement.ticketRedeemed.run.returns(dbResult);
+      assert.throws(() => db.ticketRedeemed(dbCtx, redeemedData), DBErrors.UnexpectedResult);
+    });
+  }); // ticketRedeemed
+
+  describe('ticketTokenPublished', function () {
+    let redeemedData, dbResult;
+    beforeEach(function () {
+      redeemedData = {
+        resource: 'https://resource.example.com/',
+        subject: 'https://subject.example.com/',
+        iss: 'https://idp.example.com/',
+        ticket: 'xxxTICKETxxx',
+        token: 'xxxTOKENxxx',
+      };
+      sinon.stub(db.statement.ticketTokenPublished, 'run');
+      sinon.stub(db.statement.almanacUpsert, 'run');
+      dbResult = {
+        changes: 1,
+        lastInsertRowid: undefined,
+      };
+    });
+    it('success', function () {
+      db.statement.ticketTokenPublished.run.returns(dbResult);
+      db.statement.almanacUpsert.run.returns(dbResult);
+      db.ticketTokenPublished(dbCtx, redeemedData);
+      assert(db.statement.ticketTokenPublished.run.called);
+      assert(db.statement.almanacUpsert.run.called);
+    });
+    it('failure', function () {
+      dbResult.changes = 0;
+      db.statement.ticketTokenPublished.run.returns(dbResult);
+      assert.throws(() => db.ticketTokenPublished(dbCtx, redeemedData), DBErrors.UnexpectedResult);
+    });
+    it('failure of almanac', function () {
+      const dbResultAlmanac = {
+        ...dbResult,
+        changes: 0,
+      }
+      db.statement.ticketTokenPublished.run.returns(dbResult);
+      db.statement.almanacUpsert.run.returns(dbResultAlmanac);
+      assert.throws(() => db.ticketTokenPublished(dbCtx, redeemedData), DBErrors.UnexpectedResult);
+    });
+  }); // ticketTokenPublished
+
+  describe('ticketTokenGetUnpublished', function () {
+    beforeEach(function () {
+      sinon.stub(db.statement.ticketTokenGetUnpublished, 'all');
+    });
+    it('success', function () {
+      db.statement.ticketTokenGetUnpublished.all.returns([]);
+      const result = db.ticketTokenGetUnpublished();
+      assert.deepStrictEqual(result, []);
+    });
+    it('failure', function () {
+      db.statement.ticketTokenGetUnpublished.all.throws(expectedException);
+      assert.throws(() => db.ticketTokenGetUnpublished(), expectedException);
+    });
+  }); // ticketTokenGetUnpublished
+
+  describe('_redeemedTicketToNative', function () {
+    let redeemedData;
+    beforeEach(function () {
+      redeemedData = {
+        resource: 'https://resource.example.com/',
+        subject: 'https://subject.example.com/',
+        iss: 'https://idp.example.com/',
+        ticket: 'xxxTICKETxxx',
+        token: 'xxxTOKENxxx',
+        created: 1701970607n,
+        published: 1701970670n,
+      };
+    });
+    it('covers', function () {
+      const expected = {
+        ...redeemedData,
+        created: new Date('2023-12-07T17:36:47.000Z'),
+        published: new Date('2023-12-07T17:37:50.000Z'),
+      };
+      const result = DB._redeemedTicketToNative(redeemedData);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers no published', function () {
+      redeemedData.published = null;
+      const expected = {
+        ...redeemedData,
+        created: new Date('2023-12-07T17:36:47.000Z'),
+        published: null,
+      };
+      const result = DB._redeemedTicketToNative(redeemedData);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // _redeemedTicketToNative
+
 }); // DatabaseSQLite
index f3c38b4586c115088912039ab4be3efb151f9592..5acb5bbb17b2ebd5b079b9c7216f207d570b4db7 100644 (file)
@@ -49,10 +49,14 @@ describe('Manager', function () {
     sinon.stub(manager.communication, 'fetchProfile');
     sinon.stub(manager.communication, 'fetchClientIdentifier');
     sinon.stub(manager.communication, 'deliverTicket');
+    sinon.stub(manager.communication, 'redeemTicket');
     sinon.stub(dns.promises, 'lookup').resolves([{ family: 4, address: '10.11.12.13' }]);
     sinon.stub(manager.queuePublisher, 'connect');
     sinon.stub(manager.queuePublisher, 'establishAMQPPlumbing');
     sinon.stub(manager.queuePublisher, 'publish');
+    sinon.stub(manager.queueConsumer, 'connect');
+    sinon.stub(manager.queueConsumer, 'establishAMQPPlumbing');
+    sinon.stub(manager.queueConsumer, 'consume');
   });
 
   afterEach(function () {
@@ -425,7 +429,8 @@ describe('Manager', function () {
       assert.strictEqual(res.statusCode, 302);
       assert.strictEqual(ctx.session.error, 'invalid_request');
       assert.strictEqual(ctx.session.errorDescriptions.length, 1);
-    });  }); // getAuthorization
+    });
+  }); // getAuthorization
 
   describe('_setError', function () {
     it('covers', function () {
@@ -2276,6 +2281,7 @@ describe('Manager', function () {
         ticket: 'ticket123',
         resource: 'https://blog.example.com/',
         subject: 'https://otheruser.example.com/',
+        iss: 'https://ia.example.com/',
       };
     });
     it('accepts a ticket for a known profile', async function () {
@@ -2300,12 +2306,81 @@ describe('Manager', function () {
     it('covers no ticket queue', async function () {
       delete options.queues.amqp.url;
       manager = new Manager(logger, stubDb, options);
-
       await assert.rejects(() => manager.postTicket(req, res, ctx), ResponseError);
     });
+    it('covers no issuer', async function () {
+      delete ctx.parsedBody.iss;
+      manager.db.profileIsValid.resolves(true);
+      await manager.postTicket(req, res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 202);
+    });
+    it('covers bad issuer', async function () {
+      ctx.parsedBody.iss = 'not a url';
+      manager.db.profileIsValid.resolves(true);
+      await manager.postTicket(req, res, ctx);
+      assert(res.end.called);
+      assert.strictEqual(res.statusCode, 202);
+    });
 
   }); // postTicket
 
+  describe('queuedTicketProcessor', function () {
+    let channel, content;
+    const message = () => ({
+      content: Buffer.from(JSON.stringify(content)),
+    });
+    beforeEach(function () {
+      channel = {
+        ack: sinon.stub(),
+      };
+      content = {
+        ticket: 'XXXticketXXX',
+        resource: 'https://blog.example.com/',
+        subject: 'https://otheruser.exmaple.com/',
+        iss: 'https://ia.example.com/',
+        epochMs: Date.now(),
+      };
+    });
+    it('redeems a ticket', async function () {
+      await manager.queuedTicketProcessor(channel, message());
+      assert(manager.queuePublisher.publish.called);
+      assert(channel.ack.called);
+    });
+    it('redeems a ticket, missing issuer', async function () {
+      delete content.iss;
+      await manager.queuedTicketProcessor(channel, message());
+      assert(manager.queuePublisher.publish.called);
+      assert(channel.ack.called);
+    });
+    it('covers bad message', async function () {
+      await manager.queuedTicketProcessor(channel, { content: 'diddly' });
+      assert(channel.ack.called);
+    });
+    it('covers bad issuer', async function () {
+      content.iss = 'not a url';
+      await manager.queuedTicketProcessor(channel, message());
+      assert(manager.queuePublisher.publish.called);
+    });
+    it('covers bad resource', async function () {
+      content.resource = 'not a url';
+      await manager.queuedTicketProcessor(channel, message());
+      assert(manager.communication.redeemTicket.notCalled);
+      assert(manager.queuePublisher.publish.notCalled);
+      assert(channel.ack.called);
+    });
+    it('covers failed redemption', async function () {
+      const expectedException = new Error('oh no');
+      manager.communication.redeemTicket.rejects(expectedException);
+      assert.rejects(() => manager.queuedTicketProcessor(channel, message()), expectedException);
+    });
+    it('covers failed publish', async function () {
+      const expectedException = new Error('oh no');
+      manager.queuePublisher.publish.rejects(expectedException);
+      assert.rejects(() => manager.queuedTicketProcessor(channel, message()), expectedException);
+    });
+  }); // queuedTicketProcessor
+
   describe('getAdminMaintenance', function () {
     it('covers information', async function () {
       await manager.getAdminMaintenance(res, ctx);
@@ -2320,4 +2395,4 @@ describe('Manager', function () {
     });
   }); // getAdminMaintenance
 
-}); // Manager
\ No newline at end of file
+}); // Manager
index f973b8d417d0184ee3fdb61b9cf5230d2dec358c..d8cb0cd1f00bbc7babee4491727a4a7569e3f787 100644 (file)
@@ -8,6 +8,7 @@ class StubDatabase extends Base {
     return [
       ...super._stubFns,
       'almanacGetAll',
+      'almanacUpsert',
       'authenticationGet',
       'authenticationSuccess',
       'authenticationUpsert',
@@ -27,9 +28,12 @@ class StubDatabase extends Base {
       'tokenGetByCodeId',
       'tokenRefreshRevokeByCodeId',
       'tokenRevokeByCodeId',
-      'tokensGetByIdentifier',    
+      'tokensGetByIdentifier',
+      'ticketRedeemed',
+      'ticketTokenPublished',
+      'ticketTokenGetUnpublished',
     ];
   }
 }
 
-module.exports = StubDatabase;
\ No newline at end of file
+module.exports = StubDatabase;