v0.2.3 — UI-editable encrypted provider API key (#6)
Squash-merge PR #6: PR Vision v0.2.3 — UI-editable encrypted provider API key
QA APPROVED: 2026-06-14-85956da7c86bed2c9e2cf60625fda0cf2e9518bb.md
All 5 CI checks green on head SHA 85956da7c8
This commit is contained in:
185
includes/core/class-prv-crypto-helper.php
Normal file
185
includes/core/class-prv-crypto-helper.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
/**
|
||||
* Symmetric encryption/decryption helper for PR Vision.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Symmetric encryption/decryption using libsodium (preferred) or OpenSSL AES-256-GCM.
|
||||
*
|
||||
* Provides a narrow, single-purpose interface: encrypt() / decrypt(). The
|
||||
* encryption key is always supplied by the caller (PRV_Key_Store derives it
|
||||
* from WP salts). No key material is persisted here.
|
||||
*
|
||||
* Algorithm selection:
|
||||
* - libsodium XSalsa20-Poly1305 (sodium_crypto_secretbox): used when the
|
||||
* sodium extension is available (PHP 7.2+ default). Nonce = 24 bytes (random).
|
||||
* - OpenSSL AES-256-GCM fallback: used when sodium is unavailable. IV = 12 bytes
|
||||
* (random); 16-byte GCM auth tag appended to ciphertext.
|
||||
*
|
||||
* Storage format (both algorithms): hex(nonce_or_iv) : hex(ciphertext[+tag])
|
||||
* The algorithm is inferred from nonce length at decrypt time.
|
||||
*
|
||||
* Security invariants:
|
||||
* - Plaintext is NEVER stored, logged, or returned beyond the caller.
|
||||
* - Each encrypt() call uses a fresh random nonce/IV.
|
||||
* - Decryption silently returns '' on any failure (no info leak via exception).
|
||||
*
|
||||
* Who triggers: PRV_Key_Store — all encryption/decryption delegated here.
|
||||
* Dependencies: sodium_crypto_secretbox (optional), openssl_encrypt (fallback).
|
||||
*
|
||||
* @see class-prv-key-store.php -- Calls encrypt() / decrypt().
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Crypto_Helper {
|
||||
|
||||
/**
|
||||
* Separator between hex-encoded nonce/IV and ciphertext in stored format.
|
||||
*/
|
||||
const SEPARATOR = ':';
|
||||
|
||||
/**
|
||||
* Encrypt plaintext using libsodium or OpenSSL fallback.
|
||||
*
|
||||
* @param string $plaintext Value to encrypt.
|
||||
* @param string $enc_key 32-byte raw binary encryption key.
|
||||
*
|
||||
* @return string hex(nonce):hex(ciphertext), or empty string on error.
|
||||
*/
|
||||
public static function encrypt( string $plaintext, string $enc_key ): string {
|
||||
if ( function_exists( 'sodium_crypto_secretbox' ) ) {
|
||||
return self::encrypt_sodium( $plaintext, $enc_key );
|
||||
}
|
||||
return self::encrypt_openssl( $plaintext, $enc_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored ciphertext produced by encrypt().
|
||||
*
|
||||
* Algorithm is inferred from nonce length: sodium nonce = 24 bytes (48 hex).
|
||||
*
|
||||
* @param string $stored Encoded ciphertext (hex(nonce):hex(ciphertext)).
|
||||
* @param string $enc_key 32-byte raw binary encryption key.
|
||||
*
|
||||
* @return string Plaintext, or empty string on any error.
|
||||
*/
|
||||
public static function decrypt( string $stored, string $enc_key ): string {
|
||||
$sep_pos = strpos( $stored, self::SEPARATOR );
|
||||
if ( false === $sep_pos ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$nonce_hex = substr( $stored, 0, $sep_pos );
|
||||
$cipher_hex = substr( $stored, $sep_pos + 1 );
|
||||
|
||||
if ( '' === $nonce_hex || '' === $cipher_hex ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Infer algorithm from nonce length: sodium = 24 bytes (48 hex chars).
|
||||
$nonce_byte_count = strlen( $nonce_hex ) / 2;
|
||||
if ( function_exists( 'sodium_crypto_secretbox_open' )
|
||||
&& SODIUM_CRYPTO_SECRETBOX_NONCEBYTES === (int) $nonce_byte_count ) {
|
||||
return self::decrypt_sodium( $nonce_hex, $cipher_hex, $enc_key );
|
||||
}
|
||||
|
||||
// OpenSSL path: IV = 12 bytes (24 hex), 16-byte tag appended.
|
||||
return self::decrypt_openssl( $nonce_hex, $cipher_hex, $enc_key );
|
||||
}
|
||||
|
||||
// ── Private algorithm implementations ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encrypt using libsodium secretbox (XSalsa20-Poly1305).
|
||||
*
|
||||
* @param string $plaintext Value to encrypt.
|
||||
* @param string $enc_key 32-byte raw key.
|
||||
*
|
||||
* @return string hex(nonce):hex(ciphertext), or empty on error.
|
||||
*/
|
||||
private static function encrypt_sodium( string $plaintext, string $enc_key ): string {
|
||||
try {
|
||||
$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
|
||||
$ciphertext = sodium_crypto_secretbox( $plaintext, $nonce, $enc_key );
|
||||
return bin2hex( $nonce ) . self::SEPARATOR . bin2hex( $ciphertext );
|
||||
} catch ( \Throwable $e ) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt using libsodium secretbox.
|
||||
*
|
||||
* @param string $nonce_hex Hex-encoded 24-byte nonce.
|
||||
* @param string $cipher_hex Hex-encoded ciphertext with Poly1305 MAC.
|
||||
* @param string $enc_key 32-byte raw key.
|
||||
*
|
||||
* @return string Plaintext, or empty on failure.
|
||||
*/
|
||||
private static function decrypt_sodium( string $nonce_hex, string $cipher_hex, string $enc_key ): string {
|
||||
try {
|
||||
$nonce = hex2bin( $nonce_hex );
|
||||
$ciphertext = hex2bin( $cipher_hex );
|
||||
if ( false === $nonce || false === $ciphertext ) {
|
||||
return '';
|
||||
}
|
||||
$result = sodium_crypto_secretbox_open( $ciphertext, $nonce, $enc_key );
|
||||
return ( false === $result ) ? '' : $result;
|
||||
} catch ( \Throwable $e ) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt using OpenSSL AES-256-GCM (sodium fallback).
|
||||
*
|
||||
* Appends the 16-byte GCM auth tag to the ciphertext before hex-encoding,
|
||||
* so decrypt() can split it without a separate field.
|
||||
*
|
||||
* @param string $plaintext Value to encrypt.
|
||||
* @param string $enc_key 32-byte raw key.
|
||||
*
|
||||
* @return string hex(iv):hex(ciphertext+tag), or empty on error.
|
||||
*/
|
||||
private static function encrypt_openssl( string $plaintext, string $enc_key ): string {
|
||||
if ( ! function_exists( 'openssl_encrypt' ) ) {
|
||||
return '';
|
||||
}
|
||||
$iv = random_bytes( 12 ); // GCM standard IV length.
|
||||
$tag = '';
|
||||
$enc = openssl_encrypt( $plaintext, 'aes-256-gcm', $enc_key, OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
|
||||
if ( false === $enc || '' === $tag ) {
|
||||
return '';
|
||||
}
|
||||
return bin2hex( $iv ) . self::SEPARATOR . bin2hex( $enc . $tag );
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt using OpenSSL AES-256-GCM.
|
||||
*
|
||||
* Splits off the 16-byte auth tag appended by encrypt_openssl().
|
||||
*
|
||||
* @param string $iv_hex Hex-encoded 12-byte IV.
|
||||
* @param string $cipher_hex Hex-encoded ciphertext with 16-byte GCM tag appended.
|
||||
* @param string $enc_key 32-byte raw key.
|
||||
*
|
||||
* @return string Plaintext, or empty on failure.
|
||||
*/
|
||||
private static function decrypt_openssl( string $iv_hex, string $cipher_hex, string $enc_key ): string {
|
||||
if ( ! function_exists( 'openssl_decrypt' ) ) {
|
||||
return '';
|
||||
}
|
||||
$iv = hex2bin( $iv_hex );
|
||||
$cipher_raw = hex2bin( $cipher_hex );
|
||||
if ( false === $iv || false === $cipher_raw || strlen( $cipher_raw ) < 16 ) {
|
||||
return '';
|
||||
}
|
||||
$tag = substr( $cipher_raw, -16 );
|
||||
$ciphertext = substr( $cipher_raw, 0, -16 );
|
||||
$result = openssl_decrypt( $ciphertext, 'aes-256-gcm', $enc_key, OPENSSL_RAW_DATA, $iv, $tag );
|
||||
return ( false === $result ) ? '' : $result;
|
||||
}
|
||||
}
|
||||
212
includes/core/class-prv-key-manager-renderer.php
Normal file
212
includes/core/class-prv-key-manager-renderer.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
/**
|
||||
* Renderer for the Provider API Key manager card on the Settings page.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Renders the write-only Provider API Key manager card.
|
||||
*
|
||||
* Outputs only the key SOURCE (constant / admin / not set) and the
|
||||
* last-run API status. The stored key value is NEVER included in any
|
||||
* output: not in HTML, not in JS, not in data attributes.
|
||||
*
|
||||
* Three states:
|
||||
* - SOURCE_CONSTANT: constant defined → lock the input, show note.
|
||||
* - SOURCE_OPTION: admin key stored → show "Set via admin" + Replace/Remove.
|
||||
* - SOURCE_NONE: no key → show "Not set" + Set form.
|
||||
*
|
||||
* The form submits to admin-post.php (prv_key_set or prv_key_remove),
|
||||
* nonce-protected. The password input renders empty on every page load.
|
||||
*
|
||||
* Who triggers: PRV_Settings_Renderer::render_key_manager_card().
|
||||
* Dependencies: PRV_Key_Store, PRV_Settings_Page (nonce constants).
|
||||
*
|
||||
* @see class-prv-key-store.php -- Source detection.
|
||||
* @see class-prv-settings-renderer.php -- Calls render().
|
||||
* @see class-prv-settings-page.php -- Nonce constants + AJAX action.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Key_Manager_Renderer {
|
||||
|
||||
/**
|
||||
* Render the full Provider API Key card.
|
||||
*
|
||||
* Side effects: Outputs HTML directly. Contains no key value.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render(): void {
|
||||
$source = PRV_Key_Store::get_source();
|
||||
$is_const = PRV_Key_Store::SOURCE_CONSTANT === $source;
|
||||
$is_set_opt = PRV_Key_Store::SOURCE_OPTION === $source;
|
||||
$status = (string) get_option( 'prv_api_key_status', 'unknown' );
|
||||
$last_check = (string) get_option( 'prv_api_key_last_check', '' );
|
||||
|
||||
echo '<div class="prv-card" id="prv-key-card">';
|
||||
echo '<h2>' . esc_html__( 'Provider API Key', 'pr-vision' ) . '</h2>';
|
||||
|
||||
$this->render_status_badge( $source, $status, $last_check );
|
||||
$this->render_key_form( $is_const, $is_set_opt );
|
||||
$this->render_test_button( $source );
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the read-only source + health status badge.
|
||||
*
|
||||
* @param string $source One of PRV_Key_Store::SOURCE_* constants.
|
||||
* @param string $api_status Last stored API status (ok/failed/unknown).
|
||||
* @param string $last_check ISO date of last status update.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_status_badge( string $source, string $api_status, string $last_check ): void {
|
||||
echo '<div class="prv-field">';
|
||||
|
||||
if ( PRV_Key_Store::SOURCE_CONSTANT === $source ) {
|
||||
echo '<span class="prv-api-status prv-api-ok" id="prv-key-source-badge">';
|
||||
echo '● ' . esc_html__( 'Set via wp-config (takes precedence)', 'pr-vision' );
|
||||
echo '</span>';
|
||||
} elseif ( PRV_Key_Store::SOURCE_OPTION === $source ) {
|
||||
echo '<span class="prv-api-status prv-api-ok" id="prv-key-source-badge">';
|
||||
echo '● ' . esc_html__( 'Set via admin', 'pr-vision' );
|
||||
echo '</span>';
|
||||
} else {
|
||||
echo '<span class="prv-api-status prv-api-undef" id="prv-key-source-badge">';
|
||||
echo '● ' . esc_html__( 'Not set', 'pr-vision' );
|
||||
echo '</span>';
|
||||
}
|
||||
|
||||
// Last-run health state.
|
||||
if ( '' !== $last_check && PRV_Key_Store::SOURCE_NONE !== $source ) {
|
||||
if ( 'failed' === $api_status ) {
|
||||
echo ' <span class="prv-api-status prv-api-fail">' . esc_html__( 'Last run: failed (key may be invalid or revoked)', 'pr-vision' ) . '</span>';
|
||||
} elseif ( 'ok' === $api_status ) {
|
||||
echo ' <span class="prv-api-status prv-api-ok">' . esc_html__( 'Last run: OK', 'pr-vision' ) . '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the set/replace/remove key form.
|
||||
*
|
||||
* The password input always renders empty. When constant is set, the
|
||||
* input is disabled with an explanatory note.
|
||||
*
|
||||
* @param bool $is_const Whether the constant is currently defined.
|
||||
* @param bool $is_set_opt Whether an admin-stored (option) key exists.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_key_form( bool $is_const, bool $is_set_opt ): void {
|
||||
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" id="prv-key-set-form" autocomplete="off">';
|
||||
echo '<input type="hidden" name="action" value="prv_key_set">';
|
||||
wp_nonce_field( PRV_Settings_Page::NONCE_KEY, 'prv_nonce' );
|
||||
|
||||
echo '<div class="prv-field" style="margin-top:12px">';
|
||||
echo '<label class="prv-label" for="prv_api_key">';
|
||||
echo esc_html(
|
||||
$is_set_opt
|
||||
? __( 'Replace key (leave empty to keep current)', 'pr-vision' )
|
||||
: __( 'Set key', 'pr-vision' )
|
||||
);
|
||||
echo '</label>';
|
||||
|
||||
// Input always renders empty — never outputs stored key.
|
||||
echo '<input type="password" id="prv_api_key" name="prv_api_key" '
|
||||
. 'class="prv-input" style="max-width:420px" '
|
||||
. 'value="" autocomplete="new-password" '
|
||||
. 'placeholder="' . esc_attr( $is_const ? __( 'Managed via wp-config — cannot override', 'pr-vision' ) : __( 'sk-or-…', 'pr-vision' ) ) . '"'
|
||||
. ( $is_const ? ' disabled aria-disabled="true"' : '' ) . '>';
|
||||
|
||||
if ( $is_const ) {
|
||||
echo '<p style="color:var(--prv-text-muted);font-size:12px;margin:4px 0 0">'
|
||||
. esc_html__( 'PRV_OPENROUTER_API_KEY is defined in wp-config.php and takes precedence. Remove it from wp-config to manage the key here.', 'pr-vision' )
|
||||
. '</p>';
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
|
||||
if ( ! $is_const ) {
|
||||
echo '<button type="submit" class="prv-btn prv-btn-primary" style="margin-top:4px">'
|
||||
. esc_html( $is_set_opt ? __( 'Replace key', 'pr-vision' ) : __( 'Set key', 'pr-vision' ) )
|
||||
. '</button>';
|
||||
}
|
||||
|
||||
echo '</form>';
|
||||
|
||||
// Remove key form (only when admin option is set, not constant).
|
||||
if ( $is_set_opt && ! $is_const ) {
|
||||
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" id="prv-key-remove-form" style="margin-top:8px">';
|
||||
echo '<input type="hidden" name="action" value="prv_key_remove">';
|
||||
wp_nonce_field( PRV_Settings_Page::NONCE_KEY, 'prv_nonce' );
|
||||
echo '<button type="submit" class="prv-btn prv-btn-danger" '
|
||||
. 'onclick="return confirm(\'' . esc_js( __( 'Remove the stored API key? The next probe run will fail unless PRV_OPENROUTER_API_KEY is set in wp-config.php.', 'pr-vision' ) ) . '\')">'
|
||||
. esc_html__( 'Remove key', 'pr-vision' )
|
||||
. '</button>';
|
||||
echo '</form>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Test key button and aria-live result area.
|
||||
*
|
||||
* Only shown when a key source is available. The test sends one cheap
|
||||
* probe via AJAX and reports valid/invalid without leaking the key.
|
||||
*
|
||||
* @param string $source Current key source.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_test_button( string $source ): void {
|
||||
if ( PRV_Key_Store::SOURCE_NONE === $source ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nonce = wp_create_nonce( PRV_Settings_Page::NONCE_KEY_TEST );
|
||||
$ajax_url = admin_url( 'admin-ajax.php' );
|
||||
|
||||
echo '<div style="margin-top:12px;display:flex;align-items:center;gap:12px">';
|
||||
echo '<button type="button" id="prv-test-key-btn" class="prv-btn prv-btn-ghost" '
|
||||
. 'data-ajax-url="' . esc_url( $ajax_url ) . '" '
|
||||
. 'data-nonce="' . esc_attr( $nonce ) . '">'
|
||||
. esc_html__( 'Test key', 'pr-vision' )
|
||||
. '</button>';
|
||||
echo '<span id="prv-test-key-result" aria-live="polite" role="status" style="font-size:13px"></span>';
|
||||
echo '</div>';
|
||||
|
||||
// Inline JS — only a button wirer, no key value in JS.
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<script>(function(){
|
||||
var btn=document.getElementById("prv-test-key-btn");
|
||||
if(!btn)return;
|
||||
btn.addEventListener("click",function(){
|
||||
var res=document.getElementById("prv-test-key-result");
|
||||
btn.disabled=true;btn.setAttribute("aria-busy","true");
|
||||
if(res){res.textContent="Testing…";}
|
||||
fetch(btn.dataset.ajaxUrl,{
|
||||
method:"POST",
|
||||
headers:{"Content-Type":"application/x-www-form-urlencoded"},
|
||||
body:new URLSearchParams({action:"prv_test_key",prv_nonce:btn.dataset.nonce})
|
||||
}).then(function(r){return r.json();}).then(function(d){
|
||||
btn.disabled=false;btn.removeAttribute("aria-busy");
|
||||
if(d.success){
|
||||
if(res){res.textContent="✓ "+(d.data?d.data.message:"Key valid");res.style.color="var(--prv-lime)";}
|
||||
}else{
|
||||
if(res){res.textContent="✗ "+(d.data?d.data.message:"Key invalid");res.style.color="var(--prv-red-chip)";}
|
||||
}
|
||||
}).catch(function(){
|
||||
btn.disabled=false;btn.removeAttribute("aria-busy");
|
||||
if(res){res.textContent="Request error";res.style.color="var(--prv-red-chip)";}
|
||||
});
|
||||
});
|
||||
})();</script>';
|
||||
}
|
||||
}
|
||||
186
includes/core/class-prv-key-store.php
Normal file
186
includes/core/class-prv-key-store.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
/**
|
||||
* PR Vision API key resolver and encrypted-at-rest key store.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Resolves and stores the OpenRouter API key with precedence and encryption.
|
||||
*
|
||||
* Precedence order (first match wins):
|
||||
* 1. `PRV_OPENROUTER_API_KEY` wp-config constant (highest — most secure).
|
||||
* 2. Encrypted admin option `prv_provider_key_enc`.
|
||||
* 3. Empty string (no key configured).
|
||||
*
|
||||
* Encryption is delegated to PRV_Crypto_Helper (libsodium XSalsa20-Poly1305
|
||||
* preferred; OpenSSL AES-256-GCM fallback). The encryption key is derived
|
||||
* from WP salts (`AUTH_KEY` + `SECURE_AUTH_KEY`) via SHA-256 — environment-
|
||||
* specific, never stored.
|
||||
*
|
||||
* Security invariants:
|
||||
* - Plaintext key NEVER stored in any option, transient, log, or echoed.
|
||||
* - Decryption occurs only at resolve time, server-side only.
|
||||
* - Ciphertext purged on uninstall via the `prv_` wildcard DELETE.
|
||||
*
|
||||
* Who triggers: PRV_Perplexity_Provider, PRV_OpenRouter_Provider,
|
||||
* PRV_Key_Test_Ajax.
|
||||
* Dependencies: PRV_Crypto_Helper, wp_salt(), update_option(),
|
||||
* delete_option(), get_option().
|
||||
*
|
||||
* @see class-prv-crypto-helper.php -- Low-level encrypt/decrypt.
|
||||
* @see class-prv-perplexity-provider.php -- Calls get_key().
|
||||
* @see class-prv-openrouter-provider.php -- Calls get_key().
|
||||
* @see class-prv-key-test-ajax.php -- Calls get_key() for validation.
|
||||
* @see class-prv-settings-renderer.php -- Calls get_source() for UI status.
|
||||
* @see class-prv-settings-controller.php -- Calls store_key() / clear_key().
|
||||
* @see ARCHITECTURE.md -- §Key management.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Key_Store {
|
||||
|
||||
/**
|
||||
* WP option name for the encrypted key ciphertext.
|
||||
*/
|
||||
const OPTION_ENC = 'prv_provider_key_enc';
|
||||
|
||||
/**
|
||||
* Source constant: key comes from wp-config constant.
|
||||
*/
|
||||
const SOURCE_CONSTANT = 'constant';
|
||||
|
||||
/**
|
||||
* Source constant: key comes from the encrypted admin option.
|
||||
*/
|
||||
const SOURCE_OPTION = 'option';
|
||||
|
||||
/**
|
||||
* Source constant: no key is configured.
|
||||
*/
|
||||
const SOURCE_NONE = 'none';
|
||||
|
||||
/**
|
||||
* Resolve the API key following the precedence chain.
|
||||
*
|
||||
* Returns the plaintext key for server-side use ONLY. Callers MUST NOT
|
||||
* pass this value to any client-facing surface (page, REST, log, exception).
|
||||
*
|
||||
* @return string Plaintext API key, or empty string when none is configured.
|
||||
*/
|
||||
public static function get_key(): string {
|
||||
// 1. wp-config constant wins when defined and non-empty.
|
||||
if ( self::constant_is_set() ) {
|
||||
return (string) constant( 'PRV_OPENROUTER_API_KEY' );
|
||||
}
|
||||
|
||||
// 2. Encrypted admin option.
|
||||
$enc = get_option( self::OPTION_ENC, '' );
|
||||
if ( is_string( $enc ) && '' !== $enc ) {
|
||||
$enc_key = self::derive_encryption_key();
|
||||
if ( '' !== $enc_key ) {
|
||||
$decrypted = PRV_Crypto_Helper::decrypt( $enc, $enc_key );
|
||||
if ( '' !== $decrypted ) {
|
||||
return $decrypted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the source of the resolved key.
|
||||
*
|
||||
* Returns one of: SOURCE_CONSTANT, SOURCE_OPTION, SOURCE_NONE.
|
||||
* Safe for UI output — never returns the key value itself.
|
||||
*
|
||||
* @return string One of the SOURCE_* class constants.
|
||||
*/
|
||||
public static function get_source(): string {
|
||||
if ( self::constant_is_set() ) {
|
||||
return self::SOURCE_CONSTANT;
|
||||
}
|
||||
$enc = get_option( self::OPTION_ENC, '' );
|
||||
if ( is_string( $enc ) && '' !== $enc ) {
|
||||
return self::SOURCE_OPTION;
|
||||
}
|
||||
return self::SOURCE_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt and store a new API key.
|
||||
*
|
||||
* Encrypts via PRV_Crypto_Helper and stores only the ciphertext. The
|
||||
* plaintext is never persisted. Caller is responsible for capability and
|
||||
* nonce checks before calling this method.
|
||||
*
|
||||
* Side effects: Writes to wp_options (`prv_provider_key_enc`).
|
||||
*
|
||||
* @param string $plaintext_key The raw API key to store.
|
||||
*
|
||||
* @return bool True on success, false if encryption fails or key is empty.
|
||||
*/
|
||||
public static function store_key( string $plaintext_key ): bool {
|
||||
if ( '' === $plaintext_key ) {
|
||||
return false;
|
||||
}
|
||||
$enc_key = self::derive_encryption_key();
|
||||
if ( '' === $enc_key ) {
|
||||
return false;
|
||||
}
|
||||
$enc = PRV_Crypto_Helper::encrypt( $plaintext_key, $enc_key );
|
||||
if ( '' === $enc ) {
|
||||
return false;
|
||||
}
|
||||
update_option( self::OPTION_ENC, $enc, false );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored encrypted key (reverts to constant or none).
|
||||
*
|
||||
* Side effects: Deletes `prv_provider_key_enc` from wp_options.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function clear_key(): void {
|
||||
delete_option( self::OPTION_ENC );
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the PRV_OPENROUTER_API_KEY constant is defined and non-empty.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function constant_is_set(): bool {
|
||||
return defined( 'PRV_OPENROUTER_API_KEY' )
|
||||
&& '' !== (string) constant( 'PRV_OPENROUTER_API_KEY' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a 32-byte raw binary encryption key from WP salts via SHA-256.
|
||||
*
|
||||
* Uses AUTH_KEY + SECURE_AUTH_KEY when defined; falls back to wp_salt().
|
||||
* The derived key is site-specific and never persisted.
|
||||
*
|
||||
* @return string 32-byte raw binary key, or empty string when salts unavailable.
|
||||
*/
|
||||
public static function derive_encryption_key(): string {
|
||||
$salt = '';
|
||||
|
||||
if ( defined( 'AUTH_KEY' ) && defined( 'SECURE_AUTH_KEY' ) ) {
|
||||
$salt = AUTH_KEY . SECURE_AUTH_KEY;
|
||||
} elseif ( function_exists( 'wp_salt' ) ) {
|
||||
// wp_salt() is available after WP loads; safe in plugin context.
|
||||
$salt = wp_salt( 'auth' ) . wp_salt( 'secure_auth' );
|
||||
}
|
||||
|
||||
if ( '' === $salt ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return hash( 'sha256', $salt, true ); // Raw binary — exactly 32 bytes.
|
||||
}
|
||||
}
|
||||
94
includes/core/class-prv-key-test-ajax.php
Normal file
94
includes/core/class-prv-key-test-ajax.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
/**
|
||||
* AJAX handler: Test the resolved API key validity.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* AJAX handler for the "Test key" button in the Provider API Key section.
|
||||
*
|
||||
* Resolves the API key via PRV_Key_Store::get_key() and performs a single
|
||||
* cheap probe. Reports valid/invalid without revealing the key value.
|
||||
* Rate-limited to one test per 30 seconds.
|
||||
*
|
||||
* Who triggers: wp_ajax_prv_test_key (registered by PRV_Settings_Page).
|
||||
* Dependencies: PRV_Key_Store, PRV_OpenRouter_Provider, PRV_Gateway_Client.
|
||||
*
|
||||
* @see class-prv-key-store.php -- Key resolution.
|
||||
* @see class-prv-settings-page.php -- Registers the wp_ajax_ hook.
|
||||
* @see class-prv-settings-renderer.php -- Renders the "Test key" button.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Key_Test_Ajax {
|
||||
|
||||
/**
|
||||
* Rate-limit window in seconds.
|
||||
*/
|
||||
const RATE_LIMIT_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* Transient key for the rate-limit lock.
|
||||
*/
|
||||
const RATE_LIMIT_TRANSIENT = 'prv_key_test_lock';
|
||||
|
||||
/**
|
||||
* Cheap probe query — one call, minimal tokens.
|
||||
*/
|
||||
private const PROBE_QUERY = 'what is BPC-157';
|
||||
|
||||
/**
|
||||
* Handle the prv_test_key AJAX request.
|
||||
*
|
||||
* Verifies manage_options + nonce, rate-limits, resolves the key, probes,
|
||||
* and returns JSON success/error without ever including the key value.
|
||||
*
|
||||
* Side effects: HTTP call to LLM provider; sets a rate-limit transient; exits.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ) );
|
||||
}
|
||||
|
||||
$nonce = isset( $_POST['prv_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_nonce'] ) ) : '';
|
||||
if ( ! wp_verify_nonce( $nonce, PRV_Settings_Page::NONCE_KEY_TEST ) ) {
|
||||
wp_send_json_error( array( 'message' => 'Security check failed.' ) );
|
||||
}
|
||||
|
||||
if ( false !== get_transient( self::RATE_LIMIT_TRANSIENT ) ) {
|
||||
wp_send_json_error( array( 'message' => 'Rate limited — wait 30 s between tests.' ) );
|
||||
}
|
||||
set_transient( self::RATE_LIMIT_TRANSIENT, 1, self::RATE_LIMIT_SECONDS );
|
||||
|
||||
$api_key = PRV_Key_Store::get_key();
|
||||
if ( '' === $api_key ) {
|
||||
wp_send_json_error( array( 'message' => 'No API key configured.' ) );
|
||||
}
|
||||
|
||||
$start = microtime( true );
|
||||
$provider = new PRV_OpenRouter_Provider( 'openai/gpt-4o-mini', null, null );
|
||||
|
||||
try {
|
||||
$provider->probe_with_key( self::PROBE_QUERY, $api_key );
|
||||
$ms = (int) round( ( microtime( true ) - $start ) * 1000 );
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'valid' => true,
|
||||
'latency_ms' => $ms,
|
||||
'message' => sprintf( 'Key valid · %d ms', $ms ),
|
||||
)
|
||||
);
|
||||
} catch ( \Exception $e ) {
|
||||
wp_send_json_error(
|
||||
array(
|
||||
'valid' => false,
|
||||
'message' => 'Key invalid or provider error — check key and try again.',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,14 @@ declare(strict_types=1);
|
||||
* This is a point-in-time check (not a monitor) -- the UI labels it as such.
|
||||
* Never displays the API key.
|
||||
*
|
||||
* Key is resolved via PRV_Key_Store::get_key() (constant → admin option → none).
|
||||
*
|
||||
* Who triggers: wp_ajax_prv_test_model (registered by PRV_Settings_Page).
|
||||
* Dependencies: PRV_Model_Registry, PRV_Perplexity_Provider, PRV_OpenRouter_Provider.
|
||||
* Dependencies: PRV_Key_Store, PRV_Model_Registry, PRV_Perplexity_Provider,
|
||||
* PRV_OpenRouter_Provider.
|
||||
*
|
||||
* @see class-prv-settings-page.php -- Registers the wp_ajax_ hook.
|
||||
* @see class-prv-key-store.php -- Key resolution.
|
||||
* @see class-prv-model-registry.php -- Provides model data by id.
|
||||
* @package PrVision
|
||||
*/
|
||||
@@ -62,17 +66,23 @@ class PRV_Model_Test_Ajax {
|
||||
}
|
||||
set_transient( $lock_key, 1, self::RATE_LIMIT_SECONDS );
|
||||
|
||||
// Resolve key through the store — never bypass to a direct constant read.
|
||||
$api_key = PRV_Key_Store::get_key();
|
||||
if ( '' === $api_key ) {
|
||||
wp_send_json_error( array( 'message' => 'API key not configured — set it in wp-config.php or via Settings → Provider API Key.' ) );
|
||||
}
|
||||
|
||||
$start = microtime( true );
|
||||
$provider = ( 'perplexity/sonar' === $slug )
|
||||
? new PRV_Perplexity_Provider()
|
||||
: new PRV_OpenRouter_Provider( $slug );
|
||||
|
||||
if ( ! $provider->is_configured() ) {
|
||||
wp_send_json_error( array( 'message' => 'API key not configured (check wp-config.php).' ) );
|
||||
}
|
||||
|
||||
try {
|
||||
$provider->probe( 'what is BPC-157' );
|
||||
if ( $provider instanceof PRV_OpenRouter_Provider ) {
|
||||
$provider->probe_with_key( 'what is BPC-157', $api_key );
|
||||
} else {
|
||||
$provider->probe( 'what is BPC-157' );
|
||||
}
|
||||
$ms = (int) round( ( microtime( true ) - $start ) * 1000 );
|
||||
wp_send_json_success(
|
||||
array(
|
||||
|
||||
@@ -8,24 +8,31 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Handles the four settings POST actions: save config, and model CRUD.
|
||||
* Handles the six settings POST actions: save config, model CRUD, key set/remove.
|
||||
*
|
||||
* Extracted from PRV_Settings_Page to keep each file under the 300-line
|
||||
* limit. PRV_Settings_Page registers all hooks and delegates the four
|
||||
* admin_post_* handlers here.
|
||||
* limit. PRV_Settings_Page registers all hooks and delegates the handlers here.
|
||||
*
|
||||
* All actions require: current_user_can('manage_options') + valid nonce
|
||||
* (enforced by PRV_Settings_Page::require_admin_nonce(), passed in via
|
||||
* the $nonce_cb callable).
|
||||
*
|
||||
* Who triggers: PRV_Settings_Page -- via delegation in register_hooks().
|
||||
* Dependencies: PRV_Config, PRV_Config_Version, PRV_Cron,
|
||||
* PRV_Model_Registry.
|
||||
* Key security invariants:
|
||||
* - handle_key_set() reads the raw key from POST, encrypts it via PRV_Key_Store,
|
||||
* and immediately drops the reference. The plaintext is never stored in an
|
||||
* option, logged, or echoed.
|
||||
* - handle_key_remove() calls PRV_Key_Store::clear_key() -- no plaintext involved.
|
||||
* - The key value is NEVER included in any redirect URL or notice.
|
||||
*
|
||||
* @see class-prv-settings-page.php -- Page registration, dispatch, Run-now, AJAX.
|
||||
* Who triggers: PRV_Settings_Page -- via delegation in register_hooks().
|
||||
* Dependencies: PRV_Config, PRV_Config_Version, PRV_Cron, PRV_Model_Registry,
|
||||
* PRV_Key_Store.
|
||||
*
|
||||
* @see class-prv-settings-page.php -- Page registration, dispatch, Run-now, AJAX.
|
||||
* @see class-prv-key-store.php -- Encryption and storage.
|
||||
* @see class-prv-model-registry.php -- CRUD for prv_models.
|
||||
* @see class-prv-config-version.php -- Config-change versioning.
|
||||
* @see class-prv-cron.php -- Reschedule on cadence change.
|
||||
* @see class-prv-cron.php -- Reschedule on cadence change.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Settings_Controller {
|
||||
@@ -64,7 +71,7 @@ class PRV_Settings_Controller {
|
||||
( $this->nonce_cb )( PRV_Settings_Page::NONCE_SAVE );
|
||||
$old_cadence = PRV_Config::get_cadence();
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
|
||||
$budget = isset( $_POST['prv_monthly_budget_usd'] )
|
||||
? max( 0.01, (float) sanitize_text_field( wp_unslash( (string) $_POST['prv_monthly_budget_usd'] ) ) )
|
||||
: PRV_DEFAULT_MONTHLY_BUDGET_USD;
|
||||
@@ -118,6 +125,71 @@ class PRV_Settings_Controller {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle set/replace API key POST.
|
||||
*
|
||||
* Reads the raw key from POST, encrypts it via PRV_Key_Store, then
|
||||
* drops the plaintext reference. Empty submission (no-op) is silently
|
||||
* ignored. The key value NEVER appears in the redirect URL or notices.
|
||||
*
|
||||
* Side effects: Stores encrypted key in prv_provider_key_enc; redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_key_set(): void {
|
||||
( $this->nonce_cb )( PRV_Settings_Page::NONCE_KEY );
|
||||
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
|
||||
$raw_key = isset( $_POST['prv_api_key'] )
|
||||
? sanitize_text_field( wp_unslash( (string) $_POST['prv_api_key'] ) )
|
||||
: '';
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
$result_arg = 'prv_key_unchanged';
|
||||
|
||||
if ( '' !== $raw_key ) {
|
||||
$stored = PRV_Key_Store::store_key( $raw_key );
|
||||
$result_arg = $stored ? 'prv_key_set' : 'prv_key_error';
|
||||
// Explicitly unset to prevent the plaintext lingering in memory.
|
||||
unset( $raw_key );
|
||||
}
|
||||
|
||||
wp_safe_redirect(
|
||||
add_query_arg(
|
||||
array(
|
||||
'page' => self::MENU_SLUG,
|
||||
$result_arg => 1,
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle remove API key POST.
|
||||
*
|
||||
* Clears the encrypted option, reverting to constant or none.
|
||||
*
|
||||
* Side effects: Deletes prv_provider_key_enc; redirects.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_key_remove(): void {
|
||||
( $this->nonce_cb )( PRV_Settings_Page::NONCE_KEY );
|
||||
PRV_Key_Store::clear_key();
|
||||
wp_safe_redirect(
|
||||
add_query_arg(
|
||||
array(
|
||||
'page' => self::MENU_SLUG,
|
||||
'prv_key_removed' => 1,
|
||||
),
|
||||
admin_url( 'admin.php' )
|
||||
)
|
||||
);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Add model POST.
|
||||
*
|
||||
@@ -127,7 +199,7 @@ class PRV_Settings_Controller {
|
||||
*/
|
||||
public function handle_model_add(): void {
|
||||
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
|
||||
$slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) );
|
||||
$provider = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_provider'] ?? 'openrouter' ) ) );
|
||||
$note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) );
|
||||
@@ -158,7 +230,7 @@ class PRV_Settings_Controller {
|
||||
*/
|
||||
public function handle_model_update(): void {
|
||||
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
|
||||
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
|
||||
$slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) );
|
||||
$enabled = ! empty( $_POST['prv_model_enabled'] );
|
||||
@@ -196,7 +268,7 @@ class PRV_Settings_Controller {
|
||||
*/
|
||||
public function handle_model_remove(): void {
|
||||
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
|
||||
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
if ( '' !== $id ) {
|
||||
|
||||
@@ -11,21 +11,21 @@ declare(strict_types=1);
|
||||
* PR Vision Settings admin page.
|
||||
*
|
||||
* Registers "PR Vision > Settings" sub-menu, dispatches POST actions to
|
||||
* PRV_Settings_Controller, handles Run-now and AJAX test-model, and
|
||||
* delegates all HTML rendering to PRV_Settings_Renderer.
|
||||
* PRV_Settings_Controller, handles Run-now and AJAX test-model/test-key,
|
||||
* and delegates all HTML rendering to PRV_Settings_Renderer.
|
||||
*
|
||||
* All actions require: current_user_can('manage_options') + valid nonce.
|
||||
*
|
||||
* Who triggers: PRV_Plugin::init() -- is_admin() guard.
|
||||
* Dependencies: PRV_Settings_Controller, PRV_Settings_Renderer,
|
||||
* PRV_Model_Test_Ajax, PRV_Probe_Runner, PRV_Run_Lock.
|
||||
* PRV_Model_Test_Ajax, PRV_Key_Test_Ajax, PRV_Probe_Runner,
|
||||
* PRV_Run_Lock.
|
||||
*
|
||||
* @see class-prv-settings-controller.php -- POST handler implementations.
|
||||
* @see class-prv-settings-renderer.php -- Renders the HTML.
|
||||
* @see class-prv-model-test-ajax.php -- AJAX test handler.
|
||||
* @see class-prv-model-registry.php -- CRUD for prv_models.
|
||||
* @see class-prv-config-version.php -- Config-change versioning.
|
||||
* @see class-prv-cron.php -- Reschedule on cadence change.
|
||||
* @see class-prv-model-test-ajax.php -- AJAX test-model handler.
|
||||
* @see class-prv-key-test-ajax.php -- AJAX test-key handler.
|
||||
* @see class-prv-key-store.php -- Key storage actions.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Settings_Page {
|
||||
@@ -45,10 +45,16 @@ class PRV_Settings_Page {
|
||||
/** Nonce action for Test model AJAX. */
|
||||
const NONCE_TEST = 'prv_model_test';
|
||||
|
||||
/** Nonce action for key set/remove POST actions. */
|
||||
const NONCE_KEY = 'prv_key_action';
|
||||
|
||||
/** Nonce action for Test key AJAX. */
|
||||
const NONCE_KEY_TEST = 'prv_key_test';
|
||||
|
||||
/**
|
||||
* Register WordPress admin hooks.
|
||||
*
|
||||
* POST handlers for save/model-CRUD are delegated to PRV_Settings_Controller,
|
||||
* POST handlers for save/model-CRUD/key are delegated to PRV_Settings_Controller,
|
||||
* which receives a nonce-verification callback bound to this page.
|
||||
*
|
||||
* Side effects: Adds admin_menu, admin_post, and wp_ajax actions.
|
||||
@@ -67,8 +73,11 @@ class PRV_Settings_Page {
|
||||
add_action( 'admin_post_prv_model_add', array( $ctrl, 'handle_model_add' ) );
|
||||
add_action( 'admin_post_prv_model_update', array( $ctrl, 'handle_model_update' ) );
|
||||
add_action( 'admin_post_prv_model_remove', array( $ctrl, 'handle_model_remove' ) );
|
||||
add_action( 'admin_post_prv_key_set', array( $ctrl, 'handle_key_set' ) );
|
||||
add_action( 'admin_post_prv_key_remove', array( $ctrl, 'handle_key_remove' ) );
|
||||
add_action( 'admin_post_prv_settings_run_now', array( $this, 'handle_run_now' ) );
|
||||
add_action( 'wp_ajax_prv_test_model', array( $this, 'handle_test_model' ) );
|
||||
add_action( 'wp_ajax_prv_test_key', array( $this, 'handle_test_key' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
|
||||
}
|
||||
|
||||
@@ -157,6 +166,17 @@ class PRV_Settings_Page {
|
||||
( new PRV_Model_Test_Ajax() )->handle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the AJAX test-key request to PRV_Key_Test_Ajax.
|
||||
*
|
||||
* Side effects: Exits via PRV_Key_Test_Ajax::handle().
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_test_key(): void {
|
||||
( new PRV_Key_Test_Ajax() )->handle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the settings page.
|
||||
*
|
||||
@@ -175,7 +195,7 @@ class PRV_Settings_Page {
|
||||
* Verify admin capability and nonce; wp_die on failure.
|
||||
*
|
||||
* Called directly for Run-now, and passed as a callback to
|
||||
* PRV_Settings_Controller for the four POST handlers.
|
||||
* PRV_Settings_Controller for all POST handlers.
|
||||
*
|
||||
* @param string $action Nonce action.
|
||||
*
|
||||
|
||||
@@ -11,15 +11,18 @@ declare(strict_types=1);
|
||||
* Renders the PR Vision Settings admin page (dark "Assay" theme).
|
||||
*
|
||||
* Coordinates the page sections: styles, notices, model manager card
|
||||
* (via PRV_Model_Manager_Table), secondary config form, sticky save-bar,
|
||||
* and inline JS. All output is escaped at the point of emission.
|
||||
* (via PRV_Model_Manager_Table), secondary config form, provider API key
|
||||
* card (via PRV_Key_Manager_Renderer), sticky save-bar, and inline JS.
|
||||
* All output is escaped at the point of emission.
|
||||
*
|
||||
* Who triggers: PRV_Settings_Page::render_page().
|
||||
* Dependencies: PRV_Model_Registry, PRV_Config, PRV_Cron, PRV_Run_Lock,
|
||||
* PRV_Config_Version, PRV_Model_Manager_Table.
|
||||
* PRV_Config_Version, PRV_Model_Manager_Table,
|
||||
* PRV_Key_Manager_Renderer.
|
||||
*
|
||||
* @see class-prv-settings-page.php -- Instantiates and calls render().
|
||||
* @see class-prv-model-manager-table.php -- Renders the model table.
|
||||
* @see class-prv-settings-page.php -- Instantiates and calls render().
|
||||
* @see class-prv-model-manager-table.php -- Renders the model table.
|
||||
* @see class-prv-key-manager-renderer.php -- Renders the key card.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Settings_Renderer {
|
||||
@@ -40,6 +43,7 @@ class PRV_Settings_Renderer {
|
||||
( new PRV_Model_Manager_Table() )->render();
|
||||
echo '</div>';
|
||||
$this->render_config_form();
|
||||
( new PRV_Key_Manager_Renderer() )->render();
|
||||
$this->render_save_bar();
|
||||
$this->render_scripts();
|
||||
echo '</div>';
|
||||
@@ -108,7 +112,7 @@ class PRV_Settings_Renderer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render POST-redirect notices (saved, run done, model CRUD, lock).
|
||||
* Render POST-redirect notices (saved, run done, model CRUD, key actions, lock).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -118,10 +122,10 @@ class PRV_Settings_Renderer {
|
||||
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Settings saved.', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
if ( ! empty( $_GET['prv_run_done'] ) ) {
|
||||
$p = absint( $_GET['prv_probed'] ?? 0 );
|
||||
$s = absint( $_GET['prv_skipped'] ?? 0 );
|
||||
$trn = ! empty( $_GET['prv_truncated'] );
|
||||
$msg = sprintf( 'Run complete: %d probed, %d skipped.%s', $p, $s, $trn ? ' Last run was truncated by the monthly budget cap.' : '' );
|
||||
$p = absint( $_GET['prv_probed'] ?? 0 );
|
||||
$s = absint( $_GET['prv_skipped'] ?? 0 );
|
||||
$trn = ! empty( $_GET['prv_truncated'] );
|
||||
$msg = sprintf( 'Run complete: %d probed, %d skipped.%s', $p, $s, $trn ? ' Last run was truncated by the monthly budget cap.' : '' );
|
||||
echo '<div class="prv-notice prv-notice-success">' . esc_html( $msg ) . '</div>';
|
||||
}
|
||||
if ( ! empty( $_GET['prv_run_locked'] ) ) {
|
||||
@@ -136,11 +140,20 @@ class PRV_Settings_Renderer {
|
||||
if ( ! empty( $_GET['prv_model_removed'] ) ) {
|
||||
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Model removed.', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
if ( ! empty( $_GET['prv_key_set'] ) ) {
|
||||
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'API key saved.', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
if ( ! empty( $_GET['prv_key_removed'] ) ) {
|
||||
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'API key removed.', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
if ( ! empty( $_GET['prv_key_error'] ) ) {
|
||||
echo '<div class="prv-notice prv-notice-warning">' . esc_html__( 'Could not save API key — encryption failed. Check server logs.', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the secondary config form.
|
||||
* Render the secondary config form (cadence, budget, peptides, intents).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -154,7 +167,6 @@ class PRV_Settings_Renderer {
|
||||
$this->render_projected_cost();
|
||||
$this->render_peptides_field();
|
||||
$this->render_intents_field();
|
||||
$this->render_api_key_status();
|
||||
echo '</div></form>';
|
||||
}
|
||||
|
||||
@@ -233,25 +245,6 @@ class PRV_Settings_Renderer {
|
||||
echo '<textarea class="prv-input" id="prv_prompt_intents_text" name="prv_prompt_intents_text" rows="4" data-scoring-relevant="1">' . esc_textarea( implode( "\n", $intents ) ) . '</textarea></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the read-only API-key three-state status.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_api_key_status(): void {
|
||||
$defined = defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== constant( 'PRV_OPENROUTER_API_KEY' );
|
||||
$status = (string) get_option( 'prv_api_key_status', 'unknown' );
|
||||
echo '<div class="prv-field"><div class="prv-section-label">' . esc_html__( 'API Key Status (read-only)', 'pr-vision' ) . '</div>';
|
||||
if ( ! $defined ) {
|
||||
echo '<span class="prv-api-status prv-api-undef">● ' . esc_html__( 'Not defined — set PRV_OPENROUTER_API_KEY in wp-config.php', 'pr-vision' ) . '</span>';
|
||||
} elseif ( 'failed' === $status ) {
|
||||
echo '<span class="prv-api-status prv-api-fail">● ' . esc_html__( 'Defined — last run failed (key may be invalid or revoked)', 'pr-vision' ) . '</span>';
|
||||
} else {
|
||||
echo '<span class="prv-api-status prv-api-ok">● ' . esc_html__( 'Defined — last run OK', 'pr-vision' ) . '</span>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sticky save-bar with scoring-change warning and Run now.
|
||||
*
|
||||
|
||||
@@ -14,21 +14,19 @@ declare(strict_types=1);
|
||||
* at construction time; the runner instantiates one provider per model from
|
||||
* PRV_Config::get_models().
|
||||
*
|
||||
* GPT-search (openai/gpt-4o-search-preview) and Gemini-search
|
||||
* (google/gemini-2.0-flash-001) do not return a structured citations array
|
||||
* via OpenRouter — citations are parsed from URLs embedded in the response
|
||||
* text or from any 'annotations' key the model may include.
|
||||
*
|
||||
* Required WP-config constant:
|
||||
* PRV_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
|
||||
* API key is resolved through PRV_Key_Store::get_key() (constant → admin
|
||||
* option → none), never read directly from the constant.
|
||||
*
|
||||
* Who triggers: PRV_Probe_Runner via PRV_Probe_Provider interface.
|
||||
* Dependencies: PRV_Gateway_Client, PRV_Citation_Detector, PRV_Probe_Result.
|
||||
* PRV_Key_Test_Ajax (probe_with_key for test path).
|
||||
* Dependencies: PRV_Key_Store, PRV_Gateway_Client, PRV_Citation_Detector,
|
||||
* PRV_Probe_Result.
|
||||
*
|
||||
* @see interface-prv-probe-provider.php — Interface this implements.
|
||||
* @see class-prv-key-store.php — Single key resolver.
|
||||
* @see class-prv-gateway-client.php — Shared HTTP + retry logic.
|
||||
* @see class-prv-citation-detector.php — Domain extraction + detection.
|
||||
* @see ARCHITECTURE.md — §Provider implementations.
|
||||
* @see ARCHITECTURE.md — §Provider implementations, §Key management.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_OpenRouter_Provider implements PRV_Probe_Provider {
|
||||
@@ -75,14 +73,36 @@ class PRV_OpenRouter_Provider implements PRV_Probe_Provider {
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Resolves the key via PRV_Key_Store (constant → option → none).
|
||||
*
|
||||
* @param string $query The search query to probe.
|
||||
*
|
||||
* @return PRV_Probe_Result
|
||||
* @throws \RuntimeException On permanent API failure or no key configured.
|
||||
*/
|
||||
public function probe( string $query ): PRV_Probe_Result {
|
||||
$api_key = PRV_Key_Store::get_key();
|
||||
if ( '' === $api_key ) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception, not HTML
|
||||
throw new \RuntimeException( 'PRV_OPENROUTER_API_KEY is not configured. Set it in wp-config.php or via Settings.' );
|
||||
}
|
||||
return $this->probe_with_key( $query, $api_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe using a caller-supplied key (used by PRV_Key_Test_Ajax).
|
||||
*
|
||||
* Accepts the key as a parameter so the test path can call this without
|
||||
* re-resolving from the store. The key is passed only to the gateway
|
||||
* (as an Authorization header); it is never logged, stored, or returned.
|
||||
*
|
||||
* @param string $query The search query to probe.
|
||||
* @param string $api_key Plaintext API key (server-side only; never exposed).
|
||||
*
|
||||
* @return PRV_Probe_Result
|
||||
* @throws \RuntimeException On permanent API failure.
|
||||
*/
|
||||
public function probe( string $query ): PRV_Probe_Result {
|
||||
$api_key = $this->resolve_api_key();
|
||||
|
||||
public function probe_with_key( string $query, string $api_key ): PRV_Probe_Result {
|
||||
$body = array(
|
||||
'model' => $this->model,
|
||||
'messages' => array(
|
||||
@@ -156,22 +176,10 @@ class PRV_OpenRouter_Provider implements PRV_Probe_Provider {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* True when any source (constant or admin option) provides a non-empty key.
|
||||
*/
|
||||
public function is_configured(): bool {
|
||||
return defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== PRV_OPENROUTER_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the OpenRouter API key from wp-config constants.
|
||||
*
|
||||
* @return string
|
||||
* @throws \RuntimeException When the constant is not defined or empty.
|
||||
*/
|
||||
private function resolve_api_key(): string {
|
||||
if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) || '' === PRV_OPENROUTER_API_KEY ) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
|
||||
throw new \RuntimeException( __( 'PRV_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'pr-vision' ) );
|
||||
}
|
||||
return PRV_OPENROUTER_API_KEY;
|
||||
return '' !== PRV_Key_Store::get_key();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,18 @@ declare(strict_types=1);
|
||||
* an explicit `citations` array — the most faithful citation signal
|
||||
* available (real web retrieval vs. training-time knowledge).
|
||||
*
|
||||
* Required WP-config constant:
|
||||
* PRV_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
|
||||
* API key is resolved through PRV_Key_Store::get_key() (constant → admin
|
||||
* option → none), never read directly from the constant.
|
||||
*
|
||||
* Who triggers: PRV_Probe_Runner via the PRV_Probe_Provider interface.
|
||||
* Dependencies: PRV_Gateway_Client, PRV_Citation_Detector, PRV_Probe_Result.
|
||||
* Dependencies: PRV_Key_Store, PRV_Gateway_Client, PRV_Citation_Detector,
|
||||
* PRV_Probe_Result.
|
||||
*
|
||||
* @see interface-prv-probe-provider.php — Interface this implements.
|
||||
* @see class-prv-key-store.php — Single key resolver.
|
||||
* @see class-prv-gateway-client.php — Shared HTTP + retry logic.
|
||||
* @see class-prv-citation-detector.php — Domain extraction + detection.
|
||||
* @see ARCHITECTURE.md — §Provider implementations.
|
||||
* @see ARCHITECTURE.md — §Provider implementations, §Key management.
|
||||
* @see CONTEXT.md — "Perplexity sonar", "citations".
|
||||
* @package PrVision
|
||||
*/
|
||||
@@ -37,7 +39,6 @@ class PRV_Perplexity_Provider implements PRV_Probe_Provider {
|
||||
|
||||
/**
|
||||
* Estimated cost per probe call in USD (sonar is ~$0.005 / 1k tokens).
|
||||
* Used by the ledger's can_afford() pre-check. Actual cost settled post-call.
|
||||
*/
|
||||
const ESTIMATED_COST_PER_PROBE = 0.005;
|
||||
|
||||
@@ -69,16 +70,19 @@ class PRV_Perplexity_Provider implements PRV_Probe_Provider {
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Sends the query as a user message to perplexity/sonar via OpenRouter.
|
||||
* Parses the citations[] array from the OpenRouter response envelope.
|
||||
* Resolves the key via PRV_Key_Store (constant → option → none).
|
||||
*
|
||||
* @param string $query The search query to probe.
|
||||
*
|
||||
* @return PRV_Probe_Result
|
||||
* @throws \RuntimeException On permanent API failure.
|
||||
* @throws \RuntimeException On permanent API failure or no key configured.
|
||||
*/
|
||||
public function probe( string $query ): PRV_Probe_Result {
|
||||
$api_key = $this->resolve_api_key();
|
||||
$api_key = PRV_Key_Store::get_key();
|
||||
if ( '' === $api_key ) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception, not HTML
|
||||
throw new \RuntimeException( 'PRV_OPENROUTER_API_KEY is not configured. Set it in wp-config.php or via Settings.' );
|
||||
}
|
||||
|
||||
$body = array(
|
||||
'model' => self::MODEL,
|
||||
@@ -126,7 +130,6 @@ class PRV_Perplexity_Provider implements PRV_Probe_Provider {
|
||||
// Cost from usage data when available; fall back to estimate.
|
||||
$cost_usd = self::ESTIMATED_COST_PER_PROBE;
|
||||
if ( isset( $data['usage']['total_tokens'] ) ) {
|
||||
// Sonar: ~$1 per 1M input, $1 per 1M output tokens. Avg ~$0.001/1k.
|
||||
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000001;
|
||||
}
|
||||
|
||||
@@ -142,22 +145,10 @@ class PRV_Perplexity_Provider implements PRV_Probe_Provider {
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* True when any source (constant or admin option) provides a non-empty key.
|
||||
*/
|
||||
public function is_configured(): bool {
|
||||
return defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== PRV_OPENROUTER_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the OpenRouter API key from wp-config constants.
|
||||
*
|
||||
* @return string The API key.
|
||||
* @throws \RuntimeException When the constant is not defined or empty.
|
||||
*/
|
||||
private function resolve_api_key(): string {
|
||||
if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) || '' === PRV_OPENROUTER_API_KEY ) {
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
|
||||
throw new \RuntimeException( __( 'PRV_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'pr-vision' ) );
|
||||
}
|
||||
return PRV_OPENROUTER_API_KEY;
|
||||
return '' !== PRV_Key_Store::get_key();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user