PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 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 All 35 releases
xspeed / includes / class-module.php

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

802 lines 27.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 /*
314 * A module another module has taken over from.
315 *
316 * Distinct from the licence gate above: that one says "you have not
317 * paid for this", and this one says "something else on this site is
318 * already doing it, and running both would be worse than running
319 * either". The Cloudflare module behind the Cloudflare Enterprise
320 * edge is the case it was written for — two proxy layers, each with
321 * its own copy, and purges that reach one of them.
322 *
323 * Only a write that turns the module ON is refused. Turning it off,
324 * or changing anything else, is exactly what somebody resolving the
325 * conflict needs to be able to do.
326 */
327 $blocked = $this->blocked_by();
328 if ( null !== $blocked && ! empty( $params['enabled'] ) ) {
329 return new \WP_Error(
330 'xspeed_module_blocked',
331 $blocked,
332 array( 'status' => 409 )
333 );
334 }
335
336 // A field pinned by a wp-config.php constant cannot be written. Saying
337 // so beats a 200 over a write that silently did nothing -- and naming
338 // the constant tells the caller where to go and change it. (#398)
339 $locked = Settings_Manager::locked_in_input( static::SLUG, is_array( $params ) ? $params : array() );
340 if ( ! empty( $locked ) ) {
341 $pairs = array();
342 foreach ( $locked as $field => $constant ) {
343 $pairs[] = $field . ' (' . $constant . ')';
344 }
345 return new \WP_Error(
346 'xspeed_setting_defined_in_wp_config',
347 sprintf(
348 /* translators: %s: comma-separated list of "field (CONSTANT_NAME)" pairs. */
349 __( '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' ),
350 implode( ', ', $pairs )
351 ),
352 array(
353 'status' => 409,
354 'fields' => $locked,
355 )
356 );
357 }
358
359 return rest_ensure_response( $this->update_settings( $params ) );
360 }
361
362 /**
363 * Why this module may not be switched on right now, or null.
364 *
365 * A seam for one module to stand another down. Free answers null for
366 * everything and never blocks anything itself — it has no opinion about
367 * which modules conflict, and an add-on that knows the answer says so.
368 *
369 * The reason is shown to the customer, so it says what to do rather than
370 * naming an internal state.
371 */
372 final public function blocked_by(): ?string {
373 /**
374 * Filter: xspeed_module_blocked_by
375 *
376 * @param string|null $reason Why this module cannot be enabled.
377 * @param string $slug The module being asked about.
378 */
379 $reason = apply_filters( 'xspeed_module_blocked_by', null, static::SLUG );
380 return is_string( $reason ) && '' !== $reason ? $reason : null;
381 }
382
383 /**
384 * Is this a Pro module whose settings are locked for want of a licence?
385 *
386 * Shared by the REST handler and the write guard so the two can never
387 * disagree about what is locked.
388 */
389 final public function is_license_locked(): bool {
390 // The licence module itself must stay writable — otherwise an expired
391 // licence locks the user out of the very screen where a new key is
392 // entered.
393 if ( self::TIER_PRO !== $this->tier() || 'license' === static::SLUG ) {
394 return false;
395 }
396
397 /**
398 * Filter: xspeed_pro_licensed
399 *
400 * Answered by xspeed-pro — the same filter it already answers when
401 * decorating module descriptors with the `locked` flag, so the write
402 * gate and the UI lock can't disagree.
403 *
404 * Defaults to true so a Free-only install (where nothing hooks this)
405 * is never gated by a question no one is present to answer.
406 *
407 * @param bool $licensed Whether Pro is licensed right now.
408 */
409 return ! (bool) apply_filters( 'xspeed_pro_licensed', true );
410 }
411
412 /**
413 * UI panel declarations consumed by the React dashboard via the
414 * bootstrap payload. Each entry: [
415 * 'section' => 'cache' | 'performance' | 'images' | ...,
416 * 'position' => int,
417 * 'component' => 'HealthCard' | 'TogglesList' | 'StatGrid' | 'Custom',
418 * 'props' => array,
419 * ]
420 *
421 * @return array[]
422 */
423 public function ui_panels(): array {
424 return array();
425 }
426
427 /**
428 * Sidebar / dashboard metadata. The React app uses these to render the
429 * module's nav entry. Override per module to set a friendly label and
430 * a lucide-react icon name (must be in the renderer's icon whitelist —
431 * see src/components/IconResolver.tsx).
432 *
433 * @return array{label:string,icon:string,description?:string,hidden?:bool}
434 */
435 public function ui_metadata(): array {
436 return array(
437 'label' => ucfirst( str_replace( '_', ' ', static::SLUG ) ),
438 'icon' => 'Square',
439 );
440 }
441
442 /**
443 * Dynamic in-panel notices (callouts) rendered above the schema form.
444 * Computed fresh on every dashboard load. Examples: nginx GZIP
445 * snippet when the server can't be auto-configured, "drop-in
446 * missing" warning when cache_enabled but no advanced-cache.php.
447 *
448 * Each entry: [
449 * 'tone' => 'info' | 'warn' | 'danger' | 'success',
450 * 'title' => 'Short heading.',
451 * 'body' => 'One- or two-sentence explanation.',
452 * 'snippet' => 'Optional verbatim code snippet rendered in a
453 * <pre> with a Copy button.',
454 * ]
455 *
456 * @return array[]
457 */
458 public function ui_notices(): array {
459 return array();
460 }
461
462 /**
463 * Is this module actually doing something right now?
464 *
465 * "On" is not one shape across the plugin. Most modules carry an
466 * `enabled` setting, but page caching lives in the GLOBAL option
467 * (`xspeed_options.cache_enabled`), Minify and Lazy are on when any of
468 * their individual flags is set, and MCP is on when it is connected.
469 * The sidebar's "N on" badge counted only the literal `enabled` key, so
470 * it under-reported: on a site with page caching, minification, lazy
471 * loading and MCP all running it read "Cache 2 / Optimization 1" and
472 * left the plugin's headline feature out of its own count. (#363)
473 *
474 * The default below keeps the historic behaviour for the modules that
475 * genuinely do store `enabled`. A module whose "on" means something
476 * else overrides this and answers for itself, which is what stops the
477 * count drifting again the next time a module changes shape.
478 *
479 * Three-state on purpose:
480 * true — on and doing work
481 * false — off
482 * null — no meaningful on/off (a status panel like Health). Callers
483 * must exclude these rather than counting them as off.
484 */
485 public function is_active(): ?bool {
486 $settings = $this->get_settings();
487 return array_key_exists( 'enabled', $settings )
488 ? (bool) $settings['enabled']
489 : null;
490 }
491
492 /**
493 * "On if any of my boolean flags is on" — the shape used by modules
494 * that have no master switch, only a set of independent toggles
495 * (Minify, Lazy, Bloat, Gzip).
496 *
497 * Derived from the module's OWN schema rather than a hardcoded key
498 * list, so adding a flag to a module cannot silently fall out of its
499 * active state the way a literal list would. Only `bool` fields count:
500 * an int like `eager_first_n` or a list like `excluded_images` is
501 * configuration for a feature, not evidence the feature is on.
502 *
503 * Returns null when the module declares no boolean flags at all, so a
504 * caller can exclude it rather than record a misleading false.
505 */
506 final protected function any_bool_flag_on(): ?bool {
507 $schema = $this->settings_schema();
508 $settings = $this->get_settings();
509
510 $found = false;
511 foreach ( $schema as $key => $spec ) {
512 if ( 'bool' !== ( $spec['type'] ?? '' ) ) {
513 continue;
514 }
515 $found = true;
516 if ( ! empty( $settings[ $key ] ) ) {
517 return true;
518 }
519 }
520
521 return $found ? false : null;
522 }
523
524 /**
525 * Why is this module reported on or off? One short sentence for the (i)
526 * beside the status pill.
527 *
528 * "On" is not one shape (see is_active()), so without this the pill is a
529 * bare assertion the user cannot check. It is most opaque exactly where
530 * the rule is least obvious: Media Optimization reads "On" while its two
531 * most prominent switches, Lazy-load Images and Iframes, are both off --
532 * because three other flags are on. The reason names them.
533 *
534 * Computed server-side alongside is_active() so the explanation cannot
535 * drift from the verdict it explains. Returning null means "no reason to
536 * add" and the (i) is not rendered.
537 */
538 public function active_reason(): ?string {
539 // A module with its own `enabled` switch needs no explaining: the
540 // pill and the switch say the same thing, and an (i) that only
541 // restates the pill is noise on every one of those pages. Silence
542 // here is what keeps the (i) meaningful where it does appear.
543 if ( array_key_exists( 'enabled', $this->get_settings() ) ) {
544 return null;
545 }
546
547 return $this->bool_flag_reason();
548 }
549
550 /**
551 * What the customer still needs before this module can work, when that is
552 * something to buy or activate, in a few words ("Add-on needed").
553 *
554 * The dashboard shows it in the page header in place of the On/Off pill,
555 * because "Off" on a module nobody can switch on yet reads as a setting
556 * left off. Null, the default, keeps the pill.
557 */
558 public function status_label(): ?string {
559 return null;
560 }
561
562 /**
563 * The reason text for a module whose "on" is "any of my flags is on".
564 *
565 * Names the specific settings that are on, using their schema labels, so
566 * the user can go and look at them rather than take the pill on trust.
567 * Shared by every flag-based module for one consistent sentence.
568 */
569 final protected function bool_flag_reason(): ?string {
570 $schema = $this->settings_schema();
571 $settings = $this->get_settings();
572
573 $on = array();
574 foreach ( $schema as $key => $spec ) {
575 if ( 'bool' !== ( $spec['type'] ?? '' ) ) {
576 continue;
577 }
578 if ( ! empty( $settings[ $key ] ) ) {
579 $on[] = $spec['label'] ?? $key;
580 }
581 }
582
583 // No boolean flags at all means the module has no on/off to explain
584 // (a status panel like Health). Mirrors any_bool_flag_on() returning
585 // null: no verdict, so no reason.
586 if ( null === $this->any_bool_flag_on() ) {
587 return null;
588 }
589
590 if ( empty( $on ) ) {
591 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' );
592 }
593
594 return sprintf(
595 /* translators: %s: comma-separated list of setting labels that are switched on. */
596 __( 'This module has no single on/off switch. It counts as on because these settings are on: %s.', 'xspeed' ),
597 implode( ', ', $on )
598 );
599 }
600
601 /**
602 * WP-CLI command definitions. Each entry: [
603 * 'name' => 'xspeed cache purge',
604 * 'callback' => callable,
605 * 'synopsis' => array, // wp-cli synopsis spec
606 * ]
607 *
608 * @return array[]
609 */
610 public function cli_commands(): array {
611 return array();
612 }
613
614 /**
615 * Nginx directives this module contributes to the unified server-block
616 * snippet rendered by Cache::full_nginx_server_block(). Returning a
617 * non-null string opts the module into the consolidated "paste this
618 * once into your nginx vhost" UX on the Cache panel.
619 *
620 * The returned string should be the bare directives only — no `server
621 * { }` wrapper, no comment header (the aggregator adds one). Empty
622 * string and null are both treated as "no contribution this render".
623 *
624 * Return null (default) when the module is disabled, its current
625 * settings make the directives a no-op, or the module doesn't have
626 * nginx-side directives at all.
627 */
628 public function nginx_directives(): ?string {
629 return null;
630 }
631
632 /**
633 * Conflict declarations for this module — which other plugins clash
634 * with which sub-feature. Each entry: [
635 * 'plugin' => 'wp-rocket/wp-rocket.php',
636 * 'feature' => 'page_cache',
637 * 'strategy' => 'refuse' | 'warn' | 'allow',
638 * 'reason' => 'human-readable why',
639 * ]
640 *
641 * @return array[]
642 */
643 public function conflicts(): array {
644 return array();
645 }
646
647 /**
648 * Register WP hooks. Called by Module_Registry::boot_all() after
649 * dependencies are resolved. Modules should NOT register hooks in
650 * their constructors — only in boot() — so the registry can control
651 * load order.
652 */
653 public function boot(): void {}
654
655 /**
656 * One-time setup at plugin activation. Idempotent. Examples: create
657 * a custom table, write a silence guard, register a cron schedule.
658 */
659 public function activate(): void {}
660
661 /**
662 * Tear down at plugin deactivation. Reversible counterpart to
663 * activate(). MUST leave the site in a clean state — no orphaned
664 * cron jobs, no leftover drop-ins.
665 */
666 public function deactivate(): void {}
667
668 /**
669 * Convenience accessors. Modules read/write their own settings
670 * through these so the storage detail (one option per module under
671 * `xspeed_module_<slug>`) stays encapsulated.
672 */
673 final public function get_setting( string $key, $default = null ) {
674 $opts = Settings_Manager::get( static::SLUG );
675 return array_key_exists( $key, $opts ) ? $opts[ $key ] : $default;
676 }
677
678 /**
679 * Read one boolean flag WITHOUT building the settings schema.
680 *
681 * `get_setting()` routes through `Settings_Manager::get()`, which calls
682 * `settings_schema()` to know the defaults and types. That schema
683 * declares its `label` / `description` through `__()` — correct, they are
684 * UI copy a translator has to reach. But modules that read their own
685 * settings from `boot()` do so on `plugins_loaded`, before `init`, where
686 * text domains load. Building the schema there translates every label too
687 * early: WordPress 6.7+ emits `_load_textdomain_just_in_time` on each
688 * request, and the labels resolve against a domain that is not loaded
689 * yet, which silently defeats the translation.
690 *
691 * A boot-time gate only ever asks "is this feature switched on", so it
692 * needs the stored value, not the schema. This reads the module's option
693 * directly and casts. `$default` is what applies when the key was never
694 * written — pass the same value the schema declares as that field's
695 * default, or the two disagree on a fresh install.
696 *
697 * Use ONLY for a boot-time on/off check. Anything that needs coercion,
698 * schema defaults, or a non-boolean value must keep using
699 * `get_setting()` / `get_settings()`.
700 *
701 * @param string $key Field name in this module's settings.
702 * @param bool $default Value when the key has never been stored.
703 */
704 final protected function flag_at_boot( string $key, bool $default = false ): bool {
705 return (bool) $this->setting_at_boot( $key, $default );
706 }
707
708 /**
709 * Raw stored value for one setting, WITHOUT building the schema. The
710 * general form of `flag_at_boot()` — see that method for why boot-time
711 * reads must not touch `settings_schema()`.
712 *
713 * No type coercion is applied, so pass a `$default` of the type the
714 * caller expects and cast the result at the call site. Same rule: use
715 * ONLY from code that runs before `init`.
716 *
717 * @param string $key Field name in this module's settings.
718 * @param mixed $default Value when the key has never been stored.
719 * @return mixed
720 */
721 final protected function setting_at_boot( string $key, $default = null ) {
722 $stored = get_option( Settings_Manager::OPTION_PREFIX . static::SLUG, array() );
723 if ( ! is_array( $stored ) || ! array_key_exists( $key, $stored ) ) {
724 return $default;
725 }
726 return $stored[ $key ];
727 }
728
729 final public function get_settings(): array {
730 return Settings_Manager::get( static::SLUG );
731 }
732
733 final public function update_settings( array $input ): array {
734 $refusal = $this->license_write_refusal( $input );
735 if ( null !== $refusal ) {
736 return $refusal;
737 }
738 return Settings_Manager::update( static::SLUG, $input );
739 }
740
741 /**
742 * Enforce the Pro licence gate on writes, or null to allow the write.
743 *
744 * The dashboard renders a Pro module as `locked` without a valid licence
745 * and refuses to toggle it, but that flag is applied by the
746 * `xspeed_module_descriptor` filter — a decoration on the payload the UI
747 * reads. It never reached the write path, so `POST /xspeed/v1/<module>`
748 * with `{"enabled": true}` returned 200 and persisted, and CLI/MCP hit the
749 * same unguarded callbacks.
750 *
751 * That mattered because module availability is decided by
752 * `Tier_Registry::is_available()`, which asks only whether the Pro plugin
753 * is LOADED — never whether it is licensed. So a Pro module boots and runs
754 * its hooks regardless, and a persisted `enabled: true` genuinely turns the
755 * feature on. This was not cosmetic. (#143)
756 *
757 * Free never references Pro: it asks through `xspeed_pro_licensed`, the
758 * same filter Pro already answers for the descriptor. With no Pro plugin
759 * present nothing hooks it, the default `true` stands, and Free modules
760 * are unaffected either way.
761 *
762 * Reads stay open — the dashboard must still be able to GET settings to
763 * render the locked state at all.
764 *
765 * @param array $input Proposed setting values.
766 * @return array|null Current public settings when refused, else null.
767 */
768 private function license_write_refusal( array $input ): ?array {
769 if ( ! $this->is_license_locked() ) {
770 return null;
771 }
772
773 Activity_Log::record(
774 'license_write_refused',
775 sprintf(
776 /* translators: %s: module slug. */
777 __( 'Refused a settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
778 static::SLUG
779 ),
780 Activity_Log::WARN
781 );
782
783 // Return the unchanged public settings rather than throwing: callers
784 // expect the module's settings back, and the dashboard already renders
785 // this module as locked. REST surfaces the refusal explicitly in
786 // rest_update_settings(), which has a WP_Error channel.
787 return Settings_Manager::get_public( static::SLUG );
788 }
789
790 final public function slug(): string {
791 return static::SLUG;
792 }
793
794 final public function tier(): string {
795 return static::TIER;
796 }
797
798 final public function version(): string {
799 return static::VERSION;
800 }
801 }
802