update dependencies and devDependencies, address lint issues
[squeep-indie-auther] / src / manager.js
index c79c7529670b8b83dba45f55ccb824716bd427c6..ce544b949a90603ef09b8d7448410aa264fcb9e0 100644 (file)
@@ -1,3 +1,4 @@
+/* eslint-disable sonarjs/no-duplicate-string */
 'use strict';
 
 const common = require('./common');
@@ -18,6 +19,10 @@ const scopeSplitRE = / +/;
 
 const supportedCodeChallengeMethods = ['S256', 'SHA256'];
 
+/**
+ * @typedef {import('node:http')} http
+ */
+
 class Manager {
   constructor(logger, db, options) {
     this.options = options;
@@ -64,12 +69,12 @@ class Manager {
   /**
    * Add an error to a session, keeping only the most-severe code, but all descriptions.
    * This error is sent along on the redirection back to client endpoint.
-   * @param {Object} ctx
-   * @param {Object} ctx.session
-   * @param {String[]=} ctx.session.errorDescriptions
-   * @param {String=} ctx.session.error
-   * @param {String} error
-   * @param {String} errorDescription
+   * @param {object} ctx context
+   * @param {object} ctx.session session
+   * @param {string[]=} ctx.session.errorDescriptions errors
+   * @param {string=} ctx.session.error error
+   * @param {string} error error
+   * @param {string} errorDescription error
    */
   static _setError(ctx, error, errorDescription) {
     const errorPrecedence = [ // By increasing severity
@@ -109,7 +114,7 @@ class Manager {
    * The authorization server MUST include the HTTP Cache-Control response
    * header field with a value of no-store in any response
    * containing tokens, credentials, or other sensitive information.
-   * @param {http.ServerResponse} res
+   * @param {http.ServerResponse} res response
    */
   static _sensitiveResponse(res) {
     Object.entries({
@@ -121,8 +126,8 @@ class Manager {
 
   /**
    * Sets params entries as url search parameters.
-   * @param {URL} url
-   * @param {Object} params
+   * @param {URL} url url
+   * @param {object} params parameters
    */
   static _setSearchParams(url, params) {
     Object.entries(params).forEach((param) => url.searchParams.set(...param));
@@ -131,9 +136,8 @@ class Manager {
 
   /**
    * Serve the informational root page.
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async getRoot(res, ctx) {
     const _scope = _fileScope('getRoot');
@@ -146,8 +150,8 @@ class Manager {
 
   /**
    * Serve the metadata for this service.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async getMeta(res, ctx) {
     const _scope = _fileScope('getMeta');
@@ -187,8 +191,8 @@ class Manager {
    * Process an authorization request from a client.
    * User has authenticated, check if user matches profile,
    * present user with consent form.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async getAuthorization(res, ctx) {
     const _scope = _fileScope('getAuthorization');
@@ -196,9 +200,10 @@ class Manager {
 
     Manager._sensitiveResponse(res);
 
-    ctx.session = Object.assign({}, ctx.session, {
+    ctx.session = {
+      ...ctx.session,
       errorDescriptions: [],
-    });
+    };
 
     // Ingest and validate expected data, populating ctx.session.
     await this._clientIdRequired(ctx);
@@ -305,7 +310,7 @@ class Manager {
 
   /**
    * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data.
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   async _clientIdRequired(ctx) {
     if (ctx.queryParams['client_id']) {
@@ -331,7 +336,7 @@ class Manager {
 
   /**
    * Ensure redirect_uri exists and is corroborated by clientIdentifier data.
-   * @param {Object} ctx 
+   * @param {object} ctx context
    */
   static _redirectURIRequired(ctx) {
     if (ctx.queryParams['redirect_uri']) {
@@ -354,7 +359,7 @@ class Manager {
             }
           }
         }
-      } catch (e) {
+      } catch (e) { // eslint-disable-line no-unused-vars
         Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'redirect_uri\'');
       }
     } else {
@@ -365,7 +370,7 @@ class Manager {
 
   /**
    * response_type must be valid
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   static _responseTypeRequired(ctx) {
     ctx.session.responseType = ctx.queryParams['response_type'];
@@ -382,7 +387,7 @@ class Manager {
 
   /**
    * A state parameter must be present
-   * @param {Object} ctx 
+   * @param {object} ctx context
    */
   static _stateRequired(ctx) {
     ctx.session.state = ctx.queryParams['state'];
@@ -396,7 +401,7 @@ class Manager {
 
   /**
    * A code_challenge_method must be present and valid
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   _codeChallengeMethodRequired(ctx) {
     ctx.session.codeChallengeMethod = ctx.queryParams['code_challenge_method'];
@@ -415,7 +420,7 @@ class Manager {
 
   /**
    * A code_challenge must be present
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   _codeChallengeRequired(ctx) {
     ctx.session.codeChallenge = ctx.queryParams['code_challenge'];
@@ -434,7 +439,7 @@ class Manager {
 
   /**
    * Scopes may be present, with one known combination limitation
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   _scopeOptional(ctx) {
     const _scope = _fileScope('_scopeOptional');
@@ -459,14 +464,14 @@ class Manager {
 
   /**
    * Parses me, if provided
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   async _meOptional(ctx) {
     const me = ctx.queryParams['me'];
     if (me) {
       try {
         ctx.session.me = await this.communication.validateProfile(me);
-      } catch (e) {
+      } catch (e) { // eslint-disable-line no-unused-vars
         ctx.session.me = undefined;
       }
     }
@@ -475,8 +480,8 @@ class Manager {
 
   /**
    * Ensure authenticated identifier matches profile.
-   * @param {Object} ctx
-   * @returns {Boolean}
+   * @param {object} ctx context
+   * @returns {boolean} is valid
    */
   _profileValidForIdentifier(ctx) {
     const _scope = _fileScope('_profileValidForIdentifier');
@@ -492,10 +497,10 @@ class Manager {
 
   /**
    * Get numeric value from form field data.
-   * @param {*} ctx
-   * @param {String} field
-   * @param {String} customField
-   * @returns {Number=}
+   * @param {*} ctx context
+   * @param {string} field field
+   * @param {string} customField custom field
+   * @returns {number=} lifespan
    */
   _parseLifespan(ctx, field, customField) {
     const _scope = _fileScope('_parseLifespan');
@@ -527,15 +532,15 @@ class Manager {
 
   /**
    * Validate any accepted scopes, ensure uniqueness, return as array.
-   * @param {Object} ctx
-   * @returns {String=}
+   * @param {object} ctx context
+   * @returns {string[]} scopes
    */
   _parseConsentScopes(ctx) {
     const _scope = _fileScope('_ingestConsentScopes');
     const acceptedScopesSet = new Set();
     const rejectedScopesSet = new Set();
 
-    const submittedScopes = common.ensureArray(ctx.parsedBody['accepted_scopes'])
+    const submittedScopes = common.ensureArray(ctx.parsedBody['accepted_scopes[]'])
       .concat((ctx.parsedBody['ad_hoc_scopes'] || '').split(scopeSplitRE));
     submittedScopes.forEach((scope) => {
       if (scope) {
@@ -560,8 +565,8 @@ class Manager {
 
   /**
    * Parse and validate selected me is a valid profile option.
-   * @param {Object} ctx
-   * @returns {URL}
+   * @param {object} ctx context
+   * @returns {URL} url
    */
   _parseConsentMe(ctx) {
     const _scope = _fileScope('_parseConsentMe');
@@ -584,8 +589,8 @@ class Manager {
 
   /**
    * Get up-to-date profile data from selected profile endpoint.
-   * @param {Object} ctx
-   * @returns {Promise<Object>}
+   * @param {object} ctx context
+   * @returns {Promise<object>} profile data
    */
   async _fetchConsentProfileData(ctx) {
     const _scope = _fileScope('_fetchConsentProfileData');
@@ -616,15 +621,15 @@ class Manager {
    * Receives POST request from consent page, expecting these form fields:
    *   session - encrypted data collected from initial auth call
    *   accept - 'true' if consent was granted
-   *   accepted_scopes - list of scopes to grant
+   *   accepted_scopes[] - list of scopes to grant
    *   ad_hoc_scopes - additional scopes specified by user
    *   me - selected profile to identify as
    *   expires - optional lifespan
    *   expires-seconds - optional custom lifespan
    *   refresh - optional refresh lifespan
    *   refresh-seconds - optional custom refresh lifespan
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async postConsent(res, ctx) {
     const _scope = _fileScope('postConsent');
@@ -633,7 +638,9 @@ class Manager {
     Manager._sensitiveResponse(res);
 
     // Ensure session exists, persisting any login session data.
-    ctx.session = Object.assign({}, ctx.session);
+    ctx.session = {
+      ...ctx.session,
+    };
     try {
       // Recover the session established on initial auth request.
       const oldSession = await this.mysteryBox.unpack(ctx.parsedBody['session']);
@@ -725,8 +732,8 @@ class Manager {
 
   /**
    * Redeem a code for a profile url, and maybe more profile info.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async postAuthorization(res, ctx) {
     const _scope = _fileScope('postAuthorization');
@@ -783,15 +790,16 @@ class Manager {
   /**
    * Ingest an incoming authorization redemption request, parsing fields
    * onto a new session object on the context.
-   * @param {*} dbCtx
-   * @param {Object} ctx
+   * @param {object} ctx context
+   * @returns {Promise<void>}
    */
   async _ingestPostAuthorizationRequest(ctx) {
     const _scope = _fileScope('_ingestPostAuthorizationRequest');
 
-    ctx.session = Object.assign({}, ctx.session, {
+    ctx.session = {
+      ...ctx.session,
       errorDescriptions: [],
-    });
+    };
 
     if (!ctx.parsedBody) {
       this.logger.debug(_scope, 'no body data', { ctx });
@@ -821,7 +829,7 @@ class Manager {
 
   /**
    * Unpack the session data from provided code overtop of context session ..
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   async _restoreSessionFromCode(ctx) {
     const _scope = _fileScope('_restoreSessionFromCode');
@@ -869,7 +877,7 @@ class Manager {
 
   /**
    * Ensure provided client_id matches session clientId.
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   _checkSessionMatchingClientId(ctx) {
     const _scope = _fileScope('_checkSessionMatchingClientId');
@@ -879,7 +887,7 @@ class Manager {
       try {
         clientId = new URL(clientId);
         ctx.session.clientId = new URL(ctx.session.clientId);
-      } catch (e) {
+      } catch (e) { // eslint-disable-line no-unused-vars
         this.logger.debug(_scope, 'un-parsable client_id url', { ctx });
         delete ctx.session.clientId;
         Manager._setError(ctx, 'invalid_request', 'malformed client_id');
@@ -897,7 +905,7 @@ class Manager {
 
 
   /**
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   _checkSessionMatchingRedirectUri(ctx) {
     const _scope = _fileScope('_checkSessionMatchingClientId');
@@ -907,7 +915,7 @@ class Manager {
       try {
         redirectUri = new URL(redirectUri);
         ctx.session.redirectUri = new URL(ctx.session.redirectUri);
-      } catch (e) {
+      } catch (e) { // eslint-disable-line no-unused-vars
         this.logger.debug(_scope, 'un-parsable redirect_uri url', { ctx });
         delete ctx.session.redirectUri;
         Manager._setError(ctx, 'invalid_request', 'malformed redirect_url');
@@ -926,9 +934,9 @@ class Manager {
 
   /**
    * Validate grant_type, either persist on session or set error.
-   * @param {Object} ctx
-   * @param {String[]} validGrantTypes
-   * @param {Boolean} treatEmptyAs
+   * @param {object} ctx context
+   * @param {string[]} validGrantTypes grant types
+   * @param {string=} treatEmptyAs grant type
    */
   _checkGrantType(ctx, validGrantTypes = ['authorization_code'], treatEmptyAs = 'authorization_code') {
     const _scope = _fileScope('_checkGrantType');
@@ -946,7 +954,7 @@ class Manager {
 
 
   /**
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   _checkSessionMatchingCodeVerifier(ctx) {
     const _scope = _fileScope('_checkSessionMatchingCodeVerifier');
@@ -977,9 +985,9 @@ class Manager {
 
   /**
    * Attempt to revoke a token.
-   * @param {*} dbCtx
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
+   * @param {*} dbCtx db context
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async _revokeToken(dbCtx, res, ctx) {
     const _scope = _fileScope('_revokeToken');
@@ -1035,10 +1043,10 @@ class Manager {
 
   /**
    * Legacy token validation flow.
-   * @param {*} dbCtx
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
+   * @param {*} dbCtx db context
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async _validateToken(dbCtx, req, res, ctx) {
     const _scope = _fileScope('_validateToken');
@@ -1071,9 +1079,9 @@ class Manager {
   /**
    * Given a list of newly-requested scopes, return a list of scopes
    * from previousScopes which are not in requestedScopes.
-   * @param {String[]} previousScopes
-   * @param {String[]} requestedScopes
-   * @returns {String[]}
+   * @param {string[]} previousScopes scopes
+   * @param {string[]} requestedScopes scopes
+   * @returns {string[]} scopes
    */
   static _scopeDifference(previousScopes, requestedScopes) {
     const scopesToRemove = [];
@@ -1091,10 +1099,10 @@ class Manager {
 
   /**
    * Redeem a refresh token for a new token.
-   * @param {*} dbCtx
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
+   * @param {*} dbCtx db context
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async _refreshToken(dbCtx, req, res, ctx) {
     const _scope = _fileScope('_refreshToken');
@@ -1188,15 +1196,15 @@ class Manager {
 
   /**
    * Generate a new ticket for later redemption.
-   * @param {Object} payload
-   * @param {} payload.subject deliver ticket to this endpoint
-   * @param {} payload.resource url the redeemed ticket is valid for accessing
-   * @param {String[]} payload.scopes list of scopes assigned to ticket
-   * @param {String} payload.identifier user generating ticket
-   * @param {} payload.profile profile of user generating ticket
-   * @param {Number} payload.ticketLifespanSeconds ticket redeemable for this long
-   * @returns {Promise<String>}
-  */
+   * @param {object} payload payload
+   * @param {string} payload.subject deliver ticket to this endpoint
+   * @param {string} payload.resource url the redeemed ticket is valid for accessing
+   * @param {string[]} payload.scopes list of scopes assigned to ticket
+   * @param {string} payload.identifier user generating ticket
+   * @param {string} payload.profile profile of user generating ticket
+   * @param {number} payload.ticketLifespanSeconds ticket redeemable for this long
+   * @returns {Promise<string>} ticket
+   */
   async _mintTicket({ subject, resource, scopes, identifier, profile, ticketLifespanSeconds }) {
     const _scope = _fileScope('_mintTicket');
     this.logger.debug(_scope, 'called', { subject, resource, scopes, identifier, profile, ticketLifespanSeconds });
@@ -1217,19 +1225,19 @@ class Manager {
 
   /**
    * @typedef Ticket
-   * @property {String} codeId
-   * @property {Date} issued
-   * @property {Date} expires
-   * @property {URL} subject
-   * @property {URL} resource
-   * @property {String[]} scopes
-   * @property {String} identifier
-   * @property {URL} profile
+   * @property {string} codeId code id
+   * @property {Date} issued issued at
+   * @property {Date} expires expires at
+   * @property {URL} subject subject
+   * @property {URL} resource resource
+   * @property {string[]} scopes scopes
+   * @property {string} identifier identifier
+   * @property {URL} profile profile
    */
   /**
    * 
-   * @param {String} ticket
-   * @returns {Promise<Ticket>}
+   * @param {string} ticket ticket
+   * @returns {Promise<Ticket>} ticket object
    */
   async _unpackTicket(ticket) {
     const ticketObj = await this.mysteryBox.unpack(ticket);
@@ -1248,10 +1256,11 @@ class Manager {
 
   /**
    * Redeem a ticket for a token.
-   * @param {*} dbCtx
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
+   * @param {*} dbCtx db context
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @returns {Promise<void>} 
    */
   async _ticketAuthToken(dbCtx, req, res, ctx) {
     const _scope = _fileScope('_ticketAuthToken');
@@ -1306,10 +1315,10 @@ class Manager {
 
   /**
    * Redeem a code for a token.
-   * @param {*} dbCtx
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
+   * @param {*} dbCtx db context
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async _codeToken(dbCtx, req, res, ctx) {
     const _scope = _fileScope('_codeToken');
@@ -1389,15 +1398,17 @@ class Manager {
 
   /**
    * Issue, refresh, or validate a token.
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async postToken(req, res, ctx) {
     const _scope = _fileScope('postToken');
     this.logger.debug(_scope, 'called', { ctx });
 
-    ctx.session = Object.assign({}, ctx.session);
+    ctx.session = {
+      ...ctx.session,
+    };
 
     await this.db.context(async (dbCtx) => {
 
@@ -1445,9 +1456,10 @@ class Manager {
    * Ingest token from authorization header, setting ctx.bearer.isValid appropriately.
    * ctx.bearer not set if auth method not recognized.
    * This is for legacy validation on token endpoint.
-   * @param {*} dbCtx
-   * @param {http.ClientRequest} req
-   * @param {Object} ctx
+   * @param {*} dbCtx db context
+   * @param {http.ClientRequest} req request
+   * @param {object} ctx context
+   * @returns {Promise<void>}
    */
   async _checkTokenValidationRequest(dbCtx, req, ctx) {
     const _scope = _fileScope('_checkTokenValidationRequest');
@@ -1462,7 +1474,7 @@ class Manager {
           };  
           try {
             Object.assign(ctx.bearer, await this.mysteryBox.unpack(authString));
-          } catch (e) {
+          } catch (e) { // eslint-disable-line no-unused-vars
             this.logger.debug(_scope, 'failed to unpack token', { ctx });
             Manager._setError(ctx, 'invalid_request', 'invalid token');
             return;
@@ -1503,9 +1515,9 @@ class Manager {
 
   /**
    * Accept an unsolicited ticket proffering.
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async postTicket(req, res, ctx) {
     const _scope = _fileScope('postTicket');
@@ -1522,14 +1534,14 @@ class Manager {
     if (iss) {
       try {
         new URL(iss);
-      } catch (e) {
+      } catch (e) { // eslint-disable-line no-unused-vars
         this.logger.debug(_scope, 'unparsable issuer', { ticket, resource, subject, iss, ctx });
         // continue, will try resource for metadata
       }
     }
     try {
       new URL(resource);
-    } catch (e) {
+    } catch (e) { // eslint-disable-line no-unused-vars
       this.logger.debug(_scope, 'unparsable resource', { ticket, resource, subject, ctx });
       throw new ResponseError(Enum.ErrorResponse.BadRequest);
     }
@@ -1556,11 +1568,15 @@ class Manager {
   }
 
 
+  /**
+   * @typedef {object} AMQPChannel
+   * @property {Function} ack ack
+   */
   /**
    * Process messages from proffered ticket queue.
    * Attempt to redeem ticket and publish to redeemed token queue.
-   * @param {AMQPChannel} channel
-   * @param {Buffer} message
+   * @param {AMQPChannel} channel channel
+   * @param {Buffer} message message
    */
   async queuedTicketProcessor(channel, message) {
     const _scope = _fileScope('queuedTicketProcessor');
@@ -1595,7 +1611,7 @@ class Manager {
     let resourceUrlObj;
     try {
       resourceUrlObj = new URL(resource);
-    } catch (e) {
+    } catch (e) { // eslint-disable-line no-unused-vars
       this.logger.error(_scope, 'unparsable resource, discarding', { payload });
       channel.ack(message);
       return;
@@ -1656,8 +1672,8 @@ class Manager {
 
   /**
    * Validate a token and return data about it.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async postIntrospection(res, ctx) {
     const _scope = _fileScope('postIntrospection');
@@ -1712,8 +1728,8 @@ class Manager {
 
   /**
    * Revoke a token or refresh token.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async postRevocation(res, ctx) {
     const _scope = _fileScope('postRevocation');
@@ -1734,8 +1750,8 @@ class Manager {
 
   /**
    * Profile information for a token.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async postUserInfo(res, ctx) {
     const _scope = _fileScope('postUserInfo');
@@ -1794,8 +1810,8 @@ class Manager {
 
   /**
    * Show admin interface, allowing manipulation of profiles and scopes.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async getAdmin(res, ctx) {
     const _scope = _fileScope('getAdmin');
@@ -1816,8 +1832,8 @@ class Manager {
   
   /**
    * Process admin interface events.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async postAdmin(res, ctx) {
     const _scope = _fileScope('postAdmin');
@@ -1829,16 +1845,16 @@ class Manager {
 
       if (action === 'save-scopes') {
         // Update the convenience scopes set for profiles.
-        // Expect 'scopes-<profile>' with value of array of scopes
+        // Expect 'scopes-<profile>[]' with value of array of scopes
         const profileKeys = ctx.parsedBody && Object.keys(ctx.parsedBody)
-          .filter((k) => k.startsWith('scopes-'));
+          .filter((k) => k.startsWith('scopes-') && k.endsWith('[]'));
         try {
           await this.db.transaction(dbCtx, async (txCtx) => {
             await Promise.all(
               /* For each scopes-profile submitted, set those. */
               profileKeys.map((profileKey) => {
-                /* elide 'scope-' prefix to get the profile */
-                const profile = profileKey.slice(7);
+                /* elide 'scope-' prefix and '[]' postfix to get the profile */
+                const profile = profileKey.slice(7, -2);
                 /* (should validate profile here) */
 
                 /* remove invalid scopes from submitted list */
@@ -1946,8 +1962,8 @@ class Manager {
 
   /**
    * Show ticket proffer interface.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async getAdminTicket(res, ctx) {
     const _scope = _fileScope('getAdminTicket');
@@ -1969,8 +1985,8 @@ class Manager {
 
   /**
    * Handle ticket proffer interface submission.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async postAdminTicket(res, ctx) {
     const _scope = _fileScope('postAdminTicket');
@@ -1986,7 +2002,7 @@ class Manager {
         ].forEach((param) => {
           try {
             ctx[param.ctxProp] = new URL(ctx.parsedBody[param.bodyParam]);
-          } catch (e) {
+          } catch (e) { // eslint-disable-line no-unused-vars
             this.logger.debug(_scope, `invalid ${param.bodyParam}`, { ctx });
             ctx.errors.push(param.err);
           }
@@ -2007,7 +2023,7 @@ class Manager {
 
         const scopesSet = new Set();
         const rawScopes = [
-          ...(common.ensureArray(ctx.parsedBody['scopes'])),
+          ...(common.ensureArray(ctx.parsedBody['scopes[]'])),
           ...((ctx.parsedBody['adhoc'] || '').split(scopeSplitRE)),
         ].filter((scope) => scope);
         rawScopes.forEach((scope) => {
@@ -2081,8 +2097,8 @@ class Manager {
   /**
    * Report on generally uninteresting backend information.
    * Also allow a few event invocations.
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async getAdminMaintenance(res, ctx) {
     const _scope = _fileScope('getAdminMaintenance');
@@ -2119,8 +2135,8 @@ class Manager {
 
   /**
    * 
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx 
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async getHealthcheck(res, ctx) {
     const _scope = _fileScope('getHealthcheck');