All files / src/oauth/storage file.ts

97.54% Statements 238/244
94.54% Branches 52/55
95.87% Functions 93/97
98.23% Lines 223/227

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();
  }
}