5 Commits

Author SHA1 Message Date
Engineer (geomonitor)
f5cfa39f60 ci: switch PHPCS job to global composer require (fixes Packagist SSL timeout)
No project-level composer.lock → full Packagist fetch was timing out with
SSL error 28 on every run. Switch to setup-php built-in phpcs tool +
composer global require for WPCS, matching the pattern used in prautoblogger
which does not need a lock file for the global install path.

Refs #4
2026-06-14 13:08:44 +00:00
Engineer (geomonitor)
da25f0d9cb Fix CI (round 3): stub esc_attr__() in bootstrap
PRV_Dashboard_Renderer::render_trendline() uses esc_attr__() (the
WP function combining esc_attr + translation) for the canvas
aria-label. The stub was missing; PHP fatal on line 112 when
test-dashboard-panel.php called render_trendline() directly.
2026-06-14 13:02:41 +00:00
Engineer (geomonitor)
b3a96cf627 Fix CI (round 2): renderer docblock alignment + ARRAY_A + delta_html ignore
- Simplify render_bento and render_score_tile @param types in
  PRV_Dashboard_Renderer from complex aligned generics to plain
  'float', 'float|null', 'int', 'array', 'bool', 'string' with
  uniform per-group alignment — eliminates SpacingAfterParamType
  errors on lines 31-41 and 192-196.
- Add phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
  above the echo $delta_html line (output built by build_delta_html
  which applies esc_html() to all user-controlled values).
- Add ARRAY_A / OBJECT / ARRAY_N WP DB constants to tests/bootstrap.php
  — PRV_Ai_Visibility_Collector::collect() passes ARRAY_A to
  $wpdb->get_results(); the constant was never defined in bootstrap
  because existing tests only call run() not collect() directly. The
  new test-dashboard-panel.php test calls collect() and hit the
  undefined constant fatal.
2026-06-14 13:00:58 +00:00
Engineer (geomonitor)
7b9f53ec60 Fix CI: PHPCS param alignment + stub _n() in bootstrap
- Simplify @param type-hints in render_bento_band docblock from
  complex array<K,V{...}> generics to plain 'array' — eliminates
  SpacingAfterParamType alignment errors (PHPCS rule requires exact
  column alignment across multi-param functions; long generics are
  impossible to auto-align). Alignment is now uniform (longest type
  'string' = 6 chars; all others padded to col 7).
- Add phpcs:ignore comment on line above the /
  echo (dot is a fixed HTML entity; label is escaped by esc_html__()).
- Add _n() stub to tests/bootstrap.php — WP plural-form helper used
  by derive_health_pill_state(); not a PHP built-in; was missing.
2026-06-14 12:57:57 +00:00
Engineer (geomonitor)
2a2424ce8c v0.2.1 — dashboard visuals + deploy pipeline
Piece 1 — Visuals
- Add KPI bento bar (score tile with run-health pill, cost tile, last-run tile).
  Run-health pill shows 'All models healthy' / 'N model(s) degraded' / 'No run
  yet' sourced from PRV_Model_Registry health fields via collector.
- Add config-change trendline marker: inline configMarker Chart.js plugin draws
  a vertical dashed orange line + pin at every config_version boundary.
  Legend swatch + annotation note appear when breaks exist.
- Chart sits in fixed .prv-chartbox (300px); typeof-Chart guard + .prv-noscript
  fallback — cannot grow layout, degrades gracefully offline.
- PRV_Dashboard_Renderer: new class owns bento tile + trendline rendering.
  PRV_Ai_Visibility_Panel is now a thin orchestrator (237 lines).
- PRV_Ai_Visibility_Collector: trendline query now includes MIN(config_version)
  per run; collect() adds last_run_counts and config_versions to payload.
- PRV_Admin_Page: get_dashboard_css() returns Assay dark palette CSS.

Piece 2 — Deploy pipeline
- .github/workflows/deploy.yml: on push to main, runs CI, rsyncs plugin to
  Hostinger PROD (excl. .git .github tests phpunit composer vendor phpcs *.md),
  purges LiteSpeed, health-checks (200/403). Standalone — not smoke-gated.

DoD
- Version 0.2.1 in header + constant; CHANGELOG, ARCHITECTURE, CONVENTIONS,
  CONTEXT all updated same commit.
