'sk-ant-', 'openai' => 'sk-', 'gemini' => 'AIza', ]; /** Canonical whitelist of WCAG standards the axe-core scanner can filter by. */ public const VALID_RULESETS = [ 'wcag20a', 'wcag20aa', 'wcag20aaa', 'wcag21a', 'wcag21aa', 'wcag21aaa', 'wcag22aa', 'section508', 'best-practice', ]; public static function get(): array { $s = get_option( self::OPTION_KEY, [] ); // Migrate legacy single-string ruleset (pre multi-select) to an array. if ( isset( $s['default_rulesets'] ) ) { $default_rulesets = $s['default_rulesets']; } elseif ( isset( $s['default_ruleset'] ) && is_string( $s['default_ruleset'] ) ) { $default_rulesets = [ $s['default_ruleset'] ]; } else { $default_rulesets = [ 'wcag22aa' ]; } return [ 'ai_provider' => $s['ai_provider'] ?? 'none', // boolean — raw key never leaves PHP. Also false for a key saved under an // older version (or any other state) where ai_key_provider doesn't match // ai_provider, so a stale/mismatched key is never reported as usable. 'ai_key_set' => self::has_usable_key( $s ), 'ai_model' => $s['ai_model'] ?? '', 'scan_schedule' => $s['scan_schedule'] ?? 'weekly', 'default_rulesets' => $default_rulesets, 'email_alerts' => $s['email_alerts'] ?? true, ]; } /** * Save settings. If $raw_key is empty the previously stored key is preserved. */ public static function save( string $provider, string $raw_key, string $model, string $schedule, array $default_rulesets = [ 'wcag22aa' ], bool $email_alerts = true, bool $remove_key = false ): void { $current = get_option( self::OPTION_KEY, [] ); // The user has been to Settings and saved — the re-entry prompt has done its job. delete_option( self::REENTRY_NOTICE_OPTION ); $default_rulesets = array_values( array_unique( array_intersect( $default_rulesets, self::VALID_RULESETS ) ) ); if ( empty( $default_rulesets ) ) { $default_rulesets = [ 'wcag22aa' ]; } // A stored key only ever belongs to the provider it was saved for (tracked // explicitly via ai_key_provider, not inferred from ai_provider — keys saved // before this existed are adopted or cleared by migrate_legacy_key()). Keep the key // across a save only when it already belongs to the provider being saved; // otherwise clear it rather than let a provider switch silently reuse the // previous provider's key. $remove_key is a distinct "clear it" signal from // the user (vs. an empty $raw_key, which just means "didn't change it"). if ( $remove_key ) { $ai_key = ''; $ai_key_provider = ''; } elseif ( $raw_key !== '' ) { $ai_key = AccessMate_AI_Proxy::encrypt_key( $raw_key ); $ai_key_provider = $provider; } elseif ( ( $current['ai_key_provider'] ?? '' ) === $provider ) { $ai_key = $current['ai_key'] ?? ''; $ai_key_provider = $provider; } else { $ai_key = ''; $ai_key_provider = ''; } update_option( self::OPTION_KEY, [ 'ai_provider' => $provider, 'ai_key' => $ai_key, 'ai_key_provider' => $ai_key_provider, 'ai_model' => $model, 'scan_schedule' => $schedule, 'default_rulesets' => $default_rulesets, 'email_alerts' => $email_alerts, ]); } /** * One-time migration for keys saved by 1.2.0 and earlier, which have no * ai_key_provider. The key is adopted only when it carries the selected * provider's prefix; otherwise it's cleared (never reused against another * provider) and an admin notice asks the user to re-enter it. */ public static function migrate_legacy_key(): void { $s = get_option( self::OPTION_KEY, [] ); if ( ! is_array( $s ) || empty( $s ) || array_key_exists( 'ai_key_provider', $s ) ) { return; } $provider = $s['ai_provider'] ?? 'none'; $s['ai_key_provider'] = ''; if ( ! empty( $s['ai_key'] ) ) { $raw = AccessMate_AI_Proxy::decrypt_key( $s['ai_key'] ); $prefix = self::KEY_PREFIXES[ $provider ] ?? null; $is_match = $prefix !== null && 0 === strpos( $raw, $prefix ) // OpenAI's 'sk-' prefix is also the start of Anthropic's 'sk-ant-'. && ! ( 'openai' === $provider && 0 === strpos( $raw, self::KEY_PREFIXES['anthropic'] ) ); if ( $is_match ) { $s['ai_key_provider'] = $provider; } else { $s['ai_key'] = ''; if ( $prefix !== null ) { update_option( self::REENTRY_NOTICE_OPTION, 1, false ); } } } update_option( self::OPTION_KEY, $s ); } /** Admin notice shown after migrate_legacy_key() had to clear a key. */ public static function render_reentry_notice(): void { if ( ! get_option( self::REENTRY_NOTICE_OPTION ) || ! current_user_can( 'manage_options' ) ) { return; } printf( '

%s %s

', esc_html__( 'AccessMate: your saved AI API key could not be matched to the selected AI provider, so it was removed for safety. AI features are off until you enter it again.', 'accessmate' ), esc_url( admin_url( 'admin.php?page=accessmate-settings' ) ), esc_html__( 'Re-enter your API key', 'accessmate' ) ); } /** True when $s has an encrypted key that was actually saved for $s's current provider. */ private static function has_usable_key( array $s ): bool { return ! empty( $s['ai_key'] ) && ( $s['ai_key_provider'] ?? '' ) === ( $s['ai_provider'] ?? '' ); } public static function get_raw_key(): string { $s = get_option( self::OPTION_KEY, [] ); if ( ! self::has_usable_key( $s ) ) return ''; return AccessMate_AI_Proxy::decrypt_key( $s['ai_key'] ); } }