Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 | 254x 254x 254x 254x 254x 254x 254x 58x 254x 163x 163x 163x 163x 163x 163x 163x 163x 163x 163x 163x 165x 165x 165x 1x 1x 1x 165x 165x 64x 64x 64x 101x 165x 165x 165x 164x 164x 1x 1x 164x 164x 164x 164x 64x 64x 64x 63x 1x 63x 63x 63x 63x 10x 63x 63x 63x 63x 63x 63x 2x 63x 63x 63x 1x 182x 182x 1x 113x 113x 300x 1x 1x 300x 523x 523x 503x 20x 523x 295x 150x 150x 150x 150x 140x 140x 14x 14x 126x 456x 455x 455x 455x 455x 440x 440x 440x 440x 440x 440x 435x 1x 435x 10x 10x 1x 10x 164x 164x 164x 92x 92x 92x 92x 1x 46x 22x 22x 3x 9x 6x 6x 6x 6x 11x 6x 6x 1x 8x 8x 8x 8x 8x 3x 6x 1x 2x 9x 9x 5x 1x 2x 2x 2x 15x 15x 15x 50x 50x 37x 2x 2x 2x 36x 36x 4x 2x 2x 2x 5x 5x 2x 2x 2x 2x 15x 15x 14x 1x 1x 3x 27x 27x 15x 15x 15x 15x 3x 15x 10x 10x 3x 11x 11x 1x 4x 4x 1x 2x 2x 2x 38x 38x 3x 4x 4x 4x 4x 4x 4x 4x 2x 7x 7x 2x 2x 2x 11x 11x 163x 161x 161x 163x 3x 3x 163x 163x 163x 7x 8x 5x 5x 8x 8x | /**
* File-Based Session Storage Backend
*
* Persists sessions to a JSON file for survival across server restarts.
* Suitable for single-instance deployments without external database.
*
* Features:
* - Every OAuth record (client, session, flow, code) and every transition of one is
* written through before it is reported; cleanup and MCP transport mappings are
* saved debounced
* - Periodic auto-save interval
* - Atomic file writes (write to temp, then rename)
* - Data version migration support
*/
import * as fs from 'fs';
import * as path from 'path';
import {
OAuthSession,
DeviceFlowState,
AuthCodeFlowState,
AuthorizationCode,
RegisteredOAuthClient,
GitLabTokenResponse,
} from '../types';
import {
AbandonedFlowGrantHandler,
ExpiredSessionHandler,
LinkedAccountCommit,
SessionStorageBackend,
SessionStorageStats,
StorageData,
STORAGE_DATA_VERSION,
} from './types';
import { MemoryStorageBackend } from './memory';
import type { AccountSettings, AccountSettingsRecord } from '../../configuration/types';
import { validStoredRecord } from '../../configuration/settings-store';
import { logInfo, logDebug, logError, logWarn } from '../../logger';
import { syncDirectory } from '../../utils/sync-directory';
export interface FileStorageOptions {
/** Path to the storage file */
filePath: string;
/** Auto-save interval in milliseconds (default: 30000 = 30 seconds) */
saveInterval?: number;
/** Debounce delay for change-triggered saves (default: 1000ms) */
saveDebounce?: number;
}
function isPresent<T>(record: T | undefined): record is T {
return record !== undefined;
}
export class FileStorageBackend implements SessionStorageBackend {
readonly type = 'file' as const;
private memory: MemoryStorageBackend;
private filePath: string;
private saveInterval: number;
private saveDebounce: number;
private saveIntervalId: ReturnType<typeof setInterval> | null = null;
private saveDebounceId: ReturnType<typeof setTimeout> | null = null;
private pendingSave = false;
private initialized = false;
/** Tail of the serialized file writes */
private writeQueue: Promise<void> = Promise.resolve();
/** Per account being written: its settings as saved before the write. */
private readonly savingAccountSettings = new Map<
string,
{ saved: AccountSettingsRecord | undefined }
>();
constructor(options: FileStorageOptions) {
// Use memory backend internally as cache, but suppress its logging
this.memory = new MemoryStorageBackend({ silent: true });
this.filePath = options.filePath;
this.saveInterval = options.saveInterval ?? 30000;
this.saveDebounce = options.saveDebounce ?? 1000;
}
async initialize(): Promise<void> {
// Ensure directory exists
const dir = path.dirname(this.filePath);
let dirCreated = false;
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
dirCreated = true;
logInfo('Created storage directory', { dir });
}
// Load existing data if file exists
const fileExists = fs.existsSync(this.filePath);
if (fileExists) {
const stats = fs.statSync(this.filePath);
logInfo('Found existing session file', {
filePath: this.filePath,
size: stats.size,
mtime: stats.mtime.toISOString(),
});
await this.loadFromFile();
} else {
logInfo('No existing session file, will create on first save', {
filePath: this.filePath,
});
}
// Verify we can write to the file
try {
const testPath = `${this.filePath}.test`;
fs.writeFileSync(testPath, 'test', 'utf-8');
fs.unlinkSync(testPath);
logDebug('Write access verified', { filePath: this.filePath });
} catch (error) {
logError('Cannot write to storage file path - sessions will NOT persist!', {
err: error as Error,
filePath: this.filePath,
});
throw new Error(`File storage path not writable: ${this.filePath}`, { cause: error });
}
// Initialize memory backend (starts cleanup interval)
await this.memory.initialize();
// Start auto-save interval
this.startSaveInterval();
this.initialized = true;
logInfo('File storage backend initialized', {
filePath: this.filePath,
dirCreated,
fileExisted: fileExists,
});
}
private async loadFromFile(): Promise<void> {
try {
const content = fs.readFileSync(this.filePath, 'utf-8');
const data = JSON.parse(content) as StorageData;
// Validate version
if (data.version !== STORAGE_DATA_VERSION) {
logWarn('Storage file version mismatch, migrating data', {
fileVersion: data.version,
currentVersion: STORAGE_DATA_VERSION,
});
// Future: add migration logic here
}
// Filter expired data before import. Sessions past their age limit are kept: they hold
// GitLab grants, and cleanup (run as the server starts) ends those as it removes them.
const now = Date.now();
const validSessions = data.sessions;
const validDeviceFlows = data.deviceFlows.filter((d) => d.flow.expiresAt > now);
// An expired flow holding exchanged GitLab tokens is kept for cleanup, which ends that
// grant as it removes the flow; nothing else holds those tokens.
const validAuthCodeFlows = data.authCodeFlows.filter(
(a) => a.flow.expiresAt > now || a.flow.gitlabTokens !== undefined,
);
const validAuthCodes = data.authCodes.filter((a) => a.expiresAt > now);
// A damaged settings record is dropped, not loaded: settings of the wrong shape would
// break resolving every tool of the account, the one that repairs them included.
const stored: unknown = data.accountSettings;
const listed = Array.isArray(stored);
const storedSettings: unknown[] = Array.isArray(stored) ? stored : [];
const accountSettings = storedSettings.map(validStoredRecord).filter(isPresent);
if (accountSettings.length !== storedSettings.length || (!listed && stored !== undefined)) {
logWarn('Skipped invalid account settings in the storage file', {
skipped: listed ? storedSettings.length - accountSettings.length : 'all',
});
}
// Import into memory
this.memory.importData({
sessions: validSessions,
deviceFlows: validDeviceFlows,
authCodeFlows: validAuthCodeFlows,
authCodes: validAuthCodes,
mcpSessionMappings: data.mcpSessionMappings,
clients: data.clients,
accountSettings,
});
const stats = await this.memory.getStats();
logInfo('Loaded sessions from file', {
loadedSessions: stats.sessions,
loadedDeviceFlows: stats.deviceFlows,
loadedAuthCodes: stats.authCodes,
});
} catch (error) {
logError('Failed to load sessions from file', {
err: error as Error,
filePath: this.filePath,
});
// Start fresh on load error
}
}
private async saveToFile(): Promise<void> {
try {
await this.writeSnapshot();
} catch (error) {
logError('Failed to save sessions to file', {
err: error as Error,
filePath: this.filePath,
});
}
}
/**
* Write the current state now, replacing any pending debounced save. Single-use
* transitions (consumed codes and flows, rotated refresh tokens, revoked sessions) are
* reported only after this succeeds, so a crash cannot bring them back.
*/
private async persistNow(): Promise<void> {
this.cancelPendingSave();
await this.writeSnapshot();
}
private cancelPendingSave(): void {
if (this.saveDebounceId) {
clearTimeout(this.saveDebounceId);
this.saveDebounceId = null;
}
this.pendingSave = false;
}
/**
* Run `task` as the next step of the write queue: steps run one at a time in call order,
* so concurrent writes never share the temp file. A failed step is reported to its
* caller; the next one still runs.
*/
private enqueue<T>(task: () => Promise<T>): Promise<T> {
const step = this.writeQueue.then(task);
this.writeQueue = step.then(
() => undefined,
() => undefined,
);
return step;
}
/** Atomically replace the file with the state at the time the write runs. */
private writeSnapshot(): Promise<void> {
return this.enqueue(() => this.writeState());
}
/**
* A single-use transition as one step of the write queue: the change, the write of the
* resulting state and, when the write fails, the undo of the change. No other write can
* capture the changed state in between, so a transition reported as failed is never
* persisted. `changed` tells whether there is anything to write.
*/
private transition<T, Changed extends T>(
change: () => Promise<T>,
changed: (result: T) => result is Changed,
undo: (result: Changed) => unknown,
): Promise<T> {
this.cancelPendingSave();
return this.enqueue(async () => {
const result = await change();
if (!changed(result)) return result;
try {
await this.writeState();
} catch (error: unknown) {
// The caller never used the result, so the code, flow or refresh token it would
// have spent stays usable for the retry.
await undo(result);
throw error;
}
return result;
});
}
/** Write the current state; rejects when the write fails. Runs only inside the queue. */
private async writeState(): Promise<void> {
if (!this.initialized) return;
const exportedData = this.memory.exportData();
const data: StorageData = {
version: STORAGE_DATA_VERSION,
exportedAt: Date.now(),
sessions: exportedData.sessions,
deviceFlows: exportedData.deviceFlows,
authCodeFlows: exportedData.authCodeFlows,
authCodes: exportedData.authCodes,
mcpSessionMappings: exportedData.mcpSessionMappings,
clients: exportedData.clients,
accountSettings: exportedData.accountSettings,
};
// Atomic write: write to temp file, then rename. Both are flushed to disk, so a power
// loss cannot bring back a spent code or a revoked session either. The store holds
// account tokens: owner-only, whatever the umask. The mode applies only when the file
// is created, so a temp file left by a crash is narrowed too.
const tempPath = `${this.filePath}.tmp`;
const file = await fs.promises.open(tempPath, 'w', 0o600);
try {
await file.chmod(0o600);
await file.writeFile(JSON.stringify(data), 'utf-8');
await file.sync();
} finally {
await file.close();
}
await fs.promises.rename(tempPath, this.filePath);
// The rename is the commit: a restart already reads this state, so a failed flush only
// leaves it at risk from a power loss and must not report the write as failed.
await syncDirectory(path.dirname(this.filePath)).catch((error: unknown) =>
logWarn('Saved sessions may not survive a power loss: the directory could not be synced', {
filePath: this.filePath,
err: error as Error,
}),
);
logDebug('Saved sessions to file', {
sessions: data.sessions.length,
deviceFlows: data.deviceFlows.length,
authCodes: data.authCodes.length,
});
}
private scheduleSave(): void {
// Mark as pending
this.pendingSave = true;
// Debounce: cancel previous scheduled save
if (this.saveDebounceId) {
clearTimeout(this.saveDebounceId);
}
// Schedule save after debounce delay
this.saveDebounceId = setTimeout(() => {
if (this.pendingSave) {
this.pendingSave = false;
this.saveToFile().catch((err) => logError('Failed to save to file', { err }));
}
}, this.saveDebounce);
}
private startSaveInterval(): void {
this.saveIntervalId = setInterval(() => {
this.saveToFile().catch((err) => logError('Failed to save to file', { err }));
}, this.saveInterval);
Eif (this.saveIntervalId.unref) {
this.saveIntervalId.unref();
}
}
// Records handed to a client or to GitLab right after they are stored (client ids,
// codes and their sessions, flows GitLab calls back for, device flows holding tokens
// GitLab issued once) are written through, so a crash cannot lose what was handed out.
// Cleanup and MCP transport mappings stay debounced.
async createSession(session: OAuthSession): Promise<void> {
// A session whose write failed was never returned to anyone: it is removed again.
await this.transition(
async () => {
await this.memory.createSession(session);
return true as const;
},
(created): created is true => created,
() => this.memory.deleteSession(session.id),
);
}
async getSession(sessionId: string): Promise<OAuthSession | undefined> {
return this.memory.getSession(sessionId);
}
commitLinkedAccount(commit: LinkedAccountCommit): Promise<boolean> {
this.cancelPendingSave();
// Memory applies/undoes both records synchronously. Persistence and lease ownership
// share the write queue, so another callback cannot take over during the disk write.
return this.enqueue(() => this.memory.commitLinkedAccount(commit, () => this.writeState()));
}
async getSessionByToken(token: string): Promise<OAuthSession | undefined> {
return this.memory.getSessionByToken(token);
}
async getSessionByRefreshToken(refreshToken: string): Promise<OAuthSession | undefined> {
return this.memory.getSessionByRefreshToken(refreshToken);
}
updateSession(sessionId: string, updates: Partial<OAuthSession>): Promise<boolean> {
// Written through: updates carry tokens just handed to clients or replacing spent
// GitLab refresh tokens, which a crash must not lose. An update whose write failed is
// undone, so tokens never handed out are not persisted by a later write.
const previous: Partial<OAuthSession> = {};
return this.transition(
async () => {
// The stored session is updated in place: copy the fields the update replaces.
const current = await this.memory.getSession(sessionId);
for (const key of Object.keys(updates) as Array<keyof OAuthSession>) {
Object.assign(previous, { [key]: current?.[key] });
}
return this.memory.updateSession(sessionId, updates);
},
(updated): updated is true => updated,
() => this.memory.updateSession(sessionId, previous),
);
}
deleteSession(sessionId: string): Promise<boolean> {
// A revoked session must not come back after a crash; a revocation whose write failed
// restores the session and its linked accounts, so the retry revokes it instead of
// finding nothing to revoke.
return this.transition(
async () => {
const session = await this.memory.getSession(sessionId);
const linked = await this.memory.getLinkedSessions(sessionId);
const removed = session ? [session, ...linked] : undefined;
return (await this.memory.deleteSession(sessionId)) ? removed : undefined;
},
isPresent,
async (removed) => {
await Promise.all(removed.map((session) => this.memory.createSession(session)));
},
).then((removed) => removed !== undefined);
}
async getAllSessions(): Promise<OAuthSession[]> {
return this.memory.getAllSessions();
}
async getLinkedSessions(sessionId: string): Promise<OAuthSession[]> {
return this.memory.getLinkedSessions(sessionId);
}
// Device flow operations
async storeDeviceFlow(state: string, flow: DeviceFlowState): Promise<void> {
await this.memory.storeDeviceFlow(state, flow);
await this.persistNow();
}
async getDeviceFlow(state: string): Promise<DeviceFlowState | undefined> {
return this.memory.getDeviceFlow(state);
}
async getDeviceFlowByDeviceCode(deviceCode: string): Promise<DeviceFlowState | undefined> {
return this.memory.getDeviceFlowByDeviceCode(deviceCode);
}
async deleteDeviceFlow(state: string): Promise<boolean> {
const result = await this.memory.deleteDeviceFlow(state);
if (result) this.scheduleSave();
return result;
}
// Auth code flow operations
cacheLinkedFlowTokens(flow: AuthCodeFlowState, tokens: GitLabTokenResponse): Promise<boolean> {
this.cancelPendingSave();
return this.enqueue(() =>
this.memory.cacheLinkedFlowTokens(flow, tokens, () => this.writeState()),
);
}
async storeAuthCodeFlow(internalState: string, flow: AuthCodeFlowState): Promise<void> {
await this.memory.storeAuthCodeFlow(internalState, flow);
await this.persistNow();
}
async getAuthCodeFlow(internalState: string): Promise<AuthCodeFlowState | undefined> {
return this.memory.getAuthCodeFlow(internalState);
}
async deleteAuthCodeFlow(internalState: string): Promise<boolean> {
const result = await this.memory.deleteAuthCodeFlow(internalState);
if (result) this.scheduleSave();
return result;
}
// Authorization code operations
async storeAuthCode(code: AuthorizationCode): Promise<void> {
await this.memory.storeAuthCode(code);
await this.persistNow();
}
async getAuthCode(code: string): Promise<AuthorizationCode | undefined> {
return this.memory.getAuthCode(code);
}
async deleteAuthCode(code: string): Promise<boolean> {
const result = await this.memory.deleteAuthCode(code);
if (result) this.scheduleSave();
return result;
}
// MCP session mapping
async associateMcpSession(mcpSessionId: string, oauthSessionId: string): Promise<void> {
await this.memory.associateMcpSession(mcpSessionId, oauthSessionId);
this.scheduleSave();
}
async getSessionByMcpSessionId(mcpSessionId: string): Promise<OAuthSession | undefined> {
return this.memory.getSessionByMcpSessionId(mcpSessionId);
}
async removeMcpSessionAssociation(mcpSessionId: string): Promise<boolean> {
const result = await this.memory.removeMcpSessionAssociation(mcpSessionId);
if (result) this.scheduleSave();
return result;
}
// Registered OAuth clients
async storeClient(client: RegisteredOAuthClient): Promise<void> {
await this.memory.storeClient(client);
await this.persistNow();
}
async getClient(clientId: string): Promise<RegisteredOAuthClient | undefined> {
return this.memory.getClient(clientId);
}
async markClientUsed(clientId: string): Promise<void> {
await this.memory.markClientUsed(clientId);
await this.persistNow();
}
async countClientsRegisteredSince(registeredFrom: string, since: number): Promise<number> {
return this.memory.countClientsRegisteredSince(registeredFrom, since);
}
// Account settings
async getAccountSettings(accountKey: string): Promise<AccountSettingsRecord | undefined> {
// While a write of this account's settings is in flight its saved record is the one
// before it: an edit is enforced only once it is on disk.
const saving = this.savingAccountSettings.get(accountKey);
return saving ? saving.saved : this.memory.getAccountSettings(accountKey);
}
putAccountSettings(
accountKey: string,
settings: AccountSettings,
expectedVersion: number,
): Promise<AccountSettingsRecord | undefined> {
// Written through and undone when the write fails: settings reported as not saved
// must not apply now or after a restart. The compare-and-set runs against this
// process's state: file storage serves one process, as for its sessions, and replicas
// that share storage use PostgreSQL.
let saving: { saved: AccountSettingsRecord | undefined } | undefined;
return this.transition(
async () => {
saving = { saved: await this.memory.getAccountSettings(accountKey) };
this.savingAccountSettings.set(accountKey, saving);
return this.memory.putAccountSettings(accountKey, settings, expectedVersion);
},
isPresent,
() => this.memory.restoreAccountSettings(accountKey, saving?.saved),
// Writes run one at a time, and the next starts only after this one settled: the
// marker is this write's own when it is dropped.
).finally(() => this.savingAccountSettings.delete(accountKey));
}
// Single-use consumption and refresh rotation: written through before they are
// reported, so a crash cannot make a spent code, flow or refresh token usable again.
consumeAuthCode(code: string): Promise<AuthorizationCode | undefined> {
return this.transition(
() => this.memory.consumeAuthCode(code),
isPresent,
(record) => this.memory.storeAuthCode(record),
);
}
consumeAuthCodeFlow(internalState: string): Promise<AuthCodeFlowState | undefined> {
return this.transition(
() => this.memory.consumeAuthCodeFlow(internalState),
isPresent,
(record) => this.memory.storeAuthCodeFlow(internalState, record),
);
}
consumeDeviceFlow(state: string): Promise<DeviceFlowState | undefined> {
return this.transition(
() => this.memory.consumeDeviceFlow(state),
isPresent,
(record) => this.memory.storeDeviceFlow(state, record),
);
}
async claimDevicePoll(
state: string,
now: number,
nextPollAt: number,
): Promise<DeviceFlowState | undefined> {
const claimed = await this.memory.claimDevicePoll(state, now, nextPollAt);
if (claimed) this.scheduleSave();
return claimed;
}
// GitLab refresh leases only coordinate requests of this process (the file backend has
// one writer) and are not persisted.
async claimGitLabRefresh(
sessionId: string,
expectedRefreshToken: string,
now: number,
leaseUntil: number,
): Promise<boolean> {
// Ownership cannot change while an account-link commit is being written through.
return this.enqueue(() =>
this.memory.claimGitLabRefresh(sessionId, expectedRefreshToken, now, leaseUntil),
);
}
async releaseGitLabRefresh(sessionId: string, leaseUntil: number): Promise<void> {
await this.enqueue(() => this.memory.releaseGitLabRefresh(sessionId, leaseUntil));
}
async rotateSession(
sessionId: string,
expectedRefreshToken: string,
updates: Partial<OAuthSession>,
): Promise<boolean> {
const previous: Partial<OAuthSession> = {};
return this.transition(
async () => {
// The stored session is updated in place: copy the fields the rotation replaces.
const current = await this.memory.getSession(sessionId);
for (const key of Object.keys(updates) as Array<keyof OAuthSession>) {
Object.assign(previous, { [key]: current?.[key] });
}
return this.memory.rotateSession(sessionId, expectedRefreshToken, updates);
},
(rotated): rotated is true => rotated,
() => this.memory.updateSession(sessionId, previous),
);
}
onAbandonedFlowGrant(handler: AbandonedFlowGrantHandler, leaseMs: number): void {
this.memory.onAbandonedFlowGrant(
(flow, persistTokens, leaseUntil) =>
handler(
flow,
async (tokens) => {
await persistTokens(tokens);
await this.persistNow();
},
leaseUntil,
),
leaseMs,
);
}
onExpiredSession(handler: ExpiredSessionHandler): void {
this.memory.onExpiredSession(handler);
}
// Cleanup
async cleanup(): Promise<void> {
await this.memory.cleanup();
await this.saveToFile();
}
async close(): Promise<void> {
// Stop intervals
if (this.saveIntervalId) {
clearInterval(this.saveIntervalId);
this.saveIntervalId = null;
}
if (this.saveDebounceId) {
clearTimeout(this.saveDebounceId);
this.saveDebounceId = null;
}
// Final save
await this.saveToFile();
// Close memory backend
await this.memory.close();
logInfo('File storage backend closed');
}
async getStats(): Promise<SessionStorageStats> {
return this.memory.getStats();
}
/** Force immediate save (for graceful shutdown) */
async forceSave(): Promise<void> {
if (this.saveDebounceId) {
clearTimeout(this.saveDebounceId);
this.saveDebounceId = null;
}
this.pendingSave = false;
await this.saveToFile();
}
}
|