HEX
Server: Apache/2.4.68 (Debian)
System: Linux as-cs-widget-demo-us-central1 6.1.0-44-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.164-1 (2026-03-09) x86_64
User: root (0)
PHP: 8.2.32
Disabled: NONE
Upload Files
File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/membership.php
<?php
// includes/membership.php
//
// First-party membership-session bridge.
//
// Bridges the existing Allspice account + entitlement system into a signed, HttpOnly,
// first-party cookie that PHP can read before content and ad scripts initialize. It creates
// no WordPress users, no login system, and no membership tables; the single source of truth
// stays GET /me/entitlements on the Allspice Public API, called server-to-server with the
// reader's own bearer token. Nothing user-specific is ever persisted in WordPress - the
// cookie in the reader's browser is the only state, and it holds no identity.
//
// Flow:
//   Allspice ID token (page JS, existing auth discovery)
//     -> POST /wp-json/allspice/v1/member-session   (same origin)
//     -> plugin forwards Authorization + X-Allspice-Ecosystem to /me/entitlements
//     -> entitlements validated against the synced memberships.products (never against
//        anything the browser claims)
//     -> signed cookie set; PHP helpers below read it on later requests.

if (!defined('ABSPATH')) exit;

const ALLSPICE_MEMBER_COOKIE = 'allspice_member_session';
/*
 * TTL policy: 24 hours. Long enough that a returning daily reader is never rendered as
 * anonymous by PHP before JS can refresh (the 6h original did exactly that); short enough
 * that revocation propagates within a day even if the reader never runs JS again. The page
 * bundle refreshes proactively when less than a quarter of the TTL remains, so an active
 * reader's cookie in practice never expires. Revocation channels, fastest first: explicit
 * logout (immediate DELETE, mandatory), entitlement refresh events (minutes), config_version
 * rotation (invalidates every cookie at once), TTL (worst case, 24h).
 */
const ALLSPICE_MEMBER_COOKIE_TTL = DAY_IN_SECONDS;
const ALLSPICE_MEMBER_SCHEMA_VERSION = 1;

/* ---------------------------------------------------------------- Public API base (req 16) */

function allspice_public_api_base(): string {
    $base = defined('ALLSPICE_PUBLIC_API_BASE')
        ? (string)ALLSPICE_PUBLIC_API_BASE
        : ((defined('ALLSPICE_ENV') && ALLSPICE_ENV === 'dev')
            ? 'https://api-dev.allspicelabs.com/v1'
            : 'https://api.allspicelabs.com/v1');
    $base = rtrim($base, '/');
    /* Same override pattern as allspice_webhook_base(): dev/prod (or staging) can differ. */
    return apply_filters('allspice_public_api_base', $base);
}

/* ------------------------------------------------------------------- Memberships config */

/* Synced memberships config (from theme_policies_cache - NOT a new option). */
function allspice_memberships_config(): array {
    if (!function_exists('allspice_theme_policies_cache_get')) return [];
    $tp = allspice_theme_policies_cache_get();
    $m = $tp['memberships'] ?? [];
    return is_array($m) ? $m : [];
}

function allspice_memberships_config_version(): string {
    $m = allspice_memberships_config();
    return isset($m['config_version']) ? (string)$m['config_version'] : '';
}

/* ------------------------------------------------- Normalized model (schema v2 + legacy) */

/*
 * THE one PHP normalizer (its JS twin is normalizeMemberships in the widget's
 * membershipPlan.js - keep the rules in step). Accepts both schemas:
 *
 *   NEW (authoritative whenever site_membership_id is a nonempty string):
 *     config_version, new_signups_enabled, site_membership_id,
 *     products.included.{mealPlans,recipes,articles,pages,premiumPico[,adFree]},
 *     products.custom_benefits[], gate_ui.{recipe_card,content_preview,content_immediate,
 *     join_label,login_label}, gate_style, lock_badges
 *
 *   OLD (temporary migration support): enabled/ready, products as an ARRAY of
 *     {id|product_id, benefits: string codes}, flat gate_ui title/description or gates[].
 *
 * Output shape (identical for both):
 *   configured, new_signups_enabled, site_membership_id, config_version, legacy,
 *   benefits[]: {id, title, description|null, custom},
 *   gate_ui: {recipe_card:{title,description}, content_preview:{...}, content_immediate:{...},
 *             join_label, login_label, closed_signups_message},
 *   gate_style: sanitized subset, lock_badges: {enabled,label,background_color|null}.
 *
 * Never mutates the sync payload; unknown/malformed values are dropped, not fatal.
 */
const ALLSPICE_BUILTIN_BENEFITS = [
    /* Deterministic built-in order; custom benefits always append after these. */
    'mealPlans' => 'Meal plans',
    'recipes' => 'Members-only recipes',
    'articles' => 'Exclusive articles',
    'pages' => 'Member resources',
    'premiumPico' => 'Premium Pico',
    'adFree' => 'Ad-free browsing',
];

