@web-ts-toolkit/express-oidc-vault-redis-store 0.43.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -67,6 +67,9 @@ var RedisOidcVaultStoreKeys = class {
67
67
  backchannelLogoutTokenJti(jti) {
68
68
  return prefixKey(this.keyPrefix, "backchannel-logout-jti", jti);
69
69
  }
70
+ dpopProofs() {
71
+ return `${this.keyPrefix}:dpop-proofs`;
72
+ }
70
73
  rotatedSessionAliasIndex(logicalSessionId) {
71
74
  return prefixKey(this.keyPrefix, "rotated-session-alias-index", logicalSessionId);
72
75
  }
@@ -94,14 +97,27 @@ var OidcVaultRedisStoreRecordError = class extends Error {
94
97
  };
95
98
  var isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
96
99
  var isString = (value) => typeof value === "string";
100
+ var isBindingHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
101
+ var validateDpopBinding = (value) => isPlainRecord(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) && Object.keys(value).length === 2 && Object.hasOwn(value, "type") && Object.hasOwn(value, "jkt") && value.type === "dpop" && isBindingHash(value.jkt);
102
+ var validateRecordBinding = (record) => (record.deviceBinding === void 0 || validateDpopBinding(record.deviceBinding)) && (record.browserBindingHash === void 0 || isBindingHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
103
+ var validateBindingMatch = (value) => isPlainRecord(value) && Object.hasOwn(value, "deviceBinding") && Object.hasOwn(value, "browserBindingHash") && (value.deviceBinding === null || validateDpopBinding(value.deviceBinding)) && (value.browserBindingHash === null || isBindingHash(value.browserBindingHash));
104
+ var assertRecordBinding = (record) => {
105
+ if (!validateRecordBinding(record))
106
+ throw new TypeError("OIDC vault store record has invalid device/browser binding.");
107
+ };
108
+ var assertSessionBinding = (record) => {
109
+ if (record.deviceBinding !== void 0 && !validateDpopBinding(record.deviceBinding)) {
110
+ throw new TypeError("OIDC vault store session has invalid device binding.");
111
+ }
112
+ };
97
113
  var isOptionalString = (value) => value === void 0 || isString(value);
98
114
  var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
99
115
  var isOptionalFiniteNumber = (value) => value === void 0 || isFiniteNumber(value);
100
116
  var isStringRecord = (value) => isPlainRecord(value);
101
- var validateAuthorizationTransaction = (value) => isPlainRecord(value) && isString(value.state) && isString(value.nonce) && isString(value.pkceVerifier) && isString(value.codeChallenge) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt) && (value.metadata === void 0 || isStringRecord(value.metadata));
102
- var validateExchangeCodeRecord = (value) => isPlainRecord(value) && isString(value.code) && isString(value.sessionId) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
117
+ var validateAuthorizationTransaction = (value, state) => isPlainRecord(value) && isString(value.state) && value.state === state && isString(value.nonce) && isString(value.pkceVerifier) && isString(value.codeChallenge) && validateRecordBinding(value) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt) && (value.metadata === void 0 || isStringRecord(value.metadata));
118
+ var validateExchangeCodeRecord = (value, code) => isPlainRecord(value) && isString(value.code) && value.code === code && isString(value.sessionId) && validateRecordBinding(value) && isOptionalString(value.returnTo) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.expiresAt);
103
119
  var validateProviderMetadata = (value) => value === void 0 || isPlainRecord(value) && isOptionalString(value.issuer) && isOptionalString(value.clientId);
