@web-ts-toolkit/express-oidc-vault-memory-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.mjs CHANGED
@@ -1,10 +1,133 @@
1
1
  // src/index.ts
2
2
  import {
3
- OidcVaultStoreConflictError
3
+ OidcVaultStoreConflictError as OidcVaultStoreConflictError2
4
4
  } from "@web-ts-toolkit/express-oidc-vault";
5
+
6
+ // src/binding.ts
7
+ import { OidcVaultStoreConflictError } from "@web-ts-toolkit/express-oidc-vault";
8
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
9
+ var isHash = (value) => typeof value === "string" && value.length === 43 && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
10
+ var isBinding = (value) => isRecord(value) && Object.keys(value).length === 2 && Object.hasOwn(value, "type") && Object.hasOwn(value, "jkt") && value.type === "dpop" && isHash(value.jkt);
11
+ var hasValidBinding = (record) => record.deviceBinding === void 0 || isBinding(record.deviceBinding);
12
+ var hasValidRecordBinding = (record) => hasValidBinding(record) && (record.browserBindingHash === void 0 || isHash(record.browserBindingHash)) && (record.deviceBinding === void 0 || record.browserBindingHash !== void 0);
13
+ var assertRecordBinding = (record) => {
14
+ if (!hasValidRecordBinding(record))
15
+ throw new TypeError("OIDC vault store record has invalid device/browser binding.");
16
+ };
17
+ var assertSessionBinding = (record) => {
18
+ if (!hasValidBinding(record)) throw new TypeError("OIDC vault store session has invalid device binding.");
19
+ };
20
+ var isBindingMatch = (match) => isRecord(match) && Object.hasOwn(match, "deviceBinding") && Object.hasOwn(match, "browserBindingHash") && (match.deviceBinding === null || isBinding(match.deviceBinding)) && (match.browserBindingHash === null || isHash(match.browserBindingHash));
21
+ var matchesRecordBinding = (record, match) => (match.deviceBinding === null ? record.deviceBinding === void 0 : record.deviceBinding?.type === match.deviceBinding.type && record.deviceBinding.jkt === match.deviceBinding.jkt) && (match.browserBindingHash === null ? record.browserBindingHash === void 0 : record.browserBindingHash === match.browserBindingHash);
22
+ var inheritSessionBinding = (source, next) => {
23
+ assertSessionBinding(source);
24
+ assertSessionBinding(next);
25
+ if (next.deviceBinding !== void 0 && next.deviceBinding.jkt !== source.deviceBinding?.jkt) {
26
+ throw new OidcVaultStoreConflictError("OIDC vault session rotation cannot change or add device binding.");
27
+ }
28
+ if (source.deviceBinding !== void 0) next.deviceBinding = { type: "dpop", jkt: source.deviceBinding.jkt };
29
+ };
30
+ var sessionRevocationContext = (session) => {
31
+ assertSessionBinding(session);
32
+ if (typeof session.sessionId !== "string" || typeof session.subject !== "string" || session.logicalSessionId !== void 0 && typeof session.logicalSessionId !== "string") {
33
+ throw new Error("OIDC vault store lineage has malformed session authority.");
34
+ }
35
+ const provider = session.provider;
36
+ if (provider !== void 0 && (!isRecord(provider) || provider.issuer !== void 0 && typeof provider.issuer !== "string" || provider.clientId !== void 0 && typeof provider.clientId !== "string")) {
37
+ throw new Error("OIDC vault store lineage has malformed provider identity.");
38
+ }
39
+ return {
40
+ logicalSessionId: session.logicalSessionId ?? session.sessionId,
41
+ ...provider === void 0 ? {} : {
42
+ provider: {
43
+ ...typeof provider.issuer === "string" ? { issuer: provider.issuer } : {},
44
+ ...typeof provider.clientId === "string" ? { clientId: provider.clientId } : {}
45
+ }
46
+ },
47
+ ...session.deviceBinding === void 0 ? {} : { deviceBinding: { ...session.deviceBinding } }
48
+ };
49
+ };
50
+ var hasValidSessionRecord = (session, sessionId) => session.sessionId === sessionId && typeof session.subject === "string" && (session.logicalSessionId === void 0 || typeof session.logicalSessionId === "string") && typeof session.refreshToken === "string" && typeof session.idToken === "string" && (session.expiresAt === void 0 || Number.isFinite(session.expiresAt)) && hasValidBinding(session);
51
+ var assertSameLineageAuthority = (source, member) => {
52
+ assertSessionBinding(member);
53
+ const authority = sessionRevocationContext(member);
54
+ if (source.deviceBinding?.jkt !== authority.deviceBinding?.jkt || source.subject !== member.subject || source.provider?.issuer !== authority.provider?.issuer || source.provider?.clientId !== authority.provider?.clientId) {
55
+ throw new Error("OIDC vault store lineage has inconsistent device binding or provider identity.");
56
+ }
57
+ };
58
+
59
+ // src/dpop-replay.ts
60
+ import { OidcVaultDpopReplayCapacityError } from "@web-ts-toolkit/express-oidc-vault";
61
+ var DpopReplayReservations = class {
62
+ constructor(maxEntries) {
63
+ this.maxEntries = maxEntries;
64
+ this.reservations = /* @__PURE__ */ new Map();
65
+ this.expiryHeap = [];
66
+ }
67
+ reserve({ replayKey, expiresAt }, now) {
68
+ if (!Number.isFinite(now)) throw new Error("OIDC vault DPoP replay store clock is invalid.");
69
+ if (typeof replayKey !== "string" || !Number.isSafeInteger(expiresAt) || expiresAt <= now || expiresAt - now > 36e4) {
70
+ return false;
71
+ }
72
+ const existing = this.reservations.get(replayKey);
73
+ if (existing && existing.expiresAt > now) return false;
74
+ if (existing) this.remove(existing.index);
75
+ for (let cleaned = 0; cleaned < 64 && this.expiryHeap[0]?.expiresAt <= now; cleaned += 1) this.remove(0);
76
+ if (this.reservations.size >= this.maxEntries) throw new OidcVaultDpopReplayCapacityError();
77
+ const reservation = { replayKey, expiresAt, index: this.expiryHeap.length };
78
+ this.reservations.set(replayKey, reservation);
79
+ this.expiryHeap.push(reservation);
80
+ this.moveUp(reservation.index);
81
+ return true;
82
+ }
83
+ remove(index) {
84
+ const removed = this.expiryHeap[index];
85
+ const last = this.expiryHeap.pop();
86
+ this.reservations.delete(removed.replayKey);
87
+ if (index === this.expiryHeap.length) return;
88
+ this.expiryHeap[index] = last;
89
+ last.index = index;
90
+ this.moveUp(index);
91
+ this.moveDown(last.index);
92
+ }
93
+ swap(left, right) {
94
+ [this.expiryHeap[left], this.expiryHeap[right]] = [this.expiryHeap[right], this.expiryHeap[left]];
95
+ this.expiryHeap[left].index = left;
96
+ this.expiryHeap[right].index = right;
97
+ }
98
+ moveUp(index) {
99
+ while (index > 0) {
100
+ const parent = Math.floor((index - 1) / 2);
101
+ if (this.expiryHeap[parent].expiresAt <= this.expiryHeap[index].expiresAt) break;
102
+ this.swap(parent, index);
103
+ index = parent;
104
+ }
105
+ }
106
+ moveDown(index) {
107
+ for (; ; ) {
108
+ const left = index * 2 + 1;
109
+ if (left >= this.expiryHeap.length) return;
110
+ const right = left + 1;
111
+ const child = right < this.expiryHeap.length && this.expiryHeap[right].expiresAt < this.expiryHeap[left].expiresAt ? right : left;
112
+ if (this.expiryHeap[index].expiresAt <= this.expiryHeap[child].expiresAt) return;
113
+ this.swap(index, child);
114
+ index = child;
115
+ }
116
+ }
117
+ };
118
+
119
+ // src/index.ts
5
120
  var cloneRecord = (value) => structuredClone(value);