function allspice_membership_clean_text($v): string {
    return is_string($v) ? trim($v) : '';
}

function allspice_memberships_normalize_model(?array $m = null): array {
    if ($m === null) $m = allspice_memberships_config();
    $site_id = allspice_membership_clean_text($m['site_membership_id'] ?? '');
    $is_v2 = $site_id !== '';
    $legacy_configured = ($m['enabled'] ?? null) === true && ($m['ready'] ?? null) === true;

    $benefits = [];
    $seen = [];
    if ($is_v2) {
        $products = isset($m['products']) && is_array($m['products']) ? $m['products'] : [];
        $included = isset($products['included']) && is_array($products['included']) ? $products['included'] : [];
        foreach (ALLSPICE_BUILTIN_BENEFITS as $key => $fallback_title) {
            $entry = isset($included[$key]) && is_array($included[$key]) ? $included[$key] : null;
            if ($entry === null || ($entry['enabled'] ?? null) !== true) continue; /* exactly true */
            if (isset($seen[$key])) continue;
            $seen[$key] = true;
            $title = allspice_membership_clean_text($entry['title'] ?? '');
            $desc = allspice_membership_clean_text($entry['description'] ?? '');
            $benefits[] = [
                'id' => $key,
                'title' => $title !== '' ? $title : $fallback_title,
                'description' => $desc !== '' ? $desc : null,
                'custom' => false,
            ];
        }
        $custom = isset($products['custom_benefits']) && is_array($products['custom_benefits']) ? $products['custom_benefits'] : [];
        foreach ($custom as $c) {
            if (!is_array($c)) continue;
            $id = allspice_membership_clean_text($c['id'] ?? '');
            $title = allspice_membership_clean_text($c['title'] ?? '');
            if ($id === '' || $title === '' || isset($seen[$id])) continue; /* dedupe by id */
            $seen[$id] = true;
            $desc = allspice_membership_clean_text($c['description'] ?? '');
            $benefits[] = ['id' => $id, 'title' => $title, 'description' => $desc !== '' ? $desc : null, 'custom' => true];
        }
    } elseif ($legacy_configured) {
        /* Old schema: benefit string codes on the products array, labeled via the fallback map. */
        $products = isset($m['products']) && is_array($m['products']) ? $m['products'] : [];
        foreach ($products as $p) {
            if (!is_array($p) || !isset($p['benefits']) || !is_array($p['benefits'])) continue;
            foreach ($p['benefits'] as $code) {
                $code = allspice_membership_clean_text($code);
                if ($code === '' || isset($seen[$code])) continue;
                $seen[$code] = true;
                $benefits[] = [
                    'id' => $code,
                    'title' => function_exists('allspice_membership_benefit_label')
                        ? allspice_membership_benefit_label($code)
                        : ucwords(str_replace(['_', '-'], ' ', $code)),
                    'description' => null,
                    'custom' => false,
                ];
            }
        }
    }

    /* gate_ui: v2 nested per-type objects; legacy flat title/description and/or gates[]
       matched by gate_type. Output is ALWAYS the nested shape so consumers stay schema-blind. */
    $raw_ui = isset($m['gate_ui']) && is_array($m['gate_ui']) ? $m['gate_ui'] : [];
    $type_copy = static function (string $type) use ($raw_ui): array {
        $out = ['title' => '', 'description' => ''];
        if (isset($raw_ui[$type]) && is_array($raw_ui[$type])) {
            $out['title'] = allspice_membership_clean_text($raw_ui[$type]['title'] ?? '');
            $out['description'] = allspice_membership_clean_text($raw_ui[$type]['description'] ?? $raw_ui[$type]['copy'] ?? '');
        }
        if ($out['title'] === '' || $out['description'] === '') {
            /* Legacy gates[]: matched strictly by gate_type - NEVER gates[0]. */
            if (isset($raw_ui['gates']) && is_array($raw_ui['gates'])) {
                foreach ($raw_ui['gates'] as $g) {
                    if (!is_array($g) || ($g['gate_type'] ?? '') !== $type) continue;
                    if ($out['title'] === '') $out['title'] = allspice_membership_clean_text($g['title'] ?? '');
                    if ($out['description'] === '') $out['description'] = allspice_membership_clean_text($g['copy'] ?? $g['description'] ?? '');
                    break;
                }
            }
        }
        if ($out['title'] === '') $out['title'] = allspice_membership_clean_text($raw_ui['title'] ?? '');
        if ($out['description'] === '') $out['description'] = allspice_membership_clean_text($raw_ui['copy'] ?? $raw_ui['description'] ?? '');
        return $out;
    };
    $gate_ui = [
        'recipe_card' => $type_copy('recipe_card'),
        'content_preview' => $type_copy('content_preview'),
        'content_immediate' => $type_copy('content_immediate'),
        'join_label' => allspice_membership_clean_text($raw_ui['join_label'] ?? ''),
        'login_label' => allspice_membership_clean_text($raw_ui['login_label'] ?? ''),
        'closed_signups_message' => allspice_membership_clean_text($raw_ui['closed_signups_message'] ?? ''),
    ];

    $raw_style = isset($m['gate_style']) && is_array($m['gate_style']) ? $m['gate_style'] : [];
    $gate_style = [
        'background_color' => allspice_sanitize_css_color($raw_style['background_color'] ?? null),
        'text_color' => allspice_sanitize_css_color($raw_style['text_color'] ?? null),
        'heading_color' => allspice_sanitize_css_color($raw_style['heading_color'] ?? null),
        'border_color' => allspice_sanitize_css_color($raw_style['border_color'] ?? null),
        'border_width' => allspice_sanitize_clamped_int($raw_style['border_width'] ?? null, 0, 10),
        'corner_radius' => allspice_sanitize_clamped_int($raw_style['corner_radius'] ?? null, 0, 50),
        'button_background_color' => allspice_sanitize_css_color($raw_style['button_background_color'] ?? null),
        'button_text_color' => allspice_sanitize_css_color($raw_style['button_text_color'] ?? null),
        'font_family' => allspice_sanitize_gate_font_family($raw_style['font_family'] ?? null),
    ];

    $raw_badges = isset($m['lock_badges']) && is_array($m['lock_badges']) ? $m['lock_badges'] : [];
    $badge_label = allspice_membership_clean_text($raw_badges['label'] ?? '');
    $lock_badges = [
        'enabled' => ($raw_badges['enabled'] ?? null) === true, /* exactly true */
        'label' => $badge_label !== '' ? $badge_label : 'Members',
        'background_color' => allspice_sanitize_css_color($raw_badges['background_color'] ?? null),
    ];

    return [
        'configured' => $is_v2 || $legacy_configured,
        'legacy' => !$is_v2 && $legacy_configured,
        'new_signups_enabled' => $is_v2 ? (($m['new_signups_enabled'] ?? true) !== false) : true,
        'site_membership_id' => $site_id,
        'config_version' => isset($m['config_version']) ? (string)$m['config_version'] : '',
        'benefits' => $benefits,
        'gate_ui' => $gate_ui,
        'gate_style' => $gate_style,
        'lock_badges' => $lock_badges,
    ];
}

