Compare commits
6 Commits
claude/pr-
...
claude/pr-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85956da7c8 | ||
|
|
54e3687acc | ||
|
|
67499506ba | ||
|
|
648b72efad | ||
|
|
28e3ce6576 | ||
|
|
43bcb8b17d |
@@ -43,7 +43,11 @@ pr-vision/
|
||||
│ │ ├── class-prv-settings-controller.php # [v0.2] POST handler impl: save config, model CRUD (split from settings-page)
|
||||
│ │ ├── class-prv-settings-renderer.php # [v0.2] Settings HTML renderer (dark Assay theme)
|
||||
│ │ ├── class-prv-model-manager-table.php # [v0.2] Model manager table + Add form renderer
|
||||
│ │ └── class-prv-model-test-ajax.php # [v0.2] Rate-limited AJAX handler for Test button
|
||||
│ │ ├── class-prv-model-test-ajax.php # [v0.2] Rate-limited AJAX handler for Test button
|
||||
│ │ ├── class-prv-key-store.php # [v0.2.3] API key resolver: constant → enc-option → none
|
||||
│ │ ├── class-prv-crypto-helper.php # [v0.2.3] libsodium/OpenSSL encrypt+decrypt
|
||||
│ │ ├── class-prv-key-manager-renderer.php # [v0.2.3] Write-only key card renderer
|
||||
│ │ └── class-prv-key-test-ajax.php # [v0.2.3] AJAX handler for Test key button
|
||||
│ ├── providers/
|
||||
│ │ ├── interface-prv-probe-provider.php # probe(query): PRV_Probe_Result contract
|
||||
│ │ ├── class-prv-gateway-client.php # Cloudflare AI Gateway HTTP + retry
|
||||
@@ -155,7 +159,8 @@ Schema version: `prv_schema_version` = 2.
|
||||
| prv_config_versions | array | All config-version records |
|
||||
| prv_active_config_version | int | Currently active version number |
|
||||
| prv_cadence | string | 'weekly'\|'daily'\|'twicedaily' |
|
||||
| prv_api_key_status | string | 'ok'\|'failed'\|'unknown' |
|
||||
| prv_api_key_status | string | 'ok'|'failed'|'unknown' |
|
||||
| prv_provider_key_enc | string | [v0.2.3] Encrypted API key ciphertext (hex(nonce):hex(ciphertext)); PRV_Key_Store |
|
||||
| prv_api_key_last_check | string | Datetime of last status update |
|
||||
| prv_last_run_at | string | Datetime of last run |
|
||||
| prv_last_run_counts | array | {probed, skipped_budget, skipped_error, truncated, run_id} |
|
||||
@@ -193,12 +198,22 @@ Same as v0.1.0 -- see CONTEXT.md for the extension guide.
|
||||
|
||||
## 7. Security
|
||||
|
||||
- Secrets read from `wp-config.php` constants at runtime -- never stored in DB.
|
||||
- API key status shown in 3 states; key never displayed.
|
||||
- **API key resolver (v0.2.3):** `PRV_Key_Store::get_key()` is the single access point for
|
||||
the API key. Precedence: `PRV_OPENROUTER_API_KEY` constant → encrypted option `prv_provider_key_enc` → none.
|
||||
All probe + test paths route through this resolver; direct constant reads are removed.
|
||||
- **Encrypted at rest (v0.2.3):** admin-entered key stored as ciphertext via `PRV_Crypto_Helper`
|
||||
(libsodium preferred, OpenSSL AES-256-GCM fallback). Encryption key derived from WP salts via SHA-256.
|
||||
Plaintext never stored in any option, transient, log, or error message.
|
||||
- **Write-only UI (v0.2.3):** Settings shows source status only; password input always renders empty.
|
||||
Key value never in page source, JS, REST responses, or AJAX responses.
|
||||
- **wp-config precedence:** constant takes priority; admin option is only used when constant is absent.
|
||||
Input is disabled with a note when constant is defined.
|
||||
- Secrets read from `wp-config.php` constants at runtime (pre-v0.2.3 primary path).
|
||||
- All admin actions: `manage_options` check + nonce verification.
|
||||
- All DB values escaped with `$wpdb->prepare()`.
|
||||
- All HTML output escaped at render.
|
||||
- AJAX `prv_test_model`: capability + nonce + 30s rate-limit transient.
|
||||
- AJAX `prv_test_key`: capability + nonce + 30s rate-limit transient; key never in response.
|
||||
|
||||
---
|
||||
|
||||
@@ -212,3 +227,7 @@ Same as v0.1.0 -- see CONTEXT.md for the extension guide.
|
||||
| Projected cost from probe_count × fixed estimate | Conservative overestimate; avoids the need for per-model price config in v1; operator sees a safe upper bound. |
|
||||
| Three-state API key (not two) | A defined-but-revoked key showed false-green in v1 (QA finding M4). The third state "defined, last run failed" closes that gap without ever displaying the key. |
|
||||
| Sticky save-bar warning (not just card banner) | Card banner scrolls off; save-bar is always in viewport on a page with a tall model table (QA finding M2). |
|
||||
| wp-config constant beats admin option | Most-secure path (constant) stays the default and is untouched by the admin UI. Admin option is a convenience fallback, never a replacement. |
|
||||
| libsodium preferred, OpenSSL fallback | Sodium is bundled in PHP 7.2+ (available on all supported hosts); OpenSSL AES-256-GCM is the universal fallback. Algorithm inferred from nonce length at decrypt time, no stored metadata needed. |
|
||||
| Key resolver in PRV_Key_Store, not in providers | Single source of truth — any future provider added to the plugin automatically gets the right key without repeating the precedence logic. |
|
||||
| PRV_Crypto_Helper split from PRV_Key_Store | Keeps each file under the 300-line AI-readability limit while preserving a deep-module interface: Key_Store owns the WP-layer (options, salts, source detection); Crypto_Helper owns the crypto primitives. |
|
||||
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -7,6 +7,80 @@ Versioning: [Semantic Versioning](https://semver.org/)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.3] — 2026-06-15
|
||||
|
||||
UI-editable provider API key: encrypted at rest, write-only, wp-config precedence.
|
||||
Output-escaping hardening in key manager renderer (`esc_url`/`esc_attr`/literal-conditional on all echoed values).
|
||||
|
||||
### Added
|
||||
|
||||
- **`PRV_Key_Store`** (`includes/core/class-prv-key-store.php`): single key resolver with
|
||||
precedence chain — `PRV_OPENROUTER_API_KEY` constant → encrypted admin option `prv_provider_key_enc` → none.
|
||||
`get_key()` is the sole key-access point for all probe and test paths.
|
||||
- **`PRV_Crypto_Helper`** (`includes/core/class-prv-crypto-helper.php`): low-level
|
||||
symmetric encryption/decryption. Prefers libsodium `sodium_crypto_secretbox`
|
||||
(XSalsa20-Poly1305); falls back to OpenSSL AES-256-GCM. Encryption key is derived
|
||||
from WP salts (`AUTH_KEY` + `SECURE_AUTH_KEY`) via SHA-256 — environment-specific,
|
||||
never stored. Storage format: `hex(nonce):hex(ciphertext)`.
|
||||
- **`PRV_Key_Manager_Renderer`** (`includes/core/class-prv-key-manager-renderer.php`):
|
||||
renders the write-only "Provider API Key" card in Settings. Shows source status only
|
||||
("Set via wp-config (takes precedence)" / "Set via admin" / "Not set") plus last-run
|
||||
health. Password input always renders empty. Constant defined → input disabled with note.
|
||||
- **`PRV_Key_Test_Ajax`** (`includes/core/class-prv-key-test-ajax.php`): AJAX handler for
|
||||
the "Test key" button. Resolves key via `PRV_Key_Store::get_key()`, runs one cheap probe,
|
||||
reports valid/invalid with `aria-live` result; key never in response.
|
||||
- **Key set/remove POST actions** in `PRV_Settings_Controller`:
|
||||
`handle_key_set()` (encrypts + stores; empty input = no-op) and `handle_key_remove()`.
|
||||
- **`prv_key_set`, `prv_key_remove`, `prv_key_error`** notices in `PRV_Settings_Renderer`.
|
||||
- **`wp_ajax_prv_test_key`** hook registered in `PRV_Settings_Page` → dispatches to `PRV_Key_Test_Ajax`.
|
||||
- **Tests** (`tests/unit/test-key-store.php`): encrypt↔decrypt round-trip, wrong-key fails
|
||||
silently, resolver precedence (constant > option > none), write-only render emits no key,
|
||||
Remove clears option, constant takes precedence.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`PRV_OpenRouter_Provider`**: removed direct `PRV_OPENROUTER_API_KEY` constant reads;
|
||||
`probe()` now calls `PRV_Key_Store::get_key()`. New `probe_with_key(string $q, string $key)`
|
||||
method for the test path (PRV_Key_Test_Ajax injects the resolved key). `is_configured()`
|
||||
now checks `PRV_Key_Store::get_key() !== ''`.
|
||||
- **`PRV_Perplexity_Provider`**: same key-resolver migration — `probe()` calls `PRV_Key_Store::get_key()`.
|
||||
`is_configured()` checks the store. Direct constant reads removed.
|
||||
- **`PRV_Model_Test_Ajax`**: resolves key via `PRV_Key_Store::get_key()` instead of checking
|
||||
the constant directly. Returns a specific "key not configured — set via Settings" message
|
||||
when no key is available.
|
||||
- **`PRV_Settings_Renderer`**: replaced read-only `render_api_key_status()` with a call to
|
||||
`PRV_Key_Manager_Renderer::render()`. Added key-action notices. Provider API Key card is
|
||||
now separate from the Probe Configuration form.
|
||||
- **`PRV_Settings_Page`**: added `NONCE_KEY` + `NONCE_KEY_TEST` constants; registered
|
||||
`admin_post_prv_key_set`, `admin_post_prv_key_remove`, `wp_ajax_prv_test_key`.
|
||||
- **`uninstall.php`**: updated comment to explicitly list `prv_provider_key_enc` as a v0.2.3
|
||||
addition purged by the `prv_` wildcard DELETE.
|
||||
- **`tests/unit/test-uninstall.php`**: seeds + asserts `prv_provider_key_enc` purge.
|
||||
|
||||
### Security
|
||||
|
||||
- Plaintext API key never stored in any option, transient, log, or echoed to the browser.
|
||||
- The encrypted option (`prv_provider_key_enc`) is ciphertext only; decryption is server-side
|
||||
at probe time only. The encryption key is derived from WP salts — not stored.
|
||||
- `manage_options` + nonce required for all key actions (set, remove, test).
|
||||
- `wp-config` constant always takes precedence; admin path is only active when absent.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.2] — 2026-06-14
|
||||
|
||||
Kill white WP admin gutters around the dark "Assay" UI — CSS only, no logic change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Admin chrome (dashboard + settings):** `#wpcontent`, `#wpbody`, and `#wpbody-content` now receive the dark page background (`#14181C`) on both PR Vision screens. WP's default `20px` left padding on `#wpcontent` is removed so no light gutter appears between the left menu and the dark content area.
|
||||
- **WP admin bar / first-element gap:** `#wpbody` dark fill removes the white strip between the admin bar and the first card.
|
||||
- **Page `<h1>` header:** `.wrap > h1` colour set to the light text token (`#EEF2F5`) so the dashboard heading renders on dark, not white.
|
||||
- **Proxy-note banner (dashboard):** `.notice.prv-proxy-note` overridden to dark surface (`#1C2228`) with teal left border — banner now sits on dark bg matching the surrounding cards.
|
||||
- **Scope:** entire CSS block delivered via `wp_add_inline_style('wp-admin', …)` inside `PRV_Admin_Page::enqueue_assets()`, which is already guarded by `strpos($hook, 'pr-vision')`. Styles are absent on every other WP admin page. WP admin bar and left menu are not touched.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.1] — 2026-06-13
|
||||
|
||||
Rename to PR Vision; fix www-strip in competitor display; PHPCS gating; remove dead cache.
|
||||
|
||||
15
CONTEXT.md
15
CONTEXT.md
@@ -126,3 +126,18 @@ The plugin's scores are API-probe measurements, not real-world consumer LLM usag
|
||||
Month-to-date spend in USD, summed from the `cost_usd` column for rows in the current calendar month.
|
||||
|
||||
**Code identifiers:** `PRV_Cost_Ledger::get_month_to_date_usd()`.
|
||||
|
||||
### Key Store (v0.2.3)
|
||||
The subsystem that manages the OpenRouter API key with a precedence chain and encrypted-at-rest storage. Precedence: `PRV_OPENROUTER_API_KEY` wp-config constant (highest) → encrypted admin option `prv_provider_key_enc` → none. All key reads by providers and AJAX handlers route through `PRV_Key_Store::get_key()`. The plaintext key is never stored in any option, log, or client-side surface.
|
||||
|
||||
**Code identifiers:** `PRV_Key_Store`, `PRV_Key_Store::get_key()`, `PRV_Key_Store::store_key()`, `PRV_Key_Store::clear_key()`, `PRV_Key_Store::get_source()`, `prv_provider_key_enc` option.
|
||||
|
||||
### Write-only Key UI (v0.2.3)
|
||||
The Settings page section that allows the operator to set/replace/clear the API key without the key ever being rendered in the browser. The password input always renders empty. The UI shows only the key *source* (wp-config / admin / not set) and the last-run health state. The key value is never output to page HTML, JS, REST responses, or logs.
|
||||
|
||||
**Code identifiers:** `PRV_Key_Manager_Renderer`, `PRV_Key_Test_Ajax`, `PRV_Settings_Controller::handle_key_set()`, `PRV_Settings_Controller::handle_key_remove()`.
|
||||
|
||||
### Encryption Key (v0.2.3)
|
||||
The symmetric key used to encrypt/decrypt `prv_provider_key_enc`. Derived via SHA-256 from `AUTH_KEY + SECURE_AUTH_KEY` (or `wp_salt()` fallback) — environment-specific and never stored. Derived fresh at every encrypt/decrypt call.
|
||||
|
||||
**Code identifiers:** `PRV_Key_Store::derive_encryption_key()`, `PRV_Crypto_Helper::encrypt()`, `PRV_Crypto_Helper::decrypt()`.
|
||||
|
||||
@@ -82,3 +82,13 @@ Every public method must have:
|
||||
2. Implement `PRV_Dashboard_Panel` in `includes/panel/`.
|
||||
3. Register both in `PRV_Plugin::init()` via `PRV_Collector_Registry::instance()`.
|
||||
4. Update `ARCHITECTURE.md` §6 (Collector/Panel seam).
|
||||
|
||||
## 9. API key access (v0.2.3)
|
||||
|
||||
**Never read `PRV_OPENROUTER_API_KEY` directly in provider or handler code.**
|
||||
All key reads must go through `PRV_Key_Store::get_key()`. This maintains the precedence chain (constant → encrypted option → none) and ensures a key set via the admin UI is used without code changes.
|
||||
|
||||
- Providers: call `PRV_Key_Store::get_key()` in `probe()`.
|
||||
- AJAX handlers: call `PRV_Key_Store::get_key()` before any LLM call.
|
||||
- New provider that needs the key: implement `is_configured()` as `'' !== PRV_Key_Store::get_key()`.
|
||||
- Do NOT pass the resolved key to any client-side surface, log, exception message, or `error_log()`.
|
||||
|
||||
@@ -203,14 +203,28 @@ class PRV_Admin_Page {
|
||||
/**
|
||||
* Return the dashboard CSS for the PR Vision admin page.
|
||||
*
|
||||
* Uses CSS custom properties matching the "Assay" dark design palette from
|
||||
* design/pr-vision/design-proposal.md. Scoped to .prv-* selectors so it
|
||||
* cannot leak into WP admin chrome.
|
||||
* Covers two concerns:
|
||||
* 1. WP admin wrapper chrome (injected here; fires on BOTH PR Vision screens
|
||||
* because the enqueue_assets guard matches 'pr-vision' in the hook suffix):
|
||||
* #wpcontent / #wpbody-content are set to the dark page bg so no white
|
||||
* gutter/strip shows around or above the dark UI.
|
||||
* 2. Component tokens (.prv-*) — unchanged from prior versions.
|
||||
*
|
||||
* This inline style block is attached to wp-admin (always loaded on admin
|
||||
* pages) via wp_add_inline_style() and therefore only present on the two
|
||||
* PR Vision screens. It must NOT be registered globally.
|
||||
*
|
||||
* @return string CSS string.
|
||||
*/
|
||||
private function get_dashboard_css(): string {
|
||||
return '
|
||||
/* === PR Vision — WP admin chrome: kill white wrappers (scoped via conditional enqueue) === */
|
||||
#wpcontent{background:#14181C;padding-left:0;}
|
||||
#wpbody-content{background:#14181C;min-height:calc(100vh - 32px);padding-bottom:40px;}
|
||||
#wpbody{background:#14181C;}
|
||||
.wrap>h1,.prv-settings-wrap~*>h1,.wp-heading-inline{color:#EEF2F5;}
|
||||
.notice.prv-proxy-note{background:#1C2228;border-left-color:#34C0CA;color:#EEF2F5;}
|
||||
.notice.prv-proxy-note p{color:#C2CCD6;}
|
||||
/* === PR Vision dashboard — "Assay" palette (dark-adapted) === */
|
||||
.prv-bento{display:grid;grid-template-columns:1.25fr 1fr 1fr;gap:16px;margin:18px 0 22px;}
|
||||
@media(max-width:820px){.prv-bento{grid-template-columns:1fr;}}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: PR Vision
|
||||
* Plugin URI: https://peptiderepo.com
|
||||
* Description: Weekly server-side LLM probes for AI-visibility (GEO) tracking. Records whether peptiderepo.com is cited by LLMs across core peptides, stores time-series, and renders an admin dashboard + settings UI.
|
||||
* Version: 0.2.1
|
||||
* Version: 0.2.3
|
||||
* Author: peptiderepo
|
||||
* Author URI: https://peptiderepo.com
|
||||
* License: GPL-2.0-or-later
|
||||
@@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────────────────── */
|
||||
|
||||
define( 'PRV_VERSION', '0.2.1' );
|
||||
define( 'PRV_VERSION', '0.2.3' );
|
||||
define( 'PRV_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRV_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'PRV_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
@@ -27,7 +27,7 @@ if ( ! defined( 'ARRAY_N' ) ) {
|
||||
define( 'ARRAY_N', 'ARRAY_N' );
|
||||
}
|
||||
|
||||
define( 'PRV_VERSION', '0.2.1' );
|
||||
define( 'PRV_VERSION', '0.2.3' );
|
||||
define( 'PRV_PLUGIN_FILE', __DIR__ . '/../pr-vision.php' );
|
||||
define( 'PRV_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
|
||||
define( 'PRV_PLUGIN_URL', 'http://example.test/wp-content/plugins/pr-vision/' );
|
||||
|
||||
182
tests/unit/test-key-store.php
Normal file
182
tests/unit/test-key-store.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Key_Store and PRV_Crypto_Helper:
|
||||
* encrypt/decrypt round-trip, resolver precedence, write-only render, Remove.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
// Provide deterministic WP-salt constants so derive_encryption_key() works.
|
||||
if ( ! defined( 'AUTH_KEY' ) ) {
|
||||
define( 'AUTH_KEY', 'test-auth-key-at-least-64-chars-long-for-realistic-entropy-aaaa' );
|
||||
}
|
||||
if ( ! defined( 'SECURE_AUTH_KEY' ) ) {
|
||||
define( 'SECURE_AUTH_KEY', 'test-secure-auth-key-64-chars-long-realistic-entropy-bbbb-cccc' );
|
||||
}
|
||||
|
||||
if ( ! function_exists( 'wp_salt' ) ) {
|
||||
function wp_salt( string $scheme ): string {
|
||||
return 'static-test-salt-' . $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Load classes under test ────────────────────────────────────────────── */
|
||||
|
||||
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-crypto-helper.php';
|
||||
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-key-store.php';
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Capture rendered HTML from a callable.
|
||||
*
|
||||
* @param callable $fn Function that produces output.
|
||||
*
|
||||
* @return string Captured HTML.
|
||||
*/
|
||||
function prv_capture( callable $fn ): string {
|
||||
ob_start();
|
||||
$fn();
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
echo "=== PRV_Key_Store + PRV_Crypto_Helper tests ===\n\n";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* Group 1: PRV_Crypto_Helper — encrypt/decrypt round-trip
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
echo "Group 1: PRV_Crypto_Helper encrypt/decrypt round-trip\n";
|
||||
|
||||
// Derive a test enc key the same way PRV_Key_Store does.
|
||||
$enc_key = PRV_Key_Store::derive_encryption_key();
|
||||
prv_assert( 32 === strlen( $enc_key ), 'derive_encryption_key() returns 32-byte key' );
|
||||
|
||||
$plaintext = 'sk-or-test-key-value-abc123';
|
||||
$encrypted = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
|
||||
|
||||
prv_assert( '' !== $encrypted, 'encrypt() returns non-empty string' );
|
||||
prv_assert( $plaintext !== $encrypted, 'encrypt() does not return plaintext' );
|
||||
prv_assert( false === strpos( $encrypted, $plaintext ), 'Plaintext not visible in ciphertext' );
|
||||
|
||||
$decrypted = PRV_Crypto_Helper::decrypt( $encrypted, $enc_key );
|
||||
prv_assert_equals( $plaintext, $decrypted, 'decrypt(encrypt(key)) === original key' );
|
||||
|
||||
// Different calls produce different ciphertexts (random nonce).
|
||||
$encrypted2 = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
|
||||
prv_assert( $encrypted !== $encrypted2, 'Two encryptions differ (random nonce)' );
|
||||
prv_assert_equals( $plaintext, PRV_Crypto_Helper::decrypt( $encrypted2, $enc_key ), 'Second ciphertext also decrypts correctly' );
|
||||
|
||||
// Malformed inputs are rejected gracefully.
|
||||
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( '', $enc_key ), 'decrypt("") returns empty' );
|
||||
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( 'garbage', $enc_key ), 'decrypt(garbage) returns empty' );
|
||||
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( 'aabb:', $enc_key ), 'decrypt(empty cipher) returns empty' );
|
||||
|
||||
// Wrong key decrypts to empty (not wrong plaintext).
|
||||
$wrong_key = hash( 'sha256', 'wrong-salt', true );
|
||||
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( $encrypted, $wrong_key ), 'decrypt with wrong key returns empty' );
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* Group 2: PRV_Key_Store — store_key / clear_key / get_source
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
echo "\nGroup 2: store_key / clear_key / get_source\n";
|
||||
|
||||
prv_test_reset();
|
||||
|
||||
prv_assert_equals( PRV_Key_Store::SOURCE_NONE, PRV_Key_Store::get_source(), 'get_source() == NONE when no key stored' );
|
||||
|
||||
$stored = PRV_Key_Store::store_key( $plaintext );
|
||||
prv_assert( $stored, 'store_key() returns true on success' );
|
||||
|
||||
$option_val = get_option( PRV_Key_Store::OPTION_ENC, '' );
|
||||
prv_assert( '' !== $option_val, 'Option is set after store_key()' );
|
||||
prv_assert( $plaintext !== $option_val, 'Option value is NOT the plaintext key (encrypted at rest)' );
|
||||
prv_assert( false === strpos( (string) $option_val, $plaintext ), 'Plaintext not in stored option value' );
|
||||
prv_assert( false === strpos( (string) $option_val, 'sk-or-' ), 'Key prefix not in stored option' );
|
||||
|
||||
prv_assert_equals( PRV_Key_Store::SOURCE_OPTION, PRV_Key_Store::get_source(), 'get_source() == OPTION after store_key()' );
|
||||
|
||||
PRV_Key_Store::clear_key();
|
||||
prv_assert_equals( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option cleared after clear_key()' );
|
||||
prv_assert_equals( PRV_Key_Store::SOURCE_NONE, PRV_Key_Store::get_source(), 'get_source() == NONE after clear_key()' );
|
||||
|
||||
// store_key('') is a no-op.
|
||||
prv_assert( ! PRV_Key_Store::store_key( '' ), 'store_key("") returns false' );
|
||||
prv_assert_equals( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option unchanged after store_key("")' );
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* Group 3: Resolver precedence — constant > option > none
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
echo "\nGroup 3: Resolver precedence\n";
|
||||
|
||||
prv_test_reset();
|
||||
|
||||
// None scenario.
|
||||
prv_assert_equals( '', PRV_Key_Store::get_key(), 'get_key() returns "" when no source' );
|
||||
|
||||
// Option scenario (no constant defined yet).
|
||||
PRV_Key_Store::store_key( $plaintext );
|
||||
prv_assert_equals( $plaintext, PRV_Key_Store::get_key(), 'get_key() returns plaintext from encrypted option' );
|
||||
|
||||
// Constant wins over option.
|
||||
define( 'PRV_OPENROUTER_API_KEY', 'sk-or-constant-key-xyz' );
|
||||
prv_assert_equals( 'sk-or-constant-key-xyz', PRV_Key_Store::get_key(), 'Constant takes precedence over option' );
|
||||
prv_assert( PRV_Key_Store::constant_is_set(), 'constant_is_set() true when constant is defined' );
|
||||
prv_assert_equals( PRV_Key_Store::SOURCE_CONSTANT, PRV_Key_Store::get_source(), 'get_source() == CONSTANT when constant defined' );
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* Group 4: Write-only — key value never in rendered HTML output
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
echo "\nGroup 4: Write-only render — key not in output\n";
|
||||
|
||||
prv_test_reset();
|
||||
PRV_Key_Store::store_key( $plaintext );
|
||||
|
||||
// Load rendering dependencies.
|
||||
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-settings-page.php';
|
||||
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-key-manager-renderer.php';
|
||||
|
||||
$html = prv_capture( function () {
|
||||
( new PRV_Key_Manager_Renderer() )->render();
|
||||
} );
|
||||
|
||||
prv_assert( '' !== $html, 'Key manager card renders non-empty HTML' );
|
||||
|
||||
// Plaintext key must be absent everywhere.
|
||||
prv_assert( false === strpos( $html, $plaintext ), 'Plaintext key absent from rendered HTML' );
|
||||
prv_assert( false === strpos( $html, 'sk-or-test' ), 'Key value prefix absent from rendered HTML' );
|
||||
prv_assert( false === strpos( $html, 'sk-or-constant-key-xyz' ), 'Constant key value absent from rendered HTML' );
|
||||
|
||||
// Password input must render with empty value.
|
||||
prv_assert( false !== strpos( $html, 'type="password"' ), 'Password input is present in output' );
|
||||
prv_assert( false !== strpos( $html, 'value=""' ), 'Password input has empty value attribute' );
|
||||
prv_assert( false === strpos( $html, 'value="sk-' ), 'No key value in any value= attribute' );
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* Group 5: Remove clears the encrypted option
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
echo "\nGroup 5: Remove clears encrypted option\n";
|
||||
|
||||
prv_test_reset();
|
||||
PRV_Key_Store::store_key( $plaintext );
|
||||
prv_assert( '' !== get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Key present before remove' );
|
||||
|
||||
PRV_Key_Store::clear_key();
|
||||
prv_assert_equals( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option empty after clear_key()' );
|
||||
|
||||
// get_key() falls back to constant when present.
|
||||
prv_assert_equals( 'sk-or-constant-key-xyz', PRV_Key_Store::get_key(), 'get_key() returns constant after option cleared' );
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* Summary
|
||||
* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
exit( prv_test_summary() );
|
||||
@@ -36,6 +36,7 @@ update_option( 'prv_last_run_at', '2026-06-14 12:00:00' );
|
||||
update_option( 'prv_last_run_counts', [] );
|
||||
update_option( 'prv_last_run_truncated', 0 );
|
||||
update_option( 'prv_last_run_truncated_at', '' );
|
||||
update_option( 'prv_provider_key_enc', 'dummy-encrypted-ciphertext-value' );
|
||||
update_option( 'prv_schema_version', PRV_SCHEMA_VERSION );
|
||||
$GLOBALS['prv_test_state']['transients'][ PRV_Run_Lock::LOCK_KEY ] = time();
|
||||
$GLOBALS['prv_test_state']['cron_events'][ PRV_CRON_HOOK ] = [ 'timestamp' => time() + 3600, 'schedule' => 'weekly' ];
|
||||
@@ -72,6 +73,7 @@ prv_assert( false === get_option( 'prv_active_config_version' ), 'post-uninstall
|
||||
prv_assert( false === get_option( 'prv_config_versions' ), 'post-uninstall: prv_config_versions deleted' );
|
||||
prv_assert( false === get_option( 'prv_models_schema_version' ), 'post-uninstall: prv_models_schema_version deleted' );
|
||||
prv_assert( false === get_option( 'prv_schema_version' ), 'post-uninstall: prv_schema_version deleted' );
|
||||
prv_assert( false === get_option( 'prv_provider_key_enc' ), 'post-uninstall: prv_provider_key_enc (v0.2.3 encrypted key) deleted' );
|
||||
prv_assert( false === get_option( 'prv_last_run_truncated' ), 'post-uninstall: prv_last_run_truncated deleted' );
|
||||
prv_assert( false === get_transient( PRV_Run_Lock::LOCK_KEY ), 'post-uninstall: run lock transient cleared' );
|
||||
prv_assert( false === PRV_Cron::is_scheduled(), 'post-uninstall: cron cleared' );
|
||||
|
||||
@@ -11,8 +11,12 @@
|
||||
* prv_last_run_at, prv_last_run_counts, prv_last_run_truncated,
|
||||
* prv_last_run_truncated_at -- all purged by the prv_ wildcard DELETE below.
|
||||
*
|
||||
* @see ARCHITECTURE.md -- Section Uninstall specification.
|
||||
* v0.2.3 addition: prv_provider_key_enc (encrypted API key) -- also purged
|
||||
* by the prv_ wildcard DELETE below. No plaintext is stored.
|
||||
*
|
||||
* @see ARCHITECTURE.md -- Section Uninstall specification.
|
||||
* @see class-prv-deactivator.php -- Clears cron on deactivation.
|
||||
* @see class-prv-key-store.php -- Encrypted key storage (prv_provider_key_enc).
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
@@ -28,8 +32,9 @@ $table = $wpdb->prefix . 'prv_ai_visibility';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->query( "DROP TABLE IF EXISTS {$table}" );
|
||||
|
||||
/* ── 2. Delete all prv_ prefixed options (covers v0.1 + v0.2 keys) ── */
|
||||
/* ── 2. Delete all prv_ prefixed options (v0.1, v0.2, v0.2.3) ───── */
|
||||
|
||||
// Covers prv_provider_key_enc (v0.2.3) along with all prior option keys.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$wpdb->query(
|
||||
"DELETE FROM {$wpdb->options} WHERE option_name LIKE 'prv\_%'"
|
||||
|
||||
Reference in New Issue
Block a user