6
121
  var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
7
122
  var EXPIRY_SWEEP_BATCH_SIZE = 64;
123
+ var createMapSweepState = () => ({ keys: [], position: 0 });
124
+ var createMemoryStoreSweepWork = () => ({
125
+ inspectedKeys: 0,
126
+ staleSnapshotSlots: 0,
127
+ snapshotRebuilds: 0,
128
+ aliasSessionVisits: 0,
129
+ aliasEntryVisits: 0
130
+ });
8
131
  var matchesProviderScope = (session, input) => {
9
132
  if (input.issuer !== void 0 && session.provider?.issuer !== input.issuer) {
10
133
  return false;
@@ -24,68 +147,105 @@ var MemoryOidcVaultStore = class {
24
147
  this.sessions = /* @__PURE__ */ new Map();
25
148
  this.rotatedSessionAliases = /* @__PURE__ */ new Map();
26
149
  this.backchannelLogoutTokenJtis = /* @__PURE__ */ new Map();
150
+ this.authorizationTransactionSweep = createMapSweepState();
151
+ this.exchangeCodeSweep = createMapSweepState();
152
+ this.sessionSweep = createMapSweepState();
153
+ this.rotatedSessionAliasSweep = createMapSweepState();
154
+ this.backchannelLogoutTokenJtiSweep = createMapSweepState();
155
+ this.sweepWork = createMemoryStoreSweepWork();
156
+ const maxEntries = options.dpopReplayMaxEntries === void 0 ? 1e5 : options.dpopReplayMaxEntries;
157
+ if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) {
158
+ throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
159
+ }
160
+ this.dpopProofs = new DpopReplayReservations(maxEntries);
27
161
  this.now = options.now ?? (() => Date.now());
28
162
  }
29
163
  async createAuthorizationTransaction(input) {
164
+ input = cloneRecord(input);
165
+ assertRecordBinding(input);
30
166
  const now = this.now();
31
- this.authorizationTransactionSweepCursor = this.pruneMapBatch(
32
- this.authorizationTransactions,
33
- now,
34
- this.authorizationTransactionSweepCursor
35
- );
36
- this.authorizationTransactions.set(input.state, cloneRecord(input));
167
+ this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
168
+ this.authorizationTransactions.set(input.state, input);
37
169
  }
38
170
  async consumeAuthorizationTransaction(state) {
171
+ return this.consumeAuthorizationTransactionIfMatches({
172
+ state,
173
+ match: { deviceBinding: null, browserBindingHash: null }
174
+ });
175
+ }
176
+ async getAuthorizationTransaction(state) {
177
+ const record = this.readOneTimeRecord(this.authorizationTransactions, state, this.now());
178
+ return record && record.state === state && typeof record.nonce === "string" && typeof record.pkceVerifier === "string" && typeof record.codeChallenge === "string" ? cloneRecord(record) : null;
179
+ }
180
+ async consumeAuthorizationTransactionIfMatches(input) {
181
+ input = cloneRecord(input);
182
+ if (!isBindingMatch(input.match)) return null;
39
183
  const now = this.now();
40
- this.authorizationTransactionSweepCursor = this.pruneMapBatch(
41
- this.authorizationTransactions,
42
- now,
43
- this.authorizationTransactionSweepCursor
44
- );
45
- const record = this.authorizationTransactions.get(state);
46
- if (!record) {
184
+ this.pruneMapBatch(this.authorizationTransactions, this.authorizationTransactionSweep, now);
185
+ const record = this.readOneTimeRecord(this.authorizationTransactions, input.state, now);
186
+ if (!record || record.state !== input.state || typeof record.nonce !== "string" || typeof record.pkceVerifier !== "string" || typeof record.codeChallenge !== "string" || !matchesRecordBinding(record, input.match))
47
187
  return null;
48
- }
49
- this.authorizationTransactions.delete(state);
50
- if (isExpiredRecord(record, now)) {
51
- return null;
52
- }
188
+ this.authorizationTransactions.delete(input.state);
53
189
  return cloneRecord(record);
54
190
  }
55
191
  async createExchangeCode(input) {
192
+ input = cloneRecord(input);
193
+ assertRecordBinding(input);
56
194
  const now = this.now();
57
- this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
58
- this.exchangeCodes.set(input.code, cloneRecord(input));
195
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
196
+ this.exchangeCodes.set(input.code, input);
59
197
  }
60
198
  async consumeExchangeCode(code) {
61
199
  const now = this.now();
62
- this.exchangeCodeSweepCursor = this.pruneMapBatch(this.exchangeCodes, now, this.exchangeCodeSweepCursor);
63
- const record = this.exchangeCodes.get(code);
64
- if (!record) {
200
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
201
+ return this.consumeExchangeRecord(code, { deviceBinding: null, browserBindingHash: null }, now);
202
+ }
203
+ async getExchangeCode(code) {
204
+ const record = this.readOneTimeRecord(this.exchangeCodes, code, this.now());
205
+ return record && record.code === code && typeof record.sessionId === "string" ? cloneRecord(record) : null;
206
+ }
207
+ async consumeExchangeCodeIfMatches(input) {
208
+ input = cloneRecord(input);
209
+ if (!isBindingMatch(input.match) || typeof input.expectedSessionId !== "string") return null;
210
+ const now = this.now();
211
+ this.pruneMapBatch(this.exchangeCodes, this.exchangeCodeSweep, now);
212
+ return this.consumeExchangeRecord(input.code, input.match, now, input.expectedSessionId);
213
+ }
214
+ consumeExchangeRecord(code, match, now, expectedSessionId) {
215
+ const record = this.readOneTimeRecord(this.exchangeCodes, code, now);
216
+ if (!record || record.code !== code || typeof record.sessionId !== "string" || expectedSessionId !== void 0 && record.sessionId !== expectedSessionId || !matchesRecordBinding(record, match))
65
217
  return null;
66
- }
67
218
  this.exchangeCodes.delete(code);
68
- if (isExpiredRecord(record, now)) {
219
+ return cloneRecord(record);
220
+ }
221
+ readOneTimeRecord(map, id, now) {
222
+ const record = map.get(id);
223
+ if (!record) return null;
224
+ if (!Number.isFinite(record.createdAt) || !Number.isFinite(record.expiresAt) || isExpiredRecord(record, now) || !hasValidRecordBinding(record)) {
225
+ map.delete(id);
69
226
  return null;
70
227
  }
71
- return cloneRecord(record);
228
+ return record;
72
229
  }
73
230
  async createSession(input) {
231
+ input = cloneRecord(input);
232
+ assertSessionBinding(input);
74
233
  const timestamp = this.now();
75
234
  this.pruneSessionsBatch(timestamp);
76
- this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
77
- this.rotatedSessionAliases,
78
- timestamp,
79
- this.rotatedSessionAliasSweepCursor
80
- );
235
+ this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, timestamp);
81
236
  const session = {
82
- ...cloneRecord(input),
237
+ ...input,
83
238
  logicalSessionId: input.logicalSessionId ?? input.sessionId,
84
239
  createdAt: input.createdAt ?? timestamp,
85
240
  updatedAt: input.updatedAt ?? timestamp
86
241
  };
242
+ const previousSession = this.sessions.get(session.sessionId);
243
+ const previousLogicalSessionId = previousSession?.logicalSessionId ?? previousSession?.sessionId;
87
244
  this.sessions.set(session.sessionId, session);
88
245
  this.rotatedSessionAliases.delete(session.sessionId);
246
+ if (previousLogicalSessionId !== void 0 && previousLogicalSessionId !== session.logicalSessionId) {
247
+ this.removeAliasesForInactiveLogicalSession(previousLogicalSessionId, timestamp);
248
+ }
89
249
  return cloneRecord(session);
90
250
  }