/* Request-cached normalized model over the live synced config. */
function allspice_memberships_normalized(): array {
    if (isset($GLOBALS['allspice_memberships_normalized_cache']) && is_array($GLOBALS['allspice_memberships_normalized_cache'])) {
        return $GLOBALS['allspice_memberships_normalized_cache'];
    }
    $n = allspice_memberships_normalize_model();
    $GLOBALS['allspice_memberships_normalized_cache'] = $n;
    return $n;
}
function allspice_memberships_normalized_reset(): void {
    $GLOBALS['allspice_memberships_normalized_cache'] = null;
}

/* "The membership program is configured" - a valid site_membership_id (v2) or the legacy
   enabled+ready pair. new_signups_enabled plays no part here. */
function allspice_memberships_configured(): bool {
    $n = allspice_memberships_normalized();
    return $n['configured'] === true;
}

function allspice_memberships_new_signups_enabled(): bool {
    $n = allspice_memberships_normalized();
    return $n['new_signups_enabled'] === true;
}

/* -------------------------------------------------------------- gate_style sanitizers */

/* Strict CSS color: #RGB/#RGBA/#RRGGBB/#RRGGBBAA, or rgb()/rgba() with plain numbers.
   Anything else (var(), url(), gradients, expressions, keywords) is rejected -> null. */
function allspice_sanitize_css_color($v) {
    if (!is_string($v)) return null;
    $v = trim($v);
    if ($v === '') return null;
    if (preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $v)) return strtolower($v);
    if (preg_match('/^rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)$/i', $v)) return strtolower(preg_replace('/\s+/', '', $v));
    if (preg_match('/^rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(?:0|1|0?\.\d+)\s*\)$/i', $v)) return strtolower(preg_replace('/\s+/', '', $v));
    return null;
}

function allspice_sanitize_clamped_int($v, int $min, int $max) {
    if (!is_numeric($v)) return null;
    $n = (int)round((float)$v);
    if ($n < $min) $n = $min;
    if ($n > $max) $n = $max;
    return $n;
}

/* Initial allow-list only - no arbitrary font stacks into CSS. */
function allspice_sanitize_gate_font_family($v) {
    if (!is_string($v)) return null;
    $v = strtolower(trim($v));
    return in_array($v, ['inherit', 'serif', 'sans-serif', 'monospace'], true) ? $v : null;
}

/* ----------------------------------------------------------- benefits list (checkmarks) */

/*
 * Shared benefits <ul> for gates, the landing shortcode, and the CTA block. $limit 0 = all
 * (landing surfaces); gates pass the filtered compact limit. Every string is escaped; no
 * shortcode execution; returns '' (no empty container) when there are no enabled benefits.
 */