- tests/unit/test-dashboard-panel.php: 27 assertions.
- Pre-push checks: no PHP built-in name shadows in bootstrap.php; all
  includes/*.php files <= 300 lines (max: 298).
2026-06-14 12:52:07 +00:00
20 changed files with 121 additions and 1213 deletions

View File

@@ -43,11 +43,7 @@ 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-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
│ │ └── class-prv-model-test-ajax.php # [v0.2] Rate-limited AJAX handler for Test button
│ ├── providers/
│ │ ├── interface-prv-probe-provider.php # probe(query): PRV_Probe_Result contract
│ │ ├── class-prv-gateway-client.php # Cloudflare AI Gateway HTTP + retry
@@ -159,8 +155,7 @@ 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_provider_key_enc | string | [v0.2.3] Encrypted API key ciphertext (hex(nonce):hex(ciphertext)); PRV_Key_Store |
| prv_api_key_status | string | 'ok'\|'failed'\|'unknown' |
| 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} |
@@ -198,22 +193,12 @@ Same as v0.1.0 -- see CONTEXT.md for the extension guide.
## 7. Security
- **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).
- Secrets read from `wp-config.php` constants at runtime -- never stored in DB.
- API key status shown in 3 states; key never displayed.
- 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.
---
@@ -227,7 +212,3 @@ 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. |

View File

@@ -7,80 +7,6 @@ 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.

View File

@@ -126,18 +126,3 @@ 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()`.

View File

@@ -82,13 +82,3 @@ 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()`.

View File

@@ -203,28 +203,14 @@ class PRV_Admin_Page {
/**
* Return the dashboard CSS for the PR Vision admin page.
*
* 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.
* 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.
*
* @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;}}

View File

@@ -1,185 +0,0 @@
<?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;
}
}

View File

@@ -1,212 +0,0 @@
<?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 '&#9679; ' . 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 '&#9679; ' . 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 '&#9679; ' . 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 '&nbsp;<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 '&nbsp;<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>';
}
}

View File

@@ -1,186 +0,0 @@
<?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.
}
}

View File

@@ -1,94 +0,0 @@
<?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.',
)
);
}
}
}

View File

@@ -15,14 +15,10 @@ 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_Key_Store, PRV_Model_Registry, PRV_Perplexity_Provider,
* PRV_OpenRouter_Provider.
* Dependencies: 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
*/
@@ -66,23 +62,17 @@ 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 {
if ( $provider instanceof PRV_OpenRouter_Provider ) {
$provider->probe_with_key( 'what is BPC-157', $api_key );
} else {
$provider->probe( 'what is BPC-157' );
}
$provider->probe( 'what is BPC-157' );
$ms = (int) round( ( microtime( true ) - $start ) * 1000 );
wp_send_json_success(
array(

View File

@@ -8,31 +8,24 @@
declare(strict_types=1);
/**
* Handles the six settings POST actions: save config, model CRUD, key set/remove.
* Handles the four settings POST actions: save config, and model CRUD.
*
* Extracted from PRV_Settings_Page to keep each file under the 300-line
* limit. PRV_Settings_Page registers all hooks and delegates the handlers here.
* limit. PRV_Settings_Page registers all hooks and delegates the four
* admin_post_* 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).
*
* 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.
*
* Who triggers: PRV_Settings_Page -- via delegation in register_hooks().
* Dependencies: PRV_Config, PRV_Config_Version, PRV_Cron, PRV_Model_Registry,
* PRV_Key_Store.
* Dependencies: PRV_Config, PRV_Config_Version, PRV_Cron,
* PRV_Model_Registry.
*
* @see class-prv-settings-page.php -- Page registration, dispatch, Run-now, AJAX.
* @see class-prv-key-store.php -- Encryption and storage.
* @see class-prv-settings-page.php -- Page registration, dispatch, Run-now, AJAX.
* @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 {
@@ -71,7 +64,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.
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
$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;
@@ -125,71 +118,6 @@ 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.
*
@@ -199,7 +127,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.
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
$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'] ?? '' ) ) );
@@ -230,7 +158,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.
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
$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'] );
@@ -268,7 +196,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.
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( '' !== $id ) {

View File

@@ -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/test-key,
* and delegates all HTML rendering to PRV_Settings_Renderer.
* PRV_Settings_Controller, handles Run-now and AJAX test-model, 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_Key_Test_Ajax, PRV_Probe_Runner,
* PRV_Run_Lock.
* PRV_Model_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-model handler.
* @see class-prv-key-test-ajax.php -- AJAX test-key handler.
* @see class-prv-key-store.php -- Key storage actions.
* @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.
* @package PrVision
*/
class PRV_Settings_Page {
@@ -45,16 +45,10 @@ 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/key are delegated to PRV_Settings_Controller,
* POST handlers for save/model-CRUD 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.
@@ -73,11 +67,8 @@ 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' ) );
}
@@ -166,17 +157,6 @@ 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.
*
@@ -195,7 +175,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 all POST handlers.
* PRV_Settings_Controller for the four POST handlers.
*
* @param string $action Nonce action.
*

View File

@@ -11,18 +11,15 @@ 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, provider API key
* card (via PRV_Key_Manager_Renderer), sticky save-bar, and inline JS.
* All output is escaped at the point of emission.
* (via PRV_Model_Manager_Table), secondary config form, 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_Key_Manager_Renderer.
* PRV_Config_Version, PRV_Model_Manager_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.
* @see class-prv-settings-page.php -- Instantiates and calls render().
* @see class-prv-model-manager-table.php -- Renders the model table.
* @package PrVision
*/
class PRV_Settings_Renderer {
@@ -43,7 +40,6 @@ 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>';
@@ -112,7 +108,7 @@ class PRV_Settings_Renderer {
}
/**
* Render POST-redirect notices (saved, run done, model CRUD, key actions, lock).
* Render POST-redirect notices (saved, run done, model CRUD, lock).
*
* @return void
*/
@@ -122,10 +118,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'] ) ) {
@@ -140,20 +136,11 @@ 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 (cadence, budget, peptides, intents).
* Render the secondary config form.
*
* @return void
*/
@@ -167,6 +154,7 @@ class PRV_Settings_Renderer {
$this->render_projected_cost();
$this->render_peptides_field();
$this->render_intents_field();
$this->render_api_key_status();
echo '</div></form>';
}
@@ -245,6 +233,25 @@ 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">&#9679; ' . 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">&#9679; ' . 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">&#9679; ' . esc_html__( 'Defined — last run OK', 'pr-vision' ) . '</span>';
}
echo '</div>';
}
/**
* Render the sticky save-bar with scoring-change warning and Run now.
*

View File

@@ -14,19 +14,21 @@ declare(strict_types=1);
* at construction time; the runner instantiates one provider per model from
* PRV_Config::get_models().
*
* API key is resolved through PRV_Key_Store::get_key() (constant → admin
* option → none), never read directly from the constant.
* 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-…)
*
* Who triggers: PRV_Probe_Runner via PRV_Probe_Provider interface.
* PRV_Key_Test_Ajax (probe_with_key for test path).
* Dependencies: PRV_Key_Store, PRV_Gateway_Client, PRV_Citation_Detector,
* PRV_Probe_Result.
* Dependencies: 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, §Key management.
* @see ARCHITECTURE.md — §Provider implementations.
* @package PrVision
*/
class PRV_OpenRouter_Provider implements PRV_Probe_Provider {
@@ -73,36 +75,14 @@ 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_with_key( string $query, string $api_key ): PRV_Probe_Result {
public function probe( string $query ): PRV_Probe_Result {
$api_key = $this->resolve_api_key();
$body = array(
'model' => $this->model,
'messages' => array(
@@ -176,10 +156,22 @@ 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 '' !== PRV_Key_Store::get_key();
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;
}
}

View File

@@ -15,18 +15,16 @@ declare(strict_types=1);
* an explicit `citations` array — the most faithful citation signal
* available (real web retrieval vs. training-time knowledge).
*
* API key is resolved through PRV_Key_Store::get_key() (constant → admin
* option → none), never read directly from the constant.
* Required WP-config constant:
* PRV_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
*
* Who triggers: PRV_Probe_Runner via the PRV_Probe_Provider interface.
* Dependencies: PRV_Key_Store, PRV_Gateway_Client, PRV_Citation_Detector,
* PRV_Probe_Result.
* Dependencies: 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, §Key management.
* @see ARCHITECTURE.md — §Provider implementations.
* @see CONTEXT.md — "Perplexity sonar", "citations".
* @package PrVision
*/
@@ -39,6 +37,7 @@ 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;
@@ -70,19 +69,16 @@ class PRV_Perplexity_Provider implements PRV_Probe_Provider {
/**
* {@inheritDoc}
*
* Resolves the key via PRV_Key_Store (constant → option → none).
* Sends the query as a user message to perplexity/sonar via OpenRouter.
* Parses the citations[] array from the OpenRouter response envelope.
*
* @param string $query The search query to probe.
*
* @return PRV_Probe_Result
* @throws \RuntimeException On permanent API failure or no key configured.
* @throws \RuntimeException On permanent API failure.
*/
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.' );
}
$api_key = $this->resolve_api_key();
$body = array(
'model' => self::MODEL,
@@ -130,6 +126,7 @@ 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;
}
@@ -145,10 +142,22 @@ 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 '' !== PRV_Key_Store::get_key();
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;
}
}

View File

@@ -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.3
* Version: 0.2.1
* 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.3' );
define( 'PRV_VERSION', '0.2.1' );
define( 'PRV_PLUGIN_FILE', __FILE__ );
define( 'PRV_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PRV_PLUGIN_URL', plugin_dir_url( __FILE__ ) );

View File

@@ -27,7 +27,7 @@ if ( ! defined( 'ARRAY_N' ) ) {
define( 'ARRAY_N', 'ARRAY_N' );
}
define( 'PRV_VERSION', '0.2.3' );
define( 'PRV_VERSION', '0.2.1' );
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/' );

View File

@@ -1,182 +0,0 @@
<?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() );

View File

@@ -36,7 +36,6 @@ 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' ];
@@ -73,7 +72,6 @@ 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' );

View File

@@ -11,12 +11,8 @@
* 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.
*
* 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 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
*/
@@ -32,9 +28,8 @@ $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 (v0.1, v0.2, v0.2.3) ───── */
/* ── 2. Delete all prv_ prefixed options (covers v0.1 + v0.2 keys) ── */
// 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\_%'"