91
251
  async getSession(sessionId) {
@@ -93,63 +253,97 @@ var MemoryOidcVaultStore = class {
93
253
  const session = this.sessions.get(sessionId);
94
254
  if (session && isExpiredRecord(session, now)) {
95
255
  this.sessions.delete(sessionId);
96
- this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId);
256
+ this.removeAliasesForInactiveLogicalSession(session.logicalSessionId ?? session.sessionId, now);
97
257
  return null;
98
258
  }
99
- return session ? cloneRecord(session) : null;
259
+ return session && hasValidSessionRecord(session, sessionId) ? cloneRecord(session) : null;
260
+ }
261
+ async getSessionRevocationContext(sessionId) {
262
+ const now = this.now();
263
+ const live = this.sessions.get(sessionId);
264
+ const alias = this.rotatedSessionAliases.get(sessionId);
265
+ if (live && !isExpiredRecord(live, now)) {
266
+ if (live.sessionId !== sessionId) throw new Error("OIDC vault store has malformed revocation authority.");
267
+ sessionRevocationContext(live);
268
+ }
269
+ if ((!live || isExpiredRecord(live, now)) && alias && (typeof alias.logicalSessionId !== "string" || alias.expiresAt !== void 0 && !Number.isFinite(alias.expiresAt))) {
270
+ throw new Error("OIDC vault store has malformed revocation alias.");
271
+ }
272
+ const logicalSessionId = live && !isExpiredRecord(live, now) ? live.logicalSessionId ?? live.sessionId : alias && !isExpiredRecord(alias, now) ? alias.logicalSessionId : void 0;
273
+ if (logicalSessionId === void 0) return null;
274
+ if (typeof logicalSessionId !== "string") throw new Error("OIDC vault store has malformed revocation authority.");
275
+ let source;
276
+ for (const member of this.sessions.values()) {
277
+ if ((member.logicalSessionId ?? member.sessionId) !== logicalSessionId || isExpiredRecord(member, now)) continue;
278
+ if (member.sessionId === void 0 || typeof member.subject !== "string" || member.expiresAt !== void 0 && !Number.isFinite(member.expiresAt)) {
279
+ throw new Error("OIDC vault store lineage has malformed session authority.");
280
+ }
281
+ sessionRevocationContext(member);
282
+ if (source) assertSameLineageAuthority(source, member);
283
+ else source = member;
284
+ }
285
+ return source ? sessionRevocationContext(source) : null;
286
+ }
287
+ async reserveDpopProof(input) {
288
+ input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
289
+ return this.dpopProofs.reserve(input, this.now());
100
290
  }
