> */ function aafm_get_abilities_registry(): array { static $cache = null; static $consumed = 0; $generation = aafm_registry_flush_generation(); if ( $generation > $consumed ) { $consumed = $generation; $cache = null; } if ( null !== $cache ) { return $cache; } /** * Filters the static ability registry. * * @param array> $registry Registry keyed by ability name. */ $cache = (array) apply_filters( 'aafm_abilities_registry', array() ); return $cache; } /** * The full catalog of available abilities, INCLUDING integration rows whose host plugin is inactive. * * The live aafm_get_abilities_registry() is host-gated: an integration contributes its rows only * while its host plugin is active, so on a site without WooCommerce the live registry has no * WC rows. That gating is correct for registration - actual wp_register_ability() must only fire for * active hosts - but the catalog still "counts" inactive integrations, and the Integrations tab and * the manifest need every integration's label/description even when its host is off. * * This view answers that need WITHOUT touching the registration path. It starts from the live * registry, then overlays every integration's definitions from the unguarded * 'aafm_abilities_registry_integrations' filter - each integration file contributes its rows there * with NO host guard. For an active host the overlay is identical to what the live registry already * holds; for an inactive host it adds the rows the guard withheld. The registration walk * (aafm_register_enabled_abilities()) reads aafm_get_abilities_registry(), never this, so an inactive * host still exposes zero tools. * * Memoized per request and flushed by the same aafm_flush_registry_cache() flag as the live registry, * so host-toggling tests stay consistent across both views. * * @return array> */ function aafm_get_abilities_registry_full(): array { static $cache = null; if ( aafm_registry_cache_should_flush_full() ) { $cache = null; } if ( null !== $cache ) { return $cache; } $registry = aafm_get_abilities_registry(); /** * Filters the guard-independent integration definitions overlaid onto the full registry view. * * Integration files contribute their rows here UNCONDITIONALLY (no host-active guard), so the * full view enumerates every integration ability even when its host plugin is inactive. * * @param array> $integrations Integration rows keyed by ability name. */ $integrations = (array) apply_filters( 'aafm_abilities_registry_integrations', array() ); $cache = array_merge( $registry, $integrations ); return $cache; } /** * The canonical label for an ability, taken from its registry definition. * * The registry entry is the single source of truth for an ability's label, so the args builder, the * Integrations manifest, and the admin UI all read it back through this rather than re-typing it. The * underlying __() literal stays at the registry-definition site, so this returns the already-translated * string and Plugin Check's i18n scanner still sees a real literal at the source. Reads the full view * so a derive call works even for an integration whose host is currently inactive. * * @param string $name Ability name, e.g. 'aafm/get-posts'. * @return string The canonical label, or '' when the name is unknown. */ function aafm_ability_label( string $name ): string { $registry = aafm_get_abilities_registry_full(); return isset( $registry[ $name ]['label'] ) ? (string) $registry[ $name ]['label'] : ''; } /** * The canonical description for an ability, taken from its registry definition. * * The counterpart to aafm_ability_label(): the registry description is authoritative, and the args * builder and manifest derive from it instead of keeping their own copy. The __() literal stays at the * registry site (so i18n extraction and Plugin Check are unaffected); this returns the translated * string. Reads the full view so it resolves for an inactive-host integration too. * * @param string $name Ability name, e.g. 'aafm/get-posts'. * @return string The canonical description, or '' when the name is unknown. */ function aafm_ability_description( string $name ): string { $registry = aafm_get_abilities_registry_full(); return isset( $registry[ $name ]['description'] ) ? (string) $registry[ $name ]['description'] : ''; } /** * Whether the full-view memo should be flushed on the next read, consuming the flag. * * Driven off the same flush flag as the live registry: a single aafm_flush_registry_cache() raises a * shared counter, and each view consumes it once per raise so both rebuild together. Without this the * full view could serve stale rows after a test toggles a host filter and flushes. * * @return bool True when aafm_get_abilities_registry_full() should rebuild its memo. */ function aafm_registry_cache_should_flush_full(): bool { static $consumed = 0; $generation = aafm_registry_flush_generation(); if ( $generation > $consumed ) { $consumed = $generation; return true; } return false; } /** * The current registry flush generation, a monotonic counter raised on every flush. * * Each memoized view (live registry and full view) keeps its own "last consumed" generation and * rebuilds when this counter has advanced past it, so a single flush invalidates both views once. * * @param bool $raise Internal: true to advance the generation by one. * @return int The current generation. */ function aafm_registry_flush_generation( bool $raise = false ): int { static $generation = 0; if ( $raise ) { ++$generation; } return $generation; } /** * Flush the in-request registry memo so the next read rebuilds it. * * @return void */ function aafm_flush_registry_cache(): void { aafm_registry_flush_generation( true ); } /** * The option storing the operator's enabled-ability allow-list. * * @return array */ function aafm_get_enabled_abilities(): array { $normalize = static function ( $value ): array { return is_array( $value ) ? array_values( array_filter( array_map( 'strval', $value ) ) ) : array(); }; $stored = $normalize( get_option( 'aafm_enabled_abilities', array() ) ); // A cache copy that disagrees with the row enables only what both hold; an unreadable row // enables nothing. $row = aafm_policy_row_if_stale( 'aafm_enabled_abilities' ); if ( null !== $row ) { $stored = $row['ok'] ? array_values( array_intersect( $stored, $normalize( $row['value'] ) ) ) : array(); } // Only honor keys that still exist in the registry (stale keys never enable anything). $registry = aafm_get_abilities_registry(); $enabled = array_values( array_intersect( $stored, array_keys( $registry ) ) ); // The high-risk floor. Locked abilities are removed from the REGISTERED set only; the stored // option keeps the operator's choice untouched, so unlocking the category restores exactly what // was ticked before rather than presenting a blank slate. This is the security boundary for // includes/audit/high-risk.php: the admin lock icon is presentation, this is the thing that // keeps a locked ability out of tools/list entirely. // // The lock state and the high-risk set are read once for the whole list rather than per ability // via aafm_ability_is_locked(): that helper re-runs get_option() and both filters on every call, // and this walks the operator's full enabled list on any request that needs the registered set. if ( ! aafm_high_risk_unlocked() ) { $enabled = array_values( array_diff( $enabled, aafm_high_risk_abilities() ) ); } // The read-only floor, the second subtraction. Independent of the one above and applied after // it, so unlocking the high-risk category can never put a write back while read-only mode is // on. Same shape as the high-risk floor in every other respect: the stored option is untouched, // so switching the mode off restores exactly what was ticked. // // The mode is read once for the whole list, and risk comes off the registry rows already in // hand rather than through aafm_ability_is_read() per name, which would re-fetch the full // registry view on every call. Anything the registry does not classify as a read is dropped - // fail closed, the same rule aafm_ability_is_read() applies for the admin screens. if ( ! aafm_read_only_mode() ) { return $enabled; } $reads = array(); foreach ( $enabled as $name ) { if ( 'read' === (string) ( $registry[ $name ]['risk'] ?? '' ) ) { $reads[] = $name; } } return $reads; } /** * Whether a specific ability is enabled by the operator. * * @param string $ability_name Ability name. * @return bool */ function aafm_is_ability_enabled( string $ability_name ): bool { return in_array( $ability_name, aafm_get_enabled_abilities(), true ); }