function allspice_membership_benefits_limit(): int {
    $limit = (int)apply_filters('allspice_gate_benefits_limit', 5);
    return $limit < 0 ? 0 : $limit;
}

function allspice_membership_benefits_html(int $limit = 0): string {
    $n = allspice_memberships_normalized();
    $benefits = $n['benefits'];
    if ($benefits === []) return '';
    if ($limit > 0) $benefits = array_slice($benefits, 0, $limit);
    /* 16px with a stroke over the fill: the bare 14px fill path read small and thin against
       publisher themes' larger body type (live finding, baking4happiness 2026-08-26). Same
       treatment as the widget's membership dialog checks. */
    $check = '<span class="allspice-membership-benefits__check" aria-hidden="true">'
        . '<svg viewBox="0 0 24 24" width="16" height="16" focusable="false" aria-hidden="true">'
        . '<path fill="currentColor" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" d="M9.55 17.05 4.5 12l1.4-1.4 3.65 3.64 8.15-8.14 1.4 1.4z"/></svg>'
        . '</span>';
    $html = '<ul class="allspice-membership-benefits">';
    foreach ($benefits as $b) {
        $html .= '<li>' . $check . '<span class="allspice-membership-benefits__text">'
            . '<strong>' . esc_html((string)$b['title']) . '</strong>';
        if (isset($b['description']) && is_string($b['description']) && $b['description'] !== '') {
            $html .= '<span class="allspice-membership-benefits__desc">' . esc_html($b['description']) . '</span>';
        }
        $html .= '</span></li>';
    }
    return $html . '</ul>';
}

/* ------------------------------------------------------ shared gate copy + action pieces */

/*
 * Gate copy strictly by gate type (recipe_card | content_preview | content_immediate) from
 * the normalized model - never gates[0], never shared across types. Per-type fallbacks are
 * the schema-v2 contract wording.
 */
function allspice_membership_gate_copy(string $gate_type): array {
    $n = allspice_memberships_normalized();
    $fallback_title = [
        'recipe_card' => 'Unlock this recipe',
        'content_preview' => 'Continue reading as a member',
        'content_immediate' => 'This content is for members',
    ];
    $fallback_copy = [
        'recipe_card' => 'Join to view the full recipe.',
        'content_preview' => 'Join for exclusive articles and recipes.',
        'content_immediate' => 'Become a member to unlock it.',
    ];
    $c = isset($n['gate_ui'][$gate_type]) && is_array($n['gate_ui'][$gate_type])
        ? $n['gate_ui'][$gate_type] : ['title' => '', 'description' => ''];
    return [
        'title' => $c['title'] !== '' ? $c['title'] : ($fallback_title[$gate_type] ?? 'This content is for members'),
        'copy' => $c['description'] !== '' ? $c['description'] : ($fallback_copy[$gate_type] ?? 'Join to unlock this content.'),
        'join_label' => $n['gate_ui']['join_label'] !== '' ? $n['gate_ui']['join_label'] : 'Become a member',
        'login_label' => $n['gate_ui']['login_label'] !== '' ? $n['gate_ui']['login_label'] : 'Already a member? Log in',
    ];
}

function allspice_membership_closed_signups_message(): string {
    $n = allspice_memberships_normalized();
    $msg = $n['gate_ui']['closed_signups_message'];
    if ($msg === '') $msg = 'Membership is not currently accepting new signups.';
    return (string)apply_filters('allspice_membership_closed_signups_message', $msg);
}

/*
 * Shared Join/Login action row. new_signups_enabled === false swaps the ACTIVE Join button
 * for the closed-signups message - Login always stays (existing members keep full access,
 * management and portal; only NEW purchases pause).
 */
function allspice_membership_gate_actions_html(string $join_label, string $login_label): string {
    $html = '<div class="allspice-recipe-gate__actions">';
    if (allspice_memberships_new_signups_enabled()) {
        $html .= '<a class="allspice-recipe-gate__join" href="#allspice-membership" data-allspice-action="open_membership" role="button">'
            . esc_html($join_label) . '</a>';
    } else {
        $html .= '<p class="allspice-recipe-gate__closed">' . esc_html(allspice_membership_closed_signups_message()) . '</p>';
    }
    $html .= '<a class="allspice-recipe-gate__login" href="#allspice-login" data-allspice-action="open_login" role="button">'
        . esc_html($login_label) . '</a>'
        . '</div>';
    return $html;
}

/* -------------------------------------------------- entitlement -> canonical relationship */

/*
 * STRICT relationship reader (Part 3 of the schema-v2 contract): an entitlement maps to the
 * canonical site membership only through a real field,
 *   1. an explicit siteMembershipId / site_membership_id / canonicalProductId equal to the
 *      configured site_membership_id;
 *   2. the entitlement's own product id EQUAL to site_membership_id (issued directly on the
 *      canonical id);
 *   3. the legacy synced alias map (old schema's monthly/annual product_id declarations).
 * NEVER by display name, price interval, array order, or absent metadata. When nothing
 * matches, the entitlement grants nothing here (and the caller's telemetry counts it).
 */