101
291
  async rotateSession(input) {
102
292
  const timestamp = this.now();
103
293
  const sourceSession = this.sessions.get(input.sessionId);
104
294
  if (sourceSession && isExpiredRecord(sourceSession, timestamp)) {
105
295
  this.sessions.delete(input.sessionId);
106
- this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId);
296
+ this.removeAliasesForInactiveLogicalSession(sourceSession.logicalSessionId ?? sourceSession.sessionId, timestamp);
107
297
  }
108
298
  const liveSourceSession = this.sessions.get(input.sessionId);
109
- if (!liveSourceSession) {
110
- throw new OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
299
+ if (!liveSourceSession || !hasValidSessionRecord(liveSourceSession, input.sessionId)) {
300
+ throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
111
301
  }
112
302
  if (input.nextSession.sessionId === input.sessionId) {
113
- throw new OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
303
+ throw new OidcVaultStoreConflictError2("OIDC vault session rotation target must use a different session ID.");
114
304
  }
115
305
  const existingTargetSession = this.sessions.get(input.nextSession.sessionId);
116
- if (existingTargetSession && isExpiredRecord(existingTargetSession, timestamp)) {
117
- this.sessions.delete(input.nextSession.sessionId);
118
- this.removeAliasesForInactiveLogicalSession(
119
- existingTargetSession.logicalSessionId ?? existingTargetSession.sessionId
120
- );
121
- } else if (existingTargetSession) {
122
- throw new OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
306
+ if (existingTargetSession && !isExpiredRecord(existingTargetSession, timestamp)) {
307
+ throw new OidcVaultStoreConflictError2("OIDC vault session rotation target already exists.");
123
308
  }
124
309
  const nextSession = cloneRecord(input.nextSession);
310
+ inheritSessionBinding(liveSourceSession, nextSession);
125
311
  const session = {
126
312
  ...nextSession,
127
313
  logicalSessionId: nextSession.logicalSessionId ?? liveSourceSession.logicalSessionId ?? liveSourceSession.sessionId,
128
314
  createdAt: nextSession.createdAt ?? timestamp,
129
315
  updatedAt: nextSession.updatedAt ?? timestamp
130
316
  };
317
+ if (existingTargetSession) {
318
+ this.sessions.delete(session.sessionId);
319
+ this.removeAliasesForInactiveLogicalSession(
320
+ existingTargetSession.logicalSessionId ?? existingTargetSession.sessionId,
321
+ timestamp
322
+ );
323
+ }
324
+ const previousLogicalSessionId = liveSourceSession.logicalSessionId ?? liveSourceSession.sessionId;
131
325
  this.sessions.delete(input.sessionId);
132
326
  this.sessions.set(session.sessionId, session);
327
+ this.rotatedSessionAliases.delete(session.sessionId);
133
328
  this.rotatedSessionAliases.set(input.sessionId, {
134
329
  logicalSessionId: session.logicalSessionId ?? session.sessionId,
135
330
  expiresAt: session.expiresAt
136
331
  });
332
+ if (previousLogicalSessionId !== session.logicalSessionId) {
333
+ this.removeAliasesForInactiveLogicalSession(previousLogicalSessionId, timestamp);
334
+ }
137
335
  return cloneRecord(session);
138
336
  }
139
337
  async deleteSession(sessionId) {
140
338
  const now = this.now();
141
339
  this.pruneSessionsBatch(now);
142
- this.rotatedSessionAliasSweepCursor = this.pruneMapBatch(
143
- this.rotatedSessionAliases,
144
- now,
145
- this.rotatedSessionAliasSweepCursor
146
- );
340
+ this.pruneMapBatch(this.rotatedSessionAliases, this.rotatedSessionAliasSweep, now);
147
341
  const session = this.sessions.get(sessionId);
148
342
  if (session) {
149
343
  const logicalSessionId = session.logicalSessionId ?? session.sessionId;
150
344
  this.sessions.delete(sessionId);
151
345
  this.rotatedSessionAliases.delete(sessionId);
152
- this.removeAliasesForInactiveLogicalSession(logicalSessionId);
346
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId, now);
153
347
  return;
154
348
  }
155
349
  const alias = this.rotatedSessionAliases.get(sessionId);
@@ -178,17 +372,13 @@ var MemoryOidcVaultStore = class {
178
372
  deleted += 1;
179
373
  }
180
374
  }
