2026 10 12 xp pro - xopherdeep/do-it-for-the-xp GitHub Wiki

XP Pro Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Sell XP Pro (Temple Creator, advanced scheduling, AI helpers) on the web through Stripe and in the iOS/Android apps through RevenueCat, with Gatekeeper as the single source of truth, and close the security holes found while designing it.

Architecture: Bazaar /buy/doitforthexp/{plan} (resolver in the XP plugin) creates Stripe Checkout; RevenueCat handles the stores. Both webhooks arrive at Hookshot, which authenticates them and fires xophz_hookshot_incoming; Gatekeeper's XP Pro intake turns each event into a grant, extend, expire or revoke on a per-family license. The app identifies the family by a random Family ID, claims or validates its license through Gatekeeper REST, caches it with offline rules, and gates three features. AI helpers move from a key shipped in the app to a Gatekeeper proxy that checks the license.

Tech Stack: WordPress plugins (PHP 8, COMPASS plugin base, WP options and transients), COMPASS PHP test harness (Node + docker exec u-wordpress php), Vue 3 + Ionic 7 + Pinia + Capacitor 6 (TypeScript, Jest, Cypress 8), @revenuecat/purchases-capacitor, Stripe REST API, Gemini REST API.

Spec: docs/superpowers/specs/2026-10-11-xp-pro-purchase-design.md (in the do-it-for-the-xp repo's docs submodule). Read it before any task; section numbers below refer to it.

Global Constraints

  • Plans, exactly: monthly $4.99/month, yearly $39.99/year, both with a 7-day trial; lifetime $79.00 one-time, sold only while option xp_pro_lifetime_on_sale is true. Store product ids xp_pro_monthly, xp_pro_yearly, xp_pro_lifetime. RevenueCat entitlement pro.
  • Pro gates are exactly templeCreator, advancedSchedule, aiHelpers. Heroes, custom quests and custom beasts are never gated.
  • Gates block creating or editing only. Nothing a family already made stops working when Pro is missing or lapses.
  • License key format XP-PRO-XXXX-XXXX-XXXX, groups drawn from ABCDEFGHJKLMNPQRSTUVWXYZ23456789 with random_int. One record per family in option gk_xp_pro_{family_id} (autoload off); lookups gk_xp_pro_key_{KEY}, gk_xp_pro_sub_{id}, gk_xp_pro_pi_{id}, gk_xp_pro_cus_{id} → family_id.
  • Entitled = status === 'active' and (expires_at null or now < expires_at + 3 days). Max 10 devices per license. Remember the last 50 event ids per license.
  • family_id is a lowercase UUID v4; device_id is 8 to 64 chars of [A-Za-z0-9_-]. Validate both at every server entry point.
  • XP Pro licenses never pass through Gatekeeper_Licenses::validate().
  • XP Pro webhook events are processed only when $webhook_id is one of gatekeeper_xp_pro_webhooks ({stripe: id, revenuecat: id}) and Hookshot_Signature::was_verified( $webhook_id ) is true.
  • Sandbox licenses (env === 'sandbox') are not Pro in production app builds, and the AI proxy refuses them unless wp_get_environment_type() is local or development.
  • AI proxy: tasks monster_name, checklist, full_beast, schedule only; prompts live in PHP; no free-form prompt; 100 calls per license per day (option xp_pro_ai_daily_limit); key from Xophz_Compass_HTTP::get_gemini_api_key().
  • App offline rules: recheck when the last check is older than 24 h or expiresAt has passed; Pro stays on offline for 30 days since lastValidatedAt.
  • COMPASS code (all PHP and the COMPASS Vue UI): no em dash characters (—) anywhere, and no @example.com, @gmail.com, @test.com or 555- values in fixtures (the harness's assertNoEmDashes / assertNoSyntheticData reject them). Fixture emails use @doit.forthexp.com.
  • COMPASS workflow (from Xophz-COMPASS/CLAUDE.md): claim a chemx task and lock files before editing; commit on main inside the submodule, then bump the pointer in the COMPASS root; no worktrees, side branches or stash. Do not push a plugin submodule until Task 17: a push to main builds a release that doit.forthexp.com installs.
  • App workflow (from do-it-for-the-xp/CLAUDE.md): chemx verify before every commit; chemx test <spec> for single specs; commit on master; no push unless the owner asks. Pages are defineComponent with sibling .ts and .scss; new components use <script setup lang="ts">; sounds via play$fx(key); toasts use cssClass: "rpg-toast", position top.
  • Prerequisite: background task "Close Gatekeeper license-validation bypasses" (removes the CX- fallback, the hardcoded bypass keys and the user_id = 1 fallback) lands in xophz-compass-gatekeeper before Task 3. Pull it first; do not redo it.

Review Focus

  1. A subscription is refunded (charge.refunded whose charge has a customer but no stored payment_intent) → the family's Pro is revoked. Test in Task 4.
  2. A family holds the lifetime plan and a subscription; the subscription later ends → Pro stays on (lifetime wins). Test in Task 3.
  3. The device clock is set backwards so lastValidatedAt is in the future → treat as needing a recheck, never as fresh. Test in Task 10.
  4. Stripe returns to a hash-routed URL with the query after the hash (/#/pro/return?session_id=…&purchased=doitforthexp, or ?status=cancelled) → the Return page reads both. Test in Task 13.
  5. An 11th device restores a key → the parent sees "This key is on 10 devices already; contact support to reset them," not a generic error. Test in Task 12.

Part A: Security quick fix

Task 1: Stop publishing source maps

The live site serves 219 .js.map files, which hand out the full private source.

Files:

  • Modify: vue.config.js (add productionSourceMap: false at the top level, beside publicPath at line 54)
  • Test: tests/unit/buildConfig.spec.ts

Interfaces: none.

  • Step 1: Write the failing test
// tests/unit/buildConfig.spec.ts
const config = require("../../vue.config.js");
describe("production build config", () => {
  it("never emits source maps", () => {
    expect(config.productionSourceMap).toBe(false);
  });
});
  • Step 2: Run it and see it fail: chemx test tests/unit/buildConfig.spec.ts → FAIL (undefined is not false).
  • Step 3: Add productionSourceMap: false to vue.config.js.
  • Step 4: Run it again → PASS. Then pnpm build and confirm find dist -name '*.map' | wc -l prints 0.
  • Step 5: Commit (chemx verify first): fix(build): stop shipping source maps to production. Tell the owner the live site keeps serving the old maps until the next deploy to Cloudflare Pages; deploying is their call.

Part B: Server (Xophz-COMPASS)

Run all server tests from /home/xopher/www/x/Xophz-COMPASS with the Docker stack up (pnpm up; container u-wordpress). Each plugin's tests/run-tests.mjs follows wp-content/plugins/xophz-compass-xp/tests/run-tests.mjs: import harness from ../../xophz-compass/tests/harness/test-framework.mjs, import the spec files, await harness.run(), exit 1 on failures. Specs call runPhpJson(code) from ../../xophz-compass/tests/harness/php-executor.mjs with container paths (/var/www/html/wp-content/plugins/...); the stubs give get_option, update_option, get_transient, set_transient, apply_filters, wp_remote_post; define any other WP function a snippet needs (get_post_meta, delete_option, wp_mail) inside the snippet.

Task 2: Hookshot bearer verify mode

Files:

  • Modify: wp-content/plugins/xophz-compass-hookshot/includes/class-hookshot-signature.php (verify(), line 31)
  • Modify: wp-content/plugins/xophz-compass-hookshot/includes/class-hookshot-rest-dashboard.php (add 'verify_mode' => 'hookshot_verify_mode' to save_webhook_meta() at line 461, and hookshot_verify_mode to the field list at line 402; save only hmac or bearer, anything else saves hmac)
  • Modify: src/routes/hookshot/views/hookshot-detail.vue (a "Verify with" select, HMAC signature / Authorization: Bearer, beside the sig_header field at line 73; add verify_mode: "hmac" to the form defaults at line 420 and the load mapping at line 457) and the matching type in src/routes/hookshot/hookshot.api.ts
  • Create: wp-content/plugins/xophz-compass-hookshot/tests/run-tests.mjs, wp-content/plugins/xophz-compass-hookshot/tests/verify-mode.test.mjs
  • Modify: wp-content/plugins/xophz-compass-hookshot/docs/CHANGELOG.md

Interfaces:

  • Produces: post meta hookshot_verify_mode (hmac default | bearer). In bearer mode verify() returns true and marks the webhook verified (so was_verified() is true) only when the Authorization header equals "Bearer " . $signing_secret under hash_equals; a missing secret, missing header or mismatch returns WP_Error status 401. hmac mode is unchanged.

  • Step 1: Write the failing tests in verify-mode.test.mjs. Each snippet stubs get_post_meta from a PHP array and a minimal WP_REST_Request (get_header($name), get_body()), requires class-hookshot-signature.php, and echoes JSON. Cases:

    • bearer accepts the exact header and marks verified → verify returns true, was_verified(7) true.
    • bearer refuses a wrong secret → WP_Error, data status 401, was_verified(7) false.
    • bearer refuses a missing header → 401.
    • bearer refuses when no secret is stored → 401 (unlike hmac, which lets an unsigned webhook through).
    • hmac stays the default and still checks Stripe signatures → a body signed with Hookshot_Signature::sign() verifies with no hookshot_verify_mode meta.
    • files contain no em dashes → assertNoEmDashes on the two PHP files.
  • Step 2: Run: node wp-content/plugins/xophz-compass-hookshot/tests/run-tests.mjs → the bearer cases FAIL.

  • Step 3: Implement the bearer branch at the top of verify() (read hookshot_verify_mode; on bearer do the check above and return), then the REST mapping and the UI select.

  • Step 4: Run the Hookshot tests → PASS; chemx typecheck in COMPASS → no new errors.

  • Step 5: Commit in the Hookshot submodule: feat(verify): bearer mode for webhooks that send a fixed Authorization header; changelog entry; bump the root pointer.

Task 3: Gatekeeper XP Pro license store

Files:

  • Create: wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-xp-pro-licenses.php
  • Modify: wp-content/plugins/xophz-compass-gatekeeper/xophz-compass-gatekeeper.php (require_once after line 33, before the REST class)
  • Create: wp-content/plugins/xophz-compass-gatekeeper/tests/run-tests.mjs, tests/xp-pro-licenses.test.mjs

Interfaces:

  • Produces Gatekeeper_Xp_Pro_Licenses (all static):

    • is_uuid4( string $s ): bool, is_device_id( string $s ): bool
    • get( string $family_id ): ?array, find_by_key( string $key ): ?array, find_by_ref( string $type /* sub|pi|cus */, string $id ): ?array
    • apply( array $op, string $event_id ): array returning ['changed' => bool, 'created_key' => bool, 'record' => ?array]. $op is the operation shape adapters produce (Tasks 4 and 5): ['action' => 'grant'|'extend'|'expire'|'revoke'|'none', 'source' => 'stripe'|'revenuecat', 'family_id' => ?string, 'ref' => ?['type' => 'sub'|'pi'|'cus', 'id' => string], 'plan' => ?string, 'expires_at' => ?int /* unix */, 'env' => ?string, 'email' => ?string, 'refs' => array /* sub, pi, cus, product_id, store */]. Without family_id, apply resolves the family through ref; unknown → changed false. An $event_id already in event_ids → changed false.
    • is_entitled( array $record, int $now ): bool
    • attach_device( string $family_id, string $device_id ): true|WP_Error (device_limit, status 409)
    • public_view( array $record, int $now ): array → ['pro' => bool, 'license_key', 'plan', 'expires_at' => ISO 8601 UTC|null, 'env', 'family_id']
    • reset_devices( string $family_id ): bool
  • Step 1: Write the failing tests (harness stubs get_option/update_option; define delete_option). Cases, using a fixed family 3f6c2a1e-8b4d-4c7a-9e2f-1a2b3c4d5e6f:

    • grant creates one key in XP-PRO-XXXX-XXXX-XXXX form and indexes it (regex ^XP-PRO-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$; find_by_key returns the record; created_key true).
    • a second grant keeps the key (created_key false, same key).
    • extend through a sub ref moves expires_at.
    • replayed event id changes nothing.
    • lifetime wins: a grant of lifetime then a subscription expire leaves plan lifetime, expires_at null, status active (Review Focus 2).
    • revoke then re-grant reuses the key and sets status active.
    • is_entitled is true until expires_at + 3 days and false after.
    • attach_device allows 10 and refuses the 11th with device_limit 409.
    • invalid family_id or device_id is rejected by the validators (uppercase UUID, v1 UUID, 7-char and 65-char device ids, a space).
  • Step 2: Run: node wp-content/plugins/xophz-compass-gatekeeper/tests/run-tests.mjs → FAIL (class missing).

  • Step 3: Implement the class per the Interfaces and Global Constraints (one option per family plus the lookup options; never touch compass_gatekeeper_licenses).

  • Step 4: Run → PASS.

  • Step 5: Commit in the Gatekeeper submodule: feat(xp-pro): per-family license store.

Task 4: Stripe adapter

Files:

  • Create: wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-xp-pro-stripe.php (+ require_once)
  • Create: tests/xp-pro-stripe.test.mjs and tests/fixtures/stripe/*.json (one file per event below; shapes copied from Stripe's API reference for the pinned API version, emails @doit.forthexp.com)

Interfaces:

  • Consumes: the $op shape from Task 3.
  • Produces: Gatekeeper_Xp_Pro_Stripe::to_op( array $event ): array (pure) and Gatekeeper_Xp_Pro_Stripe::event_id( array $event ): string.

Rules (spec 3.3): checkout.session.completed with metadata.product === 'doitforthexp' → grant (family, plan from metadata; expires_at = the subscription's trial_end or current_period_end if present in the payload, else now + 7 days for a trialing subscription, null for lifetime; refs sub = subscription, pi = payment_intent, cus = customer; email customer_details.email; env livemode ? production : sandbox). invoice.paid → extend by ref sub (subscription id from subscription or parent.subscription_details.subscription) to the line's period.end. customer.subscription.deleted → expire by ref sub. charge.refunded with refunded === true → revoke by ref pi when payment_intent is set, else by ref cus. charge.dispute.created → revoke by ref pi. Anything else, or another product → none.

  • Step 1: Write the failing tests, one per rule plus: another product's checkout is none; a partial refund (refunded false) is none; invoice.paid reads the subscription from parent.subscription_details on new API versions; a subscription refund with only customer set revokes by customer (Review Focus 1, end to end: to_op then Gatekeeper_Xp_Pro_Licenses::apply on a granted family, then is_entitled false).
  • Step 2: Run the Gatekeeper tests → the new ones FAIL.
  • Step 3: Implement to_op and event_id.
  • Step 4: Run → PASS.
  • Step 5: Commit: feat(xp-pro): Stripe events to license operations.

Task 5: RevenueCat adapter

Files:

  • Create: wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-xp-pro-revenuecat.php (+ require_once)
  • Create: tests/xp-pro-revenuecat.test.mjs, tests/fixtures/revenuecat/*.json (RevenueCat webhook v1 shapes: { "api_version": "1.0", "event": { "id", "type", "app_user_id", "product_id", "expiration_at_ms", "environment", "store", "cancel_reason" } })

Interfaces:

  • Produces: Gatekeeper_Xp_Pro_RevenueCat::to_op( array $payload ): array, ::event_id( array $payload ): string.

Rules (spec 3.3): app_user_id must pass is_uuid4 or the op is none. INITIAL_PURCHASE, RENEWAL, UNCANCELLATION, PRODUCT_CHANGE → grant with expires_at = floor(expiration_at_ms / 1000) and plan from product_id (xp_pro_monthly → monthly, xp_pro_yearly → yearly). NON_RENEWING_PURCHASE of xp_pro_lifetime → grant lifetime. CANCELLATION → none, except cancel_reason === 'CUSTOMER_SUPPORT' → revoke. EXPIRATION → expire. BILLING_ISSUE, TRANSFER, others → none. environment === 'SANDBOX' → env sandbox.

  • Step 1: Write the failing tests, one per row, plus a non-UUID app_user_id is none and an unknown product_id is none.
  • Step 2: Run → FAIL. Step 3: Implement. Step 4: Run → PASS.
  • Step 5: Commit: feat(xp-pro): RevenueCat events to license operations.

Task 6: Webhook intake, key email, admin screen

Files:

  • Create: wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-xp-pro-intake.php (+ require_once)
  • Modify: wp-content/plugins/xophz-compass-gatekeeper/includes/class-xophz-compass-gatekeeper.php (init(), line 29: add_action( 'xophz_hookshot_incoming', array( 'Gatekeeper_Xp_Pro_Intake', 'handle' ), 20, 2 ); beside the existing forthexp hook, which stays)
  • Modify: wp-content/plugins/xophz-compass-gatekeeper/admin/class-xophz-compass-gatekeeper-admin.php (an "XP Pro" section: the two webhook-id fields saved to gatekeeper_xp_pro_webhooks, the xp_pro_ai_daily_limit number, and a license list with a per-row "Reset devices" button calling reset_devices; manage_options and a nonce on every write)
  • Create: tests/xp-pro-intake.test.mjs

Interfaces:

  • Consumes: Tasks 3 to 5.

  • Produces: Gatekeeper_Xp_Pro_Intake::handle( $payload, $webhook_id ): void. It picks the adapter by matching $webhook_id to the option, returns early when class_exists( 'Hookshot_Signature' ) is false or was_verified is false (logging via error_log with the webhook id only), applies the op, and when created_key is true and the record has an email sends one wp_mail (subject Your XP Pro key; body: the key, the steps "Settings > XP Pro > Restore with key", and the support address from get_option( 'admin_email' )).

  • Step 1: Write the failing tests (stub Hookshot_Signature::was_verified with a small class in the snippet, stub wp_mail to record calls):

    • a verified Stripe checkout grants and emails once.
    • the same event delivered twice grants once and emails once.
    • an unverified delivery changes nothing.
    • a delivery on an unconfigured webhook id changes nothing (even with a valid Stripe payload).
    • a RevenueCat payload on the Stripe webhook id is ignored (the id picks the adapter, not the shape).
  • Step 2: Run → FAIL. Step 3: Implement the intake, then the admin section. Step 4: Run → PASS; load wp-admin locally and save the XP Pro section once.

  • Step 5: Commit: feat(xp-pro): Hookshot intake, key email and admin screen.

Task 7: XP Pro REST endpoints

Files:

  • Create: wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-xp-pro-rest.php (+ require_once before class-gatekeeper-rest.php)
  • Modify: wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-rest.php (register_routes(): call Gatekeeper_Xp_Pro_REST::register( $this->namespace );)
  • Create: tests/xp-pro-rest.test.mjs
  • Modify: wp-content/plugins/xophz-compass-gatekeeper/README.md (endpoint table), docs/CHANGELOG.md

Interfaces:

  • Consumes: Task 3.

  • Produces (namespace compass/v1/gatekeeper, all permission_callback public): GET /xp-pro/plans → apply_filters( 'gatekeeper_xp_pro_plans', array() ); POST /xp-pro/claim {family_id, device_id}; POST /xp-pro/validate {license_key, device_id}; POST /xp-pro/recover {email}; POST /xp-pro/manage {license_key, return_url}. Responses exactly as spec 3.4: 200 with public_view, 404 {pro:false}, 409 {error:'device_limit'}, 400 {error:'invalid_input'}, 202 for recover, 429 {error:'rate_limited'}. manage for a Stripe-sourced license POSTs https://api.stripe.com/v1/billing_portal/sessions (customer, return_url checked by Xophz_Compass_Security::is_allowed_redirect_url) with Xophz_Bazaar_Stripe_Key::resolve( $record['env'] === 'sandbox' ) and returns {url}; for a RevenueCat-sourced one returns {store: 'APP_STORE'|'PLAY_STORE'}.

  • Also produces Gatekeeper_Xp_Pro_REST::rate_limited( string $bucket, int $max, int $window_seconds ): bool (transient gk_xppro_rl_{bucket}_{md5(REMOTE_ADDR)}), reused by Task 9. Limits: 30/minute for every route, recover 5/hour.

  • Step 1: Write the failing tests (call the static handlers with a stub WP_REST_Request): claim returns 404 before any grant and 200 after; claim adds the device; claim with a bad family_id is 400; validate an expired license returns pro false; validate on an 11th device is 409 device_limit; recover answers 202 for a known and an unknown email and mails only the known one; the 31st request in a minute is 429; manage for a Stripe license posts the customer to billing_portal/sessions (inspect the stubbed wp_remote_post call; assert the key is in the Authorization header and nowhere in the URL); plans returns what the filter provides.

  • Step 2: Run → FAIL. Step 3: Implement. Step 4: Run → PASS; curl -s localhost/wp-json/compass/v1/gatekeeper/xp-pro/plans on the local stack returns [] until Task 8.

  • Step 5: Commit: feat(xp-pro): claim, validate, recover, manage and plans endpoints.

Task 8: XP plugin catalog and /buy/doitforthexp resolver

Files:

  • Create: wp-content/plugins/xophz-compass-xp/includes/class-xophz-compass-xp-pro.php
  • Modify: wp-content/plugins/xophz-compass-xp/includes/class-xophz-compass-xp.php (require the file; $this->define_class_hooks( 'Xophz_Compass_Xp_Pro' ); beside line 188)
  • Create: wp-content/plugins/xophz-compass-xp/tests/xp-pro-buy.test.mjs (import it in tests/run-tests.mjs)
  • Modify: wp-content/plugins/xophz-compass-xp/docs/CHANGELOG.md (create if missing)

Interfaces:

  • Produces Xophz_Compass_Xp_Pro with public $filter_hooks = [ 'xophz_resolve_buy_request' => [ 'resolve_buy_request', 10, 3 ], 'gatekeeper_xp_pro_plans' => [ 'provide_plans', 10, 1 ] ];, static get_plans(): array (keys monthly|yearly|lifetime, each ['price' => float, 'mode' => 'subscription'|'payment', 'interval' => 'month'|'year'|null, 'trial_days' => int, 'store_product_id' => string, 'on_sale' => bool]), resolve_buy_request( $resolved, array $segments, array $query ), provide_plans( array $plans ): array.

  • The resolver handles only $segments[0] === 'doitforthexp'; returns WP_Error 404 for an unknown or off-sale plan, 400 for a bad family_id/device_id; otherwise ['handled' => true, 'product_name' => 'Do It For The XP: XP Pro (' . ucfirst($plan) . ')', 'price', 'mode', 'interval', 'trial_days', 'metadata' => ['product' => 'doitforthexp', 'plan', 'family_id', 'device_id', 'plugin_slug' => 'doitforthexp']] and leaves success_url/cancel_url unset (Bazaar builds them from return_url).

  • Step 1: Write the failing tests: other segments pass $resolved through untouched (['xp','developer'] returns the input); monthly returns 4.99 subscription month with 7 trial days and the metadata; lifetime is 79 payment; lifetime off sale is 404; a bad family_id is 400; product_name, not name, is set; provide_plans returns the three plans.

  • Step 2: Run: node wp-content/plugins/xophz-compass-xp/tests/run-tests.mjs → FAIL. Step 3: Implement. Step 4: Run → PASS. Then locally: curl -sI "http://localhost/buy/doitforthexp/monthly?family_id=3f6c2a1e-8b4d-4c7a-9e2f-1a2b3c4d5e6f&device_id=devicetest01&return_url=http://localhost:8100/%23/pro/return" → 302 to Stripe test checkout (or Bazaar's simulator when no test key is set).

  • Step 5: Commit in the XP submodule: feat(pro): XP Pro catalog and /buy/doitforthexp resolver; bump the root pointer.

Task 9: AI helpers proxy

Files:

  • Create: wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-xp-pro-ai.php (+ require_once; route registered from Gatekeeper_Xp_Pro_REST::register)
  • Create: tests/xp-pro-ai.test.mjs
  • Read (do not change): do-it-for-the-xp/src/lib/services/ai/GeminiService.ts, the source of the four prompts and reply shapes

Interfaces:

  • Consumes: Tasks 3 and 7 (rate_limited).

  • Produces: POST /compass/v1/gatekeeper/xp-pro/ai {license_key, device_id, task, input} → 200 with the task's JSON (the same shape GeminiService.ts returns today for generateMonsterName, generateChecklistFromName, generateFullBeast, generateSchedule), 402 {error:'pro_required'}, 400 {error:'invalid_input'}, 429 {error:'daily_limit'}, 502 {error:'ai_unavailable'}. Static helpers: build_prompt( string $task, array $input ): array|WP_Error (['prompt' => string, 'system' => string]), parse_reply( string $task, string $text ): array|WP_Error.

  • Prompts: port each function's prompt text from GeminiService.ts into PHP, verbatim apart from interpolation. Inputs: strings capped at 200 characters, arrays at 20 items, unknown keys dropped. Model: the Google connector's model option as Xophz_Compass_Gemini_API reads it, falling back to gemini-3.6-flash; key in the x-goog-api-key header only. Daily counter transient gk_xppro_ai_{family_id}_{Ymd}, limit (int) get_option( 'xp_pro_ai_daily_limit', 100 ).

  • Step 1: Write the failing tests (stub wp_remote_post to return a canned Gemini candidates[0].content.parts[0].text per task; stub wp_get_environment_type): no license is 402; an expired license is 402; a sandbox license is 402 in production and allowed in local; an unknown task is 400; a 201-character name is 400; the 101st call of the day is 429; each task maps the canned reply to its JSON shape; a non-JSON reply is 502; the request never contains a caller-supplied prompt field (send input.prompt, assert it is absent from the stubbed request body); the API key is sent only in x-goog-api-key.

  • Step 2: Run → FAIL. Step 3: Implement. Step 4: Run → PASS.

  • Step 5: Commit: feat(xp-pro): license-checked AI helpers proxy; Gatekeeper changelog lists Tasks 3 to 9; bump the root pointer.


Part C: App (do-it-for-the-xp)

Task 10: Plans and entitlement rules

Files:

  • Create: src/lib/services/pro/proPlans.ts, src/lib/services/pro/entitlement.ts
  • Test: tests/unit/proEntitlement.spec.ts

Interfaces:

  • Produces:

    • type ProPlanId = "monthly" | "yearly" | "lifetime"; type ProGate = "templeCreator" | "advancedSchedule" | "aiHelpers"; PRO_PLANS: Record<ProPlanId, { price: number; interval: "month" | "year" | null; trialDays: number; storeProductId: string }>; PRO_GATE_NAMES: Record<ProGate, string> = { templeCreator: "Temple Creator", advancedSchedule: "Advanced scheduling", aiHelpers: "AI helpers" }.
    • interface CachedLicense { key: string; plan: ProPlanId; expiresAt: string | null; env: "production" | "sandbox"; pro: boolean; lastValidatedAt: string }
    • isPro(cached: CachedLicense | null, now: Date, productionBuild: boolean): boolean; needsRecheck(cached: CachedLicense | null, now: Date): boolean; constants GRACE_DAYS = 3, OFFLINE_DAYS = 30, RECHECK_HOURS = 24.
  • Step 1: Write the failing tests: lifetime is Pro; active inside expiry; Pro on day 2 after expiry and not on day 4; not Pro 31 days after lastValidatedAt; pro: false is never Pro; sandbox is Pro in a dev build and not in a production build; needsRecheck true after 24 h, true once expiresAt passed, true with no license, true when lastValidatedAt is in the future (Review Focus 3), false 1 h after a check.

  • Step 2: Run: chemx test tests/unit/proEntitlement.spec.ts → FAIL. Step 3: Implement. Step 4: Run → PASS.

  • Step 5: Commit (chemx verify): feat(pro): plans and entitlement rules.

Task 11: Family identity, storage and Gatekeeper client

Files:

  • Create: src/lib/databases/ProDb.ts (pattern of FamilySettingsDb.ts: storage name __xp_pro, one record key pro), src/lib/services/pro/familyIdentity.ts, src/lib/services/pro/proClient.ts
  • Modify: src/lib/api/apiHost.ts (add the two helpers)
  • Test: tests/unit/familyIdentity.spec.ts, tests/unit/proClient.spec.ts, extend tests/unit/apiHost.spec.ts

Interfaces:

  • Consumes: CachedLicense, ProPlanId from Task 10.

  • Produces:

    • interface ProState { familyId: string; deviceId: string; license: CachedLicense | null }; getProDb(): { getState(): Promise<ProState | null>; saveState(s: ProState): Promise<void> }
    • ensureIdentity(): Promise<{ familyId: string; deviceId: string }> (familyId from uuid v4, lowercased; deviceId 16 chars of [A-Za-z0-9]); adoptFamily(familyId: string): Promise<void> (keeps the deviceId, clears nothing else)
    • compassRestRoot(settings = window.wpApiSettings): string → hosted root without trailing slash, else https://doit.forthexp.com/wp-json; compassSiteRoot(settings = window.wpApiSettings): string → the REST root minus a trailing /wp-json
    • interface ProStatus { pro: boolean; licenseKey: string; plan: ProPlanId; expiresAt: string | null; env: "production" | "sandbox"; familyId: string }
    • class ProClientError extends Error { code: "device_limit" | "pro_required" | "rate_limited" | "daily_limit" | "invalid_input" | "network" | "server" }
    • fetchPlans(), claim(familyId, deviceId): Promise<ProStatus | null> (null on 404), validate(licenseKey, deviceId): Promise<ProStatus | null>, recover(email): Promise<void>, manage(licenseKey, returnUrl): Promise<{ url?: string; store?: "APP_STORE" | "PLAY_STORE" }>, askAi<T>(task: "monster_name" | "checklist" | "full_beast" | "schedule", input: Record<string, unknown>, licenseKey: string, deviceId: string): Promise<T>; each maps the snake_case response to ProStatus and the status codes of spec 3.4 to ProClientError codes.
  • Step 1: Write the failing tests: identity is created once and stable across calls; adoptFamily changes familyId and keeps deviceId; compassSiteRoot gives https://doit.forthexp.com without hosting and the host origin with { root: "https://doit.forthexp.com/wp-json/" }; claim returns null on 404 and a mapped ProStatus on 200 (mock global.fetch); 409 becomes ProClientError("device_limit"); a rejected fetch becomes "network".

  • Step 2: Run the three specs → FAIL. Step 3: Implement. Step 4: Run → PASS.

  • Step 5: Commit: feat(pro): family identity, storage and Gatekeeper client.

Task 12: Pro store, guard and upsell

Files:

  • Create: src/lib/store/stores/pro.ts, src/router/_Guards/pro.guard.ts, src/components/molecules/Modals/XpProUpsell.vue
  • Modify: src/router/_Guards/index.ts (after the Game Master gate: if ((await handleProGuard(to)) === false) return next(false);)
  • Modify: src/main.ts or the app's existing startup hook (call useProStore().init() once after Pinia is installed)
  • Test: tests/unit/proStore.spec.ts, tests/unit/proGuard.spec.ts

Interfaces:

  • Consumes: Tasks 10 and 11.

  • Produces useProStore (setup store, like chat.ts): state isPro (computed from isPro(license, new Date(), process.env.NODE_ENV === "production")), plan, expiresAt, licenseKey, loading; actions init(): Promise<void>, claimWithRetry(opts?: { intervalMs?: number; timeoutMs?: number }): Promise<boolean> (defaults 2000 / 30000), purchase(plan: ProPlanId): Promise<void> (web branch only here; Task 16 adds native), restoreWithKey(key: string): Promise<void>, recover(email: string): Promise<void>, manage(): Promise<void>, requirePro(gate: ProGate): Promise<boolean> (true when Pro; otherwise opens XpProUpsell with the gate and returns false). handleProGuard(to): Promise<boolean | void> returns false and opens the upsell when to.meta.pro is a gate and the family isn't Pro.

  • XpProUpsell props { gate: ProGate }; text "{PRO_GATE_NAMES[gate]} is part of XP Pro." with buttons "See XP Pro" (routes to /pro) and "Not now"; play$fx("openMenu") on open, play$fx("no") on dismiss.

  • Step 1: Write the failing tests (mock proClient and ProDb as chatStore.spec.ts mocks its db): init with no key claims, and a 200 stores the license and sets isPro (also covers a buyer who closed the tab before the Return page); init with a fresh license does not call the network; a network error during init keeps the cached Pro; a 404 on validate turns Pro off but keeps the key; claimWithRetry stops at the first 200 and gives up at the timeout (fake timers); restoreWithKey adopts the returned family; restoreWithKey on an 11th device shows the device-limit message (Review Focus 5: the toast text is exactly "This key is on 10 devices already; contact support to reset them."); requirePro returns true without a modal when Pro; purchase sends the browser to compassSiteRoot()/buy/doitforthexp/yearly with family_id, device_id and return_url={origin}/#/pro/return (assert on a stubbed window.location.assign); guard: a meta.pro route is refused when not Pro and allowed when Pro.

  • Step 2: Run → FAIL. Step 3: Implement. Step 4: Run → PASS.

  • Step 5: Commit: feat(pro): Pro store, route guard and upsell.

Task 13: Go Pro, Return and the Settings panel

Files:

  • Create: src/app/SideMenu/XpPro/XpGoPro/XpGoPro.vue, XpGoPro.ts, _XpGoPro.scss; src/app/SideMenu/XpPro/XpProReturn/XpProReturn.vue, .ts, _XpProReturn.scss; src/router/SideMenu/pro.routes.ts (/pro name xp-pro, /pro/return name xp-pro-return), spread into src/router/index.ts beside SettingsRoutes
  • Create: src/app/SideMenu/XpSettings/components/ProSettings.vue; Modify: src/app/SideMenu/XpSettings/routes.ts (path pro) and the settings menu items so "XP Pro" appears
  • Modify: docs/XP-Membership.md (rewrite from spec section 1: what Pro adds, what stays free, prices, restore)
  • Test: tests/e2e/specs/pro.spec.js

Interfaces:

  • Consumes: useProStore, fetchPlans, PRO_PLANS, PRO_GATE_NAMES.

  • Go Pro (spec 4.2): the three plans as shop items in the SNES style of the existing shops (prices from fetchPlans() on the web; Task 16 swaps in store prices on native), lifetime only when on_sale, "7 days free" on the two subscriptions, "Restore" linking to the panel.

  • Return: reads session_id, purchased and status from route.query (vue-router puts the post-hash query there); status=cancelled → replace to /pro quietly; otherwise claimWithRetry() with the dialog "The shopkeeper is counting your gold…"; success → rpg-toast "XP Pro unlocked!" and router.back(); timeout → "Payment received. Your unlock is on its way." with a Retry button.

  • ProSettings: status, plan, renews/ends date, masked key (XP-PRO-****-****- + last 4) with a copy button, "Restore with key" (input), "Email me my key", "Manage subscription".

  • Step 1: Write the failing Cypress spec pro.spec.js (cy.intercept the Gatekeeper routes; cy.seedWorld()): Go Pro lists three plans with the server prices; /#/pro/return?session_id=cs_1&purchased=doitforthexp unlocks after one pending claim (first intercept 404, second 200; Review Focus 4); /#/pro/return?status=cancelled returns to /pro without claiming; Settings > XP Pro restores with a key.

  • Step 2: Run against the dev server: pnpm vue-cli-service test:e2e --headless --url http://localhost:8100 --spec tests/e2e/specs/pro.spec.js → FAIL.

  • Step 3: Implement the pages, routes, panel and doc.

  • Step 4: Run the spec → PASS; check the screenshots in tests/e2e/screenshots at 390x844.

  • Step 5: Commit: feat(pro): Go Pro, purchase return and the XP Pro settings panel.

Task 14: The three gates

Files:

  • Modify: src/router/Admin/temples.routes.ts (meta: { pro: "templeCreator" } on xp-room-editor and the temples/:templeId editor children: settings, layout, rooms, beasts)
  • Modify: the "New temple" button in src/app/Admin/XpCompendium/views/XpTemples/ (call requirePro("templeCreator") first)
  • Modify: src/app/Admin/XpCompendium/views/XpAchievements/XpConfigAchievement/components/AchievementScheduleTab.vue (when not Pro: custom schedule, the times-of-day chips, and times-per-day above 1 show a padlock and call requirePro("advancedSchedule") instead of changing the value; once, daily at 1 a day, and weekly with "which days" stay open; existing values on a saved quest are displayed and kept)
  • Modify: the AI buttons in BeastDetailsModal.vue, QuestTypeTab.vue and AchievementScheduleTab.vue (padlock when not Pro; requirePro("aiHelpers") before the call)
  • Test: tests/unit/proGates.spec.ts; extend tests/e2e/specs/pro.spec.js

Interfaces:

  • Consumes: useProStore().requirePro, isPro; the route meta read by handleProGuard.

  • Step 1: Write the failing tests: unit (mount AchievementScheduleTab with a non-Pro store): choosing custom opens the upsell and keeps the old schedule; daily and weekly can still be chosen; a saved quest with times of day keeps them when opened without Pro. E2E: a free family can add a fourth kid, opening the room editor without Pro shows the upsell, the Bestiary create page opens without Pro.

  • Step 2: Run both → FAIL. Step 3: Implement. Step 4: Run → PASS.

  • Step 5: Commit: feat(pro): gate Temple Creator, advanced scheduling and AI helpers.

Task 15: AI helpers through the proxy; the key leaves the app

Files:

  • Modify: src/lib/services/ai/GeminiService.ts (keep the four exported function names, parameters and return types; each calls askAi with its task and fields; isGeminiConfigured() returns useProStore().isPro; delete GEMINI_API_URL and every process.env.VUE_APP_GEMINI_API_KEY read)
  • Modify: .env.example (remove VUE_APP_GEMINI_API_KEY), src/app/Admin/XpCompendium/views/XpAchievements/XpConfigAchievement/hooks/useAchievementForm.ts:809 (the "not configured" message becomes the upsell)
  • Test: tests/unit/GeminiService.spec.ts

Interfaces:

  • Consumes: askAi, ProClientError, useProStore (license key and device id).

  • Step 1: Write the failing tests (mock proClient): each function sends its task name and only the expected fields; each maps the proxy reply to today's return shape; pro_required → { success: false } and the upsell opens; daily_limit → { success: false, error: "Your party used today's AI magic. It recharges tomorrow." }.

  • Step 2: Run → FAIL. Step 3: Implement. Step 4: Run → PASS; grep -rn "VUE_APP_GEMINI_API_KEY\|generativelanguage" src prints nothing; pnpm build then grep -rlE "AIza[0-9A-Za-z_-]{35}" dist prints nothing.

  • Step 5: Commit: fix(ai): call Gemini through the license-checked proxy; no API key in the app.

Task 16: Native purchase and restore (RevenueCat)

Files:

  • Modify: package.json (add @revenuecat/purchases-capacitor, the release line that supports Capacitor 6), then npx cap sync
  • Create: src/lib/services/pro/storeBilling.ts
  • Modify: src/lib/store/stores/pro.ts (native branches of purchase, restoreWithKey, and a new restoreStorePurchases()), XpGoPro.ts (store prices on native), ProSettings.vue ("Restore purchases" button on native, required by Apple), .env.example (VUE_APP_RC_IOS_KEY=, VUE_APP_RC_ANDROID_KEY=)
  • Test: tests/unit/storeBilling.spec.ts, extend tests/unit/proStore.spec.ts

Interfaces:

  • Produces storeBilling: isNative(): boolean (Capacitor.isNativePlatform()), configure(familyId: string): Promise<void> (Purchases.configure({ apiKey, appUserID: familyId }), key by platform from the env vars), getPackages(): Promise<{ plan: ProPlanId; priceString: string; pkg: unknown }[]> (from the current offering, mapped by product id), buy(plan: ProPlanId): Promise<void> (Purchases.purchasePackage), restore(): Promise<void> (Purchases.restorePurchases), logIn(familyId: string): Promise<void>.

  • On native, purchase(plan) = buy(plan) then claimWithRetry(); the app never unlocks from the SDK result alone. restoreWithKey also calls logIn(familyId) after adoptFamily.

  • Step 1: Write the failing tests (mock @revenuecat/purchases-capacitor and @capacitor/core): configure uses the family id as appUserID and the iOS key on iOS; getPackages maps the three product ids to plans; store: native purchase buys then claims and is Pro only after the claim; a cancelled store sheet leaves Pro off and shows no error toast; restoreWithKey on native logs RevenueCat in as the adopted family.

  • Step 2: Run → FAIL. Step 3: Implement. Step 4: Run → PASS; chemx verify.

  • Step 5: Commit: feat(pro): App Store and Play purchases through RevenueCat. On-device sandbox testing waits for the store setup in spec section 5.


Part D: Ship

Task 17: End-to-end check, then deploy

Files: none new.

  • Step 1: Local Stripe test run. Stripe CLI stripe listen --forward-to http://localhost/wp-json/xophz/v1/hookshot/receive/<stripe webhook id>; put its whsec_ secret in the local Hookshot webhook (mode hmac, header Stripe-Signature); set gatekeeper_xp_pro_webhooks locally. From localhost:8100 buy yearly with card 4242 4242 4242 4242 → the Return page unlocks within 30 s. Then with a Stripe test clock: renewal extends expires_at; cancel expires; a full refund revokes.
  • Step 2: RevenueCat sandbox on one iOS and one Android device once spec section 5 is configured: buy, restore, restore with key on a second device.
  • Step 3: Gate check: chemx verify in the app; every plugin's tests/run-tests.mjs green.
  • Step 4: Ask the owner before pushing. Pushing Hookshot, Gatekeeper and XP to main releases them to doit.forthexp.com; deploying the app to Cloudflare Pages also removes the old source maps. Then the spec section 5 checklist on the live site (Stripe live key and webhook, RevenueCat webhook, trusted redirect domain, https:// WordPress Address, maintenance mode off).
⚠️ **GitHub.com Fallback** ⚠️