function allspice_membership_entitlement_canonical(array $e) {
    $n = allspice_memberships_normalized();
    $pid = trim((string)($e['productId'] ?? $e['productID'] ?? $e['product_id'] ?? ''));
    if ($n['site_membership_id'] !== '') {
        $rel = trim((string)($e['siteMembershipId'] ?? $e['site_membership_id'] ?? $e['canonicalProductId'] ?? $e['canonical_product_id'] ?? ''));
        if ($rel !== '' && $rel === $n['site_membership_id']) return $n['site_membership_id'];
        if ($pid !== '' && $pid === $n['site_membership_id']) return $n['site_membership_id'];
    }
    if ($pid !== '') {
        $map = allspice_memberships_product_map();
        if (isset($map[$pid])) return $map[$pid]['canonical'];
    }
    return null;
}

/*
 * CANONICAL PRODUCT CONTRACT (single rule shared by PHP, page JS, and the widget):
 * the synced config's product entry id IS the canonical id. Monthly/annual Stripe SALE
 * products (real dev shapes: one `prod_XXXX` per interval) are aliases declared on the entry
 * (monthly_product_id / annual_product_id / product_ids). Recipes gate on canonical ids, the
 * cookie stores canonical ids, and every entitlement product id is canonicalized (sale ->
 * canonical) BEFORE comparison. The map below is the only authority - browser-supplied ids
 * are never consulted.
 *
 * Returns: saleOrCanonicalId => ['canonical' => id, 'benefits' => string[]].
 */
function allspice_memberships_product_map(): array {
    $m = allspice_memberships_config();

    /* SCHEMA V2: site_membership_id IS the canonical id and products is a benefit-flag
       dictionary, not sale definitions. The map carries exactly the canonical id (benefit
       ids from the normalized model); sale-product relationships come only from the strict
       entitlement reader (allspice_membership_entitlement_canonical) - never invented here. */
    $site_id = allspice_membership_clean_text($m['site_membership_id'] ?? '');
    if ($site_id !== '') {
        $n = allspice_memberships_normalize_model($m);
        $benefit_ids = array_values(array_map(static function ($b) { return (string)$b['id']; }, $n['benefits']));
        return [$site_id => ['canonical' => $site_id, 'benefits' => $benefit_ids]];
    }

    $products = isset($m['products']) && is_array($m['products']) ? $m['products'] : [];
    $map = [];
    foreach ($products as $p) {
        if (!is_array($p)) continue;
        $canonical = trim((string)($p['id'] ?? $p['product_id'] ?? $p['productId'] ?? ''));
        if ($canonical === '') continue;
        $benefits = [];
        if (isset($p['benefits']) && is_array($p['benefits'])) {
            foreach ($p['benefits'] as $b) {
                $b = trim((string)$b);
                if ($b !== '') $benefits[] = $b;
            }
        }
        $benefits = array_values(array_unique($benefits));
        $sale_ids = [$canonical];
        foreach (['monthly_product_id', 'annual_product_id', 'monthlyProductId', 'annualProductId'] as $k) {
            $v = trim((string)($p[$k] ?? ''));
            if ($v !== '') $sale_ids[] = $v;
        }
        foreach (['product_ids', 'productIds', 'stripe_product_ids'] as $k) {
            if (isset($p[$k]) && is_array($p[$k])) {
                foreach ($p[$k] as $v) {
                    $v = trim((string)$v);
                    if ($v !== '') $sale_ids[] = $v;
                }
            }
        }
        foreach (array_unique($sale_ids) as $sid) {
            $map[$sid] = ['canonical' => $canonical, 'benefits' => $benefits];
        }
    }
    return $map;
}

/* Sale -> canonical, or null when the id is not a membership product at all. */
function allspice_membership_canonicalize(string $product_id) {
    $map = allspice_memberships_product_map();
    return isset($map[$product_id]) ? $map[$product_id]['canonical'] : null;
}

/* -------------------------------------------------------------------------- Cookie crypto */

/*
 * HMAC key derived from the WordPress salts (req 6). Two salts are mixed with a purpose
 * label so this key can never collide with anything else derived from the same salts.
 */
function allspice_member_cookie_key(): string {
    return hash('sha256', wp_salt('auth') . '|' . wp_salt('secure_auth') . '|allspice-member-session-v1');
}

function allspice_member_b64url_encode(string $bin): string {
    return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}

function allspice_member_b64url_decode(string $str) {
    $pad = strlen($str) % 4;
    if ($pad) $str .= str_repeat('=', 4 - $pad);
    return base64_decode(strtr($str, '-_', '+/'), true);
}

/* payload.signature - both parts base64url; signature is HMAC-SHA256 of the payload part. */
function allspice_member_cookie_encode(array $payload): string {
    $body = allspice_member_b64url_encode((string)wp_json_encode($payload));
    $sig = allspice_member_b64url_encode(hash_hmac('sha256', $body, allspice_member_cookie_key(), true));
    return $body . '.' . $sig;
}