181
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
375
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
182
376
  this.removeAliasesForLogicalSession(resolved.logicalSessionId);
183
377
  return deleted;
184
378
  }
185
379
  async consumeBackchannelLogoutTokenJti(input) {
186
380
  const now = this.now();
187
- this.backchannelLogoutTokenJtiSweepCursor = this.pruneMapBatch(
188
- this.backchannelLogoutTokenJtis,
189
- now,
190
- this.backchannelLogoutTokenJtiSweepCursor
191
- );
381
+ this.pruneMapBatch(this.backchannelLogoutTokenJtis, this.backchannelLogoutTokenJtiSweep, now);
192
382
  if (!Number.isFinite(input.expiresAt) || input.expiresAt <= now) {
193
383
  return false;
194
384
  }
@@ -217,8 +407,8 @@ var MemoryOidcVaultStore = class {
217
407
  deleted += 1;
218
408
  }
219
409
  }
220
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
221
- this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
410
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
411
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds, now);
222
412
  return deleted;
223
413
  }
224
414
  async deleteSessionsByProviderSessionId(input) {
@@ -239,83 +429,118 @@ var MemoryOidcVaultStore = class {
239
429
  deleted += 1;
240
430
  }
241
431
  }
242
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
243
- this.removeAliasesForInactiveLogicalSessions(logicalSessionIds);
432
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
433
+ this.removeAliasesForInactiveLogicalSessions(logicalSessionIds, now);
244
434
  return deleted;
