"` where `` matches the route * literally as registered in `register_rest_route()`. Variable segments use * the original regex named-group form (e.g. `(?P[\d]+)`). Method is one * of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. * * **Composite scopes**: an entry's value is an array of scopes that must * **all** be present in the plugin's cache (AND-semantics — beslissing E * uit story 8.3). * * **Admin-only routes** (`/debug`, `/system-info`) are deliberately absent * from the map. They remain protected by `check_api_key`-only and never * touch `check_scope` — see `Genius_Commerce::register_rest_routes()`. * * @package GeniusCommerce */ namespace GeniusCommerce; use WP_REST_Request; defined( 'ABSPATH' ) || exit; /** * GC_Scope_Map class. */ final class GC_Scope_Map { /** * Sentinel returned by {@see for_request()} for routes that are not in * the map. Callers MUST treat this as "deny" — never grant. */ public const UNKNOWN_SCOPE = '__UNKNOWN__'; /** * Return the full route→scope map. * * @return array> */ public static function get(): array { return array( // -------- Orders --------------------------------------------------- 'GET /orders' => array( 'orders.read' ), 'POST /orders' => array( 'orders.write' ), 'GET /orders/(?P[\d]+)' => array( 'orders.read' ), 'PUT /orders/(?P[\d]+)' => array( 'orders.write' ), 'DELETE /orders/(?P[\d]+)' => array( 'orders.write' ), 'POST /orders/batch' => array( 'orders.write' ), // -------- Customers ----------------------------------------------- 'GET /customers' => array( 'customers.read' ), 'POST /customers' => array( 'customers.write' ), 'GET /customers/(?P[\d]+)' => array( 'customers.read' ), 'PUT /customers/(?P[\d]+)' => array( 'customers.write' ), 'POST /customers/batch' => array( 'customers.write' ), // -------- Products ------------------------------------------------ 'GET /products' => array( 'products.read' ), 'POST /products' => array( 'products.write' ), 'GET /products/(?P[\d]+)' => array( 'products.read' ), 'PUT /products/(?P[\d]+)' => array( 'products.write' ), 'DELETE /products/(?P[\d]+)' => array( 'products.write' ), 'POST /products/batch' => array( 'products.write' ), 'POST /products/update' => array( 'products.write' ), 'GET /products/stock-levels' => array( 'products.read' ), 'POST /products/stock-levels' => array( 'products.read' ), // -------- Variations (nested under product) ----------------------- 'GET /products/(?P[\d]+)/variants' => array( 'variations.read' ), 'POST /products/(?P[\d]+)/variants' => array( 'variations.write' ), 'GET /products/(?P[\d]+)/variants/(?P[\d]+)' => array( 'variations.read' ), 'PUT /products/(?P[\d]+)/variants/(?P[\d]+)' => array( 'variations.write' ), 'DELETE /products/(?P[\d]+)/variants/(?P[\d]+)' => array( 'variations.write' ), 'POST /products/(?P[\d]+)/variants/batch' => array( 'variations.write' ), // -------- Brands -------------------------------------------------- 'GET /brands' => array( 'brands.read' ), 'POST /brands' => array( 'brands.write' ), 'GET /brands/(?P[\d]+)' => array( 'brands.read' ), 'PUT /brands/(?P[\d]+)' => array( 'brands.write' ), 'DELETE /brands/(?P[\d]+)' => array( 'brands.write' ), 'POST /brands/batch' => array( 'brands.write' ), // -------- Categories --------------------------------------------- 'GET /categories' => array( 'categories.read' ), 'POST /categories' => array( 'categories.write' ), 'GET /categories/(?P[\d]+)' => array( 'categories.read' ), 'PUT /categories/(?P[\d]+)' => array( 'categories.write' ), 'DELETE /categories/(?P[\d]+)' => array( 'categories.write' ), 'POST /categories/batch' => array( 'categories.write' ), // -------- Attributes (and their terms) --------------------------- 'GET /attributes' => array( 'attributes.read' ), 'POST /attributes' => array( 'attributes.write' ), 'GET /attributes/(?P[\d]+)' => array( 'attributes.read' ), 'PUT /attributes/(?P[\d]+)' => array( 'attributes.write' ), 'DELETE /attributes/(?P[\d]+)' => array( 'attributes.write' ), 'GET /attributes/(?P[\d]+)/terms' => array( 'attributes.read' ), 'POST /attributes/(?P[\d]+)/terms' => array( 'attributes.write' ), 'PUT /attributes/(?P[\d]+)/terms/(?P[\d]+)' => array( 'attributes.write' ), 'DELETE /attributes/(?P[\d]+)/terms/(?P[\d]+)' => array( 'attributes.write' ), 'POST /attributes/(?P[\d]+)/terms/batch' => array( 'attributes.write' ), // -------- Taxonomies --------------------------------------------- 'GET /taxonomies' => array( 'taxonomies.read' ), 'GET /taxonomies/(?P[a-z0-9_-]+)/terms' => array( 'taxonomies.read' ), // -------- Invoices ----------------------------------------------- 'GET /invoices/status' => array( 'invoices.read' ), 'GET /invoices' => array( 'invoices.read' ), 'GET /invoices/(?P[\d]+)' => array( 'invoices.read' ), 'GET /invoices/(?P[\d]+)/pdf' => array( 'invoices.read' ), // -------- Mapping ------------------------------------------------ 'GET /mapping' => array( 'mapping.read' ), // -------- B2BKing ------------------------------------------------ 'GET /b2bking/status' => array( 'b2bking.read' ), 'GET /b2bking/groups' => array( 'b2bking.read' ), 'GET /b2bking/rules' => array( 'b2bking.read' ), 'GET /b2bking/customer-groups' => array( 'b2bking.read' ), // -------- Coupons (Story 2.1 - Project 36) ------------------------- // Push-only from Genius — no GET route exists for this resource. 'POST /coupons' => array( 'coupons.write' ), 'PUT /coupons/(?P[\d]+)' => array( 'coupons.write' ), 'DELETE /coupons/(?P[\d]+)' => array( 'coupons.write' ), ); } /** * Resolve a request to the scope(s) it requires. * * The plugin's own namespace prefix (`/genius-commerce/v1`) is stripped * before lookup so the map stays readable. Unknown routes return a * one-element array with {@see UNKNOWN_SCOPE} — caller must deny. * * @param WP_REST_Request $request The request being authorized. * @return array Scopes required (AND), or `[UNKNOWN_SCOPE]`. */ public static function for_request( WP_REST_Request $request ): array { $method = strtoupper( (string) $request->get_method() ); $route = (string) $request->get_route(); // Strip the WP-REST namespace + plugin prefix so map keys stay short. $namespace_prefix = '/genius-commerce/v1'; if ( strpos( $route, $namespace_prefix ) === 0 ) { $route = substr( $route, strlen( $namespace_prefix ) ); } $needle = $method . ' ' . $route; $map = self::get(); if ( isset( $map[ $needle ] ) ) { return $map[ $needle ]; } // Best-effort match: try replacing concrete `\d+` segments with the // map's regex placeholders. We iterate map keys and compare against // a regex built from the key. This handles requests where WP-REST // resolves variables and `get_route()` returns the templated form. foreach ( $map as $pattern => $scopes ) { if ( strncmp( $pattern, $method . ' ', strlen( $method ) + 1 ) !== 0 ) { continue; } $path_pattern = substr( $pattern, strlen( $method ) + 1 ); // Translate `(?Pregex)` to a generic `[^/]+` to compare // against a real (resolved) path like `/orders/123`. $compare_regex = '#^' . preg_replace( '#\(\?P<[^>]+>[^)]+\)#', '[^/]+', preg_quote( $path_pattern, '#' ) ) . '$#'; // preg_quote escaped the regex placeholders; un-escape them. $compare_regex = str_replace( array( '\(', '\)', '\?', '\<', '\>', '\\\\d', '\\\\+' ), array( '(', ')', '?', '<', '>', '\\d', '\\+' ), $compare_regex ); if ( preg_match( $compare_regex, $route ) === 1 ) { return $scopes; } } return array( self::UNKNOWN_SCOPE ); } }