| check_scope | * | e.g. GET /orders | | - check_api_key | * +--------------------+ | - has_scope(orders.r) | * ^ +-----+--------+---------+ * | | | * | cache hit| | cache miss * | v v * | +-------+--+ +--+----------------+ * | | return T | | fetch_from_backend| * | +----------+ | /scopes | * | +--+----------------+ * | | * | X-GC-Scope-Version header | * +---------------------------------+ * (next response triggers * async refresh on mismatch) * ``` * * **Feature-flag**: `genius_commerce_scope_enforcement_enabled` (option). When `'no'` * (the default after install — beslissing D), `check_scope` short-circuits * to `true` so the cutover from "all scopes" → "config-derived scopes" * can be staged without breaking the tenant's plugin install. * * **Degraded mode**: `/scopes` unreachable + cache `updated_at < 1h` → * return cache (logged warning). Cache empty or `> 1h` → return `null` so * the caller can respond `503 SCOPES_UNAVAILABLE` (beslissing C). * * @package GeniusCommerce */ namespace GeniusCommerce; use WP_Error; defined( 'ABSPATH' ) || exit; /** * GC_Scopes class. */ final class GC_Scopes { /** * Transient name that holds the cached scopes structure. * * Stored value shape: `[ 'version' => int, 'scopes' => string[], 'updated_at' => int ]`. */ public const TRANSIENT_KEY = 'genius_commerce_scopes_cache'; /** * Transient TTL in seconds. AC 2: a synchronous refresh runs when the * cache is empty or older than this. 5 min is short enough to pick up * config-changes promptly and long enough to keep the hot-path cheap. */ public const TTL_SECONDS = 300; // 5 min. /** * Maximum age (seconds) the cache may have when `/scopes` is unreachable * before we fail-closed instead of degrading. Wider than {@see TTL_SECONDS} * because TTL eviction is normal — fail-closed only kicks in when the * cache is genuinely stale (beslissing C, 1 uur). */ public const DEGRADED_MAX_AGE_SECONDS = 3600; // 1 hour. /** * Feature-flag option. When not equal to `'yes'`, all scope-checks pass. * Lets a tenant install Story 8.3 without immediately activating * enforcement, so the cutover can be verified in the UI first. */ public const FLAG_OPTION = 'genius_commerce_scope_enforcement_enabled'; /** * Action hook used by `wp_schedule_single_event` for the lazy refresh * triggered by the `X-GC-Scope-Version` header-piggyback. */ public const REFRESH_ACTION = 'genius_commerce_refresh_scopes'; /** * Endpoint on the backend (relative to the configured backend URL). * The backend_url stored in options already ends in * `/api/integrations/woocommerce` so this is just `/scopes`. */ public const SCOPES_ENDPOINT = '/scopes'; /** * Re-entrance guard for `has_scope` so a single failed request can * trigger at most one refresh-on-miss. * * @var bool */ private static bool $refresh_attempted = false; /** * Whether scope-enforcement is currently active. * * The flag is read via `get_option()` each time so toggling it from the * settings page takes effect on the next request. */ public static function is_enforcement_enabled(): bool { return 'yes' === get_option( self::FLAG_OPTION, 'no' ); } /** * Read the cached structure or `null` if not present / malformed. * * @return array{version: int, scopes: array, updated_at: int}|null */ public static function get_cached(): ?array { $value = get_transient( self::TRANSIENT_KEY ); if ( ! is_array( $value ) ) { return null; } if ( ! isset( $value['version'], $value['scopes'], $value['updated_at'] ) ) { return null; } if ( ! is_array( $value['scopes'] ) ) { return null; } return array( 'version' => (int) $value['version'], 'scopes' => array_values( array_map( 'strval', $value['scopes'] ) ), 'updated_at' => (int) $value['updated_at'], ); } /** * Persist scopes + version. Bumps `updated_at` to `time()` so degraded- * mode can decide whether the cache is still trustworthy. * * @param array $scopes Sorted list of scope strings. * @param int $version Backend-supplied scopeVersion. */ public static function set_cached( array $scopes, int $version ): void { set_transient( self::TRANSIENT_KEY, array( 'version' => $version, 'scopes' => array_values( array_map( 'strval', $scopes ) ), 'updated_at' => time(), ), self::TTL_SECONDS ); } /** * Compare a remote-version hint against the cached version. * * @param int $remote_version Version reported by the backend (header). * @return bool True when the cache should be refreshed. */ public static function is_stale_version( int $remote_version ): bool { $cached = self::get_cached(); if ( null === $cached ) { return true; } return $cached['version'] !== $remote_version; } /** * Fetch the canonical scope-set from the Genius backend. * * Returns the parsed body on success or `WP_Error` on transport failure. * `success: false` response bodies (HTTP non-2xx) translate into a * `WP_Error` with code `gc_scopes_fetch_failed`. * * @return array{version:int,scopes:array,generatedAt:string}|WP_Error */ public static function fetch_from_backend() { $client = new GC_API_Client(); $result = $client->request( 'GET', self::SCOPES_ENDPOINT ); if ( empty( $result['success'] ) ) { return new WP_Error( 'gc_scopes_fetch_failed', isset( $result['error'] ) ? (string) $result['error'] : 'Unknown error fetching scopes' ); } $data = $result['data'] ?? null; if ( ! is_array( $data ) || ! isset( $data['scopes'], $data['version'] ) ) { return new WP_Error( 'gc_scopes_malformed', 'Malformed scopes response' ); } return array( 'version' => (int) $data['version'], 'scopes' => is_array( $data['scopes'] ) ? array_values( array_map( 'strval', $data['scopes'] ) ) : array(), 'generatedAt' => isset( $data['generatedAt'] ) ? (string) $data['generatedAt'] : '', ); } /** * Refresh the cache when it's empty or its version differs from a hint. * * On fetch-failure with a non-empty cache aged < 1 hour, the existing * cache is left in place (degraded mode) and `null` is returned to * signal the caller "I didn't update". On failure with no cache or an * expired cache, `null` is returned and the caller decides whether to * fail-closed. * * @param int|null $remote_version_hint Optional version reported by the backend. * @return array{version:int,scopes:array,updated_at:int}|null */ public static function refresh_if_stale( ?int $remote_version_hint = null ): ?array { $cached = self::get_cached(); // Decide if we need to fetch at all. $needs_fetch = false; if ( null === $cached ) { $needs_fetch = true; } elseif ( null !== $remote_version_hint && $cached['version'] !== $remote_version_hint ) { $needs_fetch = true; } if ( ! $needs_fetch ) { return $cached; } $fetched = self::fetch_from_backend(); if ( $fetched instanceof WP_Error ) { Genius_Commerce::log( sprintf( 'Scopes endpoint unreachable: %s', $fetched->get_error_message() ), 'warning' ); return self::degraded_or_null( $cached ); } self::set_cached( $fetched['scopes'], $fetched['version'] ); return self::get_cached(); } /** * Force-refresh the cache (admin "Nu verversen"-knop). * * Unlike {@see refresh_if_stale} this always hits the backend and * returns either the new payload or a `WP_Error` for UI feedback. * * @return array{version:int,scopes:array,generatedAt:string}|WP_Error */ public static function force_refresh() { $fetched = self::fetch_from_backend(); if ( $fetched instanceof WP_Error ) { Genius_Commerce::log( sprintf( 'Force-refresh scopes failed: %s', $fetched->get_error_message() ), 'warning' ); return $fetched; } self::set_cached( $fetched['scopes'], $fetched['version'] ); return $fetched; } /** * Decide whether a known-stale cache may still be served. * * @param array{version:int,scopes:array,updated_at:int}|null $cached * @return array{version:int,scopes:array,updated_at:int}|null */ private static function degraded_or_null( ?array $cached ): ?array { if ( null === $cached ) { return null; } $age = time() - $cached['updated_at']; if ( $age > self::DEGRADED_MAX_AGE_SECONDS ) { Genius_Commerce::log( sprintf( 'Scopes cache expired (age %ds > %ds), failing closed', $age, self::DEGRADED_MAX_AGE_SECONDS ), 'warning' ); return null; } // Caller may serve the existing cache for now. return $cached; } /** * Check whether a single scope is present in the cached set. * * Attempts at most one synchronous refresh before returning `false`. * Composite-scope checks happen in `check_scope`, not here. * * @param string $scope The scope string, e.g. `orders.read`. */ public static function has_scope( string $scope ): bool { $cached = self::get_cached(); if ( null !== $cached && in_array( $scope, $cached['scopes'], true ) ) { return true; } // Only one refresh-on-miss attempt per request — avoids hammering // the backend when several scope checks fail in a row. if ( self::$refresh_attempted ) { return false; } self::$refresh_attempted = true; $refreshed = self::refresh_if_stale(); if ( null === $refreshed ) { return false; } return in_array( $scope, $refreshed['scopes'], true ); } /** * Test-only: reset the in-process refresh guard. * * Used by the CLI smoke-script to simulate independent requests in one * PHP process. Not invoked by production code. */ public static function reset_refresh_guard_for_tests(): void { self::$refresh_attempted = false; } }