/**
* 設定管理(複数DB対応版)
*
* 環境変数から複数FileMaker接続設定を読み込み
* 実装計画書 Phase 1 準拠
*
* 環境変数パターン:
* - FM_SERVER_{ALIAS} : FileMakerサーバーURL(必須)
* - FM_DATABASE_{ALIAS} : データベース名(必須)
* - FM_ACCOUNT_{ALIAS} : ユーザー名(必須)
* - FM_PASSWORD_{ALIAS} : パスワード(必須)
* - FM_API_VERSION_{ALIAS}: Data APIバージョン(オプション)
* - FM_SSL_VERIFY_{ALIAS} : SSL検証フラグ(オプション)
*/
import type { DatabaseConfig, DatabaseRegistry } from './types/filemaker.js';
import { loggers } from './utils/logger.js';
const logger = loggers.config;
// ============================================================================
// 型定義(既存互換)
// ============================================================================
/**
* FileMaker接続設定(既存互換インターフェース)
* 注: 内部では DatabaseConfig を使用するが、既存コードとの互換性のため維持
*/
export interface FileMakerConfig {
/** FileMakerサーバーURL(https://必須) */
server: string;
/** データベース名 */
database: string;
/** ユーザー名 */
username: string;
/** パスワード */
password: string;
/** Data APIバージョン(デフォルト: vLatest) */
apiVersion: string;
/** SSL証明書検証(デフォルト: true) */
sslVerify: boolean;
/** セッションタイムアウト秒数(デフォルト: 840秒 = 14分) */
sessionTimeout: number;
}
/**
* 設定検証結果
*/
export interface ConfigValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
// ============================================================================
// 定数
// ============================================================================
/**
* デフォルト設定値
*/
export const CONFIG_DEFAULTS = {
apiVersion: 'vLatest',
sslVerify: true,
sessionTimeout: 840, // 14分(FileMakerデフォルトの15分より短く設定)
} as const;
/**
* 環境変数プレフィックス(複数DB用)
*/
export const ENV_PREFIXES = {
server: 'FM_SERVER_',
database: 'FM_DATABASE_',
account: 'FM_ACCOUNT_',
password: 'FM_PASSWORD_',
apiVersion: 'FM_API_VERSION_',
sslVerify: 'FM_SSL_VERIFY_',
} as const;
/**
* グローバル設定用環境変数名
*/
export const GLOBAL_ENV_VARS = {
apiVersion: 'FM_API_VERSION',
sslVerify: 'FM_SSL_VERIFY',
sessionTimeout: 'FM_SESSION_TIMEOUT',
} as const;
// ============================================================================
// ユーティリティ関数
// ============================================================================
/**
* 環境変数からブール値を読み込み
*
* @param value - 環境変数の値
* @param defaultValue - デフォルト値
* @returns ブール値
*/
function parseBooleanEnv(value: string | undefined, defaultValue: boolean): boolean {
if (value === undefined || value === '') {
return defaultValue;
}
const normalized = value.toLowerCase().trim();
return normalized === 'true' || normalized === '1' || normalized === 'yes';
}
/**
* 環境変数から数値を読み込み
*
* @param value - 環境変数の値
* @param defaultValue - デフォルト値
* @returns 数値
*/
function parseIntEnv(value: string | undefined, defaultValue: number): number {
if (value === undefined || value === '') {
return defaultValue;
}
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? defaultValue : parsed;
}
/**
* サーバーURLを検証
*
* https://で始まることを検証する。補完は行わない。
*
* @param url - サーバーURL
* @returns 検証結果(エラーメッセージまたはundefined)
*/
function validateServerUrl(url: string): string | undefined {
if (!url) {
return 'Server URL is required';
}
const trimmed = url.trim();
// https:// で始まることを必須とする(http:// は不許可)
if (!trimmed.startsWith('https://')) {
if (trimmed.startsWith('http://')) {
return 'Server URL must use HTTPS, not HTTP (SEC-005)';
}
return 'Server URL must start with https://';
}
return undefined;
}
/**
* サーバーURLを正規化(末尾スラッシュ除去のみ)
*
* @param url - サーバーURL
* @returns 正規化されたURL
*/
function normalizeServerUrl(url: string): string {
let normalized = url.trim();
// 末尾スラッシュを除去
while (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
// ============================================================================
// 複数DB設定読み込み
// ============================================================================
/**
* エイリアス情報(正規化前後のマッピング)
*/
interface AliasInfo {
/** 正規化されたエイリアス(大文字) */
normalized: string;
/** 環境変数で使用されている元のエイリアス(原型) */
original: string;
}
/**
* 環境変数から全エイリアスを抽出
*
* FM_SERVER_* パターンからエイリアスを検出し、大文字に正規化する。
* 衝突検出: 正規化後に同じエイリアスになる環境変数が複数ある場合はエラー。
*
* @returns エイリアス情報の配列(正規化済みエイリアスでソート)
* @throws Error エイリアス衝突時
*/
function extractAliases(): AliasInfo[] {
const serverKeys = Object.keys(process.env).filter((key) => key.startsWith(ENV_PREFIXES.server));
// エイリアス抽出と正規化(元のエイリアスも保持)
const aliasMap = new Map<string, string[]>();
for (const key of serverKeys) {
const originalAlias = key.slice(ENV_PREFIXES.server.length);
const normalizedAlias = originalAlias.toUpperCase();
// 衝突検出用に元のキーを記録
const existing = aliasMap.get(normalizedAlias) || [];
existing.push(originalAlias);
aliasMap.set(normalizedAlias, existing);
}
// 衝突検出
for (const [normalizedAlias, originalAliases] of aliasMap) {
if (originalAliases.length > 1) {
const keys = originalAliases.map((a) => `${ENV_PREFIXES.server}${a}`);
throw new Error(
`Alias collision detected: ${keys.join(' and ')} both normalize to "${normalizedAlias}". Please use consistent casing for environment variable aliases.`
);
}
}
// AliasInfo配列として返却(ソート済み)
// 注: 衝突チェック後なので originals[0] は必ず存在する
return Array.from(aliasMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([normalized, originals]): AliasInfo => ({
normalized,
original: originals[0] as string,
}));
}
/**
* 単一エイリアスの設定を読み込み
*
* @param aliasInfo - エイリアス情報(正規化済みと原型の両方を含む)
* @returns データベース設定(バリデーション未実施)
*/
function loadConfigForAlias(aliasInfo: AliasInfo): Partial<DatabaseConfig> {
const { normalized, original } = aliasInfo;
// エイリアス固有の設定を読み込み
// 環境変数で使用されている元のエイリアス(原型)を優先して使用
const getEnv = (prefix: string): string | undefined => {
// まず元のエイリアス(原型)で試す(FM_SERVER_Prod のような混在ケースに対応)
const originalKey = `${prefix}${original}`;
if (process.env[originalKey] !== undefined) {
return process.env[originalKey];
}
// 正規化されたエイリアス(大文字)で試す
const normalizedKey = `${prefix}${normalized}`;
return process.env[normalizedKey];
};
const config: Partial<DatabaseConfig> = {
alias: normalized,
server: getEnv(ENV_PREFIXES.server),
database: getEnv(ENV_PREFIXES.database),
username: getEnv(ENV_PREFIXES.account), // FM_ACCOUNT_* → username
password: getEnv(ENV_PREFIXES.password),
};
// APIバージョン: エイリアス固有 → グローバル → デフォルト
const aliasApiVersion = getEnv(ENV_PREFIXES.apiVersion);
const globalApiVersion = process.env[GLOBAL_ENV_VARS.apiVersion];
config.apiVersion = aliasApiVersion || globalApiVersion || CONFIG_DEFAULTS.apiVersion;
// SSL検証: エイリアス固有 → グローバル → デフォルト
const aliasSslVerify = getEnv(ENV_PREFIXES.sslVerify);
const globalSslVerify = process.env[GLOBAL_ENV_VARS.sslVerify];
if (aliasSslVerify !== undefined) {
config.sslVerify = parseBooleanEnv(aliasSslVerify, CONFIG_DEFAULTS.sslVerify);
} else if (globalSslVerify !== undefined) {
config.sslVerify = parseBooleanEnv(globalSslVerify, CONFIG_DEFAULTS.sslVerify);
} else {
config.sslVerify = CONFIG_DEFAULTS.sslVerify;
}
// セッションタイムアウト(グローバルのみ)
config.sessionTimeout = parseIntEnv(
process.env[GLOBAL_ENV_VARS.sessionTimeout],
CONFIG_DEFAULTS.sessionTimeout
);
return config;
}
/**
* 単一データベース設定のバリデーション
*
* @param config - 検証対象の設定
* @param alias - エイリアス(エラーメッセージ用)
* @returns バリデーション結果
*/
export function validateDatabaseConfig(
config: Partial<DatabaseConfig>,
alias: string
): ConfigValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// 必須項目チェック
if (!config.server) {
errors.push(`[${alias}] ${ENV_PREFIXES.server}${alias} is required`);
} else {
// サーバーURLのバリデーション
const urlError = validateServerUrl(config.server);
if (urlError) {
errors.push(`[${alias}] ${urlError}`);
}
}
if (!config.database) {
errors.push(`[${alias}] ${ENV_PREFIXES.database}${alias} is required`);
}
if (!config.username) {
errors.push(`[${alias}] ${ENV_PREFIXES.account}${alias} is required`);
}
if (!config.password) {
errors.push(`[${alias}] ${ENV_PREFIXES.password}${alias} is required`);
}
// 警告チェック
// SEC-006: SSL検証無効化時の警告
if (config.sslVerify === false) {
warnings.push(
`[${alias}] SEC-006: SSL certificate verification is disabled. This is insecure and should only be used in development.`
);
}
// セッションタイムアウトの検証
if (config.sessionTimeout !== undefined) {
if (config.sessionTimeout < 60) {
warnings.push(
`[${alias}] Session timeout is very short (${config.sessionTimeout}s). Consider using at least 60 seconds.`
);
}
if (config.sessionTimeout > 900) {
warnings.push(
`[${alias}] Session timeout (${config.sessionTimeout}s) exceeds FileMaker's default (900s). Session may expire unexpectedly.`
);
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* 環境変数から複数DB設定を読み込み
*
* @returns DatabaseRegistry 読み込まれた設定レジストリ
* @throws Error 有効な設定が1つも見つからない場合、またはバリデーションエラー時
*/
export function loadMultiDatabaseConfig(): DatabaseRegistry {
logger.info('Loading multi-database configuration from environment variables');
// 1. エイリアス抽出(衝突検出含む)
const aliasInfoList = extractAliases();
if (aliasInfoList.length === 0) {
throw new Error(
`No database configuration found. Please set environment variables with pattern: ${ENV_PREFIXES.server}{ALIAS}, ${ENV_PREFIXES.database}{ALIAS}, ${ENV_PREFIXES.account}{ALIAS}, ${ENV_PREFIXES.password}{ALIAS}`
);
}
// 正規化されたエイリアス一覧(ログ出力用)
const normalizedAliases = aliasInfoList.map((info) => info.normalized);
logger.info(`Found ${aliasInfoList.length} database alias(es): ${normalizedAliases.join(', ')}`);
// 2. 各エイリアスの設定を読み込み・バリデーション
const databases = new Map<string, DatabaseConfig>();
const allErrors: string[] = [];
const allWarnings: string[] = [];
for (const aliasInfo of aliasInfoList) {
const config = loadConfigForAlias(aliasInfo);
const validation = validateDatabaseConfig(config, aliasInfo.normalized);
allErrors.push(...validation.errors);
allWarnings.push(...validation.warnings);
if (validation.valid) {
// サーバーURLを正規化して保存
const normalizedConfig: DatabaseConfig = {
alias: aliasInfo.normalized,
server: normalizeServerUrl(config.server as string),
database: config.database as string,
username: config.username as string,
password: config.password as string,
apiVersion: config.apiVersion as string,
sslVerify: config.sslVerify as boolean,
sessionTimeout: config.sessionTimeout as number,
};
databases.set(aliasInfo.normalized, normalizedConfig);
}
}
// 警告をログ出力
for (const warning of allWarnings) {
logger.warn(warning);
}
// エラーがあれば例外をスロー
if (allErrors.length > 0) {
for (const error of allErrors) {
logger.error(`Configuration error: ${error}`);
}
throw new Error(`Configuration validation failed:\n${allErrors.join('\n')}`);
}
logger.info(`Successfully loaded ${databases.size} database configuration(s)`);
return {
databases,
aliases: normalizedAliases,
};
}
// ============================================================================
// DatabaseConfig → FileMakerConfig 変換(既存互換)
// ============================================================================
/**
* DatabaseConfigをFileMakerConfigに変換
*
* 既存のSessionManagerとの互換性のために使用
*
* @param dbConfig - データベース設定
* @returns FileMaker設定
*/
export function toFileMakerConfig(dbConfig: DatabaseConfig): FileMakerConfig {
return {
server: dbConfig.server,
database: dbConfig.database,
username: dbConfig.username,
password: dbConfig.password,
apiVersion: dbConfig.apiVersion,
sslVerify: dbConfig.sslVerify,
sessionTimeout: dbConfig.sessionTimeout,
};
}
// ============================================================================
// 設定表示(デバッグ用)
// ============================================================================
/**
* 設定を安全に表示(パスワードはマスク)
*
* @param config - 表示する設定
* @returns マスク済み設定オブジェクト
*/
export function formatConfigForLog(
config: Partial<FileMakerConfig> | Partial<DatabaseConfig>
): Record<string, unknown> {
return {
alias: 'alias' in config ? config.alias : undefined,
server: config.server,
database: config.database,
username: config.username,
password: config.password ? '***MASKED***' : undefined,
apiVersion: config.apiVersion,
sslVerify: config.sslVerify,
sessionTimeout: config.sessionTimeout,
};
}
/**
* DatabaseRegistryの内容を安全に表示
*
* @param registry - 表示するレジストリ
* @returns ログ用文字列
*/
export function formatRegistryForLog(registry: DatabaseRegistry): string {
const entries = Array.from(registry.databases.values()).map((config) => {
return ` ${config.alias}: ${config.database}@${config.server}`;
});
return `DatabaseRegistry (${registry.aliases.length} database(s)):\n${entries.join('\n')}`;
}