245
435
  }
246
- pruneMapBatch(map, now, cursor) {
247
- let inspected = 0;
248
- let nextCursor;
249
- for (const [key, value] of this.entriesAfterCursor(map, cursor)) {
436
+ pruneMapBatch(map, sweep, now) {
437
+ if (map.size === 0) {
438
+ sweep.keys = [];
439
+ sweep.position = 0;
440
+ return;
441
+ }
442
+ if (sweep.position >= sweep.keys.length) {
443
+ this.rebuildSweepSnapshot(map, sweep);
444
+ }
445
+ let visited = 0;
446
+ while (visited < EXPIRY_SWEEP_BATCH_SIZE) {
447
+ if (sweep.position >= sweep.keys.length) {
448
+ if (map.size <= sweep.keys.length) {
449
+ break;
450
+ }
451
+ this.rebuildSweepSnapshot(map, sweep);
452
+ }
453
+ const key = sweep.keys[sweep.position];
454
+ sweep.position += 1;
455
+ visited += 1;
456
+ const value = map.get(key);
457
+ if (value === void 0) {
458
+ this.sweepWork.staleSnapshotSlots += 1;
459
+ continue;
460
+ }
461
+ this.sweepWork.inspectedKeys += 1;
250
462
  if (isExpiredRecord(value, now)) {
251
463
  map.delete(key);
252
464
  }
253
- inspected += 1;
254
- nextCursor = key;
255
- if (inspected >= EXPIRY_SWEEP_BATCH_SIZE) {
256
- return nextCursor;
257
- }
258
465
  }
259
- return void 0;
260
466
  }
261
- pruneSessionsBatch(now) {
262
- const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
263
- let inspected = 0;
264
- for (const [sessionId, session] of this.entriesAfterCursor(this.sessions, this.sessionSweepCursor)) {
265
- if (isExpiredRecord(session, now)) {
266
- expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
267
- this.sessions.delete(sessionId);
268
- }
269
- inspected += 1;
270
- this.sessionSweepCursor = sessionId;
271
- if (inspected >= EXPIRY_SWEEP_BATCH_SIZE) {
272
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
273
- return;
274
- }
275
- }
276
- this.sessionSweepCursor = void 0;
277
- this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds);
467
+ rebuildSweepSnapshot(map, sweep) {
468
+ sweep.keys = Array.from(map.keys());
469
+ sweep.position = 0;
470
+ this.sweepWork.snapshotRebuilds += 1;
278
471
  }
279
- *entriesAfterCursor(map, cursor) {
280
- if (!cursor || !map.has(cursor)) {
281
- yield* map.entries();
472
+ pruneSessionsBatch(now) {
473
+ const sweep = this.sessionSweep;
474
+ if (this.sessions.size === 0) {
475
+ sweep.keys = [];
476
+ sweep.position = 0;
282
477
  return;
283
478
  }
284
- let foundCursor = false;
285
- for (const entry of map.entries()) {
286
- if (!foundCursor) {
287
- foundCursor = entry[0] === cursor;
479
+ if (sweep.position >= sweep.keys.length) {
480
+ this.rebuildSweepSnapshot(this.sessions, sweep);
481
+ }
482
+ const expiredLogicalSessionIds = /* @__PURE__ */ new Set();
483
+ let visited = 0;
484
+ while (visited < EXPIRY_SWEEP_BATCH_SIZE) {
485
+ if (sweep.position >= sweep.keys.length) {
486
+ if (this.sessions.size <= sweep.keys.length) {
487
+ break;
488
+ }
489
+ this.rebuildSweepSnapshot(this.sessions, sweep);
490
+ }
491
+ const sessionId = sweep.keys[sweep.position];
492
+ sweep.position += 1;
493
+ visited += 1;
494
+ const session = this.sessions.get(sessionId);
495
+ if (session === void 0) {
496
+ this.sweepWork.staleSnapshotSlots += 1;
288
497
  continue;
289
498
  }
290
- yield entry;
291
- }
292
- for (const entry of map.entries()) {
293
- if (entry[0] === cursor) {
294
- return;
499
+ this.sweepWork.inspectedKeys += 1;
500
+ if (isExpiredRecord(session, now)) {
501
+ expiredLogicalSessionIds.add(session.logicalSessionId ?? session.sessionId);
502
+ this.sessions.delete(sessionId);
295
503
  }
296
- yield entry;
297
504
  }
505
+ this.removeAliasesForInactiveLogicalSessions(expiredLogicalSessionIds, now);
298
506
  }
299
- removeAliasesForInactiveLogicalSessions(logicalSessionIds) {
507
+ removeAliasesForInactiveLogicalSessions(logicalSessionIds, now) {
300
508
  for (const logicalSessionId of logicalSessionIds) {
301
- this.removeAliasesForInactiveLogicalSession(logicalSessionId);
509
+ this.removeAliasesForInactiveLogicalSession(logicalSessionId, now);
302
510
  }
303
511
  }
304
- removeAliasesForInactiveLogicalSession(logicalSessionId) {
305
- if (!this.hasLiveSessionForLogicalSession(logicalSessionId)) {
512
+ removeAliasesForInactiveLogicalSession(logicalSessionId, now) {
513
+ if (!this.hasLiveSessionForLogicalSession(logicalSessionId, now)) {
306
514
  this.removeAliasesForLogicalSession(logicalSessionId);
307
515
  }
308
516
  }
309
- hasLiveSessionForLogicalSession(logicalSessionId) {
517
+ /**
518
+ * Residual cost note (SVH-04): liveness is a full scan of `sessions`
519
+ * (early-out on the first live member), so per retired logical lineage the
520
+ * cleanup work is proportional to retained sessions, not to the 64-entry sweep
521
+ * batch. Visits are counted in `sweepWork.aliasSessionVisits` so this
522
+ * nested cost stays measurable and is never implied to be constant-time.
523
+ */
524
+ hasLiveSessionForLogicalSession(logicalSessionId, now) {
310
525
  for (const session of this.sessions.values()) {
311
- if ((session.logicalSessionId ?? session.sessionId) === logicalSessionId) {
526
+ this.sweepWork.aliasSessionVisits += 1;
527
+ if ((session.logicalSessionId ?? session.sessionId) === logicalSessionId && !isExpiredRecord(session, now)) {
312
528
  return true;
313
529
  }
314
530
  }
315
531
  return false;
316
532
  }
533
+ /**
534
+ * Residual cost note (SVH-04): alias removal scans every
535
+ * `rotatedSessionAliases` entry for the lineage, so per expired lineage the
536
+ * work is proportional to live aliases. Visits are counted in
537
+ * `sweepWork.aliasEntryVisits`. Bounding this further would need a
538
+ * lineage-to-alias index; that was deferred as unjustified complexity for
539
+ * the measured workloads (see README sweep-bounds note).
540
+ */
317
541
  removeAliasesForLogicalSession(logicalSessionId) {
318
542
  for (const [sessionId, alias] of this.rotatedSessionAliases.entries()) {
543
+ this.sweepWork.aliasEntryVisits += 1;
319
544
  if (alias.logicalSessionId === logicalSessionId) {
320
545
  this.rotatedSessionAliases.delete(sessionId);
321
546
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@web-ts-toolkit/express-oidc-vault-memory-store",
3
3
  "description": "In-memory store provider for @web-ts-toolkit/express-oidc-vault",
4
4
  "homepage": "https://web-ts-toolkit.pages.dev/docs/packages/express-oidc-vault-memory-store",
5
- "version": "0.43.0",
5
+ "version": "0.48.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -26,10 +26,10 @@
26
26
  }
27
27
  },
28
28
  "engines": {
29
- "node": ">=22"
29
+ "node": ">=22.12.0"
30
30
  },
31
31
  "dependencies": {
32
- "@web-ts-toolkit/express-oidc-vault": "0.43.0"
32
+ "@web-ts-toolkit/express-oidc-vault": "0.48.0"
33
33
  },
34
34
  "files": [
35
35
  "**/*",