104
- var validateSession = (value) => isPlainRecord(value) && isString(value.sessionId) && isOptionalString(value.logicalSessionId) && isString(value.subject) && isOptionalString(value.providerSessionId) && validateProviderMetadata(value.provider) && isString(value.refreshToken) && isString(value.idToken) && isOptionalString(value.accessToken) && isOptionalString(value.scope) && isOptionalFiniteNumber(value.expiresAt) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.updatedAt) && (value.user === void 0 || isPlainRecord(value.user)) && (value.metadata === void 0 || isPlainRecord(value.metadata));
120
+ var validateSession = (value, sessionId) => isPlainRecord(value) && isString(value.sessionId) && value.sessionId === sessionId && isOptionalString(value.logicalSessionId) && isString(value.subject) && isOptionalString(value.providerSessionId) && validateProviderMetadata(value.provider) && (value.deviceBinding === void 0 || validateDpopBinding(value.deviceBinding)) && isString(value.refreshToken) && isString(value.idToken) && isOptionalString(value.accessToken) && isOptionalString(value.scope) && isOptionalFiniteNumber(value.expiresAt) && isFiniteNumber(value.createdAt) && isFiniteNumber(value.updatedAt) && (value.user === void 0 || isPlainRecord(value.user)) && (value.metadata === void 0 || isPlainRecord(value.metadata));
105
121
  var parseStoredJson = (value, recordKind, validate) => {
106
122
  if (value === null) {
107
123
  return null;
@@ -130,6 +146,228 @@ var evalCommand = (script, keys, args) => [
130
146
  ...keys,
131
147
  ...args
132
148
  ];
149
+ var BINDING_VALIDATION_HELPER = `
150
+ local function isHash(value)
151
+ return type(value) == 'string' and #value == 43
152
+ and string.match(value, '^[A-Za-z0-9_-]+$') ~= nil
153
+ and string.match(string.sub(value, 43), '^[AEIMQUYcgkosw048]$') ~= nil
154
+ end
155
+ local function isBinding(value)
156
+ if type(value) ~= 'table' or value['type'] ~= 'dpop' or not isHash(value['jkt']) then return false end
157
+ local count = 0
158
+ for key, _ in pairs(value) do
159
+ if key ~= 'type' and key ~= 'jkt' then return false end
160
+ count = count + 1
161
+ end
162
+ return count == 2
163
+ end
164
+ local function validBinding(value)
165
+ return value == nil or isBinding(value)
166
+ end
167
+ local function validRecordBinding(record)
168
+ return validBinding(record['deviceBinding'])
169
+ and (record['browserBindingHash'] == nil or isHash(record['browserBindingHash']))
170
+ and (record['deviceBinding'] == nil or record['browserBindingHash'] ~= nil)
171
+ end
172
+ local function sameBinding(left, right)
173
+ if left == nil or right == nil then return left == right end
174
+ return isBinding(left) and isBinding(right) and left['jkt'] == right['jkt']
175
+ end
176
+ local function optionalString(value)
177
+ return value == nil or type(value) == 'string'
178
+ end
179
+ local function finiteNumber(value)
180
+ return type(value) == 'number' and value == value and value > -math.huge and value < math.huge
181
+ end
182
+ local function serverNow()
183
+ local time = redis.call('TIME')
184
+ return tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
185
+ end
186
+ -- cjson decodes both {} and [] to a Lua table. Inspect the top-level value's
187
+ -- lexical kind so an empty provider array cannot masquerade as legacy {}.
188
+ -- Skip strings/escapes and nested containers; duplicate keys use the last
189
+ -- occurrence, matching cjson/JSON.parse. No credential values are returned.
190
+ local function optionalObjectField(raw, wanted)
191
+ local depth, position, shape = 0, 1, nil
192
+ while position <= #raw do
193
+ local char = string.sub(raw, position, position)
194
+ if char == '"' then
195
+ local start = position
196
+ position = position + 1
197
+ while position <= #raw do
198
+ local current = string.sub(raw, position, position)
199
+ if current == '\\\\' then position = position + 2
200
+ elseif current == '"' then break
201
+ else position = position + 1 end
202
+ end
203
+ if depth == 1 then
204
+ local after = position + 1
205
+ while string.match(string.sub(raw, after, after), '%s') do after = after + 1 end
206
+ if string.sub(raw, after, after) == ':' and cjson.decode(string.sub(raw, start, position)) == wanted then
207
+ after = after + 1
208
+ while string.match(string.sub(raw, after, after), '%s') do after = after + 1 end
209
+ shape = string.sub(raw, after, after)
210
+ end
211
+ end
212
+ elseif char == '{' or char == '[' then depth = depth + 1
213
+ elseif char == '}' or char == ']' then depth = depth - 1 end
214
+ position = position + 1
215
+ end
216
+ return shape == nil or shape == '{'
217
+ end
218
+ `;
219
+ var CONSUME_GUARDED_RECORD_SCRIPT = `
220
+ ${BINDING_VALIDATION_HELPER}
221
+ local raw = redis.call('GET', KEYS[1])
222
+ if raw == false then return false end
223
+ local ok, record = pcall(cjson.decode, raw)
224
+ local kind = ARGV[1]
225
+ local valid = ok and type(record) == 'table' and validRecordBinding(record)
226
+ if valid then
227
+ valid = optionalString(record['returnTo']) and finiteNumber(record['createdAt']) and finiteNumber(record['expiresAt'])
228
+ if kind == 'transaction' then
229
+ valid = valid and record['state'] == ARGV[2] and type(record['nonce']) == 'string'
230
+ and type(record['pkceVerifier']) == 'string' and type(record['codeChallenge']) == 'string'
231
+ and (record['metadata'] == nil or type(record['metadata']) == 'table')
232
+ else
233
+ valid = valid and record['code'] == ARGV[2] and type(record['sessionId']) == 'string'
234
+ end
235
+ end
236
+ if not valid then
237
+ redis.call('DEL', KEYS[1])
238
+ return false
239
+ end
240
+ -- Legacy consumes preserve the historical Redis key-TTL policy. Guarded
241
+ -- consumes additionally check payload expiry with server time (D5).
242
+ if ARGV[3] ~= 'legacy' and record['expiresAt'] <= serverNow() then
243
+ redis.call('DEL', KEYS[1])
244
+ return false
245
+ end
246
+ if ARGV[3] == 'legacy' then
247
+ if record['deviceBinding'] ~= nil or record['browserBindingHash'] ~= nil then return false end
248
+ else
249
+ local match = cjson.decode(ARGV[4])
250
+ if (match['deviceBinding'] ~= cjson.null and not isBinding(match['deviceBinding']))
251
+ or (match['browserBindingHash'] ~= cjson.null and not isHash(match['browserBindingHash'])) then return false end
252
+ if match['deviceBinding'] == cjson.null then
253
+ if record['deviceBinding'] ~= nil then return false end
254
+ elseif not sameBinding(record['deviceBinding'], match['deviceBinding']) then return false end
255
+ if match['browserBindingHash'] == cjson.null then
256
+ if record['browserBindingHash'] ~= nil then return false end
257
+ elseif record['browserBindingHash'] ~= match['browserBindingHash'] then return false end
258
+ if kind == 'exchange' and record['sessionId'] ~= ARGV[5] then return false end
259
+ end
260
+ redis.call('DEL', KEYS[1])
261
+ return raw
262
+ `;
263
+ var buildConsumeGuardedRecordCommand = (key, kind, id, match, expectedSessionId) => evalCommand(
264
+ CONSUME_GUARDED_RECORD_SCRIPT,
265
+ [key],
266
+ [
267
+ kind,
268
+ id,
269
+ match === void 0 ? "legacy" : "guarded",
270
+ match === void 0 ? "" : serialize(match),
271
+ expectedSessionId ?? ""
272
+ ]
273
+ );
274
+ var RESERVE_DPOP_PROOF_SCRIPT = `
275
+ ${BINDING_VALIDATION_HELPER}
276
+ local expiresAt = tonumber(ARGV[2])
277
+ local now = serverNow()
278
+ if not finiteNumber(expiresAt) or expiresAt ~= math.floor(expiresAt) or expiresAt > 9007199254740991
279
+ or expiresAt <= now or expiresAt - now > 360000 then return 0 end
280
+ local keyType = redis.call('TYPE', KEYS[1])['ok']
281
+ if keyType ~= 'none' and keyType ~= 'zset' then error('OIDC vault Redis replay key has unexpected type') end
282
+ local existing = redis.call('ZSCORE', KEYS[1], ARGV[1])
283
+ if existing ~= false and tonumber(existing) > now then return 0 end
284
+ -- Hard LIMIT, not SCAN's hint: at most 64 expired entries per admission.
285
+ local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, 64)
286
+ if #expired > 0 then redis.call('ZREM', KEYS[1], unpack(expired)) end
287
+ if existing ~= false then redis.call('ZREM', KEYS[1], ARGV[1]) end
288
+ if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[3]) then return 2 end
289
+ redis.call('ZADD', KEYS[1], expiresAt, ARGV[1])
290
+ -- Reclaim the whole set if traffic stops after its last retained expiry.
291
+ local last = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')
292
+ redis.call('PEXPIREAT', KEYS[1], last[2])
293
+ return 1
294
+ `;
295
+ var buildReserveDpopProofCommand = (keys, input, maxEntries) => evalCommand(
296
+ RESERVE_DPOP_PROOF_SCRIPT,
297
+ [keys.dpopProofs()],
298
+ [input.replayKey, String(input.expiresAt), String(maxEntries)]
299
+ );
300
+ var SESSION_REVOCATION_CONTEXT_SCRIPT = `
301
+ ${BINDING_VALIDATION_HELPER}
302
+ local now = serverNow()
303
+ local handle = ARGV[1]
304
+ local sessionPrefix = ARGV[2]
305
+ local logicalPrefix = ARGV[3]
306
+ local function authority(raw, id)
307
+ local ok, record = pcall(cjson.decode, raw)
308
+ if not ok or type(record) ~= 'table' or record['sessionId'] ~= id
309
+ or not optionalString(record['logicalSessionId']) or type(record['subject']) ~= 'string'
310
+ or not validBinding(record['deviceBinding']) then error('OIDC vault Redis lineage has malformed session authority') end
311
+ local provider = record['provider']
312
+ if not optionalObjectField(raw, 'provider') or (provider ~= nil and (type(provider) ~= 'table' or not optionalString(provider['issuer'])
313
+ or not optionalString(provider['clientId']))) then error('OIDC vault Redis lineage has malformed provider identity') end
314
+ local expiry = record['expiresAt']
315
+ if expiry ~= nil and not finiteNumber(expiry) then error('OIDC vault Redis lineage has malformed session expiry') end
316
+ if expiry ~= nil and expiry <= now then return nil end
317
+ return record
318
+ end
319
+ local logicalId = nil
320
+ local source = nil
321
+ local primary = redis.call('GET', KEYS[1])
322
+ if primary ~= false then
323
+ local record = authority(primary, handle)
324
+ if record ~= nil then
325
+ logicalId = record['logicalSessionId'] or handle
326
+ -- The directly observed live handle must participate even if its reverse
327
+ -- index membership was corrupted; never infer unboundness from peers alone.
328
+ source = record
329
+ end
330
+ end
331
+ if logicalId == nil then
332
+ local alias = redis.call('GET', KEYS[2])
333
+ if alias == false then return false end
334
+ local ok, decoded = pcall(cjson.decode, alias)
335
+ if not ok or type(decoded) ~= 'string' then error('OIDC vault Redis store has malformed revocation alias') end
336
+ logicalId = decoded
337
+ end
338
+ local members = redis.call('ZRANGE', logicalPrefix .. logicalId, 0, -1)
339
+ for _, id in ipairs(members) do
340
+ local raw = redis.call('GET', sessionPrefix .. id)
341
+ if raw ~= false then
342
+ local member = authority(raw, id)
343
+ if member ~= nil and (member['logicalSessionId'] or id) == logicalId then
344
+ if source ~= nil then
345
+ local left = source['provider'] or {}
346
+ local right = member['provider'] or {}
347
+ if not sameBinding(source['deviceBinding'], member['deviceBinding']) or source['subject'] ~= member['subject']
348
+ or left['issuer'] ~= right['issuer'] or left['clientId'] ~= right['clientId'] then
349
+ error('OIDC vault Redis lineage has inconsistent device binding or provider identity')
350
+ end
351
+ else source = member end
352
+ end
353
+ end
354
+ end
355
+ if source == nil then return false end
356
+ local result = { logicalSessionId = logicalId }
357
+ if source['deviceBinding'] ~= nil then result['deviceBinding'] = source['deviceBinding'] end
358
+ if source['provider'] ~= nil then
359
+ local provider = {}
360
+ provider['issuer'] = source['provider']['issuer']
361
+ provider['clientId'] = source['provider']['clientId']
362
+ result['provider'] = provider
363
+ end
364
+ return cjson.encode(result)
365
+ `;
366
+ var buildSessionRevocationContextCommand = (keys, sessionId) => evalCommand(
367
+ SESSION_REVOCATION_CONTEXT_SCRIPT,
368
+ [keys.session(sessionId), keys.rotatedSessionAlias(sessionId)],
369
+ [sessionId, keys.sessionPrefix(), keys.logicalSessionIndexPrefix()]
370
+ );
133
371
  var scriptSha1 = (script) => (0, import_node_crypto.createHash)("sha1").update(script).digest("hex");
134
372
  var NOSCRIPT_ERROR_PREFIX = "NOSCRIPT";
135
373
  var isNoScriptError = (error) => {
@@ -267,7 +505,42 @@ end
267
505
 
268
506
  return 1
269
507
  `;
508
+ var LIVE_LINEAGE_HELPER = `
509
+ local function hasLiveMember(logicalIndexKey, sessionKeyPrefix, logicalSessionId, excludedId)
510
+ local members = redis.call('ZRANGE', logicalIndexKey, 0, -1)
511
+ for _, memberId in ipairs(members) do
512
+ if memberId ~= excludedId then
513
+ local value = redis.call('GET', sessionKeyPrefix .. memberId)
514
+ if value ~= false then
515
+ local ok, member = pcall(cjson.decode, value)
516
+ if ok and type(member) == 'table' and member['sessionId'] == memberId
517
+ and (member['logicalSessionId'] or memberId) == logicalSessionId then
518
+ return true
519
+ end
520
+ end
521
+ end
522
+ end
523
+ return false
524
+ end
525
+ `;
526
+ var CLEANUP_INACTIVE_ALIASES_SCRIPT = `
527
+ ${LIVE_LINEAGE_HELPER}
528
+ if hasLiveMember(KEYS[1], ARGV[1], ARGV[3], nil) then
529
+ return 0
530
+ end
531
+ local aliases = redis.call('ZRANGE', KEYS[2], 0, -1)
532
+ for _, aliasId in ipairs(aliases) do
533
+ redis.call('DEL', ARGV[2] .. aliasId)
534
+ end
535
+ return redis.call('DEL', KEYS[2])
536
+ `;
537
+ var buildCleanupInactiveAliasesCommand = (keys, logicalSessionId) => evalCommand(
538
+ CLEANUP_INACTIVE_ALIASES_SCRIPT,
539
+ [keys.logicalSessionIndex(logicalSessionId), keys.rotatedSessionAliasIndex(logicalSessionId)],
540
+ [keys.sessionPrefix(), keys.rotatedSessionAliasPrefix(), logicalSessionId]
541
+ );
270
542
  var DELETE_SESSION_SCRIPT = `
543
+ ${LIVE_LINEAGE_HELPER}
271
544
  local expected = cjson.decode(ARGV[1])
272
545
  local scopeKind = ARGV[2]
273
546
  local scopeValue = ARGV[3]
@@ -347,7 +620,16 @@ local function deleteRecord(sessionKey, session)
347
620
  assertZsetOrMissing(providerIndexKeyPrefix .. session['providerSessionId'])
348
621
  end
349
622
 
350
- local aliasSessionIds = redis.call('ZRANGE', aliasIndexKey, 0, -1)
623
+ -- Check actual primaries, not just scores: TTL is authoritative and stale
624
+ -- logical memberships must not keep a terminated lineage's aliases alive.
625
+ -- This check and deletion share the script's atomic boundary with rotation.
626
+ local logicalIndexKey = logicalIndexKeyPrefix .. logicalSessionId
627
+ local hasSurvivor = hasLiveMember(logicalIndexKey, sessionKeyPrefix, logicalSessionId, sessionId)
628
+
629
+ local aliasSessionIds = {}
630
+ if not hasSurvivor then
631
+ aliasSessionIds = redis.call('ZRANGE', aliasIndexKey, 0, -1)
632
+ end
351
633
 
352
634
  if redis.call('DEL', sessionKey) == 0 then
353
635
  return 0
@@ -364,7 +646,9 @@ local function deleteRecord(sessionKey, session)
364
646
  redis.call('DEL', aliasKeyPrefix .. aliasSessionId)
365
647
  end
366
648
 
367
- redis.call('DEL', aliasIndexKey)
649
+ if not hasSurvivor then
650
+ redis.call('DEL', aliasIndexKey)
651
+ end
368
652
 
369
653
  return 1
370
654
  end
@@ -403,6 +687,7 @@ end
403
687
  return deleted
404
688
  `;
405
689
  var ROTATE_SESSION_SCRIPT = `
690
+ ${BINDING_VALIDATION_HELPER}
406
691
  local newValue = ARGV[1]
407
692
  local newExpiresAt = ARGV[2]
408
693
  local oldSessionId = ARGV[3]
@@ -416,6 +701,7 @@ local oldAliasValue = ARGV[10]
416
701
  local oldAliasExpiresAt = ARGV[11]
417
702
  local aliasIndexKey = ARGV[12]
418
703
  local aliasIndexKeyPrefix = ARGV[13]
704
+ local expectedSource = cjson.decode(ARGV[14])
419
705
 
420
706
  local function assertStringOrMissing(key)
421
707
  local keyType = redis.call('TYPE', key)['ok']
@@ -474,6 +760,25 @@ if redis.call('EXISTS', KEYS[1]) == 0 then
474
760
  return 0
475
761
  end
476
762
 
763
+ local ok, source = pcall(cjson.decode, redis.call('GET', KEYS[1]))
764
+ local next = cjson.decode(newValue)
765
+ if not ok or type(source) ~= 'table' or source['sessionId'] ~= oldSessionId
766
+ or not validBinding(source['deviceBinding']) or not validBinding(next['deviceBinding']) then return 3 end
767
+ local sourceProvider = source['provider'] or {}
768
+ local expectedProvider = expectedSource['provider'] or {}
769
+ if type(sourceProvider) ~= 'table' or type(expectedProvider) ~= 'table' then return 3 end
770
+ -- Source authority CAS protects immutable binding and index ownership from
771
+ -- same-ID replacement between the application preflight and this script.
772
+ if (source['logicalSessionId'] or oldSessionId) ~= (expectedSource['logicalSessionId'] or oldSessionId)
773
+ or source['subject'] ~= expectedSource['subject'] or source['providerSessionId'] ~= expectedSource['providerSessionId']
774
+ or sourceProvider['issuer'] ~= expectedProvider['issuer'] or sourceProvider['clientId'] ~= expectedProvider['clientId']
775
+ or source['refreshToken'] ~= expectedSource['refreshToken'] or source['idToken'] ~= expectedSource['idToken']
776
+ or source['accessToken'] ~= expectedSource['accessToken'] or source['scope'] ~= expectedSource['scope']
777
+ or source['createdAt'] ~= expectedSource['createdAt'] or source['updatedAt'] ~= expectedSource['updatedAt']
778
+ or source['expiresAt'] ~= expectedSource['expiresAt']
779
+ or not sameBinding(source['deviceBinding'], expectedSource['deviceBinding'])
780
+ or not sameBinding(source['deviceBinding'], next['deviceBinding']) then return 3 end
781
+
477
782
  if oldSessionId == newSessionId then
478
783
  return 2
479
784
  end
@@ -526,6 +831,30 @@ redis.call('ZADD', aliasIndexKey, newScore, oldSessionId)
526
831
 
527
832
  return 1
528
833
  `;
834
+ var COMPARE_AND_DELETE_SCRIPT = `
835
+ local current = redis.call('GET', KEYS[1])
836
+
837
+ if current == ARGV[1] then
838
+ return redis.call('DEL', KEYS[1])
839
+ else
840
+ return 0
841
+ end
842
+ `;
843
+ var buildCompareAndDeleteCommand = (key, observedValue) => evalCommand(COMPARE_AND_DELETE_SCRIPT, [key], [observedValue]);
844
+ var REPAIR_SESSION_INDEX_SCRIPT = `
845
+ local current = redis.call('GET', KEYS[1])
846
+
847
+ if current == false or (ARGV[3] == '1' and current == ARGV[2]) then
848
+ return redis.call('ZREM', KEYS[2], ARGV[1])
849
+ end
850
+
851
+ return 0
852
+ `;
853
+ var buildRepairSessionIndexCommand = (sessionKey, indexKey, sessionId, observedValue) => evalCommand(
854
+ REPAIR_SESSION_INDEX_SCRIPT,
855
+ [sessionKey, indexKey],
856
+ [sessionId, observedValue ?? "", observedValue === null ? "0" : "1"]
857
+ );
529
858
  var buildWriteSessionCommand = (keys, session) => evalCommand(
530
859
  WRITE_SESSION_SCRIPT,
531
860
  [
@@ -583,18 +912,38 @@ var buildRotateSessionCommand = (keys, previousSession, nextSession) => evalComm
583
912
  serialize(nextSession.logicalSessionId ?? nextSession.sessionId),
584
913
  serializeExpiresAt(nextSession.expiresAt),
585
914
  keys.rotatedSessionAliasIndex(nextSession.logicalSessionId ?? nextSession.sessionId),
586
- keys.rotatedSessionAliasIndexPrefix()
915
+ keys.rotatedSessionAliasIndexPrefix(),
916
+ serialize(previousSession)
587
917
  ]
588
918
  );
589
919
 
590
920
  // src/index.ts
591
921
  var INDEX_CLEANUP_SCAN_COUNT = 100;
592
922
  var INDEX_REVOCATION_SCAN_COUNT = 250;
923
+ var snapshotInput = (input, seen = /* @__PURE__ */ new WeakMap()) => {
924
+ if (input === null || typeof input !== "object") return input;
925
+ const prototype = Object.getPrototypeOf(input);
926
+ if (!Array.isArray(input) && prototype !== Object.prototype && prototype !== null) return input;
927
+ if (seen.has(input)) return seen.get(input);
928
+ const copy = Array.isArray(input) ? new Array(input.length) : Object.create(prototype);
929
+ seen.set(input, copy);
930
+ for (const key of Reflect.ownKeys(input)) {
931
+ if (Object.prototype.propertyIsEnumerable.call(input, key)) {
932
+ Object.defineProperty(copy, key, {
933
+ value: snapshotInput(Reflect.get(input, key), seen),
934
+ enumerable: true,
935
+ configurable: true,
936
+ writable: true
937
+ });
938
+ }
939
+ }
940
+ return copy;
941
+ };
593
942
  var isRecord = (value) => typeof value === "object" && value !== null;
594
943
  var isUnsupportedClusterClient = (client) => isRecord(client) && typeof client.getSlotMaster === "function" && typeof client.nodeClient === "function" && Array.isArray(client.masters) && Array.isArray(client.slots);
595
- var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : input;
596
- var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : input;
597
- var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : input;
944
+ var toSubjectDeleteInput = (input) => typeof input === "string" ? { subject: input } : { ...input };
945
+ var toProviderSessionDeleteInput = (input) => typeof input === "string" ? { providerSessionId: input } : { ...input };
946
+ var toLogicalSessionDeleteInput = (input) => typeof input === "string" ? { logicalSessionId: input } : { ...input };
598
947
  var matchesProviderScope = (session, input) => {
599
948
  if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
600
949
  return false;
@@ -610,7 +959,12 @@ var RedisOidcVaultStore = class {
610
959
  now;
611
960
  scriptRunner;
612
961
  indexCleanupCursor = "0";
962
+ dpopReplayMaxEntries;
613
963
  constructor(options) {
964
+ this.dpopReplayMaxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
965
+ if (!Number.isSafeInteger(this.dpopReplayMaxEntries) || this.dpopReplayMaxEntries <= 0) {
966
+ throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
967
+ }
614
968
  if (typeof options.client.sendCommand !== "function") {
615
969
  throw new Error("Redis store client must implement sendCommand(args) for atomic vault operations.");
616
970
  }
@@ -625,22 +979,109 @@ var RedisOidcVaultStore = class {
625
979
  this.scriptRunner = new RedisScriptRunner(options.client);
626
980
  }
627
981
  async createAuthorizationTransaction(input) {
982
+ input = snapshotInput({ ...input });
983
+ assertRecordBinding(input);
628
984
  await this.setJson(this.keys.authorizationTransaction(input.state), input, input.expiresAt);
629
985
  }
630
986
  async consumeAuthorizationTransaction(state) {
631
- return this.consumeJson(
987
+ const value = await this.runScript(
988
+ buildConsumeGuardedRecordCommand(this.keys.authorizationTransaction(state), "transaction", state)
989
+ );
990
+ return this.parseOneTimeRecord(
991
+ value,
992
+ "authorization transaction",
993
+ (value2) => validateAuthorizationTransaction(value2, state)
994
+ );
995
+ }
996
+ async getAuthorizationTransaction(state) {
997
+ return this.getJson(
632
998
  this.keys.authorizationTransaction(state),
633
999
  "authorization transaction",
634
- validateAuthorizationTransaction
1000
+ (value) => validateAuthorizationTransaction(value, state),
1001
+ { deleteMalformed: true, checkExpiry: true }
1002
+ );
1003
+ }
1004
+ async consumeAuthorizationTransactionIfMatches(input) {
1005
+ input = snapshotInput({ ...input });
1006
+ if (!validateBindingMatch(input.match)) return null;
1007
+ const value = await this.runScript(
1008
+ buildConsumeGuardedRecordCommand(
1009
+ this.keys.authorizationTransaction(input.state),
1010
+ "transaction",
1011
+ input.state,
1012
+ input.match
1013
+ )
1014
+ );
1015
+ return this.parseOneTimeRecord(
1016
+ value,
1017
+ "authorization transaction",
1018
+ (value2) => validateAuthorizationTransaction(value2, input.state)
635
1019
  );
636
1020
  }
637
1021
  async createExchangeCode(input) {
1022
+ input = snapshotInput({ ...input });
1023
+ assertRecordBinding(input);
638
1024
  await this.setJson(this.keys.exchangeCode(input.code), input, input.expiresAt);
639
1025
  }
640
1026
  async consumeExchangeCode(code) {
641
- return this.consumeJson(this.keys.exchangeCode(code), "exchange code", validateExchangeCodeRecord);
1027
+ const value = await this.runScript(
1028
+ buildConsumeGuardedRecordCommand(this.keys.exchangeCode(code), "exchange", code)
1029
+ );
1030
+ return this.parseOneTimeRecord(value, "exchange code", (value2) => validateExchangeCodeRecord(value2, code));
1031
+ }
1032
+ async getExchangeCode(code) {
1033
+ return this.getJson(
1034
+ this.keys.exchangeCode(code),
1035
+ "exchange code",
1036
+ (value) => validateExchangeCodeRecord(value, code),
1037
+ { deleteMalformed: true, checkExpiry: true }
1038
+ );
1039
+ }
1040
+ async consumeExchangeCodeIfMatches(input) {
1041
+ input = snapshotInput({ ...input });
1042
+ if (!validateBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
1043
+ const value = await this.runScript(
1044
+ buildConsumeGuardedRecordCommand(
1045
+ this.keys.exchangeCode(input.code),
1046
+ "exchange",
1047
+ input.code,
1048
+ input.match,
1049
+ input.expectedSessionId
1050
+ )
1051
+ );
1052
+ return this.parseOneTimeRecord(value, "exchange code", (value2) => validateExchangeCodeRecord(value2, input.code));
642
1053
  }
1054
+ async getSessionRevocationContext(sessionId) {
1055
+ const raw = await this.runScript(buildSessionRevocationContextCommand(this.keys, sessionId));
1056
+ if (typeof raw !== "string") return null;
1057
+ const context = JSON.parse(raw);
1058
+ if (Array.isArray(context.provider) && context.provider.length === 0) context.provider = {};
1059
+ return context;
1060
+ }
1061
+ async reserveDpopProof(input) {
1062
+ input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
1063
+ if (typeof input.replayKey !== "string" || !Number.isSafeInteger(input.expiresAt)) return false;
1064
+ const result = await this.runScript(buildReserveDpopProofCommand(this.keys, input, this.dpopReplayMaxEntries));
1065
+ if (result === 2 || result === "2") throw new import_express_oidc_vault.OidcVaultDpopReplayCapacityError();
1066
+ return result === 1 || result === "1";
1067
+ }
1068
+ /**
1069
+ * Creates a session record atomically, then runs best-effort index
1070
+ * maintenance.
1071
+ *
1072
+ * Result policy (SVH-02): once `writeSessionRecord` reports success the
1073
+ * session is committed and this method resolves with the session even if
1074
+ * post-commit index maintenance (`SCAN`/`TYPE`/`TIME`/`ZREMRANGEBYSCORE`)
1075
+ * fails. A maintenance failure is reported once via a fixed-text
1076
+ * `console.warn` identifying only the operation (no adapter error details,
1077
+ * session IDs, keys, or token material) and is retried opportunistically by
1078
+ * a later `createSession`/`rotateSession` call. Mutation-command failures (the
1079
+ * atomic write script) still reject, and this method never retries a
1080
+ * committed mutation.
1081
+ */
643
1082
  async createSession(input) {
1083
+ input = snapshotInput({ ...input });
1084
+ assertSessionBinding(input);
644
1085
  const timestamp = this.now();
645
1086
  const session = {
646
1087
  ...input,
@@ -652,13 +1093,17 @@ var RedisOidcVaultStore = class {
652
1093
  if (!written) {
653
1094
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session already exists.");
654
1095
  }
655
- await this.cleanupStaleIndexKeys();
1096
+ await this.runPostCommitIndexMaintenance("createSession");
656
1097
  return session;
657
1098
  }
658
1099
  async getSession(sessionId) {
659
- return this.getJson(this.keys.session(sessionId), "session", validateSession, { deleteMalformed: true });
1100
+ return this.getJson(this.keys.session(sessionId), "session", (value) => validateSession(value, sessionId), {
1101
+ deleteMalformed: true
1102
+ });
660
1103
  }
661
1104
  async rotateSession(input) {
1105
+ input = { sessionId: input.sessionId, nextSession: snapshotInput({ ...input.nextSession }) };
1106
+ assertSessionBinding(input.nextSession);
662
1107
  const previousSession = await this.getSession(input.sessionId);
663
1108
  if (!previousSession) {
664
1109
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
@@ -666,15 +1111,19 @@ var RedisOidcVaultStore = class {
666
1111
  if (input.nextSession.sessionId === input.sessionId) {
667
1112
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
668
1113
  }
1114
+ if (input.nextSession.deviceBinding !== void 0 && input.nextSession.deviceBinding.jkt !== previousSession.deviceBinding?.jkt) {
1115
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
1116
+ }
669
1117
  const nextSession = {
670
1118
  ...input.nextSession,
1119
+ ...previousSession.deviceBinding === void 0 ? {} : { deviceBinding: { ...previousSession.deviceBinding } },
671
1120
  logicalSessionId: input.nextSession.logicalSessionId ?? previousSession.logicalSessionId ?? previousSession.sessionId
672
1121
  };
673
1122
  const rotated = await this.rotateSessionRecord(previousSession, nextSession);
674
1123
  if (!rotated) {
675
1124
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
676
1125
  }
677
- await this.cleanupStaleIndexKeys();
1126
+ await this.runPostCommitIndexMaintenance("rotateSession");
678
1127
  return nextSession;
679
1128
  }
680
1129
  async deleteSession(sessionId) {
@@ -688,13 +1137,11 @@ var RedisOidcVaultStore = class {
688
1137
  );
689
1138
  if (logicalSessionId) {
690
1139
  await this.deleteSessionsByLogicalSessionId(logicalSessionId);
691
- await this.deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId);
692
1140
  }
693
- await this.client.del(this.keys.session(sessionId));
1141
+ await this.deleteSessionKeyIfMalformed(sessionId);
694
1142
  return;
695
1143
  }
696
1144
  await this.deleteSessionRecord(session, { kind: "single" });
697
- await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
698
1145
  }
699
1146
  async deleteSessionsByLogicalSessionId(input) {
700
1147
  const resolved = toLogicalSessionDeleteInput(input);
@@ -724,18 +1171,21 @@ var RedisOidcVaultStore = class {
724
1171
  await this.client.set(key, serialize(value), options);
725
1172
  }
726
1173
  async getJson(key, recordKind, validate, options) {
1174
+ const raw = await this.client.get(key);
727
1175
  try {
728
- return parseStoredJson(await this.client.get(key), recordKind, validate);
1176
+ const record = parseStoredJson(raw, recordKind, validate);
1177
+ if (record !== null && options?.checkExpiry && isPlainRecord(record) && typeof record.expiresAt === "number" && record.expiresAt <= await this.redisServerTime())
1178
+ return null;
1179
+ return record;
729
1180
  } catch (error) {
730
- if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError) {
731
- await this.client.del(key);
1181
+ if (options?.deleteMalformed && error instanceof OidcVaultRedisStoreRecordError && raw !== null) {
1182
+ await this.deleteMalformedValueIfUnchanged(key, raw);
732
1183
  return null;
733
1184
  }
734
1185
  throw error;
735
1186
  }
736
1187
  }
737
- async consumeJson(key, recordKind, validate) {
738
- const value = await this.sendCommand(["GETDEL", key]);
1188
+ parseOneTimeRecord(value, recordKind, validate) {
739
1189
  try {
740
1190
  return typeof value === "string" ? parseStoredJson(value, recordKind, validate) : null;
741
1191
  } catch (error) {
@@ -753,11 +1203,51 @@ var RedisOidcVaultStore = class {
753
1203
  const result = await this.runScript(buildDeleteSessionCommand(this.keys, session, scope));
754
1204
  return typeof result === "number" ? result : Number(result);
755
1205
  }
1206
+ /**
1207
+ * Removes a stored value only when it still equals the malformed payload
1208
+ * observed by a prior read (SVH-03). The equality check runs inside the Lua
1209
+ * script, so a concurrent `createSession` reusing the same ID is never
1210
+ * destroyed by a stale repair.
1211
+ */
1212
+ async deleteMalformedValueIfUnchanged(key, observedRaw) {
1213
+ await this.runScript(buildCompareAndDeleteCommand(key, observedRaw));
1214
+ }
1215
+ /**
1216
+ * Best-effort cleanup for the missing-session branch of `deleteSession`.
1217
+ * Re-reads the session key and removes it only when it still holds malformed
1218
+ * data. A missing key needs no cleanup and a freshly created valid session
1219
+ * (same-ID reuse after the earlier read) is preserved; genuine logout of a
1220
+ * live session never reaches this branch.
1221
+ */
1222
+ async deleteSessionKeyIfMalformed(sessionId) {
1223
+ const sessionKey = this.keys.session(sessionId);
1224
+ let raw;
1225
+ try {
1226
+ raw = await this.client.get(sessionKey);
1227
+ } catch {
1228
+ return;
1229
+ }
1230
+ if (raw === null) {
1231
+ return;
1232
+ }
1233
+ try {
1234
+ parseStoredJson(raw, "session", (value) => validateSession(value, sessionId));
1235
+ } catch (error) {
1236
+ if (error instanceof OidcVaultRedisStoreRecordError) {
1237
+ await this.deleteMalformedValueIfUnchanged(sessionKey, raw);
1238
+ return;
1239
+ }
1240
+ throw error;
1241
+ }
1242
+ }
756
1243
  async rotateSessionRecord(previousSession, nextSession) {
757
1244
  const result = await this.runScript(buildRotateSessionCommand(this.keys, previousSession, nextSession));
758
1245
  if (result === 2 || result === "2") {
759
1246
  throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
760
1247
  }
1248
+ if (result === 3 || result === "3") {
1249
+ throw new import_express_oidc_vault.OidcVaultStoreConflictError("OIDC vault session changed before rotation could commit.");
1250
+ }
761
1251
  return result === 1 || result === "1";
762
1252
  }
763
1253
  async runScript(command) {
@@ -766,6 +1256,20 @@ var RedisOidcVaultStore = class {
766
1256
  async cleanupExpiredIndexMembers(indexKey) {
767
1257
  await this.sendCommand(["ZREMRANGEBYSCORE", indexKey, "-inf", String(await this.redisServerTime())]);
768
1258
  }
1259
+ /**
1260
+ * Runs optional post-commit index maintenance without letting it masquerade
1261
+ * as a failed mutation. Any failure is reported once via a fixed-text
1262
+ * warning and swallowed so the already-committed session result stands; the
1263
+ * next successful create/rotate retries the incremental scan from the
1264
+ * retained cursor. Never retries the committed mutation itself.
1265
+ */
1266
+ async runPostCommitIndexMaintenance(operation) {
1267
+ try {
1268
+ await this.cleanupStaleIndexKeys();
1269
+ } catch {
1270
+ console.warn(`OIDC vault Redis index maintenance failed after ${operation} and will retry on a later write.`);
1271
+ }
1272
+ }
769
1273
  async cleanupStaleIndexKeys() {
770
1274
  const response = await this.sendCommand([
771
1275
  "SCAN",
@@ -816,10 +1320,6 @@ var RedisOidcVaultStore = class {
816
1320
  }
817
1321
  return seconds * 1e3 + Math.floor(microseconds / 1e3);
818
1322
  }
819
- async getSessionIdsFromIndex(indexKey) {
820
- const response = await this.sendCommand(["ZRANGE", indexKey, "0", "-1"]);
821
- return Array.isArray(response) ? response.filter((value) => typeof value === "string") : [];
822
- }
823
1323
  async scanSessionIdsFromIndex(indexKey, cursor) {
824
1324
  const response = await this.sendCommand(["ZSCAN", indexKey, cursor, "COUNT", String(INDEX_REVOCATION_SCAN_COUNT)]);
825
1325
  if (!Array.isArray(response) || typeof response[0] !== "string" || !Array.isArray(response[1])) {
@@ -844,21 +1344,25 @@ var RedisOidcVaultStore = class {
844
1344
  throw new Error("OIDC vault Redis store received an unexpected MGET response.");
845
1345
  }
846
1346
  return Promise.all(
847
- response.map(async (value, index) => {
1347
+ sessionIds.map(async (sessionId, index) => {
1348
+ const value = response[index];
1349
+ const raw = typeof value === "string" ? value : null;
848
1350
  try {
849
- return typeof value === "string" ? parseStoredJson(value, "session", validateSession) : null;
1351
+ return { session: parseStoredJson(raw, "session", (parsed) => validateSession(parsed, sessionId)), raw };
850
1352
  } catch (error) {
851
- if (error instanceof OidcVaultRedisStoreRecordError) {
852
- await this.client.del(this.keys.session(sessionIds[index]));
853
- return null;
1353
+ if (error instanceof OidcVaultRedisStoreRecordError && typeof value === "string") {
1354
+ await this.deleteMalformedValueIfUnchanged(this.keys.session(sessionId), value);
1355
+ return { session: null, raw };
854
1356
  }
855
1357
  throw error;
856
1358
  }
857
1359
  })
858
1360
  );
859
1361
  }
860
- async removeSessionIdFromIndex(indexKey, sessionId) {
861
- await this.sendCommand(["ZREM", indexKey, sessionId]);
1362
+ async removeSessionIdFromIndex(indexKey, sessionId, observedRaw) {
1363
+ await this.runScript(
1364
+ buildRepairSessionIndexCommand(this.keys.session(sessionId), indexKey, sessionId, observedRaw)
1365
+ );
862
1366
  }
863
1367
  async deleteSessionsFromIndex(indexKey, scope) {
864
1368
  await this.cleanupExpiredIndexMembers(indexKey);
@@ -872,21 +1376,21 @@ var RedisOidcVaultStore = class {
872
1376
  }
873
1377
  const sessions = await this.getSessionRecords(batch.sessionIds);
874
1378
  for (const [index, sessionId] of batch.sessionIds.entries()) {
875
- const session = sessions[index];
1379
+ const { session, raw } = sessions[index] ?? { session: null, raw: null };
876
1380
  if (!session) {
877
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1381
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
878
1382
  continue;
879
1383
  }
880
1384
  if ("logicalSessionId" in scope && (session.logicalSessionId ?? session.sessionId) !== scope.logicalSessionId) {
881
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1385
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
882
1386
  continue;
883
1387
  }
884
1388
  if ("subject" in scope && session.subject !== scope.subject) {
885
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1389
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
886
1390
  continue;
887
1391
  }
888
1392
  if ("providerSessionId" in scope && session.providerSessionId !== scope.providerSessionId) {
889
- await this.removeSessionIdFromIndex(indexKey, sessionId);
1393
+ await this.removeSessionIdFromIndex(indexKey, sessionId, raw);
890
1394
  continue;
891
1395
  }
892
1396
  if (!("logicalSessionId" in scope) && !matchesProviderScope(session, scope)) {
@@ -894,11 +1398,13 @@ var RedisOidcVaultStore = class {
894
1398
  }
895
1399
  const revoked = await this.deleteSessionRecord(session, this.toDeleteScriptScope(scope));
896
1400
  if (revoked > 0) {
897
- await this.deleteRotatedSessionAliasesByLogicalSessionId(session.logicalSessionId ?? session.sessionId);
898
1401
  deleted += revoked;
899
1402
  }
900
1403
  }
901
1404
  } while (cursor !== "0");
1405
+ if (deleted === 0 && "logicalSessionId" in scope) {
1406
+ await this.runScript(buildCleanupInactiveAliasesCommand(this.keys, scope.logicalSessionId));
1407
+ }
902
1408
  return deleted;
903
1409
  }
904
1410
  toDeleteScriptScope(scope) {
@@ -915,16 +1421,6 @@ var RedisOidcVaultStore = class {
915
1421
  clientId: scope.clientId
916
1422
  };
917
1423
  }
918
- async deleteRotatedSessionAliasesByLogicalSessionId(logicalSessionId) {
919
- const aliasIndexKey = this.keys.rotatedSessionAliasIndex(logicalSessionId);
920
- await this.cleanupExpiredIndexMembers(aliasIndexKey);
921
- const aliasSessionIds = await this.getSessionIdsFromIndex(aliasIndexKey);
922
- if (aliasSessionIds.length === 0) {
923
- return;
924
- }
925
- await this.client.del(aliasSessionIds.map((sessionId) => this.keys.rotatedSessionAlias(sessionId)));
926
- await this.client.del(aliasIndexKey);
927
- }
928
1424
  async sendCommand(args) {
929
1425
  return this.client.sendCommand(args);
930
1426
  }