@web-ts-toolkit/express-oidc-vault-memory-store 0.52.0 → 0.54.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/README.md CHANGED
@@ -94,6 +94,7 @@ The store keeps these record kinds in separate in-process maps:
94
94
  - rotated-session aliases, used only so an old public session ID can revoke the current logical session
95
95
  - backchannel logout token JTIs, consumed once until their expiry time
96
96
  - DPoP proof reservations with their own bounded expiry heap/capacity
97
+ - dedicated logout continuation tickets with independent capacity and removable expiry nodes
97
98
 
98
99
  `createAuthorizationTransaction`, `createExchangeCode`, and `createSession` are upserts; creating the same key again replaces the old value. A `createSession` replacement takes over the session ID with its own subject/logical/provider-session scope, and clears any stale rotation alias held under that ID so the reused ID cannot invoke an old logical-session meaning. Portable callers must still create sessions with a fresh unused ID and handle `OidcVaultStoreConflictError`: reusing a live ID replaces on memory/MongoDB but rejects on Redis. MongoDB also retains an alias row under a reused ID until expiry or lineage cleanup; only fresh-ID creation is portable.
99
100
 
@@ -128,6 +129,16 @@ All shared types (`OidcVaultDeviceBindingStoreProvider`, `OidcVaultRecordBinding
128
129
 
129
130
  Session rotation inherits omitted/undefined binding and accepts the same original key; it rejects null/malformed binding, a changed key, or adding a binding to an unbound source before mutation. Session IDs still follow the upsert/alias contracts above. A revocation alias never supplies historical or missing binding as proof of unboundness: all live lineage members must agree on binding/provider identity (malformed/inconsistent authority throws a fixed private diagnostic). Revocation context scans that lineage's candidates in the session map; it does not authenticate aliases through `getSession`.
130
131
 
132
+ ## Dedicated Logout Continuation Tickets
133
+
134
+ The inferred factory result satisfies both core `OidcVaultDeviceBindingStoreProvider` and `OidcVaultLogoutNavigationStoreProvider`. The latter requires `createLogoutNavigationTicket` / `consumeLogoutNavigationTicket`; they remain optional on the base custom-store contract. Import ticket/create/consume types and `OidcVaultLogoutNavigationCapacityError` from the **core root**. This storage extension does not enable HTTP routes.
135
+
136
+ `logoutNavigationMaxEntries` is a positive safe integer, default **10000**, shared across effective ticket namespaces in this object, independent of replay capacity. Live collisions throw `OidcVaultStoreConflictError` before capacity rejection, preserving destination/expiry. New admissions at capacity throw `OidcVaultLogoutNavigationCapacityError`, never evicting live grants. Exactly five scalar fields are captured before yielding and returned detached: `namespace`, `ticketHash`, `upstreamLogoutUrl`, `createdAt`, `expiresAt`. Invalid creates throw TypeError; invalid/unknown/spent/expired/malformed selected consumes return null and retire selected unusable data. Clock failures throw. Core's README defines canonical scope/hash formats, 256-bit generation, Date-range integer clocks/epochs, 1000..120000-ms original and maximum remaining lifetime, strict expiry, and 16384-byte HTTP(S) URL syntax.
137
+
138
+ A dedicated map and **removable indexed min-heap** retain one node per ticket. Check/remove has no asynchronous gap; arbitrary consumes remove their heap node immediately, without tombstones. Each ticket operation removes its selected entry plus at most **64 unrelated expiries**, with O(log N) heap work and no session/alias/replay sweeps. Unknown consumes allocate no entries. Expired payloads can remain idle until later ticket traffic or disposal, but are unusable and occupy the fixed cap. No timers. Only this same memory object shares consumption; separate objects/processes do not.
139
+
140
+ The already-built URL can retain an ID-token hint; hashing does not encrypt it. No raw ticket/session/profile/separate tokens are stored. Exclude records/URLs from diagnostics. Session/lineage/backchannel deletion does not cascade to authorized tickets; discarding the store loses them. Existing identity, alias, rotation and replay contracts remain intact.
141
+
131
142
  ## Per-Request DPoP Replay And Capacity
132
143
 
133
144
  | Memory option | Default | Contract |
package/index.d.mts CHANGED
@@ -1,7 +1,9 @@
1
- import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
1
+ import { OidcVaultDeviceBindingStoreProvider, OidcVaultLogoutNavigationStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
2
 
3
3
  /** Construction options for the named package-root memory-store factory. */
4
4
  interface MemoryOidcVaultStoreOptions {
5
+ /** Independent physical-store ticket cap, shared across namespaces; default 10000. No live eviction. */
6
+ logoutNavigationMaxEntries?: number;
5
7
  /** Positive safe integer shared by this store object; default 100000. No live replay reservations are evicted. */
6
8
  dpopReplayMaxEntries?: number;
7
9
  /**
@@ -20,10 +22,11 @@ interface MemoryOidcVaultStoreOptions {
20
22
  * during store operations, and lost when the Node.js process exits. Do not use
21
23
  * this provider when sessions must survive restarts or be shared by multiple
22
24
  * application instances. The returned stronger provider includes guarded
23
- * exact/null consumes, token-free revocation context, and bounded DPoP replay.
25
+ * exact/null consumes, token-free revocation context, bounded DPoP replay and
26
+ * independent, atomic logout continuation tickets (default cap 10000).
24
27
  * Replay is shared only by callers reusing this same object; the store does not
25
28
  * verify HTTP proofs. Capacity defaults to 100000 and never evicts live entries.
26
29
  */
27
- declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider;
30
+ declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider & OidcVaultLogoutNavigationStoreProvider;
28
31
 
29
32
  export { type MemoryOidcVaultStoreOptions, createMemoryOidcVaultStore };
package/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { OidcVaultDeviceBindingStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
1
+ import { OidcVaultDeviceBindingStoreProvider, OidcVaultLogoutNavigationStoreProvider } from '@web-ts-toolkit/express-oidc-vault';
2
2
 
3
3
  /** Construction options for the named package-root memory-store factory. */
4
4
  interface MemoryOidcVaultStoreOptions {
5
+ /** Independent physical-store ticket cap, shared across namespaces; default 10000. No live eviction. */
6
+ logoutNavigationMaxEntries?: number;
5
7
  /** Positive safe integer shared by this store object; default 100000. No live replay reservations are evicted. */
6
8
  dpopReplayMaxEntries?: number;
7
9
  /**
@@ -20,10 +22,11 @@ interface MemoryOidcVaultStoreOptions {
20
22
  * during store operations, and lost when the Node.js process exits. Do not use
21
23
  * this provider when sessions must survive restarts or be shared by multiple
22
24
  * application instances. The returned stronger provider includes guarded
23
- * exact/null consumes, token-free revocation context, and bounded DPoP replay.
25
+ * exact/null consumes, token-free revocation context, bounded DPoP replay and
26
+ * independent, atomic logout continuation tickets (default cap 10000).
24
27
  * Replay is shared only by callers reusing this same object; the store does not
25
28
  * verify HTTP proofs. Capacity defaults to 100000 and never evicts live entries.
26
29
  */
27
- declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider;
30
+ declare function createMemoryOidcVaultStore(options?: MemoryOidcVaultStoreOptions): OidcVaultDeviceBindingStoreProvider & OidcVaultLogoutNavigationStoreProvider;
28
31
 
29
32
  export { type MemoryOidcVaultStoreOptions, createMemoryOidcVaultStore };
package/index.js CHANGED
@@ -23,7 +23,7 @@ __export(index_exports, {
23
23
  createMemoryOidcVaultStore: () => createMemoryOidcVaultStore
24
24
  });
25
25
  module.exports = __toCommonJS(index_exports);
26
- var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
26
+ var import_express_oidc_vault4 = require("@web-ts-toolkit/express-oidc-vault");
27
27
 
28
28
  // src/binding.ts
29
29
  var import_express_oidc_vault = require("@web-ts-toolkit/express-oidc-vault");
@@ -138,6 +138,117 @@ var DpopReplayReservations = class {
138
138
  }
139
139
  };
140
140
 
141
+ // src/logout-tickets.ts
142
+ var import_express_oidc_vault3 = require("@web-ts-toolkit/express-oidc-vault");
143
+
144
+ // src/logout-ticket-validation.ts
145
+ var HASH = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
146
+ var PREFIX = "logout-navigation:v1:";
147
+ var UNSAFE_URL = /[\s\\\u0000-\u001f\u007f-\u009f]/u;
148
+ var usableEpoch = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= 864e13;
149
+ var ticketNow = (now) => {
150
+ if (!usableEpoch(now)) throw new Error("OIDC vault logout navigation store clock is invalid.");
151
+ return now;
152
+ };
153
+ var captureTicketIdentity = (input) => ({
154
+ namespace: input?.namespace,
155
+ ticketHash: input?.ticketHash
156
+ });
157
+ var validTicketIdentity = (input) => typeof input.namespace === "string" && input.namespace.startsWith(PREFIX) && HASH.test(input.namespace.slice(PREFIX.length)) && typeof input.ticketHash === "string" && HASH.test(input.ticketHash);
158
+ var captureTicket = (input) => ({
159
+ ...captureTicketIdentity(input),
160
+ upstreamLogoutUrl: input?.upstreamLogoutUrl,
161
+ createdAt: input?.createdAt,
162
+ expiresAt: input?.expiresAt
163
+ });
164
+ var validTicket = (record, now) => {
165
+ if (!record || typeof record !== "object" || Array.isArray(record) || Object.keys(record).length !== 5 || !validTicketIdentity(record) || !usableEpoch(record.createdAt) || !usableEpoch(record.expiresAt) || record.expiresAt - record.createdAt < 1e3 || record.expiresAt - record.createdAt > 12e4 || now !== void 0 && (record.expiresAt <= now || record.expiresAt - now > 12e4) || typeof record.upstreamLogoutUrl !== "string" || !/^https?:\/\//i.test(record.upstreamLogoutUrl) || /^https?:\/\/[^/?#]*@/i.test(record.upstreamLogoutUrl) || UNSAFE_URL.test(record.upstreamLogoutUrl) || Buffer.byteLength(record.upstreamLogoutUrl, "utf8") > 16384)
166
+ return false;
167
+ try {
168
+ const url = new URL(record.upstreamLogoutUrl);
169
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password && !url.hash && !record.upstreamLogoutUrl.includes("#") && Buffer.byteLength(url.href, "utf8") <= 16384;
170
+ } catch {
171
+ return false;
172
+ }
173
+ };
174
+ var assertTicket = (record, now) => {
175
+ if (!validTicket(record, now)) throw new TypeError("OIDC vault logout navigation ticket is invalid or expired.");
176
+ };
177
+ var ticketLimit = (value) => {
178
+ const max = value === void 0 ? 1e4 : value;
179
+ if (!Number.isSafeInteger(max) || max <= 0)
180
+ throw new TypeError("OIDC vault logoutNavigationMaxEntries must be a positive safe integer.");
181
+ return max;
182
+ };
183
+ var ticketId = ({ namespace, ticketHash }) => `${namespace}:${ticketHash}`;
184
+
185
+ // src/logout-tickets.ts
186
+ var LogoutNavigationTickets = class {
187
+ constructor(maxEntries) {
188
+ this.maxEntries = maxEntries;
189
+ this.tickets = /* @__PURE__ */ new Map();
190
+ this.expiryHeap = [];
191
+ }
192
+ create(record, now) {
193
+ ticketNow(now);
194
+ assertTicket(record, now);
195
+ const id = ticketId(record);
196
+ const existing = this.tickets.get(id);
197
+ if (existing && validTicket(existing.record, now)) throw new import_express_oidc_vault3.OidcVaultStoreConflictError();
198
+ if (existing) this.remove(existing.index);
199
+ this.cleanup(now);
200
+ if (this.tickets.size >= this.maxEntries) throw new import_express_oidc_vault3.OidcVaultLogoutNavigationCapacityError();
201
+ const entry = { id, record, index: this.expiryHeap.length };
202
+ this.tickets.set(id, entry);
203
+ this.expiryHeap.push(entry);
204
+ this.moveUp(entry.index);
205
+ }
206
+ consume(input, now) {
207
+ ticketNow(now);
208
+ const selected = this.tickets.get(ticketId(input));
209
+ if (selected) this.remove(selected.index);
210
+ this.cleanup(now);
211
+ return selected && validTicket(selected.record, now) && ticketId(selected.record) === ticketId(input) ? { ...selected.record } : null;
212
+ }
213
+ cleanup(now) {
214
+ for (let cleaned = 0; cleaned < 64 && this.expiryHeap[0]?.record.expiresAt <= now; cleaned += 1) this.remove(0);
215
+ }
216
+ remove(index) {
217
+ const removed = this.expiryHeap[index];
218
+ const last = this.expiryHeap.pop();
219
+ this.tickets.delete(removed.id);
220
+ if (index === this.expiryHeap.length) return;
221
+ this.expiryHeap[index] = last;
222
+ last.index = index;
223
+ this.moveUp(index);
224
+ this.moveDown(last.index);
225
+ }
226
+ swap(left, right) {
227
+ [this.expiryHeap[left], this.expiryHeap[right]] = [this.expiryHeap[right], this.expiryHeap[left]];
228
+ this.expiryHeap[left].index = left;
229
+ this.expiryHeap[right].index = right;
230
+ }
231
+ moveUp(index) {
232
+ while (index > 0) {
233
+ const parent = Math.floor((index - 1) / 2);
234
+ if (this.expiryHeap[parent].record.expiresAt <= this.expiryHeap[index].record.expiresAt) break;
235
+ this.swap(parent, index);
236
+ index = parent;
237
+ }
238
+ }
239
+ moveDown(index) {
240
+ for (; ; ) {
241
+ const left = index * 2 + 1;
242
+ if (left >= this.expiryHeap.length) return;
243
+ const right = left + 1;
244
+ const child = right < this.expiryHeap.length && this.expiryHeap[right].record.expiresAt < this.expiryHeap[left].record.expiresAt ? right : left;
245
+ if (this.expiryHeap[index].record.expiresAt <= this.expiryHeap[child].record.expiresAt) return;
246
+ this.swap(index, child);
247
+ index = child;
248
+ }
249
+ }
250
+ };
251
+
141
252
  // src/index.ts
142
253
  var cloneRecord = (value) => structuredClone(value);
143
254
  var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
@@ -180,6 +291,7 @@ var MemoryOidcVaultStore = class {
180
291
  throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
181
292
  }
182
293
  this.dpopProofs = new DpopReplayReservations(maxEntries);
294
+ this.logoutTickets = new LogoutNavigationTickets(ticketLimit(options.logoutNavigationMaxEntries));
183
295
  this.now = options.now ?? (() => Date.now());
184
296
  }
185
297
  async createAuthorizationTransaction(input) {
@@ -310,6 +422,15 @@ var MemoryOidcVaultStore = class {
310
422
  input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
311
423
  return this.dpopProofs.reserve(input, this.now());
312
424
  }
425
+ async createLogoutNavigationTicket(input) {
426
+ const record = captureTicket(input);
427
+ this.logoutTickets.create(record, this.now());
428
+ }
429
+ async consumeLogoutNavigationTicket(input) {
430
+ const identity = captureTicketIdentity(input);
431
+ if (!validTicketIdentity(identity)) return null;
432
+ return this.logoutTickets.consume(identity, this.now());
433
+ }
313
434
  async rotateSession(input) {
314
435
  const timestamp = this.now();
315
436
  const sourceSession = this.sessions.get(input.sessionId);
@@ -319,14 +440,14 @@ var MemoryOidcVaultStore = class {
319
440
  }
320
441
  const liveSourceSession = this.sessions.get(input.sessionId);
321
442
  if (!liveSourceSession || !hasValidSessionRecord(liveSourceSession, input.sessionId)) {
322
- throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
443
+ throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session no longer exists for rotation.");
323
444
  }
324
445
  if (input.nextSession.sessionId === input.sessionId) {
325
- throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
446
+ throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session rotation target must use a different session ID.");
326
447
  }
327
448
  const existingTargetSession = this.sessions.get(input.nextSession.sessionId);
328
449
  if (existingTargetSession && !isExpiredRecord(existingTargetSession, timestamp)) {
329
- throw new import_express_oidc_vault3.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
450
+ throw new import_express_oidc_vault4.OidcVaultStoreConflictError("OIDC vault session rotation target already exists.");
330
451
  }
331
452
  const nextSession = cloneRecord(input.nextSession);
332
453
  inheritSessionBinding(liveSourceSession, nextSession);
package/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import {
3
- OidcVaultStoreConflictError as OidcVaultStoreConflictError2
3
+ OidcVaultStoreConflictError as OidcVaultStoreConflictError3
4
4
  } from "@web-ts-toolkit/express-oidc-vault";
5
5
 
6
6
  // src/binding.ts
@@ -116,6 +116,120 @@ var DpopReplayReservations = class {
116
116
  }
117
117
  };
118
118
 
119
+ // src/logout-tickets.ts
120
+ import {
121
+ OidcVaultLogoutNavigationCapacityError,
122
+ OidcVaultStoreConflictError as OidcVaultStoreConflictError2
123
+ } from "@web-ts-toolkit/express-oidc-vault";
124
+
125
+ // src/logout-ticket-validation.ts
126
+ var HASH = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
127
+ var PREFIX = "logout-navigation:v1:";
128
+ var UNSAFE_URL = /[\s\\\u0000-\u001f\u007f-\u009f]/u;
129
+ var usableEpoch = (value) => typeof value === "number" && Number.isSafeInteger(value) && Math.abs(value) <= 864e13;
130
+ var ticketNow = (now) => {
131
+ if (!usableEpoch(now)) throw new Error("OIDC vault logout navigation store clock is invalid.");
132
+ return now;
133
+ };
134
+ var captureTicketIdentity = (input) => ({
135
+ namespace: input?.namespace,
136
+ ticketHash: input?.ticketHash
137
+ });
138
+ var validTicketIdentity = (input) => typeof input.namespace === "string" && input.namespace.startsWith(PREFIX) && HASH.test(input.namespace.slice(PREFIX.length)) && typeof input.ticketHash === "string" && HASH.test(input.ticketHash);
139
+ var captureTicket = (input) => ({
140
+ ...captureTicketIdentity(input),
141
+ upstreamLogoutUrl: input?.upstreamLogoutUrl,
142
+ createdAt: input?.createdAt,
143
+ expiresAt: input?.expiresAt
144
+ });
145
+ var validTicket = (record, now) => {
146
+ if (!record || typeof record !== "object" || Array.isArray(record) || Object.keys(record).length !== 5 || !validTicketIdentity(record) || !usableEpoch(record.createdAt) || !usableEpoch(record.expiresAt) || record.expiresAt - record.createdAt < 1e3 || record.expiresAt - record.createdAt > 12e4 || now !== void 0 && (record.expiresAt <= now || record.expiresAt - now > 12e4) || typeof record.upstreamLogoutUrl !== "string" || !/^https?:\/\//i.test(record.upstreamLogoutUrl) || /^https?:\/\/[^/?#]*@/i.test(record.upstreamLogoutUrl) || UNSAFE_URL.test(record.upstreamLogoutUrl) || Buffer.byteLength(record.upstreamLogoutUrl, "utf8") > 16384)
147
+ return false;
148
+ try {
149
+ const url = new URL(record.upstreamLogoutUrl);
150
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password && !url.hash && !record.upstreamLogoutUrl.includes("#") && Buffer.byteLength(url.href, "utf8") <= 16384;
151
+ } catch {
152
+ return false;
153
+ }
154
+ };
155
+ var assertTicket = (record, now) => {
156
+ if (!validTicket(record, now)) throw new TypeError("OIDC vault logout navigation ticket is invalid or expired.");
157
+ };
158
+ var ticketLimit = (value) => {
159
+ const max = value === void 0 ? 1e4 : value;
160
+ if (!Number.isSafeInteger(max) || max <= 0)
161
+ throw new TypeError("OIDC vault logoutNavigationMaxEntries must be a positive safe integer.");
162
+ return max;
163
+ };
164
+ var ticketId = ({ namespace, ticketHash }) => `${namespace}:${ticketHash}`;
165
+
166
+ // src/logout-tickets.ts
167
+ var LogoutNavigationTickets = class {
168
+ constructor(maxEntries) {
169
+ this.maxEntries = maxEntries;
170
+ this.tickets = /* @__PURE__ */ new Map();
171
+ this.expiryHeap = [];
172
+ }
173
+ create(record, now) {
174
+ ticketNow(now);
175
+ assertTicket(record, now);
176
+ const id = ticketId(record);
177
+ const existing = this.tickets.get(id);
178
+ if (existing && validTicket(existing.record, now)) throw new OidcVaultStoreConflictError2();
179
+ if (existing) this.remove(existing.index);
180
+ this.cleanup(now);
181
+ if (this.tickets.size >= this.maxEntries) throw new OidcVaultLogoutNavigationCapacityError();
182
+ const entry = { id, record, index: this.expiryHeap.length };
183
+ this.tickets.set(id, entry);
184
+ this.expiryHeap.push(entry);
185
+ this.moveUp(entry.index);
186
+ }
187
+ consume(input, now) {
188
+ ticketNow(now);
189
+ const selected = this.tickets.get(ticketId(input));
190
+ if (selected) this.remove(selected.index);
191
+ this.cleanup(now);
192
+ return selected && validTicket(selected.record, now) && ticketId(selected.record) === ticketId(input) ? { ...selected.record } : null;
193
+ }
194
+ cleanup(now) {
195
+ for (let cleaned = 0; cleaned < 64 && this.expiryHeap[0]?.record.expiresAt <= now; cleaned += 1) this.remove(0);
196
+ }
197
+ remove(index) {
198
+ const removed = this.expiryHeap[index];
199
+ const last = this.expiryHeap.pop();
200
+ this.tickets.delete(removed.id);
201
+ if (index === this.expiryHeap.length) return;
202
+ this.expiryHeap[index] = last;
203
+ last.index = index;
204
+ this.moveUp(index);
205
+ this.moveDown(last.index);
206
+ }
207
+ swap(left, right) {
208
+ [this.expiryHeap[left], this.expiryHeap[right]] = [this.expiryHeap[right], this.expiryHeap[left]];
209
+ this.expiryHeap[left].index = left;
210
+ this.expiryHeap[right].index = right;
211
+ }
212
+ moveUp(index) {
213
+ while (index > 0) {
214
+ const parent = Math.floor((index - 1) / 2);
215
+ if (this.expiryHeap[parent].record.expiresAt <= this.expiryHeap[index].record.expiresAt) break;
216
+ this.swap(parent, index);
217
+ index = parent;
218
+ }
219
+ }
220
+ moveDown(index) {
221
+ for (; ; ) {
222
+ const left = index * 2 + 1;
223
+ if (left >= this.expiryHeap.length) return;
224
+ const right = left + 1;
225
+ const child = right < this.expiryHeap.length && this.expiryHeap[right].record.expiresAt < this.expiryHeap[left].record.expiresAt ? right : left;
226
+ if (this.expiryHeap[index].record.expiresAt <= this.expiryHeap[child].record.expiresAt) return;
227
+ this.swap(index, child);
228
+ index = child;
229
+ }
230
+ }
231
+ };
232
+
119
233
  // src/index.ts
120
234
  var cloneRecord = (value) => structuredClone(value);
121
235
  var isExpiredRecord = (value, now) => typeof value.expiresAt === "number" && value.expiresAt <= now;
@@ -158,6 +272,7 @@ var MemoryOidcVaultStore = class {
158
272
  throw new TypeError("OIDC vault DPoP replay maximum entries must be a positive safe integer.");
159
273
  }
160
274
  this.dpopProofs = new DpopReplayReservations(maxEntries);
275
+ this.logoutTickets = new LogoutNavigationTickets(ticketLimit(options.logoutNavigationMaxEntries));
161
276
  this.now = options.now ?? (() => Date.now());
162
277
  }
163
278
  async createAuthorizationTransaction(input) {
@@ -288,6 +403,15 @@ var MemoryOidcVaultStore = class {
288
403
  input = { replayKey: input.replayKey, expiresAt: input.expiresAt };
289
404
  return this.dpopProofs.reserve(input, this.now());
290
405
  }
406
+ async createLogoutNavigationTicket(input) {
407
+ const record = captureTicket(input);
408
+ this.logoutTickets.create(record, this.now());
409
+ }
410
+ async consumeLogoutNavigationTicket(input) {
411
+ const identity = captureTicketIdentity(input);
412
+ if (!validTicketIdentity(identity)) return null;
413
+ return this.logoutTickets.consume(identity, this.now());
414
+ }
291
415
  async rotateSession(input) {
292
416
  const timestamp = this.now();
293
417
  const sourceSession = this.sessions.get(input.sessionId);
@@ -297,14 +421,14 @@ var MemoryOidcVaultStore = class {
297
421
  }
298
422
  const liveSourceSession = this.sessions.get(input.sessionId);
299
423
  if (!liveSourceSession || !hasValidSessionRecord(liveSourceSession, input.sessionId)) {
300
- throw new OidcVaultStoreConflictError2("OIDC vault session no longer exists for rotation.");
424
+ throw new OidcVaultStoreConflictError3("OIDC vault session no longer exists for rotation.");
301
425
  }
302
426
  if (input.nextSession.sessionId === input.sessionId) {
303
- throw new OidcVaultStoreConflictError2("OIDC vault session rotation target must use a different session ID.");
427
+ throw new OidcVaultStoreConflictError3("OIDC vault session rotation target must use a different session ID.");
304
428
  }
305
429
  const existingTargetSession = this.sessions.get(input.nextSession.sessionId);
306
430
  if (existingTargetSession && !isExpiredRecord(existingTargetSession, timestamp)) {
307
- throw new OidcVaultStoreConflictError2("OIDC vault session rotation target already exists.");
431
+ throw new OidcVaultStoreConflictError3("OIDC vault session rotation target already exists.");
308
432
  }
309
433
  const nextSession = cloneRecord(input.nextSession);
310
434
  inheritSessionBinding(liveSourceSession, nextSession);
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.52.0",
5
+ "version": "0.54.0",
6
6
  "sideEffects": false,
7
7
  "keywords": [
8
8
  "express",
@@ -29,7 +29,7 @@
29
29
  "node": ">=22.12.0"
30
30
  },
31
31
  "dependencies": {
32
- "@web-ts-toolkit/express-oidc-vault": "0.52.0"
32
+ "@web-ts-toolkit/express-oidc-vault": "0.54.0"
33
33
  },
34
34
  "files": [
35
35
  "**/*",