function allspice_member_cookie_decode(string $raw) {
    $parts = explode('.', $raw, 2);
    if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') return null;
    $expected = allspice_member_b64url_encode(hash_hmac('sha256', $parts[0], allspice_member_cookie_key(), true));
    /* Constant-time comparison (req 7). */
    if (!hash_equals($expected, $parts[1])) return null;
    $json = allspice_member_b64url_decode($parts[0]);
    if (!is_string($json)) return null;
    $payload = json_decode($json, true);
    return is_array($payload) ? $payload : null;
}

function allspice_member_cookie_set(array $payload): void {
    $value = allspice_member_cookie_encode($payload);
    $expires = (int)($payload['exp'] ?? (time() + ALLSPICE_MEMBER_COOKIE_TTL));
    setcookie(ALLSPICE_MEMBER_COOKIE, $value, [
        'expires' => $expires,
        'path' => '/',
        'secure' => is_ssl(),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
}

function allspice_member_cookie_clear(): void {
    setcookie(ALLSPICE_MEMBER_COOKIE, '', [
        'expires' => time() - HOUR_IN_SECONDS,
        'path' => '/',
        'secure' => is_ssl(),
        'httponly' => true,
        'samesite' => 'Lax',
    ]);
}

/* ------------------------------------------------------------------ Validated read (req 10) */

/*
 * Parse + validate the cookie for the CURRENT request. Result is cached per PHP request
 * (req 14) and never persisted. Returns the payload array, or null when the cookie is
 * absent, tampered, expired, from other settings, or from a stale memberships config.
 */
function allspice_member_session_read() {
    static $cached = false;
    static $value = null;
    if ($cached) return $value;
    $cached = true;
    $value = null;

    $raw = isset($_COOKIE[ALLSPICE_MEMBER_COOKIE]) ? (string)$_COOKIE[ALLSPICE_MEMBER_COOKIE] : '';
    if ($raw === '' || strlen($raw) > 4096) return null;
    $p = allspice_member_cookie_decode($raw);
    if (!is_array($p)) return null;
    if ((int)($p['v'] ?? 0) !== ALLSPICE_MEMBER_SCHEMA_VERSION) return null;
    if ((int)($p['exp'] ?? 0) <= time()) return null;

    $s = function_exists('allspice_opt_get') ? allspice_opt_get() : [];
    if ((string)($p['pid'] ?? '') !== (string)($s['partner_id'] ?? '')) return null;
    if ((string)($p['did'] ?? '') !== (string)($s['domain_id'] ?? '')) return null;
    /* Config rotated (products/benefits may have changed) -> cookie is stale by definition. */
    if ((string)($p['cv'] ?? '') !== allspice_memberships_config_version()) return null;

    $value = $p;
    return $value;
}

/* ------------------------------------------------------------------------ Helpers (req 13) */

function allspice_get_member_access() {
    $p = allspice_member_session_read();
    if (!is_array($p)) return null;
    return [
        'products' => isset($p['prods']) && is_array($p['prods']) ? array_values($p['prods']) : [],
        'benefits' => isset($p['ben']) && is_array($p['ben']) ? array_values($p['ben']) : [],
        'config_version' => (string)($p['cv'] ?? ''),
        'issued_at' => (int)($p['iat'] ?? 0),
        'expires_at' => (int)($p['exp'] ?? 0),
    ];
}

function allspice_member_has_product(string $product_id): bool {
    $a = allspice_get_member_access();
    return $a !== null && in_array($product_id, $a['products'], true);
}

function allspice_member_has_any_product(array $product_ids): bool {
    $a = allspice_get_member_access();
    if ($a === null) return false;
    foreach ($product_ids as $id) {
        if (in_array((string)$id, $a['products'], true)) return true;
    }
    return false;
}

function allspice_member_has_benefit(string $benefit): bool {
    $a = allspice_get_member_access();
    return $a !== null && in_array($benefit, $a['benefits'], true);
}

/* --------------------------------------------------------------- Page-cache safety (item 7)
 *
 * CONTRACT: public (gated) responses stay fully cacheable - the gate placeholder is the same
 * for every anonymous reader. Any request CARRYING the member cookie bypasses shared page
 * caching entirely (cookie presence, not validity: a stale-but-present cookie
 * must never poison a shared cache with a member render either way).
 *
 * Host/CDN configuration this signals to (must ALSO be configured at those layers):
 *  - WordPress page-cache plugins (WP Rocket, W3TC, BigScoots/LiteSpeed): DONOTCACHEPAGE +
 *     nocache_headers() below are honored natively; additionally add `allspice_member_session`
 *     to the plugin's "never cache users with this cookie" list.
 *  - Cloudflare (incl. APO) / other CDNs: add a Cache Rule bypassing cache when the request
 *     Cookie header contains `allspice_member_session`.
 * Production gating must not be declared safe until this is verified against the actual
 * publisher caching stack - no such test has been run yet.
 */
add_action('template_redirect', 'allspice_member_page_cache_bypass', 0);
function allspice_member_page_cache_bypass(): void {
    if (empty($_COOKIE[ALLSPICE_MEMBER_COOKIE])) return;
    if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);
    if (!defined('DONOTCACHEOBJECT')) define('DONOTCACHEOBJECT', true);
    nocache_headers();
}

/*
 * WP Rocket, programmatically (first live-site finding, baking4happiness 2026-08-26).
 *
 * DONOTCACHEPAGE above only stops Rocket STORING a member render. Rocket SERVES its cached
 * files from advanced-cache before WordPress runs at all, so a member carrying the cookie
 * was still handed the anonymous cached HTML -- ads and gate placeholders included -- and
 * the manual "add the cookie in Rocket's settings" step the comment above prescribes had
 * predictably not happened. The reject list makes Rocket bypass its cache for any request
 * carrying the member cookie; registering it here removes the per-site manual step.
 *
 * The list is COMPILED into Rocket's config file, so the filter only takes effect after a
 * config regeneration. The admin_init hook performs that once (option-flagged); it also
 * clears Rocket's page cache that one time, since every cached page predates the rule.
 * Deliberately not version-gated to our plugin: if Rocket is installed later, the functions
 * appear, the flag is still unset, and the regeneration runs then.
 */
add_filter('rocket_cache_reject_cookies', 'allspice_member_rocket_reject_cookie');
function allspice_member_rocket_reject_cookie($cookies) {
    if (!is_array($cookies)) $cookies = [];
    $cookies[] = ALLSPICE_MEMBER_COOKIE;
    return array_values(array_unique($cookies));
}

add_action('admin_init', 'allspice_member_rocket_config_refresh');
function allspice_member_rocket_config_refresh(): void {
    if (!function_exists('rocket_generate_config_file')) return;
    if (get_option('allspice_member_rocket_cfg') === '1') return;
    update_option('allspice_member_rocket_cfg', '1', false);
    rocket_generate_config_file();
    if (function_exists('rocket_clean_domain')) rocket_clean_domain();
}

/* ------------------------------------------------------------------------- REST endpoints */

add_action('rest_api_init', 'allspice_member_session_register_routes');

function allspice_member_session_register_routes(): void {
    register_rest_route('allspice/v1', '/member-session', [
        [
            'methods' => 'POST',
            'callback' => 'allspice_member_session_rest_post',
            'permission_callback' => '__return_true', // auth = the forwarded bearer token
        ],
        [
            'methods' => 'GET',
            'callback' => 'allspice_member_session_rest_get',
            'permission_callback' => '__return_true', // returns a safe summary only
        ],
        [
            'methods' => 'DELETE',
            'callback' => 'allspice_member_session_rest_delete',
            'permission_callback' => '__return_true', // clearing a cookie is harmless
        ],
    ]);
}

/* Safe summary - never the cookie value, never a signature, never identity (req 11). */
function allspice_member_session_summary(): array {
    $a = allspice_get_member_access();
    if ($a === null) {
        return ['active' => false, 'config_version' => allspice_memberships_config_version()];
    }
    return [
        'active' => true,
        'products' => $a['products'],
        'benefits' => $a['benefits'],
        'config_version' => $a['config_version'],
        'expires_at' => $a['expires_at'],
    ];
}

/* Item 7: member-session responses are per-reader - never shared-cacheable. */
function allspice_member_session_no_store($data) {
    $response = rest_ensure_response($data);
    if ($response instanceof WP_REST_Response) {
        $response->header('Cache-Control', 'no-store, private');
    }
    return $response;
}

function allspice_member_session_rest_get() {
    return allspice_member_session_no_store(allspice_member_session_summary());
}

function allspice_member_session_rest_delete() {
    allspice_member_cookie_clear();
    return allspice_member_session_no_store(['ok' => true, 'active' => false]);
}

function allspice_member_session_rest_post(WP_REST_Request $request) {
    /* Bearer token from the SAME reader who is asking - forwarded, never stored, never
       logged (req 18). */
    $authorization = trim((string)$request->get_header('authorization'));
    if ($authorization === '' || stripos($authorization, 'bearer ') !== 0 || strlen($authorization) > 8192) {
        return new WP_Error('allspice_no_token', 'Missing bearer token.', ['status' => 401]);
    }
    $ecosystem = trim((string)$request->get_header('x-allspice-ecosystem'));

    /* partner/domain come from PLUGIN SETTINGS only - never from the request (req 3). */
    $s = function_exists('allspice_opt_get') ? allspice_opt_get() : [];
    $partner_id = trim((string)($s['partner_id'] ?? ''));
    $domain_id = trim((string)($s['domain_id'] ?? ''));
    if ($partner_id === '' || $domain_id === '') {
        return new WP_Error('allspice_not_configured', 'Plugin is not configured.', ['status' => 409]);
    }

    /* Configured = valid site_membership_id (v2) OR legacy enabled+ready. new_signups_enabled
       is IRRELEVANT here - existing members must keep validating when signups are closed. */
    $product_map = allspice_memberships_product_map();
    if (!allspice_memberships_configured() || $product_map === []) {
        return new WP_Error('allspice_memberships_not_ready', 'Memberships are not enabled for this site.', ['status' => 409]);
    }

    /* Server-to-server entitlement check - the single source of truth. Authorization and
       X-Allspice-Ecosystem are forwarded EXACTLY as received. */
    $url = allspice_public_api_base() . '/me/entitlements?' . http_build_query([
        'partnerId' => $partner_id,
        'domainId' => $domain_id,
    ]);
    $headers = [
        'Accept' => 'application/json',
        'Authorization' => $authorization,
    ];
    if ($ecosystem !== '') {
        $headers['X-Allspice-Ecosystem'] = $ecosystem;
    }
    $resp = wp_remote_get($url, ['timeout' => 8, 'headers' => $headers]);
    if (is_wp_error($resp)) {
        /* Sanitized: transport class only - no URLs with tokens exist here, but stay terse. */
        return new WP_Error('allspice_upstream_unreachable', 'Entitlement service unreachable.', ['status' => 502]);
    }
    $code = (int)wp_remote_retrieve_response_code($resp);
    if ($code === 401 || $code === 403) {
        /* Expired / invalid / tenant-mismatched token: the API is the judge (req 3). */
        allspice_member_cookie_clear();
        return new WP_Error('allspice_invalid_token', 'Token was not accepted.', ['status' => 401]);
    }
    if ($code < 200 || $code >= 300) {
        return new WP_Error('allspice_upstream_error', 'Entitlement service error.', ['status' => 502]);
    }
    $json = json_decode((string)wp_remote_retrieve_body($resp), true);
    if (!is_array($json)) {
        return new WP_Error('allspice_upstream_error', 'Entitlement service error.', ['status' => 502]);
    }

    /* Tolerant unwrap: top-level list, {entitlements: []}, or {data: {entitlements: []}}. */
    $entitlements = $json;
    if (isset($json['entitlements']) && is_array($json['entitlements'])) {
        $entitlements = $json['entitlements'];
    } elseif (isset($json['data']['entitlements']) && is_array($json['data']['entitlements'])) {
        $entitlements = $json['data']['entitlements'];
    }
    if (!is_array($entitlements)) $entitlements = [];

    /* Keep only: status active|active_grace AND a REAL relationship to the configured
       membership (strict reader: explicit siteMembershipId/canonicalProductId field, exact
       site_membership_id equality, or the legacy synced alias map - never names, intervals
       or array order). The canonical id is what goes into the cookie - never the raw
       upstream string beyond the exact match. Benefits derive from the synced config only. */
    $products = [];
    $benefits = [];
    foreach ($entitlements as $e) {
        if (!is_array($e)) continue;
        $status = strtolower(trim((string)($e['status'] ?? '')));
        if ($status !== 'active' && $status !== 'active_grace') continue;
        $canonical = allspice_membership_entitlement_canonical($e);
        if ($canonical === null) continue;
        /* Canonical id into the cookie - a monthly SALE entitlement and an annual one both
           resolve to the same canonical gate product (item 6). */
        $products[$canonical] = true;
        foreach (($product_map[$canonical]['benefits'] ?? []) as $b) $benefits[$b] = true;
    }
    $products = array_keys($products);
    $benefits = array_keys($benefits);

    if (function_exists('allspice_console_log')) {
        /* Shape only: counts, never ids joined to a person, never the token (req 18). */
        allspice_console_log('[Allspice] member-session refresh', [
            'entitlements_seen' => count($entitlements),
            'products_matched' => count($products),
            'benefits' => count($benefits),
        ]);
    }

    if ($products === []) {
        /* Authenticated non-member: no cookie (absence == no access), clear any stale one. */
        allspice_member_cookie_clear();
        return allspice_member_session_no_store([
            'ok' => true,
            'active' => false,
            'config_version' => allspice_memberships_config_version(),
        ]);
    }

    $now = time();
    /* ONLY the fields allowed by req 4/5 - no identity, no token, no billing ids. */
    $payload = [
        'v' => ALLSPICE_MEMBER_SCHEMA_VERSION,
        'pid' => $partner_id,
        'did' => $domain_id,
        'prods' => $products,
        'ben' => $benefits,
        'cv' => allspice_memberships_config_version(),
        'iat' => $now,
        'exp' => $now + ALLSPICE_MEMBER_COOKIE_TTL,
    ];
    allspice_member_cookie_set($payload);

    return allspice_member_session_no_store([
        'ok' => true,
        'active' => true,
        'products' => $products,
        'benefits' => $benefits,
        'config_version' => (string)$payload['cv'],
        'expires_at' => (int)$payload['exp'],
    ]);
}