PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-module.php

class-module.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.0, at includes/class-module.php

746 lines 25.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Module abstract base class.
4 *
5 * Every feature in xSpeed (Free or Pro) extends this class. The contract is
6 * documented in IMPLEMENTATION.md §1.1. A Module is a self-contained unit
7 * that declares its tier, settings schema, REST routes, UI panels, CLI
8 * commands, conflicts, and lifecycle hooks in one place — so moving a
9 * feature between Free and Pro is a `git mv` + flipping the TIER constant,
10 * with no call-site changes.
11 *
12 * Concrete modules MUST:
13 * - Set the SLUG class constant.
14 * - Set the TIER class constant (TIER_FREE or TIER_PRO).
15 * - Set the VERSION class constant.
16 *
17 * @package XSpeed
18 */
19
20 namespace XSpeed;
21
22 defined( 'ABSPATH' ) || exit;
23
24 abstract class Module {
25
26 public const TIER_FREE = 'free';
27 public const TIER_PRO = 'pro';
28
29 /**
30 * Concrete modules override these three constants.
31 */
32 public const SLUG = '';
33 public const TIER = self::TIER_FREE;
34 public const VERSION = '1.0.0';
35
36 /**
37 * Other module slugs this module needs at boot. Resolved by
38 * Module_Registry via topological sort; missing deps fail loudly.
39 *
40 * @return string[]
41 */
42 public function dependencies(): array {
43 return array();
44 }
45
46 /**
47 * Typed settings schema. See Settings_Manager::validate() for the
48 * supported `type` values (bool, int, string, enum, list, url). Each
49 * field declares `default` and optional `min` / `max` / `options` /
50 * `item_type`. Storage key is `xspeed_module_<slug>`.
51 *
52 * A field may also declare `constants` -- an ordered list of wp-config.php
53 * constant names that pin its value, most specific first:
54 *
55 * 'redis_host' => array(
56 * 'type' => 'string',
57 * 'default' => '127.0.0.1',
58 * 'constants' => array( 'XSPEED_OC_HOST', 'WP_REDIS_HOST' ),
59 * ),
60 *
61 * Resolution order is then: first DEFINED constant -> stored option ->
62 * `default`. A constant defined as an empty string still wins -- it is an
63 * answer, not an absence. A pinned field is never persisted, and writes
64 * targeting it are refused rather than silently dropped; see
65 * Settings_Manager::origins() / locked_in_input(). This lets a host
66 * (xCloud provisioning Redis) configure a module with no admin visit. (#398)
67 *
68 * @return array<string,array>
69 */
70 public function settings_schema(): array {
71 return array();
72 }
73
74 /**
75 * Settings this module keeps at their own defaults when xSpeed cannot own
76 * the page cache, even though they default to ON.
77 *
78 * Settings::conflict_safe_profile() switches off every bool so a
79 * site that already has a caching plugin gets an xSpeed that does nothing
80 * until asked. Two kinds of setting do not belong in that sweep: one where
81 * OFF is the wrong answer (a consent requirement), and one that cannot act
82 * at all while the feature above it is off, where writing false would
83 * suggest a decision nobody made.
84 *
85 * Naming a field here is a decision, not a default: the sweep covers every
86 * bool, so a field is only left alone because someone said so.
87 *
88 * @return string[] Field names from this module's settings_schema().
89 */
90 public function conflict_safe_exempt(): array {
91 return array();
92 }
93
94 /**
95 * Option keys a module stores OUTSIDE its settings_schema that must
96 * survive a schema-driven save. Settings_Manager rebuilds the option
97 * from the schema on get()/update(), which would otherwise drop these.
98 * Example: the REST-cache module keeps its route `rules` array here so a
99 * plain enabled/ttl save doesn't wipe the rules table. (FBS-82408)
100 *
101 * @return string[]
102 */
103 public function preserved_keys(): array {
104 return array();
105 }
106
107 /**
108 * Schema migrations keyed by target version. Each value is a callable
109 * that receives the stored options array and returns the migrated
110 * array. Migrations run in version order on first load after upgrade.
111 *
112 * @return array<string,callable>
113 */
114 public function migrations(): array {
115 return array();
116 }
117
118 /**
119 * REST routes the module owns. Paths are prefixed with
120 * `/xspeed/v1/<slug>/` by Rest_Manager; declare without the prefix.
121 * `permission_callback` is wrapped automatically with a final cap
122 * check + tier gate, so modules don't need to repeat that boilerplate
123 * — but they MUST still declare a sensible callback.
124 *
125 * Default impl returns the standard GET + POST pair for modules that
126 * declare a settings_schema. Modules that need extra endpoints can
127 * extend the array. Modules with truly custom REST should override
128 * entirely and skip parent::rest_routes().
129 *
130 * Per SETTINGS.md §5.1 every module's settings live at:
131 * GET /xspeed/v1/<slug>/ → current settings
132 * POST /xspeed/v1/<slug>/ → partial patch, returns updated settings
133 *
134 * @return array[]
135 */
136 public function rest_routes(): array {
137 if ( empty( $this->settings_schema() ) ) {
138 return array();
139 }
140 return array(
141 array(
142 'path' => '/',
143 'methods' => 'GET',
144 'callback' => array( $this, 'rest_get_settings' ),
145 ),
146 array(
147 'path' => '/',
148 'methods' => 'POST',
149 'callback' => array( $this, 'rest_update_settings' ),
150 'feature' => static::SLUG,
151 ),
152 // Take a constant-pinned field back, or hand it to wp-config again.
153 // On every module, because any field can declare `constants`. (#398)
154 array(
155 'path' => '/override',
156 'methods' => 'POST',
157 'callback' => array( $this, 'rest_set_override' ),
158 'feature' => static::SLUG,
159 ),
160 // Per-field provenance on its own, for a panel that needs to
161 // re-read it after an action that changes which fields are pinned
162 // (enabling the object cache writes constants) without re-fetching
163 // every module descriptor. (#398)
164 array(
165 'path' => '/origins',
166 'methods' => 'GET',
167 'callback' => array( $this, 'rest_get_origins' ),
168 ),
169 );
170 }
171
172 /**
173 * Where each of this module's settings currently comes from.
174 */
175 public function rest_get_origins( \WP_REST_Request $request ) {
176 return rest_ensure_response( Settings_Manager::origins( static::SLUG ) );
177 }
178
179 /**
180 * Toggle a deliberate override of a constant-pinned field.
181 *
182 * Body: `{ "field": "redis_host", "override": true }`. Overriding does not
183 * itself set a value -- it unlocks the field, and the admin's next save
184 * writes it like any other setting. Reverting hands the field back to the
185 * constant, which resumes winning immediately.
186 */
187 public function rest_set_override( \WP_REST_Request $request ) {
188 if ( $this->is_license_locked() ) {
189 return new \WP_Error(
190 'xspeed_license_required',
191 sprintf(
192 /* translators: %s: module slug. */
193 __( '"%s" is a Pro module and this site has no valid license, so its settings cannot be changed.', 'xspeed' ),
194 static::SLUG
195 ),
196 array( 'status' => 403 )
197 );
198 }
199
200 $body = (array) $request->get_json_params();
201 $field = isset( $body['field'] ) ? (string) $body['field'] : '';
202 $on = ! empty( $body['override'] );
203
204 $spec = $this->settings_schema()[ $field ] ?? null;
205 if ( ! is_array( $spec ) ) {
206 return new \WP_Error(
207 'xspeed_unknown_setting',
208 sprintf(
209 /* translators: %s: setting key. */
210 __( 'Unknown setting: %s', 'xspeed' ),
211 $field
212 ),
213 array( 'status' => 400 )
214 );
215 }
216
217 // Overriding a field no constant pins is meaningless -- it is already
218 // editable -- and would leave a stale entry that silently disables the
219 // lock if a constant appeared later.
220 if ( $on && null === Settings_Manager::constant_source( $spec ) ) {
221 return new \WP_Error(
222 'xspeed_setting_not_pinned',
223 sprintf(
224 /* translators: %s: setting key. */
225 __( '"%s" is not defined in wp-config.php, so there is nothing to override.', 'xspeed' ),
226 $field
227 ),
228 array( 'status' => 409 )
229 );
230 }
231
232 if ( $on ) {
233 Settings_Manager::set_override( static::SLUG, $field, true );
234 } else {
235 /*
236 * The full hand-back, not just dropping the override entry. Once we
237 * have written our own define it outranks the host's, so clearing
238 * the entry alone left the field on our stale value with no route
239 * back -- the panel button did nothing while `wp xspeed objcache
240 * revert`, which did the extra work inline, worked. Both now go
241 * through Settings_Manager::revert(). (#398)
242 */
243 $reverted = Settings_Manager::revert( static::SLUG, $field );
244 if ( is_wp_error( $reverted ) ) {
245 return $reverted;
246 }
247 }
248
249 if ( class_exists( '\\XSpeed\\Activity_Log' ) ) {
250 Activity_Log::record(
251 $on ? 'setting_override_taken' : 'setting_override_reverted',
252 sprintf(
253 $on
254 /* translators: 1: setting key, 2: module slug. */
255 ? __( 'Overrode "%1$s" on "%2$s" — the wp-config.php value no longer applies.', 'xspeed' )
256 /* translators: 1: setting key, 2: module slug. */
257 : __( 'Reverted "%1$s" on "%2$s" to the value set in wp-config.php.', 'xspeed' ),
258 $field,
259 static::SLUG
260 ),
261 Activity_Log::INFO
262 );
263 }
264
265 return rest_ensure_response(
266 array(
267 'ok' => true,
268 'field' => $field,
269 'override' => $on,
270 'settings' => Settings_Manager::get_public( static::SLUG ),
271 'origins' => Settings_Manager::origins( static::SLUG ),
272 )
273 );
274 }
275
276 /**
277 * Default GET handler — returns all settings (defaults + stored)
278 * coerced against the schema, with secret fields masked. Uses the public
279 * view (not get_settings()) so a credential never leaves in a REST payload;
280 * the engine reads real values through get_settings()/get_setting(). (#115)
281 * Modules can override but rarely need to.
282 */
283 public function rest_get_settings( \WP_REST_Request $request ) {
284 return rest_ensure_response( Settings_Manager::get_public( static::SLUG ) );
285 }
286
287 /**
288 * Default POST handler — validates the JSON body against the
289 * schema, persists, returns the post-update settings. Unknown keys
290 * are stripped by Settings_Manager.
291 */
292 public function rest_update_settings( \WP_REST_Request $request ) {
293 $params = $request->get_json_params();
294 if ( ! is_array( $params ) ) {
295 $params = $request->get_params();
296 }
297
298 // Say so, rather than returning 200 over a write that didn't happen.
299 // update_settings() enforces the gate on every surface; REST is the
300 // one with an error channel, so it reports the reason. (#143)
301 if ( $this->is_license_locked() ) {
302 return new \WP_Error(
303 'xspeed_license_required',
304 sprintf(
305 /* translators: %s: module slug. */
306 __( '"%s" is a Pro module and this site has no valid license, so its settings cannot be changed.', 'xspeed' ),
307 static::SLUG
308 ),
309 array( 'status' => 403 )
310 );
311 }
312
313 // A field pinned by a wp-config.php constant cannot be written. Saying
314 // so beats a 200 over a write that silently did nothing -- and naming
315 // the constant tells the caller where to go and change it. (#398)
316 $locked = Settings_Manager::locked_in_input( static::SLUG, is_array( $params ) ? $params : array() );
317 if ( ! empty( $locked ) ) {
318 $pairs = array();
319 foreach ( $locked as $field => $constant ) {
320 $pairs[] = $field . ' (' . $constant . ')';
321 }
322 return new \WP_Error(
323 'xspeed_setting_defined_in_wp_config',
324 sprintf(
325 /* translators: %s: comma-separated list of "field (CONSTANT_NAME)" pairs. */
326 __( 'These settings are defined in wp-config.php and cannot be changed here: %s. Edit the constant, or remove it to manage the setting from this screen.', 'xspeed' ),
327 implode( ', ', $pairs )
328 ),
329 array(
330 'status' => 409,
331 'fields' => $locked,
332 )
333 );
334 }
335
336 return rest_ensure_response( $this->update_settings( $params ) );
337 }
338
339 /**
340 * Is this a Pro module whose settings are locked for want of a licence?
341 *
342 * Shared by the REST handler and the write guard so the two can never
343 * disagree about what is locked.
344 */
345 final public function is_license_locked(): bool {
346 // The licence module itself must stay writable — otherwise an expired
347 // licence locks the user out of the very screen where a new key is
348 // entered.
349 if ( self::TIER_PRO !== $this->tier() || 'license' === static::SLUG ) {
350 return false;
351 }
352
353 /**
354 * Filter: xspeed_pro_licensed
355 *
356 * Answered by xspeed-pro — the same filter it already answers when
357 * decorating module descriptors with the `locked` flag, so the write
358 * gate and the UI lock can't disagree.
359 *
360 * Defaults to true so a Free-only install (where nothing hooks this)
361 * is never gated by a question no one is present to answer.
362 *
363 * @param bool $licensed Whether Pro is licensed right now.
364 */
365 return ! (bool) apply_filters( 'xspeed_pro_licensed', true );
366 }
367
368 /**
369 * UI panel declarations consumed by the React dashboard via the
370 * bootstrap payload. Each entry: [
371 * 'section' => 'cache' | 'performance' | 'images' | ...,
372 * 'position' => int,
373 * 'component' => 'HealthCard' | 'TogglesList' | 'StatGrid' | 'Custom',
374 * 'props' => array,
375 * ]
376 *
377 * @return array[]
378 */
379 public function ui_panels(): array {
380 return array();
381 }
382
383 /**
384 * Sidebar / dashboard metadata. The React app uses these to render the
385 * module's nav entry. Override per module to set a friendly label and
386 * a lucide-react icon name (must be in the renderer's icon whitelist —
387 * see src/components/IconResolver.tsx).
388 *
389 * @return array{label:string,icon:string,description?:string,hidden?:bool}
390 */
391 public function ui_metadata(): array {
392 return array(
393 'label' => ucfirst( str_replace( '_', ' ', static::SLUG ) ),
394 'icon' => 'Square',
395 );
396 }
397
398 /**
399 * Dynamic in-panel notices (callouts) rendered above the schema form.
400 * Computed fresh on every dashboard load. Examples: nginx GZIP
401 * snippet when the server can't be auto-configured, "drop-in
402 * missing" warning when cache_enabled but no advanced-cache.php.
403 *
404 * Each entry: [
405 * 'tone' => 'info' | 'warn' | 'danger' | 'success',
406 * 'title' => 'Short heading.',
407 * 'body' => 'One- or two-sentence explanation.',
408 * 'snippet' => 'Optional verbatim code snippet rendered in a
409 * <pre> with a Copy button.',
410 * ]
411 *
412 * @return array[]
413 */
414 public function ui_notices(): array {
415 return array();
416 }
417
418 /**
419 * Is this module actually doing something right now?
420 *
421 * "On" is not one shape across the plugin. Most modules carry an
422 * `enabled` setting, but page caching lives in the GLOBAL option
423 * (`xspeed_options.cache_enabled`), Minify and Lazy are on when any of
424 * their individual flags is set, and MCP is on when it is connected.
425 * The sidebar's "N on" badge counted only the literal `enabled` key, so
426 * it under-reported: on a site with page caching, minification, lazy
427 * loading and MCP all running it read "Cache 2 / Optimization 1" and
428 * left the plugin's headline feature out of its own count. (#363)
429 *
430 * The default below keeps the historic behaviour for the modules that
431 * genuinely do store `enabled`. A module whose "on" means something
432 * else overrides this and answers for itself, which is what stops the
433 * count drifting again the next time a module changes shape.
434 *
435 * Three-state on purpose:
436 * true — on and doing work
437 * false — off
438 * null — no meaningful on/off (a status panel like Health). Callers
439 * must exclude these rather than counting them as off.
440 */
441 public function is_active(): ?bool {
442 $settings = $this->get_settings();
443 return array_key_exists( 'enabled', $settings )
444 ? (bool) $settings['enabled']
445 : null;
446 }
447
448 /**
449 * "On if any of my boolean flags is on" — the shape used by modules
450 * that have no master switch, only a set of independent toggles
451 * (Minify, Lazy, Bloat, Gzip).
452 *
453 * Derived from the module's OWN schema rather than a hardcoded key
454 * list, so adding a flag to a module cannot silently fall out of its
455 * active state the way a literal list would. Only `bool` fields count:
456 * an int like `eager_first_n` or a list like `excluded_images` is
457 * configuration for a feature, not evidence the feature is on.
458 *
459 * Returns null when the module declares no boolean flags at all, so a
460 * caller can exclude it rather than record a misleading false.
461 */
462 final protected function any_bool_flag_on(): ?bool {
463 $schema = $this->settings_schema();
464 $settings = $this->get_settings();
465
466 $found = false;
467 foreach ( $schema as $key => $spec ) {
468 if ( 'bool' !== ( $spec['type'] ?? '' ) ) {
469 continue;
470 }
471 $found = true;
472 if ( ! empty( $settings[ $key ] ) ) {
473 return true;
474 }
475 }
476
477 return $found ? false : null;
478 }
479
480 /**
481 * Why is this module reported on or off? One short sentence for the (i)
482 * beside the status pill.
483 *
484 * "On" is not one shape (see is_active()), so without this the pill is a
485 * bare assertion the user cannot check. It is most opaque exactly where
486 * the rule is least obvious: Media Optimization reads "On" while its two
487 * most prominent switches, Lazy-load Images and Iframes, are both off --
488 * because three other flags are on. The reason names them.
489 *
490 * Computed server-side alongside is_active() so the explanation cannot
491 * drift from the verdict it explains. Returning null means "no reason to
492 * add" and the (i) is not rendered.
493 */
494 public function active_reason(): ?string {
495 // A module with its own `enabled` switch needs no explaining: the
496 // pill and the switch say the same thing, and an (i) that only
497 // restates the pill is noise on every one of those pages. Silence
498 // here is what keeps the (i) meaningful where it does appear.
499 if ( array_key_exists( 'enabled', $this->get_settings() ) ) {
500 return null;
501 }
502
503 return $this->bool_flag_reason();
504 }
505
506 /**
507 * The reason text for a module whose "on" is "any of my flags is on".
508 *
509 * Names the specific settings that are on, using their schema labels, so
510 * the user can go and look at them rather than take the pill on trust.
511 * Shared by every flag-based module for one consistent sentence.
512 */
513 final protected function bool_flag_reason(): ?string {
514 $schema = $this->settings_schema();
515 $settings = $this->get_settings();
516
517 $on = array();
518 foreach ( $schema as $key => $spec ) {
519 if ( 'bool' !== ( $spec['type'] ?? '' ) ) {
520 continue;
521 }
522 if ( ! empty( $settings[ $key ] ) ) {
523 $on[] = $spec['label'] ?? $key;
524 }
525 }
526
527 // No boolean flags at all means the module has no on/off to explain
528 // (a status panel like Health). Mirrors any_bool_flag_on() returning
529 // null: no verdict, so no reason.
530 if ( null === $this->any_bool_flag_on() ) {
531 return null;
532 }
533
534 if ( empty( $on ) ) {
535 return __( 'This module has no single on/off switch. It counts as on when any of its settings is on, and none currently is.', 'xspeed' );
536 }
537
538 return sprintf(
539 /* translators: %s: comma-separated list of setting labels that are switched on. */
540 __( 'This module has no single on/off switch. It counts as on because these settings are on: %s.', 'xspeed' ),
541 implode( ', ', $on )
542 );
543 }
544
545 /**
546 * WP-CLI command definitions. Each entry: [
547 * 'name' => 'xspeed cache purge',
548 * 'callback' => callable,
549 * 'synopsis' => array, // wp-cli synopsis spec
550 * ]
551 *
552 * @return array[]
553 */
554 public function cli_commands(): array {
555 return array();
556 }
557
558 /**
559 * Nginx directives this module contributes to the unified server-block
560 * snippet rendered by Cache::full_nginx_server_block(). Returning a
561 * non-null string opts the module into the consolidated "paste this
562 * once into your nginx vhost" UX on the Cache panel.
563 *
564 * The returned string should be the bare directives only — no `server
565 * { }` wrapper, no comment header (the aggregator adds one). Empty
566 * string and null are both treated as "no contribution this render".
567 *
568 * Return null (default) when the module is disabled, its current
569 * settings make the directives a no-op, or the module doesn't have
570 * nginx-side directives at all.
571 */
572 public function nginx_directives(): ?string {
573 return null;
574 }
575
576 /**
577 * Conflict declarations for this module — which other plugins clash
578 * with which sub-feature. Each entry: [
579 * 'plugin' => 'wp-rocket/wp-rocket.php',
580 * 'feature' => 'page_cache',
581 * 'strategy' => 'refuse' | 'warn' | 'allow',
582 * 'reason' => 'human-readable why',
583 * ]
584 *
585 * @return array[]
586 */
587 public function conflicts(): array {
588 return array();
589 }
590
591 /**
592 * Register WP hooks. Called by Module_Registry::boot_all() after
593 * dependencies are resolved. Modules should NOT register hooks in
594 * their constructors — only in boot() — so the registry can control
595 * load order.
596 */
597 public function boot(): void {}
598
599 /**
600 * One-time setup at plugin activation. Idempotent. Examples: create
601 * a custom table, write a silence guard, register a cron schedule.
602 */
603 public function activate(): void {}
604
605 /**
606 * Tear down at plugin deactivation. Reversible counterpart to
607 * activate(). MUST leave the site in a clean state — no orphaned
608 * cron jobs, no leftover drop-ins.
609 */
610 public function deactivate(): void {}
611
612 /**
613 * Convenience accessors. Modules read/write their own settings
614 * through these so the storage detail (one option per module under
615 * `xspeed_module_<slug>`) stays encapsulated.
616 */
617 final public function get_setting( string $key, $default = null ) {
618 $opts = Settings_Manager::get( static::SLUG );
619 return array_key_exists( $key, $opts ) ? $opts[ $key ] : $default;
620 }
621
622 /**
623 * Read one boolean flag WITHOUT building the settings schema.
624 *
625 * `get_setting()` routes through `Settings_Manager::get()`, which calls
626 * `settings_schema()` to know the defaults and types. That schema
627 * declares its `label` / `description` through `__()` — correct, they are
628 * UI copy a translator has to reach. But modules that read their own
629 * settings from `boot()` do so on `plugins_loaded`, before `init`, where
630 * text domains load. Building the schema there translates every label too
631 * early: WordPress 6.7+ emits `_load_textdomain_just_in_time` on each
632 * request, and the labels resolve against a domain that is not loaded
633 * yet, which silently defeats the translation.
634 *
635 * A boot-time gate only ever asks "is this feature switched on", so it
636 * needs the stored value, not the schema. This reads the module's option
637 * directly and casts. `$default` is what applies when the key was never
638 * written — pass the same value the schema declares as that field's
639 * default, or the two disagree on a fresh install.
640 *
641 * Use ONLY for a boot-time on/off check. Anything that needs coercion,
642 * schema defaults, or a non-boolean value must keep using
643 * `get_setting()` / `get_settings()`.
644 *
645 * @param string $key Field name in this module's settings.
646 * @param bool $default Value when the key has never been stored.
647 */
648 final protected function flag_at_boot( string $key, bool $default = false ): bool {
649 return (bool) $this->setting_at_boot( $key, $default );
650 }
651
652 /**
653 * Raw stored value for one setting, WITHOUT building the schema. The
654 * general form of `flag_at_boot()` — see that method for why boot-time
655 * reads must not touch `settings_schema()`.
656 *
657 * No type coercion is applied, so pass a `$default` of the type the
658 * caller expects and cast the result at the call site. Same rule: use
659 * ONLY from code that runs before `init`.
660 *
661 * @param string $key Field name in this module's settings.
662 * @param mixed $default Value when the key has never been stored.
663 * @return mixed
664 */
665 final protected function setting_at_boot( string $key, $default = null ) {
666 $stored = get_option( Settings_Manager::OPTION_PREFIX . static::SLUG, array() );
667 if ( ! is_array( $stored ) || ! array_key_exists( $key, $stored ) ) {
668 return $default;
669 }
670 return $stored[ $key ];
671 }
672
673 final public function get_settings(): array {
674 return Settings_Manager::get( static::SLUG );
675 }
676
677 final public function update_settings( array $input ): array {
678 $refusal = $this->license_write_refusal( $input );
679 if ( null !== $refusal ) {
680 return $refusal;
681 }
682 return Settings_Manager::update( static::SLUG, $input );
683 }
684
685 /**
686 * Enforce the Pro licence gate on writes, or null to allow the write.
687 *
688 * The dashboard renders a Pro module as `locked` without a valid licence
689 * and refuses to toggle it, but that flag is applied by the
690 * `xspeed_module_descriptor` filter — a decoration on the payload the UI
691 * reads. It never reached the write path, so `POST /xspeed/v1/<module>`
692 * with `{"enabled": true}` returned 200 and persisted, and CLI/MCP hit the
693 * same unguarded callbacks.
694 *
695 * That mattered because module availability is decided by
696 * `Tier_Registry::is_available()`, which asks only whether the Pro plugin
697 * is LOADED — never whether it is licensed. So a Pro module boots and runs
698 * its hooks regardless, and a persisted `enabled: true` genuinely turns the
699 * feature on. This was not cosmetic. (#143)
700 *
701 * Free never references Pro: it asks through `xspeed_pro_licensed`, the
702 * same filter Pro already answers for the descriptor. With no Pro plugin
703 * present nothing hooks it, the default `true` stands, and Free modules
704 * are unaffected either way.
705 *
706 * Reads stay open — the dashboard must still be able to GET settings to
707 * render the locked state at all.
708 *
709 * @param array $input Proposed setting values.
710 * @return array|null Current public settings when refused, else null.
711 */
712 private function license_write_refusal( array $input ): ?array {
713 if ( ! $this->is_license_locked() ) {
714 return null;
715 }
716
717 Activity_Log::record(
718 'license_write_refused',
719 sprintf(
720 /* translators: %s: module slug. */
721 __( 'Refused a settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
722 static::SLUG
723 ),
724 Activity_Log::WARN
725 );
726
727 // Return the unchanged public settings rather than throwing: callers
728 // expect the module's settings back, and the dashboard already renders
729 // this module as locked. REST surfaces the refusal explicitly in
730 // rest_update_settings(), which has a WP_Error channel.
731 return Settings_Manager::get_public( static::SLUG );
732 }
733
734 final public function slug(): string {
735 return static::SLUG;
736 }
737
738 final public function tier(): string {
739 return static::TIER;
740 }
741
742 final public function version(): string {
743 return static::VERSION;
744 }
745 }
746