/**
* セッション管理(複数DB対応版)
*
* FileMaker Data APIセッションの管理
* 設計書 3.2.1, 3.2.2 セクション準拠
* 実装計画書 Phase 2 準拠
*
* 主要コンポーネント:
* - SessionManager: 単一DB接続のセッション管理
* - SessionManagerRegistry: 複数SessionManagerの統合管理(エイリアスベース)
*/
import type { FileMakerConfig } from '../config.js';
import { toFileMakerConfig } from '../config.js';
import type {
DatabaseConfig,
DatabaseInfo,
DatabaseRegistry,
FMLoginResponse,
} from '../types/filemaker.js';
import type { ErrorResponse } from '../types/tools.js';
import { createTimedLogger, loggers } from '../utils/logger.js';
import { FileMakerHttpClient, isErrorResponse } from './client.js';
import { ErrorCodes } from './error-mapper.js';
const logger = loggers.session;
// ============================================================================
// 型定義
// ============================================================================
/**
* セッション情報
*/
export interface SessionInfo {
/** データベース名 */
database: string;
/** サーバーURL */
server: string;
/** セッション作成時刻 */
createdAt: number;
}
/**
* セッション状態
*/
export interface SessionState {
/** セッショントークン(内部保持) */
token: string;
/** セッション情報 */
info: SessionInfo;
/** 設定 */
config: FileMakerConfig;
}
// ============================================================================
// セッションマネージャークラス
// ============================================================================
/**
* セッションマネージャー
*
* 特徴:
* - シングルセッション管理(同時に1つのセッションのみ)
* - セッションタイムアウト追跡
* - 安全なログアウト処理
*/
export class SessionManager {
private httpClient: FileMakerHttpClient | null = null;
private state: SessionState | null = null;
/**
* 現在のHTTPクライアントを取得
*/
getHttpClient(): FileMakerHttpClient | null {
return this.httpClient;
}
/**
* 現在のセッショントークンを取得
*/
getToken(): string | null {
return this.state?.token ?? null;
}
/**
* 現在のセッション情報を取得
*/
getSessionInfo(): SessionInfo | null {
return this.state?.info ?? null;
}
/**
* 現在の設定を取得
*/
getConfig(): FileMakerConfig | null {
return this.state?.config ?? null;
}
/**
* セッションが有効かどうか
*/
hasActiveSession(): boolean {
return this.state !== null && this.state.token !== '';
}
/**
* セッション経過時間(秒)を取得
*/
getSessionAge(): number | undefined {
if (!this.state) {
return undefined;
}
return Math.floor((Date.now() - this.state.info.createdAt) / 1000);
}
/**
* セッションがタイムアウト近いかどうか
*
* @param bufferSeconds - バッファ秒数(デフォルト: 60秒)
* @returns タイムアウト近い場合true
*/
isSessionExpiringSoon(bufferSeconds = 60): boolean {
if (!this.state) {
return false;
}
const age = this.getSessionAge();
if (age === undefined) {
return false;
}
const timeout = this.state.config.sessionTimeout;
return age >= timeout - bufferSeconds;
}
/**
* ログイン
*
* @param config - FileMaker設定
* @returns 成功時はセッション情報、失敗時はエラー
*/
async login(
config: FileMakerConfig
): Promise<{ success: true; sessionInfo: SessionInfo } | ErrorResponse> {
const logComplete = createTimedLogger(logger, 'login');
// 既存セッションがあれば先にログアウト
if (this.hasActiveSession()) {
logger.info('Existing session found, logging out first');
await this.logout();
}
// HTTPクライアント作成
this.httpClient = new FileMakerHttpClient(config);
logger.info(`Logging in to ${config.database}@${config.server}`);
// ログインリクエスト
const response = await this.httpClient.loginRequest<FMLoginResponse>(
config.username,
config.password
);
if (isErrorResponse(response)) {
logger.warn('Login failed');
logComplete();
this.httpClient = null;
return response;
}
// セッション状態を保存
const sessionInfo: SessionInfo = {
database: config.database,
server: config.server,
createdAt: Date.now(),
};
this.state = {
token: response.data.token,
info: sessionInfo,
config,
};
logger.info(`Login successful to ${config.database}`);
logComplete();
return {
success: true,
sessionInfo,
};
}
/**
* ログアウト
*
* @returns 成功時はtrue、失敗時はエラー
*/
async logout(): Promise<{ success: true; message: string } | ErrorResponse> {
const logComplete = createTimedLogger(logger, 'logout');
if (!this.hasActiveSession() || !this.httpClient || !this.state) {
logger.warn('No active session to logout');
logComplete();
return {
success: false,
error: {
code: ErrorCodes.SESSION_INVALID,
message: 'No active session',
retryable: false,
},
};
}
const token = this.state.token;
const database = this.state.info.database;
logger.info(`Logging out from ${database}`);
// ログアウトリクエスト
const response = await this.httpClient.delete<Record<string, never>>(
`/sessions/${token}`,
token
);
// セッション状態をクリア(エラーでもクリア)
this.clearSession();
if (isErrorResponse(response)) {
// 401エラーは既にセッションが無効なのでエラーにしない
if (response.error.code === ErrorCodes.SESSION_EXPIRED) {
logger.info('Session already expired');
logComplete();
return {
success: true,
message: 'Session already expired',
};
}
logger.warn('Logout failed', { error: response.error });
logComplete();
return response;
}
logger.info('Logout successful');
logComplete();
return {
success: true,
message: 'Logged out successfully',
};
}
/**
* セッション有効性確認
*
* 設計書 3.2.2 準拠:
* Data APIにはセッション検証専用エンドポイントがないため、
* 認証済みエンドポイント(GET /layouts)を呼び出して検証
*
* @returns セッション有効性
*/
async validateSession(): Promise<{
valid: boolean;
message: string;
sessionAge?: number;
}> {
const logComplete = createTimedLogger(logger, 'validateSession');
if (!this.hasActiveSession() || !this.httpClient || !this.state) {
logComplete();
return {
valid: false,
message: 'No active session',
};
}
logger.debug('Validating session');
// GET /layouts でセッション検証
const response = await this.httpClient.get<{ layouts: unknown[] }>(
'/layouts',
this.state.token
);
if (isErrorResponse(response)) {
// 401はセッション期限切れ
if (response.error.code === ErrorCodes.SESSION_EXPIRED) {
logger.info('Session expired');
this.clearSession();
logComplete();
return {
valid: false,
message: 'Session expired or invalid',
};
}
// その他のエラーは検証失敗だがセッションは維持
logger.warn('Session validation failed with error', { error: response.error });
logComplete();
return {
valid: false,
message: response.error.message,
};
}
const sessionAge = this.getSessionAge();
logger.debug('Session is valid', { sessionAge });
logComplete();
return {
valid: true,
message: 'Session is valid',
sessionAge,
};
}
/**
* 認証済みリクエストを実行
*
* @param requestFn - リクエスト実行関数
* @returns レスポンス
*/
async withSession<T>(
requestFn: (client: FileMakerHttpClient, token: string) => Promise<T>
): Promise<T | ErrorResponse> {
if (!this.hasActiveSession() || !this.httpClient || !this.state) {
// SESSION_INVALID: セッション未確立は retryable: false
// (SESSION_EXPIRED とは異なり、先にログインが必要)
return {
success: false,
error: {
code: ErrorCodes.SESSION_INVALID,
message: 'No active session. Please login first.',
details: 'withSession called without active session',
retryable: false,
},
};
}
return requestFn(this.httpClient, this.state.token);
}
/**
* セッション状態をクリア
*/
private clearSession(): void {
this.state = null;
this.httpClient = null;
logger.debug('Session cleared');
}
}
// ============================================================================
// シングルトンインスタンス(既存互換・廃止予定)
// ============================================================================
/**
* グローバルセッションマネージャー
*
* @deprecated 複数DB対応のため、SessionManagerRegistryを使用してください
*/
let globalSessionManager: SessionManager | null = null;
/**
* セッションマネージャーを取得
*
* @deprecated 複数DB対応のため、getRegistry().getManager(alias)を使用してください
*/
export function getSessionManager(): SessionManager {
if (!globalSessionManager) {
globalSessionManager = new SessionManager();
}
return globalSessionManager;
}
/**
* セッションマネージャーをリセット(テスト用)
*
* @deprecated 複数DB対応のため、resetRegistry()を使用してください
*/
export function resetSessionManager(): void {
globalSessionManager = null;
}
// ============================================================================
// SessionManagerRegistry(複数DB対応)
// ============================================================================
/**
* SessionManagerRegistry
*
* 複数のFileMakerデータベース接続を管理するレジストリ。
* エイリアス(大文字に正規化)をキーとしてSessionManagerインスタンスを保持する。
*
* 主な責務:
* - エイリアスごとのSessionManager管理
* - データベース情報の提供(fm_list_databases用)
* - 全セッションの一括ログアウト
*
* 使用例:
* ```typescript
* // 初期化(サーバー起動時)
* const registry = loadMultiDatabaseConfig();
* initializeRegistry(registry);
*
* // ツール内での使用
* const manager = getRegistry().getManager('PROD');
* await manager.login(config);
* ```
*/
export class SessionManagerRegistry {
/**
* エイリアスをキーとしたSessionManagerマップ
* キーは常に大文字に正規化される
*/
private managers: Map<string, SessionManager> = new Map();
/**
* データベース設定レジストリ
* 初期化時に環境変数から読み込まれた設定を保持
*/
private databaseRegistry: DatabaseRegistry;
/**
* コンストラクタ
*
* @param databaseRegistry - 環境変数から読み込まれたデータベース設定
*/
constructor(databaseRegistry: DatabaseRegistry) {
this.databaseRegistry = databaseRegistry;
// 各エイリアスに対してSessionManagerを初期化
for (const alias of databaseRegistry.aliases) {
this.managers.set(alias, new SessionManager());
}
logger.info(
`SessionManagerRegistry initialized with ${databaseRegistry.aliases.length} database(s): ${databaseRegistry.aliases.join(', ')}`
);
}
/**
* 指定エイリアスのSessionManagerを取得
*
* エイリアスは大文字に正規化される。
* 存在しないエイリアスの場合はエラーをスローする。
*
* @param alias - データベースエイリアス(大文字小文字不問)
* @returns 対応するSessionManager
* @throws Error 指定エイリアスが存在しない場合
*/
getManager(alias: string): SessionManager {
// エイリアスを大文字に正規化
const normalizedAlias = alias.toUpperCase();
const manager = this.managers.get(normalizedAlias);
if (!manager) {
const availableAliases = this.databaseRegistry.aliases.join(', ');
throw new Error(
`Database alias "${alias}" (normalized: "${normalizedAlias}") not found. ` +
`Available aliases: ${availableAliases}`
);
}
return manager;
}
/**
* 指定エイリアスのデータベース設定を取得
*
* @param alias - データベースエイリアス(大文字小文字不問)
* @returns 対応するDatabaseConfig
* @throws Error 指定エイリアスが存在しない場合
*/
getDatabaseConfig(alias: string): DatabaseConfig {
const normalizedAlias = alias.toUpperCase();
const config = this.databaseRegistry.databases.get(normalizedAlias);
if (!config) {
const availableAliases = this.databaseRegistry.aliases.join(', ');
throw new Error(
`Database alias "${alias}" (normalized: "${normalizedAlias}") not found. ` +
`Available aliases: ${availableAliases}`
);
}
return config;
}
/**
* 指定エイリアスのFileMakerConfigを取得
*
* SessionManager.login()で使用するFileMakerConfig形式に変換して返す。
*
* @param alias - データベースエイリアス(大文字小文字不問)
* @returns FileMaker設定
*/
getFileMakerConfig(alias: string): FileMakerConfig {
const dbConfig = this.getDatabaseConfig(alias);
return toFileMakerConfig(dbConfig);
}
/**
* 指定エイリアスのデータベース情報を取得(公開情報のみ)
*
* パスワードを含まない公開可能な情報のみを返す。
* fm_list_databases ツールで使用される。
*
* @param alias - データベースエイリアス(大文字小文字不問)
* @returns DatabaseInfo(パスワードなし)
*/
getDatabaseInfo(alias: string): DatabaseInfo {
const config = this.getDatabaseConfig(alias);
return {
alias: config.alias,
server: config.server,
database: config.database,
};
}
/**
* 全データベースの情報を取得(公開情報のみ)
*
* fm_list_databases ツールで全データベース一覧を返す際に使用。
*
* @returns 全DatabaseInfoの配列
*/
getAllDatabaseInfo(): DatabaseInfo[] {
return this.databaseRegistry.aliases.map((alias) => this.getDatabaseInfo(alias));
}
/**
* 利用可能なエイリアス一覧を取得
*
* @returns エイリアス配列(ソート済み、大文字)
*/
getAliases(): string[] {
return [...this.databaseRegistry.aliases];
}
/**
* 指定エイリアスが存在するかチェック
*
* @param alias - チェック対象のエイリアス(大文字小文字不問)
* @returns 存在する場合true
*/
hasAlias(alias: string): boolean {
const normalizedAlias = alias.toUpperCase();
return this.managers.has(normalizedAlias);
}
/**
* 全セッションをログアウト
*
* サーバー終了時やリセット時に全てのアクティブセッションをクリーンアップする。
* 各ログアウトは並列実行され、個別のエラーは記録されるが全体の処理は継続する。
*
* @returns ログアウト結果のサマリー
*/
async logoutAll(): Promise<{
success: boolean;
loggedOut: string[];
failed: string[];
skipped: string[];
}> {
const logComplete = createTimedLogger(logger, 'logoutAll');
logger.info('Logging out all sessions');
const loggedOut: string[] = [];
const failed: string[] = [];
const skipped: string[] = [];
// 全エイリアスに対して並列でログアウト実行
const logoutPromises = this.databaseRegistry.aliases.map(async (alias) => {
const manager = this.managers.get(alias);
if (!manager) {
skipped.push(alias);
return;
}
if (!manager.hasActiveSession()) {
skipped.push(alias);
return;
}
try {
const result = await manager.logout();
if ('success' in result && result.success) {
loggedOut.push(alias);
} else {
failed.push(alias);
}
} catch (error) {
logger.error(`Failed to logout ${alias}`, { error });
failed.push(alias);
}
});
await Promise.all(logoutPromises);
const success = failed.length === 0;
logger.info(
`Logout all completed: ${loggedOut.length} logged out, ${failed.length} failed, ${skipped.length} skipped`
);
logComplete();
return {
success,
loggedOut,
failed,
skipped,
};
}
/**
* 全セッションの状態サマリーを取得
*
* デバッグやモニタリング用に全セッションの状態を返す。
*
* @returns セッション状態のサマリー
*/
getSessionSummary(): {
total: number;
active: number;
inactive: number;
sessions: Array<{
alias: string;
active: boolean;
database?: string;
server?: string;
sessionAge?: number;
}>;
} {
const sessions = this.databaseRegistry.aliases.map((alias) => {
const manager = this.managers.get(alias);
const info = manager?.getSessionInfo();
return {
alias,
active: manager?.hasActiveSession() ?? false,
database: info?.database,
server: info?.server,
sessionAge: manager?.getSessionAge(),
};
});
const active = sessions.filter((s) => s.active).length;
return {
total: sessions.length,
active,
inactive: sessions.length - active,
sessions,
};
}
}
// ============================================================================
// グローバルレジストリインスタンス
// ============================================================================
/**
* グローバルSessionManagerRegistry
*/
let globalRegistry: SessionManagerRegistry | null = null;
/**
* レジストリを初期化
*
* サーバー起動時にloadMultiDatabaseConfig()で読み込んだ設定を使用して
* SessionManagerRegistryを初期化する。
*
* @param databaseRegistry - 環境変数から読み込まれたデータベース設定
* @returns 初期化されたSessionManagerRegistry
*/
export function initializeRegistry(databaseRegistry: DatabaseRegistry): SessionManagerRegistry {
if (globalRegistry) {
logger.warn('Registry already initialized, reinitializing');
}
globalRegistry = new SessionManagerRegistry(databaseRegistry);
return globalRegistry;
}
/**
* グローバルレジストリを取得
*
* @returns SessionManagerRegistry
* @throws Error レジストリが初期化されていない場合
*/
export function getRegistry(): SessionManagerRegistry {
if (!globalRegistry) {
throw new Error(
'SessionManagerRegistry not initialized. Call initializeRegistry() first at server startup.'
);
}
return globalRegistry;
}
/**
* レジストリをリセット(テスト用)
*
* 全セッションのログアウトは行わず、インスタンスのみをクリアする。
* テスト前後のクリーンアップに使用。
*/
export function resetRegistry(): void {
globalRegistry = null;
logger.debug('Registry reset');
}
/**
* レジストリが初期化済みかどうか
*
* @returns 初期化済みの場合true
*/
export function isRegistryInitialized(): boolean {
return globalRegistry !== null;
}