diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8ee87c5..89e9fd5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. | diff --git a/CHANGELOG.md b/CHANGELOG.md index 594fbec..a927238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,65 @@ 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. + +### 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. @@ -37,105 +96,3 @@ Rename to PR Vision; fix www-strip in competitor display; PHPCS gating; remove d ### CI - **P2-B:** Dropped `continue-on-error: true` from the PHPCS step — WordPress Coding Standards now gates the build. PHPCS was already green on v0.1.0; this enforces the DoD for future commits. - -### Tests - -- Added four assertions to `test-citation-detector.php` covering `wikipedia.org`, `webmd.com` (non-www domains starting with `w`) and `www.examine.com` to lock in the P2-A fix. - ---- - -## [0.1.0] — 2026-06-13 - -**Initial release — AI-visibility dashboard.** - -### Added - -- `PRV_Probe_Provider` interface + `PRV_Probe_Result` value object. -- `PRV_Perplexity_Provider`: Perplexity `sonar` via OpenRouter/Cloudflare AI Gateway; parses `citations[]` array. -- `PRV_OpenRouter_Provider`: generic parameterised provider for GPT-search and Gemini-search models; parses annotations + inline URL regex fallback. -- `PRV_Gateway_Client`: shared CF AI Gateway HTTP client with exponential-backoff retry and cURL auth injection (mirrors PRAutoBlogger pattern). -- `PRV_Citation_Detector`: domain extraction from citation arrays/URLs, `is_cited()`, `get_our_position()`. -- `PRV_Cost_Ledger`: month-to-date spend from DB, `can_afford()` pre-check, `update_row_cost()` settlement. -- `PRV_Probe_Runner`: full peptides × intents × models run with budget cap abort and partial-run persistence. -- `PRV_Table_Manager`: `{prefix}prv_ai_visibility` via `dbDelta`, schema-version option, `drop_table()` for uninstall. -- `PRV_Config`: typed getters + `seed_defaults()` with ~12 peptides, 3 intents, 3 models. -- `PRV_Cron`: weekly `prv_weekly_probe` WP-Cron; `schedule_weekly()` / `clear_schedule()`. -- `PRV_Data_Collector` + `PRV_Dashboard_Panel` interfaces (collector/panel seam). -- `PRV_Collector_Registry`: singleton registry for future extensibility. -- `PRV_Ai_Visibility_Collector`: trendline (per-run visibility score), standings (per-peptide latest), MTD cost. -- `PRV_Ai_Visibility_Panel`: proxy-metric note, Chart.js trendline, per-peptide standings table, MTD cost bar, "Run now" button. -- `PRV_Admin_Page`: "PR Vision" top-level menu, Chart.js enqueue, Run now POST handler (nonce + `manage_options`). -- `uninstall.php`: DROP TABLE + DELETE `prv_*` options + `wp_clear_scheduled_hook`. -- Unit tests: citation detection, score formula, cost ledger + budget cap abort, provider response parsing (mock HTTP), cron registration, uninstall purge. -- `ARCHITECTURE.md`, `CONVENTIONS.md`, `CONTEXT.md` (incl. visibility score formula), `README.md`. -- CI: PHP lint (8.1/8.2/8.3) + PHPCS + 300-line check (mirrors `peptide-repo-core`). -- Seed config: 12 peptides, 3 prompt intents, Perplexity sonar + GPT-4o-search + Gemini-2.0-Flash. - ---- - -## [0.2.0] — 2026-06-14 - -Admin/Settings interface + correctness fixes. All adversarial-QA must-fixes addressed. - -### Added - -- **Model manager (`prv_models` v2):** CRUD for models via Settings page — add, enable/disable, remove, edit, without a code deploy. Per-model: provider, slug, enabled flag, operator note, run-health, Test button. -- **`PRV_Model_Registry`:** manages the `prv_models` option in v2 rich-object format; migration from v0.1.x flat-string format is versioned, idempotent, and tested. -- **`PRV_Upgrader`:** runs all pending migrations on every `plugins_loaded` (not just activation) so live-upgraded installs get migrated automatically. -- **`PRV_Config_Version`:** stamps every run with the scored config (models × peptides × intents); bumps on scoring-relevant saves; `get_all_versions()` for trendline annotations. -- **`PRV_Run_Lock`:** transient-based distributed lock preventing concurrent cron + "Run now" executions; `acquire()` / `release()` / `is_locked()`. -- **`PRV_Settings_Page`:** "PR Vision → Settings" sub-menu with POST handlers for settings save, model CRUD, Run now, Test model AJAX. All actions `manage_options` + nonce gated. -- **`PRV_Settings_Renderer`:** renders the dark "Assay" settings page (CSS variables, sticky save-bar with scoring-change warning, projected cost inset, API-key status, Run now). -- **`PRV_Model_Manager_Table`:** renders the model manager table with health badges, keyboard-sortable `'; + } + + echo ''; + + // Remove key form (only when admin option is set, not constant). + if ( $is_set_opt && ! $is_const ) { + echo '
'; + echo ''; + wp_nonce_field( PRV_Settings_Page::NONCE_KEY, 'prv_nonce' ); + echo ''; + echo '
'; + } + } + + /** + * 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 '
'; + echo ''; + echo ''; + echo '
'; + + // Inline JS — only a button wirer, no key value in JS. + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo ''; + } +} diff --git a/includes/core/class-prv-key-store.php b/includes/core/class-prv-key-store.php new file mode 100644 index 0000000..cb78aee --- /dev/null +++ b/includes/core/class-prv-key-store.php @@ -0,0 +1,186 @@ + '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.', + ) + ); + } + } +} diff --git a/includes/core/class-prv-model-test-ajax.php b/includes/core/class-prv-model-test-ajax.php index d72f60f..55df981 100644 --- a/includes/core/class-prv-model-test-ajax.php +++ b/includes/core/class-prv-model-test-ajax.php @@ -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( diff --git a/includes/core/class-prv-settings-controller.php b/includes/core/class-prv-settings-controller.php index 7a43f8f..9352d38 100644 --- a/includes/core/class-prv-settings-controller.php +++ b/includes/core/class-prv-settings-controller.php @@ -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 ) { diff --git a/includes/core/class-prv-settings-page.php b/includes/core/class-prv-settings-page.php index a7188b4..9c8b70b 100644 --- a/includes/core/class-prv-settings-page.php +++ b/includes/core/class-prv-settings-page.php @@ -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. * diff --git a/includes/core/class-prv-settings-renderer.php b/includes/core/class-prv-settings-renderer.php index 48a3331..6083d3c 100644 --- a/includes/core/class-prv-settings-renderer.php +++ b/includes/core/class-prv-settings-renderer.php @@ -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 ''; $this->render_config_form(); + ( new PRV_Key_Manager_Renderer() )->render(); $this->render_save_bar(); $this->render_scripts(); echo ''; @@ -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 '
' . esc_html__( 'Settings saved.', 'pr-vision' ) . '
'; } 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 '
' . esc_html( $msg ) . '
'; } if ( ! empty( $_GET['prv_run_locked'] ) ) { @@ -136,11 +140,20 @@ class PRV_Settings_Renderer { if ( ! empty( $_GET['prv_model_removed'] ) ) { echo '
' . esc_html__( 'Model removed.', 'pr-vision' ) . '
'; } + if ( ! empty( $_GET['prv_key_set'] ) ) { + echo '
' . esc_html__( 'API key saved.', 'pr-vision' ) . '
'; + } + if ( ! empty( $_GET['prv_key_removed'] ) ) { + echo '
' . esc_html__( 'API key removed.', 'pr-vision' ) . '
'; + } + if ( ! empty( $_GET['prv_key_error'] ) ) { + echo '
' . esc_html__( 'Could not save API key — encryption failed. Check server logs.', 'pr-vision' ) . '
'; + } // 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 ''; } @@ -233,25 +245,6 @@ class PRV_Settings_Renderer { echo ''; } - /** - * 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 '
' . esc_html__( 'API Key Status (read-only)', 'pr-vision' ) . '
'; - if ( ! $defined ) { - echo '● ' . esc_html__( 'Not defined — set PRV_OPENROUTER_API_KEY in wp-config.php', 'pr-vision' ) . ''; - } elseif ( 'failed' === $status ) { - echo '● ' . esc_html__( 'Defined — last run failed (key may be invalid or revoked)', 'pr-vision' ) . ''; - } else { - echo '● ' . esc_html__( 'Defined — last run OK', 'pr-vision' ) . ''; - } - echo '
'; - } - /** * Render the sticky save-bar with scoring-change warning and Run now. * diff --git a/includes/providers/class-prv-openrouter-provider.php b/includes/providers/class-prv-openrouter-provider.php index ce8f87f..f3e4819 100644 --- a/includes/providers/class-prv-openrouter-provider.php +++ b/includes/providers/class-prv-openrouter-provider.php @@ -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(); } } diff --git a/includes/providers/class-prv-perplexity-provider.php b/includes/providers/class-prv-perplexity-provider.php index ef22bc4..7fce671 100644 --- a/includes/providers/class-prv-perplexity-provider.php +++ b/includes/providers/class-prv-perplexity-provider.php @@ -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(); } } diff --git a/pr-vision.php b/pr-vision.php index 7dc3d3d..94e266e 100644 --- a/pr-vision.php +++ b/pr-vision.php @@ -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.2 + * 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.2' ); +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__ ) ); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8adba5f..c9eaa1d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -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/' ); diff --git a/tests/unit/test-key-store.php b/tests/unit/test-key-store.php new file mode 100644 index 0000000..f8435b9 --- /dev/null +++ b/tests/unit/test-key-store.php @@ -0,0 +1,182 @@ + 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() ); diff --git a/tests/unit/test-uninstall.php b/tests/unit/test-uninstall.php index 3ca3dfa..27607bb 100644 --- a/tests/unit/test-uninstall.php +++ b/tests/unit/test-uninstall.php @@ -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' ); diff --git a/uninstall.php b/uninstall.php index cd178ca..31754a3 100644 --- a/uninstall.php +++ b/uninstall.php @@ -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\_%'"